@hypersocial/decks-cli 0.9.7 → 0.9.8

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/README.md CHANGED
@@ -64,7 +64,7 @@ share list <slug> [--json]
64
64
  share on|off <token>
65
65
  share mute|unmute <token>
66
66
  share rm <token>
67
- share url <token>
67
+ share url <token> [--backup]
68
68
 
69
69
  recipient archive|unarchive <name>
70
70
  import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live|archived]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -149,7 +149,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
149
149
  | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
150
150
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
151
151
  | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
152
- | Slides or scroll, viewer toggle | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off]` | `set_deck_view` |
152
+ | Slides or scroll, viewer toggle, pace | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default]` | `set_deck_view` |
153
153
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
154
154
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
155
155
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
@@ -169,7 +169,15 @@ HTML says. Viewers get a slides/scroll toggle in the player, remembered per
169
169
  link, unless you turn it off (`--toggle off`, `viewToggle: false`). A viewer
170
170
  who already switched keeps their own choice while the toggle is on; turn the
171
171
  toggle off to make every viewer see the deck's view.
172
- `deck list --json` / `list_decks` and `deck stats` / `deck_stats` show both.
172
+ Pace is the third: `--pace auto` (`pace: "auto"`, the default) plays itself,
173
+ `slide` waits for Next (the Continue pill, →, a swipe) after each slide's
174
+ narration, and `step` also stops the narration before each `[reveal]` cue,
175
+ so a slide's reveals are the steps the viewer clicks through: group what
176
+ belongs together at one word, so each stop is a meaningful step. Viewers can
177
+ switch pace (and slides/scroll) in the player's Preferences, in its top bar
178
+ (remembered per link); a video export always plays straight through. Pace
179
+ waits apply side to side (slides) only: scroll keeps its own flow.
180
+ `deck list --json` / `list_decks` and `deck stats` / `deck_stats` show all three.
173
181
 
174
182
  ### 1. Ground it in the user's own material
175
183
 
@@ -258,6 +266,18 @@ The deck contract (`check` enforces or warns on each line):
258
266
  <!-- topic: presenter -->
259
267
  #### Which presenter style
260
268
 
269
+ **Who presents is set at release; leave the tag alone.** `deck new --narrated`
270
+ writes `data-presenter="none"` on the player tag, and `release` (and
271
+ `release_deck`) puts the narrator's face on the copy it publishes: your own
272
+ narrator's avatar, or D.A.S.H.'s live head for a deck in D.A.S.H.'s voice. It
273
+ says `presented by …` only when the published page has that face; otherwise
274
+ it warns `releasing captions only` with the reason. To pick the face by hand,
275
+ set both attributes: `data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
276
+ `data-presenter="head" data-avatar="https://hyperdecks.ai/player/v1/dash-avatar.json"`
277
+ (it loads from any host). `data-presenter="head"` with no `data-avatar` plays
278
+ captions only, and so does a `data-avatar` beside `data-presenter="none"`.
279
+ `check` warns on both, and `release` leaves a hand-set `data-avatar` alone.
280
+
261
281
  Set the deck's on the player tag (`data-presenter-style="stage"`) or in
262
282
  script.md's front matter (`presenterStyle: stage`); override one slide with
263
283
  `data-presenter-style` on its `<section>` or `presenter-style: corner` under
@@ -596,6 +616,20 @@ with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
596
616
  `timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
597
617
  one action, stickers again.
598
618
 
619
+ **No presenter.** When nothing stands in the corner, no face (presenter
620
+ `none`) *and* no captions (`data-silent`, or an export with captions off), or
621
+ when a host calls `window.hyperdecks.setPresenterOff(true)`, the player puts
622
+ `hd-no-presenter` on `<html>` and hides every `.narration-slot`. Each layout
623
+ has a variant for that in its own `<style>` (`deck_guide layouts` says what
624
+ each does): compare and chart widen; features, team and timeline centre
625
+ vertically; split, product and phones centre their copy; number, quote,
626
+ closer and a hero without art re-centre. Type sizes stay. A deck with
627
+ captions keeps the slot (the bubble lives there). For a custom slide, keep it
628
+ compatible: the presenter's corner is the slide's `.narration-slot`, never a
629
+ hard-coded gutter (`padding-right: 400px`); if it needs one, override it in a
630
+ `:root.hd-no-presenter .l-yours …` rule (check warns on a right gutter until
631
+ you do).
632
+
599
633
  <!-- topic: script -->
600
634
  ### 3. Write slides and script
601
635
 
@@ -91,9 +91,11 @@ makes the hello deck; continue from step 5 below.
91
91
  10. Later: `list_shares` and `deck_stats` (both show the card),
92
92
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
93
93
  email as the deck's moving preview (it needs a recipient's link, not a QA
94
- one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle }`
95
- for how the deck opens and whether viewers may switch (every link, next
96
- load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
94
+ one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle, pace }`
95
+ for how the deck opens, whether viewers may switch, and its pace (`auto`
96
+ plays itself, `slide` waits for Next after each slide, `step` also stops
97
+ before each `[reveal]`, so group a slide's reveals into meaningful steps)
98
+ (every link, next load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
97
99
  `set_recipient_archived` to tidy the activity feed; `credits` for the
98
100
  balance. `remove_deck` deletes a deck and breaks every link to it: confirm
99
101
  with the user first.
@@ -5,6 +5,8 @@
5
5
  // the deck's index.html and script.md as text, with the manifest and clip fetches
6
6
  // injected.
7
7
 
8
+ import { DASH_AVATAR, presenterTagIssue } from "./presenter.mjs";
9
+
8
10
  export const PLAYER_V1 = "https://hyperdecks.ai/player/v1/player.js";
9
11
  // Decks released before the app moved to hyperdecks.ai load the same player
10
12
  // from the old host, which serves it forever; both pass.
@@ -58,6 +60,8 @@ export const MESSAGES = {
58
60
  selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
59
61
  gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
60
62
  gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
63
+ presenterUnfilled: (mode) => `the player tag says data-presenter="${mode}" but names no face (${mode === "head" ? "data-avatar" : "data-presenter-image"}), so as written it plays captions only. release puts the narrator's face on it; to set one by hand, D.A.S.H. is data-presenter="head" data-avatar="${DASH_AVATAR}" (deck_guide topic presenter)`,
64
+ presenterKept: (mode) => `the player tag has a data-avatar but data-presenter="${mode ?? "none"}", so it plays captions only and release leaves it that way: set data-presenter="head" to show that face, or remove data-avatar so release adds the narrator's`,
61
65
  presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
62
66
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
63
67
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
@@ -67,6 +71,7 @@ export const MESSAGES = {
67
71
  flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
68
72
  // Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
69
73
  fewPictures: (shown, total) => `only ${shown} of ${total} slides show a picture (a screenshot, a recording, a photo, a chart); aim for a real one on every other slide (deck_guide topic look)`,
74
+ presenterGutter: (where, decl) => `${where}: ${decl} keeps a right gutter for the presenter; leave that corner to the slide's .narration-slot so a deck without a presenter can use it (deck_guide topic look)`,
70
75
  mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
71
76
  // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
72
77
  placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
@@ -790,6 +795,68 @@ export function lookWarnings(html) {
790
795
  return out;
791
796
  }
792
797
 
798
+ // A wide right padding or margin (PRESENTER_GUTTER px or more, and wider than
799
+ // the left: a centred column is not a gutter) is a gutter held for the
800
+ // presenter outside its .narration-slot: without a presenter
801
+ // (:root.hd-no-presenter) it stays empty. Read from inline styles on a slide
802
+ // and its elements, and from the deck's <style> rules (by selector). Skipped:
803
+ // rules in a phone media query (max-width, no min-width), in @keyframes and
804
+ // the like, on the slot itself, the no-presenter rules, and a selector whose
805
+ // gutter a :root.hd-no-presenter rule overrides (the variant the guide asks for).
806
+ // One linear pass over the text: a malformed paste must not stall check.
807
+ export const PRESENTER_GUTTER = 240;
808
+ const sides = (prop, value) => {
809
+ const px = (v) => { const m = /^(\d+(?:\.\d+)?)(px|rem|em)$/i.exec(v ?? ""); return m ? Number(m[1]) * (m[2].toLowerCase() === "px" ? 1 : 16) : 0; };
810
+ const parts = value.trim().split(/\s+/);
811
+ if (/-(right|inline-end)$/i.test(prop)) return { right: px(parts[0]), left: 0 };
812
+ return { right: px(parts[1] ?? parts[0]), left: px(parts[3] ?? parts[1] ?? parts[0]) };
813
+ };
814
+ const gutters = (decls) => decls.split(";").map((d) => /^\s*((?:padding|margin)(?:-right|-inline-end)?)\s*:\s*([^!]+)/i.exec(d)).filter(Boolean)
815
+ .filter(([, prop, value]) => { const { right, left } = sides(prop, value); return right >= PRESENTER_GUTTER && right > left; })
816
+ .map(([, prop, value]) => `${prop.toLowerCase()}: ${value.trim()}`);
817
+ // Each style rule of a stylesheet: { selector, decls, at } where `at` lists the
818
+ // @-rule preludes around it. Comments drop out; braces nest.
819
+ function cssRules(css) {
820
+ const out = [], stack = [];
821
+ let start = 0, i = 0;
822
+ let text = "";
823
+ while (i < css.length) {
824
+ if (css.startsWith("/*", i)) { const end = css.indexOf("*/", i + 2); text += css.slice(start, i); i = start = end === -1 ? css.length : end + 2; continue; }
825
+ const ch = css[i];
826
+ if (ch === "{" || ch === "}") {
827
+ text += css.slice(start, i);
828
+ if (ch === "{") stack.push(text.trim());
829
+ else {
830
+ const head = stack.pop();
831
+ if (head !== undefined && !head.startsWith("@")) out.push({ selector: head, decls: text, at: stack.filter((h) => h.startsWith("@")) });
832
+ }
833
+ text = "";
834
+ start = i + 1;
835
+ }
836
+ i++;
837
+ }
838
+ return out;
839
+ }
840
+ const phoneOnly = (at) => at.some((h) => /^@media\b/i.test(h) && /max-width/i.test(h) && !/min-width/i.test(h));
841
+ const NO_PRESENTER = /^:root\.hd-no-presenter\s+/;
842
+ export function presenterGutterWarnings(html) {
843
+ const out = [];
844
+ for (const [i, s] of slides(html).entries()) {
845
+ const where = `slide ${s.id || i + 1}`;
846
+ for (const [, value] of `${s.tag}\n${uncommented(s.body)}`.matchAll(/\sstyle\s*=\s*("[^"]*"|'[^']*')/gi)) {
847
+ for (const decl of gutters(value.slice(1, -1))) out.push(MESSAGES.presenterGutter(where, decl));
848
+ }
849
+ }
850
+ const rules = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].flatMap(([, css]) => cssRules(css));
851
+ const overridden = new Set(rules.filter((r) => NO_PRESENTER.test(r.selector) && /(padding|margin)/i.test(r.decls)).map((r) => r.selector.replace(NO_PRESENTER, "")));
852
+ for (const r of rules) {
853
+ if (r.at.some((h) => !/^@(media|supports|layer|container)\b/i.test(h)) || phoneOnly(r.at)) continue;
854
+ if (NO_PRESENTER.test(r.selector) || /\.narration-slot\b/.test(r.selector) || overridden.has(r.selector)) continue;
855
+ for (const decl of gutters(r.decls)) out.push(MESSAGES.presenterGutter(`rule ${r.selector}`, decl));
856
+ }
857
+ return out;
858
+ }
859
+
793
860
  // Every rule on the deck's text. `exists(ref)` answers whether a local reference
794
861
  // resolves (true, false or "outside"); `hosted: true` means there is no folder,
795
862
  // so every local reference is an error naming upload_deck_asset, a hotlinked
@@ -809,7 +876,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
809
876
  const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
810
877
  const warnings = [];
811
878
  if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
812
- warnings.push(...lookWarnings(html));
879
+ warnings.push(...lookWarnings(html), ...presenterGutterWarnings(html));
813
880
  // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
814
881
  const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
815
882
  for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
@@ -873,6 +940,9 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
873
940
  }
874
941
  }
875
942
  warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
943
+ // A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
944
+ const tagIssue = silent ? null : presenterTagIssue(html);
945
+ if (tagIssue) warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter) : MESSAGES.presenterUnfilled(tagIssue.presenter));
876
946
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
877
947
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
878
948
  if (opts.hosted) {
package/src/commands.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import {
4
+ backupLinkUrl,
4
5
  installHyperdecksSkill,
5
6
  login,
6
7
  logout,
7
8
  openBrowser,
9
+ linkBase,
8
10
  parse,
9
11
  target,
10
12
  VERSION,
@@ -60,7 +62,7 @@ async function deckStats(pos, opts) {
60
62
  console.log(` unique viewers: ${s.viewers}`);
61
63
  console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
62
64
  console.log(` total engaged: ${fmtMs(s.totalMs)}`);
63
- if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
65
+ if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.pace ? `, pace ${s.pace}` : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
64
66
  if (s.recent?.length) {
65
67
  console.log(" recent:");
66
68
  for (const r of s.recent) {
@@ -85,10 +87,6 @@ async function deckRemove(pos, opts) {
85
87
  console.log(`OK removed deck ${slug}`);
86
88
  }
87
89
 
88
- function shareBase(opts) {
89
- return target(opts).shareBase;
90
- }
91
-
92
90
  async function shareNew(pos, opts) {
93
91
  const [slug, recipient] = pos;
94
92
  if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
@@ -105,9 +103,13 @@ async function shareNew(pos, opts) {
105
103
  expires: opts.expires,
106
104
  limit: opts.limit,
107
105
  });
108
- const url = `${shareBase(opts)}/v/${token}`;
109
- if (opts.json) console.log(JSON.stringify({ token, url }, null, 2));
110
- else console.log(`OK share for "${recipient}" -> ${url}`);
106
+ const url = `${await linkBase(opts)}/v/${token}`;
107
+ const backupUrl = backupLinkUrl(url);
108
+ if (opts.json) console.log(JSON.stringify({ token, url, backupUrl }, null, 2));
109
+ else {
110
+ console.log(`OK share for "${recipient}" -> ${url}`);
111
+ if (backupUrl) console.log(` backup link (same link, other domain, if their network blocks it): ${backupUrl}`);
112
+ }
111
113
  }
112
114
 
113
115
  async function shareList(pos, opts) {
@@ -156,23 +158,28 @@ async function deckForward(pos, opts) {
156
158
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
157
159
  }
158
160
 
159
- const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off]";
161
+ const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]";
160
162
  const VIEWS = { slides: "slides", scroll: "scroll", default: null };
163
+ const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
161
164
  async function deckView(pos, opts) {
162
165
  const [slug, mode] = pos;
163
166
  const toggle = opts.toggle;
164
- if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle"))) throw new Error(VIEW_USAGE);
167
+ const pace = opts.pace;
168
+ if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle") && !Object.hasOwn(opts, "pace"))) throw new Error(VIEW_USAGE);
165
169
  if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
166
170
  // A trailing --toggle with no value is a mistake, not "leave it".
167
171
  if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
172
+ if (Object.hasOwn(opts, "pace") && !Object.hasOwn(PACES, pace)) throw new Error(VIEW_USAGE);
168
173
  const out = await ops.setDeckView(opts, slug, {
169
174
  view: mode === undefined ? undefined : VIEWS[mode],
170
175
  viewToggle: toggle === undefined ? undefined : toggle === "on",
176
+ pace: pace === undefined ? undefined : PACES[pace],
171
177
  });
172
178
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
173
179
  const said = [];
174
180
  if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
175
181
  if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
182
+ if (pace !== undefined) said.push(pace === "default" ? "paces as its HTML says" : `pace ${pace}`);
176
183
  if (out.applies === false) {
177
184
  console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
178
185
  console.warn(`warning: ${out.warning}`);
@@ -181,10 +188,14 @@ async function deckView(pos, opts) {
181
188
  console.log(`OK deck ${slug} ${said.join(", ")} (every link, on its next load)`);
182
189
  }
183
190
 
184
- function shareUrl(pos, opts) {
191
+ async function shareUrl(pos, opts) {
185
192
  const [token] = pos;
186
- if (!token) throw new Error("usage: share url <token>");
187
- console.log(`${shareBase(opts)}/v/${token}`);
193
+ if (!token) throw new Error("usage: share url <token> [--backup]");
194
+ const url = `${await linkBase(opts)}/v/${token}`;
195
+ console.log(url);
196
+ // Second line: the same link on the other domain (stdout stays one URL per line).
197
+ const backupUrl = backupLinkUrl(url);
198
+ if (backupUrl && opts.backup) console.log(backupUrl);
188
199
  }
189
200
 
190
201
  async function recipientArchive(pos, opts, archived) {
@@ -336,7 +347,7 @@ commands:
336
347
  deck list|stats|open|rm|forward|view
337
348
  deck forward on|off <slug>
338
349
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
339
- deck view <slug> [slides|scroll|default] [--toggle on|off]
350
+ deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]
340
351
  how the deck opens in the player (default: as its HTML says, data-view or slides) and
341
352
  whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
342
353
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
@@ -441,7 +452,7 @@ commands:
441
452
 
442
453
  narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
443
454
  script.md → voiced clips, timelines and a manifest, rendered server-side; only changed slides re-render
444
- voice list [--json] your voices and their status
455
+ voice list [--json] your voices and their status, then the built-in voices anyone can narrate with
445
456
  voice clone <file...> --name <name>
446
457
  clone your voice from 1-3 mp3/m4a/wav recordings (30 s to 3 min in total, 10 MB each);
447
458
  asks you to type "I consent" before anything is uploaded
@@ -851,10 +862,12 @@ function commandTable(pos, rest, opts) {
851
862
  if (opts.json) console.log(JSON.stringify(out, null, 2));
852
863
  },
853
864
  "voice list": async () => {
854
- const voices = await ops.listVoices(opts);
865
+ const voices = await ops.listAllVoices(opts);
855
866
  if (opts.json) return void console.log(JSON.stringify(voices, null, 2));
856
- for (const x of voices) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
857
- if (!voices.length) console.log("(no voices; voice clone <file...> --name <name> makes one)");
867
+ const own = voices.filter((x) => !x.builtin);
868
+ for (const x of own) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
869
+ if (!own.length) console.log("(no voices of your own; voice clone <file...> --name <name> makes one)");
870
+ for (const x of voices.filter((x) => x.builtin)) console.log(`${x.name}\tbuilt in\t${x.description}\t${x.id}`);
858
871
  },
859
872
  "voice clone": async () => {
860
873
  const { voiceClone } = await import("./voice.mjs");
package/src/guide.mjs CHANGED
@@ -29,7 +29,7 @@ export function skillSection(skill, topic) {
29
29
  function layoutsTopic() {
30
30
  const rows = LAYOUTS.map((name) => {
31
31
  const { meta } = readLayout(name);
32
- return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
32
+ return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.nopresenter && `Without a presenter: ${meta.nopresenter}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
33
33
  });
34
34
  return `# Layouts\n\nPass any of these, in order, as new_deck's \`layouts\` (repeat one for more of the same: number, number gives slides number and number-2).\n\n${rows.join("\n\n")}`;
35
35
  }
package/src/init.mjs CHANGED
@@ -17,7 +17,7 @@ import { createInterface } from "node:readline/promises";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import * as ops from "./ops.mjs";
19
19
  import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
20
- import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
20
+ import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, linkBase, login as runLogin, openBrowser, target } from "./runtime.mjs";
21
21
  import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
22
22
  import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
23
23
  import { narrate as runNarrate } from "./narrate.mjs";
@@ -450,9 +450,13 @@ async function presenterStep(base, opts, { interactive, ask, log, sub, avatars,
450
450
  }
451
451
  }
452
452
 
453
+ // decks:get answers only for decks the caller owns, so whether the slug is
454
+ // taken at all comes from slugTaken.
453
455
  async function deckOwnedBy(base, slug, who) {
454
- const deck = await (await client(base)).query(api.decks.get, { slug });
455
- return deck ? { exists: true, mine: deck.ownerId === who.subject } : { exists: false, mine: false };
456
+ const c = await client(base);
457
+ if (!(await c.query(api.decks.slugTaken, { slug }))) return { exists: false, mine: false };
458
+ const deck = await c.query(api.decks.get, { slug });
459
+ return { exists: true, mine: !!deck && deck.ownerId === who.subject };
456
460
  }
457
461
 
458
462
  async function scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save }) {
@@ -544,7 +548,7 @@ async function releaseStep(base, opts, state, { release, sub, save, fetch }) {
544
548
  // A run killed after release made its link but before init.json recorded it.
545
549
  const mine = !state.releasedAt && shares.find((s) => s.qa && s.recipient === "Me" && s.enabled);
546
550
  if (mine) {
547
- const url = `${target(base).shareBase}/v/${mine.token}`;
551
+ const url = `${await linkBase(base)}/v/${mine.token}`;
548
552
  save({ releasedAt: new Date().toISOString(), shareUrl: url, shareToken: mine.token });
549
553
  return step("release", "done", `released: ${url}`);
550
554
  }
package/src/layouts.mjs CHANGED
@@ -20,7 +20,7 @@ export function fillTemplate(file, { slug, title, presenterStyle }) {
20
20
  }
21
21
 
22
22
  // One layout file: a leading comment of `key: value` lines (layout, summary,
23
- // use, slot, presenter, cues, script), its <style> and its <section>.
23
+ // use, slot, presenter, nopresenter, cues, script), its <style> and its <section>.
24
24
  export function readLayout(name) {
25
25
  const text = packageText(`templates/layouts/${name}.html`);
26
26
  const head = /^<!--([\s\S]*?)-->/.exec(text);
package/src/ops.mjs CHANGED
@@ -2,7 +2,7 @@ import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer
2
2
  import { zipSync } from "fflate";
3
3
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
4
4
  import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
5
- import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
5
+ import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
6
6
  import {
7
7
  bytesFromText,
8
8
  isTextAsset,
@@ -19,8 +19,9 @@ export async function whoami(opts = {}) {
19
19
  return (await client(opts)).query(api.decks.whoami, {});
20
20
  }
21
21
 
22
- export async function listDecks(opts = {}) {
23
- const rows = await (await client(opts)).query(api.decks.listForViewer, {});
22
+ // Your own decks. `all: true` lists every deck, for admins only (support tools).
23
+ export async function listDecks(opts = {}, { all = false } = {}) {
24
+ const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
24
25
  return rows.map((d) => ({
25
26
  slug: d.slug,
26
27
  title: d.title,
@@ -30,6 +31,8 @@ export async function listDecks(opts = {}) {
30
31
  // How it opens (null: as its HTML says) and whether viewers may switch.
31
32
  view: d.view ?? null,
32
33
  viewToggle: d.viewToggle !== false,
34
+ // How the player moves on: auto, slide or step (null: as its HTML says, else auto).
35
+ pace: d.pace ?? null,
33
36
  // false: the deck runs its own player, so view/viewToggle do not apply
34
37
  // (null: not known yet, treated as on).
35
38
  usesPlayer: d.usesPlayer ?? null,
@@ -264,7 +267,11 @@ export async function ownedDeck(opts, slug) {
264
267
  const c = await client(opts);
265
268
  const [deck, me] = await Promise.all([c.query(api.decks.get, { slug }), c.query(api.decks.whoami, {})]);
266
269
  if (!me) throw new Error("not logged in: run `hyperdecks login` first");
267
- if (!deck) throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
270
+ if (!deck) {
271
+ // get answers only for your own decks; say so when the slug is someone else's.
272
+ if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`forbidden: ${slug} is not your deck`);
273
+ throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
274
+ }
268
275
  if (!me.isAdmin && deck.ownerId !== me.subject) throw new Error(`forbidden: ${slug} is not your deck`);
269
276
  return deck;
270
277
  }
@@ -288,7 +295,7 @@ export const VIDEO_RECIPIENT = "Video viewers";
288
295
  export async function videoShareLink(opts, slug) {
289
296
  const live = (await listShares(opts, slug)).find((s) => s.recipient === VIDEO_RECIPIENT && s.enabled && !s.qa && !s.archived && !s.requireEmail && (s.expiresAt == null || s.expiresAt > Date.now()) && (s.viewLimit == null || s.opens < s.viewLimit));
290
297
  const token = live?.token ?? (await createShare(opts, { slug, recipient: VIDEO_RECIPIENT })).token;
291
- return `${target(opts).shareBase}/v/${token}`;
298
+ return `${await linkBase(opts)}/v/${token}`;
292
299
  }
293
300
 
294
301
  // `export --video --upload`: file the MP4 with the deck's exports (30 days).
@@ -428,10 +435,24 @@ export async function checkNarrationJob(opts, jobId) {
428
435
  return (await client(opts)).query(api.narration.getJob, { jobId: String(jobId) });
429
436
  }
430
437
 
438
+ // Your own voices (what clone, rename and delete act on).
431
439
  export async function listVoices(opts = {}) {
432
440
  return (await client(opts)).query(api.voices.listVoices, {});
433
441
  }
434
442
 
443
+ // Every voice you can narrate with: yours, then the built-in ones (builtin: true,
444
+ // id stock:<id>), which any account may use. What list_voices shows.
445
+ export async function listAllVoices(opts = {}) {
446
+ const c = await client(opts);
447
+ try {
448
+ return await c.query(api.voices.listVoices, { builtin: true });
449
+ } catch (e) {
450
+ // A backend from before built-in voices refuses the argument: your own voices, then.
451
+ if (!/builtin|extra field|Validator/i.test(errorText(e))) throw e;
452
+ return c.query(api.voices.listVoices, {});
453
+ }
454
+ }
455
+
435
456
  // ---- voice cloning (convex/voices.ts) ---------------------------------------
436
457
 
437
458
  // The text of a backend error: ConvexError data (production redacts the rest), else the message.
@@ -603,18 +624,23 @@ export async function setDeckForwarding(opts, slug, allowForward) {
603
624
 
604
625
  // How a deck opens in the player and whether viewers may switch: view is
605
626
  // "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
606
- // true/false. Either may be left out; both go in one owner-only mutation, so a
607
- // failure changes neither.
627
+ // true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
628
+ // slide), "step" (also stops before each [reveal] cue), or null for the deck's
629
+ // own HTML. Any may be left out; all go in one owner-only mutation, so a
630
+ // failure changes none.
608
631
  // Applies on every link's next load (the route injects it), with no re-release.
609
632
  export const DECK_VIEWS = ["slides", "scroll"];
610
- export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
633
+ export const DECK_PACES = ["auto", "slide", "step"];
634
+ export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
611
635
  if (!slug) throw new Error("slug is required");
612
- if (view === undefined && viewToggle === undefined) throw new Error("give a view (slides, scroll or default) and/or a viewToggle");
636
+ if (view === undefined && viewToggle === undefined && pace === undefined) throw new Error("give a view (slides, scroll or default), a viewToggle and/or a pace (auto, slide, step or default)");
613
637
  if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
638
+ if (pace !== undefined && pace !== null && !DECK_PACES.includes(pace)) throw new Error(`pace must be auto, slide, step or null (got ${JSON.stringify(pace)})`);
614
639
  const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
615
640
  slug,
616
641
  ...(view !== undefined ? { view } : {}),
617
642
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
643
+ ...(pace !== undefined ? { pace } : {}),
618
644
  });
619
645
  // Saved either way; a deck on its own player says the setting won't apply.
620
646
  const applies = res?.applies !== false;
@@ -622,6 +648,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
622
648
  slug,
623
649
  ...(view !== undefined ? { view } : {}),
624
650
  ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
651
+ ...(pace !== undefined ? { pace } : {}),
625
652
  applies,
626
653
  ...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
627
654
  };
@@ -631,7 +658,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
631
658
  // upsert would otherwise reset your own live deck to a draft and rename it.
632
659
  export async function createDeck(opts, { slug, title }) {
633
660
  const c = await client(opts);
634
- if (await c.query(api.decks.get, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
661
+ if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
635
662
  await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
636
663
  return { slug, title, status: "draft" };
637
664
  }
@@ -1146,7 +1173,7 @@ export async function listNarrators(opts = {}) {
1146
1173
  default: out.defaultId,
1147
1174
  ...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
1148
1175
  narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, spokenName: n.spokenName ?? null, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
1149
- voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
1176
+ voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, builtin: !!x.builtin, narrators: x.narrators })),
1150
1177
  };
1151
1178
  }
1152
1179
 
@@ -1188,7 +1215,8 @@ export async function removeNarrator(opts, ref) {
1188
1215
  return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
1189
1216
  }
1190
1217
 
1191
- // voice: one of your ready voices (name or id) or dash.
1218
+ // voice: one of your ready voices (name or id), a built-in voice (name or
1219
+ // stock:<id>, list_voices), or dash.
1192
1220
  export async function setNarratorVoice(opts, ref, voice) {
1193
1221
  if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
1194
1222
  const n = await findNarrator(opts, ref);
package/src/presenter.mjs CHANGED
@@ -12,32 +12,118 @@
12
12
  export const DASH_AVATAR = "https://hyperdecks.ai/player/v1/dash-avatar.json";
13
13
  export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://hyperdecks.ai/avatars/dash.png" });
14
14
 
15
- const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
15
+ const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/gi;
16
16
 
17
17
  const attr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
18
18
 
19
- // Whether this tag plays captions only: data-presenter="none", or no presenter
20
- // at all (the player's default when there is no data-presenter-image either).
21
- const noPresenter = (tag) => /\sdata-presenter="none"/.test(tag) || (!/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag));
19
+ // The live player tag: the first one outside an HTML comment (a commented-out
20
+ // copy is not what plays), with where it starts. check's playerScript reads it
21
+ // the same way (closed comments only, so a stray "<!--" in a script hides nothing).
22
+ function playerTag(html) {
23
+ const comments = [...html.matchAll(/<!--[\s\S]*?-->/g)].map((m) => [m.index, m.index + m[0].length]);
24
+ for (const m of html.matchAll(PLAYER_TAG)) {
25
+ if (!comments.some(([a, b]) => m.index >= a && m.index < b)) return { tag: m[0], index: m.index };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const value = (tag, name) => {
31
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`).exec(tag);
32
+ return m ? (m[1] ?? m[2] ?? m[3]) : null;
33
+ };
34
+
35
+ // The face this tag shows, decided as packages/player/player.js does: data-presenter
36
+ // head|static|none, else static when there is a still, else none. head draws
37
+ // data-avatar (and falls back to the still without one); static draws the
38
+ // still. Null: the deck plays the voice and captions with no face.
39
+ function tagFace(tag) {
40
+ const presenter = value(tag, "data-presenter");
41
+ const avatarUrl = value(tag, "data-avatar") || null;
42
+ const imageUrl = value(tag, "data-presenter-image") || null;
43
+ const mode = ["head", "static", "none"].includes(presenter) ? presenter : imageUrl ? "static" : "none";
44
+ if (mode === "head" && (avatarUrl || imageUrl)) return { avatarUrl, imageUrl };
45
+ if (mode === "static" && imageUrl) return { avatarUrl: null, imageUrl };
46
+ return null;
47
+ }
48
+
49
+ // The face the page's player shows ({ avatarUrl, imageUrl }, either may be
50
+ // null), or null when it plays captions only or has no player tag.
51
+ export function presentedFace(html) {
52
+ const found = playerTag(html);
53
+ return found ? tagFace(found.tag) : null;
54
+ }
22
55
 
23
- // Whether the page's player tag names nobody, so without a face it has captions only.
56
+ // Whether the page's player tag shows no face, so it has captions only.
24
57
  export function playsCaptionsOnly(html) {
25
- const tag = PLAYER_TAG.exec(html)?.[0];
26
- return !!tag && !/\sdata-avatar=/.test(tag) && noPresenter(tag);
58
+ const found = playerTag(html);
59
+ return !!found && !tagFace(found.tag);
27
60
  }
28
61
 
29
62
  // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
30
- // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
31
- // or html unchanged when there is no such tag or it already has a presenter.
63
+ // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
64
+ // html unchanged when there is no such tag, the tag already shows a face, or it
65
+ // names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
66
+ // Anything else the player would play captions only (data-presenter none or
67
+ // missing, head with no data-avatar, static with no still) gets the face.
32
68
  export function withAvatar(html, { avatarUrl, imageUrl }) {
33
- const tag = PLAYER_TAG.exec(html)?.[0];
34
- if (!tag || /\sdata-avatar=/.test(tag)) return html;
35
- if (!noPresenter(tag)) return html;
36
- const none = /\sdata-presenter="none"/.test(tag);
37
- const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
69
+ const found = playerTag(html);
70
+ if (!found) return html;
71
+ const { tag, index } = found;
72
+ if (tagFace(tag) || value(tag, "data-avatar")) return html;
73
+ const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
38
74
  const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
39
- const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
40
- return html.replace(tag, () => next);
75
+ const named = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
76
+ const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
77
+ return html.slice(0, index) + next + html.slice(index + tag.length);
78
+ }
79
+
80
+ // What is wrong with the player tag's presenter as written, for check: null, or
81
+ // { kind, presenter }. "unfilled": it asks for a face (head or static) but names
82
+ // none, so it plays captions only until release fills it in. "kept": it has a
83
+ // data-avatar the player ignores (data-presenter="none"), and release leaves it.
84
+ // A plain "none" or no data-presenter is not an issue: release adds the face.
85
+ export function presenterTagIssue(html) {
86
+ const found = playerTag(html);
87
+ if (!found || tagFace(found.tag)) return null;
88
+ const presenter = value(found.tag, "data-presenter");
89
+ if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
90
+ if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
91
+ return null;
92
+ }
93
+
94
+ // Why a resolved face did not land on the page: withAvatar leaves a tag with
95
+ // its own data-avatar alone, and a page with no live player tag has nowhere to put it.
96
+ function keptOutReason(html) {
97
+ const found = playerTag(html);
98
+ if (!found) return "the page has no live player tag to put the face on";
99
+ const mode = value(found.tag, "data-presenter");
100
+ return `the player tag names its own data-avatar, but data-presenter=${mode === null ? " is missing" : `"${mode}"`} so it plays no face, and release leaves a hand-set data-avatar alone: set data-presenter="head", or remove data-avatar`;
101
+ }
102
+
103
+ // What a release says about the face, from the page as written (`html`), the
104
+ // face it puts on (`presenter`, from avatars.presenterFor; null for none), who
105
+ // the backend says presents (`who`: { narrator, voice, face, reason }) and, when
106
+ // the face lookup itself failed, `lookupError`. Returns the page that ships, a
107
+ // line for the terminal and the summary to report: "presented by" only when
108
+ // that page shows a face, and with no resolved face on it, the tag's own.
109
+ // Both releases use it: release.mjs and convex/deckSources.ts.
110
+ export const TAG_FACE = "the face set on the player tag";
111
+ export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
112
+ const page = presenter ? withAvatar(html, presenter) : html;
113
+ const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
114
+ const shown = presentedFace(page);
115
+ if (shown) {
116
+ // Left as written, with no resolved face or another one: whatever the tag names is what shows.
117
+ if (page === html && shown.avatarUrl !== presenter?.avatarUrl) {
118
+ return { page, line: `✓ presented by ${TAG_FACE} (${shown.avatarUrl ? "data-avatar" : "data-presenter-image"})`, summary: who ? { ...who, face: TAG_FACE } : null };
119
+ }
120
+ return { page, line: `✓ presented by ${who?.face ?? "your avatar"}`, summary: who ?? null };
121
+ }
122
+ if (lookupError) return captions(`could not look up your avatar (${lookupError}); release again to try once more`);
123
+ if (presenter) return captions(keptOutReason(page));
124
+ if (who && !who.face) return { page, line: `⚠ releasing captions only: ${who.reason ?? "no face"}`, summary: who };
125
+ if (who?.face) return captions(`no face for "${who.face}" came back to put on the player tag`);
126
+ return { page, line: "", summary: null };
41
127
  }
42
128
 
43
129
  // One line for who presents a deck, from narrate's or release's `presenter`
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
@@ -16,8 +16,8 @@ import { readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { zipSync } from "fflate";
18
18
  import * as ops from "./ops.mjs";
19
- import { withAvatar } from "./presenter.mjs";
20
- import { target } from "./runtime.mjs";
19
+ import { releasedPresenter, withAvatar } from "./presenter.mjs";
20
+ import { linkBase } from "./runtime.mjs";
21
21
  import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
22
22
  import { narrate as runNarrate } from "./narrate.mjs";
23
23
 
@@ -112,16 +112,20 @@ export async function release(pos, opts = {}, io = {}) {
112
112
  }
113
113
  // The released page gets the narrator's face: your avatar, or D.A.S.H.'s for its voice.
114
114
  let presenter = null;
115
+ let lookupError;
115
116
  try {
116
117
  presenter = await ops.presenterFor(opts, slug, opts.voice);
117
118
  } catch (e) {
118
- log(`⚠ could not look up your avatar (${ops.errorText(e)}); releasing captions only`);
119
+ lookupError = ops.errorText(e);
119
120
  }
120
121
  const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
121
- if (presenter) log(`✓ presented by ${job.presenter?.face ?? "your avatar"}`);
122
+ // Say who presents from the page that ships, never from the lookup alone: a
123
+ // resolved face that did not land on the player tag must not be claimed, and
124
+ // a failed lookup is captions only only when the tag names no face of its own.
125
+ const said = releasedPresenter(readFileSync(join(deckDir, "index.html"), "utf8"), presenter, job.presenter, { lookupError });
122
126
  // 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 });
127
+ if (said.line) log(said.line);
128
+ return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: said.summary });
125
129
  }
126
130
 
127
131
  // Import the checked bundle and make the links: the end of every release.
@@ -137,7 +141,7 @@ async function publish({ opts, io: { log, commit }, deckDir, slug, to, checked,
137
141
  const sha = commit(deckDir);
138
142
  log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
139
143
 
140
- const base = target(opts).shareBase;
144
+ const base = await linkBase(opts);
141
145
  const shares = [];
142
146
  for (const r of to) {
143
147
  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.8";
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 {
@@ -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>