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 +24 -0
- package/README.md +16 -1
- package/focus.css +28 -4
- package/focus.d.ts +17 -3
- package/focus.js +60 -6
- package/focusHtml.d.ts +6 -2
- package/focusHtml.js +22 -8
- package/package.json +1 -1
- package/plugin/skills/storydeck/SKILL.md +7 -2
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.
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
129
|
-
* map's box. `area` ([x0, y0, x1, y1]) limits the blur, so a headline above it stays clear.
|
|
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: 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
|
|