@hypersocial/decks-cli 0.9.7 → 0.9.9

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/src/preview.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
2
2
  import { resolve, extname } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { api, client, target, uploadBytes } from "./runtime.mjs";
4
+ import { api, client, linkBase, target, uploadBytes } from "./runtime.mjs";
5
5
 
6
6
  const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
7
7
  export function httpUrl(value) {
@@ -58,7 +58,7 @@ export async function createPreview(pos, opts, adapters = {}) {
58
58
  if (opts.share) {
59
59
  if (deck.status === 'archived') throw new Error('Archived deck cannot provide a sendable share preview');
60
60
  validateShare(await c.query(api.shares.listForDeck, { deckSlug: config.slug }), config.slug, opts.share);
61
- shareUrl = httpUrl(`${target(opts).shareBase}/v/${opts.share}`);
61
+ shareUrl = httpUrl(`${await linkBase(opts)}/v/${opts.share}`);
62
62
  }
63
63
  html = deck.html;
64
64
  if (!html && deck.htmlUrl) {
package/src/release.mjs CHANGED
@@ -12,13 +12,16 @@
12
12
  // folder: script.md, evidence.md and narration/ stay private);
13
13
  // 5. creates one share per --to (paired in order with --email) and prints the URLs.
14
14
  import { execFileSync } from "node:child_process";
15
+ import { createHash } from "node:crypto";
15
16
  import { readFileSync } from "node:fs";
16
17
  import { join } from "node:path";
17
18
  import { zipSync } from "fflate";
18
19
  import * as ops from "./ops.mjs";
19
- import { withAvatar } from "./presenter.mjs";
20
- import { target } from "./runtime.mjs";
21
- import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
20
+ import { releasedPresenter, withAvatar } from "./presenter.mjs";
21
+ import { linkBase } from "./runtime.mjs";
22
+ import { checkDeck, deckFiles, deckFolder, deckPathResolver, resolveDeckDir } from "./check.mjs";
23
+ import { mediaRefs, withMediaPreviews } from "./mediaPreviews.mjs";
24
+ import { bundledFfmpeg, HTTPS_TOO, makePreview } from "./mediaPreviewMake.mjs";
22
25
  import { narrate as runNarrate } from "./narrate.mjs";
23
26
 
24
27
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -54,19 +57,79 @@ export function deckCommit(deckDir) {
54
57
  // it brought in, so release can say what came from where.
55
58
  // `presenter` (from avatars.presenterFor) puts the owner's avatar on the
56
59
  // uploaded page's player tag; the committed index.html is never changed.
57
- export function releaseBundle(deckDir, presenter = null) {
60
+ // `media` (from mediaPreviewsFor) writes the blur previews onto the uploaded
61
+ // page and adds the posters it made; like the avatar, never to the commit.
62
+ export function releaseBundle(deckDir, presenter = null, media = null) {
58
63
  const raw = readFileSync(join(deckDir, "index.html"));
59
- const html = presenter ? Buffer.from(withAvatar(raw.toString("utf8"), presenter)) : raw;
64
+ const page = presenter ? withAvatar(raw.toString("utf8"), presenter) : raw.toString("utf8");
60
65
  // check has already refused refs that leave the folder or do not resolve.
61
- const found = deckFiles(html.toString("utf8"), deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
66
+ const found = deckFiles(page, deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
67
+ const html = media ? Buffer.from(withMediaPreviews(page, media.previews).html) : presenter ? Buffer.from(page) : raw;
68
+ const made = Object.entries(media?.files ?? {});
62
69
  const added = {};
63
70
  for (const f of found) if (f.from !== "html") (added[f.from] ??= []).push(f.path);
64
- if (!found.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
71
+ if (!found.length && !made.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
65
72
  const files = { "index.html": html };
66
73
  for (const f of found) files[f.path] = readFileSync(join(deckDir, f.path));
74
+ for (const [path, bytes] of made) files[path] = bytes;
67
75
  return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files), added };
68
76
  }
69
77
 
78
+ // Blur previews (and posters for videos without one) for the media the page
79
+ // shows: local files inside the deck folder and https URLs, read by ffmpeg.
80
+ // Returns { previews, files, failed }: previews keyed by src as written, the
81
+ // poster files to bundle (hd-media/<hash>.jpg), and what could not be read. A
82
+ // preview is an extra, never a reason to stop a release.
83
+ export async function mediaPreviewsFor(deckDir, html, { make = makePreview, ffmpeg } = {}) {
84
+ const { images, videos } = mediaRefs(html);
85
+ const previews = {}, files = {}, failed = [];
86
+ if (!images.length && !videos.length) return { previews, files, failed };
87
+ let bin = ffmpeg;
88
+ try {
89
+ bin ??= await bundledFfmpeg();
90
+ } catch (e) {
91
+ return { previews, files, failed: [...images, ...videos].map((src) => ({ src, why: `no ffmpeg (${e.message})` })) };
92
+ }
93
+ const inside = deckPathResolver(deckDir);
94
+ const inputOf = (src) => {
95
+ if (/^https:\/\//i.test(src)) return src;
96
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(src)) return null; // http:, file:, protocol-relative: not read
97
+ let path;
98
+ try {
99
+ path = decodeURIComponent(src.split(/[?#]/)[0]).replace(/^\.\//, "");
100
+ } catch {
101
+ return null;
102
+ }
103
+ return inside(path) === true ? join(deckDir, path) : null;
104
+ };
105
+ const jobs = [...images.map((src) => ({ src, video: false })), ...videos.map((src) => ({ src, video: true }))];
106
+ const work = async ({ src, video }) => {
107
+ const input = inputOf(src);
108
+ if (!input) return failed.push({ src, why: "not a file in the deck folder or an https URL" });
109
+ try {
110
+ // A URL input may use https (the user's own machine reads it); a file stays local-only.
111
+ const p = await make(bin, input, { video, ...(input === src ? { allow: HTTPS_TOO } : {}) });
112
+ if (p.poster) {
113
+ const path = `hd-media/${createHash("sha256").update(src).digest("hex").slice(0, 16)}.jpg`;
114
+ files[path] = p.poster;
115
+ p.poster = path;
116
+ }
117
+ previews[src] = p;
118
+ } catch (e) {
119
+ failed.push({ src, why: e.message });
120
+ }
121
+ };
122
+ // A few at a time: each is an ffmpeg process.
123
+ for (let i = 0; i < jobs.length; i += 4) await Promise.all(jobs.slice(i, i + 4).map(work));
124
+ return { previews, files, failed };
125
+ }
126
+
127
+ function logPreviews(log, media) {
128
+ const n = Object.keys(media.previews).length;
129
+ if (n) log(`✓ blur previews for ${n} image(s)/video(s)${Object.keys(media.files).length ? `, ${Object.keys(media.files).length} poster(s) made` : ""}`);
130
+ for (const f of media.failed) log(` ⚠ no blur preview for ${f.src}: ${f.why}`);
131
+ }
132
+
70
133
  // From the CLI, parse() collects opts.recipients in order (each --email follows
71
134
  // its --to); MCP passes the same shape.
72
135
  export function recipients(opts) {
@@ -77,7 +140,7 @@ export function recipients(opts) {
77
140
  }
78
141
 
79
142
  export async function release(pos, opts = {}, io = {}) {
80
- const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd, measure, measureFit } = io;
143
+ const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, previews = mediaPreviewsFor, fetch, cwd, measure, measureFit } = io;
81
144
  const deckDir = resolveDeckDir(pos[0], cwd);
82
145
  const to = recipients(opts);
83
146
 
@@ -97,7 +160,15 @@ export async function release(pos, opts = {}, io = {}) {
97
160
  for (const w of checked.warnings) log(` ⚠ ${w}`);
98
161
  for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
99
162
 
100
- if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: bundle, summary: null });
163
+ let media;
164
+ try {
165
+ media = await previews(deckDir, readFileSync(join(deckDir, "index.html"), "utf8"));
166
+ logPreviews(log, media);
167
+ } catch (e) {
168
+ media = null; // a preview is an extra: the release goes on without them
169
+ log(` ⚠ no blur previews: ${e.message}`);
170
+ }
171
+ if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: releaseBundle(deckDir, null, media), summary: null });
101
172
  // Cached slides cost nothing, so this only renders what changed since the commit.
102
173
  // Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
103
174
  const narrateOpts = { ...opts, slug };
@@ -112,16 +183,20 @@ export async function release(pos, opts = {}, io = {}) {
112
183
  }
113
184
  // The released page gets the narrator's face: your avatar, or D.A.S.H.'s for its voice.
114
185
  let presenter = null;
186
+ let lookupError;
115
187
  try {
116
188
  presenter = await ops.presenterFor(opts, slug, opts.voice);
117
189
  } catch (e) {
118
- log(`⚠ could not look up your avatar (${ops.errorText(e)}); releasing captions only`);
190
+ lookupError = ops.errorText(e);
119
191
  }
120
- const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
121
- if (presenter) log(`✓ presented by ${job.presenter?.face ?? "your avatar"}`);
192
+ const released = releaseBundle(deckDir, presenter, media);
193
+ // Say who presents from the page that ships, never from the lookup alone: a
194
+ // resolved face that did not land on the player tag must not be claimed, and
195
+ // a failed lookup is captions only only when the tag names no face of its own.
196
+ const said = releasedPresenter(readFileSync(join(deckDir, "index.html"), "utf8"), presenter, job.presenter, { lookupError });
122
197
  // A terminal user sees this before it ships, so the CLI warns rather than refuses.
123
- else if (job.presenter && !job.presenter.face) log(`⚠ releasing captions only: ${job.presenter.reason ?? "no face"}`);
124
- return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: job.presenter ?? null });
198
+ if (said.line) log(said.line);
199
+ return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: said.summary });
125
200
  }
126
201
 
127
202
  // Import the checked bundle and make the links: the end of every release.
@@ -137,7 +212,7 @@ async function publish({ opts, io: { log, commit }, deckDir, slug, to, checked,
137
212
  const sha = commit(deckDir);
138
213
  log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
139
214
 
140
- const base = target(opts).shareBase;
215
+ const base = await linkBase(opts);
141
216
  const shares = [];
142
217
  for (const r of to) {
143
218
  let token;
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.7";
11
+ export const VERSION = "0.9.9";
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
@@ -117,7 +117,9 @@ export const api = {
117
117
  },
118
118
  decks: {
119
119
  get: makeFunctionReference("decks:get"),
120
+ slugTaken: makeFunctionReference("decks:slugTaken"),
120
121
  listForViewer: makeFunctionReference("decks:listForViewer"),
122
+ listAll: makeFunctionReference("decks:listAll"),
121
123
  remove: makeFunctionReference("decks:remove"),
122
124
  setAllowForward: makeFunctionReference("decks:setAllowForward"),
123
125
  setView: makeFunctionReference("decks:setView"),
@@ -135,6 +137,7 @@ export const api = {
135
137
  setAllowForward: makeFunctionReference("shares:setAllowForward"),
136
138
  setEnabled: makeFunctionReference("shares:setEnabled"),
137
139
  setNotify: makeFunctionReference("shares:setNotify"),
140
+ linkOrigins: makeFunctionReference("shares:linkOrigins"),
138
141
  },
139
142
  imagegen: {
140
143
  generateImage: makeFunctionReference("imagegen:generateImage"),
@@ -191,7 +194,7 @@ export const api = {
191
194
  },
192
195
  };
193
196
 
194
- 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"]);
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"]);
195
198
  // release's recipients, in order: each --to starts one, and an --email right
196
199
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
197
200
  // opts.email stays the last --email for the other commands (share new).
@@ -259,6 +262,63 @@ export function target(opts = {}) {
259
262
  };
260
263
  }
261
264
 
265
+ // Where recipient links (/v/<token>) are printed: the server says
266
+ // (shares:linkOrigins, RECIPIENT_LINK_ORIGIN in lib/linkOrigin.ts), so links
267
+ // follow the deployment rather than a default baked into this version. Both app
268
+ // hosts serve every link; this only picks the one a new link names.
269
+ // HYPERDECKS_LINK_ORIGIN overrides it; the dev target keeps its share base.
270
+ export const DEFAULT_LINK_ORIGIN = "https://decks.hypersocial.ai";
271
+ const LINK_HOSTS = ["https://decks.hypersocial.ai", "https://hyperdecks.ai"];
272
+ let linkOriginPin = null;
273
+ const linkOriginCache = new Map();
274
+
275
+ // The hosted MCP pins it to the server's own setting (lib/serverCliHost.ts).
276
+ export function pinLinkOrigin(origin) {
277
+ linkOriginPin = origin ? normalizeBase(origin) : null;
278
+ linkOriginCache.clear();
279
+ }
280
+
281
+ const isLinkHost = (o) => typeof o === "string" && LINK_HOSTS.includes(normalizeBase(o.trim()).toLowerCase());
282
+
283
+ async function askLinkOrigin(c) {
284
+ try {
285
+ const out = await c.query(api.shares.linkOrigins, {});
286
+ // The server only ever names one of the two app hosts; print nothing else.
287
+ if (isLinkHost(out?.origin)) return normalizeBase(out.origin.trim()).toLowerCase();
288
+ console.warn(`warning: the server named no usable link origin; printing links on ${DEFAULT_LINK_ORIGIN}`);
289
+ } catch (e) {
290
+ // An older server without shares:linkOrigins stays quiet: links on the
291
+ // established host open everywhere. Anything else is worth a line.
292
+ const msg = String(e?.message ?? e);
293
+ if (!/Could not find public function/.test(msg)) console.warn(`warning: could not ask where to print links (${msg}); printing them on ${DEFAULT_LINK_ORIGIN}`);
294
+ }
295
+ return DEFAULT_LINK_ORIGIN;
296
+ }
297
+
298
+ export async function linkBase(opts = {}) {
299
+ const t = target(opts);
300
+ if (t.name === "dev") return t.shareBase;
301
+ const env = process.env.HYPERDECKS_LINK_ORIGIN?.trim();
302
+ if (env) {
303
+ if (isLinkHost(env) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/.test(env)) return normalizeBase(env).toLowerCase();
304
+ console.warn(`warning: HYPERDECKS_LINK_ORIGIN must be ${LINK_HOSTS.join(" or ")} (or a localhost URL); ignoring ${JSON.stringify(env)}`);
305
+ }
306
+ if (linkOriginPin) return linkOriginPin;
307
+ // A caller's own client (the hosted MCP, tests) is asked each time.
308
+ if (opts.client) return askLinkOrigin(opts.client);
309
+ if (!linkOriginCache.has(t.convexUrl)) linkOriginCache.set(t.convexUrl, askLinkOrigin(new ConvexHttpClient(t.convexUrl)));
310
+ return linkOriginCache.get(t.convexUrl);
311
+ }
312
+
313
+ // The same link on the other app host, for a recipient whose network blocks
314
+ // the printed one; null for a link that is not on an app host (dev).
315
+ export function backupLinkUrl(url) {
316
+ const m = /^(https?:\/\/[^/]+)(\/.*)$/.exec(String(url || ""));
317
+ if (!m) return null;
318
+ const i = LINK_HOSTS.indexOf(m[1].toLowerCase());
319
+ return i < 0 ? null : `${LINK_HOSTS[1 - i]}${m[2]}`;
320
+ }
321
+
262
322
  export function loadCreds() {
263
323
  if (!existsSync(CRED_FILE)) return {};
264
324
  try {
package/src/source.mjs CHANGED
@@ -112,7 +112,7 @@ export async function sourcePush(pos, opts = {}, { log = (line) => console.log(l
112
112
  return { slug, rev, files };
113
113
  }
114
114
 
115
- const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".woff2": "font/woff2", ".woff": "font/woff" };
115
+ const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".woff2": "font/woff2", ".woff": "font/woff", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".json": "application/json" };
116
116
 
117
117
  export async function assetAdd(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
118
118
  const [slug, from] = pos;
@@ -122,7 +122,7 @@ export async function assetAdd(pos, opts = {}, { log = (line) => console.log(lin
122
122
  else {
123
123
  const path = resolve(/*turbopackIgnore: true*/ cwd, from);
124
124
  const contentType = TYPES[extname(path).toLowerCase()];
125
- if (!contentType) throw new Error(`${from}: asset add takes images, video and fonts (${Object.keys(TYPES).join(" ")})`);
125
+ if (!contentType) throw new Error(`${from}: asset add takes images, video, audio, fonts and JSON (${Object.keys(TYPES).join(" ")})`);
126
126
  const c = await client(opts);
127
127
  const bytes = readFileSync(path);
128
128
  const storageId = await uploadBytes(c, bytes, contentType);
@@ -76,6 +76,12 @@
76
76
  .narration-slot { position: absolute; right: 56px; bottom: 64px; }
77
77
  .slot-left .narration-slot { right: auto; left: 56px; }
78
78
  .slot-top .narration-slot { top: 96px; bottom: auto; }
79
+ /* No presenter (no face AND no captions, e.g. presenter none on a
80
+ data-silent deck; or forced off by setPresenterOff): the player puts
81
+ .hd-no-presenter on the root and hides the slot, each layout's
82
+ no-presenter variant (:root.hd-no-presenter .l-…) uses the corner, and on
83
+ phones the slide drops its 170px bottom padding (below). Keep custom slides
84
+ compatible: leave the corner to .narration-slot, no hard-coded right gutter. */
79
85
  .reveal { opacity: 0; transform: translateY(12px); transition: opacity .5s, transform .5s; }
80
86
  .reveal.is-revealed { opacity: 1; transform: none; }
81
87
  /* The phone card (product's .frame.card, the phones row): a frameless phone,
@@ -136,6 +142,7 @@
136
142
  :root { --t-display: 54px; --t-title: 38px; --t-sub: 28px; --t-lead: 17px; --t-body: 15px; --t-label: 12px; }
137
143
  .slide { padding: 28px 20px 170px; gap: 14px; }
138
144
  .narration-slot { right: 12px; left: 12px; bottom: 64px; }
145
+ :root.hd-no-presenter .slide { padding-bottom: 28px; }
139
146
  .slide .frame.card { width: 160px; padding: 5px; border-width: 2px; border-radius: 24px; box-shadow: 8px 8px 0 var(--shadow); }
140
147
  .slide .frame.card > .shot { border-radius: 19px; border-width: 1px; }
141
148
  /* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
@@ -4,6 +4,7 @@ summary: A chart of real numbers with its takeaway in one line; the presenter be
4
4
  use: a trend or a comparison in numbers. Its svg is a placeholder of made-up numbers check fails on: draw the real data with `hyperdecks chart` (render_chart; never a generated picture of a chart) as <img class="chart"> and name the data's source.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
+ nopresenter: The chart spans both columns, under the takeaway.
7
8
  cues: [look: #__ID__-chart] when the narration turns to it; [reveal: #__ID__-takeaway] at the point.
8
9
  script: Here are the numbers. [look: #__ID__-chart] The last bar is the one to see. [reveal: #__ID__-takeaway]
9
10
  -->
@@ -23,6 +24,10 @@ script: Here are the numbers. [look: #__ID__-chart] The last bar is the one to s
23
24
  .l-chart .chart { height: 210px; flex: none; }
24
25
  .l-chart .takeaway { padding-left: 12px; border-left-width: 4px; }
25
26
  }
27
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
28
+ @media (min-width: 651px) {
29
+ :root.hd-no-presenter .l-chart .chart { grid-column: 1 / -1; }
30
+ }
26
31
  </style>
27
32
  <section class="slide l-chart" id="__ID__" data-chapter="The numbers" data-note="Data: name the source of the numbers and the period they cover.">
28
33
  <div class="kicker">The numbers</div>
@@ -4,6 +4,7 @@ summary: The last slide. One line, the one action to take (a command, a link) an
4
4
  use: the end. One call to action, never three. Label an ask that is not agreed as Proposed.
5
5
  slot: bottom-left
6
6
  presenter: stage
7
+ nopresenter: The line and the action drop the left gutter and re-centre in the full frame.
7
8
  cues: [go: #__ID__-cta] at the action, [go: slot] and [wave] at the goodbye.
8
9
  script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [go: slot] for watching! [wave]
9
10
  -->
@@ -16,6 +17,10 @@ script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [g
16
17
  .l-closer .content { padding-left: 0; justify-content: flex-start; gap: 16px; }
17
18
  .l-closer .cta { padding: 12px 16px; border-radius: 14px; font-size: 15px; }
18
19
  }
20
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
21
+ @media (min-width: 651px) {
22
+ :root.hd-no-presenter .l-closer .content { padding-left: 0; align-self: center; align-items: center; text-align: center; }
23
+ }
19
24
  </style>
20
25
  <section class="slide on-ink l-closer slot-left" id="__ID__" data-chapter="Next" data-note="Closing slide: the link or command is the deck's own call to action.">
21
26
  <div class="content">
@@ -4,6 +4,7 @@ summary: Before and after (or them and us) side by side, three short lines each.
4
4
  use: the change your idea makes. Keep the rows parallel so each line has its counterpart.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
+ nopresenter: The two columns widen to the full frame.
7
8
  cues: [look: #__ID__-before] on the old way, [go: #__ID__-after] on the new; [go: slot] to come home.
8
9
  script: Today it looks like this. [look: #__ID__-before] With us, [go: #__ID__-after] it looks like this. [go: slot]
9
10
  -->
@@ -21,6 +22,10 @@ script: Today it looks like this. [look: #__ID__-before] With us, [go: #__ID__-a
21
22
  .l-compare .label { margin-bottom: 4px; }
22
23
  .l-compare li { padding: 5px 0; }
23
24
  }
25
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
26
+ @media (min-width: 651px) {
27
+ :root.hd-no-presenter .l-compare .cols { max-width: none; }
28
+ }
24
29
  </style>
25
30
  <section class="slide l-compare" id="__ID__" data-chapter="Before and after" data-note="Source: name where each side's facts come from.">
26
31
  <div class="kicker">Before and after</div>
@@ -4,6 +4,7 @@ summary: Three features side by side, an icon, a name and one line each; the pre
4
4
  use: what it does, the three reasons, the three parts. Swap each icon for a real one: `hyperdecks icons search <word>` (search_icons) gives inline SVG in currentColor.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
+ nopresenter: The title and cards centre vertically and the cards grow a little, instead of leaving a strip below for the presenter.
7
8
  cues: [go: #__ID__-f1], [go: #__ID__-f2], [go: #__ID__-f3] at the word that starts each, then [go: slot].
8
9
  script: It does three things. First, [go: #__ID__-f1] it writes. Then [go: #__ID__-f2] it speaks. Last, [go: #__ID__-f3] it sends. [go: slot]
9
10
  -->
@@ -22,6 +23,11 @@ script: It does three things. First, [go: #__ID__-f1] it writes. Then [go: #__ID
22
23
  .l-features b { font-size: 18px; }
23
24
  .l-features p { font-size: 13px; }
24
25
  }
26
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
27
+ @media (min-width: 651px) {
28
+ :root.hd-no-presenter .l-features { justify-content: center; }
29
+ :root.hd-no-presenter .l-features .card { padding: 36px 34px; }
30
+ }
25
31
  </style>
26
32
  <section class="slide l-features" id="__ID__" data-chapter="What it does" data-note="Source: name the spec, docs or repo each feature is described in.">
27
33
  <div class="kicker">What it does</div>
@@ -4,6 +4,7 @@ summary: The opening. One display-size line, a visual on the right, the presente
4
4
  use: the first slide; a section opener. Its .art is a placeholder check fails on: put in a picture of the real subject (gen-image / generate_image) as <img class="art">, or delete the div and let the presenter be the visual.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
+ nopresenter: With art (the placeholder div counts), unchanged: the art already fills the right. Delete the art and the line re-centres in the full frame.
7
8
  cues: [wave] at the hello, [look: h1] when the narration says the title.
8
9
  script: Hi, [wave] this is __TITLE__. [look: h1] One idea, in a few minutes.
9
10
  -->
@@ -21,6 +22,10 @@ script: Hi, [wave] this is __TITLE__. [look: h1] One idea, in a few minutes.
21
22
  .l-hero .art { width: 100%; height: 230px; -webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent); mask-image: linear-gradient(to bottom, #000 60%, transparent); }
22
23
  .l-hero div.art::after { left: 50%; top: 45%; padding: 8px 14px; border-width: 2px; font-size: 13px; }
23
24
  }
25
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
26
+ @media (min-width: 651px) {
27
+ :root.hd-no-presenter .l-hero:not(:has(> .art)) .content { align-self: center; align-items: center; text-align: center; max-width: 1200px; }
28
+ }
24
29
  </style>
25
30
  <section class="slide on-accent l-hero" id="__ID__" data-chapter="Hello" data-note="Title slide: no facts.">
26
31
  <div class="art" role="presentation"></div>
@@ -4,6 +4,7 @@ summary: One big number, what it measures and its source. The number is the slid
4
4
  use: proof, traction, a before/after delta. Never an estimate shown as fact: the source line is required.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
+ nopresenter: The figure, title and source re-centre in the full frame.
7
8
  cues: [look: #__ID__-figure] as the narration says the number; [reveal: #__ID__-source] optional.
8
9
  script: Here is the number that matters. [look: #__ID__-figure] Three times faster.
9
10
  -->
@@ -17,6 +18,10 @@ script: Here is the number that matters. [look: #__ID__-figure] Three times fast
17
18
  .l-number .content { justify-content: flex-start; gap: 12px; }
18
19
  .l-number .figure { font-size: 120px; }
19
20
  }
21
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
22
+ @media (min-width: 651px) {
23
+ :root.hd-no-presenter .l-number .content { align-self: center; align-items: center; text-align: center; max-width: 1200px; }
24
+ }
20
25
  </style>
21
26
  <section class="slide on-ink l-number" id="__ID__" data-chapter="The number" data-note="Source: name the dashboard, report or email the number comes from, with its date.">
22
27
  <div class="content">
@@ -4,6 +4,7 @@ summary: A fanned row of two to four phone cards (the inner ones larger and in f
4
4
  use: a phone app's flow or its key screens. Each card's .shot is a placeholder check fails on: put in a phone-size screenshot (`hyperdecks screenshot <url> --width 390 --height 844`, capture_url) as <img class="shot">, or a screen recording as <video class="shot" autoplay muted loop playsinline>. Two to four cards: the row spaces and tilts them (on phones only the first three show); change one card's --tilt or --shadow on it. One phone only: use product with .frame.card.
5
5
  slot: bottom-left
6
6
  presenter: stage
7
+ nopresenter: The copy centres vertically beside the row instead of leaving the bottom-left free.
7
8
  cues: [look: #__ID__-row] when the narration turns to the app; [go: #__ID__-p1] … [go: #__ID__-p3] to walk the screens, then [go: slot].
8
9
  script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__ID__-p1] you pick one, [go: #__ID__-p2] and it is done. [go: #__ID__-p3] [go: slot]
9
10
  -->
@@ -32,6 +33,10 @@ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__
32
33
  .l-phones .row > .frame.card:nth-child(n + 4) { display: none; }
33
34
  .l-phones .row:has(> :nth-child(4)) > .frame.card:nth-child(3) { --tilt: 7deg; --shadow: var(--accent-3); width: 108px; translate: 0 16px; }
34
35
  }
36
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
37
+ @media (min-width: 651px) {
38
+ :root.hd-no-presenter .l-phones .text { align-self: center; }
39
+ }
35
40
  </style>
36
41
  <section class="slide l-phones slot-left bg-dots" id="__ID__" data-chapter="On a phone" data-note="Phone screens: name the app build and the date they were captured.">
37
42
  <div class="text">
@@ -4,6 +4,7 @@ summary: A product screenshot in a browser frame (for a phone shot, .frame.card:
4
4
  use: a demo moment. Its .shot is a placeholder check fails on: put in `hyperdecks screenshot <url>` (capture_url) output as <img class="shot">; a callout chip names what to look at. For a phone app, make the frame `<div class="frame card" id="__ID__-frame">` holding only the shot (a phone screenshot or a `<video class="shot" autoplay muted loop playsinline>` clip) and the callout; tilt and shadow colour are `--tilt` and `--shadow` on it. `.frame.phone` (the black bezel) stays for older decks.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
+ nopresenter: The copy centres vertically beside the screenshot instead of leaving the bottom-right free.
7
8
  cues: [look: #__ID__-frame] when the narration turns to the product; [reveal: #__ID__-callout] for the detail.
8
9
  script: This is the product. [look: #__ID__-frame] Look at this part. [reveal: #__ID__-callout]
9
10
  -->
@@ -45,6 +46,10 @@ script: This is the product. [look: #__ID__-frame] Look at this part. [reveal: #
45
46
  .l-product .frame.card { align-self: center; }
46
47
  .l-product .callout { left: 10px; bottom: 10px; padding: 6px 12px; font-size: 12px; }
47
48
  }
49
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
50
+ @media (min-width: 651px) {
51
+ :root.hd-no-presenter .l-product .text { align-self: center; }
52
+ }
48
53
  </style>
49
54
  <section class="slide l-product" id="__ID__" data-chapter="The product" data-note="Screenshot: name the page and the date it was captured.">
50
55
  <div class="text">
@@ -4,6 +4,7 @@ summary: One quote, large, with who said it. The quote is the title.
4
4
  use: a customer or user in their own words. Quote them exactly and name the source in data-note.
5
5
  slot: bottom-left
6
6
  presenter: stage
7
+ nopresenter: The quote drops its left gutter and re-centres in the full frame.
7
8
  cues: [look: #__ID__-quote] as the narration reads it.
8
9
  script: In their words. [look: #__ID__-quote] Here is what they told us.
9
10
  -->
@@ -20,6 +21,11 @@ script: In their words. [look: #__ID__-quote] Here is what they told us.
20
21
  .l-quote .quote { font-size: 32px; }
21
22
  .l-quote .avatar { width: 44px; height: 44px; font-size: 16px; }
22
23
  }
24
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
25
+ @media (min-width: 651px) {
26
+ :root.hd-no-presenter .l-quote .content { padding-left: 0; align-self: center; align-items: center; text-align: center; max-width: 1240px; }
27
+ :root.hd-no-presenter .l-quote .quote { text-indent: 0; }
28
+ }
23
29
  </style>
24
30
  <section class="slide on-accent l-quote slot-left" id="__ID__" data-chapter="In their words" data-note="Quote: name who said it, where (email, call, review) and when.">
25
31
  <figure class="content">
@@ -4,6 +4,7 @@ summary: Image plus copy: the copy on the left (a sticker for the logo or icon,
4
4
  use: the workhorse for a product, a feature or an app: most slides of a good deck are this one. The phone's .shot is a placeholder check fails on: put in a phone screenshot (capture_url at 390×844) or a screen recording (upload_deck_asset) as <img class="shot"> or <video class="shot" autoplay muted loop playsinline>. For a desktop app, replace the card with <div class="frame laptop" id="__ID__-frame"><img class="shot" src="…" alt="…"></div>; for a 16:9 clip or photo, <img class="shot wide" src="…" alt="…" id="__ID__-frame">. Swap the sticker's icon for the product's logo (<span class="sticker photo"><img src="…" alt=""></span>) or one icon from the deck's set.
5
5
  slot: bottom-left
6
6
  presenter: stage
7
+ nopresenter: The copy centres vertically beside the picture instead of leaving the bottom-left free.
7
8
  cues: [look: #__ID__-frame] when the narration turns to the picture; [go: #__ID__-t1], [go: #__ID__-t2], [go: #__ID__-t3] at the word that starts each tick, then [go: slot].
8
9
  script: Here it is, doing the job. [look: #__ID__-frame] It starts with [go: #__ID__-t1] the first thing. Then [go: #__ID__-t2] the second. And [go: #__ID__-t3] the third. [go: slot]
9
10
  -->
@@ -26,6 +27,10 @@ script: Here it is, doing the job. [look: #__ID__-frame] It starts with [go: #__
26
27
  .l-split .frame.card { width: 128px; }
27
28
  .l-split .shot.wide { border-radius: 10px; box-shadow: 6px 6px 0 var(--accent); }
28
29
  }
30
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
31
+ @media (min-width: 651px) {
32
+ :root.hd-no-presenter .l-split .text { align-self: center; padding-top: 0; }
33
+ }
29
34
  </style>
30
35
  <section class="slide l-split slot-left bg-lines" id="__ID__" data-chapter="The product" data-note="Picture: name the screen or clip and the date it was captured.">
31
36
  <div class="text">
@@ -4,6 +4,7 @@ summary: Up to four people, a face, a name and a role each; the presenter flies
4
4
  use: who is behind it, who to talk to. Use real photos only with permission; initials otherwise.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
+ nopresenter: The title and people centre vertically and the cards grow a little, instead of leaving a strip below for the presenter.
7
8
  cues: [go: #__ID__-p1] … [go: #__ID__-p4] at each name, then [go: slot].
8
9
  script: Meet the team. [go: #__ID__-p1] Ana builds it. [go: #__ID__-p2] Sam sells it. [go: #__ID__-p3] Lee runs it. [go: #__ID__-p4] Kai designs it. [go: slot]
9
10
  -->
@@ -23,6 +24,11 @@ script: Meet the team. [go: #__ID__-p1] Ana builds it. [go: #__ID__-p2] Sam sell
23
24
  .l-team b { font-size: 16px; }
24
25
  .l-team small { font-size: 10px; }
25
26
  }
27
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
28
+ @media (min-width: 651px) {
29
+ :root.hd-no-presenter .l-team { justify-content: center; }
30
+ :root.hd-no-presenter .l-team .person { padding: 32px 30px; }
31
+ }
26
32
  </style>
27
33
  <section class="slide l-team" id="__ID__" data-chapter="The team" data-note="People: names and roles as they gave them; photos only with their permission.">
28
34
  <div class="kicker">The team</div>
@@ -4,6 +4,7 @@ summary: Four milestones on a line, left to right, the presenter flying under ea
4
4
  use: history, a roadmap, a process over time. Label future steps "Proposed" if not agreed.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
+ nopresenter: The title and milestones centre vertically instead of leaving a strip below for the presenter.
7
8
  cues: [go: #__ID__-m1] … [go: #__ID__-m4] at the word that starts each milestone, then [go: slot].
8
9
  script: It started [go: #__ID__-m1] with a sketch. Then [go: #__ID__-m2] a first user. Then [go: #__ID__-m3] a launch. Next, [go: #__ID__-m4] the plan. [go: slot]
9
10
  -->
@@ -25,6 +26,10 @@ script: It started [go: #__ID__-m1] with a sketch. Then [go: #__ID__-m2] a first
25
26
  .l-timeline b { font-size: 18px; margin-bottom: 2px; }
26
27
  .l-timeline p { font-size: 13px; }
27
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-timeline { justify-content: center; }
32
+ }
28
33
  </style>
29
34
  <section class="slide l-timeline" id="__ID__" data-chapter="How we got here" data-note="Source: name where each date comes from.">
30
35
  <div class="kicker">How we got here</div>