@hypersocial/decks-cli 0.9.9 → 0.9.11

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/commands.mjs CHANGED
@@ -72,12 +72,23 @@ async function deckStats(pos, opts) {
72
72
  }
73
73
  }
74
74
 
75
+ // The owner link: present or check your own deck (untracked, owner-only).
76
+ function deckLink(pos, opts) {
77
+ const [slug] = pos;
78
+ if (!slug) throw new Error("usage: deck link <slug> [--json]");
79
+ const out = ops.ownerLink(opts, slug);
80
+ if (opts.json) return console.log(JSON.stringify(out, null, 2));
81
+ console.log(out.ownerUrl);
82
+ if (out.backupUrl) console.log(`backup (other domain): ${out.backupUrl}`);
83
+ console.log("Only you can open it, signed in; nothing is tracked. Present (or H) hides your owner tools for a screen share.");
84
+ }
85
+
75
86
  function deckOpen(pos, opts) {
76
87
  const [slug] = pos;
77
88
  if (!slug) throw new Error("usage: deck open <slug>");
78
- const url = `${target(opts).brokerBase}/d/${slug}`;
79
- console.log(url);
80
- openBrowser(url);
89
+ const { ownerUrl } = ops.ownerLink(opts, slug);
90
+ console.log(ownerUrl);
91
+ openBrowser(ownerUrl);
81
92
  }
82
93
 
83
94
  async function deckRemove(pos, opts) {
@@ -89,8 +100,11 @@ async function deckRemove(pos, opts) {
89
100
 
90
101
  async function shareNew(pos, opts) {
91
102
  const [slug, recipient] = pos;
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]");
103
+ const usage = "usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]";
104
+ if (!slug || !recipient) throw new Error(usage);
93
105
  if (opts["allow-forward"] && opts["no-forward"]) throw new Error("pick one of --allow-forward and --no-forward");
106
+ // The link's own pace; without it the link follows the deck's.
107
+ if ("pace" in opts && !ops.DECK_PACES.includes(opts.pace)) throw new Error(usage);
94
108
  const { token } = await ops.createShare(opts, {
95
109
  slug,
96
110
  recipient,
@@ -100,6 +114,7 @@ async function shareNew(pos, opts) {
100
114
  qa: opts.qa || undefined,
101
115
  notify: opts["no-notify"] ? false : undefined,
102
116
  allowForward: opts["allow-forward"] ? true : opts["no-forward"] ? false : undefined,
117
+ pace: opts.pace,
103
118
  expires: opts.expires,
104
119
  limit: opts.limit,
105
120
  });
@@ -123,7 +138,7 @@ async function shareList(pos, opts) {
123
138
  // Rows arrive with each link's forwards right after it; indent those.
124
139
  for (const r of rows) {
125
140
  const who = r.parentToken ? ` ↳ ${r.recipient} via ${r.forwardedBy ?? "?"}` : r.recipient;
126
- console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
141
+ console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.pace ? `\tpace:${r.pace}` : ""}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
127
142
  }
128
143
  if (!rows.length) console.log("(no shares)");
129
144
  }
@@ -348,7 +363,10 @@ commands:
348
363
  login [--token <pat>] [--broker <url>] [--dev]
349
364
  logout [--dev]
350
365
  whoami [--json] [--dev]
351
- deck list|stats|open|rm|forward|view
366
+ deck list|stats|open|link|rm|forward|view
367
+ deck link <slug> [--json]
368
+ the owner link: present or check your own deck (only you, signed in; untracked;
369
+ Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
352
370
  deck forward on|off <slug>
353
371
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
354
372
  deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
@@ -378,7 +396,7 @@ commands:
378
396
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
379
397
  on a committed deck: check → narrate → import → share new per --to
380
398
  share new|list|on|off|forward|mute|unmute|rm|url
381
- share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
399
+ share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]
382
400
  share forward on|off|default <token>
383
401
  let this link's viewer forward it as their own tracked link ("Dana via Peter",
384
402
  one hop); default follows the deck. share off on a link also turns off its forwards.
@@ -972,9 +990,20 @@ function commandTable(pos, rest, opts) {
972
990
  console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
973
991
  },
974
992
  "deck narrator": async () => {
975
- if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
993
+ if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default> [--dir <deck folder>]");
976
994
  const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
977
- console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
995
+ const log = opts.json ? (line) => console.error(line) : (line) => console.log(line);
996
+ log(`OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
997
+ // The local file names the face too, so the draft opened from disk shows it.
998
+ const { writeDraftFace } = await import("./draftFace.mjs");
999
+ const dir = resolve(typeof opts.dir === "string" ? opts.dir : join("decks", out.slug));
1000
+ let local = { wrote: false };
1001
+ try {
1002
+ local = await writeDraftFace(opts, out.slug, join(dir, "index.html"), { log });
1003
+ } catch (e) {
1004
+ log(`⚠ presenter: the server was updated but index.html was not (${e.message})`);
1005
+ }
1006
+ if (opts.json) console.log(JSON.stringify({ ...out, indexHtml: local.wrote ? join(dir, "index.html") : null }, null, 2));
978
1007
  },
979
1008
  "preview setup": async () => {
980
1009
  const { setupCapture } = await import("./preview-capture.mjs");
@@ -986,6 +1015,7 @@ function commandTable(pos, rest, opts) {
986
1015
  "deck list": () => deckList(opts),
987
1016
  "deck stats": () => deckStats(rest, opts),
988
1017
  "deck open": () => deckOpen(rest, opts),
1018
+ "deck link": () => deckLink(rest, opts),
989
1019
  "deck rm": () => deckRemove(rest, opts),
990
1020
  "deck forward": () => deckForward(rest, opts),
991
1021
  "deck view": () => deckView(rest, opts),
@@ -0,0 +1,56 @@
1
+ // The narrator's face written into a draft's index.html on disk (roadmap:
2
+ // a-narrated-draft-shows-its-narrator-s-face-from-the-file-on-), so the file
3
+ // plays with its face however it is opened, not only through play and release.
4
+ // deck new, deck narrator and narrate use it.
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import * as ops from "./ops.mjs";
7
+ import { DASH_PRESENTER, withNarrator, writeNarratorFace } from "./presenter.mjs";
8
+
9
+ // What to write for `slug`: { face } (null: captions only on purpose), or
10
+ // { face: undefined, why } when the narrator has no face and nobody chose captions
11
+ // only (writeDraftFace then takes a CLI-written face off), or the lookup failed
12
+ // (`failed`: nothing is decided, the file stays).
13
+ export async function draftFace(opts, slug) {
14
+ let found;
15
+ try {
16
+ found = await ops.presenterWithReason(opts, slug, undefined, { draft: true });
17
+ } catch (e) {
18
+ return { face: undefined, why: `could not look up the narrator's face (${e.message})`, failed: true };
19
+ }
20
+ if (found?.presenter) return { face: found.presenter, name: found.who?.face ?? null };
21
+ // Captions only on purpose only when the deck says so; faces off or a missing face stays unwritten.
22
+ if (!found?.presenter && found?.captionsOnly) return { face: null, name: null };
23
+ return { face: undefined, why: found?.who?.reason ?? "no narrator with a face" };
24
+ }
25
+
26
+ // Write `slug`'s face into `htmlPath`. `fallback`: the face to write when the
27
+ // lookup fails (deck new writes D.A.S.H.). Logs one line; returns what it wrote.
28
+ export async function writeDraftFace(opts, slug, htmlPath, { log = () => {}, fallback } = {}) {
29
+ if (!existsSync(htmlPath)) {
30
+ log(`⚠ presenter: ${htmlPath} not found, so the file was not updated (the server was); pass --dir <deck folder>`);
31
+ return { wrote: false };
32
+ }
33
+ const got = await draftFace(opts, slug);
34
+ const face = got.face === undefined && got.failed && fallback ? fallback : got.face;
35
+ const html = readFileSync(htmlPath, "utf8");
36
+ if (face === undefined) {
37
+ // The narrator has no face now: a face the CLI wrote for an earlier one comes off, as play and release do.
38
+ const off = got.failed ? html : withNarrator(html, null, { face: null });
39
+ if (off !== html) {
40
+ writeFileSync(htmlPath, off);
41
+ log(`⚠ presenter: no face (${got.why}); the earlier narrator's face was taken off index.html`);
42
+ return { wrote: true, face: null, why: got.why };
43
+ }
44
+ log(`⚠ presenter: index.html left as written: ${got.why}`);
45
+ return { wrote: false, why: got.why };
46
+ }
47
+ const out = writeNarratorFace(html, face);
48
+ if (!out.wrote) {
49
+ log(`⚠ presenter: index.html left as written: ${out.why}`);
50
+ return { wrote: false, why: out.why };
51
+ }
52
+ if (out.html !== html) writeFileSync(htmlPath, out.html);
53
+ const who = face === null ? "captions only" : face === DASH_PRESENTER && got.failed ? `D.A.S.H. (${got.why}; deck narrator <name> fixes it)` : got.name ?? "the narrator's face";
54
+ log(` presenter: ${who}, written to index.html`);
55
+ return { wrote: true, face };
56
+ }
package/src/files.mjs CHANGED
@@ -13,6 +13,9 @@ const FILES = {
13
13
  "templates/layouts/closer.html": () => new URL("../templates/layouts/closer.html", import.meta.url),
14
14
  "templates/layouts/compare.html": () => new URL("../templates/layouts/compare.html", import.meta.url),
15
15
  "templates/layouts/features.html": () => new URL("../templates/layouts/features.html", import.meta.url),
16
+ "templates/layouts/cover-band.html": () => new URL("../templates/layouts/cover-band.html", import.meta.url),
17
+ "templates/layouts/cover-poster.html": () => new URL("../templates/layouts/cover-poster.html", import.meta.url),
18
+ "templates/layouts/cover-type.html": () => new URL("../templates/layouts/cover-type.html", import.meta.url),
16
19
  "templates/layouts/hero.html": () => new URL("../templates/layouts/hero.html", import.meta.url),
17
20
  "templates/layouts/number.html": () => new URL("../templates/layouts/number.html", import.meta.url),
18
21
  "templates/layouts/product.html": () => new URL("../templates/layouts/product.html", import.meta.url),
package/src/layouts.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  import { packageText } from "./files.mjs";
5
5
 
6
6
  export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
7
- // The library, in a sensible deck order; `--layouts` takes any order and repeats.
8
- export const LAYOUTS = ["hero", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
7
+ // The library: the four covers first (hero is the default; a deck opens on one), then
8
+ // the rest in a sensible deck order. `--layouts` takes any order and repeats.
9
+ export const LAYOUTS = ["hero", "cover-band", "cover-poster", "cover-type", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
10
  const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features", "image-copy": "split", "image-text": "split" };
10
11
 
11
12
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -4,11 +4,17 @@
4
4
  // (lib/loadingBlock.test.ts keeps the three in step). A JS string, not a read of
5
5
  // the template, because Convex (the hosted draft's write) cannot read package files.
6
6
  // No Node imports: the CLI and Convex (and so the MCP route's writes) use it.
7
+ // The opening card (opening.mjs) is the loader: its rules in the style, and the
8
+ // <script data-hd-opening> after it that builds it once the page is parsed. A
9
+ // frame or ?thumb (data-hd-frame) gets the quiet dots instead; an export neither.
10
+ import { OPENING_CSS, OPENING_PAGE_SCRIPT } from "./opening.mjs";
11
+
7
12
  export const LOADING_BLOCK = [
8
13
  "<!-- Loading state (keep verbatim; `hyperdecks check` requires it). Until the",
9
14
  " player marks body[data-hd-ready] the page shows only the slide card, at its",
10
15
  " final size, in the deck's --hd-ground (its first slide's colour) on the",
11
- " page's brand colour, with a quiet loader in --hd-ground-ink; then the",
16
+ " page's brand colour, with the opening card over it (the title and the",
17
+ " presenter's still), or in a frame quiet dots in --hd-ground-ink; then the",
12
18
  " slides fade in. If the player never arrives, the slides show after 10 s",
13
19
  " rather than never. Put data-layout=\"overlay\" on <html> for the overlay. -->",
14
20
  "<link rel=\"modulepreload\" href=\"https://hyperdecks.ai/player/v1/player.js\">",
@@ -22,7 +28,7 @@ export const LOADING_BLOCK = [
22
28
  " /* The card as the player draws it (player.css, framed): 16:9, scaled to fit",
23
29
  " between the 64px masthead and the 56px dock, 24px in from the sides. */",
24
30
  " body::before { inset: 64px 24px 56px; margin: auto; width: min(100vw - 48px, (100dvh - 120px) * 16 / 9); height: min(100dvh - 120px, (100vw - 48px) * 9 / 16); border-radius: calc(min(100vw - 48px, (100dvh - 120px) * 16 / 9) * .01125); background: var(--hd-ground, #fff); box-shadow: 0 0 0 1px color-mix(in srgb, var(--hd-ground-ink, #0b0b0f) 16%, transparent); }",
25
- " body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }",
31
+ " [data-hd-frame] body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }",
26
32
  " body[data-hd-ready]::before, body[data-hd-ready]::after { opacity: 0; animation: none; }",
27
33
  " /* The overlay (embedded, ?thumb, or data-layout=\"overlay\"): the stage fills the window. */",
28
34
  " :is([data-hd-frame], [data-layout=\"overlay\"]) body::before { inset: 0; width: min(100vw, 100dvh * 16 / 9); height: min(100dvh, 100vw * 9 / 16); border-radius: calc(min(100vw, 100dvh * 16 / 9) * .01125); box-shadow: none; }",
@@ -42,21 +48,28 @@ export const LOADING_BLOCK = [
42
48
  " @keyframes hd-in { from { opacity: 0; } }",
43
49
  " @keyframes hd-dots { from { opacity: .15; } to { opacity: .6; } }",
44
50
  " @keyframes hd-fail-open { to { visibility: visible; overflow: auto; } }",
51
+ ...OPENING_CSS.replace(/\n$/, "").split("\n"),
45
52
  "</style>",
53
+ OPENING_PAGE_SCRIPT,
46
54
  ].join("\n");
47
55
 
48
56
  const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i;
49
57
  const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
50
58
  const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
51
59
  const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
52
- // The block's pieces besides its style, on any player host: the two preloads and
53
- // the frame flag (each kept once), and its own comment (matched only as a whole
60
+ const OPENING_SCRIPT = /<script data-hd-opening>[\s\S]*?<\/script>/;
61
+ const OPENING_SCRIPT_PIECE = new RegExp(`${OPENING_SCRIPT.source}[ \\t]*\\n?`, "g");
62
+ // The script with the line break before it (as the block writes it after the style).
63
+ const OPENING_SCRIPT_LINE = new RegExp(`\\n?[ \\t]*${OPENING_SCRIPT.source}`, "g");
64
+ // The block's pieces besides its style, on any player host: the two preloads,
65
+ // the frame flag and the card's <script data-hd-opening> (each kept once), and its own comment (matched only as a whole
54
66
  // comment, so a person's comments in <head> are never touched).
55
67
  const PIECES = [
56
68
  /<link rel="modulepreload" href="[^"]*\/player\/v\d+\/player\.js">[ \t]*\n?/g,
57
69
  /<link rel="preload" as="style" href="[^"]*\/player\/v\d+\/player\.css">[ \t]*\n?/g,
58
70
  /<script>if \(self !== top \|\| [^<]*document\.documentElement\.dataset\.hdFrame = "";<\/script>[ \t]*\n?/g,
59
71
  new RegExp(`${STYLE.source}[ \\t]*\\n?`, "gi"),
72
+ OPENING_SCRIPT_PIECE,
60
73
  ];
61
74
  const BLOCK_COMMENT = /^<!-- Loading state \(keep verbatim/;
62
75
 
@@ -93,11 +106,13 @@ export function withLoadingBlock(html) {
93
106
  const at = title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(cleaned)[0].length;
94
107
  return { html: splice(`${cleaned.slice(0, at)}\n${LOADING_BLOCK}${cleaned.slice(at)}`), fixed: "inserted" };
95
108
  }
96
- const deduped = trimPieces(rawHead, 1);
109
+ // The card's script goes right after the style, as the template's (a block
110
+ // from before the card gets it; a stray copy elsewhere goes).
111
+ const deduped = trimPieces(rawHead, 1).split(/(<!--[\s\S]*?-->)/).map((part, i) => (i % 2 ? part : part.replace(OPENING_SCRIPT_LINE, ""))).join("");
97
112
  // The one style left, outside comments, made the template's.
98
113
  const blank = deduped.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
99
114
  const style = STYLE.exec(blank);
100
- const fresh = deduped.slice(0, style.index) + LOADING_STYLE + deduped.slice(style.index + style[0].length);
115
+ const fresh = deduped.slice(0, style.index) + LOADING_STYLE + "\n" + OPENING_PAGE_SCRIPT + deduped.slice(style.index + style[0].length);
101
116
  if (fresh === rawHead) return { html, fixed: null };
102
- return { html: splice(fresh), fixed: deduped !== rawHead ? "deduped" : "refreshed" };
117
+ return { html: splice(fresh), fixed: trimPieces(rawHead, 1) !== rawHead ? "deduped" : "refreshed" };
103
118
  }
package/src/narrate.mjs CHANGED
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { basename, join, resolve } from "node:path";
10
10
  import * as ops from "./ops.mjs";
11
11
  import { presenterLine } from "./presenter.mjs";
12
+ import { writeDraftFace } from "./draftFace.mjs";
12
13
  export { sectionIds, setManifestUrl } from "./checkRules.mjs";
13
14
  import { sectionIds, setManifestUrl } from "./checkRules.mjs";
14
15
 
@@ -39,7 +40,8 @@ export function narrationRequest(pos, opts) {
39
40
  };
40
41
  }
41
42
 
42
- export async function narrate(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
43
+ // writeFace false: leave the presenter as written (release narrates, then puts the face on the upload itself).
44
+ export async function narrate(pos, opts = {}, { log = (line) => console.log(line), writeFace = true } = {}) {
43
45
  const { deckDir, fields } = narrationRequest(pos, opts);
44
46
  if (opts["dry-run"]) {
45
47
  const preview = await ops.previewNarration(opts, fields);
@@ -85,6 +87,9 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
85
87
  writeFileSync(htmlPath, next);
86
88
  log(` index.html: data-manifest → ${job.manifestUrl}`);
87
89
  }
90
+ // The narrator's face too, so the file opened as written shows what play and release show.
91
+ // The narration is paid for and saved: a failed face write warns and never fails narrate.
92
+ if (writeFace) await writeDraftFace(opts, fields.slug, htmlPath, { log }).catch((e) => log(`⚠ presenter: index.html not updated (${e?.message ?? e})`));
88
93
  }
89
94
  log(`✓ ${job.manifest.clips.length} clips, ${job.manifest.spokenDuration}s spoken, ${job.manifest.totalDuration}s with holds; ${job.renderedChars} characters rendered`);
90
95
  log(` manifest: ${job.manifestUrl}\n saved: ${join(out, "manifest.json")}`);
@@ -0,0 +1,117 @@
1
+ // The opening card a deck shows while its player loads (#207): the first
2
+ // slide blurred and breathing where the slide will be, and a glass card with
3
+ // the presenter's still hopping, the title and the player's real boot steps
4
+ // (body[data-hd-boot], player.js). When the player marks body[data-hd-ready]
5
+ // the presenter jumps, the card fades and the slide sharpens.
6
+ //
7
+ // One card, two ways in: a route that serves the deck adds it with the deck's
8
+ // poster and narrator (lib/deckLoadingOverlay.ts), and every deck's own
9
+ // loading state (loadingBlock.mjs, the templates) builds it in the page from
10
+ // what the file has (its <title>, the player tag's data-presenter-image), so a
11
+ // raw deck file opens on it too. The page's own card steps aside when a route
12
+ // already added one. Never in a frame, a thumbnail (?thumb,
13
+ // data-hyperdecks-thumb) or an export (?export); a deck ready within FAST_MS
14
+ // never shows it; if the player never says ready it goes after FAIL_OPEN_MS.
15
+ // No Node imports: the CLI, Convex and the app all use it.
16
+
17
+ export const FAST_MS = 150;
18
+ export const FAIL_OPEN_MS = 10000;
19
+
20
+ // The boot steps player.js reports on body[data-hd-boot], in the order it
21
+ // reaches them: [progress %, copy]. {name} is the presenter's.
22
+ export const BOOT_STEPS = Object.freeze({
23
+ page: [10, "Opening the deck…"],
24
+ player: [38, "Loading the player…"],
25
+ presenter: [62, "Waking up {name}…"],
26
+ narration: [84, "Getting the narration…"],
27
+ });
28
+
29
+ // The card's rules (the route wraps them in <style data-hd-opening>; the
30
+ // loading block carries them in its <style data-hd-loading>).
31
+ export const OPENING_CSS = `#hd-open{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:visible!important;animation:none!important;opacity:0;font-family:var(--hd-ui-font,"Space Grotesk",system-ui,sans-serif);color:#0b0b0f}
32
+ #hd-open.is-shown{opacity:1;transition:opacity .2s ease-out}
33
+ #hd-open.is-gone{opacity:0;transition:opacity .45s ease-out}
34
+ #hd-open .hdo-slide{position:absolute;inset:64px 24px 56px;margin:auto;width:min(100vw - 48px,(100dvh - 120px)*16/9);height:min(100dvh - 120px,(100vw - 48px)*9/16);border-radius:calc(min(100vw - 48px,(100dvh - 120px)*16/9)*.01125);overflow:hidden;container-type:inline-size;background:var(--hd-ground,#fff)}
35
+ #hd-open .hdo-slide img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;filter:blur(2.2cqi) saturate(1.15);transform:scale(1.12);animation:hdo-breathe 2.4s ease-in-out infinite}
36
+ #hd-open .hdo-slide::after{content:"";position:absolute;inset:0;background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.3) 50%,transparent 70%);background-size:250% 100%;animation:hdo-shim 1.8s ease-in-out infinite;mix-blend-mode:soft-light}
37
+ #hd-open.is-done .hdo-slide img{animation:hdo-deblur .5s cubic-bezier(.2,.8,.2,1) .5s forwards}
38
+ #hd-open.is-done .hdo-slide::after{opacity:0;transition:opacity .3s}
39
+ .hdo-card{position:absolute;left:50%;top:50%;translate:-50% -50%;width:min(86vw,420px);display:grid;justify-items:center;gap:.6em;padding:1.3em 1.5em 1.2em;border-radius:22px;background:rgba(255,255,255,.74);-webkit-backdrop-filter:blur(14px) saturate(1.3);backdrop-filter:blur(14px) saturate(1.3);box-shadow:4px 4px 0 #0b0b0f;border:1.5px solid #0b0b0f;text-align:center;box-sizing:border-box}
40
+ #hd-open.is-done .hdo-card{opacity:0;transform:scale(.96);transition:opacity .25s .5s,transform .25s .5s}
41
+ .hdo-buddy{position:relative;width:92px;height:96px;margin-top:-62px;display:grid;justify-items:center}
42
+ .hdo-hop{width:86px;height:80px;transform-origin:50% 100%;animation:hdo-hop 1.15s cubic-bezier(.3,0,.4,1) infinite}
43
+ .hdo-look{width:100%;height:100%;animation:hdo-look 4.6s ease-in-out infinite}
44
+ .hdo-buddy img{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 3px 0 rgba(11,11,15,.18))}
45
+ .hdo-shadow{position:absolute;bottom:4px;width:46px;height:8px;border-radius:50%;background:rgba(11,11,15,.22);animation:hdo-shadow 1.15s cubic-bezier(.3,0,.4,1) infinite}
46
+ .hdo-think{position:absolute;right:-30px;top:-6px;display:flex;gap:3px;padding:5px 8px;border-radius:14px;background:#fff;border:1.5px solid #0b0b0f;box-shadow:2px 2px 0 #0b0b0f}
47
+ .hdo-think::after{content:"";position:absolute;left:4px;bottom:-7px;width:7px;height:7px;border-radius:50%;background:#fff;border:1.5px solid #0b0b0f}
48
+ .hdo-think i{width:5px;height:5px;border-radius:50%;background:#0b0b0f;animation:hdo-tdot 1s ease-in-out infinite}
49
+ .hdo-think i:nth-child(2){animation-delay:.15s}.hdo-think i:nth-child(3){animation-delay:.3s}
50
+ .hdo-spark{position:absolute;left:50%;top:40%;width:8px;height:8px;margin:-4px;opacity:0}
51
+ .hdo-spark::before{content:"";position:absolute;inset:0;background:#0b0b0f;clip-path:polygon(50% 0,62% 38%,100% 50%,62% 62%,50% 100%,38% 62%,0 50%,38% 38%)}
52
+ #hd-open.is-done .hdo-hop{animation:hdo-yay .55s cubic-bezier(.2,1.4,.4,1) forwards}
53
+ #hd-open.is-done :is(.hdo-look,.hdo-shadow){animation:none}
54
+ #hd-open.is-done .hdo-think{opacity:0;transition:opacity .15s}
55
+ #hd-open.is-done .hdo-spark{animation:hdo-spark .6s ease-out forwards}
56
+ .hdo-title{font-weight:700;font-size:18px;line-height:1.2;text-wrap:balance;overflow-wrap:anywhere}
57
+ .hdo-by{font:12.5px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#3d3b35}
58
+ .hdo-bar{width:100%;height:5px;border-radius:999px;background:rgba(11,11,15,.12);overflow:hidden}
59
+ .hdo-bar i{display:block;height:100%;width:8%;background:#0b0b0f;border-radius:999px;transition:width .35s ease-out}
60
+ .hdo-step{font:12px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#55534b;min-height:1.3em}
61
+ @keyframes hdo-breathe{50%{filter:blur(2.6cqi) saturate(1.25) brightness(1.05)}}
62
+ @keyframes hdo-shim{0%{background-position:120% 0}100%{background-position:-120% 0}}
63
+ @keyframes hdo-deblur{to{filter:blur(0);transform:none}}
64
+ @keyframes hdo-hop{0%,100%{transform:translateY(0) scale(1.06,.92)}12%{transform:translateY(0) scale(.95,1.06)}45%{transform:translateY(-14px) scale(1,1)}70%{transform:translateY(0) scale(1.04,.95)}82%{transform:translateY(0) scale(1,1)}}
65
+ @keyframes hdo-shadow{0%,100%{transform:scale(1.05);opacity:.9}45%{transform:scale(.62);opacity:.45}70%{transform:scale(1);opacity:.85}}
66
+ @keyframes hdo-look{0%,18%{transform:rotate(0)}24%,38%{transform:rotate(-9deg) translateX(-2px)}44%,58%{transform:rotate(8deg) translateX(2px)}64%,100%{transform:rotate(0)}}
67
+ @keyframes hdo-tdot{0%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
68
+ @keyframes hdo-yay{0%{transform:translateY(0) scale(1.08,.9)}40%{transform:translateY(-26px) scale(.94,1.08) rotate(-6deg)}100%{transform:translateY(-6px) scale(1) rotate(0)}}
69
+ @keyframes hdo-spark{0%{opacity:0;transform:translate(0,0) scale(.4)}30%{opacity:1}100%{opacity:0;transform:translate(var(--x),var(--y)) scale(1.2) rotate(90deg)}}
70
+ :is([data-hd-frame],[data-layout="overlay"]) #hd-open .hdo-slide{inset:0;width:min(100vw,100dvh*16/9);height:min(100dvh,100vw*9/16)}
71
+ @media (max-width:650px){#hd-open .hdo-slide{inset:52px 8px calc(56px + env(safe-area-inset-bottom));width:auto;height:auto;border-radius:14px}.hdo-card{width:min(84vw,320px);padding:1em 1.1em .95em}.hdo-title{font-size:15.5px}.hdo-buddy{transform:scale(.85);margin-top:-58px}}
72
+ @media (prefers-reduced-motion:reduce){#hd-open *,#hd-open *::before,#hd-open *::after{animation:none!important;transition:none!important}#hd-open .hdo-slide::after{display:none}#hd-open.is-done .hdo-card{opacity:1;transform:none}}
73
+ @media print{#hd-open{display:none!important}}
74
+ `;
75
+
76
+ // Runs the card `o` (#hd-open): shows it after FAST_MS unless the deck is
77
+ // ready, follows data-hd-boot, and on data-hd-ready jumps, fades and removes
78
+ // itself. In a frame, a thumbnail or an export it removes itself at once.
79
+ export const OPENING_RUN = `function(o){var b=document.body,d=document.documentElement;if(!o||!b)return;
80
+ if(self!==top||/[?&](thumb|export)\\b/.test(location.search)||d.hasAttribute("data-hyperdecks-thumb")){o.remove();return}
81
+ var S=${JSON.stringify(BOOT_STEPS)},bar=o.querySelector(".hdo-bar i"),step=o.querySelector(".hdo-step"),who=o.getAttribute("data-name")||"the presenter",at=0,shown=false,done=false,mo;
82
+ var rm=window.matchMedia&&matchMedia("(prefers-reduced-motion: reduce)").matches;
83
+ function boot(){var s=S[b.getAttribute("data-hd-boot")];if(!s||s[0]<=at)return;at=s[0];bar.style.width=s[0]+"%";step.textContent=s[1].split("{name}").join(who)}
84
+ function finish(){if(done)return;done=true;if(mo)mo.disconnect();clearTimeout(t1);clearTimeout(t2);
85
+ if(!shown){o.remove();return}bar.style.width="100%";step.textContent="Ready";
86
+ o.classList.add("is-done");setTimeout(function(){o.classList.add("is-gone")},rm?0:500);setTimeout(function(){o.remove()},rm?500:1000)}
87
+ function check(){if(b.hasAttribute("data-hd-ready"))finish();else boot()}
88
+ var t1=setTimeout(function(){if(!done){shown=true;o.classList.add("is-shown")}},${FAST_MS});
89
+ var t2=setTimeout(finish,${FAIL_OPEN_MS});
90
+ step.textContent=S.page[1];
91
+ if(window.MutationObserver){mo=new MutationObserver(check);mo.observe(b,{attributes:true,attributeFilter:["data-hd-ready","data-hd-boot"]})}
92
+ check()}`;
93
+
94
+ // Five sparks around the presenter for the jump at ready.
95
+ export const OPENING_SPARKS = [[-40, -30], [42, -34], [-30, 10], [36, 6], [0, -46]]
96
+ .map(([x, y]) => `<span class="hdo-spark" style="--x:${x}px;--y:${y}px"></span>`).join("");
97
+ // The presenter's still in the card (its src set by whoever builds it).
98
+ export const OPENING_BUDDY = `<div class="hdo-buddy"><div class="hdo-hop"><div class="hdo-look"><img src="" alt=""></div></div><div class="hdo-shadow"></div><div class="hdo-think"><i></i><i></i><i></i></div>${OPENING_SPARKS}</div>`;
99
+
100
+ // The page's own card (the loading block's <script data-hd-opening>): once the
101
+ // page is parsed (readyState "interactive", before the player module runs),
102
+ // unless a route already added a card, the player is ready, or the page is
103
+ // framed, a thumbnail or an export, it puts a card at the top of <body> with
104
+ // the page's <title> and the player tag's presenter still (none when it
105
+ // presents nobody), with no poster and no narrator's name, and runs it.
106
+ export const OPENING_PAGE_SCRIPT = `<script data-hd-opening>(function(){var d=document;
107
+ if(self!==top||/[?&](thumb|export)\\b/.test(location.search))return;
108
+ function go(){if(d.readyState==="loading")return;d.removeEventListener("readystatechange",go);
109
+ var b=d.body,p=d.querySelector('script[src*="/player/v"][src*="/player.js"]');
110
+ if(!b||!p||d.getElementById("hd-open")||b.hasAttribute("data-hd-ready")||d.documentElement.hasAttribute("data-hyperdecks-thumb"))return;
111
+ var o=d.createElement("div"),img=p.getAttribute("data-presenter")!=="none"&&p.getAttribute("data-presenter-image");
112
+ o.id="hd-open";o.setAttribute("aria-hidden","true");
113
+ o.innerHTML='<div class="hdo-slide"></div><div class="hdo-card">'+(img?${JSON.stringify(OPENING_BUDDY)}:'')+'<div class="hdo-title"></div><div class="hdo-bar"><i></i></div><div class="hdo-step"></div></div>';
114
+ if(img){var i=o.querySelector(".hdo-buddy img");i.addEventListener("error",function(){var y=o.querySelector(".hdo-buddy");if(y)y.remove()});i.src=img}
115
+ o.querySelector(".hdo-title").textContent=(d.title||"").replace(/\\s+/g," ").trim();
116
+ b.insertBefore(o,b.firstChild);(${OPENING_RUN})(o)}
117
+ d.addEventListener("readystatechange",go);go()})();</script>`;
package/src/ops.mjs CHANGED
@@ -3,7 +3,7 @@ import { zipSync } from "fflate";
3
3
  import { SPECIES } from "./species.mjs";
4
4
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
5
5
  import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
6
- import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
6
+ import { api, backupLinkUrl, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
7
7
  import {
8
8
  bytesFromText,
9
9
  isTextAsset,
@@ -21,6 +21,17 @@ export async function whoami(opts = {}) {
21
21
  }
22
22
 
23
23
  // Your own decks. `all: true` lists every deck, for admins only (support tools).
24
+ // The owner link (/d/<slug>, app/d/[slug]): the released deck for its owner
25
+ // only, untracked, with the owner toolbar (Present hides it for a screen
26
+ // share). How an owner or their agent presents or checks a deck; anyone else
27
+ // gets a sign-in notice. QA links are only for testing what a recipient sees.
28
+ // `backupUrl`: the same link on the other app host (null in dev).
29
+ export function ownerLink(opts, slug) {
30
+ if (!slug) throw new Error("slug is required");
31
+ const url = `${target(opts).brokerBase}/d/${encodeURIComponent(slug)}`;
32
+ return { slug, ownerUrl: url, backupUrl: backupLinkUrl(url) };
33
+ }
34
+
24
35
  export async function listDecks(opts = {}, { all = false } = {}) {
25
36
  const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
26
37
  return rows.map((d) => ({
@@ -39,6 +50,9 @@ export async function listDecks(opts = {}, { all = false } = {}) {
39
50
  // false: the deck runs its own player, so view/viewToggle do not apply
40
51
  // (null: not known yet, treated as on).
41
52
  usesPlayer: d.usesPlayer ?? null,
53
+ // Present or check it yourself here (owner-only, untracked); not a link to
54
+ // send. Null with nothing released, or on someone else's deck (admins' all).
55
+ ownerUrl: !all && (d.html || d.htmlStorageId || d.hasHtml) ? ownerLink(opts, d.slug).ownerUrl : null,
42
56
  }));
43
57
  }
44
58
 
@@ -568,6 +582,8 @@ export async function listShares(opts, slug) {
568
582
  // viewer who forwarded it. Rows come back with each link's forwards after it.
569
583
  allowForward: !!r.allowForward,
570
584
  allowForwardOverride: r.allowForwardOverride ?? null,
585
+ // The link's own pace (auto, slide, step); null = it follows the deck.
586
+ pace: r.pace ?? null,
571
587
  parentToken: r.parentToken ?? null,
572
588
  forwardedBy: r.forwardedBy ?? null,
573
589
  probablyForwarded: !!r.probablyForwarded,
@@ -584,6 +600,11 @@ export async function createShare(opts, fields) {
584
600
  if (fields.note) args.note = fields.note;
585
601
  if (fields.requireEmail) args.requireEmail = true;
586
602
  if (fields.allowForward !== undefined) args.allowForward = !!fields.allowForward;
603
+ // The link's own pace; absent = it follows the deck's.
604
+ if (fields.pace !== undefined) {
605
+ if (!DECK_PACES.includes(fields.pace)) throw new Error(`pace must be auto, slide or step (got ${JSON.stringify(fields.pace)})`);
606
+ args.pace = fields.pace;
607
+ }
587
608
  if (fields.expires) {
588
609
  const t = Date.parse(fields.expires);
589
610
  if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
@@ -1191,8 +1212,9 @@ export async function presenterFor(opts, slug, voice) {
1191
1212
  }
1192
1213
 
1193
1214
  // The same answer as presenterFor plus who presents in words: { presenter, who }.
1194
- export async function presenterWithReason(opts, slug, voice) {
1195
- return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}) });
1215
+ // `draft`: also answer for a deck not narrated yet, from the voice its narrate would use.
1216
+ export async function presenterWithReason(opts, slug, voice, { draft = false } = {}) {
1217
+ return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}), ...(draft ? { draft: true } : {}) });
1196
1218
  }
1197
1219
 
1198
1220
  // ---- narrators (convex/narrators.ts) ------------------------------------------
package/src/play.mjs CHANGED
@@ -12,7 +12,7 @@ import { basename, join, relative } from "node:path";
12
12
  import { playerScript, sectionIds } from "./checkRules.mjs";
13
13
  import { resolveDeckDir } from "./check.mjs";
14
14
  import * as ops from "./ops.mjs";
15
- import { playsCaptionsOnly, presenterLine, withAvatar } from "./presenter.mjs";
15
+ import { playsCaptionsOnly, presenterLine, withNarrator } from "./presenter.mjs";
16
16
  import { serveSource } from "./preview-capture.mjs";
17
17
  import { openBrowser } from "./runtime.mjs";
18
18
 
@@ -88,7 +88,7 @@ export async function startPlay(pos, opts = {}, { log = (line) => console.log(li
88
88
  const html = readFileSync(file, "utf8");
89
89
 
90
90
  const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
91
- const page = presenter ? withAvatar(html, presenter) : html;
91
+ const page = withNarrator(html, presenter, who);
92
92
  sayWhoPresents({ html, page, presenter, who, log });
93
93
  // --voice names the voice it was narrated in, so there is nothing to compare it to.
94
94
  if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });