zumly 0.9.11 → 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,61 +1,72 @@
1
1
  {
2
2
  "name": "zumly",
3
- "version": "0.9.11",
3
+ "version": "0.92.0",
4
4
  "description": "Javascript library for building zooming user interfaces",
5
- "author": {
6
- "name": "Juan Martín Muda",
7
- "email": "zumly.js@gmail.com"
8
- },
5
+ "type": "module",
6
+ "author": "Juan Martin Muda - Zumerlab",
9
7
  "license": "MIT",
10
- "homepage": "https://zumly.org",
8
+ "homepage": "https://zumerlab.github.io/zumly-docs",
11
9
  "repository": {
12
10
  "type": "git",
13
- "url": "https://github.com/zumly/zumly.git"
11
+ "url": "https://github.com/zumerlab/zumly.git"
14
12
  },
15
13
  "bugs": {
16
- "url": "https://github.com/zumly/zumly/issues"
14
+ "url": "https://github.com/zumerlab/zumly/issues"
17
15
  },
18
16
  "keywords": [
19
17
  "zumly",
20
18
  "zooming",
21
19
  "javascript",
22
20
  "UI",
23
- "library"
21
+ "library",
22
+ "ZUI"
24
23
  ],
25
- "main": "dist/zumly.min.mjs",
24
+ "main": "dist/zumly.js",
26
25
  "module": "dist/zumly.mjs",
27
- "browser": "dist/zumly.umd.js",
28
- "scripts": {
29
- "art": "ascii-art image src/assets/zumly_art.svg",
30
- "watch": "rollup --config rollup.config.dev.js -w",
31
- "dev": "npm-run-all -s --silent art watch",
32
- "build": "rollup --config rollup.config.prod.js",
33
- "lint": "standard --fix 'src/*.js' | snazzy",
34
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --verbose --silent"
35
- },
36
- "devDependencies": {
37
- "@rollup/plugin-commonjs": "^14.0.0",
38
- "@rollup/plugin-node-resolve": "^8.4.0",
39
- "ascii-art": "^2.5.0",
40
- "jest": "^26.1.0",
41
- "jest-environment-jsdom-sixteen": "^1.0.3",
42
- "npm-run-all": "^4.1.5",
43
- "postcss": "^7.0.27",
44
- "postcss-banner": "^3.0.2",
45
- "rollup": "^2.2.1",
46
- "rollup-plugin-copy": "^3.3.0",
47
- "rollup-plugin-live-server": "^1.0.3",
48
- "rollup-plugin-postcss": "^3.1.2",
49
- "rollup-plugin-terser": "^6.1.0",
50
- "snazzy": "^8.0.0",
51
- "standard": "^14.3.3"
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
+ }
52
42
  },
53
- "type": "module",
54
- "jest": {
55
- "transform": {},
56
- "testEnvironment": "jest-environment-jsdom-sixteen"
43
+ "sideEffects": [
44
+ "dist/zumly.css",
45
+ "dist/zumly.min.css"
46
+ ],
47
+ "scripts": {
48
+ "compile": "node esbuild.config.mjs",
49
+ "build": "npm run compile && npm pack",
50
+ "dev": "npm run compile && npx serve . -p 9090 -c ./serve.json",
51
+ "test": "npx vitest run --browser.headless --reporter=verbose",
52
+ "test:unit": "npx vitest run __tests__/utils.test.js --reporter=verbose",
53
+ "test:coverage": "npx vitest run --browser.headless --coverage",
54
+ "test:install-browsers": "npx playwright install chromium",
55
+ "bump": "npx @zumerbox/bump && npx @zumerbox/changelog",
56
+ "prebuild": "git add CHANGELOG.md && git commit -m \"Bumped version\" && git push --follow-tags"
57
57
  },
58
58
  "files": [
59
- "dist"
60
- ]
59
+ "dist",
60
+ "src/drivers/driver-helpers.js",
61
+ "src/drivers/index.js",
62
+ "docs/DRIVER_API.md",
63
+ "README.md"
64
+ ],
65
+ "devDependencies": {
66
+ "esbuild": "^0.24.0",
67
+ "@vitest/browser": "^3.1.2",
68
+ "@vitest/coverage-v8": "^3.1.2",
69
+ "playwright": "^1.52.0",
70
+ "vitest": "^3.1.2"
71
+ }
61
72
  }