wand-kit 0.2.0 → 0.3.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/PROMPT.md +86 -5
- package/SKILL.md +41 -4
- package/START-HERE.md +6 -0
- package/examples/storyboard/job.mjs +72 -0
- package/examples/storyboard/script.txt +6 -0
- package/examples/talk/agents-demo.html +69 -0
- package/examples/talk/agents-demo.poster-next.png +0 -0
- package/examples/talk/agents-demo.poster.png +0 -0
- package/examples/talk/job.mjs +85 -0
- package/lib/board.js +152 -0
- package/lib/brand.js +3 -0
- package/lib/components.js +151 -5
- package/lib/engine.js +32 -6
- package/lib/html_export.js +35 -4
- package/lib/runtime_check.js +108 -0
- package/lib/talk.js +620 -0
- package/package.json +2 -2
- package/templates/storyboard.mjs +33 -0
- package/wand.js +52 -9
package/lib/talk.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/talk.js — the runtime a slide deck carries in its one-file HTML.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the BofA talk (runs/2026-10-07-bofa-agentic-age-html/src/deck.js
|
|
5
|
+
* and deck.css), generalised from that deck's markup to the studio's `.page`
|
|
6
|
+
* and from its colours to the brand's: the controls read `--ui-*`, which
|
|
7
|
+
* brand.js writes on :root from the brand's dark roles, so the same runtime
|
|
8
|
+
* dresses itself in either brand.
|
|
9
|
+
*
|
|
10
|
+
* What it adds to the pages, none of which the PDF, the PNGs or the .pptx see
|
|
11
|
+
* (they are read from the static render):
|
|
12
|
+
* - a crossfade between slides, the slide being left kept under the next;
|
|
13
|
+
* - entrances: `data-a` (rise, fade, pop, grow, widen, draw, count) with `--i` as
|
|
14
|
+
* the stagger, played going forward; with `motion: "auto"` a slide that
|
|
15
|
+
* marks nothing has its blocks staggered in for it;
|
|
16
|
+
* - steps: `data-step="n"` holds an element back until the presenter moves
|
|
17
|
+
* on, a group per number; going back undoes the last group;
|
|
18
|
+
* - media: a `.media.video` plays while its slide is on, a `.media.demo`
|
|
19
|
+
* mounts its page in a frame (the next one preloads), posters until then;
|
|
20
|
+
* - the presenter's tools: the dock, the talk clock and its pace against
|
|
21
|
+
* each slide's `seconds`, speaker notes (N), the overview (G), the
|
|
22
|
+
* presenter view in a second window kept in step by postMessage (P),
|
|
23
|
+
* full screen (F), blank screen (B), shortcuts (?), a number then Enter
|
|
24
|
+
* to jump, click and swipe to move, `#n` deep links, print as pages.
|
|
25
|
+
* Reduced motion turns every entrance and transition off.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const ICON = {
|
|
29
|
+
prev: '<path d="M15 18l-6-6 6-6"/>',
|
|
30
|
+
next: '<path d="M9 18l6-6-6-6"/>',
|
|
31
|
+
grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
|
32
|
+
more: '<circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/>',
|
|
33
|
+
play: '<path d="M7 4.5v15l12-7.5z"/>',
|
|
34
|
+
pause: '<path d="M8 4.5v15M16 4.5v15"/>',
|
|
35
|
+
reset: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
|
|
36
|
+
};
|
|
37
|
+
const svg = (k) => `<svg viewBox="0 0 24 24" aria-hidden="true">${ICON[k]}</svg>`;
|
|
38
|
+
|
|
39
|
+
const KEYS = [
|
|
40
|
+
["Next, or the next step", ["→", "Space", "PgDn"]], ["Back", ["←", "PgUp"]], ["First, last", ["Home", "End"]],
|
|
41
|
+
["Go to a slide", ["3", "Enter"]], ["All slides", ["G"]], ["Speaker notes", ["N"]], ["Presenter view", ["P"]],
|
|
42
|
+
["Full screen", ["F"]], ["Blank the screen", ["B"]], ["Talk clock", ["T"]], ["Play or pause media", ["D"]], ["Restart media", ["R"]],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** The controls around the stage. */
|
|
46
|
+
export function talkShell(total) {
|
|
47
|
+
return {
|
|
48
|
+
before: `<div class="viewport"><div class="stage-cell"><div class="stage" role="region" aria-roledescription="slide deck">`,
|
|
49
|
+
after: `</div></div>
|
|
50
|
+
<aside class="notes-panel" hidden aria-label="Speaker notes">
|
|
51
|
+
<div class="notes-head"><h2></h2><div class="window"></div></div>
|
|
52
|
+
<div class="notes-text"></div>
|
|
53
|
+
<div class="notes-next"><span></span><div class="mini"></div></div>
|
|
54
|
+
</aside>
|
|
55
|
+
<aside class="presenter-side" hidden aria-label="Presenter">
|
|
56
|
+
<div class="p-times"><div><span>Talk clock</span><b class="p-elapsed">00:00</b></div><div><span>Pace</span><b class="p-pace">Not started</b></div><div><span>Time</span><b class="p-clock">--:--</b></div></div>
|
|
57
|
+
<div class="p-next"><span></span><div class="mini"></div></div>
|
|
58
|
+
<div class="p-notes"></div>
|
|
59
|
+
<div class="p-ctl">
|
|
60
|
+
<button class="ib" type="button" data-p="prev">${svg("prev")} Back</button>
|
|
61
|
+
<button class="ib" type="button" data-p="next">Next ${svg("next")}</button>
|
|
62
|
+
<button class="ib" type="button" data-p="clock">${svg("play")} Start or pause clock</button>
|
|
63
|
+
<button class="ib" type="button" data-p="reset-clock">${svg("reset")} Reset clock</button>
|
|
64
|
+
<button class="ib p-media" type="button" data-p="media" hidden>${svg("play")} Play or pause media</button>
|
|
65
|
+
</div>
|
|
66
|
+
</aside>
|
|
67
|
+
</div>
|
|
68
|
+
<nav class="dock" aria-label="Deck controls">
|
|
69
|
+
<button class="ib" type="button" data-ui="prev" aria-label="Back">${svg("prev")}</button>
|
|
70
|
+
<span class="counter" aria-hidden="true"><b>01</b> <i>/ ${String(total).padStart(2, "0")}</i></span>
|
|
71
|
+
<button class="ib" type="button" data-ui="next" aria-label="Next">${svg("next")}</button>
|
|
72
|
+
<span class="sep"></span>
|
|
73
|
+
<button class="clock" type="button" data-ui="clock" aria-label="Talk clock: start or pause (T)"><span class="dot"></span><b>00:00</b></button>
|
|
74
|
+
<span class="media-ctl" hidden><button class="ib" type="button" data-ui="media" aria-label="Play or pause media (D)">${svg("play")}</button><button class="ib" type="button" data-ui="media-reset" aria-label="Restart media (R)">${svg("reset")}</button></span>
|
|
75
|
+
<button class="ib" type="button" data-ui="overview" aria-label="All slides (G)">${svg("grid")}</button>
|
|
76
|
+
<span class="more">
|
|
77
|
+
<button class="ib" type="button" data-ui="more" aria-haspopup="menu" aria-expanded="false" aria-label="More">${svg("more")}</button>
|
|
78
|
+
<span class="menu" role="menu" hidden>
|
|
79
|
+
<button type="button" role="menuitem" data-ui="notes">Speaker notes <kbd>N</kbd></button>
|
|
80
|
+
<button type="button" role="menuitem" data-ui="presenter">Presenter view <kbd>P</kbd></button>
|
|
81
|
+
<button type="button" role="menuitem" data-ui="full">Full screen <kbd>F</kbd></button>
|
|
82
|
+
<button type="button" role="menuitem" data-ui="help">Shortcuts <kbd>?</kbd></button>
|
|
83
|
+
</span>
|
|
84
|
+
</span>
|
|
85
|
+
</nav>
|
|
86
|
+
<div class="progress" aria-hidden="true"><i></i></div>
|
|
87
|
+
<div class="overlay overview" role="dialog" aria-modal="true" aria-label="All slides" hidden>
|
|
88
|
+
<header><h2>All slides</h2><span class="hint">Arrows to move · Enter to open · G or Esc to close</span></header>
|
|
89
|
+
<div class="thumbs"></div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="overlay help" role="dialog" aria-modal="true" aria-label="Shortcuts" hidden>
|
|
92
|
+
<div class="help-card"><h2>Shortcuts</h2><p>Click the slide to move on; Shift-click to go back.</p>
|
|
93
|
+
<dl class="keys">${KEYS.map(([d, ks]) => `<div><dt>${d}</dt><dd>${ks.map((k) => `<kbd>${k}</kbd>`).join("")}</dd></div>`).join("")}</dl></div>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="blackout" hidden></div>
|
|
96
|
+
<div id="announce" class="sr-only" aria-live="polite"></div>`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const TALK_CSS = `<style id="wand-talk">
|
|
101
|
+
:root{--ease:cubic-bezier(.16,1,.3,1);--ease-io:cubic-bezier(.65,0,.35,1);--t-fast:160ms;--t-mid:320ms;
|
|
102
|
+
--ui-glass:color-mix(in srgb,var(--ui-bg) 86%,transparent);--ui-line:color-mix(in srgb,var(--ui-stroke) 60%,transparent);--ui-shadow:color-mix(in srgb,var(--ui-bg) 75%,transparent);color-scheme:dark}
|
|
103
|
+
html,body{margin:0;height:100%}
|
|
104
|
+
body.talk-on{background:var(--ui-bg);color:var(--ui-text);font-family:var(--ui-face),system-ui,sans-serif;-webkit-font-smoothing:antialiased;overflow:hidden}
|
|
105
|
+
body.talk-on button{font:inherit;color:inherit}
|
|
106
|
+
body.talk-on :focus-visible{outline:3px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
|
|
107
|
+
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
108
|
+
[hidden]{display:none!important}
|
|
109
|
+
|
|
110
|
+
/* the stage: the pages at their own size, scaled to the window by --k */
|
|
111
|
+
.talk-on .viewport{position:fixed;inset:0;display:grid;grid-template-columns:1fr;transition:grid-template-columns var(--t-mid) var(--ease)}
|
|
112
|
+
body.notes-open .viewport{grid-template-columns:1fr min(440px,34vw)}
|
|
113
|
+
.talk-on .stage-cell{container-type:size;display:grid;place-items:center;min-width:0}
|
|
114
|
+
.talk-on .stage{position:relative;width:min(100cqw,100cqh * var(--ar));aspect-ratio:var(--ar);overflow:hidden;box-shadow:0 0 0 1px var(--ui-line)}
|
|
115
|
+
.talk-on .stage>.page,.mini>.page{position:absolute;left:0;top:0;transform-origin:0 0;transform:scale(var(--k,1))}
|
|
116
|
+
.talk-on .stage>.page{opacity:0;visibility:hidden;z-index:0;transition:opacity 340ms var(--ease-io),visibility 0s linear 340ms}
|
|
117
|
+
/* a true crossfade: the slide being left stays opaque under the next until it has faded in */
|
|
118
|
+
.talk-on .stage>.page.is-current{opacity:1;visibility:visible;z-index:2;transition:opacity 340ms var(--ease-io),visibility 0s}
|
|
119
|
+
.talk-on .stage>.page.is-leaving{opacity:1;visibility:visible;z-index:1;transition:none}
|
|
120
|
+
|
|
121
|
+
/* entrances, played going forward */
|
|
122
|
+
.page.play [data-a]{animation:wa-rise 600ms var(--ease) both;animation-delay:calc(min(var(--i,0),12) * 70ms + 60ms)}
|
|
123
|
+
.page.play [data-a="fade"]{animation-name:wa-fade}
|
|
124
|
+
.page.play [data-a="pop"]{animation-name:wa-pop}
|
|
125
|
+
.page.play [data-a="grow"]{animation-name:wa-grow;transform-box:fill-box;transform-origin:center bottom}
|
|
126
|
+
.page.play [data-a="widen"]{animation-name:wa-widen;transform-box:fill-box;transform-origin:left center}
|
|
127
|
+
.page.play [data-a="draw"]{stroke-dasharray:1;animation-name:wa-draw;animation-duration:1100ms}
|
|
128
|
+
.page.play [data-a="count"]{animation-name:wa-fade}
|
|
129
|
+
/* steps: held back on the stage until the presenter moves on */
|
|
130
|
+
.talk-on .stage [data-step]:not(.shown){opacity:0;visibility:hidden}
|
|
131
|
+
.talk-on .stage [data-step].shown{animation:wa-rise 520ms var(--ease) both}
|
|
132
|
+
.talk-on .stage [data-step][data-a="fade"].shown{animation-name:wa-fade}
|
|
133
|
+
.talk-on .stage [data-step][data-a="pop"].shown{animation-name:wa-pop}
|
|
134
|
+
@keyframes wa-rise{from{opacity:0;transform:translateY(18px)}}
|
|
135
|
+
@keyframes wa-fade{from{opacity:0}}
|
|
136
|
+
@keyframes wa-pop{from{opacity:0;transform:scale(.6)}}
|
|
137
|
+
@keyframes wa-grow{from{transform:scaleY(0)}}
|
|
138
|
+
@keyframes wa-widen{from{transform:scaleX(0)}}
|
|
139
|
+
@keyframes wa-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
|
|
140
|
+
|
|
141
|
+
/* media: posters until the slide is on and the media is ready */
|
|
142
|
+
.talk-on .stage .media.video.playing>.poster,.talk-on .stage .media.demo.is-ready>.poster{opacity:0;transition:opacity 400ms var(--ease-io)}
|
|
143
|
+
.talk-on .stage .media.video>video{display:block}
|
|
144
|
+
.talk-on .stage .media.demo>.screen>iframe{position:absolute;left:0;top:0;border:0;transform-origin:0 0;background:transparent}
|
|
145
|
+
|
|
146
|
+
/* the dock: back, where you are, next, the clock, media, the overview, a menu */
|
|
147
|
+
.dock{position:fixed;left:50%;bottom:12px;z-index:30;display:flex;align-items:center;gap:2px;padding:4px;transform:translate(-50%,8px);opacity:0;pointer-events:none;
|
|
148
|
+
background:var(--ui-glass);backdrop-filter:blur(16px) saturate(140%);border:1px solid var(--ui-line);border-radius:12px;box-shadow:0 12px 34px -12px var(--ui-shadow);
|
|
149
|
+
font-size:12.5px;color:var(--ui-text);transition:opacity 220ms var(--ease),transform 220ms var(--ease)}
|
|
150
|
+
body.ui-visible .dock,.dock:focus-within,.dock:hover{opacity:1;transform:translate(-50%,0);pointer-events:auto}
|
|
151
|
+
body.notes-open .dock{left:calc((100vw - min(440px,34vw)) / 2)}
|
|
152
|
+
.dock .sep{width:1px;height:18px;background:var(--ui-line);margin:0 4px}
|
|
153
|
+
.ib{display:inline-grid;place-items:center;width:30px;height:30px;border:0;border-radius:8px;background:transparent;cursor:pointer;color:var(--ui-text);transition:background var(--t-fast),color var(--t-fast)}
|
|
154
|
+
.ib:hover{background:var(--ui-card);color:var(--ui-ink)}
|
|
155
|
+
.ib[aria-pressed="true"],.ib[aria-expanded="true"]{background:var(--ui-card);color:var(--ui-accent)}
|
|
156
|
+
.ib svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
|
|
157
|
+
.counter{padding:0 6px;min-width:56px;text-align:center;font-variant-numeric:tabular-nums}
|
|
158
|
+
.counter b{font-weight:600;color:var(--ui-ink)}
|
|
159
|
+
.counter i{font-style:normal;color:var(--ui-label)}
|
|
160
|
+
.clock{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 8px;border:0;border-radius:8px;background:transparent;cursor:pointer;font-variant-numeric:tabular-nums;color:var(--ui-text)}
|
|
161
|
+
.clock:hover{background:var(--ui-card)}
|
|
162
|
+
.clock b{color:var(--ui-ink);font-weight:600}
|
|
163
|
+
.clock .dot{width:7px;height:7px;border-radius:50%;background:var(--ui-stroke)}
|
|
164
|
+
.clock .dot.on{background:var(--ui-accent)}
|
|
165
|
+
.clock .dot.behind{background:var(--ui-ink);box-shadow:0 0 0 3px color-mix(in srgb,var(--ui-ink) 18%,transparent)}
|
|
166
|
+
.clock.is-paused b{color:var(--ui-label)}
|
|
167
|
+
.media-ctl{display:inline-flex;gap:2px}
|
|
168
|
+
.more{position:relative}
|
|
169
|
+
.menu{position:absolute;right:0;bottom:calc(100% + 8px);min-width:210px;padding:6px;background:var(--ui-card);border:1px solid var(--ui-stroke);border-radius:12px;box-shadow:0 18px 40px -12px var(--ui-shadow);display:grid;animation:wa-fade 160ms var(--ease)}
|
|
170
|
+
.menu button{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:8px 10px;border:0;border-radius:8px;background:transparent;cursor:pointer;font-size:13px;color:var(--ui-text);text-align:left}
|
|
171
|
+
.menu button:hover,.menu button:focus-visible{background:var(--ui-bg);color:var(--ui-ink);outline:none}
|
|
172
|
+
.progress{position:fixed;left:0;right:0;bottom:0;height:3px;z-index:25;background:color-mix(in srgb,var(--ui-stroke) 55%,transparent)}
|
|
173
|
+
.progress i{position:absolute;inset:0 auto 0 0;width:var(--fill,0%);background:var(--ui-accent);transition:width var(--t-mid) var(--ease)}
|
|
174
|
+
body:not(.talk-on) .dock,body:not(.talk-on) .progress{display:none}
|
|
175
|
+
|
|
176
|
+
.overlay{position:fixed;inset:0;z-index:40;display:grid;background:color-mix(in srgb,var(--ui-bg) 92%,transparent);backdrop-filter:blur(10px);animation:wa-fade var(--t-mid) var(--ease)}
|
|
177
|
+
.overview{grid-template-rows:auto 1fr;padding:28px 40px 40px}
|
|
178
|
+
.overview>header{display:flex;align-items:baseline;justify-content:space-between;color:var(--ui-ink)}
|
|
179
|
+
.overview>header>h2{margin:0 0 18px;font-size:18px;font-weight:600}
|
|
180
|
+
.overview>header>.hint{font-size:13px;color:var(--ui-label)}
|
|
181
|
+
.thumbs{overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:22px 18px;align-content:start;padding:4px}
|
|
182
|
+
.thumb{border:0;padding:0;background:none;cursor:pointer;text-align:left;color:var(--ui-text)}
|
|
183
|
+
.mini{position:relative;aspect-ratio:var(--ar);overflow:hidden;border-radius:10px;box-shadow:0 0 0 1px var(--ui-line);transition:box-shadow var(--t-fast),transform var(--t-mid) var(--ease)}
|
|
184
|
+
.thumb:hover .mini,.thumb:focus-visible .mini{transform:translateY(-2px);box-shadow:0 0 0 2px var(--ui-accent)}
|
|
185
|
+
.thumb.is-here .mini{box-shadow:0 0 0 3px var(--ui-accent)}
|
|
186
|
+
.thumb>figure{margin:0}
|
|
187
|
+
.thumb>figure>figcaption{display:flex;gap:8px;align-items:baseline;margin-top:8px;font-size:13px}
|
|
188
|
+
.thumb>figure>figcaption>b{color:var(--ui-ink);font-variant-numeric:tabular-nums}
|
|
189
|
+
.thumb:focus-visible{outline:none}
|
|
190
|
+
.help{place-items:center}
|
|
191
|
+
.help-card{width:min(720px,92vw);padding:30px 34px;background:var(--ui-card);border:1px solid var(--ui-stroke);border-radius:20px;box-shadow:0 30px 80px -20px var(--ui-shadow)}
|
|
192
|
+
.help-card h2{margin:0 0 6px;font-size:20px;color:var(--ui-ink)}
|
|
193
|
+
.help-card p{margin:0 0 20px;font-size:14px;color:var(--ui-label)}
|
|
194
|
+
.keys{display:grid;grid-template-columns:1fr 1fr;gap:10px 34px;margin:0}
|
|
195
|
+
.keys div{display:flex;justify-content:space-between;gap:16px;font-size:14px}
|
|
196
|
+
.keys dt{color:var(--ui-text)}
|
|
197
|
+
.keys dd{margin:0;display:flex;gap:4px}
|
|
198
|
+
kbd{font:600 12px var(--ui-face),sans-serif;padding:3px 7px;border-radius:6px;background:var(--ui-bg);border:1px solid var(--ui-stroke);color:var(--ui-ink);min-width:22px;text-align:center}
|
|
199
|
+
.notes-panel{min-width:0;display:grid;grid-template-rows:auto 1fr auto;gap:16px;padding:26px 26px 20px;background:var(--ui-bg);border-left:1px solid var(--ui-line);overflow:hidden}
|
|
200
|
+
.notes-head>h2{margin:0;font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--ui-label)}
|
|
201
|
+
.notes-head>.window{margin-top:6px;color:var(--ui-ink);font-weight:600;font-size:16px;font-variant-numeric:tabular-nums}
|
|
202
|
+
.notes-text{overflow:auto;font-size:16px;line-height:1.6;color:var(--ui-text);padding-right:6px}
|
|
203
|
+
.notes-text p{margin:0 0 12px}
|
|
204
|
+
.notes-next>span,.p-next>span{font-size:12px;color:var(--ui-label)}
|
|
205
|
+
.notes-next>.mini,.p-next>.mini{margin-top:8px}
|
|
206
|
+
.blackout{position:fixed;inset:0;z-index:50;background:var(--ui-bg);cursor:none}
|
|
207
|
+
.toast{position:fixed;left:50%;top:22px;transform:translateX(-50%);z-index:60;padding:10px 16px;border-radius:12px;background:var(--ui-card);border:1px solid var(--ui-stroke);color:var(--ui-ink);font-size:14px;box-shadow:0 16px 40px -12px var(--ui-shadow);animation:wa-fade var(--t-mid) var(--ease)}
|
|
208
|
+
body.idle,body.idle *{cursor:none!important}
|
|
209
|
+
|
|
210
|
+
/* the presenter's window: the slide, and beside it the clock, the next slide and the notes */
|
|
211
|
+
body.presenter .viewport{grid-template-columns:1.7fr 1fr}
|
|
212
|
+
body.presenter .dock,body.presenter .progress{display:none}
|
|
213
|
+
body.presenter .stage-cell{padding:24px}
|
|
214
|
+
.presenter-side{display:grid;grid-template-rows:auto auto 1fr auto;gap:18px;padding:24px 26px;background:var(--ui-bg);border-left:1px solid var(--ui-line);min-width:0;overflow:hidden}
|
|
215
|
+
.p-times{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
|
|
216
|
+
.p-times div{padding:12px 14px;border-radius:12px;background:var(--ui-card);border:1px solid var(--ui-stroke)}
|
|
217
|
+
.p-times span{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--ui-label)}
|
|
218
|
+
.p-times b{display:block;margin-top:4px;font-size:22px;font-weight:600;color:var(--ui-ink);font-variant-numeric:tabular-nums}
|
|
219
|
+
.p-notes{overflow:auto;font-size:19px;line-height:1.6;color:var(--ui-ink);padding-right:8px}
|
|
220
|
+
.p-notes h3{margin:0 0 8px;font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--ui-label)}
|
|
221
|
+
.p-notes p{margin:0 0 14px}
|
|
222
|
+
.p-ctl{display:flex;gap:6px;flex-wrap:wrap}
|
|
223
|
+
.p-ctl .ib{width:auto;padding:0 14px;gap:8px;display:inline-flex;font-size:14px;background:var(--ui-card)}
|
|
224
|
+
|
|
225
|
+
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:1ms!important;animation-delay:0ms!important;transition-duration:1ms!important}}
|
|
226
|
+
|
|
227
|
+
@media print{
|
|
228
|
+
html,body,body.talk-on{height:auto;overflow:visible;background:none}
|
|
229
|
+
.talk-on .viewport{position:static;display:block}
|
|
230
|
+
.talk-on .stage-cell,.talk-on .stage{display:block;width:auto;aspect-ratio:auto;overflow:visible;box-shadow:none;container-type:normal}
|
|
231
|
+
.talk-on .stage>.page{position:relative;transform:none;opacity:1!important;visibility:visible!important;break-after:page}
|
|
232
|
+
.talk-on .stage [data-step]{opacity:1!important;visibility:visible!important}
|
|
233
|
+
.page *,.page{animation:none!important;transition:none!important}
|
|
234
|
+
.dock,.progress,.overlay,.notes-panel,.presenter-side,.toast,.blackout,.media video,.media iframe{display:none!important}
|
|
235
|
+
.media>.poster{opacity:1!important}
|
|
236
|
+
}
|
|
237
|
+
</style>`;
|
|
238
|
+
|
|
239
|
+
export const TALK_JS = String.raw`(() => {
|
|
240
|
+
"use strict";
|
|
241
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
242
|
+
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
|
243
|
+
const B = document.body, W = +B.dataset.w, H = +B.dataset.h;
|
|
244
|
+
const DATA = JSON.parse($("#deck-data").textContent);
|
|
245
|
+
const stage = $(".stage");
|
|
246
|
+
const slides = $$(".stage > .page");
|
|
247
|
+
const total = slides.length;
|
|
248
|
+
const PRESENTER = new URLSearchParams(location.search).get("view") === "presenter";
|
|
249
|
+
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
250
|
+
const KEY = "wand-talk:" + (DATA.title || "deck") + ":";
|
|
251
|
+
const store = { get(k) { try { return JSON.parse(sessionStorage.getItem(KEY + k)); } catch { return null; } },
|
|
252
|
+
set(k, v) { try { sessionStorage.setItem(KEY + k, JSON.stringify(v)); } catch {} } };
|
|
253
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
254
|
+
const mmss = (s) => { const a = Math.abs(Math.round(s)); return (s < 0 ? "-" : "") + pad(Math.floor(a / 60)) + ":" + pad(a % 60); };
|
|
255
|
+
const escH = (t) => String(t).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
256
|
+
B.classList.add("talk-on");
|
|
257
|
+
document.documentElement.style.setProperty("--ar", W + " / " + H);
|
|
258
|
+
|
|
259
|
+
/* ------------------------------------------------------ the slides' own facts */
|
|
260
|
+
// a slide's name: its heading, a closing's title, then its label or eyebrow
|
|
261
|
+
const titleOf = (s) => { const h = ["h1", "h2", ".lockup .title", ".label", ".eyebrow"].map((q) => $(q, s)).find((e) => e && e.textContent.trim()); return (h ? h.textContent : "").replace(/\s*\|\s*/g, " · ").replace(/\s+/g, " ").trim().slice(0, 80) || "Slide " + s.dataset.n; };
|
|
262
|
+
const META = slides.map((s) => ({ title: titleOf(s), notes: s.dataset.notes || "", seconds: +s.dataset.seconds || 0 }));
|
|
263
|
+
const timed = META.some((m) => m.seconds);
|
|
264
|
+
const plan = META.reduce((a, m, i) => (a.push(a[i] + (m.seconds || 0)), a), [0]);
|
|
265
|
+
const PLANNED = DATA.minutes ? DATA.minutes * 60 : plan[total];
|
|
266
|
+
|
|
267
|
+
/* a slide that marks nothing has its blocks staggered in, when the deck asks */
|
|
268
|
+
const GROUPS = ".cols, .strip, .stats, .steps, .flow, .list, ul, ol";
|
|
269
|
+
if (DATA.motion === "auto") slides.forEach((s) => {
|
|
270
|
+
if (s.dataset.motion === "none" || $('[data-a]:not([data-a="count"]), [data-step]', s)) return;
|
|
271
|
+
const content = $(".content", s); if (!content) return;
|
|
272
|
+
let i = 0;
|
|
273
|
+
const mark = (el) => { if (el.classList.contains("deco") || el.matches("script, style")) return; if (!el.hasAttribute("data-a")) el.setAttribute("data-a", ""); el.style.setProperty("--i", i++); };
|
|
274
|
+
const walk = (el) => { for (const ch of el.children) { if (ch.matches(GROUPS) && ch.children.length > 1) { for (const g of ch.children) mark(g); } else if (ch.matches(".top, .bottom") ) walk(ch); else mark(ch); } };
|
|
275
|
+
walk(content);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/* ------------------------------------------------------------- the scale */
|
|
279
|
+
function fit(box) { const k = box.clientWidth / W; if (k) box.style.setProperty("--k", k); }
|
|
280
|
+
new ResizeObserver(() => { fit(stage); $$(".mini").forEach(fit); }).observe(stage);
|
|
281
|
+
fit(stage);
|
|
282
|
+
|
|
283
|
+
/* -------------------------------------------------------------- the steps */
|
|
284
|
+
const stepsOf = (s) => [...new Set($$("[data-step]", s).map((e) => +e.dataset.step))].sort((a, b) => a - b);
|
|
285
|
+
let shown = 0;
|
|
286
|
+
function reveal(k) {
|
|
287
|
+
const st = stepsOf(slides[cur]); shown = Math.max(0, Math.min(st.length, k));
|
|
288
|
+
$$("[data-step]", slides[cur]).forEach((e) => e.classList.toggle("shown", st.indexOf(+e.dataset.step) < shown));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ------------------------------------------------------------- counting */
|
|
292
|
+
function count(s) {
|
|
293
|
+
$$('[data-a="count"]', s).forEach((el) => {
|
|
294
|
+
const raw = el.dataset.final || (el.dataset.final = el.textContent);
|
|
295
|
+
const m = raw.match(/^(\D*?)(-?[\d,]*\.?\d+)(.*)$/s); if (!m) return;
|
|
296
|
+
const dec = (m[2].split(".")[1] || "").length, comma = m[2].includes(","), to = parseFloat(m[2].replace(/,/g, ""));
|
|
297
|
+
const fmt = (v) => { let t = v.toFixed(dec); if (comma) t = Number(t).toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec }); return m[1] + t + m[3]; };
|
|
298
|
+
const t0 = performance.now(), D = 1100, delay = 60 + Math.min(+getComputedStyle(el).getPropertyValue("--i") || 0, 12) * 70;
|
|
299
|
+
el.textContent = fmt(0);
|
|
300
|
+
const tick = (t) => { const p = Math.min(1, Math.max(0, (t - t0 - delay) / D)); el.textContent = fmt(to * (1 - Math.pow(1 - p, 3))); if (p < 1) requestAnimationFrame(tick); else el.textContent = raw; };
|
|
301
|
+
requestAnimationFrame(tick);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* --------------------------------------------------------- navigation */
|
|
306
|
+
let cur = 0, leaveT = null;
|
|
307
|
+
const announce = $("#announce");
|
|
308
|
+
function go(n, { from = "local", animate, steps } = {}) {
|
|
309
|
+
n = Math.max(0, Math.min(total - 1, n));
|
|
310
|
+
const prevN = cur, forward = n > prevN, leaving = slides[prevN], sl = slides[n];
|
|
311
|
+
if (n !== prevN || !sl.classList.contains("is-current")) {
|
|
312
|
+
slides.forEach((s) => s.classList.remove("is-leaving"));
|
|
313
|
+
if (leaving !== sl) { leaving.classList.add("is-leaving"); clearTimeout(leaveT); leaveT = setTimeout(() => leaving.classList.remove("is-leaving"), 380); }
|
|
314
|
+
slides.forEach((s, i) => { const on = i === n; s.classList.toggle("is-current", on); s.inert = !on; s.setAttribute("aria-hidden", on ? "false" : "true"); });
|
|
315
|
+
sl.classList.remove("play");
|
|
316
|
+
if ((animate ?? forward) && !reduced) { void sl.offsetWidth; sl.classList.add("play"); count(sl); }
|
|
317
|
+
if (leaving !== sl) leaving.classList.remove("play");
|
|
318
|
+
}
|
|
319
|
+
cur = n;
|
|
320
|
+
reveal(steps ?? (forward || animate ? 0 : Infinity));
|
|
321
|
+
if (!talkStart && n > 0 && from !== "init" && !PRESENTER) startTalk();
|
|
322
|
+
media.sync();
|
|
323
|
+
paint();
|
|
324
|
+
try { history.replaceState(null, "", location.search + "#" + (n + 1)); } catch {}
|
|
325
|
+
announce.textContent = "Slide " + (n + 1) + " of " + total + ": " + META[n].title;
|
|
326
|
+
if (from !== "remote") link.send({ type: "go", n, s: shown });
|
|
327
|
+
}
|
|
328
|
+
function next() {
|
|
329
|
+
if (shown < stepsOf(slides[cur]).length) { reveal(shown + 1); paint(); link.send({ type: "go", n: cur, s: shown }); return; }
|
|
330
|
+
go(cur + 1);
|
|
331
|
+
}
|
|
332
|
+
function prev() {
|
|
333
|
+
if (shown > 0) { reveal(shown - 1); paint(); link.send({ type: "go", n: cur, s: shown }); return; }
|
|
334
|
+
go(cur - 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* ---------------------------------------------------------- the clock */
|
|
338
|
+
let talkStart = store.get("talkStart"), talkPaused = store.get("talkPaused");
|
|
339
|
+
const elapsed = () => (talkStart ? (talkPaused ?? (Date.now() - talkStart)) / 1000 : 0);
|
|
340
|
+
function startTalk() { talkStart = Date.now(); talkPaused = null; persistClock(); paint(); }
|
|
341
|
+
function toggleTalk() {
|
|
342
|
+
if (!talkStart) return startTalk();
|
|
343
|
+
if (talkPaused != null) { talkStart = Date.now() - talkPaused; talkPaused = null; } else talkPaused = Date.now() - talkStart;
|
|
344
|
+
persistClock(); paint();
|
|
345
|
+
}
|
|
346
|
+
function resetTalk() { talkStart = null; talkPaused = null; persistClock(); paint(); }
|
|
347
|
+
function persistClock() { store.set("talkStart", talkStart); store.set("talkPaused", talkPaused); link.send({ type: "clock", talkStart, talkPaused }); }
|
|
348
|
+
function pace() {
|
|
349
|
+
if (!talkStart) return { text: "Ready", cls: "" };
|
|
350
|
+
if (!timed) return { text: PLANNED ? mmss(PLANNED - elapsed()) + " left" : "Running", cls: "on" };
|
|
351
|
+
const d = elapsed() - plan[cur], span = plan[cur + 1] - plan[cur];
|
|
352
|
+
if (d > span) return { text: mmss(d - span) + " behind", cls: "behind" };
|
|
353
|
+
if (d < -45) return { text: mmss(-d) + " ahead", cls: "on" };
|
|
354
|
+
return { text: "On pace", cls: "on" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* -------------------------------------------------------------- paint */
|
|
358
|
+
const dock = $(".dock");
|
|
359
|
+
function paint() {
|
|
360
|
+
$(".counter b").textContent = pad(cur + 1);
|
|
361
|
+
const p = pace(), clock = $(".clock");
|
|
362
|
+
$("b", clock).textContent = mmss(elapsed());
|
|
363
|
+
$(".dot", clock).className = "dot " + (talkPaused != null ? "" : p.cls);
|
|
364
|
+
clock.classList.toggle("is-paused", talkPaused != null);
|
|
365
|
+
clock.title = (talkPaused != null ? "Paused" : p.text) + (PLANNED ? " · " + mmss(PLANNED) + " planned" : "") + " · T to start or pause";
|
|
366
|
+
$(".media-ctl").hidden = !media.here();
|
|
367
|
+
$(".progress i").style.setProperty("--fill", ((cur + 1) / total) * 100 + "%");
|
|
368
|
+
if (notesOpen) paintNotes();
|
|
369
|
+
if (PRESENTER) paintPresenter();
|
|
370
|
+
}
|
|
371
|
+
setInterval(() => { if (talkStart && talkPaused == null) paint(); }, 1000);
|
|
372
|
+
|
|
373
|
+
/* -------------------------------------------------------------- notes */
|
|
374
|
+
let notesOpen = false;
|
|
375
|
+
const notesEl = $(".notes-panel");
|
|
376
|
+
const window_ = (i) => (timed ? mmss(plan[i]) + " to " + mmss(plan[i + 1]) : "");
|
|
377
|
+
const notesHTML = (i) => (META[i].notes || "No notes on this slide.").split("\n").map((t) => "<p>" + escH(t) + "</p>").join("");
|
|
378
|
+
function paintNotes() {
|
|
379
|
+
$(":scope > .notes-head > h2", notesEl).textContent = pad(cur + 1) + " · " + META[cur].title;
|
|
380
|
+
$(":scope > .notes-head > .window", notesEl).textContent = window_(cur);
|
|
381
|
+
$(":scope > .notes-text", notesEl).innerHTML = notesHTML(cur);
|
|
382
|
+
$(":scope > .notes-next > .mini", notesEl).replaceChildren(...(cur + 1 < total ? [mini(cur + 1)] : []));
|
|
383
|
+
$(":scope > .notes-next > span", notesEl).textContent = cur + 1 < total ? "Next · " + META[cur + 1].title : "End of the deck";
|
|
384
|
+
$$(".mini", notesEl).forEach(fit);
|
|
385
|
+
}
|
|
386
|
+
function toggleNotes(on = !notesOpen) {
|
|
387
|
+
notesOpen = on; notesEl.hidden = !on; B.classList.toggle("notes-open", on);
|
|
388
|
+
if (on) paintNotes();
|
|
389
|
+
setTimeout(() => fit(stage), 360);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* ------------------------------------------------------- thumbnails */
|
|
393
|
+
const minis = new Map();
|
|
394
|
+
function mini(i) {
|
|
395
|
+
let c = minis.get(i);
|
|
396
|
+
if (!c) {
|
|
397
|
+
c = slides[i].cloneNode(true);
|
|
398
|
+
c.inert = true; c.classList.remove("play", "is-leaving"); c.classList.add("is-current");
|
|
399
|
+
c.querySelectorAll("[id]").forEach((e) => e.removeAttribute("id"));
|
|
400
|
+
c.querySelectorAll("iframe, video").forEach((f) => f.remove());
|
|
401
|
+
c.querySelectorAll(".is-ready, .playing").forEach((l) => l.classList.remove("is-ready", "playing"));
|
|
402
|
+
c.querySelectorAll('[data-a="count"]').forEach((e) => { if (e.dataset.final) e.textContent = e.dataset.final; });
|
|
403
|
+
c.setAttribute("aria-hidden", "true");
|
|
404
|
+
minis.set(i, c);
|
|
405
|
+
}
|
|
406
|
+
return c.cloneNode(true);
|
|
407
|
+
}
|
|
408
|
+
function miniBox(i) { const d = document.createElement("div"); d.className = "mini"; d.append(mini(i)); return d; }
|
|
409
|
+
|
|
410
|
+
/* ----------------------------------------------------------- overview */
|
|
411
|
+
const ov = $(".overview"), helpEl = $(".help");
|
|
412
|
+
let ovCols = 4;
|
|
413
|
+
function openOverview() {
|
|
414
|
+
const grid = $(".thumbs", ov);
|
|
415
|
+
if (!grid.childElementCount) META.forEach((m, i) => {
|
|
416
|
+
const b = document.createElement("button");
|
|
417
|
+
b.className = "thumb"; b.type = "button"; b.dataset.n = i; b.setAttribute("aria-label", "Slide " + (i + 1) + ": " + m.title);
|
|
418
|
+
const f = document.createElement("figure"); f.append(miniBox(i));
|
|
419
|
+
const cap = document.createElement("figcaption"); cap.innerHTML = "<b>" + pad(i + 1) + "</b>" + escH(m.title); f.append(cap);
|
|
420
|
+
b.append(f); b.onclick = () => { closeOverlays(); go(i, { animate: true }); };
|
|
421
|
+
grid.append(b);
|
|
422
|
+
});
|
|
423
|
+
ov.hidden = false;
|
|
424
|
+
$$(".mini", ov).forEach(fit);
|
|
425
|
+
$$(".thumb", ov).forEach((t, i) => t.classList.toggle("is-here", i === cur));
|
|
426
|
+
const here = $$(".thumb", ov)[cur]; here.focus(); here.scrollIntoView({ block: "nearest" });
|
|
427
|
+
const r = $$(".thumb", ov).map((t) => t.getBoundingClientRect().top);
|
|
428
|
+
ovCols = Math.max(1, r.filter((t) => t === r[0]).length);
|
|
429
|
+
}
|
|
430
|
+
function closeOverlays() { ov.hidden = true; helpEl.hidden = true; slides[cur].focus({ preventScroll: true }); }
|
|
431
|
+
const overlayOpen = () => !ov.hidden || !helpEl.hidden;
|
|
432
|
+
|
|
433
|
+
/* ---------------------------------------------------------- media */
|
|
434
|
+
const media = (() => {
|
|
435
|
+
const frames = new Map(), sources = new Map();
|
|
436
|
+
async function load(id) {
|
|
437
|
+
if (sources.has(id)) return sources.get(id);
|
|
438
|
+
const el = document.getElementById("wand-demo-" + id);
|
|
439
|
+
const bin = Uint8Array.from(atob(el.textContent.trim()), (c) => c.charCodeAt(0));
|
|
440
|
+
const p = new Response(new Blob([bin]).stream().pipeThrough(new DecompressionStream("gzip"))).text();
|
|
441
|
+
sources.set(id, p); return p;
|
|
442
|
+
}
|
|
443
|
+
// the keys a clicker sends, and Escape, reach the deck while a demo has focus
|
|
444
|
+
const bridge = '<script>addEventListener("keydown",function(e){if(["PageDown","PageUp","Escape"].indexOf(e.key)>-1){e.preventDefault();parent.postMessage({wand:"key",key:e.key},"*")}},true);<\/script>';
|
|
445
|
+
function size(box, fr, min) {
|
|
446
|
+
const r = box.getBoundingClientRect(); if (!r.width) return;
|
|
447
|
+
const k = Math.min(1, r.width / min[0], r.height / min[1]);
|
|
448
|
+
Object.assign(fr.style, { width: r.width / k + "px", height: r.height / k + "px", transform: "scale(" + k + ")" });
|
|
449
|
+
}
|
|
450
|
+
async function mount(fig) {
|
|
451
|
+
const id = fig.dataset.demo; if (!id || frames.has(fig) || PRESENTER) return;
|
|
452
|
+
const screen = $(".screen", fig), min = (fig.dataset.min || "1280x720").split("x").map(Number);
|
|
453
|
+
const fr = document.createElement("iframe");
|
|
454
|
+
// no allow="fullscreen": the deck goes full screen, not the demo, and
|
|
455
|
+
// Chromium 141 (the chat sandbox's) logs the request as a policy error
|
|
456
|
+
fr.title = fig.getAttribute("aria-label") || "Live demo";
|
|
457
|
+
frames.set(fig, fr);
|
|
458
|
+
// the deck's brand, for a demo that dresses itself: bg, card, stroke, ink, text, label, accent
|
|
459
|
+
const cs = getComputedStyle(document.documentElement), brand = {};
|
|
460
|
+
["bg", "card", "stroke", "ink", "text", "label", "accent"].forEach((k) => { brand[k] = cs.getPropertyValue("--ui-" + k).trim(); });
|
|
461
|
+
const cfg = { motion: !reduced, brand };
|
|
462
|
+
const doc = (await load(id)).replace(/<head[^>]*>/i, (m) => m + "<script>window.wandEmbed=" + JSON.stringify(cfg) + ";<\/script>" + bridge);
|
|
463
|
+
fr.addEventListener("load", () => fig.classList.add("is-ready"), { once: true });
|
|
464
|
+
fr.srcdoc = doc; screen.append(fr);
|
|
465
|
+
size(screen, fr, min); new ResizeObserver(() => size(screen, fr, min)).observe(screen);
|
|
466
|
+
}
|
|
467
|
+
const win = (fig) => { try { return frames.get(fig)?.contentWindow; } catch { return null; } };
|
|
468
|
+
const call = (fig, what) => { const w = win(fig); if (!w) return; const api = w.wandDemo || {}; (api[what] || w["bofa" + what[0].toUpperCase() + what.slice(1)])?.call(api); };
|
|
469
|
+
let playing = false;
|
|
470
|
+
const here = () => $$(".media", slides[cur]);
|
|
471
|
+
function sync() {
|
|
472
|
+
slides.forEach((s, i) => { if (i === cur) return; $$(".media.video video", s).forEach((v) => { v.pause(); v.closest(".media").classList.remove("playing"); }); $$(".media.demo", s).forEach((f) => call(f, "pause")); });
|
|
473
|
+
playing = false;
|
|
474
|
+
here().forEach((m) => {
|
|
475
|
+
if (m.classList.contains("demo")) mount(m);
|
|
476
|
+
if (m.classList.contains("video") && m.dataset.autoplay && !PRESENTER) { const v = $("video", m); v.muted = true; v.play().then(() => { m.classList.add("playing"); playing = true; paintMedia(); }).catch(() => {}); }
|
|
477
|
+
});
|
|
478
|
+
const nx = slides[cur + 1]; if (nx) $$(".media.demo", nx).forEach(mount);
|
|
479
|
+
paintMedia();
|
|
480
|
+
}
|
|
481
|
+
function paintMedia() { const b = $('[data-ui="media"]'); b.innerHTML = playing ? '${svg("pause")}' : '${svg("play")}'; $(".p-media").hidden = !here().length; }
|
|
482
|
+
function act(what) {
|
|
483
|
+
here().forEach((m) => {
|
|
484
|
+
if (m.classList.contains("video")) { const v = $("video", m); if (what === "reset") { v.currentTime = 0; v.pause(); m.classList.remove("playing"); playing = false; } else if (v.paused) { v.play().catch(() => {}); m.classList.add("playing"); playing = true; } else { v.pause(); playing = false; } }
|
|
485
|
+
if (m.classList.contains("demo")) { if (what === "reset") { call(m, "reset"); playing = false; } else { call(m, playing ? "pause" : "play"); playing = !playing; } }
|
|
486
|
+
});
|
|
487
|
+
paintMedia(); link.send({ type: "media", playing });
|
|
488
|
+
}
|
|
489
|
+
return { sync, act, here: () => here().length > 0, get playing() { return playing; }, set playing(v) { playing = v; paintMedia(); } };
|
|
490
|
+
})();
|
|
491
|
+
|
|
492
|
+
/* ------------------------------------------------------- the room link */
|
|
493
|
+
// the presenter view is this same file in a second window; the two keep the
|
|
494
|
+
// same slide, step and clock by postMessage, which works from file://
|
|
495
|
+
const link = (() => {
|
|
496
|
+
let peer = PRESENTER ? window.opener : null;
|
|
497
|
+
return {
|
|
498
|
+
send(msg) { try { peer && !peer.closed && peer.postMessage({ wand: "sync", ...msg }, "*"); } catch {} },
|
|
499
|
+
open() {
|
|
500
|
+
if (peer && !peer.closed) { peer.focus(); return; }
|
|
501
|
+
const p = new URLSearchParams(location.search); p.set("view", "presenter");
|
|
502
|
+
peer = window.open(location.pathname + "?" + p + "#" + (cur + 1), "wand-presenter", "popup,width=1440,height=900");
|
|
503
|
+
if (!peer) toast("Allow pop-ups to open the presenter view");
|
|
504
|
+
},
|
|
505
|
+
set(p) { peer = p; },
|
|
506
|
+
get peer() { return peer && !peer.closed ? peer : null; },
|
|
507
|
+
};
|
|
508
|
+
})();
|
|
509
|
+
addEventListener("message", (e) => {
|
|
510
|
+
const d = e.data || {};
|
|
511
|
+
if (d.wand === "key") { if (d.key === "PageDown") next(); else if (d.key === "PageUp") prev(); else slides[cur].focus({ preventScroll: true }); return; }
|
|
512
|
+
if (d.wand !== "sync") return;
|
|
513
|
+
if (!PRESENTER && e.source && !link.peer) link.set(e.source);
|
|
514
|
+
if (d.type === "hello") { link.send({ type: "go", n: cur, s: shown }); link.send({ type: "clock", talkStart, talkPaused }); link.send({ type: "media", playing: media.playing }); }
|
|
515
|
+
if (d.type === "go") { if (d.n !== cur) go(d.n, { from: "remote", animate: d.n > cur, steps: d.s }); else { reveal(d.s); paint(); } }
|
|
516
|
+
if (d.type === "clock") { talkStart = d.talkStart; talkPaused = d.talkPaused; store.set("talkStart", talkStart); store.set("talkPaused", talkPaused); paint(); }
|
|
517
|
+
if (d.type === "media-act" && !PRESENTER) media.act(d.act);
|
|
518
|
+
if (d.type === "media" && PRESENTER) media.playing = d.playing;
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
/* ----------------------------------------------------- presenter view */
|
|
522
|
+
function paintPresenter() {
|
|
523
|
+
const side = $(".presenter-side"), p = pace();
|
|
524
|
+
$(".p-elapsed", side).textContent = mmss(elapsed());
|
|
525
|
+
$(".p-pace", side).textContent = talkStart ? (talkPaused != null ? "Paused" : p.text) : "Not started";
|
|
526
|
+
$(".p-clock", side).textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
527
|
+
const st = stepsOf(slides[cur]).length;
|
|
528
|
+
$(".p-notes", side).innerHTML = "<h3>" + pad(cur + 1) + " · " + escH(META[cur].title) + (st ? " · step " + shown + " of " + st : "") + (timed ? " · " + window_(cur) : "") + "</h3>" + notesHTML(cur);
|
|
529
|
+
$(":scope > .p-next > span", side).textContent = cur + 1 < total ? "Next · " + pad(cur + 2) + " " + META[cur + 1].title : "End of the deck";
|
|
530
|
+
$(":scope > .p-next > .mini", side)?.replaceWith(cur + 1 < total ? miniBox(cur + 1) : Object.assign(document.createElement("div"), { className: "mini" }));
|
|
531
|
+
$$(".mini", side).forEach(fit);
|
|
532
|
+
}
|
|
533
|
+
if (PRESENTER) {
|
|
534
|
+
B.classList.add("presenter"); $(".presenter-side").hidden = false;
|
|
535
|
+
document.title = "Presenter · " + document.title;
|
|
536
|
+
setInterval(paintPresenter, 1000);
|
|
537
|
+
link.send({ type: "hello" });
|
|
538
|
+
$(".presenter-side").addEventListener("click", (e) => {
|
|
539
|
+
const b = e.target.closest("[data-p]"); if (!b) return;
|
|
540
|
+
({ prev, next, clock: toggleTalk, "reset-clock": resetTalk, media: () => link.send({ type: "media-act", act: "toggle" }) })[b.dataset.p]?.();
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/* ------------------------------------------------------------- input */
|
|
545
|
+
let digits = "", digitT = null;
|
|
546
|
+
const menu = $(".menu"), moreBtn = $('[data-ui="more"]');
|
|
547
|
+
const openMenu = () => { menu.hidden = false; moreBtn.setAttribute("aria-expanded", "true"); $("button", menu).focus(); };
|
|
548
|
+
const closeMenu = () => { menu.hidden = true; moreBtn.setAttribute("aria-expanded", "false"); };
|
|
549
|
+
const blackout = $(".blackout");
|
|
550
|
+
addEventListener("keydown", (e) => {
|
|
551
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
552
|
+
const k = e.key;
|
|
553
|
+
if (k === "Escape") { if (!menu.hidden) closeMenu(); else if (overlayOpen()) closeOverlays(); else if (!blackout.hidden) blackout.hidden = true; else if (notesOpen) toggleNotes(false); return; }
|
|
554
|
+
if (!ov.hidden) {
|
|
555
|
+
const ts = $$(".thumb", ov), i = ts.indexOf(document.activeElement);
|
|
556
|
+
const mv = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: ovCols, ArrowUp: -ovCols }[k];
|
|
557
|
+
if (mv && i > -1) { e.preventDefault(); ts[Math.max(0, Math.min(ts.length - 1, i + mv))].focus(); }
|
|
558
|
+
if (k === "g" || k === "o") closeOverlays();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!helpEl.hidden) { if (k === "?") closeOverlays(); return; }
|
|
562
|
+
if (/^[0-9]$/.test(k)) { digits += k; clearTimeout(digitT); digitT = setTimeout(() => (digits = ""), 1500); return; }
|
|
563
|
+
if (k === "Enter" && digits) { go(+digits - 1, { animate: true }); digits = ""; return; }
|
|
564
|
+
const map = {
|
|
565
|
+
ArrowRight: next, ArrowDown: next, PageDown: next, " ": next, ArrowLeft: prev, ArrowUp: prev, PageUp: prev,
|
|
566
|
+
Home: () => go(0), End: () => go(total - 1), g: openOverview, o: openOverview, n: () => toggleNotes(), p: () => link.open(),
|
|
567
|
+
f: fullscreen, b: () => { blackout.hidden = !blackout.hidden; }, ".": () => { blackout.hidden = !blackout.hidden; }, t: toggleTalk,
|
|
568
|
+
"?": () => { helpEl.hidden = false; },
|
|
569
|
+
d: () => (PRESENTER ? link.send({ type: "media-act", act: "toggle" }) : media.act("toggle")),
|
|
570
|
+
r: () => (PRESENTER ? link.send({ type: "media-act", act: "reset" }) : media.act("reset")),
|
|
571
|
+
};
|
|
572
|
+
const fn = map[k] || map[k.toLowerCase()]; if (!fn) return;
|
|
573
|
+
if (k === " " && document.activeElement && document.activeElement.matches("button, a, [role=button]")) return;
|
|
574
|
+
e.preventDefault(); fn();
|
|
575
|
+
});
|
|
576
|
+
document.addEventListener("click", (e) => {
|
|
577
|
+
if (!e.target.closest(".more")) closeMenu();
|
|
578
|
+
if (e.target.closest(".stage") && !e.target.closest("a, button, iframe, video, .media, input, select, textarea, [data-go]") && !overlayOpen() && !PRESENTER && !String(getSelection?.() || "").length) { e.shiftKey ? prev() : next(); return; }
|
|
579
|
+
const g = e.target.closest("[data-go]"); if (g) { e.preventDefault(); go(+g.dataset.go - 1, { animate: true }); return; }
|
|
580
|
+
const u = e.target.closest("[data-ui]"); if (!u) return;
|
|
581
|
+
if (u.closest(".menu")) closeMenu();
|
|
582
|
+
({ prev, next, overview: openOverview, notes: () => toggleNotes(), presenter: () => link.open(), full: fullscreen,
|
|
583
|
+
more: () => (menu.hidden ? openMenu() : closeMenu()), help: () => { helpEl.hidden = false; }, clock: toggleTalk,
|
|
584
|
+
media: () => media.act("toggle"), "media-reset": () => media.act("reset") })[u.dataset.ui]?.();
|
|
585
|
+
});
|
|
586
|
+
let wheelX = 0, wheelLock = 0;
|
|
587
|
+
addEventListener("wheel", (e) => {
|
|
588
|
+
if (overlayOpen() || Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
|
589
|
+
e.preventDefault(); if (Date.now() < wheelLock) return;
|
|
590
|
+
wheelX += e.deltaX; if (Math.abs(wheelX) > 60) { wheelX > 0 ? next() : prev(); wheelX = 0; wheelLock = Date.now() + 650; }
|
|
591
|
+
}, { passive: false });
|
|
592
|
+
let sx = null;
|
|
593
|
+
stage.addEventListener("pointerdown", (e) => { if (e.pointerType !== "mouse") sx = [e.clientX, e.clientY]; });
|
|
594
|
+
stage.addEventListener("pointerup", (e) => { if (!sx) return; const dx = e.clientX - sx[0], dy = e.clientY - sx[1]; sx = null; if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.4) dx < 0 ? next() : prev(); });
|
|
595
|
+
let idleT = null;
|
|
596
|
+
function wake() {
|
|
597
|
+
B.classList.add("ui-visible"); B.classList.remove("idle"); clearTimeout(idleT);
|
|
598
|
+
idleT = setTimeout(() => { if (!dock.matches(":hover, :focus-within")) B.classList.remove("ui-visible"); if (document.fullscreenElement) B.classList.add("idle"); }, 2400);
|
|
599
|
+
}
|
|
600
|
+
addEventListener("mousemove", wake, { passive: true });
|
|
601
|
+
let wakeLock = null;
|
|
602
|
+
async function fullscreen() {
|
|
603
|
+
if (!document.fullscreenElement) { await document.documentElement.requestFullscreen?.().catch(() => {}); try { wakeLock = await navigator.wakeLock?.request("screen"); } catch {} }
|
|
604
|
+
else { await document.exitFullscreen?.(); wakeLock?.release?.(); wakeLock = null; }
|
|
605
|
+
}
|
|
606
|
+
let toastT = null;
|
|
607
|
+
function toast(msg) {
|
|
608
|
+
let t = $(".toast"); if (!t) { t = document.createElement("div"); t.className = "toast"; t.setAttribute("role", "status"); B.append(t); }
|
|
609
|
+
t.textContent = msg; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(() => (t.hidden = true), 2600);
|
|
610
|
+
}
|
|
611
|
+
addEventListener("hashchange", () => { const n = parseInt(location.hash.slice(1), 10) - 1; if (n >= 0 && n !== cur) go(n, { animate: true }); });
|
|
612
|
+
|
|
613
|
+
/* -------------------------------------------------------------- start */
|
|
614
|
+
slides.forEach((s) => s.setAttribute("tabindex", "-1"));
|
|
615
|
+
const start = Math.max(0, Math.min(total - 1, (parseInt(location.hash.slice(1), 10) || 1) - 1));
|
|
616
|
+
cur = start;
|
|
617
|
+
go(start, { from: "init", animate: true });
|
|
618
|
+
window.wandTalk = { go, next, prev, get cur() { return cur; }, get shown() { return shown; }, total };
|
|
619
|
+
if (!PRESENTER) { wake(); if (!store.get("helped")) { toast("Press ? for shortcuts · P opens the presenter view"); store.set("helped", true); } }
|
|
620
|
+
})();`;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wand-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One HTML design surface for every sales and marketing material, on either Wand brand: decks, documents, one-pagers, case studies, social cards. Every export (PDF, PNG, PowerPoint, one-file HTML) is read from the page that was reviewed.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "One HTML design surface for every sales and marketing material, on either Wand brand: decks that animate and present, documents, one-pagers, case studies, storyboards from a script, social cards. Every export (PDF, PNG, PowerPoint, one-file HTML) is read from the page that was reviewed.",
|
|
5
5
|
"license": "SEE LICENSE IN PROMPT.md",
|
|
6
6
|
"bin": {
|
|
7
7
|
"wand-kit": "./wand.js"
|