footprint-storydeck 0.8.0 → 0.9.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,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — living system diagrams: loops, wires between pieces, a wire's label, halo and running packets
4
+
5
+ - **A wire between pieces: `data-arrow="client to server"`.** The wire runs from the edge of one piece's box to the
6
+ edge of the other's (6 px clear), so it follows them: move a box, in the file or by Alt-drag, and its wires come
7
+ along — no four numbers to keep in step. Two or more wires between the same pieces each take a lane (80 px apart at
8
+ most, always inside both boxes), keeping to their left, so a request going right runs above its answer coming back
9
+ whatever the pieces are called. A bend may follow the names (`"gw to db 40"`). Words, not `>`, because a `>` would
10
+ end the tag. A name not placed, a wire to itself and pieces too close for a wire are refused, naming the fix; a
11
+ numbered wire is drawn byte for byte as before. Alt-dragging a wire between pieces is refused with its fix (move one
12
+ of its pieces). The starter deck now writes its four wires this way (they land within 5 px of where its numbers put them).
13
+ - **A wire's label, halo and running packets.** Three attributes on any wire: `data-label="InvokeAgentRuntime"` writes
14
+ its name beside its middle, on its left — above a level wire going right, below one coming back, beside a standing
15
+ one by half its width — in the wire's colour on an edge of the ground (the wire's box grows to hold it, so a wire
16
+ showing itself in shows its label too); class `halo` gives the line and its head a soft glow (`--wire-halo`); and
17
+ `data-packets="2" data-speed="5"` keeps packets running along the curve, evenly spaced, 5 s a run (3 by default;
18
+ 1–6 packets, 0.5–60 s), fading in at the start and out before the head (`--packet`, `--packet-glow`). The packets are
19
+ loops, so they run only on the slide shown, in step from click to click, and where loops do not run (reduced motion,
20
+ print, motion off) the first rests at the wire's middle. They are the wire's, not the deck's loops: `review` and
21
+ `check` do not list them. A wire without these is drawn byte for byte as before; packets it cannot run, and a speed
22
+ with no packets, are refused naming the fix. They are read as a browser reads the tag (never from inside another
23
+ attribute's value, in any case, quoted or not); a `>` or `<` in a value is written `&gt;` or `&lt;`, since a tag ends
24
+ at its first `>`. A deck that already put its own `data-label`, `data-packets` or `data-speed` on a wire (for its
25
+ own CSS) now gets them drawn: rename them. A loop an icon file carries is now found by `review` and `check` too.
26
+ - **A piece and what it touches: `name+`.** A wire between pieces carries `data-ends` (the pieces it joins; a wire in
27
+ numbers may carry it too), so a click can name `gw+`: the gateway, the wires with it as an end, and the pieces at
28
+ their other ends — `data-on="gw+"` lights that neighbourhood and greys the rest. It reads anywhere a click names
29
+ pieces (`data-in`, `data-out`, `data-on`, `data-hot`, and the groups a mover moves: `subject`, `aside`, `place`,
30
+ `apart`); `check`'s lit-on-entry reads it the same way, and `review` lists what it lit. A piece actually called `gw+`
31
+ is itself. On a `path` click it lights the piece, then its wires, then what they reach — for a request's own order,
32
+ write the route out (`data-focus-route`), where `+` is not read. `footprint-storydeck/focus` exports `expandNames`.
33
+
34
+ - **Motion that keeps going: loops.** `data-loop="pulse"` on a piece keeps it pulsing while its slide is shown.
35
+ `pulse`, `float` and `marquee` are the endless moves of the motion table StoryReel's films use
36
+ (`footprint-story/motion`), sampled from the same curves and drawn into the page only when a deck names them
37
+ (`data-loop="float 4s"` sets the pace; any other name is refused at build, naming the piece and the loops there are).
38
+ A named loop moves its element by `transform` (a shape inside a drawing about its own middle), so a tag that sets a
39
+ transform of its own is refused, naming the fix: put data-loop on an element inside it. `data-loop` alone runs the
40
+ piece's own animation, `--loop` in theme.css (`.gear { --loop: spin 6s linear infinite; }`); a theme.css rule that
41
+ gives the element its own `animation` wins, so list `var(--loop)` in it to keep both.
42
+ <deck-stage> owns where loops run, so it works in any deck on the stage: only on the slide shown and the layers under
43
+ it (it stamps `data-deck-loop` on them), so a deck of 40 map clicks runs one slide's loops, not 40. Every loop runs
44
+ on one clock (only a data-loop element's animations are set to it: a deck's other animations are left as they were), so a map drawn again for the next click shows each loop where the last click left it, and a jump or a
45
+ step back keeps it running (a static arrival no longer zeroes a loop, only entrances). A loop never runs under
46
+ reduced motion (switched live), in print, or with motion off: there the piece rests in its own style. A piece that
47
+ 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.
50
+ - **Motion off: a still deck.** `?motion=off` after the page's address, or `<deck-stage data-motion="off">` (switched
51
+ 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
+ time.
53
+ - **The checks know loops.** `review` lists each click's loops (`loops (2): db pulse, gear (its own --loop)`). `check`
54
+ warns (`loop-unset`) about a `data-loop` that neither theme.css nor its own style gives a `--loop` (nothing would
55
+ loop). `check --layout` reports an endless animation that is not a loop: one running on a slide no one sees is an
56
+ error (`hidden-loop`, with how many and on which pieces, one problem for all the slides it runs on); one shown on a
57
+ click is a warning (`loose-loop`: it starts over at each click and stops on a jump).
58
+
59
+ ## 0.8.1 — footprint-narration is now footprint-story
60
+
61
+ - **footprint-narration is now footprint-story.** StoryDeck's shared narration code (shown-vs-spoken text, captions,
62
+ chapters, the voice cache) comes from `footprint-story` 0.3.0, the same package under its new name: what StoryDeck
63
+ and footprint-storyreel share, now with a `/motion` door too. Nothing a deck says, shows or voices changes. A project
64
+ that imports `footprint-narration` itself beside StoryDeck should move to `footprint-story` (the two doors export
65
+ exactly what they did).
66
+
3
67
  ## 0.8.0 — a part can have its own look (data-theme)
4
68
 
5
69
  - **A part can have its own look: `data-theme`.** `<section … data-theme="ledger">` in clicks.html puts
package/README.md CHANGED
@@ -240,7 +240,7 @@ through it the same way.
240
240
  A talk lives on after the room: someone opens the deck alone, or it becomes a video. The words were in the
241
241
  speaker notes all along, so narration is data on each click, and the library turns it into speech, captions and
242
242
  timing — the same words, three uses. The rules underneath — what a voice says for a number or a symbol, the clip
243
- cache, the captions and YouTube's chapter rule — are [footprint-narration](https://github.com/footprintjs/footprint-narration)'s,
243
+ cache, the captions and YouTube's chapter rule — are [footprint-story](https://github.com/footprintjs/footprint-story)'s,
244
244
  shared with footprint-storyreel; StoryDeck adds what is the deck's own:
245
245
 
246
246
  - **what the voice says** — `narrationText(notes)`: the notes as a voice reads them. An acronym stays as written
@@ -249,7 +249,7 @@ shared with footprint-storyreel; StoryDeck adds what is the deck's own:
249
249
  another script its reading), numbers become words, a lone symbol that means something is said (`SAY`: & + = < > ×
250
250
  % and Greek letters), any other word without a letter is a pause that keeps its sentence end (a forced aligner
251
251
  needs letters in every word), and a line after `[draft]` (a bridge you wrote for the deck) is said, and shown apart;
252
- - **a clip per click, cached by what it says** — `footprint-narration/voice` (Node). A clip's key is a hash of the
252
+ - **a clip per click, cached by what it says** — `footprint-story/voice` (Node). A clip's key is a hash of the
253
253
  voice settings and the exact words, so editing one note re-voices that click and nothing else, and an interrupted
254
254
  run keeps every batch it finished. The voice is a port (`{ name, synthesize(scenes, { work }) }`); `chatterboxKit`
255
255
  is one adapter — a local voice kit (Chatterbox, words aligned with MMS_FA; nothing is uploaded). `footprint-storydeck/voice`
@@ -259,7 +259,7 @@ shared with footprint-storyreel; StoryDeck adds what is the deck's own:
259
259
  the browser's where a click has none — captions lighting sentence by sentence (styles: `footprint-storydeck/narration.css`).
260
260
 
261
261
  ```js
262
- import { voiceClips, chatterboxKit } from 'footprint-narration/voice';
262
+ import { voiceClips, chatterboxKit } from 'footprint-story/voice';
263
263
  import { narrationText } from 'footprint-storydeck/narration';
264
264
  import { embedClips } from 'footprint-storydeck/voice';
265
265
  import { notesData, notesRuntime, listenRuntime } from 'footprint-storydeck/narration-html';
@@ -290,7 +290,7 @@ would drop one entrance frame in six at 30 fps, and a test with the real ffmpeg
290
290
  ```js
291
291
  import { chromium } from 'playwright-core';
292
292
  import { renderVideo, deckStageDriver } from 'footprint-storydeck/video';
293
- import { clipFor } from 'footprint-narration/voice';
293
+ import { clipFor } from 'footprint-story/voice';
294
294
  import { narrationText, writtenText } from 'footprint-storydeck/narration';
295
295
 
296
296
  const steps = clicks.map(c => {
@@ -413,16 +413,45 @@ npx footprint-storydeck build my-talk # my-talk/index.html — one page, eve
413
413
  its route, its notes), `check` (what to fix, each with its click and its fix: a headline over 12 words, pieces that
414
414
  light just by coming in, a click that changes nothing, a piece never shown, clicks without notes, a theme theme.css
415
415
  never styles). `check --layout`
416
- opens the page in a browser, offline, and finds text that spills out of its box (it needs Playwright installed beside
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
417
418
  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:
418
419
  `claude mcp add storydeck -- npx footprint-storydeck mcp my-talk`.
419
- - **The helpers a diagram needs.** `<i data-k="w1" class="wire pkt" data-arrow="252 352 318 352"></i>` is a wire (an
420
- SVG arrow, with an optional bend; class `pkt` adds a packet that rides it on a `path` click, at its turn — along the
421
- curve itself, not a straight line across the bend). A wire that comes in shows itself from its start to its tip,
422
- its dashes and colours as your theme draws them.
420
+ - **The helpers a diagram needs.** `<i data-k="w1" class="wire pkt" data-arrow="client to server"></i>` is a wire (an
421
+ SVG arrow) from the edge of one piece's box to the edge of the other's. It follows them: move a box (in the file, or
422
+ by Alt-drag in the dev page) and its wires go with it. Two wires between the same pieces each take a lane, keeping to
423
+ their left, so a request going right runs above its answer coming back; a bend may follow the names
424
+ (`"gw to db 40"`). `data-arrow="252 352 318 352"` places a wire by hand, in slide px. Class `pkt` adds a packet that
425
+ rides the wire on a `path` click, at its turn — along the curve itself, not a straight line across the bend. A wire
426
+ that comes in shows itself from its start to its tip, its dashes and colours as your theme draws them. A wire between
427
+ pieces knows its ends, so a click can be about a piece and everything it touches: `data-on="gw+"` lights the
428
+ gateway, its wires and the pieces at their other ends, and greys the rest (`review` lists them); it reads in every
429
+ list of names a click has, the groups a mover moves too (`data-focus-apart="gw+: 0 -200"`). A living system
430
+ diagram needs three more things on a wire, each an attribute: `data-label="InvokeAgentRuntime"` writes its name at its
431
+ middle, on its left (a request's above it, its answer's below; in the wire's colour, on an edge of the ground so it
432
+ reads over anything); class `halo` gives it a soft glow (`--wire-halo`); and `data-packets="2" data-speed="5"` keeps
433
+ packets running along it, evenly spaced, 5 s a run (`--packet`, `--packet-glow`). The packets are loops (see below):
434
+ only on the slide shown, in step from click to click, and under reduced motion, in print or with motion off one rests
435
+ at the wire's middle.
423
436
  `<i class="ic" data-icon="server"></i>` is `icons/server.svg`. `deck.css` styles the chrome (kicker, headline, footer
424
437
  and count), wires, packets, icons and the theme toggle from the token contract, every rule `:where()`, so your
425
438
  theme wins.
439
+ - **Motion that keeps going: a loop.** `data-loop="pulse"` on a piece makes it pulse while its slide is shown;
440
+ `pulse`, `float` and `marquee` are the endless moves of the motion table StoryReel's films use
441
+ (`footprint-story/motion`), drawn into the page only when a deck names them (`data-loop="float 4s"` sets the pace).
442
+ `data-loop` alone runs the piece's own animation from theme.css: `.gear { --loop: spin 6s linear infinite; }`. A
443
+ named loop moves its element by `transform` (`marquee` slides it 1600 px left and starts over: a ticker), so it goes
444
+ on an element without a transform of its own — a tag that sets one is refused; one theme.css gives it is replaced
445
+ while the loop runs. A rule of theme.css that gives the element its own `animation` wins over its loop: list
446
+ `var(--loop)` in it to keep both. A
447
+ map is drawn again for every click, yet a loop never starts over: every loop in the deck runs on one clock, so the
448
+ next click shows it where the last one left it, and a jump or a step back keeps it running. A loop runs only on the
449
+ slide shown (a deck of 40 map clicks runs one slide's loops, not 40), and never under reduced motion, in print, or
450
+ with motion off — there the piece rests in its own style. **Motion off** is `?motion=off` after the page's address
451
+ (or `<deck-stage data-motion="off">`): no loops, and every click lands at once, so a screenshot of a click is the same
452
+ every time. `review` lists each click's loops; `check` warns about a `data-loop` that nothing gives a `--loop`, and
453
+ `check --layout` finds an endless animation that is not a loop: one running on a slide no one sees (an error: it
454
+ costs the whole deck), and one that starts over at each click.
426
455
  - **A deck is files, never a program — on your machine.** No deck code runs in Node: no build script, no deck module.
427
456
  Playwright (1.48 or newer), for `check --layout`, is loaded only from beside StoryDeck — never from the deck folder or
428
457
  the folder you run in — and the page is measured offline: no name resolves, the browser context is offline and routed
@@ -547,9 +576,9 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
547
576
  | `planFocus` · `focusStep` · `readFocus` · `FOCUS` | focus: per click, what it is about and what happens to the rest (also `footprint-storydeck/focus`) |
548
577
  | `readPieces` · `readStep` · `drawFocus` · `wrapStage` · `slideClass` · `focusOverlay` · `focusRuntime` | focus, written into slide HTML (also `footprint-storydeck/focus-html`; styles in `footprint-storydeck/focus.css`) |
549
578
  | `idleTour` · `tourPlan` · `tourText` | tour: a deck (or a figure) that shows its point while nobody is using it, and its plan in data and in one line (also `footprint-storydeck/tour`) |
550
- | `narrationText` · `writtenText` | narration: what a click says (also `footprint-storydeck/narration`); the rules beneath, sentences, captions and chapters are `footprint-narration`'s |
579
+ | `narrationText` · `writtenText` | narration: what a click says (also `footprint-storydeck/narration`); the rules beneath, sentences, captions and chapters are `footprint-story`'s |
551
580
  | `notesData` · `notesRuntime` · `listenRuntime` | narration in the page: notes, presenter window, listen mode (also `footprint-storydeck/narration-html`; styles in `footprint-storydeck/narration.css`) |
552
- | `embedClips` | `footprint-storydeck/voice` (Node only): a deck's clips inside one page; the clip cache, the voice port and its local-kit adapter are `footprint-narration/voice`'s |
581
+ | `embedClips` | `footprint-storydeck/voice` (Node only): a deck's clips inside one page; the clip cache, the voice port and its local-kit adapter are `footprint-story/voice`'s |
553
582
  | `renderVideo` · `planVideo` · `deckStageDriver` · `ffmpegMajor` | `footprint-storydeck/video` (Node only, ffmpeg 5.0+): the deck as a narrated video, with captions and chapters |
554
583
  | `exportPptx` · `pptxParts` · `zip` · `unzip` · `checkPptx` | `footprint-storydeck/pptx` (Node only): the deck as a PowerPoint file — a slide per click, its picture, notes and clip — through a footprintjs flowchart that records why |
555
584
  | `loadDeck` · `buildDeck` · `serveDeck` · `outline` · `reviewClick` · `checkDeck` · `layoutCheck` · `initDeck` · `serveMcp` · `runCli` | `footprint-storydeck/deck` (Node only): a deck folder made into one page, served for editing, read back as text — the `storydeck` command (also `npx footprint-storydeck`); its page's base styles are `footprint-storydeck/deck.css` |
package/deck.css CHANGED
@@ -41,11 +41,11 @@
41
41
  click it rides the curve once, at the wire's turn: --sd-t runs 0 → 1 and it stands at B(t) = 2(1-t)t·C + t²·E from
42
42
  its start (C: --cdx/--cdy, E: --pdx/--pdy, from transforms.js). */
43
43
  @property --sd-t { syntax: '<number>'; inherits: false; initial-value: 0; }
44
- :where(svg.wire .pkt) {
45
- opacity: 0; fill: var(--sd-glow, currentColor); stroke: none;
44
+ :where(svg.wire :is(.pkt, .run)) {
46
45
  transform: translate(calc(var(--cdx) * 2 * (1 - var(--sd-t)) * var(--sd-t) + var(--pdx) * var(--sd-t) * var(--sd-t)),
47
46
  calc(var(--cdy) * 2 * (1 - var(--sd-t)) * var(--sd-t) + var(--pdy) * var(--sd-t) * var(--sd-t)));
48
47
  }
48
+ :where(svg.wire .pkt) { opacity: 0; fill: var(--sd-glow, currentColor); stroke: none; }
49
49
  @keyframes sdPkt { from { --sd-t: 0; } to { --sd-t: 1; } }
50
50
  @keyframes sdPktShow { 0%, 100% { opacity: 0; } 15%, 80% { opacity: 1; } }
51
51
  /* a wire that comes in shows itself start to tip behind a sweeping soft edge (a mask: a theme's dashes stay), square to
@@ -70,6 +70,23 @@
70
70
  }
71
71
  }
72
72
 
73
+ /* a wire's options (transforms.js): its label at its middle, in its colour, on a ground-coloured edge so it reads over
74
+ anything; class "halo", a soft glow; data-packets, packets that keep running along it — loops (deck-stage.js USAGE
75
+ (i)): on the slide shown, on one clock, each fading in at the start and out before the head. Where loops do not run
76
+ (reduced motion, print, motion off) the first packet rests at the wire's middle and the others are hidden. */
77
+ :where(svg.wire text.label) {
78
+ font: 600 18px/1 var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); letter-spacing: .06em;
79
+ text-transform: uppercase; dominant-baseline: central; fill: currentColor;
80
+ stroke: var(--bg, #ffffff); stroke-width: 6px; stroke-linejoin: round; paint-order: stroke;
81
+ }
82
+ :where(svg.wire.halo path) { filter: drop-shadow(0 0 6px var(--wire-halo, currentColor)); } /* the line and its head, not the label */
83
+ :where(svg.wire .run) {
84
+ --sd-t: .5; fill: var(--packet, var(--sd-glow, currentColor)); stroke: none;
85
+ filter: drop-shadow(0 0 5px var(--packet-glow, var(--packet, var(--sd-glow, currentColor))));
86
+ }
87
+ :where(svg.wire .run + .run) { opacity: 0; }
88
+ @keyframes sdRun { 0% { --sd-t: 0; opacity: 0; } 12%, 85% { opacity: 1; } 100% { --sd-t: 1; opacity: 0; } }
89
+
73
90
  /* icons (data-icon): drawn in currentColor */
74
91
  :where(svg.ic) { width: 60px; height: 60px; flex: none; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
75
92
 
package/deck.d.ts CHANGED
@@ -67,6 +67,8 @@ export interface DeckSource {
67
67
  }
68
68
  export function readDeck(dir: string): DeckSource;
69
69
 
70
+ /** A loop a click shows (deck-stage: data-loop runs its --loop on the slide shown): its piece's first name (null when the element is not a piece), and its value as written — a named loop ("pulse", "float 4s") or '' for the deck's own (`styled` when its own style sets --loop). */
71
+ export interface DeckLoop { readonly piece: string | null; readonly value: string; readonly styled?: true }
70
72
  /** A click of the compiled deck, as review and check read it. */
71
73
  export type DeckClick = {
72
74
  readonly n: number;
@@ -76,6 +78,8 @@ export type DeckClick = {
76
78
  /** The headline as plain text ('' on a plain slide). */
77
79
  readonly headline: string;
78
80
  readonly notes: readonly string[];
81
+ /** What loops on the click (on a map: on the pieces it shows). */
82
+ readonly loops: readonly DeckLoop[];
79
83
  } & (
80
84
  | { readonly kind: 'plain'; readonly map: null; readonly text: string }
81
85
  | { readonly kind: 'map'; readonly map: string; readonly step: FocusClick; readonly look: FocusLook; readonly stepIndex: number; readonly steps: number }
@@ -92,6 +96,8 @@ export interface CompiledDeck {
92
96
  readonly themeCss: string;
93
97
  readonly slides: readonly DeckSlide[];
94
98
  readonly clicks: readonly DeckClick[];
99
+ /** The named loops the clicks show (data-loop values), drawn into the page's CSS once each. */
100
+ readonly loops: readonly string[];
95
101
  readonly maps: Readonly<Record<string, { readonly pieces: readonly FocusPiece[] }>>;
96
102
  }
97
103
  /** The deck's clicks planned (each map's state carried click to click) and drawn as <deck-stage> slides. */
@@ -133,10 +139,10 @@ export function formatProblems(problems: readonly DeckProblem[]): string;
133
139
  /** The browser port the layout check drives (the video's deckStageDriver, with measure). */
134
140
  export interface LayoutDriver {
135
141
  open(url: string): Promise<void>;
136
- measure(n: number): Promise<readonly { kind: 'spills' | 'outside'; piece: string | null; text: string; dx: number; dy: number }[]>;
142
+ measure(n: number): Promise<readonly { kind: 'spills' | 'outside' | 'loose-loop' | 'hidden-loop'; piece: string | null; text: string; dx: number; dy: number; slide?: number; count?: number }[]>;
137
143
  close(): Promise<unknown> | unknown;
138
144
  }
139
- /** Text that spills out of its box and lit pieces off the slide, measured click by click in a browser. */
145
+ /** Text that spills out of its box, lit pieces off the slide, and endless animations that are not loops, measured click by click in a browser. */
140
146
  export function layoutCheck(url: string, options: { clicks: readonly DeckClick[]; driver?: LayoutDriver; chromium?: unknown }): Promise<DeckProblem[]>;
141
147
 
142
148
  export interface DeckServer {
@@ -1,4 +1,6 @@
1
1
  import { wordCount } from "./parse.js";
2
+ import { expandNames } from "../focus.js";
3
+ import { LOOP_NAMES } from "./loops.js";
2
4
  const names = (pieces, idx) => [...new Set(idx.map((i) => pieces[i].keys[0]))];
3
5
  const problem = (level, rule, click, message) => ({ level, rule, click: click?.n ?? null, label: click?.label ?? null, message });
4
6
  function words(c, { maxWords, maxSlideWords }) {
@@ -9,7 +11,7 @@ function words(c, { maxWords, maxSlideWords }) {
9
11
  }
10
12
  function litOnEntry(c, pieces) {
11
13
  if (c.kind !== "map" || c.step.on === null || c.step.quiet) return [];
12
- const on = c.step.on;
14
+ const on = expandNames(pieces, c.step.on);
13
15
  if (on.includes("*")) return [];
14
16
  const extra = c.look.pieces.map((p, i) => p.state === "on" && (p.change === "enter" || p.change === "arrive") && !pieces[i].keys.some((k) => on.includes(k)) ? i : null).filter((i) => i !== null);
15
17
  if (!extra.length) return [];
@@ -53,6 +55,13 @@ function unstyledThemes(compiled) {
53
55
  }
54
56
  return out;
55
57
  }
58
+ function unsetLoops(compiled) {
59
+ if (/--loop\s*:/.test(compiled.themeCss ?? "")) return [];
60
+ const bare = compiled.clicks.flatMap((c) => (c.loops ?? []).filter((l) => l.value === "" && !l.styled).map((l) => ({ c, piece: l.piece ?? "an element" })));
61
+ if (!bare.length) return [];
62
+ const list = [...new Set(bare.map((b) => b.piece))], one = list.length === 1;
63
+ 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
+ }
56
65
  function checkDeck(compiled) {
57
66
  const out = [];
58
67
  compiled.clicks.forEach((c, i) => {
@@ -60,7 +69,7 @@ function checkDeck(compiled) {
60
69
  if (c.kind === "map") out.push(...litOnEntry(c, compiled.maps[c.map].pieces));
61
70
  out.push(...noChange(c, compiled.clicks[i - 1]));
62
71
  });
63
- out.push(...neverShown(compiled), ...missingNotes(compiled), ...unstyledThemes(compiled));
72
+ out.push(...neverShown(compiled), ...missingNotes(compiled), ...unstyledThemes(compiled), ...unsetLoops(compiled));
64
73
  return out;
65
74
  }
66
75
  const failure = (error) => [problem("error", "compile", null, error.message)];
@@ -1,6 +1,7 @@
1
1
  import { focusStep } from "../focus.js";
2
2
  import { readPieces, readStep, drawFocus, wrapStage, slideClass, focusOverlay } from "../focusHtml.js";
3
- import { applyTransforms } from "./transforms.js";
3
+ import { applyTransforms, icons as drawIcons } from "./transforms.js";
4
+ import { loopsIn } from "./loops.js";
4
5
  import { plainText } from "./parse.js";
5
6
  import { esc, stripComments } from "./html.js";
6
7
  import { readAttributes, tagsWith } from "../pieceRules.js";
@@ -31,7 +32,10 @@ function prepareMap(name, html, icons, dev) {
31
32
  }
32
33
  const { html: out, pieces } = readPieces(drawn);
33
34
  if (!pieces.length) throw new Error(`map "${name}" has no pieces \u2014 a piece is an element with data-k="name" and an inline left/top (width/height)`);
34
- return { name, html: dev ? numberForDev(out) : out, pieces, state: null };
35
+ return { name, html: dev ? numberForDev(out) : out, pieces, loops: loopsIn(drawIcons(stripComments(html), icons), `map "${name}"`), state: null };
36
+ }
37
+ function shownLoops(map, look) {
38
+ return map.loops.filter((l) => l.piece === null || look.pieces.some((p, i) => p.state !== "gone" && map.pieces[i].keys.includes(l.piece)));
35
39
  }
36
40
  function planClicks(parts, maps, { area, canvas, icons }) {
37
41
  let lastMap = null;
@@ -45,7 +49,7 @@ function planClicks(parts, maps, { area, canvas, icons }) {
45
49
  } catch (e) {
46
50
  throw new Error(`"${part.label}": ${e.message}`, { cause: e });
47
51
  }
48
- clicks.push({ label: part.label, part, kind: "plain", inner, notes: part.notes, headline: "", step: 0, steps: 1 });
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 });
49
53
  lastMap = null;
50
54
  continue;
51
55
  }
@@ -60,7 +64,7 @@ function planClicks(parts, maps, { area, canvas, icons }) {
60
64
  }
61
65
  map.state = r.state;
62
66
  lastMap = map;
63
- clicks.push({ label: `${part.label} \xB7 ${k + 1}`, part, kind: "map", map, step, look: r.look, headline: s.headline, notes: s.notes, stepIndex: k, steps: part.steps.length });
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 });
64
68
  });
65
69
  }
66
70
  const seen = /* @__PURE__ */ new Set();
@@ -113,9 +117,11 @@ function compileDeck(source, { dev = false } = {}) {
113
117
  map: c.kind === "map" ? c.map.name : null,
114
118
  headline: plainText(c.headline),
115
119
  notes: c.notes,
120
+ loops: c.loops,
116
121
  ...c.kind === "map" ? { step: c.step, look: c.look, stepIndex: c.stepIndex, steps: c.steps } : { text: plainText(c.inner) }
117
122
  }));
118
- return { config, title: config.title, themeCss: source.themeCss ?? "", slides, clicks, maps: Object.fromEntries(Object.values(maps).map((m) => [m.name, { pieces: m.pieces }])) };
123
+ 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 }])) };
119
125
  }
120
126
  export {
121
127
  compileDeck
package/dist/deck/edit.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { attrValue, pieceTags, readAttributes, styleLength, tagsWith } from "../pieceRules.js";
2
2
  import { UNSEEN, commentRanges, stripComments } from "./html.js";
3
- import { wireTags } from "./transforms.js";
3
+ import { wireEnds, wireTags } from "./transforms.js";
4
4
  const FEEDBACK = "feedback.md";
5
5
  const MAX_MOVE = 5e3;
6
6
  const num = (v) => String(Math.round(v * 100) / 100);
@@ -53,7 +53,8 @@ function moveEdits(html, piece, dx, dy) {
53
53
  if (browserValueAt(html, piece, "data-k") !== piece.start + 1 + piece.tag.length + piece.before.length + ' data-k="'.length) throw ambiguous();
54
54
  const wire = wiresBySource(html).get(piece.start);
55
55
  if (wire) {
56
- const spec = wire.m[1], parts = spec.trim().split(/[\s,]+/), nums = parts.map(Number);
56
+ const spec = wire.m[1], parts = spec.trim().split(/[\s,]+/), nums = parts.map(Number), ends = wireEnds(spec);
57
+ if (ends) throw new Error(`"${piece.name}" runs from "${ends[0]}" to "${ends[1]}" and follows them \u2014 move one of them`);
57
58
  if (parts.length !== 4 && parts.length !== 5 || nums.some((n) => !Number.isFinite(n))) {
58
59
  throw new Error(`"${piece.name}": its data-arrow is not "x1 y1 x2 y2 [bend]" \u2014 fix it before moving it`);
59
60
  }
package/dist/deck/init.js CHANGED
@@ -9,16 +9,20 @@ const DECK_JSON = `{
9
9
  const MAP = `<!--
10
10
  The map: one picture the story is told on, in slide pixels (1920 \xD7 1080).
11
11
  Every element with data-k="name" and an inline left/top/width/height is a piece a click can bring in, light,
12
- grey or move. Pieces never nest. <i data-arrow="x1 y1 x2 y2 [bend]"> becomes a wire (class "pkt": a packet runs
13
- along it on a path click); <i class="ic" data-icon="name"> is icons/name.svg.
12
+ grey or move. Pieces never nest. <i data-arrow="client to server"> becomes a wire from one piece's edge to the
13
+ other's (it follows them when they move; "x1 y1 x2 y2" in slide px places it by hand; a bend may follow either),
14
+ class "pkt": a packet runs along it on a path click. A wire may carry data-label="its name", class "halo", and
15
+ data-packets="2" data-speed="5" (packets that keep running along it). In a click, "server+" is the server, its wires
16
+ and what they reach. <i class="ic" data-icon="name"> is icons/name.svg. data-loop="pulse" (float, marquee)
17
+ keeps a piece moving while its slide is shown; data-loop alone runs its own --loop from theme.css.
14
18
  -->
15
19
  <div data-k="client" class="box" style="left:160px;top:420px;width:300px;height:210px"><i class="ic" data-icon="client"></i><b>Client</b><small>a browser</small></div>
16
- <i data-k="req1" class="wire req pkt" data-arrow="464 490 716 490"></i>
20
+ <i data-k="req1" class="wire req pkt" data-arrow="client to server"></i>
17
21
  <div data-k="server" class="box" style="left:720px;top:420px;width:300px;height:210px"><i class="ic" data-icon="server"></i><b>Server</b><small>an API</small></div>
18
- <i data-k="req2" class="wire req pkt" data-arrow="1024 490 1276 490"></i>
22
+ <i data-k="req2" class="wire req pkt" data-arrow="server to db"></i>
19
23
  <div data-k="db" class="box" style="left:1280px;top:420px;width:300px;height:210px"><i class="ic" data-icon="database"></i><b>Database</b><small>where the data lives</small></div>
20
- <i data-k="ret2" class="wire ret pkt" data-arrow="1276 570 1024 570"></i>
21
- <i data-k="ret1" class="wire ret pkt" data-arrow="716 570 464 570"></i>
24
+ <i data-k="ret2" class="wire ret pkt" data-arrow="db to server"></i>
25
+ <i data-k="ret1" class="wire ret pkt" data-arrow="server to client"></i>
22
26
  <div data-k="time" class="chip" style="left:760px;top:670px;width:220px;height:70px"><b>120 ms</b><small>illustration</small></div>
23
27
  `;
24
28
  const CLICKS = `<!--
@@ -25,20 +25,26 @@ function clickRuns(ns) {
25
25
  }
26
26
  return runs.map(([a, b]) => a === b ? `${a}` : `${a}\u2013${b}`).join(", ");
27
27
  }
28
+ const KINDS = {
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
+ 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
+ "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
+ "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
+ const size = ({ dx, dy }) => [dx > 0 && `${Math.round(dx)} px wide`, dy > 0 && `${Math.round(dy)} px tall`].filter(Boolean).join(" and ");
28
35
  function layoutProblems(found, clicks) {
29
36
  const same = /* @__PURE__ */ new Map();
30
37
  for (const f of found) {
31
- const key = JSON.stringify([f.kind, f.piece, f.text, Math.round(f.dx), Math.round(f.dy)]);
32
- if (!same.has(key)) same.set(key, { ...f, ns: [] });
33
- same.get(key).ns.push(f.n);
38
+ const at = f.kind === "hidden-loop" ? f.slide : f.n;
39
+ const key = JSON.stringify(f.kind === "hidden-loop" ? [f.kind, f.piece, f.count] : [f.kind, f.piece, f.text, Math.round(f.dx), Math.round(f.dy)]);
40
+ if (!same.has(key)) same.set(key, { ...f, ns: /* @__PURE__ */ new Set() });
41
+ same.get(key).ns.add(at);
34
42
  }
35
- return [...same.values()].sort((a, b) => a.ns[0] - b.ns[0]).map(({ ns, kind, piece, text, dx, dy }) => {
36
- const c = clicks[ns[0] - 1];
37
- const where = piece ? `"${piece}"` : "an element";
38
- const by = [dx > 0 && `${Math.round(dx)} px wide`, dy > 0 && `${Math.round(dy)} px tall`].filter(Boolean).join(" and ");
39
- const also = ns.length > 1 ? ` (on clicks ${clickRuns(ns)})` : "";
40
- const message = kind === "spills" ? `text spills out of ${where} by ${by} ("${text}")${also} \u2014 shorten it, or give the box more room` : `${where} reaches ${by} past the slide's edge ("${text}")${also} \u2014 move it in, or zoom less`;
41
- return { level: "warn", rule: kind === "spills" ? "spills" : "outside", click: ns[0], label: c?.label ?? null, message };
43
+ return [...same.values()].map((f) => ({ ...f, ns: [...f.ns].sort((a, b) => a - b) })).sort((a, b) => a.ns[0] - b.ns[0]).map((f) => {
44
+ const c = clicks[f.ns[0] - 1];
45
+ const also = f.ns.length > 1 ? ` (on clicks ${clickRuns(f.ns)})` : "";
46
+ const [level, message] = KINDS[f.kind](f, f.piece ? `"${f.piece}"` : "an element", also);
47
+ return { level, rule: f.kind, click: f.ns[0], label: c?.label ?? null, message };
42
48
  });
43
49
  }
44
50
  async function layoutCheck(url, { clicks, driver, chromium }) {
@@ -0,0 +1,75 @@
1
+ import { moveAt, movesBy, readMove } from "footprint-story/motion";
2
+ import { readAttributes, tagsWith } from "../pieceRules.js";
3
+ const LOOP_NAMES = Object.freeze(movesBy({ kind: "ambient" }).map((m) => m.name));
4
+ const LOOP_SECONDS = Object.freeze([0.2, 60]);
5
+ const STEPS = 24;
6
+ const FORM = /^([a-z][a-z-]*)(?: (\d+(?:\.\d+)?)s)?$/;
7
+ function readLoop(value, where) {
8
+ if (value === "") return null;
9
+ const m = FORM.exec(value);
10
+ if (!m || !LOOP_NAMES.includes(m[1])) {
11
+ throw new Error(`${where}: data-loop="${value}" is not a loop \u2014 name one of ${LOOP_NAMES.join(", ")}, with its seconds if you like ("float 4s"), or write data-loop alone and give the piece its own --loop in theme.css`);
12
+ }
13
+ const seconds = m[2] === void 0 ? readMove(m[1]).seconds : Number(m[2]);
14
+ if (!(seconds >= LOOP_SECONDS[0] && seconds <= LOOP_SECONDS[1])) {
15
+ throw new Error(`${where}: data-loop="${value}" \u2014 a cycle lasts ${LOOP_SECONDS[0]} to ${LOOP_SECONDS[1]} s`);
16
+ }
17
+ return { name: m[1], seconds };
18
+ }
19
+ function loopsIn(html, where) {
20
+ const out = [];
21
+ for (const t of tagsWith(html, /\sdata-loop(?![\w-])/i)) {
22
+ const attrs = readAttributes(t.before + t.m[0] + t.after);
23
+ if (!attrs) throw new Error(`${where}: <${t.tag}> with data-loop holds a quote never closed before its ">" \u2014 write a ">" inside a value as &gt;`);
24
+ const loop = attrs.find((a) => a.name === "data-loop");
25
+ if (!loop) continue;
26
+ const k = attrs.find((a) => a.name === "data-k")?.value.trim().split(/\s+/)[0] || null, at = k ? `${where}, piece "${k}"` : where;
27
+ readLoop(loop.value, at);
28
+ const style = attrs.find((a) => a.name === "style")?.value ?? "";
29
+ if (loop.value !== "" && (attrs.some((a) => a.name === "transform") || /(?:^|;)\s*transform\s*:/i.test(style))) {
30
+ throw new Error(`${at}: data-loop="${loop.value}" moves the element by its transform, which would replace the one it has \u2014 put data-loop on an element inside it`);
31
+ }
32
+ const styled = loop.value === "" && /(?:^|;)\s*--loop\s*:/.test(style);
33
+ out.push({ piece: k, value: loop.value, ...styled ? { styled } : {} });
34
+ }
35
+ return out;
36
+ }
37
+ const r4 = (v) => +(+v).toFixed(4) + 0;
38
+ const TRANSFORM = ["x", "y", "rotate", "scale"];
39
+ function frameCss(v, props) {
40
+ const out = [];
41
+ if (TRANSFORM.some((p) => p in props)) {
42
+ const parts = [];
43
+ if ("x" in props || "y" in props) parts.push(`translate(${r4(v.x)}px, ${r4(v.y)}px)`);
44
+ if ("rotate" in props) parts.push(`rotate(${r4(v.rotate)}deg)`);
45
+ if ("scale" in props) parts.push(`scale(${r4(v.scale)})`);
46
+ out.push(`transform: ${parts.join(" ")}`);
47
+ }
48
+ if ("opacity" in props) out.push(`opacity: ${r4(v.opacity)}`);
49
+ if ("blur" in props) out.push(`filter: blur(${r4(v.blur)}px)`);
50
+ return out.join("; ");
51
+ }
52
+ function keyframes(name) {
53
+ const m = readMove(name), at = (i) => moveAt(m, i / STEPS * m.seconds * (1 - 1e-9), { seconds: m.seconds });
54
+ const frames = Array.from({ length: STEPS + 1 }, (_, i) => `${r4(i / STEPS * 100)}% { ${frameCss(at(i), m.props)}; }`);
55
+ return `@keyframes sd-loop-${name} { ${frames.join(" ")} }`;
56
+ }
57
+ function loopCss(values) {
58
+ const named = [...new Set(values)].filter((v) => v !== "").sort(), names = /* @__PURE__ */ new Set();
59
+ if (!named.length) return "";
60
+ const rules = named.map((v) => {
61
+ const { name, seconds } = readLoop(v, "loopCss");
62
+ names.add(name);
63
+ return `:where([data-loop="${v}"]) { --loop: sd-loop-${name} ${r4(seconds)}s linear infinite; }`;
64
+ });
65
+ const shapes = ':where(svg [data-loop]:not([data-loop=""])) { transform-box: fill-box; transform-origin: center; }';
66
+ return [...[...names].map(keyframes), ...rules, shapes].join("\n");
67
+ }
68
+ export {
69
+ LOOP_NAMES,
70
+ LOOP_SECONDS,
71
+ frameCss,
72
+ loopCss,
73
+ loopsIn,
74
+ readLoop
75
+ };
package/dist/deck/page.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { focusRuntime } from "../focusHtml.js";
2
2
  import { notesData, notesRuntime, listenRuntime } from "../narrationHtml.js";
3
3
  import { esc, scriptSafe, styleSafe } from "./html.js";
4
+ import { loopCss } from "./loops.js";
4
5
  function themeRuntime() {
5
6
  const root = document.documentElement, btn = document.querySelector(".theme-toggle");
6
7
  const set = (dark) => {
@@ -32,7 +33,8 @@ const THEME_FIRST = "(function(){var d;try{var t=localStorage.getItem('theme');d
32
33
  const TOGGLE = '<button class="theme-toggle" type="button" aria-label="Switch theme" title="Light / dark (T)"><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path stroke-linecap="round" d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/></svg><svg class="moon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>';
33
34
  function deckPage(compiled, assets, { dev = null } = {}) {
34
35
  const { config, slides } = compiled;
35
- const css = [...assets.css, compiled.themeCss ?? ""].join("\n");
36
+ const loops = loopCss(compiled.loops ?? []);
37
+ const css = [...assets.css, ...loops ? [loops] : [], compiled.themeCss ?? ""].join("\n");
36
38
  const script = dev?.nonce ? `<script nonce="${esc(dev.nonce)}">` : "<script>";
37
39
  const runtimes = [focusRuntime, notesRuntime, listenRuntime, themeRuntime].map((fn) => `${script}(${scriptSafe(fn)})();<\/script>`);
38
40
  return `<!doctype html>
@@ -4,6 +4,7 @@ const unique = (names) => [...new Set(names)];
4
4
  const list = (names, max = 14) => names.length ? `${names.slice(0, max).join(", ")}${names.length > max ? `, \u2026 (${names.length - max} more)` : ""}` : "\u2014";
5
5
  const themed = (c) => c.theme ? ` \xB7 theme ${c.theme}` : "";
6
6
  const quote = (s, max = 160) => s.length > max ? `"${s.slice(0, max - 1)}\u2026"` : `"${s}"`;
7
+ 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;
7
8
  function pieceGroups(click, pieces) {
8
9
  const by = (pred) => unique(click.look.pieces.map((p, i) => pred(p) ? nameOf(pieces, i) : null).filter(Boolean));
9
10
  return {
@@ -59,6 +60,7 @@ function reviewClick(compiled, which, { maxWords = compiled.config?.checks?.maxW
59
60
  if (c.kind === "plain") {
60
61
  lines.push(`click ${c.n} of ${compiled.clicks.length} \xB7 "${c.label}" (plain slide)${themed(c)}`);
61
62
  lines.push(`text (${words(wordCount(c.text))}): ${quote(c.text, 300)}`);
63
+ if (loopsLine(c)) lines.push(loopsLine(c));
62
64
  } else {
63
65
  lines.push(`click ${c.n} of ${compiled.clicks.length} \xB7 "${c.label}" (map "${c.map}", step ${c.stepIndex + 1} of ${c.steps})${themed(c)}`);
64
66
  const hw = wordCount(c.headline);
@@ -82,6 +84,7 @@ function reviewClick(compiled, which, { maxWords = compiled.config?.checks?.maxW
82
84
  lines.push(`this click: ${changes.length ? changes.join(" \xB7 ") : "nothing changes on the map"}`);
83
85
  const motion = motionOf(c.look);
84
86
  if (motion.length) lines.push(`motion: ${motion.join(" \xB7 ")}`);
87
+ if (loopsLine(c)) lines.push(loopsLine(c));
85
88
  }
86
89
  const noteWords = c.notes.reduce((sum, p) => sum + wordCount(p), 0);
87
90
  lines.push(c.notes.length ? `notes (${c.notes.length}, ${words(noteWords)}): ${quote(c.notes.join(" "), 240)}` : "notes: \u2014");