@hypersocial/decks-cli 0.9.6 → 0.9.7

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.6",
3
+ "version": "0.9.7",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -448,11 +448,8 @@ saves one.
448
448
 
449
449
  <!-- font pairings -->
450
450
 
451
- **Icons: real ones first.** `hyperdecks icons search <word>` (`search_icons`)
452
- returns Lucide line icons as inline SVG stroked in `currentColor`, so they take
453
- the deck's colours: paste the `<svg>` in place of a features card's icon. Use
454
- `icons <subject...> --style` (`generate_icon_set`) only for illustrated icons
455
- in a style of your own.
451
+ **Textures, icons, pictures:** the `look` topic (one texture, one icon set
452
+ from `search_icons` or `generate_icon_set`, a picture on every other slide).
456
453
 
457
454
  **Pick the layout from what the slide says.** Each layout's file in
458
455
  `templates/layouts/` opens with its slot side, presenter and the cues that
@@ -463,6 +460,7 @@ suit it.
463
460
  | `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]` |
464
461
  | `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
465
462
  | `product` | a screenshot in a browser frame (`.frame.card` for a phone) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
463
+ | `split` | the workhorse: a real picture (phone, laptop, 16:9) beside sticker, title, three ticks | stage, bottom-left; `[look: #<id>-frame]`, `[go: #<id>-t1]` … `t3` |
466
464
  | `phones` | a phone app's flow: a fanned row of phone cards | stage, bottom-left; `[go: #<id>-p1]` … `p3`, `[go: slot]` |
467
465
  | `quote` | a customer or user in their own words, quoted exactly | stage, bottom-left; `[look: #<id>-quote]` |
468
466
  | `compare` | before/after, them/us, three parallel lines each | stage; `[look: #<id>-before]`, `[go: #<id>-after]` |
@@ -476,12 +474,10 @@ suit it.
476
474
  two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
477
475
  `bg-dots` (never under text), a `.deco` of inked tiles.
478
476
 
479
- Presenter style follows the deck (step 2, *Which presenter style*): `stage`
480
- when the presenter carries the story, which is what the layouts are drawn for
481
- (they keep the slot's corner clear); `corner` on a slide where a big
482
- visual is the point and moves are not needed; `glass` on a dark or busy
483
- picture. Three layouts (`quote`, `phones`, `closer`) sit left; put a `slot-left` on others
484
- where there is room so slide changes move the presenter.
477
+ Presenter style follows the deck (step 2): `stage` when the presenter carries
478
+ the story (the layouts keep the slot's corner clear); `corner` where a big
479
+ visual is the point; `glass` on a dark or busy picture. `quote`, `split`,
480
+ `phones` and `closer` sit left; add `slot-left` elsewhere where there is room.
485
481
 
486
482
  **Visuals: real first, generated second.**
487
483
 
@@ -495,10 +491,12 @@ where there is room so slide changes move the presenter.
495
491
  floating shapes, abstract "tech" art, clip-art metaphors) makes a deck look
496
492
  cheap. A visual earns its place only if it shows something real from the
497
493
  story: the product, the data, a concrete situation or object. When in doubt,
498
- leave it out: one big statement in type beats a picture of nothing. The
499
- presenter is the hero of the opening slide; don't put art behind it.
500
- - Icons: only when they label something the viewer must tell apart at a
501
- glance. Three short statements set large usually beat three icon cards.
494
+ leave it out: one big statement in type beats a picture of nothing. But a
495
+ deck of nothing but type is not the answer: reach for the real things
496
+ first (`look` topic). The presenter is the hero of the opening slide;
497
+ don't put generated art behind it.
498
+ - Icons: from the deck's one set (`look` topic), labelling things or in a
499
+ `.sticker`; not one per sentence. Bullets are `.ticks`.
502
500
  - Images: `gen-image` (`generate_image`) only for a concrete subject the
503
501
  deck is about (a product in use, a place, a real object), never a metaphor.
504
502
  One style per deck, described the same way every time (subject, medium,
@@ -543,6 +541,61 @@ still.
543
541
  - No CSS animation on slides beyond `.reveal`; the player animates the slide
544
542
  change.
545
543
 
544
+ <!-- topic: look -->
545
+ #### Look: textures, icons, pictures
546
+
547
+ What the house decks do that a plain agent deck doesn't. Decide these three
548
+ once per deck, before writing slides, and say them in your plan.
549
+
550
+ **1. A picture on at least every other slide**, the most real one you have:
551
+
552
+ 1. the product itself: `capture_url` (phone: width 390, height 844), or a
553
+ screen recording or photo the person gives you (`upload_deck_asset`);
554
+ 2. their people, logos and app icons (with permission);
555
+ 3. real data drawn with `render_chart`;
556
+ 4. only then `generate_image`, of a concrete subject (a place, an object,
557
+ the product in use), never a metaphor or abstract "tech" art.
558
+
559
+ No product to show (a plan, a strategy)? Make the slides visual with type
560
+ and the kit below: a `number` slide, stickers, ticks on a `panel`.
561
+
562
+ **2. One texture**, on two or three slides (an opener, a closer, a slide
563
+ with a visual), never under body text. On the section:
564
+
565
+ | Class | Looks like | For |
566
+ |---|---|---|
567
+ | `bg-lines` | diagonal speed lines fading toward `--fade-at` | product slides, beside a picture |
568
+ | `bg-halftone` | a corner of halftone dots | openers, numbers |
569
+ | `bg-rays` | rays from `--rays-at` | an opener or a closer |
570
+ | `bg-dots` | a fading dot grid | a phones row |
571
+
572
+ Grounds carry the rest: alternate `on-accent`, plain, `on-ink` and the soft
573
+ washes `on-tint`, `on-tint-2`, `on-tint-3` so each slide change reads.
574
+
575
+ **3. One icon set.** Pick one source and keep it for every icon in the deck:
576
+ Lucide (`search_icons`, same `strokeWidth` throughout); or one
577
+ `generate_icon_set` call with every subject you need and one `preset`
578
+ (`line`, `duotone`, `sticker`, `clay`; the palette defaults to the brand's);
579
+ or the product's own app icons and logos. Never mix two.
580
+ Put an icon or logo in a `.sticker` (tilted tile, hard shadow) on openers
581
+ and product slides; use `.ticks` (accent squares) for lists, not icons.
582
+
583
+ **Kit (in every layout deck's shell):** `.sticker` (`--tilt`; `.photo` for
584
+ an app icon or a face), `.ticks`, `.panel`, `.pill`, `.frame.card` (a phone),
585
+ `.frame.laptop` (a desktop shot). The `split` layout is image plus copy.
586
+
587
+ **Image prompts.** One recipe per deck, the same every time:
588
+ `<concrete subject>, <setting>, <medium: e.g. editorial photo, soft daylight>,
589
+ palette <ink hex> <accent hex> <paper hex>, no text, no logos`
590
+ (`generate_image { brand: true }` adds the palette and the "no text" for
591
+ you). Generate at the slide's shape (wide for a hero), in one batch.
592
+
593
+ **Slide recipes.** Opener: `on-accent` + `bg-rays` or `bg-halftone`, the logo
594
+ or a few app-icon stickers, one display line. Product or feature: `split`
595
+ with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
596
+ `timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
597
+ one action, stickers again.
598
+
546
599
  <!-- topic: script -->
547
600
  ### 3. Write slides and script
548
601
 
@@ -4,7 +4,7 @@ You are building a narrated deck through the Hyperdecks connector: no terminal,
4
4
  no git, no folder. The deck's files (`index.html`, `script.md`, `evidence.md`)
5
5
  live on Hyperdecks as a draft; you read and edit them with tools, and nothing
6
6
  reaches a recipient until `release_deck`. Topics: call `deck_guide` with
7
- `format`, `script`, `scripts`, `design`, `layouts`, `presenter`, `transitions` or `check`
7
+ `format`, `script`, `scripts`, `design`, `look`, `layouts`, `presenter`, `transitions` or `check`
8
8
  before the step that needs it.
9
9
 
10
10
  ## First time here?
@@ -21,7 +21,8 @@ makes the hello deck; continue from step 5 below.
21
21
 
22
22
  ## The loop
23
23
 
24
- 1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides.
24
+ 1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides
25
+ and `deck_guide { topic: "look" }` to pick the deck's texture, icon set and pictures.
25
26
  `get_brand` for the person's saved brand: write in its designMd's voice.
26
27
  None yet? Offer to import it from what they have: `import_brand` with
27
28
  their website (`url`), a PowerPoint or PDF they attach (`file`) or one of
@@ -46,8 +47,8 @@ makes the hello deck; continue from step 5 below.
46
47
  layouts' stand-ins (hero's `<div class="art">`, product's
47
48
  `<div class="shot">`, chart's placeholder `<svg>`) fail `check_deck` until
48
49
  you swap each for an `<img>` of its real file, or delete the hero's art.
49
- Icons: `search_icons { query }` returns real line icons as inline `<svg>`
50
- in `currentColor`; paste one where a features card has its icon.
50
+ Icons: one set per deck (`look` topic): `search_icons { query }` for real
51
+ line icons as inline `<svg>` in `currentColor`, or one `generate_icon_set`.
51
52
  For motion, `generate_video` (poll `check_video_job`): `loop: true` for a
52
53
  clip that repeats with no jump, `transparent: true` for a moving cut-out
53
54
  over the slide. Paste the finished job's `markup` as is (a transparent
@@ -65,6 +65,9 @@ export const MESSAGES = {
65
65
  morphPair: (id, prev) => `slide ${id}: morph needs an element with the same data-morph="<name>" on this slide and the one before it (${prev})`,
66
66
  transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
67
67
  flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
68
+ // Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
69
+ fewPictures: (shown, total) => `only ${shown} of ${total} slides show a picture (a screenshot, a recording, a photo, a chart); aim for a real one on every other slide (deck_guide topic look)`,
70
+ mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
68
71
  // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
69
72
  placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
70
73
  // Warnings (check --layout): the deck still passes.
@@ -526,6 +529,10 @@ const PLACEHOLDERS = [
526
529
  layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
527
530
  how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
528
531
  },
532
+ {
533
+ layout: "l-split", tag: "div", cls: "shot", what: "the picture", img: '<img class="shot" src="…" alt="…"> (or <video class="shot" src="…" autoplay muted loop playsinline>)',
534
+ how: { hosted: "capture the app at phone size with capture_url (width 390, height 844), or upload a screen recording or photo with upload_deck_asset", cli: "capture the app at phone size with `hyperdecks screenshot <url> --width 390 --height 844`, or add a screen recording or photo with `hyperdecks asset add`" },
535
+ },
529
536
  {
530
537
  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',
531
538
  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`" },
@@ -754,6 +761,35 @@ const isHostedAsset = (url) => {
754
761
  }
755
762
  };
756
763
 
764
+ // The look topic as warnings. A slide shows a picture when it holds an <img>
765
+ // (not the brand logo or an icon), <video>, <canvas>, <iframe>, a chart <svg> or an
766
+ // inline-style url() background (not a stylesheet's: that is usually a texture); a deck of three or more slides with fewer than a third that do
767
+ // reads as all type (the topic aims for every other slide; a plan with no
768
+ // product to show may have fewer). Icons count as one set each: inline Lucide <svg> (by stroke width),
769
+ // and <img> icons (class icon, or a generated icon-* asset).
770
+ const iconImg = (tag) => /\bicon\b/.test(attr(tag, "class") ?? "") || /\/icon-[\w-]+\.(png|webp|svg)\b/i.test(attr(tag, "src") ?? "");
771
+ export function lookWarnings(html) {
772
+ const all = slides(html);
773
+ const out = [];
774
+ const picture = (body) =>
775
+ /<(video|canvas|iframe)\b/i.test(body) ||
776
+ /<svg\b[^>]*class=["'][^"']*\bchart\b/i.test(body) ||
777
+ [...body.matchAll(/\bstyle=("[^"]*"|'[^']*')/gi)].some(([, v]) => /\burl\(/i.test(v)) ||
778
+ [...body.matchAll(/<img\b[^>]*>/gi)].some(([tag]) => !/\bbrand-logo\b/.test(attr(tag, "class") ?? "") && !iconImg(tag));
779
+ const shown = all.filter((s) => picture(uncommented(s.body)) || /\burl\(/i.test(attr(s.tag, "style") ?? "")).length;
780
+ if (all.length >= 3 && shown * 3 < all.length) out.push(MESSAGES.fewPictures(shown, all.length));
781
+ const body = uncommented(all.map((s) => s.body).join("\n"));
782
+ const sets = new Set();
783
+ for (const [tag] of body.matchAll(/<svg\b[^>]*>/gi)) {
784
+ if (/\blucide/.test(attr(tag, "class") ?? "")) sets.add(`Lucide at stroke-width ${attr(tag, "stroke-width") ?? "2"}`);
785
+ }
786
+ for (const [tag] of body.matchAll(/<img\b[^>]*>/gi)) {
787
+ if (iconImg(tag)) sets.add("image icons");
788
+ }
789
+ if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
790
+ return out;
791
+ }
792
+
757
793
  // Every rule on the deck's text. `exists(ref)` answers whether a local reference
758
794
  // resolves (true, false or "outside"); `hosted: true` means there is no folder,
759
795
  // so every local reference is an error naming upload_deck_asset, a hotlinked
@@ -773,6 +809,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
773
809
  const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
774
810
  const warnings = [];
775
811
  if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
812
+ warnings.push(...lookWarnings(html));
776
813
  // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
777
814
  const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
778
815
  for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
package/src/commands.mjs CHANGED
@@ -376,8 +376,9 @@ commands:
376
376
  engagement <recipient|token> [--deck <slug>] [--json]
377
377
  what one recipient watched per deck: narration heard, replays, drop-off, visits
378
378
  gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--transparent] [--image <url>] [--size <s>]
379
- [--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
380
- default model gpt-image-2.5; --seed applies to nano-banana-2.1
379
+ [--quality <q>] [--count <n>] [--seed <n>] [--brand] [--slug <deck>] [--download <file>]
380
+ default model gpt-image-2.5; --seed applies to nano-banana-2.1;
381
+ --brand adds your brand's colours and "no text, no logos" to the prompt
381
382
  image-job <jobId> [--json]
382
383
  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>]
383
384
  a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
@@ -390,8 +391,9 @@ commands:
390
391
  a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
391
392
  icons search <word...> [--limit 8] [--stroke-width 2] [--json]
392
393
  real icons from the Lucide library as inline SVG in currentColor (free, offline)
393
- icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
394
- a style-consistent set of generated (illustrated) icons, one per subject (max 12)
394
+ icons <subject...> --preset line|duotone|sticker|clay | --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
395
+ a style-consistent set of generated (illustrated) icons, one per subject (max 12);
396
+ palette defaults to your brand's colours
395
397
  brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
396
398
  brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
397
399
  [--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
@@ -477,7 +479,7 @@ async function genImage(pos, opts) {
477
479
  const prompt = pos.join(" ").trim() || opts.prompt;
478
480
  if (!prompt) {
479
481
  throw new Error(
480
- 'usage: gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--seed <n>] [--slug <deck>] [--name <name>] [--download <file>]',
482
+ 'usage: gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--seed <n>] [--brand] [--slug <deck>] [--name <name>] [--download <file>]',
481
483
  );
482
484
  }
483
485
  let out = await ops.generateImage(opts, {
@@ -490,6 +492,7 @@ async function genImage(pos, opts) {
490
492
  format: opts.format,
491
493
  model: opts.model,
492
494
  seed: opts.seed,
495
+ brand: opts.brand,
493
496
  slug: opts.slug,
494
497
  name: opts.name,
495
498
  });
@@ -628,12 +631,13 @@ async function chart(pos, opts) {
628
631
  }
629
632
 
630
633
  async function icons(pos, opts) {
631
- if (!pos.length || !opts.style) {
632
- throw new Error('usage: icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--model nano-banana-2.1|gpt-image-2.5|gpt-image-2] [--slug <deck>] [--name <prefix>]');
634
+ if (!pos.length || (!opts.style && !opts.preset)) {
635
+ throw new Error('usage: icons <subject...> --preset line|duotone|sticker|clay | --style "<style>" [--palette "#hex,#hex"] [--model nano-banana-2.1|gpt-image-2.5|gpt-image-2] [--slug <deck>] [--name <prefix>]');
633
636
  }
634
637
  const queued = await ops.generateIconSet(opts, {
635
638
  subjects: pos,
636
639
  style: opts.style,
640
+ preset: opts.preset,
637
641
  palette: opts.palette ? String(opts.palette).split(",").map((c) => c.trim()).filter(Boolean) : undefined,
638
642
  model: opts.model,
639
643
  slug: opts.slug,
package/src/files.mjs CHANGED
@@ -17,6 +17,7 @@ const FILES = {
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
19
  "templates/layouts/phones.html": () => new URL("../templates/layouts/phones.html", import.meta.url),
20
+ "templates/layouts/split.html": () => new URL("../templates/layouts/split.html", import.meta.url),
20
21
  "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
21
22
  "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
22
23
  "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
package/src/guide.mjs CHANGED
@@ -8,7 +8,7 @@ import { packageText } from "./files.mjs";
8
8
  import { LAYOUTS, readLayout } from "./layouts.mjs";
9
9
  import { fontPairingsMarkdown } from "./brand.mjs";
10
10
 
11
- export const GUIDE_TOPICS = ["workflow", "format", "script", "scripts", "design", "layouts", "presenter", "transitions", "check"];
11
+ export const GUIDE_TOPICS = ["workflow", "format", "script", "scripts", "design", "look", "layouts", "presenter", "transitions", "check"];
12
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";
13
13
 
14
14
  // One <!-- topic: name --> section of SKILL.md: its heading and body, up to the
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", "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" };
8
+ export const LAYOUTS = ["hero", "number", "product", "split", "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", "image-copy": "split", "image-text": "split" };
10
10
 
11
11
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
12
 
@@ -49,24 +49,55 @@ const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i
49
49
  const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
50
50
  const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
51
51
  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
54
+ // comment, so a person's comments in <head> are never touched).
55
+ const PIECES = [
56
+ /<link rel="modulepreload" href="[^"]*\/player\/v\d+\/player\.js">[ \t]*\n?/g,
57
+ /<link rel="preload" as="style" href="[^"]*\/player\/v\d+\/player\.css">[ \t]*\n?/g,
58
+ /<script>if \(self !== top \|\| [^<]*document\.documentElement\.dataset\.hdFrame = "";<\/script>[ \t]*\n?/g,
59
+ new RegExp(`${STYLE.source}[ \\t]*\\n?`, "gi"),
60
+ ];
61
+ const BLOCK_COMMENT = /^<!-- Loading state \(keep verbatim/;
52
62
 
53
- // The page with its loading state put right: inserted after <title> (or at the
54
- // top of <head>) when <head> has none, or its <style data-hd-loading> replaced
55
- // when it differs from the template's. Only a page with the player script, as
56
- // the check asks only those. Returns { html, fixed: "inserted" | "refreshed" | null,
63
+ // <head>'s text with each piece kept `keep` times (0: all out, 1: the first stays),
64
+ // working outside comments only; the block's own comment counts as a piece too.
65
+ function trimPieces(head, keep) {
66
+ const seen = new Array(PIECES.length + 1).fill(0);
67
+ return head
68
+ .split(/(<!--[\s\S]*?-->[ \t]*\n?)/)
69
+ .map((part, i) => {
70
+ if (i % 2) return BLOCK_COMMENT.test(part) && ++seen[PIECES.length] > keep ? "" : part;
71
+ return PIECES.reduce((text, re, k) => text.replace(re, (m) => (++seen[k] > keep ? "" : m)), part);
72
+ })
73
+ .join("");
74
+ }
75
+
76
+ // The page with its loading state put right. Only a page with the player script,
77
+ // as the check asks only those. With no <style data-hd-loading> in <head>, the
78
+ // block goes in after <title> (or at the top of <head>), any of its pieces already
79
+ // there taken out first. With one, it becomes the template's (refreshed) and any
80
+ // second copy of the style or a piece goes (deduped). A person's comments are left
81
+ // alone. Returns { html, fixed: "inserted" | "refreshed" | "deduped" | null,
57
82
  // skipped? }: a player page with no <head>…</head> is left alone, with `skipped` saying why.
58
83
  export function withLoadingBlock(html) {
59
84
  const live = html.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length)); // same offsets, comments blanked
60
85
  const head = HEAD.exec(live);
61
86
  if (!PLAYER.test(live)) return { html, fixed: null };
62
87
  if (!head) return { html, fixed: null, skipped: "no <head>…</head> to put the loading state in" };
63
- const style = STYLE.exec(head[0]);
64
- if (style) {
65
- const at = head.index + style.index;
66
- if (html.slice(at, at + style[0].length) === LOADING_STYLE) return { html, fixed: null };
67
- return { html: html.slice(0, at) + LOADING_STYLE + html.slice(at + style[0].length), fixed: "refreshed" };
88
+ const rawHead = html.slice(head.index, head.index + head[0].length);
89
+ const splice = (text) => html.slice(0, head.index) + text + html.slice(head.index + head[0].length);
90
+ if (!STYLE.test(head[0])) {
91
+ const cleaned = trimPieces(rawHead, 0);
92
+ const title = /<\/title>/i.exec(cleaned);
93
+ const at = title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(cleaned)[0].length;
94
+ return { html: splice(`${cleaned.slice(0, at)}\n${LOADING_BLOCK}${cleaned.slice(at)}`), fixed: "inserted" };
68
95
  }
69
- const title = /<\/title>/i.exec(head[0]);
70
- const at = head.index + (title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(head[0])[0].length);
71
- return { html: `${html.slice(0, at)}\n${LOADING_BLOCK}${html.slice(at)}`, fixed: "inserted" };
96
+ const deduped = trimPieces(rawHead, 1);
97
+ // The one style left, outside comments, made the template's.
98
+ const blank = deduped.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
99
+ const style = STYLE.exec(blank);
100
+ const fresh = deduped.slice(0, style.index) + LOADING_STYLE + deduped.slice(style.index + style[0].length);
101
+ if (fresh === rawHead) return { html, fixed: null };
102
+ return { html: splice(fresh), fixed: deduped !== rawHead ? "deduped" : "refreshed" };
72
103
  }
package/src/ops.mjs CHANGED
@@ -78,6 +78,7 @@ export async function generateImage(opts, fields = {}) {
78
78
  if (fields.format) args.format = String(fields.format);
79
79
  if (fields.model) args.model = String(fields.model);
80
80
  if (fields.seed != null && fields.seed !== "") args.seed = Number(fields.seed);
81
+ if (fields.brand) args.brand = true;
81
82
  // Returns { images } for fast requests, or { jobId, status, count } for slow ones
82
83
  // (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
83
84
  // never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
@@ -179,8 +180,10 @@ export async function renderChart(opts, spec = {}) {
179
180
  export async function generateIconSet(opts, fields = {}) {
180
181
  const subjects = (fields.subjects || []).map(String).filter(Boolean);
181
182
  if (!subjects.length) throw new Error("subjects is required (one or more things to draw)");
182
- if (!fields.style) throw new Error("style is required");
183
- const args = { subjects, style: String(fields.style) };
183
+ if (!fields.style && !fields.preset) throw new Error("style or preset is required");
184
+ const args = { subjects };
185
+ if (fields.style) args.style = String(fields.style);
186
+ if (fields.preset) args.preset = String(fields.preset);
184
187
  if (fields.palette?.length) args.palette = fields.palette.map(String);
185
188
  if (fields.slug) args.slug = String(fields.slug);
186
189
  if (fields.name) args.name = String(fields.name);
@@ -229,7 +232,7 @@ export async function requestVideoExport(opts, fields = {}) {
229
232
  const c = await client(opts);
230
233
  try {
231
234
  const out = await c.mutation(api.videoExports.requestVideo, { slug, options });
232
- return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a deck renders in about its own length` };
235
+ return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a 4-minute deck takes about 12 minutes` };
233
236
  } catch (e) {
234
237
  // A backend from before the render worker has no requestVideo.
235
238
  if (/Could not find public function/i.test(errorText(e))) throw new Error(videoFromCli(slug));
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.6";
11
+ export const VERSION = "0.9.7";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  // The app's home. HYPERDECKS_HOST points the prod target's broker and share
@@ -191,7 +191,7 @@ export const api = {
191
191
  },
192
192
  };
193
193
 
194
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
194
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
195
195
  // release's recipients, in order: each --to starts one, and an --email right
196
196
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
197
197
  // opts.email stays the last --email for the other commands (share new).
@@ -56,12 +56,17 @@
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;
58
58
  --hd-ground: var(--accent); --hd-ground-ink: var(--ink); /* the loading state: the first slide's colours */
59
+ /* Soft grounds (on-tint, on-tint-2, on-tint-3): washes of the accents, so slide changes read without going loud. */
60
+ --tint-1: color-mix(in srgb, var(--accent) 18%, var(--paper)); --tint-2: color-mix(in srgb, var(--accent-2) 20%, var(--paper)); --tint-3: color-mix(in srgb, var(--accent-3, var(--accent)) 24%, var(--paper));
59
61
  }
60
62
  * { box-sizing: border-box; margin: 0; padding: 0; }
61
63
  body { color: var(--ink); font-family: var(--font); font-weight: 500; -webkit-font-smoothing: antialiased; --hd-font: var(--font); --hd-mono: var(--mono); }
62
64
  .slide { display: flex; flex-direction: column; gap: 24px; padding: 64px 72px 56px; background: var(--paper); color: var(--ink); --hd-speech: #f3c9dc; }
63
65
  .slide.on-accent { background: var(--accent); --hd-speech: var(--card); }
64
66
  .slide.on-ink { background: var(--ink); color: var(--paper); --hd-speech: #d9ccff; }
67
+ .slide.on-tint { background: var(--tint-1); --hd-speech: var(--card); }
68
+ .slide.on-tint-2 { background: var(--tint-2); --hd-speech: var(--card); }
69
+ .slide.on-tint-3 { background: var(--tint-3); --hd-speech: var(--card); }
65
70
  .kicker { font: var(--t-label) var(--mono); letter-spacing: .16em; text-transform: uppercase; }
66
71
  .tt { font-family: var(--display, var(--font)); font-weight: 700; font-size: var(--t-title); line-height: .95; letter-spacing: -.045em; text-wrap: balance; }
67
72
  .lead { font-size: var(--t-lead); line-height: 1.3; max-width: 28em; text-wrap: pretty; }
@@ -97,6 +102,34 @@
97
102
  .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
103
  .slide > .deco { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
99
104
  .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); }
105
+ /* The look kit (deck_guide topic look): one texture per deck, one icon set, real pictures.
106
+ Textures go on the section, behind the content, on a few slides: bg-lines, diagonal
107
+ speed lines fading out towards the text (--fade-at: where they are strongest, default
108
+ the right); bg-halftone, a corner of halftone dots (--fade-at, default top right);
109
+ bg-rays, rays from a point (--rays-at), for an opener or a closer. */
110
+ .slide:is(.bg-lines, .bg-halftone, .bg-rays) { isolation: isolate; overflow: hidden; }
111
+ .slide:is(.bg-lines, .bg-halftone, .bg-rays)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; }
112
+ .slide.bg-lines::before { background: repeating-linear-gradient(-22deg, transparent 0 30px, color-mix(in srgb, currentColor 10%, transparent) 30px 32px); -webkit-mask-image: radial-gradient(1000px 780px at var(--fade-at, 82% 52%), #000 12%, transparent 70%); mask-image: radial-gradient(1000px 780px at var(--fade-at, 82% 52%), #000 12%, transparent 70%); }
113
+ .slide.bg-halftone::before { background: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 2.6px, transparent 3px) 0 0 / 22px 22px; -webkit-mask-image: radial-gradient(640px 640px at var(--fade-at, 88% 14%), #000 25%, transparent 70%); mask-image: radial-gradient(640px 640px at var(--fade-at, 88% 14%), #000 25%, transparent 70%); }
114
+ .slide.bg-rays::before { background: repeating-conic-gradient(from 0deg at var(--rays-at, 72% 50%), color-mix(in srgb, currentColor 9%, transparent) 0deg 2.2deg, transparent 2.2deg 9deg); -webkit-mask-image: radial-gradient(circle at var(--rays-at, 72% 50%), transparent 10%, #000 45%); mask-image: radial-gradient(circle at var(--rays-at, 72% 50%), transparent 10%, #000 45%); }
115
+ /* Pieces. .sticker: a logo or icon tile, tilted (--tilt), ink edge, hard offset
116
+ shadow (.sticker.photo for a face or an app icon that fills it). .ticks: a short
117
+ list with accent-square bullets. .panel: a quiet card. .pill: a small tag. */
118
+ .sticker { --tilt: -3deg; display: inline-grid; place-items: center; flex: none; width: 96px; height: 96px; padding: 18px; border: 4px solid var(--ink); border-radius: 26px; background: var(--card); color: var(--ink); box-shadow: 6px 6px 0 var(--ink); transform: rotate(var(--tilt)); }
119
+ .sticker > :is(img, svg) { width: 100%; height: 100%; object-fit: contain; }
120
+ .sticker.photo { padding: 0; overflow: hidden; }
121
+ .sticker.photo > img { object-fit: cover; }
122
+ .ticks { list-style: none; display: flex; flex-direction: column; gap: 14px; }
123
+ .ticks li { position: relative; padding-left: 34px; font-size: var(--t-body); line-height: 1.3; font-weight: 600; }
124
+ .ticks li::before { content: ""; position: absolute; left: 0; top: .4em; width: 16px; height: 16px; border-radius: 5px; background: var(--accent); }
125
+ .on-accent .ticks li::before { background: var(--ink); }
126
+ .panel { padding: 24px 28px; border-radius: 22px; background: color-mix(in srgb, currentColor 6%, transparent); }
127
+ .pill { display: inline-flex; align-items: center; gap: 8px; width: fit-content; padding: 8px 16px; border-radius: 999px; background: var(--card); color: var(--ink); font: 700 var(--t-label) var(--mono); letter-spacing: .08em; text-transform: uppercase; box-shadow: 4px 4px 0 var(--accent); }
128
+ .on-accent .pill { box-shadow: 4px 4px 0 var(--ink); }
129
+ /* A laptop for a desktop screenshot or clip: a .frame.laptop holding an img.shot or video.shot. */
130
+ .slide .frame.laptop { position: relative; width: 860px; max-width: 100%; padding: 14px 14px 22px; border-radius: 20px 20px 6px 6px; background: #1a1a1d; box-shadow: 0 30px 70px color-mix(in srgb, var(--ink) 22%, transparent); }
131
+ .slide .frame.laptop::after { content: ""; position: absolute; left: -6%; right: -6%; bottom: -16px; height: 16px; border-radius: 0 0 16px 16px; background: linear-gradient(#d9d9de, #a9a9b0); }
132
+ .slide .frame.laptop > .shot { display: block; width: 100%; aspect-ratio: 16 / 10; border-radius: 6px; object-fit: cover; object-position: top; background: var(--paper); }
100
133
  /* Phones: the slide is the 374×736 card; the presenter is a small head in
101
134
  the corner, so keep the bottom 170px clear. */
102
135
  @media (max-width: 650px) {
@@ -108,6 +141,17 @@
108
141
  /* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
109
142
  .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
143
  .slide > .deco { display: none; }
144
+ /* Textures fade in towards the bottom, under the presenter, away from the text. */
145
+ .slide:is(.bg-lines, .bg-halftone)::before { -webkit-mask-image: linear-gradient(180deg, transparent 40%, #000 90%); mask-image: linear-gradient(180deg, transparent 40%, #000 90%); }
146
+ .slide.bg-halftone::before { background-size: 14px 14px; background-image: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 1.6px, transparent 2px); }
147
+ .sticker { width: 48px; height: 48px; padding: 9px; border-width: 2px; border-radius: 13px; box-shadow: 3px 3px 0 var(--ink); }
148
+ .ticks { gap: 6px; }
149
+ .ticks li { padding-left: 18px; }
150
+ .ticks li::before { width: 9px; height: 9px; border-radius: 3px; }
151
+ .panel { padding: 10px 12px; border-radius: 12px; }
152
+ .pill { padding: 4px 10px; box-shadow: 2px 2px 0 var(--accent); }
153
+ .slide .frame.laptop { padding: 5px 5px 9px; border-radius: 10px 10px 3px 3px; }
154
+ .slide .frame.laptop::after { bottom: -7px; height: 7px; border-radius: 0 0 8px 8px; }
111
155
  }
112
156
  </style>
113
157
  <!-- LAYOUT STYLES -->
@@ -0,0 +1,48 @@
1
+ <!--
2
+ layout: split
3
+ summary: Image plus copy: the copy on the left (a sticker for the logo or icon, kicker, title, one line, three ticks), a real picture on the right in a phone card, on speed lines.
4
+ use: the workhorse for a product, a feature or an app: most slides of a good deck are this one. The phone's .shot is a placeholder check fails on: put in a phone screenshot (capture_url at 390×844) or a screen recording (upload_deck_asset) as <img class="shot"> or <video class="shot" autoplay muted loop playsinline>. For a desktop app, replace the card with <div class="frame laptop" id="__ID__-frame"><img class="shot" src="…" alt="…"></div>; for a 16:9 clip or photo, <img class="shot wide" src="…" alt="…" id="__ID__-frame">. Swap the sticker's icon for the product's logo (<span class="sticker photo"><img src="…" alt=""></span>) or one icon from the deck's set.
5
+ slot: bottom-left
6
+ presenter: stage
7
+ cues: [look: #__ID__-frame] when the narration turns to the picture; [go: #__ID__-t1], [go: #__ID__-t2], [go: #__ID__-t3] at the word that starts each tick, then [go: slot].
8
+ script: Here it is, doing the job. [look: #__ID__-frame] It starts with [go: #__ID__-t1] the first thing. Then [go: #__ID__-t2] the second. And [go: #__ID__-t3] the third. [go: slot]
9
+ -->
10
+ <style>
11
+ .l-split { display: grid; grid-template-columns: 680px 1fr; column-gap: 64px; align-items: center; }
12
+ .l-split .text { align-self: start; display: flex; flex-direction: column; gap: 22px; padding-top: 24px; }
13
+ .l-split .head { display: flex; align-items: center; gap: 22px; }
14
+ .l-split .tt { font-size: var(--t-sub); line-height: 1.02; }
15
+ .l-split .lead { font-size: var(--t-body); }
16
+ .l-split .ticks { margin-top: 6px; }
17
+ .l-split .media { display: grid; place-items: center; height: 100%; }
18
+ .l-split .frame.card { width: 300px; --tilt: 4deg; }
19
+ .l-split .shot.wide { display: block; width: 100%; aspect-ratio: 16 / 9; border-radius: 18px; object-fit: cover; box-shadow: 14px 14px 0 var(--accent); }
20
+ .on-accent.l-split .shot.wide { box-shadow: 14px 14px 0 var(--ink); }
21
+ @media (max-width: 650px) {
22
+ .l-split { display: flex; flex-direction: column; align-items: stretch; }
23
+ .l-split .text { gap: 10px; padding-top: 0; }
24
+ .l-split .head { gap: 12px; }
25
+ .l-split .media { height: auto; }
26
+ .l-split .frame.card { width: 128px; }
27
+ .l-split .shot.wide { border-radius: 10px; box-shadow: 6px 6px 0 var(--accent); }
28
+ }
29
+ </style>
30
+ <section class="slide l-split slot-left bg-lines" id="__ID__" data-chapter="The product" data-note="Picture: name the screen or clip and the date it was captured.">
31
+ <div class="text">
32
+ <div class="head">
33
+ <span class="sticker" id="__ID__-logo"><svg class="lucide-smartphone" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg></span>
34
+ <div class="kicker">The product · Live</div>
35
+ </div>
36
+ <h2 class="tt" data-title-mobile="Here it is.">Here it is, doing the job.</h2>
37
+ <p class="lead">One line on what the picture shows.</p>
38
+ <ul class="ticks">
39
+ <li id="__ID__-t1">The first thing it does</li>
40
+ <li id="__ID__-t2">The second thing</li>
41
+ <li id="__ID__-t3">The third thing</li>
42
+ </ul>
43
+ </div>
44
+ <div class="media">
45
+ <div class="frame card" id="__ID__-frame"><div class="shot" role="img" aria-label="Phone screen placeholder"></div></div>
46
+ </div>
47
+ <div class="narration-slot"></div>
48
+ </section>