@hypersocial/decks-cli 0.9.7 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +36 -2
- package/skills/hyperdecks/chat.md +5 -3
- package/src/checkRules.mjs +71 -1
- package/src/commands.mjs +31 -18
- package/src/guide.mjs +1 -1
- package/src/init.mjs +8 -4
- package/src/layouts.mjs +1 -1
- package/src/ops.mjs +40 -12
- package/src/presenter.mjs +102 -16
- package/src/preview.mjs +2 -2
- package/src/release.mjs +11 -7
- package/src/runtime.mjs +62 -2
- package/templates/layouts/_deck.html +7 -0
- package/templates/layouts/chart.html +5 -0
- package/templates/layouts/closer.html +5 -0
- package/templates/layouts/compare.html +5 -0
- package/templates/layouts/features.html +6 -0
- package/templates/layouts/hero.html +5 -0
- package/templates/layouts/number.html +5 -0
- package/templates/layouts/phones.html +5 -0
- package/templates/layouts/product.html +5 -0
- package/templates/layouts/quote.html +6 -0
- package/templates/layouts/split.html +5 -0
- package/templates/layouts/team.html +6 -0
- package/templates/layouts/timeline.html +5 -0
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ share list <slug> [--json]
|
|
|
64
64
|
share on|off <token>
|
|
65
65
|
share mute|unmute <token>
|
|
66
66
|
share rm <token>
|
|
67
|
-
share url <token>
|
|
67
|
+
share url <token> [--backup]
|
|
68
68
|
|
|
69
69
|
recipient archive|unarchive <name>
|
|
70
70
|
import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live|archived]
|
package/package.json
CHANGED
|
@@ -149,7 +149,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
149
149
|
| One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
|
|
150
150
|
| Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
|
|
151
151
|
| Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
|
|
152
|
-
| Slides or scroll, viewer toggle | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off]` | `set_deck_view` |
|
|
152
|
+
| Slides or scroll, viewer toggle, pace | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default]` | `set_deck_view` |
|
|
153
153
|
| Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
|
|
154
154
|
| All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
|
|
155
155
|
| What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
|
|
@@ -169,7 +169,15 @@ HTML says. Viewers get a slides/scroll toggle in the player, remembered per
|
|
|
169
169
|
link, unless you turn it off (`--toggle off`, `viewToggle: false`). A viewer
|
|
170
170
|
who already switched keeps their own choice while the toggle is on; turn the
|
|
171
171
|
toggle off to make every viewer see the deck's view.
|
|
172
|
-
|
|
172
|
+
Pace is the third: `--pace auto` (`pace: "auto"`, the default) plays itself,
|
|
173
|
+
`slide` waits for Next (the Continue pill, →, a swipe) after each slide's
|
|
174
|
+
narration, and `step` also stops the narration before each `[reveal]` cue,
|
|
175
|
+
so a slide's reveals are the steps the viewer clicks through: group what
|
|
176
|
+
belongs together at one word, so each stop is a meaningful step. Viewers can
|
|
177
|
+
switch pace (and slides/scroll) in the player's Preferences, in its top bar
|
|
178
|
+
(remembered per link); a video export always plays straight through. Pace
|
|
179
|
+
waits apply side to side (slides) only: scroll keeps its own flow.
|
|
180
|
+
`deck list --json` / `list_decks` and `deck stats` / `deck_stats` show all three.
|
|
173
181
|
|
|
174
182
|
### 1. Ground it in the user's own material
|
|
175
183
|
|
|
@@ -258,6 +266,18 @@ The deck contract (`check` enforces or warns on each line):
|
|
|
258
266
|
<!-- topic: presenter -->
|
|
259
267
|
#### Which presenter style
|
|
260
268
|
|
|
269
|
+
**Who presents is set at release; leave the tag alone.** `deck new --narrated`
|
|
270
|
+
writes `data-presenter="none"` on the player tag, and `release` (and
|
|
271
|
+
`release_deck`) puts the narrator's face on the copy it publishes: your own
|
|
272
|
+
narrator's avatar, or D.A.S.H.'s live head for a deck in D.A.S.H.'s voice. It
|
|
273
|
+
says `presented by …` only when the published page has that face; otherwise
|
|
274
|
+
it warns `releasing captions only` with the reason. To pick the face by hand,
|
|
275
|
+
set both attributes: `data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
|
|
276
|
+
`data-presenter="head" data-avatar="https://hyperdecks.ai/player/v1/dash-avatar.json"`
|
|
277
|
+
(it loads from any host). `data-presenter="head"` with no `data-avatar` plays
|
|
278
|
+
captions only, and so does a `data-avatar` beside `data-presenter="none"`.
|
|
279
|
+
`check` warns on both, and `release` leaves a hand-set `data-avatar` alone.
|
|
280
|
+
|
|
261
281
|
Set the deck's on the player tag (`data-presenter-style="stage"`) or in
|
|
262
282
|
script.md's front matter (`presenterStyle: stage`); override one slide with
|
|
263
283
|
`data-presenter-style` on its `<section>` or `presenter-style: corner` under
|
|
@@ -596,6 +616,20 @@ with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
|
|
|
596
616
|
`timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
|
|
597
617
|
one action, stickers again.
|
|
598
618
|
|
|
619
|
+
**No presenter.** When nothing stands in the corner, no face (presenter
|
|
620
|
+
`none`) *and* no captions (`data-silent`, or an export with captions off), or
|
|
621
|
+
when a host calls `window.hyperdecks.setPresenterOff(true)`, the player puts
|
|
622
|
+
`hd-no-presenter` on `<html>` and hides every `.narration-slot`. Each layout
|
|
623
|
+
has a variant for that in its own `<style>` (`deck_guide layouts` says what
|
|
624
|
+
each does): compare and chart widen; features, team and timeline centre
|
|
625
|
+
vertically; split, product and phones centre their copy; number, quote,
|
|
626
|
+
closer and a hero without art re-centre. Type sizes stay. A deck with
|
|
627
|
+
captions keeps the slot (the bubble lives there). For a custom slide, keep it
|
|
628
|
+
compatible: the presenter's corner is the slide's `.narration-slot`, never a
|
|
629
|
+
hard-coded gutter (`padding-right: 400px`); if it needs one, override it in a
|
|
630
|
+
`:root.hd-no-presenter .l-yours …` rule (check warns on a right gutter until
|
|
631
|
+
you do).
|
|
632
|
+
|
|
599
633
|
<!-- topic: script -->
|
|
600
634
|
### 3. Write slides and script
|
|
601
635
|
|
|
@@ -91,9 +91,11 @@ makes the hello deck; continue from step 5 below.
|
|
|
91
91
|
10. Later: `list_shares` and `deck_stats` (both show the card),
|
|
92
92
|
`activity_overview`; `email_embed { token }` for the HTML that pastes into an
|
|
93
93
|
email as the deck's moving preview (it needs a recipient's link, not a QA
|
|
94
|
-
one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle }`
|
|
95
|
-
for how the deck opens
|
|
96
|
-
|
|
94
|
+
one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle, pace }`
|
|
95
|
+
for how the deck opens, whether viewers may switch, and its pace (`auto`
|
|
96
|
+
plays itself, `slide` waits for Next after each slide, `step` also stops
|
|
97
|
+
before each `[reveal]`, so group a slide's reveals into meaningful steps)
|
|
98
|
+
(every link, next load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
|
|
97
99
|
`set_recipient_archived` to tidy the activity feed; `credits` for the
|
|
98
100
|
balance. `remove_deck` deletes a deck and breaks every link to it: confirm
|
|
99
101
|
with the user first.
|
package/src/checkRules.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// the deck's index.html and script.md as text, with the manifest and clip fetches
|
|
6
6
|
// injected.
|
|
7
7
|
|
|
8
|
+
import { DASH_AVATAR, presenterTagIssue } from "./presenter.mjs";
|
|
9
|
+
|
|
8
10
|
export const PLAYER_V1 = "https://hyperdecks.ai/player/v1/player.js";
|
|
9
11
|
// Decks released before the app moved to hyperdecks.ai load the same player
|
|
10
12
|
// from the old host, which serves it forever; both pass.
|
|
@@ -58,6 +60,8 @@ export const MESSAGES = {
|
|
|
58
60
|
selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
|
|
59
61
|
gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
|
|
60
62
|
gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
|
|
63
|
+
presenterUnfilled: (mode) => `the player tag says data-presenter="${mode}" but names no face (${mode === "head" ? "data-avatar" : "data-presenter-image"}), so as written it plays captions only. release puts the narrator's face on it; to set one by hand, D.A.S.H. is data-presenter="head" data-avatar="${DASH_AVATAR}" (deck_guide topic presenter)`,
|
|
64
|
+
presenterKept: (mode) => `the player tag has a data-avatar but data-presenter="${mode ?? "none"}", so it plays captions only and release leaves it that way: set data-presenter="head" to show that face, or remove data-avatar so release adds the narrator's`,
|
|
61
65
|
presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
|
|
62
66
|
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
63
67
|
transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
|
|
@@ -67,6 +71,7 @@ export const MESSAGES = {
|
|
|
67
71
|
flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
|
|
68
72
|
// Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
|
|
69
73
|
fewPictures: (shown, total) => `only ${shown} of ${total} slides show a picture (a screenshot, a recording, a photo, a chart); aim for a real one on every other slide (deck_guide topic look)`,
|
|
74
|
+
presenterGutter: (where, decl) => `${where}: ${decl} keeps a right gutter for the presenter; leave that corner to the slide's .narration-slot so a deck without a presenter can use it (deck_guide topic look)`,
|
|
70
75
|
mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
|
|
71
76
|
// A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
|
|
72
77
|
placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
|
|
@@ -790,6 +795,68 @@ export function lookWarnings(html) {
|
|
|
790
795
|
return out;
|
|
791
796
|
}
|
|
792
797
|
|
|
798
|
+
// A wide right padding or margin (PRESENTER_GUTTER px or more, and wider than
|
|
799
|
+
// the left: a centred column is not a gutter) is a gutter held for the
|
|
800
|
+
// presenter outside its .narration-slot: without a presenter
|
|
801
|
+
// (:root.hd-no-presenter) it stays empty. Read from inline styles on a slide
|
|
802
|
+
// and its elements, and from the deck's <style> rules (by selector). Skipped:
|
|
803
|
+
// rules in a phone media query (max-width, no min-width), in @keyframes and
|
|
804
|
+
// the like, on the slot itself, the no-presenter rules, and a selector whose
|
|
805
|
+
// gutter a :root.hd-no-presenter rule overrides (the variant the guide asks for).
|
|
806
|
+
// One linear pass over the text: a malformed paste must not stall check.
|
|
807
|
+
export const PRESENTER_GUTTER = 240;
|
|
808
|
+
const sides = (prop, value) => {
|
|
809
|
+
const px = (v) => { const m = /^(\d+(?:\.\d+)?)(px|rem|em)$/i.exec(v ?? ""); return m ? Number(m[1]) * (m[2].toLowerCase() === "px" ? 1 : 16) : 0; };
|
|
810
|
+
const parts = value.trim().split(/\s+/);
|
|
811
|
+
if (/-(right|inline-end)$/i.test(prop)) return { right: px(parts[0]), left: 0 };
|
|
812
|
+
return { right: px(parts[1] ?? parts[0]), left: px(parts[3] ?? parts[1] ?? parts[0]) };
|
|
813
|
+
};
|
|
814
|
+
const gutters = (decls) => decls.split(";").map((d) => /^\s*((?:padding|margin)(?:-right|-inline-end)?)\s*:\s*([^!]+)/i.exec(d)).filter(Boolean)
|
|
815
|
+
.filter(([, prop, value]) => { const { right, left } = sides(prop, value); return right >= PRESENTER_GUTTER && right > left; })
|
|
816
|
+
.map(([, prop, value]) => `${prop.toLowerCase()}: ${value.trim()}`);
|
|
817
|
+
// Each style rule of a stylesheet: { selector, decls, at } where `at` lists the
|
|
818
|
+
// @-rule preludes around it. Comments drop out; braces nest.
|
|
819
|
+
function cssRules(css) {
|
|
820
|
+
const out = [], stack = [];
|
|
821
|
+
let start = 0, i = 0;
|
|
822
|
+
let text = "";
|
|
823
|
+
while (i < css.length) {
|
|
824
|
+
if (css.startsWith("/*", i)) { const end = css.indexOf("*/", i + 2); text += css.slice(start, i); i = start = end === -1 ? css.length : end + 2; continue; }
|
|
825
|
+
const ch = css[i];
|
|
826
|
+
if (ch === "{" || ch === "}") {
|
|
827
|
+
text += css.slice(start, i);
|
|
828
|
+
if (ch === "{") stack.push(text.trim());
|
|
829
|
+
else {
|
|
830
|
+
const head = stack.pop();
|
|
831
|
+
if (head !== undefined && !head.startsWith("@")) out.push({ selector: head, decls: text, at: stack.filter((h) => h.startsWith("@")) });
|
|
832
|
+
}
|
|
833
|
+
text = "";
|
|
834
|
+
start = i + 1;
|
|
835
|
+
}
|
|
836
|
+
i++;
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
const phoneOnly = (at) => at.some((h) => /^@media\b/i.test(h) && /max-width/i.test(h) && !/min-width/i.test(h));
|
|
841
|
+
const NO_PRESENTER = /^:root\.hd-no-presenter\s+/;
|
|
842
|
+
export function presenterGutterWarnings(html) {
|
|
843
|
+
const out = [];
|
|
844
|
+
for (const [i, s] of slides(html).entries()) {
|
|
845
|
+
const where = `slide ${s.id || i + 1}`;
|
|
846
|
+
for (const [, value] of `${s.tag}\n${uncommented(s.body)}`.matchAll(/\sstyle\s*=\s*("[^"]*"|'[^']*')/gi)) {
|
|
847
|
+
for (const decl of gutters(value.slice(1, -1))) out.push(MESSAGES.presenterGutter(where, decl));
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const rules = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].flatMap(([, css]) => cssRules(css));
|
|
851
|
+
const overridden = new Set(rules.filter((r) => NO_PRESENTER.test(r.selector) && /(padding|margin)/i.test(r.decls)).map((r) => r.selector.replace(NO_PRESENTER, "")));
|
|
852
|
+
for (const r of rules) {
|
|
853
|
+
if (r.at.some((h) => !/^@(media|supports|layer|container)\b/i.test(h)) || phoneOnly(r.at)) continue;
|
|
854
|
+
if (NO_PRESENTER.test(r.selector) || /\.narration-slot\b/.test(r.selector) || overridden.has(r.selector)) continue;
|
|
855
|
+
for (const decl of gutters(r.decls)) out.push(MESSAGES.presenterGutter(`rule ${r.selector}`, decl));
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
|
|
793
860
|
// Every rule on the deck's text. `exists(ref)` answers whether a local reference
|
|
794
861
|
// resolves (true, false or "outside"); `hosted: true` means there is no folder,
|
|
795
862
|
// so every local reference is an error naming upload_deck_asset, a hotlinked
|
|
@@ -809,7 +876,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
809
876
|
const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
|
|
810
877
|
const warnings = [];
|
|
811
878
|
if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
|
|
812
|
-
warnings.push(...lookWarnings(html));
|
|
879
|
+
warnings.push(...lookWarnings(html), ...presenterGutterWarnings(html));
|
|
813
880
|
// Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
|
|
814
881
|
const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
|
|
815
882
|
for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
|
|
@@ -873,6 +940,9 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
873
940
|
}
|
|
874
941
|
}
|
|
875
942
|
warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
|
|
943
|
+
// A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
|
|
944
|
+
const tagIssue = silent ? null : presenterTagIssue(html);
|
|
945
|
+
if (tagIssue) warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter) : MESSAGES.presenterUnfilled(tagIssue.presenter));
|
|
876
946
|
for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
|
|
877
947
|
for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
|
|
878
948
|
if (opts.hosted) {
|
package/src/commands.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
|
+
backupLinkUrl,
|
|
4
5
|
installHyperdecksSkill,
|
|
5
6
|
login,
|
|
6
7
|
logout,
|
|
7
8
|
openBrowser,
|
|
9
|
+
linkBase,
|
|
8
10
|
parse,
|
|
9
11
|
target,
|
|
10
12
|
VERSION,
|
|
@@ -60,7 +62,7 @@ async function deckStats(pos, opts) {
|
|
|
60
62
|
console.log(` unique viewers: ${s.viewers}`);
|
|
61
63
|
console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
|
|
62
64
|
console.log(` total engaged: ${fmtMs(s.totalMs)}`);
|
|
63
|
-
if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
|
|
65
|
+
if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.pace ? `, pace ${s.pace}` : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
|
|
64
66
|
if (s.recent?.length) {
|
|
65
67
|
console.log(" recent:");
|
|
66
68
|
for (const r of s.recent) {
|
|
@@ -85,10 +87,6 @@ async function deckRemove(pos, opts) {
|
|
|
85
87
|
console.log(`OK removed deck ${slug}`);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
function shareBase(opts) {
|
|
89
|
-
return target(opts).shareBase;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
90
|
async function shareNew(pos, opts) {
|
|
93
91
|
const [slug, recipient] = pos;
|
|
94
92
|
if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
|
|
@@ -105,9 +103,13 @@ async function shareNew(pos, opts) {
|
|
|
105
103
|
expires: opts.expires,
|
|
106
104
|
limit: opts.limit,
|
|
107
105
|
});
|
|
108
|
-
const url = `${
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
const url = `${await linkBase(opts)}/v/${token}`;
|
|
107
|
+
const backupUrl = backupLinkUrl(url);
|
|
108
|
+
if (opts.json) console.log(JSON.stringify({ token, url, backupUrl }, null, 2));
|
|
109
|
+
else {
|
|
110
|
+
console.log(`OK share for "${recipient}" -> ${url}`);
|
|
111
|
+
if (backupUrl) console.log(` backup link (same link, other domain, if their network blocks it): ${backupUrl}`);
|
|
112
|
+
}
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
async function shareList(pos, opts) {
|
|
@@ -156,23 +158,28 @@ async function deckForward(pos, opts) {
|
|
|
156
158
|
console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off]";
|
|
161
|
+
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]";
|
|
160
162
|
const VIEWS = { slides: "slides", scroll: "scroll", default: null };
|
|
163
|
+
const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
|
|
161
164
|
async function deckView(pos, opts) {
|
|
162
165
|
const [slug, mode] = pos;
|
|
163
166
|
const toggle = opts.toggle;
|
|
164
|
-
|
|
167
|
+
const pace = opts.pace;
|
|
168
|
+
if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle") && !Object.hasOwn(opts, "pace"))) throw new Error(VIEW_USAGE);
|
|
165
169
|
if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
|
|
166
170
|
// A trailing --toggle with no value is a mistake, not "leave it".
|
|
167
171
|
if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
|
|
172
|
+
if (Object.hasOwn(opts, "pace") && !Object.hasOwn(PACES, pace)) throw new Error(VIEW_USAGE);
|
|
168
173
|
const out = await ops.setDeckView(opts, slug, {
|
|
169
174
|
view: mode === undefined ? undefined : VIEWS[mode],
|
|
170
175
|
viewToggle: toggle === undefined ? undefined : toggle === "on",
|
|
176
|
+
pace: pace === undefined ? undefined : PACES[pace],
|
|
171
177
|
});
|
|
172
178
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
173
179
|
const said = [];
|
|
174
180
|
if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
|
|
175
181
|
if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
|
|
182
|
+
if (pace !== undefined) said.push(pace === "default" ? "paces as its HTML says" : `pace ${pace}`);
|
|
176
183
|
if (out.applies === false) {
|
|
177
184
|
console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
|
|
178
185
|
console.warn(`warning: ${out.warning}`);
|
|
@@ -181,10 +188,14 @@ async function deckView(pos, opts) {
|
|
|
181
188
|
console.log(`OK deck ${slug} ${said.join(", ")} (every link, on its next load)`);
|
|
182
189
|
}
|
|
183
190
|
|
|
184
|
-
function shareUrl(pos, opts) {
|
|
191
|
+
async function shareUrl(pos, opts) {
|
|
185
192
|
const [token] = pos;
|
|
186
|
-
if (!token) throw new Error("usage: share url <token>");
|
|
187
|
-
|
|
193
|
+
if (!token) throw new Error("usage: share url <token> [--backup]");
|
|
194
|
+
const url = `${await linkBase(opts)}/v/${token}`;
|
|
195
|
+
console.log(url);
|
|
196
|
+
// Second line: the same link on the other domain (stdout stays one URL per line).
|
|
197
|
+
const backupUrl = backupLinkUrl(url);
|
|
198
|
+
if (backupUrl && opts.backup) console.log(backupUrl);
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
async function recipientArchive(pos, opts, archived) {
|
|
@@ -336,7 +347,7 @@ commands:
|
|
|
336
347
|
deck list|stats|open|rm|forward|view
|
|
337
348
|
deck forward on|off <slug>
|
|
338
349
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
339
|
-
deck view <slug> [slides|scroll|default] [--toggle on|off]
|
|
350
|
+
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]
|
|
340
351
|
how the deck opens in the player (default: as its HTML says, data-view or slides) and
|
|
341
352
|
whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
|
|
342
353
|
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
|
|
@@ -441,7 +452,7 @@ commands:
|
|
|
441
452
|
|
|
442
453
|
narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
|
|
443
454
|
script.md → voiced clips, timelines and a manifest, rendered server-side; only changed slides re-render
|
|
444
|
-
voice list [--json] your voices and their status
|
|
455
|
+
voice list [--json] your voices and their status, then the built-in voices anyone can narrate with
|
|
445
456
|
voice clone <file...> --name <name>
|
|
446
457
|
clone your voice from 1-3 mp3/m4a/wav recordings (30 s to 3 min in total, 10 MB each);
|
|
447
458
|
asks you to type "I consent" before anything is uploaded
|
|
@@ -851,10 +862,12 @@ function commandTable(pos, rest, opts) {
|
|
|
851
862
|
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
852
863
|
},
|
|
853
864
|
"voice list": async () => {
|
|
854
|
-
const voices = await ops.
|
|
865
|
+
const voices = await ops.listAllVoices(opts);
|
|
855
866
|
if (opts.json) return void console.log(JSON.stringify(voices, null, 2));
|
|
856
|
-
|
|
857
|
-
|
|
867
|
+
const own = voices.filter((x) => !x.builtin);
|
|
868
|
+
for (const x of own) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
|
|
869
|
+
if (!own.length) console.log("(no voices of your own; voice clone <file...> --name <name> makes one)");
|
|
870
|
+
for (const x of voices.filter((x) => x.builtin)) console.log(`${x.name}\tbuilt in\t${x.description}\t${x.id}`);
|
|
858
871
|
},
|
|
859
872
|
"voice clone": async () => {
|
|
860
873
|
const { voiceClone } = await import("./voice.mjs");
|
package/src/guide.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function skillSection(skill, topic) {
|
|
|
29
29
|
function layoutsTopic() {
|
|
30
30
|
const rows = LAYOUTS.map((name) => {
|
|
31
31
|
const { meta } = readLayout(name);
|
|
32
|
-
return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
|
|
32
|
+
return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.nopresenter && `Without a presenter: ${meta.nopresenter}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
|
|
33
33
|
});
|
|
34
34
|
return `# Layouts\n\nPass any of these, in order, as new_deck's \`layouts\` (repeat one for more of the same: number, number gives slides number and number-2).\n\n${rows.join("\n\n")}`;
|
|
35
35
|
}
|
package/src/init.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import * as ops from "./ops.mjs";
|
|
19
19
|
import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
|
|
20
|
-
import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
|
|
20
|
+
import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, linkBase, login as runLogin, openBrowser, target } from "./runtime.mjs";
|
|
21
21
|
import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
|
|
22
22
|
import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
|
|
23
23
|
import { narrate as runNarrate } from "./narrate.mjs";
|
|
@@ -450,9 +450,13 @@ async function presenterStep(base, opts, { interactive, ask, log, sub, avatars,
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
// decks:get answers only for decks the caller owns, so whether the slug is
|
|
454
|
+
// taken at all comes from slugTaken.
|
|
453
455
|
async function deckOwnedBy(base, slug, who) {
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
+
const c = await client(base);
|
|
457
|
+
if (!(await c.query(api.decks.slugTaken, { slug }))) return { exists: false, mine: false };
|
|
458
|
+
const deck = await c.query(api.decks.get, { slug });
|
|
459
|
+
return { exists: true, mine: !!deck && deck.ownerId === who.subject };
|
|
456
460
|
}
|
|
457
461
|
|
|
458
462
|
async function scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save }) {
|
|
@@ -544,7 +548,7 @@ async function releaseStep(base, opts, state, { release, sub, save, fetch }) {
|
|
|
544
548
|
// A run killed after release made its link but before init.json recorded it.
|
|
545
549
|
const mine = !state.releasedAt && shares.find((s) => s.qa && s.recipient === "Me" && s.enabled);
|
|
546
550
|
if (mine) {
|
|
547
|
-
const url = `${
|
|
551
|
+
const url = `${await linkBase(base)}/v/${mine.token}`;
|
|
548
552
|
save({ releasedAt: new Date().toISOString(), shareUrl: url, shareToken: mine.token });
|
|
549
553
|
return step("release", "done", `released: ${url}`);
|
|
550
554
|
}
|
package/src/layouts.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export function fillTemplate(file, { slug, title, presenterStyle }) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// One layout file: a leading comment of `key: value` lines (layout, summary,
|
|
23
|
-
// use, slot, presenter, cues, script), its <style> and its <section>.
|
|
23
|
+
// use, slot, presenter, nopresenter, cues, script), its <style> and its <section>.
|
|
24
24
|
export function readLayout(name) {
|
|
25
25
|
const text = packageText(`templates/layouts/${name}.html`);
|
|
26
26
|
const head = /^<!--([\s\S]*?)-->/.exec(text);
|
package/src/ops.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer
|
|
|
2
2
|
import { zipSync } from "fflate";
|
|
3
3
|
import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
|
|
4
4
|
import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
|
|
5
|
-
import { api, client, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
5
|
+
import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
6
6
|
import {
|
|
7
7
|
bytesFromText,
|
|
8
8
|
isTextAsset,
|
|
@@ -19,8 +19,9 @@ export async function whoami(opts = {}) {
|
|
|
19
19
|
return (await client(opts)).query(api.decks.whoami, {});
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Your own decks. `all: true` lists every deck, for admins only (support tools).
|
|
23
|
+
export async function listDecks(opts = {}, { all = false } = {}) {
|
|
24
|
+
const rows = await (await client(opts)).query(all ? api.decks.listAll : api.decks.listForViewer, {});
|
|
24
25
|
return rows.map((d) => ({
|
|
25
26
|
slug: d.slug,
|
|
26
27
|
title: d.title,
|
|
@@ -30,6 +31,8 @@ export async function listDecks(opts = {}) {
|
|
|
30
31
|
// How it opens (null: as its HTML says) and whether viewers may switch.
|
|
31
32
|
view: d.view ?? null,
|
|
32
33
|
viewToggle: d.viewToggle !== false,
|
|
34
|
+
// How the player moves on: auto, slide or step (null: as its HTML says, else auto).
|
|
35
|
+
pace: d.pace ?? null,
|
|
33
36
|
// false: the deck runs its own player, so view/viewToggle do not apply
|
|
34
37
|
// (null: not known yet, treated as on).
|
|
35
38
|
usesPlayer: d.usesPlayer ?? null,
|
|
@@ -264,7 +267,11 @@ export async function ownedDeck(opts, slug) {
|
|
|
264
267
|
const c = await client(opts);
|
|
265
268
|
const [deck, me] = await Promise.all([c.query(api.decks.get, { slug }), c.query(api.decks.whoami, {})]);
|
|
266
269
|
if (!me) throw new Error("not logged in: run `hyperdecks login` first");
|
|
267
|
-
if (!deck)
|
|
270
|
+
if (!deck) {
|
|
271
|
+
// get answers only for your own decks; say so when the slug is someone else's.
|
|
272
|
+
if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`forbidden: ${slug} is not your deck`);
|
|
273
|
+
throw new Error(`deck not found: ${slug} (register it first, or pass --slug)`);
|
|
274
|
+
}
|
|
268
275
|
if (!me.isAdmin && deck.ownerId !== me.subject) throw new Error(`forbidden: ${slug} is not your deck`);
|
|
269
276
|
return deck;
|
|
270
277
|
}
|
|
@@ -288,7 +295,7 @@ export const VIDEO_RECIPIENT = "Video viewers";
|
|
|
288
295
|
export async function videoShareLink(opts, slug) {
|
|
289
296
|
const live = (await listShares(opts, slug)).find((s) => s.recipient === VIDEO_RECIPIENT && s.enabled && !s.qa && !s.archived && !s.requireEmail && (s.expiresAt == null || s.expiresAt > Date.now()) && (s.viewLimit == null || s.opens < s.viewLimit));
|
|
290
297
|
const token = live?.token ?? (await createShare(opts, { slug, recipient: VIDEO_RECIPIENT })).token;
|
|
291
|
-
return `${
|
|
298
|
+
return `${await linkBase(opts)}/v/${token}`;
|
|
292
299
|
}
|
|
293
300
|
|
|
294
301
|
// `export --video --upload`: file the MP4 with the deck's exports (30 days).
|
|
@@ -428,10 +435,24 @@ export async function checkNarrationJob(opts, jobId) {
|
|
|
428
435
|
return (await client(opts)).query(api.narration.getJob, { jobId: String(jobId) });
|
|
429
436
|
}
|
|
430
437
|
|
|
438
|
+
// Your own voices (what clone, rename and delete act on).
|
|
431
439
|
export async function listVoices(opts = {}) {
|
|
432
440
|
return (await client(opts)).query(api.voices.listVoices, {});
|
|
433
441
|
}
|
|
434
442
|
|
|
443
|
+
// Every voice you can narrate with: yours, then the built-in ones (builtin: true,
|
|
444
|
+
// id stock:<id>), which any account may use. What list_voices shows.
|
|
445
|
+
export async function listAllVoices(opts = {}) {
|
|
446
|
+
const c = await client(opts);
|
|
447
|
+
try {
|
|
448
|
+
return await c.query(api.voices.listVoices, { builtin: true });
|
|
449
|
+
} catch (e) {
|
|
450
|
+
// A backend from before built-in voices refuses the argument: your own voices, then.
|
|
451
|
+
if (!/builtin|extra field|Validator/i.test(errorText(e))) throw e;
|
|
452
|
+
return c.query(api.voices.listVoices, {});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
435
456
|
// ---- voice cloning (convex/voices.ts) ---------------------------------------
|
|
436
457
|
|
|
437
458
|
// The text of a backend error: ConvexError data (production redacts the rest), else the message.
|
|
@@ -603,18 +624,23 @@ export async function setDeckForwarding(opts, slug, allowForward) {
|
|
|
603
624
|
|
|
604
625
|
// How a deck opens in the player and whether viewers may switch: view is
|
|
605
626
|
// "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
|
|
606
|
-
// true/false
|
|
607
|
-
//
|
|
627
|
+
// true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
|
|
628
|
+
// slide), "step" (also stops before each [reveal] cue), or null for the deck's
|
|
629
|
+
// own HTML. Any may be left out; all go in one owner-only mutation, so a
|
|
630
|
+
// failure changes none.
|
|
608
631
|
// Applies on every link's next load (the route injects it), with no re-release.
|
|
609
632
|
export const DECK_VIEWS = ["slides", "scroll"];
|
|
610
|
-
export
|
|
633
|
+
export const DECK_PACES = ["auto", "slide", "step"];
|
|
634
|
+
export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
|
|
611
635
|
if (!slug) throw new Error("slug is required");
|
|
612
|
-
if (view === undefined && viewToggle === undefined) throw new Error("give a view (slides, scroll or default) and/or a
|
|
636
|
+
if (view === undefined && viewToggle === undefined && pace === undefined) throw new Error("give a view (slides, scroll or default), a viewToggle and/or a pace (auto, slide, step or default)");
|
|
613
637
|
if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
|
|
638
|
+
if (pace !== undefined && pace !== null && !DECK_PACES.includes(pace)) throw new Error(`pace must be auto, slide, step or null (got ${JSON.stringify(pace)})`);
|
|
614
639
|
const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
|
|
615
640
|
slug,
|
|
616
641
|
...(view !== undefined ? { view } : {}),
|
|
617
642
|
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
643
|
+
...(pace !== undefined ? { pace } : {}),
|
|
618
644
|
});
|
|
619
645
|
// Saved either way; a deck on its own player says the setting won't apply.
|
|
620
646
|
const applies = res?.applies !== false;
|
|
@@ -622,6 +648,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
|
|
|
622
648
|
slug,
|
|
623
649
|
...(view !== undefined ? { view } : {}),
|
|
624
650
|
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
651
|
+
...(pace !== undefined ? { pace } : {}),
|
|
625
652
|
applies,
|
|
626
653
|
...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
|
|
627
654
|
};
|
|
@@ -631,7 +658,7 @@ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
|
|
|
631
658
|
// upsert would otherwise reset your own live deck to a draft and rename it.
|
|
632
659
|
export async function createDeck(opts, { slug, title }) {
|
|
633
660
|
const c = await client(opts);
|
|
634
|
-
if (await c.query(api.decks.
|
|
661
|
+
if (await c.query(api.decks.slugTaken, { slug })) throw new Error(`deck ${slug} already exists; pick another slug`);
|
|
635
662
|
await c.mutation(api.decks.upsert, { slug, title, status: "draft" });
|
|
636
663
|
return { slug, title, status: "draft" };
|
|
637
664
|
}
|
|
@@ -1146,7 +1173,7 @@ export async function listNarrators(opts = {}) {
|
|
|
1146
1173
|
default: out.defaultId,
|
|
1147
1174
|
...(out.unpickedVoice ? { note: `no default narrator yet: decks narrate in your voice "${out.unpickedVoice}"` } : {}),
|
|
1148
1175
|
narrators: out.narrators.map((n) => ({ id: n.id, name: n.name, spokenName: n.spokenName ?? null, isDefault: n.isDefault, builtin: n.builtin, face: n.face?.name ?? null, voice: n.voice?.name ?? null, voiceReady: !!n.voice?.ready })),
|
|
1149
|
-
voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, narrators: x.narrators })),
|
|
1176
|
+
voices: out.voices.map((x) => ({ id: x.ref, name: x.name, status: x.status, character: x.character, builtin: !!x.builtin, narrators: x.narrators })),
|
|
1150
1177
|
};
|
|
1151
1178
|
}
|
|
1152
1179
|
|
|
@@ -1188,7 +1215,8 @@ export async function removeNarrator(opts, ref) {
|
|
|
1188
1215
|
return { ...(await (await client(opts)).mutation(api.narrators.remove, { narrator: n.id })), id: n.id };
|
|
1189
1216
|
}
|
|
1190
1217
|
|
|
1191
|
-
// voice: one of your ready voices (name or id) or
|
|
1218
|
+
// voice: one of your ready voices (name or id), a built-in voice (name or
|
|
1219
|
+
// stock:<id>, list_voices), or dash.
|
|
1192
1220
|
export async function setNarratorVoice(opts, ref, voice) {
|
|
1193
1221
|
if (!voice) throw new Error("usage: narrator voice <narrator> <voice>");
|
|
1194
1222
|
const n = await findNarrator(opts, ref);
|
package/src/presenter.mjs
CHANGED
|
@@ -12,32 +12,118 @@
|
|
|
12
12
|
export const DASH_AVATAR = "https://hyperdecks.ai/player/v1/dash-avatar.json";
|
|
13
13
|
export const DASH_PRESENTER = Object.freeze({ avatarUrl: DASH_AVATAR, imageUrl: "https://hyperdecks.ai/avatars/dash.png" });
|
|
14
14
|
|
|
15
|
-
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/
|
|
15
|
+
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/gi;
|
|
16
16
|
|
|
17
17
|
const attr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """);
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// The live player tag: the first one outside an HTML comment (a commented-out
|
|
20
|
+
// copy is not what plays), with where it starts. check's playerScript reads it
|
|
21
|
+
// the same way (closed comments only, so a stray "<!--" in a script hides nothing).
|
|
22
|
+
function playerTag(html) {
|
|
23
|
+
const comments = [...html.matchAll(/<!--[\s\S]*?-->/g)].map((m) => [m.index, m.index + m[0].length]);
|
|
24
|
+
for (const m of html.matchAll(PLAYER_TAG)) {
|
|
25
|
+
if (!comments.some(([a, b]) => m.index >= a && m.index < b)) return { tag: m[0], index: m.index };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const value = (tag, name) => {
|
|
31
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`).exec(tag);
|
|
32
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The face this tag shows, decided as packages/player/player.js does: data-presenter
|
|
36
|
+
// head|static|none, else static when there is a still, else none. head draws
|
|
37
|
+
// data-avatar (and falls back to the still without one); static draws the
|
|
38
|
+
// still. Null: the deck plays the voice and captions with no face.
|
|
39
|
+
function tagFace(tag) {
|
|
40
|
+
const presenter = value(tag, "data-presenter");
|
|
41
|
+
const avatarUrl = value(tag, "data-avatar") || null;
|
|
42
|
+
const imageUrl = value(tag, "data-presenter-image") || null;
|
|
43
|
+
const mode = ["head", "static", "none"].includes(presenter) ? presenter : imageUrl ? "static" : "none";
|
|
44
|
+
if (mode === "head" && (avatarUrl || imageUrl)) return { avatarUrl, imageUrl };
|
|
45
|
+
if (mode === "static" && imageUrl) return { avatarUrl: null, imageUrl };
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The face the page's player shows ({ avatarUrl, imageUrl }, either may be
|
|
50
|
+
// null), or null when it plays captions only or has no player tag.
|
|
51
|
+
export function presentedFace(html) {
|
|
52
|
+
const found = playerTag(html);
|
|
53
|
+
return found ? tagFace(found.tag) : null;
|
|
54
|
+
}
|
|
22
55
|
|
|
23
|
-
// Whether the page's player tag
|
|
56
|
+
// Whether the page's player tag shows no face, so it has captions only.
|
|
24
57
|
export function playsCaptionsOnly(html) {
|
|
25
|
-
const
|
|
26
|
-
return !!
|
|
58
|
+
const found = playerTag(html);
|
|
59
|
+
return !!found && !tagFace(found.tag);
|
|
27
60
|
}
|
|
28
61
|
|
|
29
62
|
// html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
|
|
30
|
-
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
|
|
31
|
-
//
|
|
63
|
+
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL), or
|
|
64
|
+
// html unchanged when there is no such tag, the tag already shows a face, or it
|
|
65
|
+
// names a non-empty data-avatar of its own (set by hand: left alone even beside "none").
|
|
66
|
+
// Anything else the player would play captions only (data-presenter none or
|
|
67
|
+
// missing, head with no data-avatar, static with no still) gets the face.
|
|
32
68
|
export function withAvatar(html, { avatarUrl, imageUrl }) {
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const image = imageUrl &&
|
|
69
|
+
const found = playerTag(html);
|
|
70
|
+
if (!found) return html;
|
|
71
|
+
const { tag, index } = found;
|
|
72
|
+
if (tagFace(tag) || value(tag, "data-avatar")) return html;
|
|
73
|
+
const image = imageUrl && value(tag, "data-presenter-image") === null ? ` data-presenter-image="${attr(imageUrl)}"` : "";
|
|
38
74
|
const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
|
|
39
|
-
const
|
|
40
|
-
|
|
75
|
+
const named = /\sdata-presenter\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/;
|
|
76
|
+
const next = named.test(tag) ? tag.replace(named, face) : tag.replace(/\s*\/?>$/, (end) => `${face}${end}`);
|
|
77
|
+
return html.slice(0, index) + next + html.slice(index + tag.length);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What is wrong with the player tag's presenter as written, for check: null, or
|
|
81
|
+
// { kind, presenter }. "unfilled": it asks for a face (head or static) but names
|
|
82
|
+
// none, so it plays captions only until release fills it in. "kept": it has a
|
|
83
|
+
// data-avatar the player ignores (data-presenter="none"), and release leaves it.
|
|
84
|
+
// A plain "none" or no data-presenter is not an issue: release adds the face.
|
|
85
|
+
export function presenterTagIssue(html) {
|
|
86
|
+
const found = playerTag(html);
|
|
87
|
+
if (!found || tagFace(found.tag)) return null;
|
|
88
|
+
const presenter = value(found.tag, "data-presenter");
|
|
89
|
+
if (value(found.tag, "data-avatar")) return { kind: "kept", presenter };
|
|
90
|
+
if (presenter === "head" || presenter === "static") return { kind: "unfilled", presenter };
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Why a resolved face did not land on the page: withAvatar leaves a tag with
|
|
95
|
+
// its own data-avatar alone, and a page with no live player tag has nowhere to put it.
|
|
96
|
+
function keptOutReason(html) {
|
|
97
|
+
const found = playerTag(html);
|
|
98
|
+
if (!found) return "the page has no live player tag to put the face on";
|
|
99
|
+
const mode = value(found.tag, "data-presenter");
|
|
100
|
+
return `the player tag names its own data-avatar, but data-presenter=${mode === null ? " is missing" : `"${mode}"`} so it plays no face, and release leaves a hand-set data-avatar alone: set data-presenter="head", or remove data-avatar`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What a release says about the face, from the page as written (`html`), the
|
|
104
|
+
// face it puts on (`presenter`, from avatars.presenterFor; null for none), who
|
|
105
|
+
// the backend says presents (`who`: { narrator, voice, face, reason }) and, when
|
|
106
|
+
// the face lookup itself failed, `lookupError`. Returns the page that ships, a
|
|
107
|
+
// line for the terminal and the summary to report: "presented by" only when
|
|
108
|
+
// that page shows a face, and with no resolved face on it, the tag's own.
|
|
109
|
+
// Both releases use it: release.mjs and convex/deckSources.ts.
|
|
110
|
+
export const TAG_FACE = "the face set on the player tag";
|
|
111
|
+
export function releasedPresenter(html, presenter, who, { lookupError } = {}) {
|
|
112
|
+
const page = presenter ? withAvatar(html, presenter) : html;
|
|
113
|
+
const captions = (reason) => ({ page, line: `⚠ releasing captions only: ${reason}`, summary: who ? { ...who, face: null, reason } : null });
|
|
114
|
+
const shown = presentedFace(page);
|
|
115
|
+
if (shown) {
|
|
116
|
+
// Left as written, with no resolved face or another one: whatever the tag names is what shows.
|
|
117
|
+
if (page === html && shown.avatarUrl !== presenter?.avatarUrl) {
|
|
118
|
+
return { page, line: `✓ presented by ${TAG_FACE} (${shown.avatarUrl ? "data-avatar" : "data-presenter-image"})`, summary: who ? { ...who, face: TAG_FACE } : null };
|
|
119
|
+
}
|
|
120
|
+
return { page, line: `✓ presented by ${who?.face ?? "your avatar"}`, summary: who ?? null };
|
|
121
|
+
}
|
|
122
|
+
if (lookupError) return captions(`could not look up your avatar (${lookupError}); release again to try once more`);
|
|
123
|
+
if (presenter) return captions(keptOutReason(page));
|
|
124
|
+
if (who && !who.face) return { page, line: `⚠ releasing captions only: ${who.reason ?? "no face"}`, summary: who };
|
|
125
|
+
if (who?.face) return captions(`no face for "${who.face}" came back to put on the player tag`);
|
|
126
|
+
return { page, line: "", summary: null };
|
|
41
127
|
}
|
|
42
128
|
|
|
43
129
|
// One line for who presents a deck, from narrate's or release's `presenter`
|
package/src/preview.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
2
2
|
import { resolve, extname } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { api, client, target, uploadBytes } from "./runtime.mjs";
|
|
4
|
+
import { api, client, linkBase, target, uploadBytes } from "./runtime.mjs";
|
|
5
5
|
|
|
6
6
|
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
7
7
|
export function httpUrl(value) {
|
|
@@ -58,7 +58,7 @@ export async function createPreview(pos, opts, adapters = {}) {
|
|
|
58
58
|
if (opts.share) {
|
|
59
59
|
if (deck.status === 'archived') throw new Error('Archived deck cannot provide a sendable share preview');
|
|
60
60
|
validateShare(await c.query(api.shares.listForDeck, { deckSlug: config.slug }), config.slug, opts.share);
|
|
61
|
-
shareUrl = httpUrl(`${
|
|
61
|
+
shareUrl = httpUrl(`${await linkBase(opts)}/v/${opts.share}`);
|
|
62
62
|
}
|
|
63
63
|
html = deck.html;
|
|
64
64
|
if (!html && deck.htmlUrl) {
|
package/src/release.mjs
CHANGED
|
@@ -16,8 +16,8 @@ import { readFileSync } from "node:fs";
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { zipSync } from "fflate";
|
|
18
18
|
import * as ops from "./ops.mjs";
|
|
19
|
-
import { withAvatar } from "./presenter.mjs";
|
|
20
|
-
import {
|
|
19
|
+
import { releasedPresenter, withAvatar } from "./presenter.mjs";
|
|
20
|
+
import { linkBase } from "./runtime.mjs";
|
|
21
21
|
import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
|
|
22
22
|
import { narrate as runNarrate } from "./narrate.mjs";
|
|
23
23
|
|
|
@@ -112,16 +112,20 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
112
112
|
}
|
|
113
113
|
// The released page gets the narrator's face: your avatar, or D.A.S.H.'s for its voice.
|
|
114
114
|
let presenter = null;
|
|
115
|
+
let lookupError;
|
|
115
116
|
try {
|
|
116
117
|
presenter = await ops.presenterFor(opts, slug, opts.voice);
|
|
117
118
|
} catch (e) {
|
|
118
|
-
|
|
119
|
+
lookupError = ops.errorText(e);
|
|
119
120
|
}
|
|
120
121
|
const released = presenter ? releaseBundle(deckDir, presenter) : bundle;
|
|
121
|
-
|
|
122
|
+
// Say who presents from the page that ships, never from the lookup alone: a
|
|
123
|
+
// resolved face that did not land on the player tag must not be claimed, and
|
|
124
|
+
// a failed lookup is captions only only when the tag names no face of its own.
|
|
125
|
+
const said = releasedPresenter(readFileSync(join(deckDir, "index.html"), "utf8"), presenter, job.presenter, { lookupError });
|
|
122
126
|
// A terminal user sees this before it ships, so the CLI warns rather than refuses.
|
|
123
|
-
|
|
124
|
-
return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary:
|
|
127
|
+
if (said.line) log(said.line);
|
|
128
|
+
return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: said.summary });
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
// Import the checked bundle and make the links: the end of every release.
|
|
@@ -137,7 +141,7 @@ async function publish({ opts, io: { log, commit }, deckDir, slug, to, checked,
|
|
|
137
141
|
const sha = commit(deckDir);
|
|
138
142
|
log(`✓ imported "${imported.title}" as ${imported.slug} (${bundle.files.length} file(s), commit ${sha})\n deck: ${imported.deckUrl}`);
|
|
139
143
|
|
|
140
|
-
const base =
|
|
144
|
+
const base = await linkBase(opts);
|
|
141
145
|
const shares = [];
|
|
142
146
|
for (const r of to) {
|
|
143
147
|
let token;
|
package/src/runtime.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { ConvexHttpClient } from "convex/browser";
|
|
9
9
|
import { makeFunctionReference } from "convex/server";
|
|
10
10
|
|
|
11
|
-
export const VERSION = "0.9.
|
|
11
|
+
export const VERSION = "0.9.8";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
// The app's home. HYPERDECKS_HOST points the prod target's broker and share
|
|
@@ -117,7 +117,9 @@ export const api = {
|
|
|
117
117
|
},
|
|
118
118
|
decks: {
|
|
119
119
|
get: makeFunctionReference("decks:get"),
|
|
120
|
+
slugTaken: makeFunctionReference("decks:slugTaken"),
|
|
120
121
|
listForViewer: makeFunctionReference("decks:listForViewer"),
|
|
122
|
+
listAll: makeFunctionReference("decks:listAll"),
|
|
121
123
|
remove: makeFunctionReference("decks:remove"),
|
|
122
124
|
setAllowForward: makeFunctionReference("decks:setAllowForward"),
|
|
123
125
|
setView: makeFunctionReference("decks:setView"),
|
|
@@ -135,6 +137,7 @@ export const api = {
|
|
|
135
137
|
setAllowForward: makeFunctionReference("shares:setAllowForward"),
|
|
136
138
|
setEnabled: makeFunctionReference("shares:setEnabled"),
|
|
137
139
|
setNotify: makeFunctionReference("shares:setNotify"),
|
|
140
|
+
linkOrigins: makeFunctionReference("shares:linkOrigins"),
|
|
138
141
|
},
|
|
139
142
|
imagegen: {
|
|
140
143
|
generateImage: makeFunctionReference("imagegen:generateImage"),
|
|
@@ -191,7 +194,7 @@ export const api = {
|
|
|
191
194
|
},
|
|
192
195
|
};
|
|
193
196
|
|
|
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"]);
|
|
197
|
+
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", "backup"]);
|
|
195
198
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
196
199
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
197
200
|
// opts.email stays the last --email for the other commands (share new).
|
|
@@ -259,6 +262,63 @@ export function target(opts = {}) {
|
|
|
259
262
|
};
|
|
260
263
|
}
|
|
261
264
|
|
|
265
|
+
// Where recipient links (/v/<token>) are printed: the server says
|
|
266
|
+
// (shares:linkOrigins, RECIPIENT_LINK_ORIGIN in lib/linkOrigin.ts), so links
|
|
267
|
+
// follow the deployment rather than a default baked into this version. Both app
|
|
268
|
+
// hosts serve every link; this only picks the one a new link names.
|
|
269
|
+
// HYPERDECKS_LINK_ORIGIN overrides it; the dev target keeps its share base.
|
|
270
|
+
export const DEFAULT_LINK_ORIGIN = "https://decks.hypersocial.ai";
|
|
271
|
+
const LINK_HOSTS = ["https://decks.hypersocial.ai", "https://hyperdecks.ai"];
|
|
272
|
+
let linkOriginPin = null;
|
|
273
|
+
const linkOriginCache = new Map();
|
|
274
|
+
|
|
275
|
+
// The hosted MCP pins it to the server's own setting (lib/serverCliHost.ts).
|
|
276
|
+
export function pinLinkOrigin(origin) {
|
|
277
|
+
linkOriginPin = origin ? normalizeBase(origin) : null;
|
|
278
|
+
linkOriginCache.clear();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const isLinkHost = (o) => typeof o === "string" && LINK_HOSTS.includes(normalizeBase(o.trim()).toLowerCase());
|
|
282
|
+
|
|
283
|
+
async function askLinkOrigin(c) {
|
|
284
|
+
try {
|
|
285
|
+
const out = await c.query(api.shares.linkOrigins, {});
|
|
286
|
+
// The server only ever names one of the two app hosts; print nothing else.
|
|
287
|
+
if (isLinkHost(out?.origin)) return normalizeBase(out.origin.trim()).toLowerCase();
|
|
288
|
+
console.warn(`warning: the server named no usable link origin; printing links on ${DEFAULT_LINK_ORIGIN}`);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
// An older server without shares:linkOrigins stays quiet: links on the
|
|
291
|
+
// established host open everywhere. Anything else is worth a line.
|
|
292
|
+
const msg = String(e?.message ?? e);
|
|
293
|
+
if (!/Could not find public function/.test(msg)) console.warn(`warning: could not ask where to print links (${msg}); printing them on ${DEFAULT_LINK_ORIGIN}`);
|
|
294
|
+
}
|
|
295
|
+
return DEFAULT_LINK_ORIGIN;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function linkBase(opts = {}) {
|
|
299
|
+
const t = target(opts);
|
|
300
|
+
if (t.name === "dev") return t.shareBase;
|
|
301
|
+
const env = process.env.HYPERDECKS_LINK_ORIGIN?.trim();
|
|
302
|
+
if (env) {
|
|
303
|
+
if (isLinkHost(env) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?$/.test(env)) return normalizeBase(env).toLowerCase();
|
|
304
|
+
console.warn(`warning: HYPERDECKS_LINK_ORIGIN must be ${LINK_HOSTS.join(" or ")} (or a localhost URL); ignoring ${JSON.stringify(env)}`);
|
|
305
|
+
}
|
|
306
|
+
if (linkOriginPin) return linkOriginPin;
|
|
307
|
+
// A caller's own client (the hosted MCP, tests) is asked each time.
|
|
308
|
+
if (opts.client) return askLinkOrigin(opts.client);
|
|
309
|
+
if (!linkOriginCache.has(t.convexUrl)) linkOriginCache.set(t.convexUrl, askLinkOrigin(new ConvexHttpClient(t.convexUrl)));
|
|
310
|
+
return linkOriginCache.get(t.convexUrl);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The same link on the other app host, for a recipient whose network blocks
|
|
314
|
+
// the printed one; null for a link that is not on an app host (dev).
|
|
315
|
+
export function backupLinkUrl(url) {
|
|
316
|
+
const m = /^(https?:\/\/[^/]+)(\/.*)$/.exec(String(url || ""));
|
|
317
|
+
if (!m) return null;
|
|
318
|
+
const i = LINK_HOSTS.indexOf(m[1].toLowerCase());
|
|
319
|
+
return i < 0 ? null : `${LINK_HOSTS[1 - i]}${m[2]}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
262
322
|
export function loadCreds() {
|
|
263
323
|
if (!existsSync(CRED_FILE)) return {};
|
|
264
324
|
try {
|
|
@@ -76,6 +76,12 @@
|
|
|
76
76
|
.narration-slot { position: absolute; right: 56px; bottom: 64px; }
|
|
77
77
|
.slot-left .narration-slot { right: auto; left: 56px; }
|
|
78
78
|
.slot-top .narration-slot { top: 96px; bottom: auto; }
|
|
79
|
+
/* No presenter (no face AND no captions, e.g. presenter none on a
|
|
80
|
+
data-silent deck; or forced off by setPresenterOff): the player puts
|
|
81
|
+
.hd-no-presenter on the root and hides the slot, each layout's
|
|
82
|
+
no-presenter variant (:root.hd-no-presenter .l-…) uses the corner, and on
|
|
83
|
+
phones the slide drops its 170px bottom padding (below). Keep custom slides
|
|
84
|
+
compatible: leave the corner to .narration-slot, no hard-coded right gutter. */
|
|
79
85
|
.reveal { opacity: 0; transform: translateY(12px); transition: opacity .5s, transform .5s; }
|
|
80
86
|
.reveal.is-revealed { opacity: 1; transform: none; }
|
|
81
87
|
/* The phone card (product's .frame.card, the phones row): a frameless phone,
|
|
@@ -136,6 +142,7 @@
|
|
|
136
142
|
:root { --t-display: 54px; --t-title: 38px; --t-sub: 28px; --t-lead: 17px; --t-body: 15px; --t-label: 12px; }
|
|
137
143
|
.slide { padding: 28px 20px 170px; gap: 14px; }
|
|
138
144
|
.narration-slot { right: 12px; left: 12px; bottom: 64px; }
|
|
145
|
+
:root.hd-no-presenter .slide { padding-bottom: 28px; }
|
|
139
146
|
.slide .frame.card { width: 160px; padding: 5px; border-width: 2px; border-radius: 24px; box-shadow: 8px 8px 0 var(--shadow); }
|
|
140
147
|
.slide .frame.card > .shot { border-radius: 19px; border-width: 1px; }
|
|
141
148
|
/* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
|
|
@@ -4,6 +4,7 @@ summary: A chart of real numbers with its takeaway in one line; the presenter be
|
|
|
4
4
|
use: a trend or a comparison in numbers. Its svg is a placeholder of made-up numbers check fails on: draw the real data with `hyperdecks chart` (render_chart; never a generated picture of a chart) as <img class="chart"> and name the data's source.
|
|
5
5
|
slot: bottom-right
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The chart spans both columns, under the takeaway.
|
|
7
8
|
cues: [look: #__ID__-chart] when the narration turns to it; [reveal: #__ID__-takeaway] at the point.
|
|
8
9
|
script: Here are the numbers. [look: #__ID__-chart] The last bar is the one to see. [reveal: #__ID__-takeaway]
|
|
9
10
|
-->
|
|
@@ -23,6 +24,10 @@ script: Here are the numbers. [look: #__ID__-chart] The last bar is the one to s
|
|
|
23
24
|
.l-chart .chart { height: 210px; flex: none; }
|
|
24
25
|
.l-chart .takeaway { padding-left: 12px; border-left-width: 4px; }
|
|
25
26
|
}
|
|
27
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
28
|
+
@media (min-width: 651px) {
|
|
29
|
+
:root.hd-no-presenter .l-chart .chart { grid-column: 1 / -1; }
|
|
30
|
+
}
|
|
26
31
|
</style>
|
|
27
32
|
<section class="slide l-chart" id="__ID__" data-chapter="The numbers" data-note="Data: name the source of the numbers and the period they cover.">
|
|
28
33
|
<div class="kicker">The numbers</div>
|
|
@@ -4,6 +4,7 @@ summary: The last slide. One line, the one action to take (a command, a link) an
|
|
|
4
4
|
use: the end. One call to action, never three. Label an ask that is not agreed as Proposed.
|
|
5
5
|
slot: bottom-left
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The line and the action drop the left gutter and re-centre in the full frame.
|
|
7
8
|
cues: [go: #__ID__-cta] at the action, [go: slot] and [wave] at the goodbye.
|
|
8
9
|
script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [go: slot] for watching! [wave]
|
|
9
10
|
-->
|
|
@@ -16,6 +17,10 @@ script: That's it. [go: #__ID__-cta] Here is the one thing to do next. Thanks [g
|
|
|
16
17
|
.l-closer .content { padding-left: 0; justify-content: flex-start; gap: 16px; }
|
|
17
18
|
.l-closer .cta { padding: 12px 16px; border-radius: 14px; font-size: 15px; }
|
|
18
19
|
}
|
|
20
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
21
|
+
@media (min-width: 651px) {
|
|
22
|
+
:root.hd-no-presenter .l-closer .content { padding-left: 0; align-self: center; align-items: center; text-align: center; }
|
|
23
|
+
}
|
|
19
24
|
</style>
|
|
20
25
|
<section class="slide on-ink l-closer slot-left" id="__ID__" data-chapter="Next" data-note="Closing slide: the link or command is the deck's own call to action.">
|
|
21
26
|
<div class="content">
|
|
@@ -4,6 +4,7 @@ summary: Before and after (or them and us) side by side, three short lines each.
|
|
|
4
4
|
use: the change your idea makes. Keep the rows parallel so each line has its counterpart.
|
|
5
5
|
slot: bottom-right
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The two columns widen to the full frame.
|
|
7
8
|
cues: [look: #__ID__-before] on the old way, [go: #__ID__-after] on the new; [go: slot] to come home.
|
|
8
9
|
script: Today it looks like this. [look: #__ID__-before] With us, [go: #__ID__-after] it looks like this. [go: slot]
|
|
9
10
|
-->
|
|
@@ -21,6 +22,10 @@ script: Today it looks like this. [look: #__ID__-before] With us, [go: #__ID__-a
|
|
|
21
22
|
.l-compare .label { margin-bottom: 4px; }
|
|
22
23
|
.l-compare li { padding: 5px 0; }
|
|
23
24
|
}
|
|
25
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
26
|
+
@media (min-width: 651px) {
|
|
27
|
+
:root.hd-no-presenter .l-compare .cols { max-width: none; }
|
|
28
|
+
}
|
|
24
29
|
</style>
|
|
25
30
|
<section class="slide l-compare" id="__ID__" data-chapter="Before and after" data-note="Source: name where each side's facts come from.">
|
|
26
31
|
<div class="kicker">Before and after</div>
|
|
@@ -4,6 +4,7 @@ summary: Three features side by side, an icon, a name and one line each; the pre
|
|
|
4
4
|
use: what it does, the three reasons, the three parts. Swap each icon for a real one: `hyperdecks icons search <word>` (search_icons) gives inline SVG in currentColor.
|
|
5
5
|
slot: bottom-right
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The title and cards centre vertically and the cards grow a little, instead of leaving a strip below for the presenter.
|
|
7
8
|
cues: [go: #__ID__-f1], [go: #__ID__-f2], [go: #__ID__-f3] at the word that starts each, then [go: slot].
|
|
8
9
|
script: It does three things. First, [go: #__ID__-f1] it writes. Then [go: #__ID__-f2] it speaks. Last, [go: #__ID__-f3] it sends. [go: slot]
|
|
9
10
|
-->
|
|
@@ -22,6 +23,11 @@ script: It does three things. First, [go: #__ID__-f1] it writes. Then [go: #__ID
|
|
|
22
23
|
.l-features b { font-size: 18px; }
|
|
23
24
|
.l-features p { font-size: 13px; }
|
|
24
25
|
}
|
|
26
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
27
|
+
@media (min-width: 651px) {
|
|
28
|
+
:root.hd-no-presenter .l-features { justify-content: center; }
|
|
29
|
+
:root.hd-no-presenter .l-features .card { padding: 36px 34px; }
|
|
30
|
+
}
|
|
25
31
|
</style>
|
|
26
32
|
<section class="slide l-features" id="__ID__" data-chapter="What it does" data-note="Source: name the spec, docs or repo each feature is described in.">
|
|
27
33
|
<div class="kicker">What it does</div>
|
|
@@ -4,6 +4,7 @@ summary: The opening. One display-size line, a visual on the right, the presente
|
|
|
4
4
|
use: the first slide; a section opener. Its .art is a placeholder check fails on: put in a picture of the real subject (gen-image / generate_image) as <img class="art">, or delete the div and let the presenter be the visual.
|
|
5
5
|
slot: bottom-right
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: With art (the placeholder div counts), unchanged: the art already fills the right. Delete the art and the line re-centres in the full frame.
|
|
7
8
|
cues: [wave] at the hello, [look: h1] when the narration says the title.
|
|
8
9
|
script: Hi, [wave] this is __TITLE__. [look: h1] One idea, in a few minutes.
|
|
9
10
|
-->
|
|
@@ -21,6 +22,10 @@ script: Hi, [wave] this is __TITLE__. [look: h1] One idea, in a few minutes.
|
|
|
21
22
|
.l-hero .art { width: 100%; height: 230px; -webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent); mask-image: linear-gradient(to bottom, #000 60%, transparent); }
|
|
22
23
|
.l-hero div.art::after { left: 50%; top: 45%; padding: 8px 14px; border-width: 2px; font-size: 13px; }
|
|
23
24
|
}
|
|
25
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
26
|
+
@media (min-width: 651px) {
|
|
27
|
+
:root.hd-no-presenter .l-hero:not(:has(> .art)) .content { align-self: center; align-items: center; text-align: center; max-width: 1200px; }
|
|
28
|
+
}
|
|
24
29
|
</style>
|
|
25
30
|
<section class="slide on-accent l-hero" id="__ID__" data-chapter="Hello" data-note="Title slide: no facts.">
|
|
26
31
|
<div class="art" role="presentation"></div>
|
|
@@ -4,6 +4,7 @@ summary: One big number, what it measures and its source. The number is the slid
|
|
|
4
4
|
use: proof, traction, a before/after delta. Never an estimate shown as fact: the source line is required.
|
|
5
5
|
slot: bottom-right
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The figure, title and source re-centre in the full frame.
|
|
7
8
|
cues: [look: #__ID__-figure] as the narration says the number; [reveal: #__ID__-source] optional.
|
|
8
9
|
script: Here is the number that matters. [look: #__ID__-figure] Three times faster.
|
|
9
10
|
-->
|
|
@@ -17,6 +18,10 @@ script: Here is the number that matters. [look: #__ID__-figure] Three times fast
|
|
|
17
18
|
.l-number .content { justify-content: flex-start; gap: 12px; }
|
|
18
19
|
.l-number .figure { font-size: 120px; }
|
|
19
20
|
}
|
|
21
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
22
|
+
@media (min-width: 651px) {
|
|
23
|
+
:root.hd-no-presenter .l-number .content { align-self: center; align-items: center; text-align: center; max-width: 1200px; }
|
|
24
|
+
}
|
|
20
25
|
</style>
|
|
21
26
|
<section class="slide on-ink l-number" id="__ID__" data-chapter="The number" data-note="Source: name the dashboard, report or email the number comes from, with its date.">
|
|
22
27
|
<div class="content">
|
|
@@ -4,6 +4,7 @@ summary: A fanned row of two to four phone cards (the inner ones larger and in f
|
|
|
4
4
|
use: a phone app's flow or its key screens. Each card's .shot is a placeholder check fails on: put in a phone-size screenshot (`hyperdecks screenshot <url> --width 390 --height 844`, capture_url) as <img class="shot">, or a screen recording as <video class="shot" autoplay muted loop playsinline>. Two to four cards: the row spaces and tilts them (on phones only the first three show); change one card's --tilt or --shadow on it. One phone only: use product with .frame.card.
|
|
5
5
|
slot: bottom-left
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The copy centres vertically beside the row instead of leaving the bottom-left free.
|
|
7
8
|
cues: [look: #__ID__-row] when the narration turns to the app; [go: #__ID__-p1] … [go: #__ID__-p3] to walk the screens, then [go: slot].
|
|
8
9
|
script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__ID__-p1] you pick one, [go: #__ID__-p2] and it is done. [go: #__ID__-p3] [go: slot]
|
|
9
10
|
-->
|
|
@@ -32,6 +33,10 @@ script: Here is the app on a phone. [look: #__ID__-row] You start here, [go: #__
|
|
|
32
33
|
.l-phones .row > .frame.card:nth-child(n + 4) { display: none; }
|
|
33
34
|
.l-phones .row:has(> :nth-child(4)) > .frame.card:nth-child(3) { --tilt: 7deg; --shadow: var(--accent-3); width: 108px; translate: 0 16px; }
|
|
34
35
|
}
|
|
36
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
37
|
+
@media (min-width: 651px) {
|
|
38
|
+
:root.hd-no-presenter .l-phones .text { align-self: center; }
|
|
39
|
+
}
|
|
35
40
|
</style>
|
|
36
41
|
<section class="slide l-phones slot-left bg-dots" id="__ID__" data-chapter="On a phone" data-note="Phone screens: name the app build and the date they were captured.">
|
|
37
42
|
<div class="text">
|
|
@@ -4,6 +4,7 @@ summary: A product screenshot in a browser frame (for a phone shot, .frame.card:
|
|
|
4
4
|
use: a demo moment. Its .shot is a placeholder check fails on: put in `hyperdecks screenshot <url>` (capture_url) output as <img class="shot">; a callout chip names what to look at. For a phone app, make the frame `<div class="frame card" id="__ID__-frame">` holding only the shot (a phone screenshot or a `<video class="shot" autoplay muted loop playsinline>` clip) and the callout; tilt and shadow colour are `--tilt` and `--shadow` on it. `.frame.phone` (the black bezel) stays for older decks.
|
|
5
5
|
slot: bottom-right
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The copy centres vertically beside the screenshot instead of leaving the bottom-right free.
|
|
7
8
|
cues: [look: #__ID__-frame] when the narration turns to the product; [reveal: #__ID__-callout] for the detail.
|
|
8
9
|
script: This is the product. [look: #__ID__-frame] Look at this part. [reveal: #__ID__-callout]
|
|
9
10
|
-->
|
|
@@ -45,6 +46,10 @@ script: This is the product. [look: #__ID__-frame] Look at this part. [reveal: #
|
|
|
45
46
|
.l-product .frame.card { align-self: center; }
|
|
46
47
|
.l-product .callout { left: 10px; bottom: 10px; padding: 6px 12px; font-size: 12px; }
|
|
47
48
|
}
|
|
49
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
50
|
+
@media (min-width: 651px) {
|
|
51
|
+
:root.hd-no-presenter .l-product .text { align-self: center; }
|
|
52
|
+
}
|
|
48
53
|
</style>
|
|
49
54
|
<section class="slide l-product" id="__ID__" data-chapter="The product" data-note="Screenshot: name the page and the date it was captured.">
|
|
50
55
|
<div class="text">
|
|
@@ -4,6 +4,7 @@ summary: One quote, large, with who said it. The quote is the title.
|
|
|
4
4
|
use: a customer or user in their own words. Quote them exactly and name the source in data-note.
|
|
5
5
|
slot: bottom-left
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The quote drops its left gutter and re-centres in the full frame.
|
|
7
8
|
cues: [look: #__ID__-quote] as the narration reads it.
|
|
8
9
|
script: In their words. [look: #__ID__-quote] Here is what they told us.
|
|
9
10
|
-->
|
|
@@ -20,6 +21,11 @@ script: In their words. [look: #__ID__-quote] Here is what they told us.
|
|
|
20
21
|
.l-quote .quote { font-size: 32px; }
|
|
21
22
|
.l-quote .avatar { width: 44px; height: 44px; font-size: 16px; }
|
|
22
23
|
}
|
|
24
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
25
|
+
@media (min-width: 651px) {
|
|
26
|
+
:root.hd-no-presenter .l-quote .content { padding-left: 0; align-self: center; align-items: center; text-align: center; max-width: 1240px; }
|
|
27
|
+
:root.hd-no-presenter .l-quote .quote { text-indent: 0; }
|
|
28
|
+
}
|
|
23
29
|
</style>
|
|
24
30
|
<section class="slide on-accent l-quote slot-left" id="__ID__" data-chapter="In their words" data-note="Quote: name who said it, where (email, call, review) and when.">
|
|
25
31
|
<figure class="content">
|
|
@@ -4,6 +4,7 @@ summary: Image plus copy: the copy on the left (a sticker for the logo or icon,
|
|
|
4
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
5
|
slot: bottom-left
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The copy centres vertically beside the picture instead of leaving the bottom-left free.
|
|
7
8
|
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
9
|
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
|
-->
|
|
@@ -26,6 +27,10 @@ script: Here it is, doing the job. [look: #__ID__-frame] It starts with [go: #__
|
|
|
26
27
|
.l-split .frame.card { width: 128px; }
|
|
27
28
|
.l-split .shot.wide { border-radius: 10px; box-shadow: 6px 6px 0 var(--accent); }
|
|
28
29
|
}
|
|
30
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
31
|
+
@media (min-width: 651px) {
|
|
32
|
+
:root.hd-no-presenter .l-split .text { align-self: center; padding-top: 0; }
|
|
33
|
+
}
|
|
29
34
|
</style>
|
|
30
35
|
<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
36
|
<div class="text">
|
|
@@ -4,6 +4,7 @@ summary: Up to four people, a face, a name and a role each; the presenter flies
|
|
|
4
4
|
use: who is behind it, who to talk to. Use real photos only with permission; initials otherwise.
|
|
5
5
|
slot: bottom-right
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The title and people centre vertically and the cards grow a little, instead of leaving a strip below for the presenter.
|
|
7
8
|
cues: [go: #__ID__-p1] … [go: #__ID__-p4] at each name, then [go: slot].
|
|
8
9
|
script: Meet the team. [go: #__ID__-p1] Ana builds it. [go: #__ID__-p2] Sam sells it. [go: #__ID__-p3] Lee runs it. [go: #__ID__-p4] Kai designs it. [go: slot]
|
|
9
10
|
-->
|
|
@@ -23,6 +24,11 @@ script: Meet the team. [go: #__ID__-p1] Ana builds it. [go: #__ID__-p2] Sam sell
|
|
|
23
24
|
.l-team b { font-size: 16px; }
|
|
24
25
|
.l-team small { font-size: 10px; }
|
|
25
26
|
}
|
|
27
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
28
|
+
@media (min-width: 651px) {
|
|
29
|
+
:root.hd-no-presenter .l-team { justify-content: center; }
|
|
30
|
+
:root.hd-no-presenter .l-team .person { padding: 32px 30px; }
|
|
31
|
+
}
|
|
26
32
|
</style>
|
|
27
33
|
<section class="slide l-team" id="__ID__" data-chapter="The team" data-note="People: names and roles as they gave them; photos only with their permission.">
|
|
28
34
|
<div class="kicker">The team</div>
|
|
@@ -4,6 +4,7 @@ summary: Four milestones on a line, left to right, the presenter flying under ea
|
|
|
4
4
|
use: history, a roadmap, a process over time. Label future steps "Proposed" if not agreed.
|
|
5
5
|
slot: bottom-right
|
|
6
6
|
presenter: stage
|
|
7
|
+
nopresenter: The title and milestones centre vertically instead of leaving a strip below for the presenter.
|
|
7
8
|
cues: [go: #__ID__-m1] … [go: #__ID__-m4] at the word that starts each milestone, then [go: slot].
|
|
8
9
|
script: It started [go: #__ID__-m1] with a sketch. Then [go: #__ID__-m2] a first user. Then [go: #__ID__-m3] a launch. Next, [go: #__ID__-m4] the plan. [go: slot]
|
|
9
10
|
-->
|
|
@@ -25,6 +26,10 @@ script: It started [go: #__ID__-m1] with a sketch. Then [go: #__ID__-m2] a first
|
|
|
25
26
|
.l-timeline b { font-size: 18px; margin-bottom: 2px; }
|
|
26
27
|
.l-timeline p { font-size: 13px; }
|
|
27
28
|
}
|
|
29
|
+
/* No presenter (:root.hd-no-presenter: no face and no captions, deck_guide look): the corner is free. Phones keep this layout's rules. */
|
|
30
|
+
@media (min-width: 651px) {
|
|
31
|
+
:root.hd-no-presenter .l-timeline { justify-content: center; }
|
|
32
|
+
}
|
|
28
33
|
</style>
|
|
29
34
|
<section class="slide l-timeline" id="__ID__" data-chapter="How we got here" data-note="Source: name where each date comes from.">
|
|
30
35
|
<div class="kicker">How we got here</div>
|