@hypersocial/decks-cli 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,7 +64,7 @@ share list <slug> [--json]
64
64
  share on|off <token>
65
65
  share mute|unmute <token>
66
66
  share rm <token>
67
- share url <token>
67
+ share url <token> [--backup]
68
68
 
69
69
  recipient archive|unarchive <name>
70
70
  import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live|archived]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -149,7 +149,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
149
149
  | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
150
150
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
151
151
  | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
152
- | Slides or scroll, viewer toggle | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off]` | `set_deck_view` |
152
+ | Slides or scroll, viewer toggle, pace | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default]` | `set_deck_view` |
153
153
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
154
154
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
155
155
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
@@ -169,7 +169,15 @@ HTML says. Viewers get a slides/scroll toggle in the player, remembered per
169
169
  link, unless you turn it off (`--toggle off`, `viewToggle: false`). A viewer
170
170
  who already switched keeps their own choice while the toggle is on; turn the
171
171
  toggle off to make every viewer see the deck's view.
172
- `deck list --json` / `list_decks` and `deck stats` / `deck_stats` show both.
172
+ Pace is the third: `--pace auto` (`pace: "auto"`, the default) plays itself,
173
+ `slide` waits for Next (the Continue pill, →, a swipe) after each slide's
174
+ narration, and `step` also stops the narration before each `[reveal]` cue,
175
+ so a slide's reveals are the steps the viewer clicks through: group what
176
+ belongs together at one word, so each stop is a meaningful step. Viewers can
177
+ switch pace (and slides/scroll) in the player's Preferences, in its top bar
178
+ (remembered per link); a video export always plays straight through. Pace
179
+ waits apply side to side (slides) only: scroll keeps its own flow.
180
+ `deck list --json` / `list_decks` and `deck stats` / `deck_stats` show all three.
173
181
 
174
182
  ### 1. Ground it in the user's own material
175
183
 
@@ -258,6 +266,18 @@ The deck contract (`check` enforces or warns on each line):
258
266
  <!-- topic: presenter -->
259
267
  #### Which presenter style
260
268
 
269
+ **Who presents is set at release; leave the tag alone.** `deck new --narrated`
270
+ writes `data-presenter="none"` on the player tag, and `release` (and
271
+ `release_deck`) puts the narrator's face on the copy it publishes: your own
272
+ narrator's avatar, or D.A.S.H.'s live head for a deck in D.A.S.H.'s voice. It
273
+ says `presented by …` only when the published page has that face; otherwise
274
+ it warns `releasing captions only` with the reason. To pick the face by hand,
275
+ set both attributes: `data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
276
+ `data-presenter="head" data-avatar="https://hyperdecks.ai/player/v1/dash-avatar.json"`
277
+ (it loads from any host). `data-presenter="head"` with no `data-avatar` plays
278
+ captions only, and so does a `data-avatar` beside `data-presenter="none"`.
279
+ `check` warns on both, and `release` leaves a hand-set `data-avatar` alone.
280
+
261
281
  Set the deck's on the player tag (`data-presenter-style="stage"`) or in
262
282
  script.md's front matter (`presenterStyle: stage`); override one slide with
263
283
  `data-presenter-style` on its `<section>` or `presenter-style: corner` under
@@ -448,11 +468,8 @@ saves one.
448
468
 
449
469
  <!-- font pairings -->
450
470
 
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.
471
+ **Textures, icons, pictures:** the `look` topic (one texture, one icon set
472
+ from `search_icons` or `generate_icon_set`, a picture on every other slide).
456
473
 
457
474
  **Pick the layout from what the slide says.** Each layout's file in
458
475
  `templates/layouts/` opens with its slot side, presenter and the cues that
@@ -463,6 +480,7 @@ suit it.
463
480
  | `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
481
  | `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
465
482
  | `product` | a screenshot in a browser frame (`.frame.card` for a phone) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
483
+ | `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
484
  | `phones` | a phone app's flow: a fanned row of phone cards | stage, bottom-left; `[go: #<id>-p1]` … `p3`, `[go: slot]` |
467
485
  | `quote` | a customer or user in their own words, quoted exactly | stage, bottom-left; `[look: #<id>-quote]` |
468
486
  | `compare` | before/after, them/us, three parallel lines each | stage; `[look: #<id>-before]`, `[go: #<id>-after]` |
@@ -476,12 +494,10 @@ suit it.
476
494
  two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
477
495
  `bg-dots` (never under text), a `.deco` of inked tiles.
478
496
 
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.
497
+ Presenter style follows the deck (step 2): `stage` when the presenter carries
498
+ the story (the layouts keep the slot's corner clear); `corner` where a big
499
+ visual is the point; `glass` on a dark or busy picture. `quote`, `split`,
500
+ `phones` and `closer` sit left; add `slot-left` elsewhere where there is room.
485
501
 
486
502
  **Visuals: real first, generated second.**
487
503
 
@@ -495,10 +511,12 @@ where there is room so slide changes move the presenter.
495
511
  floating shapes, abstract "tech" art, clip-art metaphors) makes a deck look
496
512
  cheap. A visual earns its place only if it shows something real from the
497
513
  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.
514
+ leave it out: one big statement in type beats a picture of nothing. But a
515
+ deck of nothing but type is not the answer: reach for the real things
516
+ first (`look` topic). The presenter is the hero of the opening slide;
517
+ don't put generated art behind it.
518
+ - Icons: from the deck's one set (`look` topic), labelling things or in a
519
+ `.sticker`; not one per sentence. Bullets are `.ticks`.
502
520
  - Images: `gen-image` (`generate_image`) only for a concrete subject the
503
521
  deck is about (a product in use, a place, a real object), never a metaphor.
504
522
  One style per deck, described the same way every time (subject, medium,
@@ -543,6 +561,75 @@ still.
543
561
  - No CSS animation on slides beyond `.reveal`; the player animates the slide
544
562
  change.
545
563
 
564
+ <!-- topic: look -->
565
+ #### Look: textures, icons, pictures
566
+
567
+ What the house decks do that a plain agent deck doesn't. Decide these three
568
+ once per deck, before writing slides, and say them in your plan.
569
+
570
+ **1. A picture on at least every other slide**, the most real one you have:
571
+
572
+ 1. the product itself: `capture_url` (phone: width 390, height 844), or a
573
+ screen recording or photo the person gives you (`upload_deck_asset`);
574
+ 2. their people, logos and app icons (with permission);
575
+ 3. real data drawn with `render_chart`;
576
+ 4. only then `generate_image`, of a concrete subject (a place, an object,
577
+ the product in use), never a metaphor or abstract "tech" art.
578
+
579
+ No product to show (a plan, a strategy)? Make the slides visual with type
580
+ and the kit below: a `number` slide, stickers, ticks on a `panel`.
581
+
582
+ **2. One texture**, on two or three slides (an opener, a closer, a slide
583
+ with a visual), never under body text. On the section:
584
+
585
+ | Class | Looks like | For |
586
+ |---|---|---|
587
+ | `bg-lines` | diagonal speed lines fading toward `--fade-at` | product slides, beside a picture |
588
+ | `bg-halftone` | a corner of halftone dots | openers, numbers |
589
+ | `bg-rays` | rays from `--rays-at` | an opener or a closer |
590
+ | `bg-dots` | a fading dot grid | a phones row |
591
+
592
+ Grounds carry the rest: alternate `on-accent`, plain, `on-ink` and the soft
593
+ washes `on-tint`, `on-tint-2`, `on-tint-3` so each slide change reads.
594
+
595
+ **3. One icon set.** Pick one source and keep it for every icon in the deck:
596
+ Lucide (`search_icons`, same `strokeWidth` throughout); or one
597
+ `generate_icon_set` call with every subject you need and one `preset`
598
+ (`line`, `duotone`, `sticker`, `clay`; the palette defaults to the brand's);
599
+ or the product's own app icons and logos. Never mix two.
600
+ Put an icon or logo in a `.sticker` (tilted tile, hard shadow) on openers
601
+ and product slides; use `.ticks` (accent squares) for lists, not icons.
602
+
603
+ **Kit (in every layout deck's shell):** `.sticker` (`--tilt`; `.photo` for
604
+ an app icon or a face), `.ticks`, `.panel`, `.pill`, `.frame.card` (a phone),
605
+ `.frame.laptop` (a desktop shot). The `split` layout is image plus copy.
606
+
607
+ **Image prompts.** One recipe per deck, the same every time:
608
+ `<concrete subject>, <setting>, <medium: e.g. editorial photo, soft daylight>,
609
+ palette <ink hex> <accent hex> <paper hex>, no text, no logos`
610
+ (`generate_image { brand: true }` adds the palette and the "no text" for
611
+ you). Generate at the slide's shape (wide for a hero), in one batch.
612
+
613
+ **Slide recipes.** Opener: `on-accent` + `bg-rays` or `bg-halftone`, the logo
614
+ or a few app-icon stickers, one display line. Product or feature: `split`
615
+ with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
616
+ `timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
617
+ one action, stickers again.
618
+
619
+ **No presenter.** When nothing stands in the corner, no face (presenter
620
+ `none`) *and* no captions (`data-silent`, or an export with captions off), or
621
+ when a host calls `window.hyperdecks.setPresenterOff(true)`, the player puts
622
+ `hd-no-presenter` on `<html>` and hides every `.narration-slot`. Each layout
623
+ has a variant for that in its own `<style>` (`deck_guide layouts` says what
624
+ each does): compare and chart widen; features, team and timeline centre
625
+ vertically; split, product and phones centre their copy; number, quote,
626
+ closer and a hero without art re-centre. Type sizes stay. A deck with
627
+ captions keeps the slot (the bubble lives there). For a custom slide, keep it
628
+ compatible: the presenter's corner is the slide's `.narration-slot`, never a
629
+ hard-coded gutter (`padding-right: 400px`); if it needs one, override it in a
630
+ `:root.hd-no-presenter .l-yours …` rule (check warns on a right gutter until
631
+ you do).
632
+
546
633
  <!-- topic: script -->
547
634
  ### 3. Write slides and script
548
635
 
@@ -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
@@ -90,9 +91,11 @@ makes the hello deck; continue from step 5 below.
90
91
  10. Later: `list_shares` and `deck_stats` (both show the card),
91
92
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
92
93
  email as the deck's moving preview (it needs a recipient's link, not a QA
93
- one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle }`
94
- for how the deck opens and whether viewers may switch (every link, next
95
- load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
94
+ one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle, pace }`
95
+ for how the deck opens, whether viewers may switch, and its pace (`auto`
96
+ plays itself, `slide` waits for Next after each slide, `step` also stops
97
+ before each `[reveal]`, so group a slide's reveals into meaningful steps)
98
+ (every link, next load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
96
99
  `set_recipient_archived` to tidy the activity feed; `credits` for the
97
100
  balance. `remove_deck` deletes a deck and breaks every link to it: confirm
98
101
  with the user first.
@@ -5,6 +5,8 @@
5
5
  // the deck's index.html and script.md as text, with the manifest and clip fetches
6
6
  // injected.
7
7
 
8
+ import { DASH_AVATAR, presenterTagIssue } from "./presenter.mjs";
9
+
8
10
  export const PLAYER_V1 = "https://hyperdecks.ai/player/v1/player.js";
9
11
  // Decks released before the app moved to hyperdecks.ai load the same player
10
12
  // from the old host, which serves it forever; both pass.
@@ -58,6 +60,8 @@ export const MESSAGES = {
58
60
  selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
59
61
  gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
60
62
  gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
63
+ presenterUnfilled: (mode) => `the player tag says data-presenter="${mode}" but names no face (${mode === "head" ? "data-avatar" : "data-presenter-image"}), so as written it plays captions only. release puts the narrator's face on it; to set one by hand, D.A.S.H. is data-presenter="head" data-avatar="${DASH_AVATAR}" (deck_guide topic presenter)`,
64
+ presenterKept: (mode) => `the player tag has a data-avatar but data-presenter="${mode ?? "none"}", so it plays captions only and release leaves it that way: set data-presenter="head" to show that face, or remove data-avatar so release adds the narrator's`,
61
65
  presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
62
66
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
63
67
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
@@ -65,6 +69,10 @@ export const MESSAGES = {
65
69
  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
70
  transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
67
71
  flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
72
+ // Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
73
+ fewPictures: (shown, total) => `only ${shown} of ${total} slides show a picture (a screenshot, a recording, a photo, a chart); aim for a real one on every other slide (deck_guide topic look)`,
74
+ presenterGutter: (where, decl) => `${where}: ${decl} keeps a right gutter for the presenter; leave that corner to the slide's .narration-slot so a deck without a presenter can use it (deck_guide topic look)`,
75
+ mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
68
76
  // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
69
77
  placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
70
78
  // Warnings (check --layout): the deck still passes.
@@ -526,6 +534,10 @@ const PLACEHOLDERS = [
526
534
  layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
527
535
  how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
528
536
  },
537
+ {
538
+ 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>)',
539
+ 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`" },
540
+ },
529
541
  {
530
542
  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
543
  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 +766,97 @@ const isHostedAsset = (url) => {
754
766
  }
755
767
  };
756
768
 
769
+ // The look topic as warnings. A slide shows a picture when it holds an <img>
770
+ // (not the brand logo or an icon), <video>, <canvas>, <iframe>, a chart <svg> or an
771
+ // 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
772
+ // reads as all type (the topic aims for every other slide; a plan with no
773
+ // product to show may have fewer). Icons count as one set each: inline Lucide <svg> (by stroke width),
774
+ // and <img> icons (class icon, or a generated icon-* asset).
775
+ const iconImg = (tag) => /\bicon\b/.test(attr(tag, "class") ?? "") || /\/icon-[\w-]+\.(png|webp|svg)\b/i.test(attr(tag, "src") ?? "");
776
+ export function lookWarnings(html) {
777
+ const all = slides(html);
778
+ const out = [];
779
+ const picture = (body) =>
780
+ /<(video|canvas|iframe)\b/i.test(body) ||
781
+ /<svg\b[^>]*class=["'][^"']*\bchart\b/i.test(body) ||
782
+ [...body.matchAll(/\bstyle=("[^"]*"|'[^']*')/gi)].some(([, v]) => /\burl\(/i.test(v)) ||
783
+ [...body.matchAll(/<img\b[^>]*>/gi)].some(([tag]) => !/\bbrand-logo\b/.test(attr(tag, "class") ?? "") && !iconImg(tag));
784
+ const shown = all.filter((s) => picture(uncommented(s.body)) || /\burl\(/i.test(attr(s.tag, "style") ?? "")).length;
785
+ if (all.length >= 3 && shown * 3 < all.length) out.push(MESSAGES.fewPictures(shown, all.length));
786
+ const body = uncommented(all.map((s) => s.body).join("\n"));
787
+ const sets = new Set();
788
+ for (const [tag] of body.matchAll(/<svg\b[^>]*>/gi)) {
789
+ if (/\blucide/.test(attr(tag, "class") ?? "")) sets.add(`Lucide at stroke-width ${attr(tag, "stroke-width") ?? "2"}`);
790
+ }
791
+ for (const [tag] of body.matchAll(/<img\b[^>]*>/gi)) {
792
+ if (iconImg(tag)) sets.add("image icons");
793
+ }
794
+ if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
795
+ return out;
796
+ }
797
+
798
+ // A wide right padding or margin (PRESENTER_GUTTER px or more, and wider than
799
+ // the left: a centred column is not a gutter) is a gutter held for the
800
+ // presenter outside its .narration-slot: without a presenter
801
+ // (:root.hd-no-presenter) it stays empty. Read from inline styles on a slide
802
+ // and its elements, and from the deck's <style> rules (by selector). Skipped:
803
+ // rules in a phone media query (max-width, no min-width), in @keyframes and
804
+ // the like, on the slot itself, the no-presenter rules, and a selector whose
805
+ // gutter a :root.hd-no-presenter rule overrides (the variant the guide asks for).
806
+ // One linear pass over the text: a malformed paste must not stall check.
807
+ export const PRESENTER_GUTTER = 240;
808
+ const sides = (prop, value) => {
809
+ const px = (v) => { const m = /^(\d+(?:\.\d+)?)(px|rem|em)$/i.exec(v ?? ""); return m ? Number(m[1]) * (m[2].toLowerCase() === "px" ? 1 : 16) : 0; };
810
+ const parts = value.trim().split(/\s+/);
811
+ if (/-(right|inline-end)$/i.test(prop)) return { right: px(parts[0]), left: 0 };
812
+ return { right: px(parts[1] ?? parts[0]), left: px(parts[3] ?? parts[1] ?? parts[0]) };
813
+ };
814
+ const gutters = (decls) => decls.split(";").map((d) => /^\s*((?:padding|margin)(?:-right|-inline-end)?)\s*:\s*([^!]+)/i.exec(d)).filter(Boolean)
815
+ .filter(([, prop, value]) => { const { right, left } = sides(prop, value); return right >= PRESENTER_GUTTER && right > left; })
816
+ .map(([, prop, value]) => `${prop.toLowerCase()}: ${value.trim()}`);
817
+ // Each style rule of a stylesheet: { selector, decls, at } where `at` lists the
818
+ // @-rule preludes around it. Comments drop out; braces nest.
819
+ function cssRules(css) {
820
+ const out = [], stack = [];
821
+ let start = 0, i = 0;
822
+ let text = "";
823
+ while (i < css.length) {
824
+ if (css.startsWith("/*", i)) { const end = css.indexOf("*/", i + 2); text += css.slice(start, i); i = start = end === -1 ? css.length : end + 2; continue; }
825
+ const ch = css[i];
826
+ if (ch === "{" || ch === "}") {
827
+ text += css.slice(start, i);
828
+ if (ch === "{") stack.push(text.trim());
829
+ else {
830
+ const head = stack.pop();
831
+ if (head !== undefined && !head.startsWith("@")) out.push({ selector: head, decls: text, at: stack.filter((h) => h.startsWith("@")) });
832
+ }
833
+ text = "";
834
+ start = i + 1;
835
+ }
836
+ i++;
837
+ }
838
+ return out;
839
+ }
840
+ const phoneOnly = (at) => at.some((h) => /^@media\b/i.test(h) && /max-width/i.test(h) && !/min-width/i.test(h));
841
+ const NO_PRESENTER = /^:root\.hd-no-presenter\s+/;
842
+ export function presenterGutterWarnings(html) {
843
+ const out = [];
844
+ for (const [i, s] of slides(html).entries()) {
845
+ const where = `slide ${s.id || i + 1}`;
846
+ for (const [, value] of `${s.tag}\n${uncommented(s.body)}`.matchAll(/\sstyle\s*=\s*("[^"]*"|'[^']*')/gi)) {
847
+ for (const decl of gutters(value.slice(1, -1))) out.push(MESSAGES.presenterGutter(where, decl));
848
+ }
849
+ }
850
+ const rules = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].flatMap(([, css]) => cssRules(css));
851
+ const overridden = new Set(rules.filter((r) => NO_PRESENTER.test(r.selector) && /(padding|margin)/i.test(r.decls)).map((r) => r.selector.replace(NO_PRESENTER, "")));
852
+ for (const r of rules) {
853
+ if (r.at.some((h) => !/^@(media|supports|layer|container)\b/i.test(h)) || phoneOnly(r.at)) continue;
854
+ if (NO_PRESENTER.test(r.selector) || /\.narration-slot\b/.test(r.selector) || overridden.has(r.selector)) continue;
855
+ for (const decl of gutters(r.decls)) out.push(MESSAGES.presenterGutter(`rule ${r.selector}`, decl));
856
+ }
857
+ return out;
858
+ }
859
+
757
860
  // Every rule on the deck's text. `exists(ref)` answers whether a local reference
758
861
  // resolves (true, false or "outside"); `hosted: true` means there is no folder,
759
862
  // so every local reference is an error naming upload_deck_asset, a hotlinked
@@ -773,6 +876,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
773
876
  const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
774
877
  const warnings = [];
775
878
  if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
879
+ warnings.push(...lookWarnings(html), ...presenterGutterWarnings(html));
776
880
  // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
777
881
  const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
778
882
  for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
@@ -836,6 +940,9 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
836
940
  }
837
941
  }
838
942
  warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
943
+ // A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
944
+ const tagIssue = silent ? null : presenterTagIssue(html);
945
+ if (tagIssue) warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter) : MESSAGES.presenterUnfilled(tagIssue.presenter));
839
946
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
840
947
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
841
948
  if (opts.hosted) {
package/src/commands.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import {
4
+ backupLinkUrl,
4
5
  installHyperdecksSkill,
5
6
  login,
6
7
  logout,
7
8
  openBrowser,
9
+ linkBase,
8
10
  parse,
9
11
  target,
10
12
  VERSION,
@@ -60,7 +62,7 @@ async function deckStats(pos, opts) {
60
62
  console.log(` unique viewers: ${s.viewers}`);
61
63
  console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
62
64
  console.log(` total engaged: ${fmtMs(s.totalMs)}`);
63
- if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
65
+ if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.pace ? `, pace ${s.pace}` : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
64
66
  if (s.recent?.length) {
65
67
  console.log(" recent:");
66
68
  for (const r of s.recent) {
@@ -85,10 +87,6 @@ async function deckRemove(pos, opts) {
85
87
  console.log(`OK removed deck ${slug}`);
86
88
  }
87
89
 
88
- function shareBase(opts) {
89
- return target(opts).shareBase;
90
- }
91
-
92
90
  async function shareNew(pos, opts) {
93
91
  const [slug, recipient] = pos;
94
92
  if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
@@ -105,9 +103,13 @@ async function shareNew(pos, opts) {
105
103
  expires: opts.expires,
106
104
  limit: opts.limit,
107
105
  });
108
- const url = `${shareBase(opts)}/v/${token}`;
109
- if (opts.json) console.log(JSON.stringify({ token, url }, null, 2));
110
- else console.log(`OK share for "${recipient}" -> ${url}`);
106
+ const url = `${await linkBase(opts)}/v/${token}`;
107
+ const backupUrl = backupLinkUrl(url);
108
+ if (opts.json) console.log(JSON.stringify({ token, url, backupUrl }, null, 2));
109
+ else {
110
+ console.log(`OK share for "${recipient}" -> ${url}`);
111
+ if (backupUrl) console.log(` backup link (same link, other domain, if their network blocks it): ${backupUrl}`);
112
+ }
111
113
  }
112
114
 
113
115
  async function shareList(pos, opts) {
@@ -156,23 +158,28 @@ async function deckForward(pos, opts) {
156
158
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
157
159
  }
158
160
 
159
- const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off]";
161
+ const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]";
160
162
  const VIEWS = { slides: "slides", scroll: "scroll", default: null };
163
+ const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
161
164
  async function deckView(pos, opts) {
162
165
  const [slug, mode] = pos;
163
166
  const toggle = opts.toggle;
164
- if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle"))) throw new Error(VIEW_USAGE);
167
+ const pace = opts.pace;
168
+ if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle") && !Object.hasOwn(opts, "pace"))) throw new Error(VIEW_USAGE);
165
169
  if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
166
170
  // A trailing --toggle with no value is a mistake, not "leave it".
167
171
  if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
172
+ if (Object.hasOwn(opts, "pace") && !Object.hasOwn(PACES, pace)) throw new Error(VIEW_USAGE);
168
173
  const out = await ops.setDeckView(opts, slug, {
169
174
  view: mode === undefined ? undefined : VIEWS[mode],
170
175
  viewToggle: toggle === undefined ? undefined : toggle === "on",
176
+ pace: pace === undefined ? undefined : PACES[pace],
171
177
  });
172
178
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
173
179
  const said = [];
174
180
  if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
175
181
  if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
182
+ if (pace !== undefined) said.push(pace === "default" ? "paces as its HTML says" : `pace ${pace}`);
176
183
  if (out.applies === false) {
177
184
  console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
178
185
  console.warn(`warning: ${out.warning}`);
@@ -181,10 +188,14 @@ async function deckView(pos, opts) {
181
188
  console.log(`OK deck ${slug} ${said.join(", ")} (every link, on its next load)`);
182
189
  }
183
190
 
184
- function shareUrl(pos, opts) {
191
+ async function shareUrl(pos, opts) {
185
192
  const [token] = pos;
186
- if (!token) throw new Error("usage: share url <token>");
187
- console.log(`${shareBase(opts)}/v/${token}`);
193
+ if (!token) throw new Error("usage: share url <token> [--backup]");
194
+ const url = `${await linkBase(opts)}/v/${token}`;
195
+ console.log(url);
196
+ // Second line: the same link on the other domain (stdout stays one URL per line).
197
+ const backupUrl = backupLinkUrl(url);
198
+ if (backupUrl && opts.backup) console.log(backupUrl);
188
199
  }
189
200
 
190
201
  async function recipientArchive(pos, opts, archived) {
@@ -336,7 +347,7 @@ commands:
336
347
  deck list|stats|open|rm|forward|view
337
348
  deck forward on|off <slug>
338
349
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
339
- deck view <slug> [slides|scroll|default] [--toggle on|off]
350
+ deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]
340
351
  how the deck opens in the player (default: as its HTML says, data-view or slides) and
341
352
  whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
342
353
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
@@ -376,8 +387,9 @@ commands:
376
387
  engagement <recipient|token> [--deck <slug>] [--json]
377
388
  what one recipient watched per deck: narration heard, replays, drop-off, visits
378
389
  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
390
+ [--quality <q>] [--count <n>] [--seed <n>] [--brand] [--slug <deck>] [--download <file>]
391
+ default model gpt-image-2.5; --seed applies to nano-banana-2.1;
392
+ --brand adds your brand's colours and "no text, no logos" to the prompt
381
393
  image-job <jobId> [--json]
382
394
  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
395
  a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
@@ -390,8 +402,9 @@ commands:
390
402
  a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
391
403
  icons search <word...> [--limit 8] [--stroke-width 2] [--json]
392
404
  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)
405
+ icons <subject...> --preset line|duotone|sticker|clay | --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
406
+ a style-consistent set of generated (illustrated) icons, one per subject (max 12);
407
+ palette defaults to your brand's colours
395
408
  brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
396
409
  brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
397
410
  [--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
@@ -439,7 +452,7 @@ commands:
439
452
 
440
453
  narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
441
454
  script.md → voiced clips, timelines and a manifest, rendered server-side; only changed slides re-render
442
- voice list [--json] your voices and their status
455
+ voice list [--json] your voices and their status, then the built-in voices anyone can narrate with
443
456
  voice clone <file...> --name <name>
444
457
  clone your voice from 1-3 mp3/m4a/wav recordings (30 s to 3 min in total, 10 MB each);
445
458
  asks you to type "I consent" before anything is uploaded
@@ -477,7 +490,7 @@ async function genImage(pos, opts) {
477
490
  const prompt = pos.join(" ").trim() || opts.prompt;
478
491
  if (!prompt) {
479
492
  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>]',
493
+ '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
494
  );
482
495
  }
483
496
  let out = await ops.generateImage(opts, {
@@ -490,6 +503,7 @@ async function genImage(pos, opts) {
490
503
  format: opts.format,
491
504
  model: opts.model,
492
505
  seed: opts.seed,
506
+ brand: opts.brand,
493
507
  slug: opts.slug,
494
508
  name: opts.name,
495
509
  });
@@ -628,12 +642,13 @@ async function chart(pos, opts) {
628
642
  }
629
643
 
630
644
  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>]');
645
+ if (!pos.length || (!opts.style && !opts.preset)) {
646
+ 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
647
  }
634
648
  const queued = await ops.generateIconSet(opts, {
635
649
  subjects: pos,
636
650
  style: opts.style,
651
+ preset: opts.preset,
637
652
  palette: opts.palette ? String(opts.palette).split(",").map((c) => c.trim()).filter(Boolean) : undefined,
638
653
  model: opts.model,
639
654
  slug: opts.slug,
@@ -847,10 +862,12 @@ function commandTable(pos, rest, opts) {
847
862
  if (opts.json) console.log(JSON.stringify(out, null, 2));
848
863
  },
849
864
  "voice list": async () => {
850
- const voices = await ops.listVoices(opts);
865
+ const voices = await ops.listAllVoices(opts);
851
866
  if (opts.json) return void console.log(JSON.stringify(voices, null, 2));
852
- for (const x of voices) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
853
- if (!voices.length) console.log("(no voices; voice clone <file...> --name <name> makes one)");
867
+ const own = voices.filter((x) => !x.builtin);
868
+ for (const x of own) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
869
+ if (!own.length) console.log("(no voices of your own; voice clone <file...> --name <name> makes one)");
870
+ for (const x of voices.filter((x) => x.builtin)) console.log(`${x.name}\tbuilt in\t${x.description}\t${x.id}`);
854
871
  },
855
872
  "voice clone": async () => {
856
873
  const { voiceClone } = await import("./voice.mjs");
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
@@ -29,7 +29,7 @@ export function skillSection(skill, topic) {
29
29
  function layoutsTopic() {
30
30
  const rows = LAYOUTS.map((name) => {
31
31
  const { meta } = readLayout(name);
32
- return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
32
+ return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.nopresenter && `Without a presenter: ${meta.nopresenter}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
33
33
  });
34
34
  return `# Layouts\n\nPass any of these, in order, as new_deck's \`layouts\` (repeat one for more of the same: number, number gives slides number and number-2).\n\n${rows.join("\n\n")}`;
35
35
  }