@hypersocial/decks-cli 0.9.10 → 0.9.12

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.
@@ -0,0 +1,243 @@
1
+ // `preview create --scene <id> --start <seconds>`: the email GIF of one moment
2
+ // of a narrated deck, not its opening.
3
+ //
4
+ // The ordinary preview (preview-capture.mjs capturePreview) opens the deck as
5
+ // a recipient would and screencasts it in real time; its narration never
6
+ // starts, so it can only show the opening. This one records the way the video
7
+ // export does (export-video.mjs): the player's export mode (?export=1: no
8
+ // chrome, plays without a tap, narration on a silent clock media element) on
9
+ // a page clock the recorder steps (frame-clock.mjs). The deck opens on the
10
+ // chosen slide (window.__hdExportFrom, export-mode.js), the clock is stepped
11
+ // `start` seconds into its narration, so every cue, guest line, caption
12
+ // bubble and mouth shape that would have happened by then has happened, and
13
+ // then each GIF frame is the clock advanced 1/fps and a screenshot taken.
14
+ // Nothing plays aloud; no view is counted (the capture request rules refuse
15
+ // /v/ and telemetry, and export mode sends no engagement events).
16
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { extname, join } from 'node:path';
19
+ import { installFrameClock } from './frame-clock.mjs';
20
+ import { exportPageUrl, launchRecorder, planTimeline } from './export-video.mjs';
21
+ import { recorderArgs, routeCaptureRequest } from './preview-capture.mjs';
22
+
23
+ // Page-clock milliseconds per step while seeking to `start`: timers fire in
24
+ // order within a step, so a coarser step only means fewer animation frames
25
+ // drawn on the way. The GIF's own frames are 1/fps apart.
26
+ const SEEK_STEP_MS = 100;
27
+ // Real milliseconds the deck may take to become ready (the head loads in it).
28
+ const READY_MS = 60000;
29
+ // Real milliseconds a slide's heads may take to finish loading once on it.
30
+ const HEADS_MS = 15000;
31
+
32
+ // Where to start: the slide (`scene`, the first slide when omitted) and the
33
+ // seconds into its narration, checked against the player's own plan
34
+ // ([{ id, duration, hold }], the manifest's clip length plus its hold).
35
+ export function seekTarget(plan, { scene, start = 0 } = {}) {
36
+ const timeline = planTimeline(plan ?? []);
37
+ if (!timeline.ids.length) throw new Error('the deck has no slides');
38
+ const index = scene == null ? 0 : timeline.ids.indexOf(scene);
39
+ if (index < 0) throw new Error(`Unknown scene "${scene}". This deck's slides: ${timeline.ids.join(', ')}`);
40
+ const id = timeline.ids[index], seconds = timeline.seconds[index], entry = plan[index];
41
+ // A clip whose length the plan doesn't know (a data-clip deck's): its start can't be bounded here.
42
+ const known = !entry.audio || entry.duration > 0;
43
+ if (known && start > 0 && !(start < seconds)) {
44
+ throw new Error(seconds > 0
45
+ ? `--start ${start} is past the end of slide ${id}: it runs ${seconds.toFixed(1)}s (${entry.audio ? 'its narration plus its hold' : 'its hold; it has no narration'}), so pick 0 to ${Math.max(0, seconds - 0.1).toFixed(1)}`
46
+ : `--start ${start}: slide ${id} has no narration to start into; use --start 0`);
47
+ }
48
+ return { index, id, start, seconds, narrated: !!entry.audio };
49
+ }
50
+
51
+ const within = async (promise, ms, fallback) => {
52
+ promise.catch(() => {});
53
+ let timer;
54
+ try { return await Promise.race([promise, new Promise((ok) => { timer = setTimeout(() => ok(fallback), ms); })]); }
55
+ finally { clearTimeout(timer); }
56
+ };
57
+
58
+ // What the first frame shows, read from the page: the slide the player is on,
59
+ // the guest speaking and its caption bubble's words (null when none), and the
60
+ // state of each character head on the slide (head-slots.js data-hd-head-state).
61
+ function readMoment() {
62
+ const log = window.__hdExport;
63
+ const slide = log?.slides?.at(-1)?.id ?? null;
64
+ const bubble = document.querySelector('.hd-guest-bubble:not([hidden])');
65
+ const heads = [...(document.getElementById(slide)?.querySelectorAll('[data-hd-head-state]') ?? [])].map((e) => e.dataset.hdHeadState);
66
+ return {
67
+ slide,
68
+ // Every slide the player showed, in order: the scene alone when it opened there.
69
+ visited: (log?.slides ?? []).map((s) => s.id),
70
+ guest: bubble ? { speaker: bubble.dataset.speaker ?? null, caption: bubble.textContent.trim() } : null,
71
+ caption: document.querySelector('.hd-caption')?.textContent.trim() || null,
72
+ heads,
73
+ };
74
+ }
75
+
76
+ // Records `frames` PNGs (frame-0000.png…) into `dir` and returns
77
+ // { aspect, moment, warnings }. config: { html | file, scene, start, frames,
78
+ // fps, delay, label, aspect, firstPartyOrigins, playerDir }.
79
+ export async function recordMoment(config, { chromium, serveSource, dir }) {
80
+ const warnings = [];
81
+ let browser, server, phase = 'launch';
82
+ try {
83
+ browser = await launchRecorder(chromium, {
84
+ headless: true, timeout: 30000, chromiumSandbox: config.sandbox ?? process.platform === 'linux',
85
+ args: ['--autoplay-policy=no-user-gesture-required', '--mute-audio', ...recorderArgs()],
86
+ env: { ...(process.platform === 'win32' ? { SystemRoot: process.env.SystemRoot } : {}), PATH: process.env.PATH, TMPDIR: dir, HOME: dir },
87
+ });
88
+ server = await serveSource(config);
89
+ const pageUrl = exportPageUrl(server.url);
90
+ const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1, serviceWorkers: 'block', acceptDownloads: false });
91
+ let blocked = 0;
92
+ await context.route('**/*', async (route) => {
93
+ if (!await routeCaptureRequest(route, pageUrl, config.firstPartyOrigins)) blocked++;
94
+ });
95
+ // A development checkout (or a test) plays its own player/v1, as video export does.
96
+ const missingPlayer = [];
97
+ if (config.playerDir) {
98
+ await context.route(/\/player\/v1\/[^?#]+/, async (route) => {
99
+ const name = new URL(route.request().url()).pathname.replace(/^.*\/player\/v1\/(b\/[^/]+\/)?/, '');
100
+ if (name.split('/').some((s) => s === '..' || s.startsWith('.'))) return route.abort();
101
+ const type = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png' }[extname(name)] ?? 'application/octet-stream';
102
+ try { await route.fulfill({ body: await readFile(join(config.playerDir, name)), contentType: type }); }
103
+ catch { missingPlayer.push(name); await route.abort(); }
104
+ });
105
+ }
106
+ await context.routeWebSocket('**/*', (ws) => ws.close());
107
+ await context.addInitScript({ content: `(${installFrameClock.toString()})(window);` });
108
+ // The player opens on __hdExportFrom, read once its slides are in the page:
109
+ // the scene's index among them, as the player lists them (section.slide in order).
110
+ // An unknown scene opens the first slide; seekTarget then refuses it by name.
111
+ await context.addInitScript((scene) => {
112
+ navigator.sendBeacon = () => false;
113
+ window.open = () => null;
114
+ window.__hdExportWait = new Promise((go) => { window.__hdExportGo = go; });
115
+ if (scene == null) return;
116
+ Object.defineProperty(window, '__hdExportFrom', { configurable: true, get: () => Math.max(0, [...document.querySelectorAll('section.slide')].map((s) => s.id).indexOf(scene)) });
117
+ }, config.scene ?? null);
118
+ const page = await context.newPage();
119
+ page.setDefaultTimeout(30000);
120
+ const pageErrors = [];
121
+ page.on('pageerror', (e) => pageErrors.push(String(e?.message ?? e)));
122
+ // Every call into the page is bounded: a deck that holds its main thread fails, it doesn't hang the CLI.
123
+ const ask = async (fn, arg, what) => {
124
+ const none = Symbol('timeout');
125
+ const answer = await within(page.evaluate(fn, arg), 30000, none);
126
+ if (answer === none) throw new Error(`the page stopped answering ${what} (over 30s)`);
127
+ return answer;
128
+ };
129
+ const step = (ms, what) => ask(async (ms) => { window.__hdFrameClock.step(ms); await window.__hdFrameClock.settle(); return window.__hdExport; }, ms, what);
130
+ phase = 'page load';
131
+ await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
132
+ phase = 'player ready';
133
+ await page.waitForSelector('body[data-hd-ready="true"]', { timeout: READY_MS }).catch(() => {
134
+ const missing = missingPlayer.length ? ` (missing from the local player: ${missingPlayer.slice(0, 3).join(', ')})` : '';
135
+ throw new Error(`the deck was not ready after ${READY_MS / 1000}s${missing}; --scene and --start need a deck on player/v1 (https://hyperdecks.ai/player/v1/player.js)`);
136
+ });
137
+ const state = await ask(() => (window.__hdExport ? JSON.parse(JSON.stringify(window.__hdExport)) : null), undefined, 'for its export log');
138
+ if (!state) throw new Error('--scene and --start need a deck on player/v1 (https://hyperdecks.ai/player/v1/player.js); this deck has its own runtime');
139
+ if (state.manifestError) throw new Error(`the deck's narration manifest could not load (${state.manifestError.url}: ${state.manifestError.message})`);
140
+ if (!(state.version >= 3 && state.clock === 'page' && state.range === true)) throw new Error('the deck\'s player is too old to open on a slide; reload https://hyperdecks.ai/player/v1/player.js, or update the deck\'s copy');
141
+ phase = 'scene check';
142
+ const target = seekTarget(state.plan, { scene: config.scene, start: config.start ?? 0 });
143
+ if (state.silent && target.start > 0) throw new Error('--start needs a narrated deck: this one has no narration to start into (run `hyperdecks narrate`)');
144
+ const duration = config.frames / config.fps;
145
+ if (!state.silent && target.start + duration > target.seconds + 0.05) warnings.push(`slide ${target.id} ends ${Math.max(0, target.seconds - target.start).toFixed(1)}s after --start; the rest of the ${duration}s preview shows the deck moving on`);
146
+ const face = state.presenter;
147
+ if (face?.wanted === 'head' && face.shown !== 'head') warnings.push(`the deck's live head did not load${face.why ? ` (${face.why})` : ''}; the preview shows ${face.shown === 'static' ? 'its still image' : 'captions only'}`);
148
+
149
+ phase = 'layout probe';
150
+ // The deck's own shape, as the ordinary preview reads it (preview-capture.mjs), unless --aspect-ratio names one.
151
+ let aspect = config.aspect;
152
+ if (!aspect) {
153
+ const detected = await ask(() => {
154
+ const meta = document.querySelector('meta[name="preview-aspect-ratio"]')?.getAttribute('content');
155
+ if (meta && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(meta)) { const [w, h] = meta.split(':').map(Number); return w / h; }
156
+ const rect = document.querySelector('section.slide')?.getBoundingClientRect();
157
+ return rect && rect.width > 100 && rect.height > 100 ? rect.width / rect.height : null;
158
+ }, undefined, 'to the layout probe');
159
+ aspect = detected >= 0.5 && detected <= 3 ? detected : 16 / 9;
160
+ if (aspect !== detected) warnings.push('Using 16:9 aspect ratio; use --aspect-ratio W:H or preview-aspect-ratio metadata for a different deck shape.');
161
+ }
162
+ if (Math.abs(aspect - 16 / 9) > 0.001) {
163
+ await page.setViewportSize({ width: 1280, height: Math.round(1280 / aspect) });
164
+ await page.waitForTimeout(200); // the player refits on resize; the clock still runs in real time
165
+ }
166
+
167
+ // The slide's heads load before the tour starts: once it runs, the player gives late guest heads
168
+ // a budget on the page clock (player.js holdForGuestHeads), which the seek spends in moments.
169
+ // Polled from here: in-page waits run on timers and rAF, which the frozen clock holds.
170
+ const headsSettled = async () => {
171
+ for (const until = Date.now() + HEADS_MS; Date.now() < until; await new Promise((ok) => setTimeout(ok, 250))) {
172
+ const loading = await ask((id) => [...(document.getElementById(id)?.querySelectorAll('[data-hd-head-state="loading"]') ?? [])].length, target.id, 'while heads loaded');
173
+ if (!loading) return true;
174
+ }
175
+ return false;
176
+ };
177
+ phase = 'heads';
178
+ await headsSettled();
179
+
180
+ phase = 'seek';
181
+ // Clip lengths come from the manifest (player.js clipLength), as a recipient's <audio> would roughly end them.
182
+ await ask(async () => {
183
+ window.__hdFrameClock.freeze();
184
+ window.__hdExportGo();
185
+ await window.__hdFrameClock.settle();
186
+ }, undefined, 'as the tour started');
187
+ const failure = (l) => l.errors?.length && `narration failed on ${l.errors[0].id} (${l.errors[0].message})`;
188
+ // The start counts from when the slide's own clip plays (a clip that loads late starts late), or,
189
+ // for a slide with no narration of its own, from when it lands.
190
+ const arrived = (l) => l.silent || (target.narrated ? l.clips.some((c) => c.id === target.id && !c.audio) : l.slides.some((s) => s.id === target.id));
191
+ let log = await ask(() => window.__hdExport, undefined, 'for its export log');
192
+ for (let n = 0; !arrived(log); n++) {
193
+ if (failure(log)) throw new Error(failure(log));
194
+ if (n === 600) throw new Error(`slide ${target.id}'s narration did not start within 60s of the deck's time`);
195
+ log = await step(SEEK_STEP_MS, 'while the tour started');
196
+ }
197
+ for (let n = Math.round((target.start * 1000) / SEEK_STEP_MS); n > 0; n--) log = await step(SEEK_STEP_MS, 'while seeking');
198
+ if (failure(log)) throw new Error(failure(log));
199
+
200
+ phase = 'heads';
201
+ // The clock is frozen, so waiting changes nothing on the slide: it lets heads and images finish loading.
202
+ const headsLoaded = await headsSettled();
203
+ if (!headsLoaded) warnings.push(`a character head on ${target.id} was still loading after ${HEADS_MS / 1000}s; it shows as a still or silhouette`);
204
+ phase = 'delay';
205
+ await page.waitForTimeout(config.delay);
206
+ await step(0, 'after the delay');
207
+
208
+ phase = 'label overlay';
209
+ await ask((label) => {
210
+ const badge = document.createElement('div');
211
+ badge.textContent = '▶ ' + label;
212
+ badge.setAttribute('style', 'all:initial;position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.85);color:white;font:600 25px sans-serif;padding:14px 24px;border-radius:40px;z-index:2147483647;white-space:nowrap;max-width:90%;overflow:hidden;text-overflow:ellipsis;pointer-events:none');
213
+ document.documentElement.appendChild(badge);
214
+ }, config.label, 'to the label overlay');
215
+ const moment = { scene: target.id, start: target.start, ...(await ask(readMoment, undefined, 'to the moment check')) };
216
+ if (moment.slide !== target.id) throw new Error(`the player showed ${moment.slide ?? 'no slide'}, not ${target.id}`);
217
+ const unready = moment.heads.filter((s) => s !== 'ready').length;
218
+ if (headsLoaded && unready) warnings.push(`${unready} character head(s) on ${target.id} are not live (${[...new Set(moment.heads.filter((s) => s !== 'ready'))].join(', ')}); they show as stills or silhouettes`);
219
+
220
+ phase = 'frames';
221
+ const session = await context.newCDPSession(page);
222
+ for (let n = 0; n < config.frames; n++) {
223
+ if (n) log = await step(1000 / config.fps, 'while recording (a frame took over 30s to draw)');
224
+ const shot = await within(session.send('Page.captureScreenshot', { format: 'png' }), 30000, null);
225
+ if (!shot?.data) throw new Error('a frame took over 30s to capture');
226
+ await writeFile(join(dir, `frame-${String(n).padStart(4, '0')}.png`), Buffer.from(shot.data, 'base64'));
227
+ }
228
+ if (failure(log)) warnings.push(`${failure(log)} while recording; the preview may stop moving there`);
229
+ if (blocked) warnings.push(`${blocked} navigation/telemetry/unsafe request(s) blocked; no recipient page was visited.`);
230
+ if (pageErrors.length) warnings.push(`${pageErrors.length} page error(s): ${pageErrors.slice(0, 2).join('; ').slice(0, 300)}`);
231
+ return { aspect, moment, warnings };
232
+ } catch (e) {
233
+ throw new Error(`Capture failed during ${phase}: ${e.message}`);
234
+ } finally {
235
+ if (browser) await browser.close().catch(() => {});
236
+ if (server) await server.close();
237
+ }
238
+ }
239
+
240
+ export async function withTempDir(fn) {
241
+ const dir = await mkdtemp(join(tmpdir(), 'hyperdecks-preview-'));
242
+ try { return await fn(dir); } finally { await rm(dir, { recursive: true, force: true }); }
243
+ }
package/src/preview.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { withAvatarView } from "./avatar-view.mjs";
1
2
  import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
2
3
  import { resolve, extname } from "node:path";
3
4
  import { randomUUID } from "node:crypto";
@@ -15,7 +16,7 @@ function bounded(value, fallback, min, max, name, integer = false) {
15
16
  return n;
16
17
  }
17
18
  export function previewOptions(pos, opts) {
18
- const allowed = new Set(['file', 'out', 'duration', 'width', 'delay', 'label', 'aspect-ratio', 'share', 'upload', 'json', 'prod', 'dev', 'admin', 'broker', 'convex', 'token', 'client']);
19
+ const allowed = new Set(['file', 'out', 'duration', 'width', 'delay', 'label', 'aspect-ratio', 'share', 'upload', 'scene', 'start', 'json', 'prod', 'dev', 'admin', 'broker', 'convex', 'token', 'client']);
19
20
  for (const k of Object.keys(opts)) if (!allowed.has(k)) throw new Error(`Unknown preview option --${k}`);
20
21
  for (const [k, v] of Object.entries(opts)) if (v === undefined) throw new Error(`--${k} requires a value`);
21
22
  if (pos.length > 1 || (opts.file ? pos.length !== 0 : pos.length !== 1)) throw new Error("usage: preview create <slug> --out <new-directory> OR preview create --file <index.html> --out <new-directory>");
@@ -33,7 +34,13 @@ export function previewOptions(pos, opts) {
33
34
  aspect = m ? Number(m[1]) / Number(m[2]) : NaN;
34
35
  bounded(aspect, null, 0.5, 3, '--aspect-ratio');
35
36
  }
36
- return { slug: pos[0], file: opts.file && resolve(opts.file), out: resolve(opts.out), duration: bounded(opts.duration, 4, 1, 10, '--duration'), width: bounded(opts.width, 600, 240, 1200, '--width', true), delay: bounded(opts.delay, 1000, 0, 10000, '--delay', true), aspect, label };
37
+ // --scene <slide-id> / --start <seconds>: a moment of the narration, not the opening (preview-moment.mjs).
38
+ // The id is checked against the deck's slides, and the start against that slide's length, once it loads.
39
+ if (opts.scene !== undefined && (typeof opts.scene !== 'string' || !opts.scene.trim() || opts.scene.length > 200 || /[\x00-\x1f]/.test(opts.scene))) throw new Error('--scene must be a slide id (the id of its <section class="slide">)');
40
+ const moment = opts.scene !== undefined || opts.start !== undefined
41
+ ? { scene: opts.scene ?? null, start: bounded(opts.start, 0, 0, 3600, '--start (seconds into the slide\'s narration)') }
42
+ : {};
43
+ return { slug: pos[0], file: opts.file && resolve(opts.file), out: resolve(opts.out), duration: bounded(opts.duration, 4, 1, 10, '--duration'), width: bounded(opts.width, 600, 240, 1200, '--width', true), delay: bounded(opts.delay, 1000, 0, 10000, '--delay', true), aspect, label, ...moment };
37
44
  }
38
45
  export function validateShare(rows, slug, token, now = Date.now()) {
39
46
  const row = rows.find(s => s.token === token && s.deckSlug === slug);
@@ -67,6 +74,7 @@ export async function createPreview(pos, opts, adapters = {}) {
67
74
  html = await res.text();
68
75
  }
69
76
  if (!html) throw new Error('Deck has no HTML to capture');
77
+ html = await withAvatarView(c, config.slug, html, 'released');
70
78
  }
71
79
  // A new directory prevents replacing artifacts referenced by an earlier manifest.
72
80
  await mkdir(config.out, { recursive: false });
@@ -78,6 +86,8 @@ export async function createPreview(pos, opts, adapters = {}) {
78
86
  const capture = adapters.capture || (await import('./preview-capture.mjs')).capturePreview;
79
87
  const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://hyperdecks.ai', 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
80
88
  result.files = captured.files;
89
+ // What the first frame shows (--scene/--start): the slide, the speaking guest and its caption.
90
+ if (captured.moment) result.moment = captured.moment;
81
91
  warnings.push(...captured.warnings);
82
92
  for (const f of result.files) if (f.bytes > 5 * 1024 * 1024) warnings.push(`${f.type} exceeds 5 MiB; shorten --duration or reduce --width for email`);
83
93
  result.status = 'exported';
package/src/runtime.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { ConvexHttpClient } from "convex/browser";
9
9
  import { makeFunctionReference } from "convex/server";
10
10
 
11
- export const VERSION = "0.9.10";
11
+ export const VERSION = "0.9.12";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  // The app's home. HYPERDECKS_HOST points the prod target's broker and share
@@ -180,6 +180,13 @@ export const api = {
180
180
  getJob: makeFunctionReference("narration:getJob"),
181
181
  listVoices: makeFunctionReference("narration:listVoices"),
182
182
  },
183
+ deckTransfers: {
184
+ create: makeFunctionReference("deckTransfers:create"),
185
+ cancel: makeFunctionReference("deckTransfers:cancel"),
186
+ list: makeFunctionReference("deckTransfers:list"),
187
+ accept: makeFunctionReference("deckTransfers:accept"),
188
+ decline: makeFunctionReference("deckTransfers:decline"),
189
+ },
183
190
  voices: {
184
191
  listVoices: makeFunctionReference("voices:listVoices"),
185
192
  getVoice: makeFunctionReference("voices:getVoice"),
@@ -194,7 +201,7 @@ export const api = {
194
201
  },
195
202
  };
196
203
 
197
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card", "backup"]);
204
+ const BOOL = new Set(["private-only", "prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-narrate", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card", "backup"]);
198
205
  // release's recipients, in order: each --to starts one, and an --email right
199
206
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
200
207
  // opts.email stays the last --email for the other commands (share new).
@@ -223,7 +230,7 @@ export function parse(args) {
223
230
  else {
224
231
  const v = args[++i];
225
232
  if (k === "to" || (k === "email" && opts.recipients)) addRecipient(opts, k, v);
226
- if (k === "agent" && opts[k]) opts[k] = `${opts[k]},${v}`;
233
+ if ((k === "agent" || k === "guest") && opts[k]) opts[k] = `${opts[k]},${v}`;
227
234
  else opts[k] = v;
228
235
  }
229
236
  } else {
package/src/scaffold.mjs CHANGED
@@ -32,7 +32,7 @@ export async function deckNew(pos, opts = {}, { log = (line) => console.log(line
32
32
  if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
33
33
  const dir = resolve(/*turbopackIgnore: true*/ cwd, typeof opts.dir === "string" ? opts.dir : join("decks", slug));
34
34
  if (existsSync(dir)) throw new Error(`${dir} already exists; pick another slug or --dir`);
35
- if (opts.layouts === true) throw new Error(`--layouts needs a list, e.g. --layouts ${LAYOUTS.slice(0, 3).join(",")},closer`);
35
+ if (opts.layouts === true) throw new Error(`--layouts needs a list, e.g. --layouts hero,product,number,closer`);
36
36
  if (opts.layouts !== undefined) parseLayouts(opts.layouts);
37
37
  // The account's brand goes on the template (--no-brand: the template's look);
38
38
  // an explicit --presenter-style wins over the brand's.
@@ -7,7 +7,8 @@
7
7
  <!-- Loading state (keep verbatim; `hyperdecks check` requires it). Until the
8
8
  player marks body[data-hd-ready] the page shows only the slide card, at its
9
9
  final size, in the deck's --hd-ground (its first slide's colour) on the
10
- page's brand colour, with a quiet loader in --hd-ground-ink; then the
10
+ page's brand colour, with the opening card over it (the title and the
11
+ presenter's still), or in a frame quiet dots in --hd-ground-ink; then the
11
12
  slides fade in. If the player never arrives, the slides show after 10 s
12
13
  rather than never. Put data-layout="overlay" on <html> for the overlay. -->
13
14
  <link rel="modulepreload" href="https://hyperdecks.ai/player/v1/player.js">
@@ -21,7 +22,7 @@
21
22
  /* The card as the player draws it (player.css, framed): 16:9, scaled to fit
22
23
  between the 64px masthead and the 56px dock, 24px in from the sides. */
23
24
  body::before { inset: 64px 24px 56px; margin: auto; width: min(100vw - 48px, (100dvh - 120px) * 16 / 9); height: min(100dvh - 120px, (100vw - 48px) * 9 / 16); border-radius: calc(min(100vw - 48px, (100dvh - 120px) * 16 / 9) * .01125); background: var(--hd-ground, #fff); box-shadow: 0 0 0 1px color-mix(in srgb, var(--hd-ground-ink, #0b0b0f) 16%, transparent); }
24
- body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }
25
+ [data-hd-frame] body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }
25
26
  body[data-hd-ready]::before, body[data-hd-ready]::after { opacity: 0; animation: none; }
26
27
  /* The overlay (embedded, ?thumb, or data-layout="overlay"): the stage fills the window. */
27
28
  :is([data-hd-frame], [data-layout="overlay"]) body::before { inset: 0; width: min(100vw, 100dvh * 16 / 9); height: min(100dvh, 100vw * 9 / 16); border-radius: calc(min(100vw, 100dvh * 16 / 9) * .01125); box-shadow: none; }
@@ -41,7 +42,75 @@
41
42
  @keyframes hd-in { from { opacity: 0; } }
42
43
  @keyframes hd-dots { from { opacity: .15; } to { opacity: .6; } }
43
44
  @keyframes hd-fail-open { to { visibility: visible; overflow: auto; } }
45
+ #hd-open{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:visible!important;animation:none!important;opacity:0;font-family:var(--hd-ui-font,"Space Grotesk",system-ui,sans-serif);color:#0b0b0f}
46
+ #hd-open.is-shown{opacity:1;transition:opacity .2s ease-out}
47
+ #hd-open.is-gone{opacity:0;transition:opacity .45s ease-out}
48
+ #hd-open .hdo-slide{position:absolute;inset:64px 24px 56px;margin:auto;width:min(100vw - 48px,(100dvh - 120px)*16/9);height:min(100dvh - 120px,(100vw - 48px)*9/16);border-radius:calc(min(100vw - 48px,(100dvh - 120px)*16/9)*.01125);overflow:hidden;container-type:inline-size;background:var(--hd-ground,#fff)}
49
+ #hd-open .hdo-slide img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;filter:blur(2.2cqi) saturate(1.15);transform:scale(1.12);animation:hdo-breathe 2.4s ease-in-out infinite}
50
+ #hd-open .hdo-slide::after{content:"";position:absolute;inset:0;background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.3) 50%,transparent 70%);background-size:250% 100%;animation:hdo-shim 1.8s ease-in-out infinite;mix-blend-mode:soft-light}
51
+ #hd-open.is-done .hdo-slide img{animation:hdo-deblur .5s cubic-bezier(.2,.8,.2,1) .5s forwards}
52
+ #hd-open.is-done .hdo-slide::after{opacity:0;transition:opacity .3s}
53
+ .hdo-card{position:absolute;left:50%;top:50%;translate:-50% -50%;width:min(86vw,420px);display:grid;justify-items:center;gap:.6em;padding:1.3em 1.5em 1.2em;border-radius:22px;background:rgba(255,255,255,.74);-webkit-backdrop-filter:blur(14px) saturate(1.3);backdrop-filter:blur(14px) saturate(1.3);box-shadow:4px 4px 0 #0b0b0f;border:1.5px solid #0b0b0f;text-align:center;box-sizing:border-box}
54
+ #hd-open.is-done .hdo-card{opacity:0;transform:scale(.96);transition:opacity .25s .5s,transform .25s .5s}
55
+ .hdo-buddy{position:relative;width:92px;height:96px;margin-top:-62px;display:grid;justify-items:center}
56
+ .hdo-hop{width:86px;height:80px;transform-origin:50% 100%;animation:hdo-hop 1.15s cubic-bezier(.3,0,.4,1) infinite}
57
+ .hdo-look{width:100%;height:100%;animation:hdo-look 4.6s ease-in-out infinite}
58
+ .hdo-buddy img{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 3px 0 rgba(11,11,15,.18))}
59
+ .hdo-shadow{position:absolute;bottom:4px;width:46px;height:8px;border-radius:50%;background:rgba(11,11,15,.22);animation:hdo-shadow 1.15s cubic-bezier(.3,0,.4,1) infinite}
60
+ .hdo-think{position:absolute;right:-30px;top:-6px;display:flex;gap:3px;padding:5px 8px;border-radius:14px;background:#fff;border:1.5px solid #0b0b0f;box-shadow:2px 2px 0 #0b0b0f}
61
+ .hdo-think::after{content:"";position:absolute;left:4px;bottom:-7px;width:7px;height:7px;border-radius:50%;background:#fff;border:1.5px solid #0b0b0f}
62
+ .hdo-think i{width:5px;height:5px;border-radius:50%;background:#0b0b0f;animation:hdo-tdot 1s ease-in-out infinite}
63
+ .hdo-think i:nth-child(2){animation-delay:.15s}.hdo-think i:nth-child(3){animation-delay:.3s}
64
+ .hdo-spark{position:absolute;left:50%;top:40%;width:8px;height:8px;margin:-4px;opacity:0}
65
+ .hdo-spark::before{content:"";position:absolute;inset:0;background:#0b0b0f;clip-path:polygon(50% 0,62% 38%,100% 50%,62% 62%,50% 100%,38% 62%,0 50%,38% 38%)}
66
+ #hd-open.is-done .hdo-hop{animation:hdo-yay .55s cubic-bezier(.2,1.4,.4,1) forwards}
67
+ #hd-open.is-done :is(.hdo-look,.hdo-shadow){animation:none}
68
+ #hd-open.is-done .hdo-think{opacity:0;transition:opacity .15s}
69
+ #hd-open.is-done .hdo-spark{animation:hdo-spark .6s ease-out forwards}
70
+ .hdo-title{font-weight:700;font-size:18px;line-height:1.2;text-wrap:balance;overflow-wrap:anywhere}
71
+ .hdo-by{font:12.5px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#3d3b35}
72
+ .hdo-bar{width:100%;height:5px;border-radius:999px;background:rgba(11,11,15,.12);overflow:hidden}
73
+ .hdo-bar i{display:block;height:100%;width:8%;background:#0b0b0f;border-radius:999px;transition:width .35s ease-out}
74
+ .hdo-step{font:12px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#55534b;min-height:1.3em}
75
+ @keyframes hdo-breathe{50%{filter:blur(2.6cqi) saturate(1.25) brightness(1.05)}}
76
+ @keyframes hdo-shim{0%{background-position:120% 0}100%{background-position:-120% 0}}
77
+ @keyframes hdo-deblur{to{filter:blur(0);transform:none}}
78
+ @keyframes hdo-hop{0%,100%{transform:translateY(0) scale(1.06,.92)}12%{transform:translateY(0) scale(.95,1.06)}45%{transform:translateY(-14px) scale(1,1)}70%{transform:translateY(0) scale(1.04,.95)}82%{transform:translateY(0) scale(1,1)}}
79
+ @keyframes hdo-shadow{0%,100%{transform:scale(1.05);opacity:.9}45%{transform:scale(.62);opacity:.45}70%{transform:scale(1);opacity:.85}}
80
+ @keyframes hdo-look{0%,18%{transform:rotate(0)}24%,38%{transform:rotate(-9deg) translateX(-2px)}44%,58%{transform:rotate(8deg) translateX(2px)}64%,100%{transform:rotate(0)}}
81
+ @keyframes hdo-tdot{0%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
82
+ @keyframes hdo-yay{0%{transform:translateY(0) scale(1.08,.9)}40%{transform:translateY(-26px) scale(.94,1.08) rotate(-6deg)}100%{transform:translateY(-6px) scale(1) rotate(0)}}
83
+ @keyframes hdo-spark{0%{opacity:0;transform:translate(0,0) scale(.4)}30%{opacity:1}100%{opacity:0;transform:translate(var(--x),var(--y)) scale(1.2) rotate(90deg)}}
84
+ :is([data-hd-frame],[data-layout="overlay"]) #hd-open .hdo-slide{inset:0;width:min(100vw,100dvh*16/9);height:min(100dvh,100vw*9/16)}
85
+ @media (max-width:650px){#hd-open .hdo-slide{inset:52px 8px calc(56px + env(safe-area-inset-bottom));width:auto;height:auto;border-radius:14px}.hdo-card{width:min(84vw,320px);padding:1em 1.1em .95em}.hdo-title{font-size:15.5px}.hdo-buddy{transform:scale(.85);margin-top:-58px}}
86
+ @media (prefers-reduced-motion:reduce){#hd-open *,#hd-open *::before,#hd-open *::after{animation:none!important;transition:none!important}#hd-open .hdo-slide::after{display:none}#hd-open.is-done .hdo-card{opacity:1;transform:none}}
87
+ @media print{#hd-open{display:none!important}}
44
88
  </style>
89
+ <script data-hd-opening>(function(){var d=document;
90
+ if(self!==top||/[?&](thumb|export)\b/.test(location.search))return;
91
+ function go(){if(d.readyState==="loading")return;d.removeEventListener("readystatechange",go);
92
+ var b=d.body,p=d.querySelector('script[src*="/player/v"][src*="/player.js"]');
93
+ if(!b||!p||d.getElementById("hd-open")||b.hasAttribute("data-hd-ready")||d.documentElement.hasAttribute("data-hyperdecks-thumb"))return;
94
+ var o=d.createElement("div"),img=p.getAttribute("data-presenter")!=="none"&&p.getAttribute("data-presenter-image");
95
+ o.id="hd-open";o.setAttribute("aria-hidden","true");
96
+ o.innerHTML='<div class="hdo-slide"></div><div class="hdo-card">'+(img?"<div class=\"hdo-buddy\"><div class=\"hdo-hop\"><div class=\"hdo-look\"><img src=\"\" alt=\"\"></div></div><div class=\"hdo-shadow\"></div><div class=\"hdo-think\"><i></i><i></i><i></i></div><span class=\"hdo-spark\" style=\"--x:-40px;--y:-30px\"></span><span class=\"hdo-spark\" style=\"--x:42px;--y:-34px\"></span><span class=\"hdo-spark\" style=\"--x:-30px;--y:10px\"></span><span class=\"hdo-spark\" style=\"--x:36px;--y:6px\"></span><span class=\"hdo-spark\" style=\"--x:0px;--y:-46px\"></span></div>":'')+'<div class="hdo-title"></div><div class="hdo-bar"><i></i></div><div class="hdo-step"></div></div>';
97
+ if(img){var i=o.querySelector(".hdo-buddy img");i.addEventListener("error",function(){var y=o.querySelector(".hdo-buddy");if(y)y.remove()});i.src=img}
98
+ o.querySelector(".hdo-title").textContent=(d.title||"").replace(/\s+/g," ").trim();
99
+ b.insertBefore(o,b.firstChild);(function(o){var b=document.body,d=document.documentElement;if(!o||!b)return;
100
+ if(self!==top||/[?&](thumb|export)\b/.test(location.search)||d.hasAttribute("data-hyperdecks-thumb")){o.remove();return}
101
+ var S={"page":[10,"Opening the deck…"],"player":[38,"Loading the player…"],"presenter":[62,"Waking up {name}…"],"narration":[84,"Getting the narration…"]},bar=o.querySelector(".hdo-bar i"),step=o.querySelector(".hdo-step"),who=o.getAttribute("data-name")||"the presenter",at=0,shown=false,done=false,mo;
102
+ var rm=window.matchMedia&&matchMedia("(prefers-reduced-motion: reduce)").matches;
103
+ function boot(){var s=S[b.getAttribute("data-hd-boot")];if(!s||s[0]<=at)return;at=s[0];bar.style.width=s[0]+"%";step.textContent=s[1].split("{name}").join(who)}
104
+ function finish(){if(done)return;done=true;if(mo)mo.disconnect();clearTimeout(t1);clearTimeout(t2);
105
+ if(!shown){o.remove();return}bar.style.width="100%";step.textContent="Ready";
106
+ o.classList.add("is-done");setTimeout(function(){o.classList.add("is-gone")},rm?0:500);setTimeout(function(){o.remove()},rm?500:1000)}
107
+ function check(){if(b.hasAttribute("data-hd-ready"))finish();else boot()}
108
+ var t1=setTimeout(function(){if(!done){shown=true;o.classList.add("is-shown")}},150);
109
+ var t2=setTimeout(finish,10000);
110
+ step.textContent=S.page[1];
111
+ if(window.MutationObserver){mo=new MutationObserver(check);mo.observe(b,{attributes:true,attributeFilter:["data-hd-ready","data-hd-boot"]})}
112
+ check()})(o)}
113
+ d.addEventListener("readystatechange",go);go()})();</script>
45
114
  <link rel="preconnect" href="https://fonts.googleapis.com">
46
115
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
47
116
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Space+Mono:wght@400;700&display=swap">
@@ -141,20 +210,20 @@
141
210
  -webkit-mask-image: radial-gradient(circle at 100% 0, #000 45%, rgba(0,0,0,.4) 100%); mask-image: radial-gradient(circle at 100% 0, #000 45%, rgba(0,0,0,.4) 100%); }
142
211
  .l-closer.bg-corner::before { --corner-r: 460px; }
143
212
  .on-ink.bg-corner::before { --corner-a: 22%; }
144
- :is(.l-split, .l-product, .l-chart, .l-quote, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-corner::before { content: none; }
213
+ :is(.l-split, .l-product, .l-chart, .l-quote, .l-number, .l-features, .l-team, .l-timeline, .l-compare, .l-cover-band, .l-cover-poster).bg-corner::before { content: none; }
145
214
  /* bg-stage: a rounded dot plate exactly the picture's column plus 32px, so the frame or the phones stand on it; on the hero a tile behind the stickers. */
146
215
  .slide.bg-stage::before { inset: auto; background: radial-gradient(color-mix(in srgb, currentColor var(--stage-a, 24%), transparent) 2.4px, transparent 2.9px) 0 0 / 22px 22px; border-radius: 40px; }
147
216
  .l-product.bg-stage::before { position: relative; grid-area: frame; align-self: stretch; margin: var(--stage-m, -32px -24px -32px -40px); }
148
217
  .l-phones.bg-stage::before { position: relative; grid-area: row; align-self: stretch; margin: var(--stage-m, -32px); }
149
218
  .l-hero.bg-stage::before { right: 110px; top: 110px; width: 410px; height: 350px; }
150
- :is(.l-split, .l-chart, .l-quote, .l-closer, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-stage::before { content: none; }
219
+ :is(.l-split, .l-chart, .l-quote, .l-closer, .l-number, .l-features, .l-team, .l-timeline, .l-compare, .l-cover-band, .l-cover-poster, .l-cover-type).bg-stage::before { content: none; }
151
220
  /* bg-mark: the slide number, set as style="--mark:'04'" on the section, huge in --display, faint,
152
221
  cropped by the top edge; right on the hero, in the indent on slot-left slides. A signature: cover, an opener, closer. */
153
222
  .slide.bg-mark::before { content: var(--mark, ""); inset: auto; right: 24px; top: -.26em; font: var(--display-weight, 700) var(--mark-size, 560px)/1 var(--display, var(--font)); font-synthesis: none; letter-spacing: var(--display-tracking, -.05em); white-space: nowrap;
154
223
  color: color-mix(in srgb, currentColor var(--mark-a, 8%), transparent); }
155
224
  .slide.on-ink.bg-mark::before { --mark-a: 6%; }
156
225
  .slide.slot-left.bg-mark::before { right: auto; left: -.08em; font-size: var(--mark-size, 440px); } /* ends inside the 500px indent, clear of the title */
157
- :is(.l-split, .l-product, .l-chart, .l-phones, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-mark::before { content: none; }
226
+ :is(.l-split, .l-product, .l-chart, .l-phones, .l-number, .l-features, .l-team, .l-timeline, .l-compare, .l-cover-band, .l-cover-poster).bg-mark::before { content: none; }
158
227
  /* Pieces. .sticker: a logo or icon tile, tilted (--tilt), ink edge, hard offset
159
228
  shadow (.sticker.photo for a face or an app icon that fills it). .ticks: a short
160
229
  list with accent-square bullets. .panel: a quiet card. .pill: a small tag. */
@@ -170,7 +239,7 @@
170
239
  .pill { display: inline-flex; align-items: center; gap: 8px; width: fit-content; padding: 8px 16px; border-radius: 999px; background: var(--card); color: var(--ink); font: 700 var(--t-label) var(--mono); letter-spacing: .08em; text-transform: uppercase; box-shadow: 4px 4px 0 var(--accent); }
171
240
  .on-accent .pill { box-shadow: 4px 4px 0 var(--accent-ink); }
172
241
  /* A laptop for a desktop screenshot or clip: a .frame.laptop holding an img.shot or video.shot. */
173
- .slide .frame.laptop { position: relative; width: 860px; max-width: 100%; padding: 14px 14px 22px; border-radius: 20px 20px 6px 6px; background: #1a1a1d; box-shadow: 0 30px 70px color-mix(in srgb, var(--ink) 22%, transparent); }
242
+ .slide .frame.laptop { position: relative; width: min(860px, 100%); padding: 14px 14px 22px; border-radius: 20px 20px 6px 6px; background: #1a1a1d; box-shadow: 0 30px 70px color-mix(in srgb, var(--ink) 22%, transparent); }
174
243
  .slide .frame.laptop::after { content: ""; position: absolute; left: -6%; right: -6%; bottom: -16px; height: 16px; border-radius: 0 0 16px 16px; background: linear-gradient(#d9d9de, #a9a9b0); }
175
244
  .slide .frame.laptop > .shot { display: block; width: 100%; aspect-ratio: 16 / 10; border-radius: 6px; object-fit: cover; object-position: top; background: var(--paper); }
176
245
  /* Phones: the slide is the 374×736 card; the presenter is a small head in
@@ -195,7 +264,7 @@
195
264
  .panel { padding: 10px 12px; border-radius: 12px; }
196
265
  .pill { padding: 4px 10px; box-shadow: 2px 2px 0 var(--accent); }
197
266
  .slide .frame.laptop { padding: 5px 5px 9px; border-radius: 10px 10px 3px 3px; }
198
- .slide .frame.laptop::after { bottom: -7px; height: 7px; border-radius: 0 0 8px 8px; }
267
+ .slide .frame.laptop::after { left: -3%; right: -3%; bottom: -7px; height: 7px; border-radius: 0 0 8px 8px; }
199
268
  }
200
269
  </style>
201
270
  <!-- LAYOUT STYLES -->
@@ -12,6 +12,8 @@ script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [g
12
12
  .l-closer .content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 32px; padding-left: 500px; }
13
13
  .l-closer .tt { font-size: var(--t-display); line-height: .9; }
14
14
  .l-closer .cta { display: inline-flex; align-items: center; gap: 16px; width: fit-content; max-width: 100%; padding: 22px 34px; border-radius: 22px; background: var(--accent); color: var(--accent-ink); font: 700 34px var(--mono); letter-spacing: -.01em; text-decoration: none; }
15
+ /* The arrow is an SVG, not "→": Space Mono's (and Space Grotesk's) web subsets have no U+2192, so the glyph fell back to another face and read as malformed. */
16
+ .l-closer .cta svg { flex: none; width: 1em; height: 1em; }
15
17
  .l-closer .contact { font: var(--t-label) var(--mono); letter-spacing: .12em; text-transform: uppercase; }
16
18
  @media (max-width: 650px) {
17
19
  .l-closer .content { padding-left: 0; justify-content: flex-start; gap: 16px; }
@@ -26,7 +28,7 @@ script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [g
26
28
  <div class="content">
27
29
  <h2 class="tt" data-title-mobile="Thanks.">Thanks.</h2>
28
30
  <p class="lead">One sentence on the one thing to do next.</p>
29
- <a class="cta" id="__ID__-cta" href="https://example.com" target="_blank" rel="noopener">example.com →</a>
31
+ <a class="cta" id="__ID__-cta" href="https://example.com" target="_blank" rel="noopener">example.com <svg class="lucide-arrow-right" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg></a>
30
32
  <div class="contact muted">your@email · your.site</div>
31
33
  </div>
32
34
  <div class="narration-slot"></div>
@@ -0,0 +1,44 @@
1
+ <!--
2
+ layout: cover-band
3
+ summary: A cover with the picture full bleed and the title on a solid, inked panel of the theme's paper in the bottom-left, so the title's contrast never depends on the picture.
4
+ use: the first slide when the picture is the story (a place, an object, a crowd) and wants the whole frame. Its .art is a placeholder check fails on: put in a wide picture of the real subject (gen-image / generate_image, 16:9) as <img class="art">; keep the subject top or top-right: the panel covers the bottom-left and the presenter the bottom-right. The panel grows with the title; never move the title off the panel onto the picture. Without art the panel sits on the slide's ground.
5
+ slot: bottom-right
6
+ presenter: stage
7
+ nopresenter: The panel may widen into the corner the presenter held.
8
+ cues: [wave] at the hello, [look: h1] when the narration says the title.
9
+ script: Hi, [wave] this is __TITLE__. [look: h1] One idea, in a few minutes.
10
+ -->
11
+ <style>
12
+ /* The picture fills the slide; the title sits on a solid panel of the theme's paper in the bottom-left, never on the picture. */
13
+ .l-cover-band { justify-content: flex-end; align-items: flex-start; overflow: hidden; }
14
+ /* A brand logo (applyBrand puts it first in the slide) sits top-left on the picture, not on the panel's edge. */
15
+ .l-cover-band:has(> .art) > .brand-logo { position: absolute; top: 28px; left: 72px; }
16
+ .l-cover-band .art { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
17
+ .l-cover-band div.art { background: color-mix(in srgb, var(--ink) 6%, var(--paper)) repeating-linear-gradient(135deg, color-mix(in srgb, var(--ink) 10%, transparent) 0 2px, transparent 2px 22px); }
18
+ .l-cover-band div.art::after { content: "Art goes here"; position: absolute; left: 70%; top: 34%; transform: translate(-50%, -50%); padding: 14px 26px; border: 3px dashed var(--ink); border-radius: 16px; background: var(--paper); color: var(--ink); font: 700 24px var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
19
+ .l-cover-band .content { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 18px; max-width: 980px; padding: 36px 44px 40px; border: 4px solid var(--ink); border-radius: 28px; background: var(--paper); color: var(--ink); box-shadow: 14px 14px 0 var(--ink); }
20
+ .l-cover-band .tt { font-size: min(var(--t-display), 96px); line-height: .92; }
21
+ .l-cover-band:not(:has(> .art)) { justify-content: center; }
22
+ @media (max-width: 650px) {
23
+ /* Phones: the picture across the top, the panel below it, down to the presenter's corner. */
24
+ .l-cover-band { justify-content: flex-start; align-items: stretch; gap: 0; }
25
+ .l-cover-band .art { position: relative; inset: auto; flex: none; width: auto; height: 210px; margin: -28px -20px 0; }
26
+ .l-cover-band div.art::after { left: 50%; top: 50%; padding: 8px 14px; border-width: 2px; font-size: 13px; }
27
+ .l-cover-band .content { gap: 12px; margin-top: -36px; padding: 18px 18px 20px; border-width: 2px; border-radius: 16px; box-shadow: 6px 6px 0 var(--ink); }
28
+ .l-cover-band .tt { font-size: var(--t-display); }
29
+ .l-cover-band:has(> .art) > .brand-logo { top: 12px; left: 20px; }
30
+ }
31
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
32
+ @media (min-width: 651px) {
33
+ :root.hd-no-presenter .l-cover-band .content { max-width: 1200px; }
34
+ }
35
+ </style>
36
+ <section class="slide l-cover-band" id="__ID__" data-chapter="Hello" data-note="Title slide: no facts.">
37
+ <div class="art" role="presentation"></div>
38
+ <div class="content">
39
+ <div class="kicker">__SLUG__</div>
40
+ <h1 class="tt" data-title-mobile="__TITLE__">__TITLE__</h1>
41
+ <p class="lead">One sentence on what this deck is about.</p>
42
+ </div>
43
+ <div class="narration-slot"></div>
44
+ </section>
@@ -0,0 +1,42 @@
1
+ <!--
2
+ layout: cover-poster
3
+ summary: A poster cover: the picture across the top, edge to edge, and the title below it on the slide's ground. The picture takes whatever height the title leaves, so a long title shortens the picture instead of covering it.
4
+ use: the first slide when the picture is a scene or a landscape that crops well to a wide strip. Its .art is a placeholder check fails on: put in a wide picture of the real subject (gen-image / generate_image, 16:9 or wider) as <img class="art">; keep the subject in the middle band, the strip crops top and bottom. Never move the title up onto the picture. Without art the title fills the slide.
5
+ slot: bottom-right
6
+ presenter: stage
7
+ nopresenter: The title and lead run the slide's full width instead of stopping short of the corner.
8
+ cues: [wave] at the hello, [look: h1] when the narration says the title.
9
+ script: Hi, [wave] this is __TITLE__. [look: h1] One idea, in a few minutes.
10
+ -->
11
+ <style>
12
+ /* The picture is a flexible strip in the slide's flow; the text below it keeps its natural height. */
13
+ .l-cover-poster { gap: 0; }
14
+ /* A brand logo (applyBrand puts it first in the slide) sits on the picture's top-left, out of the flow, so the strip still starts at the top edge. */
15
+ .l-cover-poster:has(> .art) > .brand-logo { position: absolute; top: 28px; left: 72px; }
16
+ .l-cover-poster .art { display: block; flex: 1 1 0; min-height: 240px; width: auto; margin: -64px -72px 0; object-fit: cover; border-bottom: 4px solid var(--ink); }
17
+ .l-cover-poster div.art { position: relative; background: color-mix(in srgb, var(--ink) 6%, var(--paper)) repeating-linear-gradient(135deg, color-mix(in srgb, var(--ink) 10%, transparent) 0 2px, transparent 2px 22px); }
18
+ .l-cover-poster div.art::after { content: "Art goes here"; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 14px 26px; border: 3px dashed var(--ink); border-radius: 16px; background: var(--paper); color: var(--ink); font: 700 24px var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
19
+ .l-cover-poster .content { flex: none; display: flex; flex-direction: column; gap: 18px; padding: 40px 400px 0 0; }
20
+ .l-cover-poster .tt { font-size: min(var(--t-display), 104px); line-height: .92; }
21
+ .l-cover-poster:not(:has(> .art)) .content { flex: 1; justify-content: center; padding-top: 0; }
22
+ @media (max-width: 650px) {
23
+ .l-cover-poster .art { flex: none; height: 196px; min-height: 0; margin: -28px -20px 0; border-bottom-width: 2px; }
24
+ .l-cover-poster div.art::after { padding: 8px 14px; border-width: 2px; font-size: 13px; }
25
+ .l-cover-poster .content { gap: 12px; padding: 18px 0 0; }
26
+ .l-cover-poster:has(> .art) > .brand-logo { top: 12px; left: 20px; }
27
+ .l-cover-poster .tt { font-size: var(--t-display); }
28
+ }
29
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
30
+ @media (min-width: 651px) {
31
+ :root.hd-no-presenter .l-cover-poster .content { padding-right: 0; }
32
+ }
33
+ </style>
34
+ <section class="slide l-cover-poster" id="__ID__" data-chapter="Hello" data-note="Title slide: no facts.">
35
+ <div class="art" role="presentation"></div>
36
+ <div class="content">
37
+ <div class="kicker">__SLUG__</div>
38
+ <h1 class="tt" data-title-mobile="__TITLE__">__TITLE__</h1>
39
+ <p class="lead">One sentence on what this deck is about.</p>
40
+ </div>
41
+ <div class="narration-slot"></div>
42
+ </section>