@xenosystem/blocks 0.4.1 → 0.5.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.
@@ -0,0 +1,2261 @@
1
+ import { PanelHost, PanelModule, PanelManifest } from '@xenosystem/panel-sdk';
2
+ import { ThrottleClock, XenoPlayheadClock, XenoTimebase, XenoTransportState, XenoTick, XenoStepUnit, XenoLoopRange } from '@xenosystem/time-core';
3
+ export { BAR_SUBDIVISION, BarsBeats, DEFAULT_TIMEBASE, DISPLAY_CYCLE, ExtrapolateInput, IDLE_CLOCK, ThrottleClock, Throttled, XenoLoopRange, XenoPlayheadClock, XenoStepUnit, XenoTempoEntry, XenoTick, XenoTimeDisplay, XenoTimeSignature, XenoTimebase, XenoTransportIntent, XenoTransportState, clampTick, clockAt, createThrottle, displayLabel, effectiveFps, extrapolateTick, formatBars, formatFrames, formatSeconds, formatTick, formatTimecode, frameToTick, isAnimating, nextDisplay, normalizeClock, normalizeTimebase, secondsToTicks, tickToFrame, ticksToBarsBeats, ticksToSeconds, toTick, wrapIntoLoop } from '@xenosystem/time-core';
4
+ import { ReactElement, ReactNode } from 'react';
5
+ import { XenoValue, XenoRow, XenoResultSet, XenoColumn } from '@xenosystem/data-core';
6
+
7
+ /**
8
+ * `TransportPanelController` — the framework-free brain of the Transport panel.
9
+ *
10
+ * It owns display state only. The clock, playback and every transport decision are the host's; the
11
+ * panel extrapolates a tick for the readout and emits {@link XenoTransportIntent}s.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ /** Options for {@link TransportPanelController}. */
17
+ interface TransportPanelControllerOptions {
18
+ /** Injectable clock for deterministic throttle + extrapolation tests. */
19
+ throttleClock?: ThrottleClock;
20
+ /**
21
+ * Reads "now" in the same domain as `clock.originTime`. Defaults to `performance.now()`
22
+ * (falling back to `Date.now()`); tests inject a fake.
23
+ */
24
+ now?: () => number;
25
+ }
26
+ /** A keyboard event, framework-free. */
27
+ interface TransportKeyEvent {
28
+ key: string;
29
+ ctrl?: boolean;
30
+ meta?: boolean;
31
+ shift?: boolean;
32
+ alt?: boolean;
33
+ }
34
+ /** Serialized panel state (for the `.xapp`). */
35
+ interface TransportPanelSerialized {
36
+ displayOverride?: XenoTimebase['display'] | null;
37
+ }
38
+ declare class TransportPanelController {
39
+ private readonly host;
40
+ private readonly nowFn;
41
+ private readonly seekThrottle;
42
+ private clock;
43
+ private timebase;
44
+ private durationTicks;
45
+ private state;
46
+ private displayOverride;
47
+ private scrubbing;
48
+ private scrubTick;
49
+ private shuttleIndex;
50
+ private lastRenderedTick;
51
+ /** J/K/L: `true` while K is held (K+J / K+L are nudges, not shuttles). */
52
+ private kHeld;
53
+ private configDisplay;
54
+ private clickReadoutCycles;
55
+ private showRecord;
56
+ private showLoop;
57
+ private showMetronome;
58
+ private showPunch;
59
+ private showPreroll;
60
+ private showBpm;
61
+ private showShuttle;
62
+ private showScrubber;
63
+ private showInOut;
64
+ private shuttleRates;
65
+ private jklShuttle;
66
+ private stepUnit;
67
+ private revision;
68
+ private disposed;
69
+ private readonly listeners;
70
+ private unsubConfig;
71
+ constructor(host: PanelHost, options?: TransportPanelControllerOptions);
72
+ /** Load the persisted display-unit preference. */
73
+ load(): Promise<void>;
74
+ private readConfig;
75
+ /** Accept a clock push. Each push SNAPS the extrapolation to host truth. */
76
+ setClock(input: unknown): void;
77
+ /** Accept a timebase push. */
78
+ setTimebase(input: unknown): void;
79
+ /** Accept the live duration (ticks). */
80
+ setDuration(input: unknown): void;
81
+ /** Accept the transport state. Absent keys hide their controls. */
82
+ setState(input: unknown): void;
83
+ /** The current clock (host truth, not the extrapolation). */
84
+ getClock(): XenoPlayheadClock;
85
+ /** The current timebase. */
86
+ getTimebase(): XenoTimebase;
87
+ /** The live duration in ticks. */
88
+ getDuration(): number;
89
+ /** The transport state. */
90
+ getState(): XenoTransportState;
91
+ /** `playing` is HOST-authoritative — the panel only reports it. */
92
+ isPlaying(): boolean;
93
+ /** Whether the display should keep animating (a moving playhead only). */
94
+ isAnimating(): boolean;
95
+ /**
96
+ * The tick to display right now: the scrub position while scrubbing, else the dead-reckoned
97
+ * extrapolation, always clamped against the LIVE duration.
98
+ */
99
+ getCurrentTick(): XenoTick;
100
+ /**
101
+ * Advance the display. The view calls this each animation frame; it notifies **only when the
102
+ * integer tick actually changed**, so a paused transport or a sub-tick frame costs nothing.
103
+ *
104
+ * @returns `true` if listeners were notified.
105
+ */
106
+ pump(): boolean;
107
+ /** The formatted readout for the current position. */
108
+ getReadout(): string;
109
+ /** The formatted duration. */
110
+ getDurationReadout(): string;
111
+ /** Playback progress `0..1` (0 when the duration is unknown). */
112
+ getProgress(): number;
113
+ /** The effective display unit: panel override → config → the host timebase. */
114
+ getDisplay(): XenoTimebase['display'];
115
+ /** Set the display unit (persisted). */
116
+ setDisplay(display: XenoTimebase['display']): Promise<void>;
117
+ /** Cycle to the next display unit (the readout click). */
118
+ cycleDisplay(): void;
119
+ private emit;
120
+ /** Start playback. */
121
+ play(): void;
122
+ /** Pause playback. */
123
+ pause(): void;
124
+ /** Toggle playback. The host decides — the panel never flips `playing` itself. */
125
+ togglePlay(): void;
126
+ /** Stop (the host decides whether that returns to start). */
127
+ stop(): void;
128
+ /** Start/stop recording. */
129
+ record(recording?: boolean): void;
130
+ /** Seek to a tick (clamped to the live duration). */
131
+ seek(tick: XenoTick): void;
132
+ /** Step by whole units. */
133
+ step(delta: number, unit?: XenoStepUnit): void;
134
+ /** Jump to the start. */
135
+ goToStart(): void;
136
+ /** Jump to the end. */
137
+ goToEnd(): void;
138
+ /** Set the shuttle rate directly (negative reverses). */
139
+ shuttle(rate: number): void;
140
+ /** The configured shuttle rates. */
141
+ getShuttleRates(): readonly number[];
142
+ /**
143
+ * Step through the shuttle rates in a direction — the classic J/L behaviour: each press moves one
144
+ * notch up the ladder, crossing zero to reverse.
145
+ *
146
+ * @param direction - `+1` faster forward, `-1` faster reverse.
147
+ * @returns The rate emitted.
148
+ */
149
+ shuttleStep(direction: number): number;
150
+ /** The current shuttle ladder position (0 = stopped). */
151
+ getShuttleIndex(): number;
152
+ /** Set (or clear) the loop range. */
153
+ setLoop(loop: XenoLoopRange | null): void;
154
+ /** Toggle looping, preserving the existing range. */
155
+ toggleLoop(): void;
156
+ /** Set the in point (defaults to the playhead). */
157
+ setInPoint(tick?: XenoTick): void;
158
+ /** Set the out point (defaults to the playhead). */
159
+ setOutPoint(tick?: XenoTick): void;
160
+ /** Clear both in/out points. */
161
+ clearInOut(): void;
162
+ /** Toggle the metronome. */
163
+ toggleMetronome(): void;
164
+ /** Set the tempo. */
165
+ setTempo(bpm: number): void;
166
+ /** Whether a scrub is in progress. */
167
+ isScrubbing(): boolean;
168
+ /** Begin a scrub at a tick. */
169
+ beginScrub(tick: XenoTick): void;
170
+ /** Update the scrub — emits a throttled `seekPreview`, never a commit. */
171
+ updateScrub(tick: XenoTick): void;
172
+ /** Finish the scrub — flushes the preview and commits ONE `seek` intent. */
173
+ endScrub(): void;
174
+ /** Abandon the scrub without seeking. */
175
+ cancelScrub(): void;
176
+ /** Convert a scrubber fraction `0..1` to a tick. */
177
+ fractionToTick(fraction: number): XenoTick;
178
+ /** Whether a control renders: its config flag AND the host declaring the state key. */
179
+ showsControl(control: 'record' | 'loop' | 'metronome' | 'punch' | 'preroll' | 'bpm' | 'shuttle' | 'scrubber' | 'inOut'): boolean;
180
+ /**
181
+ * Handle a key. Returns `true` when consumed.
182
+ *
183
+ * Space toggles; Home/End jump; ←/→ step; and with `jklShuttle` on, J/K/L drive the Motion /
184
+ * Resolve shuttle — **K held + J or L nudges one frame** instead of shuttling.
185
+ */
186
+ handleKey(event: TransportKeyEvent, phase?: 'down' | 'up'): boolean;
187
+ /** Whether K is currently held (the J/K/L nudge modifier). */
188
+ isKHeld(): boolean;
189
+ /** Route a host `onInput(id, value)` call. */
190
+ onInput(id: string, value: unknown): void;
191
+ /** Route a host `onCommand(id, params)` call. */
192
+ onCommand(id: string, params: Record<string, unknown>): Promise<unknown>;
193
+ serialize(): TransportPanelSerialized;
194
+ deserialize(state: unknown): void;
195
+ subscribe(listener: () => void): () => void;
196
+ getRevision(): number;
197
+ private notify;
198
+ /** Release timers and subscriptions. Idempotent. */
199
+ dispose(): void;
200
+ }
201
+ /** Ticks for one step of a unit (host bridges may use this for `step` intents). */
202
+ declare function ticksPerStep(unit: XenoStepUnit, timebase: XenoTimebase): number;
203
+
204
+ /**
205
+ * `createTransportPanel` — the `PanelModule` factory for `xeno.core.transport`.
206
+ *
207
+ * @module
208
+ */
209
+
210
+ /** Options for {@link createTransportPanel}. */
211
+ type CreateTransportPanelOptions = TransportPanelControllerOptions;
212
+ /**
213
+ * Build the Transport panel module.
214
+ *
215
+ * @param options - Controller options (an injectable `now` / throttle clock for tests).
216
+ * @returns A {@link PanelModule} ready to register with a `PanelRegistry`.
217
+ */
218
+ declare function createTransportPanel(options?: CreateTransportPanelOptions): PanelModule;
219
+ /** The default Transport panel module. */
220
+ declare const transportPanel: PanelModule;
221
+
222
+ /**
223
+ * The `xeno.core.transport` manifest (timeline-family union spec §3).
224
+ *
225
+ * The smallest, most universal panel in the catalog — five hosts adopt it day one (Motion, Sound,
226
+ * Pixel, 3D, harness). Capabilities are `storage.local` ONLY: the host owns the clock and playback
227
+ * by construction, so there is nothing a transport capability could grant.
228
+ *
229
+ * @module
230
+ */
231
+
232
+ declare const transportManifest: PanelManifest;
233
+
234
+ /**
235
+ * `TransportPanelView` — the React rendering half of the Transport panel.
236
+ *
237
+ * It runs the **display-only animation frame**: each frame calls `controller.pump()`, which
238
+ * re-renders only when the integer tick actually changed, and the loop stops entirely when the
239
+ * transport is not moving. That is the whole 60fps-without-streaming trick — no per-frame IPC, no
240
+ * idle wakeups.
241
+ *
242
+ * Monochrome, dense, rectangles with small corner radius, square knobs, no circles, no shadows
243
+ * (DESIGN_SYSTEM §2/§3/§4).
244
+ *
245
+ * @module
246
+ */
247
+
248
+ /** Props for the view. */
249
+ interface TransportPanelViewProps {
250
+ controller: TransportPanelController;
251
+ }
252
+ declare function TransportPanelView({ controller }: TransportPanelViewProps): ReactElement;
253
+
254
+ /**
255
+ * `XenoTimeScale` — **the only thing in this package that converts between pixels and ticks.**
256
+ *
257
+ * ## Why one object
258
+ *
259
+ * Every timeline that scatters `clientX → time` arithmetic through its event handlers ships the same
260
+ * bug: `WheelEvent.clientX` is *viewport*-relative, the lane area starts after a track-header gutter,
261
+ * and forgetting to subtract it makes the content **slide under the cursor** on every zoom notch. The
262
+ * error is proportional to the visible span, so it never converges — measured on a 240 px gutter at
263
+ * 10 % per notch, the content slips ~1.3 s on the first notch and is still slipping 0.5 s on the
264
+ * tenth.
265
+ *
266
+ * Two shipping products solve it structurally rather than by remembering:
267
+ *
268
+ * - **Ardour** keeps two deliberately *differently-named* converters — `pixel_to_sample_from_event`
269
+ * subtracts the timeline origin, `pixel_to_sample` does not — so the two spaces cannot be silently
270
+ * mixed.
271
+ * - **Perfetto** puts the gutter *inside* the scale object, so there is nothing to forget.
272
+ *
273
+ * This is Perfetto's approach. The gutter is a field; `xToTick` takes a **container-relative** x and
274
+ * accounts for it. Nothing else in the package does this arithmetic.
275
+ *
276
+ * ## Why the state is `(pxPerTick, scrollTick)` and not `(startTick, endTick)`
277
+ *
278
+ * Storing the visible window as a pair of edges loses precision catastrophically under repeated
279
+ * zoom: once the span shrinks far below the absolute offset, both endpoints round to the same double
280
+ * and all relative information is gone. Measured over 300 zoom-ins and 300 zoom-outs about a cursor,
281
+ * `(left, right)` drifts by ~1.3 ms while `(pxPerTick, scroll)` drifts by ~1.8e-14 s. Ticks are
282
+ * integers here, which removes the rest.
283
+ *
284
+ * @module
285
+ */
286
+
287
+ /** How the visible window is described. */
288
+ interface XenoTimeScaleState {
289
+ /** Horizontal zoom. The single scalar — ticks are linear wall-clock, so tempo never enters here. */
290
+ pxPerTick: number;
291
+ /** The tick at the left edge of the LANE area (not of the container). */
292
+ scrollTick: number;
293
+ /** Vertical scroll, in CSS px. */
294
+ scrollTopPx: number;
295
+ }
296
+ /** Construction geometry. */
297
+ interface XenoTimeScaleGeometry {
298
+ /** Full container width in CSS px, gutter included. */
299
+ widthPx: number;
300
+ /** Container height in CSS px. */
301
+ heightPx: number;
302
+ /**
303
+ * Width of the track-header gutter in CSS px.
304
+ *
305
+ * The field that exists so no caller has to remember it.
306
+ */
307
+ gutterPx: number;
308
+ }
309
+ /** Zoom bounds, in px per tick. */
310
+ interface XenoZoomBounds {
311
+ min: number;
312
+ max: number;
313
+ }
314
+ /** The default zoom range: from ~1 tick per pixel out to 64 px per tick. */
315
+ declare const DEFAULT_ZOOM: XenoZoomBounds;
316
+ /**
317
+ * An immutable view→time mapping.
318
+ *
319
+ * Cheap to construct; make a new one rather than mutating, so a render never sees a half-updated
320
+ * scale.
321
+ */
322
+ declare class XenoTimeScale {
323
+ readonly pxPerTick: number;
324
+ readonly scrollTick: number;
325
+ readonly scrollTopPx: number;
326
+ readonly widthPx: number;
327
+ readonly heightPx: number;
328
+ readonly gutterPx: number;
329
+ constructor(state: XenoTimeScaleState, geometry: XenoTimeScaleGeometry);
330
+ /** Width of the lane area — the container minus the gutter. Always ≥ 1. */
331
+ get lanePx(): number;
332
+ /** Ticks visible in the lane area. */
333
+ get spanTicks(): number;
334
+ /** The tick at the right edge of the lane area. */
335
+ get endTick(): number;
336
+ /**
337
+ * Container-relative x → tick.
338
+ *
339
+ * @param x - Pixels from the **container's** left edge (`event.clientX - rect.left`).
340
+ * @returns The tick under that pixel. May be negative left of the origin.
341
+ *
342
+ * @example
343
+ * ```ts
344
+ * const rect = el.getBoundingClientRect()
345
+ * const tick = scale.xToTick(event.clientX - rect.left)
346
+ * ```
347
+ */
348
+ xToTick(x: number): number;
349
+ /**
350
+ * Tick → container-relative x.
351
+ *
352
+ * @param tick - A tick.
353
+ * @returns Pixels from the container's left edge.
354
+ */
355
+ tickToX(tick: number): number;
356
+ /**
357
+ * A tick DURATION → a pixel width.
358
+ *
359
+ * Deliberately separate from {@link tickToX}, because rounding is **not associative**:
360
+ * `durationToPx(b - a)` and `tickToX(b) - tickToX(a)` differ by up to a pixel, and mixing them is
361
+ * how a clip's right edge ends up one pixel from its neighbour's left edge at some zoom levels and
362
+ * overlapping at others. Pick one per axis and never mix.
363
+ *
364
+ * @param ticks - A duration in ticks.
365
+ * @returns Width in CSS px.
366
+ */
367
+ durationToPx(ticks: number): number;
368
+ /** A pixel distance → a tick distance. Used for screen-space thresholds. */
369
+ pxToTicks(px: number): number;
370
+ /** Is this x inside the lane area (rather than the gutter)? */
371
+ isInLane(x: number): boolean;
372
+ /** A copy with new state. */
373
+ with(state: Partial<XenoTimeScaleState>): XenoTimeScale;
374
+ /** A copy with new geometry. */
375
+ resized(geometry: Partial<XenoTimeScaleGeometry>): XenoTimeScale;
376
+ }
377
+ /**
378
+ * Zoom about a cursor, keeping the tick under the pointer **exactly** where it was.
379
+ *
380
+ * The whole formula, and the reason the gutter lives in the scale:
381
+ *
382
+ * ```
383
+ * anchorTick = scale.xToTick(x) // BEFORE the zoom, gutter accounted for
384
+ * pxPerTick' = clamp(pxPerTick * factor) // clamp FIRST
385
+ * scrollTick' = anchorTick - (x - gutter) / pxPerTick'
386
+ * ```
387
+ *
388
+ * 🔴 The factor must come from the **clamped** zoom, not the requested one. Deriving the new scroll
389
+ * from a zoom that was then clamped moves the content under a stationary cursor at both ends of the
390
+ * range — which is the moment a user is most likely to be zoomed in on something they care about.
391
+ * Neither surveyed incumbent has zoom-to-cursor at all, so there is no prior art to inherit the bug
392
+ * from; this is written from the correct form.
393
+ *
394
+ * @param scale - The current scale.
395
+ * @param x - Container-relative pointer x.
396
+ * @param factor - Requested multiplier (>1 zooms in).
397
+ * @param bounds - Zoom limits.
398
+ * @param maxTick - Content duration, so scroll cannot run past the end.
399
+ * @returns The new state.
400
+ *
401
+ * @example
402
+ * ```ts
403
+ * const next = zoomAboutCursor(scale, event.clientX - rect.left, 1.2, DEFAULT_ZOOM, durationTicks)
404
+ * ```
405
+ */
406
+ declare function zoomAboutCursor(scale: XenoTimeScale, x: number, factor: number, bounds?: XenoZoomBounds, maxTick?: number): XenoTimeScaleState;
407
+ /**
408
+ * Normalize a wheel event into a zoom factor.
409
+ *
410
+ * Two things every implementation has to get right and both surveyed incumbents get wrong today:
411
+ *
412
+ * 1. **Clamp `deltaY` before using it.** A single trackpad flick can deliver a delta in the
413
+ * hundreds; feeding that to an exponential produces a zoom jump of several orders of magnitude in
414
+ * one frame, and the user has no idea where they are. Clamping to ±10 first bounds one event to
415
+ * one sane step.
416
+ * 2. **`ctrlKey` means pinch, not control-click.** The browser reports a trackpad pinch as a wheel
417
+ * event with `ctrlKey: true` — including when the user never touched a key. Treating it as a
418
+ * modifier makes pinch-zoom do something else entirely.
419
+ *
420
+ * @param deltaY - The raw wheel delta.
421
+ * @param sensitivity - Exponent scale. Default `0.01`.
422
+ * @returns A multiplier for {@link zoomAboutCursor}.
423
+ *
424
+ * @example
425
+ * ```ts
426
+ * wheelZoomFactor(-53) // ≈ 1.43 — one notch in
427
+ * wheelZoomFactor(9999) // clamped: never more than one step out
428
+ * ```
429
+ */
430
+ declare function wheelZoomFactor(deltaY: number, sensitivity?: number): number;
431
+ /**
432
+ * Is this wheel event a zoom, or a scroll?
433
+ *
434
+ * @param event - The wheel event's relevant fields.
435
+ * @param zoomModifier - Which key means zoom when it is not a pinch.
436
+ * @returns Whether to zoom.
437
+ */
438
+ declare function isZoomGesture(event: {
439
+ ctrlKey?: boolean;
440
+ metaKey?: boolean;
441
+ altKey?: boolean;
442
+ }, zoomModifier?: 'alt' | 'meta' | 'none'): boolean;
443
+ /**
444
+ * Clamp horizontal scroll so the lane cannot be scrolled past the content.
445
+ *
446
+ * Allows scrolling until the content's end reaches the **left** edge is *not* the rule — the end is
447
+ * kept at least one lane-width from running away, so there is always something on screen.
448
+ *
449
+ * @param scrollTick - The requested scroll.
450
+ * @param pxPerTick - Current zoom.
451
+ * @param lanePx - Lane width.
452
+ * @param maxTick - Content duration.
453
+ * @returns A safe scroll position.
454
+ */
455
+ declare function clampScroll(scrollTick: number, pxPerTick: number, lanePx: number, maxTick: number): number;
456
+ /**
457
+ * The zoom that fits a tick range into the lane.
458
+ *
459
+ * @param fromTick - Range start.
460
+ * @param toTick - Range end.
461
+ * @param lanePx - Lane width.
462
+ * @param bounds - Zoom limits.
463
+ * @returns The state that shows exactly that range.
464
+ */
465
+ declare function zoomToFit(fromTick: number, toTick: number, lanePx: number, bounds?: XenoZoomBounds): {
466
+ pxPerTick: number;
467
+ scrollTick: number;
468
+ };
469
+ /**
470
+ * Scroll the minimum distance needed to bring a tick into view.
471
+ *
472
+ * @param scale - Current scale.
473
+ * @param tick - The tick to reveal.
474
+ * @param marginPx - Keep this much clearance from the edge.
475
+ * @param maxTick - Content duration.
476
+ * @returns The new scroll tick.
477
+ */
478
+ declare function scrollToReveal(scale: XenoTimeScale, tick: XenoTick, marginPx?: number, maxTick?: number): number;
479
+
480
+ /**
481
+ * The `xeno.core.timeline` wire contract.
482
+ *
483
+ * The arrangement is **host-owned and read-only**. The panel renders a projection of it and emits
484
+ * {@link XenoTimelineOp} intents; it never mutates a clip, never decodes a frame, and never asks for
485
+ * a capability beyond `storage.local`. Lane pixels arrive through the tile round-trip, which is the
486
+ * Layers thumbnail round-trip generalized: *pixel access stays host-side.*
487
+ *
488
+ * @module
489
+ */
490
+
491
+ /** What a track is for. Drives default adornments only — the host's own type is authoritative. */
492
+ type XenoTrackKind = 'video' | 'audio' | 'midi' | 'automation' | 'subtitle' | 'other';
493
+ /** One track / lane. */
494
+ interface XenoTrack {
495
+ id: string;
496
+ name: string;
497
+ kind: XenoTrackKind;
498
+ /**
499
+ * The host's own type string, carried **verbatim and never interpreted**.
500
+ *
501
+ * The panel round-trips it in every op so a host can tell its own subtypes apart without the
502
+ * panel needing to learn a vocabulary that differs per product.
503
+ */
504
+ nativeType?: string;
505
+ /** Row height in CSS px. Variable per track — the reason row layout is a prefix sum. */
506
+ heightPx: number;
507
+ /**
508
+ * Clip ids on this track, **in start order**.
509
+ *
510
+ * 🔴 The bridge pre-builds this join. The alternative — filtering the clip map by `trackId` on
511
+ * every render — is O(tracks × clips) per frame, which is what makes the reference implementation
512
+ * stall at a few hundred clips. Ordering is also what lets culling walk backwards and stop early.
513
+ */
514
+ clipIds: string[];
515
+ muted?: boolean;
516
+ solo?: boolean;
517
+ locked?: boolean;
518
+ visible?: boolean;
519
+ armed?: boolean;
520
+ targeted?: boolean;
521
+ /** A colour token, not a raw hex — the panel is monochrome and the host owns accent policy. */
522
+ color?: string;
523
+ /** Collapsed to a header with no lane. */
524
+ collapsed?: boolean;
525
+ }
526
+ /** A clip's fade shape. */
527
+ interface XenoClipFade {
528
+ /** Length in ticks from the clip's own edge. */
529
+ ticks: number;
530
+ curve?: 'linear' | 'equalPower' | 'exponential';
531
+ }
532
+ /** One clip on a track. */
533
+ interface XenoClip {
534
+ id: string;
535
+ trackId: string;
536
+ /** Position on the timeline. Integer ticks, always. */
537
+ startTick: XenoTick;
538
+ /** Exclusive end. `endTick - startTick` is the duration. */
539
+ endTick: XenoTick;
540
+ name?: string;
541
+ /**
542
+ * Where the clip reads from within its source.
543
+ *
544
+ * **Absence disables slip and slide**, because without a source window there is nothing to slide
545
+ * within — and offering a tool that silently does nothing is worse than not offering it.
546
+ */
547
+ sourceInTick?: XenoTick;
548
+ sourceOutTick?: XenoTick;
549
+ /** The source this clip reads. Part of the tile cache key. */
550
+ sourceId?: string;
551
+ /** Clips that move with this one — an A/V pair. */
552
+ linkedIds?: string[];
553
+ /**
554
+ * Keyframe positions, **pre-flattened by the host**.
555
+ *
556
+ * The panel draws diamonds; it never walks a property tree to find them.
557
+ */
558
+ keyframeTicks?: XenoTick[];
559
+ fadeIn?: XenoClipFade;
560
+ fadeOut?: XenoClipFade;
561
+ /** Opaque key naming the lane content, for the tile round-trip. */
562
+ tileKey?: string;
563
+ color?: string;
564
+ /** Short badges drawn on the clip at the largest render tier. */
565
+ adornments?: string[];
566
+ locked?: boolean;
567
+ /** Per-clip capability overrides. Absent means "whatever the track allows". */
568
+ caps?: {
569
+ move?: boolean;
570
+ trim?: boolean;
571
+ split?: boolean;
572
+ delete?: boolean;
573
+ };
574
+ }
575
+ /** A named point in time. */
576
+ interface XenoMarker {
577
+ id: string;
578
+ tick: XenoTick;
579
+ name?: string;
580
+ color?: string;
581
+ /** A range marker, when it has an end. */
582
+ endTick?: XenoTick;
583
+ }
584
+ /** A host-drawn strip over a time range — cache bars, loop brace, punch region. */
585
+ interface XenoOverlaySpan {
586
+ id: string;
587
+ startTick: XenoTick;
588
+ endTick: XenoTick;
589
+ kind: string;
590
+ /** Which track, or the ruler when absent. */
591
+ trackId?: string;
592
+ label?: string;
593
+ }
594
+ /** The whole arrangement. Host-owned, read-only, re-walked only when `rev` changes. */
595
+ interface XenoArrangement {
596
+ timebase: XenoTimebase;
597
+ durationTicks: number;
598
+ tracks: XenoTrack[];
599
+ /** Every clip, by id. Tracks reference them through `clipIds`. */
600
+ clips: Record<string, XenoClip>;
601
+ markers?: XenoMarker[];
602
+ /** Monotonic. The panel re-walks only when this moves — the throttled re-read gate. */
603
+ rev: number;
604
+ }
605
+ /** How a trim affects everything downstream. */
606
+ type XenoTrimMode = 'standard' | 'ripple' | 'roll';
607
+ /**
608
+ * A requested mutation. **An intent, never a mutation** — the host may refuse, clamp, or
609
+ * reinterpret it, and the panel renders nothing until a new `arrangement` arrives.
610
+ */
611
+ type XenoTimelineOp = {
612
+ kind: 'move';
613
+ clipIds: string[];
614
+ deltaTicks: number;
615
+ toTrackId?: string;
616
+ } | {
617
+ kind: 'trim';
618
+ clipId: string;
619
+ edge: 'start' | 'end';
620
+ toTick: XenoTick;
621
+ mode: XenoTrimMode;
622
+ } | {
623
+ kind: 'split';
624
+ clipIds: string[];
625
+ atTick: XenoTick;
626
+ } | {
627
+ kind: 'splitAll';
628
+ atTick: XenoTick;
629
+ } | {
630
+ kind: 'delete';
631
+ clipIds: string[];
632
+ ripple?: boolean;
633
+ } | {
634
+ kind: 'duplicate';
635
+ clipIds: string[];
636
+ deltaTicks: number;
637
+ } | {
638
+ kind: 'slip';
639
+ clipId: string;
640
+ deltaTicks: number;
641
+ } | {
642
+ kind: 'slide';
643
+ clipId: string;
644
+ deltaTicks: number;
645
+ } | {
646
+ kind: 'setTrackProp';
647
+ trackId: string;
648
+ prop: 'muted' | 'solo' | 'locked' | 'visible' | 'armed' | 'targeted';
649
+ value: boolean;
650
+ } | {
651
+ kind: 'setTrackHeight';
652
+ trackId: string;
653
+ heightPx: number;
654
+ } | {
655
+ kind: 'reorderTrack';
656
+ trackId: string;
657
+ toIndex: number;
658
+ } | {
659
+ kind: 'addMarker';
660
+ tick: XenoTick;
661
+ name?: string;
662
+ } | {
663
+ kind: 'moveMarker';
664
+ markerId: string;
665
+ toTick: XenoTick;
666
+ } | {
667
+ kind: 'removeMarker';
668
+ markerId: string;
669
+ };
670
+ /** The panel's ask for lane pixels. */
671
+ interface XenoLaneTileRequest {
672
+ /** Cache key. Derived from SOURCE position — see `tiles.ts`. */
673
+ key: string;
674
+ clipId: string;
675
+ trackId: string;
676
+ /** The SOURCE range this tile covers, not the screen range. */
677
+ startTick: XenoTick;
678
+ endTick: XenoTick;
679
+ widthPx: number;
680
+ heightPx: number;
681
+ dpr: number;
682
+ }
683
+ /** The host's answer. The panel **blits it and never interprets it**. */
684
+ interface XenoLaneTile {
685
+ key: string;
686
+ /** `null` means "there is genuinely nothing to draw" — distinct from "not asked yet". */
687
+ bitmap: ImageBitmap | ImageData | null;
688
+ }
689
+ /** What is selected. */
690
+ interface XenoTimelineSelection {
691
+ clipIds: string[];
692
+ trackIds?: string[];
693
+ /** A time range selection, when the user dragged in the ruler. */
694
+ range?: {
695
+ startTick: XenoTick;
696
+ endTick: XenoTick;
697
+ };
698
+ }
699
+ /** What is on screen. Emitted so a host can prefetch decode and render caches. */
700
+ interface XenoTimelineViewport {
701
+ startTick: XenoTick;
702
+ endTick: XenoTick;
703
+ pxPerTick: number;
704
+ scrollTopPx: number;
705
+ /** Track ids currently rendered, after row virtualization. */
706
+ trackIds: string[];
707
+ }
708
+ /** The active tool. Frozen at mousedown for the duration of a gesture. */
709
+ type XenoTimelineTool = 'select' | 'razor' | 'ripple' | 'roll' | 'slip' | 'slide' | 'hand' | 'zoom';
710
+ /** Snapping mode. */
711
+ type XenoSnapMode = 'off' | 'grid' | 'targets' | 'both';
712
+ /**
713
+ * Are two clips overlapping?
714
+ *
715
+ * Half-open intervals: a clip ending exactly where the next begins is **adjacent, not overlapping**.
716
+ * Getting this wrong makes every butt-joined cut in a sequence report a collision.
717
+ *
718
+ * @param a - One clip.
719
+ * @param b - Another.
720
+ * @returns Whether they genuinely overlap.
721
+ */
722
+ declare function clipsOverlap(a: {
723
+ startTick: number;
724
+ endTick: number;
725
+ }, b: {
726
+ startTick: number;
727
+ endTick: number;
728
+ }): boolean;
729
+ /** A clip's duration in ticks. */
730
+ declare function clipDuration(clip: XenoClip): number;
731
+ /**
732
+ * Can this clip be slipped or slid?
733
+ *
734
+ * Only when the host declared a source window. Offering the tool without one produces a gesture that
735
+ * appears to work and changes nothing.
736
+ */
737
+ declare function hasSourceWindow(clip: XenoClip): boolean;
738
+
739
+ /**
740
+ * Row virtualization and clip culling — **both from day one**, not as a later optimization.
741
+ *
742
+ * The reference implementation has neither: it renders every track and every clip, and its
743
+ * purpose-built virtualizer is dead code that was never wired up. The parity gate is 50 tracks ×
744
+ * 2000 clips, which is not reachable by rendering 2000 rectangles per frame.
745
+ *
746
+ * @module
747
+ */
748
+
749
+ /** Where each track sits vertically. */
750
+ interface XenoRowLayout {
751
+ /** `offsets[i]` is the top of track `i`; `offsets[n]` is the total height. One entry longer. */
752
+ offsets: Float64Array;
753
+ /** Total content height in CSS px. */
754
+ totalPx: number;
755
+ }
756
+ /**
757
+ * Build the row offset table.
758
+ *
759
+ * A prefix sum plus binary search, which is what every shipping virtualizer converged on for
760
+ * variable heights — vertical layout is a partition, so nothing fancier is warranted. A collapsed
761
+ * track keeps a header row but no lane.
762
+ *
763
+ * @param tracks - The tracks, in display order.
764
+ * @param collapsedPx - Height of a collapsed track's header.
765
+ * @returns Offsets and total height.
766
+ *
767
+ * @example
768
+ * ```ts
769
+ * const rows = buildRowLayout(tracks)
770
+ * const { first, last } = visibleRows(rows, scrollTopPx, viewportHeightPx)
771
+ * ```
772
+ */
773
+ declare function buildRowLayout(tracks: readonly XenoTrack[], collapsedPx?: number): XenoRowLayout;
774
+ /**
775
+ * Which rows intersect the viewport.
776
+ *
777
+ * 🔴 The rejection test uses a track's **whole height**, not just its top. Testing only the top
778
+ * makes a row disappear the instant it is one pixel above the fold, so the topmost track flickers
779
+ * out while half of it is still visible.
780
+ *
781
+ * @param layout - From {@link buildRowLayout}.
782
+ * @param scrollTopPx - Vertical scroll.
783
+ * @param viewportPx - Visible height.
784
+ * @param overscan - Extra rows above and below. Default 1.
785
+ * @returns Inclusive `first`, exclusive `last`.
786
+ */
787
+ declare function visibleRows(layout: XenoRowLayout, scrollTopPx: number, viewportPx: number, overscan?: number): {
788
+ first: number;
789
+ last: number;
790
+ };
791
+ /** The row containing a y offset, or -1. */
792
+ declare function rowAtY(layout: XenoRowLayout, y: number): number;
793
+ /**
794
+ * Clips of one track that intersect the visible tick window.
795
+ *
796
+ * 🔴 **This walks BACKWARD, and that is the whole point.** The obvious implementation —
797
+ * binary-search to the first clip whose `startTick >= viewportStart`, then scan forward — silently
798
+ * drops the clip that *begins before the viewport and extends into it*. On a timeline that is the
799
+ * long clip you are currently looking at: scroll into the middle of a five-minute take and it
800
+ * vanishes.
801
+ *
802
+ * Because clips on one track are non-overlapping and ordered, walking left from the last clip
803
+ * starting before `viewportEnd` and stopping at the first clip that ends at or before
804
+ * `viewportStart` is **provably complete** — the straddling clip is the last one visited before the
805
+ * break. O(log n + k), and it needs no extra index.
806
+ *
807
+ * @param track - The track, with `clipIds` in start order.
808
+ * @param clips - The clip map.
809
+ * @param startTick - Viewport start.
810
+ * @param endTick - Viewport end.
811
+ * @returns The visible clips, in display order.
812
+ *
813
+ * @example
814
+ * ```ts
815
+ * const visible = visibleClips(track, arrangement.clips, scale.scrollTick, scale.endTick)
816
+ * ```
817
+ */
818
+ declare function visibleClips(track: XenoTrack, clips: Readonly<Record<string, XenoClip>>, startTick: number, endTick: number): XenoClip[];
819
+ /** How much detail a clip's width can carry. */
820
+ type XenoRenderTier = 'sliver' | 'compact' | 'full';
821
+ /**
822
+ * Pick a render tier from a clip's pixel width.
823
+ *
824
+ * Below the first breakpoint a clip is a bare rectangle: no label, no waveform, no keyframes, no
825
+ * fade handles. Drawing text into eight pixels costs a text-shaping pass to produce an ellipsis.
826
+ *
827
+ * @param widthPx - The clip's drawn width.
828
+ * @param breakpoints - `[compact, full]`. Default `[40, 120]`.
829
+ * @returns The tier.
830
+ */
831
+ declare function renderTier(widthPx: number, breakpoints?: readonly [number, number]): XenoRenderTier;
832
+ /**
833
+ * Collapse clips that would land on the same pixel column.
834
+ *
835
+ * At a zoom where a whole sequence fits on screen, a thousand clips occupy a few hundred pixels and
836
+ * most draw calls land on a column already painted. Keeping one per column turns "draw every clip"
837
+ * into "draw at most lane-width clips" and is worth more than any smarter search.
838
+ *
839
+ * @param clips - Visible clips, in order.
840
+ * @param scale - The scale.
841
+ * @param minGapPx - Minimum distance between kept clips. Default 1.
842
+ * @returns The clips worth drawing.
843
+ */
844
+ declare function dedupeByColumn(clips: readonly XenoClip[], scale: XenoTimeScale, minGapPx?: number): XenoClip[];
845
+ /** One track's worth of what to draw. */
846
+ interface XenoVisibleTrack {
847
+ track: XenoTrack;
848
+ index: number;
849
+ /** Top of this row in content space. */
850
+ topPx: number;
851
+ heightPx: number;
852
+ clips: XenoClip[];
853
+ }
854
+ /**
855
+ * Everything on screen, culled on both axes.
856
+ *
857
+ * @param arrangement - The host's arrangement.
858
+ * @param scale - The current scale.
859
+ * @param options - Overscan, collapsed height, dedupe.
860
+ * @returns Visible tracks with their visible clips.
861
+ */
862
+ declare function computeVisible(arrangement: XenoArrangement, scale: XenoTimeScale, options?: {
863
+ overscan?: number;
864
+ collapsedPx?: number;
865
+ dedupe?: boolean;
866
+ rulerPx?: number;
867
+ }): {
868
+ rows: XenoRowLayout;
869
+ tracks: XenoVisibleTrack[];
870
+ };
871
+
872
+ /**
873
+ * Snapping — **the threshold is constant in SCREEN space, always.**
874
+ *
875
+ * A threshold expressed in time is unusable, and the numbers say why. With a fixed 50 ms window:
876
+ *
877
+ * | zoom | 10 px is | 50 ms is |
878
+ * |---|---|---|
879
+ * | far out | 5 s | **0.1 px** — snapping never fires |
880
+ * | normal | 50 ms | 10 px ✓ |
881
+ * | far in | 0.2 ms | **2205 px** — everything snaps |
882
+ *
883
+ * So the threshold is declared in CSS pixels and converted through the scale on every call. A DAW
884
+ * that gets this right documents the second reason too: a magnetic distance also *"helps to avoid
885
+ * snapping to somewhere the user can't see — i.e. I clicked on a region and it disappeared."*
886
+ *
887
+ * @module
888
+ */
889
+
890
+ /** Something a drag can snap to. */
891
+ interface XenoSnapCandidate {
892
+ tick: XenoTick;
893
+ kind: 'grid' | 'clipStart' | 'clipEnd' | 'playhead' | 'marker' | 'origin';
894
+ /** For reporting which clip won, so the view can highlight it. */
895
+ id?: string;
896
+ }
897
+ /** A resolved snap. */
898
+ interface XenoSnapResult {
899
+ /** The delta to apply — **snapped, then returned as a delta**. */
900
+ deltaTicks: number;
901
+ /** What it snapped to, or `null` when nothing was near enough. */
902
+ target: XenoSnapCandidate | null;
903
+ /** Which of the dragged edges did the snapping. */
904
+ sourceTick?: XenoTick;
905
+ }
906
+ /** Snap configuration. */
907
+ interface XenoSnapConfig {
908
+ mode: XenoSnapMode;
909
+ /** In CSS px. 8–12 is the consensus band; trim wants tighter than move. */
910
+ thresholdPx: number;
911
+ /** Grid spacing in ticks. `0` disables grid snapping. */
912
+ gridTicks: number;
913
+ }
914
+ /** Sensible defaults. Trim uses a tighter threshold than move — a frame matters more at an edge. */
915
+ declare const DEFAULT_SNAP: XenoSnapConfig;
916
+ /** The tighter threshold used while trimming. */
917
+ declare const TRIM_SNAP_PX = 5;
918
+ /**
919
+ * Collect snap targets from the arrangement.
920
+ *
921
+ * 🔴 The dragged clips' own edges are **excluded**. Without that a clip snaps to itself, the delta
922
+ * resolves to zero, and the drag feels stuck — which reads as a broken timeline rather than as
923
+ * snapping.
924
+ *
925
+ * @param arrangement - The host's arrangement.
926
+ * @param excludeClipIds - The clips being dragged.
927
+ * @param playheadTick - The playhead, added as a target only for this query.
928
+ * @param window - Only collect targets in this tick range.
929
+ * @returns Candidates, unsorted.
930
+ */
931
+ declare function collectSnapTargets(arrangement: XenoArrangement, excludeClipIds: readonly string[], playheadTick: XenoTick | null, window: {
932
+ startTick: number;
933
+ endTick: number;
934
+ }): XenoSnapCandidate[];
935
+ /**
936
+ * Snap a **delta**, given every edge that is moving.
937
+ *
938
+ * 🔴 Snapping the *position* of one clip is wrong when several are selected: the group would tear,
939
+ * because each clip would land on its own nearest target. Snapping the shared **delta** keeps the
940
+ * selection rigid — every edge is tested against every candidate, the closest pairing wins, and the
941
+ * whole group moves by that one delta.
942
+ *
943
+ * @param sourceTicks - Every edge that is moving, at its ORIGINAL position.
944
+ * @param rawDelta - The unsnapped delta from the pointer.
945
+ * @param candidates - Targets from {@link collectSnapTargets}.
946
+ * @param scale - For the screen-space threshold.
947
+ * @param config - Mode, threshold, grid.
948
+ * @returns The delta to apply and what it snapped to.
949
+ *
950
+ * @example
951
+ * ```ts
952
+ * const snap = snapDelta(
953
+ * selected.flatMap((c) => [c.startTick, c.endTick]),
954
+ * rawDelta, candidates, scale, DEFAULT_SNAP,
955
+ * )
956
+ * ```
957
+ */
958
+ declare function snapDelta(sourceTicks: readonly XenoTick[], rawDelta: number, candidates: readonly XenoSnapCandidate[], scale: XenoTimeScale, config?: XenoSnapConfig): XenoSnapResult;
959
+ /**
960
+ * Snap a single absolute tick — for a trim edge, a marker drag, or a razor cut.
961
+ *
962
+ * @param tick - The unsnapped position.
963
+ * @param candidates - Targets.
964
+ * @param scale - For the threshold.
965
+ * @param config - Mode, threshold, grid.
966
+ * @returns The snapped tick and its target.
967
+ */
968
+ declare function snapTick(tick: number, candidates: readonly XenoSnapCandidate[], scale: XenoTimeScale, config?: XenoSnapConfig): {
969
+ tick: XenoTick;
970
+ target: XenoSnapCandidate | null;
971
+ };
972
+ /**
973
+ * Should snapping be suppressed at this zoom?
974
+ *
975
+ * Once a single tick is wide enough to click precisely, snapping stops helping and starts fighting
976
+ * the user — they can already hit the frame they want, and the magnet drags them off it. A shipping
977
+ * NLE disables snapping entirely past this point for exactly that reason.
978
+ *
979
+ * @param scale - Current scale.
980
+ * @param ticksPerFrame - Ticks in one displayable frame. Default 1.
981
+ * @returns Whether to turn snapping off.
982
+ */
983
+ declare function snapWouldFight(scale: XenoTimeScale, ticksPerFrame?: number): boolean;
984
+ /**
985
+ * Apply the snap-invert modifier.
986
+ *
987
+ * Held, it **inverts** rather than disables: a user who works with snapping off still wants a way to
988
+ * land exactly on a cut. This is also the one modifier that is re-read on every move rather than
989
+ * frozen at mousedown — every surveyed product lets it change mid-drag.
990
+ *
991
+ * @param config - The configured snapping.
992
+ * @param inverted - Whether the modifier is down right now.
993
+ * @returns The effective config.
994
+ */
995
+ declare function withSnapModifier(config: XenoSnapConfig, inverted: boolean): XenoSnapConfig;
996
+
997
+ /**
998
+ * The gesture state machine — **mode frozen at mousedown, bookkeeping in one object.**
999
+ *
1000
+ * ## The two bugs this file exists to prevent
1001
+ *
1002
+ * 1. **Re-deciding the mode mid-drag.** If the handler asks "am I over an edge?" on every move, then
1003
+ * a move that starts in the middle of a clip and passes over its edge silently becomes a trim.
1004
+ * Every shipping editor instead instantiates a gesture *object* at button-press, chosen once by
1005
+ * hit-target, and never re-decides. Here that object is {@link XenoDragState} and the decision is
1006
+ * {@link beginDrag}.
1007
+ *
1008
+ * 2. **Bookkeeping in closure locals.** The reference implementation keeps drag state in variables
1009
+ * captured by the move handler, and has a documented bug where the commit is dropped because a
1010
+ * re-render replaced the closure. State lives in **one plain object owned by the caller** — a ref,
1011
+ * never a captured local — so a re-render cannot lose it.
1012
+ *
1013
+ * ## One undo entry per drag
1014
+ *
1015
+ * The model is never mutated during a gesture. `update()` produces a **preview** the renderer reads
1016
+ * through; only `commit()` returns an op. A cancel is therefore free — nothing was written — and a
1017
+ * drag that never moved returns `null`, so a click-to-select can never leave an undo entry behind.
1018
+ *
1019
+ * @module
1020
+ */
1021
+
1022
+ /**
1023
+ * Movement, in CSS px, before a press becomes a drag.
1024
+ *
1025
+ * Compared **squared**, so no `sqrt` runs per pointermove. Below this a press is a click: without a
1026
+ * dead zone, magnet snapping nudges the clip during a click-to-select, and at normal zoom a
1027
+ * sub-pixel nudge is already one or two frames — committing that creates a tiny accidental edit.
1028
+ */
1029
+ declare const DRAG_THRESHOLD_PX = 4;
1030
+ /** Width of the trim grab zone at each clip edge, in CSS px. */
1031
+ declare const EDGE_ZONE_PX = 10;
1032
+ /**
1033
+ * Below this clip width the edge zones are **hidden entirely**.
1034
+ *
1035
+ * Two 10 px handles on a 24 px clip leave 4 px of body, so the clip becomes un-draggable — the
1036
+ * gesture the user wants most is the one they can no longer start.
1037
+ */
1038
+ declare const EDGE_ZONE_MIN_CLIP_PX: number;
1039
+ /** The kind of gesture, decided once. */
1040
+ type XenoDragKind = 'move' | 'trimStart' | 'trimEnd' | 'marquee' | 'scrub' | 'pan' | 'trackHeight' | 'marker';
1041
+ /** What was under the pointer at mousedown. */
1042
+ interface XenoHitTarget {
1043
+ kind: 'clip' | 'clipEdgeStart' | 'clipEdgeEnd' | 'lane' | 'ruler' | 'trackDivider' | 'marker';
1044
+ clipId?: string;
1045
+ trackId?: string;
1046
+ markerId?: string;
1047
+ }
1048
+ /**
1049
+ * Decide which edge zone — if any — a pointer is in.
1050
+ *
1051
+ * @param offsetPx - Pointer distance from the clip's left edge.
1052
+ * @param widthPx - The clip's drawn width.
1053
+ * @returns The zone, or `null` for the body.
1054
+ *
1055
+ * @example
1056
+ * ```ts
1057
+ * edgeZone(3, 200) // → 'start'
1058
+ * edgeZone(3, 24) // → null — too narrow to carry handles
1059
+ * ```
1060
+ */
1061
+ declare function edgeZone(offsetPx: number, widthPx: number): 'start' | 'end' | null;
1062
+ /** Modifier keys, split by when they are read. */
1063
+ interface XenoDragModifiers {
1064
+ /** Add to selection. Read at mousedown — it changes *what* the gesture is. */
1065
+ additive?: boolean;
1066
+ /** Copy rather than move. Read at mousedown. */
1067
+ copy?: boolean;
1068
+ /** Invert snapping. **Re-read on every move** — every product lets this change mid-drag. */
1069
+ snapInvert?: boolean;
1070
+ /** Constrain to one axis. Re-read on every move. */
1071
+ constrain?: boolean;
1072
+ }
1073
+ /**
1074
+ * Everything a drag needs to remember.
1075
+ *
1076
+ * Owned by the caller and stored in a ref. Deliberately a plain, serializable-ish object rather than
1077
+ * a class with captured state.
1078
+ */
1079
+ interface XenoDragState {
1080
+ kind: XenoDragKind;
1081
+ tool: XenoTimelineTool;
1082
+ /** Frozen at mousedown. */
1083
+ startX: number;
1084
+ startY: number;
1085
+ startTick: XenoTick;
1086
+ /** The clips this gesture moves. Frozen at mousedown. */
1087
+ clipIds: string[];
1088
+ /** Their original positions, so a preview is computed from the ORIGINAL, never cumulatively. */
1089
+ origin: Record<string, {
1090
+ startTick: XenoTick;
1091
+ endTick: XenoTick;
1092
+ trackId: string;
1093
+ }>;
1094
+ /** Track index the press started on, for cross-track drags. */
1095
+ startTrackIndex: number;
1096
+ /** Modifiers captured at mousedown. */
1097
+ modifiers: XenoDragModifiers;
1098
+ /** Set once the movement threshold is passed. */
1099
+ moved: boolean;
1100
+ /** Live delta after snapping. */
1101
+ deltaTicks: number;
1102
+ /** Live track delta for a cross-track move. */
1103
+ deltaTracks: number;
1104
+ /** For a trim: where the edge currently is. */
1105
+ edgeTick?: XenoTick;
1106
+ /** For a marquee: the live rectangle in content space. */
1107
+ marquee?: {
1108
+ fromTick: number;
1109
+ toTick: number;
1110
+ fromY: number;
1111
+ toY: number;
1112
+ };
1113
+ markerId?: string;
1114
+ trackId?: string;
1115
+ /** For a track-height drag. */
1116
+ startHeightPx?: number;
1117
+ }
1118
+ /** Options for {@link beginDrag}. */
1119
+ interface BeginDragOptions {
1120
+ target: XenoHitTarget;
1121
+ tool: XenoTimelineTool;
1122
+ x: number;
1123
+ y: number;
1124
+ tick: XenoTick;
1125
+ trackIndex: number;
1126
+ /** Clips that will move — the selection, or just the pressed clip. */
1127
+ clips: readonly XenoClip[];
1128
+ modifiers?: XenoDragModifiers;
1129
+ startHeightPx?: number;
1130
+ }
1131
+ /**
1132
+ * Start a gesture, choosing its kind **once**.
1133
+ *
1134
+ * @param options - What was hit, which tool is active, and where.
1135
+ * @returns Frozen drag state, or `null` when this press starts no gesture.
1136
+ *
1137
+ * @example
1138
+ * ```ts
1139
+ * dragRef.current = beginDrag({ target, tool, x, y, tick, trackIndex, clips: selected })
1140
+ * ```
1141
+ */
1142
+ declare function beginDrag(options: BeginDragOptions): XenoDragState | null;
1143
+ /**
1144
+ * Has the press travelled far enough to be a drag?
1145
+ *
1146
+ * @param state - The gesture.
1147
+ * @param x - Current pointer x.
1148
+ * @param y - Current pointer y.
1149
+ * @returns Whether the threshold is passed.
1150
+ */
1151
+ declare function passedThreshold(state: XenoDragState, x: number, y: number): boolean;
1152
+ /** The preview a renderer reads through. The model is untouched. */
1153
+ interface XenoDragPreview {
1154
+ /** Clip id → its previewed position. */
1155
+ clips: Record<string, {
1156
+ startTick: XenoTick;
1157
+ endTick: XenoTick;
1158
+ trackId: string;
1159
+ }>;
1160
+ marquee?: XenoDragState['marquee'];
1161
+ /** For a scrub. */
1162
+ playheadTick?: XenoTick;
1163
+ }
1164
+ /**
1165
+ * Compute the preview for the current pointer position.
1166
+ *
1167
+ * 🔴 Always derived from `state.origin`, **never from the previous preview**. Accumulating deltas
1168
+ * makes a drag that crosses a snap target drift permanently, because the snap correction is applied
1169
+ * to an already-corrected position.
1170
+ *
1171
+ * @param state - The gesture.
1172
+ * @param deltaTicks - The snapped horizontal delta.
1173
+ * @param deltaTracks - The vertical track delta.
1174
+ * @param tracksById - Track ids in display order, for cross-track moves.
1175
+ * @returns The preview.
1176
+ */
1177
+ declare function previewDrag(state: XenoDragState, deltaTicks: number, deltaTracks: number, trackIds: readonly string[]): XenoDragPreview;
1178
+ /**
1179
+ * Turn a finished gesture into an op — or into nothing.
1180
+ *
1181
+ * 🔴 Returns `null` when the gesture never passed the threshold **or** when its net delta is zero.
1182
+ * A click that selects, and a drag that returns exactly where it started, must both leave the undo
1183
+ * stack untouched. An editor that records those makes undo useless: the user presses it and the
1184
+ * document does not change.
1185
+ *
1186
+ * @param state - The finished gesture.
1187
+ * @param trimMode - How a trim ripples. From the active tool.
1188
+ * @returns One op, or `null`.
1189
+ */
1190
+ declare function commitDrag(state: XenoDragState, trimMode?: XenoTrimMode): XenoTimelineOp | null;
1191
+ /**
1192
+ * Which trim mode a tool implies.
1193
+ *
1194
+ * @param tool - The active tool.
1195
+ * @returns The trim mode.
1196
+ */
1197
+ declare function trimModeFor(tool: XenoTimelineTool): XenoTrimMode;
1198
+ /**
1199
+ * Clips inside a marquee rectangle.
1200
+ *
1201
+ * @param marquee - The rectangle in content space.
1202
+ * @param rows - Track top offsets, one longer than the track list.
1203
+ * @param tracks - Track ids in display order.
1204
+ * @param clips - Every clip.
1205
+ * @returns Selected clip ids.
1206
+ */
1207
+ declare function clipsInMarquee(marquee: NonNullable<XenoDragState['marquee']>, rows: {
1208
+ offsets: Float64Array;
1209
+ }, tracks: readonly {
1210
+ id: string;
1211
+ clipIds: string[];
1212
+ }[], clips: Readonly<Record<string, XenoClip>>): string[];
1213
+
1214
+ /**
1215
+ * The lane-tile cache — **keyed by SOURCE position, never by screen position.**
1216
+ *
1217
+ * ## The rule, and what breaks without it
1218
+ *
1219
+ * A lane tile is a strip of waveform or filmstrip. If its cache key contains a screen x or a slot
1220
+ * index, then **every one-pixel scroll misses**, every zoom step invalidates the whole cache, and
1221
+ * trimming a clip's left edge makes all its thumbnails visibly shuffle. Key on
1222
+ * `(sourceId, source tick range, quantized zoom, height)` and a scroll is free, a re-trim keeps its
1223
+ * pixels, and two clips of the same source share tiles.
1224
+ *
1225
+ * Every implementation surveyed is source-keyed. One goes further and satisfies a request from any
1226
+ * cached tile that **contains** it, which is why it renders wider than the viewport.
1227
+ *
1228
+ * ## Why tile widths are jittered
1229
+ *
1230
+ * With a fixed tile width, every track's tiles fall due on the same scroll pixel and one frame has
1231
+ * to render all of them — a periodic stall exactly when the user is scrubbing. Multiplying the
1232
+ * nominal width by a per-tile random factor in 1.4–1.8× de-synchronizes them, so the work spreads
1233
+ * across frames. This is lifted directly from a shipping DAW, whose source comment says the same.
1234
+ *
1235
+ * @module
1236
+ */
1237
+
1238
+ /** A tile held in the cache. */
1239
+ interface XenoCachedTile {
1240
+ key: string;
1241
+ sourceId: string;
1242
+ /** SOURCE tick range this tile covers. */
1243
+ startTick: number;
1244
+ endTick: number;
1245
+ /** Quantized zoom the tile was rendered at. */
1246
+ zoomBucket: number;
1247
+ heightPx: number;
1248
+ bitmap: ImageBitmap | ImageData | null;
1249
+ /** Approximate bytes, for the budget. */
1250
+ bytes: number;
1251
+ /** Monotonic counter for LRU. */
1252
+ usedAt: number;
1253
+ }
1254
+ /** Cache configuration. */
1255
+ interface XenoTileCacheOptions {
1256
+ /**
1257
+ * Byte budget.
1258
+ *
1259
+ * A **byte** budget rather than a count: row heights are user-variable, so a fixed tile count can
1260
+ * mean 4 MB or 400 MB depending on how tall the user made their tracks.
1261
+ */
1262
+ maxBytes?: number;
1263
+ /** Injectable RNG, so the jitter is testable. */
1264
+ random?: () => number;
1265
+ }
1266
+ /**
1267
+ * Quantize zoom to a power-of-two bucket.
1268
+ *
1269
+ * Without this the key changes on **every frame of a zoom gesture** and the cache never hits during
1270
+ * the one interaction that most needs it. A tile rendered at 1.3× the current zoom is visually fine;
1271
+ * one rendered at 8× is not.
1272
+ *
1273
+ * @param pxPerTick - Current zoom.
1274
+ * @returns The bucket, as an integer exponent.
1275
+ */
1276
+ declare function zoomBucket(pxPerTick: number): number;
1277
+ /**
1278
+ * Build a tile's cache key.
1279
+ *
1280
+ * Everything in it is source-space or style. **No screen coordinate appears.**
1281
+ *
1282
+ * @param parts - Source identity, source range, zoom bucket, height.
1283
+ * @returns The key.
1284
+ *
1285
+ * @example
1286
+ * ```ts
1287
+ * tileKey({ sourceId: 'take-3', startTick: 0, endTick: 4800, zoomBucket: -3, heightPx: 64 })
1288
+ * // → 'take-3|0|4800|-3|64'
1289
+ * ```
1290
+ */
1291
+ declare function tileKey(parts: {
1292
+ sourceId: string;
1293
+ startTick: number;
1294
+ endTick: number;
1295
+ zoomBucket: number;
1296
+ heightPx: number;
1297
+ }): string;
1298
+ /**
1299
+ * The source tick range a clip's visible portion reads from.
1300
+ *
1301
+ * A clip trimmed at the head reads from further into its source, so the tile grid must be anchored
1302
+ * to the **source**, not to the clip's timeline position — otherwise dragging the left handle makes
1303
+ * every thumbnail slide.
1304
+ *
1305
+ * @param clip - The clip.
1306
+ * @param fromTick - Visible timeline start.
1307
+ * @param toTick - Visible timeline end.
1308
+ * @returns The source range, or `null` when the clip has no source window.
1309
+ */
1310
+ declare function sourceRangeFor(clip: XenoClip, fromTick: number, toTick: number): {
1311
+ startTick: number;
1312
+ endTick: number;
1313
+ } | null;
1314
+ /**
1315
+ * Quantize a source range onto a tile grid anchored at the source origin.
1316
+ *
1317
+ * @param range - A source range.
1318
+ * @param tileTicks - Grid spacing.
1319
+ * @returns The aligned tiles covering it.
1320
+ */
1321
+ declare function alignToTileGrid(range: {
1322
+ startTick: number;
1323
+ endTick: number;
1324
+ }, tileTicks: number): {
1325
+ startTick: number;
1326
+ endTick: number;
1327
+ }[];
1328
+ /**
1329
+ * The nominal tile width in ticks at the current zoom, jittered.
1330
+ *
1331
+ * @param scale - The scale.
1332
+ * @param random - Injectable RNG.
1333
+ * @returns Tile width in ticks.
1334
+ */
1335
+ declare function tileWidthTicks(scale: XenoTimeScale, random?: () => number): number;
1336
+ /** A source-keyed, byte-budgeted, LRU tile cache. */
1337
+ declare class XenoTileCache {
1338
+ private readonly tiles;
1339
+ private readonly maxBytes;
1340
+ private readonly random;
1341
+ private clock;
1342
+ private bytes;
1343
+ /** Keys already asked for, so a request is never sent twice while one is in flight. */
1344
+ private readonly pending;
1345
+ constructor(options?: XenoTileCacheOptions);
1346
+ /** Bytes currently held. */
1347
+ get sizeBytes(): number;
1348
+ /** Tiles currently held. */
1349
+ get size(): number;
1350
+ /**
1351
+ * Find a usable tile for a request.
1352
+ *
1353
+ * 🔴 Satisfied by **containment**, not equality: a cached tile whose source range *covers* the
1354
+ * request, at the same zoom bucket and height, is a hit. That is what makes scrolling produce hits
1355
+ * instead of thrash, and it is why tiles are rendered wider than the viewport.
1356
+ *
1357
+ * @param request - Source identity, range, zoom bucket, height.
1358
+ * @returns A tile that covers it, or `null`.
1359
+ */
1360
+ find(request: {
1361
+ sourceId: string;
1362
+ startTick: number;
1363
+ endTick: number;
1364
+ zoomBucket: number;
1365
+ heightPx: number;
1366
+ }): XenoCachedTile | null;
1367
+ /**
1368
+ * Record a tile the host returned.
1369
+ *
1370
+ * @param tile - Everything but the LRU stamp.
1371
+ * @returns The stored tile.
1372
+ */
1373
+ put(tile: Omit<XenoCachedTile, 'usedAt' | 'bytes'> & {
1374
+ bytes?: number;
1375
+ }): XenoCachedTile;
1376
+ /** Has a request for this key already gone out? */
1377
+ isPending(key: string): boolean;
1378
+ /**
1379
+ * Mark a key as requested.
1380
+ *
1381
+ * Coalescing matters: without it, every frame of a scroll re-asks the host for the same tile, and
1382
+ * a decode queue that is already behind falls further behind.
1383
+ *
1384
+ * @param key - The tile key.
1385
+ * @returns `false` when a request was already outstanding.
1386
+ */
1387
+ markPending(key: string): boolean;
1388
+ /** Abandon an outstanding request — the tile scrolled out of view. */
1389
+ cancelPending(key: string): void;
1390
+ /** A tile width in ticks for the current zoom, jittered. */
1391
+ widthTicks(scale: XenoTimeScale): number;
1392
+ /** Drop everything for one source — it was edited, or its media changed. */
1393
+ invalidateSource(sourceId: string): number;
1394
+ /** Release everything, closing bitmaps. */
1395
+ clear(): void;
1396
+ /** Evict least-recently-used tiles until inside the budget. */
1397
+ private trim;
1398
+ /** Remove one tile and release its bitmap. */
1399
+ private evictOne;
1400
+ }
1401
+ /**
1402
+ * Blit a tile, defensively.
1403
+ *
1404
+ * 🔴 **Every blit is wrapped.** A closed `ImageBitmap` throws on `drawImage`, and inside a React
1405
+ * render that exception unmounts the subtree — the whole timeline disappears because one tile was
1406
+ * evicted a frame early. A failed tile must degrade to a blank strip, not to a blank panel.
1407
+ *
1408
+ * @param ctx - The 2D context.
1409
+ * @param tile - The tile.
1410
+ * @param x - Destination x.
1411
+ * @param y - Destination y.
1412
+ * @param width - Destination width.
1413
+ * @param height - Destination height.
1414
+ * @returns Whether it drew.
1415
+ */
1416
+ declare function blitTile(ctx: CanvasRenderingContext2D, tile: XenoCachedTile | null, x: number, y: number, width: number, height: number): boolean;
1417
+
1418
+ /**
1419
+ * The Timeline controller — framework-free, testable in Node.
1420
+ *
1421
+ * Owns the view state (scale, tool, selection, gesture) and **nothing else**. The arrangement is the
1422
+ * host's; the clock is the host's; the pixels in a lane are the host's. What this produces is
1423
+ * intents.
1424
+ *
1425
+ * 🔴 **One scroll source of truth.** Scroll and zoom live here, in `scale`, and are *emitted* on
1426
+ * `viewport`. The panel never reads a scroll position back from a store it does not own — the
1427
+ * reference implementation keeps a `scrollX` in its store that is written and never applied, and the
1428
+ * resulting drift class is the single most confusing thing about that timeline.
1429
+ *
1430
+ * @module
1431
+ */
1432
+
1433
+ /** The host seam. */
1434
+ interface TimelineHostBridge {
1435
+ emit(portId: string, value: unknown): void;
1436
+ now?: () => number;
1437
+ makeId?: () => string;
1438
+ }
1439
+ /** Construction options. */
1440
+ interface TimelineControllerOptions {
1441
+ host: TimelineHostBridge;
1442
+ tool?: XenoTimelineTool;
1443
+ snapMode?: XenoSnapMode;
1444
+ zoom?: XenoZoomBounds;
1445
+ /** Throttle for `opPreview` and `viewport`, ms. */
1446
+ throttleMs?: number;
1447
+ crossTrackDrag?: boolean;
1448
+ multiSelect?: boolean;
1449
+ marquee?: boolean;
1450
+ virtualize?: boolean;
1451
+ rulerPx?: number;
1452
+ gutterPx?: number;
1453
+ tileCache?: XenoTileCache;
1454
+ }
1455
+ /** What the view renders. */
1456
+ interface TimelineViewState {
1457
+ scale: XenoTimeScale;
1458
+ arrangement: XenoArrangement | null;
1459
+ timebase: XenoTimebase;
1460
+ tool: XenoTimelineTool;
1461
+ snap: XenoSnapConfig;
1462
+ selection: XenoTimelineSelection;
1463
+ /** Live gesture preview. The arrangement is untouched until the host answers. */
1464
+ preview: XenoDragPreview | null;
1465
+ /** Culled, virtualized rows. */
1466
+ visible: XenoVisibleTrack[];
1467
+ rows: XenoRowLayout;
1468
+ playheadTick: XenoTick;
1469
+ playing: boolean;
1470
+ durationTicks: number;
1471
+ }
1472
+ /** Persisted preferences. **Never content.** */
1473
+ interface TimelinePanelState {
1474
+ tool?: XenoTimelineTool;
1475
+ snapMode?: XenoSnapMode;
1476
+ pxPerTick?: number;
1477
+ scrollTick?: number;
1478
+ scrollTopPx?: number;
1479
+ }
1480
+ /** The Timeline panel controller. */
1481
+ declare class TimelineController {
1482
+ private readonly host;
1483
+ private readonly listeners;
1484
+ private readonly now;
1485
+ private readonly zoomBounds;
1486
+ private readonly crossTrackDrag;
1487
+ private readonly multiSelect;
1488
+ private readonly marqueeEnabled;
1489
+ private readonly virtualize;
1490
+ private readonly rulerPx;
1491
+ private scale;
1492
+ private arrangement;
1493
+ private clock;
1494
+ private tool;
1495
+ private snapMode;
1496
+ private selection;
1497
+ /**
1498
+ * The live gesture.
1499
+ *
1500
+ * 🔴 A field on the controller, **not a closure local in an event handler**. The reference
1501
+ * implementation keeps drag bookkeeping in variables the move handler closes over, and loses the
1502
+ * commit whenever a re-render swaps the closure.
1503
+ */
1504
+ private drag;
1505
+ private preview;
1506
+ private readonly tiles;
1507
+ private readonly emitPreview;
1508
+ private readonly emitViewport;
1509
+ private snapshot;
1510
+ private cachedVisible;
1511
+ constructor(options: TimelineControllerOptions);
1512
+ subscribe: (listener: () => void) => (() => void);
1513
+ getState: () => TimelineViewState;
1514
+ private notify;
1515
+ /**
1516
+ * Install a new arrangement.
1517
+ *
1518
+ * The re-walk is gated on `rev`: an identical revision is a no-op, so a host that re-pushes on
1519
+ * every animation frame costs nothing.
1520
+ *
1521
+ * @param arrangement - The host's arrangement.
1522
+ * @returns Whether anything changed.
1523
+ */
1524
+ setArrangement(arrangement: XenoArrangement): boolean;
1525
+ /**
1526
+ * Install a playhead clock push.
1527
+ *
1528
+ * A malformed push is **ignored**, not installed: the last good clock keeps extrapolating, which
1529
+ * is far better than a playhead that jumps to zero because one message was wrong.
1530
+ */
1531
+ setClock(clock: XenoPlayheadClock): void;
1532
+ /** Install an externally-driven selection. */
1533
+ setSelection(selection: XenoTimelineSelection, emit?: boolean): void;
1534
+ /** Record a tile the host returned. */
1535
+ setLaneTile(tile: XenoLaneTile): void;
1536
+ /** The tile cache, for the renderer. */
1537
+ get tileCache(): XenoTileCache;
1538
+ /** Tell the controller how big it is. */
1539
+ resize(widthPx: number, heightPx: number, gutterPx?: number): void;
1540
+ /** The current scale. */
1541
+ get timeScale(): XenoTimeScale;
1542
+ /**
1543
+ * Zoom about a pointer.
1544
+ *
1545
+ * @param x - Container-relative pointer x.
1546
+ * @param factor - Zoom multiplier.
1547
+ */
1548
+ zoomAt(x: number, factor: number): void;
1549
+ /**
1550
+ * Handle a wheel event.
1551
+ *
1552
+ * @param event - Deltas, modifiers, and the container-relative x.
1553
+ */
1554
+ wheel(event: {
1555
+ deltaX?: number;
1556
+ deltaY?: number;
1557
+ x: number;
1558
+ zoom: boolean;
1559
+ }): void;
1560
+ /** Scroll by a tick delta and a pixel delta. */
1561
+ scrollBy(deltaTicks: number, deltaTopPx: number): void;
1562
+ /** Scroll to an absolute position. The ONE place scroll is written. */
1563
+ scrollTo(scrollTick: number, scrollTopPx: number): void;
1564
+ /** Fit the whole arrangement, or a range, into the lane. */
1565
+ zoomToFitRange(fromTick?: number, toTick?: number): void;
1566
+ /** Change the active tool. Ignored mid-gesture — the mode is frozen at mousedown. */
1567
+ setTool(tool: XenoTimelineTool): void;
1568
+ /** Change snapping. */
1569
+ setSnapMode(mode: XenoSnapMode): void;
1570
+ /** The snap config for the current zoom, with the visible grid. */
1571
+ private snapConfig;
1572
+ /**
1573
+ * Begin a gesture.
1574
+ *
1575
+ * @param options - What was hit and where.
1576
+ * @returns Whether a gesture started.
1577
+ */
1578
+ pointerDown(options: Omit<BeginDragOptions, 'tool' | 'clips'> & {
1579
+ clips?: readonly XenoClip[];
1580
+ }): boolean;
1581
+ /**
1582
+ * Update the live gesture.
1583
+ *
1584
+ * @param x - Container-relative pointer x.
1585
+ * @param y - Container-relative pointer y.
1586
+ * @param modifiers - Modifiers **as of now** — the snap invert is re-read every move.
1587
+ */
1588
+ pointerMove(x: number, y: number, modifiers?: {
1589
+ snapInvert?: boolean;
1590
+ constrain?: boolean;
1591
+ }): void;
1592
+ /**
1593
+ * Finish the gesture, emitting **at most one** op.
1594
+ *
1595
+ * @returns The op that was emitted, or `null`.
1596
+ */
1597
+ pointerUp(): XenoTimelineOp | null;
1598
+ /** Abandon the gesture. Nothing was mutated, so there is nothing to roll back. */
1599
+ cancelDrag(): void;
1600
+ /** Is a gesture live? */
1601
+ get dragging(): boolean;
1602
+ /** Split the selection, or every clip, at a tick. */
1603
+ split(atTick: XenoTick, all?: boolean): XenoTimelineOp | null;
1604
+ /** Delete the selection. */
1605
+ deleteSelection(ripple?: boolean): XenoTimelineOp | null;
1606
+ /** Emit any op. The single door for the view's buttons. */
1607
+ dispatchOp(op: XenoTimelineOp): void;
1608
+ private applySelection;
1609
+ /** Select a clip, or add it to the selection. */
1610
+ selectClip(clipId: string, additive?: boolean): void;
1611
+ /** Clear the selection. */
1612
+ clearSelection(): void;
1613
+ /**
1614
+ * Move the selection to an adjacent clip.
1615
+ *
1616
+ * Keyboard clip and track navigation, which none of the surveyed products has — and which is what
1617
+ * makes the panel usable without a mouse at all.
1618
+ *
1619
+ * @param direction - Where to go.
1620
+ * @returns The newly selected clip, or `null`.
1621
+ */
1622
+ navigate(direction: 'next' | 'previous' | 'up' | 'down'): XenoClip | null;
1623
+ /** Scroll the minimum distance to bring a clip on screen. */
1624
+ revealClip(clip: XenoClip): void;
1625
+ /**
1626
+ * The playhead right now.
1627
+ *
1628
+ * Dead-reckoned from the last host push, and **clamped against the LIVE duration every read** —
1629
+ * an arrangement that shrinks while playing would otherwise leave the playhead extrapolating past
1630
+ * the end of a document that no longer goes that far.
1631
+ */
1632
+ playheadTick(): XenoTick;
1633
+ private clampToDuration;
1634
+ /** The duration to clamp against; an unset duration must not clamp everything to zero. */
1635
+ private durationOrMax;
1636
+ /**
1637
+ * Ask the host for any lane tiles the visible clips need.
1638
+ *
1639
+ * Requests are coalesced by key, so a scroll does not re-ask for a tile already in flight.
1640
+ *
1641
+ * @returns The requests emitted.
1642
+ */
1643
+ requestTiles(): XenoLaneTileRequest[];
1644
+ private publishViewport;
1645
+ /**
1646
+ * The visible set, recomputed only when something that affects it moved.
1647
+ *
1648
+ * Computing it once per read rather than once per access is what keeps a render from doing the
1649
+ * culling walk several times for the same frame.
1650
+ */
1651
+ private computeVisibleCached;
1652
+ /** The clips a gesture on `clipId` should move. */
1653
+ private gestureClips;
1654
+ /** Every edge a gesture moves, for delta snapping. */
1655
+ private gestureEdges;
1656
+ /** Snap a bare tick, for the scrub. */
1657
+ private snapAbsolute;
1658
+ /** Preferences only — never the arrangement, never the selection, never a tile. */
1659
+ serialize(): TimelinePanelState;
1660
+ /** Restore preferences. */
1661
+ deserialize(state: Partial<TimelinePanelState>): void;
1662
+ /** Release timers, listeners and every cached bitmap. */
1663
+ dispose(): void;
1664
+ }
1665
+
1666
+ /**
1667
+ * The `PanelModule` — what a host registers and mounts.
1668
+ *
1669
+ * Every line marked 🔴 is a shipped bug's fix. See `packages/panel-template/README.md`.
1670
+ *
1671
+ * @module
1672
+ */
1673
+
1674
+ /** Everything a renderer needs. */
1675
+ interface TimelineRenderContext {
1676
+ controller: TimelineController;
1677
+ config: TimelineRenderConfig;
1678
+ }
1679
+ /** Resolved, observed config. */
1680
+ interface TimelineRenderConfig {
1681
+ trackHeightPx: number;
1682
+ virtualize: boolean;
1683
+ }
1684
+ /** Options for {@link createTimelinePanel}. */
1685
+ interface CreateTimelinePanelOptions {
1686
+ /** Override the renderer. Rarely needed. */
1687
+ render?: (root: HTMLElement, context: TimelineRenderContext) => () => void;
1688
+ }
1689
+ /**
1690
+ * Build the Timeline panel module.
1691
+ *
1692
+ * @param options - Optional renderer override.
1693
+ * @returns The module.
1694
+ */
1695
+ declare function createTimelinePanel(options?: CreateTimelinePanelOptions): PanelModule;
1696
+ /** The default Timeline panel module — view already wired. Register THIS. */
1697
+ declare const timelinePanel: PanelModule;
1698
+
1699
+ /**
1700
+ * The `xeno.core.timeline` manifest.
1701
+ *
1702
+ * **`storage.local` and nothing else.** A timeline is the panel most likely to be handed a
1703
+ * capability it does not need — it draws waveforms and filmstrips, so `fs.read`, `gpu` and
1704
+ * `lib.process` all look reasonable. None of them is. Lane pixels arrive through the tile
1705
+ * round-trip, which is the Layers thumbnail round-trip generalized: *pixel access stays host-side.*
1706
+ *
1707
+ * @module
1708
+ */
1709
+
1710
+ /** The canonical manifest id. */
1711
+ declare const TIMELINE_PANEL_ID = "xeno.core.timeline";
1712
+ /** The `xeno.core.timeline` manifest. */
1713
+ declare const timelineManifest: PanelManifest;
1714
+
1715
+ /**
1716
+ * The ruler — a mixed-radix tick ladder, not a computed "nice numbers" step.
1717
+ *
1718
+ * ## Why not 1-2-5
1719
+ *
1720
+ * The classic nice-numbers algorithm (Heckbert, and d3's `ticks` after it) picks steps from
1721
+ * 1·2·5 × 10ⁿ. That is right for a linear quantity and **wrong for time**, which is mixed-radix:
1722
+ * 1000 ms/s, 60 s/min, 60 min/h, 24 h/day, plus 24/25/30/50/60 frames per second and 3/4/5/7 beats
1723
+ * per bar. A 1-2-5 ladder produces a ruler marked every 20 seconds and every 50 minutes.
1724
+ *
1725
+ * Every shipping timeline surveyed uses a **hard-coded ladder** instead. So does this.
1726
+ *
1727
+ * ## Two details that are easy to get wrong
1728
+ *
1729
+ * - **Classify ticks by index, not by time modulo.** Deciding "is this a major tick?" with
1730
+ * `tick % majorStep === 0` drifts and drops ticks as soon as the step is not an exact divisor of
1731
+ * the origin. Counting position within a repeating pattern cannot drift.
1732
+ * - **Start from a quantized floor**, so the first tick is on the ladder rather than wherever the
1733
+ * viewport happens to begin.
1734
+ *
1735
+ * Bars and beats are handled here as **formatting**: tick geometry stays linear wall-clock, so zoom
1736
+ * remains a single scalar and hit-testing never inverts a tempo curve.
1737
+ *
1738
+ * @module
1739
+ */
1740
+
1741
+ /** How prominent a tick is. */
1742
+ type XenoTickWeight = 'major' | 'medium' | 'minor';
1743
+ /** One ruler tick. */
1744
+ interface XenoRulerTick {
1745
+ tick: XenoTick;
1746
+ x: number;
1747
+ weight: XenoTickWeight;
1748
+ /** Present on majors only — labelling every tick is unreadable and costs a shaping pass each. */
1749
+ label?: string;
1750
+ }
1751
+ /**
1752
+ * Minimum pixels between **labelled** ticks.
1753
+ *
1754
+ * The surveyed products land between 70 and 120 px, all of them describing it as an approximation of
1755
+ * how wide the label text is. Timecode (`00:00:00:00`) is the widest thing this ruler draws, so this
1756
+ * sits at the top of that band.
1757
+ */
1758
+ declare const MIN_MAJOR_PX = 120;
1759
+ /** Minimum pixels between unlabelled minors. */
1760
+ declare const MIN_MINOR_PX = 8;
1761
+ /**
1762
+ * Choose the ladder rung for the current zoom.
1763
+ *
1764
+ * @param scale - The scale.
1765
+ * @param timebase - For frame and tempo awareness.
1766
+ * @returns Major step in ticks, its subdivision count, and which subdivision is medium.
1767
+ */
1768
+ declare function chooseRung(scale: XenoTimeScale, timebase: XenoTimebase): {
1769
+ majorTicks: number;
1770
+ subdivisions: number;
1771
+ medium: number;
1772
+ };
1773
+ /**
1774
+ * Generate the visible ruler ticks.
1775
+ *
1776
+ * @param scale - The scale.
1777
+ * @param timebase - For the ladder and the labels.
1778
+ * @param options - Label override.
1779
+ * @returns Ticks, left to right.
1780
+ *
1781
+ * @example
1782
+ * ```ts
1783
+ * for (const t of rulerTicks(scale, timebase)) {
1784
+ * ctx.fillRect(Math.floor(t.x), 0, 1, t.weight === 'major' ? 10 : 5)
1785
+ * }
1786
+ * ```
1787
+ */
1788
+ declare function rulerTicks(scale: XenoTimeScale, timebase: XenoTimebase, options?: {
1789
+ label?: (tick: XenoTick) => string;
1790
+ }): XenoRulerTick[];
1791
+ /**
1792
+ * The grid spacing snapping should use at this zoom.
1793
+ *
1794
+ * Derived from the same ladder the ruler draws, so the grid a user snaps to is **the grid they can
1795
+ * see**. A 1/128 grid at a zoom showing whole bars is a grid nobody can aim at, and snapping to it
1796
+ * feels random.
1797
+ *
1798
+ * @param scale - The scale.
1799
+ * @param timebase - The timebase.
1800
+ * @returns Grid spacing in ticks.
1801
+ */
1802
+ declare function gridTicksFor(scale: XenoTimeScale, timebase: XenoTimebase): number;
1803
+
1804
+ /**
1805
+ * The Timeline view.
1806
+ *
1807
+ * Composes `@xenosystem/workbench/primitives`. **The host must import
1808
+ * `@xenosystem/workbench/primitives.css`** or this renders unstyled with no error — the most common
1809
+ * integration mistake, and the hardest to diagnose because nothing fails.
1810
+ *
1811
+ * Monochromatic per `DESIGN_SYSTEM.md`: rectangles with a corner radius, no circles, compact
1812
+ * chrome, no colour except what the host declares on a clip.
1813
+ *
1814
+ * @module
1815
+ */
1816
+
1817
+ /** Props for {@link TimelinePanelView}. */
1818
+ interface TimelinePanelViewProps {
1819
+ controller: TimelineController;
1820
+ trackHeightPx?: number;
1821
+ virtualize?: boolean;
1822
+ }
1823
+ /** The Timeline panel view. */
1824
+ declare function TimelinePanelView({ controller }: TimelinePanelViewProps): ReactNode;
1825
+
1826
+ /**
1827
+ * The `xeno.core.calendar` contract.
1828
+ *
1829
+ * ## Why this is a panel and not a table view mode
1830
+ *
1831
+ * **The visible window is an OUTPUT that drives the query.** For table, board and gallery the
1832
+ * visible set is a *projection of an already-fetched result*; for calendar the window **is an input
1833
+ * to the fetch** — month navigation derives `from`/`to` and those become the fetch key. That single
1834
+ * port is the whole reason this is a sibling panel rather than a mode.
1835
+ *
1836
+ * ## 🔴 Landmine §6.7 — two wrong answers in shipped code
1837
+ *
1838
+ * - notes matches a day with `value === 'YYYY-MM-DD'`, so **any datetime, or any value with an
1839
+ * offset suffix, silently never renders at all**.
1840
+ * - post derives a day with `toISOString().slice(0, 10)`, which is the day **in UTC** — so a
1841
+ * 23:00-local event lands on the wrong day, invisibly, for everyone not at `UTC+0`.
1842
+ *
1843
+ * Both are structurally impossible here: `date` and `datetime` are different types, bucketing goes
1844
+ * through `@xenosystem/data-core`'s zone-aware `bucketKey`, and `timeZone` is **mandatory** on the window.
1845
+ *
1846
+ * @module
1847
+ */
1848
+
1849
+ /** How the time axis is laid out. */
1850
+ type XenoCalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'timeline';
1851
+ /** Every mode v1 implements. */
1852
+ declare const ALL_CALENDAR_MODES: readonly XenoCalendarViewMode[];
1853
+ /**
1854
+ * The visible window — **the output that makes this a panel**.
1855
+ *
1856
+ * `timeZone` is mandatory, exactly as on `XenoWindow`. It is not defaulted anywhere, because a
1857
+ * default is how both shipped bugs got in.
1858
+ */
1859
+ interface XenoCalendarWindow {
1860
+ /** Inclusive start, ISO 8601. */
1861
+ from: string;
1862
+ /** Exclusive end, ISO 8601. */
1863
+ to: string;
1864
+ /** The time column the window applies to. */
1865
+ columnId: string;
1866
+ /** IANA zone. **Mandatory.** */
1867
+ timeZone: string;
1868
+ }
1869
+ /** One event, derived from a row. */
1870
+ interface XenoCalendarEvent {
1871
+ /** The row id — identity comes from the result, never from a position. */
1872
+ id: string;
1873
+ /** Display title. */
1874
+ title: string;
1875
+ /** Start bucket key (`'2026-08-15'` for a day-grained view). */
1876
+ startKey: string;
1877
+ /** End bucket key. Equal to `startKey` for a single-day event. */
1878
+ endKey: string;
1879
+ /** Minutes from midnight, for timed events in week/day/timeline modes. `null` for all-day. */
1880
+ startMinutes: number | null;
1881
+ /** Minutes from midnight for the end. */
1882
+ endMinutes: number | null;
1883
+ /** The event spans more than one day. */
1884
+ multiDay: boolean;
1885
+ /** All-day (the source column is a `date`, or no time is present). */
1886
+ allDay: boolean;
1887
+ /** Pre-formatted extra fields for the chip. */
1888
+ fields?: Record<string, string>;
1889
+ /** Status value, for tone. */
1890
+ status?: XenoValue;
1891
+ /** The source row, for `rowActivate` and drag intents. */
1892
+ row: XenoRow;
1893
+ }
1894
+ /** A day cell in month/week view. */
1895
+ interface XenoCalendarCell {
1896
+ /** The day key. */
1897
+ key: string;
1898
+ /** Day of month. */
1899
+ day: number;
1900
+ /** The cell belongs to the displayed period (not a leading/trailing filler day). */
1901
+ inPeriod: boolean;
1902
+ /** Today, in the declared zone. */
1903
+ today: boolean;
1904
+ /** Events starting or spanning this day, after overlap packing. */
1905
+ events: XenoCalendarEvent[];
1906
+ /** How many events were hidden by `maxChipsPerCell`. */
1907
+ overflow: number;
1908
+ }
1909
+ /** A drag-to-reschedule intent. The host writes. */
1910
+ interface XenoCalendarEdit {
1911
+ rowId: string;
1912
+ columnId: string;
1913
+ /** The new start, ISO 8601 in the declared zone. */
1914
+ value: string;
1915
+ /** The previous value, so a host can build an undo entry without re-reading. */
1916
+ previousValue: XenoValue;
1917
+ /** Set when the event has an end column that moved with it. */
1918
+ endColumnId?: string;
1919
+ endValue?: string;
1920
+ /** One gesture, one undo entry. */
1921
+ gestureId: string;
1922
+ }
1923
+ /** Clicking an empty slot — the create affordance. */
1924
+ interface XenoCalendarSlotActivate {
1925
+ /** The day key. */
1926
+ date: string;
1927
+ /** Minutes from midnight, in a timed view. */
1928
+ time?: number;
1929
+ }
1930
+ /** The panel's serialized state. */
1931
+ interface CalendarPanelState {
1932
+ viewMode: XenoCalendarViewMode;
1933
+ /** The anchor date the view is centred on, as a day key. */
1934
+ anchor?: string;
1935
+ selection: string[];
1936
+ }
1937
+ /** What the controller exposes to its view. */
1938
+ interface CalendarViewState {
1939
+ viewMode: XenoCalendarViewMode;
1940
+ /** The current window — also what was last emitted. */
1941
+ window: XenoCalendarWindow | null;
1942
+ /** Day cells, for month/week/day. */
1943
+ cells: XenoCalendarCell[];
1944
+ /** Every event in the window, in start order — the agenda and timeline source. */
1945
+ events: XenoCalendarEvent[];
1946
+ /** Selected row ids. */
1947
+ selection: string[];
1948
+ /** No result, or no events in the window. */
1949
+ empty: boolean;
1950
+ /** The result was `partial`. */
1951
+ partial: boolean;
1952
+ /** A typed error from the result. */
1953
+ errorMessage: string | null;
1954
+ /** Set when the configured date column is missing or untyped. */
1955
+ configProblem: string | null;
1956
+ }
1957
+ /** Days in a month, 1-indexed month. */
1958
+ declare function daysInMonth(year: number, month: number): number;
1959
+ /** A day key from calendar parts. */
1960
+ declare function dayKey(year: number, month: number, day: number): string;
1961
+ /** Parse a day key back into parts. Returns `null` for anything malformed. */
1962
+ declare function parseDayKey(key: string): {
1963
+ year: number;
1964
+ month: number;
1965
+ day: number;
1966
+ } | null;
1967
+ /**
1968
+ * ISO day-of-week for a day key, `1` = Monday … `7` = Sunday.
1969
+ *
1970
+ * Computed from the key's own calendar parts, never from a local `Date` — reading a day key through
1971
+ * the runtime's zone is how a grid shifts by one column for half the world.
1972
+ */
1973
+ declare function isoWeekday(key: string): number;
1974
+ /** Add days to a day key, staying in pure calendar arithmetic. */
1975
+ declare function addDays(key: string, days: number): string;
1976
+ /** Add months to a day key, CLAMPING the day (31 Mar − 1 month is 28 Feb, not 3 Mar). */
1977
+ declare function addMonths(key: string, months: number): string;
1978
+ /**
1979
+ * The first cell of a month grid, honouring `firstDayOfWeek`.
1980
+ *
1981
+ * **Config, not a constant**: post is Monday-first, notes is Sunday-first. A calendar that assumes
1982
+ * either is wrong for half its users.
1983
+ */
1984
+ declare function gridStart(monthKey: string, firstDayOfWeek: number): string;
1985
+ /** The tone a status value maps to. Presentation only. */
1986
+ declare function statusTone(value: XenoValue): 'neutral' | 'success' | 'warning' | 'error';
1987
+
1988
+ /**
1989
+ * The Calendar controller.
1990
+ *
1991
+ * **The window is an OUTPUT.** Navigating a month derives `from`/`to` and emits both `window` and a
1992
+ * `queryRequest` — the visible range *is* the fetch key. That is what makes this a panel rather than
1993
+ * a table view mode.
1994
+ *
1995
+ * @module
1996
+ */
1997
+
1998
+ /** The host seam. */
1999
+ interface CalendarHostBridge {
2000
+ emit(portId: string, value: unknown): void;
2001
+ makeId?: () => string;
2002
+ now?: () => number;
2003
+ }
2004
+ /** Construction options. */
2005
+ interface CalendarControllerOptions {
2006
+ host: CalendarHostBridge;
2007
+ initial?: Partial<CalendarPanelState>;
2008
+ /** The start column. Without it the panel reports a config problem rather than rendering blank. */
2009
+ dateColumnId?: string;
2010
+ endDateColumnId?: string;
2011
+ /**
2012
+ * IANA zone, or `'local'` to resolve the runtime's.
2013
+ *
2014
+ * Explicit by design: the two shipped calendar bugs are both a zone nobody declared.
2015
+ */
2016
+ timeZone?: string;
2017
+ viewMode?: XenoCalendarViewMode;
2018
+ /** `1` = Monday (post), `7` = Sunday (notes). CONFIG — a calendar that assumes either is wrong. */
2019
+ firstDayOfWeek?: number;
2020
+ maxChipsPerCell?: number;
2021
+ chipFields?: string[];
2022
+ titleColumnId?: string;
2023
+ statusColumnId?: string;
2024
+ slotMinutes?: number;
2025
+ allowDrag?: boolean;
2026
+ }
2027
+ /** The Calendar panel controller. */
2028
+ declare class CalendarController {
2029
+ private readonly host;
2030
+ private readonly makeId;
2031
+ private readonly now;
2032
+ private readonly listeners;
2033
+ private readonly firstDayOfWeek;
2034
+ private readonly maxChipsPerCell;
2035
+ private readonly allowDrag;
2036
+ private readonly derive;
2037
+ private result;
2038
+ private viewMode;
2039
+ private anchor;
2040
+ private selection;
2041
+ private window;
2042
+ private snapshot;
2043
+ constructor(options: CalendarControllerOptions);
2044
+ subscribe: (listener: () => void) => (() => void);
2045
+ getState: () => CalendarViewState;
2046
+ private notify;
2047
+ /** Why the panel cannot render, as a sentence. A blank grid explains nothing. */
2048
+ private configProblem;
2049
+ /** Receive a result. */
2050
+ setResult(result: XenoResultSet): void;
2051
+ /** Receive an externally set window (a host driving navigation). Does not re-emit. */
2052
+ setWindow(window: XenoCalendarWindow): void;
2053
+ /** Receive a host-driven selection. */
2054
+ setSelection(rowIds: readonly string[]): void;
2055
+ /** Events derived from the current result. */
2056
+ events(): XenoCalendarEvent[];
2057
+ /** The visible day cells for the current mode. */
2058
+ cells(events?: XenoCalendarEvent[]): XenoCalendarCell[];
2059
+ /** Grid geometry for the current mode. `null` for agenda/timeline, which are not grids. */
2060
+ private geometry;
2061
+ /**
2062
+ * The window the current view covers.
2063
+ *
2064
+ * Agenda and timeline follow the month, so navigation stays meaningful in every mode.
2065
+ */
2066
+ computeWindow(): XenoCalendarWindow | null;
2067
+ /**
2068
+ * Emit the window and the matching query.
2069
+ *
2070
+ * **This is the port that makes calendar a panel**: the visible range is the fetch key, so moving
2071
+ * a month is a re-fetch, not a re-projection.
2072
+ */
2073
+ emitWindow(force?: boolean): XenoCalendarWindow | null;
2074
+ /** Switch view mode and re-emit the window. */
2075
+ setViewMode(mode: XenoCalendarViewMode): boolean;
2076
+ /** Step forward or back by one period. */
2077
+ navigate(direction: 1 | -1): void;
2078
+ /** Jump to today, in the DECLARED zone — never the runtime's, unless that is what was declared. */
2079
+ goToToday(): void;
2080
+ /** Jump to a specific day. */
2081
+ goToDate(key: string): boolean;
2082
+ /** The anchor day key. */
2083
+ anchorKey(): string;
2084
+ /** Select events. */
2085
+ select(rowIds: readonly string[], additive?: boolean): void;
2086
+ /** Open an event. */
2087
+ activateRow(rowId: string): boolean;
2088
+ /** Click an empty slot — the create affordance. */
2089
+ activateSlot(date: string, time?: number): boolean;
2090
+ /**
2091
+ * Drag-to-reschedule — an INTENT. The host writes and republishes.
2092
+ *
2093
+ * The new value carries the target day and, for a timed event, the original clock time: dropping
2094
+ * an event on a day should move the day, not silently reset it to midnight.
2095
+ *
2096
+ * @returns `true` when an intent was emitted.
2097
+ */
2098
+ rescheduleTo(rowId: string, targetDate: string, targetMinutes?: number): boolean;
2099
+ /** Ask the host to open a context menu. */
2100
+ requestContextMenu(rowIds: readonly string[], x: number, y: number): void;
2101
+ /** Serialize. View mode, anchor and selection — never a result, never events. */
2102
+ serialize(): CalendarPanelState;
2103
+ /** Restore. Does not emit. */
2104
+ deserialize(state: Partial<CalendarPanelState>): void;
2105
+ /** The resolved zone — exposed so a host can render it. */
2106
+ timeZone(): string;
2107
+ /** Drop listeners. */
2108
+ dispose(): void;
2109
+ }
2110
+
2111
+ /**
2112
+ * The `PanelModule` — view wired inside the package (one React copy).
2113
+ *
2114
+ * @module
2115
+ */
2116
+
2117
+ /** Everything a renderer needs: the controller plus the resolved config. */
2118
+ interface CalendarRenderContext {
2119
+ controller: CalendarController;
2120
+ config: {
2121
+ firstDayOfWeek: number;
2122
+ emptyHint?: string;
2123
+ };
2124
+ }
2125
+ /** Options for {@link createCalendarPanel}. */
2126
+ interface CreateCalendarPanelOptions {
2127
+ /** Override the view. Rarely needed — mounting inside the package keeps React singular. */
2128
+ render?: (root: HTMLElement, context: CalendarRenderContext) => () => void;
2129
+ }
2130
+ /**
2131
+ * Build the Calendar panel module.
2132
+ *
2133
+ * @param options - Optional renderer override.
2134
+ * @returns The module.
2135
+ */
2136
+ declare function createCalendarPanel(options?: CreateCalendarPanelOptions): PanelModule;
2137
+ /** The default Calendar panel module — view already wired. Register THIS. */
2138
+ declare const calendarPanel: PanelModule;
2139
+
2140
+ /**
2141
+ * The `xeno.core.calendar` manifest.
2142
+ *
2143
+ * **`window` is an OUTPUT.** That port is why this is a panel and not a table view mode: the visible
2144
+ * range drives the query rather than projecting an already-fetched result.
2145
+ *
2146
+ * @module
2147
+ */
2148
+
2149
+ /** The canonical manifest id. */
2150
+ declare const CALENDAR_PANEL_ID = "xeno.core.calendar";
2151
+ /** The `xeno.core.calendar` manifest. */
2152
+ declare const calendarManifest: PanelManifest;
2153
+
2154
+ /**
2155
+ * Event derivation and calendar layout — the calendar-only work.
2156
+ *
2157
+ * ## 🔴 The two shipped wrong answers this module refuses
2158
+ *
2159
+ * **notes** matches a day with `value === 'YYYY-MM-DD'`, so any datetime — or any value carrying an
2160
+ * offset — silently never renders. **post** derives a day with `toISOString().slice(0, 10)`, which
2161
+ * is the day in UTC, so a 23:00-local event lands on the wrong day for everyone not at `UTC+0`.
2162
+ *
2163
+ * Both are impossible here because bucketing goes through `@xenosystem/data-core`'s `parseTime` +
2164
+ * `bucketKey`, which parse every ISO form and apply the zone **only to instants** — a `date` column
2165
+ * is a calendar day everywhere and is never shifted.
2166
+ *
2167
+ * The rest — multi-day spans and overlap packing — is genuinely calendar-only.
2168
+ *
2169
+ * @module
2170
+ */
2171
+
2172
+ /** Options for {@link deriveEvents}. */
2173
+ interface DeriveOptions {
2174
+ /** The start column. Required. */
2175
+ dateColumnId: string;
2176
+ /** The end column, for spans. */
2177
+ endDateColumnId?: string;
2178
+ /** IANA zone. **Mandatory** — a default is how both shipped bugs got in. */
2179
+ timeZone: string;
2180
+ /** Column supplying the chip title. Defaults to the first `identifier`/`string` column. */
2181
+ titleColumnId?: string;
2182
+ /** Extra columns to render on the chip. */
2183
+ chipFields?: string[];
2184
+ /** Column supplying status tone. */
2185
+ statusColumnId?: string;
2186
+ }
2187
+ /**
2188
+ * Derive events from a result.
2189
+ *
2190
+ * @param result - The result set.
2191
+ * @param options - Column mapping + the mandatory zone.
2192
+ * @returns Events in start order. A row whose date cannot be parsed is SKIPPED, not defaulted —
2193
+ * placing an unparseable row on today is worse than omitting it, because it looks correct.
2194
+ */
2195
+ declare function deriveEvents(result: XenoResultSet, options: DeriveOptions): XenoCalendarEvent[];
2196
+ /** Options for {@link buildCells}. */
2197
+ interface BuildCellsOptions {
2198
+ /** First day key of the grid. */
2199
+ start: string;
2200
+ /** How many days the grid covers. */
2201
+ days: number;
2202
+ /** The month the grid is "about", for the in-period flag. `null` in week/day views. */
2203
+ periodMonth?: string | null;
2204
+ /** Today's key, in the declared zone. */
2205
+ today?: string;
2206
+ /** Chips rendered before an overflow indicator appears. */
2207
+ maxChipsPerCell?: number;
2208
+ }
2209
+ /**
2210
+ * Lay events into day cells.
2211
+ *
2212
+ * A **multi-day event appears in every day it covers** — that is the whole point of a span, and it
2213
+ * is why a naive `group by startKey` is wrong. Each cell then caps its chips and reports the
2214
+ * remainder rather than silently truncating.
2215
+ *
2216
+ * @param events - Derived events.
2217
+ * @param options - Grid geometry.
2218
+ * @returns The cells, in grid order.
2219
+ */
2220
+ declare function buildCells(events: readonly XenoCalendarEvent[], options: BuildCellsOptions): XenoCalendarCell[];
2221
+ /**
2222
+ * Pack overlapping timed events into columns, for week/day views.
2223
+ *
2224
+ * Two events that overlap in time share a row's width; the packer assigns each the lowest column
2225
+ * index not already taken by something it overlaps. Without it, overlapping events draw on top of
2226
+ * each other and the lower one is invisible.
2227
+ *
2228
+ * @param events - Events for ONE day, in start order.
2229
+ * @returns Each event with its column index and the total column count for that cluster.
2230
+ */
2231
+ declare function packOverlaps(events: readonly XenoCalendarEvent[]): {
2232
+ event: XenoCalendarEvent;
2233
+ column: number;
2234
+ columns: number;
2235
+ }[];
2236
+ /** Today's day key in a zone — never `new Date().toISOString().slice(0,10)`, which is UTC. */
2237
+ declare function todayKey(timeZone: string, now?: number): string;
2238
+ /** The week key range a day belongs to, honouring `firstDayOfWeek`. */
2239
+ declare function weekStart(key: string, firstDayOfWeek: number): string;
2240
+ /** Whether a row would be visible in a window — exported so a host can pre-filter. */
2241
+ declare function rowInWindow(row: XenoRow, columnId: string, column: XenoColumn | undefined, from: string, to: string): boolean;
2242
+
2243
+ /**
2244
+ * The Calendar panel view.
2245
+ *
2246
+ * Composes `@xenosystem/workbench/primitives`; the host must ensure `@xenosystem/workbench/primitives.css` is
2247
+ * present.
2248
+ *
2249
+ * @module
2250
+ */
2251
+
2252
+ /** Props for {@link CalendarPanelView}. */
2253
+ interface CalendarPanelViewProps {
2254
+ controller: CalendarController;
2255
+ firstDayOfWeek?: number;
2256
+ emptyHint?: string;
2257
+ }
2258
+ /** The Calendar panel view. */
2259
+ declare function CalendarPanelView({ controller, firstDayOfWeek, emptyHint, }: CalendarPanelViewProps): ReactNode;
2260
+
2261
+ export { ALL_CALENDAR_MODES, type BeginDragOptions, type BuildCellsOptions, CALENDAR_PANEL_ID, CalendarController, type CalendarControllerOptions, type CalendarHostBridge, type CalendarPanelState, CalendarPanelView, type CalendarPanelViewProps, type CalendarRenderContext, type CalendarViewState, type CreateCalendarPanelOptions, type CreateTimelinePanelOptions, type CreateTransportPanelOptions, DEFAULT_SNAP, DEFAULT_ZOOM, DRAG_THRESHOLD_PX, type DeriveOptions, EDGE_ZONE_MIN_CLIP_PX, EDGE_ZONE_PX, MIN_MAJOR_PX, MIN_MINOR_PX, TIMELINE_PANEL_ID, TRIM_SNAP_PX, TimelineController, type TimelineControllerOptions, type TimelineHostBridge, type TimelinePanelState, TimelinePanelView, type TimelinePanelViewProps, type TimelineRenderConfig, type TimelineRenderContext, type TimelineViewState, type TransportKeyEvent, TransportPanelController, type TransportPanelControllerOptions, type TransportPanelSerialized, TransportPanelView, type TransportPanelViewProps, type XenoArrangement, type XenoCachedTile, type XenoCalendarCell, type XenoCalendarEdit, type XenoCalendarEvent, type XenoCalendarSlotActivate, type XenoCalendarViewMode, type XenoCalendarWindow, type XenoClip, type XenoClipFade, type XenoDragKind, type XenoDragModifiers, type XenoDragPreview, type XenoDragState, type XenoHitTarget, type XenoLaneTile, type XenoLaneTileRequest, type XenoMarker, type XenoOverlaySpan, type XenoRenderTier, type XenoRowLayout, type XenoRulerTick, type XenoSnapCandidate, type XenoSnapConfig, type XenoSnapMode, type XenoSnapResult, type XenoTickWeight, XenoTileCache, type XenoTileCacheOptions, XenoTimeScale, type XenoTimeScaleGeometry, type XenoTimeScaleState, type XenoTimelineOp, type XenoTimelineSelection, type XenoTimelineTool, type XenoTimelineViewport, type XenoTrack, type XenoTrackKind, type XenoTrimMode, type XenoVisibleTrack, type XenoZoomBounds, addDays, addMonths, alignToTileGrid, beginDrag, blitTile, buildCells, buildRowLayout, calendarManifest, calendarPanel, chooseRung, clampScroll, clipDuration, clipsInMarquee, clipsOverlap, collectSnapTargets, commitDrag, computeVisible, createCalendarPanel, createTimelinePanel, createTransportPanel, dayKey, daysInMonth, dedupeByColumn, deriveEvents, edgeZone, gridStart, gridTicksFor, hasSourceWindow, isZoomGesture, isoWeekday, packOverlaps, parseDayKey, passedThreshold, previewDrag, renderTier, rowAtY, rowInWindow, rulerTicks, scrollToReveal, snapDelta, snapTick, snapWouldFight, sourceRangeFor, statusTone, ticksPerStep, tileKey, tileWidthTicks, timelineManifest, timelinePanel, todayKey, transportManifest, transportPanel, trimModeFor, visibleClips, visibleRows, weekStart, wheelZoomFactor, withSnapModifier, zoomAboutCursor, zoomBucket, zoomToFit };