footprint-storydeck 0.3.0 → 0.4.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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — a path: the subject piece after piece along a route
4
+
5
+ - **A fourth kind of focus strategy, the order — how the subject comes on — and its first strategy, `path`.**
6
+ A click lights its subject piece after piece along a route, as a request travels it (node, wire, node): each lit
7
+ piece on it lights at its turn, `step` seconds apart (0.35), and glows as it is reached — on every forward click,
8
+ so the request travels again; `route` names the order (default, and for an empty `route`: the click's `on`, or its
9
+ `in`), a name shared by several pieces is one turn, a name with nothing lit takes none (a greyed piece never waits
10
+ or glows grey), and a lit piece off the route lights at once. A click that asks for a path with no order at all
11
+ (`on: ['*']`) is refused, naming the fix: write the pieces in the order they light. A `line` names the route, shown
12
+ as the last turn starts (`lineAt` places it; default the area's bottom left; a blank one is none). Borrowed from the isometric figures where a
13
+ request lights its path.
14
+ - Everything on a path keeps its turn. A piece lit from grey plays one animation, `sdLightPulse`, whose filter lists
15
+ the same functions at every keyframe, so it glides into colour and the glow (a light and a pulse on one filter
16
+ would flash); a ring (`hot`) comes .3 s after its piece's turn; a dashed twin stays until its solid twin's turn,
17
+ then steps aside, so the wire is never missing; and under a new blur the whole path, line included, waits .4 s for
18
+ it, as what a click adds does. One variable carries a piece's moment, `--sd-turn`.
19
+ - `focusStep` gives each piece its `path` (its turn's delay, or null) and `twinTurn` (stepping aside for a lit twin
20
+ on the route: that twin's turn, or null), and the look its `path` ({turns, step, done, line, lineAt}; `line` '' and
21
+ `lineAt` null when none is given). What an order's `route()` gives — a deck's own too — is checked (names, a
22
+ `step` of 0–5 s, a text `line`, a `lineAt` of 'x y' or null) and refused with the strategy's name and the fix,
23
+ and is only read, never written to (a frozen result is fine). `focusOverlay` writes the line; `focus.css` animates
24
+ the path only on a forward click, never under reduced motion (`--sd-glow` colours the pulse). A click without a
25
+ path is drawn byte for byte as 0.3.0 drew it.
26
+
3
27
  ## 0.3.0 — `footprint-storydeck`; the narration rules move into footprint-narration
4
28
 
5
29
  - **The package is renamed `footprint-storydeck`** — the footprintjs family's name, as footprint-storyreel and
package/README.md CHANGED
@@ -74,9 +74,24 @@ decision every click makes again, so it is data on the click, and the library do
74
74
  | mover | `left` · `right` · `up` · `down` | the context steps aside that way, smaller (`scale`), and the subject grows into the room it leaves (`grow`, `fill`, `gap`, `align`; `move: 'aside'` keeps the subject where it is) |
75
75
  | mover | `place` | named groups moved by the amounts you give (`groups: 'page floor: -90 -200 0.5 \| tl: 0 -230 1.1 @ 960 968'`): a layout you art-direct, still animated from wherever the last click left it |
76
76
  | overlay | `blur` | everything outside the subject's frame is blurred; a frame (and a `label`) on it, or on the `rect`s you give |
77
+ | order | `path` | the subject comes on piece after piece along a route, as a request travels it — node, wire, node: each lit piece on it lights at its turn (`step` seconds apart, 0.35) and glows as it is reached; `route` names the order (default — and for an empty `route` — the click's `on`, or its `in`); a greyed piece on it takes no turn, and a route with no order at all (`on: ['*']`) is refused; a `line` ("agent → MCP → gateway") names the route, shown as the last turn starts. A deck's own `filter` on a path piece is not carried through the glow: it steps where the glow starts and ends |
77
78
 
78
79
  `'left blur'` reads: the context steps aside to the left, greyed, under a blur, and the subject grows on
79
- the right, framed. Bring your own strategy of any kind (`strategies: { name: { kind, … } }`).
80
+ the right, framed. `'path grey'` lights a route in turn and greys the rest. Bring your own strategy of any kind
81
+ (`strategies: { name: { kind, … } }`); what your own order's `route()` gives is checked like the built-in's — piece
82
+ names, a `step` of 0–5 s, a text `line`, a `lineAt` of `'x y'` — and refused with the fix when it is not.
83
+
84
+ **A path, on a diagram of an app.** An architecture slide is a route more often than a picture: who calls whom.
85
+ Write the click's `on` in the order a request travels it, wires included, and the path lights it that way:
86
+
87
+ ```html
88
+ <step data-on="agent link-mcp mcp link-gw gw" data-focus="path" data-focus-line="agent → MCP → gateway"></step>
89
+ ```
90
+
91
+ Everything on a path keeps its turn: a piece lit from grey glides into colour and the glow in one animation (two
92
+ on one `filter` would flash), its ring (`hot`) comes .3 s after its turn, and a dashed twin stays until its solid
93
+ twin's turn and only then steps aside, so the wire is never missing. Under a new blur the whole path, line included,
94
+ waits .4 s for it, as what a click adds does.
80
95
 
81
96
  The plan is plain data (each piece's state, what changed since the last click, how far it moved), so a
82
97
  renderer only draws it. `footprint-storydeck/focus-html` writes it into slide HTML at build time and
package/focus.css CHANGED
@@ -9,7 +9,8 @@
9
9
  * its own entrance, say — wins without !important.
10
10
  *
11
11
  * Tokens (set them in your theme; each has a fallback): --sd-dim-opacity · --sd-hot · --sd-blur ·
12
- * --sd-veil · --sd-accent (falls back to storydeck's --yellow) · --sd-ease · --sd-move-time.
12
+ * --sd-veil · --sd-accent (falls back to storydeck's --yellow) · --sd-ease · --sd-move-time · --sd-glow (a path's
13
+ * pulse, falls back to the accent).
13
14
  */
14
15
 
15
16
  /* a piece's state: on (in colour), dim (the context: greyed in place, or hidden, or kept), gone */
@@ -37,10 +38,16 @@
37
38
  color: var(--sd-accent, var(--yellow, #facc15)); }
38
39
  @media print { .sd-veil { display: none; } }
39
40
 
41
+ /* a path: its line, naming the route, in slide px (it shows once the route is done) */
42
+ .sd-path-line { position: absolute; pointer-events: none; white-space: nowrap; font: 600 22px/1 var(--mono, ui-monospace, monospace);
43
+ letter-spacing: .04em; color: var(--sd-accent, var(--yellow, #facc15)); }
44
+
40
45
  /* motion: each change fills its own slot, so a piece that moves AND lights does both */
41
46
  @property --sd-a-state { syntax: '*'; inherits: false; }
42
47
  @property --sd-a-move { syntax: '*'; inherits: false; }
43
48
  @property --sd-a-hot { syntax: '*'; inherits: false; }
49
+ @property --sd-a-path { syntax: '*'; inherits: false; }
50
+ @property --sd-turn { syntax: '*'; inherits: false; } /* a piece's moment on a path; never inherited, so 0 off the route */
44
51
  @keyframes sdNone {}
45
52
  @keyframes sdEnter { from { opacity: 0; scale: calc(var(--sd-s, 1) * .92); } }
46
53
  @keyframes sdArrive { from { opacity: 0; } }
@@ -52,6 +59,12 @@
52
59
  @keyframes sdStage { from { translate: var(--sd-tx0, 0px) var(--sd-ty0, 0px); scale: var(--sd-ts0, 1); } }
53
60
  @keyframes sdVeilIn { from { opacity: 0; } }
54
61
  @keyframes sdVeilOut { from { opacity: 1; } to { opacity: 0; } }
62
+ @keyframes sdPulse { 40% { filter: drop-shadow(0 0 14px var(--sd-glow, var(--sd-accent, var(--yellow, #facc15)))) brightness(1.2); } }
63
+ /* a piece that lights from grey at its turn: sdLight and sdPulse as ONE animation. Two on one filter swap their lists
64
+ (grayscale ↔ drop-shadow brightness) at a jump — a flash; here every keyframe lists the same functions in the same
65
+ order, so the filter glides from grey into colour and the glow, and back to the plain style (the implicit end) */
66
+ @keyframes sdLightPulse { from { opacity: var(--sd-dim-opacity, .32); filter: grayscale(1) drop-shadow(0 0 0 transparent) brightness(1); }
67
+ 40% { filter: grayscale(0) drop-shadow(0 0 14px var(--sd-glow, var(--sd-accent, var(--yellow, #facc15)))) brightness(1.2); } }
55
68
  @media screen and (prefers-reduced-motion: no-preference) {
56
69
  .sd-enter { --sd-a-state: sdEnter .6s var(--sd-ease, cubic-bezier(.2, .7, .2, 1)) var(--sd-delay, 0s) both; }
57
70
  .sd-arrive { --sd-a-state: sdArrive .6s ease both; }
@@ -62,10 +75,21 @@
62
75
  .sd-relook.sd-from-hide { --sd-a-state: sdArrive .6s ease both; }
63
76
  .sd-relook.sd-from-keep { --sd-a-state: sdFade .7s ease both; }
64
77
  .sd-move { --sd-a-move: sdMove var(--sd-move-time, 1s) var(--sd-ease, cubic-bezier(.2, .7, .2, 1)) both; }
65
- .sd-hot-in { --sd-a-hot: sdRing .6s var(--sd-ease, cubic-bezier(.2, .7, .2, 1)) .3s both; }
78
+ .sd-hot-in { --sd-a-hot: sdRing .6s var(--sd-ease, cubic-bezier(.2, .7, .2, 1)) calc(.3s + var(--sd-turn, 0s)) both; } /* on a path, .3s after its turn */
66
79
  .sd-focus-new .sd-enter { --sd-delay: .4s; } /* what a click adds waits for its blur */
67
- :where([data-deck-active]:not([data-sd-instant]):not([data-deck-static])) :is(.sd-enter, .sd-arrive, .sd-light, .sd-fade, .sd-leave, .sd-relook, .sd-move, .sd-hot-in) {
68
- animation: var(--sd-a-state, sdNone 0s), var(--sd-a-move, sdNone 0s), var(--sd-a-hot, sdNone 0s); }
80
+ /* a path: everything on it has one moment, --sd-turn — its delay, and .4s more under a new blur, so the whole path
81
+ waits for the blur and then runs. A piece on the route enters, arrives or lights then, and glows as the route
82
+ reaches it (on every forward click); a piece stepping aside for its lit twin goes then; the line shows then. */
83
+ :is(.sd-path, .sd-twin-turn, .sd-path-line) { --sd-turn: var(--sd-path-delay, 0s); }
84
+ .sd-focus-new :is(.sd-path, .sd-twin-turn, .sd-path-line) { --sd-turn: calc(var(--sd-path-delay, 0s) + .4s); }
85
+ .sd-path { --sd-a-path: sdPulse .9s ease var(--sd-turn); }
86
+ .sd-path.sd-enter { --sd-a-state: sdEnter .6s var(--sd-ease, cubic-bezier(.2, .7, .2, 1)) var(--sd-turn) both; }
87
+ .sd-path.sd-arrive { --sd-a-state: sdArrive .6s ease var(--sd-turn) both; }
88
+ .sd-path.sd-light { --sd-a-state: sdLightPulse .9s ease var(--sd-turn) backwards; --sd-a-path: sdNone 0s; } /* grey until its turn */
89
+ .sd-twin-turn.sd-leave { --sd-a-state: sdLeave .45s ease var(--sd-turn) both; }
90
+ :where([data-deck-active]:not([data-sd-instant]):not([data-deck-static])) :is(.sd-enter, .sd-arrive, .sd-light, .sd-fade, .sd-leave, .sd-relook, .sd-move, .sd-hot-in, .sd-path) {
91
+ animation: var(--sd-a-state, sdNone 0s), var(--sd-a-move, sdNone 0s), var(--sd-a-hot, sdNone 0s), var(--sd-a-path, sdNone 0s); }
92
+ :where([data-deck-active]:not([data-sd-instant]):not([data-deck-static])) .sd-path-line { animation: sdArrive .5s ease var(--sd-turn) both; }
69
93
  :where([data-deck-active]:not([data-sd-instant]):not([data-deck-static])) .sd-stage.sd-move { animation: sdStage var(--sd-move-time, 1s) var(--sd-ease, cubic-bezier(.2, .7, .2, 1)) both; }
70
94
  :where([data-deck-active]:not([data-sd-instant]):not([data-deck-static])) .sd-veil-in-new { animation: sdVeilIn .7s cubic-bezier(.4, 0, .2, 1) both; }
71
95
  :where([data-deck-active]:not([data-sd-instant]):not([data-deck-static])) .sd-frame.sd-new { animation: sdArrive .5s ease .35s both; }
package/focus.d.ts CHANGED
@@ -53,11 +53,19 @@ export interface FocusContext {
53
53
  readonly options: Readonly<Record<string, unknown>>;
54
54
  }
55
55
 
56
- /** A focus strategy: a look (how the context looks), a mover (where things go) or an overlay (what lies over the slide). */
56
+ /**
57
+ * A route, as an order strategy gives it — checked, and refused (naming the strategy and the fix) when it is not this:
58
+ * the names in turn (each one's LIT pieces take the next turn; a name with nothing lit takes none), the seconds between
59
+ * two turns (0–5), and the line that names it, with where it goes ('x y', slide px). The strategy's object is only read.
60
+ */
61
+ export interface FocusRoute { readonly names: readonly string[]; readonly step: number; readonly line?: string; readonly lineAt?: string | null }
62
+
63
+ /** A focus strategy: a look (how the context looks), a mover (where things go), an overlay (what lies over the slide) or an order (how the subject comes on). */
57
64
  export type FocusStrategy =
58
65
  | { readonly name?: string; readonly kind: 'look'; readonly look: 'grey' | 'hide' | 'keep' }
59
66
  | { readonly name?: string; readonly kind: 'mover'; place(ctx: FocusContext): { stage?: FocusGroup; subject?: FocusGroup; context?: FocusGroup; groups?: { names: readonly string[]; g: FocusGroup | null }[] } }
60
- | { readonly name?: string; readonly kind: 'overlay'; cover(ctx: { subjectOnSlide: FocusBox | null; canvas: { w: number; h: number }; options: Readonly<Record<string, unknown>> }): { rects: number[][]; label?: string; out?: number[][] } | null };
67
+ | { readonly name?: string; readonly kind: 'overlay'; cover(ctx: { subjectOnSlide: FocusBox | null; canvas: { w: number; h: number }; options: Readonly<Record<string, unknown>> }): { rects: number[][]; label?: string; out?: number[][] } | null }
68
+ | { readonly name?: string; readonly kind: 'order'; route(ctx: { names: readonly string[]; options: Readonly<Record<string, unknown>> }): FocusRoute };
61
69
 
62
70
  export interface FocusDeck {
63
71
  readonly pieces: readonly FocusPiece[];
@@ -89,6 +97,10 @@ export interface FocusPieceLook {
89
97
  readonly from: FocusMove | null;
90
98
  /** It stepped aside for its lit twin. */
91
99
  readonly twinOff: boolean;
100
+ /** Stepping aside for a lit twin on a path: the seconds until that twin's turn, when it goes (the first, for several); null when it goes at once (a lit twin off the route) or does not step aside on this click. */
101
+ readonly twinTurn: number | null;
102
+ /** On a path: the seconds its turn waits (0 for the first); null when it takes no turn — not on the route, or not lit. */
103
+ readonly path: number | null;
92
104
  }
93
105
 
94
106
  export interface FocusLook {
@@ -97,6 +109,8 @@ export interface FocusLook {
97
109
  readonly stage: { readonly move: { s: number; x: number; y: number } | null; readonly from: { s: number; x: number; y: number } | null } | null;
98
110
  /** The blur: 'in-new' when the last click had none, 'in' while it stays, 'out' as it melts away; frameNew when its frame moved or appeared. */
99
111
  readonly overlay: { readonly rects: readonly (readonly number[])[]; readonly label: string; readonly phase: 'in-new' | 'in' | 'out'; readonly frameNew: boolean } | null;
112
+ /** A path: how many turns, the seconds between two, when the last starts (done), and its line ('' for none) and where it goes (null: the area's bottom left); null when no lit piece took a turn. */
113
+ readonly path: { readonly turns: number; readonly step: number; readonly done: number; readonly line: string; readonly lineAt: string | null } | null;
100
114
  readonly strategies: readonly string[];
101
115
  /** The subject where it ends up, on the slide. */
102
116
  readonly subject: FocusBox | null;
@@ -105,7 +119,7 @@ export interface FocusLook {
105
119
  /** The state a click leaves, to start the next from. Opaque. */
106
120
  export type FocusState = { readonly __focusState: true } & Record<string, unknown>;
107
121
 
108
- export const FOCUS: Readonly<Record<'grey' | 'hide' | 'keep' | 'zoom' | 'left' | 'right' | 'up' | 'down' | 'place' | 'blur', FocusStrategy>>;
122
+ export const FOCUS: Readonly<Record<'grey' | 'hide' | 'keep' | 'zoom' | 'left' | 'right' | 'up' | 'down' | 'place' | 'path' | 'blur', FocusStrategy>>;
109
123
  export function readFocus(spec: FocusSpec | undefined | null, shared?: Readonly<Record<string, unknown>>): { strategy: string; options: Record<string, unknown> }[];
110
124
  export function focusStep(prev: FocusState | null, click: FocusClick, deck: FocusDeck): { state: FocusState; look: FocusLook };
111
125
  /** `end` is null only when there are no clicks and no `start`. */
package/focus.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // A slide of this kind is drawn once: a set of named PIECES that keep their places (a diagram, a page,
4
4
  // a timeline). Each CLICK says which pieces are there and which ones it is about — its SUBJECT; the
5
- // rest of what is shown is the CONTEXT. A FOCUS STRATEGY says how the two are shown. There are three
5
+ // rest of what is shown is the CONTEXT. A FOCUS STRATEGY says how the two are shown. There are four
6
6
  // kinds, and a click takes one of each at most:
7
7
  //
8
8
  // look how the context looks grey (greyed in place — the default) · hide · keep
@@ -13,6 +13,9 @@
13
13
  // art-direct, still animated from wherever the last click left it)
14
14
  // overlay what lies over the slide blur (everything outside the subject's frame is blurred; a frame,
15
15
  // and a label if given, on the subject)
16
+ // order how the subject comes on all at once (the default) · path (piece after piece along a route —
17
+ // a request travelling node, wire, node — each lighting in turn, with
18
+ // a line that names the route if given)
16
19
  //
17
20
  // So `'left blur'` is: the context steps aside to the left, greyed, under a blur, and the subject grows on
18
21
  // the right inside a frame. A deck can bring strategies of its own (`strategies`), of any kind.
@@ -25,7 +28,7 @@
25
28
  // slide (p' = s·p + [x, y]; the identity unless the map is drawn scaled or shifted); `area` is the part
26
29
  // of the slide (slide pixels) where moved things may go — below a headline, above a footer.
27
30
 
28
- const KINDS = ['look', 'mover', 'overlay'];
31
+ const KINDS = ['look', 'mover', 'overlay', 'order'];
29
32
 
30
33
  /** A box {x, y, w, h} around a list of boxes (null for none). */
31
34
  function union(boxes) {
@@ -111,6 +114,21 @@ export const FOCUS = Object.freeze({
111
114
  return {names, g: {o, s, t: [dx / view.s, dy / view.s]}};
112
115
  })};
113
116
  }}),
117
+ /**
118
+ * The subject comes on piece after piece along a route, as a request travels it: `route` names the pieces in
119
+ * order (default — and for an empty `route` too — the click's `on`, or its `in`, in the order written), each lit
120
+ * one lights `step` (0.35) s after the one before and glows as it is reached; `line` is text that names the route
121
+ * ("agent → MCP → gateway"), shown when the route is done, at `lineAt` (x y, slide px; default the area's bottom
122
+ * left). A lit piece not on the route lights at once. A route still empty (`on: ['*']` names no order) is refused.
123
+ */
124
+ path: Object.freeze({name: 'path', kind: 'order', route({names, options}) {
125
+ const step = num(options.step, .35);
126
+ if (!(step >= 0 && step <= 5)) throw new Error(`focus "path": step is the seconds between two pieces of the route (0–5), not "${options.step}"`);
127
+ const route = options.route === undefined || options.route === null ? [] : [].concat(options.route).flatMap(x => String(x).trim().split(/\s+/)).filter(x => x && x !== '*');
128
+ const order = route.length ? route : names;
129
+ if (!order.length) throw new Error('focus "path": the route is empty — write the pieces in the order they light, in `on` or `route` (\'*\' names no order)');
130
+ return {names: order, step, line: options.line ?? '', lineAt: options.lineAt ?? null};
131
+ }}),
114
132
  /**
115
133
  * Everything outside the frame is blurred. The frame is `rect` (slide px, x y w h; several are fine — the
116
134
  * first gets the frame and the label, the others are clear holes too) or, without one, the subject where it
@@ -145,13 +163,29 @@ function pick(focus, strategies) {
145
163
  for (const {strategy, options} of focus) {
146
164
  const s = all[strategy];
147
165
  if (!s) throw new Error(`no focus strategy "${strategy}" (there are ${Object.keys(all).join(', ')})`);
148
- if (!KINDS.includes(s.kind)) throw new Error(`focus strategy "${strategy}" has kind ${JSON.stringify(s.kind)}: a strategy is a look, a mover or an overlay`);
166
+ if (!KINDS.includes(s.kind)) throw new Error(`focus strategy "${strategy}" has kind ${JSON.stringify(s.kind)}: a strategy is a look, a mover, an overlay or an order`);
149
167
  if (byKind[s.kind]) throw new Error(`a click takes one ${s.kind} at most: "${byKind[s.kind].name}" and "${strategy}" are both ${s.kind}s`);
150
168
  byKind[s.kind] = {s: {...s, name: s.name ?? strategy}, options, name: strategy};
151
169
  }
152
170
  return byKind;
153
171
  }
154
172
 
173
+ /**
174
+ * What an order's `route()` gave, checked like its kind — a deck's own order is held to what the built-in gives —
175
+ * and read into a new object (the strategy's own is never written to): line '' and lineAt null when it gives none.
176
+ */
177
+ function readRoute(r, strategy) {
178
+ const shown = v => (typeof v === 'number' || v === undefined ? String(v) : JSON.stringify(v));
179
+ const refuse = fix => { throw new Error(`focus "${strategy}": ${fix}`); };
180
+ if (!r || typeof r !== 'object') refuse(`route() gives {names, step, line?, lineAt?}, not ${shown(r)}`);
181
+ const {names, step, line, lineAt} = r;
182
+ if (!Array.isArray(names) || names.some(n => typeof n !== 'string')) refuse(`a route's names are a list of piece names, in the order they light — not ${shown(names)}`);
183
+ if (typeof step !== 'number' || !Number.isFinite(step) || step < 0 || step > 5) refuse(`a route's step is the seconds between two turns, a number from 0 to 5 — not ${shown(step)}`);
184
+ if (line !== undefined && typeof line !== 'string') refuse(`a route's line is text that names it, or left out — not ${shown(line)}`);
185
+ if (lineAt !== undefined && lineAt !== null && typeof lineAt !== 'string') refuse(`a route's lineAt is 'x y' in slide px, null, or left out — not ${shown(lineAt)}`);
186
+ return {names: [...names], step, line: line ?? '', lineAt: lineAt?.trim() ? lineAt : null}; // (a blank lineAt, an empty data-focus-line-at, is none)
187
+ }
188
+
155
189
  /** Where a group move puts one piece: its reference point (the centre, or the top-left when it has no size) moves by [dx, dy], and it scales by s around it. */
156
190
  function placePiece(g, box) {
157
191
  if (!g || !box) return null;
@@ -182,7 +216,9 @@ function changeOf(st, was, look, wasLook, entering, before) {
182
216
  * One click. `prev` is the state the last click left (null on a slide's first click when the map arrives
183
217
  * fresh). `deck`: {pieces: [{keys, box, twin?}], strategies?, area?, view?, canvas?}. Returns {state, look}:
184
218
  * the state to hand the next click, and the look to draw — per piece {state, was, change, look, fromLook, hot,
185
- * hotIn, move, from, twinOff}, the stage's move, the overlay, and which strategies were used.
219
+ * hotIn, move, from, twinOff, twinTurn (stepping aside for a lit twin on a route: the seconds until that twin's
220
+ * turn; else null), path (a lit piece on a route: the seconds until its turn; else null)}, the stage's move, the
221
+ * overlay, the path ({turns, step, done, line, lineAt}, or null) and which strategies were used.
186
222
  */
187
223
  export function focusStep(prev, step, deck) {
188
224
  const {pieces, strategies = {}} = deck, canvas = deck.canvas ?? {w: 1920, h: 1080};
@@ -196,7 +232,8 @@ export function focusStep(prev, step, deck) {
196
232
  pieces.forEach((_, i) => { if (has(i, ins)) vis.add(i); if (has(i, outs)) vis.delete(i); });
197
233
  const states = pieces.map((_, i) => !vis.has(i) ? 'gone' : !on || has(i, on) || (!step.quiet && has(i, ins)) ? 'on' : 'dim');
198
234
  // A piece with a twin (a dashed wire under a solid one) steps aside while its twin is lit, so the two never show at once.
199
- const twinOff = new Set(pieces.map((_, i) => i).filter(i => pieces[i].twin?.length && states[i] !== 'gone' && pieces.some((q, j) => states[j] === 'on' && j !== i && pieces[i].twin.some(t => q.keys.includes(t)))));
235
+ const litTwins = pieces.map((p, i) => p.twin?.length ? pieces.map((_, j) => j).filter(j => j !== i && states[j] === 'on' && p.twin.some(t => pieces[j].keys.includes(t))) : []);
236
+ const twinOff = new Set(pieces.map((_, i) => i).filter(i => states[i] !== 'gone' && litTwins[i].length));
200
237
  twinOff.forEach(i => { states[i] = 'gone'; });
201
238
  const chosen = pick(readFocus(step.focus, step.options), strategies);
202
239
  const look = chosen.look?.s.look ?? 'grey';
@@ -222,6 +259,21 @@ export function focusStep(prev, step, deck) {
222
259
  const subjectOnSlide = ends ? {x: ends.x * view.s + view.x, y: ends.y * view.s + view.y, w: ends.w * view.s, h: ends.h * view.s} : null;
223
260
  const cover = chosen.overlay ? chosen.overlay.s.cover({subjectOnSlide, canvas, options: chosen.overlay.options}) : null;
224
261
  const looks = pieces.map((_, i) => states[i] === 'dim' ? look : null);
262
+ // An order: the subject piece after piece along a route. The LIT pieces of each name on it take the next turn
263
+ // together — a name with nothing lit takes none, so a greyed piece never waits or glows grey; a piece named twice
264
+ // keeps its first turn.
265
+ const order = chosen.order ? readRoute(chosen.order.s.route({names: (on ?? ins).filter(x => x !== '*'), options: chosen.order.options}), chosen.order.name) : null;
266
+ const turnOf = new Map();
267
+ let turns = 0;
268
+ for (const name of order ? known(order.names, 'focus route: ') : []) {
269
+ const reached = all.filter(i => states[i] === 'on' && pieces[i].keys.includes(name) && !turnOf.has(i));
270
+ reached.forEach(i => turnOf.set(i, turns));
271
+ if (reached.length) turns += 1;
272
+ }
273
+ const delayOf = i => (turnOf.has(i) ? round(turnOf.get(i) * order.step, 3) : null);
274
+ // A piece stepping aside for a lit twin on the route stays until that twin's turn (the first of them to come), then
275
+ // goes — so the wire never goes missing while its twin waits; a lit twin off the route lights at once, and so it goes.
276
+ const twinTurn = i => (litTwins[i].length && litTwins[i].every(j => turnOf.has(j)) ? Math.min(...litTwins[i].map(delayOf)) : null);
225
277
  // What changed since the last click: only that moves. A piece that leaves keeps how it looked and where it
226
278
  // stood, so it fades out from there instead of jumping home in colour first.
227
279
  const before = prev && !step.fresh ? prev : null;
@@ -233,7 +285,8 @@ export function focusStep(prev, step, deck) {
233
285
  const from = stood, moved = st !== 'gone' && was !== 'gone' && !!before && !sameMove(from, moves[i]);
234
286
  return {state: st, was, change, look: leaving ? wasLook : looks[i], fromLook: change === 'relook' ? wasLook : null,
235
287
  hot: hotNow.has(i), hotIn: hotNow.has(i) && (!before || !before.hot.has(i)),
236
- move: leaving ? stood : st === 'gone' ? null : moves[i], from: moved ? from ?? {dx: 0, dy: 0, s: 1} : null, twinOff: twinOff.has(i)};
288
+ move: leaving ? stood : st === 'gone' ? null : moves[i], from: moved ? from ?? {dx: 0, dy: 0, s: 1} : null, twinOff: twinOff.has(i),
289
+ twinTurn: leaving && twinOff.has(i) ? twinTurn(i) : null, path: delayOf(i)};
237
290
  });
238
291
  const stageMoved = !!before && !sameStage(before.stage, stage);
239
292
  // The blur: new only when the last click had none (a blur that moves to another subject stays, and only its
@@ -243,6 +296,7 @@ export function focusStep(prev, step, deck) {
243
296
  return {
244
297
  state: {vis, states, moves, stage, hot: hotNow, cover, looks},
245
298
  look: {pieces: out, stage: stage || stageMoved ? {move: stage, from: stageMoved ? before.stage ?? {s: 1, x: 0, y: 0} : null} : null, overlay,
299
+ path: turns ? {turns, step: order.step, done: round((turns - 1) * order.step, 3), line: order.line, lineAt: order.lineAt} : null,
246
300
  strategies: Object.values(chosen).map(c => c.name), subject: subjectOnSlide},
247
301
  };
248
302
  }
package/focusHtml.d.ts CHANGED
@@ -7,13 +7,17 @@ import type { FocusClick, FocusLook, FocusPiece } from './focus';
7
7
  export function readPieces(html: string, options?: { attr?: string }): { html: string; pieces: FocusPiece[] };
8
8
  /** A click written as attributes (data-in, data-out, data-on, data-hot, data-quiet, data-focus, data-focus-* → camelCased options). */
9
9
  export function readStep(attrs: string): FocusClick;
10
- /** That HTML, each piece dressed for this click's look (classes from focus.css, its move as CSS variables). */
10
+ /**
11
+ * That HTML, each piece dressed for this click's look (classes from focus.css, its move as CSS variables). On a path, a
12
+ * piece's moment rides as `--sd-path-delay`: its turn (`sd-path`), or, stepping aside for its lit twin, that twin's
13
+ * (`sd-twin-turn`); focus.css adds .4 s under a new blur. A click without a path is drawn exactly as before.
14
+ */
11
15
  export function drawFocus(html: string, look: FocusLook): string;
12
16
  /** The pieces inside the stage the view moves (a zoom); unchanged when nothing moves the view. Put it in the map's own box. */
13
17
  export function wrapStage(inner: string, look: FocusLook): string;
14
18
  /** Classes for the slide around a click: 'sd-focus-new' while its blur comes in, else ''. */
15
19
  export function slideClass(look: FocusLook): string;
16
- /** The click's blur, holes and labelled frame, in slide px — outside the map's box (empty when it has none). */
20
+ /** The overlay of a click in slide px — the blur, its frame and label, and a path's line (once its route is done, .4 s later under a new blur) — put outside the map's box; '' when the click has none. */
17
21
  export function focusOverlay(look: FocusLook, options?: { canvas?: { w: number; h: number }; area?: readonly [number, number, number, number] }): string;
18
22
  /** Runs in the page; call it once, at any time: only a forward click animates; a jump or a step back lands at once. */
19
23
  export function focusRuntime(): void;
package/focusHtml.js CHANGED
@@ -8,7 +8,8 @@
8
8
  //
9
9
  // Pieces are read from their inline style (left/top/width/height in px, or 0; an <svg>'s width/height
10
10
  // attributes too): a piece without left/top keeps its place whatever the strategy, and one without a
11
- // size moves by its top-left corner. data-twin="name …" makes a piece step aside while its twin is lit.
11
+ // size moves by its top-left corner. data-twin="name …" makes a piece step aside while its twin is lit (on a
12
+ // path, at that twin's turn).
12
13
  // focus.css draws the classes; call focusRuntime() once in the page, so only a forward click animates.
13
14
 
14
15
  const PIECE = attr => new RegExp(`<([a-zA-Z][\\w-]*)\\b([^>]*?)\\s${attr}=(["'])(.*?)\\3([^>]*)>`, 'g');
@@ -69,9 +70,13 @@ function pieceLook(p) {
69
70
  if (p.hot) cls.push('sd-hot');
70
71
  if (p.hotIn) cls.push('sd-hot-in');
71
72
  if (p.twinOff) cls.push('sd-twin-off');
73
+ const onPath = p.path !== null && p.path !== undefined, twinTurn = p.twinTurn !== null && p.twinTurn !== undefined;
74
+ if (twinTurn) cls.push('sd-twin-turn'); // it steps aside at its lit twin's turn on the route
75
+ if (onPath) cls.push('sd-path');
72
76
  const vars = [];
73
77
  if (p.move) { cls.push('sd-moved'); vars.push(`--sd-dx:${p.move.dx}px`, `--sd-dy:${p.move.dy}px`, `--sd-s:${p.move.s}`); }
74
78
  if (p.from) { cls.push('sd-move'); vars.push(`--sd-dx0:${p.from.dx}px`, `--sd-dy0:${p.from.dy}px`, `--sd-s0:${p.from.s}`); }
79
+ if (onPath || twinTurn) vars.push(`--sd-path-delay:${onPath ? p.path : p.twinTurn}s`); // its moment on the route: its turn, or its twin's
75
80
  // The point it scales around, inline, so no stylesheet's transform-origin can move it off the planned place.
76
81
  const ref = p.move ?? p.from;
77
82
  if (ref) { if (ref.origin === 'top-left') cls.push('sd-origin-tl'); vars.push(`transform-origin:${ref.origin === 'top-left' ? '0 0' : '50% 50%'}`); }
@@ -124,19 +129,28 @@ function veilPath([x0, y0, x1, y1], rects) {
124
129
  return runs.join(' ');
125
130
  }
126
131
 
132
+ /** A path's line — the text naming its route — in slide pixels, shown once the route is done (its moment is `done`). */
133
+ function pathLine(look, [x0, , , y1]) {
134
+ const p = look.path;
135
+ if (!p?.line) return '';
136
+ const at = p.lineAt ? String(p.lineAt).trim().split(/[\s,]+/).map(Number) : [x0 + 24, y1 - 56];
137
+ if (at.length !== 2 || at.some(n => !Number.isFinite(n))) throw new Error(`focus "path": lineAt is x y in slide px, not "${p.lineAt}"`);
138
+ return `<div class="sd-path-line" style="left:${at[0]}px;top:${at[1]}px;--sd-path-delay:${p.done}s">${esc(p.line)}</div>`;
139
+ }
140
+
127
141
  /**
128
- * The overlay of a click — a blur with holes, a frame and its label — in slide pixels: put it outside the
129
- * map's box. `area` ([x0, y0, x1, y1]) limits the blur, so a headline above it stays clear. Empty when the
130
- * click has none.
142
+ * The overlay of a click — a blur with holes, a frame and its label, and a path's line — in slide pixels: put
143
+ * it outside the map's box. `area` ([x0, y0, x1, y1]) limits the blur, so a headline above it stays clear.
144
+ * Empty when the click has none.
131
145
  */
132
146
  export function focusOverlay(look, {canvas = {w: 1920, h: 1080}, area} = {}) {
133
- const o = look.overlay;
134
- if (!o || !o.rects.length) return '';
147
+ const o = look.overlay, line = pathLine(look, area ?? [0, 0, canvas.w, canvas.h]);
148
+ if (!o || !o.rects.length) return line;
135
149
  const path = veilPath(area ?? [0, 0, canvas.w, canvas.h], o.rects);
136
150
  const veil = `<div class="sd-veil sd-veil-${o.phase}" style="width:${canvas.w}px;height:${canvas.h}px;clip-path:path(nonzero,'${path}')"></div>`;
137
- if (o.phase === 'out') return veil;
151
+ if (o.phase === 'out') return veil + line;
138
152
  const [x, y, w, h] = o.rects[0];
139
- return `${veil}<div class="sd-frame${o.frameNew ? ' sd-new' : ''}" style="left:${x}px;top:${y}px;width:${w}px;height:${h}px">${o.label ? `<span>${esc(o.label)}</span>` : ''}</div>`;
153
+ return `${veil}<div class="sd-frame${o.frameNew ? ' sd-new' : ''}" style="left:${x}px;top:${y}px;width:${w}px;height:${h}px">${o.label ? `<span>${esc(o.label)}</span>` : ''}</div>${line}`;
140
154
  }
141
155
 
142
156
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "footprint-storydeck",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "One source, many lenses — render structured content as a Read article, a Scroll (scrollytelling) story, or a Watch deck.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: storydeck
3
- description: Build, focus, narrate and film talks and posts with StoryDeck — one source shown as a Read article, a Scroll story or a Watch deck; per-click focus on a diagram (grey, zoom, left/right, place, blur); narration from the speaker notes (a clip per click in a voice, cached by its words; notes, presenter and listen mode in the page; captions and chapters); and a narrated video for YouTube. Use when creating or editing a StoryDeck post or deck, adding focus to a diagram slide, voicing a talk, or rendering a talk as a video.
3
+ description: Build, focus, narrate and film talks and posts with StoryDeck — one source shown as a Read article, a Scroll story or a Watch deck; per-click focus on a diagram (grey, zoom, left/right, place, blur, path); narration from the speaker notes (a clip per click in a voice, cached by its words; notes, presenter and listen mode in the page; captions and chapters); and a narrated video for YouTube. Use when creating or editing a StoryDeck post or deck, adding focus to a diagram slide, voicing a talk, or rendering a talk as a video.
4
4
  ---
5
5
 
6
6
  # StoryDeck: one source, many lenses — and a deck that tells itself
@@ -48,10 +48,15 @@ const { looks } = planFocus([
48
48
  ], { pieces, area });
49
49
  const slides = looks.map(look => `<section class="${slideClass(look)}"><div class="map">${wrapStage(drawFocus(html, look), look)}</div>${focusOverlay(look, { area })}</section>`);
50
50
  // in the page, once: `(${focusRuntime})()` — only a forward click animates; a jump lands at once
51
+ // A route (who calls whom): `{ on: ['agent', 'link-mcp', 'mcp', 'link-gw', 'gw'], focus: 'path', options: { line: 'agent → MCP → gateway' } }`
52
+ // lights it piece after piece, wires included, in the order written — lit pieces only; `on: ['*']` names no order and is refused.
51
53
  ```
52
54
 
53
55
  Strategies, one of each kind at most: look `grey` (default) · `hide` · `keep`; mover `zoom` · `left` ·
54
- `right` · `up` · `down` · `place` (named groups moved by amounts you art-direct); overlay `blur`.
56
+ `right` · `up` · `down` · `place` (named groups moved by amounts you art-direct); overlay `blur`; order `path` (the
57
+ subject lit piece after piece along a route, a `line` naming it; write `on` — or `route` — in the order the pieces
58
+ light: only lit pieces take a turn, a ring and a dashed twin keep their piece's turn, and a new blur delays the
59
+ whole path .4 s).
55
60
 
56
61
  **Narrate a deck in a voice.**
57
62