zumly 0.18.1 → 0.92.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,340 @@
1
+ # Writing a Zumly Transition Driver
2
+
3
+ **Zumly** (*Z over XY* — focus-driven navigation, zoom into what matters) delegates animation to **transition drivers**. A driver controls **how** views move during zoom-in, zoom-out, and lateral navigation. The engine computes **what** moves where (transforms, origins, snapshot); the driver applies motion — CSS keyframes, WAAPI, GSAP, Motion, Anime.js, or a custom timeline.
4
+
5
+ **See also:** [README.md](../README.md) (options, built-in drivers, plugins) · [geometry-optimization.md](geometry-optimization.md) (how zoom-out layout reads are batched before the driver runs).
6
+
7
+ ## Quick start
8
+
9
+ A driver is an object with a single method:
10
+
11
+ ```js
12
+ const myDriver = {
13
+ runTransition(spec, onComplete) {
14
+ // Animate views from spec.currentStage backward → forward states
15
+ // Call onComplete() when done — this is mandatory.
16
+ }
17
+ }
18
+ ```
19
+
20
+ Register it in the constructor:
21
+
22
+ ```js
23
+ new Zumly({
24
+ mount: '.canvas',
25
+ initialView: 'home',
26
+ views: { home, detail },
27
+ transitions: {
28
+ driver: myDriver.runTransition, // pass the function directly
29
+ duration: '600ms',
30
+ ease: 'ease-in-out',
31
+ },
32
+ })
33
+ ```
34
+
35
+ Or as a factory function:
36
+
37
+ ```js
38
+ transitions: {
39
+ driver: (spec, onComplete) => myDriver.runTransition(spec, onComplete),
40
+ }
41
+ ```
42
+
43
+ ---
44
+
45
+ ## The `spec` object
46
+
47
+ Your `runTransition(spec, onComplete)` receives a spec with everything needed:
48
+
49
+ ### Common fields (all transition types)
50
+
51
+ | Field | Type | Description |
52
+ |-------|------|-------------|
53
+ | `type` | `'zoomIn' \| 'zoomOut' \| 'lateral'` | What kind of transition |
54
+ | `currentView` | `HTMLElement` | The incoming view (zoom-in) or outgoing view (zoom-out) |
55
+ | `previousView` | `HTMLElement` | The parent view |
56
+ | `lastView` | `HTMLElement \| null` | Grandparent view (null at depth ≤ 2) |
57
+ | `currentStage` | `object` | Snapshot with computed states (see below) |
58
+ | `duration` | `string` | e.g. `'500ms'`, `'1s'` |
59
+ | `ease` | `string` | CSS easing, e.g. `'ease-in-out'` |
60
+ | `canvas` | `HTMLElement` | The canvas container (zoom-out and lateral only) |
61
+
62
+ ### `currentStage.views[]` — The animation data
63
+
64
+ An array of view entries, indexed by role:
65
+
66
+ ```
67
+ views[0] → current view (the one zooming in/out)
68
+ views[1] → previous view (the parent)
69
+ views[2] → last view (grandparent, when depth > 2)
70
+ ```
71
+
72
+ Each entry has:
73
+
74
+ ```js
75
+ {
76
+ viewName: 'detail',
77
+ backwardState: {
78
+ origin: '125px 100px', // CSS transform-origin
79
+ transform: 'translate(50px, 60px) scale(0.25)', // "start" for zoom-in, "end" for zoom-out
80
+ duration: '500ms',
81
+ ease: 'ease-in-out',
82
+ },
83
+ forwardState: {
84
+ origin: '125px 100px',
85
+ transform: 'translate(100px, 50px)', // "end" for zoom-in, "start" for zoom-out
86
+ duration: '500ms',
87
+ ease: 'ease-in-out',
88
+ }
89
+ }
90
+ ```
91
+
92
+ **For zoom-in:** animate from `backwardState.transform` → `forwardState.transform`.
93
+ **For zoom-out:** animate from `forwardState.transform` → `backwardState.transform`.
94
+
95
+ ### Lateral-specific fields
96
+
97
+ | Field | Type | Description |
98
+ |-------|------|-------------|
99
+ | `backView` | `HTMLElement \| null` | The parent view behind current depth |
100
+ | `backViewState` | `{ transformStart, transformEnd }` | Slide transforms for backView |
101
+ | `lastViewState` | `{ transformStart, transformEnd }` | Slide transforms for lastView |
102
+ | `incomingTransformStart` | `string` | Incoming view start transform |
103
+ | `incomingTransformEnd` | `string` | Incoming view final transform |
104
+ | `outgoingTransform` | `string` | Outgoing view current transform |
105
+ | `outgoingTransformEnd` | `string` | Outgoing view exit transform |
106
+ | `slideDeltaX` | `number` | Horizontal slide distance |
107
+ | `slideDeltaY` | `number` | Vertical slide distance |
108
+
109
+ ---
110
+
111
+ ## What your driver MUST do
112
+
113
+ ### 1. Call `onComplete()` — always, exactly once
114
+
115
+ This is the #1 rule. The engine sets `blockEvents = true` before calling your driver and only resets it in `onComplete`. If you never call it, the UI freezes permanently.
116
+
117
+ Use the `createFinishGuard` helper to guarantee this:
118
+
119
+ ```js
120
+ import { createFinishGuard, SAFETY_BUFFER_MS, parseDurationMs } from 'zumly/driver-helpers'
121
+
122
+ function runTransition(spec, onComplete) {
123
+ const durationMs = parseDurationMs(spec.duration)
124
+
125
+ const { finish } = createFinishGuard(() => {
126
+ // cleanup work here...
127
+ onComplete()
128
+ }, durationMs + SAFETY_BUFFER_MS)
129
+
130
+ // Your animation...
131
+ myAnimation.onfinish = finish
132
+ // If the animation fails/gets cancelled, the safety timer calls finish() anyway.
133
+ }
134
+ ```
135
+
136
+ *(In this repo, you can import from `../src/drivers/driver-helpers.js` from your own source files.)*
137
+
138
+ ### 2. Apply final DOM state after animation
139
+
140
+ When the animation ends, the DOM must reflect the final state — the engine does NOT do this for you. Use the shared helpers:
141
+
142
+ ```js
143
+ import {
144
+ applyZoomInEndState,
145
+ applyZoomOutPreviousState,
146
+ applyZoomOutLastState,
147
+ removeViewFromCanvas,
148
+ showViews
149
+ } from 'zumly/driver-helpers'
150
+
151
+ // Before animating — make views visible:
152
+ showViews(spec.currentView, spec.previousView, spec.lastView)
153
+
154
+ // After zoom-in animation completes:
155
+ applyZoomInEndState(currentView, currentStage)
156
+ applyZoomInEndState(previousView, currentStage)
157
+ if (lastView) applyZoomInEndState(lastView, currentStage)
158
+
159
+ // After zoom-out animation completes:
160
+ removeViewFromCanvas(currentView, canvas)
161
+ applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
162
+ if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)
163
+ ```
164
+
165
+ ### 3. Handle all three types
166
+
167
+ Your driver receives `spec.type` which is one of:
168
+ - `'zoomIn'` — drill deeper
169
+ - `'zoomOut'` — go back
170
+ - `'lateral'` — same-level swap
171
+
172
+ For lateral, the built-in **`waapi`** driver runs a **slide animation**. If you author a minimal custom driver and want **no** lateral motion, call the instant helper:
173
+
174
+ ```js
175
+ import { runLateralInstant } from 'zumly/driver-helpers'
176
+
177
+ if (spec.type === 'lateral') {
178
+ runLateralInstant(spec, onComplete)
179
+ return
180
+ }
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Available helpers
186
+
187
+ Import from `zumly/driver-helpers` (published) or `src/drivers/driver-helpers.js` (monorepo):
188
+
189
+ | Helper | Purpose |
190
+ |--------|---------|
191
+ | `parseDurationMs(duration)` | Parse `'1s'`/`'500ms'`/number → ms |
192
+ | `parseDurationSec(duration)` | Same but returns seconds |
193
+ | `applyZoomInEndState(el, stage)` | Apply final classes + transforms after zoom-in |
194
+ | `applyZoomOutPreviousState(el, state)` | Final state for previous view after zoom-out |
195
+ | `applyZoomOutLastState(el, state)` | Final state for last view after zoom-out |
196
+ | `removeViewFromCanvas(el, canvas)` | Safe removal (handles wrapped elements) |
197
+ | `showViews(...elements)` | Remove `hide` class + set `contentVisibility: auto` |
198
+ | `runLateralInstant(spec, onComplete)` | Instant lateral transition (no animation) |
199
+ | `createFinishGuard(cleanup, timeoutMs)` | Once-only finish + safety timeout |
200
+ | `SAFETY_BUFFER_MS` | Default safety buffer (150ms) |
201
+
202
+ ### Matrix interpolation toolkit
203
+
204
+ For drivers that need to interpolate through computed CSS matrices (e.g. when transform-origin varies between states):
205
+
206
+ | Helper | Purpose |
207
+ |--------|---------|
208
+ | `readComputedMatrix(el, origin, transform)` | Read browser-computed matrix (⚠️ forces reflow) |
209
+ | `interpolateMatrix(from, to, t)` | Lerp between two matrix objects |
210
+ | `matrixToString(m)` | `{ a,b,c,d,e,f }` → `"matrix(...)"` |
211
+ | `parseMatrixString(str)` | `"matrix(...)"` → `{ a,b,c,d,e,f }` |
212
+ | `identityMatrix()` | Returns `{ a:1, b:0, c:0, d:1, e:0, f:0 }` |
213
+ | `lerp(a, b, t)` | Linear interpolation |
214
+
215
+ ---
216
+
217
+ ## Example: minimal custom driver
218
+
219
+ A complete, minimal driver that does a simple opacity crossfade instead of zoom:
220
+
221
+ ```js
222
+ import {
223
+ parseDurationMs,
224
+ showViews,
225
+ applyZoomInEndState,
226
+ applyZoomOutPreviousState,
227
+ applyZoomOutLastState,
228
+ removeViewFromCanvas,
229
+ runLateralInstant,
230
+ createFinishGuard,
231
+ SAFETY_BUFFER_MS,
232
+ } from 'zumly/driver-helpers'
233
+
234
+ export function runTransition(spec, onComplete) {
235
+ const { type, currentView, previousView, lastView, currentStage, duration, canvas } = spec
236
+
237
+ if (type === 'lateral') {
238
+ runLateralInstant(spec, onComplete)
239
+ return
240
+ }
241
+
242
+ const ms = parseDurationMs(duration)
243
+ showViews(currentView, previousView, lastView)
244
+
245
+ if (type === 'zoomIn') {
246
+ // Simple crossfade: incoming fades in, outgoing fades out
247
+ currentView.style.opacity = '0'
248
+ currentView.style.transition = `opacity ${ms}ms ease`
249
+ previousView.style.transition = `opacity ${ms}ms ease`
250
+
251
+ requestAnimationFrame(() => {
252
+ currentView.style.opacity = '1'
253
+ previousView.style.opacity = '0.3'
254
+ })
255
+
256
+ const { finish } = createFinishGuard(() => {
257
+ currentView.style.transition = ''
258
+ previousView.style.transition = ''
259
+ previousView.style.opacity = ''
260
+ applyZoomInEndState(currentView, currentStage)
261
+ applyZoomInEndState(previousView, currentStage)
262
+ if (lastView) applyZoomInEndState(lastView, currentStage)
263
+ onComplete()
264
+ }, ms + SAFETY_BUFFER_MS)
265
+
266
+ currentView.addEventListener('transitionend', finish, { once: true })
267
+ return
268
+ }
269
+
270
+ if (type === 'zoomOut') {
271
+ currentView.style.transition = `opacity ${ms}ms ease`
272
+ requestAnimationFrame(() => {
273
+ currentView.style.opacity = '0'
274
+ previousView.style.opacity = '1'
275
+ })
276
+
277
+ const { finish } = createFinishGuard(() => {
278
+ currentView.style.transition = ''
279
+ removeViewFromCanvas(currentView, canvas)
280
+ applyZoomOutPreviousState(previousView, currentStage.views[1].backwardState)
281
+ if (lastView) applyZoomOutLastState(lastView, currentStage.views[2].backwardState)
282
+ onComplete()
283
+ }, ms + SAFETY_BUFFER_MS)
284
+
285
+ currentView.addEventListener('transitionend', finish, { once: true })
286
+ return
287
+ }
288
+
289
+ onComplete()
290
+ }
291
+ ```
292
+
293
+ ---
294
+
295
+ ## Testing your driver
296
+
297
+ Use `driver: 'none'` as a reference — it applies final state instantly and calls `onComplete()` synchronously. Your driver should produce the same final DOM state, just animated.
298
+
299
+ Key things to test:
300
+ 1. After zoom-in: `.is-current-view` exists with correct `dataset.viewName`
301
+ 2. After zoom-out: previous view is now `.is-current-view`, old current is removed from DOM
302
+ 3. `onComplete()` is always called, even if elements are removed mid-animation
303
+ 4. `blockEvents` is reset (the engine handles this in `onComplete`, but verify your driver calls it)
304
+
305
+ ```js
306
+ const app = new Zumly({
307
+ mount: '.canvas',
308
+ initialView: 'home',
309
+ views: { home, detail },
310
+ transitions: { driver: myDriver, duration: '100ms' },
311
+ })
312
+ await app.init()
313
+ await app.zoomTo('detail')
314
+ expect(app.getCurrentViewName()).toBe('detail')
315
+ app.back()
316
+ expect(app.getCurrentViewName()).toBe('home')
317
+ ```
318
+
319
+ ---
320
+
321
+ ## Registering with `getDriver()`
322
+
323
+ Built-in drivers are resolved by name (`'css'`, `'waapi'`, `'none'`, etc.) in [`src/drivers/index.js`](../src/drivers/index.js). Community drivers are passed as functions — no registration needed:
324
+
325
+ ```js
326
+ // Direct function — works out of the box:
327
+ transitions: { driver: myDriver.runTransition }
328
+
329
+ // If you want to publish as a package:
330
+ // npm: zumly-driver-lottie
331
+ import { runTransition } from 'zumly-driver-lottie'
332
+ new Zumly({ transitions: { driver: runTransition } })
333
+ ```
334
+
335
+ ---
336
+
337
+ ## See also
338
+
339
+ - [README.md](../README.md) — installation and transition driver table
340
+ - [roadMap.md](roadMap.md) — architecture notes and animation driver history
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zumly",
3
- "version": "0.18.1",
3
+ "version": "0.92.0",
4
4
  "description": "Javascript library for building zooming user interfaces",
5
5
  "type": "module",
6
6
  "author": "Juan Martin Muda - Zumerlab",
@@ -22,10 +22,32 @@
22
22
  "ZUI"
23
23
  ],
24
24
  "main": "dist/zumly.js",
25
+ "module": "dist/zumly.mjs",
26
+ "types": "types/zumly.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./types/zumly.d.ts",
30
+ "import": "./dist/zumly.mjs",
31
+ "require": "./dist/zumly.js",
32
+ "default": "./dist/zumly.mjs"
33
+ },
34
+ "./style.css": "./dist/zumly.css",
35
+ "./css": "./dist/zumly.css",
36
+ "./drivers": {
37
+ "import": "./src/drivers/index.js"
38
+ },
39
+ "./driver-helpers": {
40
+ "import": "./src/drivers/driver-helpers.js"
41
+ }
42
+ },
43
+ "sideEffects": [
44
+ "dist/zumly.css",
45
+ "dist/zumly.min.css"
46
+ ],
25
47
  "scripts": {
26
48
  "compile": "node esbuild.config.mjs",
27
49
  "build": "npm run compile && npm pack",
28
- "dev": "npm run compile && npx serve public -p 9090",
50
+ "dev": "npm run compile && npx serve . -p 9090 -c ./serve.json",
29
51
  "test": "npx vitest run --browser.headless --reporter=verbose",
30
52
  "test:unit": "npx vitest run __tests__/utils.test.js --reporter=verbose",
31
53
  "test:coverage": "npx vitest run --browser.headless --coverage",
@@ -35,6 +57,9 @@
35
57
  },
36
58
  "files": [
37
59
  "dist",
60
+ "src/drivers/driver-helpers.js",
61
+ "src/drivers/index.js",
62
+ "docs/DRIVER_API.md",
38
63
  "README.md"
39
64
  ],
40
65
  "devDependencies": {
@@ -0,0 +1,315 @@
1
+ import { showViewContent } from '../view-visibility.js'
2
+
3
+ /**
4
+ * Shared helpers for Zumly transition drivers.
5
+ *
6
+ * These utilities handle the repetitive DOM work that every driver needs:
7
+ * applying final states, removing views, parsing durations, and running
8
+ * lateral (same-level) transitions. Import what you need; skip what you don't.
9
+ *
10
+ * @module driver-helpers
11
+ * @see ../../docs/DRIVER_API.md for the full driver authoring guide
12
+ */
13
+
14
+ // ─── Duration parsing ────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Parse a CSS-style duration string to milliseconds.
18
+ * Accepts "1s", "500ms", "0.3s", or a raw number (treated as ms).
19
+ * Returns a safe fallback (500ms) for garbage input.
20
+ *
21
+ * @param {string|number} duration
22
+ * @returns {number} Duration in milliseconds (≥ 0)
23
+ *
24
+ * @example
25
+ * parseDurationMs('1s') // → 1000
26
+ * parseDurationMs('200ms') // → 200
27
+ * parseDurationMs(300) // → 300
28
+ * parseDurationMs('nope') // → 500
29
+ */
30
+ export function parseDurationMs (duration) {
31
+ if (typeof duration === 'number' && !Number.isNaN(duration)) return Math.max(0, duration)
32
+ const str = String(duration)
33
+ const m = str.match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/i)
34
+ if (!m) return 500
35
+ const val = parseFloat(m[1])
36
+ const unit = (m[2] || 's').toLowerCase()
37
+ return unit === 'ms' ? Math.max(0, val) : Math.max(0, val * 1000)
38
+ }
39
+
40
+ /**
41
+ * Parse duration to seconds (convenience for libs like GSAP that use seconds).
42
+ *
43
+ * @param {string|number} duration
44
+ * @returns {number} Duration in seconds (≥ 0)
45
+ */
46
+ export function parseDurationSec (duration) {
47
+ return parseDurationMs(duration) / 1000
48
+ }
49
+
50
+ // ─── End-state application ───────────────────────────────────────────
51
+
52
+ /**
53
+ * Apply the final zoom-in state to an element based on its current class.
54
+ * This is the DOM cleanup that runs after the animation finishes.
55
+ *
56
+ * What it does for each role:
57
+ * - `is-new-current-view` → becomes `is-current-view`, no-events removed, final transform applied
58
+ * - `is-previous-view` → no-events removed, final transform applied
59
+ * - `is-last-view` → no-events removed, final transform applied
60
+ *
61
+ * @param {HTMLElement} element - The view element
62
+ * @param {object} currentStage - The snapshot with `views[]` array
63
+ */
64
+ export function applyZoomInEndState (element, currentStage) {
65
+ if (element.classList.contains('is-new-current-view')) {
66
+ const v = currentStage.views[0].forwardState
67
+ element.classList.replace('is-new-current-view', 'is-current-view')
68
+ element.classList.remove('zoom-current-view', 'has-no-events')
69
+ element.style.transformOrigin = v.origin
70
+ element.style.transform = v.transform
71
+ return
72
+ }
73
+ if (element.classList.contains('is-previous-view')) {
74
+ const v = currentStage.views[1].forwardState
75
+ element.classList.remove('zoom-previous-view', 'has-no-events')
76
+ element.style.transformOrigin = v.origin
77
+ element.style.transform = v.transform
78
+ return
79
+ }
80
+ if (element.classList.contains('is-last-view')) {
81
+ const v = currentStage.views[2].forwardState
82
+ element.classList.remove('zoom-last-view', 'has-no-events')
83
+ element.style.transformOrigin = v.origin
84
+ element.style.transform = v.transform
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Apply the final zoom-out state to the previous view (it becomes current).
90
+ *
91
+ * @param {HTMLElement} element
92
+ * @param {{ origin?: string, transform: string }} backwardState
93
+ */
94
+ export function applyZoomOutPreviousState (element, backwardState) {
95
+ element.classList.remove('zoom-previous-view-reverse', 'has-no-events', 'has-effect', 'has-effect-reverse')
96
+ element.style.removeProperty('--z-effect-filter')
97
+ element.style.transformOrigin = '0 0'
98
+ element.style.transform = backwardState.transform
99
+ }
100
+
101
+ /**
102
+ * Apply the final zoom-out state to the last view (it becomes previous).
103
+ *
104
+ * @param {HTMLElement} element
105
+ * @param {{ origin: string, transform: string }} backwardState
106
+ */
107
+ export function applyZoomOutLastState (element, backwardState) {
108
+ element.classList.remove('zoom-last-view-reverse', 'has-no-events', 'has-effect', 'has-effect-reverse')
109
+ element.style.removeProperty('--z-effect-filter')
110
+ element.style.transformOrigin = backwardState.origin
111
+ element.style.transform = backwardState.transform
112
+ }
113
+
114
+ // ─── DOM removal ─────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Safely remove a view from the canvas. Handles wrapped elements (e.g. Svelte
118
+ * components that get an extra parent div).
119
+ *
120
+ * @param {HTMLElement} element - The view to remove
121
+ * @param {HTMLElement} canvas - The canvas container
122
+ */
123
+ export function removeViewFromCanvas (element, canvas) {
124
+ try {
125
+ if (canvas) canvas.removeChild(element)
126
+ } catch (e) {
127
+ try {
128
+ if (element.parentElement) canvas.removeChild(element.parentElement)
129
+ } catch (e2) {
130
+ // Element already removed or re-parented — safe to ignore.
131
+ }
132
+ }
133
+ }
134
+
135
+ // ─── Visibility helpers ──────────────────────────────────────────────
136
+
137
+ /**
138
+ * Make a view visible and interactive before animating it.
139
+ * Views start hidden (class `hide` for opacity, `contentVisibility: hidden` via view-visibility)
140
+ * when inserted by prepareAndInsertView. Drivers must call this before running any animation.
141
+ *
142
+ * @param {...HTMLElement} elements - One or more view elements (nulls are skipped)
143
+ */
144
+ export function showViews (...elements) {
145
+ for (const el of elements) {
146
+ if (!el) continue
147
+ el.classList.remove('hide')
148
+ showViewContent(el)
149
+ }
150
+ }
151
+
152
+ // ─── Lateral (same-level) transition ─────────────────────────────────
153
+
154
+ /**
155
+ * Instant lateral navigation: swap current view without animation.
156
+ * This is the shared implementation that drivers use as a baseline.
157
+ * Drivers that want animated lateral transitions can override this.
158
+ *
159
+ * @param {object} spec - Full lateral spec from the engine
160
+ * @param {function} onComplete - Must be called when done
161
+ */
162
+ export function runLateralInstant (spec, onComplete) {
163
+ const {
164
+ currentView: incomingView,
165
+ previousView: outgoingView,
166
+ backView,
167
+ backViewState,
168
+ lastView,
169
+ lastViewState,
170
+ incomingTransformEnd,
171
+ currentStage,
172
+ canvas
173
+ } = spec
174
+ const v0 = currentStage.views[0]
175
+
176
+ showViews(incomingView)
177
+ incomingView.classList.replace('is-new-current-view', 'is-current-view')
178
+ incomingView.classList.remove('zoom-current-view', 'has-no-events')
179
+ incomingView.style.transformOrigin = v0.forwardState.origin
180
+ incomingView.style.transform = incomingTransformEnd || v0.forwardState.transform
181
+
182
+ if (backView && backViewState) backView.style.transform = backViewState.transformEnd
183
+ if (lastView && lastViewState) lastView.style.transform = lastViewState.transformEnd
184
+
185
+ if (!spec.keepAlive) removeViewFromCanvas(outgoingView, canvas)
186
+ onComplete()
187
+ }
188
+
189
+ // ─── Safety timeout ──────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Default safety buffer (ms) beyond the parsed duration. Ensures onComplete
193
+ * fires even if animationend / Promise.all never settles (element removed,
194
+ * duration 0, browser quirk, etc.).
195
+ */
196
+ export const SAFETY_BUFFER_MS = 150
197
+
198
+ /**
199
+ * Create a finish-once guard: returns a `finish()` function that only runs
200
+ * the first time it's called, and clears the safety timer.
201
+ *
202
+ * Use this to avoid the "completed" flag boilerplate in every driver.
203
+ *
204
+ * @param {function} cleanup - The actual cleanup + onComplete work
205
+ * @param {number} timeoutMs - Safety timeout duration
206
+ * @returns {{ finish: function, safetyTimer: number }}
207
+ *
208
+ * @example
209
+ * const { finish, safetyTimer } = createFinishGuard(() => {
210
+ * cancelAnimations()
211
+ * applyFinalState()
212
+ * onComplete()
213
+ * }, durationMs + SAFETY_BUFFER_MS)
214
+ *
215
+ * // In your animation's onComplete:
216
+ * animation.onfinish = finish
217
+ *
218
+ * // The safetyTimer ensures finish() runs even if onfinish never fires.
219
+ */
220
+ export function createFinishGuard (cleanup, timeoutMs) {
221
+ let completed = false
222
+ const safetyTimer = setTimeout(() => {
223
+ if (!completed) { completed = true; cleanup() }
224
+ }, timeoutMs)
225
+
226
+ return {
227
+ finish () {
228
+ if (completed) return
229
+ completed = true
230
+ clearTimeout(safetyTimer)
231
+ cleanup()
232
+ },
233
+ safetyTimer
234
+ }
235
+ }
236
+
237
+ // ─── Matrix interpolation toolkit ────────────────────────────────────
238
+ // Used by matrix-based drivers (anime, motion, or any custom driver that
239
+ // needs to interpolate transforms through computed matrices).
240
+
241
+ /** Identity CSS matrix components. */
242
+ export function identityMatrix () {
243
+ return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }
244
+ }
245
+
246
+ /**
247
+ * Parse a CSS matrix() string into components.
248
+ * @param {string} mStr - e.g. "matrix(1, 0, 0, 1, 100, 50)"
249
+ * @returns {{ a, b, c, d, e, f }}
250
+ */
251
+ export function parseMatrixString (mStr) {
252
+ if (!mStr || mStr === 'none') return identityMatrix()
253
+ const m = String(mStr).match(
254
+ /matrix\(([-\d.eE]+),\s*([-\d.eE]+),\s*([-\d.eE]+),\s*([-\d.eE]+),\s*([-\d.eE]+),\s*([-\d.eE]+)\)/
255
+ )
256
+ if (!m) return identityMatrix()
257
+ return {
258
+ a: parseFloat(m[1]) || 1,
259
+ b: parseFloat(m[2]) || 0,
260
+ c: parseFloat(m[3]) || 0,
261
+ d: parseFloat(m[4]) || 1,
262
+ e: parseFloat(m[5]) || 0,
263
+ f: parseFloat(m[6]) || 0,
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Convert matrix components back to a CSS matrix() string.
269
+ * @param {{ a, b, c, d, e, f }} m
270
+ * @returns {string}
271
+ */
272
+ export function matrixToString (m) {
273
+ return `matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.e}, ${m.f})`
274
+ }
275
+
276
+ /** Linear interpolation between two numbers. */
277
+ export function lerp (a, b, t) {
278
+ return a + (b - a) * t
279
+ }
280
+
281
+ /**
282
+ * Interpolate between two matrix objects.
283
+ * @param {{ a,b,c,d,e,f }} from
284
+ * @param {{ a,b,c,d,e,f }} to
285
+ * @param {number} t - Progress 0..1
286
+ * @returns {{ a,b,c,d,e,f }}
287
+ */
288
+ export function interpolateMatrix (from, to, t) {
289
+ const tt = Math.max(0, Math.min(1, t))
290
+ return {
291
+ a: lerp(from.a, to.a, tt),
292
+ b: lerp(from.b, to.b, tt),
293
+ c: lerp(from.c, to.c, tt),
294
+ d: lerp(from.d, to.d, tt),
295
+ e: lerp(from.e, to.e, tt),
296
+ f: lerp(from.f, to.f, tt),
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Read the browser-computed matrix for a given transform + origin pair.
302
+ * ⚠️ Forces a reflow (getBoundingClientRect). Use sparingly — once per
303
+ * element per animation setup, not per frame.
304
+ *
305
+ * @param {HTMLElement} element
306
+ * @param {string} origin - CSS transform-origin
307
+ * @param {string} transformStr - CSS transform value
308
+ * @returns {{ a,b,c,d,e,f }}
309
+ */
310
+ export function readComputedMatrix (element, origin, transformStr) {
311
+ element.style.transformOrigin = origin
312
+ element.style.transform = transformStr
313
+ try { element.getBoundingClientRect() } catch {}
314
+ return parseMatrixString(getComputedStyle(element).transform)
315
+ }