@hypersocial/decks-cli 0.2.1 → 0.3.0
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 +9 -4
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +297 -13
- package/skills/hyperdecks/chat.md +49 -0
- package/src/check.mjs +110 -240
- package/src/checkRules.mjs +558 -0
- package/src/commands.mjs +200 -10
- package/src/files.mjs +35 -0
- package/src/guide.mjs +55 -0
- package/src/hosted.mjs +74 -0
- package/src/importer.mjs +1 -1
- package/src/init.mjs +605 -0
- package/src/layouts.mjs +71 -0
- package/src/narrate.mjs +4 -13
- package/src/ops.mjs +101 -7
- package/src/runtime.mjs +73 -14
- package/src/scaffold.mjs +23 -22
- package/src/source.mjs +69 -0
- package/src/voice.mjs +11 -0
- package/templates/first-deck/character.json +26 -0
- package/templates/first-deck/self.json +26 -0
- package/templates/layouts/_deck.html +102 -0
- package/templates/layouts/chart.html +39 -0
- package/templates/layouts/closer.html +28 -0
- package/templates/layouts/compare.html +33 -0
- package/templates/layouts/features.html +35 -0
- package/templates/layouts/hero.html +35 -0
- package/templates/layouts/number.html +29 -0
- package/templates/layouts/product.html +57 -0
- package/templates/layouts/quote.html +31 -0
- package/templates/layouts/team.html +37 -0
- package/templates/layouts/timeline.html +39 -0
- package/templates/narrated-deck.html +57 -11
- package/templates/script.md +11 -3
package/src/layouts.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The deck templates as text: the narrated outline and the layout library, filled
|
|
2
|
+
// for a slug and title. No file I/O beyond reading the packaged templates, so the
|
|
3
|
+
// hosted MCP route (new_deck) and the CLI (deck new) assemble the same deck.
|
|
4
|
+
import { packageText } from "./files.mjs";
|
|
5
|
+
|
|
6
|
+
export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
|
|
7
|
+
// The library, in a sensible deck order; `--layouts` takes any order and repeats.
|
|
8
|
+
export const LAYOUTS = ["hero", "number", "product", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
|
|
9
|
+
const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", comparison: "compare", "three-up": "features" };
|
|
10
|
+
|
|
11
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12
|
+
|
|
13
|
+
// Template text with __SLUG__ / __TITLE__ filled; HTML gets an escaped title.
|
|
14
|
+
// Without a presenter style the player tag carries none (the player's default).
|
|
15
|
+
export function fillTemplate(file, { slug, title, presenterStyle }) {
|
|
16
|
+
const text = packageText(`templates/${file}`);
|
|
17
|
+
const t = /\.html$/.test(file) ? escapeHtml(title) : title;
|
|
18
|
+
const styled = presenterStyle ? text.split("__PRESENTER_STYLE__").join(presenterStyle) : text.replace(/\s*data-presenter-style="__PRESENTER_STYLE__"/g, "");
|
|
19
|
+
return styled.split("__SLUG__").join(slug).split("__TITLE__").join(t);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// One layout file: a leading comment of `key: value` lines (layout, summary,
|
|
23
|
+
// use, slot, presenter, cues, script), its <style> and its <section>.
|
|
24
|
+
export function readLayout(name) {
|
|
25
|
+
const text = packageText(`templates/layouts/${name}.html`);
|
|
26
|
+
const head = /^<!--([\s\S]*?)-->/.exec(text);
|
|
27
|
+
const style = /<style>[\s\S]*?<\/style>/.exec(text);
|
|
28
|
+
const section = /<section\b[\s\S]*<\/section>/.exec(text);
|
|
29
|
+
if (!head || !style || !section) throw new Error(`layout ${name}: needs a leading comment, a <style> and a <section>`);
|
|
30
|
+
const meta = {};
|
|
31
|
+
for (const line of head[1].split("\n")) {
|
|
32
|
+
const m = /^([a-z]+):\s*(.*)$/.exec(line.trim());
|
|
33
|
+
if (m) meta[m[1]] = m[2];
|
|
34
|
+
}
|
|
35
|
+
return { name, meta, style: style[0], section: section[0] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseLayouts(list) {
|
|
39
|
+
const names = String(list).split(",").map((n) => n.trim().toLowerCase()).filter(Boolean).map((n) => LAYOUT_ALIASES[n] ?? n);
|
|
40
|
+
const unknown = names.filter((n) => !LAYOUTS.includes(n));
|
|
41
|
+
if (unknown.length) throw new Error(`unknown layout${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}: pick from ${LAYOUTS.join(", ")}`);
|
|
42
|
+
if (!names.length) throw new Error(`--layouts needs at least one of ${LAYOUTS.join(", ")}`);
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// index.html, script.md and evidence.md for a deck of `layouts`, one slide
|
|
47
|
+
// each, in order. A layout used twice gets ids hero, hero-2, …; ids inside a
|
|
48
|
+
// slide start with its id, so its cues stay unique.
|
|
49
|
+
export function assembleLayoutDeck({ slug, title, presenterStyle, layouts }) {
|
|
50
|
+
const names = parseLayouts(layouts);
|
|
51
|
+
const used = new Map();
|
|
52
|
+
const slides = names.map((name) => {
|
|
53
|
+
const n = (used.get(name) ?? 0) + 1;
|
|
54
|
+
used.set(name, n);
|
|
55
|
+
return { layout: readLayout(name), id: n === 1 ? name : `${name}-${n}` };
|
|
56
|
+
});
|
|
57
|
+
const fill = (text, id, t) => text.split("__ID__").join(id).split("__SLUG__").join(slug).split("__TITLE__").join(t);
|
|
58
|
+
const sections = slides.map(({ layout, id }, i) => {
|
|
59
|
+
let html = fill(layout.section, id, escapeHtml(title));
|
|
60
|
+
if (i === 0 && !/\sdata-intro=/.test(html.slice(0, html.indexOf(">")))) html = html.replace("<section ", '<section data-intro="Hi! Press play." ');
|
|
61
|
+
return `<!-- layout: ${layout.name}. ${layout.meta.summary ?? ""} -->\n${html}`;
|
|
62
|
+
});
|
|
63
|
+
const styles = [...new Set(names)].map((name) => `<!-- layout: ${name} -->\n${readLayout(name).style}`);
|
|
64
|
+
const shell = fillTemplate("layouts/_deck.html", { slug, title, presenterStyle });
|
|
65
|
+
// Function replacements: a "$" in the title or a layout is text, not a replacement pattern.
|
|
66
|
+
const html = shell.replace("<!-- LAYOUT STYLES -->", () => styles.join("\n")).replace("<!-- SLIDES -->", () => sections.join("\n\n"));
|
|
67
|
+
const intro = fillTemplate("script.md", { slug, title }).split(/\n## /)[0].trimEnd();
|
|
68
|
+
const script = `${intro}\n\n${slides.map(({ layout, id }) => `## ${id}\n${fill(layout.meta.script ?? "", id, title)}`).join("\n\n")}\n`;
|
|
69
|
+
return { "index.html": html, "script.md": script, "evidence.md": fillTemplate("evidence.md", { slug, title }) };
|
|
70
|
+
}
|
|
71
|
+
|
package/src/narrate.mjs
CHANGED
|
@@ -8,18 +8,8 @@
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { basename, join, resolve } from "node:path";
|
|
10
10
|
import * as ops from "./ops.mjs";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export const sectionIds = (html) => [...html.matchAll(/<section\b[^>]*?\sid\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
|
|
14
|
-
|
|
15
|
-
// Set data-manifest on the hosted player's <script> tag; html is unchanged when
|
|
16
|
-
// there is no player tag or it already names this URL.
|
|
17
|
-
export function setManifestUrl(html, url) {
|
|
18
|
-
return html.replace(/<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/, (tag) =>
|
|
19
|
-
/\sdata-manifest\s*=\s*"[^"]*"/.test(tag)
|
|
20
|
-
? tag.replace(/(\sdata-manifest\s*=\s*)"[^"]*"/, (_, lead) => `${lead}"${url}"`)
|
|
21
|
-
: tag.replace(/\s*>$/, () => ` data-manifest="${url}">`));
|
|
22
|
-
}
|
|
11
|
+
export { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
12
|
+
import { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
23
13
|
|
|
24
14
|
function slideList(value) {
|
|
25
15
|
const ids = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
@@ -52,7 +42,8 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
|
|
|
52
42
|
const { deckDir, fields } = narrationRequest(pos, opts);
|
|
53
43
|
if (opts["dry-run"]) {
|
|
54
44
|
const preview = await ops.previewNarration(opts, fields);
|
|
55
|
-
|
|
45
|
+
if (preview.presenterStyle) log(`presenter style: ${preview.presenterStyle}`);
|
|
46
|
+
for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars${s.presenterStyle ? ` presenter-style: ${s.presenterStyle}` : ""}`);
|
|
56
47
|
const render = preview.slides.filter((s) => s.render);
|
|
57
48
|
log(`${render.length} of ${preview.slides.length} slide(s) would render in voice "${preview.voice}", ~${render.reduce((n, s) => n + s.chars, 0)} characters of TTS`);
|
|
58
49
|
return preview;
|
package/src/ops.mjs
CHANGED
|
@@ -36,7 +36,8 @@ export async function activityOverview(opts = {}) {
|
|
|
36
36
|
return (await client(opts)).query(api.activity.overview, {});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// Generate an image via fal (GPT Image 2
|
|
39
|
+
// Generate an image via fal (GPT Image 2.5 by default, or Nano Banana 2.1 / GPT
|
|
40
|
+
// Image 2 with `model`; t2i/i2i, optional BiRefNet transparency),
|
|
40
41
|
// store it in Convex, and return the hosted URL. Runs the imagegen action as the
|
|
41
42
|
// authenticated user (injected client supported via opts.client).
|
|
42
43
|
export async function generateImage(opts, fields = {}) {
|
|
@@ -51,6 +52,8 @@ export async function generateImage(opts, fields = {}) {
|
|
|
51
52
|
if (fields.quality) args.quality = String(fields.quality);
|
|
52
53
|
if (fields.count != null && fields.count !== "") args.count = Number(fields.count);
|
|
53
54
|
if (fields.format) args.format = String(fields.format);
|
|
55
|
+
if (fields.model) args.model = String(fields.model);
|
|
56
|
+
if (fields.seed != null && fields.seed !== "") args.seed = Number(fields.seed);
|
|
54
57
|
// Returns { images } for fast requests, or { jobId, status, count } for slow ones
|
|
55
58
|
// (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
|
|
56
59
|
// never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
|
|
@@ -99,8 +102,9 @@ export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs =
|
|
|
99
102
|
|
|
100
103
|
// ---- video (convex/videogen.ts) ------------------------------------------
|
|
101
104
|
|
|
102
|
-
// Generate a short clip via fal (
|
|
103
|
-
//
|
|
105
|
+
// Generate a short clip via fal (MiniMax H3 Max by default, Kling v3 with
|
|
106
|
+
// model "kling-v3": text→video, or image→video when an image URL is given as the
|
|
107
|
+
// first frame). Always a background job: returns
|
|
104
108
|
// { jobId, status, seconds, aspect?, creditsRemaining? } without waiting, so the MCP
|
|
105
109
|
// tool never blocks; the CLI polls with pollVideoJob.
|
|
106
110
|
export async function generateVideo(opts, fields = {}) {
|
|
@@ -110,11 +114,100 @@ export async function generateVideo(opts, fields = {}) {
|
|
|
110
114
|
if (img) args.imageUrl = String(img);
|
|
111
115
|
if (fields.seconds != null && fields.seconds !== "") args.seconds = Number(fields.seconds);
|
|
112
116
|
if (fields.aspect) args.aspect = String(fields.aspect);
|
|
117
|
+
if (fields.model) args.model = String(fields.model);
|
|
113
118
|
if (fields.slug) args.slug = String(fields.slug);
|
|
114
119
|
if (fields.name) args.name = String(fields.name);
|
|
115
120
|
return (await client(opts)).action(api.videogen.generateVideo, args);
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
// ---- deck assets: screenshots, charts, icon sets --------------------------
|
|
124
|
+
|
|
125
|
+
// Screenshot a public URL (convex/screenshots.ts) and store it as a PNG asset.
|
|
126
|
+
// Returns { url, storageId, contentType, name?, width, height, creditsRemaining? }.
|
|
127
|
+
export async function captureUrl(opts, fields = {}) {
|
|
128
|
+
if (!fields.url) throw new Error("url is required");
|
|
129
|
+
const args = { url: String(fields.url) };
|
|
130
|
+
if (fields.width != null && fields.width !== "") args.width = Number(fields.width);
|
|
131
|
+
if (fields.height != null && fields.height !== "") args.height = Number(fields.height);
|
|
132
|
+
if (fields.fullPage) args.fullPage = true;
|
|
133
|
+
if (fields.slug) args.slug = String(fields.slug);
|
|
134
|
+
if (fields.name) args.name = String(fields.name);
|
|
135
|
+
return (await client(opts)).action(api.screenshots.captureUrl, args);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const CHART_FIELDS = ["kind", "title", "subtitle", "labels", "series", "values", "value", "label", "delta", "prefix", "suffix", "palette", "width", "height", "slug", "name"];
|
|
139
|
+
|
|
140
|
+
// Render a chart spec (convex/charts.ts) to a branded SVG asset. Unknown keys are
|
|
141
|
+
// dropped so a spec file may carry notes of its own.
|
|
142
|
+
export async function renderChart(opts, spec = {}) {
|
|
143
|
+
if (!spec.kind) throw new Error("kind is required (bar, line, donut or number)");
|
|
144
|
+
const args = {};
|
|
145
|
+
for (const k of CHART_FIELDS) if (spec[k] !== undefined && spec[k] !== null) args[k] = spec[k];
|
|
146
|
+
return (await client(opts)).action(api.charts.renderChart, args);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Queue a style-consistent transparent icon set (convex/imagegen.ts
|
|
150
|
+
// generateIconSet). Returns { jobId, status, count, subjects }; poll it like any
|
|
151
|
+
// image job (checkImageJob / pollImageJob).
|
|
152
|
+
export async function generateIconSet(opts, fields = {}) {
|
|
153
|
+
const subjects = (fields.subjects || []).map(String).filter(Boolean);
|
|
154
|
+
if (!subjects.length) throw new Error("subjects is required (one or more things to draw)");
|
|
155
|
+
if (!fields.style) throw new Error("style is required");
|
|
156
|
+
const args = { subjects, style: String(fields.style) };
|
|
157
|
+
if (fields.palette?.length) args.palette = fields.palette.map(String);
|
|
158
|
+
if (fields.slug) args.slug = String(fields.slug);
|
|
159
|
+
if (fields.name) args.name = String(fields.name);
|
|
160
|
+
if (fields.model) args.model = String(fields.model);
|
|
161
|
+
return (await client(opts)).action(api.imagegen.generateIconSet, args);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- share-link poster (convex/posters.ts) -------------------------------
|
|
165
|
+
|
|
166
|
+
// Regenerate the deck's share-link poster (release and import already make
|
|
167
|
+
// one). Returns { slug, url, unchanged }: unchanged when the poster already
|
|
168
|
+
// shows the deck's current HTML, unless `force`.
|
|
169
|
+
export async function generatePoster(opts, fields = {}) {
|
|
170
|
+
if (!fields.slug) throw new Error("slug is required");
|
|
171
|
+
const args = { slug: String(fields.slug) };
|
|
172
|
+
if (fields.force) args.force = true;
|
|
173
|
+
return (await client(opts)).action(api.posters.generatePoster, args);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- deck review (convex/review.ts) --------------------------------------
|
|
177
|
+
|
|
178
|
+
// Queue a GPT-6 Luna review of every slide (desktop + phone screenshots of the
|
|
179
|
+
// deck's QA share). Returns { jobId, status, slides, shareToken, creditsRequired }.
|
|
180
|
+
export async function reviewDeck(opts, fields = {}) {
|
|
181
|
+
if (!fields.slug) throw new Error("slug is required");
|
|
182
|
+
const args = { slug: String(fields.slug) };
|
|
183
|
+
if (fields.script) args.script = String(fields.script);
|
|
184
|
+
if (fields.draft) args.draft = true;
|
|
185
|
+
return (await client(opts)).action(api.review.reviewDeck, args);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function checkReviewJob(opts, jobId) {
|
|
189
|
+
if (!jobId) throw new Error("jobId is required");
|
|
190
|
+
return (await client(opts)).query(api.review.checkReviewJob, { jobId: String(jobId) });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Poll a review job until it finishes; returns the job (findings and all).
|
|
194
|
+
// Throws on job error or timeout.
|
|
195
|
+
export async function pollReviewJob(opts, jobId, { intervalMs = 4000, timeoutMs = 1_800_000, onProgress } = {}) {
|
|
196
|
+
const c = await client(opts);
|
|
197
|
+
const start = Date.now();
|
|
198
|
+
for (;;) {
|
|
199
|
+
const job = await c.query(api.review.checkReviewJob, { jobId: String(jobId) });
|
|
200
|
+
if (job.status === "done") return job;
|
|
201
|
+
if (job.status === "error") {
|
|
202
|
+
const done = job.findings?.length ? ` (${job.progress} slides reviewed, ${job.findings.length} finding(s) so far: review-job ${jobId})` : "";
|
|
203
|
+
throw new Error(`${job.error || "review failed"}${done}`);
|
|
204
|
+
}
|
|
205
|
+
if (Date.now() - start > timeoutMs) throw new Error(`review ${jobId} is still running; check it later with: review-job ${jobId}`);
|
|
206
|
+
if (onProgress) onProgress(job);
|
|
207
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
118
211
|
// Read one video job's status (and the hosted MP4 once done). Shared by the MCP
|
|
119
212
|
// check_video_job tool and the CLI poller.
|
|
120
213
|
export async function checkVideoJob(opts, jobId) {
|
|
@@ -153,8 +246,9 @@ export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs =
|
|
|
153
246
|
// thin client calls shared by the CLI `narrate` command and the MCP tools.
|
|
154
247
|
function narrationArgs(fields = {}) {
|
|
155
248
|
if (!fields.slug) throw new Error("slug is required");
|
|
156
|
-
|
|
157
|
-
|
|
249
|
+
// No script: narrate the deck's hosted script.md (deckSources), and the
|
|
250
|
+
// finished job writes the manifest URL into the hosted draft.
|
|
251
|
+
const args = fields.script ? { slug: String(fields.slug), script: String(fields.script) } : { slug: String(fields.slug), fromSource: true };
|
|
158
252
|
if (fields.voice) args.voice = String(fields.voice);
|
|
159
253
|
if (fields.slides) {
|
|
160
254
|
if (!fields.slides.length) throw new Error("slides is empty; omit it to narrate every slide");
|
|
@@ -613,7 +707,7 @@ function designServeUrl(projectId, token, path) {
|
|
|
613
707
|
}
|
|
614
708
|
|
|
615
709
|
// Pull every local asset path a deck file references (HTML or CSS): the
|
|
616
|
-
// ext-resource-dependency manifest, src/href/poster, srcset, <script>/<link>,
|
|
710
|
+
// ext-resource-dependency manifest, src/href/poster/data-presenter-image, srcset, <script>/<link>,
|
|
617
711
|
// and CSS url(). Mirrors the importer's rewrite surface so we fetch the same set
|
|
618
712
|
// it will later rewrite. External/data/blob refs are skipped.
|
|
619
713
|
function extractDesignRefs(text) {
|
|
@@ -625,7 +719,7 @@ function extractDesignRefs(text) {
|
|
|
625
719
|
if (clean) out.add(clean);
|
|
626
720
|
};
|
|
627
721
|
for (const m of text.matchAll(/ext-resource-dependency"\s+content="([^"]+)"/gi)) add(m[1]);
|
|
628
|
-
for (const m of text.matchAll(/\b(?:src|href|poster)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
|
|
722
|
+
for (const m of text.matchAll(/\b(?:src|href|poster|data-presenter-image)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
|
|
629
723
|
for (const m of text.matchAll(/\bsrcset\s*=\s*["']([^"']+)["']/gi)) {
|
|
630
724
|
for (const part of m[1].split(",")) add(part.trim().split(/\s+/)[0]);
|
|
631
725
|
}
|
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.
|
|
11
|
+
export const VERSION = "0.3.0";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
export const PROD = {
|
|
@@ -24,6 +24,16 @@ export const api = {
|
|
|
24
24
|
generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
|
|
25
25
|
saveAsset: makeFunctionReference("assets:saveAsset"),
|
|
26
26
|
},
|
|
27
|
+
assetsNode: {
|
|
28
|
+
importAssetFromUrl: makeFunctionReference("assetsNode:importAssetFromUrl"),
|
|
29
|
+
},
|
|
30
|
+
deckSources: {
|
|
31
|
+
create: makeFunctionReference("deckSources:create"),
|
|
32
|
+
get: makeFunctionReference("deckSources:get"),
|
|
33
|
+
write: makeFunctionReference("deckSources:write"),
|
|
34
|
+
check: makeFunctionReference("deckSources:check"),
|
|
35
|
+
release: makeFunctionReference("deckSources:release"),
|
|
36
|
+
},
|
|
27
37
|
activity: {
|
|
28
38
|
overview: makeFunctionReference("activity:overview"),
|
|
29
39
|
setArchived: makeFunctionReference("activity:setArchived"),
|
|
@@ -50,6 +60,20 @@ export const api = {
|
|
|
50
60
|
imagegen: {
|
|
51
61
|
generateImage: makeFunctionReference("imagegen:generateImage"),
|
|
52
62
|
checkImageJob: makeFunctionReference("imagegen:checkImageJob"),
|
|
63
|
+
generateIconSet: makeFunctionReference("imagegen:generateIconSet"),
|
|
64
|
+
},
|
|
65
|
+
screenshots: {
|
|
66
|
+
captureUrl: makeFunctionReference("screenshots:captureUrl"),
|
|
67
|
+
},
|
|
68
|
+
charts: {
|
|
69
|
+
renderChart: makeFunctionReference("charts:renderChart"),
|
|
70
|
+
},
|
|
71
|
+
posters: {
|
|
72
|
+
generatePoster: makeFunctionReference("posters:generatePoster"),
|
|
73
|
+
},
|
|
74
|
+
review: {
|
|
75
|
+
reviewDeck: makeFunctionReference("review:reviewDeck"),
|
|
76
|
+
checkReviewJob: makeFunctionReference("review:checkReviewJob"),
|
|
53
77
|
},
|
|
54
78
|
videogen: {
|
|
55
79
|
generateVideo: makeFunctionReference("videogen:generateVideo"),
|
|
@@ -75,7 +99,7 @@ export const api = {
|
|
|
75
99
|
},
|
|
76
100
|
};
|
|
77
101
|
|
|
78
|
-
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated"]);
|
|
102
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open"]);
|
|
79
103
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
80
104
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
81
105
|
// opts.email stays the last --email for the other commands (share new).
|
|
@@ -260,7 +284,42 @@ function splitAgents(value) {
|
|
|
260
284
|
return String(value || "").split(/[,\s]+/).filter(Boolean);
|
|
261
285
|
}
|
|
262
286
|
|
|
263
|
-
export
|
|
287
|
+
export const DEFAULT_AGENTS = ["codex", "claude-code"];
|
|
288
|
+
|
|
289
|
+
// Agents `npx skills` installs into, keyed by its agent id: the directory whose
|
|
290
|
+
// presence means the agent is installed (the same test `npx skills` uses) and
|
|
291
|
+
// where its global skills live. From skills@1.7 (dist/cli.mjs, agent table).
|
|
292
|
+
export function agentTable({ home = homedir(), env = process.env } = {}) {
|
|
293
|
+
const config = env.XDG_CONFIG_HOME || join(home, ".config");
|
|
294
|
+
const claude = env.CLAUDE_CONFIG_DIR || join(home, ".claude");
|
|
295
|
+
return [
|
|
296
|
+
{ id: "claude-code", label: "Claude Code", home: claude, skills: join(claude, "skills") },
|
|
297
|
+
{ id: "codex", label: "Codex", home: env.CODEX_HOME || join(home, ".codex"), skills: join(home, ".agents", "skills") },
|
|
298
|
+
{ id: "cursor", label: "Cursor", home: join(home, ".cursor"), skills: join(home, ".cursor", "skills") },
|
|
299
|
+
{ id: "gemini-cli", label: "Gemini CLI", home: join(home, ".gemini"), skills: join(home, ".gemini", "skills") },
|
|
300
|
+
{ id: "opencode", label: "OpenCode", home: join(config, "opencode"), skills: join(config, "opencode", "skills") },
|
|
301
|
+
{ id: "windsurf", label: "Windsurf", home: join(home, ".codeium", "windsurf"), skills: join(home, ".codeium", "windsurf", "skills") },
|
|
302
|
+
{ id: "github-copilot", label: "GitHub Copilot", home: join(home, ".copilot"), skills: join(home, ".copilot", "skills") },
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The agents whose home directory exists on this machine.
|
|
307
|
+
export function detectAgents({ home, env, exists = existsSync } = {}) {
|
|
308
|
+
return agentTable({ home, env }).filter((a) => exists(a.home)).map((a) => a.id);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Which agents an install targets: --all-agents, --agent, or the detected ones
|
|
312
|
+
// (today's codex,claude-code when none is found).
|
|
313
|
+
export function chooseAgents(opts = {}, extraArgs = [], detect = {}) {
|
|
314
|
+
if (opts["all-agents"]) return { agents: ["*"], source: "all" };
|
|
315
|
+
if (opts.agent) return { agents: [...splitAgents(opts.agent), ...extraArgs.flatMap(splitAgents)], source: "flag" };
|
|
316
|
+
const found = detectAgents(detect);
|
|
317
|
+
return found.length ? { agents: found, source: "detected" } : { agents: DEFAULT_AGENTS, source: "default" };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// `quiet` pipes `npx skills` output instead of inheriting the terminal (init
|
|
321
|
+
// --json keeps stdout for its one JSON object); a failure then carries its tail.
|
|
322
|
+
export function installHyperdecksSkill(opts = {}, extraArgs = [], { spawn = spawnSync, quiet = false, log = (line) => console.log(line), home, env } = {}) {
|
|
264
323
|
const skillDir = join(PACKAGED_SKILLS_DIR, "hyperdecks");
|
|
265
324
|
if (!existsSync(skillDir)) {
|
|
266
325
|
throw new Error(`packaged hyperdecks skill not found at ${skillDir}`);
|
|
@@ -278,19 +337,19 @@ export function installHyperdecksSkill(opts = {}, extraArgs = []) {
|
|
|
278
337
|
throw new Error(`unexpected argument(s): ${extraArgs.join(" ")}; pass agent ids with --agent`);
|
|
279
338
|
}
|
|
280
339
|
|
|
281
|
-
const agents = opts
|
|
282
|
-
|
|
283
|
-
: [...splitAgents(opts.agent || "codex,claude-code"), ...extraArgs.flatMap(splitAgents)];
|
|
340
|
+
const { agents, source } = chooseAgents(opts, extraArgs, { home, env });
|
|
341
|
+
if (source === "default" && !quiet) log(`no agent found in your home directory; installing for ${agents.join(", ")}`);
|
|
284
342
|
const args = ["-y", "skills@latest", "add", PACKAGED_SKILLS_DIR, "-g"];
|
|
285
343
|
args.push("-a", ...agents, "-s", "hyperdecks", "--copy", "-y", "--full-depth");
|
|
286
344
|
|
|
287
345
|
const command = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
288
|
-
const result =
|
|
346
|
+
const result = spawn(command, args, quiet ? { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" } : { stdio: "inherit" });
|
|
289
347
|
if (result.error) throw result.error;
|
|
290
348
|
if (result.status !== 0) {
|
|
291
|
-
|
|
349
|
+
const tail = quiet ? `${result.stdout || ""}\n${result.stderr || ""}`.trim().split("\n").filter((l) => l.trim() && !/^npm warn/i.test(l)).slice(-3).join(" ") : "";
|
|
350
|
+
throw new Error(`skills add failed with exit code ${result.status}${tail ? `: ${tail}` : ""}`);
|
|
292
351
|
}
|
|
293
|
-
return { installed: true };
|
|
352
|
+
return { installed: true, agents, source };
|
|
294
353
|
}
|
|
295
354
|
|
|
296
355
|
async function verifyAndStampEmail(t, opts) {
|
|
@@ -302,14 +361,14 @@ async function verifyAndStampEmail(t, opts) {
|
|
|
302
361
|
return who;
|
|
303
362
|
}
|
|
304
363
|
|
|
305
|
-
export async function login(opts) {
|
|
364
|
+
export async function login(opts, { log = (line) => console.log(line), open = openBrowser } = {}) {
|
|
306
365
|
const t = target(opts);
|
|
307
366
|
|
|
308
367
|
if (opts.token) {
|
|
309
368
|
saveCred(t.name, { refreshToken: opts.token, convexUrl: t.convexUrl, brokerBase: t.brokerBase });
|
|
310
369
|
try {
|
|
311
370
|
const who = await verifyAndStampEmail(t, opts);
|
|
312
|
-
|
|
371
|
+
log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
|
|
313
372
|
} catch (e) {
|
|
314
373
|
clearCred(t.name);
|
|
315
374
|
throw e;
|
|
@@ -367,14 +426,14 @@ export async function login(opts) {
|
|
|
367
426
|
server.listen(0, "127.0.0.1", () => {
|
|
368
427
|
const { port } = server.address();
|
|
369
428
|
const url = `${t.brokerBase}/cli/authorize?port=${port}&state=${state}`;
|
|
370
|
-
|
|
371
|
-
|
|
429
|
+
log(`Opening your browser to sign in (${t.name})...\n ${url}\nIf it does not open, paste that URL into your browser.`);
|
|
430
|
+
open(url);
|
|
372
431
|
});
|
|
373
432
|
timer = setTimeout(() => finish(new Error("login timed out after 3 min")), 180000);
|
|
374
433
|
});
|
|
375
434
|
|
|
376
435
|
const who = await verifyAndStampEmail(t, opts);
|
|
377
|
-
|
|
436
|
+
log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
|
|
378
437
|
}
|
|
379
438
|
|
|
380
439
|
export async function logout(opts) {
|
package/src/scaffold.mjs
CHANGED
|
@@ -1,39 +1,40 @@
|
|
|
1
|
-
// hyperdecks deck new <slug> "<title>" --narrated [--dir <path>]
|
|
1
|
+
// hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,…] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
|
|
2
2
|
//
|
|
3
3
|
// Writes decks/<slug>/ (or --dir) from the packaged templates: index.html (eight
|
|
4
|
-
// slides for the hosted player/v1
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
// slides for the hosted player/v1, or one slide per --layouts entry from
|
|
5
|
+
// templates/layouts/), script.md (the narration) and evidence.md (where each
|
|
6
|
+
// fact comes from), then registers the deck as a draft you own so narrate can
|
|
7
|
+
// render into it.
|
|
8
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
10
|
import * as ops from "./ops.mjs";
|
|
11
|
+
import { PRESENTER_STYLES } from "./checkRules.mjs";
|
|
12
|
+
import { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate } from "./layouts.mjs";
|
|
11
13
|
|
|
12
|
-
export
|
|
13
|
-
export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
|
|
14
|
-
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
15
|
-
|
|
16
|
-
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
14
|
+
export { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts, readLayout } from "./layouts.mjs";
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const text = readFileSync(join(TEMPLATE_DIR, file), "utf8");
|
|
21
|
-
const t = /\.html$/.test(file) ? escapeHtml(title) : title;
|
|
22
|
-
return text.split("__SLUG__").join(slug).split("__TITLE__").join(t);
|
|
23
|
-
}
|
|
16
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
17
|
+
const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--dir <path>]';
|
|
24
18
|
|
|
25
19
|
export async function deckNew(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
26
20
|
const [slug, title] = pos;
|
|
27
|
-
if (!slug || !title) throw new Error(
|
|
21
|
+
if (!slug || !title) throw new Error(USAGE);
|
|
28
22
|
if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
|
|
23
|
+
const presenterStyle = opts["presenter-style"];
|
|
24
|
+
if (presenterStyle !== undefined && !PRESENTER_STYLES.includes(presenterStyle)) {
|
|
25
|
+
throw new Error(`--presenter-style must be one of ${PRESENTER_STYLES.join(", ")}`);
|
|
26
|
+
}
|
|
29
27
|
if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
|
|
30
|
-
const dir = resolve(cwd, typeof opts.dir === "string" ? opts.dir : join("decks", slug));
|
|
28
|
+
const dir = resolve(/*turbopackIgnore: true*/ cwd, typeof opts.dir === "string" ? opts.dir : join("decks", slug));
|
|
31
29
|
if (existsSync(dir)) throw new Error(`${dir} already exists; pick another slug or --dir`);
|
|
32
|
-
|
|
30
|
+
if (opts.layouts === true) throw new Error(`--layouts needs a list, e.g. --layouts ${LAYOUTS.slice(0, 3).join(",")},closer`);
|
|
31
|
+
const files = opts.layouts !== undefined
|
|
32
|
+
? Object.entries(assembleLayoutDeck({ slug, title, presenterStyle, layouts: opts.layouts }))
|
|
33
|
+
: Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle })]);
|
|
33
34
|
await ops.createDeck(opts, { slug, title });
|
|
34
35
|
try {
|
|
35
36
|
mkdirSync(dir, { recursive: true });
|
|
36
|
-
for (const [out, text] of files) writeFileSync(join(dir, out), text);
|
|
37
|
+
for (const [out, text] of files) writeFileSync(join(/*turbopackIgnore: true*/ dir, out), text);
|
|
37
38
|
} catch (e) {
|
|
38
39
|
rmSync(dir, { recursive: true, force: true });
|
|
39
40
|
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`);
|
package/src/source.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The hosted deck source (convex/deckSources.ts): a deck's files kept on
|
|
2
|
+
// Hyperdecks as a draft, for chat agents on the remote MCP and for
|
|
3
|
+
// hyperdecks deck new <slug> "<title>" --narrated --hosted [--layouts …]
|
|
4
|
+
// hyperdecks source pull <slug> [dir] the draft into a folder
|
|
5
|
+
// hyperdecks source push <slug> [dir] [--base-rev N] a folder's files to the draft
|
|
6
|
+
// hyperdecks check <slug> --hosted
|
|
7
|
+
// hyperdecks release <slug> --hosted [--to "<name>" [--email e]]... [--qa]
|
|
8
|
+
// hyperdecks asset add <slug> <file|url> [--name n]
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
11
|
+
import { api, client, uploadBytes } from "./runtime.mjs";
|
|
12
|
+
import { SOURCE_FILES, getDeckSource, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
|
|
13
|
+
|
|
14
|
+
export * from "./hosted.mjs";
|
|
15
|
+
|
|
16
|
+
// ---- CLI ------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export async function sourcePull(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
19
|
+
const [slug, dirArg] = pos;
|
|
20
|
+
if (!slug) throw new Error("usage: source pull <slug> [dir]");
|
|
21
|
+
const dir = resolve(/*turbopackIgnore: true*/ cwd, dirArg ?? join("decks", slug));
|
|
22
|
+
const got = await getDeckSource(opts, { slug });
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
const written = [];
|
|
25
|
+
for (const [file, text] of Object.entries(got.files)) {
|
|
26
|
+
if (typeof text !== "string") continue;
|
|
27
|
+
writeFileSync(join(/*turbopackIgnore: true*/ dir, file), text);
|
|
28
|
+
written.push(file);
|
|
29
|
+
}
|
|
30
|
+
log(`✓ pulled ${slug} rev ${got.rev} → ${dir} (${written.join(", ")})\n push it back with: hyperdecks source push ${slug} ${dirArg ?? dir} --base-rev ${got.rev}`);
|
|
31
|
+
return { slug, rev: got.rev, dir, files: written };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function sourcePush(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
35
|
+
const [slug, dirArg] = pos;
|
|
36
|
+
if (!slug) throw new Error("usage: source push <slug> [dir] [--base-rev N]");
|
|
37
|
+
const dir = resolve(/*turbopackIgnore: true*/ cwd, dirArg ?? join("decks", slug));
|
|
38
|
+
const files = SOURCE_FILES.filter((f) => existsSync(join(/*turbopackIgnore: true*/ dir, f)));
|
|
39
|
+
if (!files.includes("index.html")) throw new Error(`no index.html in ${dir}`);
|
|
40
|
+
let baseRev = opts["base-rev"] !== undefined ? Number(opts["base-rev"]) : undefined;
|
|
41
|
+
let rev;
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
({ rev } = await writeDeckSource(opts, { slug, file, content: readFileSync(join(/*turbopackIgnore: true*/ dir, file), "utf8"), baseRev }));
|
|
44
|
+
baseRev = baseRev === undefined ? undefined : rev; // each write moves the rev
|
|
45
|
+
}
|
|
46
|
+
log(`✓ pushed ${files.join(", ")} to ${slug}: rev ${rev}`);
|
|
47
|
+
return { slug, rev, files };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".woff2": "font/woff2", ".woff": "font/woff" };
|
|
51
|
+
|
|
52
|
+
export async function assetAdd(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
53
|
+
const [slug, from] = pos;
|
|
54
|
+
if (!slug || !from) throw new Error("usage: asset add <slug> <file|url> [--name <name>]");
|
|
55
|
+
let out;
|
|
56
|
+
if (/^https?:\/\//i.test(from)) out = await uploadDeckAsset(opts, { slug, name: opts.name, url: from });
|
|
57
|
+
else {
|
|
58
|
+
const path = resolve(/*turbopackIgnore: true*/ cwd, from);
|
|
59
|
+
const contentType = TYPES[extname(path).toLowerCase()];
|
|
60
|
+
if (!contentType) throw new Error(`${from}: asset add takes images, video and fonts (${Object.keys(TYPES).join(" ")})`);
|
|
61
|
+
const c = await client(opts);
|
|
62
|
+
const bytes = readFileSync(path);
|
|
63
|
+
const storageId = await uploadBytes(c, bytes, contentType);
|
|
64
|
+
const saved = await c.mutation(api.assets.saveAsset, { name: String(opts.name || basename(path)), storageId, contentType, deckSlug: slug });
|
|
65
|
+
out = { url: saved.url, name: saved.name, contentType, bytes: bytes.byteLength };
|
|
66
|
+
}
|
|
67
|
+
log(out.url);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
package/src/voice.mjs
CHANGED
|
@@ -11,6 +11,17 @@ import * as ops from "./ops.mjs";
|
|
|
11
11
|
// Same text as convex/lib/voice/consent.ts (the published CLI cannot import it).
|
|
12
12
|
export const CONSENT_STATEMENT = "I confirm this is my own voice and I consent to it being cloned for narrating my decks";
|
|
13
13
|
export const CONSENT_PHRASE = "I consent";
|
|
14
|
+
// About 45 s read aloud at an easy pace (init prints it to record). Plain
|
|
15
|
+
// sentences with varied sounds, questions and numbers give the clone range.
|
|
16
|
+
export const READING_PASSAGE = [
|
|
17
|
+
"Hi, this is a recording of my own voice, for my Hyperdecks presenter.",
|
|
18
|
+
"I'm reading at my normal pace, the way I would talk to a friend across the table.",
|
|
19
|
+
"Some days start slowly, with coffee and a long list. Others start with a phone call at seven thirty.",
|
|
20
|
+
"Either way, I like to get to the point: what's the problem, who has it, and what would make it better?",
|
|
21
|
+
"When I show people my work, I want them to hear me, not a robot.",
|
|
22
|
+
"So here are a few numbers, said clearly: twelve, forty-five, three hundred, and two thousand and six.",
|
|
23
|
+
"Thanks for listening. That's the end of my sample.",
|
|
24
|
+
].join(" ");
|
|
14
25
|
export const MAX_SAMPLES = 3;
|
|
15
26
|
export const MAX_SAMPLE_BYTES = 10 * 1024 * 1024;
|
|
16
27
|
const TYPES = { ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".wav": "audio/wav" };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "The first deck `hyperdecks init` makes when D.A.S.H. presents: the same three slides, introduced in the third person. D.A.S.H. quotes the answers rather than rephrasing them, so no pronoun is guessed from a name.",
|
|
3
|
+
"hero": {
|
|
4
|
+
"kicker": "Meet",
|
|
5
|
+
"title": "Meet __NAME__.",
|
|
6
|
+
"lead": "__WHAT__",
|
|
7
|
+
"script": "Hi, [wave] I'm D.A.S.H. [look: h1] I'm here to introduce __NAME__."
|
|
8
|
+
},
|
|
9
|
+
"features": {
|
|
10
|
+
"kicker": "In short",
|
|
11
|
+
"title": "Three things to know.",
|
|
12
|
+
"cards": [
|
|
13
|
+
{ "name": "Who", "line": "__NAME__" },
|
|
14
|
+
{ "name": "What", "line": "__WHAT__" },
|
|
15
|
+
{ "name": "The ask", "line": "__ASK__" }
|
|
16
|
+
],
|
|
17
|
+
"script": "Three quick things. [go: #features-f1] Who this is. [go: #features-f2] What __NAME__ does, in their own words. __WHAT__ [go: #features-f3] And what __NAME__ would love from you. [go: slot]"
|
|
18
|
+
},
|
|
19
|
+
"closer": {
|
|
20
|
+
"title": "Thanks.",
|
|
21
|
+
"lead": "What __NAME__ would love from you:",
|
|
22
|
+
"cta": "__ASK__",
|
|
23
|
+
"contact": "Presented by D.A.S.H. on hyperdecks.ai",
|
|
24
|
+
"script": "Here's the ask. [go: #closer-cta] __ASK__ Thanks [go: slot] for watching! [wave]"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "The first deck `hyperdecks init` makes, in your own voice: three slides from your three answers. __NAME__, __WHAT__ and __ASK__ are the answers; text is escaped before it goes into index.html.",
|
|
3
|
+
"hero": {
|
|
4
|
+
"kicker": "Hello",
|
|
5
|
+
"title": "Hi, I'm __NAME__.",
|
|
6
|
+
"lead": "__WHAT__",
|
|
7
|
+
"script": "Hi, [wave] I'm __NAME__. [look: h1] Thanks for taking a minute to watch this."
|
|
8
|
+
},
|
|
9
|
+
"features": {
|
|
10
|
+
"kicker": "In short",
|
|
11
|
+
"title": "Me, in three cards.",
|
|
12
|
+
"cards": [
|
|
13
|
+
{ "name": "Who", "line": "__NAME__" },
|
|
14
|
+
{ "name": "What I do", "line": "__WHAT__" },
|
|
15
|
+
{ "name": "My ask", "line": "__ASK__" }
|
|
16
|
+
],
|
|
17
|
+
"script": "Here's me, in three cards. [go: #features-f1] Who I am. [go: #features-f2] What I do. __WHAT__ [go: #features-f3] And what I'd love from you. [go: slot]"
|
|
18
|
+
},
|
|
19
|
+
"closer": {
|
|
20
|
+
"title": "Thanks.",
|
|
21
|
+
"lead": "One thing I'd love from you:",
|
|
22
|
+
"cta": "__ASK__",
|
|
23
|
+
"contact": "Made with hyperdecks.ai",
|
|
24
|
+
"script": "So here's my ask. [go: #closer-cta] __ASK__ Thanks [go: slot] for watching! [wave]"
|
|
25
|
+
}
|
|
26
|
+
}
|