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.
package/README.md CHANGED
@@ -1,26 +1,46 @@
1
1
  <p align="center">
2
- <a href="https://zumly.org">
3
- <img src="https://raw.githubusercontent.com/zumly/website/gh-pages/images/logo-zumly.png" width="200">
2
+ <a href="https://github.com/zumerlab/zumly">
3
+ <!-- Built from docs/zumly-logo.png in this repo; raw URL so the image works on npm too -->
4
+ <img src="https://raw.githubusercontent.com/zumerlab/zumly/main/docs/zumly-logo.png" alt="Zumly" width="200">
4
5
  </a>
5
6
  </p>
6
7
 
8
+ <h1 align="center">Z over XY</h1>
9
+
10
+ <p align="center"><strong>Focus-driven navigation.</strong></p>
11
+ <p align="center"><strong>Zoom into what matters.</strong></p>
12
+
7
13
  <p align="center">
8
- Zumly is a JavaScript library for building zooming user interfaces. Create zooming experiences using web standards.
14
+ <a href="https://www.npmjs.com/package/zumly"><img src="https://img.shields.io/npm/v/zumly.svg" alt="npm version"></a>
9
15
  </p>
10
16
 
11
17
  <p align="center">
12
- <a href="https://www.npmjs.com/package/zumly"><img src="https://img.shields.io/npm/v/zumly.svg"></a>
18
+ <strong>Zumly</strong> is a JavaScript library for <strong>hierarchical zoom navigation</strong>: you move in <strong>Z</strong> (depth) through discrete views laid out in the <strong>XY</strong> plane, with spatial transitions instead of flat screen swaps. It is inspired by <a href="https://en.wikipedia.org/wiki/Zooming_user_interface">zoomable user interfaces (ZUI)</a> but targets <strong>structured, trigger-driven</strong> zoom—not infinite pan/zoom canvases.
13
19
  </p>
14
20
 
15
21
  ## Status
16
22
 
17
- Zumly is under active development. The library is usable in production with a stable core API; view preloading, prefetch on hover, and multiple view sources (HTML, URL, async functions, web components) are supported. See [docs/roadMap.md](docs/roadMap.md) for improvement topics and roadmap.
23
+ Zumly is under active development. The core stack is stable: depth and lateral navigation, pluggable transition drivers (CSS, WAAPI, none, Anime.js, GSAP, Motion, custom), unified nav UI (depth + lateral, eight positions), view resolver and prefetch cache, optional **plugin** API (<code>.use()</code>), and the **hash router** plugin. View sources include HTML strings, URLs, async functions, objects with <code>render()</code>, DOM nodes, and web component tags.
24
+
25
+ Zoom-out geometry uses batched DOM reads plus pure math where possible to cut layout thrash before animations (see [Geometry optimization](docs/geometry-optimization.md)).
26
+
27
+ **Docs:** [Roadmap & topics](docs/roadMap.md) · [Transition drivers](docs/DRIVER_API.md) · [Geometry notes](docs/geometry-optimization.md)
18
28
 
19
29
  ## Overview
20
30
 
21
- Zumly is a frontend library for creating **zoomable user interfaces** ([ZUI](https://en.wikipedia.org/wiki/Zooming_user_interface)). Instead of hyperlinks and windows, Zumly uses zooming as the main way to move through information: an infinite virtual canvas where you zoom into elements to reveal more detail.
31
+ Unlike free-pan ZUIs, Zumly focuses on **discrete, hierarchical navigation**: users zoom into a focused element (<code>.zoom-me</code>) to open the next view, so **attention** (focus) and **depth** (Z) stay aligned with **layout** (XY).
32
+
33
+ The engine is **UI-agnostic**—you supply markup and CSS. Transforms and timing are handled for you; design systems and frameworks integrate by resolving each view to a DOM subtree (see **View sources** and **Framework integration** below).
22
34
 
23
- The library focuses on **zoom transitions** and stays **UI-agnostic**—you bring your own CSS and layout. Any design system or custom styling works with Zumly.
35
+ ## What Zumly is
36
+
37
+ Zumly is **not** a freeform zooming canvas or map-like navigation system. It is a **discrete, hierarchical zoom interface**: screens are **views** at different depths, connected by **triggers**, with continuous motion between them.
38
+
39
+ It fits especially well when:
40
+
41
+ * you want **focus-driven** flow (zoom into what matters)
42
+ * **spatial context** between parent and child should persist
43
+ * you are building menus, stories, dashboards, or exploratory UIs without a classic router-only metaphor
24
44
 
25
45
  ## Installation
26
46
 
@@ -93,7 +113,7 @@ const app = new Zumly({
93
113
  await app.init();
94
114
  ```
95
115
 
96
- - Live example: [CodePen](https://codepen.io/zumly/pen/gOPQovd)
116
+ - Live example: [CodePen](https://codepen.io/zircle/pen/yyaXvRN)
97
117
 
98
118
  ### Options
99
119
 
@@ -105,33 +125,43 @@ await app.init();
105
125
  | `initialView` | string | Yes | Name of the first view to show. |
106
126
  | `views` | object | Yes | Map of view names to view sources (see View sources below). |
107
127
  | `preload` | string[] | No | View names to resolve and cache when the app initializes. |
108
- | `transitions` | object | No | Duration, ease, cover, and effects for zoom transitions. |
128
+ | `transitions` | object | No | Duration, ease, cover, driver, effects, stagger, hideTrigger for zoom transitions. |
129
+ | `deferred` | boolean | No | Defer content rendering until after animation completes (default: `false`). |
109
130
  | `debug` | boolean | No | Enable debug messages (default: `false`). |
131
+ | `lateralNav` | boolean \| object | No | Lateral navigation UI: `{ mode, arrows, dots, keepAlive }`. |
132
+ | `depthNav` | boolean \| object | No | Depth navigation UI: `{ button, indicator }`. |
133
+ | `navPosition` | string | No | Nav bar position preset (default: `'bottom-center'`). |
134
+ | `inputs` | boolean \| object | No | Input methods: `{ click, keyboard, wheel, touch }`. |
110
135
  | `componentContext` | object | No | Context passed to component-style views. |
111
136
 
112
137
  **Transitions (optional):**
113
138
 
114
139
  ```js
115
140
  transitions: {
116
- driver: 'css', // 'css' | 'waapi' | 'anime' | 'gsap' | 'motion' | 'none' or custom function(spec, onComplete)
117
- effects: ['blur', 'sepia', 'saturate'], // background view effects
118
- cover: 'width', // or 'height' — how the previous view scales to cover the trigger
141
+ driver: 'css', // 'css' | 'waapi' | 'anime' | 'gsap' | 'motion' | 'none' or custom function(spec, onComplete)
142
+ cover: 'width', // or 'height' how the previous view scales to cover the trigger
119
143
  duration: '1s',
120
144
  ease: 'ease-in-out',
145
+ effects: ['blur(3px) brightness(0.7)', 'blur(8px) saturate(0)'], // CSS filters for [previous, last] background views
146
+ stagger: 0, // delay (ms) between layers during transition
147
+ hideTrigger: false, // false | true (visibility:hidden) | 'fade' (opacity crossfade)
148
+ // threshold: { enabled: true, duration: 300, commitAt: 0.5 } // parsed but not wired in the engine yet
121
149
  }
122
150
  ```
123
151
 
124
- **Transition drivers:** Zoom animations are handled by a pluggable driver so you can swap the implementation without changing app logic.
152
+ `transitions.parallax` is accepted for compatibility but **not applied** (reserved; intensity is fixed to `0` in the engine).
153
+
154
+ **Transition drivers:** Zoom animations are handled by a pluggable driver (`transitions.driver`). You can swap implementations without changing app logic. To author your own, see [docs/DRIVER_API.md](docs/DRIVER_API.md) and the `zumly/driver-helpers` export.
125
155
 
126
156
  | Driver | Description |
127
157
  |--------|-------------|
128
158
  | `'css'` (default) | CSS keyframes and `animationend`; uses `zumly.css` variables. |
129
- | `'waapi'` | Web Animations API (`element.animate()`). |
130
- | `'anime'` | [Anime.js](https://animejs.com/) — load the library (e.g. from CDN) before use. |
131
- | `'gsap'` | [GSAP](https://greensock.com/gsap/) — load the library (e.g. from CDN) before use. |
132
- | `'motion'` | [Motion](https://motion.dev/) (motion.dev) — load the library (e.g. from CDN) before use. |
159
+ | `'waapi'` | Web Animations API (`element.animate()`). No extra dependency. |
133
160
  | `'none'` | No animation; applies final state immediately. Useful for tests or instant UX. |
134
- | `function(spec, onComplete)` | Custom driver. Receives a transition spec and must call `onComplete()` when done. |
161
+ | `'anime'` | [Anime.js](https://animejs.com/) requires global `anime` (load from CDN before use). |
162
+ | `'gsap'` | [GSAP](https://greensock.com/gsap/) — requires global `gsap` (load from CDN before use). |
163
+ | `'motion'` | [Motion](https://motion.dev/) — requires global `Motion` (load from CDN before use). |
164
+ | `function(spec, onComplete)` | Custom driver. Receives `{ type, currentView, previousView, lastView, currentStage, duration, ease }` and must call `onComplete()` when done. |
135
165
 
136
166
  Example with instant transitions (e.g. for tests):
137
167
 
@@ -144,15 +174,67 @@ const app = new Zumly({
144
174
  });
145
175
  ```
146
176
 
177
+ **Lateral navigation:**
178
+
179
+ ```js
180
+ lateralNav: true // mode: 'auto' (default)
181
+ lateralNav: false // disabled
182
+ lateralNav: { mode: 'always' } // always show when siblings exist
183
+ lateralNav: { mode: 'auto', dots: false } // auto mode, no dots
184
+ ```
185
+
186
+ | Mode | Description |
187
+ |------|-------------|
188
+ | `'auto'` (default) | Shows lateral nav only when the current view doesn't cover the full canvas — preserving spatial context. |
189
+ | `'always'` | Always shows lateral nav when siblings exist, regardless of coverage. |
190
+
191
+ In `'auto'` mode, when a view covers 100% of the canvas the user perceives a new independent space, so the lateral nav is suppressed to avoid a floating control with no visual context.
192
+
193
+ **Navigation position (`navPosition`):**
194
+
195
+ ```js
196
+ navPosition: 'bottom-center' // default
197
+ ```
198
+
199
+ | Preset | Position | Layout |
200
+ |--------|----------|--------|
201
+ | `'bottom-center'` | Bottom center | Horizontal |
202
+ | `'bottom-left'` | Bottom left | Horizontal |
203
+ | `'bottom-right'` | Bottom right | Horizontal |
204
+ | `'top-center'` | Top center | Horizontal |
205
+ | `'top-left'` | Top left | Horizontal |
206
+ | `'top-right'` | Top right | Horizontal |
207
+ | `'middle-left'` | Left center | Vertical |
208
+ | `'middle-right'` | Right center | Vertical |
209
+
210
+ The `'middle-left'` and `'middle-right'` presets render the nav bar vertically with rotated arrows.
211
+
147
212
  **Zoomable elements:**
148
213
 
149
214
  - Give the view root the class `z-view`.
150
215
  - Add class `zoom-me` and `data-to="viewName"` to the element that triggers zoom-in.
151
- - Optional per-trigger: `data-with-duration`, `data-with-ease`.
216
+ - Per-trigger overrides via `data-*` attributes:
217
+
218
+ | Attribute | Description |
219
+ |-----------|-------------|
220
+ | `data-to` | **Required.** Target view name. |
221
+ | `data-with-duration` | Override transition duration (e.g. `"2s"`). |
222
+ | `data-with-ease` | Override easing function. |
223
+ | `data-with-cover` | Override cover dimension (`"width"` or `"height"`). |
224
+ | `data-with-stagger` | Override stagger delay in ms (e.g. `"100"`). |
225
+ | `data-with-effects` | Override effects (pipe-separated: `"blur(5px)\|blur(10px)"`). |
226
+ | `data-hide-trigger` | Override hideTrigger (`"fade"` or presence = hide). |
227
+ | `data-deferred` | Override deferred rendering (presence = true). |
228
+ | `data-*` | Any other data attribute becomes a prop in `ViewContext.props`. |
152
229
 
153
230
  ```html
154
231
  <div class="z-view">
155
- <div class="zoom-me" data-to="detail" data-with-duration="2s" data-with-ease="ease-in">
232
+ <div class="zoom-me" data-to="detail"
233
+ data-with-duration="2s"
234
+ data-with-ease="ease-in"
235
+ data-with-cover="height"
236
+ data-with-stagger="100"
237
+ data-id="42">
156
238
  Zoom in
157
239
  </div>
158
240
  </div>
@@ -160,15 +242,147 @@ const app = new Zumly({
160
242
 
161
243
  ### View sources
162
244
 
163
- Views can be:
245
+ Each entry in `views` is a **view source**. The resolver detects the type and resolves to a DOM node. Hyphenated view names (e.g. `'my-dashboard'`) are resolved as keys in `views` first; only raw template strings with a hyphen are treated as web components.
246
+
247
+ | Type | Example | Cached? |
248
+ |------|---------|---------|
249
+ | **HTML string** | `'<div class="z-view">…</div>'` | Yes (indefinitely) |
250
+ | **URL** | `'/views/detail.html'`, `https://…` | Yes (5 min TTL) |
251
+ | **Async function** | `(ctx) => fetch(...).then(r => r.text())` or return `HTMLElement` | No |
252
+ | **Object with `render()`** | `{ render(ctx) { return '<div>…</div>' }, mounted?() }` | No |
253
+ | **Web component** | `'my-view'` (string with hyphen, not a key in `views`) | No |
254
+
255
+ **View pipeline:** Resolve → normalize `.z-view` → insert into canvas → call `mounted()` (if present). Static/URL views are cloned from cache on each `get()` so consumers cannot mutate the stored node.
256
+
257
+ ### Framework integration
258
+
259
+ Zumly is framework-agnostic. Since views resolve to DOM elements, any framework that can mount into a container works out of the box. Use **function views** or **object views** to bridge your framework:
260
+
261
+ **React**
262
+
263
+ ```jsx
264
+ import { createRoot } from 'react-dom/client'
265
+ import Dashboard from './Dashboard'
266
+
267
+ const app = new Zumly({
268
+ mount: '.canvas',
269
+ initialView: 'home',
270
+ views: {
271
+ home: '<div class="z-view"><div class="zoom-me" data-to="dashboard" data-id="42">Open</div></div>',
272
+ dashboard: ({ target, props }) => {
273
+ const root = createRoot(target)
274
+ root.render(<Dashboard id={props.id} />)
275
+ }
276
+ }
277
+ })
278
+ ```
279
+
280
+ **Vue**
281
+
282
+ ```js
283
+ import { createApp } from 'vue'
284
+ import Dashboard from './Dashboard.vue'
285
+
286
+ views: {
287
+ dashboard: ({ target, props }) => {
288
+ createApp(Dashboard, { id: props.id }).mount(target)
289
+ }
290
+ }
291
+ ```
292
+
293
+ **Svelte**
294
+
295
+ ```js
296
+ import Dashboard from './Dashboard.svelte'
297
+
298
+ views: {
299
+ dashboard: ({ target, props }) => {
300
+ new Dashboard({ target, props: { id: props.id } })
301
+ }
302
+ }
303
+ ```
304
+
305
+ **Angular**
306
+
307
+ ```ts
308
+ views: {
309
+ dashboard: ({ target, props }) => {
310
+ const compRef = viewContainerRef.createComponent(DashboardComponent)
311
+ compRef.instance.id = props.id
312
+ target.appendChild(compRef.location.nativeElement)
313
+ }
314
+ }
315
+ ```
316
+
317
+ **Key points:**
164
318
 
165
- - **HTML string**static markup.
166
- - **Async function** `(context) => string | Promise<string>` or return an `HTMLElement`.
167
- - **Object with `render()`** `{ render(): string | Promise<string>, mounted?(): void }`.
168
- - **URL** — path or full URL to an HTML file (fetched and cached).
169
- - **Web component tag name** string containing a hyphen (e.g. `'my-view'`); the custom element is created after it is defined.
319
+ - The `target` parameter is a fresh `<div>` created by Zumly mount your component there.
320
+ - `props` contains data attributes from the trigger element (`data-id="42"` `props.id`).
321
+ - `componentContext` (constructor option) is passed as `context` to all function/object views — use it for shared state (router, store, API client).
322
+ - Function views are **never cached** — they resolve fresh each time, so framework components get proper lifecycle management.
323
+ - Use `mounted()` (object views) for post-insertion setup it runs after the node is in the DOM.
324
+ - Zumly handles wrapped elements (e.g. Svelte's extra parent div) in its cleanup logic.
170
325
 
171
- Preloading: use `preload: ['viewA', 'viewB']` to resolve those views on init. Hovering over a `zoom-me` trigger prefetches its target view; when a view becomes current, its `zoom-me` targets are prefetched in the background.
326
+ ### Preload and prefetch
327
+
328
+ - **Eager preload:** `preload: ['viewA', 'viewB']` — those views are resolved and cached during `init()`.
329
+ - **Hover prefetch:** `mouseover` on a `.zoom-me[data-to]` trigger prefetches its target in the background.
330
+ - **Focus prefetch:** `focusin` on a `.zoom-me[data-to]` also prefetches (for keyboard/accessibility).
331
+ - **Scan prefetch:** When a view becomes current, all `.zoom-me[data-to]` targets inside it are prefetched in the background. This works on touch devices where hover is unavailable.
332
+
333
+ ### Plugins
334
+
335
+ Zumly has a lightweight plugin system. Register plugins with `.use()` before or after `init()`:
336
+
337
+ ```js
338
+ app.use(plugin, options)
339
+ ```
340
+
341
+ A plugin is an object with `install(instance, options)` or a plain function `(instance, options) => void`.
342
+
343
+ #### Router plugin
344
+
345
+ Syncs the browser URL hash with Zumly's navigation state. Browser back triggers zoom-out or lateral navigation. Forward is intentionally blocked — in a ZUI, zoom-in requires a trigger element for proper origin and animation context.
346
+
347
+ ```js
348
+ // Script tag
349
+ const app = new Zumly({ ... })
350
+ app.use(Zumly.Router)
351
+ await app.init()
352
+
353
+ // ES Module (named export from the package entry)
354
+ import { Zumly, ZumlyRouter } from 'zumly'
355
+ const app = new Zumly({ ... })
356
+ app.use(ZumlyRouter)
357
+ await app.init()
358
+ ```
359
+
360
+ The UMD/IIFE bundle attaches the same plugin as `Zumly.Router`. There is no separate published subpath for the router; import it from `'zumly'` or use `Zumly.Router` on `window` when using a script tag.
361
+
362
+ **Options:**
363
+
364
+ | Option | Type | Default | Description |
365
+ |--------|------|---------|-------------|
366
+ | `separator` | string | `'/'` | Character used to join view path segments in the hash. |
367
+ | `prefix` | string | `'/'` | Prefix before the path in the hash. |
368
+
369
+ **Behavior:**
370
+
371
+ | Action | Hash update | History |
372
+ |--------|-------------|---------|
373
+ | Zoom in | `pushState` | Enables browser back |
374
+ | Lateral | `pushState` | Enables browser back |
375
+ | Zoom out | `replaceState` | No forward entry |
376
+ | Browser back | Triggers `zoomOut()` or lateral `goTo()` | — |
377
+ | Browser forward | Blocked (`history.back()`) | — |
378
+
379
+ **Example URL:** `#/home/showcases/mercedes`
380
+
381
+ ### Limitations and non-goals
382
+
383
+ - **No deep-linking:** The router plugin syncs hash on navigation and supports browser back, but does not support forward or deep-linking (entering a multi-level URL directly). In a ZUI, zoom-in requires a trigger element for proper spatial context.
384
+ - **Resize handling:** Cheap correction when canvas resizes — translate and origin scaled by ratio; scale preserved. Correction is deferred if a transition is running.
385
+ - **Remote views:** URL-backed views use `innerHTML`; sanitize external content to avoid XSS.
172
386
 
173
387
  ## Development
174
388
 
@@ -212,13 +426,21 @@ See [CHANGELOG.md](CHANGELOG.md) for version history.
212
426
 
213
427
  ## Roadmap
214
428
 
215
- - Additional view/template adapters
216
- - Lateral navigation (same zoom level)
217
- - Navigation widget and programmatic navigation
218
- - Router integration (e.g. URL sync)
219
- - Recalculate zoom on window resize
429
+ **Done:**
430
+ - Depth and lateral navigation (`zoomIn`, `zoomOut`, `goTo`, `back`, `zoomTo`)
431
+ - Lateral nav UI (`lateralNav`: `mode` auto/always, arrows, dots, `keepAlive`)
432
+ - Depth nav UI (`depthNav`) and `navPosition` presets
433
+ - Inputs toggles (`inputs`: wheel, keyboard, click, touch)
434
+ - Plugin system (`use()`), router plugin (hash sync, back, forward blocked)
435
+ - Resize correction (translate/origin scaling; deferred while transitioning)
436
+ - Pluggable drivers (CSS, WAAPI, none, Anime.js, GSAP, Motion, custom)
437
+ - Batched zoom-out reads + math helpers to reduce reflow (see [geometry-optimization.md](docs/geometry-optimization.md))
438
+
439
+ **Planned:**
440
+ - Router deep-linking (open a multi-level hash cold)
441
+ - Accessibility (focus moves, broader ARIA)
220
442
 
221
- Details and more topics: [docs/roadMap.md](docs/roadMap.md).
443
+ Details and more topics: [docs/roadMap.md](docs/roadMap.md). Driver contract and helpers: [docs/DRIVER_API.md](docs/DRIVER_API.md).
222
444
 
223
445
  ## Community
224
446
 
@@ -226,7 +448,7 @@ Details and more topics: [docs/roadMap.md](docs/roadMap.md).
226
448
 
227
449
  ## Origin
228
450
 
229
- Zumly is a reimagined, framework-agnostic zoom engine inspired by [Zircle UI](https://github.com/zircleUI/zircleUI). It can be used together with [Orbit](https://github.com/zumerlab/orbit) for radial layouts.
451
+ Zumly is a reimagined, framework-agnostic zoom engine inspired by [Zircle UI](https://github.com/zircleUI/zircleUI). Part of the [Zumerlab](https://github.com/zumerlab) ecosystem — use it with [Orbit](https://github.com/zumerlab/orbit) for radial layouts and [SnapDOM](https://github.com/zumerlab/snapdom) for lightweight DOM diffing.
230
452
 
231
453
  ## License
232
454
 
package/dist/zumly.css CHANGED
@@ -27,26 +27,70 @@
27
27
  user-select: none;
28
28
  }
29
29
  .z-view {
30
- will-change: transform, opacity;
31
30
  content-visibility: auto;
32
31
  }
32
+ .zoom-current-view,
33
+ .zoom-current-view-reverse,
34
+ .zoom-previous-view,
35
+ .zoom-previous-view-reverse,
36
+ .zoom-last-view,
37
+ .zoom-last-view-reverse,
38
+ .zoom-lateral-back,
39
+ .zoom-lateral-out,
40
+ .zoom-lateral-in {
41
+ will-change:
42
+ transform,
43
+ opacity,
44
+ filter;
45
+ }
46
+ .z-view.has-effect {
47
+ transition: filter var(--zoom-duration, 1s) var(--zoom-ease, ease-in-out);
48
+ filter: var(--z-effect-filter, none);
49
+ }
50
+ .z-view.has-effect-reverse {
51
+ transition: filter var(--zoom-duration, 1s) var(--zoom-ease, ease-in-out);
52
+ filter: none;
53
+ }
33
54
  .z-view.hide {
34
55
  opacity: 0;
35
- content-visibility: hidden;
56
+ }
57
+ .zoom-me.z-trigger-hidden {
58
+ visibility: hidden;
59
+ }
60
+ .zoom-me.z-trigger-fade {
61
+ transition: opacity var(--zoom-duration, 1s) var(--zoom-ease, ease-in-out);
62
+ opacity: 0;
63
+ }
64
+ .zoom-me.z-trigger-fade-reverse {
65
+ transition: opacity var(--zoom-duration, 1s) var(--zoom-ease, ease-in-out);
66
+ opacity: 1;
67
+ }
68
+ .z-view.z-view-fade-in {
69
+ transition: opacity var(--zoom-duration, 1s) var(--zoom-ease, ease-in-out);
70
+ opacity: 1;
71
+ }
72
+ .z-view.z-view-fade-out {
73
+ transition: opacity var(--zoom-duration, 1s) var(--zoom-ease, ease-in-out);
74
+ opacity: 0;
36
75
  }
37
76
  .zoom-me {
38
77
  cursor: zoom-in;
39
78
  }
79
+ .zoom-me[role=button] {
80
+ -webkit-tap-highlight-color: transparent;
81
+ }
40
82
  .zoom-current-view-reverse {
41
83
  animation-name: zoom-current-view;
42
84
  animation-duration: var(--zoom-duration);
43
85
  animation-timing-function: var(--zoom-ease);
86
+ animation-fill-mode: both;
44
87
  animation-direction: reverse;
45
88
  }
46
89
  .zoom-current-view {
47
90
  animation-name: zoom-current-view;
48
91
  animation-duration: var(--zoom-duration);
49
92
  animation-timing-function: var(--zoom-ease);
93
+ animation-fill-mode: both;
50
94
  }
51
95
  @keyframes zoom-current-view {
52
96
  0% {
@@ -60,11 +104,13 @@
60
104
  animation-name: zoom-previous-view;
61
105
  animation-duration: var(--zoom-duration);
62
106
  animation-timing-function: var(--zoom-ease);
107
+ animation-fill-mode: both;
63
108
  }
64
109
  .zoom-previous-view-reverse {
65
110
  animation-name: zoom-previous-view;
66
111
  animation-duration: var(--zoom-duration);
67
112
  animation-timing-function: var(--zoom-ease);
113
+ animation-fill-mode: both;
68
114
  animation-direction: reverse;
69
115
  }
70
116
  @keyframes zoom-previous-view {
@@ -79,12 +125,14 @@
79
125
  animation-name: zoom-last-view;
80
126
  animation-duration: var(--zoom-duration);
81
127
  animation-timing-function: var(--zoom-ease);
128
+ animation-fill-mode: both;
82
129
  animation-direction: reverse;
83
130
  }
84
131
  .zoom-last-view {
85
132
  animation-name: zoom-last-view;
86
133
  animation-duration: var(--zoom-duration);
87
134
  animation-timing-function: var(--zoom-ease);
135
+ animation-fill-mode: both;
88
136
  }
89
137
  @keyframes zoom-last-view {
90
138
  0% {
@@ -94,3 +142,196 @@
94
142
  transform: var(--last-view-transform-end);
95
143
  }
96
144
  }
145
+ .zoom-lateral-back {
146
+ animation: zoom-lateral-back var(--zoom-duration) var(--zoom-ease) forwards;
147
+ }
148
+ @keyframes zoom-lateral-back {
149
+ 0% {
150
+ transform: var(--lateral-from);
151
+ }
152
+ 100% {
153
+ transform: var(--lateral-to);
154
+ }
155
+ }
156
+ .zoom-lateral-out {
157
+ animation: zoom-lateral-out var(--zoom-duration) var(--zoom-ease) forwards;
158
+ }
159
+ @keyframes zoom-lateral-out {
160
+ 0% {
161
+ transform: var(--lateral-out-from);
162
+ opacity: 1;
163
+ }
164
+ 100% {
165
+ transform: var(--lateral-out-to);
166
+ opacity: 0;
167
+ }
168
+ }
169
+ .zoom-lateral-in {
170
+ animation: zoom-lateral-in var(--zoom-duration) var(--zoom-ease) forwards;
171
+ }
172
+ @keyframes zoom-lateral-in {
173
+ 0% {
174
+ transform: var(--lateral-in-from);
175
+ opacity: 0;
176
+ }
177
+ 100% {
178
+ transform: var(--lateral-in-to);
179
+ opacity: 1;
180
+ }
181
+ }
182
+ .z-nav {
183
+ position: absolute;
184
+ z-index: 10;
185
+ display: flex;
186
+ align-items: center;
187
+ gap: 0;
188
+ pointer-events: none;
189
+ background: rgba(0, 0, 0, 0.5);
190
+ backdrop-filter: blur(12px);
191
+ border: 1px solid rgba(255, 255, 255, 0.12);
192
+ border-radius: 100px;
193
+ padding: 6px 10px;
194
+ }
195
+ .z-nav--bottom-center {
196
+ bottom: 20px;
197
+ left: 50%;
198
+ transform: translateX(-50%);
199
+ }
200
+ .z-nav--bottom-left {
201
+ bottom: 20px;
202
+ left: 20px;
203
+ }
204
+ .z-nav--bottom-right {
205
+ bottom: 20px;
206
+ right: 20px;
207
+ }
208
+ .z-nav--top-center {
209
+ top: 20px;
210
+ left: 50%;
211
+ transform: translateX(-50%);
212
+ }
213
+ .z-nav--top-left {
214
+ top: 20px;
215
+ left: 20px;
216
+ }
217
+ .z-nav--top-right {
218
+ top: 20px;
219
+ right: 20px;
220
+ }
221
+ .z-nav--middle-left {
222
+ top: 50%;
223
+ left: 16px;
224
+ transform: translateY(-50%);
225
+ flex-direction: column;
226
+ }
227
+ .z-nav--middle-right {
228
+ top: 50%;
229
+ right: 16px;
230
+ transform: translateY(-50%);
231
+ flex-direction: column;
232
+ }
233
+ .z-nav > * {
234
+ pointer-events: auto;
235
+ }
236
+ .z-nav-depth,
237
+ .z-nav-lateral {
238
+ display: flex;
239
+ align-items: center;
240
+ gap: 8px;
241
+ }
242
+ .z-nav-sep {
243
+ width: 1px;
244
+ height: 18px;
245
+ background: rgba(255, 255, 255, 0.15);
246
+ margin: 0 8px;
247
+ flex-shrink: 0;
248
+ }
249
+ .z-nav--middle-left .z-nav-depth,
250
+ .z-nav--middle-left .z-nav-lateral,
251
+ .z-nav--middle-right .z-nav-depth,
252
+ .z-nav--middle-right .z-nav-lateral {
253
+ flex-direction: column;
254
+ }
255
+ .z-nav--middle-left .z-nav-depth-dots,
256
+ .z-nav--middle-left .z-nav-lateral-dots,
257
+ .z-nav--middle-right .z-nav-depth-dots,
258
+ .z-nav--middle-right .z-nav-lateral-dots {
259
+ flex-direction: column;
260
+ }
261
+ .z-nav--middle-left .z-nav-sep,
262
+ .z-nav--middle-right .z-nav-sep {
263
+ width: 18px;
264
+ height: 1px;
265
+ margin: 8px 0;
266
+ }
267
+ .z-nav--middle-left .z-nav-back,
268
+ .z-nav--middle-right .z-nav-back {
269
+ transform: rotate(180deg);
270
+ }
271
+ .z-nav--middle-left .z-nav-prev,
272
+ .z-nav--middle-right .z-nav-prev {
273
+ transform: rotate(90deg);
274
+ }
275
+ .z-nav--middle-left .z-nav-next,
276
+ .z-nav--middle-right .z-nav-next {
277
+ transform: rotate(90deg);
278
+ }
279
+ .z-nav-back {
280
+ transform: rotate(90deg);
281
+ }
282
+ .z-nav-back,
283
+ .z-nav-arrow {
284
+ width: 28px;
285
+ height: 28px;
286
+ border-radius: 50%;
287
+ border: 1px solid rgba(255, 255, 255, 0.25);
288
+ background: rgba(255, 255, 255, 0.08);
289
+ color: #fff;
290
+ font-size: 1rem;
291
+ line-height: 1;
292
+ cursor: pointer;
293
+ display: flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ transition: background 0.15s, border-color 0.15s;
297
+ padding: 0;
298
+ }
299
+ .z-nav-back:hover,
300
+ .z-nav-arrow:hover:not(:disabled) {
301
+ background: rgba(255, 255, 255, 0.18);
302
+ border-color: rgba(255, 255, 255, 0.4);
303
+ }
304
+ .z-nav-arrow:disabled {
305
+ opacity: 0.2;
306
+ cursor: default;
307
+ }
308
+ .z-nav-depth-dots,
309
+ .z-nav-lateral-dots {
310
+ display: flex;
311
+ align-items: center;
312
+ gap: 5px;
313
+ }
314
+ .z-nav-dot {
315
+ width: 7px;
316
+ height: 7px;
317
+ border-radius: 50%;
318
+ border: 1px solid rgba(255, 255, 255, 0.3);
319
+ background: rgba(255, 255, 255, 0.15);
320
+ padding: 0;
321
+ cursor: default;
322
+ transition:
323
+ background 0.2s,
324
+ transform 0.2s,
325
+ border-color 0.2s;
326
+ }
327
+ .z-nav-lat-dot {
328
+ cursor: pointer;
329
+ }
330
+ .z-nav-lat-dot:hover {
331
+ background: rgba(255, 255, 255, 0.4);
332
+ }
333
+ .z-nav-dot.is-active {
334
+ background: #fff;
335
+ border-color: #fff;
336
+ transform: scale(1.35);
337
+ }