@hypersocial/decks-cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ // The page a brand import (brandImport.mjs, convex/brandDrafts.ts) is shown on
2
+ // before it is saved: a hero and a features slide from the layout library, in
3
+ // the draft's colours, fonts and logo (applyBrand, the same code new decks
4
+ // use). Served at /p/brand/<token> (app/p/brand/[token]/route.ts), and with
5
+ // { saved: true } for the saved brand on the dashboard's Brand tab
6
+ // (app/dashboard/brand/sample/route.ts).
7
+ import { applyBrand } from "./brand.mjs";
8
+ import { assembleLayoutDeck } from "./layouts.mjs";
9
+
10
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11
+
12
+ export function brandPreviewHtml(draft, { saved = false } = {}) {
13
+ const name = (draft?.name ?? "").trim() || "Your brand";
14
+ let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts: ["hero", "features"] })["index.html"];
15
+ // The hero's art slot is a stand-in a real deck fills; the preview has no art.
16
+ html = html.replace(/\s*<div class="art" role="presentation"><\/div>/, "");
17
+ html = html.replace(/(<div class="kicker">)brand-preview(<\/div>)/, saved ? "$1Your brand$2" : "$1Brand preview$2");
18
+ html = html.replace(
19
+ "One sentence on what this deck is about.",
20
+ saved ? "Your colours, fonts and logo on a sample slide. Every new deck starts from this." : "Your colours, fonts and logo on a sample slide. Nothing is saved until you say yes.",
21
+ );
22
+ html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(name)} · ${saved ? "brand" : "brand preview"}</title>`);
23
+ return applyBrand(html, { name: draft?.name ?? undefined, colors: draft?.colors ?? {}, fonts: draft?.fonts ?? {}, logoUrl: draft?.logoUrl ?? undefined });
24
+ }
@@ -41,12 +41,16 @@ export const MESSAGES = {
41
41
  mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
42
42
  slideId: (n) => `slide ${n} has no id`,
43
43
  selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
44
+ gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
45
+ gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
44
46
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
45
47
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
46
48
  zoomTarget: (id, target, prev) => `slide ${id}: zoom target "${target}" matches nothing on the slide before it (${prev})`,
47
49
  morphPair: (id, prev) => `slide ${id}: morph needs an element with the same data-morph="<name>" on this slide and the one before it (${prev})`,
48
50
  transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
49
51
  flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
52
+ // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
53
+ placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
50
54
  // Warnings (check --layout): the deck still passes.
51
55
  layoutUnchecked: (why) => `layout not checked: ${why}`,
52
56
  hotlink: (url) => `${url} is hotlinked from another site and can vanish after release; upload it with upload_deck_asset`,
@@ -215,8 +219,33 @@ export function selectorMatches(body, selector) {
215
219
  });
216
220
  }
217
221
 
218
- // [go: <selector>] and [look: <selector>] in script.md must match something in
219
- // their slide ([go: slot] returns home and always resolves).
222
+ // The Minimoji head's gestures, for [gesture: <id>]: lib/deckScript.ts GESTURES
223
+ // (the published CLI cannot import it; a test keeps the two equal).
224
+ export const GESTURES = [
225
+ "big-double-laugh", "boiling-point", "bright-tilt", "buoyant-pop", "chuckle-bob", "clipped-huff", "comfort-tilt",
226
+ "curious-glance", "deep-snore", "delighted-sway", "dodge-glance", "double-take", "dream-twitch", "final-flop",
227
+ "fold-inward", "freeze-and-shiver", "hard-recoil", "head-shake", "knowing-nod", "lean-out-laugh", "lightbulb",
228
+ "look-away", "low-simmer", "nervous-scan", "nod-on-the-beat", "one-sided-lean", "other-side", "party-bounce",
229
+ "ponder-tilt", "pop-back", "pressure-build", "quiet-sigh", "radiant-lift", "shaky-sniff", "sharp-glare",
230
+ "side-eye-hmm", "sideways-flinch", "sleepy-sway", "slow-droop", "snap-and-fume", "sob-heave", "soft-reset",
231
+ "spark-salute", "sunny-nod", "thinking-it-over", "tiny-aftershock", "tiny-double-take", "tiny-wince", "uneasy-sway",
232
+ ];
233
+
234
+ // [gesture: <id>] in script.md must name one of the head's gestures.
235
+ export function cueGestureErrors(script = "") {
236
+ const errs = [];
237
+ script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
238
+ for (const [, colon, raw] of line.matchAll(/\[\s*gesture\b\s*(:?)\s*([^\]]*?)\s*\]/gi)) {
239
+ if (!GESTURES.includes(raw.toLowerCase())) errs.push(MESSAGES.gesture(i + 1, raw));
240
+ else if (!colon) errs.push(MESSAGES.gestureColon(i + 1, raw));
241
+ }
242
+ });
243
+ return errs;
244
+ }
245
+
246
+ // [go: <selector>] and [look: <selector>] (or [look <selector>]) in script.md
247
+ // must match something in their slide ([go: slot] returns home, and [look
248
+ // viewer], [look away] and [look back] need no element).
220
249
  export function cueSelectorErrors(html, script = "") {
221
250
  const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
222
251
  const errs = [];
@@ -225,8 +254,9 @@ export function cueSelectorErrors(html, script = "") {
225
254
  const heading = /^##\s+(\S+)/.exec(line);
226
255
  if (heading) { id = heading[1]; return; }
227
256
  if (!id || !bodies.has(id)) return;
228
- for (const [, cue, raw] of line.matchAll(/\[\s*(go|look)\s*:\s*([^\]]*?)\s*\]/gi)) {
229
- if (raw === "slot" || !raw) continue;
257
+ for (const [, colon, spaced, raw] of line.matchAll(/\[\s*(?:(go|look)\s*:\s*|(look)\s+)([^\]]*?)\s*\]/gi)) {
258
+ const cue = colon ?? spaced;
259
+ if (!raw || raw === "slot" || (cue.toLowerCase() === "look" && /^(viewer|away|back)$/i.test(raw))) continue;
230
260
  if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
231
261
  }
232
262
  });
@@ -250,13 +280,13 @@ export function playerScript(html) {
250
280
  return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
251
281
  }
252
282
 
253
- // Relative references: src, href, poster and data-presenter-image (either quote), srcset candidates and
283
+ // Relative references: src, href, poster, data-presenter-image and data-alpha-stacked (either quote), srcset candidates and
254
284
  // CSS url(…) in style attributes and <style> blocks; everything that is not a URL
255
285
  // scheme, protocol-relative or a fragment. Query strings and fragments dropped.
256
286
  export function localRefs(html) {
257
287
  const doc = uncommented(html);
258
288
  const raw = [
259
- ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
289
+ ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
260
290
  ...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
261
291
  ...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
262
292
  ];
@@ -271,10 +301,40 @@ export function phraseList(value) {
271
301
  return value.map((s) => String(s).trim()).filter(Boolean);
272
302
  }
273
303
 
304
+ // Layout stand-ins a deck must swap for its own file, found by the templates'
305
+ // own markers: the empty div (or the chart's labelled svg). A div that holds
306
+ // its own picture, or gets one from a url() in its style or a <style> rule
307
+ // naming its class, is the deck's art. A deck that wants none removes it.
308
+ const PLACEHOLDERS = [
309
+ {
310
+ 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)',
311
+ how: { hosted: "make a picture of the real subject with generate_image", cli: "make a picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
312
+ },
313
+ {
314
+ layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
315
+ how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
316
+ },
317
+ {
318
+ layout: "l-chart", tag: "svg", cls: "chart", label: /placeholder/i, what: "the chart (made-up numbers)", img: '<img class="chart" src="…" alt="…">',
319
+ how: { hosted: "draw the real data with render_chart", cli: "draw the real data with `hyperdecks chart <spec.json>`" },
320
+ },
321
+ ];
322
+ const classes = (tag) => (attr(tag, "class") ?? "").split(/\s+/).filter(Boolean);
323
+ export function placeholderErrors(html, { hosted = false } = {}) {
324
+ const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
325
+ const styled = (cls) => [...css.matchAll(/([^{}]*)\{([^{}]*)\}/g)].some(([, sel, body]) => new RegExp(`\\.${cls}(?![\\w-])`).test(sel) && /url\(/i.test(body));
326
+ return slides(html).flatMap((s, i) => {
327
+ const layouts = classes(s.tag);
328
+ 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]) =>
329
+ classes(tag).includes(p.cls) && (p.label ? p.label.test(attr(tag, "aria-label") ?? "") : !inner.trim() && !/url\(/i.test(attr(tag, "style") ?? ""))))
330
+ .map((p) => MESSAGES.placeholder(s.id || `${i + 1}`, p.what, `<${p.tag} class="${p.cls}">`, hosted ? p.how.hosted : p.how.cli, p.img));
331
+ });
332
+ }
333
+
274
334
  // Everything checkable from the deck's own files. `exists(ref)` answers whether a
275
335
  // local reference resolves inside the deck folder: true, false or "outside".
276
- export function htmlErrors(html, { exists = () => true, forbid = [], script = "" } = {}) {
277
- const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script)];
336
+ export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
337
+ const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
278
338
  const player = playerScript(html);
279
339
  if (!player) errs.push(MESSAGES.noPlayer());
280
340
  else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
@@ -455,7 +515,7 @@ export function stageSize(html) {
455
515
  export function absoluteRefs(html) {
456
516
  const doc = uncommented(html);
457
517
  const raw = [
458
- ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
518
+ ...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
459
519
  ...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
460
520
  ...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
461
521
  ];
@@ -486,7 +546,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
486
546
  throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
487
547
  }
488
548
  const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
489
- const errors = htmlErrors(html, { exists, forbid, script });
549
+ const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted });
490
550
  const ids = slides(html).map((s) => s.id);
491
551
  const player = playerScript(html);
492
552
  let manifest = null;
package/src/commands.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  VERSION,
10
10
  } from "./runtime.mjs";
11
11
  import * as ops from "./ops.mjs";
12
+ import { presenterLine } from "./presenter.mjs";
12
13
 
13
14
  export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
14
15
  : p.narrationStarted ? "narration started"
@@ -306,8 +307,9 @@ commands:
306
307
  deck list|stats|open|rm|forward
307
308
  deck forward on|off <slug>
308
309
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
309
- deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
310
- scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
310
+ deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
311
+ scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
312
+ in your saved brand's colours, fonts and logo (--no-brand: the template's look)
311
313
  layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
312
314
  check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
313
315
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
@@ -320,7 +322,7 @@ commands:
320
322
  file an image, video or font under the deck; prints its hosted URL
321
323
  guide [workflow|format|script|design|layouts|presenter|transitions|check]
322
324
  the skill, one topic at a time (what deck_guide serves)
323
- release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
325
+ release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
324
326
  on a committed deck: check → narrate → import → share new per --to
325
327
  share new|list|on|off|forward|mute|unmute|rm|url
326
328
  share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
@@ -340,15 +342,29 @@ commands:
340
342
  [--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
341
343
  default model gpt-image-2.5; --seed applies to nano-banana-2.1
342
344
  image-job <jobId> [--json]
343
- gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
345
+ gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]
344
346
  a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
347
+ --loop starts and ends on one frame (no jump at the seam); --transparent cuts the subject
348
+ out as a WebM + a stacked-alpha MP4 for Safari (prints both URLs and the <video> markup)
345
349
  video-job <jobId> [--json]
346
350
  screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]
347
351
  a PNG of a public web page (private and local addresses are refused)
348
352
  chart <spec.json|-> [--slug <deck>] [--name <name>] [--download <file>]
349
353
  a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
354
+ icons search <word...> [--limit 8] [--stroke-width 2] [--json]
355
+ real icons from the Lucide library as inline SVG in currentColor (free, offline)
350
356
  icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
351
- a style-consistent set of transparent icons, one per subject (max 12)
357
+ a style-consistent set of generated (illustrated) icons, one per subject (max 12)
358
+ brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
359
+ brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
360
+ [--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
361
+ [--presenter-style s] [--design <file.md|->] [--name "<company>"] [--draft <token>]
362
+ brand import <url|file|slug> [--json]
363
+ read a brand from your website, a .pptx / .potx / .pdf or one of your decks: shows its
364
+ colours, fonts and logo and a preview link; save it with brand set --draft <token>
365
+ brand edit edit design.md (voice, do and don't) in $EDITOR
366
+ brand fonts the curated font pairings (display + body + mono)
367
+ brand clear forget the brand; new decks get the template look
352
368
  review <slug> [--script <script.md>] [--json]
353
369
  GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
354
370
  review-job <jobId> [--json]
@@ -461,7 +477,7 @@ async function imageJob(pos, opts) {
461
477
  async function genVideo(pos, opts) {
462
478
  const prompt = pos.join(" ").trim() || opts.prompt;
463
479
  if (!prompt) {
464
- throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
480
+ throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]');
465
481
  }
466
482
  const queued = await ops.generateVideo(opts, {
467
483
  prompt,
@@ -469,12 +485,15 @@ async function genVideo(pos, opts) {
469
485
  seconds: opts.seconds,
470
486
  aspect: opts.aspect,
471
487
  model: opts.model,
488
+ loop: opts.loop,
489
+ transparent: opts.transparent,
472
490
  slug: opts.slug,
473
491
  name: opts.name,
474
492
  });
475
493
  // A clip takes a minute or more; poll so gen-video stays one blocking command.
476
494
  // Progress goes to stderr so stdout stays the URL (or --json).
477
- process.stderr.write(`queued a ${queued.seconds}s ${queued.model} clip — job ${queued.jobId}\n`);
495
+ const kind = [queued.loop && "looping", queued.transparent && "transparent"].filter(Boolean).join(" ");
496
+ process.stderr.write(`queued a ${queued.seconds}s ${kind ? `${kind} ` : ""}${queued.model} clip — job ${queued.jobId}\n`);
478
497
  const job = await ops.pollVideoJob(opts, queued.jobId, {
479
498
  onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
480
499
  });
@@ -482,13 +501,21 @@ async function genVideo(pos, opts) {
482
501
  const url = job.video?.url;
483
502
  if (!url) throw new Error(`video job ${queued.jobId} finished without a URL; check it with: video-job ${queued.jobId}`);
484
503
  // Print the paid clip's URL before downloading, so a failed download never hides it.
504
+ // A transparent clip prints both files (WebM, then Safari's stacked MP4) and the markup.
485
505
  console.log(opts.json ? JSON.stringify(job, null, 2) : url);
506
+ if (!opts.json && job.video?.stacked?.url) console.log(job.video.stacked.url);
507
+ if (!opts.json && job.transparent && job.markup) process.stderr.write(`markup: ${job.markup}\n`);
486
508
  if (job.creditsRemaining != null) process.stderr.write(`credits left: ${job.creditsRemaining}\n`);
487
509
  if (opts.download) {
488
- const res = await fetch(url);
489
- if (!res.ok) throw new Error(`download of ${url} failed (${res.status})`);
490
- writeFileSync(opts.download, Buffer.from(await res.arrayBuffer()));
491
- process.stderr.write(`saved to ${opts.download}\n`);
510
+ // A transparent clip is two files: the WebM where asked, the stacked MP4 beside it.
511
+ const files = [[url, opts.download]];
512
+ if (job.video?.stacked?.url) files.push([job.video.stacked.url, `${String(opts.download).replace(/\.[^./]+$/, "")}-stacked.mp4`]);
513
+ for (const [from, to] of files) {
514
+ const res = await fetch(from);
515
+ if (!res.ok) throw new Error(`download of ${from} failed (${res.status})`);
516
+ writeFileSync(to, Buffer.from(await res.arrayBuffer()));
517
+ process.stderr.write(`saved to ${to}\n`);
518
+ }
492
519
  }
493
520
  }
494
521
 
@@ -503,6 +530,8 @@ async function videoJob(pos, opts) {
503
530
  console.log(`status: ${job.status}`);
504
531
  if (job.error) console.log(`error: ${job.error}`);
505
532
  if (job.video?.url) console.log(job.video.url);
533
+ if (job.video?.stacked?.url) console.log(job.video.stacked.url);
534
+ if (job.markup) console.log(`markup: ${job.markup}`);
506
535
  }
507
536
 
508
537
  async function screenshot(pos, opts) {
@@ -629,7 +658,7 @@ function commandTable(pos, rest, opts) {
629
658
  if (opts.hosted) {
630
659
  const { hostedNewDeck } = await import("./source.mjs");
631
660
  if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
632
- const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"] });
661
+ const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"], brand: !opts["no-brand"] });
633
662
  return void console.log(opts.json ? JSON.stringify(out, null, 2) : `✓ created hosted deck "${out.slug}" (rev ${out.rev})\n preview: ${out.previewUrl}\n edit locally: hyperdecks source pull ${out.slug}`);
634
663
  }
635
664
  const { deckNew } = await import("./scaffold.mjs");
@@ -652,9 +681,11 @@ function commandTable(pos, rest, opts) {
652
681
  release: async () => {
653
682
  if (opts.hosted) {
654
683
  const { hostedRelease } = await import("./source.mjs");
655
- const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid });
684
+ const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] });
656
685
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
657
686
  console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
687
+ const who = presenterLine(out.presenter);
688
+ if (who) console.log(who);
658
689
  for (const s of out.shares) console.log(`✓ share for "${s.recipient}"${s.qa ? " (QA, untracked)" : ""} → ${s.url}`);
659
690
  return;
660
691
  }
@@ -826,6 +857,21 @@ function commandTable(pos, rest, opts) {
826
857
  screenshot: () => screenshot(pos.slice(1), opts),
827
858
  chart: () => chart(pos.slice(1), opts),
828
859
  icons: () => icons(pos.slice(1), opts),
860
+ // The icon library (Lucide); `icons search … --style` still generates.
861
+ "icons search": async () => {
862
+ if (opts.style) return icons(pos.slice(1), opts);
863
+ if (!rest.length) throw new Error("usage: icons search <word...> [--limit N] [--stroke-width N] [--json]");
864
+ const { icons: searchIcons } = await import("./icons.mjs");
865
+ const out = await searchIcons(rest, opts);
866
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
867
+ },
868
+ brand: async () => (await import("./brandCli.mjs")).brandShow(opts),
869
+ "brand show": async () => (await import("./brandCli.mjs")).brandShow(opts),
870
+ "brand set": async () => (await import("./brandCli.mjs")).brandSet(opts),
871
+ "brand edit": async () => (await import("./brandCli.mjs")).brandEdit(opts),
872
+ "brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
873
+ "brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
874
+ "brand import": async () => (await import("./brandCli.mjs")).brandImport(rest[0], opts),
829
875
  review: () => review(pos.slice(1), opts),
830
876
  "review-job": () => reviewJob(pos.slice(1), opts),
831
877
  poster: () => poster(pos.slice(1), opts),
package/src/files.mjs CHANGED
@@ -19,6 +19,7 @@ const FILES = {
19
19
  "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
20
20
  "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
21
21
  "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
22
+ "templates/icons/lucide.json": () => new URL("../templates/icons/lucide.json", import.meta.url),
22
23
  "templates/first-deck/self.json": () => new URL("../templates/first-deck/self.json", import.meta.url),
23
24
  "templates/first-deck/character.json": () => new URL("../templates/first-deck/character.json", import.meta.url),
24
25
  "skills/hyperdecks/SKILL.md": () => new URL("../skills/hyperdecks/SKILL.md", import.meta.url),
package/src/firstDeck.mjs CHANGED
@@ -52,6 +52,8 @@ export function fillFirstDeck({ html, title, answers, presenter, voice }) {
52
52
  const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
53
53
  let out = section(html, "hero", (s) => {
54
54
  s = note(s);
55
+ // No art: the presenter is the opening's visual, and check fails on the stand-in.
56
+ s = swap(s, /\s*<div class="art"[^>]*><\/div>/, "", "hero art");
55
57
  s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
56
58
  s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
57
59
  return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
package/src/guide.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // each layout file's leading comment.
7
7
  import { packageText } from "./files.mjs";
8
8
  import { LAYOUTS, readLayout } from "./layouts.mjs";
9
+ import { fontPairingsMarkdown } from "./brand.mjs";
9
10
 
10
11
  export const GUIDE_TOPICS = ["workflow", "format", "script", "design", "layouts", "presenter", "transitions", "check"];
11
12
  const CLI_NOTE = "_Written for the CLI. On the hosted MCP there is no folder: use the tool names in `deck_guide` (workflow), hosted URLs for every file, and write_deck_source for edits._\n\n";
@@ -43,7 +44,8 @@ export function deckGuide(topic = "workflow") {
43
44
  else {
44
45
  const section = skillSection(packageText("skills/hyperdecks/SKILL.md"), t);
45
46
  if (!section) throw new Error(`SKILL.md has no <!-- topic: ${t} --> section`);
46
- text = CLI_NOTE + section;
47
+ // The design topic carries the curated font pairings (brand.mjs) in place of its marker.
48
+ text = CLI_NOTE + section.replace("<!-- font pairings -->", () => fontPairingsMarkdown());
47
49
  }
48
50
  return { topic: t, topics: GUIDE_TOPICS, text };
49
51
  }
package/src/hosted.mjs CHANGED
@@ -3,7 +3,9 @@
3
3
  // (source.mjs) share these; no local file I/O, so the route can bundle them.
4
4
  import { api, client, target } from "./runtime.mjs";
5
5
  import { PRESENTER_STYLES } from "./checkRules.mjs";
6
- import { NARRATED_FILES, assembleLayoutDeck, fillTemplate } from "./layouts.mjs";
6
+ import { NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts } from "./layouts.mjs";
7
+ import { applyBrand, brandForNewDeck, brandSummary } from "./brand.mjs";
8
+ import { getBrand } from "./ops.mjs";
7
9
 
8
10
  export const SOURCE_FILES = Object.keys(NARRATED_FILES);
9
11
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -13,16 +15,22 @@ const urls = (opts, slug, previewToken) => {
13
15
  };
14
16
 
15
17
  // Scaffold the files (the same templates as deck new) and register the draft.
16
- export async function hostedNewDeck(opts, { slug, title, layouts, presenterStyle } = {}) {
18
+ // The account's brand (colours, fonts, logo, presenter style) goes on the
19
+ // template unless brand is false; an explicit presenterStyle wins over it.
20
+ export async function hostedNewDeck(opts, { slug, title, layouts, presenterStyle, brand: useBrand } = {}) {
17
21
  if (!slug || !title) throw new Error("slug and title are required");
18
22
  if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
19
23
  if (presenterStyle !== undefined && !PRESENTER_STYLES.includes(presenterStyle)) throw new Error(`presenterStyle must be one of ${PRESENTER_STYLES.join(", ")}`);
20
24
  const list = Array.isArray(layouts) ? layouts.join(",") : layouts;
25
+ if (list) parseLayouts(list);
26
+ const { brand, readError } = await brandForNewDeck(() => getBrand(opts), useBrand);
27
+ const style = presenterStyle ?? (PRESENTER_STYLES.includes(brand?.presenterStyle) ? brand.presenterStyle : undefined);
21
28
  const files = list
22
- ? assembleLayoutDeck({ slug, title, presenterStyle, layouts: list })
23
- : Object.fromEntries(Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle })]));
29
+ ? assembleLayoutDeck({ slug, title, presenterStyle: style, layouts: list })
30
+ : Object.fromEntries(Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle: style })]));
31
+ files["index.html"] = applyBrand(files["index.html"], brand);
24
32
  const made = await (await client(opts)).mutation(api.deckSources.create, { slug, title, files });
25
- return { slug, rev: made.rev, files, ...urls(opts, slug, made.previewToken) };
33
+ return { slug, rev: made.rev, files, brand: brandSummary(brand, readError), ...urls(opts, slug, made.previewToken) };
26
34
  }
27
35
 
28
36
  export async function getDeckSource(opts, { slug, file } = {}) {
@@ -59,7 +67,7 @@ export async function hostedCheck(opts, { slug, forbid, maxDuration } = {}) {
59
67
  return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}) });
60
68
  }
61
69
 
62
- export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice } = {}) {
70
+ export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice, captionsOnly } = {}) {
63
71
  if (!slug) throw new Error("slug is required");
64
72
  const list = typeof forbid === "string" ? forbid.split(",").map((s) => s.trim()).filter(Boolean) : forbid;
65
73
  const args = { slug };
@@ -69,6 +77,7 @@ export async function hostedRelease(opts, { slug, recipients, qa, title, forbid,
69
77
  if (list?.length) args.forbid = list;
70
78
  if (rev !== undefined && rev !== null) args.rev = Number(rev);
71
79
  if (voice) args.voice = String(voice);
80
+ if (captionsOnly) args.captionsOnly = true;
72
81
  return (await client(opts)).action(api.deckSources.release, args);
73
82
  }
74
83
 
package/src/icons.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // hyperdecks icons search <word> [--limit N] [--stroke-width N]
2
+ //
3
+ // The icon library: Lucide's open-source line icons (ISC licence, vendored in
4
+ // templates/icons/lucide.json by scripts/sync-icons.mjs). A search returns
5
+ // inline SVG stroked in currentColor, so a pasted icon takes the colour of the
6
+ // text around it, i.e. the deck's palette. No network and no credits; for
7
+ // illustrated icons in a style of your own, `hyperdecks icons <subject...>
8
+ // --style` (generate_icon_set) still draws them.
9
+ import { packageText } from "./files.mjs";
10
+
11
+ export const MAX_ICON_RESULTS = 24;
12
+ const DEFAULT_RESULTS = 8;
13
+ let library;
14
+ const lib = () => (library ??= JSON.parse(packageText("templates/icons/lucide.json")));
15
+
16
+ export function iconSvg(name, { strokeWidth = 2 } = {}) {
17
+ const icon = lib().icons[name];
18
+ if (!icon) return null;
19
+ const sw = Number(strokeWidth);
20
+ const width = Number.isFinite(sw) && sw > 0 && sw <= 4 ? sw : 2;
21
+ return `<svg class="icon lucide-${name}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon[0]}</svg>`;
22
+ }
23
+
24
+ // Best matches first: the exact name, a name starting with or containing a
25
+ // word, then an exact tag, then a tag containing a word. Several words rank by
26
+ // how many of them match.
27
+ export function searchIcons({ query, limit, strokeWidth } = {}) {
28
+ const words = String(query ?? "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
29
+ if (!words.length) throw new Error("search needs a word, e.g. rocket");
30
+ const n = Math.min(Math.max(Number(limit) || DEFAULT_RESULTS, 1), MAX_ICON_RESULTS);
31
+ const phrase = words.join("-");
32
+ const scored = [];
33
+ for (const [name, [, tagText]] of Object.entries(lib().icons)) {
34
+ const parts = name.split("-");
35
+ const tags = tagText ? tagText.split(",") : [];
36
+ let score = name === phrase ? 100 : 0;
37
+ for (const w of words) {
38
+ if (parts[0] === w) score += 12;
39
+ else if (parts.includes(w)) score += 10;
40
+ else if (name.includes(w)) score += 6;
41
+ else if (tags.includes(w)) score += 5;
42
+ else if (tags.some((t) => t.split(" ").includes(w))) score += 3;
43
+ else if (tags.some((t) => t.includes(w))) score += 1;
44
+ }
45
+ // Plainer icons first among equals: rocket before rocket-launch.
46
+ if (score) scored.push({ name, tags, score: score - parts.length * 0.1 });
47
+ }
48
+ scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
49
+ return {
50
+ query: words.join(" "),
51
+ library: lib().source,
52
+ license: lib().license,
53
+ total: scored.length,
54
+ icons: scored.slice(0, n).map(({ name, tags }) => ({ name, tags, svg: iconSvg(name, { strokeWidth }) })),
55
+ usage: "Paste svg inline (e.g. in .l-features .card). It is stroked in currentColor: set `color` on it or its parent to put it in the deck's palette.",
56
+ };
57
+ }
58
+
59
+ export async function icons(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
60
+ const out = searchIcons({ query: pos.join(" "), limit: opts.limit, strokeWidth: opts["stroke-width"] });
61
+ if (opts.json) return out;
62
+ if (!out.icons.length) log(`no icons match "${out.query}"; try a plainer word (e.g. chart, user, lock)`);
63
+ for (const icon of out.icons) log(`${icon.name}${icon.tags.length ? ` (${icon.tags.slice(0, 5).join(", ")})` : ""}\n${icon.svg}\n`);
64
+ if (out.icons.length) log(`${out.icons.length} of ${out.total} from ${out.library}. ${out.usage}`);
65
+ return out;
66
+ }
package/src/importer.mjs CHANGED
@@ -158,7 +158,7 @@ export function rewriteTextAssetUrls(text, sourcePath, assets, uploadedByName) {
158
158
  export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
159
159
  const assetMap = buildAssetUrlMap(assets, uploadedByName, htmlEntry);
160
160
  let out = html.replace(
161
- /\b(src|href|poster|data-presenter-image)\s*=\s*(["'])([^"']+)\2/gi,
161
+ /\b(src|href|poster|data-presenter-image|data-alpha-stacked)\s*=\s*(["'])([^"']+)\2/gi,
162
162
  (match, attr, quote, raw) => {
163
163
  const url = resolveLocalUrl(raw, assetMap);
164
164
  return url ? `${attr}=${quote}${url}${quote}` : match;
package/src/narrate.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  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
+ import { presenterLine } from "./presenter.mjs";
11
12
  export { sectionIds, setManifestUrl } from "./checkRules.mjs";
12
13
  import { sectionIds, setManifestUrl } from "./checkRules.mjs";
13
14
 
@@ -46,10 +47,14 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
46
47
  for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars${s.presenterStyle ? ` presenter-style: ${s.presenterStyle}` : ""}`);
47
48
  const render = preview.slides.filter((s) => s.render);
48
49
  log(`${render.length} of ${preview.slides.length} slide(s) would render in voice "${preview.voice}", ~${render.reduce((n, s) => n + s.chars, 0)} characters of TTS`);
50
+ const who = presenterLine(preview.presenter);
51
+ if (who) log(who);
49
52
  return preview;
50
53
  }
51
- const { jobId, slides } = await ops.narrateDeck(opts, fields);
54
+ const { jobId, slides, presenter } = await ops.narrateDeck(opts, fields);
52
55
  log(`narration job ${jobId}: ${slides.filter((s) => s.status === "render").length} to render, ${slides.filter((s) => s.status === "cached").length} cached`);
56
+ const who = presenterLine(presenter);
57
+ if (who) log(who);
53
58
  const seen = new Map();
54
59
  const job = await ops.pollNarrationJob(opts, jobId, {
55
60
  onProgress: (j) => {
@@ -63,6 +68,8 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
63
68
  },
64
69
  });
65
70
  if (job.status === "error") throw new Error(job.error || "narration failed");
71
+ // release reads who presents from here (older backends send no presenter).
72
+ if (presenter) job.presenter = presenter;
66
73
  if (!job.manifest) {
67
74
  log(`⚠ ${job.notice || "done, but no manifest was written"}`);
68
75
  return job;