@hypersocial/decks-cli 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -123,14 +123,17 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
123
123
 
124
124
  | Step | CLI | MCP tool |
125
125
  |---|---|---|
126
+ | Brand (once; every new deck starts from it) | `hyperdecks brand show` / `brand set` / `brand edit` | `get_brand` / `set_brand` |
126
127
  | Scaffold | `hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer]` | `new_deck` (`layouts`) |
127
128
  | Images | `hyperdecks gen-image "<prompt>" --slug <slug>` | `generate_image` |
128
129
  | Video clips | `hyperdecks gen-video "<prompt>" --slug <slug>` | `generate_video` |
129
130
  | Product screenshot | `hyperdecks screenshot <url> --slug <slug>` | `capture_url` |
130
131
  | Chart from numbers | `hyperdecks chart <spec.json> --slug <slug>` | `render_chart` |
131
- | Icon set | `hyperdecks icons <subject...> --style "<style>" --slug <slug>` | `generate_icon_set` |
132
+ | Real icons (Lucide, inline SVG) | `hyperdecks icons search <word>` | `search_icons` |
133
+ | Illustrated icon set | `hyperdecks icons <subject...> --style "<style>" --slug <slug>` | `generate_icon_set` |
132
134
  | Outside review (required before release) | `hyperdecks review <slug> --script decks/<slug>/script.md` | `review_deck` |
133
135
  | Your voice (once) | `hyperdecks voice clone <file...> --name <name>` | `clone_voice` |
136
+ | Who narrates (ask the person first; see *Narrators*) | `hyperdecks deck narrator <slug> <name\|none\|default>` | `list_narrators`, then `set_deck_narrator` |
134
137
  | Narrate | `hyperdecks narrate decks/<slug>` | `narrate_deck` |
135
138
  | Check | `hyperdecks check decks/<slug>` | `check_deck` |
136
139
  | Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
@@ -299,13 +302,36 @@ plain, `on-ink`) so the slide changes read, and keep every number, source and
299
302
  label at full contrast. Change the palette and fonts once, in `:root`; every
300
303
  layout reads them.
301
304
 
305
+ **Start from the brand.** The account keeps a brand: colours, fonts, a logo,
306
+ a presenter style and a design.md (voice, do and don't). `deck new` and
307
+ `new_deck` put its tokens in `:root` and its logo on the first slide, so read
308
+ it first (`hyperdecks brand show --json`, `get_brand`) and write in its voice.
309
+ An explicit request for this deck wins over the brand; `--no-brand`
310
+ (`brand: false`) starts from the template's look. No brand yet? Offer to set
311
+ one up: read the person's website (`capture_url`, its colours, fonts and logo),
312
+ show them what you would save, and `brand set` / `set_brand` only after they
313
+ agree.
314
+
315
+ **Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
316
+ curated pairings below (display for titles, body for text, mono for kickers
317
+ and labels) rather than whatever family comes to mind; `brand set --fonts <id>`
318
+ saves one.
319
+
320
+ <!-- font pairings -->
321
+
322
+ **Icons: real ones first.** `hyperdecks icons search <word>` (`search_icons`)
323
+ returns Lucide line icons as inline SVG stroked in `currentColor`, so they take
324
+ the deck's colours: paste the `<svg>` in place of a features card's icon. Use
325
+ `icons <subject...> --style` (`generate_icon_set`) only for illustrated icons
326
+ in a style of your own.
327
+
302
328
  **Pick the layout from what the slide says.** Each layout's file in
303
329
  `templates/layouts/` opens with its slot side, presenter and the cues that
304
330
  suit it.
305
331
 
306
332
  | Layout | Use it for | Presenter, moves |
307
333
  |---|---|---|
308
- | `hero` | the opening, a section opener; the presenter is the visual, no `.art` unless it shows the real subject | stage, bottom-right; `[wave]`, `[look: h1]` |
334
+ | `hero` | the opening, a section opener; the presenter is the visual, no `.art` unless it shows the real subject (check fails while the placeholder `<div class="art">` is in: swap it for an `<img class="art">` or delete it) | stage, bottom-right; `[wave]`, `[look: h1]` |
309
335
  | `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
310
336
  | `product` | a screenshot in a browser frame (`.frame.phone` for a phone shot) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
311
337
  | `quote` | a customer or user in their own words, quoted exactly | stage, bottom-left; `[look: #<id>-quote]` |
@@ -462,6 +488,7 @@ an internal codename). Messages and what to do:
462
488
  | `forbidden phrase` | remove it from the page, script or narration |
463
489
  | `slide <id>: needs a data-note` | add the source |
464
490
  | `slide <id>: title … needs a data-title-mobile` | add a shorter title |
491
+ | `slide <id>: the hero's art` / `the product screenshot` / `the chart … is still the layout's placeholder` | make the real file (`gen-image`, `screenshot <url>`, `chart <spec.json>`) and swap the element for the `<img>` the message names; or delete the hero's `<div class="art">` for no art |
465
492
  | `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
466
493
  | `script.md line N: [go: …] matches nothing in slide <id>` | fix the selector, or add the element to that slide |
467
494
  | `… transition "…" is not one of` | use drift, cut, push, wipe, zoom `<selector>`, morph or fly |
@@ -489,8 +516,9 @@ the deck's QA link on desktop and phone and list each problem as `fix` or
489
516
  Pick image models by job: `gen-image` defaults to GPT Image 2.5; `--model
490
517
  nano-banana-2.1` is stronger on typography and photoreal scenes. `gen-video`
491
518
  defaults to MiniMax H3 Max (`--model kling-v3` is the fallback). Use `chart` for
492
- real numbers instead of a generated picture of a chart, and `icons` with the
493
- deck's palette for a set that matches.
519
+ real numbers instead of a generated picture of a chart, `icons search` for
520
+ real line icons, and `icons --style` with the deck's palette for an
521
+ illustrated set that matches.
494
522
 
495
523
  ### 7. Release
496
524
 
@@ -22,32 +22,55 @@ makes the hello deck; continue from step 5 below.
22
22
  ## The loop
23
23
 
24
24
  1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides.
25
+ `get_brand` for the person's saved brand: write in its designMd's voice.
26
+ None yet and they have a website? Offer to set one up: `capture_url` the
27
+ site, read its colours, fonts and logo, show what you would save and
28
+ `set_brand` once they agree (`deck_guide { topic: "design" }` lists the
29
+ font pairings).
25
30
  2. `new_deck { slug, title, layouts }`: registers your draft and returns the
26
- full text of every file plus a `previewUrl` (the draft, untracked).
31
+ full text of every file plus a `previewUrl` (the draft, untracked). It
32
+ starts in the saved brand's colours, fonts and logo (`brand` in the result
33
+ says what it took); `brand: false` gives the template's look.
27
34
  3. Images: `generate_image`, `render_chart`, `capture_url`, or
28
35
  `upload_deck_asset` for a picture the user gave you (a URL, or a file they
29
36
  uploaded). Each returns a hosted URL: put that URL in the HTML. A hosted deck
30
- has no folder, so a relative path like `img/a.png` is an error.
37
+ has no folder, so a relative path like `img/a.png` is an error. The
38
+ layouts' stand-ins (hero's `<div class="art">`, product's
39
+ `<div class="shot">`, chart's placeholder `<svg>`) fail `check_deck` until
40
+ you swap each for an `<img>` of its real file, or delete the hero's art.
41
+ Icons: `search_icons { query }` returns real line icons as inline `<svg>`
42
+ in `currentColor`; paste one where a features card has its icon.
31
43
  4. `write_deck_source` to change a file: `edits: [{ find, replace }]` with
32
44
  exact text that occurs once (copy it from `get_deck_source`), or `content`
33
45
  to replace the file. Pass `baseRev` (the `rev` you last read) so a
34
46
  concurrent edit fails instead of being overwritten.
35
- 5. `check_deck { slug }` until it passes (the narration errors clear in step 6).
36
- 6. `narrate_deck { slug }` with no `script` narrates the stored `script.md`;
47
+ 5. `check_deck { slug }` until it passes (the narration errors clear in step 7).
48
+ 6. **Ask who narrates** before the first narrate, unless they already named
49
+ one. `list_narrators`, then offer their narrators (`hear_voice` to listen)
50
+ and D.A.S.H. If none of theirs has a face, say so: their voice alone plays
51
+ captions only, no face. Offer `new_narrator_link` (they make one, a face plus
52
+ their voice) or D.A.S.H. Then `set_deck_narrator { slug, narrator }`
53
+ (`none` only if they want captions only). Never pick for them.
54
+ 7. `narrate_deck { slug }` with no `script` narrates the stored `script.md`;
37
55
  poll `check_narration_job` until `done`. The draft's player tag then has its
38
- `data-manifest`. No voice of their own yet? Pass `voice: "dash"`.
39
- 7. `check_deck { slug }` again, then `review_deck { slug, draft: true }` and
56
+ `data-manifest`. Its `presenter` says who will present (`face: null` is
57
+ captions only, `reason` says why): tell the person. No voice of their own
58
+ yet? Pass `voice: "dash"`.
59
+ 8. `check_deck { slug }` again, then `review_deck { slug, draft: true }` and
40
60
  `check_review_job`: it shoots desktop and 390×844 phone frames, which is how
41
61
  you catch a slide that overflows a phone (the hosted check cannot measure
42
62
  layout).
43
- 8. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
63
+ 9. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
44
64
  `release_deck { slug, recipients: [{ recipient, email }] }` makes one
45
65
  tracked link each. Release creates live links: confirm the recipients with
46
66
  the user before you call it. In Claude and ChatGPT the result shows as a
47
67
  deck card: the deck playing from its QA link (the draft before release),
48
68
  with each share link and its opens. Never paste a recipient's link to "test"
49
- it: opening it counts as their open.
50
- 9. Later: `list_shares` and `deck_stats` (both show the card),
69
+ it: opening it counts as their open. A release that would play captions
70
+ only, when nobody chose that, is refused: hand the person its Add narrator
71
+ link, or offer D.A.S.H., and pass `captionsOnly: true` only if they say so.
72
+ The result's `presenter` names who presents.
73
+ 10. Later: `list_shares` and `deck_stats` (both show the card),
51
74
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
52
75
  email as the deck's moving preview (it needs a recipient's link, not a QA
53
76
  one); `set_share_notify` to mute a link's "opened" alerts;
@@ -63,8 +86,8 @@ makes the hello deck; continue from step 5 below.
63
86
  scaffold wrote; never write `data-manifest` yourself.
64
87
  - Edit the draft freely: recipients keep seeing the last release until you call
65
88
  `release_deck` again. A release is refused when the check fails, when
66
- narration is stale (narrate again first), or when the draft changed after
67
- the check.
89
+ narration is stale (narrate again first), when the draft changed after
90
+ the check, or when it would play captions only and nobody chose that.
68
91
  - Files are capped at 800 KB each: use hosted image URLs, never inline base64.
69
92
  - Facts come from the user's material; put the source of each in `evidence.md`
70
93
  and the slide's `data-note`.
package/src/brand.mjs ADDED
@@ -0,0 +1,171 @@
1
+ // The account's brand (convex/brand.ts) applied to a new deck, and the curated
2
+ // font pairings it can name. Text in, text out (no I/O), so the hosted MCP route
3
+ // (new_deck) and the CLI (deck new) put the same brand on the same templates.
4
+ //
5
+ // A brand is { colors: { ink, paper, accent, accent2, card }, fonts: { display,
6
+ // body, mono }, logoUrl, presenterStyle, designMd }; every field is optional and
7
+ // an absent one keeps the template's own. designMd is guidance the agent reads;
8
+ // only the tokens here touch the deck.
9
+
10
+ // Weights each role loads: the templates set body text at 500, titles at 700
11
+ // and labels in mono at 400/700. Every family below has these on Google Fonts.
12
+ const ROLE_WEIGHTS = { display: "600;700", body: "400;500;700", mono: "400;700" };
13
+
14
+ // 16 pairings, display + body + mono, each a family Google Fonts serves at the
15
+ // role's weights. `use` is the one line an agent picks by.
16
+ export const FONT_PAIRINGS = [
17
+ { id: "space", display: "Space Grotesk", body: "Space Grotesk", mono: "Space Mono", use: "The template's own: techy, playful, a startup demo." },
18
+ { id: "inter", display: "Inter", body: "Inter", mono: "JetBrains Mono", use: "Neutral product and SaaS; reads like the app itself." },
19
+ { id: "geist", display: "Geist", body: "Geist", mono: "Geist Mono", use: "Developer tools and infrastructure; crisp and modern." },
20
+ { id: "plex", display: "IBM Plex Sans", body: "IBM Plex Sans", mono: "IBM Plex Mono", use: "Enterprise, data and engineering; sober and precise." },
21
+ { id: "editorial", display: "Fraunces", body: "Inter", mono: "IBM Plex Mono", use: "Editorial and thoughtful: essays, research, a founder's letter." },
22
+ { id: "classic", display: "Playfair Display", body: "Source Sans 3", mono: "Source Code Pro", use: "Premium, luxury, finance or hospitality; classic contrast." },
23
+ { id: "newsroom", display: "Newsreader", body: "Public Sans", mono: "IBM Plex Mono", use: "Reports, policy and media; a newspaper's calm authority." },
24
+ { id: "bold", display: "Bricolage Grotesque", body: "DM Sans", mono: "DM Mono", use: "Consumer brands and launches; characterful and loud." },
25
+ { id: "friendly", display: "Plus Jakarta Sans", body: "Plus Jakarta Sans", mono: "Fira Code", use: "Friendly consumer and fintech apps; soft and approachable." },
26
+ { id: "geometric", display: "Sora", body: "Manrope", mono: "JetBrains Mono", use: "Web3, AI and future-facing products; geometric and clean." },
27
+ { id: "studio", display: "Syne", body: "Inter", mono: "Space Mono", use: "Design studios, agencies and portfolios; art-school edge." },
28
+ { id: "warm", display: "Outfit", body: "Nunito Sans", mono: "Red Hat Mono", use: "Education, health and community; warm and open." },
29
+ { id: "swiss", display: "Archivo", body: "Archivo", mono: "Azeret Mono", use: "Swiss-style grids, architecture and hardware; tight and rational." },
30
+ { id: "literary", display: "Libre Baskerville", body: "Lora", mono: "Courier Prime", use: "Books, writing, nonprofits; literary and humane." },
31
+ { id: "display", display: "Unbounded", body: "Hanken Grotesk", mono: "Martian Mono", use: "Events, gaming and culture; wide display type that shouts." },
32
+ { id: "grotesk", display: "Schibsted Grotesk", body: "Work Sans", mono: "Roboto Mono", use: "Marketplaces and media brands; confident and newsy." },
33
+ ];
34
+
35
+ // Weights for families that do not have every role weight (load what exists).
36
+ const FAMILY_WEIGHTS = { "Space Mono": "400;700", "DM Mono": "400;500", "Libre Baskerville": "400;700", "Courier Prime": "400;700", "Syne": "600;700" };
37
+
38
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
39
+ // A Google Fonts family name: letters, digits and spaces (e.g. "Source Sans 3").
40
+ const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 ]{0,59}$/;
41
+ export const COLOR_KEYS = ["ink", "paper", "accent", "accent2", "card"];
42
+ export const FONT_ROLES = ["display", "body", "mono"];
43
+ const CSS_VAR = { ink: "--ink", paper: "--paper", accent: "--accent", accent2: "--accent-2", card: "--card" };
44
+ const FALLBACK = { display: "system-ui, sans-serif", body: "system-ui, sans-serif", mono: "ui-monospace, monospace" };
45
+
46
+ export const isHexColor = (s) => typeof s === "string" && HEX.test(s.trim());
47
+ export const isFontFamily = (s) => typeof s === "string" && FAMILY.test(s.trim()) && !/ {2}/.test(s.trim());
48
+
49
+ export function findPairing(id) {
50
+ const want = String(id ?? "").trim().toLowerCase();
51
+ return FONT_PAIRINGS.find((p) => p.id === want) ?? null;
52
+ }
53
+
54
+ // The Google Fonts stylesheet URL for one family at the weights its roles use
55
+ // (a family that is both display and body loads both roles' weights).
56
+ export function googleFontsUrl(family, roles = "body") {
57
+ const wanted = [roles].flat().flatMap((r) => (ROLE_WEIGHTS[r] ?? "400;700").split(";"));
58
+ const weights = FAMILY_WEIGHTS[family] ?? [...new Set(wanted)].sort().join(";");
59
+ return `https://fonts.googleapis.com/css2?family=${family.trim().replace(/ /g, "+")}:wght@${weights}&display=swap`;
60
+ }
61
+
62
+ // The pairings as markdown, for deck_guide { topic: "design" } and `hyperdecks brand fonts`.
63
+ export function fontPairingsMarkdown() {
64
+ const rows = FONT_PAIRINGS.map((p) => `| \`${p.id}\` | ${p.display} | ${p.body} | ${p.mono} | ${p.use} |`);
65
+ return [
66
+ "Pick one, then either save it as the brand (`set_brand { fontPairing: \"<id>\" }`, `hyperdecks brand set --fonts <id>`) or set the deck's own fonts: link each family from Google Fonts (one `<link rel=\"stylesheet\">` each, at the weights shown in the URL) and set `--font` (body), `--mono` and, for titles, `--display` in `:root`.",
67
+ "",
68
+ "| id | display | body | mono | use for |",
69
+ "| --- | --- | --- | --- | --- |",
70
+ ...rows,
71
+ "",
72
+ "URL for one family: `https://fonts.googleapis.com/css2?family=<Name+With+Pluses>:wght@400;700&display=swap`.",
73
+ ].join("\n");
74
+ }
75
+
76
+ // Only the fields that are well formed: a brand read from the server (or a
77
+ // test double) never puts anything but a hex colour, a family name or an
78
+ // https URL into the deck.
79
+ export function cleanBrand(brand) {
80
+ if (!brand || typeof brand !== "object") return null;
81
+ const colors = {};
82
+ for (const k of COLOR_KEYS) if (isHexColor(brand.colors?.[k])) colors[k] = brand.colors[k].trim().toLowerCase();
83
+ const fonts = {};
84
+ for (const r of FONT_ROLES) if (isFontFamily(brand.fonts?.[r])) fonts[r] = brand.fonts[r].trim();
85
+ const logoUrl = typeof brand.logoUrl === "string" && /^https:\/\/[^\s"'<>]+$/.test(brand.logoUrl) ? brand.logoUrl : undefined;
86
+ const presenterStyle = typeof brand.presenterStyle === "string" ? brand.presenterStyle : undefined;
87
+ const name = typeof brand.name === "string" && brand.name.trim() ? brand.name.trim().slice(0, 60) : undefined;
88
+ const hasDesignMd = typeof brand.designMd === "string" && !!brand.designMd.trim();
89
+ if (!Object.keys(colors).length && !Object.keys(fonts).length && !logoUrl && !presenterStyle && !hasDesignMd && !name) return null;
90
+ return { name, colors, fonts, logoUrl, presenterStyle, hasDesignMd };
91
+ }
92
+
93
+ const escapeAttr = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
94
+ const fontStack = (family, role) => `"${family}", ${FALLBACK[role]}`;
95
+ const LOGO_CSS = `
96
+ /* The brand's logo (hyperdecks brand), on the first slide. */
97
+ .brand-logo { align-self: flex-start; height: 56px; width: auto; max-width: 320px; object-fit: contain; position: relative; z-index: 1; }
98
+ @media (max-width: 650px) { .brand-logo { height: 32px; max-width: 180px; } }`;
99
+
100
+ // index.html with the brand's colours, fonts and logo in place of the
101
+ // template's. Tokens in the deck's `:root` are replaced (or added), the Google
102
+ // Fonts link is swapped for the brand's families and the logo goes at the top
103
+ // of the first slide. A brand with nothing set returns the html unchanged.
104
+ export function applyBrand(html, brand) {
105
+ const b = cleanBrand(brand);
106
+ if (!b || (!Object.keys(b.colors).length && !Object.keys(b.fonts).length && !b.logoUrl)) return html;
107
+ // Our templates all have these; a template that lost them is a bug, not a brand-less deck.
108
+ const root = /:root\s*\{[^}]*--ink:[^}]*\}/.exec(html);
109
+ if (!root) throw new Error("applyBrand: the template has no :root block with --ink");
110
+ let block = root[0];
111
+ const set = (name, value) => {
112
+ const decl = new RegExp(`(${name}\\s*:\\s*)[^;}]*`);
113
+ block = decl.test(block) ? block.replace(decl, (_, k) => `${k}${value}`) : block.replace(/\s*\}$/, (end) => ` ${name}: ${value};${end}`);
114
+ };
115
+ for (const [k, v] of Object.entries(b.colors)) set(CSS_VAR[k], v);
116
+ if (b.fonts.body) set("--font", fontStack(b.fonts.body, "body"));
117
+ if (b.fonts.mono) set("--mono", fontStack(b.fonts.mono, "mono"));
118
+ if (b.fonts.display) set("--display", fontStack(b.fonts.display, "display"));
119
+ let out = html.slice(0, root.index) + block + html.slice(root.index + root[0].length);
120
+
121
+ const families = FONT_ROLES.filter((r) => b.fonts[r]).map((r) => [b.fonts[r], r]);
122
+ if (families.length) {
123
+ // Keep the template's link for the roles the brand leaves alone.
124
+ const kept = /<link rel="stylesheet" href="(https:\/\/fonts\.googleapis\.com\/css2\?[^"]*)">/.exec(out);
125
+ const rolesOf = new Map();
126
+ for (const [f, r] of families) rolesOf.set(f, [...(rolesOf.get(f) ?? []), r]);
127
+ const links = [...rolesOf].map(([f, roles]) => `<link rel="stylesheet" href="${escapeAttr(googleFontsUrl(f, roles))}">`);
128
+ if (kept && families.length < FONT_ROLES.length) links.unshift(kept[0]);
129
+ out = kept ? out.replace(kept[0], () => links.join("\n")) : out.replace("</head>", () => `${links.join("\n")}\n</head>`);
130
+ }
131
+ if (b.logoUrl) {
132
+ // The logo's rule goes in the style block that holds :root (not the loading state's).
133
+ const close = out.indexOf("</style>", out.indexOf(block));
134
+ if (close === -1) throw new Error("applyBrand: no </style> after the template's :root");
135
+ out = out.slice(0, close) + LOGO_CSS + "\n" + out.slice(close);
136
+ // The first real slide: the templates name `<section class="slide" id>` in comments too.
137
+ const inComment = (i) => out.lastIndexOf("<!--", i) > out.lastIndexOf("-->", i);
138
+ const first = [...out.matchAll(/<section\b[^>]*class="[^"]*\bslide\b[^"]*"[^>]*>/g)].find((m) => !inComment(m.index));
139
+ if (!first) throw new Error("applyBrand: the template has no <section class=\"slide\"> for the logo");
140
+ const img = `\n <img class="brand-logo" src="${escapeAttr(b.logoUrl)}" alt="${escapeAttr(b.name ? `${b.name} logo` : "Logo")}">`;
141
+ out = out.slice(0, first.index + first[0].length) + img + out.slice(first.index + first[0].length);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ // The brand a new deck starts from: { brand: null } when asked not to
147
+ // (brand: false / --no-brand) or when nothing is saved. A failed read does not
148
+ // stop the deck (making decks must not depend on the brand, e.g. during a
149
+ // rollout); it starts in the template's look and readError says so.
150
+ export async function brandForNewDeck(getBrand, useBrand) {
151
+ if (useBrand === false) return { brand: null };
152
+ try {
153
+ return { brand: cleanBrand(await getBrand()) };
154
+ } catch (e) {
155
+ return { brand: null, readError: String(e?.data ?? e?.message ?? e) };
156
+ }
157
+ }
158
+
159
+ // What a new deck took from the brand, for the agent to read back.
160
+ export function brandSummary(b, readError) {
161
+ if (readError) return { applied: [], error: readError, note: `Could not read your saved brand (${readError}), so the deck has the template's look. Try get_brand (hyperdecks brand show); once it works, apply the brand's colours, fonts and logo yourself or make the deck again.` };
162
+ if (!b) return { applied: [], note: "No brand saved: the deck has the template's look. set_brand (hyperdecks brand set) saves one for every new deck." };
163
+ const applied = [
164
+ Object.keys(b.colors).length && "colors",
165
+ Object.keys(b.fonts).length && "fonts",
166
+ b.logoUrl && "logo",
167
+ b.presenterStyle && "presenterStyle",
168
+ ].filter(Boolean);
169
+ if (!applied.length) return { applied, note: "Your brand has no colours, fonts or logo yet, so the deck has the template's look. Read its designMd (get_brand / hyperdecks brand show) for voice and do's and don'ts." };
170
+ return { applied, note: "Started from your saved brand. Read its designMd (get_brand / hyperdecks brand show) for voice and do's and don'ts; an explicit request for this deck wins over the brand." };
171
+ }
@@ -0,0 +1,127 @@
1
+ // hyperdecks brand show|set|edit|fonts|clear
2
+ //
3
+ // The account's brand (convex/brand.ts): colours, fonts, a logo and a
4
+ // design.md that every new deck (deck new, new_deck) starts from.
5
+ // brand show the saved brand (--json for all of it)
6
+ // brand set [--accent #hex] [--ink …] [--paper …] [--accent-2 …] [--card …]
7
+ // [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …]
8
+ // [--logo <url|file>] [--no-logo] [--presenter-style s]
9
+ // [--design <file.md|->] [--name "<company>"]
10
+ // brand edit design.md in $VISUAL / $EDITOR, saved on exit
11
+ // brand fonts the curated font pairings
12
+ // brand clear forget the brand (new decks get the template look)
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { extname, join } from "node:path";
17
+ import * as ops from "./ops.mjs";
18
+ import { FONT_PAIRINGS } from "./brand.mjs";
19
+
20
+ const LOGO_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", ".avif": "image/avif" };
21
+ const COLOR_FLAGS = { ink: "ink", paper: "paper", accent: "accent", "accent-2": "accent2", card: "card" };
22
+ const FONT_FLAGS = ["display", "body", "mono"];
23
+ const SET_USAGE = 'usage: brand set [--accent "#hex"] [--ink …] [--paper …] [--accent-2 …] [--card …] [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …] [--logo <url|file>] [--no-logo] [--presenter-style stage|bubble|glass|corner] [--design <file.md|->] [--name "<company>"]';
24
+
25
+ export function brandLines(b) {
26
+ if (!b) return ["no brand saved: new decks get the template look", " set one: hyperdecks brand set --accent \"#ff5a1f\" --fonts editorial --logo ./logo.svg"];
27
+ const colors = Object.entries(b.colors ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
28
+ const fonts = Object.entries(b.fonts ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
29
+ const design = b.designMd ? `${b.designMd.split("\n").length} lines (hyperdecks brand show --json, or brand edit)` : "(none: hyperdecks brand edit)";
30
+ return [
31
+ ...(b.name ? [`name: ${b.name}`] : []),
32
+ `colours: ${colors || "(template)"}`,
33
+ `fonts: ${fonts || "(template)"}`,
34
+ `logo: ${b.logoUrl ?? "(none)"}`,
35
+ `presenter: ${b.presenterStyle ?? "(template)"}`,
36
+ `design.md: ${design}`,
37
+ ];
38
+ }
39
+
40
+ const flag = (opts, k) => (opts[k] === undefined || opts[k] === true ? undefined : String(opts[k]));
41
+
42
+ // brand set's flags as setBrand's arguments; a --logo that is not a URL is a file.
43
+ export function brandArgs(opts, { readFile = readFileSync, stdin = () => readFileSync(0, "utf8") } = {}) {
44
+ const colors = {};
45
+ for (const [f, k] of Object.entries(COLOR_FLAGS)) if (flag(opts, f) !== undefined) colors[k] = flag(opts, f);
46
+ const fonts = {};
47
+ for (const r of FONT_FLAGS) if (flag(opts, r) !== undefined) fonts[r] = flag(opts, r);
48
+ const args = {};
49
+ if (Object.keys(colors).length) args.colors = colors;
50
+ if (Object.keys(fonts).length) args.fonts = fonts;
51
+ if (flag(opts, "fonts") !== undefined) args.fontPairing = flag(opts, "fonts");
52
+ if (flag(opts, "name") !== undefined) args.name = flag(opts, "name");
53
+ if (flag(opts, "presenter-style") !== undefined) args.presenterStyle = flag(opts, "presenter-style");
54
+ const design = flag(opts, "design");
55
+ if (design !== undefined) args.designMd = design === "-" ? stdin() : readFile(design, "utf8");
56
+ const logo = flag(opts, "logo");
57
+ if (logo !== undefined) {
58
+ if (/^https?:\/\//i.test(logo)) args.logoUrl = logo;
59
+ else {
60
+ const type = LOGO_TYPES[extname(logo).toLowerCase()];
61
+ if (!type) throw new Error(`--logo ${logo}: use a .png, .jpg, .webp, .gif, .svg or .avif file, or an image URL`);
62
+ args.logoBytes = readFile(logo);
63
+ args.logoType = type;
64
+ }
65
+ }
66
+ if (opts["no-logo"]) args.clearLogo = true;
67
+ return args;
68
+ }
69
+
70
+ export async function brandShow(opts = {}, { log = (line) => console.log(line) } = {}) {
71
+ const b = await ops.getBrand(opts);
72
+ if (opts.json) log(JSON.stringify(b, null, 2));
73
+ else for (const line of brandLines(b)) log(line);
74
+ return b;
75
+ }
76
+
77
+ export async function brandSet(opts = {}, { log = (line) => console.log(line), ...io } = {}) {
78
+ const args = brandArgs(opts, io);
79
+ if (!Object.keys(args).length) throw new Error(SET_USAGE);
80
+ const b = await ops.setBrand(opts, args);
81
+ if (opts.json) log(JSON.stringify(b, null, 2));
82
+ else for (const line of ["✓ brand saved; new decks start from it", ...brandLines(b)]) log(line);
83
+ return b;
84
+ }
85
+
86
+ // design.md in the person's editor; saved only when it changed.
87
+ export async function brandEdit(opts = {}, { log = (line) => console.log(line), spawn = spawnSync, env = process.env } = {}) {
88
+ const b = await ops.getBrand(opts);
89
+ const before = b?.designMd ?? "# Brand\n\nVoice: …\n\nDo:\n- …\n\nDon't:\n- …\n";
90
+ const dir = mkdtempSync(join(tmpdir(), "hyperdecks-brand-"));
91
+ const file = join(dir, "design.md");
92
+ writeFileSync(file, before);
93
+ const editor = env.VISUAL || env.EDITOR || "vi";
94
+ const ran = spawn(`${editor} "${file}"`, { stdio: "inherit", shell: true });
95
+ const after = existsSync(file) ? readFileSync(file, "utf8") : before;
96
+ if (ran.error || ran.status !== 0 || after === before) {
97
+ rmSync(dir, { recursive: true, force: true });
98
+ if (ran.error) throw new Error(`could not start ${editor}: ${ran.error.message}`);
99
+ if (ran.status !== 0) throw new Error(`${editor} exited with ${ran.signal ?? ran.status}; design.md not saved`);
100
+ log("design.md unchanged");
101
+ return b;
102
+ }
103
+ let saved;
104
+ try {
105
+ saved = await ops.setBrand(opts, { designMd: after });
106
+ } catch (e) {
107
+ // Keep what they wrote: the temp file stays, and the error says where.
108
+ throw new Error(`design.md not saved (${ops.errorText(e)}); your text is still in ${file}. Retry with: hyperdecks brand set --design ${file}`);
109
+ }
110
+ rmSync(dir, { recursive: true, force: true });
111
+ log("✓ design.md saved");
112
+ return saved;
113
+ }
114
+
115
+ export function brandFonts(opts = {}, { log = (line) => console.log(line) } = {}) {
116
+ const out = ops.listFontPairings();
117
+ if (opts.json) log(JSON.stringify(out, null, 2));
118
+ else for (const p of FONT_PAIRINGS) log(`${p.id.padEnd(10)} ${`${p.display} / ${p.body} / ${p.mono}`.padEnd(54)} ${p.use}`);
119
+ if (!opts.json) log("\nhyperdecks brand set --fonts <id>");
120
+ return out;
121
+ }
122
+
123
+ export async function brandClear(opts = {}, { log = (line) => console.log(line) } = {}) {
124
+ const out = await ops.clearBrand(opts);
125
+ log(opts.json ? JSON.stringify(out) : "✓ brand cleared; new decks get the template look");
126
+ return out;
127
+ }
@@ -47,6 +47,8 @@ export const MESSAGES = {
47
47
  morphPair: (id, prev) => `slide ${id}: morph needs an element with the same data-morph="<name>" on this slide and the one before it (${prev})`,
48
48
  transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
49
49
  flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
50
+ // A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
51
+ placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
50
52
  // Warnings (check --layout): the deck still passes.
51
53
  layoutUnchecked: (why) => `layout not checked: ${why}`,
52
54
  hotlink: (url) => `${url} is hotlinked from another site and can vanish after release; upload it with upload_deck_asset`,
@@ -271,10 +273,40 @@ export function phraseList(value) {
271
273
  return value.map((s) => String(s).trim()).filter(Boolean);
272
274
  }
273
275
 
276
+ // Layout stand-ins a deck must swap for its own file, found by the templates'
277
+ // own markers: the empty div (or the chart's labelled svg). A div that holds
278
+ // its own picture, or gets one from a url() in its style or a <style> rule
279
+ // naming its class, is the deck's art. A deck that wants none removes it.
280
+ const PLACEHOLDERS = [
281
+ {
282
+ layout: "l-hero", tag: "div", cls: "art", what: "the hero's art", img: '<img class="art" src="…" alt="">, or delete the div for no art (the presenter is the visual)',
283
+ how: { hosted: "make a picture of the real subject with generate_image", cli: "make a picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
284
+ },
285
+ {
286
+ layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
287
+ how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
288
+ },
289
+ {
290
+ layout: "l-chart", tag: "svg", cls: "chart", label: /placeholder/i, what: "the chart (made-up numbers)", img: '<img class="chart" src="…" alt="…">',
291
+ how: { hosted: "draw the real data with render_chart", cli: "draw the real data with `hyperdecks chart <spec.json>`" },
292
+ },
293
+ ];
294
+ const classes = (tag) => (attr(tag, "class") ?? "").split(/\s+/).filter(Boolean);
295
+ export function placeholderErrors(html, { hosted = false } = {}) {
296
+ const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
297
+ const styled = (cls) => [...css.matchAll(/([^{}]*)\{([^{}]*)\}/g)].some(([, sel, body]) => new RegExp(`\\.${cls}(?![\\w-])`).test(sel) && /url\(/i.test(body));
298
+ return slides(html).flatMap((s, i) => {
299
+ const layouts = classes(s.tag);
300
+ return PLACEHOLDERS.filter((p) => layouts.includes(p.layout) && !(p.tag === "div" && styled(p.cls)) && [...s.body.matchAll(new RegExp(`(<${p.tag}\\b[^>]*>)([\\s\\S]*?)</${p.tag}>`, "gi"))].some(([, tag, inner]) =>
301
+ classes(tag).includes(p.cls) && (p.label ? p.label.test(attr(tag, "aria-label") ?? "") : !inner.trim() && !/url\(/i.test(attr(tag, "style") ?? ""))))
302
+ .map((p) => MESSAGES.placeholder(s.id || `${i + 1}`, p.what, `<${p.tag} class="${p.cls}">`, hosted ? p.how.hosted : p.how.cli, p.img));
303
+ });
304
+ }
305
+
274
306
  // Everything checkable from the deck's own files. `exists(ref)` answers whether a
275
307
  // local reference resolves inside the deck folder: true, false or "outside".
276
- export function htmlErrors(html, { exists = () => true, forbid = [], script = "" } = {}) {
277
- const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script)];
308
+ export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
309
+ const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...placeholderErrors(html, { hosted })];
278
310
  const player = playerScript(html);
279
311
  if (!player) errs.push(MESSAGES.noPlayer());
280
312
  else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
@@ -486,7 +518,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
486
518
  throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
487
519
  }
488
520
  const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
489
- const errors = htmlErrors(html, { exists, forbid, script });
521
+ const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted });
490
522
  const ids = slides(html).map((s) => s.id);
491
523
  const player = playerScript(html);
492
524
  let manifest = null;
package/src/commands.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  VERSION,
10
10
  } from "./runtime.mjs";
11
11
  import * as ops from "./ops.mjs";
12
+ import { presenterLine } from "./presenter.mjs";
12
13
 
13
14
  export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
14
15
  : p.narrationStarted ? "narration started"
@@ -306,8 +307,9 @@ commands:
306
307
  deck list|stats|open|rm|forward
307
308
  deck forward on|off <slug>
308
309
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
309
- deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
310
- scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
310
+ deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
311
+ scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
312
+ in your saved brand's colours, fonts and logo (--no-brand: the template's look)
311
313
  layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
312
314
  check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
313
315
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
@@ -320,7 +322,7 @@ commands:
320
322
  file an image, video or font under the deck; prints its hosted URL
321
323
  guide [workflow|format|script|design|layouts|presenter|transitions|check]
322
324
  the skill, one topic at a time (what deck_guide serves)
323
- release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
325
+ release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
324
326
  on a committed deck: check → narrate → import → share new per --to
325
327
  share new|list|on|off|forward|mute|unmute|rm|url
326
328
  share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
@@ -347,8 +349,17 @@ commands:
347
349
  a PNG of a public web page (private and local addresses are refused)
348
350
  chart <spec.json|-> [--slug <deck>] [--name <name>] [--download <file>]
349
351
  a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
352
+ icons search <word...> [--limit 8] [--stroke-width 2] [--json]
353
+ real icons from the Lucide library as inline SVG in currentColor (free, offline)
350
354
  icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
351
- a style-consistent set of transparent icons, one per subject (max 12)
355
+ a style-consistent set of generated (illustrated) icons, one per subject (max 12)
356
+ brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
357
+ brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
358
+ [--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
359
+ [--presenter-style s] [--design <file.md|->] [--name "<company>"]
360
+ brand edit edit design.md (voice, do and don't) in $EDITOR
361
+ brand fonts the curated font pairings (display + body + mono)
362
+ brand clear forget the brand; new decks get the template look
352
363
  review <slug> [--script <script.md>] [--json]
353
364
  GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
354
365
  review-job <jobId> [--json]
@@ -629,7 +640,7 @@ function commandTable(pos, rest, opts) {
629
640
  if (opts.hosted) {
630
641
  const { hostedNewDeck } = await import("./source.mjs");
631
642
  if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
632
- const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"] });
643
+ const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"], brand: !opts["no-brand"] });
633
644
  return void console.log(opts.json ? JSON.stringify(out, null, 2) : `✓ created hosted deck "${out.slug}" (rev ${out.rev})\n preview: ${out.previewUrl}\n edit locally: hyperdecks source pull ${out.slug}`);
634
645
  }
635
646
  const { deckNew } = await import("./scaffold.mjs");
@@ -652,9 +663,11 @@ function commandTable(pos, rest, opts) {
652
663
  release: async () => {
653
664
  if (opts.hosted) {
654
665
  const { hostedRelease } = await import("./source.mjs");
655
- const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid });
666
+ const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] });
656
667
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
657
668
  console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
669
+ const who = presenterLine(out.presenter);
670
+ if (who) console.log(who);
658
671
  for (const s of out.shares) console.log(`✓ share for "${s.recipient}"${s.qa ? " (QA, untracked)" : ""} → ${s.url}`);
659
672
  return;
660
673
  }
@@ -826,6 +839,20 @@ function commandTable(pos, rest, opts) {
826
839
  screenshot: () => screenshot(pos.slice(1), opts),
827
840
  chart: () => chart(pos.slice(1), opts),
828
841
  icons: () => icons(pos.slice(1), opts),
842
+ // The icon library (Lucide); `icons search … --style` still generates.
843
+ "icons search": async () => {
844
+ if (opts.style) return icons(pos.slice(1), opts);
845
+ if (!rest.length) throw new Error("usage: icons search <word...> [--limit N] [--stroke-width N] [--json]");
846
+ const { icons: searchIcons } = await import("./icons.mjs");
847
+ const out = await searchIcons(rest, opts);
848
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
849
+ },
850
+ brand: async () => (await import("./brandCli.mjs")).brandShow(opts),
851
+ "brand show": async () => (await import("./brandCli.mjs")).brandShow(opts),
852
+ "brand set": async () => (await import("./brandCli.mjs")).brandSet(opts),
853
+ "brand edit": async () => (await import("./brandCli.mjs")).brandEdit(opts),
854
+ "brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
855
+ "brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
829
856
  review: () => review(pos.slice(1), opts),
830
857
  "review-job": () => reviewJob(pos.slice(1), opts),
831
858
  poster: () => poster(pos.slice(1), opts),
package/src/files.mjs CHANGED
@@ -19,6 +19,7 @@ const FILES = {
19
19
  "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
20
20
  "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
21
21
  "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
22
+ "templates/icons/lucide.json": () => new URL("../templates/icons/lucide.json", import.meta.url),
22
23
  "templates/first-deck/self.json": () => new URL("../templates/first-deck/self.json", import.meta.url),
23
24
  "templates/first-deck/character.json": () => new URL("../templates/first-deck/character.json", import.meta.url),
24
25
  "skills/hyperdecks/SKILL.md": () => new URL("../skills/hyperdecks/SKILL.md", import.meta.url),