footprint-storydeck 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — PowerPoint, the exploded view, and a tour for decks nobody presents
4
+
5
+ - **`footprint-storydeck/pptx`: the deck as a PowerPoint file.** `exportPptx` makes a slide per click: its settled
6
+ picture full-bleed, its notes as speaker notes (a paragraph per line), its headline or label as an off-page title
7
+ (the outline and screen readers read it, the audience does not see it), and a fade. With clips, each slide plays
8
+ its clip and moves on after it (plus `gap`): a narrated deck that runs by itself. It drives the browser through
9
+ the video's port (`deckStageDriver`). The export is a footprintjs flowchart, so every run leaves a record
10
+ (`narrative`), and each check is a `decide()` with its evidence. A clip that is missing, empty, not the sound its
11
+ name says, over `maxClip` or never voiced, a click without a 16:9 PNG, or a package that does not read back is
12
+ refused before a browser opens or a file is written, naming the fix. Media are shared by their bytes, never by
13
+ name. The ZIP, the PresentationML parts and the read-back are written by hand and exported (`zip`, `unzip`,
14
+ `pptxParts`, `checkPptx`). Opened in PowerPoint 16 with no repair prompt; every part validates against the
15
+ ISO/IEC 29500 schemas. It is pictures, notes and sound, not editable native shapes. footprintjs is an optional
16
+ peer dependency: `npm i footprintjs` beside StoryDeck to export; nothing else loads it.
17
+ - **`apart`, the exploded view.** A focus mover: named groups fly apart along the vectors you give
18
+ (`data-focus-apart="lid: 0 -120 | core: 160 0 | base: 0 120 1.1"`), one spring each, `data-focus-stagger` ms apart
19
+ (35), out in the order written and back in reverse on a later click without it; closing groups settle for one
20
+ more click. A click reschedules only the groups not yet at its target, in order, never sooner than a start they
21
+ were already waiting for, so the earlier groups lead. `focusRuntime` plays the springs as Web Animations, each deck
22
+ its own, so at rest a piece is exactly as drawn and the video renderer steps them; `SlideDeck` now installs it.
23
+ Two movers on one click are refused, naming the fix; a group's numbers are strict and its scale must be above 0
24
+ (for `place` too). Every `focusStep` look now carries `apart` (`null` without it), and every piece `apart`.
25
+ - **`footprint-storydeck/tour`: a tour for decks nobody presents.** `idleTour` plays a deck's stops through its own
26
+ `goTo` after 1.4 s with nobody touching it. A key, tap, click or real move hands the deck over until 45 s of quiet;
27
+ Escape or the "Stop tour" control (its name is its visible words; hidden while no tour can move in view) stops
28
+ every tour for the page view (WCAG 2.2.2). It waits while out of sight or while listen mode plays the deck
29
+ (`data-listening`, now set by `listenRuntime`), and never runs under reduced motion. Declared as
30
+ `<deck-stage data-tour>`, `SlideDeck`'s `tour`, or post.json's `tour`; `tourPlan` and `tourText` give the plan as
31
+ data and in one line.
32
+ - **Video:** the driver finishes click n−1's motion before stepping on, so a strip renders the same frames as the
33
+ full video. `focusRuntime`'s install flag is its version, so an older inlined runtime no longer blocks it.
34
+
3
35
  ## 0.4.0 — a path: the subject piece after piece along a route
4
36
 
5
37
  - **A fourth kind of focus strategy, the order — how the subject comes on — and its first strategy, `path`.**
package/PostView.jsx CHANGED
@@ -188,7 +188,7 @@ export default function PostView({ post }) {
188
188
  ) : null}
189
189
  <span className="watch-lens-hint" title="Press Esc to return to Read" aria-hidden="true">Esc</span>
190
190
  </div>
191
- <SlideDeck steps={post.deckSteps} />
191
+ <SlideDeck steps={post.deckSteps} tour={post.tour} />
192
192
  </>
193
193
  ) : null}
194
194
  </div>
package/README.md CHANGED
@@ -73,6 +73,7 @@ decision every click makes again, so it is data on the click, and the library do
73
73
  | mover | `zoom` | the view closes in on the subject (`share`, `most`) |
74
74
  | mover | `left` · `right` · `up` · `down` | the context steps aside that way, smaller (`scale`), and the subject grows into the room it leaves (`grow`, `fill`, `gap`, `align`; `move: 'aside'` keeps the subject where it is) |
75
75
  | mover | `place` | named groups moved by the amounts you give (`groups: 'page floor: -90 -200 0.5 \| tl: 0 -230 1.1 @ 960 968'`): a layout you art-direct, still animated from wherever the last click left it |
76
+ | mover | `apart` | the exploded view: named groups fly apart along the vectors you give (`apart: 'lid: 0 -120 \| core: 160 0 \| base: 0 120 1.1'`), one spring each, `stagger` ms apart (35), and come back together on a later click without it — the last one out is the first one back |
76
77
  | overlay | `blur` | everything outside the subject's frame is blurred; a frame (and a `label`) on it, or on the `rect`s you give |
77
78
  | order | `path` | the subject comes on piece after piece along a route, as a request travels it — node, wire, node: each lit piece on it lights at its turn (`step` seconds apart, 0.35) and glows as it is reached; `route` names the order (default — and for an empty `route` — the click's `on`, or its `in`); a greyed piece on it takes no turn, and a route with no order at all (`on: ['*']`) is refused; a `line` ("agent → MCP → gateway") names the route, shown as the last turn starts. A deck's own `filter` on a path piece is not carried through the glow: it steps where the glow starts and ends |
78
79
 
@@ -93,6 +94,35 @@ on one `filter` would flash), its ring (`hot`) comes .3 s after its turn, and a
93
94
  twin's turn and only then steps aside, so the wire is never missing. Under a new blur the whole path, line included,
94
95
  waits .4 s for it, as what a click adds does.
95
96
 
97
+ **An exploded view, on a figure of parts.** A figure built of parts — an engine, a stack, a page and the layers
98
+ under it — explains itself best when the parts come apart: the audience sees what it is made of, then sees it whole
99
+ again. Name the parts as groups and the way each one leaves:
100
+
101
+ ```html
102
+ <step data-focus="apart" data-focus-apart="lid: 0 -120 | core: 160 0 | base: 0 120 1.1"></step>
103
+ <step data-on="core"></step> <!-- no apart: the parts come back together, the core lit -->
104
+ ```
105
+
106
+ Each group has one spring, its value 0 together and 1 apart, a little under critical damping, so a part settles with
107
+ a hint of weight instead of stopping dead. The groups set off `stagger` ms apart (`data-focus-stagger`, 35) — out in
108
+ the order written, back in reverse — so the last one out is the first one back. A click that turns the parts round
109
+ mid-way reschedules only the groups not yet at its target (those turning round, and those still waiting to set off),
110
+ in order, each a stagger after the one before and never sooner than a start it was already waiting for — so the
111
+ order is kept: among them the earlier groups always lead, and a click that keeps a group's target keeps its time.
112
+ The order is what the library keeps; where the parts go is yours — vectors that cross make paths that cross, so give
113
+ each part its own way out. The click after a close keeps the closing groups on their springs one click more, so a
114
+ quick click lets them settle from wherever they are. A line or connector that carries a group's name
115
+ (`data-k="wire core"`) moves with that group, keeping its place against it. `apart` is a mover, so it goes with a look (the rest
116
+ greyed), an overlay and an order, and a click that also asks for `place` is refused, naming the fix; across clicks
117
+ the two compose: the parts fly out from the layout `place` left, and back into it.
118
+
119
+ The springs run in `focusRuntime` (the Watch lens, `SlideDeck`, installs it; a built page inlines it), each deck on
120
+ the page its own, played as Web Animations of the same `translate`/`scale` every mover uses (so a piece's own
121
+ transform, an SVG group's `transform` attribute included, is never overwritten), which means the video renderer
122
+ steps them like any animation. Nothing of a spring is written on the piece: at rest the animation is over and the
123
+ piece is exactly as drawn — closed, the figure is its static art. A jump, a step back, print and reduced motion land
124
+ at once, as every focus does.
125
+
96
126
  The plan is plain data (each piece's state, what changed since the last click, how far it moved), so a
97
127
  renderer only draws it. `footprint-storydeck/focus-html` writes it into slide HTML at build time and
98
128
  `footprint-storydeck/focus.css` animates **only what changed, only on a forward click** — a jump or a step back
@@ -128,6 +158,56 @@ a piece (a dashed wire under a solid one) step aside while its twin is lit. A pi
128
158
  from how it looked and where it stood; a blur that moves to another subject stays, and only its frame is
129
159
  new; overlapping holes stay clear, and nothing outside the `area` is blurred.
130
160
 
161
+ ## Tour: a deck that shows its point while nobody is using it
162
+
163
+ A deck left on a screen with nobody presenting it — at a booth, on a post's Watch lens, as a page's hero figure — is
164
+ mostly looked at by people who have not touched it yet. If it sits on its first slide, they never see what it is
165
+ for. So, left alone for a moment (1.4 s), it plays a short loop of **stops**, the clicks that make its point, and the
166
+ moment someone reaches for it, it hands itself over:
167
+
168
+ - it drives the **same targets a person would** — the deck's own `goTo`, so a stop one click on animates as that click
169
+ does (focus, `apart`'s springs and all) and a jump lands, as a person's jump does; there is no second animation to
170
+ keep in step;
171
+ - **accessibility first**: the moment someone uses it — a key, a tap, a click, a focus, a real move of the pointer
172
+ over it (looked at every 250 ms, so a resting mouse or the deck moving under it is no move, and a finger lifting off
173
+ is not someone leaving), or a move it did not make (the presenter window) — it hands itself over, and it does not
174
+ take the deck back soon: only after **45 s of quiet** (`resumeMs`), or once the deck has left sight and come back.
175
+ It then goes on from the stop they left it on, when that is a stop;
176
+ - **Escape**, or the small **"Stop tour"** control it shows while a tour can move in view (or the page's own element
177
+ marked `data-tour-stop`, which it uses instead), stops every tour for the rest of the page view (WCAG 2.2.2). Escape
178
+ is one key with three meanings on a StoryDeck page, each by what is running: while listen mode plays, it stops listen
179
+ mode, and only that; in `PostView`'s Watch lens it returns to Read; and wherever a tour runs it stops every tour on
180
+ the page — so in a touring Watch lens one Escape does both;
181
+ - it **waits while out of sight** — scrolled away (IntersectionObserver, reading the newest entry) or the page
182
+ hidden — and while listen mode plays the deck (the deck carries `data-listening`); under **reduced motion** there
183
+ is no tour.
184
+
185
+ The plan is plain data — the stops, each one's hold, the waits — that a person or an LLM can read, and that the
186
+ deck's notes can say in one line (`tourText`):
187
+
188
+ ```js
189
+ import { idleTour, tourPlan, tourText } from 'footprint-storydeck/tour';
190
+
191
+ idleTour(document.querySelector('deck-stage'), { stops: '3 4 5 9:6s', hold: '3s' }); // clicks as the deck counts them
192
+ tourText({ stops: '3 4 5 9:6s' });
193
+ // "Left alone for 1.4 s, it tours itself: slide 3 for 3 s, then slide 4 for 3 s, then slide 5 for 3 s, then slide 9
194
+ // for 6 s, and round again. A key, tap, click or move hands it over until 45 s of quiet; Escape or "Stop tour" stops it."
195
+ ```
196
+
197
+ Declared on the deck instead, for a page built as HTML: `<deck-stage data-tour="3 4 5 9:6s" data-tour-hold="3s">` (and
198
+ `data-tour-idle`, `data-tour-resume`), with `(${idleTour})()` once in the page; a deck whose `data-tour` it cannot
199
+ read is left out with a console warning naming the fix, and the others still tour. A stop is held 300 ms or more. In
200
+ React, the Watch lens takes the same spec — `<SlideDeck steps={…} tour="3 4 5 9:6s" />` — and `PostView` passes the
201
+ post's own `tour` (post.json), so a post opts in where its structure is written. A figure that is not a deck tours
202
+ through its own function: stops are its names, `go` is what a person's click calls
203
+ (`idleTour(figure, { stops: ['lid', 'core'], go: (part) => show(part) })`). The tour's state rides on the element as `data-tour-state` (`waiting`,
204
+ `touring`, `paused`, `hidden`, `still`, `done`), so a page can say "touring — touch to explore"; the control is
205
+ `.sd-tour-stop`, styled as weakly as can be, so a page restyles it.
206
+
207
+ It lives on the Watch deck, not in the Scroll or Read lenses: their figure follows the reader's scroll position, so a
208
+ tour there would move the page under the reader's eyes; a deck is a place a person steps through, and the tour steps
209
+ through it the same way.
210
+
131
211
  ## Narration: the deck tells itself, in your voice
132
212
 
133
213
  A talk lives on after the room: someone opens the deck alone, or it becomes a video. The words were in the
@@ -199,6 +279,66 @@ await renderVideo({
199
279
  });
200
280
  ```
201
281
 
282
+ ## PowerPoint: the deck as a .pptx
283
+
284
+ Conferences often ask for the talk as a PowerPoint file. `footprint-storydeck/pptx` (Node, no ffmpeg) exports one
285
+ through the same browser port as the video, so a deck that can be filmed can be exported:
286
+
287
+ - **one slide per click** — the click's settled picture (made forward, its entrance played to the end, as the
288
+ video's held frame) full-bleed on a 16:9 slide; its written notes as the speaker notes, a paragraph per note;
289
+ its headline (or label) as the slide's title, in the title placeholder just above the page, so the outline, the
290
+ slide list and a screen reader read it and the audience does not see it; a fade between slides;
291
+ - **a narrated deck that runs by itself** — with clips (as the video takes them, or found in the voice cache), each
292
+ slide carries its clip, which plays as the slide starts, and moves on after the clip plus `gap` (a click without
293
+ a clip after `noclip` plus `gap`, the video's timing). Without clips no slide moves on by itself: the speaker clicks.
294
+
295
+ **What it is, honestly:** pictures plus notes plus sound. A slide's words and shapes are in its picture, not
296
+ native PowerPoint text or shapes, so they cannot be edited there. Turning a deck's HTML into native shapes is a
297
+ possible later step.
298
+
299
+ **Why a flowchart.** The export is a [footprintjs](https://github.com/footprintjs/footPrint) flowchart —
300
+ PlanClicks → GatherClips → CheckClips → TimeTheDeck → ShootPictures → CheckPictures → WriteParts → Zip →
301
+ CheckPackage → Verdict → Deliver — so every run leaves a record that explains it, to a person or an LLM
302
+ (`done.narrative`). Each check is a `decide()` that records its evidence: a clip whose file is gone, one PowerPoint
303
+ cannot play (not a .wav, .m4a or .mp3; an empty file; or bytes that do not begin as their kind of file — RIFF…WAVE,
304
+ ID3 or an MP3 frame, ftyp), one over `maxClip` (300 s), a click that says words with no clip;
305
+ a click without a 16:9 PNG; a package that does not read back as planned. A refusal names each click and its fix,
306
+ carries the record (`error.narrative`), and writes nothing to `out`. The clips are checked before a browser opens.
307
+ footprintjs is an optional peer dependency: StoryDeck does not install it (a browser-only install stays small), so
308
+ install it beside StoryDeck to export — `npm i footprintjs` — and an export without it is refused, saying so. It is
309
+ loaded only when an export runs; the deck's browser runtime never loads it.
310
+
311
+ ```js
312
+ import { chromium } from 'playwright-core';
313
+ import { deckStageDriver } from 'footprint-storydeck/video';
314
+ import { exportPptx } from 'footprint-storydeck/pptx'; // and `npm i footprintjs`: an optional peer, needed to export
315
+
316
+ const done = await exportPptx({
317
+ url: 'file:///…/deck.html', steps, out: 'out/my-talk.pptx', // the steps the video takes; or `out` a folder → <name>.pptx
318
+ driver: deckStageDriver({ chromium }),
319
+ });
320
+ // done: { file, slides, voiced, runtime (s the deck runs by itself, or null), bytes, clicks, narrative }
321
+ // [Condition]: No rules matched, fell back to default "Every clip is there, playable and within maxClip": TimeTheDeck.
322
+ // …or, refused: exportPptx: a clip is not ready: click 12: its clip lasts 412 s, over maxClip (300 s) — split its notes across clicks, or raise maxClip
323
+
324
+ // From the deck's clicks and the voice cache instead of steps (give `voice` the `spell`/`say` the clips were voiced with):
325
+ await exportPptx({ url, deck: clicks.map(c => ({ label: c.label, notes: c.notes })), voice: { cache: 'voice/cache', profile }, out: 'out', driver });
326
+ ```
327
+
328
+ The file is written by hand, with no dependency: a small ZIP writer (`zlib`'s raw deflate and a CRC-32) and a
329
+ minimal PresentationML part set (ECMA-376) — content types, relationships, the presentation, one master and one
330
+ title-only layout, a theme for each master, the slides, a notes master and a notes page per slide, the media (a
331
+ sound said on two clicks is stored once, matched by its bytes) and the document properties. The pieces are
332
+ exported for pictures made elsewhere: `pptxParts({ slides })` gives the parts, `zip(parts)` the file, and
333
+ `checkPptx(bytes)` reads one back — the ZIP against its CRC-32s, every part well-formed and typed, every
334
+ relationship's target there, every `r:id` known, slide, master and layout ids in PowerPoint's ranges, shape ids
335
+ unique on each slide — and lists each slide's title, notes, pictures, sound and time. Each export runs that check
336
+ before it writes to `out`. The tests also run Info-ZIP's `unzip -t`; LibreOffice, where it is installed, opens
337
+ the file and prints it to PDF; and with `STORYDECK_OOXML_SCHEMAS` pointing at the ISO/IEC 29500 schemas, every part
338
+ is validated against them. Limits: a clip is embedded as it is (a .wav as voiced: about 3 MB a minute at 24 kHz
339
+ mono — convert to .m4a to shrink a long talk); no ZIP64, so a file stays under 4 GB; the slides advance by
340
+ themselves when the show's "use timings" is on (PowerPoint's default).
341
+
202
342
  ## Faster edits — what making one talk taught us
203
343
 
204
344
  - **Cache by content, not by position.** A clip is keyed by its words and settings: inserting or removing a
@@ -277,7 +417,7 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
277
417
  | Export | What |
278
418
  |---|---|
279
419
  | `PostView` | the lens controller (Read · Scroll · Watch toggle) |
280
- | `BlogView` · `ScrollyView` · `SlideDeck` | the three lenses (take `sections` / `sections` / `steps`) |
420
+ | `BlogView` · `ScrollyView` · `SlideDeck` | the three lenses (take `sections` / `sections` / `steps`; `SlideDeck` also a `tour`) |
281
421
  | `SlideFigure` | one slide rendered as a scaled static figure |
282
422
  | `StoryDeckProvider` · `useBasePath` | inject deploy config (asset base path) |
283
423
  | `useTheme` · `ThemeToggle` | headless theming + a default toggle |
@@ -287,10 +427,12 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
287
427
  | `slugify` · `scopeDeckCss` | utilities |
288
428
  | `planFocus` · `focusStep` · `readFocus` · `FOCUS` | focus: per click, what it is about and what happens to the rest (also `footprint-storydeck/focus`) |
289
429
  | `readPieces` · `readStep` · `drawFocus` · `wrapStage` · `slideClass` · `focusOverlay` · `focusRuntime` | focus, written into slide HTML (also `footprint-storydeck/focus-html`; styles in `footprint-storydeck/focus.css`) |
430
+ | `idleTour` · `tourPlan` · `tourText` | tour: a deck (or a figure) that shows its point while nobody is using it, and its plan in data and in one line (also `footprint-storydeck/tour`) |
290
431
  | `narrationText` · `writtenText` | narration: what a click says (also `footprint-storydeck/narration`); the rules beneath, sentences, captions and chapters are `footprint-narration`'s |
291
432
  | `notesData` · `notesRuntime` · `listenRuntime` | narration in the page: notes, presenter window, listen mode (also `footprint-storydeck/narration-html`; styles in `footprint-storydeck/narration.css`) |
292
433
  | `embedClips` | `footprint-storydeck/voice` (Node only): a deck's clips inside one page; the clip cache, the voice port and its local-kit adapter are `footprint-narration/voice`'s |
293
434
  | `renderVideo` · `planVideo` · `deckStageDriver` · `ffmpegMajor` | `footprint-storydeck/video` (Node only, ffmpeg 5.0+): the deck as a narrated video, with captions and chapters |
435
+ | `exportPptx` · `pptxParts` · `zip` · `unzip` · `checkPptx` | `footprint-storydeck/pptx` (Node only): the deck as a PowerPoint file — a slide per click, its picture, notes and clip — through a footprintjs flowchart that records why |
294
436
 
295
437
  Types ship beside the source in `index.d.ts` — hand-kept (there is no build to generate them from),
296
438
  and here rather than in a consumer's shim, because a package's shape belongs in the package.
@@ -302,7 +444,7 @@ npm test # vitest
302
444
  npm run test:cov
303
445
  ```
304
446
 
305
- 186 tests · ~99% lines · coverage thresholds enforced (90% statements, 85% branches, 90% functions, 95% lines).
447
+ 317 tests (and 2 that run where LibreOffice or the ECMA-376 schemas are found) · ~99% lines · coverage thresholds enforced (90% statements, 85% branches, 90% functions, 95% lines).
306
448
 
307
449
  ## License
308
450
 
package/SlideDeck.jsx CHANGED
@@ -1,14 +1,24 @@
1
1
  'use client';
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { useBasePath } from './context';
4
+ import { idleTour } from './tour';
5
+ import { focusRuntime } from './focusHtml';
4
6
 
5
7
  // Slide (watch) lens: the deck slides (as raw HTML strings, in order) mounted into <deck-stage>
6
8
  // (the deck's own web component, loaded from /deck-stage.js). We set innerHTML imperatively so
7
9
  // React doesn't fight the runtime once it restructures the stage; remounts auto-upgrade.
8
- export default function SlideDeck({ steps }) {
10
+ //
11
+ // It installs focusHtml.js · focusRuntime (once a page), as a built deck inlines it: only a forward click
12
+ // animates, and apart's springs play.
13
+ //
14
+ // `tour` (tour.js · idleTour) makes it a deck nobody has to present: left alone for a moment it shows
15
+ // its stops through the deck's own goTo, and a key, tap, click or move hands it over. It is the
16
+ // readable spec only ('3 4 5 9:6s', or {stops, hold, idleMs, resumeMs, loop}), so it can come from JSON.
17
+ export default function SlideDeck({ steps, tour }) {
9
18
  const host = useRef(null);
10
19
  const [copied, setCopied] = useState(false);
11
20
  const basePath = useBasePath();
21
+ const tourKey = tour ? JSON.stringify(tour) : '';
12
22
 
13
23
  // deck-stage keeps the current slide in location.hash (#1, #2, …), so the URL already points at
14
24
  // the visible slide — copying window.location shares that exact slide (same scheme as the blog).
@@ -21,6 +31,7 @@ export default function SlideDeck({ steps }) {
21
31
  useEffect(() => {
22
32
  const el = host.current;
23
33
  if (!el) return;
34
+ focusRuntime();
24
35
  const inner = (steps || []).join('\n');
25
36
  el.innerHTML = `<deck-stage no-rail width="1920" height="1080">${inner}</deck-stage>`;
26
37
  if (!document.querySelector('script[data-deck-stage]')) {
@@ -31,6 +42,14 @@ export default function SlideDeck({ steps }) {
31
42
  }
32
43
  }, [steps]);
33
44
 
45
+ // After the deck effect, so a new deck (new steps) gets a new tour; keyed by the spec's text, not its identity.
46
+ useEffect(() => {
47
+ const stage = host.current?.querySelector('deck-stage');
48
+ if (!tourKey || !stage) return undefined;
49
+ const t = idleTour(stage, typeof tour === 'string' ? { stops: tour } : tour);
50
+ return () => t.stop();
51
+ }, [steps, tourKey]);
52
+
34
53
  return (
35
54
  <>
36
55
  <div className="deck-shell deck-scope" ref={host} />
package/focus.css CHANGED
@@ -26,6 +26,8 @@
26
26
  .sd-moved { translate: var(--sd-dx, 0px) var(--sd-dy, 0px); scale: var(--sd-s, 1); }
27
27
  .sd-origin-tl { transform-origin: 0 0; }
28
28
  .sd-stage { position: absolute; inset: 0; transform-origin: 0 0; translate: var(--sd-tx, 0px) var(--sd-ty, 0px); scale: var(--sd-ts, 1); }
29
+ /* apart: a piece its group's spring moves is marked .sd-apart and has no CSS motion — focusRuntime plays the spring as
30
+ Web Animations of the same translate/scale; at rest it is the plain style above (together: the piece as drawn) */
29
31
 
30
32
  /* the blur overlay: everything outside the holes, in the area, blurred and darkened; a frame on the subject */
31
33
  .sd-veil { position: absolute; left: 0; top: 0; pointer-events: none; -webkit-backdrop-filter: blur(var(--sd-blur, 7px)); backdrop-filter: blur(var(--sd-blur, 7px));
package/focus.d.ts CHANGED
@@ -60,10 +60,16 @@ export interface FocusContext {
60
60
  */
61
61
  export interface FocusRoute { readonly names: readonly string[]; readonly step: number; readonly line?: string; readonly lineAt?: string | null }
62
62
 
63
+ /**
64
+ * A mover's groups going by a spring each (apart), `stagger` ms apart (0–1000): out in the order the groups are given,
65
+ * back in reverse. Checked, and refused (naming the strategy and the fix) when it is not this; only read.
66
+ */
67
+ export interface FocusSpring { readonly stagger: number }
68
+
63
69
  /** A focus strategy: a look (how the context looks), a mover (where things go), an overlay (what lies over the slide) or an order (how the subject comes on). */
64
70
  export type FocusStrategy =
65
71
  | { readonly name?: string; readonly kind: 'look'; readonly look: 'grey' | 'hide' | 'keep' }
66
- | { readonly name?: string; readonly kind: 'mover'; place(ctx: FocusContext): { stage?: FocusGroup; subject?: FocusGroup; context?: FocusGroup; groups?: { names: readonly string[]; g: FocusGroup | null }[] } }
72
+ | { readonly name?: string; readonly kind: 'mover'; place(ctx: FocusContext): { stage?: FocusGroup; subject?: FocusGroup; context?: FocusGroup; groups?: { names: readonly string[]; g: FocusGroup | null }[]; spring?: FocusSpring } }
67
73
  | { readonly name?: string; readonly kind: 'overlay'; cover(ctx: { subjectOnSlide: FocusBox | null; canvas: { w: number; h: number }; options: Readonly<Record<string, unknown>> }): { rects: number[][]; label?: string; out?: number[][] } | null }
68
74
  | { readonly name?: string; readonly kind: 'order'; route(ctx: { names: readonly string[]; options: Readonly<Record<string, unknown>> }): FocusRoute };
69
75
 
@@ -93,7 +99,11 @@ export interface FocusPieceLook {
93
99
  readonly hot: boolean;
94
100
  readonly hotIn: boolean;
95
101
  readonly move: FocusMove | null;
96
- /** Where it moved from, when it moved since the last click. (A leaving piece's `move` is where it stood.) */
102
+ /**
103
+ * Where it moved from, when it moved since the last click. (A leaving piece's `move` is where it stood.) A piece on
104
+ * its group's spring (`apart`) has the spring's other end: where it stands together as its group opens — kept while
105
+ * the group stays open — and where it stood apart as its group closes.
106
+ */
97
107
  readonly from: FocusMove | null;
98
108
  /** It stepped aside for its lit twin. */
99
109
  readonly twinOff: boolean;
@@ -101,6 +111,12 @@ export interface FocusPieceLook {
101
111
  readonly twinTurn: number | null;
102
112
  /** On a path: the seconds its turn waits (0 for the first); null when it takes no turn — not on the route, or not lit. */
103
113
  readonly path: number | null;
114
+ /**
115
+ * It goes by its group's spring on this click (apart): the group's index in the look's `apart.groups` — the page's
116
+ * focusRuntime moves it from `from` to `move`. Null for every other piece (one that enters, leaves, or is in no
117
+ * group that opens or closes; one whose group stays open but moved anyway glides as any mover's piece does).
118
+ */
119
+ readonly apart: number | null;
104
120
  }
105
121
 
106
122
  export interface FocusLook {
@@ -111,6 +127,13 @@ export interface FocusLook {
111
127
  readonly overlay: { readonly rects: readonly (readonly number[])[]; readonly label: string; readonly phase: 'in-new' | 'in' | 'out'; readonly frameNew: boolean } | null;
112
128
  /** A path: how many turns, the seconds between two, when the last starts (done), and its line ('' for none) and where it goes (null: the area's bottom left); null when no lit piece took a turn. */
113
129
  readonly path: { readonly turns: number; readonly step: number; readonly done: number; readonly line: string; readonly lineAt: string | null } | null;
130
+ /**
131
+ * apart: the groups that open on this click (to 1, in the order written), then those the last click opened that
132
+ * close now (to 0, was 1, in its order), then those the last click closed (to 0, was 0) — carried one click more, so
133
+ * a spring still closing settles from wherever it is; `was` is where the last click left a group (0 together, 1
134
+ * apart). Null when no group opens, stays open, closes or settles — as on every click of a deck without apart.
135
+ */
136
+ readonly apart: { readonly stagger: number; readonly groups: readonly { readonly names: readonly string[]; readonly to: 0 | 1; readonly was: 0 | 1 }[] } | null;
114
137
  readonly strategies: readonly string[];
115
138
  /** The subject where it ends up, on the slide. */
116
139
  readonly subject: FocusBox | null;
@@ -119,7 +142,12 @@ export interface FocusLook {
119
142
  /** The state a click leaves, to start the next from. Opaque. */
120
143
  export type FocusState = { readonly __focusState: true } & Record<string, unknown>;
121
144
 
122
- export const FOCUS: Readonly<Record<'grey' | 'hide' | 'keep' | 'zoom' | 'left' | 'right' | 'up' | 'down' | 'place' | 'path' | 'blur', FocusStrategy>>;
145
+ /**
146
+ * The built-in strategies. `apart` (a mover) is the exploded view: its option `apart` — data-focus-apart — is
147
+ * 'names: dx dy [scale] | …' (slide px; each group scaled around its own centre), `stagger` the ms between two
148
+ * groups (35); a later click without it brings the groups back together, the last one out first.
149
+ */
150
+ export const FOCUS: Readonly<Record<'grey' | 'hide' | 'keep' | 'zoom' | 'left' | 'right' | 'up' | 'down' | 'place' | 'apart' | 'path' | 'blur', FocusStrategy>>;
123
151
  export function readFocus(spec: FocusSpec | undefined | null, shared?: Readonly<Record<string, unknown>>): { strategy: string; options: Record<string, unknown> }[];
124
152
  export function focusStep(prev: FocusState | null, click: FocusClick, deck: FocusDeck): { state: FocusState; look: FocusLook };
125
153
  /** `end` is null only when there are no clicks and no `start`. */
package/focus.js CHANGED
@@ -10,7 +10,10 @@
10
10
  // left · right · up · down (the context steps aside that way, smaller,
11
11
  // and the subject grows into the room it leaves) ·
12
12
  // place (named groups moved by the amounts you give — a layout you
13
- // art-direct, still animated from wherever the last click left it)
13
+ // art-direct, still animated from wherever the last click left it) ·
14
+ // apart (the exploded view: named groups fly apart along the vectors you
15
+ // give, one spring each, staggered, and come back together on a later
16
+ // click without it — the last one out is the first one back)
14
17
  // overlay what lies over the slide blur (everything outside the subject's frame is blurred; a frame,
15
18
  // and a label if given, on the subject)
16
19
  // order how the subject comes on all at once (the default) · path (piece after piece along a route —
@@ -74,14 +77,31 @@ function aside(side) {
74
77
  }});
75
78
  }
76
79
 
77
- /** place's groups: 'names: dx dy scale [@ ox oy] | …' (or a list of such strings). */
78
- function readGroups(v) {
79
- if (v === undefined || v === null || v === '') throw new Error('focus "place": give groups, \'names: dx dy scale [@ ox oy] | …\'');
80
- return [].concat(v).flatMap(x => String(x).split('|')).map(g => g.trim()).filter(Boolean).map(g => {
81
- const m = /^([^:]+):\s*(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?(?:\s*@\s*(-?[\d.]+)\s+(-?[\d.]+))?$/.exec(g);
82
- if (!m) throw new Error(`focus "place": a group is 'names: dx dy scale [@ ox oy]', not "${g}"`);
83
- return {names: m[1].trim().split(/\s+/), dx: Number(m[2]), dy: Number(m[3]), s: m[4] === undefined ? 1 : Number(m[4]), origin: m[5] === undefined ? null : [Number(m[5]), Number(m[6])]};
80
+ /**
81
+ * Groups moved by amounts: 'names: dx dy scale [@ ox oy] | …' (or a list of such strings) — place's, and apart's
82
+ * without the origin ('names: dx dy [scale]', written in data-focus-apart).
83
+ */
84
+ const NUMBER = '(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))'; // 12, -3.5, .5 — never '1.2.3' or '.'
85
+ const GROUP = new RegExp(`^([^:]+):\\s*${NUMBER}\\s+${NUMBER}(?:\\s+${NUMBER})?(?:\\s*@\\s*${NUMBER}\\s+${NUMBER})?$`);
86
+ function readGroups(v, who = 'place', origin = true) {
87
+ const form = origin ? 'names: dx dy scale [@ ox oy]' : 'names: dx dy [scale]';
88
+ const give = `focus "${who}": give groups, '${form} | …'${origin ? '' : ` (data-focus-${who}, or the option ${who})`}`;
89
+ if (v === undefined || v === null || v === '') throw new Error(give);
90
+ const groups = [].concat(v).flatMap(x => String(x).split('|')).map(g => g.trim()).filter(Boolean).map(g => {
91
+ const m = GROUP.exec(g);
92
+ if (!m || (!origin && m[5] !== undefined)) throw new Error(`focus "${who}": a group is '${form}', not "${g}"`);
93
+ const s = m[4] === undefined ? 1 : Number(m[4]);
94
+ if (!(s > 0)) throw new Error(`focus "${who}": a group's scale is a number above 0 (1 keeps its size), not ${m[4]} in "${g}"`);
95
+ return {names: m[1].trim().split(/\s+/), dx: Number(m[2]), dy: Number(m[3]), s, origin: m[5] === undefined ? null : [Number(m[5]), Number(m[6])]};
84
96
  });
97
+ if (!groups.length) throw new Error(`${give} — not ${JSON.stringify(v)}`);
98
+ return groups;
99
+ }
100
+
101
+ /** A group move that puts the centre of the group's box (or the point `origin`, map px) where dx dy (slide px) and s say. */
102
+ function groupMove({names, dx, dy, s, origin}, boxOf, view) {
103
+ const box = boxOf(names); if (!box && !origin) return {names, g: null};
104
+ return {names, g: {o: origin ?? centre(box), s, t: [dx / view.s, dy / view.s]}};
85
105
  }
86
106
 
87
107
  /** Rectangles x y w h from an option: numbers, a string of them, or a list of either. */
@@ -108,11 +128,16 @@ export const FOCUS = Object.freeze({
108
128
  * moves by dx, dy (slide px) and scales around its own centre, or around the point ox oy (map px) when given.
109
129
  */
110
130
  place: Object.freeze({name: 'place', kind: 'mover', place({boxOf, view, options}) {
111
- return {groups: readGroups(options.groups).map(({names, dx, dy, s, origin}) => {
112
- const box = boxOf(names); if (!box && !origin) return {names, g: null};
113
- const o = origin ?? centre(box);
114
- return {names, g: {o, s, t: [dx / view.s, dy / view.s]}};
115
- })};
131
+ return {groups: readGroups(options.groups).map(g => groupMove(g, boxOf, view))};
132
+ }}),
133
+ /**
134
+ * The exploded view: `apart` is 'names: dx dy [scale] | names: …' — each named group (lines and connectors that
135
+ * carry one of its names come with it) flies out by dx, dy (slide px), scaled around its own centre, and comes back
136
+ * together on a later click without apart. Its way there is a spring per group (focusHtml.js · focusRuntime), the
137
+ * groups `stagger` (35) ms apart: out in the order written, back in reverse, so the last one out is the first one back.
138
+ */
139
+ apart: Object.freeze({name: 'apart', kind: 'mover', place({boxOf, view, options}) {
140
+ return {groups: readGroups(options.apart, 'apart', false).map(g => groupMove(g, boxOf, view)), spring: {stagger: num(options.stagger, 35)}};
116
141
  }}),
117
142
  /**
118
143
  * The subject comes on piece after piece along a route, as a request travels it: `route` names the pieces in
@@ -164,7 +189,7 @@ function pick(focus, strategies) {
164
189
  const s = all[strategy];
165
190
  if (!s) throw new Error(`no focus strategy "${strategy}" (there are ${Object.keys(all).join(', ')})`);
166
191
  if (!KINDS.includes(s.kind)) throw new Error(`focus strategy "${strategy}" has kind ${JSON.stringify(s.kind)}: a strategy is a look, a mover, an overlay or an order`);
167
- if (byKind[s.kind]) throw new Error(`a click takes one ${s.kind} at most: "${byKind[s.kind].name}" and "${strategy}" are both ${s.kind}s`);
192
+ if (byKind[s.kind]) throw new Error(`a click takes one ${s.kind} at most: "${byKind[s.kind].name}" and "${strategy}" are both ${s.kind}s — keep one, and give the other a click of its own`);
168
193
  byKind[s.kind] = {s: {...s, name: s.name ?? strategy}, options, name: strategy};
169
194
  }
170
195
  return byKind;
@@ -186,6 +211,17 @@ function readRoute(r, strategy) {
186
211
  return {names: [...names], step, line: line ?? '', lineAt: lineAt?.trim() ? lineAt : null}; // (a blank lineAt, an empty data-focus-line-at, is none)
187
212
  }
188
213
 
214
+ /**
215
+ * What a mover's `spring` gave — its groups go by a spring each, `stagger` ms apart — checked like an order's route
216
+ * (a deck's own mover is held to what apart gives) and read into a new object.
217
+ */
218
+ function readSpring(r, strategy) {
219
+ const shown = v => (typeof v === 'number' || v === undefined ? String(v) : JSON.stringify(v));
220
+ if (!r || typeof r !== 'object') throw new Error(`focus "${strategy}": spring is {stagger}, the ms between two groups — not ${shown(r)}`);
221
+ if (typeof r.stagger !== 'number' || !(r.stagger >= 0 && r.stagger <= 1000)) throw new Error(`focus "${strategy}": stagger is the ms between two groups setting off, a number from 0 to 1000 — not ${shown(r.stagger)}`);
222
+ return {stagger: r.stagger};
223
+ }
224
+
189
225
  /** Where a group move puts one piece: its reference point (the centre, or the top-left when it has no size) moves by [dx, dy], and it scales by s around it. */
190
226
  function placePiece(g, box) {
191
227
  if (!g || !box) return null;
@@ -217,8 +253,10 @@ function changeOf(st, was, look, wasLook, entering, before) {
217
253
  * fresh). `deck`: {pieces: [{keys, box, twin?}], strategies?, area?, view?, canvas?}. Returns {state, look}:
218
254
  * the state to hand the next click, and the look to draw — per piece {state, was, change, look, fromLook, hot,
219
255
  * hotIn, move, from, twinOff, twinTurn (stepping aside for a lit twin on a route: the seconds until that twin's
220
- * turn; else null), path (a lit piece on a route: the seconds until its turn; else null)}, the stage's move, the
221
- * overlay, the path ({turns, step, done, line, lineAt}, or null) and which strategies were used.
256
+ * turn; else null), path (a lit piece on a route: the seconds until its turn; else null), apart (a piece that goes by
257
+ * its group's spring on this click: the group's index in the look's apart; else null)}, the stage's move, the
258
+ * overlay, the path ({turns, step, done, line, lineAt}, or null), the apart ({stagger, groups: [{names, to, was}]} —
259
+ * to 1 opens, to 0 closes, was is where the last click left it — or null) and which strategies were used.
222
260
  */
223
261
  export function focusStep(prev, step, deck) {
224
262
  const {pieces, strategies = {}} = deck, canvas = deck.canvas ?? {w: 1920, h: 1080};
@@ -250,8 +288,15 @@ export function focusStep(prev, step, deck) {
250
288
  // boxOf(names): the box around the shown pieces of those names, for a mover that moves named groups (place).
251
289
  const boxOf = l => union(all.filter(i => states[i] !== 'gone' && has(i, known(names([].concat(l)), 'focus group: '))).map(i => pieces[i].box));
252
290
  const placed = chosen.mover ? chosen.mover.s.place({subject, context, area, view, canvas, boxOf, options: chosen.mover.options}) ?? {} : {};
253
- // Named groups (later ones win a piece in two of them) take their pieces out of the subject and context groups.
254
- for (const {names: l, g} of placed.groups ?? []) for (const i of all) if (states[i] !== 'gone' && has(i, l)) inGroup.set(i, g ? {g} : 'still');
291
+ // Named groups (later ones win a piece in two of them) take their pieces out of the subject and context groups. A
292
+ // mover whose groups go by a spring (apart) has each moved piece remember its group, so the next click knows what closes.
293
+ const spring = placed.spring === undefined || placed.spring === null ? null : readSpring(placed.spring, chosen.mover.name);
294
+ const springOf = new Map();
295
+ for (const {names: l, g} of placed.groups ?? []) for (const i of all) if (states[i] !== 'gone' && has(i, l)) {
296
+ inGroup.set(i, g ? {g} : 'still');
297
+ if (spring && g) springOf.set(i, l.join(' ')); else springOf.delete(i);
298
+ }
299
+ const springGroups = [...new Map((spring ? placed.groups ?? [] : []).filter(x => x.g).map(x => [x.names.join(' '), [...x.names]]))];
255
300
  const stage = placed.stage ? {s: round(placed.stage.s, 4), x: round(placed.stage.o[0] * (1 - placed.stage.s) + placed.stage.t[0]), y: round(placed.stage.o[1] * (1 - placed.stage.s) + placed.stage.t[1])} : null;
256
301
  const groupOf = i => { const k = inGroup.get(i); return k === 'subject' ? placed.subject : k === 'context' ? placed.context : k?.g ?? null; };
257
302
  const moves = pieces.map((p, i) => states[i] === 'gone' ? null : placePiece(groupOf(i), p.box));
@@ -278,15 +323,45 @@ export function focusStep(prev, step, deck) {
278
323
  // stood, so it fades out from there instead of jumping home in colour first.
279
324
  const before = prev && !step.fresh ? prev : null;
280
325
  const hotNow = new Set(all.filter(i => states[i] !== 'gone' && has(i, hot)));
326
+ // apart: the groups that open on this click (in the order written), then those the last click opened that close now
327
+ // (in its order, was 1), then those the last click closed (to 0, was 0): carried one click more, so a spring still
328
+ // closing settles from wherever it is instead of snapping home. A piece shown on both clicks goes by its group's
329
+ // spring; one whose group keeps its target but moved anyway (a new vector) glides as any mover's piece does.
330
+ const last = before?.spring ?? null, nowKeys = new Set(springGroups.map(([k]) => k));
331
+ const lastTo = new Map((last?.groups ?? []).map(g => [g.key, g.to]));
332
+ const springList = [...springGroups.map(([key, l]) => ({key, names: l, to: 1, was: lastTo.get(key) === 1 ? 1 : 0})),
333
+ ...(last?.groups ?? []).filter(g => !nowKeys.has(g.key)).map(g => ({key: g.key, names: g.names, to: 0, was: g.to}))];
334
+ const springAt = new Map(springList.map((g, k) => [g.key, k]));
335
+ // Each shown piece's group: the one that opens it now, else the one it was in, while that group is still listed.
336
+ const member = pieces.map((_, i) => {
337
+ if (states[i] === 'gone') return null;
338
+ const had = before?.springOf?.[i] ?? null;
339
+ return springOf.get(i) ?? (had !== null && springAt.has(had) && !nowKeys.has(had) ? had : null);
340
+ });
341
+ const springIdx = (i, moved) => {
342
+ if (!before || member[i] === null || before.states[i] === 'gone') return null;
343
+ const k = springAt.get(member[i]);
344
+ return springList[k].to === springList[k].was && moved ? null : k;
345
+ };
346
+ // The other end of a piece's spring: where it stands together as its group opens, where it stood apart as it closes —
347
+ // carried while the group keeps its target, so a spring caught mid-way goes on from there.
348
+ const home = () => ({dx: 0, dy: 0, s: 1});
349
+ const end = pieces.map((_, i) => {
350
+ if (member[i] === null) return null;
351
+ const g = springList[springAt.get(member[i])];
352
+ return g.to === g.was ? before?.springEnd?.[i] ?? home() : before?.moves[i] ?? home();
353
+ });
281
354
  const out = pieces.map((p, i) => {
282
355
  const st = states[i], was = before ? before.states[i] : 'gone', wasLook = before?.looks?.[i] ?? null;
283
356
  const change = changeOf(st, was, looks[i], wasLook, has(i, ins), before);
284
357
  const leaving = change === 'leave', stood = before?.moves[i] ?? null;
285
358
  const from = stood, moved = st !== 'gone' && was !== 'gone' && !!before && !sameMove(from, moves[i]);
359
+ // A piece on its group's spring has the spring's other end as `from`: together as it opens, apart as it closes.
360
+ const k = springIdx(i, moved), spun = k === null ? null : end[i];
286
361
  return {state: st, was, change, look: leaving ? wasLook : looks[i], fromLook: change === 'relook' ? wasLook : null,
287
362
  hot: hotNow.has(i), hotIn: hotNow.has(i) && (!before || !before.hot.has(i)),
288
- move: leaving ? stood : st === 'gone' ? null : moves[i], from: moved ? from ?? {dx: 0, dy: 0, s: 1} : null, twinOff: twinOff.has(i),
289
- twinTurn: leaving && twinOff.has(i) ? twinTurn(i) : null, path: delayOf(i)};
363
+ move: leaving ? stood : st === 'gone' ? null : moves[i], from: spun ?? (moved ? from ?? home() : null), twinOff: twinOff.has(i),
364
+ twinTurn: leaving && twinOff.has(i) ? twinTurn(i) : null, path: delayOf(i), apart: k};
290
365
  });
291
366
  const stageMoved = !!before && !sameStage(before.stage, stage);
292
367
  // The blur: new only when the last click had none (a blur that moves to another subject stays, and only its
@@ -294,9 +369,12 @@ export function focusStep(prev, step, deck) {
294
369
  const overlay = cover ? {rects: cover.rects, label: cover.label, phase: before?.cover ? 'in' : 'in-new', frameNew: !before?.cover || !sameCover(before.cover, cover)}
295
370
  : before?.cover ? {rects: before.cover.out ?? before.cover.rects, label: '', phase: 'out', frameNew: false} : null;
296
371
  return {
297
- state: {vis, states, moves, stage, hot: hotNow, cover, looks},
372
+ state: {vis, states, moves, stage, hot: hotNow, cover, looks,
373
+ spring: springList.some(g => g.to === 1 || g.was === 1) ? {stagger: (spring ?? last).stagger, groups: springList.filter(g => g.to === 1 || g.was === 1).map(({key, names: l, to}) => ({key, names: l, to}))} : null,
374
+ springOf: member, springEnd: end},
298
375
  look: {pieces: out, stage: stage || stageMoved ? {move: stage, from: stageMoved ? before.stage ?? {s: 1, x: 0, y: 0} : null} : null, overlay,
299
376
  path: turns ? {turns, step: order.step, done: round((turns - 1) * order.step, 3), line: order.line, lineAt: order.lineAt} : null,
377
+ apart: springList.length ? {stagger: (spring ?? last).stagger, groups: springList.map(({names: l, to, was}) => ({names: [...l], to, was}))} : null,
300
378
  strategies: Object.values(chosen).map(c => c.name), subject: subjectOnSlide},
301
379
  };
302
380
  }
package/focusHtml.d.ts CHANGED
@@ -10,7 +10,9 @@ export function readStep(attrs: string): FocusClick;
10
10
  /**
11
11
  * That HTML, each piece dressed for this click's look (classes from focus.css, its move as CSS variables). On a path, a
12
12
  * piece's moment rides as `--sd-path-delay`: its turn (`sd-path`), or, stepping aside for its lit twin, that twin's
13
- * (`sd-twin-turn`); focus.css adds .4 s under a new blur. A click without a path is drawn exactly as before.
13
+ * (`sd-twin-turn`); focus.css adds .4 s under a new blur. A click without a path is drawn exactly as before. A piece
14
+ * that goes by its group's spring (apart) is marked `sd-apart` (no CSS move: focusRuntime plays the spring) and
15
+ * carries `data-sd-apart="index to was stagger names…"`; a click without apart is drawn exactly as before.
14
16
  */
15
17
  export function drawFocus(html: string, look: FocusLook): string;
16
18
  /** The pieces inside the stage the view moves (a zoom); unchanged when nothing moves the view. Put it in the map's own box. */
@@ -19,5 +21,10 @@ export function wrapStage(inner: string, look: FocusLook): string;
19
21
  export function slideClass(look: FocusLook): string;
20
22
  /** The overlay of a click in slide px — the blur, its frame and label, and a path's line (once its route is done, .4 s later under a new blur) — put outside the map's box; '' when the click has none. */
21
23
  export function focusOverlay(look: FocusLook, options?: { canvas?: { w: number; h: number }; area?: readonly [number, number, number, number] }): string;
22
- /** Runs in the page; call it once, at any time: only a forward click animates; a jump or a step back lands at once. */
24
+ /**
25
+ * Runs in the page; call it once, at any time: only a forward click animates; a jump or a step back lands at once.
26
+ * It plays apart's springs (one per group, staggered, from wherever the last click left a group, mid-way too; each deck
27
+ * on the page its own) as Web Animations, so a group at rest is exactly as drawn; under reduced motion, or without
28
+ * Element.animate, it lands.
29
+ */
23
30
  export function focusRuntime(): void;