@hypersocial/decks-cli 0.8.2 → 0.9.1

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.1",
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
@@ -374,7 +460,8 @@ suit it.
374
460
  |---|---|---|
375
461
  | `hero` | the opening, a section opener; the presenter is the visual, no `.art` unless it shows the real subject (check fails while the placeholder `<div class="art">` is in: swap it for an `<img class="art">` or delete it) | stage, bottom-right; `[wave]`, `[look: h1]` |
376
462
  | `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
377
- | `product` | a screenshot in a browser frame (`.frame.phone` for a phone shot) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
463
+ | `product` | a screenshot in a browser frame (`.frame.card` for a phone) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
464
+ | `phones` | a phone app's flow: a fanned row of phone cards | stage, bottom-left; `[go: #<id>-p1]` … `p3`, `[go: slot]` |
378
465
  | `quote` | a customer or user in their own words, quoted exactly | stage, bottom-left; `[look: #<id>-quote]` |
379
466
  | `compare` | before/after, them/us, three parallel lines each | stage; `[look: #<id>-before]`, `[go: #<id>-after]` |
380
467
  | `timeline` | four milestones: history, a roadmap, a process | stage; `[go: #<id>-m1]` … `m4`, `[go: slot]` |
@@ -383,11 +470,15 @@ suit it.
383
470
  | `team` | up to four people, faces only with permission | stage; `[go: #<id>-p1]` … `p4`, `[go: slot]` |
384
471
  | `closer` | the end: one line and one action (a command, a link) | stage, bottom-left; `[go: #<id>-cta]`, `[go: slot]`, `[wave]` |
385
472
 
473
+ **Phones and backgrounds.** One phone: `product`'s `.frame.card`; a flow of
474
+ two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
475
+ `bg-dots` (never under text), a `.deco` of inked tiles.
476
+
386
477
  Presenter style follows the deck (step 2, *Which presenter style*): `stage`
387
478
  when the presenter carries the story, which is what the layouts are drawn for
388
479
  (they keep the slot's corner clear); `corner` on a slide where a big
389
480
  visual is the point and moves are not needed; `glass` on a dark or busy
390
- picture. Two layouts (`quote`, `closer`) sit left; put a `slot-left` on others
481
+ picture. Three layouts (`quote`, `phones`, `closer`) sit left; put a `slot-left` on others
391
482
  where there is room so slide changes move the presenter.
392
483
 
393
484
  **Visuals: real first, generated second.**
@@ -411,8 +502,9 @@ where there is room so slide changes move the presenter.
411
502
  One style per deck, described the same way every time (subject, medium,
412
503
  palette hexes, "no text"). `--model nano-banana-2.1` when the image must
413
504
  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
505
+ - Video: `gen-video` for a short loop behind a hero or a product moment;
506
+ never needed for a deck to work. A `muted loop` video plays while its slide
507
+ is current, no cue needed; a `data-hd-manual` one waits for `[play]`. A clip that
416
508
  plays on repeat should be made with `--loop` (`loop: true`): it starts and
417
509
  ends on the same frame, so there is no jump at the seam. Give it `--image`
418
510
  to loop a still you already have; without one a keyframe is rendered first.
@@ -449,21 +541,6 @@ still.
449
541
  - No CSS animation on slides beyond `.reveal`; the player animates the slide
450
542
  change.
451
543
 
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
544
  <!-- topic: script -->
468
545
  ### 3. Write slides and script
469
546
 
@@ -517,7 +594,7 @@ phone screenshots, the same `fix` bar.
517
594
  | `[mood: excited]` / `[emote: name]` | the head's mood or emote |
518
595
  | `[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
596
  | `[pause: 0.5]` | a beat of silence (≤ 5 s) |
520
- | `[play: video]` / `[pause: video]` | play or pause the slide's video |
597
+ | `[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
598
  | `[go: #step-2]` / `[go: slot]` | fly beside that item, grow it, fade the other items / back to the slot |
522
599
  | `[look: h2]` / `[look #card]` | look at the title / the card, then back |
523
600
  | `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
@@ -552,9 +629,13 @@ reword the sentence (spell numbers and names the way they are said, or add a
552
629
  ### 5. Check
553
630
 
554
631
  ```bash
555
- hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout]
632
+ hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout] [--strict] [--fix]
556
633
  ```
557
634
 
635
+ `--strict` fails every slide with no `data-note`, as a pitch deck does.
636
+ `--fix` writes what the tools can before checking: today, a missing or stale
637
+ loading state (`<style data-hd-loading>`).
638
+
558
639
  `--layout` lays each slide out in Chromium (needs `hyperdecks preview setup`).
559
640
  At the stage size it warns, without failing, when a slide's presenter would
560
641
  overlap its title. On a 390×844 phone (the share player's 374×736 slide card)
@@ -584,10 +665,11 @@ with the player's `decks.hypersocial.ai` script tag. Messages and what to do:
584
665
  | ⚠ `<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
666
  | ⚠ `<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
667
  | `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 |
668
+ | `the player script has no data-manifest` | run `narrate`; or, for a deck with no narration, add `data-silent` to the player script |
669
+ | ⚠ `no narration (silent deck)` | fine for a deck meant to be silent; remove `data-silent` and narrate to give it a voice |
670
+ | `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
671
  | `forbidden phrase` | remove it from the page, script or narration |
590
- | `slide <id>: needs a data-note` | add the source |
672
+ | `slide <id>: needs a data-note` / ⚠ `no data-note` | add the source (required on a `data-kind="pitch"` deck and with `--strict`) |
591
673
  | `slide <id>: title … needs a data-title-mobile` | add a shorter title |
592
674
  | `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
675
  | `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
@@ -601,6 +683,21 @@ with the player's `decks.hypersocial.ai` script tag. Messages and what to do:
601
683
  | `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
684
  | ⚠ `slide <id>: the … presenter would overlap the title` | move that slide's `.narration-slot`, pad the text away from it, or use `corner` |
603
685
 
686
+ **Review before release (required).** After `check` passes and the deck is
687
+ imported, run `hyperdecks review <slug> --script decks/<slug>/script.md`
688
+ (`review_deck`, then poll `check_review_job`). Act on every finding:
689
+
690
+ - `fix`: change the slide or the script, re-import (or `release --qa`), and
691
+ review again until no `fix` is left. Do not release with a `fix` open.
692
+ - `consider`: apply it when it serves the slide's one idea; otherwise note in
693
+ your reply to the user why you left it.
694
+ - A finding you disagree with (a deliberate choice, a misread screenshot):
695
+ say so to the user with the reason instead of silently ignoring it.
696
+
697
+ If review cannot run (no credits, the service is down), say so to the user and
698
+ do the step 6 screenshot pass yourself as the reviewer: the same desktop and
699
+ phone screenshots, the same `fix` bar.
700
+
604
701
  ### 6. Look at it, at both widths
605
702
 
606
703
  To watch it as it will really play, run `hyperdecks play decks/<slug>`. It plays
@@ -617,7 +714,7 @@ and screenshot it with Playwright at 1440×900 and 390×844, every slide
617
714
  (`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
618
715
  clear of the text, the mobile titles in use. Fix and repeat until both read well.
619
716
 
620
- Then the required review (see **Design**, *Review before release*): once the
717
+ Then the required review (see **5. Check**, *Review before release*): once the
621
718
  deck is imported, `hyperdecks review <slug> --script decks/<slug>/script.md`
622
719
  (`review_deck`, then `check_review_job`) has GPT-6 Luna look at every slide of
623
720
  the deck's QA link on desktop and phone and list each problem as `fix` or
@@ -701,6 +798,16 @@ folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
701
798
  draft), `hyperdecks asset add <slug> <file|url>` for an image, `hyperdecks guide
702
799
  [topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
703
800
 
801
+ ## After you ship: say what cost you
802
+
803
+ When a deck is released, end your report with a short list of what cost you
804
+ time, ranked by cost: a rule you had to work around, a layout or style you
805
+ hand-wrote because the library had none, a check or tool that said the wrong
806
+ thing, something you couldn't find in this guide. One line each, concrete
807
+ (the file, the command, the message), no praise needed. Send it to whoever
808
+ asked for the deck. Hyperdecks turns these into roadmap cards; the Xbox
809
+ rebuild's list on 2026-11-05 produced four.
810
+
704
811
  ## Safety
705
812
 
706
813
  - For read-only inspection, run commands directly and summarize the important
@@ -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,20 @@ 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
+ - When the deck is released, tell the person what cost you time, ranked by
111
+ cost: a rule you worked around, a layout you hand-wrote, a tool or check that
112
+ said the wrong thing, something missing from `deck_guide`. One concrete line
113
+ each. Hyperdecks turns these into roadmap cards.
114
+ - The deck's own scripts run: slide controllers, `[custom: name]` cues,
115
+ pausing the narrator, tappable and interactive slides. `deck_guide
116
+ { topic: "scripts" }` has the hooks and an example.
106
117
  - Edit the draft freely: recipients keep seeing the last release until you call
107
118
  `release_deck` again. A release is refused when the check fails, when
108
119
  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.
@@ -479,6 +511,8 @@ export function phraseList(value) {
479
511
  // own markers: the empty div (or the chart's labelled svg). A div that holds
480
512
  // its own picture, or gets one from a url() in its style or a <style> rule
481
513
  // naming its class, is the deck's art. A deck that wants none removes it.
514
+ // Each opening tag is read on its own (the inner text is a lookahead), so a
515
+ // stand-in nested in another of its tag, like a phone card's shot, is seen.
482
516
  const PLACEHOLDERS = [
483
517
  {
484
518
  layout: "l-hero", tag: "div", cls: "art", what: "the hero's art", img: '<img class="art" src="…" alt="">, or delete the div for no art (the presenter is the visual)',
@@ -488,6 +522,10 @@ const PLACEHOLDERS = [
488
522
  layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
489
523
  how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
490
524
  },
525
+ {
526
+ layout: "l-phones", tag: "div", cls: "shot", what: "each phone screen", img: '<img class="shot" src="…" alt="…"> (or <video class="shot" src="…" autoplay muted loop playsinline>) in each card',
527
+ how: { hosted: "capture the app at phone size with capture_url (width 390, height 844), or upload a screen recording with upload_deck_asset", cli: "capture the app at phone size with `hyperdecks screenshot <url> --width 390 --height 844`, or add a screen recording with `hyperdecks asset add`" },
528
+ },
491
529
  {
492
530
  layout: "l-chart", tag: "svg", cls: "chart", label: /placeholder/i, what: "the chart (made-up numbers)", img: '<img class="chart" src="…" alt="…">',
493
531
  how: { hosted: "draw the real data with render_chart", cli: "draw the real data with `hyperdecks chart <spec.json>`" },
@@ -499,7 +537,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
499
537
  const styled = (cls) => [...css.matchAll(/([^{}]*)\{([^{}]*)\}/g)].some(([, sel, body]) => new RegExp(`\\.${cls}(?![\\w-])`).test(sel) && /url\(/i.test(body));
500
538
  return slides(html).flatMap((s, i) => {
501
539
  const layouts = classes(s.tag);
502
- return PLACEHOLDERS.filter((p) => layouts.includes(p.layout) && !(p.tag === "div" && styled(p.cls)) && [...s.body.matchAll(new RegExp(`(<${p.tag}\\b[^>]*>)([\\s\\S]*?)</${p.tag}>`, "gi"))].some(([, tag, inner]) =>
540
+ return PLACEHOLDERS.filter((p) => layouts.includes(p.layout) && !(p.tag === "div" && styled(p.cls)) && [...s.body.matchAll(new RegExp(`(<${p.tag}\\b[^>]*>)(?=([\\s\\S]*?)</${p.tag}>)`, "gi"))].some(([, tag, inner]) =>
503
541
  classes(tag).includes(p.cls) && (p.label ? p.label.test(attr(tag, "aria-label") ?? "") : !inner.trim() && !/url\(/i.test(attr(tag, "style") ?? ""))))
504
542
  .map((p) => MESSAGES.placeholder(s.id || `${i + 1}`, p.what, `<${p.tag} class="${p.cls}">`, hosted ? p.how.hosted : p.how.cli, p.img));
505
543
  });
@@ -507,7 +545,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
507
545
 
508
546
  // Everything checkable from the deck's own files. `exists(ref)` answers whether a
509
547
  // local reference resolves inside the deck folder: true, false or "outside".
510
- export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
548
+ export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false, strict = false } = {}) {
511
549
  const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
512
550
  const player = playerScript(html);
513
551
  if (!player) errs.push(MESSAGES.noPlayer());
@@ -518,10 +556,11 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
518
556
  if (player && !loading?.[1].includes("hd-fail-open")) errs.push(MESSAGES.noLoading());
519
557
  const all = slides(html);
520
558
  if (!all.length) errs.push(MESSAGES.noSlides());
559
+ const notes = notesRequired(html, { strict });
521
560
  all.forEach((s, i) => {
522
561
  const id = s.id || `${i + 1}`;
523
562
  if (!s.id) errs.push(MESSAGES.slideId(i + 1));
524
- if (!attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
563
+ if (notes && !attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
525
564
  if (s.title.length > MOBILE_TITLE_CHARS && !attr(s.titleTag, "data-title-mobile") && !attr(s.tag, "data-title-mobile")) {
526
565
  errs.push(MESSAGES.mobileTitle(id, s.title));
527
566
  }
@@ -715,7 +754,9 @@ const isHostedAsset = (url) => {
715
754
  // resolves (true, false or "outside"); `hosted: true` means there is no folder,
716
755
  // so every local reference is an error naming upload_deck_asset, a hotlinked
717
756
  // 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 }.
757
+ // not checked. `strict: true` makes every slide's data-note an error, as on a
758
+ // pitch deck. Returns { slides, totalDuration, manifestUrl, manifest, silent,
759
+ // passed, errors, warnings }; `silent` is a deck with no manifest that declared data-silent.
719
760
  export async function checkSources({ html, script = "" }, opts = {}, { fetch: fetchImpl = globalThis.fetch } = {}) {
720
761
  const forbid = phraseList(opts.forbid);
721
762
  const flagCap = opts["max-duration"] ?? opts.maxDuration;
@@ -724,14 +765,22 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
724
765
  throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
725
766
  }
726
767
  const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
727
- const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted });
768
+ const strict = !!opts.strict;
769
+ const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
770
+ const warnings = [];
771
+ if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
728
772
  // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
729
773
  const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
730
774
  for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
731
775
  const ids = slides(html).map((s) => s.id);
732
776
  const player = playerScript(html);
733
777
  let manifest = null;
734
- if (player && !player.manifest) errors.push(opts.hosted ? MESSAGES.noManifestHosted() : MESSAGES.noManifest());
778
+ // A deck that declares itself silent plays without narration; without the
779
+ // declaration a missing manifest is still a forgotten narrate. Slides with
780
+ // data-clip audio are narrated (the player plays them), so not silent.
781
+ const silent = !!player && !player.manifest && player.silent && !slides(html).some((s) => attr(s.tag, "data-clip"));
782
+ if (silent) warnings.push(opts.hosted ? MESSAGES.silentHosted() : MESSAGES.silent());
783
+ else if (player && !player.manifest) errors.push(opts.hosted ? MESSAGES.noManifestHosted() : MESSAGES.noManifest());
735
784
  if (player?.manifest) {
736
785
  const got = await fetchCheck(fetchImpl, player.manifest, JSON_TYPE);
737
786
  if (got.why) errors.push(MESSAGES.manifestUnavailable(player.manifest, got.why));
@@ -782,7 +831,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
782
831
  errors.push(...timelineErrors(clip, timeline));
783
832
  }
784
833
  }
785
- const warnings = transitionWarnings(html, script, manifest);
834
+ warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
786
835
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
787
836
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
788
837
  if (opts.hosted) {
@@ -794,6 +843,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
794
843
  totalDuration: manifest ? Number(manifest.totalDuration) : null,
795
844
  manifestUrl: player?.manifest ?? null,
796
845
  manifest,
846
+ silent,
797
847
  passed: errors.length === 0,
798
848
  errors,
799
849
  warnings: [...new Set(warnings)],
package/src/commands.mjs CHANGED
@@ -340,17 +340,19 @@ commands:
340
340
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
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
- layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
344
- check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
343
+ layouts: hero, number, product, phones, quote, compare, timeline, features, chart, team, closer
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/files.mjs CHANGED
@@ -16,6 +16,7 @@ const FILES = {
16
16
  "templates/layouts/hero.html": () => new URL("../templates/layouts/hero.html", import.meta.url),
17
17
  "templates/layouts/number.html": () => new URL("../templates/layouts/number.html", import.meta.url),
18
18
  "templates/layouts/product.html": () => new URL("../templates/layouts/product.html", import.meta.url),
19
+ "templates/layouts/phones.html": () => new URL("../templates/layouts/phones.html", import.meta.url),
19
20
  "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
20
21
  "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
21
22
  "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
package/src/guide.mjs CHANGED
@@ -8,7 +8,7 @@ import { packageText } from "./files.mjs";
8
8
  import { LAYOUTS, readLayout } from "./layouts.mjs";
9
9
  import { fontPairingsMarkdown } from "./brand.mjs";
10
10
 
11
- export const GUIDE_TOPICS = ["workflow", "format", "script", "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 } = {}) {
package/src/layouts.mjs CHANGED
@@ -5,8 +5,8 @@ import { packageText } from "./files.mjs";
5
5
 
6
6
  export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
7
7
  // The library, in a sensible deck order; `--layouts` takes any order and repeats.
8
- export const LAYOUTS = ["hero", "number", "product", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
- const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", comparison: "compare", "three-up": "features" };
8
+ export const LAYOUTS = ["hero", "number", "product", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
+ const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features" };
10
10
 
11
11
  const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
12
 
@@ -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.1";
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
  }
@@ -51,7 +51,7 @@
51
51
  and the presenter, and plays the narration. Change the palette and fonts
52
52
  here; every layout reads them. */
53
53
  :root {
54
- --ink: #0b0b0f; --paper: #fafaf7; --accent: #ffda1b; --accent-2: #27ceff; --card: #ffffff;
54
+ --ink: #0b0b0f; --paper: #fafaf7; --accent: #ffda1b; --accent-2: #27ceff; --accent-3: #ff9ec7; --card: #ffffff;
55
55
  --font: "Space Grotesk", system-ui, sans-serif; --mono: "Space Mono", ui-monospace, monospace;
56
56
  /* The type scale: one display size per slide at most, then title, sub (a title beside a visual), lead, body, label. */
57
57
  --t-display: 128px; --t-title: 84px; --t-sub: 56px; --t-lead: 30px; --t-body: 24px; --t-label: 17px;
@@ -73,12 +73,41 @@
73
73
  .slot-top .narration-slot { top: 96px; bottom: auto; }
74
74
  .reveal { opacity: 0; transform: translateY(12px); transition: opacity .5s, transform .5s; }
75
75
  .reveal.is-revealed { opacity: 1; transform: none; }
76
+ /* The phone card (product's .frame.card, the phones row): a frameless phone,
77
+ white with an ink edge, a hard offset shadow and a tilt. Change --tilt and
78
+ --shadow on the element (style="--tilt: 3deg; --shadow: var(--accent-2)");
79
+ put an <img class="shot"> or <video class="shot" autoplay muted loop
80
+ playsinline> in place of the placeholder div. */
81
+ .slide .frame.card { --tilt: -4deg; --shadow: var(--accent); position: relative; width: 320px; aspect-ratio: 9 / 19.5; padding: 9px; border: 3px solid var(--ink); border-radius: 46px; background: var(--card); box-shadow: 18px 18px 0 var(--shadow); transform: rotate(var(--tilt)); overflow: visible; }
82
+ .slide .frame.card > .shot { display: block; width: 100%; height: 100%; aspect-ratio: auto; border-radius: 36px; border: 2px solid color-mix(in srgb, var(--ink) 20%, transparent); object-fit: cover; }
83
+ .slide .frame.card > div.shot { background:
84
+ linear-gradient(color-mix(in srgb, var(--ink) 16%, transparent) 0 0) 50% 5% / 36% 2.2% no-repeat,
85
+ linear-gradient(var(--accent-2) 0 0) 50% 30% / 84% 40% no-repeat,
86
+ linear-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0 0) 16% 62% / 60% 2.4% no-repeat,
87
+ linear-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0 0) 14% 67% / 44% 2.4% no-repeat,
88
+ linear-gradient(var(--accent) 0 0) 50% 90% / 84% 6% no-repeat,
89
+ var(--paper); }
90
+ /* Backgrounds, optional and for a few slides a deck. bg-dots on the section:
91
+ a dot grid in the slide's text colour, faded in from the side away from the text
92
+ (--dots-fade is the fade's direction: 90deg puts the dots on the right,
93
+ 270deg on the left). A <div class="deco" aria-hidden="true"> in the slide
94
+ holds small inked tiles, each a <span> placed by its inline left/top (and
95
+ background, rotate). Both sit behind the slide's content. */
96
+ .slide.bg-dots, .slide:has(> .deco) { isolation: isolate; }
97
+ .slide.bg-dots::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 3px, transparent 3.6px) 0 0 / 36px 36px; -webkit-mask-image: linear-gradient(var(--dots-fade, 90deg), transparent 40%, #000 80%); mask-image: linear-gradient(var(--dots-fade, 90deg), transparent 40%, #000 80%); }
98
+ .slide > .deco { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
99
+ .slide > .deco > span { position: absolute; display: grid; place-items: center; width: 80px; height: 80px; border: 3px solid var(--ink); border-radius: 20px; background: var(--card); box-shadow: 8px 8px 0 var(--ink); color: var(--ink); font: 700 34px var(--font); }
76
100
  /* Phones: the slide is the 374×736 card; the presenter is a small head in
77
101
  the corner, so keep the bottom 170px clear. */
78
102
  @media (max-width: 650px) {
79
103
  :root { --t-display: 54px; --t-title: 38px; --t-sub: 28px; --t-lead: 17px; --t-body: 15px; --t-label: 12px; }
80
104
  .slide { padding: 28px 20px 170px; gap: 14px; }
81
105
  .narration-slot { right: 12px; left: 12px; bottom: 64px; }
106
+ .slide .frame.card { width: 160px; padding: 5px; border-width: 2px; border-radius: 24px; box-shadow: 8px 8px 0 var(--shadow); }
107
+ .slide .frame.card > .shot { border-radius: 19px; border-width: 1px; }
108
+ /* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
109
+ .slide.bg-dots::before { background-size: 22px 22px; background-image: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 2px, transparent 2.5px); -webkit-mask-image: linear-gradient(180deg, transparent 45%, #000 85%); mask-image: linear-gradient(180deg, transparent 45%, #000 85%); }
110
+ .slide > .deco { display: none; }
82
111
  }
83
112
  </style>
84
113
  <!-- LAYOUT STYLES -->
@@ -0,0 +1,54 @@
1
+ <!--
2
+ layout: phones
3
+ summary: A fanned row of two to four phone cards (the inner ones larger and in front, the outer ones tilted out), the point in the column beside it.
4
+ use: a phone app's flow or its key screens. Each card's .shot is a placeholder check fails on: put in a phone-size screenshot (`hyperdecks screenshot <url> --width 390 --height 844`, capture_url) as <img class="shot">, or a screen recording as <video class="shot" autoplay muted loop playsinline>. Two to four cards: the row spaces and tilts them (on phones only the first three show); change one card's --tilt or --shadow on it. One phone only: use product with .frame.card.
5
+ slot: bottom-left
6
+ presenter: stage
7
+ cues: [look: #__ID__-row] when the narration turns to the app; [go: #__ID__-p1] … [go: #__ID__-p3] to walk the screens, then [go: slot].
8
+ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__ID__-p1] you pick one, [go: #__ID__-p2] and it is done. [go: #__ID__-p3] [go: slot]
9
+ -->
10
+ <style>
11
+ .l-phones { display: grid; grid-template-columns: 500px 1fr; grid-template-areas: "text row"; column-gap: 40px; align-items: center; }
12
+ .l-phones .text { grid-area: text; align-self: start; display: flex; flex-direction: column; gap: 20px; }
13
+ .l-phones .tt { font-size: var(--t-sub); line-height: 1.02; }
14
+ .l-phones .lead { font-size: var(--t-body); }
15
+ /* The row: cards overlap a little; the first and last tilt out and sit lower,
16
+ the ones between are larger and in front. The card's look is shared (_deck.html). */
17
+ .l-phones .row { grid-area: row; display: flex; justify-content: center; align-items: center; padding: 0 0 40px; }
18
+ .l-phones .row > .frame.card { --tilt: 0deg; flex: none; width: 260px; margin: 0 -14px; }
19
+ .l-phones .row > .frame.card:first-child { --tilt: -7deg; --shadow: var(--accent-2); translate: 0 36px; }
20
+ .l-phones .row > .frame.card:last-child { --tilt: 7deg; --shadow: var(--accent-3); translate: 0 36px; }
21
+ .l-phones .row > .frame.card:not(:first-child, :last-child) { width: 300px; z-index: 1; }
22
+ .l-phones .row:has(> :nth-child(4)) > .frame.card { width: 220px; }
23
+ .l-phones .row:has(> :nth-child(4)) > .frame.card:not(:first-child, :last-child) { width: 250px; }
24
+ @media (max-width: 650px) {
25
+ .l-phones { display: flex; flex-direction: column; align-items: stretch; }
26
+ .l-phones .text { gap: 10px; }
27
+ .l-phones .row { padding: 6px 0 18px; }
28
+ .l-phones .row > .frame.card, .l-phones .row:has(> :nth-child(4)) > .frame.card { width: 108px; margin: 0 -6px; }
29
+ .l-phones .row > .frame.card:not(:first-child, :last-child), .l-phones .row:has(> :nth-child(4)) > .frame.card:not(:first-child, :last-child) { width: 128px; }
30
+ .l-phones .row > .frame.card:is(:first-child, :last-child) { translate: 0 16px; }
31
+ /* Four do not fit the phone card: the row keeps the first three, the third as its end. */
32
+ .l-phones .row > .frame.card:nth-child(n + 4) { display: none; }
33
+ .l-phones .row:has(> :nth-child(4)) > .frame.card:nth-child(3) { --tilt: 7deg; --shadow: var(--accent-3); width: 108px; translate: 0 16px; }
34
+ }
35
+ </style>
36
+ <section class="slide l-phones slot-left bg-dots" id="__ID__" data-chapter="On a phone" data-note="Phone screens: name the app build and the date they were captured.">
37
+ <div class="text">
38
+ <div class="kicker">On a phone</div>
39
+ <h2 class="tt" data-title-mobile="Three taps.">Three taps, start to finish.</h2>
40
+ <p class="lead">One line on what the screens show, in order.</p>
41
+ </div>
42
+ <div class="row" id="__ID__-row">
43
+ <div class="frame card" id="__ID__-p1"><div class="shot" role="img" aria-label="Phone screen placeholder: the first step"></div></div>
44
+ <div class="frame card" id="__ID__-p2"><div class="shot" role="img" aria-label="Phone screen placeholder: the main screen"></div></div>
45
+ <div class="frame card" id="__ID__-p3"><div class="shot" role="img" aria-label="Phone screen placeholder: the result"></div></div>
46
+ </div>
47
+ <!-- Optional decorations, behind the content (a few slides a deck at most):
48
+ <div class="deco" aria-hidden="true">
49
+ <span style="left: 52%; top: 8%; background: var(--accent); rotate: -10deg;">★</span>
50
+ <span style="left: 92%; top: 42%; background: var(--accent-2); rotate: 12deg;">♪</span>
51
+ <span style="left: 48%; top: 78%; background: var(--accent-3); rotate: 8deg;">☺</span>
52
+ </div> -->
53
+ <div class="narration-slot"></div>
54
+ </section>
@@ -1,7 +1,7 @@
1
1
  <!--
2
2
  layout: product
3
- summary: A product screenshot in a browser frame (or .frame.phone for a phone shot), the point in the column beside it.
4
- use: a demo moment. Its .shot is a placeholder check fails on: put in `hyperdecks screenshot <url>` (capture_url) output as <img class="shot">; a callout chip names what to look at.
3
+ summary: A product screenshot in a browser frame (for a phone shot, .frame.card: the frameless phone card), the point in the column beside it.
4
+ use: a demo moment. Its .shot is a placeholder check fails on: put in `hyperdecks screenshot <url>` (capture_url) output as <img class="shot">; a callout chip names what to look at. For a phone app, make the frame `<div class="frame card" id="__ID__-frame">` holding only the shot (a phone screenshot or a `<video class="shot" autoplay muted loop playsinline>` clip) and the callout; tilt and shadow colour are `--tilt` and `--shadow` on it. `.frame.phone` (the black bezel) stays for older decks.
5
5
  slot: bottom-right
6
6
  presenter: stage
7
7
  cues: [look: #__ID__-frame] when the narration turns to the product; [reveal: #__ID__-callout] for the detail.
@@ -27,6 +27,9 @@ script: This is the product. [look: #__ID__-frame] Look at this part. [reveal: #
27
27
  linear-gradient(var(--accent-2) 0 0) 248px 176px / 300px 300px no-repeat,
28
28
  linear-gradient(color-mix(in srgb, var(--ink) 8%, transparent) 0 0) 580px 176px / 308px 300px no-repeat,
29
29
  linear-gradient(color-mix(in srgb, var(--ink) 6%, transparent) 0 0) 0 0 / 210px 100% no-repeat; }
30
+ /* The phone card's look is shared (_deck.html); here it sits centred in the frame column, without the browser bar. */
31
+ .l-product .frame.card { justify-self: center; }
32
+ .l-product .frame.card .bar { display: none; }
30
33
  .l-product .frame.phone { width: 300px; justify-self: center; border-radius: 40px; padding: 12px; background: var(--ink); }
31
34
  .l-product .frame.phone .bar { display: none; }
32
35
  .l-product .frame.phone .shot { aspect-ratio: 9 / 19.5; border-radius: 28px; }
@@ -39,6 +42,7 @@ script: This is the product. [look: #__ID__-frame] Look at this part. [reveal: #
39
42
  .l-product .bar i { width: 7px; height: 7px; }
40
43
  .l-product .bar span { font-size: 9px; padding: 2px 8px; margin-left: 6px; }
41
44
  .l-product div.shot { background-size: 30% 6%, 60% 2%, 50% 2%, 28% 40%, 28% 40%, 20% 100%; background-position: 26% 8%, 40% 18%, 36% 23%, 26% 70%, 62% 70%, 0 0; }
45
+ .l-product .frame.card { align-self: center; }
42
46
  .l-product .callout { left: 10px; bottom: 10px; padding: 6px 12px; font-size: 12px; }
43
47
  }
44
48
  </style>