@hypersocial/decks-cli 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,7 @@ whoami [--json]
58
58
  deck list [--json]
59
59
  deck stats <slug> [--json]
60
60
  deck open <slug>
61
+ deck link <slug> [--json] # the owner link: present or check your own deck, untracked
61
62
 
62
63
  share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify]
63
64
  share list <slug> [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -146,14 +146,22 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
146
146
  | PDF for a data room or attachment | `hyperdecks export <slug> --pdf [--notes] --out deck.pdf`, `hyperdecks export list` | `export_deck`, `list_exports` |
147
147
  | Video (MP4) for YouTube, LinkedIn or an attachment | `hyperdecks export <slug\|deck-dir> --video [--size 1080p\|720p\|4k] [--shape 16:9\|9:16\|1:1] [--title-card] [--end-card] [--no-captions] [--no-avatar] [--upload] --out deck.mp4`: records the narrated player in real time (a 4-minute deck takes about 5), avatar, voice and captions, no player chrome; a slug records the released copy, a deck folder its working copy; `--upload` lists it with the deck's exports | `export_deck { kind: "mp4" }` renders it on our render worker (the dashboard's Export → Video); poll `check_export`, or `hyperdecks export status <exportId>` |
148
148
  | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
149
- | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
149
+ | One more link | `hyperdecks share new <slug> "<name>" [--no-forward] [--pace auto\|slide\|step]` | `create_share` (`allowForward`, `pace`) |
150
150
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
151
151
  | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
152
152
  | Slides or scroll, viewer toggle, pace, AI-readable version | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default] [--ai-readable on\|off]` | `set_deck_view` |
153
+ | Present or check your own deck (owner link) | `hyperdecks deck link <slug>` (`deck open` opens it) | `ownerUrl` from `list_decks` or `check_deck` |
153
154
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
154
155
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
155
156
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
156
157
 
158
+ To present or check your own deck, use the owner link (`deck link`,
159
+ `ownerUrl`): the released deck as recipients get it, for the owner only
160
+ (signed in), with nothing tracked. Anyone else gets a sign-in notice, so a
161
+ leaked owner link shows nothing. Its toolbar's Present (or H) hides the owner
162
+ tools for a screen share. QA links are only for testing what a recipient sees;
163
+ never make one to present on a call.
164
+
157
165
  Forwarding is on by default: a viewer can forward the deck to someone, which
158
166
  makes a child link tracked on its own ("Dana via Peter"; `share list` shows it
159
167
  indented under Peter's, `list_shares` gives `parentToken` and `forwardedBy`).
@@ -266,17 +274,27 @@ The deck contract (`check` enforces or warns on each line):
266
274
  <!-- topic: presenter -->
267
275
  #### Which presenter style
268
276
 
269
- **Who presents is set at release; leave the tag alone.** `deck new --narrated`
270
- writes `data-presenter="none"` on the player tag, and `release` (and
271
- `release_deck`) puts the narrator's face on the copy it publishes: your own
272
- narrator's avatar, or D.A.S.H.'s live head for a deck in D.A.S.H.'s voice. It
273
- says `presented by …` only when the published page has that face; otherwise
274
- it warns `releasing captions only` with the reason. To pick the face by hand,
275
- set both attributes: `data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
277
+ **The CLI writes the face into the tag; leave it alone.** `deck new --narrated`
278
+ writes the narrator's face on the player tag (`data-presenter="head"
279
+ data-avatar="…" data-presenter-from="narrator"`): the account's default
280
+ narrator's, else D.A.S.H.'s. `deck narrator <slug> <name>` and `narrate`
281
+ rewrite it to the narrator's face and print `presenter: …, written to
282
+ index.html`; `deck narrator <slug> none` (or `deck new --captions-only`)
283
+ writes captions only on purpose. So the file plays with its face however it is
284
+ opened, a static server included. `release` (and `release_deck`) follows the
285
+ narrator: a `data-presenter-from="narrator"` face is replaced by the current
286
+ narrator's (your own narrator's avatar, or D.A.S.H.'s live head for a deck in
287
+ D.A.S.H.'s voice). It says `presented by …` only when the published page has
288
+ that face; otherwise it warns `releasing captions only` with the reason. To pick
289
+ the face by hand, set both attributes and no `data-presenter-from`:
290
+ `data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
276
291
  `data-presenter="head" data-avatar="https://hyperdecks.ai/player/v1/dash-avatar.json"`
277
292
  (it loads from any host). `data-presenter="head"` with no `data-avatar` plays
278
- captions only, and so does a `data-avatar` beside `data-presenter="none"`.
279
- `check` warns on both, and `release` leaves a hand-set `data-avatar` alone.
293
+ captions only, and so does a `data-avatar` beside `data-presenter="none"`, and
294
+ so does a plain `data-presenter="none"` from an older scaffold.
295
+ `check` warns on all three, and `release` leaves a hand-set `data-avatar` alone.
296
+ A hosted draft (`new_deck`) stores the tag as the template wrote it; its draft
297
+ link and `release_deck` add the narrator's face, so `check_deck` does not warn on its plain none.
280
298
 
281
299
  Set the deck's on the player tag (`data-presenter-style="stage"`) or in
282
300
  script.md's front matter (`presenterStyle: stage`); override one slide with
@@ -290,6 +308,20 @@ its `##` heading.
290
308
  | `glass` | dark or busy slides: head and captions on one frosted panel |
291
309
  | `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
292
310
 
311
+ **Loading and buffering.** A deck opens on its loading card (the title and
312
+ the presenter's still), and a line still loading shows as a small waiting
313
+ pill. Once the deck has spoken, a line that stalls on the network for more
314
+ than 0.3 s makes the slide play a short
315
+ "signal" glitch in the deck's accents (`--accent-2` / `--accent-3`, else
316
+ `--accent`; set `--hd-signal-1` / `--hd-signal-2` to choose others): the
317
+ title's colour splits, thin scanlines and a slice pass over the slide, and a
318
+ `SIGNAL // BUFFERING` tag sits in the top-left corner.
319
+ Body text never moves; it calms to scanlines after 6 s and stops the moment
320
+ sound plays. It never shows in thumbnails, exports, present mode or while the
321
+ owner presents, and reduced motion gets a still dim and the tag. Where it
322
+ doesn't fit the deck, turn it off with `data-loading="calm"` on the player tag
323
+ (or on `<html>`, or `"loading": "calm"` in the manifest).
324
+
293
325
  Size it per slide with `data-presenter-size="s|m|l|xl"` on the `<section>`
294
326
  (0.7×, 1×, 1.3×, 1.6× the style's head; on the player tag it is the deck's
295
327
  default): `xl` on a slide with empty space, `s` on a busy product slide. Phones
@@ -413,8 +445,8 @@ slide that overflows a 390×844 phone.
413
445
 
414
446
  #### House style (preferences)
415
447
 
416
- Everything below is the owner's house style, not a rule: follow it by
417
- default, and depart from it when the brief asks for something else.
448
+ The owner's house style, not rules: follow it by default; depart from it
449
+ when the brief asks.
418
450
 
419
451
  **One idea per slide.** The title states the idea as a sentence a listener
420
452
  could repeat ("Narration renders in two minutes", not "Speed"). Everything
@@ -433,16 +465,15 @@ new sizes:
433
465
  | `--t-body` | 24px | 15px | list items, card text |
434
466
  | `--t-label` | 17px | 12px | kickers, sources, captions (mono, uppercase) |
435
467
 
436
- Nothing on a slide below 17px at stage size (a screenshot's frame chrome aside). A title over 28 characters needs
437
- `data-title-mobile`; write the short version as a real sentence.
468
+ Nothing on a slide below 17px at stage size (a screenshot's frame chrome aside). A title over
469
+ 28 characters needs `data-title-mobile`, written as a real sentence.
438
470
 
439
471
  **Colour and contrast.** Three colours do the work: `--ink`, `--paper` and one
440
- `--accent` (a second, `--accent-2`, only for a chart's other series, an
441
- illustration or a placeholder face). Text is ink on paper or accent, or paper on ink: never accent
472
+ `--accent` (`--accent-2` only for a chart's second series or a placeholder
473
+ face). Text is ink on paper or accent, or paper on ink: never accent
442
474
  on paper, never grey on a colour. Alternate slide grounds (`on-accent`,
443
475
  plain, `on-ink`) so the slide changes read, and keep every number, source and
444
- label at full contrast. Change the palette and fonts once, in `:root`; every
445
- layout reads them.
476
+ label at full contrast. Set the palette and fonts once, in `:root`.
446
477
 
447
478
  **Start from the brand.** The account keeps a brand: colours, fonts, a logo,
448
479
  a presenter style and a design.md (voice, do and don't). `deck new` and
@@ -450,16 +481,14 @@ a presenter style and a design.md (voice, do and don't). `deck new` and
450
481
  it first (`hyperdecks brand show --json`, `get_brand`) and write in its voice.
451
482
  An explicit request for this deck wins over the brand; `--no-brand`
452
483
  (`brand: false`) starts from the template's look. No brand yet? Offer to
453
- import it from what they already have: `hyperdecks brand import
454
- <url|file.pptx|file.pdf|deck-slug>` (`import_brand`) reads the real colours,
455
- fonts and logo (a PowerPoint's theme exactly) and returns a preview link, a
456
- sample slide in that brand, and a token. Always show them the preview and
457
- ask; save with `brand set --draft <token>` (`set_brand { draft }`) only after
458
- they say yes.
459
- To change it in words ("darker accent, serif titles"), `hyperdecks brand
460
- revise "<change>"` (`revise_brand`) gives a draft and preview the same way.
461
- Every brand save is kept in its history: `brand history` (`brand_history`)
462
- lists the last changes and `brand undo [<version>]` (`undo_brand`) goes back.
484
+ import it: `hyperdecks brand import <url|file.pptx|file.pdf|deck-slug>`
485
+ (`import_brand`) reads the real colours, fonts and logo (a PowerPoint's theme
486
+ exactly) and returns a preview link of a sample slide and a token. Show them
487
+ the preview; save with `brand set --draft <token>` (`set_brand { draft }`)
488
+ only after they say yes.
489
+ To change it in words, `brand revise "<change>"` (`revise_brand`) gives a
490
+ draft and preview the same way; `brand history` (`brand_history`) lists past
491
+ saves and `brand undo [<version>]` (`undo_brand`) goes back.
463
492
 
464
493
  **Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
465
494
  curated pairings below (display for titles, body for text, mono for kickers
@@ -471,13 +500,19 @@ saves one.
471
500
  **Textures, icons, pictures:** the `look` topic (one texture, one icon set
472
501
  from `search_icons` or `generate_icon_set`, a picture on every other slide).
473
502
 
474
- **Pick the layout from what the slide says.** Each layout's file in
475
- `templates/layouts/` opens with its slot side, presenter and the cues that
476
- suit it.
503
+ **Pick the layout from what the slide says** (each file in
504
+ `templates/layouts/` opens with its slot, presenter and cues).
505
+
506
+ **The cover never puts text on the picture.** The title and lead sit on a
507
+ solid ground, never on a picture or one faded
508
+ with a gradient `mask-image` (`check` warns).
477
509
 
478
510
  | Layout | Use it for | Presenter, moves |
479
511
  |---|---|---|
480
- | `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]` |
512
+ | `hero` | the default cover, a section opener: title on the ground, the real subject framed beside it (no `.art`: the presenter is the visual) | stage; `[wave]`, `[look: h1]` |
513
+ | `cover-band` | the picture is the story: full bleed, title on a panel | as `hero` |
514
+ | `cover-poster` | a wide scene: picture on top, title below | as `hero` |
515
+ | `cover-type` | no real picture yet: type only, presenter large | as `hero` |
481
516
  | `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
482
517
  | `product` | a screenshot in a browser frame (`.frame.card` for a phone) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
483
518
  | `split` | the workhorse: a real picture (phone, laptop, 16:9) beside sticker, title, three ticks | stage, bottom-left; `[look: #<id>-frame]`, `[go: #<id>-t1]` … `t3` |
@@ -492,7 +527,7 @@ suit it.
492
527
 
493
528
  **Phones and backgrounds.** One phone: `product`'s `.frame.card`; a flow of
494
529
  two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
495
- `bg-dots` (never under text), a `.deco` of inked tiles.
530
+ `bg-stage` (never under text), a `.deco` of inked tiles.
496
531
 
497
532
  Presenter style follows the deck (step 2): `stage` when the presenter carries
498
533
  the story (the layouts keep the slot's corner clear); `corner` where a big
@@ -552,7 +587,8 @@ still.
552
587
  - `[go]` when the narration walks through items (cards, milestones, people,
553
588
  the action on the closer): one `go` per item at the word that starts it,
554
589
  then `[go: slot]` before the slide ends. Never more than one `go` per
555
- sentence; never a `go` on a slide in `corner` style.
590
+ sentence; never a `go` on a slide in `corner` style. A `go` keeps off
591
+ the picture; with no room it stays home.
556
592
  - `[look]` for a glance: the title as it is read, a number as it is said, a
557
593
  screenshot as the narration turns to it.
558
594
  - `[reveal]` for the one detail that should not be read before it is said (a
@@ -564,8 +600,8 @@ still.
564
600
  <!-- topic: look -->
565
601
  #### Look: textures, icons, pictures
566
602
 
567
- What the house decks do that a plain agent deck doesn't. Decide these three
568
- once per deck, before writing slides, and say them in your plan.
603
+ What the house decks do. Decide these three once per deck, before writing
604
+ slides, and say them in your plan.
569
605
 
570
606
  **1. A picture on at least every other slide**, the most real one you have:
571
607
 
@@ -579,18 +615,22 @@ once per deck, before writing slides, and say them in your plan.
579
615
  No product to show (a plan, a strategy)? Make the slides visual with type
580
616
  and the kit below: a `number` slide, stickers, ticks on a `panel`.
581
617
 
582
- **2. One texture**, on two or three slides (an opener, a closer, a slide
583
- with a visual), never under body text. On the section:
618
+ **2. Plain grounds; texture only behind objects.** Alternate plain,
619
+ `on-tint`, `on-tint-2`, `on-tint-3`, `on-accent`, `on-ink`. On the section:
584
620
 
585
621
  | Class | Looks like | For |
586
622
  |---|---|---|
587
- | `bg-lines` | diagonal speed lines fading toward `--fade-at` | product slides, beside a picture |
588
- | `bg-halftone` | a corner of halftone dots | openers, numbers |
589
- | `bg-rays` | rays from `--rays-at` | an opener or a closer |
590
- | `bg-dots` | a fading dot grid | a phones row |
591
-
592
- Grounds carry the rest: alternate `on-accent`, plain, `on-ink` and the soft
593
- washes `on-tint`, `on-tint-2`, `on-tint-3` so each slide change reads.
623
+ | `bg-pool` | a soft light behind the picture | any picture |
624
+ | `bg-corner` | a halftone quarter-disc, top-right | cover, closer |
625
+ | `bg-stage` | a dot plate under a mockup | product, phones |
626
+ | `bg-mark` | the slide number, huge, faint (`--mark:'04'`) | cover, opener, closer |
627
+
628
+ - One signature per deck (`bg-corner` or `bg-mark`), on three slides at
629
+ most: the cover, an opener or the ask, the closer.
630
+ - `bg-pool` and `bg-stage` are picture helpers: only beside an image.
631
+ - Nothing under text, a chart or a photo; the presenter's corner stays clear.
632
+ - Your own is fine in the deck's `<style>`: tokens only, behind the content
633
+ in an isolated slide, off with nothing to back.
594
634
 
595
635
  **3. One icon set.** Pick one source and keep it for every icon in the deck:
596
636
  Lucide (`search_icons`, same `strokeWidth` throughout); or one
@@ -608,9 +648,9 @@ an app icon or a face), `.ticks`, `.panel`, `.pill`, `.frame.card` (a phone),
608
648
  `<concrete subject>, <setting>, <medium: e.g. editorial photo, soft daylight>,
609
649
  palette <ink hex> <accent hex> <paper hex>, no text, no logos`
610
650
  (`generate_image { brand: true }` adds the palette and the "no text" for
611
- you). Generate at the slide's shape (wide for a hero), in one batch.
651
+ you). Generate at the slide's shape (16:9; near-square for the hero), in one batch.
612
652
 
613
- **Slide recipes.** Opener: `on-accent` + `bg-rays` or `bg-halftone`, the logo
653
+ **Slide recipes.** Opener: `on-accent` + `bg-corner`, the logo
614
654
  or a few app-icon stickers, one display line. Product or feature: `split`
615
655
  with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
616
656
  `timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
@@ -741,6 +781,8 @@ you do).
741
781
  clip that says "Minimoji" but is transcribed as "Moji" needs
742
782
  `normalize: moji=minimoji`, and with a second entry
743
783
  `normalize: moji=minimoji, dash=D.A.S.H.` (one line, not a YAML list).
784
+ British and American spellings of a word (centre/center, colour/color,
785
+ organise/organize, travelled/traveled) already pass: no entry needed.
744
786
  - Write a narrator's name as it is spoken when the two differ, or leave it to
745
787
  the narrator's spoken name: `narrator list` shows it, and narrate reads
746
788
  "D.A.S.H." as "Dash" (captions keep what you wrote).
@@ -802,7 +844,8 @@ with the player's `hyperdecks.ai` script tag. Messages and what to do:
802
844
  | `forbidden phrase` | remove it from the page, script or narration |
803
845
  | `slide <id>: needs a data-note` / ⚠ `no data-note` | add the source (required on a `data-kind="pitch"` deck and with `--strict`) |
804
846
  | `slide <id>: title … needs a data-title-mobile` | add a shorter title |
805
- | `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 |
847
+ | `slide <id>: the hero's art` / `the cover's picture` / `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 (on `cover-band` / `cover-poster`, switch to `cover-type` instead) |
848
+ | ⚠ `slide <id>: the … cover fades its picture into the ground with a mask-image` | the old hero's look: the title sits on picture and colour mixed. Move the deck's opening to a current cover (`hero`, `cover-band`, `cover-poster`, `cover-type`), or drop the mask and keep the title off the picture |
806
849
  | `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
807
850
  | `script.md line N: [go: …] matches nothing in slide <id>` | fix the selector, or add the element to that slide |
808
851
  | `… transition "…" is not one of` | use drift, cut, push, wipe, zoom `<selector>`, morph or fly |
@@ -840,6 +883,10 @@ browser (an agent reads the URL; `--json` prints it as one line).
840
883
  `release --qa` is not a preview: it needs a commit, and it changes what every
841
884
  link to this deck plays.
842
885
 
886
+ Watch a draft with `hyperdecks play`: it shows the face the deck will ship
887
+ with. A plain static server shows the file as written: the face named in the
888
+ tag (the narrator's, since `deck new`, `deck narrator` and `narrate` write it
889
+ there), or none for a tag that says `data-presenter="none"` (`check` warns).
843
890
  For the layout pass, serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
844
891
  and screenshot it with Playwright at 1440×900 and 390×844, every slide
845
892
  (`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
@@ -46,9 +46,11 @@ makes the hello deck; continue from step 5 below.
46
46
  `upload_deck_asset` for a picture the user gave you (a URL, or a file they
47
47
  uploaded). Each returns a hosted URL: put that URL in the HTML. A hosted deck
48
48
  has no folder, so a relative path like `img/a.png` is an error. The
49
- layouts' stand-ins (hero's `<div class="art">`, product's
49
+ layouts' stand-ins (the covers' `<div class="art">`, product's
50
50
  `<div class="shot">`, chart's placeholder `<svg>`) fail `check_deck` until
51
51
  you swap each for an `<img>` of its real file, or delete the hero's art.
52
+ Open on a cover (`hero`, `cover-band`, `cover-poster` or `cover-type`,
53
+ deck_guide topic layouts) and never put the title or lead on the picture.
52
54
  Icons: one set per deck (`look` topic): `search_icons { query }` for real
53
55
  line icons as inline `<svg>` in `currentColor`, or one `generate_icon_set`.
54
56
  For motion, `generate_video` (poll `check_video_job`): `loop: true` for a
@@ -90,6 +92,11 @@ makes the hello deck; continue from step 5 below.
90
92
  only, when nobody chose that, is refused: hand the person its Add narrator
91
93
  link, or offer D.A.S.H., and pass `captionsOnly: true` only if they say so.
92
94
  The result's `presenter` names who presents.
95
+ To present or check your own deck (on a call, say), give the user its
96
+ owner link, `ownerUrl` from `list_decks` or `check_deck`: only they can
97
+ open it, signed in, and nothing is tracked; Present (or H) hides their
98
+ owner tools for a screen share. QA links are only for testing what a
99
+ recipient sees, not for presenting.
93
100
  10. Later: `list_shares` and `deck_stats` (both show the card),
94
101
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
95
102
  email as the deck's moving preview (it needs a recipient's link, not a QA
package/src/brand.mjs CHANGED
@@ -8,7 +8,8 @@
8
8
  // only the tokens here touch the deck.
9
9
 
10
10
  // Weights each role loads: the templates set body text at 500, titles at 700
11
- // and labels in mono at 400/700. Every family below has these on Google Fonts.
11
+ // and labels in mono at 400/700. Every pairing family has these on Google Fonts;
12
+ // a display family outside the pairings loads what displayFace says it has.
12
13
  const ROLE_WEIGHTS = { display: "600;700", body: "400;500;700", mono: "400;700" };
13
14
 
14
15
  // 16 pairings, display + body + mono, each a family Google Fonts serves at the
@@ -32,8 +33,35 @@ export const FONT_PAIRINGS = [
32
33
  { id: "grotesk", display: "Schibsted Grotesk", body: "Work Sans", mono: "Roboto Mono", use: "Marketplaces and media brands; confident and newsy." },
33
34
  ];
34
35
 
35
- // Weights for families that do not have every role weight (load what exists).
36
- const FAMILY_WEIGHTS = { "Space Mono": "400;700", "DM Mono": "400;500", "Libre Baskerville": "400;700", "Courier Prime": "400;700", "Syne": "600;700" };
36
+ // Weights for families that do not have every role weight (load what exists),
37
+ // including single-weight display faces a brand may name (Google Fonts refuses
38
+ // a stylesheet that asks for a weight the family lacks).
39
+ const SINGLE_WEIGHT = ["Bangers", "Bebas Neue", "Anton", "Luckiest Guy", "Permanent Marker", "Pacifico", "Lobster", "Abril Fatface", "Alfa Slab One", "Righteous", "Press Start 2P", "Black Ops One", "Bungee", "Russo One", "Shrikhand", "Monoton", "Creepster", "Special Elite"];
40
+ const FAMILY_WEIGHTS = {
41
+ "Space Mono": "400;700", "DM Mono": "400;500", "Libre Baskerville": "400;700", "Courier Prime": "400;700", "Syne": "600;700",
42
+ ...Object.fromEntries(SINGLE_WEIGHT.map((f) => [f, "400"])),
43
+ };
44
+
45
+ // The template's titles (700, -.045em) were tuned for the pairings' sans
46
+ // display faces. Any other display face gets tracking 0; a face with a known
47
+ // weight table is set at its heaviest weight. A face we know nothing about stays
48
+ // at 700 and loads 400;700: Google Fonts' css2 serves whichever of the asked
49
+ // weights exist (it fails only when none do), and the templates' font-synthesis:
50
+ // none means a face without a 700 draws its real 400, never a faked bold.
51
+ const SERIF_DISPLAY = new Set(["Fraunces", "Playfair Display", "Newsreader", "Libre Baskerville"]);
52
+ const TUNED_DISPLAY = new Set(FONT_PAIRINGS.map((p) => p.display).filter((f) => !SERIF_DISPLAY.has(f)));
53
+ // Any family a pairing names (display or body) has the display weights.
54
+ const PAIRED_DISPLAY = new Set(FONT_PAIRINGS.flatMap((p) => [p.display, p.body]));
55
+
56
+ // { weights, weight, tracking } for a display family: the weights to load, and
57
+ // the --display-weight / --display-tracking to write (null: the template's).
58
+ export function displayFace(family) {
59
+ const f = String(family ?? "").trim();
60
+ const weights = FAMILY_WEIGHTS[f] ?? (PAIRED_DISPLAY.has(f) ? ROLE_WEIGHTS.display : "400;700");
61
+ if (TUNED_DISPLAY.has(f)) return { weights, weight: null, tracking: null };
62
+ const heaviest = Math.max(...weights.split(";").map(Number));
63
+ return { weights, weight: heaviest === 700 ? null : String(heaviest), tracking: "0" };
64
+ }
37
65
 
38
66
  const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
39
67
  // A Google Fonts family name: letters, digits and spaces (e.g. "Source Sans 3").
@@ -54,7 +82,7 @@ export function findPairing(id) {
54
82
  // The Google Fonts stylesheet URL for one family at the weights its roles use
55
83
  // (a family that is both display and body loads both roles' weights).
56
84
  export function googleFontsUrl(family, roles = "body") {
57
- const wanted = [roles].flat().flatMap((r) => (ROLE_WEIGHTS[r] ?? "400;700").split(";"));
85
+ const wanted = [roles].flat().flatMap((r) => (r === "display" ? displayFace(family).weights : ROLE_WEIGHTS[r] ?? "400;700").split(";"));
58
86
  const weights = FAMILY_WEIGHTS[family] ?? [...new Set(wanted)].sort().join(";");
59
87
  return `https://fonts.googleapis.com/css2?family=${family.trim().replace(/ /g, "+")}:wght@${weights}&display=swap`;
60
88
  }
@@ -90,6 +118,21 @@ export function cleanBrand(brand) {
90
118
  return { name, colors, fonts, logoUrl, presenterStyle, hasDesignMd };
91
119
  }
92
120
 
121
+ // The templates' own ink, paper and accent (layouts/_deck.html, narrated-deck.html), for a brand that sets only some colours.
122
+ const TEMPLATE_INK = "#0b0b0f", TEMPLATE_PAPER = "#fafaf7", TEMPLATE_ACCENT = "#ffda1b";
123
+ // WCAG relative luminance and contrast ratio of two #rgb / #rrggbb colours.
124
+ function luminance(hex) {
125
+ const h = hex.replace("#", ""), full = h.length === 3 ? [...h].map((c) => c + c).join("") : h.slice(0, 6);
126
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
127
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
128
+ }
129
+ export const contrast = (a, b) => { const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
130
+ // Which of ink and paper the text on the accent is: ink unless it falls under 4.5:1 and paper reads better.
131
+ export function accentInk(accent, ink, paper) {
132
+ const onInk = contrast(accent, ink);
133
+ return onInk < 4.5 && contrast(accent, paper) > onInk ? "paper" : "ink";
134
+ }
135
+
93
136
  const escapeAttr = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
94
137
  const fontStack = (family, role) => `"${family}", ${FALLBACK[role]}`;
95
138
  const LOGO_CSS = `
@@ -113,9 +156,18 @@ export function applyBrand(html, brand) {
113
156
  block = decl.test(block) ? block.replace(decl, (_, k) => `${k}${value}`) : block.replace(/\s*\}$/, (end) => ` ${name}: ${value};${end}`);
114
157
  };
115
158
  for (const [k, v] of Object.entries(b.colors)) set(CSS_VAR[k], v);
159
+ // Text on an accent ground reads --accent-ink (ink by default): a dark accent
160
+ // that ink cannot be read on takes the paper instead, so a cover on the accent stays legible.
161
+ const { accent = TEMPLATE_ACCENT, ink = TEMPLATE_INK, paper = TEMPLATE_PAPER } = b.colors;
162
+ if ((b.colors.accent || b.colors.ink || b.colors.paper) && accentInk(accent, ink, paper) === "paper") set("--accent-ink", "var(--paper)");
116
163
  if (b.fonts.body) set("--font", fontStack(b.fonts.body, "body"));
117
164
  if (b.fonts.mono) set("--mono", fontStack(b.fonts.mono, "mono"));
118
- if (b.fonts.display) set("--display", fontStack(b.fonts.display, "display"));
165
+ if (b.fonts.display) {
166
+ set("--display", fontStack(b.fonts.display, "display"));
167
+ const face = displayFace(b.fonts.display);
168
+ if (face.weight) set("--display-weight", face.weight);
169
+ if (face.tracking) set("--display-tracking", face.tracking);
170
+ }
119
171
  let out = html.slice(0, root.index) + block + html.slice(root.index + root[0].length);
120
172
 
121
173
  const families = FONT_ROLES.filter((r) => b.fonts[r]).map((r) => [b.fonts[r], r]);
@@ -63,6 +63,7 @@ export const MESSAGES = {
63
63
  gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
64
64
  presenterUnfilled: (mode) => `the player tag says data-presenter="${mode}" but names no face (${mode === "head" ? "data-avatar" : "data-presenter-image"}), so as written it plays captions only. release puts the narrator's face on it; to set one by hand, D.A.S.H. is data-presenter="head" data-avatar="${DASH_AVATAR}" (deck_guide topic presenter)`,
65
65
  presenterKept: (mode) => `the player tag has a data-avatar but data-presenter="${mode ?? "none"}", so it plays captions only and release leaves it that way: set data-presenter="head" to show that face, or remove data-avatar so release adds the narrator's`,
66
+ presenterCaptions: () => `the player tag plays captions only as written (data-presenter="none" or none set), so the file served as is (npx serve, python3 -m http.server, the screenshot pass) shows no face: run \`hyperdecks deck narrator <slug> <name>\` (or narrate again) to write the narrator's face into index.html, or watch it with \`hyperdecks play <deck>\`; release adds the face either way. Captions only on purpose: \`hyperdecks deck narrator <slug> none\``,
66
67
  presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
67
68
  presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
68
69
  transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
@@ -71,9 +72,13 @@ export const MESSAGES = {
71
72
  transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
72
73
  flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
73
74
  // Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
75
+ coverFade: (where, layout) => `${where}: the ${layout} cover fades its picture into the ground with a mask-image, so the title sits on a mix of picture and colour that is hard to read: keep the text off the picture (layouts hero, cover-band, cover-poster or cover-type; deck_guide topic layouts)`,
74
76
  fewPictures: (shown, total) => `only ${shown} of ${total} slides show a picture (a screenshot, a recording, a photo, a chart); aim for a real one on every other slide (deck_guide topic look)`,
75
77
  presenterGutter: (where, decl) => `${where}: ${decl} keeps a right gutter for the presenter; leave that corner to the slide's .narration-slot so a deck without a presenter can use it (deck_guide topic look)`,
76
78
  mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
79
+ retiredTexture: (where, cls, instead, inlined) => `${where}: ${cls} is a whole-slide texture the kit no longer ships ${inlined ? "(this deck still carries its rule, so it runs under the text)" : "(it ran under the text), so it paints nothing"}; ${instead} (deck_guide topic look)`,
80
+ tooManySignatures: (n) => `${n} slides carry a signature background (bg-corner or bg-mark); keep it to three at most: the cover, one opener or the ask, the closer (deck_guide topic look)`,
81
+ textureOff: (where, cls, layout) => `${where}: ${cls} does nothing on ${layout} (the shell switches it off there: nothing to back); remove it, or use bg-pool behind the picture (deck_guide topic look)`,
77
82
  // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
78
83
  placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
79
84
  // Warnings (check --layout): the deck still passes.
@@ -312,6 +317,19 @@ export function slides(html) {
312
317
  });
313
318
  }
314
319
 
320
+ // Each slide's titles written short for phones: { id, titles: [{ desktop, phone }] },
321
+ // only where the phone text differs. The player swaps data-title-mobile in below
322
+ // 650px, so review must not read the shorter phone title as cut off.
323
+ export function mobileTitles(html) {
324
+ return slides(html).map(({ id, body }) => ({
325
+ id,
326
+ titles: [...body.matchAll(/<([a-z][\w-]*)\b([^>]*\sdata-title-mobile\s*=[^>]*)>([\s\S]*?)<\/\1>/gi)]
327
+ .map(([, , attrs, inner]) => ({ desktop: textOf(inner), phone: attr(attrs, "data-title-mobile") }))
328
+ .filter((t) => t.phone != null && decode(t.phone).trim() && decode(t.phone).trim() !== t.desktop)
329
+ .map((t) => ({ desktop: t.desktop.slice(0, 200), phone: decode(t.phone).trim().slice(0, 200) })),
330
+ })).filter((s) => s.id && s.titles.length);
331
+ }
332
+
315
333
  // The player tag also carries the deck's declarations: data-silent (no
316
334
  // narration, on purpose) and data-kind="pitch" (every slide sources its facts).
317
335
  // The check reads what the deck says, never guesses from its content.
@@ -531,6 +549,14 @@ const PLACEHOLDERS = [
531
549
  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)',
532
550
  how: { hosted: "make a picture of the real subject with generate_image", cli: "make a picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
533
551
  },
552
+ {
553
+ layout: "l-cover-band", tag: "div", cls: "art", what: "the cover's picture", img: '<img class="art" src="…" alt="">, or use the cover-type layout for no picture',
554
+ how: { hosted: "make a wide picture of the real subject with generate_image", cli: "make a wide picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
555
+ },
556
+ {
557
+ layout: "l-cover-poster", tag: "div", cls: "art", what: "the cover's picture", img: '<img class="art" src="…" alt="">, or use the cover-type layout for no picture',
558
+ how: { hosted: "make a wide picture of the real subject with generate_image", cli: "make a wide picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
559
+ },
534
560
  {
535
561
  layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
536
562
  how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
@@ -810,6 +836,62 @@ export function lookWarnings(html) {
810
836
  if (iconImg(tag)) sets.add("image icons");
811
837
  }
812
838
  if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
839
+ out.push(...textureWarnings(all, html), ...coverFadeWarnings(all, html));
840
+ return out;
841
+ }
842
+
843
+ // Covers never put text on the picture. Where the text overlaps a picture can only be
844
+ // measured by rendering, so this reads the one source pattern that always does: a cover
845
+ // layout whose CSS fades its picture with a gradient mask-image (the old hero's look,
846
+ // still inlined in decks made from it). A deck's own picture and text placement beyond
847
+ // that is left to the layouts and the review.
848
+ export const COVER_LAYOUTS = ["l-hero", "l-cover-band", "l-cover-poster", "l-cover-type"];
849
+ function coverFadeWarnings(all, html) {
850
+ const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
851
+ const faded = new Set();
852
+ for (const [, sel, body] of css.matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
853
+ if (!/(?:^|;|\s)(?:-webkit-)?mask(?:-image)?\s*:[^;]*gradient\(/i.test(body)) continue;
854
+ for (const l of COVER_LAYOUTS) if (new RegExp(`\\.${l}(?![\\w-])`).test(sel)) faded.add(l);
855
+ }
856
+ if (!faded.size) return [];
857
+ return all.flatMap((s, i) => classes(s.tag).filter((c) => faded.has(c)).map((c) => MESSAGES.coverFade(`slide ${s.id || i + 1}`, c.slice(2))));
858
+ }
859
+
860
+ // Backgrounds (the look topic): the kit's textures are confined to the object they
861
+ // back. The retired whole-slide textures warn rather than vanish silently (a deck made
862
+ // before keeps their rules in its own inlined shell, where they still paint). Read from each slide's class attribute; a texture on a layout whose shell
863
+ // rule switches it off (TEXTURE_OFF, pinned to _deck.html by lib/deckLayouts.test.ts) did nothing, and a custom
864
+ // slide (no l-* class) is never judged on that. Commented-out slides do not count.
865
+ const RETIRED_TEXTURES = {
866
+ "bg-rays": "use a plain ground, or bg-corner on the cover and the closer",
867
+ "bg-lines": "use a plain ground, or bg-pool behind the picture",
868
+ "bg-halftone": "use bg-corner on the cover and the closer",
869
+ "bg-dots": "use bg-stage under the mockup or the phones",
870
+ };
871
+ const SIGNATURES = ["bg-corner", "bg-mark"];
872
+ export const MAX_SIGNATURE_SLIDES = 3;
873
+ const BUSY_LAYOUTS = ["l-number", "l-features", "l-team", "l-timeline", "l-compare"]; // full-width content: nothing to back
874
+ const PICTURE_COVERS = ["l-cover-band", "l-cover-poster"]; // the picture is the ground: a texture would only sit under it
875
+ export const TEXTURE_OFF = {
876
+ "bg-corner": ["l-split", "l-product", "l-chart", "l-quote", ...BUSY_LAYOUTS, ...PICTURE_COVERS],
877
+ "bg-stage": ["l-split", "l-chart", "l-quote", "l-closer", ...BUSY_LAYOUTS, ...PICTURE_COVERS, "l-cover-type"],
878
+ "bg-mark": ["l-split", "l-product", "l-chart", "l-phones", ...BUSY_LAYOUTS, ...PICTURE_COVERS],
879
+ };
880
+ function textureWarnings(all, html) {
881
+ const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
882
+ const inlined = (c) => new RegExp(`\\.${c}(?![\\w-])[^{}]*\\{`).test(css);
883
+ const out = [];
884
+ let signatures = 0;
885
+ all.forEach((s, i) => {
886
+ const where = `slide ${s.id || i + 1}`;
887
+ const cls = classes(s.tag);
888
+ for (const [c, instead] of Object.entries(RETIRED_TEXTURES)) if (cls.includes(c)) out.push(MESSAGES.retiredTexture(where, c, instead, inlined(c)));
889
+ if (SIGNATURES.some((c) => cls.includes(c))) signatures++;
890
+ const layout = cls.find((c) => /^l-[\w-]+$/.test(c));
891
+ if (!layout) return;
892
+ for (const [c, off] of Object.entries(TEXTURE_OFF)) if (cls.includes(c) && off.includes(layout)) out.push(MESSAGES.textureOff(where, c, layout));
893
+ });
894
+ if (signatures > MAX_SIGNATURE_SLIDES) out.push(MESSAGES.tooManySignatures(signatures));
813
895
  return out;
814
896
  }
815
897
 
@@ -970,8 +1052,14 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
970
1052
  }
971
1053
  warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
972
1054
  // A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
973
- const tagIssue = silent ? null : presenterTagIssue(html);
974
- if (tagIssue) warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter) : MESSAGES.presenterUnfilled(tagIssue.presenter));
1055
+ // A hosted draft plays only through its draft link and release_deck, which add the face, so a plain none is no issue there.
1056
+ const found = silent ? null : presenterTagIssue(html);
1057
+ const tagIssue = found?.kind === "captions" && opts.hosted ? null : found;
1058
+ if (tagIssue) {
1059
+ warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter)
1060
+ : tagIssue.kind === "captions" ? MESSAGES.presenterCaptions()
1061
+ : MESSAGES.presenterUnfilled(tagIssue.presenter));
1062
+ }
975
1063
  for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
976
1064
  for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
977
1065
  if (opts.hosted) {