@markdy/renderer-dom 0.5.8 → 0.7.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
@@ -5,10 +5,12 @@ Web Animations API renderer for [MarkdyScript](../../docs/SYNTAX.md) scenes. Tra
5
5
  ## Features
6
6
 
7
7
  - **Browser-native** — Web Animations API + CSS transforms, no Canvas or GSAP
8
- - **Emoji stick figures** — `figure` actor type with articulatable limbs (punch, kick, rotate_part)
8
+ - **Emoji stick figures** — `figure` actor type with articulatable limbs, shoulder/hip joints, and body-part rig
9
+ - **Expressive gestures** — built-in `wave`, `nod`, `jump`, `bounce`, and multi-part `pose` actions
9
10
  - **Seek-safe** — manual `currentTime` control enables reliable `seek()` in any direction
10
11
  - **Face expressions** — instant emoji face swaps that work correctly on seek-back
11
12
  - **Speech bubbles** — auto-positioned bubbles with fade-in/fade-out
13
+ - **Z-index layering** — `z` modifier for actor depth ordering
12
14
  - **Single dependency** — only `@markdy/core`
13
15
 
14
16
  ## Installation
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { SceneAST, ParseWarning } from '@markdy/core';
2
+
1
3
  /**
2
4
  * @markdy/renderer-dom — Player
3
5
  *
@@ -19,10 +21,18 @@
19
21
  * each actor's before-phase state falls through to the inline style we set,
20
22
  * which gives correct initial positions and opacity values.
21
23
  */
24
+
22
25
  interface PlayerOptions {
23
26
  container: HTMLElement;
24
27
  code: string;
25
28
  assets?: Record<string, string>;
29
+ /**
30
+ * Host-resolved ASTs for `import "<path>" as <ns>` statements. When
31
+ * provided, the namespaces' `vars`, `defs`, and `seqs` merge into the
32
+ * parsed scene under `ns.<name>`. Unresolved namespaces emit a soft
33
+ * `import-unresolved` warning.
34
+ */
35
+ imports?: Record<string, SceneAST>;
26
36
  autoplay?: boolean;
27
37
  /** Loop the animation when it reaches the end. Defaults to true. */
28
38
  loop?: boolean;
@@ -30,6 +40,12 @@ interface PlayerOptions {
30
40
  copyright?: boolean;
31
41
  /** Show a rainbow progress bar around the viewport border. Defaults to true. */
32
42
  progressBar?: boolean;
43
+ /**
44
+ * Invoked once per soft `ParseWarning` emitted by the parser — e.g. unknown
45
+ * actions, unknown modifier keys, unresolved imports. Defaults to `console.warn`.
46
+ * Pass a no-op to silence; pass a custom collector to surface warnings in a UI.
47
+ */
48
+ onWarning?: (warning: ParseWarning) => void;
33
49
  }
34
50
  interface Player {
35
51
  play(): void;
package/dist/index.js CHANGED
@@ -14,6 +14,9 @@ function stateFrom(def) {
14
14
  function tx(s) {
15
15
  return `translate(${s.x}px, ${s.y}px) scale(${s.scale}) rotate(${s.rotate}deg)`;
16
16
  }
17
+ function txCaption(s) {
18
+ return `translate(calc(${s.x}px - 50%), calc(${s.y}px - 50%)) scale(${s.scale}) rotate(${s.rotate}deg)`;
19
+ }
17
20
  var EASE_MAP = {
18
21
  linear: "linear",
19
22
  in: "ease-in",
@@ -29,7 +32,7 @@ function createFigureEl(def) {
29
32
  const skinColor = def.args[0] || "#ffdbac";
30
33
  const isFemale = def.args[1] === "f";
31
34
  const startFace = def.args[2] || (isFemale ? "\u{1F642}" : "\u{1F636}");
32
- const ink = "#2a2a2a";
35
+ const ink = "currentColor";
33
36
  const WRAP_W = 80;
34
37
  const FACE_FS = 40;
35
38
  const SHIRT_FS = isFemale ? 48 : 44;
@@ -39,6 +42,7 @@ function createFigureEl(def) {
39
42
  const shY = Math.round(SHIRT_FS * 0.28);
40
43
  const ARM_W = 36, ARM_H = 22;
41
44
  const LEG_H = 54, LEG_STICK_H = 34;
45
+ const JOINT_SIZE = 6;
42
46
  const wrap = document.createElement("div");
43
47
  Object.assign(wrap.style, {
44
48
  position: "relative",
@@ -62,13 +66,13 @@ function createFigureEl(def) {
62
66
  });
63
67
  const neck = document.createElement("div");
64
68
  Object.assign(neck.style, {
65
- width: "8px",
66
- height: "8px",
69
+ width: "10px",
70
+ height: "10px",
67
71
  background: skinColor,
68
- borderRadius: "3px",
72
+ borderRadius: "4px",
69
73
  flexShrink: "0",
70
- marginTop: "-2px",
71
- marginBottom: "-2px",
74
+ marginTop: "-3px",
75
+ marginBottom: "-3px",
72
76
  zIndex: "4"
73
77
  });
74
78
  const shirtRow = document.createElement("div");
@@ -91,6 +95,12 @@ function createFigureEl(def) {
91
95
  pointerEvents: "none"
92
96
  });
93
97
  shirtRow.appendChild(torso);
98
+ const shoulderL = buildJoint("left", skinColor, JOINT_SIZE, shLx, shY, WRAP_W);
99
+ const shoulderR = buildJoint("right", skinColor, JOINT_SIZE, shRx, shY, WRAP_W);
100
+ shoulderL.dataset.figShoulderL = "";
101
+ shoulderR.dataset.figShoulderR = "";
102
+ shirtRow.appendChild(shoulderL);
103
+ shirtRow.appendChild(shoulderR);
94
104
  const armHandEmoji = isFemale ? "\u{1F485}" : "\u{1F91C}";
95
105
  shirtRow.appendChild(
96
106
  buildArm("left", armHandEmoji, skinColor, {
@@ -112,21 +122,57 @@ function createFigureEl(def) {
112
122
  flipFist: false
113
123
  })
114
124
  );
125
+ const hipRow = document.createElement("div");
126
+ Object.assign(hipRow.style, {
127
+ position: "relative",
128
+ display: "flex",
129
+ flexDirection: "column",
130
+ alignItems: "center",
131
+ flexShrink: "0",
132
+ overflow: "visible"
133
+ });
134
+ const hipBridge = document.createElement("div");
135
+ Object.assign(hipBridge.style, {
136
+ width: "28px",
137
+ height: "4px",
138
+ background: skinColor,
139
+ borderRadius: "2px",
140
+ marginBottom: "1px",
141
+ zIndex: "3"
142
+ });
143
+ hipRow.appendChild(hipBridge);
115
144
  const legsRow = document.createElement("div");
116
145
  Object.assign(legsRow.style, {
117
146
  display: "flex",
118
147
  justifyContent: "center",
119
148
  gap: "10px",
120
149
  flexShrink: "0",
121
- overflow: "visible"
150
+ overflow: "visible",
151
+ position: "relative"
122
152
  });
123
- legsRow.append(
124
- buildLeg(true, isFemale, ink, LEG_H, LEG_STICK_H),
125
- buildLeg(false, isFemale, ink, LEG_H, LEG_STICK_H)
126
- );
127
- wrap.append(faceEl, neck, shirtRow, legsRow);
153
+ const legL = buildLeg(true, isFemale, ink, skinColor, LEG_H, LEG_STICK_H, JOINT_SIZE);
154
+ const legR = buildLeg(false, isFemale, ink, skinColor, LEG_H, LEG_STICK_H, JOINT_SIZE);
155
+ legsRow.append(legL, legR);
156
+ hipRow.appendChild(legsRow);
157
+ wrap.append(faceEl, neck, shirtRow, hipRow);
128
158
  return wrap;
129
159
  }
160
+ function buildJoint(side, skinColor, size, anchorX, anchorY, wrapW) {
161
+ const joint = document.createElement("div");
162
+ const isLeft = side === "left";
163
+ Object.assign(joint.style, {
164
+ position: "absolute",
165
+ width: `${size}px`,
166
+ height: `${size}px`,
167
+ borderRadius: "50%",
168
+ background: skinColor,
169
+ ...isLeft ? { right: `${wrapW - anchorX - size / 2}px` } : { left: `${anchorX - size / 2}px` },
170
+ top: `${anchorY - size / 2}px`,
171
+ zIndex: "5",
172
+ pointerEvents: "none"
173
+ });
174
+ return joint;
175
+ }
130
176
  function buildArm(side, handEmoji, skinColor, g) {
131
177
  const arm = document.createElement("div");
132
178
  const ds = arm.dataset;
@@ -150,7 +196,7 @@ function buildArm(side, handEmoji, skinColor, g) {
150
196
  ...isLeft ? { right: "5px" } : { left: "5px" },
151
197
  top: `${g.h / 2 - 2}px`,
152
198
  width: "18px",
153
- height: "3px",
199
+ height: "4px",
154
200
  background: skinColor,
155
201
  borderRadius: "2px"
156
202
  });
@@ -169,7 +215,7 @@ function buildArm(side, handEmoji, skinColor, g) {
169
215
  arm.append(stick, fist);
170
216
  return arm;
171
217
  }
172
- function buildLeg(isLeft, isFemale, ink, legH, stickH) {
218
+ function buildLeg(isLeft, isFemale, ink, skinColor, legH, stickH, jointSize) {
173
219
  const leg = document.createElement("div");
174
220
  const ds = leg.dataset;
175
221
  ds[isLeft ? "figLegL" : "figLegR"] = "";
@@ -181,17 +227,44 @@ function buildLeg(isLeft, isFemale, ink, legH, stickH) {
181
227
  transform: "rotate(0deg)",
182
228
  overflow: "visible"
183
229
  });
230
+ const hipJoint = document.createElement("div");
231
+ hipJoint.dataset[isLeft ? "figHipL" : "figHipR"] = "";
232
+ Object.assign(hipJoint.style, {
233
+ position: "absolute",
234
+ width: `${jointSize}px`,
235
+ height: `${jointSize}px`,
236
+ borderRadius: "50%",
237
+ background: skinColor,
238
+ left: "50%",
239
+ top: `-${jointSize / 2}px`,
240
+ transform: "translateX(-50%)",
241
+ zIndex: "3",
242
+ pointerEvents: "none"
243
+ });
184
244
  const stick = document.createElement("div");
185
245
  Object.assign(stick.style, {
186
246
  position: "absolute",
187
- width: "3px",
247
+ width: "4px",
188
248
  height: `${stickH}px`,
189
249
  background: ink,
190
- borderRadius: "1px",
250
+ borderRadius: "2px",
191
251
  left: "50%",
192
252
  top: "0",
193
253
  transform: "translateX(-50%)"
194
254
  });
255
+ const knee = document.createElement("div");
256
+ Object.assign(knee.style, {
257
+ position: "absolute",
258
+ width: "4px",
259
+ height: "4px",
260
+ borderRadius: "50%",
261
+ background: skinColor,
262
+ left: "50%",
263
+ top: `${stickH * 0.55}px`,
264
+ transform: "translateX(-50%)",
265
+ zIndex: "2",
266
+ pointerEvents: "none"
267
+ });
195
268
  const shoe = document.createElement("span");
196
269
  shoe.textContent = isFemale ? "\u{1F460}" : "\u{1F45F}";
197
270
  Object.assign(shoe.style, {
@@ -208,7 +281,7 @@ function buildLeg(isLeft, isFemale, ink, legH, stickH) {
208
281
  } else {
209
282
  shoe.style.right = "0";
210
283
  }
211
- leg.append(stick, shoe);
284
+ leg.append(hipJoint, stick, knee, shoe);
212
285
  return leg;
213
286
  }
214
287
  var PART_SEL = {
@@ -265,6 +338,31 @@ function createActorEl(name, def, assetDefs, assetOverrides) {
265
338
  el = div;
266
339
  break;
267
340
  }
341
+ case "caption": {
342
+ const div = document.createElement("div");
343
+ div.textContent = def.args[0] ?? "";
344
+ div.dataset.markdyCaption = def.anchor ?? "top";
345
+ Object.assign(div.style, {
346
+ fontSize: `${def.size ?? 32}px`,
347
+ fontFamily: "system-ui, -apple-system, sans-serif",
348
+ fontWeight: "700",
349
+ whiteSpace: "nowrap",
350
+ textAlign: "center",
351
+ lineHeight: "1.1",
352
+ padding: "6px 14px",
353
+ borderRadius: "4px",
354
+ background: "rgba(0, 0, 0, 0.55)",
355
+ color: "#fff",
356
+ textShadow: "0 2px 6px rgba(0, 0, 0, 0.45)",
357
+ userSelect: "none",
358
+ pointerEvents: "none"
359
+ // Center the caption on its (x, y) point (x = sceneWidth/2).
360
+ // We combine translate-centering with the actor transform in the
361
+ // dataset below so the player can re-apply on state changes.
362
+ });
363
+ el = div;
364
+ break;
365
+ }
268
366
  case "figure": {
269
367
  el = createFigureEl(def);
270
368
  break;
@@ -284,24 +382,42 @@ function createActorEl(name, def, assetDefs, assetOverrides) {
284
382
  el.style.left = "0";
285
383
  el.style.top = "0";
286
384
  el.style.transformOrigin = "center center";
287
- el.style.transform = tx(stateFrom(def));
385
+ el.style.transform = def.type === "caption" ? txCaption(stateFrom(def)) : tx(stateFrom(def));
288
386
  el.style.opacity = String(def.opacity ?? 1);
387
+ if (def.z !== void 0) el.style.zIndex = String(def.z);
388
+ else if (def.type === "caption") el.style.zIndex = "100";
289
389
  return el;
290
390
  }
291
391
 
292
392
  // src/animations.ts
393
+ function isSceneDark(scene) {
394
+ const bg = scene.style.background || "white";
395
+ let hex = bg.trim().replace(/^#/, "");
396
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
397
+ if (hex.length === 6) {
398
+ const r = parseInt(hex.slice(0, 2), 16);
399
+ const g = parseInt(hex.slice(2, 4), 16);
400
+ const b = parseInt(hex.slice(4, 6), 16);
401
+ return 0.299 * r + 0.587 * g + 0.114 * b <= 140;
402
+ }
403
+ const dark = { black: true, "#000": true, "#000000": true };
404
+ return dark[bg.toLowerCase()] ?? false;
405
+ }
293
406
  function buildAnimations(ast, actorEls, scene, assetOverrides, faceSwaps) {
294
407
  const anims = [];
295
408
  const states = /* @__PURE__ */ new Map();
296
409
  for (const [name, def] of Object.entries(ast.actors)) {
297
410
  states.set(name, stateFrom(def));
298
411
  }
412
+ const captionActors = /* @__PURE__ */ new Set();
413
+ for (const [name, def] of Object.entries(ast.actors)) {
414
+ if (def.type === "caption") captionActors.add(name);
415
+ }
416
+ const txFor = (actorName) => captionActors.has(actorName) ? txCaption : tx;
299
417
  const events = [...ast.events].sort((a, b) => a.time - b.time);
300
- preInitInlineStyles(ast, actorEls, states, events);
418
+ preInitInlineStyles(ast, actorEls, states, events, txFor);
419
+ const cameraState = freshCameraState();
301
420
  for (const ev of events) {
302
- const el = actorEls.get(ev.actor);
303
- const s = states.get(ev.actor);
304
- if (!el || !s) continue;
305
421
  const delayMs = ev.time * 1e3;
306
422
  const durMs = Math.max(
307
423
  1,
@@ -314,13 +430,39 @@ function buildAnimations(ast, actorEls, scene, assetOverrides, faceSwaps) {
314
430
  fill: "forwards",
315
431
  easing
316
432
  };
317
- buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, assetOverrides, faceSwaps, anims);
433
+ if (ev.actor === "camera") {
434
+ buildCameraAction(ev, scene, ast, baseOpts, anims, cameraState);
435
+ continue;
436
+ }
437
+ const el = actorEls.get(ev.actor);
438
+ const s = states.get(ev.actor);
439
+ if (!el || !s) continue;
440
+ buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, assetOverrides, faceSwaps, anims, txFor(ev.actor));
318
441
  }
319
442
  return anims;
320
443
  }
321
- function preInitInlineStyles(ast, actorEls, states, events) {
444
+ function offscreenState(s, direction, sceneWidth, sceneHeight) {
445
+ const out = { ...s };
446
+ switch (direction) {
447
+ case "left":
448
+ out.x = -sceneWidth * 1.1;
449
+ break;
450
+ case "right":
451
+ out.x = sceneWidth * 2.1;
452
+ break;
453
+ case "top":
454
+ out.y = -sceneHeight * 1.1;
455
+ break;
456
+ case "bottom":
457
+ out.y = sceneHeight * 2.1;
458
+ break;
459
+ }
460
+ return out;
461
+ }
462
+ function preInitInlineStyles(ast, actorEls, states, events, txFor) {
322
463
  const firstEventByActor = /* @__PURE__ */ new Map();
323
464
  for (const ev of events) {
465
+ if (ev.actor === "camera") continue;
324
466
  if (!firstEventByActor.has(ev.actor)) firstEventByActor.set(ev.actor, ev);
325
467
  }
326
468
  for (const [name, def] of Object.entries(ast.actors)) {
@@ -328,31 +470,78 @@ function preInitInlineStyles(ast, actorEls, states, events) {
328
470
  const s = states.get(name);
329
471
  if (!el || !s) continue;
330
472
  const firstEv = firstEventByActor.get(name);
473
+ const txFn = txFor(name);
331
474
  if (firstEv?.action === "enter") {
332
475
  const from = String(firstEv.params.from ?? "left");
333
- const offscreen = { ...s };
334
- switch (from) {
335
- case "left":
336
- offscreen.x = -ast.meta.width * 1.1;
337
- break;
338
- case "right":
339
- offscreen.x = ast.meta.width * 2.1;
340
- break;
341
- case "top":
342
- offscreen.y = -ast.meta.height * 1.1;
343
- break;
344
- case "bottom":
345
- offscreen.y = ast.meta.height * 2.1;
346
- break;
347
- }
348
- el.style.transform = tx(offscreen);
476
+ const offscreen = offscreenState(s, from, ast.meta.width, ast.meta.height);
477
+ el.style.transform = txFn(offscreen);
349
478
  }
350
479
  if (firstEv?.action === "fade_in" && (def.opacity === void 0 || def.opacity > 0)) {
351
480
  el.style.opacity = "0";
352
481
  }
353
482
  }
354
483
  }
355
- function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, assetOverrides, faceSwaps, anims) {
484
+ function freshCameraState() {
485
+ return { x: 0, y: 0, zoom: 1 };
486
+ }
487
+ function cameraTx(s) {
488
+ return `translate(${-s.x}px, ${-s.y}px) scale(${s.zoom})`;
489
+ }
490
+ function buildCameraAction(ev, scene, ast, baseOpts, anims, cameraState) {
491
+ const s = cameraState;
492
+ switch (ev.action) {
493
+ case "pan": {
494
+ const to = ev.params.to;
495
+ if (!to) break;
496
+ const sceneW = ast.meta.width;
497
+ const sceneH = ast.meta.height;
498
+ const targetX = to[0] - sceneW / 2;
499
+ const targetY = to[1] - sceneH / 2;
500
+ const next = { ...s, x: targetX, y: targetY };
501
+ anims.push(
502
+ scene.animate(
503
+ [{ transform: cameraTx(s) }, { transform: cameraTx(next) }],
504
+ baseOpts
505
+ )
506
+ );
507
+ s.x = next.x;
508
+ s.y = next.y;
509
+ break;
510
+ }
511
+ case "zoom": {
512
+ const to = typeof ev.params.to === "number" ? ev.params.to : s.zoom;
513
+ const next = { ...s, zoom: to };
514
+ anims.push(
515
+ scene.animate(
516
+ [{ transform: cameraTx(s) }, { transform: cameraTx(next) }],
517
+ baseOpts
518
+ )
519
+ );
520
+ s.zoom = next.zoom;
521
+ break;
522
+ }
523
+ case "shake": {
524
+ const mag = typeof ev.params.intensity === "number" ? ev.params.intensity : 8;
525
+ anims.push(
526
+ scene.animate(
527
+ [
528
+ { transform: cameraTx(s), offset: 0 },
529
+ { transform: cameraTx({ ...s, x: s.x - mag, y: s.y - mag * 0.4 }), offset: 0.15 },
530
+ { transform: cameraTx({ ...s, x: s.x + mag, y: s.y + mag * 0.4 }), offset: 0.35 },
531
+ { transform: cameraTx({ ...s, x: s.x - mag * 0.6, y: s.y + mag * 0.3 }), offset: 0.55 },
532
+ { transform: cameraTx({ ...s, x: s.x + mag * 0.5, y: s.y - mag * 0.3 }), offset: 0.75 },
533
+ { transform: cameraTx(s), offset: 1 }
534
+ ],
535
+ { ...baseOpts, easing: "linear" }
536
+ )
537
+ );
538
+ break;
539
+ }
540
+ default:
541
+ break;
542
+ }
543
+ }
544
+ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, assetOverrides, faceSwaps, anims, txFn) {
356
545
  switch (ev.action) {
357
546
  // ── move ────────────────────────────────────────────────────────────────
358
547
  case "move": {
@@ -361,7 +550,7 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
361
550
  const toY = toArr?.[1] ?? s.y;
362
551
  anims.push(
363
552
  el.animate(
364
- [{ transform: tx(s) }, { transform: tx({ ...s, x: toX, y: toY }) }],
553
+ [{ transform: txFn(s) }, { transform: txFn({ ...s, x: toX, y: toY }) }],
365
554
  baseOpts
366
555
  )
367
556
  );
@@ -370,29 +559,45 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
370
559
  break;
371
560
  }
372
561
  // ── enter ───────────────────────────────────────────────────────────────
562
+ // Slides the actor from the given screen edge into its current (s)
563
+ // position while also restoring opacity to 1. The opacity restore makes
564
+ // `enter` symmetric with `exit` so actors can re-enter after exiting
565
+ // without needing a separate fade_in.
373
566
  case "enter": {
374
567
  const from = String(ev.params.from ?? "left");
375
- const fromState = { ...s };
376
- switch (from) {
377
- case "left":
378
- fromState.x = -ast.meta.width;
379
- break;
380
- case "right":
381
- fromState.x = ast.meta.width * 2;
382
- break;
383
- case "top":
384
- fromState.y = -ast.meta.height;
385
- break;
386
- case "bottom":
387
- fromState.y = ast.meta.height * 2;
388
- break;
389
- }
568
+ const fromState = offscreenState(s, from, ast.meta.width, ast.meta.height);
390
569
  anims.push(
391
570
  el.animate(
392
- [{ transform: tx(fromState) }, { transform: tx(s) }],
571
+ [
572
+ { transform: txFn(fromState), opacity: s.opacity },
573
+ { transform: txFn(s), opacity: 1 }
574
+ ],
393
575
  baseOpts
394
576
  )
395
577
  );
578
+ s.opacity = 1;
579
+ break;
580
+ }
581
+ // ── exit ────────────────────────────────────────────────────────────────
582
+ // Mirror of enter: slides off-screen in the given direction while also
583
+ // fading opacity to 0. Universal — works on any actor type (caption, text,
584
+ // figure, box, sprite). Leaves `s` at the off-screen state since the
585
+ // actor is conceptually gone after this.
586
+ case "exit": {
587
+ const to = String(ev.params.to ?? "right");
588
+ const toState = offscreenState(s, to, ast.meta.width, ast.meta.height);
589
+ anims.push(
590
+ el.animate(
591
+ [
592
+ { transform: txFn(s), opacity: s.opacity },
593
+ { transform: txFn(toState), opacity: 0 }
594
+ ],
595
+ baseOpts
596
+ )
597
+ );
598
+ s.x = toState.x;
599
+ s.y = toState.y;
600
+ s.opacity = 0;
396
601
  break;
397
602
  }
398
603
  // ── fade_in ─────────────────────────────────────────────────────────────
@@ -414,7 +619,7 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
414
619
  const toScale = typeof ev.params.to === "number" ? ev.params.to : s.scale;
415
620
  anims.push(
416
621
  el.animate(
417
- [{ transform: tx(s) }, { transform: tx({ ...s, scale: toScale }) }],
622
+ [{ transform: txFn(s) }, { transform: txFn({ ...s, scale: toScale }) }],
418
623
  baseOpts
419
624
  )
420
625
  );
@@ -426,7 +631,7 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
426
631
  const toDeg = typeof ev.params.to === "number" ? ev.params.to : s.rotate;
427
632
  anims.push(
428
633
  el.animate(
429
- [{ transform: tx(s) }, { transform: tx({ ...s, rotate: toDeg }) }],
634
+ [{ transform: txFn(s) }, { transform: txFn({ ...s, rotate: toDeg }) }],
430
635
  baseOpts
431
636
  )
432
637
  );
@@ -439,12 +644,12 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
439
644
  anims.push(
440
645
  el.animate(
441
646
  [
442
- { transform: tx(s), offset: 0 },
443
- { transform: tx({ ...s, x: s.x + mag }), offset: 0.2 },
444
- { transform: tx({ ...s, x: s.x - mag }), offset: 0.4 },
445
- { transform: tx({ ...s, x: s.x + mag }), offset: 0.6 },
446
- { transform: tx({ ...s, x: s.x - mag }), offset: 0.8 },
447
- { transform: tx(s), offset: 1 }
647
+ { transform: txFn(s), offset: 0 },
648
+ { transform: txFn({ ...s, x: s.x + mag }), offset: 0.2 },
649
+ { transform: txFn({ ...s, x: s.x - mag }), offset: 0.4 },
650
+ { transform: txFn({ ...s, x: s.x + mag }), offset: 0.6 },
651
+ { transform: txFn({ ...s, x: s.x - mag }), offset: 0.8 },
652
+ { transform: txFn(s), offset: 1 }
448
653
  ],
449
654
  { ...baseOpts, easing: "linear" }
450
655
  )
@@ -529,6 +734,11 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
529
734
  case "say": {
530
735
  const text = String(ev.params.text ?? "");
531
736
  const inverseScale = 1 / (s.scale || 1);
737
+ const sceneDark = isSceneDark(scene);
738
+ const bubbleBg = sceneDark ? "#1e2530" : "white";
739
+ const bubbleBorder = sceneDark ? "#475569" : "#222";
740
+ const bubbleText = sceneDark ? "#e2e8f0" : "#222";
741
+ const bubbleShadow = sceneDark ? "0 2px 8px rgba(0,0,0,0.35)" : "0 2px 8px rgba(0,0,0,0.12)";
532
742
  const bubble = document.createElement("div");
533
743
  bubble.textContent = text;
534
744
  bubble.style.opacity = "0";
@@ -538,9 +748,9 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
538
748
  left: "50%",
539
749
  transform: `translateX(-50%) scale(${inverseScale})`,
540
750
  transformOrigin: "center bottom",
541
- background: "white",
542
- border: "2px solid #222",
543
- color: "#222",
751
+ background: bubbleBg,
752
+ border: `2px solid ${bubbleBorder}`,
753
+ color: bubbleText,
544
754
  borderRadius: "12px",
545
755
  padding: "6px 14px",
546
756
  fontFamily: "system-ui, sans-serif",
@@ -552,7 +762,7 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
552
762
  textOverflow: "ellipsis",
553
763
  pointerEvents: "none",
554
764
  zIndex: "10",
555
- boxShadow: "0 2px 8px rgba(0,0,0,0.12)"
765
+ boxShadow: bubbleShadow
556
766
  });
557
767
  const tail = document.createElement("span");
558
768
  Object.assign(tail.style, {
@@ -564,7 +774,7 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
564
774
  height: "0",
565
775
  borderLeft: "7px solid transparent",
566
776
  borderRight: "7px solid transparent",
567
- borderTop: "10px solid #222"
777
+ borderTop: `10px solid ${bubbleBorder}`
568
778
  });
569
779
  bubble.appendChild(tail);
570
780
  el.style.overflow = "visible";
@@ -584,6 +794,135 @@ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, as
584
794
  );
585
795
  break;
586
796
  }
797
+ // ── pose ────────────────────────────────────────────────────────────────
798
+ // Sets multiple body parts to target angles simultaneously.
799
+ // Usage: @1.0: hero.pose(arm_left=45, arm_right=-45, leg_left=10, dur=0.4)
800
+ case "pose": {
801
+ const poseParts = ["arm_left", "arm_right", "leg_left", "leg_right", "head", "body"];
802
+ for (const partName of poseParts) {
803
+ if (typeof ev.params[partName] !== "number") continue;
804
+ const pSel = PART_SEL[partName];
805
+ if (!pSel) continue;
806
+ const pEl = el.querySelector(pSel);
807
+ if (!pEl) continue;
808
+ const fromDeg = readRotation(pEl);
809
+ const toDeg = ev.params[partName];
810
+ anims.push(
811
+ pEl.animate(
812
+ [
813
+ { transform: `rotate(${fromDeg}deg)` },
814
+ { transform: `rotate(${toDeg}deg)` }
815
+ ],
816
+ { ...baseOpts, fill: "forwards" }
817
+ )
818
+ );
819
+ pEl.style.transform = pEl.style.transform.replace(
820
+ /rotate\([^)]*\)/,
821
+ `rotate(${toDeg}deg)`
822
+ );
823
+ }
824
+ break;
825
+ }
826
+ // ── wave ────────────────────────────────────────────────────────────────
827
+ // Built-in wave gesture: raises arm, oscillates, returns.
828
+ // Usage: @2.0: hero.wave(side=right, dur=0.8)
829
+ case "wave": {
830
+ const wSide = String(ev.params.side ?? "right");
831
+ const wArmEl = el.querySelector(
832
+ wSide === "left" ? "[data-fig-arm-l]" : "[data-fig-arm-r]"
833
+ );
834
+ if (!wArmEl) break;
835
+ const wRest = readRotation(wArmEl);
836
+ const wUp = wSide === "left" ? 70 : -70;
837
+ const wMid1 = wSide === "left" ? 50 : -50;
838
+ const wMid2 = wSide === "left" ? 70 : -70;
839
+ anims.push(
840
+ wArmEl.animate(
841
+ [
842
+ { transform: `rotate(${wRest}deg)`, offset: 0 },
843
+ { transform: `rotate(${wUp}deg)`, offset: 0.2 },
844
+ { transform: `rotate(${wMid1}deg)`, offset: 0.4 },
845
+ { transform: `rotate(${wMid2}deg)`, offset: 0.55 },
846
+ { transform: `rotate(${wMid1}deg)`, offset: 0.7 },
847
+ { transform: `rotate(${wMid2}deg)`, offset: 0.85 },
848
+ { transform: `rotate(${wRest}deg)`, offset: 1 }
849
+ ],
850
+ { ...baseOpts, easing: "ease-in-out", fill: "forwards" }
851
+ )
852
+ );
853
+ break;
854
+ }
855
+ // ── jump ────────────────────────────────────────────────────────────────
856
+ // Jumps the actor up with squash/stretch effect.
857
+ // Usage: @3.0: hero.jump(height=30, dur=0.5)
858
+ case "jump": {
859
+ const jHeight = typeof ev.params.height === "number" ? ev.params.height : 30;
860
+ anims.push(
861
+ el.animate(
862
+ [
863
+ { transform: txFn(s), offset: 0 },
864
+ { transform: txFn({ ...s, scale: s.scale * 0.9 }), offset: 0.1 },
865
+ { transform: txFn({ ...s, y: s.y - jHeight, scale: s.scale * 1.1 }), offset: 0.45 },
866
+ { transform: txFn({ ...s, y: s.y - jHeight * 0.3, scale: s.scale * 1.05 }), offset: 0.7 },
867
+ { transform: txFn({ ...s, scale: s.scale * 0.92 }), offset: 0.88 },
868
+ { transform: txFn(s), offset: 1 }
869
+ ],
870
+ { ...baseOpts, easing: "ease-in-out" }
871
+ )
872
+ );
873
+ break;
874
+ }
875
+ // ── nod ─────────────────────────────────────────────────────────────────
876
+ // Nods the head down and back up. Figure actors only.
877
+ // Usage: @2.0: hero.nod(dur=0.4)
878
+ case "nod": {
879
+ const nHeadEl = el.querySelector("[data-fig-head]");
880
+ if (!nHeadEl) break;
881
+ const nRest = readRotation(nHeadEl);
882
+ const nDown = 15;
883
+ anims.push(
884
+ nHeadEl.animate(
885
+ [
886
+ { transform: `rotate(${nRest}deg)`, offset: 0 },
887
+ { transform: `rotate(${nDown}deg)`, offset: 0.35 },
888
+ { transform: `rotate(${nRest}deg)`, offset: 0.65 },
889
+ { transform: `rotate(${nDown}deg)`, offset: 0.8 },
890
+ { transform: `rotate(${nRest}deg)`, offset: 1 }
891
+ ],
892
+ { ...baseOpts, easing: "ease-in-out", fill: "forwards" }
893
+ )
894
+ );
895
+ break;
896
+ }
897
+ // ── bounce ──────────────────────────────────────────────────────────────
898
+ // Bounces the actor vertically with diminishing amplitude.
899
+ // Usage: @1.0: hero.bounce(intensity=15, count=3, dur=0.6)
900
+ case "bounce": {
901
+ const bIntensity = typeof ev.params.intensity === "number" ? ev.params.intensity : 15;
902
+ const bCount = typeof ev.params.count === "number" ? ev.params.count : 3;
903
+ const keyframes = [{ transform: txFn(s), offset: 0 }];
904
+ for (let bi = 0; bi < bCount; bi++) {
905
+ const amp = bIntensity * Math.pow(0.55, bi);
906
+ const baseOffset = (bi + 0.5) / (bCount + 0.5);
907
+ const peakOffset = Math.min(baseOffset, 0.98);
908
+ const valleyOffset = Math.min(baseOffset + 0.25 / (bCount + 0.5), 0.99);
909
+ keyframes.push({
910
+ transform: txFn({ ...s, y: s.y - amp }),
911
+ offset: peakOffset
912
+ });
913
+ if (bi < bCount - 1) {
914
+ keyframes.push({
915
+ transform: txFn(s),
916
+ offset: valleyOffset
917
+ });
918
+ }
919
+ }
920
+ keyframes.push({ transform: txFn(s), offset: 1 });
921
+ anims.push(
922
+ el.animate(keyframes, { ...baseOpts, easing: "ease-out" })
923
+ );
924
+ break;
925
+ }
587
926
  // ── throw ───────────────────────────────────────────────────────────────
588
927
  case "throw": {
589
928
  const assetName = String(ev.params.asset ?? "");
@@ -654,9 +993,20 @@ function bgToTextColor(bg) {
654
993
  return named[bg.toLowerCase()] ?? "#1a1a1a";
655
994
  }
656
995
  function createPlayer(opts) {
657
- const { container, code, assets: assetOverrides = {}, autoplay = true, loop = true, copyright = true, progressBar = true } = opts;
658
- const ast = parse(code);
996
+ const {
997
+ container,
998
+ code,
999
+ assets: assetOverrides = {},
1000
+ imports,
1001
+ autoplay = true,
1002
+ loop = true,
1003
+ copyright = true,
1004
+ progressBar = true,
1005
+ onWarning = (w) => console.warn(`[markdy] line ${w.line}: ${w.message} (${w.kind})`)
1006
+ } = opts;
1007
+ const ast = parse(code, imports ? { imports } : void 0);
659
1008
  const totalDurationMs = (ast.meta.duration ?? 0) * 1e3;
1009
+ for (const w of ast.warnings) onWarning(w);
660
1010
  const viewport = document.createElement("div");
661
1011
  Object.assign(viewport.style, {
662
1012
  position: "relative",
@@ -735,6 +1085,17 @@ function createPlayer(opts) {
735
1085
  transformOrigin: "0 0"
736
1086
  });
737
1087
  viewport.appendChild(scene);
1088
+ const sceneContent = document.createElement("div");
1089
+ Object.assign(sceneContent.style, {
1090
+ position: "absolute",
1091
+ top: "0",
1092
+ left: "0",
1093
+ width: "100%",
1094
+ height: "100%",
1095
+ transformOrigin: "50% 50%",
1096
+ willChange: "transform"
1097
+ });
1098
+ scene.appendChild(sceneContent);
738
1099
  function scaleScene() {
739
1100
  const s = viewport.clientWidth / ast.meta.width;
740
1101
  scene.style.transform = `scale(${s})`;
@@ -745,11 +1106,11 @@ function createPlayer(opts) {
745
1106
  const actorEls = /* @__PURE__ */ new Map();
746
1107
  for (const [name, def] of Object.entries(ast.actors)) {
747
1108
  const el = createActorEl(name, def, ast.assets, assetOverrides);
748
- scene.appendChild(el);
1109
+ sceneContent.appendChild(el);
749
1110
  actorEls.set(name, el);
750
1111
  }
751
1112
  const faceSwaps = [];
752
- const allAnims = buildAnimations(ast, actorEls, scene, assetOverrides, faceSwaps);
1113
+ const allAnims = buildAnimations(ast, actorEls, sceneContent, assetOverrides, faceSwaps);
753
1114
  faceSwaps.sort((a, b) => a.timeMs - b.timeMs);
754
1115
  for (const anim of allAnims) {
755
1116
  anim.pause();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markdy/renderer-dom",
3
- "version": "0.5.8",
3
+ "version": "0.7.0",
4
4
  "description": "Web Animations API renderer for MarkdyScript scenes.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,15 +43,18 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@markdy/core": "0.5.8"
46
+ "@markdy/core": "0.7.0"
47
47
  },
48
48
  "devDependencies": {
49
+ "jsdom": "^26.0.0",
49
50
  "tsup": "^8.5.1",
50
- "typescript": "^5.9.3"
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^4.1.4"
51
53
  },
52
54
  "scripts": {
53
55
  "build": "tsup",
54
56
  "typecheck": "tsc --noEmit",
55
- "lint": "tsc --noEmit"
57
+ "lint": "tsc --noEmit",
58
+ "test": "vitest run"
56
59
  }
57
60
  }