@kitlangton/knobs 0.3.0 → 0.4.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1094,7 +1094,8 @@ Scrolling pane bodies, trees and the command palette's list fade their content o
1094
1094
 
1095
1095
  **`tree(container, options)`** is an outliner. Rows are virtualised, so 10,000 of them scroll at full speed with about 35 in the DOM. When a branch opens, its children slide out from under it as one block with the rows below, like a drawer, and closing folds them back. The tree takes items with `id`, `label` and `children` (or `getId`, `getLabel` and `getChildren` for your own shape; an array, even empty, makes a parent that takes drops). Options:
1096
1096
 
1097
- - `icon` and `detail` for a glyph and dimmed text, or `render` for your own row content
1097
+ - `icon` and `detail` for a glyph and dimmed text (or nodes of your own), or `render` for your own row content
1098
+ - `glide: true` to show the selected row as one highlight that glides to the next row picked (on the rows' .3 s spring) instead of each row lighting in place; several selected rows light in place
1098
1099
  - `toggles` for row accessories such as `visible` and `locked`, which show at rest only in their unusual state
1099
1100
  - `filter` for a query or a test; matching rows show with their ancestors, the match marked
1100
1101
  - `selection`, `expanded` and `rowHeight` (`--k-row` by default)
@@ -1180,7 +1181,34 @@ const clock = timeline(dock, {
1180
1181
  })
1181
1182
  ```
1182
1183
 
1183
- **`timeline(container, options)`** is a scrubber for time-based work. The ruler counts in seconds or frames (`units`) with ticks that thin out as it zooms (`⌘` wheel, a pinch, `+`/`-`, `0` fits) and pans with the wheel or a trackpad. The playhead follows the hand 1:1, pulls softly toward markers and lands on frames (`snap`). Markers can be added (`M`), dragged, renamed (`Enter`) and removed, and `[`/`]` jump between them; `I` and `O` set a loop range. `Space` plays on one rAF clock that sleeps when nothing moves, and `←`/`→` step a frame (`⇧` ten). A right-click on the ruler, a marker or a range opens a context menu. It reports `onSeek(time, source)` (`"scrub" | "key" | "marker" | "play" | "code"`), `onPlay`, `onMarkers`, `onRanges` and `onCross(marker)`. The handle has `time`, `playing`, `markers`, `ranges`, `view`, `seek`, `play`, `pause`, `toggle`, `update`, `fit`, `zoom`, `focus` and `dispose`. `Timeline` in `knobs/ui/react` and `knobs/ui/solid` follows its props. The Lab's scene clock and Boom retrigger run from one under the viewport.
1184
+ **`timeline(container, options)`** is a scrubber for time-based work. The ruler counts in seconds or frames (`units`) with ticks that thin out as it zooms (`⌘` wheel, a pinch, `+`/`-`, `0` fits) and pans with the wheel or a trackpad. The playhead follows the hand 1:1, pulls softly toward markers and lands on frames (`snap`). Markers can be added (`M`), dragged, renamed (`Enter`) and removed, and `[`/`]` jump between them; `I` and `O` set a loop range. `Space` plays on one rAF clock that sleeps when nothing moves, and `←`/`→` step a frame (`⇧` ten; `arrows: { step: 0.5, shift: 2 }` makes them seconds, the playhead gliding there) and `,`/`.` always do; `seek(t, { glide: true })` glides the playhead too, the time landing at once. A right-click on the ruler, a marker or a range opens a context menu. It reports `onSeek(time, source)` (`"scrub" | "key" | "marker" | "play" | "code"`), `onPlay`, `onMarkers`, `onRanges` and `onCross(marker)`. The handle has `time`, `playing`, `duration`, `fps`, `markers`, `ranges`, `view`, `seek`, `play`, `pause`, `toggle`, `update`, `fit`, `zoom`, `focus` and `dispose`. Pieces bound to a timeline hear it with `listen({ onSeek, onPlay, onView, onScrub, onRanges })` (it returns a stop), and `drive(clock)` makes playback follow another clock, such as a video's `currentTime`, so the two never drift. `Timeline` in `knobs/ui/react` and `knobs/ui/solid` follows its props. The Lab's scene clock and Boom retrigger run from one under the viewport.
1185
+
1186
+ ### Compare
1187
+
1188
+ ```ts
1189
+ import { compare, compareStrip, timeline } from "knobs/ui"
1190
+
1191
+ // All in one: the viewer and a timeline of its own.
1192
+ const review = compare(el, {
1193
+ before: { src: "/runs/41.mp4" },
1194
+ after: { src: "/runs/42.mp4" },
1195
+ mode: "side", // the default; "wipe" | "difference" | "flicker"
1196
+ timeline: true,
1197
+ onView: ({ zoom, loop }) => log(zoom, loop), // { u, v, w, h }, { start, end } | null
1198
+ })
1199
+ review.seekBy(0.5)
1200
+ review.setLoop(true) // around the playhead; drag its ends on the timeline
1201
+
1202
+ // A long page of them: each loads near the view and lets go well off it.
1203
+ for (const step of steps) compare(step.el, { before: step.before, after: step.after, timeline: true })
1204
+
1205
+ // Or composed: the app places the pieces and binds them to one timeline.
1206
+ const clock = timeline(dock, { duration: 6, fps: 30 })
1207
+ compare(viewer, { before, after, timeline: clock })
1208
+ compareStrip(strip, { before, after, timeline: clock })
1209
+ ```
1210
+
1211
+ **`compare(container, options)`** is a before/after viewer for videos and images (by extension, or `kind`). Side by side (the default `mode`: a split screen down the middle, each side filling its half), a wipe, a difference (changes above `threshold` in one accent, `--k-compare-diff`) or a flicker, drawn by one WebGPU pass that copies a frame in only when one arrives, and draws only when something changed; where there's no WebGPU, two `<video>`s with a clip-path wipe stand in. Pinch, `⌘`/ctrl-wheel or double-click zooms into either side and both zoom to the same part (the wheel and a drag pan it, both 1:1); a small glass pill in the corner shows how far, with Reset (`0`, a double-click, `Escape`), which glides back. Overlaid, the sides are letterboxed, never cropped. Both videos run from one clock: paused, scrubbed or stepped, each seeks to the frame's midpoint, coalesced while a seek is in flight; playing, After leads, Before's rate leans by up to 25% to stay on it (it seeks past 90 ms), and a bound timeline follows After's time, so only one clock ever moves it (`Space` resumes from the frame showing). Scrubbing answers at once even on raw screen recordings with a keyframe every 8–18 s: frames decoded in the background (WebCodecs where the file is a plain MP4, else a hidden `<video>`) sit in a small cache shared by every viewer and strip of the same source, and the nearest shows while the exact seek lands (mid-drag, far from any cached frame, `fastSeek` answers first; letting go seeks exactly). Encoding with a keyframe every 15 frames makes every seek exact and near instant: `COMPARE_FFMPEG` holds the command (`ffmpeg -i in.mov -vf fps=30 -fps_mode cfr -c:v libx264 -crf 18 -g 15 … out.mp4`). Sources load only near the view and are let go 3 s after leaving it (`lazy: false` keeps them), every viewer shares one GPU device, and in a container with no height of its own it takes the height its sides need, half its width in their shape, so the split has no margins (`aspect` before they load); with a height of its own (a card going full-window, say), the pair fits as a whole and refits as it resizes, keeping the zoom. On a card (`.knobs-surface`, a split's pane) or marked `data-nested`, its stage drops its fill, so one surface shows. Bind an existing `timeline()` (`timeline: clock`), or pass `timeline: true` for its own. Loop (`L`) sets a loop range around the playhead on the timeline, whose ends drag, and playback stays inside it. The wipe follows the hand 1:1 and is a slider (`←`/`→`, `⇧` 10%); a mode change glides. Keys when focused: `Space` plays, `←`/`→` seek half a second (`⇧` two), the playhead gliding there while the videos land at once, `,`/`.` step a frame, `1`–`4` or `S` `W` `D` pick a mode, `X` held flicks to Before, `Z` held shows the loupe (or `loupe: true`), `L` loops, `=`/`-`/`0` zoom. It reports `onMode`, `onWipe` and `onView({ zoom, loop })`; the handle has `mode`, `wipe`, `zoom`, `loop`, `time`, `playing`, `duration`, `renderer`, `timeline`, `strip`, `update`, `seek`, `seekBy`, `play`, `pause`, `toggle`, `step`, `setZoom`, `setLoop`, `focus` and `dispose`. A frame strip is off by default (`strip: true` puts one over the timeline); **`compareStrip(container, { before, after, timeline })`** is the same piece on its own: it draws Before over After along the timeline's view from the same frame cache, decoded only while it's on screen, with a bar for how much each column differs; a press scrubs. `Compare` and `CompareStrip` are in `knobs/ui/react` and `knobs/ui/solid`. The Lab's View switch shows one on its scene timeline.
1184
1212
 
1185
1213
  ### Activity
1186
1214
 
@@ -1242,6 +1270,35 @@ import { KnobsPanel } from "knobs/solid"
1242
1270
 
1243
1271
  The parameters are inset by the picker (`--k-picker-pad`, `0 8px 8px`), whatever a panel's own pad. They are real knobs panels: `body(option, element)` mounts whatever an option needs into `element` the first time it opens and returns a cleanup, and it stays mounted while closed, so a panel keeps its state. The picker is plain DOM chrome in `knobs/ui` and the panels come from `knobs/panel`, so `knobs/ui` stays free of the panel, and the parameters draw with the DOM or the WebGPU renderer as any panel does (nothing around them makes a stacking context at rest). `OptionPicker` in `knobs/ui/solid` and `knobs/ui/react` renders `children(option)` there instead, in the component's context. The handle has `set(options)`, `select(id)`, `selected`, `choose(id)`, `chosen`, `focus()` and `destroy()`. A `select(id)` (or a `selected` or `chosen`) for an option that isn't there yet is kept, and the `set()` that brings it picks it; a later `select` replaces it.
1244
1272
 
1273
+ ### Code diff
1274
+
1275
+ ```ts
1276
+ import { codeDiff, diffReview } from "knobs/ui"
1277
+
1278
+ // A patch's files in a tree beside the diff, in a split.
1279
+ const review = diffReview(container, { ...look, id: "review", patch: gitShowOutput })
1280
+
1281
+ // Or the diff alone, as a card: one file or many, from a patch or before and after text.
1282
+ const diff = codeDiff(el, {
1283
+ ...look,
1284
+ files: [{ name: "src/spring.ts", before, after }],
1285
+ layout: "unified", // "split" by default
1286
+ height: "auto", // as tall as the file's diff, up to maxHeight; "fill" by default
1287
+ onFile: (name) => setFile(name),
1288
+ })
1289
+
1290
+ // Many down a long page (a walkthrough): each holds its height, draws when near the viewport.
1291
+ for (const step of steps) codeDiff(step.el, { ...look, patch: step.patch, height: "auto" })
1292
+ ```
1293
+
1294
+ **`codeDiff(container, options)`** shows a code diff in a knobs card: a header with the file's path, its `+12 −3`, ‹ 2 of 5 › when there are several files (`nav: false` hides it), a wrap toggle and Split | Unified, over the diff. The diff itself is drawn by [`@pierre/diffs`](https://diffs.com) (Shiki highlighting, word-level changes, hunks that expand when whole files are known), an optional peer dependency: install it to use diffs (`npm i @pierre/diffs`). It loads on first use, so `knobs/ui` doesn't carry it. Its colours come from the pieces' tokens, so dark, light, presets and `restyle()` carry into it. `--k-diff-add`, `--k-diff-del` and `--k-diff-bg` tune it, and `syntax: { dark, light }` picks Shiki themes for the code. It takes `patch` (`git diff` or `git show` output, one file or many) or `files: [{ name, before, after, lang? }]`, plus `file`, `layout`, `wrap`, `onFile`, `onLayout`, `onWrap` and `onLines(lines, file)` (dragging over line numbers selects lines). Changing the file, the layout or the wrap fades the new view in over the old in place (.3 s in, .24 s out, a 3px blur while it fades; a .16 s fade under reduced motion). With `height: "fill"` the card fills its container and each file keeps its scroll. With `height: "auto"` it's a self-contained piece for a long page: it's as tall as the file's diff up to `maxHeight` (640, or 70% of the window when that's less) and scrolls inside past that, springing to each file's height (.4 s, no bounce). It holds the height the diff will take from the first frame, counted from the patch's rows before the renderer has loaded, so nothing below it jumps. It also draws only within a screen of the viewport (of the scroller it sits in) and lets go of its views four screens away, keeping its height; `lazy: false` draws it at once, and `lazy: true` does this with `height: "fill"`. Pass `viewed` (names) or `onViewed(viewed, file)` for a Viewed tick in the header. `stack: true` puts every file down one scroller, as GitHub's Files changed does: each under its own header (status, path, counts and their bar, Viewed) that sticks to the top until the next file's pushes it off: flush (square, no card around each file, a gap between files), opaque in the code's colour, with a hairline under it only while it's stuck. The card's header then sums the change, ‹ › and `show()` glide to a file (.4 s), the file on show follows the scroll, and each file draws only near the view.
1295
+
1296
+ Its scrollers fade their content out at an edge with more beyond it (a mask: at the top once scrolled, below a stuck header, and at the bottom while there's more below, `--k-diff-fade`, 20px), easing in and out on the .25 s spring. Its scrollbars are thin ones drawn over the code that take no room and stay hidden until the pointer is over the diff, or while you scroll or drag: `overlayScrollbar` up and down, and a bar per code column across, kept at the bottom of what shows of the column however far down a long file you are (the renderer's own, inside its shadow root, are hidden). One inset runs all round, `--k-diff-inset` (8px, the renderer's own gap): the header's controls sit it in from the top and right, the path, the separators and the line numbers it in from the left, and the code ends it above the bottom. Inner corners (separators, the header's controls) are the outer corner less that distance, so they're concentric with the card. On another surface the diff takes no inset across, so its header and code line up with the surface's own content (a walkthrough's caption), and its corners are measured from the surface's. The header's hairline shows only once the code has scrolled under it. The handle has `files`, `file`, `layout`, `wrap`, `viewed`, `show(name)`, `step(±1)`, `update({ patch?, files?, file?, layout?, wrap?, viewed?, height?, maxHeight? })`, `ready` (once drawn) and `dispose()`. `update({ height: "fill" | "auto" })` switches in place, keeping the views, their scroll and the sticky headers; the new height isn't sprung, so a container whose size is moving (a walkthrough card going full window) is followed, not fought.
1297
+
1298
+ **One surface.** Cards never nest. Whatever draws a card carries `.knobs-surface` (a walkthrough's step; put it on your own cards too), and a split's pane counts as one; a piece inside a surface draws no card of its own, so a diff in a step, a pane or your card shows no second fill or ring, and a `diffReview` inside one parts its panes on a hairline instead of drawing them as cards. The diff's code keeps an opaque fill under its sticky line numbers, the colour showing behind it, so it still matches.
1299
+
1300
+ **`diffReview(container, options)`** pairs it with a file list in one card. The list is a drawer at the card's left that springs open and shut between 0 and 256px (`listSize`; .4 s, no bounce), from a button at the start of the diff's header or `⌘B` / `Ctrl+B` while you're working in the review; the diff reflows as it moves, the list slides out from under it, and with an `id` it stays as you left it (`listOpen: false` starts it shut). The list is built on `tree()` and laid out as GitHub's, Graphite's and VS Code's are: a filter field on top (every word must be in the path: `ui diff`; `Esc` clears it, `↓` or `Enter` goes to the list), then a compact tree (28px rows, `--k-diff-row`) with folders folded into paths (`src/ui` is one row when `src` holds nothing else). Each file shows its change as a coloured letter (M amber, A green, D red and struck through, R blue; `--k-diff-mod` and `--k-diff-move` tune the two new ones), its `+12 −3` and a five-square bar of the share added and removed, and a tick to mark it viewed (on a folder, everything in it). Viewed files dim, the list's title counts them (`2/5`), and with an `id` they're remembered until their change moves on (each is saved with a fingerprint of its diff). The file on show has one highlight that glides to the next (the tree's new `glide` option, on the rows' .3 s spring). Picking a file shows it. The handle has the pieces, `tree` and `diff`, plus `listOpen`, `toggleList(open?)`, `viewed`, `setViewed(names, on)`, `filter(query)` and `update()`; `viewed` and `onViewed(viewed, file)` seed and report the marks, and `search: false` drops the field. To place the tree and the diff yourself, `diffFiles({ patch })` lists the files with their counts in tree order, `diffTree(files)` makes the tree's items, and `diffCounts(file, doc, { bar })` and `diffStatus(file)` give a row's counts and letter (a tree's `detail` and `icon`). `CodeDiff` and `DiffReview` in `knobs/ui/react` and `knobs/ui/solid` follow `patch`, `files`, `file`, `layout`, `wrap`, `viewed`, `height` and `maxHeight`. The demo is `examples/lab-diff.html`.
1301
+
1245
1302
  ### Ideas grid (bento)
1246
1303
 
1247
1304
  ```ts
@@ -1501,6 +1558,8 @@ The package has two core exports selected through the standard `development` con
1501
1558
  | --- | --- |
1502
1559
  | `split(container, { panes, direction?, id?, storage?, onLayout?, preset?, theme? })` | Resizable panes and docks that collapse to rails or headers; layouts saved by `id`. |
1503
1560
  | `tree(container, { items, onSelect?, onMove?, onRename?, toggles?, filter?, typeahead?, … })` | A virtualised outliner with keyboard moves, drag reorder and rename; `typeahead: false \| (key) => boolean` leaves letters to the page. |
1561
+ | `codeDiff(container, { patch? \| files?, file?, layout?, wrap?, height?, onFile?, onLines?, … })` | A code diff in a card (needs `@pierre/diffs`); files and layouts fade in place. |
1562
+ | `diffReview(container, { patch? \| files?, id?, … })` | The changed files (a filter, viewed ticks) in a tree beside the diff, in a split. |
1504
1563
  | `readout(field, { preset?, theme?, label? })` | A readout on its own: `{ element, push(value), value, dispose() }`. |
1505
1564
  | `popover(anchor, content, { placement?, offset?, role?, label?, focus?, trap?, closeOnOutside?, onClose? })` | A floating layer that flips and shifts; `{ element, open, reposition(), close() }`. |
1506
1565
  | `tooltip(el, text \| { text, shortcut?, placement?, delay? })` / `tooltips(doc)` | Delayed tooltips with instant hand-off and shortcut chips; `data-tip` attributes. |
@@ -1509,6 +1568,7 @@ The package has two core exports selected through the standard `development` con
1509
1568
  | `toast(message, { tone?, linger?, action?, within? })` | A status pill at the bottom centre. |
1510
1569
  | `menu(anchor, items, { placement?, label?, onSelect?, onClose? })` / `contextMenu(target, items, options?)` | Menus with icons, shortcut chips, checks, radios and submenus; a right-click or menu-key menu. |
1511
1570
  | `timeline(container, { duration, time?, fps?, units?, markers?, ranges?, snap?, onSeek?, … })` | A zoomable ruler and playhead with markers, loop ranges and playback. |
1571
+ | `compare(container, { before, after, mode?, wipe?, timeline?, onMode?, onWipe?, onView?, … })` / `compareStrip(container, { before, after, timeline })` | A before/after viewer for videos and images (a split screen zoomed together, wipe, difference, flicker) on one clock, with a loop; a frame strip with difference bars, on its own. |
1512
1572
  | `activity(container, { events, selected?, onSelect?, onActivate?, onToggle?, onPlay?, newest?, follow?, keys?, icons?, tints? })` | A rail of events on one continuous line, with groups that open in place; `{ set, add, select, expand, focus, destroy }`. |
1513
1573
  | `optionPicker(container, { options, selected?, chosen?, onSelect?, onChoose?, body?, digits? })` | Options 1–n on a gliding card, the selected one's parameters (a panel) open under it. |
1514
1574
  | `bento(container, { items, gap?, tolerance?, rowHeight?, fit?, blur?, picks?, onPick?, onZoom?, onActive? })` | An ideas grid: a bento of images and live content that zooms an item to fill the view; picks with Space. |
@@ -11396,6 +11396,75 @@ var bentoCss = `
11396
11396
  font-size:var(--ku-text-meta);white-space:nowrap;pointer-events:none}
11397
11397
  `;
11398
11398
 
11399
+ // src/ui/compare-css.ts
11400
+ var compareCss = `
11401
+ :where(.knobs-compare,.knobs-compare *,.knobs-cmp-strip,.knobs-cmp-strip *){box-sizing:border-box}
11402
+ .knobs-ui.knobs-compare{--ku-cmp-diff:var(--k-compare-diff,#7dcf9c);--ku-cmp-line:var(--k-compare-line,#fff);
11403
+ --ku-cmp-stage:var(--k-compare-stage,var(--ku-surface));
11404
+ display:flex;flex-direction:column;gap:6px;width:100%;height:100%;min-height:160px;outline:none;user-select:none;-webkit-user-select:none;
11405
+ font:var(--ku-font-weight) var(--ku-font-size)/1.35 var(--ku-font);color:var(--ku-text);letter-spacing:normal;text-align:left}
11406
+ :where(.knobs-ui) :is(.knobs-cmp-mode,.knobs-cmp-toggle){all:revert;box-sizing:border-box;font:inherit;color:inherit;letter-spacing:inherit;background:transparent;border:0;margin:0;padding:0;outline:none;box-shadow:none}
11407
+ .knobs-cmp-bar{flex:none;display:flex;align-items:center;gap:2px;height:24px;padding:0 2px}
11408
+ .knobs-cmp-modes{display:flex;align-items:center;gap:2px}
11409
+ .knobs-ui .knobs-cmp-mode,.knobs-ui .knobs-cmp-toggle{height:22px;padding:0 7px;border-radius:max(0px,calc(var(--ku-radius) - 2px));color:var(--ku-dim);cursor:pointer;white-space:nowrap;
11410
+ transition:color .15s,background .15s}
11411
+ .knobs-ui .knobs-cmp-mode:is(:hover,:focus-visible),.knobs-ui .knobs-cmp-toggle:is(:hover,:focus-visible){color:var(--ku-text);background:var(--ku-wash)}
11412
+ .knobs-ui .knobs-cmp-mode[aria-checked="true"],.knobs-ui .knobs-cmp-toggle[aria-pressed="true"]{color:var(--ku-text);font-weight:var(--ku-label-weight)}
11413
+ .knobs-cmp-fill{flex:1}
11414
+ .knobs-cmp-toggle[hidden]{display:none}
11415
+ /* One surface: on a card (a walkthrough's step, a split's pane, anything .knobs-surface) or marked
11416
+ data-nested, the stage drops its fill and the sides sit on that card. */
11417
+ :where(.knobs-surface,.knobs-pane:not([data-nested])>.knobs-pane-clip) .knobs-ui.knobs-compare,.knobs-ui.knobs-compare[data-nested]{--ku-cmp-stage:var(--k-compare-stage,transparent)}
11418
+ /* In a page's flow (a container with no height), it takes the height its sides need. */
11419
+ .knobs-ui.knobs-compare[data-auto]{height:auto}
11420
+ .knobs-compare[data-auto] .knobs-cmp-stage{flex:none;min-height:0}
11421
+ .knobs-cmp-stage{position:relative;flex:1;min-height:96px;overflow:hidden;border-radius:var(--ku-panel-radius);background:var(--ku-cmp-stage);touch-action:pan-y;outline:none}
11422
+ .knobs-compare[data-frame="flat"] .knobs-cmp-stage{border-radius:0}
11423
+ .knobs-compare[data-mode="wipe"] .knobs-cmp-stage{cursor:ew-resize}
11424
+ /* Unzoomed, a finger still scrolls the page up and down; zoomed, the stage takes every gesture. */
11425
+ .knobs-compare[data-zoomed] .knobs-cmp-stage{cursor:grab;touch-action:none}
11426
+ .knobs-compare[data-panning],.knobs-compare[data-panning] *{cursor:grabbing!important}
11427
+ .knobs-compare[data-dragging],.knobs-compare[data-dragging] *{cursor:ew-resize!important}
11428
+ .knobs-cmp-sizer{position:absolute;left:0;right:0;top:0;height:0;visibility:hidden;pointer-events:none}
11429
+ .knobs-cmp-canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}
11430
+ .knobs-cmp-media{position:absolute;left:0;top:0;display:block;max-width:none;margin:0;padding:0;border:0;object-fit:fill;transform-origin:0 0;pointer-events:none;will-change:transform}
11431
+ .knobs-compare[data-renderer="gpu"] .knobs-cmp-media{opacity:0}
11432
+ /* Glass: what's behind shows through, blurred, on the knobs tokens. */
11433
+ .knobs-cmp-label,.knobs-cmp-zoom{background:color-mix(in srgb,var(--ku-bg) 52%,transparent);-webkit-backdrop-filter:blur(14px) saturate(1.6);backdrop-filter:blur(14px) saturate(1.6);
11434
+ box-shadow:inset 0 0 0 .5px var(--ku-line),inset 0 1px 0 var(--ku-rim),0 2px 10px rgba(0,0,0,.18)}
11435
+ .knobs-cmp-label{position:absolute;left:0;top:0;padding:1px 7px;border-radius:999px;font-size:var(--ku-text-meta);line-height:16px;color:var(--ku-label);white-space:nowrap;pointer-events:none;will-change:transform;transition:color .15s}
11436
+ .knobs-cmp-label[data-lit]{color:var(--ku-text)}
11437
+ .knobs-cmp-label[data-dim]{color:var(--ku-dim)}
11438
+ /* The wipe: a hairline down the line with a grip at its middle; a wider strip takes the hand and
11439
+ the keys (a slider). It fades with the mode. */
11440
+ .knobs-cmp-handle{cursor:ew-resize;position:absolute;left:-12px;top:0;width:24px;height:0;opacity:0;pointer-events:none;outline:none;will-change:transform;transition:opacity .18s cubic-bezier(.4,0,1,1)}
11441
+ .knobs-cmp-handle[data-on]{opacity:1;pointer-events:auto;transition:opacity .22s cubic-bezier(.2,.8,.2,1)}
11442
+ .knobs-cmp-handle::before{content:"";position:absolute;top:0;bottom:0;left:11.5px;width:1px;background:var(--ku-cmp-line);box-shadow:0 0 0 .5px rgba(0,0,0,.28),0 0 6px rgba(0,0,0,.25)}
11443
+ .knobs-cmp-handle::after{content:"";position:absolute;top:50%;left:9px;width:6px;height:28px;margin-top:-14px;border-radius:3px;background:var(--ku-cmp-line);
11444
+ box-shadow:0 1px 3px rgba(0,0,0,.35),0 0 0 .5px rgba(0,0,0,.2);transition:scale .18s var(--ku-ease),box-shadow .15s}
11445
+ .knobs-compare[data-dragging] .knobs-cmp-handle::after{scale:1.12}
11446
+ .knobs-cmp-handle:focus-visible::after,.knobs-compare[data-keyboard] .knobs-cmp-handle:focus::after{box-shadow:0 1px 3px rgba(0,0,0,.35),0 0 0 3px color-mix(in srgb,var(--ku-accent) 40%,transparent)}
11447
+ /* The split's hairline, down the middle side by side. */
11448
+ .knobs-cmp-split{position:absolute;left:0;top:0;width:1px;height:0;margin-left:-.5px;background:color-mix(in srgb,var(--ku-bg) 72%,transparent);pointer-events:none;will-change:transform,opacity}
11449
+ /* Zoomed: how far, and a way back, in the stage's corner; it fades in and out, nothing shifts. */
11450
+ .knobs-cmp-zoom{position:absolute;right:8px;bottom:8px;display:flex;align-items:center;gap:4px;height:26px;padding:0 3px 0 10px;border-radius:999px;
11451
+ color:var(--ku-text);font-size:var(--ku-text-meta);font-variant-numeric:tabular-nums;font-feature-settings:"tnum";white-space:nowrap;
11452
+ opacity:0;scale:.96;pointer-events:none;transition:opacity .16s cubic-bezier(.4,0,1,1),scale .16s cubic-bezier(.4,0,1,1)}
11453
+ .knobs-cmp-zoom[data-on]{opacity:1;scale:1;pointer-events:auto;transition:opacity .2s cubic-bezier(.2,.8,.2,1),scale .2s cubic-bezier(.2,.8,.2,1)}
11454
+ :where(.knobs-ui) .knobs-cmp-zoom-reset{all:revert;box-sizing:border-box;font:inherit;letter-spacing:inherit;margin:0;border:0;outline:none;box-shadow:none;
11455
+ height:20px;padding:0 8px;border-radius:999px;background:transparent;color:var(--ku-label);cursor:pointer;transition:color .15s,background .15s}
11456
+ :where(.knobs-ui) .knobs-cmp-zoom-reset:is(:hover,:focus-visible){color:var(--ku-text);background:color-mix(in srgb,var(--ku-text) 12%,transparent)}
11457
+ .knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--ku-dim);font-size:var(--ku-text-meta);pointer-events:none}
11458
+ .knobs-cmp-note:empty{display:none}
11459
+ .knobs-cmp-strip-host{flex:none;height:60px;min-height:0}
11460
+ .knobs-cmp-timeline-host{flex:none;height:56px;min-height:0}
11461
+ /* The frame strip: Before over After per column, a difference bar under them, a dot where it peaks. */
11462
+ .knobs-ui.knobs-cmp-strip{--ku-cmp-diff:var(--k-compare-diff,#7dcf9c);position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;cursor:col-resize;touch-action:none;
11463
+ font:var(--ku-font-weight) var(--ku-font-size)/1.35 var(--ku-font);color:var(--ku-text)}
11464
+ .knobs-cmp-strip>canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}
11465
+ @media(prefers-reduced-motion:reduce){:is(.knobs-compare,.knobs-cmp-strip) :where(*,::before,::after){transition-duration:.12s!important}}
11466
+ `;
11467
+
11399
11468
  // src/ui/overlay-css.ts
11400
11469
  var overlayCss = `
11401
11470
  .knobs-layers{position:fixed;inset:0;z-index:2147480005;pointer-events:none}
@@ -11705,6 +11774,14 @@ var ease = springEasing(26, 1), TOKENS2 = {
11705
11774
  :where(.knobs-ui)[data-frame="flat"]{--ku-gap:var(--k-ui-gap,0px);--ku-pane-bg:var(--k-ui-pane,transparent);--ku-pane-radius:0px;--ku-pane-ring:none;
11706
11775
  --ku-divider:var(--k-ui-divider,var(--ku-line));--ku-row-x:var(--k-ui-row-x,0px);--ku-row-radius:0px;
11707
11776
  --ku-select:var(--k-ui-select,color-mix(in srgb,var(--ku-accent) 24%,transparent));--ku-select-focus:var(--k-ui-select-focus,color-mix(in srgb,var(--ku-accent) 34%,transparent))}
11777
+ /* One surface per thing: a piece on another surface draws no card of its own. Whatever draws a
11778
+ card (a walkthrough's step, a host's own) carries .knobs-surface, and a split's pane that's a
11779
+ card (not only a slot for another split) counts as one; any piece inside it, or one
11780
+ marked data-nested, loses its fill and ring and parts its own panes on hairlines as the flat
11781
+ frame does, so cards never stack two or three borders deep. Pieces draw their card from
11782
+ --ku-pane-bg and --ku-pane-ring so this reaches them. */
11783
+ :where(.knobs-surface,.knobs-pane:not([data-nested])>.knobs-pane-clip) .knobs-ui:not(.knobs-surface),.knobs-ui[data-nested]{--ku-gap:var(--k-ui-gap,0px);--ku-pane-bg:transparent;--ku-pane-ring:none;
11784
+ --ku-divider:var(--k-ui-divider,var(--ku-line));--k-diff-bg:transparent}
11708
11785
  :where(.knobs-ui)[data-theme="light"],
11709
11786
  :where(:root[data-theme="light"]) :where(.knobs-ui)[data-theme="auto"]{${lightBlock}}
11710
11787
  @media(prefers-color-scheme:light){:where(:root:not([data-theme="dark"])) :where(.knobs-ui)[data-theme="auto"]{${lightBlock}}}
@@ -11842,6 +11919,12 @@ var ease = springEasing(26, 1), TOKENS2 = {
11842
11919
  .knobs-tree:focus .knobs-tree-row[data-active][aria-selected="true"]::before{background:var(--ku-select-focus)}
11843
11920
  .knobs-tree-row[data-drop="into"]::before{opacity:1;background:color-mix(in srgb,var(--ku-accent) 30%,transparent)}
11844
11921
  .knobs-tree-row[data-dragged]{opacity:.38}
11922
+ /* glide: one highlight under the selected row that glides between rows (the row's own stays off). */
11923
+ .knobs-tree-cursor{position:absolute;left:var(--ku-row-x);right:var(--ku-row-x);top:0;height:var(--ku-row);border-radius:var(--ku-row-radius);background:var(--ku-select);
11924
+ opacity:0;pointer-events:none;will-change:transform;transition:opacity .15s ease-out,background .12s}
11925
+ .knobs-tree-cursor[data-on]{opacity:1}
11926
+ .knobs-tree:focus .knobs-tree-cursor{background:var(--ku-select-focus)}
11927
+ .knobs-tree[data-glide] .knobs-tree-row[aria-selected="true"]::before{opacity:0!important}
11845
11928
  .knobs-tree-row>*{position:relative}
11846
11929
  .knobs-tree-caret{flex:none;display:flex;align-items:center;justify-content:center;width:16px;height:100%;color:var(--ku-dim);transition:transform var(--ku-ease-ms) var(--ku-ease)}
11847
11930
  .knobs-tree-caret svg{width:9px;height:9px}
@@ -11937,7 +12020,7 @@ function injectUiStyles(doc) {
11937
12020
  if (doc.querySelector("style[data-knobs-ui]"))
11938
12021
  return;
11939
12022
  let style = doc.createElement("style");
11940
- style.dataset.knobsUi = "", style.textContent = css + readoutCss + overlayCss + bentoCss + activityCss + pickerCss + rolling_css_default, doc.head.append(style);
12023
+ style.dataset.knobsUi = "", style.textContent = css + readoutCss + overlayCss + bentoCss + activityCss + pickerCss + compareCss + rolling_css_default, doc.head.append(style);
11941
12024
  }
11942
12025
  function injectUiPreset(doc, preset) {
11943
12026
  let content = presetCss({ ...preset, css: void 0 }).replaceAll(".knobs-panel", ".knobs-ui"), existing = doc.querySelector(`style[data-knobs-ui-preset="${preset.name}"]`);
@@ -83,7 +83,7 @@ function attach(controls, element, options = {}) {
83
83
  return;
84
84
  loading = !0;
85
85
  try {
86
- let { createPanel } = await (__preload("jx1ktap3"), import("./chunk-jx1ktap3.js"));
86
+ let { createPanel } = await (__preload("stbcgz1y"), import("./chunk-stbcgz1y.js"));
87
87
  if (disposed || !controls.enabled || panel)
88
88
  return;
89
89
  if (panel = createPanel(controls, element, close, { restored, chip }), remember)
package/dist/dev/index.js CHANGED
@@ -74,7 +74,7 @@ import {
74
74
  urlFlag2,
75
75
  storageKey
76
76
  } from "./chunk-7xgkerd2.js";
77
- __chunks(import.meta.url,["8qt6yygv","jx1ktap3","v8ssbcbk","7xgkerd2","pmkvb9ep"],[["./index.js",3],["./chunk-jx1ktap3.js",3,4],["./chunk-v8ssbcbk.js",3,4],["./chunk-7xgkerd2.js"],["./chunk-pmkvb9ep.js",3]],0);
77
+ __chunks(import.meta.url,["freshcfm","stbcgz1y","t68smqkc","7xgkerd2","pmkvb9ep"],[["./index.js",3],["./chunk-stbcgz1y.js",3,4],["./chunk-t68smqkc.js",3,4],["./chunk-7xgkerd2.js"],["./chunk-pmkvb9ep.js",3]],0);
78
78
  // src/core/random.ts
79
79
  var lattice = (min, max, step, r) => min + Math.floor(r * (Math.floor((max - min) / step + 0.000000001) + 1)) * step, between = (min, max, r) => min + (max - min) * r;
80
80
  function pick(field, random) {
@@ -1375,11 +1375,11 @@ function defineControls(id, input, options = {}) {
1375
1375
  code: (format, values) => formatCode(values ?? get(), format),
1376
1376
  variants,
1377
1377
  attach(element, chip) {
1378
- let load = options.ship ? () => options.ship().then((m) => m.attach) : () => (__preload("v8ssbcbk"), import("./chunk-v8ssbcbk.js")).then((m) => m.attach);
1378
+ let load = options.ship ? () => options.ship().then((m) => m.attach) : () => (__preload("t68smqkc"), import("./chunk-t68smqkc.js")).then((m) => m.attach);
1379
1379
  return live(load, (attach) => attach(controls, element, chip), (detach) => detach());
1380
1380
  },
1381
1381
  mount(container, mountOptions) {
1382
- let load = options.ship ? () => options.ship().then((m) => m.mountPanel) : () => (__preload("jx1ktap3"), import("./chunk-jx1ktap3.js")).then((m) => m.mountPanel);
1382
+ let load = options.ship ? () => options.ship().then((m) => m.mountPanel) : () => (__preload("stbcgz1y"), import("./chunk-stbcgz1y.js")).then((m) => m.mountPanel);
1383
1383
  return live(load, (mountPanel) => {
1384
1384
  let panel = mountPanel(controls, container, mountOptions);
1385
1385
  return mountOptions?.onReady?.(panel), panel;
package/dist/panel.js CHANGED
@@ -19271,6 +19271,75 @@ var bentoCss = `
19271
19271
  font-size:var(--ku-text-meta);white-space:nowrap;pointer-events:none}
19272
19272
  `;
19273
19273
 
19274
+ // src/ui/compare-css.ts
19275
+ var compareCss = `
19276
+ :where(.knobs-compare,.knobs-compare *,.knobs-cmp-strip,.knobs-cmp-strip *){box-sizing:border-box}
19277
+ .knobs-ui.knobs-compare{--ku-cmp-diff:var(--k-compare-diff,#7dcf9c);--ku-cmp-line:var(--k-compare-line,#fff);
19278
+ --ku-cmp-stage:var(--k-compare-stage,var(--ku-surface));
19279
+ display:flex;flex-direction:column;gap:6px;width:100%;height:100%;min-height:160px;outline:none;user-select:none;-webkit-user-select:none;
19280
+ font:var(--ku-font-weight) var(--ku-font-size)/1.35 var(--ku-font);color:var(--ku-text);letter-spacing:normal;text-align:left}
19281
+ :where(.knobs-ui) :is(.knobs-cmp-mode,.knobs-cmp-toggle){all:revert;box-sizing:border-box;font:inherit;color:inherit;letter-spacing:inherit;background:transparent;border:0;margin:0;padding:0;outline:none;box-shadow:none}
19282
+ .knobs-cmp-bar{flex:none;display:flex;align-items:center;gap:2px;height:24px;padding:0 2px}
19283
+ .knobs-cmp-modes{display:flex;align-items:center;gap:2px}
19284
+ .knobs-ui .knobs-cmp-mode,.knobs-ui .knobs-cmp-toggle{height:22px;padding:0 7px;border-radius:max(0px,calc(var(--ku-radius) - 2px));color:var(--ku-dim);cursor:pointer;white-space:nowrap;
19285
+ transition:color .15s,background .15s}
19286
+ .knobs-ui .knobs-cmp-mode:is(:hover,:focus-visible),.knobs-ui .knobs-cmp-toggle:is(:hover,:focus-visible){color:var(--ku-text);background:var(--ku-wash)}
19287
+ .knobs-ui .knobs-cmp-mode[aria-checked="true"],.knobs-ui .knobs-cmp-toggle[aria-pressed="true"]{color:var(--ku-text);font-weight:var(--ku-label-weight)}
19288
+ .knobs-cmp-fill{flex:1}
19289
+ .knobs-cmp-toggle[hidden]{display:none}
19290
+ /* One surface: on a card (a walkthrough's step, a split's pane, anything .knobs-surface) or marked
19291
+ data-nested, the stage drops its fill and the sides sit on that card. */
19292
+ :where(.knobs-surface,.knobs-pane:not([data-nested])>.knobs-pane-clip) .knobs-ui.knobs-compare,.knobs-ui.knobs-compare[data-nested]{--ku-cmp-stage:var(--k-compare-stage,transparent)}
19293
+ /* In a page's flow (a container with no height), it takes the height its sides need. */
19294
+ .knobs-ui.knobs-compare[data-auto]{height:auto}
19295
+ .knobs-compare[data-auto] .knobs-cmp-stage{flex:none;min-height:0}
19296
+ .knobs-cmp-stage{position:relative;flex:1;min-height:96px;overflow:hidden;border-radius:var(--ku-panel-radius);background:var(--ku-cmp-stage);touch-action:pan-y;outline:none}
19297
+ .knobs-compare[data-frame="flat"] .knobs-cmp-stage{border-radius:0}
19298
+ .knobs-compare[data-mode="wipe"] .knobs-cmp-stage{cursor:ew-resize}
19299
+ /* Unzoomed, a finger still scrolls the page up and down; zoomed, the stage takes every gesture. */
19300
+ .knobs-compare[data-zoomed] .knobs-cmp-stage{cursor:grab;touch-action:none}
19301
+ .knobs-compare[data-panning],.knobs-compare[data-panning] *{cursor:grabbing!important}
19302
+ .knobs-compare[data-dragging],.knobs-compare[data-dragging] *{cursor:ew-resize!important}
19303
+ .knobs-cmp-sizer{position:absolute;left:0;right:0;top:0;height:0;visibility:hidden;pointer-events:none}
19304
+ .knobs-cmp-canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}
19305
+ .knobs-cmp-media{position:absolute;left:0;top:0;display:block;max-width:none;margin:0;padding:0;border:0;object-fit:fill;transform-origin:0 0;pointer-events:none;will-change:transform}
19306
+ .knobs-compare[data-renderer="gpu"] .knobs-cmp-media{opacity:0}
19307
+ /* Glass: what's behind shows through, blurred, on the knobs tokens. */
19308
+ .knobs-cmp-label,.knobs-cmp-zoom{background:color-mix(in srgb,var(--ku-bg) 52%,transparent);-webkit-backdrop-filter:blur(14px) saturate(1.6);backdrop-filter:blur(14px) saturate(1.6);
19309
+ box-shadow:inset 0 0 0 .5px var(--ku-line),inset 0 1px 0 var(--ku-rim),0 2px 10px rgba(0,0,0,.18)}
19310
+ .knobs-cmp-label{position:absolute;left:0;top:0;padding:1px 7px;border-radius:999px;font-size:var(--ku-text-meta);line-height:16px;color:var(--ku-label);white-space:nowrap;pointer-events:none;will-change:transform;transition:color .15s}
19311
+ .knobs-cmp-label[data-lit]{color:var(--ku-text)}
19312
+ .knobs-cmp-label[data-dim]{color:var(--ku-dim)}
19313
+ /* The wipe: a hairline down the line with a grip at its middle; a wider strip takes the hand and
19314
+ the keys (a slider). It fades with the mode. */
19315
+ .knobs-cmp-handle{cursor:ew-resize;position:absolute;left:-12px;top:0;width:24px;height:0;opacity:0;pointer-events:none;outline:none;will-change:transform;transition:opacity .18s cubic-bezier(.4,0,1,1)}
19316
+ .knobs-cmp-handle[data-on]{opacity:1;pointer-events:auto;transition:opacity .22s cubic-bezier(.2,.8,.2,1)}
19317
+ .knobs-cmp-handle::before{content:"";position:absolute;top:0;bottom:0;left:11.5px;width:1px;background:var(--ku-cmp-line);box-shadow:0 0 0 .5px rgba(0,0,0,.28),0 0 6px rgba(0,0,0,.25)}
19318
+ .knobs-cmp-handle::after{content:"";position:absolute;top:50%;left:9px;width:6px;height:28px;margin-top:-14px;border-radius:3px;background:var(--ku-cmp-line);
19319
+ box-shadow:0 1px 3px rgba(0,0,0,.35),0 0 0 .5px rgba(0,0,0,.2);transition:scale .18s var(--ku-ease),box-shadow .15s}
19320
+ .knobs-compare[data-dragging] .knobs-cmp-handle::after{scale:1.12}
19321
+ .knobs-cmp-handle:focus-visible::after,.knobs-compare[data-keyboard] .knobs-cmp-handle:focus::after{box-shadow:0 1px 3px rgba(0,0,0,.35),0 0 0 3px color-mix(in srgb,var(--ku-accent) 40%,transparent)}
19322
+ /* The split's hairline, down the middle side by side. */
19323
+ .knobs-cmp-split{position:absolute;left:0;top:0;width:1px;height:0;margin-left:-.5px;background:color-mix(in srgb,var(--ku-bg) 72%,transparent);pointer-events:none;will-change:transform,opacity}
19324
+ /* Zoomed: how far, and a way back, in the stage's corner; it fades in and out, nothing shifts. */
19325
+ .knobs-cmp-zoom{position:absolute;right:8px;bottom:8px;display:flex;align-items:center;gap:4px;height:26px;padding:0 3px 0 10px;border-radius:999px;
19326
+ color:var(--ku-text);font-size:var(--ku-text-meta);font-variant-numeric:tabular-nums;font-feature-settings:"tnum";white-space:nowrap;
19327
+ opacity:0;scale:.96;pointer-events:none;transition:opacity .16s cubic-bezier(.4,0,1,1),scale .16s cubic-bezier(.4,0,1,1)}
19328
+ .knobs-cmp-zoom[data-on]{opacity:1;scale:1;pointer-events:auto;transition:opacity .2s cubic-bezier(.2,.8,.2,1),scale .2s cubic-bezier(.2,.8,.2,1)}
19329
+ :where(.knobs-ui) .knobs-cmp-zoom-reset{all:revert;box-sizing:border-box;font:inherit;letter-spacing:inherit;margin:0;border:0;outline:none;box-shadow:none;
19330
+ height:20px;padding:0 8px;border-radius:999px;background:transparent;color:var(--ku-label);cursor:pointer;transition:color .15s,background .15s}
19331
+ :where(.knobs-ui) .knobs-cmp-zoom-reset:is(:hover,:focus-visible){color:var(--ku-text);background:color-mix(in srgb,var(--ku-text) 12%,transparent)}
19332
+ .knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--ku-dim);font-size:var(--ku-text-meta);pointer-events:none}
19333
+ .knobs-cmp-note:empty{display:none}
19334
+ .knobs-cmp-strip-host{flex:none;height:60px;min-height:0}
19335
+ .knobs-cmp-timeline-host{flex:none;height:56px;min-height:0}
19336
+ /* The frame strip: Before over After per column, a difference bar under them, a dot where it peaks. */
19337
+ .knobs-ui.knobs-cmp-strip{--ku-cmp-diff:var(--k-compare-diff,#7dcf9c);position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;cursor:col-resize;touch-action:none;
19338
+ font:var(--ku-font-weight) var(--ku-font-size)/1.35 var(--ku-font);color:var(--ku-text)}
19339
+ .knobs-cmp-strip>canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}
19340
+ @media(prefers-reduced-motion:reduce){:is(.knobs-compare,.knobs-cmp-strip) :where(*,::before,::after){transition-duration:.12s!important}}
19341
+ `;
19342
+
19274
19343
  // src/ui/overlay-css.ts
19275
19344
  var overlayCss = `
19276
19345
  .knobs-layers{position:fixed;inset:0;z-index:2147480005;pointer-events:none}
@@ -19515,7 +19584,7 @@ function injectUiStyles(doc) {
19515
19584
  return;
19516
19585
  const style = doc.createElement("style");
19517
19586
  style.dataset.knobsUi = "";
19518
- style.textContent = css2 + readoutCss + overlayCss + bentoCss + activityCss + pickerCss + rolling_css_default;
19587
+ style.textContent = css2 + readoutCss + overlayCss + bentoCss + activityCss + pickerCss + compareCss + rolling_css_default;
19519
19588
  doc.head.append(style);
19520
19589
  }
19521
19590
  function injectUiPreset(doc, preset) {
@@ -19662,6 +19731,14 @@ var init_look = __esm(() => {
19662
19731
  :where(.knobs-ui)[data-frame="flat"]{--ku-gap:var(--k-ui-gap,0px);--ku-pane-bg:var(--k-ui-pane,transparent);--ku-pane-radius:0px;--ku-pane-ring:none;
19663
19732
  --ku-divider:var(--k-ui-divider,var(--ku-line));--ku-row-x:var(--k-ui-row-x,0px);--ku-row-radius:0px;
19664
19733
  --ku-select:var(--k-ui-select,color-mix(in srgb,var(--ku-accent) 24%,transparent));--ku-select-focus:var(--k-ui-select-focus,color-mix(in srgb,var(--ku-accent) 34%,transparent))}
19734
+ /* One surface per thing: a piece on another surface draws no card of its own. Whatever draws a
19735
+ card (a walkthrough's step, a host's own) carries .knobs-surface, and a split's pane that's a
19736
+ card (not only a slot for another split) counts as one; any piece inside it, or one
19737
+ marked data-nested, loses its fill and ring and parts its own panes on hairlines as the flat
19738
+ frame does, so cards never stack two or three borders deep. Pieces draw their card from
19739
+ --ku-pane-bg and --ku-pane-ring so this reaches them. */
19740
+ :where(.knobs-surface,.knobs-pane:not([data-nested])>.knobs-pane-clip) .knobs-ui:not(.knobs-surface),.knobs-ui[data-nested]{--ku-gap:var(--k-ui-gap,0px);--ku-pane-bg:transparent;--ku-pane-ring:none;
19741
+ --ku-divider:var(--k-ui-divider,var(--ku-line));--k-diff-bg:transparent}
19665
19742
  :where(.knobs-ui)[data-theme="light"],
19666
19743
  :where(:root[data-theme="light"]) :where(.knobs-ui)[data-theme="auto"]{${lightBlock}}
19667
19744
  @media(prefers-color-scheme:light){:where(:root:not([data-theme="dark"])) :where(.knobs-ui)[data-theme="auto"]{${lightBlock}}}
@@ -19799,6 +19876,12 @@ var init_look = __esm(() => {
19799
19876
  .knobs-tree:focus .knobs-tree-row[data-active][aria-selected="true"]::before{background:var(--ku-select-focus)}
19800
19877
  .knobs-tree-row[data-drop="into"]::before{opacity:1;background:color-mix(in srgb,var(--ku-accent) 30%,transparent)}
19801
19878
  .knobs-tree-row[data-dragged]{opacity:.38}
19879
+ /* glide: one highlight under the selected row that glides between rows (the row's own stays off). */
19880
+ .knobs-tree-cursor{position:absolute;left:var(--ku-row-x);right:var(--ku-row-x);top:0;height:var(--ku-row);border-radius:var(--ku-row-radius);background:var(--ku-select);
19881
+ opacity:0;pointer-events:none;will-change:transform;transition:opacity .15s ease-out,background .12s}
19882
+ .knobs-tree-cursor[data-on]{opacity:1}
19883
+ .knobs-tree:focus .knobs-tree-cursor{background:var(--ku-select-focus)}
19884
+ .knobs-tree[data-glide] .knobs-tree-row[aria-selected="true"]::before{opacity:0!important}
19802
19885
  .knobs-tree-row>*{position:relative}
19803
19886
  .knobs-tree-caret{flex:none;display:flex;align-items:center;justify-content:center;width:16px;height:100%;color:var(--ku-dim);transition:transform var(--ku-ease-ms) var(--ku-ease)}
19804
19887
  .knobs-tree-caret svg{width:9px;height:9px}
@@ -0,0 +1 @@
1
+ export declare const compareCss = "\n:where(.knobs-compare,.knobs-compare *,.knobs-cmp-strip,.knobs-cmp-strip *){box-sizing:border-box}\n.knobs-ui.knobs-compare{--ku-cmp-diff:var(--k-compare-diff,#7dcf9c);--ku-cmp-line:var(--k-compare-line,#fff);\n --ku-cmp-stage:var(--k-compare-stage,var(--ku-surface));\n display:flex;flex-direction:column;gap:6px;width:100%;height:100%;min-height:160px;outline:none;user-select:none;-webkit-user-select:none;\n font:var(--ku-font-weight) var(--ku-font-size)/1.35 var(--ku-font);color:var(--ku-text);letter-spacing:normal;text-align:left}\n:where(.knobs-ui) :is(.knobs-cmp-mode,.knobs-cmp-toggle){all:revert;box-sizing:border-box;font:inherit;color:inherit;letter-spacing:inherit;background:transparent;border:0;margin:0;padding:0;outline:none;box-shadow:none}\n.knobs-cmp-bar{flex:none;display:flex;align-items:center;gap:2px;height:24px;padding:0 2px}\n.knobs-cmp-modes{display:flex;align-items:center;gap:2px}\n.knobs-ui .knobs-cmp-mode,.knobs-ui .knobs-cmp-toggle{height:22px;padding:0 7px;border-radius:max(0px,calc(var(--ku-radius) - 2px));color:var(--ku-dim);cursor:pointer;white-space:nowrap;\n transition:color .15s,background .15s}\n.knobs-ui .knobs-cmp-mode:is(:hover,:focus-visible),.knobs-ui .knobs-cmp-toggle:is(:hover,:focus-visible){color:var(--ku-text);background:var(--ku-wash)}\n.knobs-ui .knobs-cmp-mode[aria-checked=\"true\"],.knobs-ui .knobs-cmp-toggle[aria-pressed=\"true\"]{color:var(--ku-text);font-weight:var(--ku-label-weight)}\n.knobs-cmp-fill{flex:1}\n.knobs-cmp-toggle[hidden]{display:none}\n/* One surface: on a card (a walkthrough's step, a split's pane, anything .knobs-surface) or marked\n data-nested, the stage drops its fill and the sides sit on that card. */\n:where(.knobs-surface,.knobs-pane:not([data-nested])>.knobs-pane-clip) .knobs-ui.knobs-compare,.knobs-ui.knobs-compare[data-nested]{--ku-cmp-stage:var(--k-compare-stage,transparent)}\n/* In a page's flow (a container with no height), it takes the height its sides need. */\n.knobs-ui.knobs-compare[data-auto]{height:auto}\n.knobs-compare[data-auto] .knobs-cmp-stage{flex:none;min-height:0}\n.knobs-cmp-stage{position:relative;flex:1;min-height:96px;overflow:hidden;border-radius:var(--ku-panel-radius);background:var(--ku-cmp-stage);touch-action:pan-y;outline:none}\n.knobs-compare[data-frame=\"flat\"] .knobs-cmp-stage{border-radius:0}\n.knobs-compare[data-mode=\"wipe\"] .knobs-cmp-stage{cursor:ew-resize}\n/* Unzoomed, a finger still scrolls the page up and down; zoomed, the stage takes every gesture. */\n.knobs-compare[data-zoomed] .knobs-cmp-stage{cursor:grab;touch-action:none}\n.knobs-compare[data-panning],.knobs-compare[data-panning] *{cursor:grabbing!important}\n.knobs-compare[data-dragging],.knobs-compare[data-dragging] *{cursor:ew-resize!important}\n.knobs-cmp-sizer{position:absolute;left:0;right:0;top:0;height:0;visibility:hidden;pointer-events:none}\n.knobs-cmp-canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n.knobs-cmp-media{position:absolute;left:0;top:0;display:block;max-width:none;margin:0;padding:0;border:0;object-fit:fill;transform-origin:0 0;pointer-events:none;will-change:transform}\n.knobs-compare[data-renderer=\"gpu\"] .knobs-cmp-media{opacity:0}\n/* Glass: what's behind shows through, blurred, on the knobs tokens. */\n.knobs-cmp-label,.knobs-cmp-zoom{background:color-mix(in srgb,var(--ku-bg) 52%,transparent);-webkit-backdrop-filter:blur(14px) saturate(1.6);backdrop-filter:blur(14px) saturate(1.6);\n box-shadow:inset 0 0 0 .5px var(--ku-line),inset 0 1px 0 var(--ku-rim),0 2px 10px rgba(0,0,0,.18)}\n.knobs-cmp-label{position:absolute;left:0;top:0;padding:1px 7px;border-radius:999px;font-size:var(--ku-text-meta);line-height:16px;color:var(--ku-label);white-space:nowrap;pointer-events:none;will-change:transform;transition:color .15s}\n.knobs-cmp-label[data-lit]{color:var(--ku-text)}\n.knobs-cmp-label[data-dim]{color:var(--ku-dim)}\n/* The wipe: a hairline down the line with a grip at its middle; a wider strip takes the hand and\n the keys (a slider). It fades with the mode. */\n.knobs-cmp-handle{cursor:ew-resize;position:absolute;left:-12px;top:0;width:24px;height:0;opacity:0;pointer-events:none;outline:none;will-change:transform;transition:opacity .18s cubic-bezier(.4,0,1,1)}\n.knobs-cmp-handle[data-on]{opacity:1;pointer-events:auto;transition:opacity .22s cubic-bezier(.2,.8,.2,1)}\n.knobs-cmp-handle::before{content:\"\";position:absolute;top:0;bottom:0;left:11.5px;width:1px;background:var(--ku-cmp-line);box-shadow:0 0 0 .5px rgba(0,0,0,.28),0 0 6px rgba(0,0,0,.25)}\n.knobs-cmp-handle::after{content:\"\";position:absolute;top:50%;left:9px;width:6px;height:28px;margin-top:-14px;border-radius:3px;background:var(--ku-cmp-line);\n box-shadow:0 1px 3px rgba(0,0,0,.35),0 0 0 .5px rgba(0,0,0,.2);transition:scale .18s var(--ku-ease),box-shadow .15s}\n.knobs-compare[data-dragging] .knobs-cmp-handle::after{scale:1.12}\n.knobs-cmp-handle:focus-visible::after,.knobs-compare[data-keyboard] .knobs-cmp-handle:focus::after{box-shadow:0 1px 3px rgba(0,0,0,.35),0 0 0 3px color-mix(in srgb,var(--ku-accent) 40%,transparent)}\n/* The split's hairline, down the middle side by side. */\n.knobs-cmp-split{position:absolute;left:0;top:0;width:1px;height:0;margin-left:-.5px;background:color-mix(in srgb,var(--ku-bg) 72%,transparent);pointer-events:none;will-change:transform,opacity}\n/* Zoomed: how far, and a way back, in the stage's corner; it fades in and out, nothing shifts. */\n.knobs-cmp-zoom{position:absolute;right:8px;bottom:8px;display:flex;align-items:center;gap:4px;height:26px;padding:0 3px 0 10px;border-radius:999px;\n color:var(--ku-text);font-size:var(--ku-text-meta);font-variant-numeric:tabular-nums;font-feature-settings:\"tnum\";white-space:nowrap;\n opacity:0;scale:.96;pointer-events:none;transition:opacity .16s cubic-bezier(.4,0,1,1),scale .16s cubic-bezier(.4,0,1,1)}\n.knobs-cmp-zoom[data-on]{opacity:1;scale:1;pointer-events:auto;transition:opacity .2s cubic-bezier(.2,.8,.2,1),scale .2s cubic-bezier(.2,.8,.2,1)}\n:where(.knobs-ui) .knobs-cmp-zoom-reset{all:revert;box-sizing:border-box;font:inherit;letter-spacing:inherit;margin:0;border:0;outline:none;box-shadow:none;\n height:20px;padding:0 8px;border-radius:999px;background:transparent;color:var(--ku-label);cursor:pointer;transition:color .15s,background .15s}\n:where(.knobs-ui) .knobs-cmp-zoom-reset:is(:hover,:focus-visible){color:var(--ku-text);background:color-mix(in srgb,var(--ku-text) 12%,transparent)}\n.knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--ku-dim);font-size:var(--ku-text-meta);pointer-events:none}\n.knobs-cmp-note:empty{display:none}\n.knobs-cmp-strip-host{flex:none;height:60px;min-height:0}\n.knobs-cmp-timeline-host{flex:none;height:56px;min-height:0}\n/* The frame strip: Before over After per column, a difference bar under them, a dot where it peaks. */\n.knobs-ui.knobs-cmp-strip{--ku-cmp-diff:var(--k-compare-diff,#7dcf9c);position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;cursor:col-resize;touch-action:none;\n font:var(--ku-font-weight) var(--ku-font-size)/1.35 var(--ku-font);color:var(--ku-text)}\n.knobs-cmp-strip>canvas{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n@media(prefers-reduced-motion:reduce){:is(.knobs-compare,.knobs-cmp-strip) :where(*,::before,::after){transition-duration:.12s!important}}\n";
@@ -0,0 +1,41 @@
1
+ export interface CachedFrame {
2
+ readonly frame: number;
3
+ readonly bitmap: ImageBitmap;
4
+ /** Full size (else a small grid frame). */
5
+ readonly full: boolean;
6
+ }
7
+ export interface FramePoint {
8
+ readonly frame: number;
9
+ /** Any cached frame this close (frames) will do. */
10
+ readonly tolerance: number;
11
+ }
12
+ export interface FrameCache {
13
+ readonly src: string;
14
+ /** How it decodes, once it knows. */
15
+ readonly backend: "webcodecs" | "video" | "pending";
16
+ /** Frames held now. */
17
+ readonly size: number;
18
+ /** The cached frame nearest `frame`, no further than `within` frames. */
19
+ nearest(frame: number, within?: number): CachedFrame | null;
20
+ /** The playhead of the viewer being used: full-size frames around it decode first. */
21
+ focus(frame: number): void;
22
+ /** Frames a strip shows (small, with a tolerance each). */
23
+ want(points: readonly FramePoint[]): void;
24
+ /** A frame the viewer has decoded exactly (paused on it). */
25
+ keep(frame: number, source: CanvasImageSource, width: number, height: number): void;
26
+ /** Whether a viewer of it is near the screen: only then does it decode. */
27
+ visible(on: boolean): void;
28
+ /** New frames arrived. */
29
+ listen(f: () => void): () => void;
30
+ release(): void;
31
+ }
32
+ /** Frames either side of the focus decoded at full size. */
33
+ export declare const FOCUS_RADIUS = 6;
34
+ /** The grid's frames: `count` evenly across `total`, as points with half a step's tolerance. */
35
+ export declare function gridPoints(total: number, count?: number): FramePoint[];
36
+ /** The full-size frames wanted around `focus`, nearest first. */
37
+ export declare function focusWindow(focus: number, total: number, radius?: number): number[];
38
+ /** The key in `keys` nearest `frame` within `within` (ties go to the later frame). */
39
+ export declare function nearestKey(keys: Iterable<number>, frame: number, within?: number): number | null;
40
+ /** The shared cache for `src` at `fps`; `release()` it when done. */
41
+ export declare function acquireFrames(doc: Document, src: string, fps: number): FrameCache;
@@ -0,0 +1,58 @@
1
+ import type { Rect } from "./compare-model.js";
2
+ export declare const MODE_INDEX: {
3
+ readonly side: 0;
4
+ readonly wipe: 1;
5
+ readonly difference: 2;
6
+ readonly flicker: 3;
7
+ };
8
+ export interface CompareDraw {
9
+ /** Device px: where each side's picture lands (zoomed, it's larger than its clip). */
10
+ readonly before: Rect;
11
+ readonly after: Rect;
12
+ /** Device px: the part of the stage each side may draw in. */
13
+ readonly beforeClip: Rect;
14
+ readonly afterClip: Rect;
15
+ readonly width: number;
16
+ readonly height: number;
17
+ readonly dpr: number;
18
+ /** The wipe's line (device px) and its soft edge (device px). */
19
+ readonly wipeX: number;
20
+ readonly soft: number;
21
+ readonly from: number;
22
+ readonly to: number;
23
+ /** 0 shows `from`, 1 shows `to`. */
24
+ readonly mix: number;
25
+ /** Before in After's place (X held). */
26
+ readonly flick: boolean;
27
+ /** Flicker mode's phase: Before showing. */
28
+ readonly phase: boolean;
29
+ readonly threshold: number;
30
+ readonly gain: number;
31
+ readonly accent: readonly [number, number, number];
32
+ /** Device px centre and radius, and zoom; null for none. */
33
+ readonly loupe: {
34
+ readonly x: number;
35
+ readonly y: number;
36
+ readonly r: number;
37
+ readonly zoom: number;
38
+ } | null;
39
+ /** Each side shows its preview texture (a cached frame) instead of its video's. */
40
+ readonly preview: readonly [boolean, boolean];
41
+ }
42
+ export interface CompareGpu {
43
+ /**
44
+ * Copies a frame in (0 Before, 1 After; 2 and 3 their previews); false if it couldn't (no data
45
+ * yet, or a tainted source).
46
+ */
47
+ upload(slot: 0 | 1 | 2 | 3, source: CanvasImageSource, w: number, h: number): boolean;
48
+ /** Forgets a side's frame (a new source is loading). */
49
+ clear(side: 0 | 1): void;
50
+ /** Frees the textures and shrinks the canvas (off screen); uploads bring them back. */
51
+ shrink(): void;
52
+ draw(frame: CompareDraw): void;
53
+ dispose(): void;
54
+ }
55
+ /** A renderer on `canvas`, or null where WebGPU isn't there. `onLost` hears a lost device. */
56
+ export declare function createCompareGpu(canvas: HTMLCanvasElement, onLost: () => void): Promise<CompareGpu | null>;
57
+ /** The shared device once made (null before it is, or without WebGPU): for checks. */
58
+ export declare const sharedDevice: () => Promise<unknown>;