pele-animate 0.9.0 → 0.11.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
@@ -6,10 +6,47 @@ Unofficial add-on; not affiliated with or endorsed by the Pele or Obsidian teams
6
6
  The source stays valid Mermaid, so GitHub or Obsidian still show a normal diagram.
7
7
 
8
8
  - Nodes appear in order (BFS from the sources, or pinned with a `%% pa:` comment), edges draw in, labels fade in.
9
- - Hover or focus: tokens travel the edges in sequence. Click or Enter replays the intro.
9
+ - Hover or focus: tokens travel the edges in sequence. The intro plays once on scroll-in. Click (or Enter) enters step mode: previous / play-pause / next buttons, with Space (play/pause), ←/→ (step) and Esc (leave).
10
10
  - `@{ icon: "name" }` nodes become illustrations from a glyph pack. Unknown names fall back to a plain Pele box.
11
+ - Sequence diagrams: participant boxes in a row get the tallest height (Pele sizes each to its own text). Group and box titles are bold.
11
12
  - Idle state is the finished picture; `prefers-reduced-motion` turns the motion off. Light and dark themes.
12
13
 
14
+ ## Compare
15
+
16
+ The same Mermaid source, three renderers. The pele-animate images loop: the intro draws in, then tokens travel the edges (as on hover).
17
+ Regenerate with `examples/capture.sh`.
18
+
19
+ ```mermaid
20
+ flowchart LR
21
+ A[Player] -->|auth token| B[OTT service]
22
+ A -->|license request| C[DRM server]
23
+ C -->|license| A
24
+ ```
25
+
26
+ | Renderer | Result |
27
+ |---|---|
28
+ | Mermaid | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/m1.png" width="560" alt="Flowchart rendered by Mermaid"> |
29
+ | Pele | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/p1.png" width="560" alt="Flowchart rendered by Pele"> |
30
+ | pele-animate | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/a1.gif" width="560" alt="Animated flowchart: nodes appear, edges draw in, tokens travel"> |
31
+
32
+ ```mermaid
33
+ sequenceDiagram
34
+ Player->>OTT: sign in
35
+ OTT-->>Player: token
36
+ Player->>DRM: token
37
+ DRM-->>Player: license
38
+ ```
39
+
40
+ | Renderer | Result |
41
+ |---|---|
42
+ | Mermaid | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/m2.png" width="560" alt="Sequence diagram rendered by Mermaid"> |
43
+ | Pele | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/p2.png" width="560" alt="Sequence diagram rendered by Pele"> |
44
+ | pele-animate | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/a2.gif" width="560" alt="Animated sequence diagram: messages appear in order, tokens travel"> |
45
+
46
+ With glyphs (`@{ icon }` nodes, `%% pa: 1=P 2=O 3=D` pins the order):
47
+
48
+ <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/a3.gif" width="560" alt="Animated scene with illustrated Player, OTT service and DRM server">
49
+
13
50
  ## Install
14
51
 
15
52
  ```sh
@@ -51,15 +88,15 @@ flowchart LR
51
88
 
52
89
  | Export | |
53
90
  |---|---|
54
- | `watch(container, source?)` | Animate the SVG inside `container` (and every re-render of it); adds the scene panel, click and Enter replay |
91
+ | `watch(container, source?)` | Animate the SVG inside `container` (and every re-render of it); adds the scene panel and step mode (click / Enter); pass `{ controls: false }` as a third argument for click-to-replay instead |
55
92
  | `animate(svg, { hover, source })` | Animate one SVG; you handle re-renders |
56
93
  | `replay(svg)` | Play the intro again |
57
94
  | `parseOrder(source)` | Read the `%% pa:` pins |
58
95
  | `registerGlyphs(pack, strokeWidth = 1.1)` (`/glyphs`) | Add glyphs: `{ name: '<inner markup of a 24x24 svg>' }`; colors via `--pa-solid` / `--pa-light` |
59
96
  | `icons` (`/glyphs`) | Pele `icons` resolver |
60
97
 
61
- Generic glyphs: `player`, `server`, `key`, `document`, `shield`, `phone`. `preset-doverunner`: `package`, `token`, `lock`,
62
- `database`, `bits-a`, `bits-b`.
98
+ Generic glyphs: `player`, `server`, `key`, `document`, `shield`, `phone`, `cloud`. `preset-doverunner`: `package`, `token`, `lock`,
99
+ `database`, `video`, `media`, `encoder`, `storage`, `plugin`, `variants`, `watermark`, `bits-a`, `bits-b`.
63
100
 
64
101
  ## Theme
65
102
 
@@ -71,3 +108,7 @@ variable on it; defaults are `:where()` rules, so your stylesheet wins.
71
108
  - Relies on Pele's SVG structure (`.pele-node`, `.pele-edge`, `data-id`); `pele` is a peer dependency pinned to `^1.7.0`.
72
109
  - Flowcharts and sequence diagrams only. No types yet.
73
110
  - Glyph art is square-boxed; wide art is drawn as a wide glyph inside the square.
111
+
112
+ ## Changelog
113
+
114
+ See [CHANGELOG.md](CHANGELOG.md).
package/glyphs.js CHANGED
@@ -19,4 +19,5 @@ registerGlyphs({
19
19
  document: `<path d="M6 2.5h8l4.5 4.5v14a1 1 0 0 1-1 1h-11.5a1 1 0 0 1-1-1v-17.5a1 1 0 0 1 1-1z" fill="${L}" stroke="${S}"/><path d="M14 2.5V7h4.5" fill="none" stroke="${S}"/><path d="M8.5 12h7M8.5 15.5h7" stroke="${S}"/>`,
20
20
  shield: `<path d="M12 2.5l8 3v6c0 5-3.4 8.5-8 10-4.6-1.5-8-5-8-10v-6z" fill="${S}" stroke="none"/><path d="M8.5 12l2.6 2.6 4.6-5" fill="none" stroke="${W}" stroke-width="2.2"/>`,
21
21
  phone: `<rect x="6.5" y="2" width="11" height="20" rx="2.5" fill="${W}" stroke="${S}"/><path d="M10.5 3.8h3" stroke="${S}"/><rect x="8.5" y="6" width="7" height="4" rx="1" fill="${S}" stroke="none"/><path d="M8.5 12.5h7M8.5 15h7M8.5 17.5h4" stroke="${S}"/><path d="M10.5 20.2h3" stroke="${S}"/>`,
22
+ cloud: `<path d="M7 18a4 4 0 0 1-.6-7.96A5.5 5.5 0 0 1 17 8.5a4.75 4.75 0 0 1 .5 9.5z" fill="${L}" stroke="${S}"/>`,
22
23
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pele-animate",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Animated, illustrated SVG scenes from plain Mermaid code, as a post-processor for Pele.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/pele-animate.css CHANGED
@@ -1,17 +1,17 @@
1
1
  /* Draw-in once when scrolled into view; a dot travels each edge while hovered/focused. Idle = finished picture. */
2
- .pa-ready:not(.pa-in) .pele-node,
3
- .pa-ready:not(.pa-in) .pele-edge-label,
4
- .pa-ready:not(.pa-in) .pele-lifeline,
5
- .pa-ready:not(.pa-in) .pele-marker { opacity: 0; }
6
- .pa-ready:not(.pa-in) .pele-edge > path:not(.pele-marker) { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); }
2
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-node,
3
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-edge-label,
4
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-lifeline,
5
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-marker { opacity: 0; }
6
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-edge > path:not(.pele-marker) { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); }
7
7
 
8
8
  /* Keyframes carry the dash, so edges that are dashed in the diagram go back to their own dash pattern afterwards. */
9
- .pa-ready.pa-in .pele-node,
10
- .pa-ready.pa-in .pele-edge-label,
11
- .pa-ready.pa-in .pele-lifeline,
12
- .pa-ready.pa-in .pele-marker { animation: pa-fade .4s ease-out backwards; animation-delay: var(--d, 0s); }
13
- .pa-ready.pa-in .pele-edge > path:not(.pele-marker) { animation: pa-draw .4s ease-out backwards; animation-delay: var(--d, 0s); }
14
- .pa-ready.pa-done * { animation: none !important; }
9
+ .pa-ready.pa-in:not(.pa-step) .pele-node,
10
+ .pa-ready.pa-in:not(.pa-step) .pele-edge-label,
11
+ .pa-ready.pa-in:not(.pa-step) .pele-lifeline,
12
+ .pa-ready.pa-in:not(.pa-step) .pele-marker { animation: pa-fade .4s ease-out backwards; animation-delay: var(--d, 0s); }
13
+ .pa-ready.pa-in:not(.pa-step) .pele-edge > path:not(.pele-marker) { animation: pa-draw .4s ease-out backwards; animation-delay: var(--d, 0s); }
14
+ .pa-ready.pa-done:not(.pa-step) * { animation: none !important; }
15
15
  @keyframes pa-fade { from { opacity: 0; } }
16
16
  @keyframes pa-draw { from { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); } to { stroke-dasharray: var(--len); stroke-dashoffset: 0; } }
17
17
 
@@ -19,10 +19,17 @@
19
19
 
20
20
  @media (prefers-reduced-motion: reduce) {
21
21
  .pa-ready * { animation: none !important; }
22
- .pa-ready .pele-node, .pa-ready .pele-edge-label, .pa-ready .pele-lifeline, .pa-ready .pele-marker { opacity: 1 !important; }
22
+ .pa-ready:not(.pa-step) .pele-node, .pa-ready:not(.pa-step) .pele-edge-label, .pa-ready:not(.pa-step) .pele-lifeline, .pa-ready:not(.pa-step) .pele-marker { opacity: 1 !important; }
23
23
  .pa-ready .pele-edge > path { stroke-dasharray: none; stroke-dashoffset: 0 !important; }
24
24
  }
25
25
 
26
+ /* Step mode: elements are shown by step; the ones just revealed fade / draw in. */
27
+ .pa-step .pa-hid { opacity: 0; }
28
+ .pa-step .pa-dot { display: none; }
29
+ .pa-step .pa-pop { animation: pa-fade .4s ease-out backwards; }
30
+ .pa-step .pele-edge > path:not(.pele-marker).pa-pop { animation-name: pa-draw; }
31
+ .pa-step .pele-edge > .pele-marker.pa-pop { animation-delay: .3s; }
32
+
26
33
  /* Scene frame: tinted panel like the Lab product cards. Override any --pa-* / --pele-* on the container to retheme. */
27
34
  :where(.pa-scene) {
28
35
  --pa-bg: #efeafa; --pa-solid: #4F378B; --pa-light: #e4dcf6;
@@ -43,3 +50,16 @@
43
50
  .pa-art > rect:first-child { fill: none; stroke: none; }
44
51
  /* Edge labels sit on the line; give them the panel color so the line doesn't strike through the text. */
45
52
  .pa-scene .pele-edge-label rect { fill: var(--pa-bg); }
53
+
54
+ /* Group and box titles read as headings. */
55
+ :where(.pa-scene) .pele-cluster-label { font-weight: 700; }
56
+
57
+ /* Step controls: previous | play/pause | next, shown under the diagram in step mode. */
58
+ .pa-scene.pa-stepping { flex-direction: column; align-items: center; gap: .75rem; }
59
+ .pa-controls { display: flex; align-items: center; gap: .125rem; padding: .25rem; border-radius: 999px; cursor: default;
60
+ background: var(--pele-surface); color: var(--pele-fg); border: 1px solid var(--pele-border); font: 500 .8rem/1 system-ui, sans-serif; }
61
+ .pa-controls button { width: 2rem; height: 2rem; padding: 0; border: 0; border-radius: 50%; background: none; color: inherit; font: inherit; font-size: 1rem; cursor: pointer; }
62
+ .pa-controls button:hover:not(:disabled) { background: var(--pele-surface-alt); }
63
+ .pa-controls button:disabled { opacity: .35; cursor: default; }
64
+ .pa-controls button:focus-visible { outline: 2px solid var(--pa-solid); }
65
+ .pa-count { min-width: 4ch; padding: 0 .5rem; text-align: center; font-variant-numeric: tabular-nums; }
package/pele-animate.js CHANGED
@@ -48,8 +48,37 @@ function fitGlyphs(svg) {
48
48
  });
49
49
  }
50
50
 
51
+ // Pele sizes each sequence participant box to its own text, which leaves ragged rows. Give every box in a row the tallest height:
52
+ // header boxes grow upward (bottoms stay on the lifeline start), footer boxes grow downward (tops stay on the lifeline end).
53
+ function equalizeActors(svg) {
54
+ if (!svg.classList.contains('pele-sequence') || svg.dataset.paEqualized) return;
55
+ svg.dataset.paEqualized = '1';
56
+ for (const footer of [false, true]) {
57
+ const boxes = [...svg.querySelectorAll('g.pele-actor-participant')].filter((g) => g.classList.contains('pele-actor-footer') === footer);
58
+ const rects = boxes.map((g) => g.querySelector('rect')).filter(Boolean);
59
+ if (rects.length < 2) continue;
60
+ const num = (r, a) => +r.getAttribute(a);
61
+ const heights = rects.map((r) => num(r, 'height'));
62
+ const max = Math.max(...heights);
63
+ const top = Math.min(...rects.map((r) => num(r, 'y')));
64
+ const bottom = Math.max(...rects.map((r) => num(r, 'y') + num(r, 'height')));
65
+ boxes.forEach((g, i) => {
66
+ const grow = max - heights[i];
67
+ if (!grow) return;
68
+ rects[i].setAttribute('y', String(footer ? top : bottom - max));
69
+ rects[i].setAttribute('height', String(max));
70
+ const dy = footer ? grow / 2 : -grow / 2; // re-centre the label
71
+ g.querySelectorAll('text, tspan').forEach((t) => {
72
+ const y = t.getAttribute('y');
73
+ if (y !== null) t.setAttribute('y', String(+y + dy));
74
+ });
75
+ });
76
+ }
77
+ }
78
+
51
79
  export function animate(svg, { hover = svg, source = '' } = {}) {
52
80
  fitGlyphs(svg);
81
+ equalizeActors(svg);
53
82
  svg.querySelectorAll('.pa-dot').forEach((d) => d.remove());
54
83
  svg.classList.add('pa-ready');
55
84
  const nodes = [...svg.querySelectorAll('.pele-node')];
@@ -57,26 +86,38 @@ export function animate(svg, { hover = svg, source = '' } = {}) {
57
86
  const ids = [...new Set(nodes.map((n) => n.dataset.id))];
58
87
  const set = (el, t) => el?.style.setProperty('--d', `${t}s`);
59
88
  const times = new Map(); // edge element -> start time
89
+ const step = new Map(); // node / edge element -> step index (BFS level, or message number in a sequence)
60
90
 
61
91
  if (svg.classList.contains('pele-sequence')) {
62
92
  const u = Math.min(UNIT, MAX_INTRO / Math.max(1, edges.length));
63
93
  nodes.forEach((n) => set(n, 0));
64
94
  svg.querySelectorAll('.pele-lifeline').forEach((l) => set(l, 0.2));
65
- edges.forEach((e, i) => times.set(e, 0.5 + i * u));
95
+ edges.forEach((e, i) => { times.set(e, 0.5 + i * u); step.set(e, i + 1); });
96
+ nodes.forEach((n) => step.set(n, 0));
66
97
  } else {
67
98
  const pairs = edges.map((e) => endpoints(e.dataset.id, ids));
68
99
  const level = bfs(ids, pairs.filter(Boolean), parseOrder(source));
69
100
  const u = Math.min(UNIT, MAX_INTRO / (Math.max(0, ...level.values()) + 1));
70
101
  const k = u / UNIT;
71
- nodes.forEach((n) => set(n, level.get(n.dataset.id) * u));
102
+ nodes.forEach((n) => { set(n, level.get(n.dataset.id) * u); step.set(n, level.get(n.dataset.id)); });
72
103
  // Start after the source is up, but never so early that the arrow lands before its target appears (pinned steps).
73
104
  edges.forEach((e, i) => {
74
105
  const [s, d] = pairs[i] ?? [];
106
+ step.set(e, Math.max(level.get(s) ?? 0, level.get(d) ?? 0));
75
107
  times.set(e, Math.max((level.get(s) ?? 0) * u + 0.3 * k, (level.get(d) ?? 0) * u - 0.5 * k));
76
108
  });
77
109
  }
78
110
 
79
111
  const labels = new Map([...svg.querySelectorAll('.pele-edge-label')].map((l) => [l.dataset.id, l]));
112
+
113
+ // Step mode: each step's elements are revealed together; [element, parts that animate in].
114
+ const groups = new Map();
115
+ const add = (n, el, pop = [el]) => el && (groups.get(n) ?? groups.set(n, []).get(n)).push([el, pop]);
116
+ nodes.forEach((n) => add(step.get(n), n));
117
+ svg.querySelectorAll('.pele-lifeline').forEach((l) => add(0, l));
118
+ for (const e of edges) { add(step.get(e), e, [...e.querySelectorAll('path')]); add(step.get(e), labels.get(e.dataset.id)); }
119
+ svg.paSteps = [...groups].sort((a, b) => a[0] - b[0]).map(([, items]) => items);
120
+
80
121
  const dots = [];
81
122
  for (const [e, t] of times) {
82
123
  const path = e.querySelector('path:not(.pele-marker)');
@@ -97,7 +138,7 @@ export function animate(svg, { hover = svg, source = '' } = {}) {
97
138
  const cycle = Math.max(0, ...times.values()) + TOKEN + 0.8;
98
139
  let running = [];
99
140
  const play = () => {
100
- if (reduced() || running.length) return;
141
+ if (reduced() || running.length || svg.classList.contains('pa-step')) return;
101
142
  running = dots.map(([dot, t]) => {
102
143
  const a = t / cycle, b = (t + TOKEN) / cycle, e = 0.001;
103
144
  return dot.animate(
@@ -127,22 +168,118 @@ export function animate(svg, { hover = svg, source = '' } = {}) {
127
168
  io.observe(svg);
128
169
  }
129
170
 
171
+ const STEP_MS = 1200; // autoplay pace in step mode
172
+
173
+ // Show steps 0..k and hide the rest; `pop` animates the elements of step k.
174
+ function showStep(svg, k, pop) {
175
+ svg.classList.add('pa-step');
176
+ svg.querySelectorAll('.pa-pop').forEach((el) => el.classList.remove('pa-pop'));
177
+ svg.paSteps.forEach((items, i) => items.forEach(([el]) => el.classList.toggle('pa-hid', i > k)));
178
+ void svg.getBoundingClientRect();
179
+ if (pop) svg.paSteps[k].forEach(([, parts]) => parts.forEach((p) => p.classList.add('pa-pop')));
180
+ }
181
+
182
+ function leaveStep(svg) {
183
+ svg.classList.remove('pa-step');
184
+ svg.querySelectorAll('.pa-hid, .pa-pop').forEach((el) => el.classList.remove('pa-hid', 'pa-pop'));
185
+ svg.classList.add('pa-in', 'pa-done');
186
+ }
187
+
130
188
  // mount() re-renders on container resize; re-animate each new <svg>, skipping the intro once it has played.
131
- export function watch(container, source = '') {
189
+ // Click (or Enter/Space) enters step mode: previous | play/pause | next, Space, ←/→, Esc to leave. `controls: false` keeps click-to-replay.
190
+ export function watch(container, source = '', { controls = true } = {}) {
132
191
  let played = false;
192
+ let k = -1; // current step; -1 = not in step mode
193
+ let timer = 0;
194
+ const cur = () => container.querySelector('svg.pele');
195
+ const bar = document.createElement('div');
196
+ bar.className = 'pa-controls';
197
+ bar.setAttribute('role', 'toolbar');
198
+ bar.setAttribute('aria-label', 'Step through the diagram');
199
+ bar.title = ''; // an empty title stops the container's tooltip showing through the gaps
200
+ bar.innerHTML = '<button type="button" data-a="prev" aria-label="Previous step" title="Previous step (\u2190)">\u2039</button>'
201
+ + '<button type="button" data-a="play"></button>'
202
+ + '<button type="button" data-a="next" aria-label="Next step" title="Next step (\u2192)">\u203A</button>'
203
+ + '<span class="pa-count" aria-live="polite" title="Current step"></span>'
204
+ + '<button type="button" data-a="exit" aria-label="Exit step mode" title="Exit step mode (Esc)">\u2715\uFE0E</button>';
205
+ const btn = (a) => bar.querySelector(`[data-a=${a}]`);
206
+ const last = () => (cur()?.paSteps?.length ?? 1) - 1;
207
+ const ui = () => {
208
+ btn('prev').disabled = k <= 0;
209
+ btn('next').disabled = k >= last();
210
+ btn('play').textContent = timer ? '\u275A\u275A' : '\u25B6\uFE0E';
211
+ const label = timer ? 'Pause (Space)' : 'Play (Space)';
212
+ btn('play').setAttribute('aria-label', label);
213
+ btn('play').title = label;
214
+ bar.querySelector('.pa-count').textContent = `${k + 1} / ${last() + 1}`;
215
+ };
216
+ const pause = () => { clearTimeout(timer); timer = 0; };
217
+ const go = (n) => {
218
+ const svg = cur();
219
+ if (!svg?.paSteps) return;
220
+ const to = Math.max(0, Math.min(last(), n));
221
+ showStep(svg, to, to > k);
222
+ k = to;
223
+ ui();
224
+ };
225
+ const tick = () => {
226
+ if (k >= last()) { pause(); return ui(); }
227
+ go(k + 1);
228
+ timer = setTimeout(tick, STEP_MS);
229
+ };
230
+ const toggle = () => {
231
+ if (timer) { pause(); return ui(); }
232
+ if (k >= last()) go(0);
233
+ timer = setTimeout(tick, STEP_MS);
234
+ ui();
235
+ };
236
+ const manual = (n) => { pause(); go(n); };
237
+ const enter = () => {
238
+ played = true;
239
+ container.classList.add('pa-stepping');
240
+ container.append(bar);
241
+ go(0);
242
+ };
243
+ const exit = () => {
244
+ pause();
245
+ k = -1;
246
+ bar.remove();
247
+ container.classList.remove('pa-stepping');
248
+ if (cur()) leaveStep(cur());
249
+ container.focus();
250
+ };
251
+ const actions = { prev: () => manual(k - 1), next: () => manual(k + 1), play: toggle, exit };
252
+ bar.addEventListener('click', (e) => {
253
+ e.stopPropagation(); // else the container's click would re-enter step mode right after Exit
254
+ actions[e.target.closest('button')?.dataset.a]?.();
255
+ });
256
+
133
257
  const run = () => {
258
+ if (k >= 0 && !bar.isConnected) container.append(bar); // a re-render may have wiped it
134
259
  const svg = container.querySelector('svg.pele:not(.pa-ready)');
135
260
  if (!svg) return;
136
261
  animate(svg, { hover: container, source });
137
262
  if (played) svg.classList.add('pa-in', 'pa-done');
138
263
  else svg.addEventListener('animationend', () => (played = true), { once: true });
264
+ if (k >= 0) { k = Math.min(k, last()); showStep(svg, k, false); ui(); }
139
265
  };
140
266
  new MutationObserver(run).observe(container, { childList: true });
141
267
  container.classList.add('pa-scene');
142
268
  container.tabIndex = 0;
143
- container.title ||= 'Click to replay';
144
- container.addEventListener('click', () => replay(container.querySelector('svg.pele')));
145
- container.addEventListener('keydown', (e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), replay(container.querySelector('svg.pele'))));
269
+ container.title ||= controls ? 'Click to step through' : 'Click to replay';
270
+ if (!controls) {
271
+ container.addEventListener('click', () => replay(cur()));
272
+ container.addEventListener('keydown', (e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), replay(cur())));
273
+ } else {
274
+ container.addEventListener('click', () => k < 0 && enter());
275
+ container.addEventListener('keydown', (e) => {
276
+ const key = e.key;
277
+ if (e.target.closest('button') && (key === 'Enter' || key === ' ')) return; // native button activation
278
+ if (k < 0) return (key === 'Enter' || key === ' ') && (e.preventDefault(), enter());
279
+ const act = { ' ': toggle, ArrowRight: () => manual(k + 1), ArrowLeft: () => manual(k - 1), Escape: exit }[key];
280
+ if (act) { e.preventDefault(); act(); }
281
+ });
282
+ }
146
283
  run();
147
284
  }
148
285
 
@@ -15,6 +15,13 @@ registerGlyphs({
15
15
  package: `<path d="M12 2.5l8.5 4.5v10L12 21.5 3.5 17V7z" fill="${L}" stroke="${S}"/><path d="M3.5 7L12 11.5 20.5 7M12 11.5v10" fill="none" stroke="${S}"/><path d="M8 4.8l8.5 4.5" stroke="${S}"/>`,
16
16
  token: `<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h15A1.5 1.5 0 0 1 21 7.5V10a2 2 0 0 0 0 4v2.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 16.5V14a2 2 0 0 0 0-4z" fill="${L}" stroke="${S}"/><circle cx="9" cy="12" r="2.2" fill="${S}" stroke="none"/><path d="M13.5 10.5h4.5M13.5 13.5h3" stroke="${S}"/>`,
17
17
  lock: `<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5" fill="none" stroke="${S}"/><rect x="5" y="10.5" width="14" height="10" rx="2" fill="${S}" stroke="none"/><circle cx="12" cy="15" r="1.5" fill="${W}" stroke="none"/><path d="M12 15.5v2.2" stroke="${W}"/>`,
18
+ video: `<rect x="2" y="6" width="13" height="12" rx="2" fill="${L}" stroke="${S}"/><path d="M15 10.8l6-3.6v9.6l-6-3.6z" fill="${S}" stroke="${S}"/><circle cx="5.6" cy="9.4" r="1" fill="${S}" stroke="none"/>`,
19
+ media: `<rect x="2" y="4" width="15" height="11" rx="2" fill="${L}" stroke="${S}"/><path d="M7.6 7.2v5.6l4.6-2.8z" fill="${S}" stroke="none"/><ellipse cx="14.8" cy="19" rx="2.3" ry="1.8" fill="${S}" stroke="none"/><path d="M17 19V9.5q3 .2 3.6 3.4" fill="none" stroke="${S}"/>`,
20
+ storage: `<ellipse cx="12" cy="6" rx="8" ry="2.6" fill="${L}" stroke="${S}"/><path d="M4 6l2.4 13.4c.4 1.3 3 2.1 5.6 2.1s5.2-.8 5.6-2.1L20 6" fill="${L}" stroke="${S}"/><path d="M4.6 9.6c1.6 1.2 4.2 1.8 7.4 1.8s5.8-.6 7.4-1.8" fill="none" stroke="${S}"/>`,
21
+ plugin: `<path d="M4 8h4.5a2.2 2.2 0 1 1 4.4 0H17v4.1a2.2 2.2 0 1 1 0 4.4V20H4z" fill="${L}" stroke="${S}"/><path d="M17 8h3v12h-3" fill="none" stroke="${S}"/>`,
22
+ variants: `<rect x="2.5" y="3" width="14" height="9.5" rx="2" fill="${L}" stroke="${S}"/><rect x="7.5" y="11.5" width="14" height="9.5" rx="2" fill="${W}" stroke="${S}"/><text x="9.5" y="10.4" font-size="6.5" font-weight="700" text-anchor="middle" fill="${S}" stroke="none">A</text><text x="14.5" y="19" font-size="6.5" font-weight="700" text-anchor="middle" fill="${S}" stroke="none">B</text>`,
23
+ watermark: `<rect x="2.5" y="4" width="19" height="16" rx="2" fill="${L}" stroke="${S}"/><path d="M12 7.4c2.9 3.3 4.2 5 4.2 7a4.2 4.2 0 0 1-8.4 0c0-2 1.3-3.7 4.2-7z" fill="${S}" stroke="none"/><path d="M10.2 14.6a1.9 1.9 0 0 0 1.4 1.9" fill="none" stroke="${W}"/>`,
24
+ encoder: `<rect x="1.5" y="4" width="9" height="16" rx="1.5" fill="${L}" stroke="${S}"/><rect x="4.3" y="7" width="3.4" height="10" rx=".6" fill="${W}" stroke="${S}"/><path d="M2.9 6.5h.9M2.9 10.5h.9M2.9 14.5h.9M8.2 6.5h.9M8.2 10.5h.9M8.2 14.5h.9" stroke="${S}"/><path d="M12 12h3.5M14 10l2 2-2 2" fill="none" stroke="${S}"/><rect x="17.6" y="6.2" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="20.4" y="6.2" width="2.4" height="2.8" rx=".5" fill="${W}" stroke="${S}"/><rect x="17.6" y="10.6" width="2.4" height="2.8" rx=".5" fill="${W}" stroke="${S}"/><rect x="20.4" y="10.6" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="17.6" y="15" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="20.4" y="15" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/>`,
18
25
  database: `<path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" fill="${L}" stroke="${S}"/><ellipse cx="12" cy="6" rx="8" ry="3" fill="${L}" stroke="${S}"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" fill="none" stroke="${S}"/>`,
19
26
  });
20
27
  // Thinner line: the chips are small even at full box size.