@hypersocial/decks-cli 0.8.1 → 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.1",
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",
@@ -147,6 +147,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
147
147
  | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
148
148
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
149
149
  | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
150
+ | Slides or scroll, viewer toggle | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off]` | `set_deck_view` |
150
151
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
151
152
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
152
153
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
@@ -159,6 +160,15 @@ Disabling the parent link (`share off`) turns off its forwards too, and
159
160
  `share forward default` (or `allowForward: null`) drops a link's own setting
160
161
  so it follows the deck's.
161
162
 
163
+ How a deck opens (slides or scroll) is a deck setting too: `deck view <slug>
164
+ scroll` (`set_deck_view { slug, view: "scroll" }`) takes effect on every link's
165
+ next load, with no re-release; `default` (`view: null`) goes back to what the
166
+ HTML says. Viewers get a slides/scroll toggle in the player, remembered per
167
+ link, unless you turn it off (`--toggle off`, `viewToggle: false`). A viewer
168
+ who already switched keeps their own choice while the toggle is on; turn the
169
+ toggle off to make every viewer see the deck's view.
170
+ `deck list --json` / `list_decks` and `deck stats` / `deck_stats` show both.
171
+
162
172
  ### 1. Ground it in the user's own material
163
173
 
164
174
  Build the deck from what you can already read in this session: files in the
@@ -197,13 +207,15 @@ Files, all in `decks/<slug>/`:
197
207
  keep the cues at the words they belong to.
198
208
  - `evidence.md`: the sources table. Private: release never uploads it.
199
209
 
200
- The deck contract (`check` enforces each line):
210
+ The deck contract (`check` enforces or warns on each line):
201
211
 
202
212
  - One `<section class="slide" id="<id>">` per slide; ids are lowercase,
203
213
  unique, and in the same order as the `##` blocks in `script.md`.
204
214
  - Every slide has `data-note="<source of its facts>"`, e.g.
205
215
  `data-note="Q3 numbers: finance-update.pdf, 2026-10-02"`. A slide with no
206
- 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`.
207
219
  - A title (first `<h1>`/`<h2>`) over 28 characters carries
208
220
  `data-title-mobile="<shorter>"`. The scaffold puts one on every title.
209
221
  - Each slide keeps its `<div class="narration-slot"></div>` (where the
@@ -213,6 +225,24 @@ The deck contract (`check` enforces each line):
213
225
  - The player script stays
214
226
  `<script type="module" src="https://decks.hypersocial.ai/player/v1/player.js" data-manifest="…">`.
215
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.
237
+ - To read the deck as one long page instead of slide by slide, add
238
+ `data-view="scroll"` to that player script (on the hosted MCP, through
239
+ write_deck_source). The slides stay as they are: they stack and snap, the
240
+ page fades to each landed slide's background (`data-bg="#ffd166"` on a
241
+ `<section>` overrides it), the presenter travels to each slide's
242
+ `narration-slot` as you scroll, and the narration scrolls the page on by
243
+ itself. Remove the attribute to go back. For a released deck, prefer the
244
+ deck setting (`deck view <slug> scroll`, `set_deck_view`): it overrides the
245
+ attribute and needs no re-release.
216
246
  - Local images and files sit inside the deck folder and are referenced
217
247
  relatively (`img/chart.png`). Hosted images (`gen-image` URLs) are fine too.
218
248
  - A file the deck's own JavaScript loads (`fetch('media/data.json')`, a clip
@@ -238,6 +268,14 @@ its `##` heading.
238
268
  | `glass` | dark or busy slides: head and captions on one frosted panel |
239
269
  | `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
240
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
+
241
279
  The live head's face follows script cues too (`[nod]`, `[look #card]`,
242
280
  `[smile]`, `[gesture: lightbulb]`; see the script topic); a still face ignores them.
243
281
 
@@ -283,12 +321,79 @@ Viewers can poke the live head: a click or tap makes it react (an "oof", a
283
321
  flinch) and a drag pulls it a little before it springs back, without pausing
284
322
  the narration.
285
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
+
286
377
  <!-- topic: design -->
287
378
  ### Design: make it look good
288
379
 
289
380
  A narrated deck is only as convincing as it looks. Design before you write the
290
381
  narration, and check the result at both widths (step 6).
291
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
+
292
397
  **One idea per slide.** The title states the idea as a sentence a listener
293
398
  could repeat ("Narration renders in two minutes", not "Speed"). Everything
294
399
  else on the slide supports that sentence; if it needs a second idea, it is a
@@ -392,8 +497,9 @@ where there is room so slide changes move the presenter.
392
497
  One style per deck, described the same way every time (subject, medium,
393
498
  palette hexes, "no text"). `--model nano-banana-2.1` when the image must
394
499
  carry legible type.
395
- - Video: `gen-video` for a short loop behind a hero or a product moment, with
396
- `[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
397
503
  plays on repeat should be made with `--loop` (`loop: true`): it starts and
398
504
  ends on the same frame, so there is no jump at the seam. Give it `--image`
399
505
  to loop a still you already have; without one a keyframe is rendered first.
@@ -430,21 +536,6 @@ still.
430
536
  - No CSS animation on slides beyond `.reveal`; the player animates the slide
431
537
  change.
432
538
 
433
- **Review before release (required).** After `check` passes and the deck is
434
- imported, run `hyperdecks review <slug> --script decks/<slug>/script.md`
435
- (`review_deck`, then poll `check_review_job`). Act on every finding:
436
-
437
- - `fix`: change the slide or the script, re-import (or `release --qa`), and
438
- review again until no `fix` is left. Do not release with a `fix` open.
439
- - `consider`: apply it when it serves the slide's one idea; otherwise note in
440
- your reply to the user why you left it.
441
- - A finding you disagree with (a deliberate choice, a misread screenshot):
442
- say so to the user with the reason instead of silently ignoring it.
443
-
444
- If review cannot run (no credits, the service is down), say so to the user and
445
- do the step 6 screenshot pass yourself as the reviewer: the same desktop and
446
- phone screenshots, the same `fix` bar.
447
-
448
539
  <!-- topic: script -->
449
540
  ### 3. Write slides and script
450
541
 
@@ -498,7 +589,7 @@ phone screenshots, the same `fix` bar.
498
589
  | `[mood: excited]` / `[emote: name]` | the head's mood or emote |
499
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 |
500
591
  | `[pause: 0.5]` | a beat of silence (≤ 5 s) |
501
- | `[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 |
502
593
  | `[go: #step-2]` / `[go: slot]` | fly beside that item, grow it, fade the other items / back to the slot |
503
594
  | `[look: h2]` / `[look #card]` | look at the title / the card, then back |
504
595
  | `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
@@ -513,6 +604,10 @@ phone screenshots, the same `fix` bar.
513
604
  - No `voice:` in the front matter means the user's first ready voice. If
514
605
  `hyperdecks voice list` shows none, ask the user to record one and run
515
606
  `voice clone` (it asks them for consent; never clone anyone else's voice).
607
+ - Names speech-to-text spells another way fail narrate's transcript check
608
+ (step 4). Map what it heard to what you wrote with a `normalize:` entry in
609
+ the front matter, `heard=written`, comma-separated: a clip that says
610
+ "Minimoji" but is transcribed as "Moji" needs `normalize: moji=minimoji`.
516
611
 
517
612
  ### 4. Narrate and read the guard
518
613
 
@@ -529,16 +624,24 @@ reword the sentence (spell numbers and names the way they are said, or add a
529
624
  ### 5. Check
530
625
 
531
626
  ```bash
532
- hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout]
627
+ hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout] [--strict] [--fix]
533
628
  ```
534
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
+
535
634
  `--layout` lays each slide out in Chromium (needs `hyperdecks preview setup`).
536
635
  At the stage size it warns, without failing, when a slide's presenter would
537
636
  overlap its title. On a 390×844 phone (the share player's 374×736 slide card)
538
637
  it fails any slide whose content overflows: the player never scrolls a slide.
638
+ `release` (local or `--hosted`) always runs this layout check and refuses an
639
+ overflowing slide; `check <slug> --hosted --layout` measures a hosted draft.
539
640
 
540
641
  `--forbid` lists phrases that must not reach this audience (another customer,
541
- an internal codename). Messages and what to do:
642
+ an internal codename). It matches the page's text, alt text, titles and
643
+ script.md, not URLs in attributes, so forbidding your own brand still passes
644
+ with the player's `decks.hypersocial.ai` script tag. Messages and what to do:
542
645
 
543
646
  | Message starts with | Fix |
544
647
  |---|---|
@@ -557,10 +660,11 @@ an internal codename). Messages and what to do:
557
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 |
558
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 |
559
662
  | `player script must be` / `no player script` | restore the player/v1 script tag |
560
- | `the player script has no data-manifest` | run `narrate` |
561
- | `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) |
562
666
  | `forbidden phrase` | remove it from the page, script or narration |
563
- | `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`) |
564
668
  | `slide <id>: title … needs a data-title-mobile` | add a shorter title |
565
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 |
566
670
  | `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
@@ -574,6 +678,21 @@ an internal codename). Messages and what to do:
574
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) |
575
679
  | ⚠ `slide <id>: the … presenter would overlap the title` | move that slide's `.narration-slot`, pad the text away from it, or use `corner` |
576
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
+
577
696
  ### 6. Look at it, at both widths
578
697
 
579
698
  To watch it as it will really play, run `hyperdecks play decks/<slug>`. It plays
@@ -590,7 +709,7 @@ and screenshot it with Playwright at 1440×900 and 390×844, every slide
590
709
  (`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
591
710
  clear of the text, the mobile titles in use. Fix and repeat until both read well.
592
711
 
593
- Then the required review (see **Design**, *Review before release*): once the
712
+ Then the required review (see **5. Check**, *Review before release*): once the
594
713
  deck is imported, `hyperdecks review <slug> --script decks/<slug>/script.md`
595
714
  (`review_deck`, then `check_review_job`) has GPT-6 Luna look at every slide of
596
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?
@@ -90,7 +90,9 @@ makes the hello deck; continue from step 5 below.
90
90
  10. Later: `list_shares` and `deck_stats` (both show the card),
91
91
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
92
92
  email as the deck's moving preview (it needs a recipient's link, not a QA
93
- one); `set_share_notify` to mute a link's "opened" alerts;
93
+ one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle }`
94
+ for how the deck opens and whether viewers may switch (every link, next
95
+ load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
94
96
  `set_recipient_archived` to tidy the activity feed; `credits` for the
95
97
  balance. `remove_deck` deletes a deck and breaks every link to it: confirm
96
98
  with the user first.
@@ -98,9 +100,16 @@ makes the hello deck; continue from step 5 below.
98
100
  ## Rules that matter
99
101
 
100
102
  - Every slide is `<section class="slide" id="…" data-note="<source of its facts>">`,
101
- in the same order as the `## <id>` blocks in `script.md`.
102
- - Keep the player script tag and the `<style data-hd-loading>` block the
103
- 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.
104
113
  - Edit the draft freely: recipients keep seeing the last release until you call
105
114
  `release_deck` again. A release is refused when the check fails, when
106
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
@@ -156,6 +159,27 @@ export function deckFolder(deckDir) {
156
159
  };
157
160
  }
158
161
 
162
+ // The browser half of check --layout, on a folder holding the deck's index.html
163
+ // (the hosted release measures a draft the same way, from a temp folder). A
164
+ // presenter over the title is a warning, not a failure: only a person can judge
165
+ // it. A slide that overflows the phone card is an error: the player never
166
+ // scrolls a slide. No Chromium is a warning, as each measurement is optional.
167
+ export async function layoutCheck(deckDir, html, manifest = null, { measure = measureLayout, measureFit = measurePhoneFit } = {}) {
168
+ const errors = [], warnings = [];
169
+ const { width: w, height: h } = stageSize(html);
170
+ try {
171
+ warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
172
+ } catch (e) {
173
+ warnings.push(MESSAGES.layoutUnchecked(e.message));
174
+ }
175
+ try {
176
+ errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
177
+ } catch (e) {
178
+ warnings.push(MESSAGES.layoutUnchecked(e.message));
179
+ }
180
+ return { errors, warnings: [...new Set(warnings)] };
181
+ }
182
+
159
183
  // Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
160
184
  export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd, measure = measureLayout, measureFit = measurePhoneFit } = {}) {
161
185
  const deckDir = resolveDeckDir(dirArg, cwd);
@@ -164,22 +188,11 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
164
188
  const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
165
189
  const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir), ...deckFolder(deckDir) }, { fetch: fetchImpl });
166
190
  const { errors, warnings } = result;
167
- // A presenter over the title is a warning, not a failure: only a person can
168
- // judge it. A slide that overflows the phone card is an error. Both need a
169
- // browser, so they run only with --layout.
191
+ // Both need a browser, so they run only with --layout (release always asks).
170
192
  if (opts.layout) {
171
- const { width: w, height: h } = stageSize(html);
172
- try {
173
- warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
174
- } catch (e) {
175
- warnings.push(MESSAGES.layoutUnchecked(e.message));
176
- }
177
- // A slide that scrolls on a phone fails: the player never scrolls a slide.
178
- try {
179
- errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
180
- } catch (e) {
181
- warnings.push(MESSAGES.layoutUnchecked(e.message));
182
- }
193
+ const layout = await layoutCheck(deckDir, html, manifest, { measure, measureFit });
194
+ errors.push(...layout.errors);
195
+ warnings.push(...layout.warnings);
183
196
  }
184
197
  return {
185
198
  deckDir,
@@ -190,8 +203,21 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
190
203
  };
191
204
  }
192
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
+
193
217
  export async function check(pos, opts = {}, { log = (line) => console.log(line), ...io } = {}) {
194
- 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 } : {}) };
195
221
  if (!opts.json) {
196
222
  log(`deck "${result.slug}": ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
197
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,11 +79,14 @@ 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, "'");
87
+ // Blank every attribute value that is an absolute or protocol-relative URL
88
+ // (src, href, data-manifest, …); the audience never reads those.
89
+ const withoutUrlAttrs = (html) => html.replace(/(\s[\w:.-]+\s*=\s*)(?:"(?:https?:)?\/\/[^"]*"|'(?:https?:)?\/\/[^']*')/gi, '$1""');
79
90
  const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
80
91
 
81
92
  // Comments blanked but newlines kept, so line numbers still match the file.
@@ -100,6 +111,15 @@ export function presenterStyleErrors(html, script = "") {
100
111
  return errs;
101
112
  }
102
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
+
103
123
  // "zoom #dash-row" → { name: "zoom", target: "#dash-row" }; null when it is not a transition.
104
124
  export function parseTransition(value) {
105
125
  if (typeof value !== "string") return null;
@@ -279,11 +299,26 @@ export function slides(html) {
279
299
  });
280
300
  }
281
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);
282
306
  export function playerScript(html) {
283
307
  const tag = [...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t));
284
- 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
+ };
285
315
  }
286
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
+
287
322
  // Relative references: src, href, poster, data-presenter-image and data-alpha-stacked (either quote), srcset candidates and
288
323
  // CSS url(…) in style attributes and <style> blocks; everything that is not a URL
289
324
  // scheme, protocol-relative or a fragment. Query strings and fragments dropped.
@@ -504,7 +539,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
504
539
 
505
540
  // Everything checkable from the deck's own files. `exists(ref)` answers whether a
506
541
  // local reference resolves inside the deck folder: true, false or "outside".
507
- export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
542
+ export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false, strict = false } = {}) {
508
543
  const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
509
544
  const player = playerScript(html);
510
545
  if (!player) errs.push(MESSAGES.noPlayer());
@@ -515,10 +550,11 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
515
550
  if (player && !loading?.[1].includes("hd-fail-open")) errs.push(MESSAGES.noLoading());
516
551
  const all = slides(html);
517
552
  if (!all.length) errs.push(MESSAGES.noSlides());
553
+ const notes = notesRequired(html, { strict });
518
554
  all.forEach((s, i) => {
519
555
  const id = s.id || `${i + 1}`;
520
556
  if (!s.id) errs.push(MESSAGES.slideId(i + 1));
521
- 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));
522
558
  if (s.title.length > MOBILE_TITLE_CHARS && !attr(s.titleTag, "data-title-mobile") && !attr(s.tag, "data-title-mobile")) {
523
559
  errs.push(MESSAGES.mobileTitle(id, s.title));
524
560
  }
@@ -530,9 +566,13 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
530
566
  else if (!found) errs.push(MESSAGES.localRef(ref));
531
567
  }
532
568
  const page = textOf(uncommented(html).replace(/<(script|style)\b[\s\S]*?<\/\1>/g, " ")).toLowerCase();
569
+ // The raw HTML too (alt text, <title>, meta content, inline script), less
570
+ // attribute values that are URLs: the player's own src (PLAYER_V1) names
571
+ // decks.hypersocial.ai, so forbidding the brand would otherwise always fail.
572
+ const raw = withoutUrlAttrs(uncommented(html)).toLowerCase();
533
573
  for (const phrase of phraseList(forbid)) {
534
574
  const p = phrase.toLowerCase();
535
- if (page.includes(p) || uncommented(html).toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
575
+ if (page.includes(p) || raw.includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
536
576
  if (script.toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "script.md"));
537
577
  }
538
578
  return errs;
@@ -708,7 +748,9 @@ const isHostedAsset = (url) => {
708
748
  // resolves (true, false or "outside"); `hosted: true` means there is no folder,
709
749
  // so every local reference is an error naming upload_deck_asset, a hotlinked
710
750
  // image is a warning, and the layout rules (which need Chromium) are reported as
711
- // 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.
712
754
  export async function checkSources({ html, script = "" }, opts = {}, { fetch: fetchImpl = globalThis.fetch } = {}) {
713
755
  const forbid = phraseList(opts.forbid);
714
756
  const flagCap = opts["max-duration"] ?? opts.maxDuration;
@@ -717,14 +759,22 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
717
759
  throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
718
760
  }
719
761
  const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
720
- 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));
721
766
  // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
722
767
  const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
723
768
  for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
724
769
  const ids = slides(html).map((s) => s.id);
725
770
  const player = playerScript(html);
726
771
  let manifest = null;
727
- 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());
728
778
  if (player?.manifest) {
729
779
  const got = await fetchCheck(fetchImpl, player.manifest, JSON_TYPE);
730
780
  if (got.why) errors.push(MESSAGES.manifestUnavailable(player.manifest, got.why));
@@ -775,7 +825,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
775
825
  errors.push(...timelineErrors(clip, timeline));
776
826
  }
777
827
  }
778
- const warnings = transitionWarnings(html, script, manifest);
828
+ warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
779
829
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
780
830
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
781
831
  if (opts.hosted) {
@@ -787,6 +837,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
787
837
  totalDuration: manifest ? Number(manifest.totalDuration) : null,
788
838
  manifestUrl: player?.manifest ?? null,
789
839
  manifest,
840
+ silent,
790
841
  passed: errors.length === 0,
791
842
  errors,
792
843
  warnings: [...new Set(warnings)],
package/src/commands.mjs CHANGED
@@ -58,6 +58,7 @@ async function deckStats(pos, opts) {
58
58
  console.log(` unique viewers: ${s.viewers}`);
59
59
  console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
60
60
  console.log(` total engaged: ${fmtMs(s.totalMs)}`);
61
+ if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
61
62
  if (s.recent?.length) {
62
63
  console.log(" recent:");
63
64
  for (const r of s.recent) {
@@ -153,6 +154,31 @@ async function deckForward(pos, opts) {
153
154
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
154
155
  }
155
156
 
157
+ const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off]";
158
+ const VIEWS = { slides: "slides", scroll: "scroll", default: null };
159
+ async function deckView(pos, opts) {
160
+ const [slug, mode] = pos;
161
+ const toggle = opts.toggle;
162
+ if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle"))) throw new Error(VIEW_USAGE);
163
+ if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
164
+ // A trailing --toggle with no value is a mistake, not "leave it".
165
+ if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
166
+ const out = await ops.setDeckView(opts, slug, {
167
+ view: mode === undefined ? undefined : VIEWS[mode],
168
+ viewToggle: toggle === undefined ? undefined : toggle === "on",
169
+ });
170
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
171
+ const said = [];
172
+ if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
173
+ if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
174
+ if (out.applies === false) {
175
+ console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
176
+ console.warn(`warning: ${out.warning}`);
177
+ return;
178
+ }
179
+ console.log(`OK deck ${slug} ${said.join(", ")} (every link, on its next load)`);
180
+ }
181
+
156
182
  function shareUrl(pos, opts) {
157
183
  const [token] = pos;
158
184
  if (!token) throw new Error("usage: share url <token>");
@@ -264,6 +290,7 @@ async function importDeck(pos, opts) {
264
290
  console.log(` html: ${(out.bytes / 1024).toFixed(1)} KB`);
265
291
  console.log(` deck: ${out.deckUrl}`);
266
292
  }
293
+ for (const w of out.warnings ?? []) console.warn(`warning: ${w}`);
267
294
  }
268
295
 
269
296
  async function importDesignDeck(pos, opts) {
@@ -304,23 +331,28 @@ commands:
304
331
  login [--token <pat>] [--broker <url>] [--dev]
305
332
  logout [--dev]
306
333
  whoami [--json] [--dev]
307
- deck list|stats|open|rm|forward
334
+ deck list|stats|open|rm|forward|view
308
335
  deck forward on|off <slug>
309
336
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
337
+ deck view <slug> [slides|scroll|default] [--toggle on|off]
338
+ how the deck opens in the player (default: as its HTML says, data-view or slides) and
339
+ whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
310
340
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
311
341
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
312
342
  in your saved brand's colours, fonts and logo (--no-brand: the template's look)
313
343
  layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
314
- 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]
315
345
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
316
- 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
317
349
  deck new|check|release … --hosted
318
350
  the same on a hosted draft (no folder, no git: what chat agents use over the remote MCP)
319
351
  source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
320
352
  move a hosted draft (index.html, script.md, evidence.md) to a folder and back
321
353
  asset add <slug> <file|url> [--name n]
322
354
  file an image, video or font under the deck; prints its hosted URL
323
- guide [workflow|format|script|design|layouts|presenter|transitions|check]
355
+ guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
324
356
  the skill, one topic at a time (what deck_guide serves)
325
357
  play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
326
358
  play the folder as it is, committed or not, with its real face and voice, on 127.0.0.1
@@ -676,9 +708,10 @@ function commandTable(pos, rest, opts) {
676
708
  },
677
709
  check: async () => {
678
710
  const { check } = await import("./check.mjs");
679
- const { hostedCheck } = await import("./source.mjs");
680
- const result = opts.hosted ? await hostedCheck(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"] }) : await check(pos.slice(1), opts);
711
+ const { hostedCheckCli } = await import("./source.mjs");
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);
681
713
  if (opts.hosted && !opts.json) {
714
+ for (const f of result.fixed ?? []) console.log(f.includes(": not fixed:") ? `⚠ ${f}` : `✓ fixed ${f}`);
682
715
  console.log(`deck "${pos[1]}" rev ${result.rev}: ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
683
716
  for (const e of result.errors) console.log(` - ${e}`);
684
717
  if (!result.errors.length) console.log("✓ check passed");
@@ -689,8 +722,8 @@ function commandTable(pos, rest, opts) {
689
722
  },
690
723
  release: async () => {
691
724
  if (opts.hosted) {
692
- const { hostedRelease } = await import("./source.mjs");
693
- const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] });
725
+ const { hostedReleaseCli } = await import("./source.mjs");
726
+ const out = await hostedReleaseCli(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] }, opts.json ? { log: (line) => console.error(line) } : {});
694
727
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
695
728
  console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
696
729
  const who = presenterLine(out.presenter);
@@ -849,6 +882,7 @@ function commandTable(pos, rest, opts) {
849
882
  "deck open": () => deckOpen(rest, opts),
850
883
  "deck rm": () => deckRemove(rest, opts),
851
884
  "deck forward": () => deckForward(rest, opts),
885
+ "deck view": () => deckView(rest, opts),
852
886
  "share new": () => shareNew(rest, opts),
853
887
  "share list": () => shareList(rest, opts),
854
888
  "share on": () => shareToggle(rest, opts, true),
@@ -0,0 +1,45 @@
1
+ // Whether a deck runs on the Hyperdecks player (player/v1). One rule, shared by
2
+ // Convex (decks.usesPlayer), the share route (owner chip vs fallback pill), the
3
+ // importers and the CLI, so they never disagree. Scroll mode, the viewer
4
+ // slides/scroll toggle and the owner chip all live in player/v1; a deck that
5
+ // runs its own runtime (a legacy copy-and-swap deck.js, a plain HTML export)
6
+ // gets none of them.
7
+
8
+ export const loadsPlayer = (html) => /\/player\/v1\/player\.js\b/.test(String(html ?? ""));
9
+
10
+ const SCRIPT_SRC = /<script\b[^>]*\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>/gi;
11
+ const MAX_SCRIPT_NAME = 120;
12
+
13
+ // The deck's own entry script, for the warning ("it runs deck.js"): its first
14
+ // module script, else its first script with a src. Only the file name, so
15
+ // https://cdn.example/js/deck.js reads as deck.js. Null when
16
+ // the deck loads player/v1, has no script with a src, or its script is an
17
+ // uploaded asset (a storage URL names no file).
18
+ export function ownScript(html) {
19
+ const doc = String(html ?? "").replace(/<!--[\s\S]*?-->/g, "");
20
+ if (loadsPlayer(doc)) return null;
21
+ const tags = [...doc.matchAll(SCRIPT_SRC)].map((m) => ({ tag: m[0], src: (m[1] ?? m[2] ?? m[3] ?? "").trim() })).filter((s) => s.src);
22
+ const pick = tags.find((s) => /\stype\s*=\s*["']?module\b/i.test(s.tag)) ?? tags[0];
23
+ // An imported asset's src is a storage URL (…/api/storage/<uuid>): no name to show.
24
+ if (!pick || /\/api\/storage\//.test(pick.src)) return null;
25
+ const name = pick.src.split(/[?#]/)[0].split("/").filter(Boolean).pop() || pick.src;
26
+ return name.slice(0, MAX_SCRIPT_NAME);
27
+ }
28
+
29
+ // What Convex stores on the deck row.
30
+ export function deckPlayer(html) {
31
+ if (loadsPlayer(html)) return { usesPlayer: true, ownScript: undefined };
32
+ return { usesPlayer: false, ownScript: ownScript(html) ?? undefined };
33
+ }
34
+
35
+ const runs = (script) => (script ? ` (it runs its own ${script})` : "");
36
+
37
+ // Import: a warning, never a refusal (plain HTML exports are legitimate decks).
38
+ export const importPlayerWarning = (script) =>
39
+ `deck doesn't load player/v1${runs(script)}: scroll mode, the owner chip and the viewer toggle won't apply`;
40
+
41
+ // set_deck_view / `deck view` on such a deck: the value is saved for a later
42
+ // rebuild on player/v1, but does nothing today.
43
+ export const viewPlayerWarning = (slug, script) =>
44
+ `${slug} doesn't load player/v1${runs(script)}, so slides/scroll and the viewer toggle won't apply. ` +
45
+ "The setting is saved and takes effect once the deck is rebuilt on player/v1 (deck_guide explains the format).";
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/ops.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer.mjs";
1
2
  import { zipSync } from "fflate";
2
3
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
3
4
  import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
@@ -26,6 +27,12 @@ export async function listDecks(opts = {}) {
26
27
  status: d.status,
27
28
  ownerEmail: d.ownerEmail ?? null,
28
29
  hasHtml: !!(d.html || d.htmlStorageId || d.hasHtml),
30
+ // How it opens (null: as its HTML says) and whether viewers may switch.
31
+ view: d.view ?? null,
32
+ viewToggle: d.viewToggle !== false,
33
+ // false: the deck runs its own player, so view/viewToggle do not apply
34
+ // (null: not known yet, treated as on).
35
+ usesPlayer: d.usesPlayer ?? null,
29
36
  }));
30
37
  }
31
38
 
@@ -466,6 +473,32 @@ export async function setDeckForwarding(opts, slug, allowForward) {
466
473
  return { slug, allowForward: !!allowForward };
467
474
  }
468
475
 
476
+ // How a deck opens in the player and whether viewers may switch: view is
477
+ // "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
478
+ // true/false. Either may be left out; both go in one owner-only mutation, so a
479
+ // failure changes neither.
480
+ // Applies on every link's next load (the route injects it), with no re-release.
481
+ export const DECK_VIEWS = ["slides", "scroll"];
482
+ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
483
+ if (!slug) throw new Error("slug is required");
484
+ if (view === undefined && viewToggle === undefined) throw new Error("give a view (slides, scroll or default) and/or a viewToggle");
485
+ if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
486
+ const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
487
+ slug,
488
+ ...(view !== undefined ? { view } : {}),
489
+ ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
490
+ });
491
+ // Saved either way; a deck on its own player says the setting won't apply.
492
+ const applies = res?.applies !== false;
493
+ return {
494
+ slug,
495
+ ...(view !== undefined ? { view } : {}),
496
+ ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
497
+ applies,
498
+ ...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
499
+ };
500
+ }
501
+
469
502
  // Register a new deck (draft) you own. Refuses any slug that already exists:
470
503
  // upsert would otherwise reset your own live deck to a draft and rename it.
471
504
  export async function createDeck(opts, { slug, title }) {
@@ -614,12 +647,17 @@ export async function importDeckFromBytes(opts = {}, fields = {}, adapter = {})
614
647
  await c.mutation(api.decks.setHtmlStorage, { slug, storageId: htmlStorageId, bytes: htmlBytes.byteLength });
615
648
 
616
649
  const t = target(opts);
650
+ // A warning, never a refusal: plain HTML exports are legitimate decks.
651
+ // Read before the asset rewrite, while the script still has its own name.
652
+ const player = deckPlayer(parsed.html);
617
653
  return {
618
654
  slug,
619
655
  title,
620
656
  status,
621
657
  assets: parsed.assets.length,
622
658
  bytes: htmlBytes.byteLength,
659
+ usesPlayer: player.usesPlayer,
660
+ warnings: player.usesPlayer ? [] : [importPlayerWarning(player.ownScript)],
623
661
  deployment: t.name,
624
662
  // The owner-facing dashboard view (Preview / Stats / Share / Present), not
625
663
  // the raw /d/<slug> storage redirect.
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.
@@ -76,23 +77,27 @@ export function recipients(opts) {
76
77
  }
77
78
 
78
79
  export async function release(pos, opts = {}, io = {}) {
79
- const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd } = io;
80
+ const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd, measure, measureFit } = io;
80
81
  const deckDir = resolveDeckDir(pos[0], cwd);
81
82
  const to = recipients(opts);
82
83
 
83
84
  const dirty = changes(deckDir);
84
85
  if (dirty) throw new Error(`refusing to release: ${deckDir} has uncommitted changes\n${dirty}\ncommit them first so the release corresponds to a commit`);
85
86
 
86
- const checked = await checkDeck(deckDir, opts, { fetch, cwd });
87
+ // Always with the layout check: a slide that overflows a phone must not ship
88
+ // (the hosted release runs the same measurement, hostedLayoutCheck in source.mjs).
89
+ const checked = await checkDeck(deckDir, { ...opts, layout: true }, { fetch, cwd, ...(measure ? { measure } : {}), ...(measureFit ? { measureFit } : {}) });
87
90
  const slug = opts.slug || checked.slug;
88
91
  if (!SLUG.test(slug)) throw new Error(`"${slug}" is not a deck slug (lowercase letters, digits, dashes): rename the folder or pass --slug`);
89
92
  if (checked.errors.length) throw new Error(`check failed for ${slug}:\n${checked.errors.map((e) => ` - ${e}`).join("\n")}`);
90
93
  const bundle = releaseBundle(deckDir);
91
94
  const loose = untracked(deckDir, bundle.files);
92
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`);
93
- log(`✓ check passed: ${checked.slides} slides, ${checked.totalDuration}s`);
96
+ log(`✓ check passed: ${checked.slides} slides, ${checked.silent ? "silent (no narration)" : `${checked.totalDuration}s`}`);
97
+ for (const w of checked.warnings) log(` ⚠ ${w}`);
94
98
  for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
95
99
 
100
+ if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: bundle, summary: null });
96
101
  // Cached slides cost nothing, so this only renders what changed since the commit.
97
102
  // Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
98
103
  const narrateOpts = { ...opts, slug };
@@ -116,6 +121,11 @@ export async function release(pos, opts = {}, io = {}) {
116
121
  if (presenter) log("✓ presented by your avatar");
117
122
  // A terminal user sees this before it ships, so the CLI warns rather than refuses.
118
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 }) {
119
129
  const imported = await ops.importDeckFromBytes(opts, {
120
130
  slug,
121
131
  title: opts.title,
@@ -150,5 +160,5 @@ export async function release(pos, opts = {}, io = {}) {
150
160
  log(`✓ share for "${r.recipient}"${share.qa ? " (QA, untracked)" : ""} → ${share.url}`);
151
161
  }
152
162
  if (!shares.length) log(` no --to given: \`hyperdecks share new ${imported.slug} "Me" --qa\` makes a link for your own viewing`);
153
- 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 };
154
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.1";
11
+ export const VERSION = "0.9.0";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -98,6 +98,9 @@ export const api = {
98
98
  listForViewer: makeFunctionReference("decks:listForViewer"),
99
99
  remove: makeFunctionReference("decks:remove"),
100
100
  setAllowForward: makeFunctionReference("decks:setAllowForward"),
101
+ setView: makeFunctionReference("decks:setView"),
102
+ setViewToggle: makeFunctionReference("decks:setViewToggle"),
103
+ setViewSettings: makeFunctionReference("decks:setViewSettings"),
101
104
  setHtmlStorage: makeFunctionReference("decks:setHtmlStorage"),
102
105
  upsert: makeFunctionReference("decks:upsert"),
103
106
  whoami: makeFunctionReference("decks:whoami"),
@@ -153,7 +156,7 @@ export const api = {
153
156
  },
154
157
  };
155
158
 
156
- 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"]);
157
160
  // release's recipients, in order: each --to starts one, and an --email right
158
161
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
159
162
  // opts.email stays the last --email for the other commands (share new).
package/src/source.mjs CHANGED
@@ -6,15 +6,75 @@
6
6
  // hyperdecks check <slug> --hosted
7
7
  // hyperdecks release <slug> --hosted [--to "<name>" [--email e]]... [--qa]
8
8
  // hyperdecks asset add <slug> <file|url> [--name n]
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
10
11
  import { basename, extname, join, resolve } from "node:path";
11
12
  import { api, client, uploadBytes } from "./runtime.mjs";
12
- import { SOURCE_FILES, getDeckSource, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
13
+ import { HOSTED_LAYOUT_WHY, MESSAGES } from "./checkRules.mjs";
14
+ import { withLoadingBlock } from "./loadingBlock.mjs";
15
+ import { SOURCE_FILES, getDeckSource, hostedCheck, hostedRelease, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
13
16
 
14
17
  export * from "./hosted.mjs";
15
18
 
16
19
  // ---- CLI ------------------------------------------------------------------
17
20
 
21
+ // The browser half of check --layout for a hosted draft. The hosted check runs
22
+ // in Convex and can't open a browser (HOSTED_LAYOUT_WHY), so the CLI measures
23
+ // the draft's index.html here, from a temp folder, with check.mjs's Chromium
24
+ // code. Returns the rev it measured, and { errors, warnings } as check --layout.
25
+ export async function hostedLayoutCheck(opts, { slug } = {}, { measure, measureFit } = {}) {
26
+ if (!slug) throw new Error("slug is required");
27
+ const got = await getDeckSource(opts, { slug, file: "index.html" });
28
+ const html = got.files?.["index.html"];
29
+ if (typeof html !== "string") throw new Error(`hosted deck "${slug}" has no index.html`);
30
+ const { layoutCheck } = await import("./check.mjs");
31
+ const dir = mkdtempSync(join(tmpdir(), "hyperdecks-hosted-layout-"));
32
+ try {
33
+ writeFileSync(join(dir, "index.html"), html);
34
+ return { rev: got.rev, ...(await layoutCheck(dir, html, null, { ...(measure ? { measure } : {}), ...(measureFit ? { measureFit } : {}) })) };
35
+ } finally {
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ }
39
+
40
+ // check <slug> --hosted [--layout]: the hosted rules, plus the layout measured
41
+ // here when --layout asks for it (in place of the hosted check's "layout not
42
+ // checked" warning).
43
+ // --fix first puts the draft's loading state right (as check --fix does a folder's).
44
+ export async function hostedCheckCli(opts, args, io = {}) {
45
+ const fixed = args.fix ? await hostedFix(opts, args) : undefined;
46
+ const result = { ...(await hostedCheck(opts, args)), ...(fixed ? { fixed } : {}) };
47
+ if (!opts.layout) return result;
48
+ const layout = await hostedLayoutCheck(opts, args, io);
49
+ const errors = [...result.errors, ...layout.errors];
50
+ const unchecked = MESSAGES.layoutUnchecked(HOSTED_LAYOUT_WHY);
51
+ return { ...result, errors, passed: errors.length === 0, warnings: [...new Set([...result.warnings.filter((w) => w !== unchecked), ...layout.warnings])] };
52
+ }
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
+
68
+ // release <slug> --hosted: the same layout check the local release runs, then
69
+ // the release pinned to the rev that was measured, so a later write can't slip
70
+ // an unmeasured draft through. A slide that overflows a phone refuses it.
71
+ export async function hostedReleaseCli(opts, args, { log = (line) => console.log(line), ...io } = {}) {
72
+ const layout = await hostedLayoutCheck(opts, args, io);
73
+ if (layout.errors.length) throw new Error(`check failed for ${args.slug}:\n${layout.errors.map((e) => ` - ${e}`).join("\n")}`);
74
+ for (const w of layout.warnings) log(` ⚠ ${w}`);
75
+ return hostedRelease(opts, { ...args, rev: layout.rev });
76
+ }
77
+
18
78
  export async function sourcePull(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
19
79
  const [slug, dirArg] = pos;
20
80
  if (!slug) throw new Error("usage: source pull <slug> [dir]");
@@ -39,10 +99,15 @@ export async function sourcePush(pos, opts = {}, { log = (line) => console.log(l
39
99
  if (!files.includes("index.html")) throw new Error(`no index.html in ${dir}`);
40
100
  let baseRev = opts["base-rev"] !== undefined ? Number(opts["base-rev"]) : undefined;
41
101
  let rev;
102
+ const fixed = [];
42
103
  for (const file of files) {
43
- ({ 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}`);
44
108
  baseRev = baseRev === undefined ? undefined : rev; // each write moves the rev
45
109
  }
110
+ for (const f of fixed) log(`✓ the draft fixed ${f}; \`hyperdecks source pull ${slug}\` to bring it into the folder`);
46
111
  log(`✓ pushed ${files.join(", ")} to ${slug}: rev ${rev}`);
47
112
  return { slug, rev, files };
48
113
  }