@hypersocial/decks-cli 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,7 +154,9 @@ Hi, I'm Selçuk's mini avatar. [wave] This is Hyperdecks. [mood: excited]
154
154
  time of the word before it.
155
155
  - Voice tags (`[whispers]`, `[laughs]`, `[sighs]`, `[excited]`, …) are sent to the
156
156
  voice and left out of the captions.
157
- - Unknown tags, a tag with no word before it, and headings with no matching section
157
+ - `[play]` / `[pause]` (the video cues, not `[pause: s]`) may open a line instead:
158
+ they fire at the start of that line's first word.
159
+ - Unknown tags, any other tag with no word before it, and headings with no matching section
158
160
  fail with line numbers.
159
161
 
160
162
  `--dry-run` lists each slide, its hash and whether it would render. It renders nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -207,13 +207,15 @@ Files, all in `decks/<slug>/`:
207
207
  keep the cues at the words they belong to.
208
208
  - `evidence.md`: the sources table. Private: release never uploads it.
209
209
 
210
- The deck contract (`check` enforces each line):
210
+ The deck contract (`check` enforces or warns on each line):
211
211
 
212
212
  - One `<section class="slide" id="<id>">` per slide; ids are lowercase,
213
213
  unique, and in the same order as the `##` blocks in `script.md`.
214
214
  - Every slide has `data-note="<source of its facts>"`, e.g.
215
215
  `data-note="Q3 numbers: finance-update.pdf, 2026-10-02"`. A slide with no
216
- facts says so: `data-note="Title slide: no facts."`.
216
+ facts says so: `data-note="Title slide: no facts."`. A missing note is a
217
+ warning, and an error on a pitch deck (`data-kind="pitch"` on the player
218
+ script: its claims need sources) or under `check --strict`.
217
219
  - A title (first `<h1>`/`<h2>`) over 28 characters carries
218
220
  `data-title-mobile="<shorter>"`. The scaffold puts one on every title.
219
221
  - Each slide keeps its `<div class="narration-slot"></div>` (where the
@@ -223,6 +225,15 @@ The deck contract (`check` enforces each line):
223
225
  - The player script stays
224
226
  `<script type="module" src="https://decks.hypersocial.ai/player/v1/player.js" data-manifest="…">`.
225
227
  `narrate` fills `data-manifest`; do not edit it by hand.
228
+ - A deck with no narration (a product tour, an imported design deck, a silent
229
+ walkthrough) says so: `data-silent` on the player script. `check` then warns
230
+ "no narration (silent deck)" instead of failing, release skips narrate, and
231
+ the player shows the slides, arrows, tabs and scroll mode with no Play
232
+ button, clock or caption bubble. Without `data-silent` a missing
233
+ `data-manifest` still fails, so a forgotten narrate is caught.
234
+ - The `<style data-hd-loading>` block in `<head>` is boilerplate the tools
235
+ write: `deck new` includes it, `check --fix` inserts or refreshes it, and a
236
+ hosted `write_deck_source` of index.html puts it right on the way in.
226
237
  - To read the deck as one long page instead of slide by slide, add
227
238
  `data-view="scroll"` to that player script (on the hosted MCP, through
228
239
  write_deck_source). The slides stay as they are: they stack and snap, the
@@ -257,6 +268,14 @@ its `##` heading.
257
268
  | `glass` | dark or busy slides: head and captions on one frosted panel |
258
269
  | `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
259
270
 
271
+ Size it per slide with `data-presenter-size="s|m|l|xl"` on the `<section>`
272
+ (0.7×, 1×, 1.3×, 1.6× the style's head; on the player tag it is the deck's
273
+ default): `xl` on a slide with empty space, `s` on a busy product slide. Phones
274
+ keep `l`/`xl` at their usual size. A slide's CSS `--hd-presenter-scale: 1.15`
275
+ fine-tunes it. `check` warns on an unknown size. In `corner`, `[look]` turns
276
+ the head; `[go]` highlights the target and looks at it but the head stays in
277
+ its corner.
278
+
260
279
  The live head's face follows script cues too (`[nod]`, `[look #card]`,
261
280
  `[smile]`, `[gesture: lightbulb]`; see the script topic); a still face ignores them.
262
281
 
@@ -302,12 +321,79 @@ Viewers can poke the live head: a click or tap makes it react (an "oof", a
302
321
  flinch) and a drag pulls it a little before it springs back, without pausing
303
322
  the narration.
304
323
 
324
+ <!-- topic: scripts -->
325
+ #### What your scripts can do
326
+
327
+ A deck is a web page: its own `<script>`s run with the player, inline or from
328
+ a file in the deck folder (release ships both; the player strips nothing).
329
+ Put a module script after the player script tag, so `window.hyperdecks` is
330
+ there. What your code can hook into:
331
+
332
+ - **Controllers**: `window.hyperdecks.register("<slide-id>", { onEnter,
333
+ onExit, prebuild, onCue, onNarration })`. `onEnter(ctx)` / `onExit(ctx)`
334
+ run as the slide comes and goes, `prebuild(ctx)` during the hold before it
335
+ (load something heavy), `onCue(cue, ctx)` on each cue of the slide, and
336
+ `onNarration(state, ctx)` hears `playing`, `paused`, `ended` and `waiting`.
337
+ `ctx` is `{ id, index, slide, clip, position, presenter: { setMood, emote,
338
+ wave, go, look }, player: { show, next, prev, toggle, pause, resume } }`.
339
+ A classic script that runs before the player queues
340
+ `window.hyperdecks = [["<slide-id>", controller]]` instead.
341
+ - **Custom cues**: `[custom: confetti]` in script.md fires at the word before
342
+ it and reaches that slide's `onCue` (and the window's `deck:cue` event), as
343
+ `cue.action === "custom:confetti"` (words after the name arrive in `cue.args`).
344
+ - **Pausing the narrator**: `ctx.player.pause()` / `resume()`, or
345
+ `window.hyperdecks.pauseNarration()` / `resumeNarration()` from any script,
346
+ while something on the slide speaks. Resume undoes only your own pause.
347
+ - **Taps**: a click on a slide toggles narration, except on links, buttons,
348
+ form controls, `[role=button]`, `canvas`, focusable elements, anything drawn
349
+ with `cursor: pointer`, and anything marked `data-hd-tap`.
350
+ - **Interactive slides**: `data-interactive` on the `<section>`: the presenter
351
+ speaks its clip, then the deck waits (no timer) with a Continue button while
352
+ the viewer plays; taps and swipes inside the slide leave narration alone.
353
+
354
+ One slide using all of it (`## demo` in script.md: `Tap the bell, and [custom:
355
+ confetti] that's the whole trick.`):
356
+
357
+ ```html
358
+ <section class="slide" id="demo" data-interactive data-note="Demo: no facts.">
359
+ <h2>Try it</h2>
360
+ <button class="bell" data-hd-tap>Ring the bell</button>
361
+ <div class="narration-slot"></div>
362
+ </section>
363
+ <script type="module">
364
+ window.hyperdecks.register("demo", {
365
+ onEnter(ctx) { ctx.slide.classList.remove("is-party"); },
366
+ onCue(cue, ctx) { if (cue.action === "custom:confetti") ctx.slide.classList.add("is-party"); },
367
+ });
368
+ document.querySelector("#demo .bell").addEventListener("click", () => {
369
+ window.hyperdecks.pauseNarration();
370
+ const bell = new Audio("media/bell.mp3");
371
+ bell.onended = () => window.hyperdecks.resumeNarration();
372
+ bell.play();
373
+ });
374
+ </script>
375
+ ```
376
+
305
377
  <!-- topic: design -->
306
378
  ### Design: make it look good
307
379
 
308
380
  A narrated deck is only as convincing as it looks. Design before you write the
309
381
  narration, and check the result at both widths (step 6).
310
382
 
383
+ #### Rules the check enforces
384
+
385
+ These fail `check` (step 5): a title over 28 characters with no
386
+ `data-title-mobile`; a layout placeholder left in (the hero's empty `.art`,
387
+ the product's `.shot`, the chart's made-up `<svg>`); an unknown presenter
388
+ style or transition, a second `fly`; on a pitch deck or with `--strict`, a
389
+ slide with no `data-note`; and, with `--layout` and always at release, a
390
+ slide that overflows a 390×844 phone.
391
+
392
+ #### House style (preferences)
393
+
394
+ Everything below is the owner's house style, not a rule: follow it by
395
+ default, and depart from it when the brief asks for something else.
396
+
311
397
  **One idea per slide.** The title states the idea as a sentence a listener
312
398
  could repeat ("Narration renders in two minutes", not "Speed"). Everything
313
399
  else on the slide supports that sentence; if it needs a second idea, it is a
@@ -411,8 +497,9 @@ where there is room so slide changes move the presenter.
411
497
  One style per deck, described the same way every time (subject, medium,
412
498
  palette hexes, "no text"). `--model nano-banana-2.1` when the image must
413
499
  carry legible type.
414
- - Video: `gen-video` for a short loop behind a hero or a product moment, with
415
- `[play: video]` at its word; never needed for a deck to work. A clip that
500
+ - Video: `gen-video` for a short loop behind a hero or a product moment;
501
+ never needed for a deck to work. A `muted loop` video plays while its slide
502
+ is current, no cue needed; a `data-hd-manual` one waits for `[play]`. A clip that
416
503
  plays on repeat should be made with `--loop` (`loop: true`): it starts and
417
504
  ends on the same frame, so there is no jump at the seam. Give it `--image`
418
505
  to loop a still you already have; without one a keyframe is rendered first.
@@ -449,21 +536,6 @@ still.
449
536
  - No CSS animation on slides beyond `.reveal`; the player animates the slide
450
537
  change.
451
538
 
452
- **Review before release (required).** After `check` passes and the deck is
453
- imported, run `hyperdecks review <slug> --script decks/<slug>/script.md`
454
- (`review_deck`, then poll `check_review_job`). Act on every finding:
455
-
456
- - `fix`: change the slide or the script, re-import (or `release --qa`), and
457
- review again until no `fix` is left. Do not release with a `fix` open.
458
- - `consider`: apply it when it serves the slide's one idea; otherwise note in
459
- your reply to the user why you left it.
460
- - A finding you disagree with (a deliberate choice, a misread screenshot):
461
- say so to the user with the reason instead of silently ignoring it.
462
-
463
- If review cannot run (no credits, the service is down), say so to the user and
464
- do the step 6 screenshot pass yourself as the reviewer: the same desktop and
465
- phone screenshots, the same `fix` bar.
466
-
467
539
  <!-- topic: script -->
468
540
  ### 3. Write slides and script
469
541
 
@@ -517,7 +589,7 @@ phone screenshots, the same `fix` bar.
517
589
  | `[mood: excited]` / `[emote: name]` | the head's mood or emote |
518
590
  | `[reveal: #id]` | adds `is-revealed` to `#id` (reset when the slide is left or replays); the live head glances at it unless a `[look]`/`[go]` is beside it |
519
591
  | `[pause: 0.5]` | a beat of silence (≤ 5 s) |
520
- | `[play: video]` / `[pause: video]` | play or pause the slide's video |
592
+ | `[play]` / `[pause]` (or `[play: .sel]`) | play or pause every video on the slide (or the matches); may open a line, firing as its first word starts. Muted loops play on their own, so this is for `data-hd-manual` clips |
521
593
  | `[go: #step-2]` / `[go: slot]` | fly beside that item, grow it, fade the other items / back to the slot |
522
594
  | `[look: h2]` / `[look #card]` | look at the title / the card, then back |
523
595
  | `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
@@ -552,9 +624,13 @@ reword the sentence (spell numbers and names the way they are said, or add a
552
624
  ### 5. Check
553
625
 
554
626
  ```bash
555
- hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout]
627
+ hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout] [--strict] [--fix]
556
628
  ```
557
629
 
630
+ `--strict` fails every slide with no `data-note`, as a pitch deck does.
631
+ `--fix` writes what the tools can before checking: today, a missing or stale
632
+ loading state (`<style data-hd-loading>`).
633
+
558
634
  `--layout` lays each slide out in Chromium (needs `hyperdecks preview setup`).
559
635
  At the stage size it warns, without failing, when a slide's presenter would
560
636
  overlap its title. On a 390×844 phone (the share player's 374×736 slide card)
@@ -584,10 +660,11 @@ with the player's `decks.hypersocial.ai` script tag. Messages and what to do:
584
660
  | ⚠ `<file> builds file paths at runtime` | list each file as a quoted path (an object or array, in a script or a `.json` it fetches) and pick from it |
585
661
  | ⚠ `<file> loads files from computed values` | fine when every file it can load is also written out as a quoted path; otherwise list them so release ships them |
586
662
  | `player script must be` / `no player script` | restore the player/v1 script tag |
587
- | `the player script has no data-manifest` | run `narrate` |
588
- | `no loading state` | copy the template's `<style data-hd-loading>` block (and the preload lines above it) into `<head>`; set `--hd-ground` / `--hd-ground-ink` to the first slide's colours |
663
+ | `the player script has no data-manifest` | run `narrate`; or, for a deck with no narration, add `data-silent` to the player script |
664
+ | ⚠ `no narration (silent deck)` | fine for a deck meant to be silent; remove `data-silent` and narrate to give it a voice |
665
+ | `no loading state` | `check --fix` writes it (a hosted draft gets it on its next write of index.html); set `--hd-ground` / `--hd-ground-ink` to the first slide's colours in `:root`, never inside the block (it is replaced from the template) |
589
666
  | `forbidden phrase` | remove it from the page, script or narration |
590
- | `slide <id>: needs a data-note` | add the source |
667
+ | `slide <id>: needs a data-note` / ⚠ `no data-note` | add the source (required on a `data-kind="pitch"` deck and with `--strict`) |
591
668
  | `slide <id>: title … needs a data-title-mobile` | add a shorter title |
592
669
  | `slide <id>: the hero's art` / `the product screenshot` / `the chart … is still the layout's placeholder` | make the real file (`gen-image`, `screenshot <url>`, `chart <spec.json>`) and swap the element for the `<img>` the message names; or delete the hero's `<div class="art">` for no art |
593
670
  | `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
@@ -601,6 +678,21 @@ with the player's `decks.hypersocial.ai` script tag. Messages and what to do:
601
678
  | `slide <id>: its content runs …px past the slide on a 390×844 phone` | trim that slide on phones: smaller type, fewer items, or hide decoration in its `@media (max-width: 650px)` rules (an `#id` rule outranks them; restate it there) |
602
679
  | ⚠ `slide <id>: the … presenter would overlap the title` | move that slide's `.narration-slot`, pad the text away from it, or use `corner` |
603
680
 
681
+ **Review before release (required).** After `check` passes and the deck is
682
+ imported, run `hyperdecks review <slug> --script decks/<slug>/script.md`
683
+ (`review_deck`, then poll `check_review_job`). Act on every finding:
684
+
685
+ - `fix`: change the slide or the script, re-import (or `release --qa`), and
686
+ review again until no `fix` is left. Do not release with a `fix` open.
687
+ - `consider`: apply it when it serves the slide's one idea; otherwise note in
688
+ your reply to the user why you left it.
689
+ - A finding you disagree with (a deliberate choice, a misread screenshot):
690
+ say so to the user with the reason instead of silently ignoring it.
691
+
692
+ If review cannot run (no credits, the service is down), say so to the user and
693
+ do the step 6 screenshot pass yourself as the reviewer: the same desktop and
694
+ phone screenshots, the same `fix` bar.
695
+
604
696
  ### 6. Look at it, at both widths
605
697
 
606
698
  To watch it as it will really play, run `hyperdecks play decks/<slug>`. It plays
@@ -617,7 +709,7 @@ and screenshot it with Playwright at 1440×900 and 390×844, every slide
617
709
  (`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
618
710
  clear of the text, the mobile titles in use. Fix and repeat until both read well.
619
711
 
620
- Then the required review (see **Design**, *Review before release*): once the
712
+ Then the required review (see **5. Check**, *Review before release*): once the
621
713
  deck is imported, `hyperdecks review <slug> --script decks/<slug>/script.md`
622
714
  (`review_deck`, then `check_review_job`) has GPT-6 Luna look at every slide of
623
715
  the deck's QA link on desktop and phone and list each problem as `fix` or
@@ -4,7 +4,7 @@ You are building a narrated deck through the Hyperdecks connector: no terminal,
4
4
  no git, no folder. The deck's files (`index.html`, `script.md`, `evidence.md`)
5
5
  live on Hyperdecks as a draft; you read and edit them with tools, and nothing
6
6
  reaches a recipient until `release_deck`. Topics: call `deck_guide` with
7
- `format`, `script`, `design`, `layouts`, `presenter`, `transitions` or `check`
7
+ `format`, `script`, `scripts`, `design`, `layouts`, `presenter`, `transitions` or `check`
8
8
  before the step that needs it.
9
9
 
10
10
  ## First time here?
@@ -100,9 +100,16 @@ makes the hello deck; continue from step 5 below.
100
100
  ## Rules that matter
101
101
 
102
102
  - Every slide is `<section class="slide" id="…" data-note="<source of its facts>">`,
103
- in the same order as the `## <id>` blocks in `script.md`.
104
- - Keep the player script tag and the `<style data-hd-loading>` block the
105
- scaffold wrote; never write `data-manifest` yourself.
103
+ in the same order as the `## <id>` blocks in `script.md`. A missing note
104
+ warns; on a pitch deck (`data-kind="pitch"` on the player script) it fails.
105
+ - Keep the player script tag; never write `data-manifest` yourself. The
106
+ `<style data-hd-loading>` block is written for you on each write of index.html.
107
+ - A deck with no narration (a product tour, a silent walkthrough): put
108
+ `data-silent` on the player script and skip narrate_deck; check warns, release
109
+ goes through, and the player shows no Play button or clock.
110
+ - The deck's own scripts run: slide controllers, `[custom: name]` cues,
111
+ pausing the narrator, tappable and interactive slides. `deck_guide
112
+ { topic: "scripts" }` has the hooks and an example.
106
113
  - Edit the draft freely: recipients keep seeing the last release until you call
107
114
  `release_deck` again. A release is refused when the check fails, when
108
115
  narration is stale (narrate again first), when the draft changed after
package/src/check.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
1
+ // hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--strict] [--fix] [--json]
2
2
  //
3
3
  // The narrated-deck integrity gate, ported from the Netflix presentation's
4
4
  // scripts/check.mjs and its variant builders. Every rule returns its message from
@@ -8,11 +8,14 @@
8
8
  // every clip's audio and timeline. `fetch` and `exists` are injectable for tests.
9
9
  // The rules themselves live in checkRules.mjs (no Node imports, shared with the
10
10
  // hosted check); this file adds the folder I/O and the Chromium measurements.
11
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
11
+ import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
12
12
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
13
13
  import { PHONE, PRESENTER_FOOTPRINT, checkSources, overlapWarnings, phoneOverflowErrors, slideStyles, stageSize, MESSAGES } from "./checkRules.mjs";
14
14
 
15
+ import { withLoadingBlock } from "./loadingBlock.mjs";
16
+
15
17
  export * from "./checkRules.mjs";
18
+ export * from "./loadingBlock.mjs";
16
19
 
17
20
  // Lay each slide out in Chromium at the deck's stage size with JavaScript off
18
21
  // (no player, no deck scripts), put the style's footprint in its slot, and
@@ -200,8 +203,21 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
200
203
  };
201
204
  }
202
205
 
206
+ // --fix: what the tools can write for the deck, before the check reads it. Today
207
+ // that is the loading state (a missing or stale <style data-hd-loading>).
208
+ export function fixDeck(dirArg, { cwd } = {}) {
209
+ const deckDir = resolveDeckDir(dirArg, cwd);
210
+ const path = join(deckDir, "index.html");
211
+ const { html, fixed, skipped } = withLoadingBlock(readFileSync(path, "utf8"));
212
+ if (fixed) writeFileSync(path, html);
213
+ if (skipped) return [`index.html: not fixed: ${skipped}`];
214
+ return fixed ? [`index.html: ${fixed} the loading state (<style data-hd-loading>) from the template`] : [];
215
+ }
216
+
203
217
  export async function check(pos, opts = {}, { log = (line) => console.log(line), ...io } = {}) {
204
- const result = await checkDeck(pos[0], opts, io);
218
+ const fixed = opts.fix ? fixDeck(pos[0], io) : [];
219
+ if (!opts.json) for (const f of fixed) log(f.includes(": not fixed:") ? `⚠ ${f}` : `✓ fixed ${f}`);
220
+ const result = { ...(await checkDeck(pos[0], opts, io)), ...(opts.fix ? { fixed } : {}) };
205
221
  if (!opts.json) {
206
222
  log(`deck "${result.slug}": ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
207
223
  if (result.errors.length) {
@@ -12,6 +12,8 @@ export const CAPTION_MIN_CHARS = 4;
12
12
  export const CAPTION_MAX_CHARS = 85;
13
13
  // Same names as PRESENTER_STYLES in lib/deckManifest.ts (the published CLI cannot import it).
14
14
  export const PRESENTER_STYLES = ["stage", "bubble", "glass", "corner"];
15
+ // Same steps as PRESENTER_SIZES in packages/player/presenter-moves.js.
16
+ export const PRESENTER_SIZES = ["s", "m", "l", "xl"];
15
17
  // Same names as TRANSITIONS in lib/deckManifest.ts and packages/player/transitions.js.
16
18
  export const TRANSITIONS = ["drift", "cut", "push", "wipe", "zoom", "morph", "fly"];
17
19
  export const MAX_TRANSITION_KINDS = 2;
@@ -19,11 +21,14 @@ export const MAX_TRANSITION_KINDS = 2;
19
21
  export const MESSAGES = {
20
22
  noPlayer: () => `no player script: add <script type="module" src="${PLAYER_V1}" data-manifest="…">`,
21
23
  player: (src) => `player script must be ${PLAYER_V1} (got ${src})`,
22
- noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it",
23
- noManifestHosted: () => "the player script has no data-manifest: call narrate_deck (no script) and wait for check_narration_job; it sets it",
24
+ noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it (or, for a deck with no narration, add data-silent to the player script)",
25
+ noManifestHosted: () => "the player script has no data-manifest: call narrate_deck (no script) and wait for check_narration_job; it sets it (or, for a deck with no narration, add data-silent to the player script)",
26
+ // Warnings: a deck that declared it has no narration.
27
+ silent: () => "no narration (silent deck): data-silent on the player script, so it plays with no voice, Play button or clock; to narrate it, remove data-silent and run `hyperdecks narrate`",
28
+ silentHosted: () => "no narration (silent deck): data-silent on the player script, so it plays with no voice, Play button or clock; to narrate it, remove data-silent and call narrate_deck",
24
29
  manifestUnavailable: (url, why) => `manifest ${url} could not be read (${why})`,
25
30
  noSlides: () => 'no slides: each slide is a <section class="slide" id="…">',
26
- noLoading: () => "no loading state: copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>, and set --hd-ground to the first slide's colour",
31
+ 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",
27
32
  clipField: (id, field) => `clip ${id}: ${field}`,
28
33
  noCaptions: (id) => `clip ${id}: timeline has no captions`,
29
34
  order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
@@ -42,11 +47,14 @@ export const MESSAGES = {
42
47
  scriptComputed: (from, calls) => `${from} loads files from computed values (${calls.join(", ")}); release only ships files the deck names literally, so make sure each file it can load is written out as a quoted path in index.html, a script or a .json file the deck loads`,
43
48
  forbidden: (phrase, where) => `forbidden phrase "${phrase}" appears in ${where}`,
44
49
  note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
50
+ // The same rule as a warning: required only on a pitch deck (data-kind="pitch") or with check --strict.
51
+ noteSoft: (id) => `slide ${id}: no data-note naming the source of its facts (required on a pitch deck, data-kind="pitch", and by check --strict)`,
45
52
  mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
46
53
  slideId: (n) => `slide ${n} has no id`,
47
54
  selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
48
55
  gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
49
56
  gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
57
+ presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
50
58
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
51
59
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
52
60
  zoomTarget: (id, target, prev) => `slide ${id}: zoom target "${target}" matches nothing on the slide before it (${prev})`,
@@ -71,8 +79,8 @@ export const PHONE = { width: 390, height: 844, card: { width: 374, height: 736
71
79
 
72
80
  const uncommented = (html) => html.replace(/<!--[\s\S]*?-->/g, "");
73
81
  const attr = (tag, name) => {
74
- const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
75
- return m ? (m[1] ?? m[2]) : null;
82
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`).exec(tag);
83
+ return m ? (m[1] ?? m[2] ?? m[3]) : null;
76
84
  };
77
85
  const round = (n) => Math.round(n * 100) / 100;
78
86
  const decode = (s) => s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'");
@@ -103,6 +111,15 @@ export function presenterStyleErrors(html, script = "") {
103
111
  return errs;
104
112
  }
105
113
 
114
+ // Unknown presenter sizes (data-presenter-size on a slide or the player tag), with
115
+ // their line: a warning, since the player falls back to the default size.
116
+ export function presenterSizeWarnings(html) {
117
+ const page = blankComments(html);
118
+ return [...page.matchAll(/\sdata-presenter-size\s*=\s*(?:"([^"]*)"|'([^']*)')/g)]
119
+ .filter((m) => !PRESENTER_SIZES.includes((m[1] ?? m[2]).trim()))
120
+ .map((m) => MESSAGES.presenterSize(`index.html line ${lineAt(page, m.index)}`, m[1] ?? m[2]));
121
+ }
122
+
106
123
  // "zoom #dash-row" → { name: "zoom", target: "#dash-row" }; null when it is not a transition.
107
124
  export function parseTransition(value) {
108
125
  if (typeof value !== "string") return null;
@@ -282,11 +299,26 @@ export function slides(html) {
282
299
  });
283
300
  }
284
301
 
302
+ // The player tag also carries the deck's declarations: data-silent (no
303
+ // narration, on purpose) and data-kind="pitch" (every slide sources its facts).
304
+ // The check reads what the deck says, never guesses from its content.
305
+ const hasAttr = (tag, name) => new RegExp(`\\s${name}(?=[\\s=>/])`).test(tag);
285
306
  export function playerScript(html) {
286
307
  const tag = [...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t));
287
- return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
308
+ if (!tag) return null;
309
+ return {
310
+ src: attr(tag, "src"),
311
+ manifest: attr(tag, "data-manifest") || null,
312
+ silent: hasAttr(tag, "data-silent") && !/^(false|0|no)$/i.test(attr(tag, "data-silent") ?? ""),
313
+ kind: attr(tag, "data-kind")?.trim().toLowerCase() || null,
314
+ };
288
315
  }
289
316
 
317
+ // Source notes are required on a pitch deck (its claims) and under check
318
+ // --strict; elsewhere a missing one is a warning.
319
+ export const notesRequired = (html, { strict = false } = {}) => !!strict || playerScript(html)?.kind === "pitch";
320
+ export const slidesWithoutNotes = (html) => slides(html).map((s, i) => (attr(s.tag, "data-note")?.trim() ? null : s.id || `${i + 1}`)).filter(Boolean);
321
+
290
322
  // Relative references: src, href, poster, data-presenter-image and data-alpha-stacked (either quote), srcset candidates and
291
323
  // CSS url(…) in style attributes and <style> blocks; everything that is not a URL
292
324
  // scheme, protocol-relative or a fragment. Query strings and fragments dropped.
@@ -507,7 +539,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
507
539
 
508
540
  // Everything checkable from the deck's own files. `exists(ref)` answers whether a
509
541
  // local reference resolves inside the deck folder: true, false or "outside".
510
- export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
542
+ export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false, strict = false } = {}) {
511
543
  const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
512
544
  const player = playerScript(html);
513
545
  if (!player) errs.push(MESSAGES.noPlayer());
@@ -518,10 +550,11 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
518
550
  if (player && !loading?.[1].includes("hd-fail-open")) errs.push(MESSAGES.noLoading());
519
551
  const all = slides(html);
520
552
  if (!all.length) errs.push(MESSAGES.noSlides());
553
+ const notes = notesRequired(html, { strict });
521
554
  all.forEach((s, i) => {
522
555
  const id = s.id || `${i + 1}`;
523
556
  if (!s.id) errs.push(MESSAGES.slideId(i + 1));
524
- if (!attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
557
+ if (notes && !attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
525
558
  if (s.title.length > MOBILE_TITLE_CHARS && !attr(s.titleTag, "data-title-mobile") && !attr(s.tag, "data-title-mobile")) {
526
559
  errs.push(MESSAGES.mobileTitle(id, s.title));
527
560
  }
@@ -715,7 +748,9 @@ const isHostedAsset = (url) => {
715
748
  // resolves (true, false or "outside"); `hosted: true` means there is no folder,
716
749
  // so every local reference is an error naming upload_deck_asset, a hotlinked
717
750
  // image is a warning, and the layout rules (which need Chromium) are reported as
718
- // not checked. Returns { slides, totalDuration, manifestUrl, manifest, passed, errors, warnings }.
751
+ // not checked. `strict: true` makes every slide's data-note an error, as on a
752
+ // pitch deck. Returns { slides, totalDuration, manifestUrl, manifest, silent,
753
+ // passed, errors, warnings }; `silent` is a deck with no manifest that declared data-silent.
719
754
  export async function checkSources({ html, script = "" }, opts = {}, { fetch: fetchImpl = globalThis.fetch } = {}) {
720
755
  const forbid = phraseList(opts.forbid);
721
756
  const flagCap = opts["max-duration"] ?? opts.maxDuration;
@@ -724,14 +759,22 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
724
759
  throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
725
760
  }
726
761
  const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
727
- const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted });
762
+ const strict = !!opts.strict;
763
+ const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
764
+ const warnings = [];
765
+ if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
728
766
  // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
729
767
  const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
730
768
  for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
731
769
  const ids = slides(html).map((s) => s.id);
732
770
  const player = playerScript(html);
733
771
  let manifest = null;
734
- if (player && !player.manifest) errors.push(opts.hosted ? MESSAGES.noManifestHosted() : MESSAGES.noManifest());
772
+ // A deck that declares itself silent plays without narration; without the
773
+ // declaration a missing manifest is still a forgotten narrate. Slides with
774
+ // data-clip audio are narrated (the player plays them), so not silent.
775
+ const silent = !!player && !player.manifest && player.silent && !slides(html).some((s) => attr(s.tag, "data-clip"));
776
+ if (silent) warnings.push(opts.hosted ? MESSAGES.silentHosted() : MESSAGES.silent());
777
+ else if (player && !player.manifest) errors.push(opts.hosted ? MESSAGES.noManifestHosted() : MESSAGES.noManifest());
735
778
  if (player?.manifest) {
736
779
  const got = await fetchCheck(fetchImpl, player.manifest, JSON_TYPE);
737
780
  if (got.why) errors.push(MESSAGES.manifestUnavailable(player.manifest, got.why));
@@ -782,7 +825,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
782
825
  errors.push(...timelineErrors(clip, timeline));
783
826
  }
784
827
  }
785
- const warnings = transitionWarnings(html, script, manifest);
828
+ warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
786
829
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
787
830
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
788
831
  if (opts.hosted) {
@@ -794,6 +837,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
794
837
  totalDuration: manifest ? Number(manifest.totalDuration) : null,
795
838
  manifestUrl: player?.manifest ?? null,
796
839
  manifest,
840
+ silent,
797
841
  passed: errors.length === 0,
798
842
  errors,
799
843
  warnings: [...new Set(warnings)],
package/src/commands.mjs CHANGED
@@ -341,16 +341,18 @@ commands:
341
341
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
342
342
  in your saved brand's colours, fonts and logo (--no-brand: the template's look)
343
343
  layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
344
- check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
344
+ check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--strict] [--fix] [--json]
345
345
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
346
- local refs, pinned player, a data-note per slide, mobile titles
346
+ local refs, pinned player, mobile titles. data-silent on the player script: no narration
347
+ (a warning). Missing data-notes warn, and fail on a data-kind="pitch" deck or with --strict.
348
+ --fix writes what the tools can (a missing or stale loading state) before checking
347
349
  deck new|check|release … --hosted
348
350
  the same on a hosted draft (no folder, no git: what chat agents use over the remote MCP)
349
351
  source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
350
352
  move a hosted draft (index.html, script.md, evidence.md) to a folder and back
351
353
  asset add <slug> <file|url> [--name n]
352
354
  file an image, video or font under the deck; prints its hosted URL
353
- guide [workflow|format|script|design|layouts|presenter|transitions|check]
355
+ guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
354
356
  the skill, one topic at a time (what deck_guide serves)
355
357
  play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
356
358
  play the folder as it is, committed or not, with its real face and voice, on 127.0.0.1
@@ -707,8 +709,9 @@ function commandTable(pos, rest, opts) {
707
709
  check: async () => {
708
710
  const { check } = await import("./check.mjs");
709
711
  const { hostedCheckCli } = await import("./source.mjs");
710
- const result = opts.hosted ? await hostedCheckCli(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"] }) : await check(pos.slice(1), opts);
712
+ const result = opts.hosted ? await hostedCheckCli(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"], strict: opts.strict, fix: opts.fix }) : await check(pos.slice(1), opts);
711
713
  if (opts.hosted && !opts.json) {
714
+ for (const f of result.fixed ?? []) console.log(f.includes(": not fixed:") ? `⚠ ${f}` : `✓ fixed ${f}`);
712
715
  console.log(`deck "${pos[1]}" rev ${result.rev}: ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
713
716
  for (const e of result.errors) console.log(` - ${e}`);
714
717
  if (!result.errors.length) console.log("✓ check passed");
package/src/guide.mjs CHANGED
@@ -8,7 +8,7 @@ import { packageText } from "./files.mjs";
8
8
  import { LAYOUTS, readLayout } from "./layouts.mjs";
9
9
  import { fontPairingsMarkdown } from "./brand.mjs";
10
10
 
11
- export const GUIDE_TOPICS = ["workflow", "format", "script", "design", "layouts", "presenter", "transitions", "check"];
11
+ export const GUIDE_TOPICS = ["workflow", "format", "script", "scripts", "design", "layouts", "presenter", "transitions", "check"];
12
12
  const CLI_NOTE = "_Written for the CLI. On the hosted MCP there is no folder: use the tool names in `deck_guide` (workflow), hosted URLs for every file, and write_deck_source for edits._\n\n";
13
13
 
14
14
  // One <!-- topic: name --> section of SKILL.md: its heading and body, up to the
package/src/hosted.mjs CHANGED
@@ -61,10 +61,10 @@ export async function uploadDeckAsset(opts, { slug, name, url, file } = {}) {
61
61
  return (await client(opts)).action(api.assetsNode.importAssetFromUrl, { slug, name: assetName, url: String(from), ...(file?.mime_type ? { mimeType: String(file.mime_type) } : {}) });
62
62
  }
63
63
 
64
- export async function hostedCheck(opts, { slug, forbid, maxDuration } = {}) {
64
+ export async function hostedCheck(opts, { slug, forbid, maxDuration, strict } = {}) {
65
65
  if (!slug) throw new Error("slug is required");
66
66
  const list = typeof forbid === "string" ? forbid.split(",").map((s) => s.trim()).filter(Boolean) : forbid;
67
- return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}) });
67
+ return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}), ...(strict ? { strict: true } : {}) });
68
68
  }
69
69
 
70
70
  export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice, captionsOnly } = {}) {
@@ -0,0 +1,72 @@
1
+ // The loading state every player deck carries in <head> (check's noLoading):
2
+ // the preloads, the frame flag and the <style data-hd-loading> block, exactly as
3
+ // templates/narrated-deck.html and templates/layouts/_deck.html have it
4
+ // (lib/loadingBlock.test.ts keeps the three in step). A JS string, not a read of
5
+ // the template, because Convex (the hosted draft's write) cannot read package files.
6
+ // No Node imports: the CLI and Convex (and so the MCP route's writes) use it.
7
+ export const LOADING_BLOCK = [
8
+ "<!-- Loading state (keep verbatim; `hyperdecks check` requires it). Until the",
9
+ " player marks body[data-hd-ready] the page shows only the slide card, at its",
10
+ " final size, in the deck's --hd-ground (its first slide's colour) on the",
11
+ " page's brand colour, with a quiet loader in --hd-ground-ink; then the",
12
+ " slides fade in. If the player never arrives, the slides show after 10 s",
13
+ " rather than never. Put data-layout=\"overlay\" on <html> for the overlay. -->",
14
+ "<link rel=\"modulepreload\" href=\"https://decks.hypersocial.ai/player/v1/player.js\">",
15
+ "<link rel=\"preload\" as=\"style\" href=\"https://decks.hypersocial.ai/player/v1/player.css\">",
16
+ "<script>if (self !== top || /[?&]thumb\\b/.test(location.search)) document.documentElement.dataset.hdFrame = \"\";</script>",
17
+ "<style data-hd-loading>",
18
+ " body:not([data-hd-ready]) { overflow: hidden; background: var(--hd-frame, var(--hd-ground, var(--hd-accent, #ffda1b))); animation: hd-fail-open 0s 10s forwards; }",
19
+ " body:not([data-hd-ready]) > * { visibility: hidden; animation: hd-fail-open 0s 10s forwards; }",
20
+ " body[data-hd-ready] > * { animation: hd-in .2s ease-out; }",
21
+ " body::before, body::after { content: \"\"; position: fixed; z-index: -1; pointer-events: none; transition: opacity .2s ease-out; }",
22
+ " /* The card as the player draws it (player.css, framed): 16:9, scaled to fit",
23
+ " between the 64px masthead and the 56px dock, 24px in from the sides. */",
24
+ " body::before { inset: 64px 24px 56px; margin: auto; width: min(100vw - 48px, (100dvh - 120px) * 16 / 9); height: min(100dvh - 120px, (100vw - 48px) * 9 / 16); border-radius: calc(min(100vw - 48px, (100dvh - 120px) * 16 / 9) * .01125); background: var(--hd-ground, #fff); box-shadow: 0 0 0 1px color-mix(in srgb, var(--hd-ground-ink, #0b0b0f) 16%, transparent); }",
25
+ " body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }",
26
+ " body[data-hd-ready]::before, body[data-hd-ready]::after { opacity: 0; animation: none; }",
27
+ " /* The overlay (embedded, ?thumb, or data-layout=\"overlay\"): the stage fills the window. */",
28
+ " :is([data-hd-frame], [data-layout=\"overlay\"]) body::before { inset: 0; width: min(100vw, 100dvh * 16 / 9); height: min(100dvh, 100vw * 9 / 16); border-radius: calc(min(100vw, 100dvh * 16 / 9) * .01125); box-shadow: none; }",
29
+ " [data-layout=\"overlay\"] body:not([data-hd-ready]) { background: var(--hd-backdrop, #16161c); }",
30
+ " [data-hd-frame] body:not([data-hd-ready]) { background: transparent; }",
31
+ " [data-hd-frame] body::before { border-radius: 0; }",
32
+ " /* Phones: the card fills the space between a 52px masthead and the dock, 8px in. */",
33
+ " @media (max-width: 650px) {",
34
+ " body::before { inset: 52px 8px calc(56px + env(safe-area-inset-bottom)); width: auto; height: auto; border-radius: 14px; }",
35
+ " :is([data-hd-frame], [data-layout=\"overlay\"]) body::before { inset: 0; width: auto; height: auto; border-radius: 0; }",
36
+ " }",
37
+ " @media (prefers-reduced-motion: reduce) {",
38
+ " body::before, body::after { transition: none; }",
39
+ " body:not([data-hd-ready])::after { animation: none; opacity: .4; }",
40
+ " body[data-hd-ready] > * { animation: none; }",
41
+ " }",
42
+ " @keyframes hd-in { from { opacity: 0; } }",
43
+ " @keyframes hd-dots { from { opacity: .15; } to { opacity: .6; } }",
44
+ " @keyframes hd-fail-open { to { visibility: visible; overflow: auto; } }",
45
+ "</style>",
46
+ ].join("\n");
47
+
48
+ const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i;
49
+ const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
50
+ const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
51
+ const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
52
+
53
+ // The page with its loading state put right: inserted after <title> (or at the
54
+ // top of <head>) when <head> has none, or its <style data-hd-loading> replaced
55
+ // when it differs from the template's. Only a page with the player script, as
56
+ // the check asks only those. Returns { html, fixed: "inserted" | "refreshed" | null,
57
+ // skipped? }: a player page with no <head>…</head> is left alone, with `skipped` saying why.
58
+ export function withLoadingBlock(html) {
59
+ const live = html.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length)); // same offsets, comments blanked
60
+ const head = HEAD.exec(live);
61
+ if (!PLAYER.test(live)) return { html, fixed: null };
62
+ if (!head) return { html, fixed: null, skipped: "no <head>…</head> to put the loading state in" };
63
+ const style = STYLE.exec(head[0]);
64
+ if (style) {
65
+ const at = head.index + style.index;
66
+ if (html.slice(at, at + style[0].length) === LOADING_STYLE) return { html, fixed: null };
67
+ return { html: html.slice(0, at) + LOADING_STYLE + html.slice(at + style[0].length), fixed: "refreshed" };
68
+ }
69
+ const title = /<\/title>/i.exec(head[0]);
70
+ const at = head.index + (title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(head[0])[0].length);
71
+ return { html: `${html.slice(0, at)}\n${LOADING_BLOCK}${html.slice(at)}`, fixed: "inserted" };
72
+ }
package/src/release.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // 2. runs check (the same rules as `hyperdecks check`) and stops on any issue;
7
7
  // 3. runs narrate, which re-renders only changed slides. If that changes the
8
8
  // deck folder (a new manifest URL in index.html), release stops and asks for
9
- // a commit, then a second release goes through;
9
+ // a commit, then a second release goes through. A silent deck (data-silent
10
+ // on the player script, no manifest) skips narrate and gets no avatar attached;
10
11
  // 4. imports index.html plus the local files it references (nothing else in the
11
12
  // folder: script.md, evidence.md and narration/ stay private);
12
13
  // 5. creates one share per --to (paired in order with --email) and prints the URLs.
@@ -92,10 +93,11 @@ export async function release(pos, opts = {}, io = {}) {
92
93
  const bundle = releaseBundle(deckDir);
93
94
  const loose = untracked(deckDir, bundle.files);
94
95
  if (loose.length) throw new Error(`refusing to release: git does not track ${loose.join(", ")} (ignored or never added); commit them so the release corresponds to a commit`);
95
- log(`✓ check passed: ${checked.slides} slides, ${checked.totalDuration}s`);
96
+ log(`✓ check passed: ${checked.slides} slides, ${checked.silent ? "silent (no narration)" : `${checked.totalDuration}s`}`);
96
97
  for (const w of checked.warnings) log(` ⚠ ${w}`);
97
98
  for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
98
99
 
100
+ if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: bundle, summary: null });
99
101
  // Cached slides cost nothing, so this only renders what changed since the commit.
100
102
  // Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
101
103
  const narrateOpts = { ...opts, slug };
@@ -119,6 +121,11 @@ export async function release(pos, opts = {}, io = {}) {
119
121
  if (presenter) log("✓ presented by your avatar");
120
122
  // A terminal user sees this before it ships, so the CLI warns rather than refuses.
121
123
  else if (job.presenter && !job.presenter.face) log(`⚠ releasing captions only: ${job.presenter.reason ?? "no face"}`);
124
+ return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: job.presenter ?? null });
125
+ }
126
+
127
+ // Import the checked bundle and make the links: the end of every release.
128
+ async function publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary }) {
122
129
  const imported = await ops.importDeckFromBytes(opts, {
123
130
  slug,
124
131
  title: opts.title,
@@ -153,5 +160,5 @@ export async function release(pos, opts = {}, io = {}) {
153
160
  log(`✓ share for "${r.recipient}"${share.qa ? " (QA, untracked)" : ""} → ${share.url}`);
154
161
  }
155
162
  if (!shares.length) log(` no --to given: \`hyperdecks share new ${imported.slug} "Me" --qa\` makes a link for your own viewing`);
156
- return { slug: imported.slug, deckDir, commit: sha, deckUrl: imported.deckUrl, totalDuration: checked.totalDuration, presenter: job.presenter ?? null, files: bundle.files, shares };
163
+ return { slug: imported.slug, deckDir, commit: sha, deckUrl: imported.deckUrl, totalDuration: checked.totalDuration, silent: !!checked.silent, presenter: summary, files: bundle.files, shares };
157
164
  }
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.8.2";
11
+ export const VERSION = "0.9.0";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -156,7 +156,7 @@ export const api = {
156
156
  },
157
157
  };
158
158
 
159
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo"]);
159
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo"]);
160
160
  // release's recipients, in order: each --to starts one, and an --email right
161
161
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
162
162
  // opts.email stays the last --email for the other commands (share new).
package/src/source.mjs CHANGED
@@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
11
11
  import { basename, extname, join, resolve } from "node:path";
12
12
  import { api, client, uploadBytes } from "./runtime.mjs";
13
13
  import { HOSTED_LAYOUT_WHY, MESSAGES } from "./checkRules.mjs";
14
+ import { withLoadingBlock } from "./loadingBlock.mjs";
14
15
  import { SOURCE_FILES, getDeckSource, hostedCheck, hostedRelease, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
15
16
 
16
17
  export * from "./hosted.mjs";
@@ -39,8 +40,10 @@ export async function hostedLayoutCheck(opts, { slug } = {}, { measure, measureF
39
40
  // check <slug> --hosted [--layout]: the hosted rules, plus the layout measured
40
41
  // here when --layout asks for it (in place of the hosted check's "layout not
41
42
  // checked" warning).
43
+ // --fix first puts the draft's loading state right (as check --fix does a folder's).
42
44
  export async function hostedCheckCli(opts, args, io = {}) {
43
- const result = await hostedCheck(opts, args);
45
+ const fixed = args.fix ? await hostedFix(opts, args) : undefined;
46
+ const result = { ...(await hostedCheck(opts, args)), ...(fixed ? { fixed } : {}) };
44
47
  if (!opts.layout) return result;
45
48
  const layout = await hostedLayoutCheck(opts, args, io);
46
49
  const errors = [...result.errors, ...layout.errors];
@@ -48,6 +51,20 @@ export async function hostedCheckCli(opts, args, io = {}) {
48
51
  return { ...result, errors, passed: errors.length === 0, warnings: [...new Set([...result.warnings.filter((w) => w !== unchecked), ...layout.warnings])] };
49
52
  }
50
53
 
54
+ async function hostedFix(opts, { slug } = {}) {
55
+ if (!slug) throw new Error("slug is required");
56
+ const got = await getDeckSource(opts, { slug, file: "index.html" });
57
+ const html = got.files?.["index.html"];
58
+ if (typeof html !== "string") throw new Error(`hosted deck "${slug}" has no index.html`);
59
+ // The CLI's copy of the block only decides whether to write; the draft's write
60
+ // puts it right with the server's copy, and what it reports is what was stored.
61
+ const { fixed, skipped } = withLoadingBlock(html);
62
+ if (skipped) return [`index.html: not fixed: ${skipped}`];
63
+ if (!fixed) return [];
64
+ const written = await writeDeckSource(opts, { slug, file: "index.html", content: html, baseRev: got.rev });
65
+ return written?.fixed ? [`index.html: ${written.fixed}`] : [];
66
+ }
67
+
51
68
  // release <slug> --hosted: the same layout check the local release runs, then
52
69
  // the release pinned to the rev that was measured, so a later write can't slip
53
70
  // an unmeasured draft through. A slide that overflows a phone refuses it.
@@ -82,10 +99,15 @@ export async function sourcePush(pos, opts = {}, { log = (line) => console.log(l
82
99
  if (!files.includes("index.html")) throw new Error(`no index.html in ${dir}`);
83
100
  let baseRev = opts["base-rev"] !== undefined ? Number(opts["base-rev"]) : undefined;
84
101
  let rev;
102
+ const fixed = [];
85
103
  for (const file of files) {
86
- ({ rev } = await writeDeckSource(opts, { slug, file, content: readFileSync(join(/*turbopackIgnore: true*/ dir, file), "utf8"), baseRev }));
104
+ let written;
105
+ ({ rev, ...written } = await writeDeckSource(opts, { slug, file, content: readFileSync(join(/*turbopackIgnore: true*/ dir, file), "utf8"), baseRev }));
106
+ // The draft's write may put index.html's loading state right: say so, so the folder isn't silently behind.
107
+ if (written.fixed) fixed.push(`${file}: ${written.fixed}`);
87
108
  baseRev = baseRev === undefined ? undefined : rev; // each write moves the rev
88
109
  }
110
+ for (const f of fixed) log(`✓ the draft fixed ${f}; \`hyperdecks source pull ${slug}\` to bring it into the folder`);
89
111
  log(`✓ pushed ${files.join(", ")} to ${slug}: rev ${rev}`);
90
112
  return { slug, rev, files };
91
113
  }