@hypersocial/decks-cli 0.9.9 → 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +95 -48
- package/skills/hyperdecks/chat.md +8 -1
- package/src/brand.mjs +57 -5
- package/src/checkRules.mjs +90 -2
- package/src/commands.mjs +39 -9
- package/src/draftFace.mjs +56 -0
- package/src/files.mjs +3 -0
- package/src/layouts.mjs +3 -2
- package/src/loadingBlock.mjs +22 -7
- package/src/narrate.mjs +6 -1
- package/src/opening.mjs +117 -0
- 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 +28 -7
- package/templates/layouts/_deck.html +144 -36
- package/templates/layouts/closer.html +4 -2
- package/templates/layouts/cover-band.html +44 -0
- package/templates/layouts/cover-poster.html +42 -0
- package/templates/layouts/cover-type.html +31 -0
- package/templates/layouts/hero.html +27 -12
- package/templates/layouts/phones.html +5 -5
- package/templates/layouts/split.html +4 -4
- package/templates/narrated-deck.html +74 -3
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.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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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:
|
|
68
|
-
.slide.on-tint-2 { background: var(--tint-2); --hd-speech:
|
|
69
|
-
.slide.on-tint-3 { background: var(--tint-3); --hd-speech:
|
|
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
|
-
|
|
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
|
-
/*
|
|
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%); }
|
|
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
|
-
/*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
bg-
|
|
116
|
-
|
|
117
|
-
.slide:is(.bg-
|
|
118
|
-
.slide.bg-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
149
|
-
|
|
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
|
|
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>
|