footprint-storydeck 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 — a part arrives with a change of picture, and a deck a viewer can explore
4
+
5
+ - **A part arrives with a change of picture: `data-enter`.** `<section … data-enter="push" data-enter-from="right">`
6
+ plays one of the editor's changes StoryReel's films use (footprint-story/motion's table: the same names, settings,
7
+ lengths and eases) as the deck steps into the part: cut, fade, dip, wipe, split, iris, push, slide, whip or zoom,
8
+ each with its settings as `data-enter-*` (`from`, `line`, `at` as "x y" in slide px or "centre", `color`) and
9
+ `data-enter-seconds` / `data-enter-ease`. The build samples each distinct change once into keyframes for both slides
10
+ (30 a second, the same curves a film draws); the page plays them with Web Animations only on a forward step into the
11
+ part — a jump, a step back, reduced motion and motion off land at once — keeping the slide it leaves in view until
12
+ it ends, and a video steps them like any animation. A dip shows its colour behind both slides (black unless
13
+ `data-enter-color` says otherwise): `<deck-stage>` now paints what shows behind its slides with `--deck-canvas` (white,
14
+ as before, when unset). A change a page cannot play (clock, page, through, match: they need a canvas), a setting the
15
+ change does not take and a value out of range are refused at build, naming the part and the fix. `review` says how
16
+ each part arrives. While a change plays the frame clips both slides (a push never crosses the letterbox: `<deck-stage>`
17
+ clips its canvas while it carries `data-deck-changing`), the slide being left keeps its loops running, and printing
18
+ puts everything back first. Settings are read as a browser reads the tag. A deck without `data-enter` carries none
19
+ of it (no keyframes, rule or runtime); the `<deck-stage>` every page carries gained the two hooks, and draws as
20
+ before without them.
21
+
22
+ - **Explore: a viewer clicks a piece to go to the click about it.** With `"explore": true` in deck.json, a click on a
23
+ piece goes to the click that is about it — the first click whose `data-on` names it (`gw+`'s gw too), or, when none
24
+ does, the first that takes it in through a `name+` — and a "Back to click N" control (or Backspace) returns, a step
25
+ at a time, as a browser's Back does (to where the viewer was before each jump, whatever they stepped through since).
26
+ Pieces that lead somewhere get a pointer and a ring on hover; a piece whose click is the one shown, or one that
27
+ cannot be seen, leads nowhere; a link or control inside a piece keeps its click; a drag (the dev page's Alt-drag) is
28
+ never a click. On a touch screen a tap on a piece that leads somewhere explores — tap beside the pieces to step. The
29
+ pieces are reached by pointer, not by keyboard: arrow keys still step through the clicks in order. The presenter
30
+ window does not explore (it follows the room's deck). The authored order never changes: explore is a way through the
31
+ same clicks. A deck without `"explore"` carries none of it (its look comes with it, not in deck.css).
32
+
3
33
  ## 0.9.0 — living system diagrams: loops, wires between pieces, a wire's label, halo and running packets
4
34
 
5
35
  - **A wire between pieces: `data-arrow="client to server"`.** The wire runs from the edge of one piece's box to the
@@ -45,8 +75,9 @@
45
75
  step back keeps it running (a static arrival no longer zeroes a loop, only entrances). A loop never runs under
46
76
  reduced motion (switched live), in print, or with motion off: there the piece rests in its own style. A piece that
47
77
  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.
78
+ of the piece's list. Loop CSS is drawn only for decks that use loops, so a deck without one gains none; the
79
+ `<deck-stage>` every page carries changed, and a deck without loops draws as before. (Corrected after release: this
80
+ line first said such a deck builds byte for byte as before — its page carries the changed stage.)
50
81
  - **Motion off: a still deck.** `?motion=off` after the page's address, or `<deck-stage data-motion="off">` (switched
51
82
  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
83
  time.
package/README.md CHANGED
@@ -408,6 +408,17 @@ npx footprint-storydeck build my-talk # my-talk/index.html — one page, eve
408
408
  follow the part on screen as well, select the active slide in the same rule:
409
409
  `[data-theme="ledger"], :root:has([data-deck-active][data-theme="ledger"]) { … }`. `check` warns about a theme
410
410
  that theme.css never selects (a misspelt name changes nothing), and `review` names each click's theme.
411
+ - **A part arrives with a change of picture.** `<section … data-enter="push" data-enter-from="right">` plays one of
412
+ the editor's changes StoryReel's films use — cut, fade, dip, wipe, split, iris, push, slide, whip, zoom — with its
413
+ settings as `data-enter-*` (`from`, `line`, `at` "x y" or "centre", `color` — a dip goes through black unless it
414
+ 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
415
+ motion or with motion off. `review` says how each part arrives.
416
+ - **Explore: click a piece, go to the click about it.** `"explore": true` in deck.json lets a viewer wander the
417
+ 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
418
+ takes it in through a `name+`), and "Back to click N" (or Backspace) returns, as a browser's Back does. Pieces that
419
+ lead somewhere show a pointer and a ring on hover; on a touch screen a tap on one explores (tap beside the pieces to
420
+ step), and the keyboard keeps stepping in order. The order you wrote is
421
+ untouched — explore is a way through the same clicks, for a deck left open on a screen or sent to someone.
411
422
  - **Read first, look last.** A reviewer — a person, or a model that pays for every screenshot — reads the deck as text:
412
423
  `outline` (a line per click), `review --click 14` (its headline and words, what it lights, greys, brings in and moves,
413
424
  its route, its notes), `check` (what to fix, each with its click and its fix: a headline over 12 words, pieces that
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,12 @@ 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>> }
40
44
  /** A part of the deck: a plain slide (map null) or clicks told on a map. */
41
- export type DeckPart =
45
+ export type DeckPart = { readonly theme: string | null; readonly enter: DeckEnterSource | null } & (
42
46
  | { 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[] };
47
+ | { readonly label: string; readonly kicker: string; readonly map: string; readonly steps: readonly DeckStepSource[] });
44
48
  /**
45
49
  * The clicks file read into parts, scanned once. A step holds only its <h2> and notes (anything else is refused, naming
46
50
  * where it goes); a <section> or <step> left open, or opened inside another, is refused.
@@ -80,6 +84,8 @@ export type DeckClick = {
80
84
  readonly notes: readonly string[];
81
85
  /** What loops on the click (on a map: on the pieces it shows). */
82
86
  readonly loops: readonly DeckLoop[];
87
+ /** How the click's part arrives, on its first click (null elsewhere, or when it just appears): footprint-story/motion's change, read. */
88
+ readonly enter: { readonly type: string; readonly seconds: number; readonly ease: string; readonly p: Readonly<Record<string, unknown>> } | null;
83
89
  } & (
84
90
  | { readonly kind: 'plain'; readonly map: null; readonly text: string }
85
91
  | { readonly kind: 'map'; readonly map: string; readonly step: FocusClick; readonly look: FocusLook; readonly stepIndex: number; readonly steps: number }
@@ -98,6 +104,10 @@ export interface CompiledDeck {
98
104
  readonly clicks: readonly DeckClick[];
99
105
  /** The named loops the clicks show (data-loop values), drawn into the page's CSS once each. */
100
106
  readonly loops: readonly string[];
107
+ /** 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). */
108
+ readonly explore: { readonly slides: readonly (string | null)[]; readonly targets: Readonly<Record<string, Readonly<Record<string, number>>>> } | null;
109
+ /** 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. */
110
+ readonly transitions: { readonly kinds: Readonly<Record<string, unknown>>; readonly enter: readonly (string | null)[] } | null;
101
111
  readonly maps: Readonly<Record<string, { readonly pieces: readonly FocusPiece[] }>>;
102
112
  }
103
113
  /** The deck's clicks planned (each map's state carried click to click) and drawn as <deck-stage> slides. */
@@ -2,6 +2,8 @@ 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";
@@ -41,6 +43,7 @@ function planClicks(parts, maps, { area, canvas, icons }) {
41
43
  let lastMap = null;
42
44
  const clicks = [];
43
45
  for (const part of parts) {
46
+ const enter = readEnter(part.enter, `"${part.label}"`, { frame: canvas });
44
47
  if (!part.map) {
45
48
  refuseOwnNumbers(`"${part.label}"`, part.body);
46
49
  let inner;
@@ -49,7 +52,7 @@ function planClicks(parts, maps, { area, canvas, icons }) {
49
52
  } catch (e) {
50
53
  throw new Error(`"${part.label}": ${e.message}`, { cause: e });
51
54
  }
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 });
55
+ 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
56
  lastMap = null;
54
57
  continue;
55
58
  }
@@ -64,7 +67,7 @@ function planClicks(parts, maps, { area, canvas, icons }) {
64
67
  }
65
68
  map.state = r.state;
66
69
  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 });
70
+ 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
71
  });
69
72
  }
70
73
  const seen = /* @__PURE__ */ new Set();
@@ -118,10 +121,21 @@ function compileDeck(source, { dev = false } = {}) {
118
121
  headline: plainText(c.headline),
119
122
  notes: c.notes,
120
123
  loops: c.loops,
124
+ enter: c.enter,
121
125
  ...c.kind === "map" ? { step: c.step, look: c.look, stepIndex: c.stepIndex, steps: c.steps } : { text: plainText(c.inner) }
122
126
  }));
123
127
  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 }])) };
128
+ return {
129
+ config,
130
+ title: config.title,
131
+ themeCss: source.themeCss ?? "",
132
+ slides,
133
+ clicks,
134
+ loops,
135
+ explore: config.explore ? exploreTargets(planned) : null,
136
+ transitions: transitionTable(planned.map((c) => c.enter), config.canvas),
137
+ maps: Object.fromEntries(Object.values(maps).map((m) => [m.name, { pieces: m.pieces }]))
138
+ };
125
139
  }
126
140
  export {
127
141
  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,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/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
package/dist/deck/page.js CHANGED
@@ -2,6 +2,8 @@ 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 { EXPLORE_CSS, exploreData, exploreRuntime } from "./explore.js";
6
+ import { TRANSITION_CSS, transitionData, transitionRuntime } from "./transitions.js";
5
7
  function themeRuntime() {
6
8
  const root = document.documentElement, btn = document.querySelector(".theme-toggle");
7
9
  const set = (dark) => {
@@ -34,9 +36,9 @@ const TOGGLE = '<button class="theme-toggle" type="button" aria-label="Switch th
34
36
  function deckPage(compiled, assets, { dev = null } = {}) {
35
37
  const { config, slides } = compiled;
36
38
  const loops = loopCss(compiled.loops ?? []);
37
- const css = [...assets.css, ...loops ? [loops] : [], compiled.themeCss ?? ""].join("\n");
39
+ const css = [...assets.css, ...loops ? [loops] : [], ...compiled.explore ? [EXPLORE_CSS] : [], ...compiled.transitions ? [TRANSITION_CSS] : [], compiled.themeCss ?? ""].join("\n");
38
40
  const script = dev?.nonce ? `<script nonce="${esc(dev.nonce)}">` : "<script>";
39
- const runtimes = [focusRuntime, notesRuntime, listenRuntime, themeRuntime].map((fn) => `${script}(${scriptSafe(fn)})();<\/script>`);
41
+ const runtimes = [focusRuntime, notesRuntime, listenRuntime, themeRuntime, ...compiled.explore ? [exploreRuntime] : [], ...compiled.transitions ? [transitionRuntime] : []].map((fn) => `${script}(${scriptSafe(fn)})();<\/script>`);
40
42
  return `<!doctype html>
41
43
  <html lang="${esc(config.lang)}">
42
44
  <head>
@@ -51,12 +53,14 @@ ${config.description ? `<meta name="description" content="${esc(config.descripti
51
53
  <style>${styleSafe(css)}</style>
52
54
  </head>
53
55
  <body>
54
- <deck-stage width="${config.canvas[0]}" height="${config.canvas[1]}" no-rail>
56
+ <deck-stage width="${config.canvas[0]}" height="${config.canvas[1]}" no-rail${compiled.explore ? " data-explore" : ""}>
55
57
  ${slides.map((s) => s.html).join("\n")}
56
58
  </deck-stage>
57
59
  ${TOGGLE}
58
60
  <!-- deck-stage.js, focus and narration runtimes: footprint-storydeck ${esc(assets.version)} (MIT) -->
59
- ${notesData(slides.map((s) => s.notes))}
61
+ ${notesData(slides.map((s) => s.notes))}${compiled.explore ? `
62
+ ${exploreData(compiled.explore)}` : ""}${compiled.transitions ? `
63
+ ${transitionData(compiled.transitions)}` : ""}
60
64
  ${script}${scriptSafe(assets.stageJs)}<\/script>
61
65
  ${runtimes.join("\n")}
62
66
  ${dev ? `${script}(${scriptSafe(dev.client)})();<\/script>
@@ -1,4 +1,5 @@
1
1
  import { closing, elements, stripComments } from "./html.js";
2
+ import { readAttributes } from "../pieceRules.js";
2
3
  const NOTES_CLASS = /\sclass=(["'])(?:[^"']*\s)?notes(?:\s[^"']*)?\1/;
3
4
  const BLOCK = /* @__PURE__ */ new Set([
4
5
  "br",
@@ -106,6 +107,13 @@ function readMapStep(label, k, attrs, inner) {
106
107
  }
107
108
  return { attrs, headline, notes };
108
109
  }
110
+ function enterOf(attrs) {
111
+ const read = readAttributes(attrs) ?? [], type = read.find((a) => a.name === "data-enter");
112
+ if (!type) return null;
113
+ const given = {};
114
+ for (const a of read) if (a.name.startsWith("data-enter-") && a.name.length > 11) given[a.name.slice(11)] ??= a.value;
115
+ return { type: type.value.trim(), given };
116
+ }
109
117
  function parseClicks(html) {
110
118
  const source = stripComments(html);
111
119
  const parts = [];
@@ -121,10 +129,11 @@ function parseClicks(html) {
121
129
  const kicker = attrOf(attrs, "data-kicker") ?? "";
122
130
  const map = attrOf(attrs, "data-map");
123
131
  const theme = themeOf(label, attrs);
132
+ const enter = enterOf(attrs);
124
133
  if (map === null) {
125
134
  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
135
  const { html: inner, notes } = takeNotes(body);
127
- parts.push({ label, kicker, theme, map: null, body: inner, notes });
136
+ parts.push({ label, kicker, theme, enter, map: null, body: inner, notes });
128
137
  continue;
129
138
  }
130
139
  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 +146,7 @@ function parseClicks(html) {
137
146
  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
147
  const outside = plainText(takeNotes(without(body, steps)).html);
139
148
  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)) });
149
+ parts.push({ label, kicker, theme, enter, map: map.trim(), steps: steps.map(({ attrs: a, inner }, k) => readMapStep(label, k, a, inner)) });
141
150
  }
142
151
  if (!parts.length) throw new Error('the clicks file has no <section> \u2014 a deck is one <section data-label="\u2026"> per part');
143
152
  return parts;
@@ -1,9 +1,11 @@
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;
7
9
  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
10
  function pieceGroups(click, pieces) {
9
11
  const by = (pred) => unique(click.look.pieces.map((p, i) => pred(p) ? nameOf(pieces, i) : null).filter(Boolean));
@@ -60,6 +62,7 @@ function reviewClick(compiled, which, { maxWords = compiled.config?.checks?.maxW
60
62
  if (c.kind === "plain") {
61
63
  lines.push(`click ${c.n} of ${compiled.clicks.length} \xB7 "${c.label}" (plain slide)${themed(c)}`);
62
64
  lines.push(`text (${words(wordCount(c.text))}): ${quote(c.text, 300)}`);
65
+ if (enterLine(c)) lines.push(enterLine(c));
63
66
  if (loopsLine(c)) lines.push(loopsLine(c));
64
67
  } else {
65
68
  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 +87,7 @@ function reviewClick(compiled, which, { maxWords = compiled.config?.checks?.maxW
84
87
  lines.push(`this click: ${changes.length ? changes.join(" \xB7 ") : "nothing changes on the map"}`);
85
88
  const motion = motionOf(c.look);
86
89
  if (motion.length) lines.push(`motion: ${motion.join(" \xB7 ")}`);
90
+ if (enterLine(c)) lines.push(enterLine(c));
87
91
  if (loopsLine(c)) lines.push(loopsLine(c));
88
92
  }
89
93
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "footprint-storydeck",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
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.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
@@ -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. */