@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/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 that already names a presenter (D.A.S.H.
7
- // on a character-voice deck, or one set by hand) is left alone.
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, index } = found;
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 = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
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.slice(0, index) + next + html.slice(index + tag.length);
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
- // A plain "none" or no data-presenter is not an issue: release adds the face.
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 = presenter ? withAvatar(html, presenter) : html;
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, withAvatar } from "./presenter.mjs";
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 = presenter ? withAvatar(raw.toString("utf8"), presenter) : raw.toString("utf8");
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) : presenter ? Buffer.from(page) : raw;
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.9";
11
+ export const VERSION = "0.9.11";
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
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
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;
@@ -28,7 +32,7 @@ export async function deckNew(pos, opts = {}, { log = (line) => console.log(line
28
32
  if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
29
33
  const dir = resolve(/*turbopackIgnore: true*/ cwd, typeof opts.dir === "string" ? opts.dir : join("decks", slug));
30
34
  if (existsSync(dir)) throw new Error(`${dir} already exists; pick another slug or --dir`);
31
- if (opts.layouts === true) throw new Error(`--layouts needs a list, e.g. --layouts ${LAYOUTS.slice(0, 3).join(",")},closer`);
35
+ if (opts.layouts === true) throw new Error(`--layouts needs a list, e.g. --layouts hero,product,number,closer`);
32
36
  if (opts.layouts !== undefined) parseLayouts(opts.layouts);
33
37
  // The account's brand goes on the template (--no-brand: the template's look);
34
38
  // an explicit --presenter-style wins over the brand's.
@@ -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
  }
@@ -7,7 +7,8 @@
7
7
  <!-- Loading state (keep verbatim; `hyperdecks check` requires it). Until the
8
8
  player marks body[data-hd-ready] the page shows only the slide card, at its
9
9
  final size, in the deck's --hd-ground (its first slide's colour) on the
10
- page's brand colour, with a quiet loader in --hd-ground-ink; then the
10
+ page's brand colour, with the opening card over it (the title and the
11
+ presenter's still), or in a frame quiet dots in --hd-ground-ink; then the
11
12
  slides fade in. If the player never arrives, the slides show after 10 s
12
13
  rather than never. Put data-layout="overlay" on <html> for the overlay. -->
13
14
  <link rel="modulepreload" href="https://hyperdecks.ai/player/v1/player.js">
@@ -21,7 +22,7 @@
21
22
  /* The card as the player draws it (player.css, framed): 16:9, scaled to fit
22
23
  between the 64px masthead and the 56px dock, 24px in from the sides. */
23
24
  body::before { inset: 64px 24px 56px; margin: auto; width: min(100vw - 48px, (100dvh - 120px) * 16 / 9); height: min(100dvh - 120px, (100vw - 48px) * 9 / 16); border-radius: calc(min(100vw - 48px, (100dvh - 120px) * 16 / 9) * .01125); background: var(--hd-ground, #fff); box-shadow: 0 0 0 1px color-mix(in srgb, var(--hd-ground-ink, #0b0b0f) 16%, transparent); }
24
- body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }
25
+ [data-hd-frame] body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }
25
26
  body[data-hd-ready]::before, body[data-hd-ready]::after { opacity: 0; animation: none; }
26
27
  /* The overlay (embedded, ?thumb, or data-layout="overlay"): the stage fills the window. */
27
28
  :is([data-hd-frame], [data-layout="overlay"]) body::before { inset: 0; width: min(100vw, 100dvh * 16 / 9); height: min(100dvh, 100vw * 9 / 16); border-radius: calc(min(100vw, 100dvh * 16 / 9) * .01125); box-shadow: none; }
@@ -41,7 +42,75 @@
41
42
  @keyframes hd-in { from { opacity: 0; } }
42
43
  @keyframes hd-dots { from { opacity: .15; } to { opacity: .6; } }
43
44
  @keyframes hd-fail-open { to { visibility: visible; overflow: auto; } }
45
+ #hd-open{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:visible!important;animation:none!important;opacity:0;font-family:var(--hd-ui-font,"Space Grotesk",system-ui,sans-serif);color:#0b0b0f}
46
+ #hd-open.is-shown{opacity:1;transition:opacity .2s ease-out}
47
+ #hd-open.is-gone{opacity:0;transition:opacity .45s ease-out}
48
+ #hd-open .hdo-slide{position:absolute;inset:64px 24px 56px;margin:auto;width:min(100vw - 48px,(100dvh - 120px)*16/9);height:min(100dvh - 120px,(100vw - 48px)*9/16);border-radius:calc(min(100vw - 48px,(100dvh - 120px)*16/9)*.01125);overflow:hidden;container-type:inline-size;background:var(--hd-ground,#fff)}
49
+ #hd-open .hdo-slide img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;filter:blur(2.2cqi) saturate(1.15);transform:scale(1.12);animation:hdo-breathe 2.4s ease-in-out infinite}
50
+ #hd-open .hdo-slide::after{content:"";position:absolute;inset:0;background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.3) 50%,transparent 70%);background-size:250% 100%;animation:hdo-shim 1.8s ease-in-out infinite;mix-blend-mode:soft-light}
51
+ #hd-open.is-done .hdo-slide img{animation:hdo-deblur .5s cubic-bezier(.2,.8,.2,1) .5s forwards}
52
+ #hd-open.is-done .hdo-slide::after{opacity:0;transition:opacity .3s}
53
+ .hdo-card{position:absolute;left:50%;top:50%;translate:-50% -50%;width:min(86vw,420px);display:grid;justify-items:center;gap:.6em;padding:1.3em 1.5em 1.2em;border-radius:22px;background:rgba(255,255,255,.74);-webkit-backdrop-filter:blur(14px) saturate(1.3);backdrop-filter:blur(14px) saturate(1.3);box-shadow:4px 4px 0 #0b0b0f;border:1.5px solid #0b0b0f;text-align:center;box-sizing:border-box}
54
+ #hd-open.is-done .hdo-card{opacity:0;transform:scale(.96);transition:opacity .25s .5s,transform .25s .5s}
55
+ .hdo-buddy{position:relative;width:92px;height:96px;margin-top:-62px;display:grid;justify-items:center}
56
+ .hdo-hop{width:86px;height:80px;transform-origin:50% 100%;animation:hdo-hop 1.15s cubic-bezier(.3,0,.4,1) infinite}
57
+ .hdo-look{width:100%;height:100%;animation:hdo-look 4.6s ease-in-out infinite}
58
+ .hdo-buddy img{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 3px 0 rgba(11,11,15,.18))}
59
+ .hdo-shadow{position:absolute;bottom:4px;width:46px;height:8px;border-radius:50%;background:rgba(11,11,15,.22);animation:hdo-shadow 1.15s cubic-bezier(.3,0,.4,1) infinite}
60
+ .hdo-think{position:absolute;right:-30px;top:-6px;display:flex;gap:3px;padding:5px 8px;border-radius:14px;background:#fff;border:1.5px solid #0b0b0f;box-shadow:2px 2px 0 #0b0b0f}
61
+ .hdo-think::after{content:"";position:absolute;left:4px;bottom:-7px;width:7px;height:7px;border-radius:50%;background:#fff;border:1.5px solid #0b0b0f}
62
+ .hdo-think i{width:5px;height:5px;border-radius:50%;background:#0b0b0f;animation:hdo-tdot 1s ease-in-out infinite}
63
+ .hdo-think i:nth-child(2){animation-delay:.15s}.hdo-think i:nth-child(3){animation-delay:.3s}
64
+ .hdo-spark{position:absolute;left:50%;top:40%;width:8px;height:8px;margin:-4px;opacity:0}
65
+ .hdo-spark::before{content:"";position:absolute;inset:0;background:#0b0b0f;clip-path:polygon(50% 0,62% 38%,100% 50%,62% 62%,50% 100%,38% 62%,0 50%,38% 38%)}
66
+ #hd-open.is-done .hdo-hop{animation:hdo-yay .55s cubic-bezier(.2,1.4,.4,1) forwards}
67
+ #hd-open.is-done :is(.hdo-look,.hdo-shadow){animation:none}
68
+ #hd-open.is-done .hdo-think{opacity:0;transition:opacity .15s}
69
+ #hd-open.is-done .hdo-spark{animation:hdo-spark .6s ease-out forwards}
70
+ .hdo-title{font-weight:700;font-size:18px;line-height:1.2;text-wrap:balance;overflow-wrap:anywhere}
71
+ .hdo-by{font:12.5px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#3d3b35}
72
+ .hdo-bar{width:100%;height:5px;border-radius:999px;background:rgba(11,11,15,.12);overflow:hidden}
73
+ .hdo-bar i{display:block;height:100%;width:8%;background:#0b0b0f;border-radius:999px;transition:width .35s ease-out}
74
+ .hdo-step{font:12px/1.3 var(--hd-ui-mono,"JetBrains Mono",ui-monospace,monospace);color:#55534b;min-height:1.3em}
75
+ @keyframes hdo-breathe{50%{filter:blur(2.6cqi) saturate(1.25) brightness(1.05)}}
76
+ @keyframes hdo-shim{0%{background-position:120% 0}100%{background-position:-120% 0}}
77
+ @keyframes hdo-deblur{to{filter:blur(0);transform:none}}
78
+ @keyframes hdo-hop{0%,100%{transform:translateY(0) scale(1.06,.92)}12%{transform:translateY(0) scale(.95,1.06)}45%{transform:translateY(-14px) scale(1,1)}70%{transform:translateY(0) scale(1.04,.95)}82%{transform:translateY(0) scale(1,1)}}
79
+ @keyframes hdo-shadow{0%,100%{transform:scale(1.05);opacity:.9}45%{transform:scale(.62);opacity:.45}70%{transform:scale(1);opacity:.85}}
80
+ @keyframes hdo-look{0%,18%{transform:rotate(0)}24%,38%{transform:rotate(-9deg) translateX(-2px)}44%,58%{transform:rotate(8deg) translateX(2px)}64%,100%{transform:rotate(0)}}
81
+ @keyframes hdo-tdot{0%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-2px)}}
82
+ @keyframes hdo-yay{0%{transform:translateY(0) scale(1.08,.9)}40%{transform:translateY(-26px) scale(.94,1.08) rotate(-6deg)}100%{transform:translateY(-6px) scale(1) rotate(0)}}
83
+ @keyframes hdo-spark{0%{opacity:0;transform:translate(0,0) scale(.4)}30%{opacity:1}100%{opacity:0;transform:translate(var(--x),var(--y)) scale(1.2) rotate(90deg)}}
84
+ :is([data-hd-frame],[data-layout="overlay"]) #hd-open .hdo-slide{inset:0;width:min(100vw,100dvh*16/9);height:min(100dvh,100vw*9/16)}
85
+ @media (max-width:650px){#hd-open .hdo-slide{inset:52px 8px calc(56px + env(safe-area-inset-bottom));width:auto;height:auto;border-radius:14px}.hdo-card{width:min(84vw,320px);padding:1em 1.1em .95em}.hdo-title{font-size:15.5px}.hdo-buddy{transform:scale(.85);margin-top:-58px}}
86
+ @media (prefers-reduced-motion:reduce){#hd-open *,#hd-open *::before,#hd-open *::after{animation:none!important;transition:none!important}#hd-open .hdo-slide::after{display:none}#hd-open.is-done .hdo-card{opacity:1;transform:none}}
87
+ @media print{#hd-open{display:none!important}}
44
88
  </style>
89
+ <script data-hd-opening>(function(){var d=document;
90
+ if(self!==top||/[?&](thumb|export)\b/.test(location.search))return;
91
+ function go(){if(d.readyState==="loading")return;d.removeEventListener("readystatechange",go);
92
+ var b=d.body,p=d.querySelector('script[src*="/player/v"][src*="/player.js"]');
93
+ if(!b||!p||d.getElementById("hd-open")||b.hasAttribute("data-hd-ready")||d.documentElement.hasAttribute("data-hyperdecks-thumb"))return;
94
+ var o=d.createElement("div"),img=p.getAttribute("data-presenter")!=="none"&&p.getAttribute("data-presenter-image");
95
+ o.id="hd-open";o.setAttribute("aria-hidden","true");
96
+ o.innerHTML='<div class="hdo-slide"></div><div class="hdo-card">'+(img?"<div class=\"hdo-buddy\"><div class=\"hdo-hop\"><div class=\"hdo-look\"><img src=\"\" alt=\"\"></div></div><div class=\"hdo-shadow\"></div><div class=\"hdo-think\"><i></i><i></i><i></i></div><span class=\"hdo-spark\" style=\"--x:-40px;--y:-30px\"></span><span class=\"hdo-spark\" style=\"--x:42px;--y:-34px\"></span><span class=\"hdo-spark\" style=\"--x:-30px;--y:10px\"></span><span class=\"hdo-spark\" style=\"--x:36px;--y:6px\"></span><span class=\"hdo-spark\" style=\"--x:0px;--y:-46px\"></span></div>":'')+'<div class="hdo-title"></div><div class="hdo-bar"><i></i></div><div class="hdo-step"></div></div>';
97
+ if(img){var i=o.querySelector(".hdo-buddy img");i.addEventListener("error",function(){var y=o.querySelector(".hdo-buddy");if(y)y.remove()});i.src=img}
98
+ o.querySelector(".hdo-title").textContent=(d.title||"").replace(/\s+/g," ").trim();
99
+ b.insertBefore(o,b.firstChild);(function(o){var b=document.body,d=document.documentElement;if(!o||!b)return;
100
+ if(self!==top||/[?&](thumb|export)\b/.test(location.search)||d.hasAttribute("data-hyperdecks-thumb")){o.remove();return}
101
+ var S={"page":[10,"Opening the deck…"],"player":[38,"Loading the player…"],"presenter":[62,"Waking up {name}…"],"narration":[84,"Getting the narration…"]},bar=o.querySelector(".hdo-bar i"),step=o.querySelector(".hdo-step"),who=o.getAttribute("data-name")||"the presenter",at=0,shown=false,done=false,mo;
102
+ var rm=window.matchMedia&&matchMedia("(prefers-reduced-motion: reduce)").matches;
103
+ function boot(){var s=S[b.getAttribute("data-hd-boot")];if(!s||s[0]<=at)return;at=s[0];bar.style.width=s[0]+"%";step.textContent=s[1].split("{name}").join(who)}
104
+ function finish(){if(done)return;done=true;if(mo)mo.disconnect();clearTimeout(t1);clearTimeout(t2);
105
+ if(!shown){o.remove();return}bar.style.width="100%";step.textContent="Ready";
106
+ o.classList.add("is-done");setTimeout(function(){o.classList.add("is-gone")},rm?0:500);setTimeout(function(){o.remove()},rm?500:1000)}
107
+ function check(){if(b.hasAttribute("data-hd-ready"))finish();else boot()}
108
+ var t1=setTimeout(function(){if(!done){shown=true;o.classList.add("is-shown")}},150);
109
+ var t2=setTimeout(finish,10000);
110
+ step.textContent=S.page[1];
111
+ if(window.MutationObserver){mo=new MutationObserver(check);mo.observe(b,{attributes:true,attributeFilter:["data-hd-ready","data-hd-boot"]})}
112
+ check()})(o)}
113
+ d.addEventListener("readystatechange",go);go()})();</script>
45
114
  <link rel="preconnect" href="https://fonts.googleapis.com">
46
115
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
47
116
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Space+Mono:wght@400;700&display=swap">
@@ -55,20 +124,26 @@
55
124
  --font: "Space Grotesk", system-ui, sans-serif; --mono: "Space Mono", ui-monospace, monospace;
56
125
  /* The type scale: one display size per slide at most, then title, sub (a title beside a visual), lead, body, label. */
57
126
  --t-display: 128px; --t-title: 84px; --t-sub: 56px; --t-lead: 30px; --t-body: 24px; --t-label: 17px;
127
+ /* The text colour on an accent ground. A brand with a dark accent sets it to its paper. */
128
+ --accent-ink: var(--ink);
58
129
  --hd-ground: var(--accent); --hd-ground-ink: var(--ink); /* the loading state: the first slide's colours */
59
130
  /* Soft grounds (on-tint, on-tint-2, on-tint-3): washes of the accents, so slide changes read without going loud. */
60
131
  --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
132
  }
62
133
  * { box-sizing: border-box; margin: 0; padding: 0; }
63
134
  body { color: var(--ink); font-family: var(--font); font-weight: 500; -webkit-font-smoothing: antialiased; --hd-font: var(--font); --hd-mono: var(--mono); }
64
- .slide { display: flex; flex-direction: column; gap: 24px; padding: 64px 72px 56px; background: var(--paper); color: var(--ink); --hd-speech: #f3c9dc; }
65
- .slide.on-accent { background: var(--accent); --hd-speech: var(--card); }
135
+ /* The caption bubble is always a fixed light fill with dark ink, so it reads whatever the
136
+ brand's --card or --ink (a dark brand's card is dark). A slide can still set its own. */
137
+ .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; }
138
+ .slide.on-accent { background: var(--accent); color: var(--accent-ink); --hd-speech: #ffffff; }
66
139
  .slide.on-ink { background: var(--ink); color: var(--paper); --hd-speech: #d9ccff; }
67
- .slide.on-tint { background: var(--tint-1); --hd-speech: var(--card); }
68
- .slide.on-tint-2 { background: var(--tint-2); --hd-speech: var(--card); }
69
- .slide.on-tint-3 { background: var(--tint-3); --hd-speech: var(--card); }
140
+ .slide.on-tint { background: var(--tint-1); --hd-speech: #ffffff; }
141
+ .slide.on-tint-2 { background: var(--tint-2); --hd-speech: #ffffff; }
142
+ .slide.on-tint-3 { background: var(--tint-3); --hd-speech: #ffffff; }
70
143
  .kicker { font: var(--t-label) var(--mono); letter-spacing: .16em; text-transform: uppercase; }
71
- .tt { font-family: var(--display, var(--font)); font-weight: 700; font-size: var(--t-title); line-height: .95; letter-spacing: -.045em; text-wrap: balance; }
144
+ /* Titles take the display face's weight and tracking (brand.mjs sets them for a face the
145
+ template was not tuned for) and never fake a weight the face lacks. */
146
+ .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
147
  .lead { font-size: var(--t-lead); line-height: 1.3; max-width: 28em; text-wrap: pretty; }
73
148
  .muted { opacity: .65; }
74
149
  /* The presenter glides to each slide's slot: bottom-right by default,
@@ -98,26 +173,57 @@
98
173
  linear-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0 0) 14% 67% / 44% 2.4% no-repeat,
99
174
  linear-gradient(var(--accent) 0 0) 50% 90% / 84% 6% no-repeat,
100
175
  var(--paper); }
101
- /* Backgrounds, optional and for a few slides a deck. bg-dots on the section:
102
- a dot grid in the slide's text colour, faded in from the side away from the text
103
- (--dots-fade is the fade's direction: 90deg puts the dots on the right,
104
- 270deg on the left). A <div class="deco" aria-hidden="true"> in the slide
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%); }
176
+ /* Decorations: a <div class="deco" aria-hidden="true"> in the slide holds small
177
+ inked tiles, each a <span> placed by its inline left/top (and background, rotate),
178
+ behind the slide's content. */
179
+ .slide:has(> .deco) { isolation: isolate; }
109
180
  .slide > .deco { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
110
181
  .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
- /* The look kit (deck_guide topic look): one texture per deck, one icon set, real pictures.
112
- Textures go on the section, behind the content, on a few slides: bg-lines, diagonal
113
- speed lines fading out towards the text (--fade-at: where they are strongest, default
114
- the right); bg-halftone, a corner of halftone dots (--fade-at, default top right);
115
- bg-rays, rays from a point (--rays-at), for an opener or a closer. */
116
- .slide:is(.bg-lines, .bg-halftone, .bg-rays) { isolation: isolate; overflow: hidden; }
117
- .slide:is(.bg-lines, .bg-halftone, .bg-rays)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; }
118
- .slide.bg-lines::before { background: repeating-linear-gradient(-22deg, transparent 0 30px, color-mix(in srgb, currentColor 10%, transparent) 30px 32px); -webkit-mask-image: radial-gradient(1000px 780px at var(--fade-at, 82% 52%), #000 12%, transparent 70%); mask-image: radial-gradient(1000px 780px at var(--fade-at, 82% 52%), #000 12%, transparent 70%); }
119
- .slide.bg-halftone::before { background: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 2.6px, transparent 3px) 0 0 / 22px 22px; -webkit-mask-image: radial-gradient(640px 640px at var(--fade-at, 88% 14%), #000 25%, transparent 70%); mask-image: radial-gradient(640px 640px at var(--fade-at, 88% 14%), #000 25%, transparent 70%); }
120
- .slide.bg-rays::before { background: repeating-conic-gradient(from 0deg at var(--rays-at, 72% 50%), color-mix(in srgb, currentColor 9%, transparent) 0deg 2.2deg, transparent 2.2deg 9deg); -webkit-mask-image: radial-gradient(circle at var(--rays-at, 72% 50%), transparent 10%, #000 45%); mask-image: radial-gradient(circle at var(--rays-at, 72% 50%), transparent 10%, #000 45%); }
182
+ /* Backgrounds (deck_guide topic look). Four classes on the section, tokens only, each painted
183
+ behind the content (a ::before at z-index -1 in an isolated slide) and confined to the object
184
+ it backs, never under the text. Each carries its own per-layout rules (.l-<layout>.bg-<name>),
185
+ so on a layout with nothing to back it paints nothing. Plain grounds come first; one signature
186
+ (bg-corner or bg-mark) per deck on three slides at most; bg-pool and bg-stage only on a slide
187
+ with a picture. A deck may add its own in its <style> under the same contract. */
188
+ .slide:is(.bg-pool, .bg-corner, .bg-stage, .bg-mark) { isolation: isolate; overflow: hidden; }
189
+ .slide:is(.bg-pool, .bg-corner, .bg-stage, .bg-mark)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; }
190
+ /* bg-pool: one soft light of the next accent (accent-2 on paper, accent on a tint, a screened
191
+ accent-3 on an accent ground, accent on ink), placed per layout as the spotlight of the picture. */
192
+ .slide.bg-pool { --pool: color-mix(in srgb, var(--accent-2) 34%, transparent); }
193
+ .slide:is(.on-tint, .on-tint-2, .on-tint-3).bg-pool { --pool: color-mix(in srgb, var(--accent) 38%, transparent); }
194
+ .slide.on-accent.bg-pool { --pool: color-mix(in srgb, var(--accent-3, var(--accent-2)) 55%, transparent); }
195
+ .slide.on-accent.bg-pool::before { mix-blend-mode: screen; }
196
+ .slide.on-ink.bg-pool { --pool: color-mix(in srgb, var(--accent) 32%, transparent); }
197
+ .slide.bg-pool::before { background: radial-gradient(var(--pw, 760px) var(--ph, 600px) at var(--px, 82%) var(--py, 28%), var(--pool), transparent 70%); }
198
+ .l-hero.bg-pool { --px: 82%; --py: 28%; --pw: 760px; --ph: 600px; }
199
+ .l-split.bg-pool { --px: 73%; --py: 50%; --pw: 900px; --ph: 860px; }
200
+ .l-product.bg-pool { --px: 34%; --py: 50%; --pw: 1180px; --ph: 860px; }
201
+ .l-phones.bg-pool { --px: 68%; --py: 50%; --pw: 900px; --ph: 760px; }
202
+ .l-chart.bg-pool { --px: 84%; --py: 40%; --pw: 640px; --ph: 520px; }
203
+ .l-quote.bg-pool { --px: 10%; --py: 14%; --pw: 760px; --ph: 560px; }
204
+ .l-closer.bg-pool { --px: 90%; --py: 4%; --pw: 820px; --ph: 640px; }
205
+ /* bg-corner: a hard-edged quarter-disc of halftone, top-right, behind the stickers. A signature: cover, closer. */
206
+ .slide.bg-corner::before {
207
+ --corner-r: 580px; --corner-dot: color-mix(in srgb, currentColor var(--corner-a, 15%), transparent);
208
+ inset: auto; right: 0; top: 0; width: var(--corner-r); height: var(--corner-r); border-bottom-left-radius: 100%;
209
+ background: radial-gradient(var(--corner-dot) 2.8px, transparent 3.3px) 0 0 / 20px 20px;
210
+ -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%); }
211
+ .l-closer.bg-corner::before { --corner-r: 460px; }
212
+ .on-ink.bg-corner::before { --corner-a: 22%; }
213
+ :is(.l-split, .l-product, .l-chart, .l-quote, .l-number, .l-features, .l-team, .l-timeline, .l-compare, .l-cover-band, .l-cover-poster).bg-corner::before { content: none; }
214
+ /* 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. */
215
+ .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; }
216
+ .l-product.bg-stage::before { position: relative; grid-area: frame; align-self: stretch; margin: var(--stage-m, -32px -24px -32px -40px); }
217
+ .l-phones.bg-stage::before { position: relative; grid-area: row; align-self: stretch; margin: var(--stage-m, -32px); }
218
+ .l-hero.bg-stage::before { right: 110px; top: 110px; width: 410px; height: 350px; }
219
+ :is(.l-split, .l-chart, .l-quote, .l-closer, .l-number, .l-features, .l-team, .l-timeline, .l-compare, .l-cover-band, .l-cover-poster, .l-cover-type).bg-stage::before { content: none; }
220
+ /* bg-mark: the slide number, set as style="--mark:'04'" on the section, huge in --display, faint,
221
+ cropped by the top edge; right on the hero, in the indent on slot-left slides. A signature: cover, an opener, closer. */
222
+ .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;
223
+ color: color-mix(in srgb, currentColor var(--mark-a, 8%), transparent); }
224
+ .slide.on-ink.bg-mark::before { --mark-a: 6%; }
225
+ .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 */
226
+ :is(.l-split, .l-product, .l-chart, .l-phones, .l-number, .l-features, .l-team, .l-timeline, .l-compare, .l-cover-band, .l-cover-poster).bg-mark::before { content: none; }
121
227
  /* Pieces. .sticker: a logo or icon tile, tilted (--tilt), ink edge, hard offset
122
228
  shadow (.sticker.photo for a face or an app icon that fills it). .ticks: a short
123
229
  list with accent-square bullets. .panel: a quiet card. .pill: a small tag. */
@@ -128,12 +234,12 @@
128
234
  .ticks { list-style: none; display: flex; flex-direction: column; gap: 14px; }
129
235
  .ticks li { position: relative; padding-left: 34px; font-size: var(--t-body); line-height: 1.3; font-weight: 600; }
130
236
  .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); }
237
+ .on-accent .ticks li::before { background: var(--accent-ink); }
132
238
  .panel { padding: 24px 28px; border-radius: 22px; background: color-mix(in srgb, currentColor 6%, transparent); }
133
239
  .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); }
240
+ .on-accent .pill { box-shadow: 4px 4px 0 var(--accent-ink); }
135
241
  /* A laptop for a desktop screenshot or clip: a .frame.laptop holding an img.shot or video.shot. */
136
- .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); }
242
+ .slide .frame.laptop { position: relative; width: min(860px, 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
243
  .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); }
138
244
  .slide .frame.laptop > .shot { display: block; width: 100%; aspect-ratio: 16 / 10; border-radius: 6px; object-fit: cover; object-position: top; background: var(--paper); }
139
245
  /* Phones: the slide is the 374×736 card; the presenter is a small head in
@@ -145,12 +251,12 @@
145
251
  :root.hd-no-presenter .slide { padding-bottom: 28px; }
146
252
  .slide .frame.card { width: 160px; padding: 5px; border-width: 2px; border-radius: 24px; box-shadow: 8px 8px 0 var(--shadow); }
147
253
  .slide .frame.card > .shot { border-radius: 19px; border-width: 1px; }
148
- /* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
149
- .slide.bg-dots::before { background-size: 22px 22px; background-image: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 2px, transparent 2.5px); -webkit-mask-image: linear-gradient(180deg, transparent 45%, #000 85%); mask-image: linear-gradient(180deg, transparent 45%, #000 85%); }
254
+ /* The text fills the card's width, so the backgrounds keep off it: the corner fades in
255
+ towards the bottom, under the presenter, at a finer pitch; the stage and the mark hide;
256
+ the pool (a soft light) stays as it is; the tiles stay off. */
257
+ .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%); }
258
+ .slide:is(.bg-stage, .bg-mark)::before { content: none; }
150
259
  .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
260
  .sticker { width: 48px; height: 48px; padding: 9px; border-width: 2px; border-radius: 13px; box-shadow: 3px 3px 0 var(--ink); }
155
261
  .ticks { gap: 6px; }
156
262
  .ticks li { padding-left: 18px; }
@@ -158,7 +264,7 @@
158
264
  .panel { padding: 10px 12px; border-radius: 12px; }
159
265
  .pill { padding: 4px 10px; box-shadow: 2px 2px 0 var(--accent); }
160
266
  .slide .frame.laptop { padding: 5px 5px 9px; border-radius: 10px 10px 3px 3px; }
161
- .slide .frame.laptop::after { bottom: -7px; height: 7px; border-radius: 0 0 8px 8px; }
267
+ .slide .frame.laptop::after { left: -3%; right: -3%; bottom: -7px; height: 7px; border-radius: 0 0 8px 8px; }
162
268
  }
163
269
  </style>
164
270
  <!-- LAYOUT STYLES -->
@@ -175,7 +281,9 @@
175
281
 
176
282
  <!-- `hyperdecks narrate decks/__SLUG__` fills data-manifest with the manifest
177
283
  URL it writes. data-presenter: head (live Minimoji, needs data-avatar),
178
- static (data-presenter-image) or none (captions only). -->
284
+ static (data-presenter-image) or none (captions only). deck new, deck
285
+ narrator and narrate write the narrator's face here
286
+ (data-presenter-from="narrator", so release follows a later narrator). -->
179
287
  <script type="module" src="https://hyperdecks.ai/player/v1/player.js"
180
288
  data-manifest="" data-stage="1600x900" data-presenter="none" data-presenter-style="__PRESENTER_STYLE__"></script>
181
289
  </body>
@@ -11,7 +11,9 @@ 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
+ /* The arrow is an SVG, not "→": Space Mono's (and Space Grotesk's) web subsets have no U+2192, so the glyph fell back to another face and read as malformed. */
16
+ .l-closer .cta svg { flex: none; width: 1em; height: 1em; }
15
17
  .l-closer .contact { font: var(--t-label) var(--mono); letter-spacing: .12em; text-transform: uppercase; }
16
18
  @media (max-width: 650px) {
17
19
  .l-closer .content { padding-left: 0; justify-content: flex-start; gap: 16px; }
@@ -26,7 +28,7 @@ script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [g
26
28
  <div class="content">
27
29
  <h2 class="tt" data-title-mobile="Thanks.">Thanks.</h2>
28
30
  <p class="lead">One sentence on the one thing to do next.</p>
29
- <a class="cta" id="__ID__-cta" href="https://example.com" target="_blank" rel="noopener">example.com →</a>
31
+ <a class="cta" id="__ID__-cta" href="https://example.com" target="_blank" rel="noopener">example.com <svg class="lucide-arrow-right" 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"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg></a>
30
32
  <div class="contact muted">your@email · your.site</div>
31
33
  </div>
32
34
  <div class="narration-slot"></div>
@@ -0,0 +1,44 @@
1
+ <!--
2
+ layout: cover-band
3
+ summary: A cover with the picture full bleed and the title on a solid, inked panel of the theme's paper in the bottom-left, so the title's contrast never depends on the picture.
4
+ use: the first slide when the picture is the story (a place, an object, a crowd) and wants the whole frame. Its .art is a placeholder check fails on: put in a wide picture of the real subject (gen-image / generate_image, 16:9) as <img class="art">; keep the subject top or top-right: the panel covers the bottom-left and the presenter the bottom-right. The panel grows with the title; never move the title off the panel onto the picture. Without art the panel sits on the slide's ground.
5
+ slot: bottom-right
6
+ presenter: stage
7
+ nopresenter: The panel may widen into the corner the presenter held.
8
+ cues: [wave] at the hello, [look: h1] when the narration says the title.
9
+ script: Hi, [wave] this is __TITLE__. [look: h1] One idea, in a few minutes.
10
+ -->
11
+ <style>
12
+ /* The picture fills the slide; the title sits on a solid panel of the theme's paper in the bottom-left, never on the picture. */
13
+ .l-cover-band { justify-content: flex-end; align-items: flex-start; overflow: hidden; }
14
+ /* A brand logo (applyBrand puts it first in the slide) sits top-left on the picture, not on the panel's edge. */
15
+ .l-cover-band:has(> .art) > .brand-logo { position: absolute; top: 28px; left: 72px; }
16
+ .l-cover-band .art { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
17
+ .l-cover-band div.art { background: color-mix(in srgb, var(--ink) 6%, var(--paper)) repeating-linear-gradient(135deg, color-mix(in srgb, var(--ink) 10%, transparent) 0 2px, transparent 2px 22px); }
18
+ .l-cover-band div.art::after { content: "Art goes here"; position: absolute; left: 70%; top: 34%; transform: translate(-50%, -50%); padding: 14px 26px; border: 3px dashed var(--ink); border-radius: 16px; background: var(--paper); color: var(--ink); font: 700 24px var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
19
+ .l-cover-band .content { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 18px; max-width: 980px; padding: 36px 44px 40px; border: 4px solid var(--ink); border-radius: 28px; background: var(--paper); color: var(--ink); box-shadow: 14px 14px 0 var(--ink); }
20
+ .l-cover-band .tt { font-size: min(var(--t-display), 96px); line-height: .92; }
21
+ .l-cover-band:not(:has(> .art)) { justify-content: center; }
22
+ @media (max-width: 650px) {
23
+ /* Phones: the picture across the top, the panel below it, down to the presenter's corner. */
24
+ .l-cover-band { justify-content: flex-start; align-items: stretch; gap: 0; }
25
+ .l-cover-band .art { position: relative; inset: auto; flex: none; width: auto; height: 210px; margin: -28px -20px 0; }
26
+ .l-cover-band div.art::after { left: 50%; top: 50%; padding: 8px 14px; border-width: 2px; font-size: 13px; }
27
+ .l-cover-band .content { gap: 12px; margin-top: -36px; padding: 18px 18px 20px; border-width: 2px; border-radius: 16px; box-shadow: 6px 6px 0 var(--ink); }
28
+ .l-cover-band .tt { font-size: var(--t-display); }
29
+ .l-cover-band:has(> .art) > .brand-logo { top: 12px; left: 20px; }
30
+ }
31
+ /* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
32
+ @media (min-width: 651px) {
33
+ :root.hd-no-presenter .l-cover-band .content { max-width: 1200px; }
34
+ }
35
+ </style>
36
+ <section class="slide l-cover-band" id="__ID__" data-chapter="Hello" data-note="Title slide: no facts.">
37
+ <div class="art" role="presentation"></div>
38
+ <div class="content">
39
+ <div class="kicker">__SLUG__</div>
40
+ <h1 class="tt" data-title-mobile="__TITLE__">__TITLE__</h1>
41
+ <p class="lead">One sentence on what this deck is about.</p>
42
+ </div>
43
+ <div class="narration-slot"></div>
44
+ </section>