@kitlangton/knobs 0.4.0-next.15 → 0.4.0-next.17

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.
Files changed (41) hide show
  1. package/README.md +46 -14
  2. package/dist/dev/{chunk-v2g91mrm.js → chunk-2g0znytq.js} +1 -0
  3. package/dist/dev/{chunk-bejz4qcg.js → chunk-3vag4n3q.js} +47 -10
  4. package/dist/dev/{chunk-49hkdggk.js → chunk-5cv3g7ca.js} +395 -104
  5. package/dist/dev/{chunk-902v1jgd.js → chunk-xzm0zawh.js} +6 -6
  6. package/dist/dev/index.js +9 -8
  7. package/dist/index.js +7 -5
  8. package/dist/panel.js +549 -121
  9. package/dist/review-react.js +1341 -880
  10. package/dist/review-solid.js +1341 -880
  11. package/dist/review.js +1711 -1248
  12. package/dist/themes.js +8 -0
  13. package/dist/types/core/schema.d.ts +11 -2
  14. package/dist/types/core/store.d.ts +29 -1
  15. package/dist/types/index.d.ts +1 -1
  16. package/dist/types/panel/active.d.ts +53 -0
  17. package/dist/types/panel/description-ids.d.ts +6 -0
  18. package/dist/types/panel/folds.d.ts +7 -4
  19. package/dist/types/panel/gpu/color.d.ts +2 -0
  20. package/dist/types/panel/gpu/renderer.d.ts +8 -2
  21. package/dist/types/panel/index.d.ts +1 -0
  22. package/dist/types/panel/page-hints.d.ts +3 -2
  23. package/dist/types/panel/page.d.ts +2 -15
  24. package/dist/types/panel/position.d.ts +23 -0
  25. package/dist/types/panel/summary.d.ts +22 -0
  26. package/dist/types/tokens/schema.d.ts +9 -0
  27. package/dist/types/ui/compare-css.d.ts +1 -1
  28. package/dist/types/ui/compare-frames.d.ts +64 -2
  29. package/dist/types/ui/compare-model.d.ts +23 -0
  30. package/dist/types/ui/compare.d.ts +6 -4
  31. package/dist/types/ui/player-css.d.ts +1 -1
  32. package/dist/types/ui/player.d.ts +6 -0
  33. package/dist/types/ui/react.d.ts +1 -1
  34. package/dist/types/ui/solid.d.ts +1 -1
  35. package/dist/types/ui/tooltip-model.d.ts +35 -0
  36. package/dist/types/ui/tooltip.d.ts +1 -1
  37. package/dist/types/ui/walkthrough-model.d.ts +4 -2
  38. package/dist/ui-react.js +2015 -1507
  39. package/dist/ui-solid.js +2014 -1506
  40. package/dist/ui.js +2013 -1505
  41. package/package.json +1 -1
package/dist/themes.js CHANGED
@@ -520,6 +520,14 @@ var TOKENS = [
520
520
  doc: "Panel and card radius, concentric with controls.",
521
521
  deprecated: ["--k-panel-radius", "--ku-panel-radius"]
522
522
  },
523
+ {
524
+ name: "radius-frame",
525
+ tier: "semantic",
526
+ type: "length",
527
+ group: "Shape",
528
+ source: css("calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))"),
529
+ doc: "A tuning panel's outer corners: its cards' radius plus its padding and border, so the cards, the preset strip and the focus lens inside run concentric. Squares when --k-radius is 0."
530
+ },
523
531
  {
524
532
  name: "round",
525
533
  tier: "semantic",
@@ -5,6 +5,11 @@ import { CURVE_SHAPES, type CurveInterp, type CurvePoint, type GradientSpace, ty
5
5
  import type { SpringValue } from "./spring.js";
6
6
  export interface FieldMeta {
7
7
  readonly label?: string;
8
+ /**
9
+ * What the knob does, in a sentence: a tooltip over its row (after a pause, or on keyboard
10
+ * focus) and its control's accessible description. Search matches it.
11
+ */
12
+ readonly description?: string;
8
13
  readonly group?: string;
9
14
  /**
10
15
  * What the group's header shows when it isn't the `group` id itself (the first field that
@@ -15,7 +20,10 @@ export interface FieldMeta {
15
20
  readonly section?: string;
16
21
  /** What the section's heading shows (the first field that gives one wins); `section` by default. */
17
22
  readonly sectionLabel?: string;
18
- /** One muted line under the group's card title on a page (the first field that gives one wins). */
23
+ /**
24
+ * One muted line under the group's card title, in a panel or a page (the first field that gives
25
+ * one wins).
26
+ */
19
27
  readonly groupDescription?: string;
20
28
  /** One muted line under the section's heading on a page (the first field that gives one wins). */
21
29
  readonly sectionDescription?: string;
@@ -68,7 +76,7 @@ export interface FieldMeta {
68
76
  readonly subsection?: string;
69
77
  }
70
78
  /** Meta keys copied from a field's options or a descriptor onto the field. */
71
- export declare const META_KEYS: readonly ["label", "group", "groupLabel", "section", "sectionLabel", "groupDescription", "sectionDescription", "shortcut", "random", "disabled", "readOnly", "folded", "sectionFolded", "tab", "pinned", "inPresets", "subsection"];
79
+ export declare const META_KEYS: readonly ["label", "description", "group", "groupLabel", "section", "sectionLabel", "groupDescription", "sectionDescription", "shortcut", "random", "disabled", "readOnly", "folded", "sectionFolded", "tab", "pinned", "inPresets", "subsection"];
72
80
  /** The meta `source` gives, without undefined keys. */
73
81
  export declare function pickMeta(source: FieldMeta | undefined, skip?: readonly string[]): FieldMeta;
74
82
  /** Marks a knob as half of a flat spring: two number keys drawn as one spring card. */
@@ -490,6 +498,7 @@ export declare function range(value: readonly [number, number], options: FieldMe
490
498
  unit?: string;
491
499
  decimals?: number;
492
500
  label?: string;
501
+ description?: string;
493
502
  group?: string;
494
503
  groupLabel?: string;
495
504
  section?: string;
@@ -96,7 +96,7 @@ export interface LabelOptions {
96
96
  readonly defaultLabel?: string | Readonly<Record<string, string>>;
97
97
  /** How a page card shows its group presets (see `GroupPresetDisplay`). */
98
98
  readonly groupPresetDisplay?: GroupPresetDisplay;
99
- /** By group id: one muted line under its card's title on a page, over fields' `groupDescription`. */
99
+ /** By group id: one muted line under its card's title, over fields' `groupDescription`. */
100
100
  readonly groupDescriptions?: Readonly<Record<string, string>>;
101
101
  /** By section id: one muted line under its heading on a page, over fields' `sectionDescription`. */
102
102
  readonly sectionDescriptions?: Readonly<Record<string, string>>;
@@ -146,6 +146,20 @@ export interface HeaderOptions {
146
146
  */
147
147
  readonly buttons?: readonly HeaderButton[];
148
148
  }
149
+ /** What's active in a panel (see `MountPanelOptions.onActive`): a card, or one of its rows. */
150
+ export interface PanelActive {
151
+ readonly group: string;
152
+ readonly key?: string;
153
+ }
154
+ /** What a folded card's or section's summary is made of (see `MountPanelOptions.groupSummary`). */
155
+ export interface FoldSummaryInfo {
156
+ /** The group preset its values match (its name), "Custom" when none does; null without any. */
157
+ readonly preset: string | null;
158
+ /** How many of its rows differ from their defaults (a spring's two keys count once). */
159
+ readonly changed: number;
160
+ /** Its value keys. */
161
+ readonly keys: readonly string[];
162
+ }
149
163
  export interface MountPanelOptions extends LabelOptions {
150
164
  readonly title?: string | false;
151
165
  /**
@@ -253,6 +267,20 @@ export interface MountPanelOptions extends LabelOptions {
253
267
  readonly sound?: boolean | SoundOptions;
254
268
  /** Called once the panel is mounted, with a handle to focus it (a host's "Tune" key). */
255
269
  readonly onReady?: (panel: PanelHandle) => void;
270
+ /**
271
+ * Called when the card or row under the pointer or focus changes: `{ group }` for a card's
272
+ * header or body, `{ group, key }` for one of its rows, `null` for nothing (and once more when
273
+ * the panel goes). Whichever of pointer and focus moved last wins; at most once a frame, never
274
+ * the same value twice. For outlining the part of the app a card changes.
275
+ */
276
+ readonly onActive?: (at: PanelActive | null) => void;
277
+ /**
278
+ * What a folded group card's header says on its right. In a panel, by default, a one-row card's
279
+ * value ("CSS orb"); otherwise its matching group preset, else its first changed value, else
280
+ * its first choice, then "· N changed". On a page, "<matching preset, or Custom> · N changed".
281
+ * Each part shows only where it applies; `null` shows nothing.
282
+ */
283
+ readonly groupSummary?: (group: string, info: FoldSummaryInfo) => string | Node | null;
256
284
  /** What Copy (and ⇧C) puts on the clipboard; `controls.code()` by default. */
257
285
  readonly copy?: () => string;
258
286
  /**
@@ -5,7 +5,7 @@ export { colorHex, parseColor, type Oklch } from "./core/color.js";
5
5
  export { sampleGradient, sampleCurve, gradientCss, CURVE_SHAPES, type GradientStop, type GradientSpace, type CurvePoint, type CurveInterp, type SampleGradientOptions, type SampleCurveOptions, } from "./core/ramp.js";
6
6
  export { bezierCss, bezierPoint, EASINGS, type Bezier, type EasingName } from "./core/easing.js";
7
7
  export { AROUND } from "./core/random.js";
8
- export { defineControls, type ChangeMeta, type GestureEvent, type ChangeSource, type ChipOptions, type ControlAction, type Controls, type ControlsOptions, type DensityMode, type GroupPreset, type GroupPresetDisplay, type PresetRef, type HeaderButton, type HeaderOptions, type HostHistory, type LabelOptions, type MountPanelOptions, type PanelCommand, type PanelHandle, type PanelModule, type PanelKeys, type PersistMode, type SearchChip, type SearchOptions, type SyncOptions, type RendererMode, type RevealMode, type SectionFolded, type SoundOptions, type SoundTheme, type ThemeMode, type Appearance, } from "./core/store.js";
8
+ export { defineControls, type ChangeMeta, type GestureEvent, type ChangeSource, type ChipOptions, type ControlAction, type Controls, type ControlsOptions, type DensityMode, type FoldSummaryInfo, type GroupPreset, type GroupPresetDisplay, type PresetRef, type HeaderButton, type HeaderOptions, type HostHistory, type LabelOptions, type MountPanelOptions, type PanelActive, type PanelCommand, type PanelHandle, type PanelModule, type PanelKeys, type PersistMode, type SearchChip, type SearchOptions, type SyncOptions, type RendererMode, type RevealMode, type SectionFolded, type SoundOptions, type SoundTheme, type ThemeMode, type Appearance, } from "./core/store.js";
9
9
  export { createRemoteControls, descriptorToField, formatSwift, fromDescriptors, fromSettings, type SettingsGroup, type SettingsItem, type ActionDescriptor, type ChoiceOption, type ControlDescriptor, type SpringBindingDescriptor, type ToolbarDescriptor, type ReadoutDescriptor, type ValueDescriptor, type RemoteControls, type RemoteControlsOptions, } from "./core/remote.js";
10
10
  export type { LogEntry, LogLevel, TableRows, WatchValue } from "./core/readout.js";
11
11
  export { memoryStorage, urlFlag, type StoragePort } from "./core/storage.js";
@@ -0,0 +1,53 @@
1
+ import type { PanelActive } from "../core/store.js";
2
+ /**
3
+ * What's active in a panel, from where the pointer is and where focus is, each told as it moves.
4
+ * Whichever last moved onto something leads; while it's on nothing (or off the panel), the
5
+ * other's target stands. `report` hears the result at most once a frame (`schedule`), and never
6
+ * the same value twice in a row.
7
+ */
8
+ export declare function activeReporter<T>(resolve: (target: T | null) => PanelActive | null, report: (at: PanelActive | null) => void, schedule: (flush: () => void) => () => void): {
9
+ pointer(target: T | null): void;
10
+ focus(target: T | null): void;
11
+ /** Stops listening; what was active is no longer, so a last `null` is reported. */
12
+ dispose(): void;
13
+ };
14
+ /** Where an event's node is: in the panel, in one of its menus (on the body), or elsewhere. */
15
+ export type ActiveWhere = "inside" | "menu" | "outside";
16
+ /**
17
+ * The panel's pointer and focus events, told to `reporter` (see `activeReporter`). Focus that
18
+ * moves into one of the panel's menus keeps the row that opened it until it comes back, moves
19
+ * elsewhere, or leaves the menu for nowhere (a blur, the menu closing without handing it back).
20
+ * A pointer that moves (not just one the page scrolled under) takes the lead back from focus.
21
+ */
22
+ export declare function activeEvents<T>(reporter: {
23
+ pointer(target: T | null): void;
24
+ focus(target: T | null): void;
25
+ }, where: (node: T | null) => ActiveWhere): {
26
+ over(e: {
27
+ readonly target: T | null;
28
+ }): void;
29
+ move(e: {
30
+ readonly target: T | null;
31
+ readonly movementX: number;
32
+ readonly movementY: number;
33
+ }): void;
34
+ out(e: {
35
+ readonly relatedTarget: T | null;
36
+ }): void;
37
+ /** On the document: focus arriving anywhere. */
38
+ focusIn(e: {
39
+ readonly target: T | null;
40
+ }): void;
41
+ /** On the document: focus going nowhere (the window losing it, a blur) fires no focusin. */
42
+ focusOut(e: {
43
+ readonly target: T | null;
44
+ readonly relatedTarget: T | null;
45
+ }): void;
46
+ };
47
+ /**
48
+ * Tells `report` what the pointer or focus is on in `root` (see `activeReporter` and
49
+ * `activeEvents`), resolving the element under them with `resolve`. Returns a cleanup.
50
+ */
51
+ export declare function watchActive(root: HTMLElement, resolve: (node: Element | null) => PanelActive | null, report: (at: PanelActive | null) => void): {
52
+ dispose(): void;
53
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Ids for one panel's row descriptions (`aria-describedby`), unique in the page: each panel gets
3
+ * its own number, so a store mounted twice doesn't repeat them, and the store's id is cut to the
4
+ * characters an id reference list can carry.
5
+ */
6
+ export declare function descriptionIds(storeId: string): () => string;
@@ -1,14 +1,17 @@
1
1
  import { type StoragePort } from "../core/storage.js";
2
- /** What a page remembers folded or open: its sections and its group cards. */
3
- export type FoldKind = "sections" | "groups";
2
+ /**
3
+ * What a page or panel remembers folded or open: a page's sections, group cards, and a panel's
4
+ * sub-headings.
5
+ */
6
+ export type FoldKind = "sections" | "groups" | "subsections";
4
7
  export interface FoldMemory {
5
8
  /** What the user last chose for it, if anything. */
6
9
  get(kind: FoldKind, id: string): boolean | undefined;
7
10
  set(kind: FoldKind, id: string, folded: boolean): void;
8
11
  }
9
12
  /**
10
- * A page's folds, kept under `knobs:<id>:folds:v1` in `storage` as
11
- * `{ sections: Record<string, boolean>, groups: Record<string, boolean> }`, and nothing else (no
13
+ * A page's or panel's folds, kept under `knobs:<id>:folds:v1` in `storage` as
14
+ * `{ sections, groups, subsections? }` (each a `Record<string, boolean>`), and nothing else (no
12
15
  * values, locks or variants). `null` storage remembers nothing.
13
16
  */
14
17
  export declare function foldMemory(id: string, storage: () => StoragePort | null): FoldMemory;
@@ -70,6 +70,8 @@ export interface PanelThemeTokens {
70
70
  readonly trayPad: number;
71
71
  readonly radius: number;
72
72
  readonly panelRadius: number;
73
+ /** A tuning panel's own outer corners, concentric with its cards (`--k-radius-frame`). */
74
+ readonly frameRadius: number;
73
75
  readonly radiusMd: number;
74
76
  readonly radiusSm: number;
75
77
  readonly fontSize: number;
@@ -399,9 +399,9 @@ export interface GpuGroupBinding {
399
399
  readonly peek?: HTMLElement;
400
400
  /** On a page: the header's controls (presets, reset, die), hidden while the card is folded. */
401
401
  readonly controls?: HTMLElement;
402
- /** On a page: what a folded card's header says in place of its controls. */
402
+ /** What a folded card's header says (on a page, in place of its controls). */
403
403
  readonly summary?: HTMLElement;
404
- /** On a page: the muted line under the card's title. */
404
+ /** The muted line under the card's title. */
405
405
  readonly desc?: HTMLElement;
406
406
  /** On a page: the group's presets, as named or numbered segments, or a compact menu. */
407
407
  readonly presets?: {
@@ -732,6 +732,12 @@ declare class GpuOverlayManager {
732
732
  private clipCache;
733
733
  /** Whether the DOM lays `el`'s text out wider than its box (so text-overflow shows "…"). */
734
734
  private domClipped;
735
+ private lineCache;
736
+ /**
737
+ * The lines the DOM wraps `el`'s text into, each with its middle's offset from `el`'s top, read
738
+ * back word by word (kept until the text or the width changes).
739
+ */
740
+ private linesOf;
735
741
  private curReduced;
736
742
  private curLocks;
737
743
  /** The current row's selection wash (0…1), which its hover wash gives way to. */
@@ -5,6 +5,7 @@ export { computeHintPrefixes, type HintBadgeState, type HintCandidate, type Hint
5
5
  export { assignPageCodes, hints, pageHints, type HintPainter, type HintSourceOptions, type PageHints, type PageHintTarget, } from "./page-hints.js";
6
6
  export { Spring, VelocityTracker } from "./motion.js";
7
7
  export { createPanel, mountPage, mountPanel, type Panel, type PanelSearch } from "./panel.js";
8
+ export type { PanelActive } from "../core/store.js";
8
9
  export { balancedRuns, type FoldSummaryInfo, type MountPageOptions, type Page, type PageAction, type PageCard, type PageCardOptions, type PageControl, type PageFoldOptions, type PageFoldSource, type PageGroupInfo, type PageHighlight, type PageSectionInfo, } from "./page.js";
9
10
  export { createPresetStrip, nextPresetIndex, resolveActivePresetEntry, type ActivePresetState, type PresetEntry, type PresetStrip, } from "./presets.js";
10
11
  export { sound, type CueDetail, type Sound, type SoundCue, type SoundLayer, type SoundOptions, type SoundTheme, } from "./sound.js";
@@ -56,8 +56,9 @@ export interface PageHints {
56
56
  stop(): void;
57
57
  readonly active: boolean;
58
58
  /**
59
- * Whether `s` anywhere on the page starts hint mode: a panel or host has registered, and there's
60
- * something else on the page to label too.
59
+ * Whether `s` in a knobs/ui piece starts hint mode: a panel or host has registered, and there's
60
+ * something else on the page to label too. On the page itself it does only when the host asked
61
+ * (targets of its own, or `[data-hint]` elements).
61
62
  */
62
63
  readonly listening: boolean;
63
64
  }
@@ -1,15 +1,7 @@
1
1
  import type { StoragePort } from "../core/storage.js";
2
- import type { MountPanelOptions, SectionFolded } from "../core/store.js";
2
+ import type { FoldSummaryInfo, MountPanelOptions, SectionFolded } from "../core/store.js";
3
3
  import type { Panel } from "./panel.js";
4
- /** What a folded card's or section's summary is made of (see `MountPageOptions.groupSummary`). */
5
- export interface FoldSummaryInfo {
6
- /** The group preset its values match (its name), "Custom" when none does; null without any. */
7
- readonly preset: string | null;
8
- /** How many of its rows differ from their defaults (a spring's two keys count once). */
9
- readonly changed: number;
10
- /** Its value keys. */
11
- readonly keys: readonly string[];
12
- }
4
+ export type { FoldSummaryInfo };
13
5
  export interface MountPageOptions extends MountPanelOptions {
14
6
  /** Section order; sections not listed follow in the order they first appear. */
15
7
  readonly sections?: readonly string[];
@@ -39,11 +31,6 @@ export interface MountPageOptions extends MountPanelOptions {
39
31
  * opening it if it's folded.
40
32
  */
41
33
  readonly nav?: boolean;
42
- /**
43
- * What a folded group card's header says. By default "<matching preset, or Custom> · N
44
- * changed", leaving out what doesn't apply. `null` shows nothing.
45
- */
46
- readonly groupSummary?: (group: string, info: FoldSummaryInfo) => string | Node | null;
47
34
  /** What a folded section's heading says ("N changed · M cards" by default; `preset` is null). */
48
35
  readonly sectionSummary?: (section: string, info: FoldSummaryInfo) => string | Node | null;
49
36
  }
@@ -1,4 +1,27 @@
1
1
  import { type StoragePort } from "../core/storage.js";
2
+ /** Where a floating panel was left: `x` from the left edge, or from the right with `right`. */
3
+ export interface SavedPosition {
4
+ readonly x: number;
5
+ readonly y: number;
6
+ readonly right?: boolean;
7
+ }
8
+ /**
9
+ * The position to keep for a panel dropped at `left`, `top`: a panel on the right half keeps its
10
+ * distance from the right edge, so a wider or narrower window leaves it by the same edge.
11
+ */
12
+ export declare function savedPosition(left: number, top: number, width: number, view: number): {
13
+ x: number;
14
+ y: number;
15
+ right: true;
16
+ } | {
17
+ x: number;
18
+ y: number;
19
+ right?: undefined;
20
+ };
21
+ /** The left edge a saved position asks for in a `view` px wide window. */
22
+ export declare const leftOf: (saved: SavedPosition, width: number, view: number) => number;
23
+ /** Reads a saved position, or `undefined` for none (or one that isn't a position). */
24
+ export declare function readPosition(raw: string | null | undefined): SavedPosition | undefined;
2
25
  export declare function positionPanel(panel: HTMLElement, header: HTMLElement, anchor: HTMLElement, id: string, storage: () => StoragePort | null): {
3
26
  constrain: () => void;
4
27
  dispose(): void;
@@ -0,0 +1,22 @@
1
+ import type { Controls } from "../core/store.js";
2
+ /** A row of a folded panel card, as its summary reads it. */
3
+ export interface SummaryRow {
4
+ /** Its value as text ("CSS orb", "12 px"), or "" where it has no short form. */
5
+ readonly value: string;
6
+ /** Whether it differs from its default. */
7
+ readonly changed: boolean;
8
+ /** Whether it's a choice (the likeliest to say what the card is set to). */
9
+ readonly choice: boolean;
10
+ }
11
+ /**
12
+ * A folded panel card's summary, by default: a one-row card's value; otherwise the most telling
13
+ * value (its matching group preset, else its first changed value, else its first choice) and
14
+ * "N changed", each only where it applies. `preset` is null for a card without group presets.
15
+ */
16
+ export declare function panelFoldSummary(rows: readonly SummaryRow[], preset: string | null): string;
17
+ /**
18
+ * Which presets the values are measured against (the panel's, each scope's and each group's).
19
+ * It changes when one is chosen or saved even if no value moves, which still changes what counts
20
+ * as changed: marks, resets and folded summaries follow it.
21
+ */
22
+ export declare const baseSignature: (controls: Pick<Controls<any>, "activePreset" | "activeGroupPreset" | "scopes" | "groupPresets">) => string;
@@ -491,6 +491,15 @@ export declare const TOKENS: readonly [{
491
491
  };
492
492
  readonly doc: "Panel and card radius, concentric with controls.";
493
493
  readonly deprecated: readonly ["--k-panel-radius", "--ku-panel-radius"];
494
+ }, {
495
+ readonly name: "radius-frame";
496
+ readonly tier: "semantic";
497
+ readonly type: "length";
498
+ readonly group: "Shape";
499
+ readonly source: {
500
+ readonly css: "calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))";
501
+ };
502
+ readonly doc: "A tuning panel's outer corners: its cards' radius plus its padding and border, so the cards, the preset strip and the focus lens inside run concentric. Squares when --k-radius is 0.";
494
503
  }, {
495
504
  readonly name: "round";
496
505
  readonly tier: "semantic";
@@ -1 +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,var(--_k-ok));--_ku-cmp-line:var(--_k-compare-divider);\n --_ku-cmp-stage:var(--_k-compare-stage);\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(--_k-weight) var(--_k-type-small)/var(--_k-leading-small) var(--_k-font);color:var(--_k-fg);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:var(--_k-gap);height:var(--_k-control-h)}\n/* The modes: one fill, the selection pill on the one picked; a mode isn't brighter for being\n picked: the pill's bright copy is. */\n.knobs-cmp-modes{position:relative;display:inline-flex;height:var(--_k-control-h);border-radius:var(--_k-radius);background:var(--_k-fill)}\n.knobs-cmp-modes[hidden]{display:none}\n/* Hover is a quiet fill under the arrow cursor (no hand, no brightening), there at once. */\n.knobs-ui .knobs-cmp-mode{display:inline-flex;align-items:center;height:100%;padding:0 8px;border-radius:var(--_k-radius);color:var(--_k-fg-2);cursor:default;white-space:nowrap;\n transition:background calc(var(--_k-hover-out) * 1ms)}\n.knobs-ui .knobs-cmp-mode:not([aria-checked=\"true\"]):is(:hover,:focus-visible){background:var(--_k-fill-hover);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n/* Toggles: icon buttons, the accent's wash when on. */\n.knobs-ui .knobs-cmp-toggle{display:inline-flex;align-items:center;justify-content:center;width:var(--_k-control-h);height:var(--_k-control-h);border-radius:var(--_k-radius);color:var(--_k-fg-2);cursor:default;\n transition:background calc(var(--_k-hover-out) * 1ms)}\n.knobs-ui .knobs-cmp-toggle:is(:hover,:focus-visible){background:var(--_k-fill-hover);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n.knobs-ui .knobs-cmp-toggle[aria-pressed=\"true\"]{color:var(--_k-accent-fg);background:var(--_k-accent-soft)}\n.knobs-cmp-toggle svg{display:block}\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(--_k-radius-panel);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/* Zoomed, it pans: a move cursor, never a hand. */\n.knobs-compare[data-zoomed] .knobs-cmp-stage{cursor:move;touch-action:none}\n.knobs-compare[data-panning],.knobs-compare[data-panning] *{cursor:move!important}\n/* Wiping, the grip stands in for the cursor. */\n.knobs-compare[data-dragging],.knobs-compare[data-dragging] *{cursor:none!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/* The media comes in with its first frames: a fade and a 4px blur on the default spring. */\n.knobs-cmp-canvas,.knobs-compare[data-renderer=\"dom\"] .knobs-cmp-media{transition:opacity calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default),filter calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default)}\n.knobs-compare:not([data-ready]) :is(.knobs-cmp-canvas,.knobs-cmp-media){opacity:0;filter:blur(4px)}\n/* Over the pictures: a quiet scrim, dark in either appearance, blurred, with no ring. */\n.knobs-cmp-label,.knobs-cmp-zoom,.knobs-cmp-grip{background:var(--_ku-media-scrim);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}\n/* \u2717 Before \u00B7 \u2713 After: Before quieter, After in full. Neutral: no colour of its own. */\n.knobs-cmp-label{position:absolute;left:0;top:0;display:flex;align-items:center;gap:4px;padding:1px 7px 1px 5px;border-radius:999px;font-weight:var(--_k-weight-label);\n white-space:nowrap;pointer-events:none;will-change:transform}\n.knobs-cmp-label[data-side=\"before\"]{color:var(--_ku-on-media-2)}\n.knobs-cmp-label[data-side=\"after\"]{color:var(--_ku-on-media)}\n/* Fusing, the mark folds away and the label is its name. */\n.knobs-cmp-mark{flex:none;display:flex;justify-content:flex-end;overflow:hidden}\n.knobs-cmp-mark svg{display:block;flex:none;width:12px;height:12px}\n/* The fuse's seam: a blurred strip over where the sides meet, soft at both sides. */\n.knobs-cmp-seam{position:absolute;left:0;top:0;width:8px;height:0;opacity:0;pointer-events:none;will-change:transform,opacity;\n -webkit-mask-image:linear-gradient(to right,transparent,#000 35%,#000 65%,transparent);mask-image:linear-gradient(to right,transparent,#000 35%,#000 65%,transparent)}\n/* The wipe: a hairline down the line with a raised grip in its middle (at the pointer's height\n while dragged); a wider strip takes the hand and the keys (a slider). It fades in with the\n fuse; wiping, line and grip brighten. */\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,opacity}\n.knobs-cmp-handle[data-on]{pointer-events:auto}\n.knobs-cmp-handle::before{content:\"\";position:absolute;top:0;bottom:0;left:11.5px;width:1px;background:color-mix(in srgb,var(--_ku-cmp-line) 72%,transparent);box-shadow:0 0 6px var(--_ku-media-scrim)}\n.knobs-compare[data-dragging] .knobs-cmp-handle::before{background:var(--_ku-cmp-line)}\n.knobs-cmp-grip{position:absolute;top:0;left:4px;width:16px;height:28px;border-radius:min(8px,calc(var(--_k-radius) * 999));will-change:transform}\n.knobs-cmp-grip::before,.knobs-cmp-grip::after{content:\"\";position:absolute;top:10px;width:1px;height:8px;background:var(--_ku-on-media-2);transition:background calc(var(--_k-hover-out) * 1ms)}\n.knobs-cmp-grip::before{left:6px}\n.knobs-cmp-grip::after{left:9px}\n.knobs-compare[data-dragging] .knobs-cmp-grip::before,.knobs-compare[data-dragging] .knobs-cmp-grip::after{background:var(--_ku-on-media)}\n.knobs-cmp-handle:focus-visible .knobs-cmp-grip,.knobs-compare[data-keyboard] .knobs-cmp-handle:focus .knobs-cmp-grip{box-shadow:0 0 0 2px var(--_k-accent)}\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(--_k-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-on-media);font-variant-numeric:tabular-nums;font-feature-settings:var(--_kr-data,\"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 display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border-radius:999px;background:transparent;color:var(--_ku-on-media-2);cursor:default;transition:background calc(var(--_k-hover-out) * 1ms),color calc(var(--_k-hover-out) * 1ms)}\n:where(.knobs-ui) .knobs-cmp-zoom-reset:is(:hover,:focus-visible){background:color-mix(in srgb,var(--_ku-on-media) 16%,transparent);color:var(--_ku-on-media);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n.knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--_k-fg-3);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;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,var(--_k-ok));position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;cursor:col-resize;touch-action:none;\n font:var(--_k-weight) var(--_k-type-small)/var(--_k-leading-small) var(--_k-font);color:var(--_k-fg)}\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";
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,var(--_k-ok));--_ku-cmp-line:var(--_k-compare-divider);\n --_ku-cmp-stage:var(--_k-compare-stage);\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(--_k-weight) var(--_k-type-small)/var(--_k-leading-small) var(--_k-font);color:var(--_k-fg);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:var(--_k-gap);height:var(--_k-control-h)}\n/* The modes: one fill, the selection pill on the one picked; a mode isn't brighter for being\n picked: the pill's bright copy is. */\n.knobs-cmp-modes{position:relative;display:inline-flex;height:var(--_k-control-h);border-radius:var(--_k-radius);background:var(--_k-fill)}\n.knobs-cmp-modes[hidden]{display:none}\n/* Hover is a quiet fill under the arrow cursor (no hand, no brightening), there at once. */\n.knobs-ui .knobs-cmp-mode{display:inline-flex;align-items:center;height:100%;padding:0 8px;border-radius:var(--_k-radius);color:var(--_k-fg-2);cursor:default;white-space:nowrap;\n transition:background calc(var(--_k-hover-out) * 1ms)}\n.knobs-ui .knobs-cmp-mode:not([aria-checked=\"true\"]):is(:hover,:focus-visible){background:var(--_k-fill-hover);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n/* Toggles: icon buttons, the accent's wash when on. */\n.knobs-ui .knobs-cmp-toggle{display:inline-flex;align-items:center;justify-content:center;width:var(--_k-control-h);height:var(--_k-control-h);border-radius:var(--_k-radius);color:var(--_k-fg-2);cursor:default;\n transition:background calc(var(--_k-hover-out) * 1ms)}\n.knobs-ui .knobs-cmp-toggle:is(:hover,:focus-visible){background:var(--_k-fill-hover);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n.knobs-ui .knobs-cmp-toggle[aria-pressed=\"true\"]{color:var(--_k-accent-fg);background:var(--_k-accent-soft)}\n.knobs-cmp-toggle svg{display:block}\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(--_k-radius-panel);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/* Zoomed, it pans: a move cursor, never a hand. */\n.knobs-compare[data-zoomed] .knobs-cmp-stage{cursor:move;touch-action:none}\n.knobs-compare[data-panning],.knobs-compare[data-panning] *{cursor:move!important}\n/* Wiping, the grip stands in for the cursor. */\n.knobs-compare[data-dragging],.knobs-compare[data-dragging] *{cursor:none!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/* The media comes in with its first frames: a fade and a 4px blur on the default spring. */\n.knobs-cmp-canvas,.knobs-compare[data-renderer=\"dom\"] .knobs-cmp-media{transition:opacity calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default),filter calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default)}\n.knobs-compare:not([data-ready]) :is(.knobs-cmp-canvas,.knobs-cmp-media){opacity:0;filter:blur(4px)}\n/* Over the pictures: a quiet scrim, dark in either appearance, blurred, with no ring. */\n.knobs-cmp-label,.knobs-cmp-zoom,.knobs-cmp-grip{background:var(--_ku-media-scrim);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}\n/* \u2717 Before \u00B7 \u2713 After: Before quieter, After in full. Neutral: no colour of its own. */\n.knobs-cmp-label{position:absolute;left:0;top:0;display:flex;align-items:center;gap:4px;padding:1px 7px 1px 5px;border-radius:999px;font-weight:var(--_k-weight-label);\n white-space:nowrap;pointer-events:none;will-change:transform}\n.knobs-cmp-label[data-side=\"before\"]{color:var(--_ku-on-media-2)}\n.knobs-cmp-label[data-side=\"after\"]{color:var(--_ku-on-media)}\n/* Fusing, the mark folds away and the label is its name. */\n.knobs-cmp-mark{flex:none;display:flex;justify-content:flex-end;overflow:hidden}\n.knobs-cmp-mark svg{display:block;flex:none;width:12px;height:12px}\n/* The fuse's seam: a blurred strip over where the sides meet, soft at both sides. */\n.knobs-cmp-seam{position:absolute;left:0;top:0;width:8px;height:0;opacity:0;pointer-events:none;will-change:transform,opacity;\n -webkit-mask-image:linear-gradient(to right,transparent,#000 35%,#000 65%,transparent);mask-image:linear-gradient(to right,transparent,#000 35%,#000 65%,transparent)}\n/* The wipe: a hairline down the line with a raised grip in its middle (at the pointer's height\n while dragged); a wider strip takes the hand and the keys (a slider). It fades in with the\n fuse; wiping, line and grip brighten. */\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,opacity}\n.knobs-cmp-handle[data-on]{pointer-events:auto}\n.knobs-cmp-handle::before{content:\"\";position:absolute;top:0;bottom:0;left:11.5px;width:1px;background:color-mix(in srgb,var(--_ku-cmp-line) 72%,transparent);box-shadow:0 0 6px var(--_ku-media-scrim)}\n.knobs-compare[data-dragging] .knobs-cmp-handle::before{background:var(--_ku-cmp-line)}\n.knobs-cmp-grip{position:absolute;top:0;left:4px;width:16px;height:28px;border-radius:min(8px,calc(var(--_k-radius) * 999));will-change:transform}\n.knobs-cmp-grip::before,.knobs-cmp-grip::after{content:\"\";position:absolute;top:10px;width:1px;height:8px;background:var(--_ku-on-media-2);transition:background calc(var(--_k-hover-out) * 1ms)}\n.knobs-cmp-grip::before{left:6px}\n.knobs-cmp-grip::after{left:9px}\n.knobs-compare[data-dragging] .knobs-cmp-grip::before,.knobs-compare[data-dragging] .knobs-cmp-grip::after{background:var(--_ku-on-media)}\n.knobs-cmp-handle:focus-visible .knobs-cmp-grip,.knobs-compare[data-keyboard] .knobs-cmp-handle:focus .knobs-cmp-grip{box-shadow:0 0 0 2px var(--_k-accent)}\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(--_k-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-on-media);font-variant-numeric:tabular-nums;font-feature-settings:var(--_kr-data,\"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 display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border-radius:999px;background:transparent;color:var(--_ku-on-media-2);cursor:default;transition:background calc(var(--_k-hover-out) * 1ms),color calc(var(--_k-hover-out) * 1ms)}\n:where(.knobs-ui) .knobs-cmp-zoom-reset:is(:hover,:focus-visible){background:color-mix(in srgb,var(--_ku-on-media) 16%,transparent);color:var(--_ku-on-media);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n.knobs-cmp-note{position:absolute;left:50%;top:50%;translate:-50% -50%;color:var(--_k-fg-3);pointer-events:none}\n.knobs-cmp-note:empty{display:none}\n.knobs-cmp-timeline-host{flex:none;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,var(--_k-ok));position:relative;width:100%;height:100%;min-height:40px;overflow:hidden;cursor:col-resize;touch-action:none;\n font:var(--_k-weight) var(--_k-type-small)/var(--_k-leading-small) var(--_k-font);color:var(--_k-fg)}\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";
@@ -15,10 +15,23 @@ export interface FrameCache {
15
15
  readonly backend: "webcodecs" | "video" | "pending";
16
16
  /** Frames held now. */
17
17
  readonly size: number;
18
- /** The cached frame nearest `frame`, no further than `within` frames. */
19
- nearest(frame: number, within?: number): CachedFrame | null;
18
+ /** Scrub proxies held now, and every how many frames they are. */
19
+ readonly proxies: {
20
+ readonly count: number;
21
+ readonly stride: number;
22
+ readonly bytes: number;
23
+ };
24
+ /**
25
+ * The cached frame nearest `frame`, no further than `within` frames. `decode`: a nearer scrub
26
+ * proxy that's stored but not decoded starts decoding (listeners hear when it's in).
27
+ */
28
+ nearest(frame: number, within?: number, decode?: boolean): CachedFrame | null;
29
+ /** Whether a frame this close to `frame` is held, decoded or as a proxy (within its stride). */
30
+ covers(frame: number): boolean;
20
31
  /** The playhead of the viewer being used: full-size frames around it decode first. */
21
32
  focus(frame: number): void;
33
+ /** The playhead of a viewer being scrubbed: this cache goes first, nothing full-size is asked. */
34
+ follow(frame: number): void;
22
35
  /** Frames a strip shows (small, with a tolerance each). */
23
36
  want(points: readonly FramePoint[]): void;
24
37
  /** A frame the viewer has decoded exactly (paused on it). */
@@ -31,11 +44,60 @@ export interface FrameCache {
31
44
  }
32
45
  /** Frames either side of the focus decoded at full size. */
33
46
  export declare const FOCUS_RADIUS = 6;
47
+ /** A scrub proxy's longest side, JPEG quality, the bytes a source's proxies may hold, how many stay decoded. */
48
+ export declare const PROXY: {
49
+ readonly size: 960;
50
+ readonly quality: 0.8;
51
+ readonly budget: number;
52
+ readonly decoded: 8;
53
+ /** Proxies a unit makes before another cache takes its turn. */
54
+ readonly turn: 90;
55
+ };
56
+ /** What `scrubCaching` reads of a window. */
57
+ export interface DataSaver {
58
+ readonly matchMedia?: (query: string) => {
59
+ readonly matches: boolean;
60
+ };
61
+ readonly navigator?: object;
62
+ }
63
+ /**
64
+ * Whether a viewer caches its frames in the background (it fetches the whole file and decodes
65
+ * it): unless its `scrubCache` option is false, the reader prefers reduced data, or the
66
+ * connection is saving data. Without it, scrubbing seeks the video as it goes.
67
+ */
68
+ export declare function scrubCaching(option: boolean | undefined, win: DataSaver | null | undefined): boolean;
69
+ /** The stride a set of proxies needs to stay in `budget`: the smallest power of two that does. */
70
+ export declare function proxyStride(bytes: number, count: number, total: number, budget: number): number;
71
+ /** Where a hand moving from `last` to `frame` is likely to be next (within 0 and `total`). */
72
+ export declare const aheadOf: (frame: number, last: number | null, total: number) => number | null;
34
73
  /** The grid's frames: `count` evenly across `total`, as points with half a step's tolerance. */
35
74
  export declare function gridPoints(total: number, count?: number): FramePoint[];
36
75
  /** The full-size frames wanted around `focus`, nearest first. */
37
76
  export declare function focusWindow(focus: number, total: number, radius?: number): number[];
38
77
  /** The key in `keys` nearest `frame` within `within` (ties go to the later frame). */
39
78
  export declare function nearestKey(keys: Iterable<number>, frame: number, within?: number): number | null;
79
+ /** What the encoder pool needs of a window (a stand-in in tests). */
80
+ export interface EncoderHost {
81
+ readonly Worker?: new (url: string) => Worker;
82
+ readonly OffscreenCanvas?: unknown;
83
+ readonly URL: Pick<typeof URL, "createObjectURL" | "revokeObjectURL">;
84
+ readonly Blob: typeof Blob;
85
+ }
86
+ export interface Encoders {
87
+ readonly workers: Worker[];
88
+ readonly waiting: Map<number, {
89
+ done(b: Blob): void;
90
+ fail(e: unknown): void;
91
+ }>;
92
+ next: number;
93
+ }
94
+ /** A cache uses the pool: it's made the first time a proxy is encoded. */
95
+ export declare function holdEncoders(): void;
96
+ /** A cache is done with the pool: the last one ends its workers, and the next starts afresh. */
97
+ export declare function dropEncoders(): void;
98
+ /** The page's encoder pool, made on first use; null where workers can't run. */
99
+ export declare function encodersOf(host: EncoderHost): Encoders | null;
100
+ /** `bitmap` as a JPEG on one of the pool's workers (it takes the bitmap). */
101
+ export declare function encodeOn(pool: Encoders, bitmap: ImageBitmap, quality: number): Promise<Blob>;
40
102
  /** The shared cache for `src` at `fps`; `release()` it when done. */
41
103
  export declare function acquireFrames(doc: Document, src: string, fps: number): FrameCache;
@@ -67,6 +67,29 @@ export interface SeekStep {
67
67
  export declare function requestSeek(s: SeekState, t: number, current: number, fps: number): SeekStep;
68
68
  /** The seek in flight landed on `current`: apply what's pending, if it's another frame. */
69
69
  export declare function seeked(s: SeekState, current: number, fps: number): SeekStep;
70
+ /**
71
+ * Where a video showing `current` seeks once a scrub lets go at `t`: the midpoint of `t`'s frame,
72
+ * or null when that frame already shows. Frames, not seconds: 1.030 and 1.040 are 1/100 s apart
73
+ * and two frames at 30 fps.
74
+ */
75
+ export declare function releaseSeek(current: number, t: number, fps: number, duration?: number): number | null;
76
+ /** How long a seek may take to land before the preview over it gives way (ms). */
77
+ export declare const SEEK_STALL_MS = 2500;
78
+ /** What `stallGuard` reads of a window. */
79
+ export interface Timers {
80
+ setTimeout(f: () => void, ms: number): number;
81
+ clearTimeout(id: number): void;
82
+ }
83
+ /**
84
+ * A seek that never lands (a decoder that gave up, a network that went away) mustn't leave a
85
+ * preview over the video forever: `arm` when a seek goes out, `clear` when it lands, and
86
+ * `onStall` runs if it doesn't within `ms`.
87
+ */
88
+ export declare function stallGuard(timers: Timers, onStall: () => void, ms?: number): {
89
+ arm(): void;
90
+ clear: () => void;
91
+ readonly armed: boolean;
92
+ };
70
93
  export interface Rect {
71
94
  readonly x: number;
72
95
  readonly y: number;
@@ -1,5 +1,4 @@
1
1
  import { type CompareMode, type MediaKind, type ZoomRegion } from "./compare-model.js";
2
- import { type CompareStrip } from "./compare-strip.js";
3
2
  import { type UiLook } from "./look.js";
4
3
  import { type Timeline } from "./timeline.js";
5
4
  export type { CompareMode, MediaKind, ZoomRegion } from "./compare-model.js";
@@ -51,8 +50,6 @@ export interface CompareOptions extends UiLook {
51
50
  * `play` and keys still work.
52
51
  */
53
52
  readonly timeline?: Timeline | boolean;
54
- /** The frame strip over the timeline (off; one timeline is made if there's none). */
55
- readonly strip?: boolean;
56
53
  /**
57
54
  * The sources' width over height before they load (16/9). In a container without a height of
58
55
  * its own, the viewer takes the height its sides need: a split screen with no margins.
@@ -62,6 +59,12 @@ export interface CompareOptions extends UiLook {
62
59
  readonly lazy?: boolean;
63
60
  /** `"gpu"` (the default, where WebGPU is there) or `"dom"`. */
64
61
  readonly renderer?: "gpu" | "dom";
62
+ /**
63
+ * Decode the videos' frames in the background, so a step or a scrub shows its frame at once (it
64
+ * fetches each whole file). On unless the reader prefers reduced data or saves data; false
65
+ * never, true always. Without it, the videos seek as the playhead moves.
66
+ */
67
+ readonly scrubCache?: boolean;
65
68
  readonly label?: string;
66
69
  /** The modes, the loupe and the wipe take codes in the page's hint mode (`s`); on by default. */
67
70
  readonly hints?: boolean;
@@ -86,7 +89,6 @@ export interface Compare {
86
89
  readonly renderer: "gpu" | "dom";
87
90
  /** The bound timeline, or the one it made. */
88
91
  readonly timeline: Timeline | null;
89
- readonly strip: CompareStrip | null;
90
92
  /** The part of each side in view (`{ u: 0, v: 0, w: 1, h: 1 }` is all of it). */
91
93
  readonly zoom: ZoomRegion;
92
94
  /** The timeline's loop, or null. */
@@ -1 +1 @@
1
- export declare const playerCss = "\n:where(.knobs-player,.knobs-player *){box-sizing:border-box}\n.knobs-ui.knobs-player{--_kp-stage:var(--_k-compare-stage);display:flex;flex-direction:column;gap:6px;width:100%;outline:none;\n font:var(--_k-weight) var(--_k-type-small)/var(--_k-leading-small) var(--_k-font);color:var(--_k-fg);letter-spacing:normal;text-align:left}\n:where(.knobs-surface,.knobs-pane:not([data-nested])>.knobs-pane-clip) .knobs-ui.knobs-player,.knobs-ui.knobs-player[data-nested]{--_kp-stage:var(--k-compare-stage,transparent)}\n.knobs-player-frame{position:relative;width:100%;aspect-ratio:var(--kp-aspect,16/9);overflow:hidden;border-radius:var(--_k-radius-panel);background:var(--_kp-stage)}\n.knobs-player[data-frame=\"flat\"] .knobs-player-frame{border-radius:0}\n.knobs-player-media{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:contain;\n opacity:0;filter:blur(4px);transition:opacity calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default),filter calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default)}\n.knobs-player[data-ready] .knobs-player-media{opacity:1;filter:none}\n.knobs-player-line{flex:none;min-height:0}\n/* The timeline's own stylesheet is look.ts's; what the player's row and every timeline share\n about the hand is set here, after it. No hand cursor anywhere (the arrow, or a resize where it\n resizes); hover is a quiet fill (fill-hover), in at once (hover-in, 0 ms), out easing. */\n.knobs-ui .knobs-tl-play,.knobs-ui.knobs-timeline :is(.knobs-tl-pin,.knobs-tl-rlabel),\n.knobs-ui.knobs-timeline[data-dragging] .knobs-tl-marker :is(.knobs-tl-pin,.knobs-tl-mlabel){cursor:default}\n.knobs-ui .knobs-tl-play:hover,.knobs-tl-edge:hover::after,.knobs-tl-rlabel:hover,.knobs-tl-track:hover .knobs-tl-hover,\n.knobs-timeline[data-variant=\"line\"] .knobs-tl-track:hover .knobs-tl-segs{transition-duration:calc(var(--_k-hover-in) * 1ms)}\n/* The line's row: a minimal play/pause (the filled pair, a 28px target, no fill at rest), then\n the line, centred on it. */\n.knobs-ui.knobs-timeline[data-variant=\"line\"]{gap:4px;align-items:center}\n.knobs-timeline[data-variant=\"line\"] .knobs-tl-transport{display:flex}\n.knobs-ui.knobs-timeline[data-variant=\"line\"] .knobs-tl-play{width:28px;height:28px;min-width:28px;min-height:28px;border-radius:var(--_k-radius);background:transparent;color:var(--_k-fg-2);\n transition:background calc(var(--_k-hover-out) * 1ms),transform .15s var(--_ku-ease)}\n.knobs-ui.knobs-timeline[data-variant=\"line\"][data-playing] .knobs-tl-play{background:transparent;color:var(--_k-fg-2)}\n.knobs-ui.knobs-timeline[data-variant=\"line\"] .knobs-tl-play:hover{background:var(--_k-fill-hover);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n.knobs-ui.knobs-timeline[data-variant=\"line\"] .knobs-tl-track{cursor:default;transition:background calc(var(--_k-hover-out) * 1ms)}\n.knobs-ui.knobs-timeline[data-variant=\"line\"] .knobs-tl-track:hover{background:var(--_k-fill-hover);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n";
1
+ export declare const playerCss = "\n:where(.knobs-player,.knobs-player *){box-sizing:border-box}\n.knobs-ui.knobs-player{--_kp-stage:var(--_k-compare-stage);display:flex;flex-direction:column;gap:6px;width:100%;outline:none;\n font:var(--_k-weight) var(--_k-type-small)/var(--_k-leading-small) var(--_k-font);color:var(--_k-fg);letter-spacing:normal;text-align:left}\n:where(.knobs-surface,.knobs-pane:not([data-nested])>.knobs-pane-clip) .knobs-ui.knobs-player,.knobs-ui.knobs-player[data-nested]{--_kp-stage:var(--k-compare-stage,transparent)}\n.knobs-player-frame{position:relative;width:100%;aspect-ratio:var(--kp-aspect,16/9);overflow:hidden;border-radius:var(--_k-radius-panel);background:var(--_kp-stage)}\n.knobs-player[data-frame=\"flat\"] .knobs-player-frame{border-radius:0}\n.knobs-player-media{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:contain;\n opacity:0;filter:blur(4px);transition:opacity calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default),filter calc(var(--_k-motion-default-ms) * 1ms) var(--_k-motion-default)}\n.knobs-player[data-ready] .knobs-player-media{opacity:1;filter:none}\n/* Mid-scrub, the cached frame under the hand, over the video until it lands. */\n.knobs-player-preview{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}\n.knobs-player-preview[hidden]{display:none}\n.knobs-player-line{flex:none;min-height:0}\n/* The timeline's own stylesheet is look.ts's; what the player's row and every timeline share\n about the hand is set here, after it. No hand cursor anywhere (the arrow, or a resize where it\n resizes); hover is a quiet fill (fill-hover), in at once (hover-in, 0 ms), out easing. */\n.knobs-ui .knobs-tl-play,.knobs-ui.knobs-timeline :is(.knobs-tl-pin,.knobs-tl-rlabel),\n.knobs-ui.knobs-timeline[data-dragging] .knobs-tl-marker :is(.knobs-tl-pin,.knobs-tl-mlabel){cursor:default}\n.knobs-ui .knobs-tl-play:hover,.knobs-tl-edge:hover::after,.knobs-tl-rlabel:hover,.knobs-tl-track:hover .knobs-tl-hover,\n.knobs-timeline[data-variant=\"line\"] .knobs-tl-track:hover .knobs-tl-segs{transition-duration:calc(var(--_k-hover-in) * 1ms)}\n/* The line's row: a minimal play/pause (the filled pair, a 28px target, no fill at rest), then\n the line, centred on it. */\n.knobs-ui.knobs-timeline[data-variant=\"line\"]{gap:4px;align-items:center}\n.knobs-timeline[data-variant=\"line\"] .knobs-tl-transport{display:flex}\n.knobs-ui.knobs-timeline[data-variant=\"line\"] .knobs-tl-play{width:28px;height:28px;min-width:28px;min-height:28px;border-radius:var(--_k-radius);background:transparent;color:var(--_k-fg-2);\n transition:background calc(var(--_k-hover-out) * 1ms),transform .15s var(--_ku-ease)}\n.knobs-ui.knobs-timeline[data-variant=\"line\"][data-playing] .knobs-tl-play{background:transparent;color:var(--_k-fg-2)}\n.knobs-ui.knobs-timeline[data-variant=\"line\"] .knobs-tl-play:hover{background:var(--_k-fill-hover);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n.knobs-ui.knobs-timeline[data-variant=\"line\"] .knobs-tl-track{cursor:default;transition:background calc(var(--_k-hover-out) * 1ms)}\n.knobs-ui.knobs-timeline[data-variant=\"line\"] .knobs-tl-track:hover{background:var(--_k-fill-hover);transition-duration:calc(var(--_k-hover-in) * 1ms)}\n";
@@ -24,6 +24,12 @@ export interface PlayerOptions extends UiLook {
24
24
  readonly label?: string;
25
25
  /** The line's play key and playhead take codes in the page's hint mode (on by default). */
26
26
  readonly hints?: boolean;
27
+ /**
28
+ * Decode its frames in the background once the pointer comes near, so a scrub shows the frame
29
+ * under the hand (it fetches the whole file). On unless the reader prefers reduced data or saves
30
+ * data; false never, true always.
31
+ */
32
+ readonly scrubCache?: boolean;
27
33
  /** Played or paused, at `time` (seconds). */
28
34
  onPlay?(playing: boolean, time: number): void;
29
35
  /** Sought by the hand (the line, a key, a chapter) or code, at rest. */
@@ -66,7 +66,7 @@ export type CompareProps = CompareOptions & {
66
66
  };
67
67
  /**
68
68
  * `compare()` as a component. `before`, `after` (by `src`), `mode`, `wipe`, `loupe`, `threshold`
69
- * and `fps` follow their props; a new `timeline` handle, `strip` or `renderer` makes it afresh;
69
+ * and `fps` follow their props; a new `timeline` handle, `renderer` or `scrubCache` makes it afresh;
70
70
  * callbacks always call the latest props.
71
71
  */
72
72
  export declare function Compare(props: CompareProps): import("react").JSX.Element;
@@ -56,7 +56,7 @@ export type CompareProps = CompareOptions & {
56
56
  onReady?: (compare: CompareHandle) => void;
57
57
  };
58
58
  /**
59
- * `compare()` as a component, made once (with the `timeline`, `strip` and `renderer` it starts
59
+ * `compare()` as a component, made once (with the `timeline` and `renderer` it starts
60
60
  * with). `before`, `after` (by `src`), `mode`, `wipe`, `loupe`, `threshold` and `fps` follow
61
61
  * their props; callbacks always call the latest props.
62
62
  */