@hypersocial/decks-cli 0.9.5 → 0.9.7
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 +2 -2
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +71 -18
- package/skills/hyperdecks/chat.md +5 -4
- package/src/checkRules.mjs +45 -4
- package/src/commands.mjs +19 -12
- package/src/export-video.mjs +290 -43
- package/src/exportHelp.mjs +1 -1
- package/src/files.mjs +1 -0
- package/src/firstDeck.mjs +1 -1
- package/src/guide.mjs +1 -1
- package/src/layouts.mjs +2 -2
- package/src/loadingBlock.mjs +45 -14
- package/src/ops.mjs +6 -3
- package/src/presenter.mjs +2 -2
- package/src/preview-capture.mjs +24 -2
- package/src/preview.mjs +2 -2
- package/src/runtime.mjs +34 -6
- package/templates/layouts/_deck.html +47 -3
- package/templates/layouts/split.html +48 -0
- package/templates/narrated-deck.html +3 -3
package/src/loadingBlock.mjs
CHANGED
|
@@ -11,8 +11,8 @@ export const LOADING_BLOCK = [
|
|
|
11
11
|
" page's brand colour, with a quiet loader in --hd-ground-ink; then the",
|
|
12
12
|
" slides fade in. If the player never arrives, the slides show after 10 s",
|
|
13
13
|
" rather than never. Put data-layout=\"overlay\" on <html> for the overlay. -->",
|
|
14
|
-
"<link rel=\"modulepreload\" href=\"https://
|
|
15
|
-
"<link rel=\"preload\" as=\"style\" href=\"https://
|
|
14
|
+
"<link rel=\"modulepreload\" href=\"https://hyperdecks.ai/player/v1/player.js\">",
|
|
15
|
+
"<link rel=\"preload\" as=\"style\" href=\"https://hyperdecks.ai/player/v1/player.css\">",
|
|
16
16
|
"<script>if (self !== top || /[?&]thumb\\b/.test(location.search)) document.documentElement.dataset.hdFrame = \"\";</script>",
|
|
17
17
|
"<style data-hd-loading>",
|
|
18
18
|
" body:not([data-hd-ready]) { overflow: hidden; background: var(--hd-frame, var(--hd-ground, var(--hd-accent, #ffda1b))); animation: hd-fail-open 0s 10s forwards; }",
|
|
@@ -49,24 +49,55 @@ const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i
|
|
|
49
49
|
const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
|
|
50
50
|
const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
|
|
51
51
|
const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
|
|
52
|
+
// The block's pieces besides its style, on any player host: the two preloads and
|
|
53
|
+
// the frame flag (each kept once), and its own comment (matched only as a whole
|
|
54
|
+
// comment, so a person's comments in <head> are never touched).
|
|
55
|
+
const PIECES = [
|
|
56
|
+
/<link rel="modulepreload" href="[^"]*\/player\/v\d+\/player\.js">[ \t]*\n?/g,
|
|
57
|
+
/<link rel="preload" as="style" href="[^"]*\/player\/v\d+\/player\.css">[ \t]*\n?/g,
|
|
58
|
+
/<script>if \(self !== top \|\| [^<]*document\.documentElement\.dataset\.hdFrame = "";<\/script>[ \t]*\n?/g,
|
|
59
|
+
new RegExp(`${STYLE.source}[ \\t]*\\n?`, "gi"),
|
|
60
|
+
];
|
|
61
|
+
const BLOCK_COMMENT = /^<!-- Loading state \(keep verbatim/;
|
|
52
62
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
// <head>'s text with each piece kept `keep` times (0: all out, 1: the first stays),
|
|
64
|
+
// working outside comments only; the block's own comment counts as a piece too.
|
|
65
|
+
function trimPieces(head, keep) {
|
|
66
|
+
const seen = new Array(PIECES.length + 1).fill(0);
|
|
67
|
+
return head
|
|
68
|
+
.split(/(<!--[\s\S]*?-->[ \t]*\n?)/)
|
|
69
|
+
.map((part, i) => {
|
|
70
|
+
if (i % 2) return BLOCK_COMMENT.test(part) && ++seen[PIECES.length] > keep ? "" : part;
|
|
71
|
+
return PIECES.reduce((text, re, k) => text.replace(re, (m) => (++seen[k] > keep ? "" : m)), part);
|
|
72
|
+
})
|
|
73
|
+
.join("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The page with its loading state put right. Only a page with the player script,
|
|
77
|
+
// as the check asks only those. With no <style data-hd-loading> in <head>, the
|
|
78
|
+
// block goes in after <title> (or at the top of <head>), any of its pieces already
|
|
79
|
+
// there taken out first. With one, it becomes the template's (refreshed) and any
|
|
80
|
+
// second copy of the style or a piece goes (deduped). A person's comments are left
|
|
81
|
+
// alone. Returns { html, fixed: "inserted" | "refreshed" | "deduped" | null,
|
|
57
82
|
// skipped? }: a player page with no <head>…</head> is left alone, with `skipped` saying why.
|
|
58
83
|
export function withLoadingBlock(html) {
|
|
59
84
|
const live = html.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length)); // same offsets, comments blanked
|
|
60
85
|
const head = HEAD.exec(live);
|
|
61
86
|
if (!PLAYER.test(live)) return { html, fixed: null };
|
|
62
87
|
if (!head) return { html, fixed: null, skipped: "no <head>…</head> to put the loading state in" };
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
const rawHead = html.slice(head.index, head.index + head[0].length);
|
|
89
|
+
const splice = (text) => html.slice(0, head.index) + text + html.slice(head.index + head[0].length);
|
|
90
|
+
if (!STYLE.test(head[0])) {
|
|
91
|
+
const cleaned = trimPieces(rawHead, 0);
|
|
92
|
+
const title = /<\/title>/i.exec(cleaned);
|
|
93
|
+
const at = title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(cleaned)[0].length;
|
|
94
|
+
return { html: splice(`${cleaned.slice(0, at)}\n${LOADING_BLOCK}${cleaned.slice(at)}`), fixed: "inserted" };
|
|
68
95
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
const deduped = trimPieces(rawHead, 1);
|
|
97
|
+
// The one style left, outside comments, made the template's.
|
|
98
|
+
const blank = deduped.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
|
|
99
|
+
const style = STYLE.exec(blank);
|
|
100
|
+
const fresh = deduped.slice(0, style.index) + LOADING_STYLE + deduped.slice(style.index + style[0].length);
|
|
101
|
+
if (fresh === rawHead) return { html, fixed: null };
|
|
102
|
+
return { html: splice(fresh), fixed: deduped !== rawHead ? "deduped" : "refreshed" };
|
|
72
103
|
}
|
package/src/ops.mjs
CHANGED
|
@@ -78,6 +78,7 @@ export async function generateImage(opts, fields = {}) {
|
|
|
78
78
|
if (fields.format) args.format = String(fields.format);
|
|
79
79
|
if (fields.model) args.model = String(fields.model);
|
|
80
80
|
if (fields.seed != null && fields.seed !== "") args.seed = Number(fields.seed);
|
|
81
|
+
if (fields.brand) args.brand = true;
|
|
81
82
|
// Returns { images } for fast requests, or { jobId, status, count } for slow ones
|
|
82
83
|
// (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
|
|
83
84
|
// never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
|
|
@@ -179,8 +180,10 @@ export async function renderChart(opts, spec = {}) {
|
|
|
179
180
|
export async function generateIconSet(opts, fields = {}) {
|
|
180
181
|
const subjects = (fields.subjects || []).map(String).filter(Boolean);
|
|
181
182
|
if (!subjects.length) throw new Error("subjects is required (one or more things to draw)");
|
|
182
|
-
if (!fields.style) throw new Error("style is required");
|
|
183
|
-
const args = { subjects
|
|
183
|
+
if (!fields.style && !fields.preset) throw new Error("style or preset is required");
|
|
184
|
+
const args = { subjects };
|
|
185
|
+
if (fields.style) args.style = String(fields.style);
|
|
186
|
+
if (fields.preset) args.preset = String(fields.preset);
|
|
184
187
|
if (fields.palette?.length) args.palette = fields.palette.map(String);
|
|
185
188
|
if (fields.slug) args.slug = String(fields.slug);
|
|
186
189
|
if (fields.name) args.name = String(fields.name);
|
|
@@ -229,7 +232,7 @@ export async function requestVideoExport(opts, fields = {}) {
|
|
|
229
232
|
const c = await client(opts);
|
|
230
233
|
try {
|
|
231
234
|
const out = await c.mutation(api.videoExports.requestVideo, { slug, options });
|
|
232
|
-
return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a deck
|
|
235
|
+
return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a 4-minute deck takes about 12 minutes` };
|
|
233
236
|
} catch (e) {
|
|
234
237
|
// A backend from before the render worker has no requestVideo.
|
|
235
238
|
if (/Could not find public function/i.test(errorText(e))) throw new Error(videoFromCli(slug));
|
package/src/presenter.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// D.A.S.H.'s face: the head doc and still that ship with the player. A deck
|
|
10
10
|
// narrated in its character voice gets this face at release, even when the
|
|
11
11
|
// tag was scaffolded captions-only (data-presenter="none").
|
|
12
|
-
export const DASH_AVATAR = "https://
|
|
13
|
-
export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://
|
|
12
|
+
export const DASH_AVATAR = "https://hyperdecks.ai/player/v1/dash-avatar.json";
|
|
13
|
+
export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://hyperdecks.ai/avatars/dash.png" });
|
|
14
14
|
|
|
15
15
|
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
|
|
16
16
|
|
package/src/preview-capture.mjs
CHANGED
|
@@ -107,12 +107,17 @@ export async function serveSource({ file, html, port = 0 }) {
|
|
|
107
107
|
const url = `http://127.0.0.1:${server.address().port}${entry}`;
|
|
108
108
|
return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
|
|
109
109
|
}
|
|
110
|
+
// `type` is Playwright's resourceType, except that a document loading into a
|
|
111
|
+
// child frame (an <iframe>) is 'subdocument' (captureResourceType): the top
|
|
112
|
+
// page stays pinned to the entry URL, while an embedded page gets the rules
|
|
113
|
+
// any other public resource gets, over https (or from the entry's own origin).
|
|
110
114
|
export function allowCaptureRequest(url, method, type, entryUrl, firstPartyOrigins = []) {
|
|
111
115
|
let u, path;
|
|
112
116
|
try { u = new URL(url); path = decodeURIComponent(u.pathname); } catch { return false; }
|
|
113
117
|
if (!['http:', 'https:', 'data:', 'blob:'].includes(u.protocol) || method !== 'GET') return false;
|
|
114
118
|
if (/(^|\/)(v|track|telemetry|analytics|beacon)(\/|$)/i.test(path)) return false;
|
|
115
119
|
if (type === 'document') return url === entryUrl;
|
|
120
|
+
if (type === 'subdocument' && u.protocol !== 'https:' && u.origin !== new URL(entryUrl).origin) return false;
|
|
116
121
|
if (['websocket', 'eventsource', 'ping', 'other'].includes(type)) return false;
|
|
117
122
|
if (firstPartyOrigins.some(v => new URL(v).origin === u.origin) || /\.convex\.(cloud|site)$/.test(u.hostname)) {
|
|
118
123
|
// Stored assets, and the hosted player a player/v1 deck loads (player.js, its
|
|
@@ -158,15 +163,32 @@ export async function allowCaptureDestination(url, entryUrl, resolver = lookup)
|
|
|
158
163
|
} catch { return false; }
|
|
159
164
|
}
|
|
160
165
|
|
|
166
|
+
// Playwright's resourceType, with a document bound for a child frame told
|
|
167
|
+
// apart as 'subdocument'. A request with no frame (a service worker's) or a
|
|
168
|
+
// frame that can't be read stays 'document', the strict rule.
|
|
169
|
+
export function captureResourceType(request) {
|
|
170
|
+
const type = request.resourceType();
|
|
171
|
+
if (type !== 'document') return type;
|
|
172
|
+
try { return request.frame().parentFrame() ? 'subdocument' : type; } catch { return type; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The request's headers without its cache validators. Chromium revalidates a
|
|
176
|
+
// file it has cached (a second load of an embedded page, say) with
|
|
177
|
+
// If-None-Match / If-Modified-Since; the server's 304 is a 3xx, which the
|
|
178
|
+
// route refuses, so the file would fail. Without them the answer is the file.
|
|
179
|
+
export function unconditionalHeaders(headers) {
|
|
180
|
+
return Object.fromEntries(Object.entries(headers).filter(([k]) => !/^if-(none-match|modified-since|match|unmodified-since|range)$/i.test(k)));
|
|
181
|
+
}
|
|
182
|
+
|
|
161
183
|
// Playwright's continue() follows redirects without routing each hop. Fetch
|
|
162
184
|
// without following redirects and never fulfill a 3xx response into Chromium.
|
|
163
185
|
export async function routeCaptureRequest(route, entryUrl, origins = []) {
|
|
164
186
|
const r = route.request();
|
|
165
187
|
try {
|
|
166
|
-
if (!allowCaptureRequest(r.url(), r.method(), r
|
|
188
|
+
if (!allowCaptureRequest(r.url(), r.method(), captureResourceType(r), entryUrl, origins) || !await allowCaptureDestination(r.url(), entryUrl)) {
|
|
167
189
|
await route.abort(); return false;
|
|
168
190
|
}
|
|
169
|
-
const response = await route.fetch({ maxRedirects:0, timeout:15000 });
|
|
191
|
+
const response = await route.fetch({ headers: unconditionalHeaders(r.headers()), maxRedirects:0, timeout:15000 });
|
|
170
192
|
if (response.status() >= 300 && response.status() < 400) {
|
|
171
193
|
await response.dispose(); await route.abort(); return false;
|
|
172
194
|
}
|
package/src/preview.mjs
CHANGED
|
@@ -76,7 +76,7 @@ export async function createPreview(pos, opts, adapters = {}) {
|
|
|
76
76
|
await save();
|
|
77
77
|
try {
|
|
78
78
|
const capture = adapters.capture || (await import('./preview-capture.mjs')).capturePreview;
|
|
79
|
-
const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
|
|
79
|
+
const captured = await capture({ ...config, html, firstPartyOrigins: [targetForCapture(opts), 'https://hyperdecks.ai', 'https://decks.hypersocial.ai', 'https://dashing-snail-874.convex.cloud'] });
|
|
80
80
|
result.files = captured.files;
|
|
81
81
|
warnings.push(...captured.warnings);
|
|
82
82
|
for (const f of result.files) if (f.bytes > 5 * 1024 * 1024) warnings.push(`${f.type} exceeds 5 MiB; shorten --duration or reduce --width for email`);
|
|
@@ -113,5 +113,5 @@ export async function createPreview(pos, opts, adapters = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
function targetForCapture(opts) {
|
|
115
115
|
// Offline local-file capture must not require deployment configuration/login.
|
|
116
|
-
return opts.file ? 'https://
|
|
116
|
+
return opts.file ? 'https://hyperdecks.ai' : target(opts).brokerBase;
|
|
117
117
|
}
|
package/src/runtime.mjs
CHANGED
|
@@ -8,16 +8,38 @@ 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.7";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
|
+
// The app's home. HYPERDECKS_HOST points the prod target's broker and share
|
|
15
|
+
// links elsewhere (e.g. https://decks.hypersocial.ai, the old host, which keeps
|
|
16
|
+
// serving the broker and every share link).
|
|
17
|
+
export const PROD_HOST = "https://hyperdecks.ai";
|
|
18
|
+
|
|
14
19
|
export const PROD = {
|
|
15
20
|
name: "prod",
|
|
16
21
|
convexUrl: "https://dashing-snail-874.convex.cloud",
|
|
17
|
-
brokerBase:
|
|
18
|
-
shareBase:
|
|
22
|
+
brokerBase: PROD_HOST,
|
|
23
|
+
shareBase: PROD_HOST,
|
|
19
24
|
};
|
|
20
25
|
|
|
26
|
+
// Where the browser login page may post the credential from: the broker itself,
|
|
27
|
+
// and both app hosts.
|
|
28
|
+
const LOGIN_PAGE_ORIGINS = new Set(["https://hyperdecks.ai", "https://decks.hypersocial.ai"]);
|
|
29
|
+
|
|
30
|
+
let prodHostOverride = null;
|
|
31
|
+
|
|
32
|
+
// The hosted MCP (the Next server) runs this module too; it pins the prod host
|
|
33
|
+
// to wherever the app currently lives (lib/landingHost.ts appOrigin), so its
|
|
34
|
+
// links follow the deployment's switch rather than this CLI's default.
|
|
35
|
+
export function pinProdHost(origin) {
|
|
36
|
+
prodHostOverride = origin ? normalizeBase(origin) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function prodHost() {
|
|
40
|
+
return normalizeBase(process.env.HYPERDECKS_HOST?.trim() || prodHostOverride || PROD_HOST);
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
export const api = {
|
|
22
44
|
previews: { get: makeFunctionReference("previews:get") },
|
|
23
45
|
avatars: {
|
|
@@ -169,7 +191,7 @@ export const api = {
|
|
|
169
191
|
},
|
|
170
192
|
};
|
|
171
193
|
|
|
172
|
-
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
|
|
194
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
|
|
173
195
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
174
196
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
175
197
|
// opts.email stays the last --email for the other commands (share new).
|
|
@@ -216,9 +238,11 @@ export function target(opts = {}) {
|
|
|
216
238
|
const envWantsDev = process.env.HSDECKS_PROD === "0";
|
|
217
239
|
const wantDev = opts.dev || (envWantsDev && !opts.prod);
|
|
218
240
|
if (!wantDev) {
|
|
241
|
+
const host = prodHost();
|
|
219
242
|
return {
|
|
220
243
|
...PROD,
|
|
221
|
-
brokerBase: normalizeBase(opts.broker ||
|
|
244
|
+
brokerBase: normalizeBase(opts.broker || host),
|
|
245
|
+
shareBase: host,
|
|
222
246
|
};
|
|
223
247
|
}
|
|
224
248
|
|
|
@@ -461,7 +485,11 @@ export async function login(opts, { log = (line) => console.log(line), open = op
|
|
|
461
485
|
};
|
|
462
486
|
|
|
463
487
|
server = createServer((req, res) => {
|
|
464
|
-
|
|
488
|
+
// The authorize page may be on either app host (a login that started on
|
|
489
|
+
// one can finish on the other while the app moves to hyperdecks.ai).
|
|
490
|
+
const origin = req.headers.origin;
|
|
491
|
+
res.setHeader("Access-Control-Allow-Origin", LOGIN_PAGE_ORIGINS.has(origin) ? origin : t.brokerBase);
|
|
492
|
+
res.setHeader("Vary", "Origin");
|
|
465
493
|
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
466
494
|
res.setHeader("Access-Control-Allow-Headers", "content-type");
|
|
467
495
|
if (req.method === "OPTIONS") {
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
page's brand colour, with a quiet loader in --hd-ground-ink; then the
|
|
11
11
|
slides fade in. If the player never arrives, the slides show after 10 s
|
|
12
12
|
rather than never. Put data-layout="overlay" on <html> for the overlay. -->
|
|
13
|
-
<link rel="modulepreload" href="https://
|
|
14
|
-
<link rel="preload" as="style" href="https://
|
|
13
|
+
<link rel="modulepreload" href="https://hyperdecks.ai/player/v1/player.js">
|
|
14
|
+
<link rel="preload" as="style" href="https://hyperdecks.ai/player/v1/player.css">
|
|
15
15
|
<script>if (self !== top || /[?&]thumb\b/.test(location.search)) document.documentElement.dataset.hdFrame = "";</script>
|
|
16
16
|
<style data-hd-loading>
|
|
17
17
|
body:not([data-hd-ready]) { overflow: hidden; background: var(--hd-frame, var(--hd-ground, var(--hd-accent, #ffda1b))); animation: hd-fail-open 0s 10s forwards; }
|
|
@@ -56,12 +56,17 @@
|
|
|
56
56
|
/* The type scale: one display size per slide at most, then title, sub (a title beside a visual), lead, body, label. */
|
|
57
57
|
--t-display: 128px; --t-title: 84px; --t-sub: 56px; --t-lead: 30px; --t-body: 24px; --t-label: 17px;
|
|
58
58
|
--hd-ground: var(--accent); --hd-ground-ink: var(--ink); /* the loading state: the first slide's colours */
|
|
59
|
+
/* Soft grounds (on-tint, on-tint-2, on-tint-3): washes of the accents, so slide changes read without going loud. */
|
|
60
|
+
--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));
|
|
59
61
|
}
|
|
60
62
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
61
63
|
body { color: var(--ink); font-family: var(--font); font-weight: 500; -webkit-font-smoothing: antialiased; --hd-font: var(--font); --hd-mono: var(--mono); }
|
|
62
64
|
.slide { display: flex; flex-direction: column; gap: 24px; padding: 64px 72px 56px; background: var(--paper); color: var(--ink); --hd-speech: #f3c9dc; }
|
|
63
65
|
.slide.on-accent { background: var(--accent); --hd-speech: var(--card); }
|
|
64
66
|
.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); }
|
|
65
70
|
.kicker { font: var(--t-label) var(--mono); letter-spacing: .16em; text-transform: uppercase; }
|
|
66
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; }
|
|
67
72
|
.lead { font-size: var(--t-lead); line-height: 1.3; max-width: 28em; text-wrap: pretty; }
|
|
@@ -97,6 +102,34 @@
|
|
|
97
102
|
.slide.bg-dots::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 3px, transparent 3.6px) 0 0 / 36px 36px; -webkit-mask-image: linear-gradient(var(--dots-fade, 90deg), transparent 40%, #000 80%); mask-image: linear-gradient(var(--dots-fade, 90deg), transparent 40%, #000 80%); }
|
|
98
103
|
.slide > .deco { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
|
|
99
104
|
.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); }
|
|
105
|
+
/* The look kit (deck_guide topic look): one texture per deck, one icon set, real pictures.
|
|
106
|
+
Textures go on the section, behind the content, on a few slides: bg-lines, diagonal
|
|
107
|
+
speed lines fading out towards the text (--fade-at: where they are strongest, default
|
|
108
|
+
the right); bg-halftone, a corner of halftone dots (--fade-at, default top right);
|
|
109
|
+
bg-rays, rays from a point (--rays-at), for an opener or a closer. */
|
|
110
|
+
.slide:is(.bg-lines, .bg-halftone, .bg-rays) { isolation: isolate; overflow: hidden; }
|
|
111
|
+
.slide:is(.bg-lines, .bg-halftone, .bg-rays)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; }
|
|
112
|
+
.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%); }
|
|
113
|
+
.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%); }
|
|
114
|
+
.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%); }
|
|
115
|
+
/* Pieces. .sticker: a logo or icon tile, tilted (--tilt), ink edge, hard offset
|
|
116
|
+
shadow (.sticker.photo for a face or an app icon that fills it). .ticks: a short
|
|
117
|
+
list with accent-square bullets. .panel: a quiet card. .pill: a small tag. */
|
|
118
|
+
.sticker { --tilt: -3deg; display: inline-grid; place-items: center; flex: none; width: 96px; height: 96px; padding: 18px; border: 4px solid var(--ink); border-radius: 26px; background: var(--card); color: var(--ink); box-shadow: 6px 6px 0 var(--ink); transform: rotate(var(--tilt)); }
|
|
119
|
+
.sticker > :is(img, svg) { width: 100%; height: 100%; object-fit: contain; }
|
|
120
|
+
.sticker.photo { padding: 0; overflow: hidden; }
|
|
121
|
+
.sticker.photo > img { object-fit: cover; }
|
|
122
|
+
.ticks { list-style: none; display: flex; flex-direction: column; gap: 14px; }
|
|
123
|
+
.ticks li { position: relative; padding-left: 34px; font-size: var(--t-body); line-height: 1.3; font-weight: 600; }
|
|
124
|
+
.ticks li::before { content: ""; position: absolute; left: 0; top: .4em; width: 16px; height: 16px; border-radius: 5px; background: var(--accent); }
|
|
125
|
+
.on-accent .ticks li::before { background: var(--ink); }
|
|
126
|
+
.panel { padding: 24px 28px; border-radius: 22px; background: color-mix(in srgb, currentColor 6%, transparent); }
|
|
127
|
+
.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); }
|
|
128
|
+
.on-accent .pill { box-shadow: 4px 4px 0 var(--ink); }
|
|
129
|
+
/* A laptop for a desktop screenshot or clip: a .frame.laptop holding an img.shot or video.shot. */
|
|
130
|
+
.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); }
|
|
131
|
+
.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); }
|
|
132
|
+
.slide .frame.laptop > .shot { display: block; width: 100%; aspect-ratio: 16 / 10; border-radius: 6px; object-fit: cover; object-position: top; background: var(--paper); }
|
|
100
133
|
/* Phones: the slide is the 374×736 card; the presenter is a small head in
|
|
101
134
|
the corner, so keep the bottom 170px clear. */
|
|
102
135
|
@media (max-width: 650px) {
|
|
@@ -108,6 +141,17 @@
|
|
|
108
141
|
/* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
|
|
109
142
|
.slide.bg-dots::before { background-size: 22px 22px; background-image: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 2px, transparent 2.5px); -webkit-mask-image: linear-gradient(180deg, transparent 45%, #000 85%); mask-image: linear-gradient(180deg, transparent 45%, #000 85%); }
|
|
110
143
|
.slide > .deco { display: none; }
|
|
144
|
+
/* Textures fade in towards the bottom, under the presenter, away from the text. */
|
|
145
|
+
.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%); }
|
|
146
|
+
.slide.bg-halftone::before { background-size: 14px 14px; background-image: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 1.6px, transparent 2px); }
|
|
147
|
+
.sticker { width: 48px; height: 48px; padding: 9px; border-width: 2px; border-radius: 13px; box-shadow: 3px 3px 0 var(--ink); }
|
|
148
|
+
.ticks { gap: 6px; }
|
|
149
|
+
.ticks li { padding-left: 18px; }
|
|
150
|
+
.ticks li::before { width: 9px; height: 9px; border-radius: 3px; }
|
|
151
|
+
.panel { padding: 10px 12px; border-radius: 12px; }
|
|
152
|
+
.pill { padding: 4px 10px; box-shadow: 2px 2px 0 var(--accent); }
|
|
153
|
+
.slide .frame.laptop { padding: 5px 5px 9px; border-radius: 10px 10px 3px 3px; }
|
|
154
|
+
.slide .frame.laptop::after { bottom: -7px; height: 7px; border-radius: 0 0 8px 8px; }
|
|
111
155
|
}
|
|
112
156
|
</style>
|
|
113
157
|
<!-- LAYOUT STYLES -->
|
|
@@ -125,7 +169,7 @@
|
|
|
125
169
|
<!-- `hyperdecks narrate decks/__SLUG__` fills data-manifest with the manifest
|
|
126
170
|
URL it writes. data-presenter: head (live Minimoji, needs data-avatar),
|
|
127
171
|
static (data-presenter-image) or none (captions only). -->
|
|
128
|
-
<script type="module" src="https://
|
|
172
|
+
<script type="module" src="https://hyperdecks.ai/player/v1/player.js"
|
|
129
173
|
data-manifest="" data-stage="1600x900" data-presenter="none" data-presenter-style="__PRESENTER_STYLE__"></script>
|
|
130
174
|
</body>
|
|
131
175
|
</html>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
layout: split
|
|
3
|
+
summary: Image plus copy: the copy on the left (a sticker for the logo or icon, kicker, title, one line, three ticks), a real picture on the right in a phone card, on speed lines.
|
|
4
|
+
use: the workhorse for a product, a feature or an app: most slides of a good deck are this one. The phone's .shot is a placeholder check fails on: put in a phone screenshot (capture_url at 390×844) or a screen recording (upload_deck_asset) as <img class="shot"> or <video class="shot" autoplay muted loop playsinline>. For a desktop app, replace the card with <div class="frame laptop" id="__ID__-frame"><img class="shot" src="…" alt="…"></div>; for a 16:9 clip or photo, <img class="shot wide" src="…" alt="…" id="__ID__-frame">. Swap the sticker's icon for the product's logo (<span class="sticker photo"><img src="…" alt=""></span>) or one icon from the deck's set.
|
|
5
|
+
slot: bottom-left
|
|
6
|
+
presenter: stage
|
|
7
|
+
cues: [look: #__ID__-frame] when the narration turns to the picture; [go: #__ID__-t1], [go: #__ID__-t2], [go: #__ID__-t3] at the word that starts each tick, then [go: slot].
|
|
8
|
+
script: Here it is, doing the job. [look: #__ID__-frame] It starts with [go: #__ID__-t1] the first thing. Then [go: #__ID__-t2] the second. And [go: #__ID__-t3] the third. [go: slot]
|
|
9
|
+
-->
|
|
10
|
+
<style>
|
|
11
|
+
.l-split { display: grid; grid-template-columns: 680px 1fr; column-gap: 64px; align-items: center; }
|
|
12
|
+
.l-split .text { align-self: start; display: flex; flex-direction: column; gap: 22px; padding-top: 24px; }
|
|
13
|
+
.l-split .head { display: flex; align-items: center; gap: 22px; }
|
|
14
|
+
.l-split .tt { font-size: var(--t-sub); line-height: 1.02; }
|
|
15
|
+
.l-split .lead { font-size: var(--t-body); }
|
|
16
|
+
.l-split .ticks { margin-top: 6px; }
|
|
17
|
+
.l-split .media { display: grid; place-items: center; height: 100%; }
|
|
18
|
+
.l-split .frame.card { width: 300px; --tilt: 4deg; }
|
|
19
|
+
.l-split .shot.wide { display: block; width: 100%; aspect-ratio: 16 / 9; border-radius: 18px; object-fit: cover; box-shadow: 14px 14px 0 var(--accent); }
|
|
20
|
+
.on-accent.l-split .shot.wide { box-shadow: 14px 14px 0 var(--ink); }
|
|
21
|
+
@media (max-width: 650px) {
|
|
22
|
+
.l-split { display: flex; flex-direction: column; align-items: stretch; }
|
|
23
|
+
.l-split .text { gap: 10px; padding-top: 0; }
|
|
24
|
+
.l-split .head { gap: 12px; }
|
|
25
|
+
.l-split .media { height: auto; }
|
|
26
|
+
.l-split .frame.card { width: 128px; }
|
|
27
|
+
.l-split .shot.wide { border-radius: 10px; box-shadow: 6px 6px 0 var(--accent); }
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
30
|
+
<section class="slide l-split slot-left bg-lines" id="__ID__" data-chapter="The product" data-note="Picture: name the screen or clip and the date it was captured.">
|
|
31
|
+
<div class="text">
|
|
32
|
+
<div class="head">
|
|
33
|
+
<span class="sticker" id="__ID__-logo"><svg class="lucide-smartphone" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg></span>
|
|
34
|
+
<div class="kicker">The product · Live</div>
|
|
35
|
+
</div>
|
|
36
|
+
<h2 class="tt" data-title-mobile="Here it is.">Here it is, doing the job.</h2>
|
|
37
|
+
<p class="lead">One line on what the picture shows.</p>
|
|
38
|
+
<ul class="ticks">
|
|
39
|
+
<li id="__ID__-t1">The first thing it does</li>
|
|
40
|
+
<li id="__ID__-t2">The second thing</li>
|
|
41
|
+
<li id="__ID__-t3">The third thing</li>
|
|
42
|
+
</ul>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="media">
|
|
45
|
+
<div class="frame card" id="__ID__-frame"><div class="shot" role="img" aria-label="Phone screen placeholder"></div></div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="narration-slot"></div>
|
|
48
|
+
</section>
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
page's brand colour, with a quiet loader in --hd-ground-ink; then the
|
|
11
11
|
slides fade in. If the player never arrives, the slides show after 10 s
|
|
12
12
|
rather than never. Put data-layout="overlay" on <html> for the overlay. -->
|
|
13
|
-
<link rel="modulepreload" href="https://
|
|
14
|
-
<link rel="preload" as="style" href="https://
|
|
13
|
+
<link rel="modulepreload" href="https://hyperdecks.ai/player/v1/player.js">
|
|
14
|
+
<link rel="preload" as="style" href="https://hyperdecks.ai/player/v1/player.css">
|
|
15
15
|
<script>if (self !== top || /[?&]thumb\b/.test(location.search)) document.documentElement.dataset.hdFrame = "";</script>
|
|
16
16
|
<style data-hd-loading>
|
|
17
17
|
body:not([data-hd-ready]) { overflow: hidden; background: var(--hd-frame, var(--hd-ground, var(--hd-accent, #ffda1b))); animation: hd-fail-open 0s 10s forwards; }
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
data-avatar), static (data-presenter-image) or none (captions only).
|
|
159
159
|
data-presenter-style: stage (large head, lower-third captions), bubble,
|
|
160
160
|
glass (frosted panel) or corner (small head, subtitles). -->
|
|
161
|
-
<script type="module" src="https://
|
|
161
|
+
<script type="module" src="https://hyperdecks.ai/player/v1/player.js"
|
|
162
162
|
data-manifest="" data-stage="1600x900" data-presenter="none" data-presenter-style="__PRESENTER_STYLE__"></script>
|
|
163
163
|
<!-- Slide controllers for anything the built-in cues don't cover:
|
|
164
164
|
<script type="module">
|