@hypersocial/decks-cli 0.9.9 → 0.9.10
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 +1 -0
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +51 -24
- package/skills/hyperdecks/chat.md +5 -0
- package/src/brand.mjs +38 -5
- package/src/checkRules.mjs +50 -2
- package/src/commands.mjs +39 -9
- package/src/draftFace.mjs +56 -0
- package/src/narrate.mjs +6 -1
- package/src/ops.mjs +25 -3
- package/src/play.mjs +2 -2
- package/src/presenter.mjs +73 -9
- package/src/release.mjs +8 -7
- package/src/runtime.mjs +1 -1
- package/src/scaffold.mjs +27 -6
- package/templates/layouts/_deck.html +71 -32
- package/templates/layouts/closer.html +1 -1
- package/templates/layouts/phones.html +5 -5
- package/templates/layouts/split.html +2 -2
- package/templates/narrated-deck.html +3 -1
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
|
@@ -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
|
-
**
|
|
270
|
-
writes
|
|
271
|
-
`
|
|
272
|
-
narrator's
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|
@@ -492,7 +510,7 @@ suit it.
|
|
|
492
510
|
|
|
493
511
|
**Phones and backgrounds.** One phone: `product`'s `.frame.card`; a flow of
|
|
494
512
|
two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
|
|
495
|
-
`bg-
|
|
513
|
+
`bg-stage` (never under text), a `.deco` of inked tiles.
|
|
496
514
|
|
|
497
515
|
Presenter style follows the deck (step 2): `stage` when the presenter carries
|
|
498
516
|
the story (the layouts keep the slot's corner clear); `corner` where a big
|
|
@@ -552,7 +570,8 @@ still.
|
|
|
552
570
|
- `[go]` when the narration walks through items (cards, milestones, people,
|
|
553
571
|
the action on the closer): one `go` per item at the word that starts it,
|
|
554
572
|
then `[go: slot]` before the slide ends. Never more than one `go` per
|
|
555
|
-
sentence; never a `go` on a slide in `corner` style.
|
|
573
|
+
sentence; never a `go` on a slide in `corner` style. A `go` keeps off
|
|
574
|
+
the picture; with no room it stays home.
|
|
556
575
|
- `[look]` for a glance: the title as it is read, a number as it is said, a
|
|
557
576
|
screenshot as the narration turns to it.
|
|
558
577
|
- `[reveal]` for the one detail that should not be read before it is said (a
|
|
@@ -564,8 +583,8 @@ still.
|
|
|
564
583
|
<!-- topic: look -->
|
|
565
584
|
#### Look: textures, icons, pictures
|
|
566
585
|
|
|
567
|
-
What the house decks do
|
|
568
|
-
|
|
586
|
+
What the house decks do. Decide these three once per deck, before writing
|
|
587
|
+
slides, and say them in your plan.
|
|
569
588
|
|
|
570
589
|
**1. A picture on at least every other slide**, the most real one you have:
|
|
571
590
|
|
|
@@ -579,18 +598,22 @@ once per deck, before writing slides, and say them in your plan.
|
|
|
579
598
|
No product to show (a plan, a strategy)? Make the slides visual with type
|
|
580
599
|
and the kit below: a `number` slide, stickers, ticks on a `panel`.
|
|
581
600
|
|
|
582
|
-
**2.
|
|
583
|
-
|
|
601
|
+
**2. Plain grounds; texture only behind objects.** Alternate plain,
|
|
602
|
+
`on-tint`, `on-tint-2`, `on-tint-3`, `on-accent`, `on-ink`. On the section:
|
|
584
603
|
|
|
585
604
|
| Class | Looks like | For |
|
|
586
605
|
|---|---|---|
|
|
587
|
-
| `bg-
|
|
588
|
-
| `bg-
|
|
589
|
-
| `bg-
|
|
590
|
-
| `bg-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
606
|
+
| `bg-pool` | a soft light behind the picture | any picture |
|
|
607
|
+
| `bg-corner` | a halftone quarter-disc, top-right | cover, closer |
|
|
608
|
+
| `bg-stage` | a dot plate under a mockup | product, phones |
|
|
609
|
+
| `bg-mark` | the slide number, huge, faint (`--mark:'04'`) | cover, opener, closer |
|
|
610
|
+
|
|
611
|
+
- One signature per deck (`bg-corner` or `bg-mark`), on three slides at
|
|
612
|
+
most: the cover, an opener or the ask, the closer.
|
|
613
|
+
- `bg-pool` and `bg-stage` are picture helpers: only beside an image.
|
|
614
|
+
- Nothing under text, a chart or a photo; the presenter's corner stays clear.
|
|
615
|
+
- Your own is fine in the deck's `<style>`: tokens only, behind the content
|
|
616
|
+
in an isolated slide, off with nothing to back.
|
|
594
617
|
|
|
595
618
|
**3. One icon set.** Pick one source and keep it for every icon in the deck:
|
|
596
619
|
Lucide (`search_icons`, same `strokeWidth` throughout); or one
|
|
@@ -610,7 +633,7 @@ palette <ink hex> <accent hex> <paper hex>, no text, no logos`
|
|
|
610
633
|
(`generate_image { brand: true }` adds the palette and the "no text" for
|
|
611
634
|
you). Generate at the slide's shape (wide for a hero), in one batch.
|
|
612
635
|
|
|
613
|
-
**Slide recipes.** Opener: `on-accent` + `bg-
|
|
636
|
+
**Slide recipes.** Opener: `on-accent` + `bg-corner`, the logo
|
|
614
637
|
or a few app-icon stickers, one display line. Product or feature: `split`
|
|
615
638
|
with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
|
|
616
639
|
`timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
|
|
@@ -840,6 +863,10 @@ browser (an agent reads the URL; `--json` prints it as one line).
|
|
|
840
863
|
`release --qa` is not a preview: it needs a commit, and it changes what every
|
|
841
864
|
link to this deck plays.
|
|
842
865
|
|
|
866
|
+
Watch a draft with `hyperdecks play`: it shows the face the deck will ship
|
|
867
|
+
with. A plain static server shows the file as written: the face named in the
|
|
868
|
+
tag (the narrator's, since `deck new`, `deck narrator` and `narrate` write it
|
|
869
|
+
there), or none for a tag that says `data-presenter="none"` (`check` warns).
|
|
843
870
|
For the layout pass, serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
|
|
844
871
|
and screenshot it with Playwright at 1440×900 and 390×844, every slide
|
|
845
872
|
(`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
|
|
@@ -90,6 +90,11 @@ makes the hello deck; continue from step 5 below.
|
|
|
90
90
|
only, when nobody chose that, is refused: hand the person its Add narrator
|
|
91
91
|
link, or offer D.A.S.H., and pass `captionsOnly: true` only if they say so.
|
|
92
92
|
The result's `presenter` names who presents.
|
|
93
|
+
To present or check your own deck (on a call, say), give the user its
|
|
94
|
+
owner link, `ownerUrl` from `list_decks` or `check_deck`: only they can
|
|
95
|
+
open it, signed in, and nothing is tracked; Present (or H) hides their
|
|
96
|
+
owner tools for a screen share. QA links are only for testing what a
|
|
97
|
+
recipient sees, not for presenting.
|
|
93
98
|
10. Later: `list_shares` and `deck_stats` (both show the card),
|
|
94
99
|
`activity_overview`; `email_embed { token }` for the HTML that pastes into an
|
|
95
100
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -115,7 +143,12 @@ export function applyBrand(html, brand) {
|
|
|
115
143
|
for (const [k, v] of Object.entries(b.colors)) set(CSS_VAR[k], v);
|
|
116
144
|
if (b.fonts.body) set("--font", fontStack(b.fonts.body, "body"));
|
|
117
145
|
if (b.fonts.mono) set("--mono", fontStack(b.fonts.mono, "mono"));
|
|
118
|
-
if (b.fonts.display)
|
|
146
|
+
if (b.fonts.display) {
|
|
147
|
+
set("--display", fontStack(b.fonts.display, "display"));
|
|
148
|
+
const face = displayFace(b.fonts.display);
|
|
149
|
+
if (face.weight) set("--display-weight", face.weight);
|
|
150
|
+
if (face.tracking) set("--display-tracking", face.tracking);
|
|
151
|
+
}
|
|
119
152
|
let out = html.slice(0, root.index) + block + html.slice(root.index + root[0].length);
|
|
120
153
|
|
|
121
154
|
const families = FONT_ROLES.filter((r) => b.fonts[r]).map((r) => [b.fonts[r], r]);
|
package/src/checkRules.mjs
CHANGED
|
@@ -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")`,
|
|
@@ -74,6 +75,9 @@ export const MESSAGES = {
|
|
|
74
75
|
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
76
|
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
77
|
mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
|
|
78
|
+
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)`,
|
|
79
|
+
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)`,
|
|
80
|
+
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
81
|
// A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
|
|
78
82
|
placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
|
|
79
83
|
// Warnings (check --layout): the deck still passes.
|
|
@@ -810,6 +814,44 @@ export function lookWarnings(html) {
|
|
|
810
814
|
if (iconImg(tag)) sets.add("image icons");
|
|
811
815
|
}
|
|
812
816
|
if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
|
|
817
|
+
out.push(...textureWarnings(all, html));
|
|
818
|
+
return out;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Backgrounds (the look topic): the kit's textures are confined to the object they
|
|
822
|
+
// back. The retired whole-slide textures warn rather than vanish silently (a deck made
|
|
823
|
+
// 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
|
|
824
|
+
// rule switches it off (TEXTURE_OFF, pinned to _deck.html by lib/deckLayouts.test.ts) did nothing, and a custom
|
|
825
|
+
// slide (no l-* class) is never judged on that. Commented-out slides do not count.
|
|
826
|
+
const RETIRED_TEXTURES = {
|
|
827
|
+
"bg-rays": "use a plain ground, or bg-corner on the cover and the closer",
|
|
828
|
+
"bg-lines": "use a plain ground, or bg-pool behind the picture",
|
|
829
|
+
"bg-halftone": "use bg-corner on the cover and the closer",
|
|
830
|
+
"bg-dots": "use bg-stage under the mockup or the phones",
|
|
831
|
+
};
|
|
832
|
+
const SIGNATURES = ["bg-corner", "bg-mark"];
|
|
833
|
+
export const MAX_SIGNATURE_SLIDES = 3;
|
|
834
|
+
const BUSY_LAYOUTS = ["l-number", "l-features", "l-team", "l-timeline", "l-compare"]; // full-width content: nothing to back
|
|
835
|
+
export const TEXTURE_OFF = {
|
|
836
|
+
"bg-corner": ["l-split", "l-product", "l-chart", "l-quote", ...BUSY_LAYOUTS],
|
|
837
|
+
"bg-stage": ["l-split", "l-chart", "l-quote", "l-closer", ...BUSY_LAYOUTS],
|
|
838
|
+
"bg-mark": ["l-split", "l-product", "l-chart", "l-phones", ...BUSY_LAYOUTS],
|
|
839
|
+
};
|
|
840
|
+
function textureWarnings(all, html) {
|
|
841
|
+
const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
|
|
842
|
+
const inlined = (c) => new RegExp(`\\.${c}(?![\\w-])[^{}]*\\{`).test(css);
|
|
843
|
+
const out = [];
|
|
844
|
+
let signatures = 0;
|
|
845
|
+
all.forEach((s, i) => {
|
|
846
|
+
const where = `slide ${s.id || i + 1}`;
|
|
847
|
+
const cls = classes(s.tag);
|
|
848
|
+
for (const [c, instead] of Object.entries(RETIRED_TEXTURES)) if (cls.includes(c)) out.push(MESSAGES.retiredTexture(where, c, instead, inlined(c)));
|
|
849
|
+
if (SIGNATURES.some((c) => cls.includes(c))) signatures++;
|
|
850
|
+
const layout = cls.find((c) => /^l-[\w-]+$/.test(c));
|
|
851
|
+
if (!layout) return;
|
|
852
|
+
for (const [c, off] of Object.entries(TEXTURE_OFF)) if (cls.includes(c) && off.includes(layout)) out.push(MESSAGES.textureOff(where, c, layout));
|
|
853
|
+
});
|
|
854
|
+
if (signatures > MAX_SIGNATURE_SLIDES) out.push(MESSAGES.tooManySignatures(signatures));
|
|
813
855
|
return out;
|
|
814
856
|
}
|
|
815
857
|
|
|
@@ -970,8 +1012,14 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
970
1012
|
}
|
|
971
1013
|
warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
|
|
972
1014
|
// A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
|
|
973
|
-
|
|
974
|
-
|
|
1015
|
+
// A hosted draft plays only through its draft link and release_deck, which add the face, so a plain none is no issue there.
|
|
1016
|
+
const found = silent ? null : presenterTagIssue(html);
|
|
1017
|
+
const tagIssue = found?.kind === "captions" && opts.hosted ? null : found;
|
|
1018
|
+
if (tagIssue) {
|
|
1019
|
+
warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter)
|
|
1020
|
+
: tagIssue.kind === "captions" ? MESSAGES.presenterCaptions()
|
|
1021
|
+
: MESSAGES.presenterUnfilled(tagIssue.presenter));
|
|
1022
|
+
}
|
|
975
1023
|
for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
|
|
976
1024
|
for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
|
|
977
1025
|
if (opts.hosted) {
|
package/src/commands.mjs
CHANGED
|
@@ -72,12 +72,23 @@ async function deckStats(pos, opts) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// The owner link: present or check your own deck (untracked, owner-only).
|
|
76
|
+
function deckLink(pos, opts) {
|
|
77
|
+
const [slug] = pos;
|
|
78
|
+
if (!slug) throw new Error("usage: deck link <slug> [--json]");
|
|
79
|
+
const out = ops.ownerLink(opts, slug);
|
|
80
|
+
if (opts.json) return console.log(JSON.stringify(out, null, 2));
|
|
81
|
+
console.log(out.ownerUrl);
|
|
82
|
+
if (out.backupUrl) console.log(`backup (other domain): ${out.backupUrl}`);
|
|
83
|
+
console.log("Only you can open it, signed in; nothing is tracked. Present (or H) hides your owner tools for a screen share.");
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
function deckOpen(pos, opts) {
|
|
76
87
|
const [slug] = pos;
|
|
77
88
|
if (!slug) throw new Error("usage: deck open <slug>");
|
|
78
|
-
const
|
|
79
|
-
console.log(
|
|
80
|
-
openBrowser(
|
|
89
|
+
const { ownerUrl } = ops.ownerLink(opts, slug);
|
|
90
|
+
console.log(ownerUrl);
|
|
91
|
+
openBrowser(ownerUrl);
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
async function deckRemove(pos, opts) {
|
|
@@ -89,8 +100,11 @@ async function deckRemove(pos, opts) {
|
|
|
89
100
|
|
|
90
101
|
async function shareNew(pos, opts) {
|
|
91
102
|
const [slug, recipient] = pos;
|
|
92
|
-
|
|
103
|
+
const usage = "usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]";
|
|
104
|
+
if (!slug || !recipient) throw new Error(usage);
|
|
93
105
|
if (opts["allow-forward"] && opts["no-forward"]) throw new Error("pick one of --allow-forward and --no-forward");
|
|
106
|
+
// The link's own pace; without it the link follows the deck's.
|
|
107
|
+
if ("pace" in opts && !ops.DECK_PACES.includes(opts.pace)) throw new Error(usage);
|
|
94
108
|
const { token } = await ops.createShare(opts, {
|
|
95
109
|
slug,
|
|
96
110
|
recipient,
|
|
@@ -100,6 +114,7 @@ async function shareNew(pos, opts) {
|
|
|
100
114
|
qa: opts.qa || undefined,
|
|
101
115
|
notify: opts["no-notify"] ? false : undefined,
|
|
102
116
|
allowForward: opts["allow-forward"] ? true : opts["no-forward"] ? false : undefined,
|
|
117
|
+
pace: opts.pace,
|
|
103
118
|
expires: opts.expires,
|
|
104
119
|
limit: opts.limit,
|
|
105
120
|
});
|
|
@@ -123,7 +138,7 @@ async function shareList(pos, opts) {
|
|
|
123
138
|
// Rows arrive with each link's forwards right after it; indent those.
|
|
124
139
|
for (const r of rows) {
|
|
125
140
|
const who = r.parentToken ? ` ↳ ${r.recipient} via ${r.forwardedBy ?? "?"}` : r.recipient;
|
|
126
|
-
console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
|
|
141
|
+
console.log(`${r.enabled ? "on " : "off"}\t${r.token}\t${who}\topens:${r.opens}\tfwd:${r.allowForward ? "on" : "off"}${r.pace ? `\tpace:${r.pace}` : ""}${r.qa ? "\tQA (untracked)" : ""}${r.notify === false ? "\tmuted" : ""}${r.probablyForwarded ? "\tprobably forwarded" : ""}${r.archived ? "\t(archived)" : ""}`);
|
|
127
142
|
}
|
|
128
143
|
if (!rows.length) console.log("(no shares)");
|
|
129
144
|
}
|
|
@@ -348,7 +363,10 @@ commands:
|
|
|
348
363
|
login [--token <pat>] [--broker <url>] [--dev]
|
|
349
364
|
logout [--dev]
|
|
350
365
|
whoami [--json] [--dev]
|
|
351
|
-
deck list|stats|open|rm|forward|view
|
|
366
|
+
deck list|stats|open|link|rm|forward|view
|
|
367
|
+
deck link <slug> [--json]
|
|
368
|
+
the owner link: present or check your own deck (only you, signed in; untracked;
|
|
369
|
+
Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
|
|
352
370
|
deck forward on|off <slug>
|
|
353
371
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
354
372
|
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
|
|
@@ -378,7 +396,7 @@ commands:
|
|
|
378
396
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
|
|
379
397
|
on a committed deck: check → narrate → import → share new per --to
|
|
380
398
|
share new|list|on|off|forward|mute|unmute|rm|url
|
|
381
|
-
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
|
|
399
|
+
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward] [--pace auto|slide|step]
|
|
382
400
|
share forward on|off|default <token>
|
|
383
401
|
let this link's viewer forward it as their own tracked link ("Dana via Peter",
|
|
384
402
|
one hop); default follows the deck. share off on a link also turns off its forwards.
|
|
@@ -972,9 +990,20 @@ function commandTable(pos, rest, opts) {
|
|
|
972
990
|
console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
|
|
973
991
|
},
|
|
974
992
|
"deck narrator": async () => {
|
|
975
|
-
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
|
|
993
|
+
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default> [--dir <deck folder>]");
|
|
976
994
|
const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
977
|
-
|
|
995
|
+
const log = opts.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
996
|
+
log(`OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
|
|
997
|
+
// The local file names the face too, so the draft opened from disk shows it.
|
|
998
|
+
const { writeDraftFace } = await import("./draftFace.mjs");
|
|
999
|
+
const dir = resolve(typeof opts.dir === "string" ? opts.dir : join("decks", out.slug));
|
|
1000
|
+
let local = { wrote: false };
|
|
1001
|
+
try {
|
|
1002
|
+
local = await writeDraftFace(opts, out.slug, join(dir, "index.html"), { log });
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
log(`⚠ presenter: the server was updated but index.html was not (${e.message})`);
|
|
1005
|
+
}
|
|
1006
|
+
if (opts.json) console.log(JSON.stringify({ ...out, indexHtml: local.wrote ? join(dir, "index.html") : null }, null, 2));
|
|
978
1007
|
},
|
|
979
1008
|
"preview setup": async () => {
|
|
980
1009
|
const { setupCapture } = await import("./preview-capture.mjs");
|
|
@@ -986,6 +1015,7 @@ function commandTable(pos, rest, opts) {
|
|
|
986
1015
|
"deck list": () => deckList(opts),
|
|
987
1016
|
"deck stats": () => deckStats(rest, opts),
|
|
988
1017
|
"deck open": () => deckOpen(rest, opts),
|
|
1018
|
+
"deck link": () => deckLink(rest, opts),
|
|
989
1019
|
"deck rm": () => deckRemove(rest, opts),
|
|
990
1020
|
"deck forward": () => deckForward(rest, opts),
|
|
991
1021
|
"deck view": () => deckView(rest, opts),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The narrator's face written into a draft's index.html on disk (roadmap:
|
|
2
|
+
// a-narrated-draft-shows-its-narrator-s-face-from-the-file-on-), so the file
|
|
3
|
+
// plays with its face however it is opened, not only through play and release.
|
|
4
|
+
// deck new, deck narrator and narrate use it.
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import * as ops from "./ops.mjs";
|
|
7
|
+
import { DASH_PRESENTER, withNarrator, writeNarratorFace } from "./presenter.mjs";
|
|
8
|
+
|
|
9
|
+
// What to write for `slug`: { face } (null: captions only on purpose), or
|
|
10
|
+
// { face: undefined, why } when the narrator has no face and nobody chose captions
|
|
11
|
+
// only (writeDraftFace then takes a CLI-written face off), or the lookup failed
|
|
12
|
+
// (`failed`: nothing is decided, the file stays).
|
|
13
|
+
export async function draftFace(opts, slug) {
|
|
14
|
+
let found;
|
|
15
|
+
try {
|
|
16
|
+
found = await ops.presenterWithReason(opts, slug, undefined, { draft: true });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return { face: undefined, why: `could not look up the narrator's face (${e.message})`, failed: true };
|
|
19
|
+
}
|
|
20
|
+
if (found?.presenter) return { face: found.presenter, name: found.who?.face ?? null };
|
|
21
|
+
// Captions only on purpose only when the deck says so; faces off or a missing face stays unwritten.
|
|
22
|
+
if (!found?.presenter && found?.captionsOnly) return { face: null, name: null };
|
|
23
|
+
return { face: undefined, why: found?.who?.reason ?? "no narrator with a face" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Write `slug`'s face into `htmlPath`. `fallback`: the face to write when the
|
|
27
|
+
// lookup fails (deck new writes D.A.S.H.). Logs one line; returns what it wrote.
|
|
28
|
+
export async function writeDraftFace(opts, slug, htmlPath, { log = () => {}, fallback } = {}) {
|
|
29
|
+
if (!existsSync(htmlPath)) {
|
|
30
|
+
log(`⚠ presenter: ${htmlPath} not found, so the file was not updated (the server was); pass --dir <deck folder>`);
|
|
31
|
+
return { wrote: false };
|
|
32
|
+
}
|
|
33
|
+
const got = await draftFace(opts, slug);
|
|
34
|
+
const face = got.face === undefined && got.failed && fallback ? fallback : got.face;
|
|
35
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
36
|
+
if (face === undefined) {
|
|
37
|
+
// The narrator has no face now: a face the CLI wrote for an earlier one comes off, as play and release do.
|
|
38
|
+
const off = got.failed ? html : withNarrator(html, null, { face: null });
|
|
39
|
+
if (off !== html) {
|
|
40
|
+
writeFileSync(htmlPath, off);
|
|
41
|
+
log(`⚠ presenter: no face (${got.why}); the earlier narrator's face was taken off index.html`);
|
|
42
|
+
return { wrote: true, face: null, why: got.why };
|
|
43
|
+
}
|
|
44
|
+
log(`⚠ presenter: index.html left as written: ${got.why}`);
|
|
45
|
+
return { wrote: false, why: got.why };
|
|
46
|
+
}
|
|
47
|
+
const out = writeNarratorFace(html, face);
|
|
48
|
+
if (!out.wrote) {
|
|
49
|
+
log(`⚠ presenter: index.html left as written: ${out.why}`);
|
|
50
|
+
return { wrote: false, why: out.why };
|
|
51
|
+
}
|
|
52
|
+
if (out.html !== html) writeFileSync(htmlPath, out.html);
|
|
53
|
+
const who = face === null ? "captions only" : face === DASH_PRESENTER && got.failed ? `D.A.S.H. (${got.why}; deck narrator <name> fixes it)` : got.name ?? "the narrator's face";
|
|
54
|
+
log(` presenter: ${who}, written to index.html`);
|
|
55
|
+
return { wrote: true, face };
|
|
56
|
+
}
|
package/src/narrate.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
9
9
|
import { basename, join, resolve } from "node:path";
|
|
10
10
|
import * as ops from "./ops.mjs";
|
|
11
11
|
import { presenterLine } from "./presenter.mjs";
|
|
12
|
+
import { writeDraftFace } from "./draftFace.mjs";
|
|
12
13
|
export { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
13
14
|
import { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
14
15
|
|
|
@@ -39,7 +40,8 @@ export function narrationRequest(pos, opts) {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
// writeFace false: leave the presenter as written (release narrates, then puts the face on the upload itself).
|
|
44
|
+
export async function narrate(pos, opts = {}, { log = (line) => console.log(line), writeFace = true } = {}) {
|
|
43
45
|
const { deckDir, fields } = narrationRequest(pos, opts);
|
|
44
46
|
if (opts["dry-run"]) {
|
|
45
47
|
const preview = await ops.previewNarration(opts, fields);
|
|
@@ -85,6 +87,9 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
|
|
|
85
87
|
writeFileSync(htmlPath, next);
|
|
86
88
|
log(` index.html: data-manifest → ${job.manifestUrl}`);
|
|
87
89
|
}
|
|
90
|
+
// The narrator's face too, so the file opened as written shows what play and release show.
|
|
91
|
+
// The narration is paid for and saved: a failed face write warns and never fails narrate.
|
|
92
|
+
if (writeFace) await writeDraftFace(opts, fields.slug, htmlPath, { log }).catch((e) => log(`⚠ presenter: index.html not updated (${e?.message ?? e})`));
|
|
88
93
|
}
|
|
89
94
|
log(`✓ ${job.manifest.clips.length} clips, ${job.manifest.spokenDuration}s spoken, ${job.manifest.totalDuration}s with holds; ${job.renderedChars} characters rendered`);
|
|
90
95
|
log(` manifest: ${job.manifestUrl}\n saved: ${join(out, "manifest.json")}`);
|
package/src/ops.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { zipSync } from "fflate";
|
|
|
3
3
|
import { SPECIES } from "./species.mjs";
|
|
4
4
|
import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
|
|
5
5
|
import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
|
|
6
|
-
import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
6
|
+
import { api, backupLinkUrl, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
7
7
|
import {
|
|
8
8
|
bytesFromText,
|
|
9
9
|
isTextAsset,
|
|
@@ -21,6 +21,17 @@ export async function whoami(opts = {}) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Your own decks. `all: true` lists every deck, for admins only (support tools).
|
|
24
|
+
// The owner link (/d/<slug>, app/d/[slug]): the released deck for its owner
|
|
25
|
+
// only, untracked, with the owner toolbar (Present hides it for a screen
|
|
26
|
+
// share). How an owner or their agent presents or checks a deck; anyone else
|
|
27
|
+
// gets a sign-in notice. QA links are only for testing what a recipient sees.
|
|
28
|
+
// `backupUrl`: the same link on the other app host (null in dev).
|
|
29
|
+
export function ownerLink(opts, slug) {
|
|
30
|
+
if (!slug) throw new Error("slug is required");
|
|
31
|
+
const url = `${target(opts).brokerBase}/d/${encodeURIComponent(slug)}`;
|
|
32
|
+
return { slug, ownerUrl: url, backupUrl: backupLinkUrl(url) };
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
export async function listDecks(opts = {}, { all = false } = {}) {
|
|
25
36
|
const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
|
|
26
37
|
return rows.map((d) => ({
|
|
@@ -39,6 +50,9 @@ export async function listDecks(opts = {}, { all = false } = {}) {
|
|
|
39
50
|
// false: the deck runs its own player, so view/viewToggle do not apply
|
|
40
51
|
// (null: not known yet, treated as on).
|
|
41
52
|
usesPlayer: d.usesPlayer ?? null,
|
|
53
|
+
// Present or check it yourself here (owner-only, untracked); not a link to
|
|
54
|
+
// send. Null with nothing released, or on someone else's deck (admins' all).
|
|
55
|
+
ownerUrl: !all && (d.html || d.htmlStorageId || d.hasHtml) ? ownerLink(opts, d.slug).ownerUrl : null,
|
|
42
56
|
}));
|
|
43
57
|
}
|
|
44
58
|
|
|
@@ -568,6 +582,8 @@ export async function listShares(opts, slug) {
|
|
|
568
582
|
// viewer who forwarded it. Rows come back with each link's forwards after it.
|
|
569
583
|
allowForward: !!r.allowForward,
|
|
570
584
|
allowForwardOverride: r.allowForwardOverride ?? null,
|
|
585
|
+
// The link's own pace (auto, slide, step); null = it follows the deck.
|
|
586
|
+
pace: r.pace ?? null,
|
|
571
587
|
parentToken: r.parentToken ?? null,
|
|
572
588
|
forwardedBy: r.forwardedBy ?? null,
|
|
573
589
|
probablyForwarded: !!r.probablyForwarded,
|
|
@@ -584,6 +600,11 @@ export async function createShare(opts, fields) {
|
|
|
584
600
|
if (fields.note) args.note = fields.note;
|
|
585
601
|
if (fields.requireEmail) args.requireEmail = true;
|
|
586
602
|
if (fields.allowForward !== undefined) args.allowForward = !!fields.allowForward;
|
|
603
|
+
// The link's own pace; absent = it follows the deck's.
|
|
604
|
+
if (fields.pace !== undefined) {
|
|
605
|
+
if (!DECK_PACES.includes(fields.pace)) throw new Error(`pace must be auto, slide or step (got ${JSON.stringify(fields.pace)})`);
|
|
606
|
+
args.pace = fields.pace;
|
|
607
|
+
}
|
|
587
608
|
if (fields.expires) {
|
|
588
609
|
const t = Date.parse(fields.expires);
|
|
589
610
|
if (Number.isNaN(t)) throw new Error(`expires must be an ISO date; got ${fields.expires}`);
|
|
@@ -1191,8 +1212,9 @@ export async function presenterFor(opts, slug, voice) {
|
|
|
1191
1212
|
}
|
|
1192
1213
|
|
|
1193
1214
|
// The same answer as presenterFor plus who presents in words: { presenter, who }.
|
|
1194
|
-
|
|
1195
|
-
|
|
1215
|
+
// `draft`: also answer for a deck not narrated yet, from the voice its narrate would use.
|
|
1216
|
+
export async function presenterWithReason(opts, slug, voice, { draft = false } = {}) {
|
|
1217
|
+
return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}), ...(draft ? { draft: true } : {}) });
|
|
1196
1218
|
}
|
|
1197
1219
|
|
|
1198
1220
|
// ---- narrators (convex/narrators.ts) ------------------------------------------
|
package/src/play.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { basename, join, relative } from "node:path";
|
|
|
12
12
|
import { playerScript, sectionIds } from "./checkRules.mjs";
|
|
13
13
|
import { resolveDeckDir } from "./check.mjs";
|
|
14
14
|
import * as ops from "./ops.mjs";
|
|
15
|
-
import { playsCaptionsOnly, presenterLine,
|
|
15
|
+
import { playsCaptionsOnly, presenterLine, withNarrator } from "./presenter.mjs";
|
|
16
16
|
import { serveSource } from "./preview-capture.mjs";
|
|
17
17
|
import { openBrowser } from "./runtime.mjs";
|
|
18
18
|
|
|
@@ -88,7 +88,7 @@ export async function startPlay(pos, opts = {}, { log = (line) => console.log(li
|
|
|
88
88
|
const html = readFileSync(file, "utf8");
|
|
89
89
|
|
|
90
90
|
const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
|
|
91
|
-
const page =
|
|
91
|
+
const page = withNarrator(html, presenter, who);
|
|
92
92
|
sayWhoPresents({ html, page, presenter, who, log });
|
|
93
93
|
// --voice names the voice it was narrated in, so there is nothing to compare it to.
|
|
94
94
|
if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
|
package/src/presenter.mjs
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// captions-only (data-presenter="none"); at release, when the owner has an
|
|
4
4
|
// avatar, its player tag gets that face instead. Shared by both releases
|
|
5
5
|
// (convex/deckSources.ts, release.mjs), `hyperdecks play` and the /p draft
|
|
6
|
-
// preview, so all rewrite the tag the same way; only a release stores it. A tag
|
|
7
|
-
//
|
|
6
|
+
// preview, so all rewrite the tag the same way; only a release stores it. A tag
|
|
7
|
+
// that already names a presenter by hand is left alone. deck new, deck narrator
|
|
8
|
+
// and narrate write the narrator's face into the file itself (writeNarratorFace),
|
|
9
|
+
// marked, so a draft on disk shows its face and release still follows the narrator.
|
|
8
10
|
|
|
9
11
|
// D.A.S.H.'s face: the head doc and still that ship with the player. A deck
|
|
10
12
|
// narrated in its character voice gets this face at release, even when the
|
|
@@ -59,36 +61,98 @@ export function playsCaptionsOnly(html) {
|
|
|
59
61
|
return !!found && !tagFace(found.tag);
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// Who wrote the tag's face: data-presenter-from="narrator" is a face the CLI
|
|
65
|
+
// (deck new, deck narrator, narrate) wrote from the deck's narrator,
|
|
66
|
+
// so it follows the narrator at release and in play; "captions-only" is captions
|
|
67
|
+
// only on purpose (deck new --captions-only, deck narrator none). No marker: the
|
|
68
|
+
// tag is as its author wrote it, and a face there is hand-set. The player ignores it.
|
|
69
|
+
export const PRESENTER_FROM = "data-presenter-from";
|
|
70
|
+
const FACE_ATTRS = ["data-presenter", "data-avatar", "data-presenter-image", PRESENTER_FROM];
|
|
71
|
+
const attrRe = (name) => new RegExp(`\\s${name}\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\s"'=<>\`]+)`);
|
|
72
|
+
|
|
73
|
+
// The tag with its face attributes replaced by `face` (an ordered [name, value] list), in place of
|
|
74
|
+
// the first one it had (else before the closing >), leaving every other attribute as written.
|
|
75
|
+
function setFace(tag, face) {
|
|
76
|
+
const at = FACE_ATTRS.map((n) => attrRe(n).exec(tag)).filter(Boolean).sort((a, b) => a.index - b.index)[0];
|
|
77
|
+
let next = tag;
|
|
78
|
+
for (const n of FACE_ATTRS) next = next.replace(attrRe(n), at && attrRe(n).exec(tag)?.index === at.index ? "\u0000" : "");
|
|
79
|
+
const text = face.map(([n, v]) => ` ${n}="${attr(v)}"`).join("");
|
|
80
|
+
return next.includes("\u0000") ? next.replace("\u0000", text) : next.replace(/\s*\/?>$/, (end) => `${text}${end}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const replaceTag = (html, found, tag) => (tag === found.tag ? html : html.slice(0, found.index) + tag + html.slice(found.index + found.tag.length));
|
|
84
|
+
|
|
62
85
|
// html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
|
|
63
86
|
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
|
|
64
87
|
// html unchanged when there is no such tag, the tag already shows a face, or it
|
|
65
88
|
// names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
|
|
66
89
|
// Anything else the player would play captions only (data-presenter none or
|
|
67
|
-
// missing, head with no data-avatar, static with no still) gets the face.
|
|
90
|
+
// missing, head with no data-avatar, static with no still) gets the face. A face
|
|
91
|
+
// the CLI wrote from the narrator (data-presenter-from="narrator") is replaced by
|
|
92
|
+
// this one, so a narrator changed since follows; captions only on purpose stays.
|
|
68
93
|
export function withAvatar(html, { avatarUrl, imageUrl }) {
|
|
69
94
|
const found = playerTag(html);
|
|
70
95
|
if (!found) return html;
|
|
71
|
-
const { tag
|
|
96
|
+
const { tag } = found;
|
|
97
|
+
const from = value(tag, PRESENTER_FROM);
|
|
98
|
+
if (from === "captions-only") return html;
|
|
99
|
+
if (from === "narrator") return replaceTag(html, found, setFace(tag, narratorFace({ avatarUrl, imageUrl })));
|
|
72
100
|
if (tagFace(tag) || value(tag, "data-avatar")) return html;
|
|
73
101
|
const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
|
|
74
102
|
const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
|
|
75
|
-
const named =
|
|
103
|
+
const named = attrRe("data-presenter");
|
|
76
104
|
const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
|
|
77
|
-
return html
|
|
105
|
+
return replaceTag(html, found, next);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The page as its narrator presents it, for play, /p and both releases: the
|
|
109
|
+
// resolved face put on (withAvatar), or, when the narration is on record and
|
|
110
|
+
// resolves to no face (`who.face` null: narrator none, faces off, a face
|
|
111
|
+
// removed), a face the CLI wrote from an earlier narrator taken off again, so
|
|
112
|
+
// it never shows someone who no longer presents. A hand-set face is kept.
|
|
113
|
+
export function withNarrator(html, presenter, who) {
|
|
114
|
+
if (presenter) return withAvatar(html, presenter);
|
|
115
|
+
const found = playerTag(html);
|
|
116
|
+
if (!who || who.face || !found || value(found.tag, PRESENTER_FROM) !== "narrator") return html;
|
|
117
|
+
return replaceTag(html, found, setFace(found.tag, [["data-presenter", "none"], [PRESENTER_FROM, "narrator"]]));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const narratorFace = ({ avatarUrl, imageUrl }) => [
|
|
121
|
+
["data-presenter", "head"], ["data-avatar", avatarUrl], ...(imageUrl ? [["data-presenter-image", imageUrl]] : []), [PRESENTER_FROM, "narrator"],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
// Write the narrator's face into the page as a file on disk plays it: `face`
|
|
125
|
+
// ({ avatarUrl, imageUrl }) as a marked head, or null for captions only on
|
|
126
|
+
// purpose. For deck new, deck narrator and narrate, so a draft opened with any
|
|
127
|
+
// static server shows what play and release show. A face set by hand (a face or
|
|
128
|
+
// data-avatar with no marker) is left alone. Returns { html, wrote }, and with
|
|
129
|
+
// wrote false a `why`.
|
|
130
|
+
export function writeNarratorFace(html, face) {
|
|
131
|
+
const found = playerTag(html);
|
|
132
|
+
if (!found) return { html, wrote: false, why: "the page has no live player tag" };
|
|
133
|
+
const { tag } = found;
|
|
134
|
+
if (!value(tag, PRESENTER_FROM) && (tagFace(tag) || value(tag, "data-avatar"))) {
|
|
135
|
+
return { html, wrote: false, why: "the player tag names a face of its own (set by hand), so it is left as written" };
|
|
136
|
+
}
|
|
137
|
+
const next = setFace(tag, face ? narratorFace(face) : [["data-presenter", "none"], [PRESENTER_FROM, "captions-only"]]);
|
|
138
|
+
return { html: replaceTag(html, found, next), wrote: true };
|
|
78
139
|
}
|
|
79
140
|
|
|
80
141
|
// What is wrong with the player tag's presenter as written, for check: null, or
|
|
81
142
|
// { kind, presenter }. "unfilled": it asks for a face (head or static) but names
|
|
82
143
|
// none, so it plays captions only until release fills it in. "kept": it has a
|
|
83
144
|
// data-avatar the player ignores (data-presenter="none"), and release leaves it.
|
|
84
|
-
//
|
|
145
|
+
// "captions": data-presenter none or missing, so a file opened as written (a
|
|
146
|
+
// static server, the screenshot pass) plays captions only though release adds a
|
|
147
|
+
// face. Captions only on purpose (data-presenter-from="captions-only") is not an issue.
|
|
85
148
|
export function presenterTagIssue(html) {
|
|
86
149
|
const found = playerTag(html);
|
|
87
150
|
if (!found || tagFace(found.tag)) return null;
|
|
88
151
|
const presenter = value(found.tag, "data-presenter");
|
|
89
152
|
if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
|
|
90
153
|
if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
|
|
91
|
-
return null;
|
|
154
|
+
if (value(found.tag, PRESENTER_FROM) === "captions-only") return null;
|
|
155
|
+
return { kind: "captions", presenter };
|
|
92
156
|
}
|
|
93
157
|
|
|
94
158
|
// Why a resolved face did not land on the page: withAvatar leaves a tag with
|
|
@@ -109,7 +173,7 @@ function keptOutReason(html) {
|
|
|
109
173
|
// Both releases use it: release.mjs and convex/deckSources.ts.
|
|
110
174
|
export const TAG_FACE = "the face set on the player tag";
|
|
111
175
|
export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
|
|
112
|
-
const page =
|
|
176
|
+
const page = withNarrator(html, presenter, who);
|
|
113
177
|
const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
|
|
114
178
|
const shown = presentedFace(page);
|
|
115
179
|
if (shown) {
|
package/src/release.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { readFileSync } from "node:fs";
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { zipSync } from "fflate";
|
|
19
19
|
import * as ops from "./ops.mjs";
|
|
20
|
-
import { releasedPresenter,
|
|
20
|
+
import { releasedPresenter, withNarrator } from "./presenter.mjs";
|
|
21
21
|
import { linkBase } from "./runtime.mjs";
|
|
22
22
|
import { checkDeck, deckFiles, deckFolder, deckPathResolver, resolveDeckDir } from "./check.mjs";
|
|
23
23
|
import { mediaRefs, withMediaPreviews } from "./mediaPreviews.mjs";
|
|
@@ -56,15 +56,16 @@ export function deckCommit(deckDir) {
|
|
|
56
56
|
// turn (deckFiles). `added` maps each bundled script or stylesheet to the files
|
|
57
57
|
// it brought in, so release can say what came from where.
|
|
58
58
|
// `presenter` (from avatars.presenterFor) puts the owner's avatar on the
|
|
59
|
-
// uploaded page's player tag; the committed index.html is never changed.
|
|
59
|
+
// uploaded page's player tag; the committed index.html is never changed. `who`
|
|
60
|
+
// (narrate's presenter summary) takes a CLI-written face off when nobody presents now.
|
|
60
61
|
// `media` (from mediaPreviewsFor) writes the blur previews onto the uploaded
|
|
61
62
|
// page and adds the posters it made; like the avatar, never to the commit.
|
|
62
|
-
export function releaseBundle(deckDir, presenter = null, media = null) {
|
|
63
|
+
export function releaseBundle(deckDir, presenter = null, media = null, who = null) {
|
|
63
64
|
const raw = readFileSync(join(deckDir, "index.html"));
|
|
64
|
-
const page =
|
|
65
|
+
const page = withNarrator(raw.toString("utf8"), presenter, who);
|
|
65
66
|
// check has already refused refs that leave the folder or do not resolve.
|
|
66
67
|
const found = deckFiles(page, deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
|
|
67
|
-
const html = media ? Buffer.from(withMediaPreviews(page, media.previews).html) :
|
|
68
|
+
const html = media ? Buffer.from(withMediaPreviews(page, media.previews).html) : page !== raw.toString("utf8") ? Buffer.from(page) : raw;
|
|
68
69
|
const made = Object.entries(media?.files ?? {});
|
|
69
70
|
const added = {};
|
|
70
71
|
for (const f of found) if (f.from !== "html") (added[f.from] ??= []).push(f.path);
|
|
@@ -174,7 +175,7 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
174
175
|
const narrateOpts = { ...opts, slug };
|
|
175
176
|
for (const k of ["slide", "json", "dry-run", "to", "email", "recipients"]) delete narrateOpts[k];
|
|
176
177
|
if (narrateOpts.voice === undefined) delete narrateOpts.voice;
|
|
177
|
-
const job = await narrate([deckDir], narrateOpts, { log });
|
|
178
|
+
const job = await narrate([deckDir], narrateOpts, { log, writeFace: false });
|
|
178
179
|
if (!job?.manifestUrl) throw new Error(`narration finished without a manifest (${job?.notice || "no manifest URL"}); fix it and release again`);
|
|
179
180
|
const after = changes(deckDir);
|
|
180
181
|
if (after) {
|
|
@@ -189,7 +190,7 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
189
190
|
} catch (e) {
|
|
190
191
|
lookupError = ops.errorText(e);
|
|
191
192
|
}
|
|
192
|
-
const released = releaseBundle(deckDir, presenter, media);
|
|
193
|
+
const released = releaseBundle(deckDir, presenter, media, job.presenter);
|
|
193
194
|
// Say who presents from the page that ships, never from the lookup alone: a
|
|
194
195
|
// resolved face that did not land on the player tag must not be claimed, and
|
|
195
196
|
// a failed lookup is captions only only when the tag names no face of its own.
|
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.9.
|
|
11
|
+
export const VERSION = "0.9.10";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
// The app's home. HYPERDECKS_HOST points the prod target's broker and share
|
package/src/scaffold.mjs
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
// hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,…] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
|
|
1
|
+
// hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,…] [--presenter-style stage|bubble|glass|corner] [--captions-only] [--dir <path>]
|
|
2
2
|
//
|
|
3
3
|
// Writes decks/<slug>/ (or --dir) from the packaged templates: index.html (eight
|
|
4
4
|
// slides for the hosted player/v1, or one slide per --layouts entry from
|
|
5
5
|
// templates/layouts/), script.md (the narration) and evidence.md (where each
|
|
6
6
|
// fact comes from), then registers the deck as a draft you own so narrate can
|
|
7
|
-
// render into it.
|
|
8
|
-
|
|
7
|
+
// render into it. The player tag gets the narrator's face (the account's default
|
|
8
|
+
// narrator, else D.A.S.H.), so the file plays with a face however it is opened;
|
|
9
|
+
// --captions-only writes data-presenter="none" on purpose instead.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
11
|
import { join, resolve } from "node:path";
|
|
10
12
|
import * as ops from "./ops.mjs";
|
|
11
13
|
import { PRESENTER_STYLES } from "./checkRules.mjs";
|
|
12
14
|
import { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts } from "./layouts.mjs";
|
|
13
15
|
import { applyBrand, brandForNewDeck, brandSummary } from "./brand.mjs";
|
|
16
|
+
import { writeDraftFace } from "./draftFace.mjs";
|
|
17
|
+
import { DASH_PRESENTER, writeNarratorFace } from "./presenter.mjs";
|
|
14
18
|
|
|
15
19
|
export { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts, readLayout } from "./layouts.mjs";
|
|
16
20
|
|
|
17
21
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
18
|
-
const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--no-brand] [--dir <path>]';
|
|
22
|
+
const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--captions-only] [--no-brand] [--dir <path>]';
|
|
19
23
|
|
|
20
24
|
export async function deckNew(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
21
25
|
const [slug, title] = pos;
|
|
@@ -46,7 +50,24 @@ export async function deckNew(pos, opts = {}, { log = (line) => console.log(line
|
|
|
46
50
|
rmSync(dir, { recursive: true, force: true });
|
|
47
51
|
throw new Error(`deck ${slug} is registered but its files could not be written (${e.message}); remove the draft with \`hyperdecks deck rm ${slug}\` and run deck new again`);
|
|
48
52
|
}
|
|
53
|
+
// The face goes into the file now the deck is registered (the server answers whose).
|
|
54
|
+
const htmlPath = join(/*turbopackIgnore: true*/ dir, "index.html");
|
|
55
|
+
const lines = [];
|
|
56
|
+
let presenter;
|
|
57
|
+
if (opts["captions-only"]) {
|
|
58
|
+
writeFileSync(htmlPath, writeNarratorFace(readFileSync(htmlPath, "utf8"), null).html);
|
|
59
|
+
// The server too, so release, play and /p agree the deck has no face.
|
|
60
|
+
try {
|
|
61
|
+
await ops.setDeckNarrator(opts, slug, "none");
|
|
62
|
+
lines.push(" presenter: captions only (--captions-only), written to index.html and set on the deck");
|
|
63
|
+
} catch (e) {
|
|
64
|
+
lines.push(`⚠ presenter: captions only written to index.html, but the deck could not be set to none (${e.message}): run \`hyperdecks deck narrator ${slug} none\``);
|
|
65
|
+
}
|
|
66
|
+
presenter = { face: null };
|
|
67
|
+
} else {
|
|
68
|
+
presenter = await writeDraftFace(opts, slug, htmlPath, { log: (line) => lines.push(line), fallback: DASH_PRESENTER });
|
|
69
|
+
}
|
|
49
70
|
const summary = brandSummary(brand, readError);
|
|
50
|
-
log(`✓ created deck "${slug}" → ${dir}\n files: ${Object.keys(NARRATED_FILES).join(", ")}\n brand: ${summary.error ? `could not read it (${summary.error}); template look` : summary.applied.length ? summary.applied.join(", ") : "none applied (hyperdecks brand set)"}\n next: hyperdecks narrate ${dir} --dry-run`);
|
|
51
|
-
return { slug, title, dir, files: Object.keys(NARRATED_FILES), brand: summary };
|
|
71
|
+
log(`✓ created deck "${slug}" → ${dir}\n files: ${Object.keys(NARRATED_FILES).join(", ")}\n brand: ${summary.error ? `could not read it (${summary.error}); template look` : summary.applied.length ? summary.applied.join(", ") : "none applied (hyperdecks brand set)"}\n${lines.map((l) => (l.startsWith(" ") ? l : ` ${l}`)).join("\n")}\n next: hyperdecks narrate ${dir} --dry-run`);
|
|
72
|
+
return { slug, title, dir, files: Object.keys(NARRATED_FILES), brand: summary, presenter: presenter.face ?? null };
|
|
52
73
|
}
|
|
@@ -55,20 +55,26 @@
|
|
|
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;
|
|
58
|
+
/* The text colour on an accent ground. A brand with a dark accent sets it to its paper. */
|
|
59
|
+
--accent-ink: var(--ink);
|
|
58
60
|
--hd-ground: var(--accent); --hd-ground-ink: var(--ink); /* the loading state: the first slide's colours */
|
|
59
61
|
/* Soft grounds (on-tint, on-tint-2, on-tint-3): washes of the accents, so slide changes read without going loud. */
|
|
60
62
|
--tint-1: color-mix(in srgb, var(--accent) 18%, var(--paper)); --tint-2: color-mix(in srgb, var(--accent-2) 20%, var(--paper)); --tint-3: color-mix(in srgb, var(--accent-3, var(--accent)) 24%, var(--paper));
|
|
61
63
|
}
|
|
62
64
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
63
65
|
body { color: var(--ink); font-family: var(--font); font-weight: 500; -webkit-font-smoothing: antialiased; --hd-font: var(--font); --hd-mono: var(--mono); }
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
/* The caption bubble is always a fixed light fill with dark ink, so it reads whatever the
|
|
67
|
+
brand's --card or --ink (a dark brand's card is dark). A slide can still set its own. */
|
|
68
|
+
.slide { display: flex; flex-direction: column; gap: 24px; padding: 64px 72px 56px; background: var(--paper); color: var(--ink); --hd-speech: #f3c9dc; --hd-speech-ink: #0b0b0f; }
|
|
69
|
+
.slide.on-accent { background: var(--accent); color: var(--accent-ink); --hd-speech: #ffffff; }
|
|
66
70
|
.slide.on-ink { background: var(--ink); color: var(--paper); --hd-speech: #d9ccff; }
|
|
67
|
-
.slide.on-tint { background: var(--tint-1); --hd-speech:
|
|
68
|
-
.slide.on-tint-2 { background: var(--tint-2); --hd-speech:
|
|
69
|
-
.slide.on-tint-3 { background: var(--tint-3); --hd-speech:
|
|
71
|
+
.slide.on-tint { background: var(--tint-1); --hd-speech: #ffffff; }
|
|
72
|
+
.slide.on-tint-2 { background: var(--tint-2); --hd-speech: #ffffff; }
|
|
73
|
+
.slide.on-tint-3 { background: var(--tint-3); --hd-speech: #ffffff; }
|
|
70
74
|
.kicker { font: var(--t-label) var(--mono); letter-spacing: .16em; text-transform: uppercase; }
|
|
71
|
-
|
|
75
|
+
/* Titles take the display face's weight and tracking (brand.mjs sets them for a face the
|
|
76
|
+
template was not tuned for) and never fake a weight the face lacks. */
|
|
77
|
+
.tt { font-family: var(--display, var(--font)); font-weight: var(--display-weight, 700); font-synthesis: none; font-size: var(--t-title); line-height: .95; letter-spacing: var(--display-tracking, -.045em); text-wrap: balance; }
|
|
72
78
|
.lead { font-size: var(--t-lead); line-height: 1.3; max-width: 28em; text-wrap: pretty; }
|
|
73
79
|
.muted { opacity: .65; }
|
|
74
80
|
/* The presenter glides to each slide's slot: bottom-right by default,
|
|
@@ -98,26 +104,57 @@
|
|
|
98
104
|
linear-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0 0) 14% 67% / 44% 2.4% no-repeat,
|
|
99
105
|
linear-gradient(var(--accent) 0 0) 50% 90% / 84% 6% no-repeat,
|
|
100
106
|
var(--paper); }
|
|
101
|
-
/*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
holds small inked tiles, each a <span> placed by its inline left/top (and
|
|
106
|
-
background, rotate). Both sit behind the slide's content. */
|
|
107
|
-
.slide.bg-dots, .slide:has(> .deco) { isolation: isolate; }
|
|
108
|
-
.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%); }
|
|
107
|
+
/* Decorations: a <div class="deco" aria-hidden="true"> in the slide holds small
|
|
108
|
+
inked tiles, each a <span> placed by its inline left/top (and background, rotate),
|
|
109
|
+
behind the slide's content. */
|
|
110
|
+
.slide:has(> .deco) { isolation: isolate; }
|
|
109
111
|
.slide > .deco { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
|
|
110
112
|
.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); }
|
|
111
|
-
/*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
bg-
|
|
116
|
-
|
|
117
|
-
.slide:is(.bg-
|
|
118
|
-
.slide.bg-
|
|
119
|
-
|
|
120
|
-
|
|
113
|
+
/* Backgrounds (deck_guide topic look). Four classes on the section, tokens only, each painted
|
|
114
|
+
behind the content (a ::before at z-index -1 in an isolated slide) and confined to the object
|
|
115
|
+
it backs, never under the text. Each carries its own per-layout rules (.l-<layout>.bg-<name>),
|
|
116
|
+
so on a layout with nothing to back it paints nothing. Plain grounds come first; one signature
|
|
117
|
+
(bg-corner or bg-mark) per deck on three slides at most; bg-pool and bg-stage only on a slide
|
|
118
|
+
with a picture. A deck may add its own in its <style> under the same contract. */
|
|
119
|
+
.slide:is(.bg-pool, .bg-corner, .bg-stage, .bg-mark) { isolation: isolate; overflow: hidden; }
|
|
120
|
+
.slide:is(.bg-pool, .bg-corner, .bg-stage, .bg-mark)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; }
|
|
121
|
+
/* bg-pool: one soft light of the next accent (accent-2 on paper, accent on a tint, a screened
|
|
122
|
+
accent-3 on an accent ground, accent on ink), placed per layout as the spotlight of the picture. */
|
|
123
|
+
.slide.bg-pool { --pool: color-mix(in srgb, var(--accent-2) 34%, transparent); }
|
|
124
|
+
.slide:is(.on-tint, .on-tint-2, .on-tint-3).bg-pool { --pool: color-mix(in srgb, var(--accent) 38%, transparent); }
|
|
125
|
+
.slide.on-accent.bg-pool { --pool: color-mix(in srgb, var(--accent-3, var(--accent-2)) 55%, transparent); }
|
|
126
|
+
.slide.on-accent.bg-pool::before { mix-blend-mode: screen; }
|
|
127
|
+
.slide.on-ink.bg-pool { --pool: color-mix(in srgb, var(--accent) 32%, transparent); }
|
|
128
|
+
.slide.bg-pool::before { background: radial-gradient(var(--pw, 760px) var(--ph, 600px) at var(--px, 82%) var(--py, 28%), var(--pool), transparent 70%); }
|
|
129
|
+
.l-hero.bg-pool { --px: 82%; --py: 28%; --pw: 760px; --ph: 600px; }
|
|
130
|
+
.l-split.bg-pool { --px: 73%; --py: 50%; --pw: 900px; --ph: 860px; }
|
|
131
|
+
.l-product.bg-pool { --px: 34%; --py: 50%; --pw: 1180px; --ph: 860px; }
|
|
132
|
+
.l-phones.bg-pool { --px: 68%; --py: 50%; --pw: 900px; --ph: 760px; }
|
|
133
|
+
.l-chart.bg-pool { --px: 84%; --py: 40%; --pw: 640px; --ph: 520px; }
|
|
134
|
+
.l-quote.bg-pool { --px: 10%; --py: 14%; --pw: 760px; --ph: 560px; }
|
|
135
|
+
.l-closer.bg-pool { --px: 90%; --py: 4%; --pw: 820px; --ph: 640px; }
|
|
136
|
+
/* bg-corner: a hard-edged quarter-disc of halftone, top-right, behind the stickers. A signature: cover, closer. */
|
|
137
|
+
.slide.bg-corner::before {
|
|
138
|
+
--corner-r: 580px; --corner-dot: color-mix(in srgb, currentColor var(--corner-a, 15%), transparent);
|
|
139
|
+
inset: auto; right: 0; top: 0; width: var(--corner-r); height: var(--corner-r); border-bottom-left-radius: 100%;
|
|
140
|
+
background: radial-gradient(var(--corner-dot) 2.8px, transparent 3.3px) 0 0 / 20px 20px;
|
|
141
|
+
-webkit-mask-image: radial-gradient(circle at 100% 0, #000 45%, rgba(0,0,0,.4) 100%); mask-image: radial-gradient(circle at 100% 0, #000 45%, rgba(0,0,0,.4) 100%); }
|
|
142
|
+
.l-closer.bg-corner::before { --corner-r: 460px; }
|
|
143
|
+
.on-ink.bg-corner::before { --corner-a: 22%; }
|
|
144
|
+
:is(.l-split, .l-product, .l-chart, .l-quote, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-corner::before { content: none; }
|
|
145
|
+
/* bg-stage: a rounded dot plate exactly the picture's column plus 32px, so the frame or the phones stand on it; on the hero a tile behind the stickers. */
|
|
146
|
+
.slide.bg-stage::before { inset: auto; background: radial-gradient(color-mix(in srgb, currentColor var(--stage-a, 24%), transparent) 2.4px, transparent 2.9px) 0 0 / 22px 22px; border-radius: 40px; }
|
|
147
|
+
.l-product.bg-stage::before { position: relative; grid-area: frame; align-self: stretch; margin: var(--stage-m, -32px -24px -32px -40px); }
|
|
148
|
+
.l-phones.bg-stage::before { position: relative; grid-area: row; align-self: stretch; margin: var(--stage-m, -32px); }
|
|
149
|
+
.l-hero.bg-stage::before { right: 110px; top: 110px; width: 410px; height: 350px; }
|
|
150
|
+
:is(.l-split, .l-chart, .l-quote, .l-closer, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-stage::before { content: none; }
|
|
151
|
+
/* bg-mark: the slide number, set as style="--mark:'04'" on the section, huge in --display, faint,
|
|
152
|
+
cropped by the top edge; right on the hero, in the indent on slot-left slides. A signature: cover, an opener, closer. */
|
|
153
|
+
.slide.bg-mark::before { content: var(--mark, ""); inset: auto; right: 24px; top: -.26em; font: var(--display-weight, 700) var(--mark-size, 560px)/1 var(--display, var(--font)); font-synthesis: none; letter-spacing: var(--display-tracking, -.05em); white-space: nowrap;
|
|
154
|
+
color: color-mix(in srgb, currentColor var(--mark-a, 8%), transparent); }
|
|
155
|
+
.slide.on-ink.bg-mark::before { --mark-a: 6%; }
|
|
156
|
+
.slide.slot-left.bg-mark::before { right: auto; left: -.08em; font-size: var(--mark-size, 440px); } /* ends inside the 500px indent, clear of the title */
|
|
157
|
+
:is(.l-split, .l-product, .l-chart, .l-phones, .l-number, .l-features, .l-team, .l-timeline, .l-compare).bg-mark::before { content: none; }
|
|
121
158
|
/* Pieces. .sticker: a logo or icon tile, tilted (--tilt), ink edge, hard offset
|
|
122
159
|
shadow (.sticker.photo for a face or an app icon that fills it). .ticks: a short
|
|
123
160
|
list with accent-square bullets. .panel: a quiet card. .pill: a small tag. */
|
|
@@ -128,10 +165,10 @@
|
|
|
128
165
|
.ticks { list-style: none; display: flex; flex-direction: column; gap: 14px; }
|
|
129
166
|
.ticks li { position: relative; padding-left: 34px; font-size: var(--t-body); line-height: 1.3; font-weight: 600; }
|
|
130
167
|
.ticks li::before { content: ""; position: absolute; left: 0; top: .4em; width: 16px; height: 16px; border-radius: 5px; background: var(--accent); }
|
|
131
|
-
.on-accent .ticks li::before { background: var(--ink); }
|
|
168
|
+
.on-accent .ticks li::before { background: var(--accent-ink); }
|
|
132
169
|
.panel { padding: 24px 28px; border-radius: 22px; background: color-mix(in srgb, currentColor 6%, transparent); }
|
|
133
170
|
.pill { display: inline-flex; align-items: center; gap: 8px; width: fit-content; padding: 8px 16px; border-radius: 999px; background: var(--card); color: var(--ink); font: 700 var(--t-label) var(--mono); letter-spacing: .08em; text-transform: uppercase; box-shadow: 4px 4px 0 var(--accent); }
|
|
134
|
-
.on-accent .pill { box-shadow: 4px 4px 0 var(--ink); }
|
|
171
|
+
.on-accent .pill { box-shadow: 4px 4px 0 var(--accent-ink); }
|
|
135
172
|
/* A laptop for a desktop screenshot or clip: a .frame.laptop holding an img.shot or video.shot. */
|
|
136
173
|
.slide .frame.laptop { position: relative; width: 860px; max-width: 100%; padding: 14px 14px 22px; border-radius: 20px 20px 6px 6px; background: #1a1a1d; box-shadow: 0 30px 70px color-mix(in srgb, var(--ink) 22%, transparent); }
|
|
137
174
|
.slide .frame.laptop::after { content: ""; position: absolute; left: -6%; right: -6%; bottom: -16px; height: 16px; border-radius: 0 0 16px 16px; background: linear-gradient(#d9d9de, #a9a9b0); }
|
|
@@ -145,12 +182,12 @@
|
|
|
145
182
|
:root.hd-no-presenter .slide { padding-bottom: 28px; }
|
|
146
183
|
.slide .frame.card { width: 160px; padding: 5px; border-width: 2px; border-radius: 24px; box-shadow: 8px 8px 0 var(--shadow); }
|
|
147
184
|
.slide .frame.card > .shot { border-radius: 19px; border-width: 1px; }
|
|
148
|
-
/* The text fills the card's width
|
|
149
|
-
|
|
185
|
+
/* The text fills the card's width, so the backgrounds keep off it: the corner fades in
|
|
186
|
+
towards the bottom, under the presenter, at a finer pitch; the stage and the mark hide;
|
|
187
|
+
the pool (a soft light) stays as it is; the tiles stay off. */
|
|
188
|
+
.slide.bg-corner::before { inset: 0; width: auto; height: auto; border-radius: 0; background-size: 14px 14px; background-image: radial-gradient(var(--corner-dot) 1.6px, transparent 2px); -webkit-mask-image: linear-gradient(180deg, transparent 40%, #000 90%); mask-image: linear-gradient(180deg, transparent 40%, #000 90%); }
|
|
189
|
+
.slide:is(.bg-stage, .bg-mark)::before { content: none; }
|
|
150
190
|
.slide > .deco { display: none; }
|
|
151
|
-
/* Textures fade in towards the bottom, under the presenter, away from the text. */
|
|
152
|
-
.slide:is(.bg-lines, .bg-halftone)::before { -webkit-mask-image: linear-gradient(180deg, transparent 40%, #000 90%); mask-image: linear-gradient(180deg, transparent 40%, #000 90%); }
|
|
153
|
-
.slide.bg-halftone::before { background-size: 14px 14px; background-image: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 1.6px, transparent 2px); }
|
|
154
191
|
.sticker { width: 48px; height: 48px; padding: 9px; border-width: 2px; border-radius: 13px; box-shadow: 3px 3px 0 var(--ink); }
|
|
155
192
|
.ticks { gap: 6px; }
|
|
156
193
|
.ticks li { padding-left: 18px; }
|
|
@@ -175,7 +212,9 @@
|
|
|
175
212
|
|
|
176
213
|
<!-- `hyperdecks narrate decks/__SLUG__` fills data-manifest with the manifest
|
|
177
214
|
URL it writes. data-presenter: head (live Minimoji, needs data-avatar),
|
|
178
|
-
static (data-presenter-image) or none (captions only).
|
|
215
|
+
static (data-presenter-image) or none (captions only). deck new, deck
|
|
216
|
+
narrator and narrate write the narrator's face here
|
|
217
|
+
(data-presenter-from="narrator", so release follows a later narrator). -->
|
|
179
218
|
<script type="module" src="https://hyperdecks.ai/player/v1/player.js"
|
|
180
219
|
data-manifest="" data-stage="1600x900" data-presenter="none" data-presenter-style="__PRESENTER_STYLE__"></script>
|
|
181
220
|
</body>
|
|
@@ -11,7 +11,7 @@ script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [g
|
|
|
11
11
|
<style>
|
|
12
12
|
.l-closer .content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 32px; padding-left: 500px; }
|
|
13
13
|
.l-closer .tt { font-size: var(--t-display); line-height: .9; }
|
|
14
|
-
.l-closer .cta { display: inline-flex; align-items: center; gap: 16px; width: fit-content; max-width: 100%; padding: 22px 34px; border-radius: 22px; background: var(--accent); color: var(--ink); font: 700 34px var(--mono); letter-spacing: -.01em; text-decoration: none; }
|
|
14
|
+
.l-closer .cta { display: inline-flex; align-items: center; gap: 16px; width: fit-content; max-width: 100%; padding: 22px 34px; border-radius: 22px; background: var(--accent); color: var(--accent-ink); font: 700 34px var(--mono); letter-spacing: -.01em; text-decoration: none; }
|
|
15
15
|
.l-closer .contact { font: var(--t-label) var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
|
16
16
|
@media (max-width: 650px) {
|
|
17
17
|
.l-closer .content { padding-left: 0; justify-content: flex-start; gap: 16px; }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
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.
|
|
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. The row stands on a dot plate (bg-stage).
|
|
4
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
5
|
slot: bottom-left
|
|
6
6
|
presenter: stage
|
|
@@ -18,7 +18,7 @@ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__
|
|
|
18
18
|
.l-phones .row { grid-area: row; display: flex; justify-content: center; align-items: center; padding: 0 0 40px; }
|
|
19
19
|
.l-phones .row > .frame.card { --tilt: 0deg; flex: none; width: 260px; margin: 0 -14px; }
|
|
20
20
|
.l-phones .row > .frame.card:first-child { --tilt: -7deg; --shadow: var(--accent-2); translate: 0 36px; }
|
|
21
|
-
.l-phones .row > .frame.card:last-child { --tilt: 7deg; --shadow: var(--accent-3); translate: 0 36px; }
|
|
21
|
+
.l-phones .row > .frame.card:last-child { --tilt: 7deg; --shadow: var(--accent-3, var(--accent-2)); translate: 0 36px; }
|
|
22
22
|
.l-phones .row > .frame.card:not(:first-child, :last-child) { width: 300px; z-index: 1; }
|
|
23
23
|
.l-phones .row:has(> :nth-child(4)) > .frame.card { width: 220px; }
|
|
24
24
|
.l-phones .row:has(> :nth-child(4)) > .frame.card:not(:first-child, :last-child) { width: 250px; }
|
|
@@ -31,14 +31,14 @@ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__
|
|
|
31
31
|
.l-phones .row > .frame.card:is(:first-child, :last-child) { translate: 0 16px; }
|
|
32
32
|
/* Four do not fit the phone card: the row keeps the first three, the third as its end. */
|
|
33
33
|
.l-phones .row > .frame.card:nth-child(n + 4) { display: none; }
|
|
34
|
-
.l-phones .row:has(> :nth-child(4)) > .frame.card:nth-child(3) { --tilt: 7deg; --shadow: var(--accent-3); width: 108px; translate: 0 16px; }
|
|
34
|
+
.l-phones .row:has(> :nth-child(4)) > .frame.card:nth-child(3) { --tilt: 7deg; --shadow: var(--accent-3, var(--accent-2)); width: 108px; translate: 0 16px; }
|
|
35
35
|
}
|
|
36
36
|
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
37
37
|
@media (min-width: 651px) {
|
|
38
38
|
:root.hd-no-presenter .l-phones .text { align-self: center; }
|
|
39
39
|
}
|
|
40
40
|
</style>
|
|
41
|
-
<section class="slide l-phones slot-left bg-
|
|
41
|
+
<section class="slide l-phones slot-left bg-stage" id="__ID__" data-chapter="On a phone" data-note="Phone screens: name the app build and the date they were captured.">
|
|
42
42
|
<div class="text">
|
|
43
43
|
<div class="kicker">On a phone</div>
|
|
44
44
|
<h2 class="tt" data-title-mobile="Three taps.">Three taps, start to finish.</h2>
|
|
@@ -53,7 +53,7 @@ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__
|
|
|
53
53
|
<div class="deco" aria-hidden="true">
|
|
54
54
|
<span style="left: 52%; top: 8%; background: var(--accent); rotate: -10deg;">★</span>
|
|
55
55
|
<span style="left: 92%; top: 42%; background: var(--accent-2); rotate: 12deg;">♪</span>
|
|
56
|
-
<span style="left: 48%; top: 78%; background: var(--accent-3); rotate: 8deg;">☺</span>
|
|
56
|
+
<span style="left: 48%; top: 78%; background: var(--accent-3, var(--accent-2)); rotate: 8deg;">☺</span>
|
|
57
57
|
</div> -->
|
|
58
58
|
<div class="narration-slot"></div>
|
|
59
59
|
</section>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
layout: split
|
|
3
|
-
summary: Image plus copy: the copy on the left (a sticker for the logo or icon, kicker, title, one line, three ticks), a real picture on the right in a phone card
|
|
3
|
+
summary: Image plus copy: the copy on the left (a sticker for the logo or icon, kicker, title, one line, three ticks), a real picture on the right in a phone card (the picture is the background; add bg-pool to light it).
|
|
4
4
|
use: the workhorse for a product, a feature or an app: most slides of a good deck are this one. The phone's .shot is a placeholder check fails on: put in a phone screenshot (capture_url at 390×844) or a screen recording (upload_deck_asset) as <img class="shot"> or <video class="shot" autoplay muted loop playsinline>. For a desktop app, replace the card with <div class="frame laptop" id="__ID__-frame"><img class="shot" src="…" alt="…"></div>; for a 16:9 clip or photo, <img class="shot wide" src="…" alt="…" id="__ID__-frame">. Swap the sticker's icon for the product's logo (<span class="sticker photo"><img src="…" alt=""></span>) or one icon from the deck's set.
|
|
5
5
|
slot: bottom-left
|
|
6
6
|
presenter: stage
|
|
@@ -32,7 +32,7 @@ script: Here it is, doing the job. [look: #__ID__-frame] It starts with [go: #__
|
|
|
32
32
|
:root.hd-no-presenter .l-split .text { align-self: center; padding-top: 0; }
|
|
33
33
|
}
|
|
34
34
|
</style>
|
|
35
|
-
<section class="slide l-split slot-left
|
|
35
|
+
<section class="slide l-split slot-left" id="__ID__" data-chapter="The product" data-note="Picture: name the screen or clip and the date it was captured.">
|
|
36
36
|
<div class="text">
|
|
37
37
|
<div class="head">
|
|
38
38
|
<span class="sticker" id="__ID__-logo"><svg class="lucide-smartphone" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg></span>
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
.slide { display: flex; flex-direction: column; gap: 28px; padding: 64px 72px 56px; background: var(--paper); }
|
|
55
55
|
.slide.accent { background: var(--accent); }
|
|
56
56
|
.kicker { font: 18px var(--mono); letter-spacing: .16em; text-transform: uppercase; }
|
|
57
|
-
.tt { font-family: var(--display, var(--font)); font-size: 104px; line-height: .95; letter-spacing: -.045em; margin: 0; text-wrap: balance; }
|
|
57
|
+
.tt { font-family: var(--display, var(--font)); font-weight: var(--display-weight, 700); font-synthesis: none; font-size: 104px; line-height: .95; letter-spacing: var(--display-tracking, -.045em); margin: 0; text-wrap: balance; }
|
|
58
58
|
.lead { font-size: 32px; line-height: 1.3; max-width: 30em; margin: 0; }
|
|
59
59
|
.content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 28px; padding-right: 500px; }
|
|
60
60
|
/* The presenter glides to each slide's slot; slots alternate sides so the
|
|
@@ -156,6 +156,8 @@
|
|
|
156
156
|
<!-- `hyperdecks narrate decks/__SLUG__` fills data-manifest with the manifest
|
|
157
157
|
URL it writes. data-presenter: head (live Minimoji, needs
|
|
158
158
|
data-avatar), static (data-presenter-image) or none (captions only).
|
|
159
|
+
deck new, deck narrator and narrate write the narrator's face here
|
|
160
|
+
(data-presenter-from="narrator", so release follows a later narrator).
|
|
159
161
|
data-presenter-style: stage (large head, lower-third captions), bubble,
|
|
160
162
|
glass (frosted panel) or corner (small head, subtitles). -->
|
|
161
163
|
<script type="module" src="https://hyperdecks.ai/player/v1/player.js"
|