@hypersocial/decks-cli 0.9.8 → 0.9.10

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
@@ -58,6 +58,7 @@ whoami [--json]
58
58
  deck list [--json]
59
59
  deck stats <slug> [--json]
60
60
  deck open <slug>
61
+ deck link <slug> [--json] # the owner link: present or check your own deck, untracked
61
62
 
62
63
  share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]
63
64
  share list <slug> [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -146,14 +146,22 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
146
146
  | PDF for a data room or attachment | `hyperdecks export <slug> --pdf [--notes] --out deck.pdf`, `hyperdecks export list` | `export_deck`, `list_exports` |
147
147
  | Video (MP4) for YouTube, LinkedIn or an attachment | `hyperdecks export <slug\|deck-dir> --video [--size 1080p\|720p\|4k] [--shape 16:9\|9:16\|1:1] [--title-card] [--end-card] [--no-captions] [--no-avatar] [--upload] --out deck.mp4`: records the narrated player in real time (a 4-minute deck takes about 5), avatar, voice and captions, no player chrome; a slug records the released copy, a deck folder its working copy; `--upload` lists it with the deck's exports | `export_deck { kind: "mp4" }` renders it on our render worker (the dashboard's Export → Video); poll `check_export`, or `hyperdecks export status <exportId>` |
148
148
  | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
149
- | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
149
+ | One more link | `hyperdecks share new <slug> "<name>" [--no-forward] [--pace auto\|slide\|step]` | `create_share` (`allowForward`, `pace`) |
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, pace | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default]` | `set_deck_view` |
152
+ | Slides or scroll, viewer toggle, pace, AI-readable version | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default] [--ai-readable on\|off]` | `set_deck_view` |
153
+ | Present or check your own deck (owner link) | `hyperdecks deck link <slug>` (`deck open` opens it) | `ownerUrl` from `list_decks` or `check_deck` |
153
154
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
154
155
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
155
156
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
156
157
 
158
+ To present or check your own deck, use the owner link (`deck link`,
159
+ `ownerUrl`): the released deck as recipients get it, for the owner only
160
+ (signed in), with nothing tracked. Anyone else gets a sign-in notice, so a
161
+ leaked owner link shows nothing. Its toolbar's Present (or H) hides the owner
162
+ tools for a screen share. QA links are only for testing what a recipient sees;
163
+ never make one to present on a call.
164
+
157
165
  Forwarding is on by default: a viewer can forward the deck to someone, which
158
166
  makes a child link tracked on its own ("Dana via Peter"; `share list` shows it
159
167
  indented under Peter's, `list_shares` gives `parentToken` and `forwardedBy`).
@@ -266,17 +274,27 @@ The deck contract (`check` enforces or warns on each line):
266
274
  <!-- topic: presenter -->
267
275
  #### Which presenter style
268
276
 
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
277
+ **The CLI writes the face into the tag; leave it alone.** `deck new --narrated`
278
+ writes the narrator's face on the player tag (`data-presenter="head"
279
+ data-avatar="…" data-presenter-from="narrator"`): the account's default
280
+ narrator's, else D.A.S.H.'s. `deck narrator <slug> <name>` and `narrate`
281
+ rewrite it to the narrator's face and print `presenter: …, written to
282
+ index.html`; `deck narrator <slug> none` (or `deck new --captions-only`)
283
+ writes captions only on purpose. So the file plays with its face however it is
284
+ opened, a static server included. `release` (and `release_deck`) follows the
285
+ narrator: a `data-presenter-from="narrator"` face is replaced by the current
286
+ narrator's (your own narrator's avatar, or D.A.S.H.'s live head for a deck in
287
+ D.A.S.H.'s voice). It says `presented by …` only when the published page has
288
+ that face; otherwise it warns `releasing captions only` with the reason. To pick
289
+ the face by hand, set both attributes and no `data-presenter-from`:
290
+ `data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
276
291
  `data-presenter="head" data-avatar="https://hyperdecks.ai/player/v1/dash-avatar.json"`
277
292
  (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.
293
+ captions only, and so does a `data-avatar` beside `data-presenter="none"`, and
294
+ so does a plain `data-presenter="none"` from an older scaffold.
295
+ `check` warns on all three, and `release` leaves a hand-set `data-avatar` alone.
296
+ A hosted draft (`new_deck`) stores the tag as the template wrote it; its draft
297
+ link and `release_deck` add the narrator's face, so `check_deck` does not warn on its plain none.
280
298
 
281
299
  Set the deck's on the player tag (`data-presenter-style="stage"`) or in
282
300
  script.md's front matter (`presenterStyle: stage`); override one slide with
@@ -492,7 +510,7 @@ suit it.
492
510
 
493
511
  **Phones and backgrounds.** One phone: `product`'s `.frame.card`; a flow of
494
512
  two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
495
- `bg-dots` (never under text), a `.deco` of inked tiles.
513
+ `bg-stage` (never under text), a `.deco` of inked tiles.
496
514
 
497
515
  Presenter style follows the deck (step 2): `stage` when the presenter carries
498
516
  the story (the layouts keep the slot's corner clear); `corner` where a big
@@ -552,7 +570,8 @@ still.
552
570
  - `[go]` when the narration walks through items (cards, milestones, people,
553
571
  the action on the closer): one `go` per item at the word that starts it,
554
572
  then `[go: slot]` before the slide ends. Never more than one `go` per
555
- sentence; never a `go` on a slide in `corner` style.
573
+ sentence; never a `go` on a slide in `corner` style. A `go` keeps off
574
+ the picture; with no room it stays home.
556
575
  - `[look]` for a glance: the title as it is read, a number as it is said, a
557
576
  screenshot as the narration turns to it.
558
577
  - `[reveal]` for the one detail that should not be read before it is said (a
@@ -564,8 +583,8 @@ still.
564
583
  <!-- topic: look -->
565
584
  #### Look: textures, icons, pictures
566
585
 
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.
586
+ What the house decks do. Decide these three once per deck, before writing
587
+ slides, and say them in your plan.
569
588
 
570
589
  **1. A picture on at least every other slide**, the most real one you have:
571
590
 
@@ -579,18 +598,22 @@ once per deck, before writing slides, and say them in your plan.
579
598
  No product to show (a plan, a strategy)? Make the slides visual with type
580
599
  and the kit below: a `number` slide, stickers, ticks on a `panel`.
581
600
 
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:
601
+ **2. Plain grounds; texture only behind objects.** Alternate plain,
602
+ `on-tint`, `on-tint-2`, `on-tint-3`, `on-accent`, `on-ink`. On the section:
584
603
 
585
604
  | Class | Looks like | For |
586
605
  |---|---|---|
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.
606
+ | `bg-pool` | a soft light behind the picture | any picture |
607
+ | `bg-corner` | a halftone quarter-disc, top-right | cover, closer |
608
+ | `bg-stage` | a dot plate under a mockup | product, phones |
609
+ | `bg-mark` | the slide number, huge, faint (`--mark:'04'`) | cover, opener, closer |
610
+
611
+ - One signature per deck (`bg-corner` or `bg-mark`), on three slides at
612
+ most: the cover, an opener or the ask, the closer.
613
+ - `bg-pool` and `bg-stage` are picture helpers: only beside an image.
614
+ - Nothing under text, a chart or a photo; the presenter's corner stays clear.
615
+ - Your own is fine in the deck's `<style>`: tokens only, behind the content
616
+ in an isolated slide, off with nothing to back.
594
617
 
595
618
  **3. One icon set.** Pick one source and keep it for every icon in the deck:
596
619
  Lucide (`search_icons`, same `strokeWidth` throughout); or one
@@ -610,7 +633,7 @@ palette <ink hex> <accent hex> <paper hex>, no text, no logos`
610
633
  (`generate_image { brand: true }` adds the palette and the "no text" for
611
634
  you). Generate at the slide's shape (wide for a hero), in one batch.
612
635
 
613
- **Slide recipes.** Opener: `on-accent` + `bg-rays` or `bg-halftone`, the logo
636
+ **Slide recipes.** Opener: `on-accent` + `bg-corner`, the logo
614
637
  or a few app-icon stickers, one display line. Product or feature: `split`
615
638
  with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
616
639
  `timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
@@ -692,6 +715,43 @@ you do).
692
715
  | `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
693
716
  | `[gesture: <id>]` | one of the head's tracks: `knowing-nod`, `ponder-tilt`, `lightbulb`, `side-eye-hmm`, `chuckle-bob`, `sunny-nod`, `double-take`… (check lists them all) |
694
717
  | `[custom: name]` | only the slide's controller sees it |
718
+ | `[after: reveal #id]` | any stage cue above, fired at the END of the word before it (reveal at the end of a phrase) instead of its start |
719
+ - Guest speakers: other avatars that speak their own lines on a slide, no
720
+ slide JS. A `[speaker: <selector> voice=<voice> avatar=<face> name=<caption name>]`
721
+ line runs to the end of its paragraph; the narrator takes the next one:
722
+
723
+ ```
724
+ ## own
725
+ Let's hear them.
726
+
727
+ [speaker: #own-c1 voice=stock:sarah avatar=cat] Ooh, nice move!
728
+
729
+ [speaker: #own-c2 voice=matilda avatar=monkey name="Mono"] [reveal: #own-c2] Hola, ¿qué tal?
730
+
731
+ That's the idea.
732
+ ```
733
+
734
+ `voice` is one of the user's voices, a built-in `stock:<id>` or a character
735
+ voice. `avatar` is a cast id (`cat`, `kitty`, `monkey`, `dog`, `rabbit`,
736
+ `penguin`, `ghost`, `alien`, `agent`…), one of the user's avatars or a
737
+ narrator's name; without one the guest speaks with captions only. `name`
738
+ labels its captions (default: the avatar, capitalised). The guest's head
739
+ fills the element the selector names, or the `[data-hd-speaker]` spot
740
+ inside it, so give each card a face box (e.g. 140×140). Narrate renders
741
+ each line in its own voice (cached, transcript-checked) into the slide's
742
+ timeline: the narrator pauses, the guests speak in turn with lip-sync and a
743
+ caption bubble by their element, its tail at their face, the spoken word
744
+ highlighted ("Cat: Ooh, nice move!"), only while they talk; the speaker's
745
+ element is spotlit and the other guests dim. The player draws that bubble:
746
+ don't put static speech bubbles or the guest's line on the slide too. The presenter
747
+ glances at whoever speaks, then the narrator goes on. A cue may open a
748
+ guest's line and fires as it starts. Inside a guest's line, `[nod]`,
749
+ `[shake]`, `[brow]` and `[gesture]` move the guest's head; the other cues
750
+ (`[reveal]`, `[go]`, `[mood]`, `[wave]`…) act on the slide and the presenter. Replay, Per step (each guest line is a
751
+ step), mute, You present and video export all include them.
752
+ - Audio and data files a slide uses itself (a sound effect, a chart's JSON):
753
+ `hyperdecks asset add` / `upload_deck_asset` take `.mp3`, `.m4a` and
754
+ `.json` as well as images, video and fonts.
695
755
  - `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
696
756
  character voice (not a clone of anyone) any account may use, for decks that
697
757
  D.A.S.H. presents. It works once the deployment sets `CHARACTER_VOICE_DASH`.
@@ -803,6 +863,10 @@ browser (an agent reads the URL; `--json` prints it as one line).
803
863
  `release --qa` is not a preview: it needs a commit, and it changes what every
804
864
  link to this deck plays.
805
865
 
866
+ Watch a draft with `hyperdecks play`: it shows the face the deck will ship
867
+ with. A plain static server shows the file as written: the face named in the
868
+ tag (the narrator's, since `deck new`, `deck narrator` and `narrate` write it
869
+ there), or none for a tag that says `data-presenter="none"` (`check` warns).
806
870
  For the layout pass, serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
807
871
  and screenshot it with Playwright at 1440×900 and 390×844, every slide
808
872
  (`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
@@ -867,8 +931,10 @@ hyperdecks engagement "<name>" --deck <slug> --json # MCP: recipient_engagemen
867
931
  Then draft the email:
868
932
 
869
933
  1. Lead with the slide they spent most time on (`summary.mostTime`) or
870
- replayed (`replayedSlides`), and answer what that slide raises. Each
871
- slide's `title` says what it is about.
934
+ replayed (`replayedSlides`; `slideReplays` are the ones they replayed on
935
+ purpose, most first, and `replayNote` says it in one line), and answer what
936
+ that slide raises. Each slide's `title` says what it is about. Watching it
937
+ again from the start (`watchedAgain`) is strong interest.
872
938
  2. If they dropped off (`droppedOffOn`), offer the rest briefly, or a
873
939
  2-minute call, rather than asking them to rewatch.
874
940
  3. If they never opened it (`summary.visits` is 0), write a short nudge
@@ -889,7 +955,7 @@ the hosted MCP (`deck_guide`, `new_deck`, `get_deck_source`, `write_deck_source`
889
955
  `upload_deck_asset`, `check_deck`, `release_deck`; see `chat.md`). Move between
890
956
  the two with `hyperdecks source pull <slug> [dir]` (the hosted draft into a
891
957
  folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
892
- draft), `hyperdecks asset add <slug> <file|url>` for an image, `hyperdecks guide
958
+ draft), `hyperdecks asset add <slug> <file|url>` for an image, video, audio (.mp3, .m4a), font or JSON file, `hyperdecks guide
893
959
  [topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
894
960
 
895
961
  ## After you ship: say what cost you
@@ -15,7 +15,9 @@ narrators, credits) and the steps. No voice of their own: `clone_voice_link`
15
15
  in the chat, only after they agree to the consent statement, word for word.
16
16
  `hear_voice` plays a voice or a narrator in the chat. A face from a photo they
17
17
  attach: `avatar_from_photo { file }`, then `check_avatar_draft` until it hands
18
- back the link they approve it on. `rename_narrator` and `set_narrator_face`
18
+ back the link they approve it on (a pet photo comes back with an `offer`, say
19
+ "That looks like a cat. Make a cat avatar?": ask them, and on a yes
20
+ `new_avatar_link { species }`). `rename_narrator` and `set_narrator_face`
19
21
  change a narrator when they ask. Then `get_started { name, about, ask, voice }`
20
22
  makes the hello deck; continue from step 5 below.
21
23
 
@@ -88,6 +90,11 @@ makes the hello deck; continue from step 5 below.
88
90
  only, when nobody chose that, is refused: hand the person its Add narrator
89
91
  link, or offer D.A.S.H., and pass `captionsOnly: true` only if they say so.
90
92
  The result's `presenter` names who presents.
93
+ To present or check your own deck (on a call, say), give the user its
94
+ owner link, `ownerUrl` from `list_decks` or `check_deck`: only they can
95
+ open it, signed in, and nothing is tracked; Present (or H) hides their
96
+ owner tools for a screen share. QA links are only for testing what a
97
+ recipient sees, not for presenting.
91
98
  10. Later: `list_shares` and `deck_stats` (both show the card),
92
99
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
93
100
  email as the deck's moving preview (it needs a recipient's link, not a QA
@@ -95,7 +102,11 @@ makes the hello deck; continue from step 5 below.
95
102
  for how the deck opens, whether viewers may switch, and its pace (`auto`
96
103
  plays itself, `slide` waits for Next after each slide, `step` also stops
97
104
  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;
105
+ (every link, next load, no re-release). Any share link + `/md` (e.g.
106
+ https://decks.hypersocial.ai/v/<token>/md) returns the deck as markdown
107
+ (slide text, image descriptions and the narration), honouring the link's
108
+ gates; owners can turn it off with `set_deck_view { slug, aiReadable: false }`.
109
+ `set_share_notify` to mute a link's "opened" alerts;
99
110
  `set_recipient_archived` to tidy the activity feed; `credits` for the
100
111
  balance. `remove_deck` deletes a deck and breaks every link to it: confirm
101
112
  with the user first.
package/src/avatar.mjs CHANGED
@@ -59,7 +59,7 @@ export async function avatarNew(opts, { ask, log = console.log, interactive = in
59
59
 
60
60
  // A face from a photo file, or (no file) a selfie in the browser. Either way the
61
61
  // person approves it on the link before it is used.
62
- export async function avatarFromPhoto(file, opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
62
+ export async function avatarFromPhoto(file, opts, { ask, log = console.log, interactive = interactiveOf(opts) } = {}) {
63
63
  let photo = {};
64
64
  if (file) {
65
65
  const contentType = PHOTO_TYPES[extname(file).toLowerCase()];
@@ -67,8 +67,19 @@ export async function avatarFromPhoto(file, opts, { log = console.log, interacti
67
67
  photo = { bytes: readFileSync(file), contentType };
68
68
  }
69
69
  const out = await ops.avatarFromPhoto(opts, photo, { onProgress: (m) => !opts.json && log(m) });
70
- const url = out.reviewUrl ?? out.selfieUrl;
71
70
  if (opts.json) return out;
71
+ if (out.status === "failed" && out.suggestedSpecies) {
72
+ // No human face, but a pet: offer that species' faces instead.
73
+ log(out.offer);
74
+ if (!interactive || !ask) {
75
+ log(` hyperdecks avatar new --species ${out.suggestedSpecies}`);
76
+ return out;
77
+ }
78
+ const a = (await ask("Make one? [Y/n]: ")).trim().toLowerCase();
79
+ if (a && a !== "y" && a !== "yes") throw new Error(out.error || "no face was found in that photo");
80
+ return avatarNew({ ...opts, species: out.suggestedSpecies }, { ask, log, interactive });
81
+ }
82
+ const url = out.reviewUrl ?? out.selfieUrl;
72
83
  log(file ? `✓ Drew a cartoon of you. Check it before it's used:\n ${url}` : `No photo given. Take a selfie in your browser:\n ${url}`);
73
84
  if (opts["save-to-minimoji"]) log(" (tick \"Also save it to my Minimoji account\" there)");
74
85
  if (!interactive) return out;
package/src/brand.mjs CHANGED
@@ -8,7 +8,8 @@
8
8
  // only the tokens here touch the deck.
9
9
 
10
10
  // Weights each role loads: the templates set body text at 500, titles at 700
11
- // and labels in mono at 400/700. Every family below has these on Google Fonts.
11
+ // and labels in mono at 400/700. Every pairing family has these on Google Fonts;
12
+ // a display family outside the pairings loads what displayFace says it has.
12
13
  const ROLE_WEIGHTS = { display: "600;700", body: "400;500;700", mono: "400;700" };
13
14
 
14
15
  // 16 pairings, display + body + mono, each a family Google Fonts serves at the
@@ -32,8 +33,35 @@ export const FONT_PAIRINGS = [
32
33
  { id: "grotesk", display: "Schibsted Grotesk", body: "Work Sans", mono: "Roboto Mono", use: "Marketplaces and media brands; confident and newsy." },
33
34
  ];
34
35
 
35
- // Weights for families that do not have every role weight (load what exists).
36
- const FAMILY_WEIGHTS = { "Space Mono": "400;700", "DM Mono": "400;500", "Libre Baskerville": "400;700", "Courier Prime": "400;700", "Syne": "600;700" };
36
+ // Weights for families that do not have every role weight (load what exists),
37
+ // including single-weight display faces a brand may name (Google Fonts refuses
38
+ // a stylesheet that asks for a weight the family lacks).
39
+ const SINGLE_WEIGHT = ["Bangers", "Bebas Neue", "Anton", "Luckiest Guy", "Permanent Marker", "Pacifico", "Lobster", "Abril Fatface", "Alfa Slab One", "Righteous", "Press Start 2P", "Black Ops One", "Bungee", "Russo One", "Shrikhand", "Monoton", "Creepster", "Special Elite"];
40
+ const FAMILY_WEIGHTS = {
41
+ "Space Mono": "400;700", "DM Mono": "400;500", "Libre Baskerville": "400;700", "Courier Prime": "400;700", "Syne": "600;700",
42
+ ...Object.fromEntries(SINGLE_WEIGHT.map((f) => [f, "400"])),
43
+ };
44
+
45
+ // The template's titles (700, -.045em) were tuned for the pairings' sans
46
+ // display faces. Any other display face gets tracking 0; a face with a known
47
+ // weight table is set at its heaviest weight. A face we know nothing about stays
48
+ // at 700 and loads 400;700: Google Fonts' css2 serves whichever of the asked
49
+ // weights exist (it fails only when none do), and the templates' font-synthesis:
50
+ // none means a face without a 700 draws its real 400, never a faked bold.
51
+ const SERIF_DISPLAY = new Set(["Fraunces", "Playfair Display", "Newsreader", "Libre Baskerville"]);
52
+ const TUNED_DISPLAY = new Set(FONT_PAIRINGS.map((p) => p.display).filter((f) => !SERIF_DISPLAY.has(f)));
53
+ // Any family a pairing names (display or body) has the display weights.
54
+ const PAIRED_DISPLAY = new Set(FONT_PAIRINGS.flatMap((p) => [p.display, p.body]));
55
+
56
+ // { weights, weight, tracking } for a display family: the weights to load, and
57
+ // the --display-weight / --display-tracking to write (null: the template's).
58
+ export function displayFace(family) {
59
+ const f = String(family ?? "").trim();
60
+ const weights = FAMILY_WEIGHTS[f] ?? (PAIRED_DISPLAY.has(f) ? ROLE_WEIGHTS.display : "400;700");
61
+ if (TUNED_DISPLAY.has(f)) return { weights, weight: null, tracking: null };
62
+ const heaviest = Math.max(...weights.split(";").map(Number));
63
+ return { weights, weight: heaviest === 700 ? null : String(heaviest), tracking: "0" };
64
+ }
37
65
 
38
66
  const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
39
67
  // A Google Fonts family name: letters, digits and spaces (e.g. "Source Sans 3").
@@ -54,7 +82,7 @@ export function findPairing(id) {
54
82
  // The Google Fonts stylesheet URL for one family at the weights its roles use
55
83
  // (a family that is both display and body loads both roles' weights).
56
84
  export function googleFontsUrl(family, roles = "body") {
57
- const wanted = [roles].flat().flatMap((r) => (ROLE_WEIGHTS[r] ?? "400;700").split(";"));
85
+ const wanted = [roles].flat().flatMap((r) => (r === "display" ? displayFace(family).weights : ROLE_WEIGHTS[r] ?? "400;700").split(";"));
58
86
  const weights = FAMILY_WEIGHTS[family] ?? [...new Set(wanted)].sort().join(";");
59
87
  return `https://fonts.googleapis.com/css2?family=${family.trim().replace(/ /g, "+")}:wght@${weights}&display=swap`;
60
88
  }
@@ -115,7 +143,12 @@ export function applyBrand(html, brand) {
115
143
  for (const [k, v] of Object.entries(b.colors)) set(CSS_VAR[k], v);
116
144
  if (b.fonts.body) set("--font", fontStack(b.fonts.body, "body"));
117
145
  if (b.fonts.mono) set("--mono", fontStack(b.fonts.mono, "mono"));
118
- if (b.fonts.display) set("--display", fontStack(b.fonts.display, "display"));
146
+ if (b.fonts.display) {
147
+ set("--display", fontStack(b.fonts.display, "display"));
148
+ const face = displayFace(b.fonts.display);
149
+ if (face.weight) set("--display-weight", face.weight);
150
+ if (face.tracking) set("--display-tracking", face.tracking);
151
+ }
119
152
  let out = html.slice(0, root.index) + block + html.slice(root.index + root[0].length);
120
153
 
121
154
  const families = FONT_ROLES.filter((r) => b.fonts[r]).map((r) => [b.fonts[r], r]);
@@ -37,6 +37,7 @@ export const MESSAGES = {
37
37
  noLoading: () => "no loading state: run `hyperdecks check <deck> --fix` (`check <slug> --hosted --fix`; over the MCP, write index.html again with write_deck_source), which needs a <head>…</head> and the player script; or copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>; set --hd-ground to the first slide's colour",
38
38
  clipField: (id, field) => `clip ${id}: ${field}`,
39
39
  noCaptions: (id) => `clip ${id}: timeline has no captions`,
40
+ speakerMissing: (id, name, selector) => `slide ${id}: guest ${name} speaks from "${selector}", which is not on the slide, so its lines play with captions only and no face`,
40
41
  order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
41
42
  total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
42
43
  cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
@@ -62,6 +63,7 @@ export const MESSAGES = {
62
63
  gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
63
64
  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
65
  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`,
66
+ presenterCaptions: () => `the player tag plays captions only as written (data-presenter="none" or none set), so the file served as is (npx serve, python3 -m http.server, the screenshot pass) shows no face: run \`hyperdecks deck narrator <slug> <name>\` (or narrate again) to write the narrator's face into index.html, or watch it with \`hyperdecks play <deck>\`; release adds the face either way. Captions only on purpose: \`hyperdecks deck narrator <slug> none\``,
65
67
  presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
66
68
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
67
69
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
@@ -73,6 +75,9 @@ export const MESSAGES = {
73
75
  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
76
  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
77
  mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
78
+ retiredTexture: (where, cls, instead, inlined) => `${where}: ${cls} is a whole-slide texture the kit no longer ships ${inlined ? "(this deck still carries its rule, so it runs under the text)" : "(it ran under the text), so it paints nothing"}; ${instead} (deck_guide topic look)`,
79
+ tooManySignatures: (n) => `${n} slides carry a signature background (bg-corner or bg-mark); keep it to three at most: the cover, one opener or the ask, the closer (deck_guide topic look)`,
80
+ textureOff: (where, cls, layout) => `${where}: ${cls} does nothing on ${layout} (the shell switches it off there: nothing to back); remove it, or use bg-pool behind the picture (deck_guide topic look)`,
76
81
  // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
77
82
  placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
78
83
  // Warnings (check --layout): the deck still passes.
@@ -605,6 +610,23 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
605
610
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
606
611
  const isStr = (v) => typeof v === "string" && v.trim().length > 0;
607
612
 
613
+ // Whether a guest's selector names something on its slide: an #id, or a
614
+ // .class or [attr] inside that <section>. Other selectors are taken on trust.
615
+ export function speakerOnSlide(html, slideId, selector) {
616
+ const page = uncommented(html);
617
+ const open = new RegExp(`<section\\b[^>]*\\sid\\s*=\\s*["']${slideId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'][^>]*>`, "i").exec(page);
618
+ if (!open) return false;
619
+ const end = page.indexOf("</section>", open.index);
620
+ const section = page.slice(open.index, end < 0 ? undefined : end);
621
+ const id = /^#([\w-]+)$/.exec(selector.trim());
622
+ if (id) return new RegExp(`\\sid\\s*=\\s*["']${id[1]}["']`).test(section);
623
+ const cls = /^\.([\w-]+)$/.exec(selector.trim());
624
+ if (cls) return new RegExp(`\\sclass\\s*=\\s*["'][^"']*\\b${cls[1]}\\b`).test(section);
625
+ const attr = /^\[([\w-]+)/.exec(selector.trim());
626
+ if (attr) return new RegExp(`\\s${attr[1]}(\\s*=|[\\s>])`).test(section);
627
+ return true;
628
+ }
629
+
608
630
  // Fields every clip needs before its other rules mean anything.
609
631
  export function clipFieldErrors(clip, i) {
610
632
  const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
@@ -792,6 +814,44 @@ export function lookWarnings(html) {
792
814
  if (iconImg(tag)) sets.add("image icons");
793
815
  }
794
816
  if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
817
+ out.push(...textureWarnings(all, html));
818
+ return out;
819
+ }
820
+
821
+ // Backgrounds (the look topic): the kit's textures are confined to the object they
822
+ // back. The retired whole-slide textures warn rather than vanish silently (a deck made
823
+ // before keeps their rules in its own inlined shell, where they still paint). Read from each slide's class attribute; a texture on a layout whose shell
824
+ // rule switches it off (TEXTURE_OFF, pinned to _deck.html by lib/deckLayouts.test.ts) did nothing, and a custom
825
+ // slide (no l-* class) is never judged on that. Commented-out slides do not count.
826
+ const RETIRED_TEXTURES = {
827
+ "bg-rays": "use a plain ground, or bg-corner on the cover and the closer",
828
+ "bg-lines": "use a plain ground, or bg-pool behind the picture",
829
+ "bg-halftone": "use bg-corner on the cover and the closer",
830
+ "bg-dots": "use bg-stage under the mockup or the phones",
831
+ };
832
+ const SIGNATURES = ["bg-corner", "bg-mark"];
833
+ export const MAX_SIGNATURE_SLIDES = 3;
834
+ const BUSY_LAYOUTS = ["l-number", "l-features", "l-team", "l-timeline", "l-compare"]; // full-width content: nothing to back
835
+ export const TEXTURE_OFF = {
836
+ "bg-corner": ["l-split", "l-product", "l-chart", "l-quote", ...BUSY_LAYOUTS],
837
+ "bg-stage": ["l-split", "l-chart", "l-quote", "l-closer", ...BUSY_LAYOUTS],
838
+ "bg-mark": ["l-split", "l-product", "l-chart", "l-phones", ...BUSY_LAYOUTS],
839
+ };
840
+ function textureWarnings(all, html) {
841
+ const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
842
+ const inlined = (c) => new RegExp(`\\.${c}(?![\\w-])[^{}]*\\{`).test(css);
843
+ const out = [];
844
+ let signatures = 0;
845
+ all.forEach((s, i) => {
846
+ const where = `slide ${s.id || i + 1}`;
847
+ const cls = classes(s.tag);
848
+ for (const [c, instead] of Object.entries(RETIRED_TEXTURES)) if (cls.includes(c)) out.push(MESSAGES.retiredTexture(where, c, instead, inlined(c)));
849
+ if (SIGNATURES.some((c) => cls.includes(c))) signatures++;
850
+ const layout = cls.find((c) => /^l-[\w-]+$/.test(c));
851
+ if (!layout) return;
852
+ for (const [c, off] of Object.entries(TEXTURE_OFF)) if (cls.includes(c) && off.includes(layout)) out.push(MESSAGES.textureOff(where, c, layout));
853
+ });
854
+ if (signatures > MAX_SIGNATURE_SLIDES) out.push(MESSAGES.tooManySignatures(signatures));
795
855
  return out;
796
856
  }
797
857
 
@@ -937,12 +997,29 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
937
997
  continue;
938
998
  }
939
999
  errors.push(...timelineErrors(clip, timeline));
1000
+ // Guest lines ([speaker: …]): each is audio of its own, and each speaker needs its element on the slide.
1001
+ for (const [g, guest] of (Array.isArray(clip.guests) ? clip.guests : []).entries()) {
1002
+ let url;
1003
+ try { url = new URL(guest?.audio, player.manifest).href; } catch { errors.push(MESSAGES.clipField(clip.id, `guest line ${g + 1} needs an audio URL`)); continue; }
1004
+ const got = await fetchCheck(fetchImpl, url, AUDIO);
1005
+ if (got.why) errors.push(MESSAGES.url(clip.id, `guest line ${g + 1} audio`, url, got.why));
1006
+ else await got.response.body?.cancel?.().catch(() => {});
1007
+ }
1008
+ for (const sp of Array.isArray(clip.speakers) ? clip.speakers : []) {
1009
+ if (sp?.selector && !speakerOnSlide(html, clip.id, sp.selector)) warnings.push(MESSAGES.speakerMissing(clip.id, sp.name ?? "a guest", sp.selector));
1010
+ }
940
1011
  }
941
1012
  }
942
1013
  warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
943
1014
  // 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));
1015
+ // A hosted draft plays only through its draft link and release_deck, which add the face, so a plain none is no issue there.
1016
+ const found = silent ? null : presenterTagIssue(html);
1017
+ const tagIssue = found?.kind === "captions" && opts.hosted ? null : found;
1018
+ if (tagIssue) {
1019
+ warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter)
1020
+ : tagIssue.kind === "captions" ? MESSAGES.presenterCaptions()
1021
+ : MESSAGES.presenterUnfilled(tagIssue.presenter));
1022
+ }
946
1023
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
947
1024
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
948
1025
  if (opts.hosted) {