@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.
@@ -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://decks.hypersocial.ai/player/v1/player.js\">",
15
- "<link rel=\"preload\" as=\"style\" href=\"https://decks.hypersocial.ai/player/v1/player.css\">",
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
- // The page with its loading state put right: inserted after <title> (or at the
54
- // top of <head>) when <head> has none, or its <style data-hd-loading> replaced
55
- // when it differs from the template's. Only a page with the player script, as
56
- // the check asks only those. Returns { html, fixed: "inserted" | "refreshed" | null,
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 style = STYLE.exec(head[0]);
64
- if (style) {
65
- const at = head.index + style.index;
66
- if (html.slice(at, at + style[0].length) === LOADING_STYLE) return { html, fixed: null };
67
- return { html: html.slice(0, at) + LOADING_STYLE + html.slice(at + style[0].length), fixed: "refreshed" };
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 title = /<\/title>/i.exec(head[0]);
70
- const at = head.index + (title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(head[0])[0].length);
71
- return { html: `${html.slice(0, at)}\n${LOADING_BLOCK}${html.slice(at)}`, fixed: "inserted" };
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, style: String(fields.style) };
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 renders in about its own length` };
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://decks.hypersocial.ai/player/v1/dash-avatar.json";
13
- export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://decks.hypersocial.ai/avatars/dash.png" });
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
 
@@ -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.resourceType(), entryUrl, origins) || !await allowCaptureDestination(r.url(), entryUrl)) {
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://decks.hypersocial.ai' : target(opts).brokerBase;
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.5";
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: "https://decks.hypersocial.ai",
18
- shareBase: "https://decks.hypersocial.ai",
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 || PROD.brokerBase),
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
- res.setHeader("Access-Control-Allow-Origin", t.brokerBase);
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://decks.hypersocial.ai/player/v1/player.js">
14
- <link rel="preload" as="style" href="https://decks.hypersocial.ai/player/v1/player.css">
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://decks.hypersocial.ai/player/v1/player.js"
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://decks.hypersocial.ai/player/v1/player.js">
14
- <link rel="preload" as="style" href="https://decks.hypersocial.ai/player/v1/player.css">
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://decks.hypersocial.ai/player/v1/player.js"
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">