@hypersocial/decks-cli 0.9.9 → 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.9",
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
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
@@ -840,6 +863,10 @@ browser (an agent reads the URL; `--json` prints it as one line).
840
863
  `release --qa` is not a preview: it needs a commit, and it changes what every
841
864
  link to this deck plays.
842
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).
843
870
  For the layout pass, serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
844
871
  and screenshot it with Playwright at 1440×900 and 390×844, every slide
845
872
  (`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
@@ -90,6 +90,11 @@ makes the hello deck; continue from step 5 below.
90
90
  only, when nobody chose that, is refused: hand the person its Add narrator
91
91
  link, or offer D.A.S.H., and pass `captionsOnly: true` only if they say so.
92
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.
93
98
  10. Later: `list_shares` and `deck_stats` (both show the card),
94
99
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
95
100
  email as the deck's moving preview (it needs a recipient's link, not a QA
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]);
@@ -63,6 +63,7 @@ export const MESSAGES = {
63
63
  gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
64
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)`,
65
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\``,
66
67
  presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
67
68
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
68
69
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
@@ -74,6 +75,9 @@ export const MESSAGES = {
74
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)`,
75
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)`,
76
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)`,
77
81
  // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
78
82
  placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
79
83
  // Warnings (check --layout): the deck still passes.
@@ -810,6 +814,44 @@ export function lookWarnings(html) {
810
814
  if (iconImg(tag)) sets.add("image icons");
811
815
  }
812
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));
813
855
  return out;
814
856
  }
815
857
 
@@ -970,8 +1012,14 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
970
1012
  }
971
1013
  warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
972
1014
  // A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
973
- const tagIssue = silent ? null : presenterTagIssue(html);
974
- 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
+ }
975
1023
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
976
1024
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
977
1025
  if (opts.hosted) {
package/src/commands.mjs CHANGED
@@ -72,12 +72,23 @@ async function deckStats(pos, opts) {
72
72
  }
73
73
  }
74
74
 
75
+ // The owner link: present or check your own deck (untracked, owner-only).
76
+ function deckLink(pos, opts) {
77
+ const [slug] = pos;
78
+ if (!slug) throw new Error("usage: deck link <slug> [--json]");
79
+ const out = ops.ownerLink(opts, slug);
80
+ if (opts.json) return console.log(JSON.stringify(out, null, 2));
81
+ console.log(out.ownerUrl);
82
+ if (out.backupUrl) console.log(`backup (other domain): ${out.backupUrl}`);
83
+ console.log("Only you can open it, signed in; nothing is tracked. Present (or H) hides your owner tools for a screen share.");
84
+ }
85
+
75
86
  function deckOpen(pos, opts) {
76
87
  const [slug] = pos;
77
88
  if (!slug) throw new Error("usage: deck open <slug>");
78
- const url = `${target(opts).brokerBase}/d/${slug}`;
79
- console.log(url);
80
- openBrowser(url);
89
+ const { ownerUrl } = ops.ownerLink(opts, slug);
90
+ console.log(ownerUrl);
91
+ openBrowser(ownerUrl);
81
92
  }
82
93
 
83
94
  async function deckRemove(pos, opts) {
@@ -89,8 +100,11 @@ async function deckRemove(pos, opts) {
89
100
 
90
101
  async function shareNew(pos, opts) {
91
102
  const [slug, recipient] = pos;
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]");
103
+ const usage = "usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]";
104
+ if (!slug || !recipient) throw new Error(usage);
93
105
  if (opts["allow-forward"] && opts["no-forward"]) throw new Error("pick one of --allow-forward and --no-forward");
106
+ // The link's own pace; without it the link follows the deck's.
107
+ if ("pace" in opts && !ops.DECK_PACES.includes(opts.pace)) throw new Error(usage);
94
108
  const { token } = await ops.createShare(opts, {
95
109
  slug,
96
110
  recipient,
@@ -100,6 +114,7 @@ async function shareNew(pos, opts) {
100
114
  qa: opts.qa || undefined,
101
115
  notify: opts["no-notify"] ? false : undefined,
102
116
  allowForward: opts["allow-forward"] ? true : opts["no-forward"] ? false : undefined,
117
+ pace: opts.pace,
103
118
  expires: opts.expires,
104
119
  limit: opts.limit,
105
120
  });
@@ -123,7 +138,7 @@ async function shareList(pos, opts) {
123
138
  // Rows arrive with each link's forwards right after it; indent those.
124
139
  for (const r of rows) {
125
140
  const who = r.parentToken ? ` ↳ ${r.recipient} via ${r.forwardedBy ?? "?"}` : r.recipient;
126
- console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
141
+ console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.pace ? `\tpace:${r.pace}` : ""}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
127
142
  }
128
143
  if (!rows.length) console.log("(no shares)");
129
144
  }
@@ -348,7 +363,10 @@ commands:
348
363
  login [--token <pat>] [--broker <url>] [--dev]
349
364
  logout [--dev]
350
365
  whoami [--json] [--dev]
351
- deck list|stats|open|rm|forward|view
366
+ deck list|stats|open|link|rm|forward|view
367
+ deck link <slug> [--json]
368
+ the owner link: present or check your own deck (only you, signed in; untracked;
369
+ Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
352
370
  deck forward on|off <slug>
353
371
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
354
372
  deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
@@ -378,7 +396,7 @@ commands:
378
396
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
379
397
  on a committed deck: check → narrate → import → share new per --to
380
398
  share new|list|on|off|forward|mute|unmute|rm|url
381
- share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
399
+ share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]
382
400
  share forward on|off|default <token>
383
401
  let this link's viewer forward it as their own tracked link ("Dana via Peter",
384
402
  one hop); default follows the deck. share off on a link also turns off its forwards.
@@ -972,9 +990,20 @@ function commandTable(pos, rest, opts) {
972
990
  console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
973
991
  },
974
992
  "deck narrator": async () => {
975
- if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
993
+ if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default> [--dir <deck folder>]");
976
994
  const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
977
- console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
995
+ const log = opts.json ? (line) => console.error(line) : (line) => console.log(line);
996
+ log(`OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
997
+ // The local file names the face too, so the draft opened from disk shows it.
998
+ const { writeDraftFace } = await import("./draftFace.mjs");
999
+ const dir = resolve(typeof opts.dir === "string" ? opts.dir : join("decks", out.slug));
1000
+ let local = { wrote: false };
1001
+ try {
1002
+ local = await writeDraftFace(opts, out.slug, join(dir, "index.html"), { log });
1003
+ } catch (e) {
1004
+ log(`⚠ presenter: the server was updated but index.html was not (${e.message})`);
1005
+ }
1006
+ if (opts.json) console.log(JSON.stringify({ ...out, indexHtml: local.wrote ? join(dir, "index.html") : null }, null, 2));
978
1007
  },
979
1008
  "preview setup": async () => {
980
1009
  const { setupCapture } = await import("./preview-capture.mjs");
@@ -986,6 +1015,7 @@ function commandTable(pos, rest, opts) {
986
1015
  "deck list": () => deckList(opts),
987
1016
  "deck stats": () => deckStats(rest, opts),
988
1017
  "deck open": () => deckOpen(rest, opts),
1018
+ "deck link": () => deckLink(rest, opts),
989
1019
  "deck rm": () => deckRemove(rest, opts),
990
1020
  "deck forward": () => deckForward(rest, opts),
991
1021
  "deck view": () => deckView(rest, opts),
@@ -0,0 +1,56 @@
1
+ // The narrator's face written into a draft's index.html on disk (roadmap:
2
+ // a-narrated-draft-shows-its-narrator-s-face-from-the-file-on-), so the file
3
+ // plays with its face however it is opened, not only through play and release.
4
+ // deck new, deck narrator and narrate use it.
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import * as ops from "./ops.mjs";
7
+ import { DASH_PRESENTER, withNarrator, writeNarratorFace } from "./presenter.mjs";
8
+
9
+ // What to write for `slug`: { face } (null: captions only on purpose), or
10
+ // { face: undefined, why } when the narrator has no face and nobody chose captions
11
+ // only (writeDraftFace then takes a CLI-written face off), or the lookup failed
12
+ // (`failed`: nothing is decided, the file stays).
13
+ export async function draftFace(opts, slug) {
14
+ let found;
15
+ try {
16
+ found = await ops.presenterWithReason(opts, slug, undefined, { draft: true });
17
+ } catch (e) {
18
+ return { face: undefined, why: `could not look up the narrator's face (${e.message})`, failed: true };
19
+ }
20
+ if (found?.presenter) return { face: found.presenter, name: found.who?.face ?? null };
21
+ // Captions only on purpose only when the deck says so; faces off or a missing face stays unwritten.
22
+ if (!found?.presenter && found?.captionsOnly) return { face: null, name: null };
23
+ return { face: undefined, why: found?.who?.reason ?? "no narrator with a face" };
24
+ }
25
+
26
+ // Write `slug`'s face into `htmlPath`. `fallback`: the face to write when the
27
+ // lookup fails (deck new writes D.A.S.H.). Logs one line; returns what it wrote.
28
+ export async function writeDraftFace(opts, slug, htmlPath, { log = () => {}, fallback } = {}) {
29
+ if (!existsSync(htmlPath)) {
30
+ log(`⚠ presenter: ${htmlPath} not found, so the file was not updated (the server was); pass --dir <deck folder>`);
31
+ return { wrote: false };
32
+ }
33
+ const got = await draftFace(opts, slug);
34
+ const face = got.face === undefined && got.failed && fallback ? fallback : got.face;
35
+ const html = readFileSync(htmlPath, "utf8");
36
+ if (face === undefined) {
37
+ // The narrator has no face now: a face the CLI wrote for an earlier one comes off, as play and release do.
38
+ const off = got.failed ? html : withNarrator(html, null, { face: null });
39
+ if (off !== html) {
40
+ writeFileSync(htmlPath, off);
41
+ log(`⚠ presenter: no face (${got.why}); the earlier narrator's face was taken off index.html`);
42
+ return { wrote: true, face: null, why: got.why };
43
+ }
44
+ log(`⚠ presenter: index.html left as written: ${got.why}`);
45
+ return { wrote: false, why: got.why };
46
+ }
47
+ const out = writeNarratorFace(html, face);
48
+ if (!out.wrote) {
49
+ log(`⚠ presenter: index.html left as written: ${out.why}`);
50
+ return { wrote: false, why: out.why };
51
+ }
52
+ if (out.html !== html) writeFileSync(htmlPath, out.html);
53
+ const who = face === null ? "captions only" : face === DASH_PRESENTER && got.failed ? `D.A.S.H. (${got.why}; deck narrator <name> fixes it)` : got.name ?? "the narrator's face";
54
+ log(` presenter: ${who}, written to index.html`);
55
+ return { wrote: true, face };
56
+ }
package/src/narrate.mjs CHANGED
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { basename, join, resolve } from "node:path";
10
10
  import * as ops from "./ops.mjs";
11
11
  import { presenterLine } from "./presenter.mjs";
12
+ import { writeDraftFace } from "./draftFace.mjs";
12
13
  export { sectionIds, setManifestUrl } from "./checkRules.mjs";
13
14
  import { sectionIds, setManifestUrl } from "./checkRules.mjs";
14
15
 
@@ -39,7 +40,8 @@ export function narrationRequest(pos, opts) {
39
40
  };
40
41
  }
41
42
 
42
- export async function narrate(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
43
+ // writeFace false: leave the presenter as written (release narrates, then puts the face on the upload itself).
44
+ export async function narrate(pos, opts = {}, { log = (line) => console.log(line), writeFace = true } = {}) {
43
45
  const { deckDir, fields } = narrationRequest(pos, opts);
44
46
  if (opts["dry-run"]) {
45
47
  const preview = await ops.previewNarration(opts, fields);
@@ -85,6 +87,9 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
85
87
  writeFileSync(htmlPath, next);
86
88
  log(` index.html: data-manifest → ${job.manifestUrl}`);
87
89
  }
90
+ // The narrator's face too, so the file opened as written shows what play and release show.
91
+ // The narration is paid for and saved: a failed face write warns and never fails narrate.
92
+ if (writeFace) await writeDraftFace(opts, fields.slug, htmlPath, { log }).catch((e) => log(`⚠ presenter: index.html not updated (${e?.message ?? e})`));
88
93
  }
89
94
  log(`✓ ${job.manifest.clips.length} clips, ${job.manifest.spokenDuration}s spoken, ${job.manifest.totalDuration}s with holds; ${job.renderedChars} characters rendered`);
90
95
  log(` manifest: ${job.manifestUrl}\n saved: ${join(out, "manifest.json")}`);
package/src/ops.mjs CHANGED
@@ -3,7 +3,7 @@ import { zipSync } from "fflate";
3
3
  import { SPECIES } from "./species.mjs";
4
4
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
5
5
  import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
6
- import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
6
+ import { api, backupLinkUrl, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
7
7
  import {
8
8
  bytesFromText,
9
9
  isTextAsset,
@@ -21,6 +21,17 @@ export async function whoami(opts = {}) {
21
21
  }
22
22
 
23
23
  // Your own decks. `all: true` lists every deck, for admins only (support tools).
24
+ // The owner link (/d/<slug>, app/d/[slug]): the released deck for its owner
25
+ // only, untracked, with the owner toolbar (Present hides it for a screen
26
+ // share). How an owner or their agent presents or checks a deck; anyone else
27
+ // gets a sign-in notice. QA links are only for testing what a recipient sees.
28
+ // `backupUrl`: the same link on the other app host (null in dev).
29
+ export function ownerLink(opts, slug) {
30
+ if (!slug) throw new Error("slug is required");
31
+ const url = `${target(opts).brokerBase}/d/${encodeURIComponent(slug)}`;
32
+ return { slug, ownerUrl: url, backupUrl: backupLinkUrl(url) };
33
+ }
34
+
24
35
  export async function listDecks(opts = {}, { all = false } = {}) {
25
36
  const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
26
37
  return rows.map((d) => ({
@@ -39,6 +50,9 @@ export async function listDecks(opts = {}, { all = false } = {}) {
39
50
  // false: the deck runs its own player, so view/viewToggle do not apply
40
51
  // (null: not known yet, treated as on).
41
52
  usesPlayer: d.usesPlayer ?? null,
53
+ // Present or check it yourself here (owner-only, untracked); not a link to
54
+ // send. Null with nothing released, or on someone else's deck (admins' all).
55
+ ownerUrl: !all && (d.html || d.htmlStorageId || d.hasHtml) ? ownerLink(opts, d.slug).ownerUrl : null,
42
56
  }));
43
57
  }
44
58
 
@@ -568,6 +582,8 @@ export async function listShares(opts, slug) {
568
582
  // viewer who forwarded it. Rows come back with each link's forwards after it.
569
583
  allowForward: !!r.allowForward,
570
584
  allowForwardOverride: r.allowForwardOverride ?? null,
585
+ // The link's own pace (auto, slide, step); null = it follows the deck.
586
+ pace: r.pace ?? null,
571
587
  parentToken: r.parentToken ?? null,
572
588
  forwardedBy: r.forwardedBy ?? null,
573
589
  probablyForwarded: !!r.probablyForwarded,
@@ -584,6 +600,11 @@ export async function createShare(opts, fields) {
584
600
  if (fields.note) args.note = fields.note;
585
601
  if (fields.requireEmail) args.requireEmail = true;
586
602
  if (fields.allowForward !== undefined) args.allowForward = !!fields.allowForward;
603
+ // The link's own pace; absent = it follows the deck's.
604
+ if (fields.pace !== undefined) {
605
+ if (!DECK_PACES.includes(fields.pace)) throw new Error(`pace must be auto, slide or step (got ${JSON.stringify(fields.pace)})`);
606
+ args.pace = fields.pace;
607
+ }
587
608
  if (fields.expires) {
588
609
  const t = Date.parse(fields.expires);
589
610
  if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
@@ -1191,8 +1212,9 @@ export async function presenterFor(opts, slug, voice) {
1191
1212
  }
1192
1213
 
1193
1214
  // The same answer as presenterFor plus who presents in words: { presenter, who }.
1194
- export async function presenterWithReason(opts, slug, voice) {
1195
- return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}) });
1215
+ // `draft`: also answer for a deck not narrated yet, from the voice its narrate would use.
1216
+ export async function presenterWithReason(opts, slug, voice, { draft = false } = {}) {
1217
+ return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}), ...(draft ? { draft: true } : {}) });
1196
1218
  }
1197
1219
 
1198
1220
  // ---- narrators (convex/narrators.ts) ------------------------------------------
package/src/play.mjs CHANGED
@@ -12,7 +12,7 @@ import { basename, join, relative } from "node:path";
12
12
  import { playerScript, sectionIds } from "./checkRules.mjs";
13
13
  import { resolveDeckDir } from "./check.mjs";
14
14
  import * as ops from "./ops.mjs";
15
- import { playsCaptionsOnly, presenterLine, withAvatar } from "./presenter.mjs";
15
+ import { playsCaptionsOnly, presenterLine, withNarrator } from "./presenter.mjs";
16
16
  import { serveSource } from "./preview-capture.mjs";
17
17
  import { openBrowser } from "./runtime.mjs";
18
18
 
@@ -88,7 +88,7 @@ export async function startPlay(pos, opts = {}, { log = (line) => console.log(li
88
88
  const html = readFileSync(file, "utf8");
89
89
 
90
90
  const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
91
- const page = presenter ? withAvatar(html, presenter) : html;
91
+ const page = withNarrator(html, presenter, who);
92
92
  sayWhoPresents({ html, page, presenter, who, log });
93
93
  // --voice names the voice it was narrated in, so there is nothing to compare it to.
94
94
  if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
package/src/presenter.mjs CHANGED
@@ -3,8 +3,10 @@
3
3
  // captions-only (data-presenter="none"); at release, when the owner has an
4
4
  // avatar, its player tag gets that face instead. Shared by both releases
5
5
  // (convex/deckSources.ts, release.mjs), `hyperdecks play` and the /p draft
6
- // preview, so all rewrite the tag the same way; only a release stores it. A tag that already names a presenter (D.A.S.H.
7
- // on a character-voice deck, or one set by hand) is left alone.
6
+ // preview, so all rewrite the tag the same way; only a release stores it. A tag
7
+ // that already names a presenter by hand is left alone. deck new, deck narrator
8
+ // and narrate write the narrator's face into the file itself (writeNarratorFace),
9
+ // marked, so a draft on disk shows its face and release still follows the narrator.
8
10
 
9
11
  // D.A.S.H.'s face: the head doc and still that ship with the player. A deck
10
12
  // narrated in its character voice gets this face at release, even when the
@@ -59,36 +61,98 @@ export function playsCaptionsOnly(html) {
59
61
  return !!found && !tagFace(found.tag);
60
62
  }
61
63
 
64
+ // Who wrote the tag's face: data-presenter-from="narrator" is a face the CLI
65
+ // (deck new, deck narrator, narrate) wrote from the deck's narrator,
66
+ // so it follows the narrator at release and in play; "captions-only" is captions
67
+ // only on purpose (deck new --captions-only, deck narrator none). No marker: the
68
+ // tag is as its author wrote it, and a face there is hand-set. The player ignores it.
69
+ export const PRESENTER_FROM = "data-presenter-from";
70
+ const FACE_ATTRS = ["data-presenter", "data-avatar", "data-presenter-image", PRESENTER_FROM];
71
+ const attrRe = (name) => new RegExp(`\\s${name}\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\s"'=<>\`]+)`);
72
+
73
+ // The tag with its face attributes replaced by `face` (an ordered [name, value] list), in place of
74
+ // the first one it had (else before the closing >), leaving every other attribute as written.
75
+ function setFace(tag, face) {
76
+ const at = FACE_ATTRS.map((n) => attrRe(n).exec(tag)).filter(Boolean).sort((a, b) => a.index - b.index)[0];
77
+ let next = tag;
78
+ for (const n of FACE_ATTRS) next = next.replace(attrRe(n), at && attrRe(n).exec(tag)?.index === at.index ? "\u0000" : "");
79
+ const text = face.map(([n, v]) => ` ${n}="${attr(v)}"`).join("");
80
+ return next.includes("\u0000") ? next.replace("\u0000", text) : next.replace(/\s*\/?>$/, (end) => `${text}${end}`);
81
+ }
82
+
83
+ const replaceTag = (html, found, tag) => (tag === found.tag ? html : html.slice(0, found.index) + tag + html.slice(found.index + found.tag.length));
84
+
62
85
  // html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
63
86
  // the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
64
87
  // html unchanged when there is no such tag, the tag already shows a face, or it
65
88
  // names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
66
89
  // Anything else the player would play captions only (data-presenter none or
67
- // missing, head with no data-avatar, static with no still) gets the face.
90
+ // missing, head with no data-avatar, static with no still) gets the face. A face
91
+ // the CLI wrote from the narrator (data-presenter-from="narrator") is replaced by
92
+ // this one, so a narrator changed since follows; captions only on purpose stays.
68
93
  export function withAvatar(html, { avatarUrl, imageUrl }) {
69
94
  const found = playerTag(html);
70
95
  if (!found) return html;
71
- const { tag, index } = found;
96
+ const { tag } = found;
97
+ const from = value(tag, PRESENTER_FROM);
98
+ if (from === "captions-only") return html;
99
+ if (from === "narrator") return replaceTag(html, found, setFace(tag, narratorFace({ avatarUrl, imageUrl })));
72
100
  if (tagFace(tag) || value(tag, "data-avatar")) return html;
73
101
  const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
74
102
  const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
75
- const named = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
103
+ const named = attrRe("data-presenter");
76
104
  const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
77
- return html.slice(0, index) + next + html.slice(index + tag.length);
105
+ return replaceTag(html, found, next);
106
+ }
107
+
108
+ // The page as its narrator presents it, for play, /p and both releases: the
109
+ // resolved face put on (withAvatar), or, when the narration is on record and
110
+ // resolves to no face (`who.face` null: narrator none, faces off, a face
111
+ // removed), a face the CLI wrote from an earlier narrator taken off again, so
112
+ // it never shows someone who no longer presents. A hand-set face is kept.
113
+ export function withNarrator(html, presenter, who) {
114
+ if (presenter) return withAvatar(html, presenter);
115
+ const found = playerTag(html);
116
+ if (!who || who.face || !found || value(found.tag, PRESENTER_FROM) !== "narrator") return html;
117
+ return replaceTag(html, found, setFace(found.tag, [["data-presenter", "none"], [PRESENTER_FROM, "narrator"]]));
118
+ }
119
+
120
+ const narratorFace = ({ avatarUrl, imageUrl }) => [
121
+ ["data-presenter", "head"], ["data-avatar", avatarUrl], ...(imageUrl ? [["data-presenter-image", imageUrl]] : []), [PRESENTER_FROM, "narrator"],
122
+ ];
123
+
124
+ // Write the narrator's face into the page as a file on disk plays it: `face`
125
+ // ({ avatarUrl, imageUrl }) as a marked head, or null for captions only on
126
+ // purpose. For deck new, deck narrator and narrate, so a draft opened with any
127
+ // static server shows what play and release show. A face set by hand (a face or
128
+ // data-avatar with no marker) is left alone. Returns { html, wrote }, and with
129
+ // wrote false a `why`.
130
+ export function writeNarratorFace(html, face) {
131
+ const found = playerTag(html);
132
+ if (!found) return { html, wrote: false, why: "the page has no live player tag" };
133
+ const { tag } = found;
134
+ if (!value(tag, PRESENTER_FROM) && (tagFace(tag) || value(tag, "data-avatar"))) {
135
+ return { html, wrote: false, why: "the player tag names a face of its own (set by hand), so it is left as written" };
136
+ }
137
+ const next = setFace(tag, face ? narratorFace(face) : [["data-presenter", "none"], [PRESENTER_FROM, "captions-only"]]);
138
+ return { html: replaceTag(html, found, next), wrote: true };
78
139
  }
79
140
 
80
141
  // What is wrong with the player tag's presenter as written, for check: null, or
81
142
  // { kind, presenter }. "unfilled": it asks for a face (head or static) but names
82
143
  // none, so it plays captions only until release fills it in. "kept": it has a
83
144
  // data-avatar the player ignores (data-presenter="none"), and release leaves it.
84
- // A plain "none" or no data-presenter is not an issue: release adds the face.
145
+ // "captions": data-presenter none or missing, so a file opened as written (a
146
+ // static server, the screenshot pass) plays captions only though release adds a
147
+ // face. Captions only on purpose (data-presenter-from="captions-only") is not an issue.
85
148
  export function presenterTagIssue(html) {
86
149
  const found = playerTag(html);
87
150
  if (!found || tagFace(found.tag)) return null;
88
151
  const presenter = value(found.tag, "data-presenter");
89
152
  if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
90
153
  if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
91
- return null;
154
+ if (value(found.tag, PRESENTER_FROM) === "captions-only") return null;
155
+ return { kind: "captions", presenter };
92
156
  }
93
157
 
94
158
  // Why a resolved face did not land on the page: withAvatar leaves a tag with
@@ -109,7 +173,7 @@ function keptOutReason(html) {
109
173
  // Both releases use it: release.mjs and convex/deckSources.ts.
110
174
  export const TAG_FACE = "the face set on the player tag";
111
175
  export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
112
- const page = presenter ? withAvatar(html, presenter) : html;
176
+ const page = withNarrator(html, presenter, who);
113
177
  const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
114
178
  const shown = presentedFace(page);
115
179
  if (shown) {
package/src/release.mjs CHANGED
@@ -17,7 +17,7 @@ import { readFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { zipSync } from "fflate";
19
19
  import * as ops from "./ops.mjs";
20
- import { releasedPresenter, withAvatar } from "./presenter.mjs";
20
+ import { releasedPresenter, withNarrator } from "./presenter.mjs";
21
21
  import { linkBase } from "./runtime.mjs";
22
22
  import { checkDeck, deckFiles, deckFolder, deckPathResolver, resolveDeckDir } from "./check.mjs";
23
23
  import { mediaRefs, withMediaPreviews } from "./mediaPreviews.mjs";
@@ -56,15 +56,16 @@ export function deckCommit(deckDir) {
56
56
  // turn (deckFiles). `added` maps each bundled script or stylesheet to the files
57
57
  // it brought in, so release can say what came from where.
58
58
  // `presenter` (from avatars.presenterFor) puts the owner's avatar on the
59
- // uploaded page's player tag; the committed index.html is never changed.
59
+ // uploaded page's player tag; the committed index.html is never changed. `who`
60
+ // (narrate's presenter summary) takes a CLI-written face off when nobody presents now.
60
61
  // `media` (from mediaPreviewsFor) writes the blur previews onto the uploaded
61
62
  // page and adds the posters it made; like the avatar, never to the commit.
62
- export function releaseBundle(deckDir, presenter = null, media = null) {
63
+ export function releaseBundle(deckDir, presenter = null, media = null, who = null) {
63
64
  const raw = readFileSync(join(deckDir, "index.html"));
64
- const page = presenter ? withAvatar(raw.toString("utf8"), presenter) : raw.toString("utf8");
65
+ const page = withNarrator(raw.toString("utf8"), presenter, who);
65
66
  // check has already refused refs that leave the folder or do not resolve.
66
67
  const found = deckFiles(page, deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
67
- const html = media ? Buffer.from(withMediaPreviews(page, media.previews).html) : presenter ? Buffer.from(page) : raw;
68
+ const html = media ? Buffer.from(withMediaPreviews(page, media.previews).html) : page !== raw.toString("utf8") ? Buffer.from(page) : raw;
68
69
  const made = Object.entries(media?.files ?? {});
69
70
  const added = {};
70
71
  for (const f of found) if (f.from !== "html") (added[f.from] ??= []).push(f.path);
@@ -174,7 +175,7 @@ export async function release(pos, opts = {}, io = {}) {
174
175
  const narrateOpts = { ...opts, slug };
175
176
  for (const k of ["slide", "json", "dry-run", "to", "email", "recipients"]) delete narrateOpts[k];
176
177
  if (narrateOpts.voice === undefined) delete narrateOpts.voice;
177
- const job = await narrate([deckDir], narrateOpts, { log });
178
+ const job = await narrate([deckDir], narrateOpts, { log, writeFace: false });
178
179
  if (!job?.manifestUrl) throw new Error(`narration finished without a manifest (${job?.notice || "no manifest URL"}); fix it and release again`);
179
180
  const after = changes(deckDir);
180
181
  if (after) {
@@ -189,7 +190,7 @@ export async function release(pos, opts = {}, io = {}) {
189
190
  } catch (e) {
190
191
  lookupError = ops.errorText(e);
191
192
  }
192
- const released = releaseBundle(deckDir, presenter, media);
193
+ const released = releaseBundle(deckDir, presenter, media, job.presenter);
193
194
  // Say who presents from the page that ships, never from the lookup alone: a
194
195
  // resolved face that did not land on the player tag must not be claimed, and
195
196
  // a failed lookup is captions only only when the tag names no face of its own.
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.9";
11
+ export const VERSION = "0.9.10";
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
package/src/scaffold.mjs CHANGED
@@ -1,21 +1,25 @@
1
- // hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,…] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
1
+ // hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,…] [--presenter-style stage|bubble|glass|corner] [--captions-only] [--dir <path>]
2
2
  //
3
3
  // Writes decks/<slug>/ (or --dir) from the packaged templates: index.html (eight
4
4
  // slides for the hosted player/v1, or one slide per --layouts entry from
5
5
  // templates/layouts/), script.md (the narration) and evidence.md (where each
6
6
  // fact comes from), then registers the deck as a draft you own so narrate can
7
- // render into it.
8
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7
+ // render into it. The player tag gets the narrator's face (the account's default
8
+ // narrator, else D.A.S.H.), so the file plays with a face however it is opened;
9
+ // --captions-only writes data-presenter="none" on purpose instead.
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
11
  import { join, resolve } from "node:path";
10
12
  import * as ops from "./ops.mjs";
11
13
  import { PRESENTER_STYLES } from "./checkRules.mjs";
12
14
  import { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts } from "./layouts.mjs";
13
15
  import { applyBrand, brandForNewDeck, brandSummary } from "./brand.mjs";
16
+ import { writeDraftFace } from "./draftFace.mjs";
17
+ import { DASH_PRESENTER, writeNarratorFace } from "./presenter.mjs";
14
18
 
15
19
  export { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts, readLayout } from "./layouts.mjs";
16
20
 
17
21
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
18
- const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--no-brand] [--dir <path>]';
22
+ const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--captions-only] [--no-brand] [--dir <path>]';
19
23
 
20
24
  export async function deckNew(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
21
25
  const [slug, title] = pos;
@@ -46,7 +50,24 @@ export async function deckNew(pos, opts = {}, { log = (line) => console.log(line
46
50
  rmSync(dir, { recursive: true, force: true });
47
51
  throw new Error(`deck ${slug} is registered but its files could not be written (${e.message}); remove the draft with \`hyperdecks deck rm ${slug}\` and run deck new again`);
48
52
  }
53
+ // The face goes into the file now the deck is registered (the server answers whose).
54
+ const htmlPath = join(/*turbopackIgnore: true*/ dir, "index.html");
55
+ const lines = [];
56
+ let presenter;
57
+ if (opts["captions-only"]) {
58
+ writeFileSync(htmlPath, writeNarratorFace(readFileSync(htmlPath, "utf8"), null).html);
59
+ // The server too, so release, play and /p agree the deck has no face.
60
+ try {
61
+ await ops.setDeckNarrator(opts, slug, "none");
62
+ lines.push(" presenter: captions only (--captions-only), written to index.html and set on the deck");
63
+ } catch (e) {
64
+ lines.push(`⚠ presenter: captions only written to index.html, but the deck could not be set to none (${e.message}): run \`hyperdecks deck narrator ${slug} none\``);
65
+ }
66
+ presenter = { face: null };
67
+ } else {
68
+ presenter = await writeDraftFace(opts, slug, htmlPath, { log: (line) => lines.push(line), fallback: DASH_PRESENTER });
69
+ }
49
70
  const summary = brandSummary(brand, readError);
50
- log(`✓ created deck "${slug}" → ${dir}\n files: ${Object.keys(NARRATED_FILES).join(", ")}\n brand: ${summary.error ? `could not read it (${summary.error}); template look` : summary.applied.length ? summary.applied.join(", ") : "none applied (hyperdecks brand set)"}\n next: hyperdecks narrate ${dir} --dry-run`);
51
- return { slug, title, dir, files: Object.keys(NARRATED_FILES), brand: summary };
71
+ log(`✓ created deck "${slug}" → ${dir}\n files: ${Object.keys(NARRATED_FILES).join(", ")}\n brand: ${summary.error ? `could not read it (${summary.error}); template look` : summary.applied.length ? summary.applied.join(", ") : "none applied (hyperdecks brand set)"}\n${lines.map((l) => (l.startsWith(" ") ? l : ` ${l}`)).join("\n")}\n next: hyperdecks narrate ${dir} --dry-run`);
72
+ return { slug, title, dir, files: Object.keys(NARRATED_FILES), brand: summary, presenter: presenter.face ?? null };
52
73
  }
@@ -55,20 +55,26 @@
55
55
  --font: "Space Grotesk", system-ui, sans-serif; --mono: "Space Mono", ui-monospace, monospace;
56
56
  /* The type scale: one display size per slide at most, then title, sub (a title beside a visual), lead, body, label. */
57
57
  --t-display: 128px; --t-title: 84px; --t-sub: 56px; --t-lead: 30px; --t-body: 24px; --t-label: 17px;
58
+ /* The text colour on an accent ground. A brand with a dark accent sets it to its paper. */
59
+ --accent-ink: var(--ink);
58
60
  --hd-ground: var(--accent); --hd-ground-ink: var(--ink); /* the loading state: the first slide's colours */
59
61
  /* Soft grounds (on-tint, on-tint-2, on-tint-3): washes of the accents, so slide changes read without going loud. */
60
62
  --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));
61
63
  }
62
64
  * { box-sizing: border-box; margin: 0; padding: 0; }
63
65
  body { color: var(--ink); font-family: var(--font); font-weight: 500; -webkit-font-smoothing: antialiased; --hd-font: var(--font); --hd-mono: var(--mono); }
64
- .slide { display: flex; flex-direction: column; gap: 24px; padding: 64px 72px 56px; background: var(--paper); color: var(--ink); --hd-speech: #f3c9dc; }
65
- .slide.on-accent { background: var(--accent); --hd-speech: var(--card); }
66
+ /* The caption bubble is always a fixed light fill with dark ink, so it reads whatever the
67
+ brand's --card or --ink (a dark brand's card is dark). A slide can still set its own. */
68
+ .slide { display: flex; flex-direction: column; gap: 24px; padding: 64px 72px 56px; background: var(--paper); color: var(--ink); --hd-speech: #f3c9dc; --hd-speech-ink: #0b0b0f; }
69
+ .slide.on-accent { background: var(--accent); color: var(--accent-ink); --hd-speech: #ffffff; }
66
70
  .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); }
71
+ .slide.on-tint { background: var(--tint-1); --hd-speech: #ffffff; }
72
+ .slide.on-tint-2 { background: var(--tint-2); --hd-speech: #ffffff; }
73
+ .slide.on-tint-3 { background: var(--tint-3); --hd-speech: #ffffff; }
70
74
  .kicker { font: var(--t-label) var(--mono); letter-spacing: .16em; text-transform: uppercase; }
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; }
75
+ /* Titles take the display face's weight and tracking (brand.mjs sets them for a face the
76
+ template was not tuned for) and never fake a weight the face lacks. */
77
+ .tt { font-family: var(--display, var(--font)); font-weight: var(--display-weight, 700); font-synthesis: none; font-size: var(--t-title); line-height: .95; letter-spacing: var(--display-tracking, -.045em); text-wrap: balance; }
72
78
  .lead { font-size: var(--t-lead); line-height: 1.3; max-width: 28em; text-wrap: pretty; }
73
79
  .muted { opacity: .65; }
74
80
  /* The presenter glides to each slide's slot: bottom-right by default,
@@ -98,26 +104,57 @@
98
104
  linear-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0 0) 14% 67% / 44% 2.4% no-repeat,
99
105
  linear-gradient(var(--accent) 0 0) 50% 90% / 84% 6% no-repeat,
100
106
  var(--paper); }
101
- /* Backgrounds, optional and for a few slides a deck. bg-dots on the section:
102
- a dot grid in the slide's text colour, faded in from the side away from the text
103
- (--dots-fade is the fade's direction: 90deg puts the dots on the right,
104
- 270deg on the left). A <div class="deco" aria-hidden="true"> in the slide
105
- holds small inked tiles, each a <span> placed by its inline left/top (and
106
- background, rotate). Both sit behind the slide's content. */
107
- .slide.bg-dots, .slide:has(> .deco) { isolation: isolate; }
108
- .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%); }
107
+ /* Decorations: a <div class="deco" aria-hidden="true"> in the slide holds small
108
+ inked tiles, each a <span> placed by its inline left/top (and background, rotate),
109
+ behind the slide's content. */
110
+ .slide:has(> .deco) { isolation: isolate; }
109
111
  .slide > .deco { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
110
112
  .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); }
111
- /* The look kit (deck_guide topic look): one texture per deck, one icon set, real pictures.
112
- Textures go on the section, behind the content, on a few slides: bg-lines, diagonal
113
- speed lines fading out towards the text (--fade-at: where they are strongest, default
114
- the right); bg-halftone, a corner of halftone dots (--fade-at, default top right);
115
- bg-rays, rays from a point (--rays-at), for an opener or a closer. */
116
- .slide:is(.bg-lines, .bg-halftone, .bg-rays) { isolation: isolate; overflow: hidden; }
117
- .slide:is(.bg-lines, .bg-halftone, .bg-rays)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; }
118
- .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%); }
119
- .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%); }
120
- .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%); }
113
+ /* Backgrounds (deck_guide topic look). Four classes on the section, tokens only, each painted
114
+ behind the content (a ::before at z-index -1 in an isolated slide) and confined to the object
115
+ it backs, never under the text. Each carries its own per-layout rules (.l-<layout>.bg-<name>),
116
+ so on a layout with nothing to back it paints nothing. Plain grounds come first; one signature
117
+ (bg-corner or bg-mark) per deck on three slides at most; bg-pool and bg-stage only on a slide
118
+ with a picture. A deck may add its own in its <style> under the same contract. */
119
+ .slide:is(.bg-pool, .bg-corner, .bg-stage, .bg-mark) { isolation: isolate; overflow: hidden; }
120
+ .slide:is(.bg-pool, .bg-corner, .bg-stage, .bg-mark)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; }
121
+ /* bg-pool: one soft light of the next accent (accent-2 on paper, accent on a tint, a screened
122
+ accent-3 on an accent ground, accent on ink), placed per layout as the spotlight of the picture. */
123
+ .slide.bg-pool { --pool: color-mix(in srgb, var(--accent-2) 34%, transparent); }
124
+ .slide:is(.on-tint, .on-tint-2, .on-tint-3).bg-pool { --pool: color-mix(in srgb, var(--accent) 38%, transparent); }
125
+ .slide.on-accent.bg-pool { --pool: color-mix(in srgb, var(--accent-3, var(--accent-2)) 55%, transparent); }
126
+ .slide.on-accent.bg-pool::before { mix-blend-mode: screen; }
127
+ .slide.on-ink.bg-pool { --pool: color-mix(in srgb, var(--accent) 32%, transparent); }
128
+ .slide.bg-pool::before { background: radial-gradient(var(--pw, 760px) var(--ph, 600px) at var(--px, 82%) var(--py, 28%), var(--pool), transparent 70%); }
129
+ .l-hero.bg-pool { --px: 82%; --py: 28%; --pw: 760px; --ph: 600px; }
130
+ .l-split.bg-pool { --px: 73%; --py: 50%; --pw: 900px; --ph: 860px; }
131
+ .l-product.bg-pool { --px: 34%; --py: 50%; --pw: 1180px; --ph: 860px; }
132
+ .l-phones.bg-pool { --px: 68%; --py: 50%; --pw: 900px; --ph: 760px; }
133
+ .l-chart.bg-pool { --px: 84%; --py: 40%; --pw: 640px; --ph: 520px; }
134
+ .l-quote.bg-pool { --px: 10%; --py: 14%; --pw: 760px; --ph: 560px; }
135
+ .l-closer.bg-pool { --px: 90%; --py: 4%; --pw: 820px; --ph: 640px; }
136
+ /* bg-corner: a hard-edged quarter-disc of halftone, top-right, behind the stickers. A signature: cover, closer. */
137
+ .slide.bg-corner::before {
138
+ --corner-r: 580px; --corner-dot: color-mix(in srgb, currentColor var(--corner-a, 15%), transparent);
139
+ inset: auto; right: 0; top: 0; width: var(--corner-r); height: var(--corner-r); border-bottom-left-radius: 100%;
140
+ background: radial-gradient(var(--corner-dot) 2.8px, transparent 3.3px) 0 0 / 20px 20px;
141
+ -webkit-mask-image: radial-gradient(circle at 100% 0, #000 45%, rgba(0,0,0,.4) 100%); mask-image: radial-gradient(circle at 100% 0, #000 45%, rgba(0,0,0,.4) 100%); }
142
+ .l-closer.bg-corner::before { --corner-r: 460px; }
143
+ .on-ink.bg-corner::before { --corner-a: 22%; }
144
+ :is(.l-split, .l-product, .l-chart, .l-quote, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-corner::before { content: none; }
145
+ /* bg-stage: a rounded dot plate exactly the picture's column plus 32px, so the frame or the phones stand on it; on the hero a tile behind the stickers. */
146
+ .slide.bg-stage::before { inset: auto; background: radial-gradient(color-mix(in srgb, currentColor var(--stage-a, 24%), transparent) 2.4px, transparent 2.9px) 0 0 / 22px 22px; border-radius: 40px; }
147
+ .l-product.bg-stage::before { position: relative; grid-area: frame; align-self: stretch; margin: var(--stage-m, -32px -24px -32px -40px); }
148
+ .l-phones.bg-stage::before { position: relative; grid-area: row; align-self: stretch; margin: var(--stage-m, -32px); }
149
+ .l-hero.bg-stage::before { right: 110px; top: 110px; width: 410px; height: 350px; }
150
+ :is(.l-split, .l-chart, .l-quote, .l-closer, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-stage::before { content: none; }
151
+ /* bg-mark: the slide number, set as style="--mark:'04'" on the section, huge in --display, faint,
152
+ cropped by the top edge; right on the hero, in the indent on slot-left slides. A signature: cover, an opener, closer. */
153
+ .slide.bg-mark::before { content: var(--mark, ""); inset: auto; right: 24px; top: -.26em; font: var(--display-weight, 700) var(--mark-size, 560px)/1 var(--display, var(--font)); font-synthesis: none; letter-spacing: var(--display-tracking, -.05em); white-space: nowrap;
154
+ color: color-mix(in srgb, currentColor var(--mark-a, 8%), transparent); }
155
+ .slide.on-ink.bg-mark::before { --mark-a: 6%; }
156
+ .slide.slot-left.bg-mark::before { right: auto; left: -.08em; font-size: var(--mark-size, 440px); } /* ends inside the 500px indent, clear of the title */
157
+ :is(.l-split, .l-product, .l-chart, .l-phones, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-mark::before { content: none; }
121
158
  /* Pieces. .sticker: a logo or icon tile, tilted (--tilt), ink edge, hard offset
122
159
  shadow (.sticker.photo for a face or an app icon that fills it). .ticks: a short
123
160
  list with accent-square bullets. .panel: a quiet card. .pill: a small tag. */
@@ -128,10 +165,10 @@
128
165
  .ticks { list-style: none; display: flex; flex-direction: column; gap: 14px; }
129
166
  .ticks li { position: relative; padding-left: 34px; font-size: var(--t-body); line-height: 1.3; font-weight: 600; }
130
167
  .ticks li::before { content: ""; position: absolute; left: 0; top: .4em; width: 16px; height: 16px; border-radius: 5px; background: var(--accent); }
131
- .on-accent .ticks li::before { background: var(--ink); }
168
+ .on-accent .ticks li::before { background: var(--accent-ink); }
132
169
  .panel { padding: 24px 28px; border-radius: 22px; background: color-mix(in srgb, currentColor 6%, transparent); }
133
170
  .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); }
134
- .on-accent .pill { box-shadow: 4px 4px 0 var(--ink); }
171
+ .on-accent .pill { box-shadow: 4px 4px 0 var(--accent-ink); }
135
172
  /* A laptop for a desktop screenshot or clip: a .frame.laptop holding an img.shot or video.shot. */
136
173
  .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); }
137
174
  .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); }
@@ -145,12 +182,12 @@
145
182
  :root.hd-no-presenter .slide { padding-bottom: 28px; }
146
183
  .slide .frame.card { width: 160px; padding: 5px; border-width: 2px; border-radius: 24px; box-shadow: 8px 8px 0 var(--shadow); }
147
184
  .slide .frame.card > .shot { border-radius: 19px; border-width: 1px; }
148
- /* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
149
- .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%); }
185
+ /* The text fills the card's width, so the backgrounds keep off it: the corner fades in
186
+ towards the bottom, under the presenter, at a finer pitch; the stage and the mark hide;
187
+ the pool (a soft light) stays as it is; the tiles stay off. */
188
+ .slide.bg-corner::before { inset: 0; width: auto; height: auto; border-radius: 0; background-size: 14px 14px; background-image: radial-gradient(var(--corner-dot) 1.6px, transparent 2px); -webkit-mask-image: linear-gradient(180deg, transparent 40%, #000 90%); mask-image: linear-gradient(180deg, transparent 40%, #000 90%); }
189
+ .slide:is(.bg-stage, .bg-mark)::before { content: none; }
150
190
  .slide > .deco { display: none; }
151
- /* Textures fade in towards the bottom, under the presenter, away from the text. */
152
- .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%); }
153
- .slide.bg-halftone::before { background-size: 14px 14px; background-image: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 1.6px, transparent 2px); }
154
191
  .sticker { width: 48px; height: 48px; padding: 9px; border-width: 2px; border-radius: 13px; box-shadow: 3px 3px 0 var(--ink); }
155
192
  .ticks { gap: 6px; }
156
193
  .ticks li { padding-left: 18px; }
@@ -175,7 +212,9 @@
175
212
 
176
213
  <!-- `hyperdecks narrate decks/__SLUG__` fills data-manifest with the manifest
177
214
  URL it writes. data-presenter: head (live Minimoji, needs data-avatar),
178
- static (data-presenter-image) or none (captions only). -->
215
+ static (data-presenter-image) or none (captions only). deck new, deck
216
+ narrator and narrate write the narrator's face here
217
+ (data-presenter-from="narrator", so release follows a later narrator). -->
179
218
  <script type="module" src="https://hyperdecks.ai/player/v1/player.js"
180
219
  data-manifest="" data-stage="1600x900" data-presenter="none" data-presenter-style="__PRESENTER_STYLE__"></script>
181
220
  </body>
@@ -11,7 +11,7 @@ script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [g
11
11
  <style>
12
12
  .l-closer .content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 32px; padding-left: 500px; }
13
13
  .l-closer .tt { font-size: var(--t-display); line-height: .9; }
14
- .l-closer .cta { display: inline-flex; align-items: center; gap: 16px; width: fit-content; max-width: 100%; padding: 22px 34px; border-radius: 22px; background: var(--accent); color: var(--ink); font: 700 34px var(--mono); letter-spacing: -.01em; text-decoration: none; }
14
+ .l-closer .cta { display: inline-flex; align-items: center; gap: 16px; width: fit-content; max-width: 100%; padding: 22px 34px; border-radius: 22px; background: var(--accent); color: var(--accent-ink); font: 700 34px var(--mono); letter-spacing: -.01em; text-decoration: none; }
15
15
  .l-closer .contact { font: var(--t-label) var(--mono); letter-spacing: .12em; text-transform: uppercase; }
16
16
  @media (max-width: 650px) {
17
17
  .l-closer .content { padding-left: 0; justify-content: flex-start; gap: 16px; }
@@ -1,6 +1,6 @@
1
1
  <!--
2
2
  layout: phones
3
- summary: A fanned row of two to four phone cards (the inner ones larger and in front, the outer ones tilted out), the point in the column beside it.
3
+ summary: A fanned row of two to four phone cards (the inner ones larger and in front, the outer ones tilted out), the point in the column beside it. The row stands on a dot plate (bg-stage).
4
4
  use: a phone app's flow or its key screens. Each card's .shot is a placeholder check fails on: put in a phone-size screenshot (`hyperdecks screenshot <url> --width 390 --height 844`, capture_url) as <img class="shot">, or a screen recording as <video class="shot" autoplay muted loop playsinline>. Two to four cards: the row spaces and tilts them (on phones only the first three show); change one card's --tilt or --shadow on it. One phone only: use product with .frame.card.
5
5
  slot: bottom-left
6
6
  presenter: stage
@@ -18,7 +18,7 @@ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__
18
18
  .l-phones .row { grid-area: row; display: flex; justify-content: center; align-items: center; padding: 0 0 40px; }
19
19
  .l-phones .row > .frame.card { --tilt: 0deg; flex: none; width: 260px; margin: 0 -14px; }
20
20
  .l-phones .row > .frame.card:first-child { --tilt: -7deg; --shadow: var(--accent-2); translate: 0 36px; }
21
- .l-phones .row > .frame.card:last-child { --tilt: 7deg; --shadow: var(--accent-3); translate: 0 36px; }
21
+ .l-phones .row > .frame.card:last-child { --tilt: 7deg; --shadow: var(--accent-3, var(--accent-2)); translate: 0 36px; }
22
22
  .l-phones .row > .frame.card:not(:first-child, :last-child) { width: 300px; z-index: 1; }
23
23
  .l-phones .row:has(> :nth-child(4)) > .frame.card { width: 220px; }
24
24
  .l-phones .row:has(> :nth-child(4)) > .frame.card:not(:first-child, :last-child) { width: 250px; }
@@ -31,14 +31,14 @@ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__
31
31
  .l-phones .row > .frame.card:is(:first-child, :last-child) { translate: 0 16px; }
32
32
  /* Four do not fit the phone card: the row keeps the first three, the third as its end. */
33
33
  .l-phones .row > .frame.card:nth-child(n + 4) { display: none; }
34
- .l-phones .row:has(> :nth-child(4)) > .frame.card:nth-child(3) { --tilt: 7deg; --shadow: var(--accent-3); width: 108px; translate: 0 16px; }
34
+ .l-phones .row:has(> :nth-child(4)) > .frame.card:nth-child(3) { --tilt: 7deg; --shadow: var(--accent-3, var(--accent-2)); width: 108px; translate: 0 16px; }
35
35
  }
36
36
  /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
37
37
  @media (min-width: 651px) {
38
38
  :root.hd-no-presenter .l-phones .text { align-self: center; }
39
39
  }
40
40
  </style>
41
- <section class="slide l-phones slot-left bg-dots" id="__ID__" data-chapter="On a phone" data-note="Phone screens: name the app build and the date they were captured.">
41
+ <section class="slide l-phones slot-left bg-stage" id="__ID__" data-chapter="On a phone" data-note="Phone screens: name the app build and the date they were captured.">
42
42
  <div class="text">
43
43
  <div class="kicker">On a phone</div>
44
44
  <h2 class="tt" data-title-mobile="Three taps.">Three taps, start to finish.</h2>
@@ -53,7 +53,7 @@ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__
53
53
  <div class="deco" aria-hidden="true">
54
54
  <span style="left: 52%; top: 8%; background: var(--accent); rotate: -10deg;">★</span>
55
55
  <span style="left: 92%; top: 42%; background: var(--accent-2); rotate: 12deg;">♪</span>
56
- <span style="left: 48%; top: 78%; background: var(--accent-3); rotate: 8deg;">☺</span>
56
+ <span style="left: 48%; top: 78%; background: var(--accent-3, var(--accent-2)); rotate: 8deg;">☺</span>
57
57
  </div> -->
58
58
  <div class="narration-slot"></div>
59
59
  </section>
@@ -1,6 +1,6 @@
1
1
  <!--
2
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.
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 (the picture is the background; add bg-pool to light it).
4
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
5
  slot: bottom-left
6
6
  presenter: stage
@@ -32,7 +32,7 @@ script: Here it is, doing the job. [look: #__ID__-frame] It starts with [go: #__
32
32
  :root.hd-no-presenter .l-split .text { align-self: center; padding-top: 0; }
33
33
  }
34
34
  </style>
35
- <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.">
35
+ <section class="slide l-split slot-left" id="__ID__" data-chapter="The product" data-note="Picture: name the screen or clip and the date it was captured.">
36
36
  <div class="text">
37
37
  <div class="head">
38
38
  <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>
@@ -54,7 +54,7 @@
54
54
  .slide { display: flex; flex-direction: column; gap: 28px; padding: 64px 72px 56px; background: var(--paper); }
55
55
  .slide.accent { background: var(--accent); }
56
56
  .kicker { font: 18px var(--mono); letter-spacing: .16em; text-transform: uppercase; }
57
- .tt { font-family: var(--display, var(--font)); font-size: 104px; line-height: .95; letter-spacing: -.045em; margin: 0; text-wrap: balance; }
57
+ .tt { font-family: var(--display, var(--font)); font-weight: var(--display-weight, 700); font-synthesis: none; font-size: 104px; line-height: .95; letter-spacing: var(--display-tracking, -.045em); margin: 0; text-wrap: balance; }
58
58
  .lead { font-size: 32px; line-height: 1.3; max-width: 30em; margin: 0; }
59
59
  .content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 28px; padding-right: 500px; }
60
60
  /* The presenter glides to each slide's slot; slots alternate sides so the
@@ -156,6 +156,8 @@
156
156
  <!-- `hyperdecks narrate decks/__SLUG__` fills data-manifest with the manifest
157
157
  URL it writes. data-presenter: head (live Minimoji, needs
158
158
  data-avatar), static (data-presenter-image) or none (captions only).
159
+ deck new, deck narrator and narrate write the narrator's face here
160
+ (data-presenter-from="narrator", so release follows a later narrator).
159
161
  data-presenter-style: stage (large head, lower-third captions), bubble,
160
162
  glass (frosted panel) or corner (small head, subtitles). -->
161
163
  <script type="module" src="https://hyperdecks.ai/player/v1/player.js"