@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 +3 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +134 -27
- package/skills/hyperdecks/chat.md +15 -4
- package/src/check.mjs +19 -3
- package/src/checkRules.mjs +63 -13
- package/src/commands.mjs +8 -5
- package/src/files.mjs +1 -0
- package/src/guide.mjs +1 -1
- package/src/hosted.mjs +2 -2
- package/src/layouts.mjs +2 -2
- package/src/loadingBlock.mjs +72 -0
- package/src/release.mjs +10 -3
- package/src/runtime.mjs +2 -2
- package/src/source.mjs +24 -2
- package/templates/layouts/_deck.html +30 -1
- package/templates/layouts/phones.html +54 -0
- package/templates/layouts/product.html +6 -2
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
|
-
-
|
|
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
|
@@ -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.
|
|
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.
|
|
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
|
|
415
|
-
|
|
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
|
|
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
|
|
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 **
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
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) {
|
package/src/checkRules.mjs
CHANGED
|
@@ -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
|
|
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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/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
|
-
|
|
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}
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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>
|