@hypersocial/decks-cli 0.9.0 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -143,6 +143,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
143
143
  | Watch a draft with its real face | `hyperdecks play decks/<slug>` | the `previewUrl` from `get_deck_source` |
144
144
  | Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
145
145
  | Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
146
+ | PDF for a data room or attachment | `hyperdecks export <slug> --pdf [--notes] --out deck.pdf`, `hyperdecks export list` | `export_deck`, `list_exports` |
146
147
  | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
147
148
  | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
148
149
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
@@ -460,7 +461,8 @@ suit it.
460
461
  |---|---|---|
461
462
  | `hero` | the opening, a section opener; the presenter is the visual, no `.art` unless it shows the real subject (check fails while the placeholder `<div class="art">` is in: swap it for an `<img class="art">` or delete it) | stage, bottom-right; `[wave]`, `[look: h1]` |
462
463
  | `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
463
- | `product` | a screenshot in a browser frame (`.frame.phone` for a phone shot) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
464
+ | `product` | a screenshot in a browser frame (`.frame.card` for a phone) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
465
+ | `phones` | a phone app's flow: a fanned row of phone cards | stage, bottom-left; `[go: #<id>-p1]` … `p3`, `[go: slot]` |
464
466
  | `quote` | a customer or user in their own words, quoted exactly | stage, bottom-left; `[look: #<id>-quote]` |
465
467
  | `compare` | before/after, them/us, three parallel lines each | stage; `[look: #<id>-before]`, `[go: #<id>-after]` |
466
468
  | `timeline` | four milestones: history, a roadmap, a process | stage; `[go: #<id>-m1]` … `m4`, `[go: slot]` |
@@ -469,11 +471,15 @@ suit it.
469
471
  | `team` | up to four people, faces only with permission | stage; `[go: #<id>-p1]` … `p4`, `[go: slot]` |
470
472
  | `closer` | the end: one line and one action (a command, a link) | stage, bottom-left; `[go: #<id>-cta]`, `[go: slot]`, `[wave]` |
471
473
 
474
+ **Phones and backgrounds.** One phone: `product`'s `.frame.card`; a flow of
475
+ two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
476
+ `bg-dots` (never under text), a `.deco` of inked tiles.
477
+
472
478
  Presenter style follows the deck (step 2, *Which presenter style*): `stage`
473
479
  when the presenter carries the story, which is what the layouts are drawn for
474
480
  (they keep the slot's corner clear); `corner` on a slide where a big
475
481
  visual is the point and moves are not needed; `glass` on a dark or busy
476
- picture. Two layouts (`quote`, `closer`) sit left; put a `slot-left` on others
482
+ picture. Three layouts (`quote`, `phones`, `closer`) sit left; put a `slot-left` on others
477
483
  where there is room so slide changes move the presenter.
478
484
 
479
485
  **Visuals: real first, generated second.**
@@ -606,8 +612,13 @@ still.
606
612
  `voice clone` (it asks them for consent; never clone anyone else's voice).
607
613
  - Names speech-to-text spells another way fail narrate's transcript check
608
614
  (step 4). Map what it heard to what you wrote with a `normalize:` entry in
609
- the front matter, `heard=written`, comma-separated: a clip that says
610
- "Minimoji" but is transcribed as "Moji" needs `normalize: moji=minimoji`.
615
+ the front matter, `heard=written`, several on one line, comma-separated: a
616
+ clip that says "Minimoji" but is transcribed as "Moji" needs
617
+ `normalize: moji=minimoji`, and with a second entry
618
+ `normalize: moji=minimoji, dash=D.A.S.H.` (one line, not a YAML list).
619
+ - Write a narrator's name as it is spoken when the two differ, or leave it to
620
+ the narrator's spoken name: `narrator list` shows it, and narrate reads
621
+ "D.A.S.H." as "Dash" (captions keep what you wrote).
611
622
 
612
623
  ### 4. Narrate and read the guard
613
624
 
@@ -793,6 +804,16 @@ folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
793
804
  draft), `hyperdecks asset add <slug> <file|url>` for an image, `hyperdecks guide
794
805
  [topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
795
806
 
807
+ ## After you ship: say what cost you
808
+
809
+ When a deck is released, end your report with a short list of what cost you
810
+ time, ranked by cost: a rule you had to work around, a layout or style you
811
+ hand-wrote because the library had none, a check or tool that said the wrong
812
+ thing, something you couldn't find in this guide. One line each, concrete
813
+ (the file, the command, the message), no praise needed. Send it to whoever
814
+ asked for the deck. Hyperdecks turns these into roadmap cards; the Xbox
815
+ rebuild's list on 2026-11-05 produced four.
816
+
796
817
  ## Safety
797
818
 
798
819
  - For read-only inspection, run commands directly and summarize the important
@@ -96,6 +96,13 @@ makes the hello deck; continue from step 5 below.
96
96
  `set_recipient_archived` to tidy the activity feed; `credits` for the
97
97
  balance. `remove_deck` deletes a deck and breaks every link to it: confirm
98
98
  with the user first.
99
+ 11. When someone asks for "the PDF" (a data room, an attachment):
100
+ `export_deck { slug, kind: "pdf", notes? }` renders the released deck, one
101
+ 1600×900 page per slide with a footer carrying its tracked link (pass
102
+ `shareToken` to choose which), and returns a download `url` that lasts 30
103
+ days; `notes: true` adds the slide's script after each slide.
104
+ `list_exports` shows what was exported. Release first: it prints the live
105
+ deck, not the draft.
99
106
 
100
107
  ## Rules that matter
101
108
 
@@ -107,6 +114,10 @@ makes the hello deck; continue from step 5 below.
107
114
  - A deck with no narration (a product tour, a silent walkthrough): put
108
115
  `data-silent` on the player script and skip narrate_deck; check warns, release
109
116
  goes through, and the player shows no Play button or clock.
117
+ - When the deck is released, tell the person what cost you time, ranked by
118
+ cost: a rule you worked around, a layout you hand-wrote, a tool or check that
119
+ said the wrong thing, something missing from `deck_guide`. One concrete line
120
+ each. Hyperdecks turns these into roadmap cards.
110
121
  - The deck's own scripts run: slide controllers, `[custom: name]` cues,
111
122
  pausing the narrator, tappable and interactive slides. `deck_guide
112
123
  { topic: "scripts" }` has the hooks and an example.
@@ -511,6 +511,8 @@ export function phraseList(value) {
511
511
  // own markers: the empty div (or the chart's labelled svg). A div that holds
512
512
  // its own picture, or gets one from a url() in its style or a <style> rule
513
513
  // naming its class, is the deck's art. A deck that wants none removes it.
514
+ // Each opening tag is read on its own (the inner text is a lookahead), so a
515
+ // stand-in nested in another of its tag, like a phone card's shot, is seen.
514
516
  const PLACEHOLDERS = [
515
517
  {
516
518
  layout: "l-hero", tag: "div", cls: "art", what: "the hero's art", img: '<img class="art" src="…" alt="">, or delete the div for no art (the presenter is the visual)',
@@ -520,6 +522,10 @@ const PLACEHOLDERS = [
520
522
  layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
521
523
  how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
522
524
  },
525
+ {
526
+ layout: "l-phones", tag: "div", cls: "shot", what: "each phone screen", img: '<img class="shot" src="…" alt="…"> (or <video class="shot" src="…" autoplay muted loop playsinline>) in each card',
527
+ how: { hosted: "capture the app at phone size with capture_url (width 390, height 844), or upload a screen recording with upload_deck_asset", cli: "capture the app at phone size with `hyperdecks screenshot <url> --width 390 --height 844`, or add a screen recording with `hyperdecks asset add`" },
528
+ },
523
529
  {
524
530
  layout: "l-chart", tag: "svg", cls: "chart", label: /placeholder/i, what: "the chart (made-up numbers)", img: '<img class="chart" src="…" alt="…">',
525
531
  how: { hosted: "draw the real data with render_chart", cli: "draw the real data with `hyperdecks chart <spec.json>`" },
@@ -531,7 +537,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
531
537
  const styled = (cls) => [...css.matchAll(/([^{}]*)\{([^{}]*)\}/g)].some(([, sel, body]) => new RegExp(`\\.${cls}(?![\\w-])`).test(sel) && /url\(/i.test(body));
532
538
  return slides(html).flatMap((s, i) => {
533
539
  const layouts = classes(s.tag);
534
- return PLACEHOLDERS.filter((p) => layouts.includes(p.layout) && !(p.tag === "div" && styled(p.cls)) && [...s.body.matchAll(new RegExp(`(<${p.tag}\\b[^>]*>)([\\s\\S]*?)</${p.tag}>`, "gi"))].some(([, tag, inner]) =>
540
+ return PLACEHOLDERS.filter((p) => layouts.includes(p.layout) && !(p.tag === "div" && styled(p.cls)) && [...s.body.matchAll(new RegExp(`(<${p.tag}\\b[^>]*>)(?=([\\s\\S]*?)</${p.tag}>)`, "gi"))].some(([, tag, inner]) =>
535
541
  classes(tag).includes(p.cls) && (p.label ? p.label.test(attr(tag, "aria-label") ?? "") : !inner.trim() && !/url\(/i.test(attr(tag, "style") ?? ""))))
536
542
  .map((p) => MESSAGES.placeholder(s.id || `${i + 1}`, p.what, `<${p.tag} class="${p.cls}">`, hosted ? p.how.hosted : p.how.cli, p.img));
537
543
  });
package/src/commands.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
2
3
  import {
3
4
  installHyperdecksSkill,
4
5
  login,
@@ -340,7 +341,7 @@ commands:
340
341
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
341
342
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
342
343
  in your saved brand's colours, fonts and logo (--no-brand: the template's look)
343
- layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
344
+ layouts: hero, number, product, phones, quote, compare, timeline, features, chart, team, closer
344
345
  check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--strict] [--fix] [--json]
345
346
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
346
347
  local refs, pinned player, mobile titles. data-silent on the player script: no narration
@@ -409,6 +410,11 @@ commands:
409
410
  review <slug> [--script <script.md>] [--json]
410
411
  GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
411
412
  review-job <jobId> [--json]
413
+ export <slug|deck-dir> --pdf [--notes] [--out <file.pdf>] [--share <token>] [--json]
414
+ a PDF of the released deck: one 1600x900 page per slide (a script page after each
415
+ with --notes), a footer with the deck's tracked link; kept 30 days
416
+ export list [<slug>] [--json] your exports and their download links
417
+ export rm <exportId> delete an export now
412
418
  poster <slug> [--force] [--json]
413
419
  regenerate the share-link poster (release makes one) and print its URL
414
420
  credits [--limit <n>] [--json] show your credit balance and recent history
@@ -440,7 +446,7 @@ commands:
440
446
  narrator default <name|id|dash> make it the default
441
447
  narrator remove <name|id> remove it; decks already shared keep working
442
448
  narrator voice <name|id> <voice> change its voice (one of yours, or dash)
443
- narrator rename <name|id> <new name> rename it
449
+ narrator rename <name|id> [<new name>] [--say <spoken name>] rename it, or say how it is spoken ("" clears)
444
450
  narrator face <name|id> <avatar> change its face (one of your avatars)
445
451
  deck narrator <slug> <name|id|none|default> the deck's own narrator (none: captions only)
446
452
 
@@ -663,6 +669,47 @@ async function reviewJob(pos, opts) {
663
669
  printReview(job);
664
670
  }
665
671
 
672
+ // hyperdecks export <slug|deck-dir> --pdf: a local deck folder exports its
673
+ // released copy (the slug is --slug or the folder's name) with its own script.md
674
+ // (for its [reveal] cues and --notes); nothing renders locally.
675
+ export function exportTarget(arg, opts = {}, cwd = process.cwd()) {
676
+ const dir = [resolve(cwd, arg), join(cwd, "decks", arg)].find((d) => existsSync(join(d, "index.html")));
677
+ if (!dir) return { slug: arg, script: undefined };
678
+ const scriptPath = join(dir, "script.md");
679
+ return { slug: opts.slug || basename(dir), script: existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : undefined };
680
+ }
681
+
682
+ async function exportCmd(pos, opts) {
683
+ const [arg] = pos;
684
+ const usage = "usage: export <slug|deck-dir> --pdf [--notes] [--out <file.pdf>] [--share <token>]";
685
+ if (!arg) throw new Error(usage);
686
+ if (opts.mp4 || opts.gif) throw new Error("only --pdf exports are available so far (video comes later; the email GIF is `hyperdecks preview create`)");
687
+ if (!opts.pdf) throw new Error(`${usage}\n(--pdf is the only format so far)`);
688
+ const { slug, script } = exportTarget(arg, opts);
689
+ if (slug !== arg) process.stderr.write(`deck folder ${arg} → slug ${slug} (pass --slug to change it)\n`);
690
+ process.stderr.write(`rendering ${slug} as a PDF${opts.notes ? " with notes" : ""}…\n`);
691
+ const out = await ops.exportDeck(opts, { slug, kind: "pdf", notes: !!opts.notes, script, shareToken: opts.share });
692
+ const file = opts.out === true ? out.filename : opts.out || out.filename;
693
+ await saveTo(file, out.url);
694
+ if (opts.json) return void console.log(JSON.stringify({ ...out, file }, null, 2));
695
+ console.log(`✓ ${out.pages} page(s), ${(out.bytes / 1e6).toFixed(1)} MB → ${file}`);
696
+ console.log(` footer link: ${out.link}`);
697
+ for (const w of out.warnings ?? []) console.log(` ⚠ ${w}`);
698
+ console.log(` download (30 days): ${out.url}`);
699
+ }
700
+
701
+ async function exportList(pos, opts) {
702
+ const rows = await ops.listExports(opts, { slug: pos[0] });
703
+ if (opts.json) return void console.log(JSON.stringify(rows, null, 2));
704
+ if (!rows.length) return void console.log("no exports in the last 30 days");
705
+ for (const r of rows) {
706
+ const when = new Date(r.createdAt).toISOString().slice(0, 16).replace("T", " ");
707
+ const what = r.status === "ready" ? `${r.pages} pages, ${((r.bytes ?? 0) / 1e6).toFixed(1)} MB` : r.status === "failed" ? `failed: ${r.error}` : r.status;
708
+ console.log(`${r.exportId} ${when} ${r.slug} ${r.kind}${r.notes ? "+notes" : ""} ${what}`);
709
+ if (r.url) console.log(` ${r.url}`);
710
+ }
711
+ }
712
+
666
713
  async function poster(pos, opts) {
667
714
  const [slug] = pos;
668
715
  if (!slug) throw new Error("usage: poster <slug> [--force] [--json]");
@@ -851,9 +898,11 @@ function commandTable(pos, rest, opts) {
851
898
  console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} speaks in ${out.voice} now`);
852
899
  },
853
900
  "narrator rename": async () => {
854
- if (rest.length < 2) throw new Error("usage: narrator rename <name|id> <new name>");
855
- const out = await ops.renameNarrator(opts, rest[0], rest.slice(1).join(" "));
856
- console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.was} is ${out.name} now`);
901
+ if (Object.hasOwn(opts, "say") && (opts.say === undefined || String(opts.say).startsWith("--"))) throw new Error('--say needs a value (--say "" clears it)');
902
+ const say = Object.hasOwn(opts, "say") ? String(opts.say) : undefined;
903
+ if (rest.length < 1 || (rest.length < 2 && say === undefined)) throw new Error("usage: narrator rename <name|id> [<new name>] [--say <spoken name>]");
904
+ const out = await ops.renameNarrator(opts, rest[0], rest.slice(1).join(" "), say);
905
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.was} is ${out.name} now${out.spokenName ? `, said "${out.spokenName}"` : ""}`);
857
906
  },
858
907
  "narrator face": async () => {
859
908
  if (rest.length < 2) throw new Error("usage: narrator face <name|id> <avatar>");
@@ -928,6 +977,13 @@ function commandTable(pos, rest, opts) {
928
977
  review: () => review(pos.slice(1), opts),
929
978
  "review-job": () => reviewJob(pos.slice(1), opts),
930
979
  poster: () => poster(pos.slice(1), opts),
980
+ export: () => exportCmd(pos.slice(1), opts),
981
+ "export list": () => exportList(pos.slice(2), opts),
982
+ "export rm": async () => {
983
+ if (!pos[2]) throw new Error("usage: export rm <exportId>");
984
+ const out = await ops.removeExport(opts, { exportId: pos[2] });
985
+ console.log(opts.json ? JSON.stringify(out) : `✓ removed ${pos[2]}`);
986
+ },
931
987
  credits: () => credits(pos.slice(1), opts),
932
988
  };
933
989
  }
package/src/files.mjs CHANGED
@@ -16,6 +16,7 @@ const FILES = {
16
16
  "templates/layouts/hero.html": () => new URL("../templates/layouts/hero.html", import.meta.url),
17
17
  "templates/layouts/number.html": () => new URL("../templates/layouts/number.html", import.meta.url),
18
18
  "templates/layouts/product.html": () => new URL("../templates/layouts/product.html", import.meta.url),
19
+ "templates/layouts/phones.html": () => new URL("../templates/layouts/phones.html", import.meta.url),
19
20
  "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
20
21
  "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
21
22
  "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
package/src/firstDeck.mjs CHANGED
@@ -6,7 +6,8 @@ import { packageText } from "./files.mjs";
6
6
  import { slugify } from "./importer.mjs";
7
7
 
8
8
  export const FIRST_DECK_LAYOUTS = "hero,features,closer";
9
- export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
9
+ export { DASH_AVATAR } from "./presenter.mjs";
10
+ import { DASH_AVATAR } from "./presenter.mjs";
10
11
  export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
11
12
 
12
13
  // One answer as it goes on a slide and into the script: one line, no stage-tag
package/src/layouts.mjs CHANGED
@@ -5,8 +5,8 @@ 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
7
  // The library, in a sensible deck order; `--layouts` takes any order and repeats.
8
- export const LAYOUTS = ["hero", "number", "product", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
- const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", comparison: "compare", "three-up": "features" };
8
+ export const LAYOUTS = ["hero", "number", "product", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
+ const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features" };
10
10
 
11
11
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
12
 
package/src/narrator.mjs CHANGED
@@ -11,7 +11,7 @@ const interactiveOf = (opts) => !opts.json && !!process.stdin.isTTY && !!process
11
11
  export async function narratorList(opts, { log = console.log } = {}) {
12
12
  const out = await ops.listNarrators(opts);
13
13
  if (opts.json) return out;
14
- for (const n of out.narrators) log(`${n.isDefault ? "*" : " "} ${n.name}\t${n.face ?? "(no face)"} · ${n.voice ? `${n.voice}${n.voiceReady ? "" : " (not ready)"}` : "(voice removed)"}\t${n.id}`);
14
+ for (const n of out.narrators) log(`${n.isDefault ? "*" : " "} ${n.name}${n.spokenName && n.spokenName !== n.name ? ` (said "${n.spokenName}")` : ""}\t${n.face ?? "(no face)"} · ${n.voice ? `${n.voice}${n.voiceReady ? "" : " (not ready)"}` : "(voice removed)"}\t${n.id}`);
15
15
  if (out.note) log(`\n${out.note}`);
16
16
  log("\n* the default; decks use it unless they set their own (deck narrator <slug> <id>)");
17
17
  return out;
package/src/ops.mjs CHANGED
@@ -200,6 +200,33 @@ export async function generatePoster(opts, fields = {}) {
200
200
  return (await client(opts)).action(api.posters.generatePoster, args);
201
201
  }
202
202
 
203
+ // ---- deck exports (convex/exports.ts) -------------------------------------
204
+
205
+ // Render a PDF of a released deck you own (one 1600x900 page per slide, a notes
206
+ // page after each with notes=true, a footer with the deck's tracked link) and
207
+ // keep it 30 days. Returns { exportId, url, filename, bytes, pages, slides,
208
+ // notes, link, expiresAt }. Waits for the file (about a second per slide).
209
+ export async function exportDeck(opts, fields = {}) {
210
+ if (!fields.slug) throw new Error("slug is required");
211
+ const kind = fields.kind ?? "pdf";
212
+ if (kind !== "pdf") throw new Error(`kind "${kind}" is not supported yet: only "pdf" (video export comes from \`hyperdecks export --mp4\` later)`);
213
+ const args = { slug: String(fields.slug), kind: "pdf" };
214
+ if (fields.notes) args.notes = true;
215
+ if (fields.script) args.script = String(fields.script);
216
+ if (fields.shareToken) args.shareToken = String(fields.shareToken);
217
+ return (await client(opts)).action(api.exports.create, args);
218
+ }
219
+
220
+ // Your exports (newest first, 30 days), optionally for one deck.
221
+ export async function listExports(opts, fields = {}) {
222
+ return (await client(opts)).query(api.exports.list, fields.slug ? { slug: String(fields.slug) } : {});
223
+ }
224
+
225
+ export async function removeExport(opts, fields = {}) {
226
+ if (!fields.exportId) throw new Error("exportId is required");
227
+ return (await client(opts)).mutation(api.exports.remove, { exportId: String(fields.exportId) });
228
+ }
229
+
203
230
  // ---- deck review (convex/review.ts) --------------------------------------
204
231
 
205
232
  // Queue a GPT-6 Luna review of every slide (desktop + phone screenshots of the
@@ -1017,7 +1044,7 @@ export async function listNarrators(opts = {}) {
1017
1044
  return {
1018
1045
  default: out.defaultId,
1019
1046
  ...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
1020
- narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
1047
+ 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 })),
1021
1048
  voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
1022
1049
  };
1023
1050
  }
@@ -1068,12 +1095,20 @@ export async function setNarratorVoice(opts, ref, voice) {
1068
1095
  return { narrator: n.id, name: n.name, voice: String(voice) };
1069
1096
  }
1070
1097
 
1071
- export async function renameNarrator(opts, ref, name) {
1072
- if (!String(name ?? "").trim()) throw new Error("usage: narrator rename <name|id> <new name>");
1098
+ // A new name, and/or how it is said (`spokenName`, "" clears it): narrate gives
1099
+ // the voice the spoken form, so "J.P." is not read letter by letter.
1100
+ export async function renameNarrator(opts, ref, name, spokenName) {
1101
+ const next = String(name ?? "").trim();
1102
+ if (!next && spokenName === undefined) throw new Error("usage: narrator rename <name|id> [<new name>] [--say <spoken name>]");
1073
1103
  const n = await findNarrator(opts, ref);
1074
1104
  if (n.builtin) throw new Error("D.A.S.H. is built in; its name stays");
1075
- await (await client(opts)).mutation(api.narrators.rename, { narrator: n.id, name: String(name).trim() });
1076
- return { narrator: n.id, was: n.name, name: String(name).trim() };
1105
+ await (await client(opts)).mutation(api.narrators.rename, {
1106
+ narrator: n.id, ...(next ? { name: next } : {}), ...(spokenName !== undefined ? { spokenName: String(spokenName) } : {}),
1107
+ });
1108
+ return {
1109
+ narrator: n.id, was: n.name, name: next || n.name,
1110
+ spokenName: spokenName === undefined ? n.spokenName : String(spokenName).trim() || null,
1111
+ };
1077
1112
  }
1078
1113
 
1079
1114
  // face: one of your avatars (name or id from `avatar`); D.A.S.H. keeps its own.
package/src/play.mjs CHANGED
@@ -55,7 +55,8 @@ async function lookUpPresenter(opts, { slug, path, html, log }) {
55
55
 
56
56
  // Says what the served page does, not what the lookup answered.
57
57
  function sayWhoPresents({ html, page, presenter, who, log }) {
58
- if (presenter && page === html) log("⚠ the player tag already names a presenter, so it plays as written");
58
+ // A tag that already has this face (an init deck's D.A.S.H.) is not a conflict.
59
+ if (presenter && page === html && !html.includes(`data-avatar="${presenter.avatarUrl}"`)) log("⚠ the player tag already names a presenter, so it plays as written");
59
60
  else if (who?.face && !presenter && playsCaptionsOnly(html)) log(`⚠ captions only: the page names no presenter, and a face for voice "${who.voice}" is not added here`);
60
61
  else if (who) log(presenterLine(who));
61
62
  }
package/src/presenter.mjs CHANGED
@@ -6,6 +6,12 @@
6
6
  // preview, so all rewrite the tag the same way; only a release stores it. A tag that already names a presenter (D.A.S.H.
7
7
  // on a character-voice deck, or one set by hand) is left alone.
8
8
 
9
+ // D.A.S.H.'s face: the head doc and still that ship with the player. A deck
10
+ // narrated in its character voice gets this face at release, even when the
11
+ // tag was scaffolded captions-only (data-presenter="none").
12
+ export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
13
+ export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://decks.hypersocial.ai/avatars/dash.png" });
14
+
9
15
  const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
10
16
 
11
17
  const attr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
package/src/release.mjs CHANGED
@@ -110,7 +110,7 @@ export async function release(pos, opts = {}, io = {}) {
110
110
  throw new Error(`narration changed the deck (${after.split("\n").length} file(s)):\n${after}\n` +
111
111
  `run \`hyperdecks check ${pos[0]}\`, look at it, commit (index.html and narration/), then run release again`);
112
112
  }
113
- // Narrated in your own voice: the released page gets your avatar's face.
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
115
  try {
116
116
  presenter = await ops.presenterFor(opts, slug, opts.voice);
@@ -118,7 +118,7 @@ export async function release(pos, opts = {}, io = {}) {
118
118
  log(`⚠ could not look up your avatar (${ops.errorText(e)}); releasing captions only`);
119
119
  }
120
120
  const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
121
- if (presenter) log("✓ presented by your avatar");
121
+ if (presenter) log(`✓ presented by ${job.presenter?.face ?? "your avatar"}`);
122
122
  // A terminal user sees this before it ships, so the CLI warns rather than refuses.
123
123
  else if (job.presenter && !job.presenter.face) log(`⚠ releasing captions only: ${job.presenter.reason ?? "no face"}`);
124
124
  return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: job.presenter ?? null });
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.0";
11
+ export const VERSION = "0.9.2";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -128,6 +128,11 @@ export const api = {
128
128
  posters: {
129
129
  generatePoster: makeFunctionReference("posters:generatePoster"),
130
130
  },
131
+ exports: {
132
+ create: makeFunctionReference("exports:create"),
133
+ list: makeFunctionReference("exports:list"),
134
+ remove: makeFunctionReference("exports:remove"),
135
+ },
131
136
  review: {
132
137
  reviewDeck: makeFunctionReference("review:reviewDeck"),
133
138
  checkReviewJob: makeFunctionReference("review:checkReviewJob"),
@@ -156,7 +161,7 @@ export const api = {
156
161
  },
157
162
  };
158
163
 
159
- 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", "no-logo"]);
164
+ 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", "no-logo", "pdf", "notes", "mp4", "gif"]);
160
165
  // release's recipients, in order: each --to starts one, and an --email right
161
166
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
162
167
  // opts.email stays the last --email for the other commands (share new).
@@ -51,7 +51,7 @@
51
51
  and the presenter, and plays the narration. Change the palette and fonts
52
52
  here; every layout reads them. */
53
53
  :root {
54
- --ink: #0b0b0f; --paper: #fafaf7; --accent: #ffda1b; --accent-2: #27ceff; --card: #ffffff;
54
+ --ink: #0b0b0f; --paper: #fafaf7; --accent: #ffda1b; --accent-2: #27ceff; --accent-3: #ff9ec7; --card: #ffffff;
55
55
  --font: "Space Grotesk", system-ui, sans-serif; --mono: "Space Mono", ui-monospace, monospace;
56
56
  /* The type scale: one display size per slide at most, then title, sub (a title beside a visual), lead, body, label. */
57
57
  --t-display: 128px; --t-title: 84px; --t-sub: 56px; --t-lead: 30px; --t-body: 24px; --t-label: 17px;
@@ -73,12 +73,41 @@
73
73
  .slot-top .narration-slot { top: 96px; bottom: auto; }
74
74
  .reveal { opacity: 0; transform: translateY(12px); transition: opacity .5s, transform .5s; }
75
75
  .reveal.is-revealed { opacity: 1; transform: none; }
76
+ /* The phone card (product's .frame.card, the phones row): a frameless phone,
77
+ white with an ink edge, a hard offset shadow and a tilt. Change --tilt and
78
+ --shadow on the element (style="--tilt: 3deg; --shadow: var(--accent-2)");
79
+ put an <img class="shot"> or <video class="shot" autoplay muted loop
80
+ playsinline> in place of the placeholder div. */
81
+ .slide .frame.card { --tilt: -4deg; --shadow: var(--accent); position: relative; width: 320px; aspect-ratio: 9 / 19.5; padding: 9px; border: 3px solid var(--ink); border-radius: 46px; background: var(--card); box-shadow: 18px 18px 0 var(--shadow); transform: rotate(var(--tilt)); overflow: visible; }
82
+ .slide .frame.card > .shot { display: block; width: 100%; height: 100%; aspect-ratio: auto; border-radius: 36px; border: 2px solid color-mix(in srgb, var(--ink) 20%, transparent); object-fit: cover; }
83
+ .slide .frame.card > div.shot { background:
84
+ linear-gradient(color-mix(in srgb, var(--ink) 16%, transparent) 0 0) 50% 5% / 36% 2.2% no-repeat,
85
+ linear-gradient(var(--accent-2) 0 0) 50% 30% / 84% 40% no-repeat,
86
+ linear-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0 0) 16% 62% / 60% 2.4% no-repeat,
87
+ linear-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0 0) 14% 67% / 44% 2.4% no-repeat,
88
+ linear-gradient(var(--accent) 0 0) 50% 90% / 84% 6% no-repeat,
89
+ var(--paper); }
90
+ /* Backgrounds, optional and for a few slides a deck. bg-dots on the section:
91
+ a dot grid in the slide's text colour, faded in from the side away from the text
92
+ (--dots-fade is the fade's direction: 90deg puts the dots on the right,
93
+ 270deg on the left). A <div class="deco" aria-hidden="true"> in the slide
94
+ holds small inked tiles, each a <span> placed by its inline left/top (and
95
+ background, rotate). Both sit behind the slide's content. */
96
+ .slide.bg-dots, .slide:has(> .deco) { isolation: isolate; }
97
+ .slide.bg-dots::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 3px, transparent 3.6px) 0 0 / 36px 36px; -webkit-mask-image: linear-gradient(var(--dots-fade, 90deg), transparent 40%, #000 80%); mask-image: linear-gradient(var(--dots-fade, 90deg), transparent 40%, #000 80%); }
98
+ .slide > .deco { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
99
+ .slide > .deco > span { position: absolute; display: grid; place-items: center; width: 80px; height: 80px; border: 3px solid var(--ink); border-radius: 20px; background: var(--card); box-shadow: 8px 8px 0 var(--ink); color: var(--ink); font: 700 34px var(--font); }
76
100
  /* Phones: the slide is the 374×736 card; the presenter is a small head in
77
101
  the corner, so keep the bottom 170px clear. */
78
102
  @media (max-width: 650px) {
79
103
  :root { --t-display: 54px; --t-title: 38px; --t-sub: 28px; --t-lead: 17px; --t-body: 15px; --t-label: 12px; }
80
104
  .slide { padding: 28px 20px 170px; gap: 14px; }
81
105
  .narration-slot { right: 12px; left: 12px; bottom: 64px; }
106
+ .slide .frame.card { width: 160px; padding: 5px; border-width: 2px; border-radius: 24px; box-shadow: 8px 8px 0 var(--shadow); }
107
+ .slide .frame.card > .shot { border-radius: 19px; border-width: 1px; }
108
+ /* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
109
+ .slide.bg-dots::before { background-size: 22px 22px; background-image: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 2px, transparent 2.5px); -webkit-mask-image: linear-gradient(180deg, transparent 45%, #000 85%); mask-image: linear-gradient(180deg, transparent 45%, #000 85%); }
110
+ .slide > .deco { display: none; }
82
111
  }
83
112
  </style>
84
113
  <!-- LAYOUT STYLES -->
@@ -0,0 +1,54 @@
1
+ <!--
2
+ layout: phones
3
+ summary: A fanned row of two to four phone cards (the inner ones larger and in front, the outer ones tilted out), the point in the column beside it.
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
+ slot: bottom-left
6
+ presenter: stage
7
+ 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
+ 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
+ <style>
11
+ .l-phones { display: grid; grid-template-columns: 500px 1fr; grid-template-areas: "text row"; column-gap: 40px; align-items: center; }
12
+ .l-phones .text { grid-area: text; align-self: start; display: flex; flex-direction: column; gap: 20px; }
13
+ .l-phones .tt { font-size: var(--t-sub); line-height: 1.02; }
14
+ .l-phones .lead { font-size: var(--t-body); }
15
+ /* The row: cards overlap a little; the first and last tilt out and sit lower,
16
+ the ones between are larger and in front. The card's look is shared (_deck.html). */
17
+ .l-phones .row { grid-area: row; display: flex; justify-content: center; align-items: center; padding: 0 0 40px; }
18
+ .l-phones .row > .frame.card { --tilt: 0deg; flex: none; width: 260px; margin: 0 -14px; }
19
+ .l-phones .row > .frame.card:first-child { --tilt: -7deg; --shadow: var(--accent-2); translate: 0 36px; }
20
+ .l-phones .row > .frame.card:last-child { --tilt: 7deg; --shadow: var(--accent-3); translate: 0 36px; }
21
+ .l-phones .row > .frame.card:not(:first-child, :last-child) { width: 300px; z-index: 1; }
22
+ .l-phones .row:has(> :nth-child(4)) > .frame.card { width: 220px; }
23
+ .l-phones .row:has(> :nth-child(4)) > .frame.card:not(:first-child, :last-child) { width: 250px; }
24
+ @media (max-width: 650px) {
25
+ .l-phones { display: flex; flex-direction: column; align-items: stretch; }
26
+ .l-phones .text { gap: 10px; }
27
+ .l-phones .row { padding: 6px 0 18px; }
28
+ .l-phones .row > .frame.card, .l-phones .row:has(> :nth-child(4)) > .frame.card { width: 108px; margin: 0 -6px; }
29
+ .l-phones .row > .frame.card:not(:first-child, :last-child), .l-phones .row:has(> :nth-child(4)) > .frame.card:not(:first-child, :last-child) { width: 128px; }
30
+ .l-phones .row > .frame.card:is(:first-child, :last-child) { translate: 0 16px; }
31
+ /* Four do not fit the phone card: the row keeps the first three, the third as its end. */
32
+ .l-phones .row > .frame.card:nth-child(n + 4) { display: none; }
33
+ .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
+ </style>
36
+ <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
+ <div class="text">
38
+ <div class="kicker">On a phone</div>
39
+ <h2 class="tt" data-title-mobile="Three taps.">Three taps, start to finish.</h2>
40
+ <p class="lead">One line on what the screens show, in order.</p>
41
+ </div>
42
+ <div class="row" id="__ID__-row">
43
+ <div class="frame card" id="__ID__-p1"><div class="shot" role="img" aria-label="Phone screen placeholder: the first step"></div></div>
44
+ <div class="frame card" id="__ID__-p2"><div class="shot" role="img" aria-label="Phone screen placeholder: the main screen"></div></div>
45
+ <div class="frame card" id="__ID__-p3"><div class="shot" role="img" aria-label="Phone screen placeholder: the result"></div></div>
46
+ </div>
47
+ <!-- Optional decorations, behind the content (a few slides a deck at most):
48
+ <div class="deco" aria-hidden="true">
49
+ <span style="left: 52%; top: 8%; background: var(--accent); rotate: -10deg;">★</span>
50
+ <span style="left: 92%; top: 42%; background: var(--accent-2); rotate: 12deg;">♪</span>
51
+ <span style="left: 48%; top: 78%; background: var(--accent-3); rotate: 8deg;">☺</span>
52
+ </div> -->
53
+ <div class="narration-slot"></div>
54
+ </section>
@@ -1,7 +1,7 @@
1
1
  <!--
2
2
  layout: product
3
- summary: A product screenshot in a browser frame (or .frame.phone for a phone shot), the point in the column beside it.
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.
3
+ summary: A product screenshot in a browser frame (for a phone shot, .frame.card: the frameless phone card), the point in the column beside it.
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
7
  cues: [look: #__ID__-frame] when the narration turns to the product; [reveal: #__ID__-callout] for the detail.
@@ -27,6 +27,9 @@ script: This is the product. [look: #__ID__-frame] Look at this part. [reveal: #
27
27
  linear-gradient(var(--accent-2) 0 0) 248px 176px / 300px 300px no-repeat,
28
28
  linear-gradient(color-mix(in srgb, var(--ink) 8%, transparent) 0 0) 580px 176px / 308px 300px no-repeat,
29
29
  linear-gradient(color-mix(in srgb, var(--ink) 6%, transparent) 0 0) 0 0 / 210px 100% no-repeat; }
30
+ /* The phone card's look is shared (_deck.html); here it sits centred in the frame column, without the browser bar. */
31
+ .l-product .frame.card { justify-self: center; }
32
+ .l-product .frame.card .bar { display: none; }
30
33
  .l-product .frame.phone { width: 300px; justify-self: center; border-radius: 40px; padding: 12px; background: var(--ink); }
31
34
  .l-product .frame.phone .bar { display: none; }
32
35
  .l-product .frame.phone .shot { aspect-ratio: 9 / 19.5; border-radius: 28px; }
@@ -39,6 +42,7 @@ script: This is the product. [look: #__ID__-frame] Look at this part. [reveal: #
39
42
  .l-product .bar i { width: 7px; height: 7px; }
40
43
  .l-product .bar span { font-size: 9px; padding: 2px 8px; margin-left: 6px; }
41
44
  .l-product div.shot { background-size: 30% 6%, 60% 2%, 50% 2%, 28% 40%, 28% 40%, 20% 100%; background-position: 26% 8%, 40% 18%, 36% 23%, 26% 70%, 62% 70%, 0 0; }
45
+ .l-product .frame.card { align-self: center; }
42
46
  .l-product .callout { left: 10px; bottom: 10px; padding: 6px 12px; font-size: 12px; }
43
47
  }
44
48
  </style>