footprint-storydeck 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/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 — FootPrint 11 and a part that says how it differs
4
+
5
+ - **Migration:** PowerPoint export requires FootPrint 11. Install `footprintjs@^11.0.0` beside Storydeck;
6
+ the peer stays optional and is loaded when an export runs. Older FootPrint majors are no longer supported.
7
+
8
+ - **A part that differs from the real thing says so.** A picture is believed, so a process run 30 times faster or a part
9
+ drawn far larger than it is reads as how the thing is. A part now declares it (`data-differs-time="30"`,
10
+ `data-differs-scale="…"`), and the deck says it: a label at the top right of its slides by default, in the notes with
11
+ `data-differs-said` (`"A | B"` gives one phrase per statement), or both. Each statement declared needs a line or a
12
+ phrase of its own, a number in the time's line must be the one declared, and a deck in another language gives its own
13
+ words. A time factor is 1.01 or more or 0.99 or less, up to a billion times either way. `check` refuses a phrase the
14
+ notes do not hold (`differs-unsaid`), matching words as StoryReel matches a phrase, never inside a word, and `check
15
+ --layout` warns when a label meets the kicker, the headline or a lit piece (`differs-overlap`). `review` says how each
16
+ click differs. A `>` in a value, or `data-differs-*` on a `<step>`, is refused instead of lost. A deck that declares no
17
+ label builds byte for byte as before: the page carries the label's CSS only when a part shows one. It cannot see a
18
+ speed-up nobody declared.
19
+ - **Loops keep running in a video.** `renderVideo` stepped a click until its last entrance ended and held that frame,
20
+ so a loop (a pulse, packets on a wire) stood still for the rest of the click and started again at the next. A click
21
+ whose loops run is now stepped on to its end, each loop at the video's own time, so they run on from click to click
22
+ as in the room; a click without loops renders as before, and the timeline is unchanged. It costs frames — a looping
23
+ click is one frame per 1/fps of its length — so `loops: false` holds the frame as before, for a fast draft. The
24
+ browser port gains an optional `loops()` and `seek(ms, {loop})`; a driver without them renders as before.
25
+
26
+ ## 0.10.0 — a part arrives with a change of picture, and a deck a viewer can explore
27
+
28
+ - **A part arrives with a change of picture: `data-enter`.** `<section … data-enter="push" data-enter-from="right">`
29
+ plays one of the editor's changes StoryReel's films use (footprint-story/motion's table: the same names, settings,
30
+ lengths and eases) as the deck steps into the part: cut, fade, dip, wipe, split, iris, push, slide, whip or zoom,
31
+ each with its settings as `data-enter-*` (`from`, `line`, `at` as "x y" in slide px or "centre", `color`) and
32
+ `data-enter-seconds` / `data-enter-ease`. The build samples each distinct change once into keyframes for both slides
33
+ (30 a second, the same curves a film draws); the page plays them with Web Animations only on a forward step into the
34
+ part — a jump, a step back, reduced motion and motion off land at once — keeping the slide it leaves in view until
35
+ it ends, and a video steps them like any animation. A dip shows its colour behind both slides (black unless
36
+ `data-enter-color` says otherwise): `<deck-stage>` now paints what shows behind its slides with `--deck-canvas` (white,
37
+ as before, when unset). A change a page cannot play (clock, page, through, match: they need a canvas), a setting the
38
+ change does not take and a value out of range are refused at build, naming the part and the fix. `review` says how
39
+ each part arrives. While a change plays the frame clips both slides (a push never crosses the letterbox: `<deck-stage>`
40
+ clips its canvas while it carries `data-deck-changing`), the slide being left keeps its loops running, and printing
41
+ puts everything back first. Settings are read as a browser reads the tag. A deck without `data-enter` carries none
42
+ of it (no keyframes, rule or runtime); the `<deck-stage>` every page carries gained the two hooks, and draws as
43
+ before without them.
44
+
45
+ - **Explore: a viewer clicks a piece to go to the click about it.** With `"explore": true` in deck.json, a click on a
46
+ piece goes to the click that is about it — the first click whose `data-on` names it (`gw+`'s gw too), or, when none
47
+ does, the first that takes it in through a `name+` — and a "Back to click N" control (or Backspace) returns, a step
48
+ at a time, as a browser's Back does (to where the viewer was before each jump, whatever they stepped through since).
49
+ Pieces that lead somewhere get a pointer and a ring on hover; a piece whose click is the one shown, or one that
50
+ cannot be seen, leads nowhere; a link or control inside a piece keeps its click; a drag (the dev page's Alt-drag) is
51
+ never a click. On a touch screen a tap on a piece that leads somewhere explores — tap beside the pieces to step. The
52
+ pieces are reached by pointer, not by keyboard: arrow keys still step through the clicks in order. The presenter
53
+ window does not explore (it follows the room's deck). The authored order never changes: explore is a way through the
54
+ same clicks. A deck without `"explore"` carries none of it (its look comes with it, not in deck.css).
55
+
3
56
  ## 0.9.0 — living system diagrams: loops, wires between pieces, a wire's label, halo and running packets
4
57
 
5
58
  - **A wire between pieces: `data-arrow="client to server"`.** The wire runs from the edge of one piece's box to the
@@ -45,8 +98,9 @@
45
98
  step back keeps it running (a static arrival no longer zeroes a loop, only entrances). A loop never runs under
46
99
  reduced motion (switched live), in print, or with motion off: there the piece rests in its own style. A piece that
47
100
  loops and changes on the same click (comes in, lights up) does both: focus.css keeps the loop as the last animation
48
- of the piece's list. Loop CSS is drawn only for decks that use loops, so a deck without one builds byte for byte as
49
- before.
101
+ of the piece's list. Loop CSS is drawn only for decks that use loops, so a deck without one gains none; the
102
+ `<deck-stage>` every page carries changed, and a deck without loops draws as before. (Corrected after release: this
103
+ line first said such a deck builds byte for byte as before — its page carries the changed stage.)
50
104
  - **Motion off: a still deck.** `?motion=off` after the page's address, or `<deck-stage data-motion="off">` (switched
51
105
  live too): no loops, and every click lands at once, as a jump does — so a screenshot of a click is the same every
52
106
  time.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Footprint StoryDeck
2
2
 
3
+ For developers publishing technical explanations, product walkthroughs and teaching material.
4
+
5
+ [Quick start](#quick-start) · [Folder-based deck CLI](#a-deck-from-a-folder-storydeck) · [Live demo](https://footprintjs.github.io/storydeck/) · [API](#api)
6
+
3
7
  **One source, many lenses.** Write a piece of content once, render it three ways:
4
8
 
5
9
  - **Read** — a detailed, SEO-first article (figure + prose per section).
@@ -399,6 +403,19 @@ npx footprint-storydeck build my-talk # my-talk/index.html — one page, eve
399
403
  ticks the line (`- [x]`). Pointing costs the person a second and the agent no screenshot. Each note is signed with a
400
404
  key kept with you (`~/.storydeck/key`) for that deck's folder, so a feedback.md that came with someone else's deck is
401
405
  never read as your requests: its lines are counted, never listed.
406
+ - **A part that differs from the real thing says so.** A picture is believed, so a process run 30 times faster or a
407
+ part drawn far larger than it is reads as how the thing is. `<section data-label="Inside" data-differs-time="30"
408
+ data-differs-scale="The dots are drawn far larger than real" data-differs-said="thirty times faster">` declares it, and the
409
+ deck says it: on the slide by default, a label at the top right of each of the part's slides ("Time shown 30 times
410
+ faster"; `data-differs-shown` gives other words, `A | B` for two labels, or `false` for the notes alone), in the notes with
411
+ `data-differs-said`, the phrase that says it (`A | B` for one per statement), or both. A factor a label can say is 1.01 or
412
+ more, or 0.99 or less, up to a billion times either way. Each statement declared needs a line or a phrase of its own, and
413
+ a number in the time's line must be the one declared. A deck in another language gives its own words: the default ones
414
+ are English. A value is read as a browser reads it: write `&gt;` for a `>` and `&#124;` for a `|` in your words. `check`
415
+ refuses a phrase the part's notes do not hold, matching whole words in order, as a film does. `check --layout` warns when a
416
+ label meets the kicker, the headline or a lit piece. `review` says how each click differs, and a video shows the labels as
417
+ the page does. A deck that shows no label builds byte for byte as if this did not exist. It reads what is declared, and
418
+ cannot see a speed-up nobody declared.
402
419
  - **A part can have its own look.** `<section data-label="3 · Storage" … data-theme="ledger">` puts
403
420
  `data-theme="ledger"` on each of that part's slides, so `theme.css` can dress it: redefine the tokens for it
404
421
  (`[data-theme="ledger"] { --bg: …; --text: …; }`, and `:root.dark [data-theme="ledger"]` for dark) and give its pieces
@@ -408,13 +425,24 @@ npx footprint-storydeck build my-talk # my-talk/index.html — one page, eve
408
425
  follow the part on screen as well, select the active slide in the same rule:
409
426
  `[data-theme="ledger"], :root:has([data-deck-active][data-theme="ledger"]) { … }`. `check` warns about a theme
410
427
  that theme.css never selects (a misspelt name changes nothing), and `review` names each click's theme.
428
+ - **A part arrives with a change of picture.** `<section … data-enter="push" data-enter-from="right">` plays one of
429
+ the editor's changes StoryReel's films use — cut, fade, dip, wipe, split, iris, push, slide, whip, zoom — with its
430
+ settings as `data-enter-*` (`from`, `line`, `at` "x y" or "centre", `color` — a dip goes through black unless it
431
+ says otherwise — `seconds`, `ease`), as the deck steps into the part; the frame clips both slides while it plays. Only on a forward step: a jump or a step back lands at once, and so does every step under reduced
432
+ motion or with motion off. `review` says how each part arrives.
433
+ - **Explore: click a piece, go to the click about it.** `"explore": true` in deck.json lets a viewer wander the
434
+ deck: a click on a piece goes to the click that is about it (the first whose `data-on` names it, else the first that
435
+ takes it in through a `name+`), and "Back to click N" (or Backspace) returns, as a browser's Back does. Pieces that
436
+ lead somewhere show a pointer and a ring on hover; on a touch screen a tap on one explores (tap beside the pieces to
437
+ step), and the keyboard keeps stepping in order. The order you wrote is
438
+ untouched — explore is a way through the same clicks, for a deck left open on a screen or sent to someone.
411
439
  - **Read first, look last.** A reviewer — a person, or a model that pays for every screenshot — reads the deck as text:
412
440
  `outline` (a line per click), `review --click 14` (its headline and words, what it lights, greys, brings in and moves,
413
441
  its route, its notes), `check` (what to fix, each with its click and its fix: a headline over 12 words, pieces that
414
442
  light just by coming in, a click that changes nothing, a piece never shown, clicks without notes, a theme theme.css
415
443
  never styles). `check --layout`
416
- opens the page in a browser, offline, and finds text that spills out of its box and endless animations that are not
417
- loops (it needs Playwright installed beside
444
+ opens the page in a browser, offline, and finds text that spills out of its box, a label on how a part differs that meets
445
+ what is on its slide, and endless animations that are not loops (it needs Playwright installed beside
418
446
  StoryDeck: `npm i -D footprint-storydeck playwright-core`; without it, it says so and the other checks run). `mcp` serves the same tools to an AI assistant:
419
447
  `claude mcp add storydeck -- npx footprint-storydeck mcp my-talk`.
420
448
  - **The helpers a diagram needs.** `<i data-k="w1" class="wire pkt" data-arrow="client to server"></i>` is a wire (an
package/deck.d.ts CHANGED
@@ -29,6 +29,8 @@ export interface DeckConfig {
29
29
  readonly deckData: string | null;
30
30
  /** What `storydeck check` asks: words in a map click's headline (12), on a plain slide (40). */
31
31
  readonly checks: { readonly maxWords: number; readonly maxSlideWords: number };
32
+ /** A viewer may click a piece to go to the click about it, and come back (false). */
33
+ readonly explore: boolean;
32
34
  /** The keys deck.json wrote (a file it names must be there; a default may be absent). */
33
35
  readonly given: readonly string[];
34
36
  }
@@ -37,10 +39,14 @@ export function readConfig(json: string | object): DeckConfig;
37
39
 
38
40
  /** One click on a map, as written: its attributes (focus-html · readStep reads them), headline (HTML) and notes. */
39
41
  export interface DeckStepSource { readonly attrs: string; readonly headline: string; readonly notes: readonly string[] }
42
+ /** How a part arrives, as written: data-enter and its data-enter-* settings, as text (compile reads them against the shared table). */
43
+ export interface DeckEnterSource { readonly type: string; readonly given: Readonly<Record<string, string>> }
44
+ /** How a part differs from the real thing, checked (data-differs-*): the time factor, what is out of scale, the labels' words (none: the notes alone), the phrases of its notes that say it. */
45
+ export interface DeckDiffers { readonly time?: number; readonly scale?: string; readonly shown: readonly string[]; readonly said?: readonly string[] }
40
46
  /** A part of the deck: a plain slide (map null) or clicks told on a map. */
41
- export type DeckPart =
47
+ export type DeckPart = { readonly theme: string | null; readonly enter: DeckEnterSource | null; readonly differs: DeckDiffers | null } & (
42
48
  | { readonly label: string; readonly kicker: string; readonly map: null; readonly body: string; readonly notes: readonly string[] }
43
- | { readonly label: string; readonly kicker: string; readonly map: string; readonly steps: readonly DeckStepSource[] };
49
+ | { readonly label: string; readonly kicker: string; readonly map: string; readonly steps: readonly DeckStepSource[] });
44
50
  /**
45
51
  * The clicks file read into parts, scanned once. A step holds only its <h2> and notes (anything else is refused, naming
46
52
  * where it goes); a <section> or <step> left open, or opened inside another, is refused.
@@ -80,6 +86,10 @@ export type DeckClick = {
80
86
  readonly notes: readonly string[];
81
87
  /** What loops on the click (on a map: on the pieces it shows). */
82
88
  readonly loops: readonly DeckLoop[];
89
+ /** How the click's part differs from the real thing, when it declares it. */
90
+ readonly differs?: DeckDiffers;
91
+ /** How the click's part arrives, on its first click (null elsewhere, or when it just appears): footprint-story/motion's change, read. */
92
+ readonly enter: { readonly type: string; readonly seconds: number; readonly ease: string; readonly p: Readonly<Record<string, unknown>> } | null;
83
93
  } & (
84
94
  | { readonly kind: 'plain'; readonly map: null; readonly text: string }
85
95
  | { readonly kind: 'map'; readonly map: string; readonly step: FocusClick; readonly look: FocusLook; readonly stepIndex: number; readonly steps: number }
@@ -98,6 +108,10 @@ export interface CompiledDeck {
98
108
  readonly clicks: readonly DeckClick[];
99
109
  /** The named loops the clicks show (data-loop values), drawn into the page's CSS once each. */
100
110
  readonly loops: readonly string[];
111
+ /** With deck.json "explore": the map each click is on (null on a plain slide), and where each piece leads on its map — the first click whose data-on names it, else the first that takes it in through a name+ (0-based). */
112
+ readonly explore: { readonly slides: readonly (string | null)[]; readonly targets: Readonly<Record<string, Readonly<Record<string, number>>>> } | null;
113
+ /** The parts' entrances as the page plays them: each distinct change sampled once (kinds), and its key per slide — or null when no part has one. */
114
+ readonly transitions: { readonly kinds: Readonly<Record<string, unknown>>; readonly enter: readonly (string | null)[] } | null;
101
115
  readonly maps: Readonly<Record<string, { readonly pieces: readonly FocusPiece[] }>>;
102
116
  }
103
117
  /** The deck's clicks planned (each map's state carried click to click) and drawn as <deck-stage> slides. */
@@ -1,6 +1,7 @@
1
1
  import { wordCount } from "./parse.js";
2
2
  import { expandNames } from "../focus.js";
3
3
  import { LOOP_NAMES } from "./loops.js";
4
+ import { saidInNotes } from "./differs.js";
4
5
  const names = (pieces, idx) => [...new Set(idx.map((i) => pieces[i].keys[0]))];
5
6
  const problem = (level, rule, click, message) => ({ level, rule, click: click?.n ?? null, label: click?.label ?? null, message });
6
7
  function words(c, { maxWords, maxSlideWords }) {
@@ -62,6 +63,17 @@ function unsetLoops(compiled) {
62
63
  const list = [...new Set(bare.map((b) => b.piece))], one = list.length === 1;
63
64
  return [problem("warn", "loop-unset", bare[0].c, `${list.join(", ")} ha${one ? "s" : "ve"} data-loop but nothing sets ${one ? "its" : "their"} --loop, so nothing loops \u2014 give ${one ? "it" : "them"} one in theme.css (.gear { --loop: spin 4s linear infinite }), or name a loop: data-loop="${LOOP_NAMES[0]}" (${LOOP_NAMES.join(", ")})`)];
64
65
  }
66
+ function unsaidDiffers(compiled) {
67
+ const out = [], seen = /* @__PURE__ */ new Set();
68
+ for (const c of compiled.clicks) {
69
+ if (!c.differs?.said || seen.has(c.part)) continue;
70
+ seen.add(c.part);
71
+ const notes = compiled.clicks.filter((x) => x.part === c.part).flatMap((x) => x.notes).join(" ");
72
+ const missing = c.differs.said.filter((phrase) => !saidInNotes(phrase, notes));
73
+ if (missing.length) out.push(problem("error", "differs-unsaid", c, `part "${c.part}" says its notes tell how it differs from the real thing (${missing.map((p) => `"${p}"`).join(", ")}), and they do not hold those words \u2014 say it in the notes, or fix data-differs-said`));
74
+ }
75
+ return out;
76
+ }
65
77
  function checkDeck(compiled) {
66
78
  const out = [];
67
79
  compiled.clicks.forEach((c, i) => {
@@ -69,7 +81,7 @@ function checkDeck(compiled) {
69
81
  if (c.kind === "map") out.push(...litOnEntry(c, compiled.maps[c.map].pieces));
70
82
  out.push(...noChange(c, compiled.clicks[i - 1]));
71
83
  });
72
- out.push(...neverShown(compiled), ...missingNotes(compiled), ...unstyledThemes(compiled), ...unsetLoops(compiled));
84
+ out.push(...neverShown(compiled), ...missingNotes(compiled), ...unstyledThemes(compiled), ...unsetLoops(compiled), ...unsaidDiffers(compiled));
73
85
  return out;
74
86
  }
75
87
  const failure = (error) => [problem("error", "compile", null, error.message)];
@@ -2,9 +2,12 @@ import { focusStep } from "../focus.js";
2
2
  import { readPieces, readStep, drawFocus, wrapStage, slideClass, focusOverlay } from "../focusHtml.js";
3
3
  import { applyTransforms, icons as drawIcons } from "./transforms.js";
4
4
  import { loopsIn } from "./loops.js";
5
+ import { exploreTargets } from "./explore.js";
6
+ import { readEnter, transitionTable } from "./transitions.js";
5
7
  import { plainText } from "./parse.js";
6
8
  import { esc, stripComments } from "./html.js";
7
9
  import { readAttributes, tagsWith } from "../pieceRules.js";
10
+ import { differsHtml, refuseEnglishWords } from "./differs.js";
8
11
  function numberForDev(html) {
9
12
  let out = "", from = 0;
10
13
  for (const { start, tag, before, m } of tagsWith(html, /\sdata-sd-i="(\d+)"/)) {
@@ -41,6 +44,7 @@ function planClicks(parts, maps, { area, canvas, icons }) {
41
44
  let lastMap = null;
42
45
  const clicks = [];
43
46
  for (const part of parts) {
47
+ const enter = readEnter(part.enter, `"${part.label}"`, { frame: canvas });
44
48
  if (!part.map) {
45
49
  refuseOwnNumbers(`"${part.label}"`, part.body);
46
50
  let inner;
@@ -49,7 +53,7 @@ function planClicks(parts, maps, { area, canvas, icons }) {
49
53
  } catch (e) {
50
54
  throw new Error(`"${part.label}": ${e.message}`, { cause: e });
51
55
  }
52
- clicks.push({ label: part.label, part, kind: "plain", inner, loops: loopsIn(drawIcons(part.body, icons), `"${part.label}"`), notes: part.notes, headline: "", step: 0, steps: 1 });
56
+ clicks.push({ label: part.label, part, kind: "plain", inner, enter, loops: loopsIn(drawIcons(part.body, icons), `"${part.label}"`), notes: part.notes, headline: "", step: 0, steps: 1 });
53
57
  lastMap = null;
54
58
  continue;
55
59
  }
@@ -64,7 +68,7 @@ function planClicks(parts, maps, { area, canvas, icons }) {
64
68
  }
65
69
  map.state = r.state;
66
70
  lastMap = map;
67
- clicks.push({ label: `${part.label} \xB7 ${k + 1}`, part, kind: "map", map, step, look: r.look, loops: shownLoops(map, r.look), headline: s.headline, notes: s.notes, stepIndex: k, steps: part.steps.length });
71
+ clicks.push({ label: `${part.label} \xB7 ${k + 1}`, part, kind: "map", map, step, look: r.look, enter: k === 0 ? enter : null, loops: shownLoops(map, r.look), headline: s.headline, notes: s.notes, stepIndex: k, steps: part.steps.length });
68
72
  });
69
73
  }
70
74
  const seen = /* @__PURE__ */ new Set();
@@ -78,8 +82,8 @@ const head = (kicker, headline) => `<div class="head" data-chrome><div class="ki
78
82
  const foot = (footer, n, total) => `<div class="foot" data-chrome><span>${esc(footer)}</span><span class="count">${n} / ${total}</span></div>`;
79
83
  function drawClick(c, i, total, { footer, area, canvas }) {
80
84
  const open = `<section data-label="${esc(c.label)}"${c.part.theme ? ` data-theme="${esc(c.part.theme)}"` : ""}`;
81
- if (c.kind === "plain") return `${open} class="slide plain">${head(c.part.kicker, "")}${c.inner}${foot(footer, i + 1, total)}</section>`;
82
- return `${open} class="slide on-map ${slideClass(c.look)}">${head(c.part.kicker, c.headline)}<div class="map map-${c.map.name}">${wrapStage(drawFocus(c.map.html, c.look), c.look)}</div>${focusOverlay(c.look, { area, canvas: { w: canvas[0], h: canvas[1] } })}${foot(footer, i + 1, total)}</section>`;
85
+ if (c.kind === "plain") return `${open} class="slide plain">${head(c.part.kicker, "")}${differsHtml(c.part.differs)}${c.inner}${foot(footer, i + 1, total)}</section>`;
86
+ return `${open} class="slide on-map ${slideClass(c.look)}">${head(c.part.kicker, c.headline)}${differsHtml(c.part.differs)}<div class="map map-${c.map.name}">${wrapStage(drawFocus(c.map.html, c.look), c.look)}</div>${focusOverlay(c.look, { area, canvas: { w: canvas[0], h: canvas[1] } })}${foot(footer, i + 1, total)}</section>`;
83
87
  }
84
88
  function checkPost(post, labels) {
85
89
  if (!post) return;
@@ -94,6 +98,7 @@ function compileDeck(source, { dev = false } = {}) {
94
98
  const { config, parts, icons = {} } = source;
95
99
  const maps = Object.fromEntries(Object.entries(source.maps ?? {}).map(([name, html]) => [name, prepareMap(name, html, icons, dev)]));
96
100
  for (const part of parts) if (part.map && !maps[part.map]) throw new Error(`"${part.label}" is told on map "${part.map}", which the deck does not have`);
101
+ for (const part of parts) refuseEnglishWords(part.differs, config.lang, `"${part.label}"`);
97
102
  const opts = { area: [...config.area], canvas: config.canvas, icons, footer: config.footer };
98
103
  const planned = planClicks(parts, maps, opts);
99
104
  const total = planned.length;
@@ -113,15 +118,27 @@ function compileDeck(source, { dev = false } = {}) {
113
118
  part: c.part.label,
114
119
  kicker: c.part.kicker,
115
120
  theme: c.part.theme ?? null,
121
+ ...c.part.differs ? { differs: c.part.differs } : {},
116
122
  kind: c.kind,
117
123
  map: c.kind === "map" ? c.map.name : null,
118
124
  headline: plainText(c.headline),
119
125
  notes: c.notes,
120
126
  loops: c.loops,
127
+ enter: c.enter,
121
128
  ...c.kind === "map" ? { step: c.step, look: c.look, stepIndex: c.stepIndex, steps: c.steps } : { text: plainText(c.inner) }
122
129
  }));
123
130
  const loops = [...new Set(planned.flatMap((c) => c.loops.map((l) => l.value)))].filter(Boolean).sort();
124
- return { config, title: config.title, themeCss: source.themeCss ?? "", slides, clicks, loops, maps: Object.fromEntries(Object.values(maps).map((m) => [m.name, { pieces: m.pieces }])) };
131
+ return {
132
+ config,
133
+ title: config.title,
134
+ themeCss: source.themeCss ?? "",
135
+ slides,
136
+ clicks,
137
+ loops,
138
+ explore: config.explore ? exploreTargets(planned) : null,
139
+ transitions: transitionTable(planned.map((c) => c.enter), config.canvas),
140
+ maps: Object.fromEntries(Object.values(maps).map((m) => [m.name, { pieces: m.pieces }]))
141
+ };
125
142
  }
126
143
  export {
127
144
  compileDeck
@@ -9,7 +9,7 @@ const DEFAULTS = Object.freeze({
9
9
  out: "index.html",
10
10
  checks: Object.freeze({ maxWords: 12, maxSlideWords: 40 })
11
11
  });
12
- const KEYS = ["title", "description", "lang", "footer", "area", "canvas", "clicks", "theme", "icons", "maps", "out", "post", "deckData", "checks"];
12
+ const KEYS = ["title", "description", "lang", "footer", "area", "canvas", "clicks", "theme", "icons", "maps", "out", "post", "deckData", "checks", "explore"];
13
13
  const CHECK_KEYS = ["maxWords", "maxSlideWords"];
14
14
  const isText = (v) => typeof v === "string" && v.trim().length > 0;
15
15
  const numbers = (v, n) => Array.isArray(v) && v.length === n && v.every((x) => typeof x === "number" && Number.isFinite(x));
@@ -43,6 +43,7 @@ function readConfig(json) {
43
43
  return v;
44
44
  };
45
45
  const maxWords = count("maxWords"), maxSlideWords = count("maxSlideWords");
46
+ if (raw.explore !== void 0 && typeof raw.explore !== "boolean") throw new Error(`deck.json "explore" is true or false, not ${JSON.stringify(raw.explore)}`);
46
47
  return Object.freeze({
47
48
  title: raw.title.trim(),
48
49
  description: raw.description ?? "",
@@ -58,6 +59,7 @@ function readConfig(json) {
58
59
  post: raw.post === void 0 ? null : deckPath(raw.post, "post"),
59
60
  deckData: raw.deckData === void 0 ? null : deckPath(raw.deckData, "deckData"),
60
61
  checks: Object.freeze({ maxWords, maxSlideWords }),
62
+ explore: raw.explore === true,
61
63
  given: Object.freeze(Object.keys(raw))
62
64
  // the keys deck.json wrote: a file it names must be there; a default may be absent
63
65
  });
@@ -0,0 +1,104 @@
1
+ import { normSpeech } from "footprint-story";
2
+ import { readAttributes } from "../pieceRules.js";
3
+ import { decodeEntities, esc } from "./html.js";
4
+ const DIFFERS_MOST = 70;
5
+ const DIFFERS_SAID_MOST = 120;
6
+ const DIFFERS_TIME_MOST = 1e9;
7
+ const NAMES = ["data-differs-time", "data-differs-scale", "data-differs-shown", "data-differs-said"];
8
+ const plain = (x) => String(+x.toFixed(x >= 10 ? 0 : 2));
9
+ const timeWords = (time) => time > 1 ? `Time shown ${plain(time)} times faster` : `Time shown ${plain(1 / time)} times slower`;
10
+ const factorsOf = (time) => time > 1 ? [time, +plain(time)] : [time, 1 / time, +plain(1 / time)];
11
+ const folded = (value) => decodeEntities(value).replace(/[\s\p{Cc}]+/gu, " ").trim();
12
+ function words(value, name, where) {
13
+ const text = folded(value);
14
+ if (!text) throw new Error(`${where}: ${name} is empty \u2014 say it in words, or leave it out`);
15
+ if (text.length > DIFFERS_MOST) throw new Error(`${where}: ${name} is ${text.length} characters, more than a label can hold in a glance (${DIFFERS_MOST}) \u2014 shorten it, and say the rest in the notes`);
16
+ return text;
17
+ }
18
+ function readTime(value, where) {
19
+ const factor = Number(folded(value)), what = `${where}: data-differs-time=${JSON.stringify(value)} \u2014 it is how many times faster the picture runs than the real thing (30; 0.04 is 25 times slower)`;
20
+ if (!(folded(value) && Number.isFinite(factor) && factor > 0)) throw new Error(what);
21
+ if (plain(factor > 1 ? factor : 1 / factor) === "1") throw new Error(`${what}: it reads as 1 time, and 1 is real time, so leave it out (a factor that shows is 1.01 or more, or 0.99 or less)`);
22
+ if (factor > DIFFERS_TIME_MOST || factor < 1 / DIFFERS_TIME_MOST) throw new Error(`${what}: past ${DIFFERS_TIME_MOST} times either way no viewer reads the number, so say it in words in data-differs-scale`);
23
+ return factor;
24
+ }
25
+ function readSaid(value, where) {
26
+ if (value === null) return null;
27
+ const phrases = value.split("|").map((one) => {
28
+ const text = folded(one);
29
+ if (!normSpeech(text)) throw new Error(`${where}: data-differs-said is empty \u2014 name the phrase of the part's notes that says how it differs, one per statement ("A | B")`);
30
+ if (text.length > DIFFERS_SAID_MOST) throw new Error(`${where}: data-differs-said is ${text.length} characters, more than a phrase (${DIFFERS_SAID_MOST}) \u2014 name the few words that say it`);
31
+ return text;
32
+ });
33
+ if (new Set(phrases.map(normSpeech)).size < phrases.length) throw new Error(`${where}: data-differs-said names the same phrase twice \u2014 one per statement`);
34
+ return phrases;
35
+ }
36
+ const figures = (line) => line.match(/(?<![\p{L}\p{N}])\d+(?:[.,]\d+)*/gu) ?? [];
37
+ const readings = (run) => [run.replace(/,/g, ""), run.replace(/\./g, ""), run.replace(/\./g, "").replace(",", ".")].map(Number);
38
+ function contradicting({ time, scale }, lines) {
39
+ if (time === void 0) return null;
40
+ const says = scale === void 0 ? lines : lines.length >= 2 ? [lines[0]] : [];
41
+ return says.flatMap(figures).find((run) => !readings(run).some((v) => factorsOf(time).includes(v))) ?? null;
42
+ }
43
+ const DIFFERS_NAME = /^data-differs(-|$)/;
44
+ const cutShort = (attrs, rest) => readAttributes(attrs) === null && /(^|\s)data-differs/i.test(attrs + rest.slice(0, Math.max(0, rest.indexOf(">"))));
45
+ function readDiffers(attrs, where, rest = "") {
46
+ if (cutShort(attrs, rest)) throw new Error(`${where}: a ">" inside an attribute's value ends the tag, so its data-differs-* cannot be read \u2014 write &gt; for it`);
47
+ const given = (readAttributes(attrs) ?? []).filter((a) => DIFFERS_NAME.test(a.name));
48
+ if (!given.length) return null;
49
+ const other = given.find((a) => !NAMES.includes(a.name));
50
+ if (other) throw new Error(`${where}: ${other.name} is not a way to say how a part differs \u2014 the attributes are ${NAMES.join(", ")}`);
51
+ const of = (name) => given.find((a) => a.name === name)?.value ?? null;
52
+ const time = of("data-differs-time"), scale = of("data-differs-scale"), shown = of("data-differs-shown");
53
+ const factor = time === null ? void 0 : readTime(time, where);
54
+ if (time === null && scale === null) throw new Error(`${where}: say how the part differs: data-differs-time (how many times faster it runs), data-differs-scale (what is out of scale, in words), or both`);
55
+ const out = { ...factor === void 0 ? {} : { time: factor }, ...scale === null ? {} : { scale: words(scale, "data-differs-scale", where) } };
56
+ const lines = shown?.trim().toLowerCase() === "false" ? [] : shown === null ? [...factor === void 0 ? [] : [timeWords(factor)], ...out.scale ? [out.scale] : []] : shown.split("|").map((line) => words(line, "data-differs-shown", where));
57
+ const phrases = readSaid(of("data-differs-said"), where);
58
+ if (!lines.length && !phrases) throw new Error(`${where}: data-differs-shown="false", so the notes must say it \u2014 name their phrase in data-differs-said`);
59
+ const statements = (factor === void 0 ? 0 : 1) + (out.scale === void 0 ? 0 : 1), says = new Set(lines).size + (phrases?.length ?? 0);
60
+ if (says < statements) throw new Error(`${where}: it declares both time and scale and says only ${says} thing \u2014 give each its own line in data-differs-shown ("A | B") or its own phrase in data-differs-said ("A | B"), so neither goes unstated`);
61
+ const wrong = contradicting(out, lines);
62
+ if (wrong !== null) throw new Error(`${where}: data-differs-shown says ${wrong} where data-differs-time says ${factor} \u2014 leave shown out for the default words, or write the number declared (and with scale too, the time's line first)`);
63
+ return { ...out, shown: lines, ...phrases ? { said: phrases } : {} };
64
+ }
65
+ function refuseStepDiffers(attrs, where) {
66
+ const named = (readAttributes(attrs) ?? []).some((a) => DIFFERS_NAME.test(a.name)) || readAttributes(attrs) === null && /(^|\s)data-differs/i.test(attrs);
67
+ if (named) throw new Error(`${where}: a <step> cannot say how it differs \u2014 put data-differs-* on its <section>: a part says it on each of its slides`);
68
+ }
69
+ function refuseEnglishWords(differs, lang, where) {
70
+ if (differs?.time !== void 0 && lang && !/^en\b/i.test(lang) && differs.shown.includes(timeWords(differs.time))) {
71
+ throw new Error(`${where}: "${timeWords(differs.time)}" is English and the deck is in "${lang}" \u2014 give the words in its language (data-differs-shown), or "false" for the notes alone`);
72
+ }
73
+ }
74
+ const differsHtml = (d) => d?.shown.length ? `<div class="differs" data-chrome>${d.shown.map((line) => `<span>${esc(line)}</span>`).join("")}</div>` : "";
75
+ const DIFFERS_CSS = `/* a part that differs from the real thing says so (deck/differs.js): labels on the kicker's row, right-aligned */
76
+ :where(.differs) { position: absolute; right: 50px; top: 44px; z-index: 4; display: flex; gap: 10px; justify-content: flex-end; max-width: 75%; flex-wrap: wrap; }
77
+ :where(.differs span) { padding: 8px 16px; border-radius: 20px; background: var(--fg, #14181f); color: var(--bg, #ffffff); font: 700 20px/1.2 var(--sans, system-ui, sans-serif); }`;
78
+ function saidInNotes(phrase, notes) {
79
+ const needle = normSpeech(phrase);
80
+ if (!needle) return false;
81
+ const starts = /* @__PURE__ */ new Set(), ends = /* @__PURE__ */ new Set();
82
+ let content = "";
83
+ for (const word of notes.split(/\s+/)) {
84
+ const w = normSpeech(word);
85
+ if (!w) continue;
86
+ starts.add(content.length);
87
+ content += w;
88
+ ends.add(content.length);
89
+ }
90
+ for (let at = content.indexOf(needle); at >= 0; at = content.indexOf(needle, at + 1)) if (starts.has(at) && ends.has(at + needle.length)) return true;
91
+ return false;
92
+ }
93
+ export {
94
+ DIFFERS_CSS,
95
+ DIFFERS_MOST,
96
+ DIFFERS_SAID_MOST,
97
+ DIFFERS_TIME_MOST,
98
+ differsHtml,
99
+ readDiffers,
100
+ refuseEnglishWords,
101
+ refuseStepDiffers,
102
+ saidInNotes,
103
+ timeWords
104
+ };
@@ -0,0 +1,88 @@
1
+ import { expandNames } from "../focus.js";
2
+ function exploreTargets(planned) {
3
+ const targets = {}, through = {}, table = () => /* @__PURE__ */ Object.create(null);
4
+ planned.forEach((c, i) => {
5
+ if (c.kind !== "map" || !c.step.on) return;
6
+ const own = targets[c.map.name] ??= table(), near = through[c.map.name] ??= table();
7
+ for (const name of c.step.on.map((x) => expandNames(c.map.pieces, [x])[0])) if (name !== "*" && !Object.hasOwn(own, name)) own[name] = i;
8
+ for (const name of expandNames(c.map.pieces, c.step.on)) if (name !== "*" && !Object.hasOwn(near, name)) near[name] = i;
9
+ });
10
+ for (const [map, near] of Object.entries(through)) for (const [name, i] of Object.entries(near)) if (!Object.hasOwn(targets[map], name)) targets[map][name] = i;
11
+ return { slides: planned.map((c) => c.kind === "map" ? c.map.name : null), targets };
12
+ }
13
+ const exploreData = (explore) => `<script type="application/json" id="deck-explore">${JSON.stringify(explore).replace(/</g, "\\u003c")}<\/script>`;
14
+ const EXPLORE_CSS = `:where(deck-stage[data-explore] .sd-leads) { cursor: pointer; }
15
+ :where(deck-stage[data-explore] .sd-leads:hover) { outline: 3px solid var(--sd-accent, var(--accent-ink, #0b6e99)); outline-offset: 6px; }
16
+ :where(.explore-back) {
17
+ position: fixed; left: 12px; top: 12px; z-index: 10; padding: 8px 14px; border-radius: 20px; cursor: pointer;
18
+ border: 1.5px solid var(--line, #c9d0d9); background: var(--bg-elev, #f6f8fa); color: var(--fg, #14181f);
19
+ font: 600 14px/1 var(--sans, system-ui, sans-serif);
20
+ }
21
+ @media print { :where(.explore-back) { display: none; } }`;
22
+ function exploreRuntime() {
23
+ const stage = document.querySelector("deck-stage[data-explore]"), data = document.getElementById("deck-explore");
24
+ if (!stage || !data || window.name === "deck-presenter" || /[?&]_snthumb=/.test(location.search)) return;
25
+ const { slides, targets } = JSON.parse(data.textContent), back = [];
26
+ const button = document.createElement("button");
27
+ button.type = "button";
28
+ button.className = "explore-back";
29
+ button.hidden = true;
30
+ document.body.appendChild(button);
31
+ const shown = () => [...stage.children].filter((s) => s.tagName === "SECTION")[stage.index];
32
+ const unseen = ".sd-gone, .sd-hide, .sd-twin-off";
33
+ const controls = 'a[href], button, input, select, textarea, summary, label, video[controls], audio[controls], [role="button"], [onclick], [contenteditable]:not([contenteditable="false" i])';
34
+ const leads = (piece) => {
35
+ if (piece.closest(unseen)) return null;
36
+ const own = targets[slides[stage.index]] ?? {};
37
+ const to = piece.getAttribute("data-k").trim().split(/\s+/).map((k) => Object.hasOwn(own, k) ? own[k] : void 0).find((i) => i !== void 0);
38
+ return to === void 0 || to === stage.index ? null : to;
39
+ };
40
+ const mark = () => {
41
+ document.querySelectorAll(".sd-leads").forEach((p) => p.classList.remove("sd-leads"));
42
+ for (const p of shown().querySelectorAll("[data-k]")) if (leads(p) !== null) p.classList.add("sd-leads");
43
+ button.hidden = !back.length;
44
+ if (back.length) button.textContent = `\u2190 Back to click ${back.at(-1) + 1}`;
45
+ };
46
+ const go = (i) => {
47
+ stage.goTo(i);
48
+ mark();
49
+ };
50
+ const goBack = () => {
51
+ if (back.length) go(back.pop());
52
+ };
53
+ let down = null;
54
+ document.addEventListener("pointerdown", (e) => {
55
+ down = [e.clientX, e.clientY];
56
+ }, true);
57
+ document.addEventListener("click", (e) => {
58
+ const dragged = down && Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 4;
59
+ down = null;
60
+ if (e.altKey || e.button !== 0 || dragged || !stage.isConnected) return;
61
+ const piece = e.target.closest?.("[data-k]");
62
+ if (!piece || !shown().contains(piece) || e.target.closest(controls)) return;
63
+ const to = leads(piece);
64
+ if (to === null) return;
65
+ e.preventDefault();
66
+ back.push(stage.index);
67
+ go(to);
68
+ }, true);
69
+ button.addEventListener("click", (e) => {
70
+ e.stopPropagation();
71
+ button.blur();
72
+ goBack();
73
+ });
74
+ window.addEventListener("keydown", (e) => {
75
+ if (e.key !== "Backspace" || !back.length || !stage.isConnected || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
76
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName) || e.target?.isContentEditable) return;
77
+ e.preventDefault();
78
+ goBack();
79
+ });
80
+ stage.addEventListener("slidechange", mark);
81
+ mark();
82
+ }
83
+ export {
84
+ EXPLORE_CSS,
85
+ exploreData,
86
+ exploreRuntime,
87
+ exploreTargets
88
+ };
package/dist/deck/html.js CHANGED
@@ -30,6 +30,11 @@ const UNSEEN = new RegExp([
30
30
  String.raw`(?![\u{200C}\u{200D}\u{FE0E}\u{FE0F}])\p{Default_Ignorable_Code_Point}`,
31
31
  String.raw`[^\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}\n\t\u{200C}\u{200D}\u{FE0E}\u{FE0F}]`
32
32
  ].join("|"), "gu");
33
+ const ENTITY = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
34
+ const decodeEntities = (text) => String(text).replace(/&(#\d{1,7}|#x[\da-f]{1,6}|\w{1,32});/gi, (m, e) => e[0] === "#" ? (() => {
35
+ const cp = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : Number(e.slice(1));
36
+ return cp <= 1114111 ? String.fromCodePoint(cp) : m;
37
+ })() : ENTITY[e.toLowerCase()] ?? m);
33
38
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
34
39
  const scriptSafe = (code) => String(code).replace(/<\/(script)/gi, "<\\/$1");
35
40
  const styleSafe = (css) => String(css).replace(/<\/(style)/gi, "<\\/$1");
@@ -57,6 +62,7 @@ export {
57
62
  UNSEEN,
58
63
  closing,
59
64
  commentRanges,
65
+ decodeEntities,
60
66
  elements,
61
67
  esc,
62
68
  scriptSafe,
package/dist/deck/init.js CHANGED
@@ -29,6 +29,7 @@ const CLICKS = `<!--
29
29
  The deck, click by click.
30
30
  <section data-label="\u2026" data-kicker="\u2026"> one part of the story; without data-map it is one plain slide
31
31
  <section \u2026 data-theme="ledger"> optional: its slides carry data-theme, so theme.css can dress that part
32
+ <section \u2026 data-enter="push" data-enter-from="right"> optional: how the part arrives (cut fade dip wipe split iris push slide whip zoom)
32
33
  <section \u2026 data-map="map"> a part told on map.html: every <step> inside is ONE click
33
34
  <step data-in="\u2026" data-out="\u2026" data-on="\u2026" data-focus="\u2026">
34
35
  data-in pieces that come in (they stay) data-out pieces that leave
@@ -28,6 +28,7 @@ function clickRuns(ns) {
28
28
  const KINDS = {
29
29
  spills: (f, where, also) => ["warn", `text spills out of ${where} by ${size(f)} ("${f.text}")${also} \u2014 shorten it, or give the box more room`],
30
30
  outside: (f, where, also) => ["warn", `${where} reaches ${size(f)} past the slide's edge ("${f.text}")${also} \u2014 move it in, or zoom less`],
31
+ "differs-overlap": (f, where, also) => ["warn", `the label on how this part differs meets ${where} ("${f.text}")${also}, and one hides the other \u2014 shorten the label, or move ${where} clear of the top right`],
31
32
  "loose-loop": (f, where, also) => ["warn", `${where} has an endless animation that is not a loop${also}: it starts again at each click and stops on a jump \u2014 give the element data-loop and its animation as --loop (or name a loop: data-loop="pulse")`],
32
33
  "hidden-loop": (f, where, also) => ["error", `${f.count} endless animation${f.count === 1 ? "" : "s"} on ${f.piece} keep${f.count === 1 ? "s" : ""} running while the slide is hidden${also} \u2014 make ${f.count === 1 ? "it a loop" : "each a loop"} (data-loop and its animation as --loop): a loop runs only on the slide shown`]
33
34
  };
package/dist/deck/page.js CHANGED
@@ -2,6 +2,9 @@ import { focusRuntime } from "../focusHtml.js";
2
2
  import { notesData, notesRuntime, listenRuntime } from "../narrationHtml.js";
3
3
  import { esc, scriptSafe, styleSafe } from "./html.js";
4
4
  import { loopCss } from "./loops.js";
5
+ import { DIFFERS_CSS } from "./differs.js";
6
+ import { EXPLORE_CSS, exploreData, exploreRuntime } from "./explore.js";
7
+ import { TRANSITION_CSS, transitionData, transitionRuntime } from "./transitions.js";
5
8
  function themeRuntime() {
6
9
  const root = document.documentElement, btn = document.querySelector(".theme-toggle");
7
10
  const set = (dark) => {
@@ -34,9 +37,10 @@ const TOGGLE = '<button class="theme-toggle" type="button" aria-label="Switch th
34
37
  function deckPage(compiled, assets, { dev = null } = {}) {
35
38
  const { config, slides } = compiled;
36
39
  const loops = loopCss(compiled.loops ?? []);
37
- const css = [...assets.css, ...loops ? [loops] : [], compiled.themeCss ?? ""].join("\n");
40
+ const labelled = (compiled.clicks ?? []).some((c) => c.differs?.shown.length);
41
+ const css = [...assets.css, ...loops ? [loops] : [], ...compiled.explore ? [EXPLORE_CSS] : [], ...compiled.transitions ? [TRANSITION_CSS] : [], ...labelled ? [DIFFERS_CSS] : [], compiled.themeCss ?? ""].join("\n");
38
42
  const script = dev?.nonce ? `<script nonce="${esc(dev.nonce)}">` : "<script>";
39
- const runtimes = [focusRuntime, notesRuntime, listenRuntime, themeRuntime].map((fn) => `${script}(${scriptSafe(fn)})();<\/script>`);
43
+ const runtimes = [focusRuntime, notesRuntime, listenRuntime, themeRuntime, ...compiled.explore ? [exploreRuntime] : [], ...compiled.transitions ? [transitionRuntime] : []].map((fn) => `${script}(${scriptSafe(fn)})();<\/script>`);
40
44
  return `<!doctype html>
41
45
  <html lang="${esc(config.lang)}">
42
46
  <head>
@@ -51,12 +55,14 @@ ${config.description ? `<meta name="description" content="${esc(config.descripti
51
55
  <style>${styleSafe(css)}</style>
52
56
  </head>
53
57
  <body>
54
- <deck-stage width="${config.canvas[0]}" height="${config.canvas[1]}" no-rail>
58
+ <deck-stage width="${config.canvas[0]}" height="${config.canvas[1]}" no-rail${compiled.explore ? " data-explore" : ""}>
55
59
  ${slides.map((s) => s.html).join("\n")}
56
60
  </deck-stage>
57
61
  ${TOGGLE}
58
62
  <!-- deck-stage.js, focus and narration runtimes: footprint-storydeck ${esc(assets.version)} (MIT) -->
59
- ${notesData(slides.map((s) => s.notes))}
63
+ ${notesData(slides.map((s) => s.notes))}${compiled.explore ? `
64
+ ${exploreData(compiled.explore)}` : ""}${compiled.transitions ? `
65
+ ${transitionData(compiled.transitions)}` : ""}
60
66
  ${script}${scriptSafe(assets.stageJs)}<\/script>
61
67
  ${runtimes.join("\n")}
62
68
  ${dev ? `${script}(${scriptSafe(dev.client)})();<\/script>
@@ -1,4 +1,6 @@
1
- import { closing, elements, stripComments } from "./html.js";
1
+ import { closing, decodeEntities, elements, stripComments } from "./html.js";
2
+ import { readAttributes } from "../pieceRules.js";
3
+ import { readDiffers, refuseStepDiffers } from "./differs.js";
2
4
  const NOTES_CLASS = /\sclass=(["'])(?:[^"']*\s)?notes(?:\s[^"']*)?\1/;
3
5
  const BLOCK = /* @__PURE__ */ new Set([
4
6
  "br",
@@ -24,7 +26,6 @@ const BLOCK = /* @__PURE__ */ new Set([
24
26
  "figure",
25
27
  "figcaption"
26
28
  ]);
27
- const ENTITY = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
28
29
  function attrOf(attrs, name) {
29
30
  return new RegExp(`\\s${name}=(["'])(.*?)\\1`).exec(attrs)?.[2] ?? null;
30
31
  }
@@ -61,10 +62,7 @@ function plainText(html) {
61
62
  }
62
63
  if (BLOCK.has(name)) out += " ";
63
64
  }
64
- return out.replace(/&(#\d{1,7}|#x[\da-f]{1,6}|\w{1,32});/gi, (m, e) => e[0] === "#" ? (() => {
65
- const cp = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : Number(e.slice(1));
66
- return cp <= 1114111 ? String.fromCodePoint(cp) : m;
67
- })() : ENTITY[e.toLowerCase()] ?? m).replace(/\s+/g, " ").trim();
65
+ return decodeEntities(out).replace(/\s+/g, " ").trim();
68
66
  }
69
67
  const wordCount = (html) => (plainText(html).match(/[\p{L}\p{N}][\p{L}\p{N}'’.,%-]*/gu) ?? []).length;
70
68
  function takeNotes(html) {
@@ -92,6 +90,7 @@ function themeOf(label, attrs) {
92
90
  return theme;
93
91
  }
94
92
  function readMapStep(label, k, attrs, inner) {
93
+ refuseStepDiffers(attrs, `${where(label)} click ${k + 1}`);
95
94
  const { html, notes } = takeNotes(inner);
96
95
  let heads;
97
96
  try {
@@ -106,6 +105,13 @@ function readMapStep(label, k, attrs, inner) {
106
105
  }
107
106
  return { attrs, headline, notes };
108
107
  }
108
+ function enterOf(attrs) {
109
+ const read = readAttributes(attrs) ?? [], type = read.find((a) => a.name === "data-enter");
110
+ if (!type) return null;
111
+ const given = {};
112
+ for (const a of read) if (a.name.startsWith("data-enter-") && a.name.length > 11) given[a.name.slice(11)] ??= a.value;
113
+ return { type: type.value.trim(), given };
114
+ }
109
115
  function parseClicks(html) {
110
116
  const source = stripComments(html);
111
117
  const parts = [];
@@ -121,10 +127,11 @@ function parseClicks(html) {
121
127
  const kicker = attrOf(attrs, "data-kicker") ?? "";
122
128
  const map = attrOf(attrs, "data-map");
123
129
  const theme = themeOf(label, attrs);
130
+ const enter = enterOf(attrs), differs = readDiffers(attrs, where(label), body);
124
131
  if (map === null) {
125
132
  if (/<step\b/i.test(body)) throw new Error(`${where(label)} has <step> clicks but no data-map \u2014 name the map they are told on (data-map="map")`);
126
133
  const { html: inner, notes } = takeNotes(body);
127
- parts.push({ label, kicker, theme, map: null, body: inner, notes });
134
+ parts.push({ label, kicker, theme, enter, differs, map: null, body: inner, notes });
128
135
  continue;
129
136
  }
130
137
  if (!map.trim()) throw new Error(`${where(label)}: data-map is empty \u2014 name a map (a file in the deck, e.g. data-map="map")`);
@@ -137,7 +144,7 @@ function parseClicks(html) {
137
144
  if (!steps.length) throw new Error(`${where(label)} is told on map "${map}" but has no <step> \u2014 a map part is one click per <step>`);
138
145
  const outside = plainText(takeNotes(without(body, steps)).html);
139
146
  if (outside) throw new Error(`${where(label)}: "${outside.slice(0, 60)}" is outside every <step> \u2014 put a headline or notes inside the step they belong to`);
140
- parts.push({ label, kicker, theme, map: map.trim(), steps: steps.map(({ attrs: a, inner }, k) => readMapStep(label, k, a, inner)) });
147
+ parts.push({ label, kicker, theme, enter, differs, map: map.trim(), steps: steps.map(({ attrs: a, inner }, k) => readMapStep(label, k, a, inner)) });
141
148
  }
142
149
  if (!parts.length) throw new Error('the clicks file has no <section> \u2014 a deck is one <section data-label="\u2026"> per part');
143
150
  return parts;
@@ -1,9 +1,12 @@
1
1
  import { wordCount } from "./parse.js";
2
+ import { enterText } from "./transitions.js";
2
3
  const nameOf = (pieces, i) => pieces[i].keys[0];
3
4
  const unique = (names) => [...new Set(names)];
4
5
  const list = (names, max = 14) => names.length ? `${names.slice(0, max).join(", ")}${names.length > max ? `, \u2026 (${names.length - max} more)` : ""}` : "\u2014";
5
6
  const themed = (c) => c.theme ? ` \xB7 theme ${c.theme}` : "";
6
7
  const quote = (s, max = 160) => s.length > max ? `"${s.slice(0, max - 1)}\u2026"` : `"${s}"`;
8
+ const enterLine = (c) => c.enter ? `enters by ${enterText(c.enter)} (on a forward step)` : null;
9
+ const differsLine = (c) => c.differs ? `differs: ${c.differs.shown.length ? `on the slide: ${c.differs.shown.join(" \xB7 ")}` : "not on the slide"}${c.differs.said ? `; in the notes: ${c.differs.said.map((p) => `"${p}"`).join(" \xB7 ")}` : ""}` : null;
7
10
  const loopsLine = (c) => c.loops?.length ? `loops (${c.loops.length}): ${list(c.loops.map((l) => `${l.piece ?? "an element"} ${l.value || "(its own --loop)"}`), 10)}` : null;
8
11
  function pieceGroups(click, pieces) {
9
12
  const by = (pred) => unique(click.look.pieces.map((p, i) => pred(p) ? nameOf(pieces, i) : null).filter(Boolean));
@@ -60,6 +63,8 @@ function reviewClick(compiled, which, { maxWords = compiled.config?.checks?.maxW
60
63
  if (c.kind === "plain") {
61
64
  lines.push(`click ${c.n} of ${compiled.clicks.length} \xB7 "${c.label}" (plain slide)${themed(c)}`);
62
65
  lines.push(`text (${words(wordCount(c.text))}): ${quote(c.text, 300)}`);
66
+ if (enterLine(c)) lines.push(enterLine(c));
67
+ if (differsLine(c)) lines.push(differsLine(c));
63
68
  if (loopsLine(c)) lines.push(loopsLine(c));
64
69
  } else {
65
70
  lines.push(`click ${c.n} of ${compiled.clicks.length} \xB7 "${c.label}" (map "${c.map}", step ${c.stepIndex + 1} of ${c.steps})${themed(c)}`);
@@ -84,6 +89,8 @@ function reviewClick(compiled, which, { maxWords = compiled.config?.checks?.maxW
84
89
  lines.push(`this click: ${changes.length ? changes.join(" \xB7 ") : "nothing changes on the map"}`);
85
90
  const motion = motionOf(c.look);
86
91
  if (motion.length) lines.push(`motion: ${motion.join(" \xB7 ")}`);
92
+ if (enterLine(c)) lines.push(enterLine(c));
93
+ if (differsLine(c)) lines.push(differsLine(c));
87
94
  if (loopsLine(c)) lines.push(loopsLine(c));
88
95
  }
89
96
  const noteWords = c.notes.reduce((sum, p) => sum + wordCount(p), 0);
@@ -0,0 +1,161 @@
1
+ import { easeNamed, readTransition } from "footprint-story/motion";
2
+ const CSS_TRANSITIONS = Object.freeze(["cut", "fade", "dip", "wipe", "split", "iris", "push", "slide", "whip", "zoom"]);
3
+ const FPS = 30;
4
+ const r4 = (v) => +(+v).toFixed(4) + 0;
5
+ const pct = (v) => `${r4(v * 100)}%`;
6
+ const UNIT = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] };
7
+ const REST = { opacity: 1, transform: "translate(0%, 0%) scale(1)", filter: "blur(0px)" };
8
+ const IRIS_UNIT = Math.SQRT1_2;
9
+ function framesAt(type, p, e, [w, h]) {
10
+ const keep = { ...REST };
11
+ if (type === "fade") return [keep, { ...REST, opacity: r4(e) }];
12
+ if (type === "dip") return [{ ...REST, opacity: r4(Math.max(0, 1 - 2 * e)) }, { ...REST, opacity: r4(Math.max(0, 2 * e - 1)) }];
13
+ if (type === "wipe") {
14
+ const hidden = pct(1 - e), from = p.from;
15
+ const inset = from === "left" ? `inset(0% ${hidden} 0% 0%)` : from === "right" ? `inset(0% 0% 0% ${hidden})` : from === "top" ? `inset(0% 0% ${hidden} 0%)` : `inset(${hidden} 0% 0% 0%)`;
16
+ return [keep, { ...REST, clipPath: inset }];
17
+ }
18
+ if (type === "split") {
19
+ const half = pct((1 - e) / 2);
20
+ return [keep, { ...REST, clipPath: p.line === "horizontal" ? `inset(${half} 0% ${half} 0%)` : `inset(0% ${half} 0% ${half})` }];
21
+ }
22
+ if (type === "iris") {
23
+ const [x, y] = p.at, far = Math.max(...[[0, 0], [w, 0], [0, h], [w, h]].map(([cx, cy]) => Math.hypot(cx - x, cy - y)));
24
+ return [keep, { ...REST, clipPath: `circle(${pct(e * far / (Math.hypot(w, h) * IRIS_UNIT))} at ${pct(x / w)} ${pct(y / h)})` }];
25
+ }
26
+ if (type === "push" || type === "whip") {
27
+ const [dx, dy] = UNIT[p.from], blur = type === "whip" ? `blur(${r4(4 * e * (1 - e) * 0.02 * w)}px)` : "blur(0px)";
28
+ return [{ ...REST, transform: `translate(${pct(-dx * e)}, ${pct(-dy * e)}) scale(1)`, filter: blur }, { ...REST, transform: `translate(${pct(dx * (1 - e))}, ${pct(dy * (1 - e))}) scale(1)`, filter: blur }];
29
+ }
30
+ if (type === "slide") {
31
+ const [dx, dy] = UNIT[p.from];
32
+ return [keep, { ...REST, transform: `translate(${pct(dx * (1 - e))}, ${pct(dy * (1 - e))}) scale(1)` }];
33
+ }
34
+ const origin = `${pct(p.at[0] / w)} ${pct(p.at[1] / h)}`;
35
+ return [
36
+ { ...REST, opacity: r4(1 - e), transform: `translate(0%, 0%) scale(${r4(1 + 0.6 * e)})`, transformOrigin: origin },
37
+ { ...REST, transform: `translate(0%, 0%) scale(${r4(1 + 0.25 * (1 - e))})`, transformOrigin: origin }
38
+ ];
39
+ }
40
+ const ON_TOP = { fade: "arriving", dip: "arriving", wipe: "arriving", split: "arriving", iris: "arriving", push: "arriving", whip: "arriving", slide: "arriving", zoom: "leaving", cut: "arriving" };
41
+ function transitionKeyframes(t, frame) {
42
+ if (t.type === "cut") return { type: "cut", seconds: 0, on: "arriving", backdrop: null, leaving: [], arriving: [], options: { duration: 0, easing: "linear", fill: "both" } };
43
+ const ease = easeNamed(t.ease, `${t.type}: ease`), steps = Math.max(2, Math.ceil(t.seconds * FPS)), leaving = [], arriving = [];
44
+ for (let i = 0; i <= steps; i++) {
45
+ const [a, b] = framesAt(t.type, t.p, ease(i / steps), frame), offset = r4(i / steps);
46
+ leaving.push({ offset, ...a });
47
+ arriving.push({ offset, ...b });
48
+ }
49
+ return {
50
+ type: t.type,
51
+ seconds: t.seconds,
52
+ on: ON_TOP[t.type],
53
+ backdrop: t.type === "dip" ? t.p.color : null,
54
+ leaving,
55
+ arriving,
56
+ options: { duration: r4(t.seconds * 1e3), easing: "linear", fill: "both" }
57
+ };
58
+ }
59
+ function readEnter(enter, where, { frame = [1920, 1080] } = {}) {
60
+ if (!enter) return null;
61
+ const who = `${where}: data-enter="${enter.type}"`;
62
+ if (!CSS_TRANSITIONS.includes(enter.type)) {
63
+ let known = true;
64
+ try {
65
+ readTransition(enter.type, who);
66
+ } catch {
67
+ known = false;
68
+ }
69
+ throw new Error(`${who} \u2014 ${known ? "a page cannot play it (it needs a canvas, as StoryReel draws it); " : ""}a part arrives by ${CSS_TRANSITIONS.join(", ")}`);
70
+ }
71
+ const params = readTransition(enter.type, who, { frame }).p, out = { type: enter.type };
72
+ for (const [name, text] of Object.entries(enter.given)) {
73
+ if (name !== "seconds" && name !== "ease" && !Object.hasOwn(params, name)) {
74
+ const takes = ["seconds", "ease", ...Object.keys(params)].map((k) => `data-enter-${k}`).join(", ");
75
+ throw new Error(`${who} takes ${takes} \u2014 not data-enter-${name}`);
76
+ }
77
+ const point = Array.isArray(params[name]), centre = point && text.trim() === "centre";
78
+ const value = name === "seconds" ? Number(text) : centre ? [frame[0] / 2, frame[1] / 2] : point ? text.trim().split(/[\s,]+/).map(Number) : text.trim();
79
+ if (name === "seconds" && !Number.isFinite(value)) throw new Error(`${who}: data-enter-seconds is a number of seconds, not "${text}"`);
80
+ if (point && !centre && (value.length !== 2 || value.some((n) => !Number.isFinite(n)))) throw new Error(`${who}: data-enter-${name} is a point, "x y" in slide px (or "centre"), not "${text}"`);
81
+ out[name] = value;
82
+ }
83
+ const t = readTransition(out, who, { frame });
84
+ return { type: t.type, seconds: t.seconds, ease: t.ease, p: t.p };
85
+ }
86
+ const enterText = (t) => `${t.type}${Object.entries(t.p).map(([k, v]) => ` ${k} ${Array.isArray(v) ? v.join(" ") : v}`).join("")}, ${t.type === "cut" ? "at once" : `${t.seconds} s`}`;
87
+ function transitionTable(enters, frame) {
88
+ if (!enters.some(Boolean)) return null;
89
+ const kinds = {}, enter = enters.map((t) => {
90
+ if (!t) return null;
91
+ const key = JSON.stringify([t.type, t.seconds, t.ease, t.p]);
92
+ kinds[key] ??= transitionKeyframes(t, frame);
93
+ return key;
94
+ });
95
+ return { kinds, enter };
96
+ }
97
+ const transitionData = (table) => `<script type="application/json" id="deck-transitions">${JSON.stringify(table).replace(/</g, "\\u003c")}<\/script>`;
98
+ const TRANSITION_CSS = "deck-stage > [data-sd-leaving] { visibility: visible; opacity: 1; }";
99
+ function transitionRuntime() {
100
+ const stage = document.querySelector("deck-stage"), data = document.getElementById("deck-transitions");
101
+ if (!stage || !data) return;
102
+ const { kinds, enter } = JSON.parse(data.textContent);
103
+ let playing = null;
104
+ const settle = () => {
105
+ if (!playing) return;
106
+ const p = playing;
107
+ playing = null;
108
+ p.anims.forEach((a) => a.cancel());
109
+ p.leaving.removeAttribute("data-sd-leaving");
110
+ if (p.looped && !p.leaving.hasAttribute("data-deck-active") && !p.leaving.hasAttribute("data-deck-under")) p.leaving.removeAttribute("data-deck-loop");
111
+ p.leaving.style.zIndex = p.z[0];
112
+ p.arriving.style.zIndex = p.z[1];
113
+ if (p.bg !== null) stage.style.setProperty("--deck-canvas", p.bg);
114
+ stage.removeAttribute("data-deck-changing");
115
+ };
116
+ const calm = () => !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches || stage.getAttribute("data-motion") === "off";
117
+ window.addEventListener("beforeprint", settle);
118
+ stage.addEventListener("slidechange", (e) => {
119
+ const { index, previousIndex, slide, previousSlide } = e.detail;
120
+ if (index === previousIndex) return;
121
+ settle();
122
+ const t = kinds[enter[index]];
123
+ if (!t || t.type === "cut" || !slide || !previousSlide || index < previousIndex || calm() || slide.hasAttribute("data-deck-static")) return;
124
+ const all = [...stage.children].filter((s) => s.tagName === "SECTION");
125
+ if (!all.slice(previousIndex + 1, index).every((s) => s.hasAttribute("data-deck-skip")) || typeof slide.animate !== "function") return;
126
+ const over = t.on === "leaving";
127
+ const p = {
128
+ anims: [],
129
+ leaving: previousSlide,
130
+ arriving: slide,
131
+ z: [previousSlide.style.zIndex, slide.style.zIndex],
132
+ bg: t.backdrop ? stage.style.getPropertyValue("--deck-canvas") : null,
133
+ looped: false
134
+ };
135
+ previousSlide.setAttribute("data-sd-leaving", "");
136
+ if (slide.hasAttribute("data-deck-loop") && !previousSlide.hasAttribute("data-deck-loop")) {
137
+ previousSlide.setAttribute("data-deck-loop", "");
138
+ p.looped = true;
139
+ }
140
+ previousSlide.style.zIndex = over ? "2" : "1";
141
+ slide.style.zIndex = over ? "1" : "2";
142
+ if (t.backdrop) stage.style.setProperty("--deck-canvas", t.backdrop);
143
+ stage.setAttribute("data-deck-changing", "");
144
+ p.anims = [previousSlide.animate(t.leaving, t.options), slide.animate(t.arriving, t.options)];
145
+ playing = p;
146
+ const done = () => {
147
+ if (playing === p) settle();
148
+ };
149
+ p.anims[1].finished.then(done, done);
150
+ });
151
+ }
152
+ export {
153
+ CSS_TRANSITIONS,
154
+ TRANSITION_CSS,
155
+ enterText,
156
+ readEnter,
157
+ transitionData,
158
+ transitionKeyframes,
159
+ transitionRuntime,
160
+ transitionTable
161
+ };
package/dist/video.js CHANGED
@@ -88,7 +88,8 @@ function ffmpeg(run, args, what) {
88
88
  const why = failure(run("ffmpeg", args, { stdio: "inherit" }));
89
89
  if (why) throw new Error(`ffmpeg failed: ${what} (${why})`);
90
90
  }
91
- async function frames(driver, url, plan, dir, { fps, maxEntrance, log }) {
91
+ const LOOP_HOLD = 0.3;
92
+ async function frames(driver, url, plan, dir, { fps, maxEntrance, loops, log }) {
92
93
  rmSync(dir, { recursive: true, force: true });
93
94
  mkdirSync(dir, { recursive: true });
94
95
  const shots = [];
@@ -96,10 +97,14 @@ async function frames(driver, url, plan, dir, { fps, maxEntrance, log }) {
96
97
  await driver.open(url);
97
98
  for (const c of plan) {
98
99
  await driver.step(c.n);
99
- const end = Math.min(maxEntrance, await driver.freeze()), count = Math.max(1, Math.ceil(end / (1e3 / fps)) + 1);
100
+ const end = Math.min(maxEntrance, await driver.freeze());
101
+ const looping = loops && typeof driver.loops === "function" && await driver.loops() === true;
102
+ const count = Math.max(1, Math.ceil(end / (1e3 / fps)) + 1, looping ? Math.floor((c.length - LOOP_HOLD) * fps) : 0);
100
103
  const files = [];
101
104
  for (let k = 0; k < count; k++) {
102
- await driver.seek(Math.min(end, k * 1e3 / fps));
105
+ const t = k * 1e3 / fps;
106
+ if (looping) await driver.seek(Math.min(end, t), { loop: c.start * 1e3 + t });
107
+ else await driver.seek(Math.min(end, t));
103
108
  const file = path.join(dir, `c${NUM(c.n)}-${NUM(k)}.png`);
104
109
  await driver.shot(file);
105
110
  files.push(file);
@@ -139,6 +144,7 @@ async function renderVideo({
139
144
  noclip,
140
145
  only,
141
146
  maxEntrance = 4e3,
147
+ loops = true,
142
148
  chapters = () => null,
143
149
  intro = "Intro",
144
150
  thumbnail = () => false,
@@ -155,13 +161,13 @@ async function renderVideo({
155
161
  const own = !work, dir = own ? mkdtempSync(path.join(tmpdir(), "storydeck-video-")) : path.resolve(work);
156
162
  try {
157
163
  mkdirSync(dir, { recursive: true });
158
- return await render({ url, plan, out, name, driver, run, fps, maxEntrance, chapters, intro, thumbnail, work: dir, log });
164
+ return await render({ url, plan, out, name, driver, run, fps, maxEntrance, loops, chapters, intro, thumbnail, work: dir, log });
159
165
  } finally {
160
166
  if (own) rmSync(dir, { recursive: true, force: true });
161
167
  }
162
168
  }
163
- async function render({ url, plan, out, name, driver, run, fps, maxEntrance, chapters, intro, thumbnail, work, log }) {
164
- const shots = await frames(driver, url, plan, path.join(work, "frames"), { fps, maxEntrance, log });
169
+ async function render({ url, plan, out, name, driver, run, fps, maxEntrance, loops, chapters, intro, thumbnail, work, log }) {
170
+ const shots = await frames(driver, url, plan, path.join(work, "frames"), { fps, maxEntrance, loops, log });
165
171
  const concat = path.join(work, "frames.ffconcat");
166
172
  writeFileSync(concat, framesConcat(plan, shots, { fps }));
167
173
  const wav = narration(run, plan, work), length = plan.reduce((s, c) => s + c.length, 0), video = path.join(out, `${name}.mp4`);
@@ -245,19 +251,23 @@ function deckStageDriver({ chromium, executablePath, viewport = { width: 1920, h
245
251
  const all = [.../* @__PURE__ */ new Set([...document.getAnimations(), ...stage?.shadowRoot?.getAnimations() ?? []])];
246
252
  all.forEach((a) => a.pause());
247
253
  window.__sdFrozen = all;
254
+ window.__sdLoops = all.filter((a) => a.effect?.getTiming?.().iterations === Infinity && a.effect.target?.closest?.("[data-deck-loop]"));
248
255
  const ends = all.map((a) => a.effect?.getComputedTiming().endTime).filter((t) => Number.isFinite(t));
249
256
  return Math.max(0, ...ends);
250
257
  }),
251
- async seek(ms) {
252
- await page.evaluate((t) => {
253
- for (const a of window.__sdFrozen ?? []) a.currentTime = t;
254
- }, ms);
258
+ loops: () => page.evaluate(() => (window.__sdLoops ?? []).length > 0),
259
+ /** Every frozen animation to `ms` — its loops, given `loop`, to that (the video's own time, so they run on across clicks). */
260
+ async seek(ms, { loop } = {}) {
261
+ await page.evaluate(([t, l]) => {
262
+ const loops = new Set(window.__sdLoops ?? []);
263
+ for (const a of window.__sdFrozen ?? []) a.currentTime = l !== null && loops.has(a) ? l : t;
264
+ }, [ms, loop ?? null]);
255
265
  await settle();
256
266
  },
257
267
  shot: (file) => page.screenshot({ path: file }),
258
268
  /**
259
269
  * Click n (1-based) as it settles, measured: jump there (a jump lands at once), finish what still moves, and
260
- * report text that spills out of its box and lit pieces outside the slide — [{kind, piece, text, dx, dy}] (the
270
+ * report text that spills out of its box, lit pieces outside the slide and a label that meets what is on it — [{kind, piece, text, dx, dy}] (the
261
271
  * layout check, deck/layout.js, reads it).
262
272
  */
263
273
  async measure(n) {
@@ -316,6 +326,21 @@ function measureSlide() {
316
326
  const dx = Math.max(0, -x0, x1 - W), dy = Math.max(0, -y0, y1 - H);
317
327
  if (dx > 2 || dy > 2) out.push({ kind: "outside", piece: pieceOf(el), text: textOf(el), dx: Math.round(dx), dy: Math.round(dy) });
318
328
  }
329
+ const labels = [...slide.querySelectorAll(".differs span")].filter(shown).map((el) => el.getBoundingClientRect());
330
+ if (labels.length) {
331
+ const meets = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 2 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 2;
332
+ const textRects = (el) => {
333
+ const r = document.createRange();
334
+ r.selectNodeContents(el);
335
+ return [...r.getClientRects()];
336
+ };
337
+ for (const el of slide.querySelectorAll("*")) {
338
+ if (el.closest(".differs, .foot") || !shown(el)) continue;
339
+ const piece = el.matches(".sd-on");
340
+ if (!piece && (el.closest(".sd-on") || el.namespaceURI === "http://www.w3.org/2000/svg" || ![...el.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim()))) continue;
341
+ if ((piece ? [el.getBoundingClientRect()] : textRects(el)).some((r) => labels.some((l) => meets(r, l)))) out.push({ kind: "differs-overlap", piece: pieceOf(el), text: textOf(el), dx: 0, dy: 0 });
342
+ }
343
+ }
319
344
  for (const el of [slide, ...slide.querySelectorAll("*")]) {
320
345
  if (el.hasAttribute("data-loop")) continue;
321
346
  if (getComputedStyle(el).animationIterationCount.split(",").some((c) => c.trim() === "infinite")) out.push({ kind: "loose-loop", piece: pieceOf(el), text: textOf(el), dx: 0, dy: 0 });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "footprint-storydeck",
3
- "version": "0.9.0",
4
- "description": "One source, many lenses — render structured content as a Read article, a Scroll (scrollytelling) story, or a Watch deck; and make a deck from a folder with a live dev loop.",
3
+ "version": "0.11.0",
4
+ "description": "React views and a deck CLI for turning one structured story into an article, scrollytelling page, slide deck, narrated video or PowerPoint.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Sanjay Krishna Anbalagan",
@@ -108,7 +108,7 @@
108
108
  "test:cov": "vitest run --coverage"
109
109
  },
110
110
  "peerDependencies": {
111
- "footprintjs": "^10.0.0",
111
+ "footprintjs": "^11.0.0",
112
112
  "react": ">=18",
113
113
  "react-dom": ">=18"
114
114
  },
@@ -128,7 +128,7 @@
128
128
  "@vitejs/plugin-react": "^5.0.4",
129
129
  "@vitest/coverage-v8": "^4.1.9",
130
130
  "esbuild": "^0.28.2",
131
- "footprintjs": "^10.0.0",
131
+ "footprintjs": "11.0.0",
132
132
  "jsdom": "^29.1.1",
133
133
  "react": "^19.0.0",
134
134
  "react-dom": "^19.0.0",
@@ -65,7 +65,10 @@ a map, `<i data-k="w" class="wire pkt" data-arrow="client to server"></i>` is a
65
65
  between the two pieces and following them when they move (prefer it to `"x1 y1 x2 y2"` numbers; a wire each way between
66
66
  the same pieces gets its own lane; a bend may follow: `"gw to db 40"`); `data-on="gw+"` is gw, its wires and what they
67
67
  reach; a wire takes `data-label="name"` (at its middle, on its left), class `halo` (a glow) and `data-packets="2"
68
- data-speed="5"` (packets that keep running along it on the slide shown — loops); and
68
+ data-speed="5"` (packets that keep running along it on the slide shown — loops); `"explore": true` in deck.json lets a
69
+ viewer click a piece to go to the click about it (Back returns); `<section data-enter="push" data-enter-from="right">`
70
+ makes a part arrive with one of StoryReel's changes (cut, fade, dip, wipe, split, iris, push, slide, whip, zoom; on a
71
+ forward step only); and
69
72
  `<i class="ic" data-icon="name"></i>` is icons/name.svg. A piece that comes in lights unless the click is `data-quiet`
70
73
  (`check` warns: `lit-on-entry`). Motion that keeps going is a loop: `data-loop="pulse"` (or `float`, `marquee`, with a
71
74
  pace: `"float 4s"`) on a piece, or `data-loop` alone with the piece's own `--loop: spin 6s linear infinite` in
@@ -177,8 +180,9 @@ It is pictures + notes + sound, not editable PowerPoint shapes.
177
180
  deck says it tours.
178
181
  - **A cloned voice is its speaker's**: a page that carries clips stays internal. A public build leaves
179
182
  `embedClips` out; listen mode falls back to the browser's voice.
180
- - **Video frames are deterministic**: each click is made forward, every animation paused and stepped. Looping
181
- motion stops when the entrance ends. Compare two renders by `timeline.json` and `captions.srt` (equal) and
183
+ - **Video frames are deterministic**: each click is made forward, every animation paused and stepped. A click whose
184
+ loops run (data-loop, a wire's packets) is stepped on to its end, the loops at the video's own time, so they keep
185
+ running from click to click; `loops: false` holds the click's frame instead (a faster draft). Compare two renders by `timeline.json` and `captions.srt` (equal) and
182
186
  frames (byte-equal, or within a hair where the GPU blends a backdrop filter).
183
187
  - **Paths may be relative; scratch folders are temporary.** `voiceClips` and `renderVideo` resolve every path and
184
188
  work in a temporary folder of their own unless you name a `work` folder (which they empty before use) — never
@@ -200,9 +200,12 @@
200
200
  position: relative;
201
201
  transform-origin: center center;
202
202
  flex-shrink: 0;
203
- background: #fff;
203
+ /* what shows behind the slides (a change of slide that fades both — a dip — shows it); --deck-canvas from the page */
204
+ background: var(--deck-canvas, #fff);
204
205
  will-change: transform;
205
206
  }
207
+ /* while a change of slide plays (the page sets data-deck-changing), a slide moving in or out stays inside the frame */
208
+ :host([data-deck-changing]) .canvas { overflow: hidden; }
206
209
 
207
210
  /* Slides live in light DOM (via <slot>) so authored CSS still applies.
208
211
  We absolutely position each slotted child to stack them. */
package/video.d.ts CHANGED
@@ -15,7 +15,10 @@ export interface VideoDriver {
15
15
  open(url: string): Promise<void>;
16
16
  step(n: number): Promise<void>;
17
17
  freeze(): Promise<number>;
18
- seek(ms: number): Promise<void>;
18
+ /** Whether the click's loops run (deck-stage's data-loop): then its frames go on to its end, the loops at the video's time. */
19
+ loops?(): Promise<boolean> | boolean;
20
+ /** Every frozen animation to `ms`; with `loop`, the loops to that instead (ms since the video began). */
21
+ seek(ms: number, options?: { loop?: number }): Promise<void>;
19
22
  shot(file: string): Promise<unknown>;
20
23
  close(): Promise<unknown> | unknown;
21
24
  }
@@ -45,6 +48,8 @@ export function encodeArgs(options: { frames: string; narration: string; out: st
45
48
  export function renderVideo(options: {
46
49
  url: string; steps: readonly VideoStep[]; out: string; name?: string; driver: VideoDriver; run?: Run;
47
50
  fps?: number; gap?: number; lead?: number; noclip?: number; only?: [number, number] | null; maxEntrance?: number;
51
+ /** A click whose loops run is stepped on to its end, so they keep running (true); false holds its frame — a faster draft. */
52
+ loops?: boolean;
48
53
  chapters?: (click: PlannedClick) => string | null; intro?: string; thumbnail?: (click: PlannedClick) => boolean;
49
54
  /** where frames and sounds are made (its frames/ and audio/ are replaced); by default a temporary folder, removed afterwards */
50
55
  work?: string; log?: (line: string) => void;