@honeydeck/honeydeck 0.8.0 → 0.10.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.
@@ -16,7 +16,7 @@ Defined in the first frontmatter block of the deck entry file (before any slide
16
16
  | `colorMode` | `"system" \| "light" \| "dark"` | `"system"` | Browser color mode |
17
17
  | `pdfColorMode` | `"light" \| "dark"` | unset | Optional PDF color mode; when unset, falls back to pinned `colorMode`, then `light` |
18
18
  | `pdfSteps` | `"final" \| "all"` | `"final"` | PDF includes all steps or final state |
19
- | `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, or custom CSS name) |
19
+ | `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, `magic`, or custom CSS name) |
20
20
  | `transitionDuration` | `number` | `200` | Default slide transition duration in milliseconds |
21
21
  | `transitionEasing` | `string` | `ease` | Default slide transition timing function |
22
22
  | `magicCodeDuration` | `number` | `800` | Default Magic Code animation duration in milliseconds |
@@ -53,11 +53,12 @@ A subtle named `fade` transition is applied between slides by default:
53
53
  ```yaml
54
54
  transition: fade # default
55
55
  transition: none # disable
56
+ transition: magic # explicit data-magic-id FLIP movement
56
57
  ```
57
58
 
58
59
  Legacy booleans still work: `transition: true` maps to `fade`, and `transition: false` maps to `none`.
59
60
 
60
- For built-ins, custom CSS transitions, duration, and easing, see [Transitions](transitions.md).
61
+ For built-ins, magic `data-magic-id` matching, custom CSS transitions, duration, and easing, see [Transitions](transitions.md).
61
62
 
62
63
  ### Magic Code Duration
63
64
 
@@ -25,6 +25,7 @@ Built-in transition names:
25
25
  - `fade` — default crossfade
26
26
  - `none` — no slide transition
27
27
  - `slide-left` — horizontal slide, reverse-aware when navigating backward
28
+ - `magic` — explicit `data-magic-id` element movement with FLIP overlay clones
28
29
 
29
30
  ## Slide overrides
30
31
 
@@ -42,6 +43,38 @@ transitionEasing: cubic-bezier(0.22, 1, 0.36, 1)
42
43
 
43
44
  Slides without transition frontmatter use the deck defaults.
44
45
 
46
+ ## Magic transition
47
+
48
+ `transition: magic` moves only elements you explicitly tag with matching `data-magic-id` values. Honeydeck keeps the outgoing and incoming slides mounted, measures tagged elements at runtime, hides the original tagged elements, animates fixed-position overlay clones, then restores the real slide DOM after the transition.
49
+
50
+ ```mdx
51
+ # Before
52
+
53
+ <span data-magic-id="world">World</span>
54
+
55
+ ---
56
+ transition: magic
57
+ transitionDuration: 600
58
+ transitionEasing: ease-in-out
59
+ ---
60
+
61
+ # After
62
+
63
+ Hello <span data-magic-id="world">World</span>
64
+ ```
65
+
66
+ Matching rules:
67
+
68
+ - Same `data-magic-id` on both slides → the element moves.
69
+ - ID only on the previous slide → the element fades out.
70
+ - ID only on the incoming slide → the element fades in.
71
+ - Untagged content crossfades with the slide layers.
72
+ - Different IDs never match, even when the text is identical.
73
+
74
+ Magic is forward-only. When navigating backward into a slide that uses `transition: magic`, Honeydeck falls back to the layer crossfade instead of guessing a reverse morph.
75
+
76
+ Honeydeck does not perform automatic DOM diffing or text/tag heuristics. Layout chrome such as the navigation bar and global slide numbers is outside slide content and switches immediately unless you explicitly tag slide content yourself.
77
+
45
78
  ## Custom transitions
46
79
 
47
80
  Any transition name that is not built in becomes a CSS hook. For example:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@honeydeck/honeydeck",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "MDX and React-based presentation framework for AI-friendly slide decks.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -47,6 +47,7 @@ import {
47
47
  EffectiveColorModeProvider,
48
48
  } from "./EffectiveColorModeContext.tsx";
49
49
  import { rememberSlideRoute } from "./lastSlideRoute.ts";
50
+ import { startMagicTransition } from "./magicTransition.ts";
50
51
  import {
51
52
  closeOverview,
52
53
  toggleOverview as toggleOverviewRoute,
@@ -85,7 +86,9 @@ function calcScaleFromElement(el: HTMLElement | null): number | null {
85
86
 
86
87
  type SlideTransitionState = {
87
88
  from: number;
89
+ fromStep: number;
88
90
  to: number;
91
+ toStep: number;
89
92
  name: string;
90
93
  className: string;
91
94
  duration: number;
@@ -134,7 +137,13 @@ function getTransitionOptions(
134
137
  slideIndex: number,
135
138
  ): Omit<
136
139
  SlideTransitionState,
137
- "from" | "to" | "direction" | "enterFromOpacity" | "exitFromOpacity"
140
+ | "from"
141
+ | "fromStep"
142
+ | "to"
143
+ | "toStep"
144
+ | "direction"
145
+ | "enterFromOpacity"
146
+ | "exitFromOpacity"
138
147
  > {
139
148
  const frontmatter = slideData[slideIndex]?.frontmatter ?? {};
140
149
  const name = normalizeTransitionName(
@@ -338,36 +347,51 @@ export function Deck() {
338
347
  1,
339
348
  Math.min(route.slide, slideData.length || 1),
340
349
  );
341
- const previousSlideRef = useRef<number | null>(null);
350
+ const currentStep = Math.max(0, route.step);
351
+ const previousRouteRef = useRef<{ slide: number; step: number } | null>(null);
342
352
  const slideLayerRefs = useRef<Record<number, HTMLDivElement | null>>({});
343
353
  const [slideTransition, setSlideTransition] =
344
354
  useState<SlideTransitionState | null>(null);
345
355
  const slideTransitionRef = useRef<SlideTransitionState | null>(null);
346
356
  slideTransitionRef.current = slideTransition;
357
+ const magicTransitionScale = scale * slideZoom;
347
358
 
348
359
  useLayoutEffect(() => {
349
360
  if (route.view !== "slide") {
350
- previousSlideRef.current = currentSlide;
361
+ previousRouteRef.current = { slide: currentSlide, step: currentStep };
351
362
  setSlideTransition(null);
352
363
  return;
353
364
  }
354
365
 
355
366
  if (reducedMotion) {
356
- previousSlideRef.current = currentSlide;
367
+ previousRouteRef.current = { slide: currentSlide, step: currentStep };
357
368
  setSlideTransition(null);
358
369
  return;
359
370
  }
360
371
 
361
- const previousSlide = previousSlideRef.current;
362
- if (previousSlide === null) {
363
- previousSlideRef.current = currentSlide;
372
+ const previousRoute = previousRouteRef.current;
373
+ if (previousRoute === null) {
374
+ previousRouteRef.current = { slide: currentSlide, step: currentStep };
375
+ return;
376
+ }
377
+ if (previousRoute.slide === currentSlide) {
378
+ previousRouteRef.current = { slide: currentSlide, step: currentStep };
379
+ setSlideTransition((active) =>
380
+ active?.to === currentSlide && active.toStep !== currentStep
381
+ ? null
382
+ : active,
383
+ );
364
384
  return;
365
385
  }
366
- if (previousSlide === currentSlide) return;
367
386
 
387
+ const previousSlide = previousRoute.slide;
368
388
  const direction: 1 | -1 = currentSlide > previousSlide ? 1 : -1;
369
- const options = getTransitionOptions(currentSlide - 1);
370
- previousSlideRef.current = currentSlide;
389
+ const rawOptions = getTransitionOptions(currentSlide - 1);
390
+ const options =
391
+ rawOptions.name === "magic" && direction === -1
392
+ ? { ...rawOptions, name: "fade", className: "fade" }
393
+ : rawOptions;
394
+ previousRouteRef.current = { slide: currentSlide, step: currentStep };
371
395
 
372
396
  if (options.name === "none" || options.duration === 0) {
373
397
  setSlideTransition(null);
@@ -379,7 +403,9 @@ export function Deck() {
379
403
  const nextTransition = {
380
404
  ...options,
381
405
  from: previousSlide,
406
+ fromStep: previousRoute.step,
382
407
  to: currentSlide,
408
+ toStep: currentStep,
383
409
  direction,
384
410
  enterFromOpacity: isInterruptingFade
385
411
  ? (readLayerOpacity(slideLayerRefs.current[currentSlide]) ?? 0)
@@ -399,7 +425,19 @@ export function Deck() {
399
425
  }, options.duration);
400
426
 
401
427
  return () => window.clearTimeout(timeout);
402
- }, [currentSlide, reducedMotion, route.view]);
428
+ }, [currentSlide, currentStep, reducedMotion, route.view]);
429
+
430
+ useLayoutEffect(() => {
431
+ if (!slideTransition || slideTransition.name !== "magic") return;
432
+ return startMagicTransition({
433
+ fromLayer: slideLayerRefs.current[slideTransition.from],
434
+ toLayer: slideLayerRefs.current[slideTransition.to],
435
+ duration: slideTransition.duration,
436
+ easing: slideTransition.easing,
437
+ scale: magicTransitionScale,
438
+ direction: slideTransition.direction,
439
+ });
440
+ }, [magicTransitionScale, slideTransition]);
403
441
 
404
442
  // ── Reference mode: delegate to DocsView ─────────────────────────────
405
443
  if (route.view === "kit") {
@@ -431,7 +469,6 @@ export function Deck() {
431
469
  );
432
470
  }
433
471
 
434
- const currentStep = Math.max(0, route.step);
435
472
  const controlRoute =
436
473
  route.view === "slide" || route.view === "overview"
437
474
  ? { ...route, slide: currentSlide, step: currentStep }
@@ -486,6 +523,14 @@ export function Deck() {
486
523
  ? "exit"
487
524
  : null;
488
525
  const isVisible = isCurrent || transitionRole !== null;
526
+ const slideStepIndex =
527
+ transitionRole === "exit" && activeTransition
528
+ ? activeTransition.fromStep
529
+ : transitionRole === "enter" && activeTransition
530
+ ? activeTransition.toStep
531
+ : isCurrent
532
+ ? currentStep
533
+ : 0;
489
534
  const transitionLayerClass =
490
535
  transitionRole && activeTransition
491
536
  ? `honeydeck-transition-${activeTransition.className} honeydeck-transition-${transitionRole}`
@@ -546,7 +591,7 @@ export function Deck() {
546
591
  }}
547
592
  >
548
593
  <TimelineProvider
549
- stepIndex={isCurrent ? currentStep : 0}
594
+ stepIndex={slideStepIndex}
550
595
  stepCount={stepCount}
551
596
  >
552
597
  <SlideScaleProvider scale={activeSlideScale}>
@@ -231,10 +231,12 @@ transitionDuration: 200
231
231
  transitionEasing: ease
232
232
  ```
233
233
 
234
- Built-in transition names are `fade`, `none`, and `slide-left`. Any other string is exposed as a custom CSS hook on the participating slide layers. Legacy `transition: true` maps to `fade`, and `transition: false` maps to `none`.
234
+ Built-in transition names are `fade`, `none`, `slide-left`, and `magic`. Any other string is exposed as a custom CSS hook on the participating slide layers. Legacy `transition: true` maps to `fade`, and `transition: false` maps to `none`.
235
235
 
236
236
  During slide navigation, Honeydeck keeps the outgoing and incoming slide layers mounted inside a scaled slide-sized clipping viewport, applies `honeydeck-slide-layer`, `honeydeck-transition-{name}`, and either `honeydeck-transition-enter` or `honeydeck-transition-exit` only to those two layers, then clears transition state after the configured duration. Transition visuals are clipped to the slide canvas area and must not animate into letterbox or pillarbox bars around the slide. If the next transition is `none` or navigation is interrupted, stale transition state is cleared/replaced so old slides do not remain visible. Outgoing layers are visible during the transition but have pointer events disabled. The built-in `fade` transition uses keyframes and, when a fade is interrupted, starts the next fade from the participating layers' current computed opacity so quick back-and-forth navigation stays close to the old opacity-transition behavior.
237
237
 
238
+ The built-in `magic` transition is forward-only FLIP behavior for explicitly tagged elements. On forward navigation into a slide with `transition: magic`, Honeydeck measures only elements with `data-magic-id` in the outgoing and incoming slide DOM. Equal IDs move through fixed overlay clones; IDs present only on the outgoing slide fade out through clones; IDs present only on the incoming slide fade in through clones. Untagged slide content still crossfades as part of the slide layers. Honeydeck does not diff arbitrary DOM, text, or tag names, and equal text with different IDs must not match. During magic transitions, Honeydeck hides original tagged elements on both participating slides, copies computed styles recursively onto overlay clones, removes IDs from clones to avoid duplicate document IDs, and accounts for current slide scale when sizing and transforming clones. The magic overlay restores originals and removes itself when animations finish, when navigation interrupts the transition, or after a short timeout fallback. If clone or Web Animations API setup fails, Honeydeck leaves tagged originals visible and uses the layer crossfade. Backward navigation with `transition: magic` falls back to the layer crossfade without overlay matching.
239
+
238
240
  Participating slide layers receive CSS variables: `--honeydeck-transition-duration`, `--honeydeck-transition-easing`, and `--honeydeck-transition-direction` (`1` forward, `-1` backward). Built-in `slide-left` uses the direction variable so backward navigation reverses direction. Custom transition CSS can use the same variable for opt-in reverse awareness. Reduced-motion preferences disable slide transition animations.
239
241
 
240
242
  ### Aspect Ratio
@@ -0,0 +1,367 @@
1
+ const MAGIC_SELECTOR = "[data-magic-id]";
2
+
3
+ type MagicElementSnapshot = {
4
+ element: HTMLElement;
5
+ rect: DOMRect;
6
+ };
7
+
8
+ type HiddenOriginal = {
9
+ element: HTMLElement;
10
+ opacity: string;
11
+ transition: string;
12
+ };
13
+
14
+ type MagicClonePlan = {
15
+ wrapper: HTMLDivElement;
16
+ fromRect: DOMRect;
17
+ toRect: DOMRect;
18
+ fromOpacity: number;
19
+ toOpacity: number;
20
+ };
21
+
22
+ export type MagicTransitionOptions = {
23
+ fromLayer: HTMLElement | null | undefined;
24
+ toLayer: HTMLElement | null | undefined;
25
+ duration: number;
26
+ easing: string;
27
+ scale: number;
28
+ direction: 1 | -1;
29
+ };
30
+
31
+ export function startMagicTransition({
32
+ fromLayer,
33
+ toLayer,
34
+ duration,
35
+ easing,
36
+ scale,
37
+ direction,
38
+ }: MagicTransitionOptions): () => void {
39
+ if (direction !== 1 || !fromLayer || !toLayer || scale <= 0) {
40
+ return () => {};
41
+ }
42
+
43
+ const fromElements = collectMagicElements(fromLayer);
44
+ const toElements = collectMagicElements(toLayer);
45
+ const ids = new Set([...fromElements.keys(), ...toElements.keys()]);
46
+ if (ids.size === 0) return () => {};
47
+
48
+ const overlay = document.createElement("div");
49
+ overlay.setAttribute("aria-hidden", "true");
50
+ overlay.style.position = "fixed";
51
+ overlay.style.inset = "0";
52
+ overlay.style.pointerEvents = "none";
53
+ overlay.style.zIndex = "90";
54
+ overlay.style.overflow = "visible";
55
+ overlay.style.contain = "layout style paint";
56
+
57
+ let plans: MagicClonePlan[];
58
+ try {
59
+ plans = createMagicClonePlans(ids, fromElements, toElements, scale);
60
+ } catch {
61
+ overlay.remove();
62
+ return () => {};
63
+ }
64
+
65
+ if (plans.length === 0) return () => {};
66
+ if (typeof plans[0]?.wrapper.animate !== "function") return () => {};
67
+
68
+ const hiddenOriginals = hideOriginals(fromElements, toElements);
69
+ const animations: Animation[] = [];
70
+ let cleaned = false;
71
+ let timeout: number | null = null;
72
+
73
+ function cleanup() {
74
+ if (cleaned) return;
75
+ cleaned = true;
76
+ if (timeout !== null) {
77
+ window.clearTimeout(timeout);
78
+ timeout = null;
79
+ }
80
+ for (const animation of animations) {
81
+ if (animation.playState !== "finished") animation.cancel();
82
+ }
83
+ restoreOriginals(hiddenOriginals);
84
+ overlay.remove();
85
+ }
86
+
87
+ try {
88
+ document.body.appendChild(overlay);
89
+
90
+ for (const plan of plans) {
91
+ overlay.appendChild(plan.wrapper);
92
+ const offset = alignCloneToRect(plan.wrapper, plan.fromRect, scale);
93
+ applyCloneFrame(
94
+ plan.wrapper,
95
+ plan.fromRect,
96
+ plan.fromOpacity,
97
+ scale,
98
+ offset,
99
+ );
100
+ const animation = animateClone({
101
+ ...plan,
102
+ duration,
103
+ easing,
104
+ scale,
105
+ offsetX: offset.x,
106
+ offsetY: offset.y,
107
+ });
108
+ if (!animation) throw new Error("Magic transition animation failed");
109
+ animations.push(animation);
110
+ }
111
+ } catch {
112
+ cleanup();
113
+ return () => {};
114
+ }
115
+
116
+ void Promise.allSettled(
117
+ animations.map((animation) => animation.finished),
118
+ ).then(cleanup);
119
+ timeout = window.setTimeout(cleanup, duration + 50);
120
+
121
+ return cleanup;
122
+ }
123
+
124
+ function collectMagicElements(
125
+ root: HTMLElement,
126
+ ): Map<string, MagicElementSnapshot> {
127
+ const elements = new Map<string, MagicElementSnapshot>();
128
+ for (const element of root.querySelectorAll<HTMLElement>(MAGIC_SELECTOR)) {
129
+ const id = element.dataset.magicId?.trim();
130
+ if (!id || elements.has(id)) continue;
131
+
132
+ const rect = element.getBoundingClientRect();
133
+ if (rect.width <= 0 && rect.height <= 0) continue;
134
+ elements.set(id, { element, rect });
135
+ }
136
+ return elements;
137
+ }
138
+
139
+ function createMagicClonePlans(
140
+ ids: Set<string>,
141
+ fromElements: Map<string, MagicElementSnapshot>,
142
+ toElements: Map<string, MagicElementSnapshot>,
143
+ scale: number,
144
+ ): MagicClonePlan[] {
145
+ const plans: MagicClonePlan[] = [];
146
+ for (const id of ids) {
147
+ const from = fromElements.get(id);
148
+ const to = toElements.get(id);
149
+ if (from && to) {
150
+ plans.push({
151
+ wrapper: createMagicClone(to.element, to.rect, scale),
152
+ fromRect: from.rect,
153
+ toRect: to.rect,
154
+ fromOpacity: 1,
155
+ toOpacity: 1,
156
+ });
157
+ continue;
158
+ }
159
+
160
+ if (from) {
161
+ plans.push({
162
+ wrapper: createMagicClone(from.element, from.rect, scale),
163
+ fromRect: from.rect,
164
+ toRect: from.rect,
165
+ fromOpacity: 1,
166
+ toOpacity: 0,
167
+ });
168
+ continue;
169
+ }
170
+
171
+ if (to) {
172
+ plans.push({
173
+ wrapper: createMagicClone(to.element, to.rect, scale),
174
+ fromRect: to.rect,
175
+ toRect: to.rect,
176
+ fromOpacity: 0,
177
+ toOpacity: 1,
178
+ });
179
+ }
180
+ }
181
+ return plans;
182
+ }
183
+
184
+ function hideOriginals(
185
+ fromElements: Map<string, MagicElementSnapshot>,
186
+ toElements: Map<string, MagicElementSnapshot>,
187
+ ): HiddenOriginal[] {
188
+ const originals = new Set<HTMLElement>();
189
+ for (const { element } of fromElements.values()) originals.add(element);
190
+ for (const { element } of toElements.values()) originals.add(element);
191
+
192
+ return Array.from(originals, (element) => {
193
+ const hidden = {
194
+ element,
195
+ opacity: element.style.opacity,
196
+ transition: element.style.transition,
197
+ };
198
+ element.style.transition = "none";
199
+ element.style.opacity = "0";
200
+ return hidden;
201
+ });
202
+ }
203
+
204
+ function restoreOriginals(originals: HiddenOriginal[]) {
205
+ for (const { element, opacity, transition } of originals) {
206
+ element.style.opacity = opacity;
207
+ element.style.transition = transition;
208
+ }
209
+ }
210
+
211
+ function createMagicClone(
212
+ source: HTMLElement,
213
+ rect: DOMRect,
214
+ scale: number,
215
+ ): HTMLDivElement {
216
+ const wrapper = document.createElement("div");
217
+ wrapper.style.position = "fixed";
218
+ wrapper.style.left = "0px";
219
+ wrapper.style.top = "0px";
220
+ wrapper.style.margin = "0";
221
+ wrapper.style.width = `${rect.width / scale}px`;
222
+ wrapper.style.height = `${rect.height / scale}px`;
223
+ wrapper.style.pointerEvents = "none";
224
+ wrapper.style.transformOrigin = "top left";
225
+ wrapper.style.transform = transformForRect(rect, scale, 0, 0);
226
+ wrapper.style.opacity = "1";
227
+ wrapper.style.overflow = "visible";
228
+ wrapper.style.lineHeight = "0";
229
+
230
+ const clone = source.cloneNode(true) as HTMLElement;
231
+ const sourceDisplay = window.getComputedStyle(source).display;
232
+ copyComputedStyles(source, clone);
233
+ clone.removeAttribute("id");
234
+ for (const descendant of clone.querySelectorAll("[id]")) {
235
+ descendant.removeAttribute("id");
236
+ }
237
+ clone.style.boxSizing = "border-box";
238
+ clone.style.transform = "none";
239
+ if (sourceDisplay === "inline") {
240
+ clone.style.display = "inline";
241
+ clone.style.width = "auto";
242
+ clone.style.height = "auto";
243
+ } else {
244
+ clone.style.width = "100%";
245
+ clone.style.height = "100%";
246
+ }
247
+ clone.style.margin = "0";
248
+ clone.style.pointerEvents = "none";
249
+ clone.style.transformOrigin = "top left";
250
+ clone.style.verticalAlign = "top";
251
+ wrapper.appendChild(clone);
252
+ return wrapper;
253
+ }
254
+
255
+ function copyComputedStyles(source: Element, clone: Element) {
256
+ if (!(clone instanceof HTMLElement)) return;
257
+
258
+ const computed = window.getComputedStyle(source);
259
+ for (let index = 0; index < computed.length; index += 1) {
260
+ const property = computed.item(index);
261
+ clone.style.setProperty(
262
+ property,
263
+ computed.getPropertyValue(property),
264
+ computed.getPropertyPriority(property),
265
+ );
266
+ }
267
+
268
+ const sourceChildren = Array.from(source.children);
269
+ const cloneChildren = Array.from(clone.children);
270
+ for (let index = 0; index < sourceChildren.length; index += 1) {
271
+ const sourceChild = sourceChildren[index];
272
+ const cloneChild = cloneChildren[index];
273
+ if (sourceChild && cloneChild) copyComputedStyles(sourceChild, cloneChild);
274
+ }
275
+ }
276
+
277
+ function alignCloneToRect(
278
+ wrapper: HTMLDivElement,
279
+ rect: DOMRect,
280
+ scale: number,
281
+ ): { x: number; y: number } {
282
+ const wrapperRect = wrapper.getBoundingClientRect();
283
+ const cloneRect =
284
+ wrapper.firstElementChild?.getBoundingClientRect() ?? wrapperRect;
285
+ const x = cloneRect.left - wrapperRect.left;
286
+ const y = cloneRect.top - wrapperRect.top;
287
+ wrapper.style.left = "0px";
288
+ wrapper.style.top = "0px";
289
+ wrapper.style.transform = transformForRect(rect, scale, x, y);
290
+ return { x, y };
291
+ }
292
+
293
+ function applyCloneFrame(
294
+ wrapper: HTMLDivElement,
295
+ rect: DOMRect,
296
+ opacity: number,
297
+ scale: number,
298
+ offset: { x: number; y: number },
299
+ ) {
300
+ wrapper.style.width = `${rect.width / scale}px`;
301
+ wrapper.style.height = `${rect.height / scale}px`;
302
+ wrapper.style.opacity = `${opacity}`;
303
+ wrapper.style.transform = transformForRect(rect, scale, offset.x, offset.y);
304
+ }
305
+
306
+ function animateClone({
307
+ wrapper,
308
+ fromRect,
309
+ toRect,
310
+ fromOpacity,
311
+ toOpacity,
312
+ duration,
313
+ easing,
314
+ scale,
315
+ offsetX,
316
+ offsetY,
317
+ }: MagicClonePlan & {
318
+ duration: number;
319
+ easing: string;
320
+ scale: number;
321
+ offsetX: number;
322
+ offsetY: number;
323
+ }): Animation | null {
324
+ const keyframes: Keyframe[] = [
325
+ {
326
+ width: `${fromRect.width / scale}px`,
327
+ height: `${fromRect.height / scale}px`,
328
+ opacity: fromOpacity,
329
+ transform: transformForRect(fromRect, scale, offsetX, offsetY),
330
+ },
331
+ {
332
+ width: `${toRect.width / scale}px`,
333
+ height: `${toRect.height / scale}px`,
334
+ opacity: toOpacity,
335
+ transform: transformForRect(toRect, scale, offsetX, offsetY),
336
+ },
337
+ ];
338
+
339
+ if (typeof wrapper.animate !== "function") return null;
340
+
341
+ try {
342
+ return wrapper.animate(keyframes, {
343
+ duration,
344
+ easing,
345
+ fill: "both",
346
+ });
347
+ } catch {
348
+ try {
349
+ return wrapper.animate(keyframes, {
350
+ duration,
351
+ easing: "ease",
352
+ fill: "both",
353
+ });
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+ }
359
+
360
+ function transformForRect(
361
+ rect: DOMRect,
362
+ scale: number,
363
+ offsetX: number,
364
+ offsetY: number,
365
+ ): string {
366
+ return `translate(${rect.left - offsetX}px, ${rect.top - offsetY}px) scale(${scale})`;
367
+ }
@@ -547,6 +547,20 @@
547
547
  animation-fill-mode: both;
548
548
  }
549
549
 
550
+ .honeydeck-slide-layer.honeydeck-transition-magic.honeydeck-transition-enter {
551
+ animation-name: honeydeck-transition-fade-enter;
552
+ animation-duration: var(--honeydeck-transition-duration, 200ms);
553
+ animation-timing-function: var(--honeydeck-transition-easing, ease);
554
+ animation-fill-mode: both;
555
+ }
556
+
557
+ .honeydeck-slide-layer.honeydeck-transition-magic.honeydeck-transition-exit {
558
+ animation-name: honeydeck-transition-fade-exit;
559
+ animation-duration: var(--honeydeck-transition-duration, 200ms);
560
+ animation-timing-function: var(--honeydeck-transition-easing, ease);
561
+ animation-fill-mode: both;
562
+ }
563
+
550
564
  @keyframes honeydeck-transition-fade-enter {
551
565
  from {
552
566
  opacity: var(--honeydeck-transition-enter-from-opacity, 0);
@@ -108,7 +108,7 @@ All settings use **camelCase**. No separate config file exists. Frontmatter pars
108
108
  | `colorMode` | `"system" \| "light" \| "dark"` | `"system"` | Browser color mode |
109
109
  | `pdfColorMode` | `"light" \| "dark"` | unset | Optional explicit PDF color mode; when unset, PDF falls back to pinned deck `colorMode`, then `light` |
110
110
  | `pdfSteps` | `"final" \| "all"` | `"final"` | Whether PDF includes all steps or final state |
111
- | `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, or a custom CSS name); legacy `true` maps to `fade` and `false` maps to `none` |
111
+ | `transition` | `string \| boolean` | `fade` | Default named slide transition (`fade`, `none`, `slide-left`, `magic`, or a custom CSS name); legacy `true` maps to `fade` and `false` maps to `none` |
112
112
  | `transitionDuration` | `number` | `200` | Default slide transition duration in milliseconds |
113
113
  | `transitionEasing` | `string` | `ease` | Default slide transition timing function |
114
114
  | `magicCodeDuration` | `number` | `800` | Default Magic Code animation duration in milliseconds |
@@ -132,6 +132,6 @@ The first frontmatter block in the deck entry file is parsed as deck config. Dec
132
132
 
133
133
  Slide-level frontmatter is a frontmatter-only block after a slide separator and applies to the following slide. Imported MDX files are normal MDX modules and cannot set deck-level properties. `magicCodeDuration` is deck-level only; the same key in slide-level frontmatter is treated as a normal layout prop and does not configure Magic Code.
134
134
 
135
- Invalid `aspectRatio`, `colorMode`, and `pdfSteps` values fall back to defaults. Invalid `pdfColorMode` is ignored as unset, allowing the pinned `colorMode` fallback. `showSlideNumbers` is enabled only by literal `true`; slide transition values normalize at runtime, with non-empty strings treated as named built-ins or custom CSS hooks. Invalid explicit Magic Code block `duration` values are compile errors; invalid deck-level `magicCodeDuration` falls back to the default Magic Code duration.
135
+ Invalid `aspectRatio`, `colorMode`, and `pdfSteps` values fall back to defaults. Invalid `pdfColorMode` is ignored as unset, allowing the pinned `colorMode` fallback. `showSlideNumbers` is enabled only by literal `true`; slide transition values normalize at runtime, with non-empty strings treated as named built-ins or custom CSS hooks. `transition: magic` uses only runtime `data-magic-id` matching and does not add build-time DOM diffing. Invalid explicit Magic Code block `duration` values are compile errors; invalid deck-level `magicCodeDuration` falls back to the default Magic Code duration.
136
136
 
137
137
  During development, changes to deck-level frontmatter invalidate the virtual config and every compiled virtual slide module, because slide compilation can depend on deck settings such as `magicCodeDuration`. Layout-related virtual modules are invalidated as before so layout map and demo previews stay current.