@hypersocial/decks-cli 0.9.6 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +68 -15
- package/skills/hyperdecks/chat.md +5 -4
- package/src/checkRules.mjs +37 -0
- package/src/commands.mjs +11 -7
- package/src/files.mjs +1 -0
- package/src/guide.mjs +1 -1
- package/src/layouts.mjs +2 -2
- package/src/loadingBlock.mjs +43 -12
- package/src/ops.mjs +6 -3
- package/src/runtime.mjs +2 -2
- package/templates/layouts/_deck.html +44 -0
- package/templates/layouts/split.html +48 -0
package/package.json
CHANGED
|
@@ -448,11 +448,8 @@ saves one.
|
|
|
448
448
|
|
|
449
449
|
<!-- font pairings -->
|
|
450
450
|
|
|
451
|
-
**
|
|
452
|
-
|
|
453
|
-
the deck's colours: paste the `<svg>` in place of a features card's icon. Use
|
|
454
|
-
`icons <subject...> --style` (`generate_icon_set`) only for illustrated icons
|
|
455
|
-
in a style of your own.
|
|
451
|
+
**Textures, icons, pictures:** the `look` topic (one texture, one icon set
|
|
452
|
+
from `search_icons` or `generate_icon_set`, a picture on every other slide).
|
|
456
453
|
|
|
457
454
|
**Pick the layout from what the slide says.** Each layout's file in
|
|
458
455
|
`templates/layouts/` opens with its slot side, presenter and the cues that
|
|
@@ -463,6 +460,7 @@ suit it.
|
|
|
463
460
|
| `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]` |
|
|
464
461
|
| `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
|
|
465
462
|
| `product` | a screenshot in a browser frame (`.frame.card` for a phone) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
|
|
463
|
+
| `split` | the workhorse: a real picture (phone, laptop, 16:9) beside sticker, title, three ticks | stage, bottom-left; `[look: #<id>-frame]`, `[go: #<id>-t1]` … `t3` |
|
|
466
464
|
| `phones` | a phone app's flow: a fanned row of phone cards | stage, bottom-left; `[go: #<id>-p1]` … `p3`, `[go: slot]` |
|
|
467
465
|
| `quote` | a customer or user in their own words, quoted exactly | stage, bottom-left; `[look: #<id>-quote]` |
|
|
468
466
|
| `compare` | before/after, them/us, three parallel lines each | stage; `[look: #<id>-before]`, `[go: #<id>-after]` |
|
|
@@ -476,12 +474,10 @@ suit it.
|
|
|
476
474
|
two to four: `phones` (`--tilt`, `--shadow` on a card). A few slides at most:
|
|
477
475
|
`bg-dots` (never under text), a `.deco` of inked tiles.
|
|
478
476
|
|
|
479
|
-
Presenter style follows the deck (step 2
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
picture. Three layouts (`quote`, `phones`, `closer`) sit left; put a `slot-left` on others
|
|
484
|
-
where there is room so slide changes move the presenter.
|
|
477
|
+
Presenter style follows the deck (step 2): `stage` when the presenter carries
|
|
478
|
+
the story (the layouts keep the slot's corner clear); `corner` where a big
|
|
479
|
+
visual is the point; `glass` on a dark or busy picture. `quote`, `split`,
|
|
480
|
+
`phones` and `closer` sit left; add `slot-left` elsewhere where there is room.
|
|
485
481
|
|
|
486
482
|
**Visuals: real first, generated second.**
|
|
487
483
|
|
|
@@ -495,10 +491,12 @@ where there is room so slide changes move the presenter.
|
|
|
495
491
|
floating shapes, abstract "tech" art, clip-art metaphors) makes a deck look
|
|
496
492
|
cheap. A visual earns its place only if it shows something real from the
|
|
497
493
|
story: the product, the data, a concrete situation or object. When in doubt,
|
|
498
|
-
leave it out: one big statement in type beats a picture of nothing.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
494
|
+
leave it out: one big statement in type beats a picture of nothing. But a
|
|
495
|
+
deck of nothing but type is not the answer: reach for the real things
|
|
496
|
+
first (`look` topic). The presenter is the hero of the opening slide;
|
|
497
|
+
don't put generated art behind it.
|
|
498
|
+
- Icons: from the deck's one set (`look` topic), labelling things or in a
|
|
499
|
+
`.sticker`; not one per sentence. Bullets are `.ticks`.
|
|
502
500
|
- Images: `gen-image` (`generate_image`) only for a concrete subject the
|
|
503
501
|
deck is about (a product in use, a place, a real object), never a metaphor.
|
|
504
502
|
One style per deck, described the same way every time (subject, medium,
|
|
@@ -543,6 +541,61 @@ still.
|
|
|
543
541
|
- No CSS animation on slides beyond `.reveal`; the player animates the slide
|
|
544
542
|
change.
|
|
545
543
|
|
|
544
|
+
<!-- topic: look -->
|
|
545
|
+
#### Look: textures, icons, pictures
|
|
546
|
+
|
|
547
|
+
What the house decks do that a plain agent deck doesn't. Decide these three
|
|
548
|
+
once per deck, before writing slides, and say them in your plan.
|
|
549
|
+
|
|
550
|
+
**1. A picture on at least every other slide**, the most real one you have:
|
|
551
|
+
|
|
552
|
+
1. the product itself: `capture_url` (phone: width 390, height 844), or a
|
|
553
|
+
screen recording or photo the person gives you (`upload_deck_asset`);
|
|
554
|
+
2. their people, logos and app icons (with permission);
|
|
555
|
+
3. real data drawn with `render_chart`;
|
|
556
|
+
4. only then `generate_image`, of a concrete subject (a place, an object,
|
|
557
|
+
the product in use), never a metaphor or abstract "tech" art.
|
|
558
|
+
|
|
559
|
+
No product to show (a plan, a strategy)? Make the slides visual with type
|
|
560
|
+
and the kit below: a `number` slide, stickers, ticks on a `panel`.
|
|
561
|
+
|
|
562
|
+
**2. One texture**, on two or three slides (an opener, a closer, a slide
|
|
563
|
+
with a visual), never under body text. On the section:
|
|
564
|
+
|
|
565
|
+
| Class | Looks like | For |
|
|
566
|
+
|---|---|---|
|
|
567
|
+
| `bg-lines` | diagonal speed lines fading toward `--fade-at` | product slides, beside a picture |
|
|
568
|
+
| `bg-halftone` | a corner of halftone dots | openers, numbers |
|
|
569
|
+
| `bg-rays` | rays from `--rays-at` | an opener or a closer |
|
|
570
|
+
| `bg-dots` | a fading dot grid | a phones row |
|
|
571
|
+
|
|
572
|
+
Grounds carry the rest: alternate `on-accent`, plain, `on-ink` and the soft
|
|
573
|
+
washes `on-tint`, `on-tint-2`, `on-tint-3` so each slide change reads.
|
|
574
|
+
|
|
575
|
+
**3. One icon set.** Pick one source and keep it for every icon in the deck:
|
|
576
|
+
Lucide (`search_icons`, same `strokeWidth` throughout); or one
|
|
577
|
+
`generate_icon_set` call with every subject you need and one `preset`
|
|
578
|
+
(`line`, `duotone`, `sticker`, `clay`; the palette defaults to the brand's);
|
|
579
|
+
or the product's own app icons and logos. Never mix two.
|
|
580
|
+
Put an icon or logo in a `.sticker` (tilted tile, hard shadow) on openers
|
|
581
|
+
and product slides; use `.ticks` (accent squares) for lists, not icons.
|
|
582
|
+
|
|
583
|
+
**Kit (in every layout deck's shell):** `.sticker` (`--tilt`; `.photo` for
|
|
584
|
+
an app icon or a face), `.ticks`, `.panel`, `.pill`, `.frame.card` (a phone),
|
|
585
|
+
`.frame.laptop` (a desktop shot). The `split` layout is image plus copy.
|
|
586
|
+
|
|
587
|
+
**Image prompts.** One recipe per deck, the same every time:
|
|
588
|
+
`<concrete subject>, <setting>, <medium: e.g. editorial photo, soft daylight>,
|
|
589
|
+
palette <ink hex> <accent hex> <paper hex>, no text, no logos`
|
|
590
|
+
(`generate_image { brand: true }` adds the palette and the "no text" for
|
|
591
|
+
you). Generate at the slide's shape (wide for a hero), in one batch.
|
|
592
|
+
|
|
593
|
+
**Slide recipes.** Opener: `on-accent` + `bg-rays` or `bg-halftone`, the logo
|
|
594
|
+
or a few app-icon stickers, one display line. Product or feature: `split`
|
|
595
|
+
with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
|
|
596
|
+
`timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
|
|
597
|
+
one action, stickers again.
|
|
598
|
+
|
|
546
599
|
<!-- topic: script -->
|
|
547
600
|
### 3. Write slides and script
|
|
548
601
|
|
|
@@ -4,7 +4,7 @@ You are building a narrated deck through the Hyperdecks connector: no terminal,
|
|
|
4
4
|
no git, no folder. The deck's files (`index.html`, `script.md`, `evidence.md`)
|
|
5
5
|
live on Hyperdecks as a draft; you read and edit them with tools, and nothing
|
|
6
6
|
reaches a recipient until `release_deck`. Topics: call `deck_guide` with
|
|
7
|
-
`format`, `script`, `scripts`, `design`, `layouts`, `presenter`, `transitions` or `check`
|
|
7
|
+
`format`, `script`, `scripts`, `design`, `look`, `layouts`, `presenter`, `transitions` or `check`
|
|
8
8
|
before the step that needs it.
|
|
9
9
|
|
|
10
10
|
## First time here?
|
|
@@ -21,7 +21,8 @@ makes the hello deck; continue from step 5 below.
|
|
|
21
21
|
|
|
22
22
|
## The loop
|
|
23
23
|
|
|
24
|
-
1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides
|
|
24
|
+
1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides
|
|
25
|
+
and `deck_guide { topic: "look" }` to pick the deck's texture, icon set and pictures.
|
|
25
26
|
`get_brand` for the person's saved brand: write in its designMd's voice.
|
|
26
27
|
None yet? Offer to import it from what they have: `import_brand` with
|
|
27
28
|
their website (`url`), a PowerPoint or PDF they attach (`file`) or one of
|
|
@@ -46,8 +47,8 @@ makes the hello deck; continue from step 5 below.
|
|
|
46
47
|
layouts' stand-ins (hero's `<div class="art">`, product's
|
|
47
48
|
`<div class="shot">`, chart's placeholder `<svg>`) fail `check_deck` until
|
|
48
49
|
you swap each for an `<img>` of its real file, or delete the hero's art.
|
|
49
|
-
Icons: `search_icons { query }`
|
|
50
|
-
|
|
50
|
+
Icons: one set per deck (`look` topic): `search_icons { query }` for real
|
|
51
|
+
line icons as inline `<svg>` in `currentColor`, or one `generate_icon_set`.
|
|
51
52
|
For motion, `generate_video` (poll `check_video_job`): `loop: true` for a
|
|
52
53
|
clip that repeats with no jump, `transparent: true` for a moving cut-out
|
|
53
54
|
over the slide. Paste the finished job's `markup` as is (a transparent
|
package/src/checkRules.mjs
CHANGED
|
@@ -65,6 +65,9 @@ export const MESSAGES = {
|
|
|
65
65
|
morphPair: (id, prev) => `slide ${id}: morph needs an element with the same data-morph="<name>" on this slide and the one before it (${prev})`,
|
|
66
66
|
transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
|
|
67
67
|
flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
|
|
68
|
+
// Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
|
|
69
|
+
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)`,
|
|
70
|
+
mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
|
|
68
71
|
// A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
|
|
69
72
|
placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
|
|
70
73
|
// Warnings (check --layout): the deck still passes.
|
|
@@ -526,6 +529,10 @@ const PLACEHOLDERS = [
|
|
|
526
529
|
layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
|
|
527
530
|
how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
|
|
528
531
|
},
|
|
532
|
+
{
|
|
533
|
+
layout: "l-split", tag: "div", cls: "shot", what: "the picture", img: '<img class="shot" src="…" alt="…"> (or <video class="shot" src="…" autoplay muted loop playsinline>)',
|
|
534
|
+
how: { hosted: "capture the app at phone size with capture_url (width 390, height 844), or upload a screen recording or photo with upload_deck_asset", cli: "capture the app at phone size with `hyperdecks screenshot <url> --width 390 --height 844`, or add a screen recording or photo with `hyperdecks asset add`" },
|
|
535
|
+
},
|
|
529
536
|
{
|
|
530
537
|
layout: "l-phones", tag: "div", cls: "shot", what: "each phone screen", img: '<img class="shot" src="…" alt="…"> (or <video class="shot" src="…" autoplay muted loop playsinline>) in each card',
|
|
531
538
|
how: { hosted: "capture the app at phone size with capture_url (width 390, height 844), or upload a screen recording with upload_deck_asset", cli: "capture the app at phone size with `hyperdecks screenshot <url> --width 390 --height 844`, or add a screen recording with `hyperdecks asset add`" },
|
|
@@ -754,6 +761,35 @@ const isHostedAsset = (url) => {
|
|
|
754
761
|
}
|
|
755
762
|
};
|
|
756
763
|
|
|
764
|
+
// The look topic as warnings. A slide shows a picture when it holds an <img>
|
|
765
|
+
// (not the brand logo or an icon), <video>, <canvas>, <iframe>, a chart <svg> or an
|
|
766
|
+
// inline-style url() background (not a stylesheet's: that is usually a texture); a deck of three or more slides with fewer than a third that do
|
|
767
|
+
// reads as all type (the topic aims for every other slide; a plan with no
|
|
768
|
+
// product to show may have fewer). Icons count as one set each: inline Lucide <svg> (by stroke width),
|
|
769
|
+
// and <img> icons (class icon, or a generated icon-* asset).
|
|
770
|
+
const iconImg = (tag) => /\bicon\b/.test(attr(tag, "class") ?? "") || /\/icon-[\w-]+\.(png|webp|svg)\b/i.test(attr(tag, "src") ?? "");
|
|
771
|
+
export function lookWarnings(html) {
|
|
772
|
+
const all = slides(html);
|
|
773
|
+
const out = [];
|
|
774
|
+
const picture = (body) =>
|
|
775
|
+
/<(video|canvas|iframe)\b/i.test(body) ||
|
|
776
|
+
/<svg\b[^>]*class=["'][^"']*\bchart\b/i.test(body) ||
|
|
777
|
+
[...body.matchAll(/\bstyle=("[^"]*"|'[^']*')/gi)].some(([, v]) => /\burl\(/i.test(v)) ||
|
|
778
|
+
[...body.matchAll(/<img\b[^>]*>/gi)].some(([tag]) => !/\bbrand-logo\b/.test(attr(tag, "class") ?? "") && !iconImg(tag));
|
|
779
|
+
const shown = all.filter((s) => picture(uncommented(s.body)) || /\burl\(/i.test(attr(s.tag, "style") ?? "")).length;
|
|
780
|
+
if (all.length >= 3 && shown * 3 < all.length) out.push(MESSAGES.fewPictures(shown, all.length));
|
|
781
|
+
const body = uncommented(all.map((s) => s.body).join("\n"));
|
|
782
|
+
const sets = new Set();
|
|
783
|
+
for (const [tag] of body.matchAll(/<svg\b[^>]*>/gi)) {
|
|
784
|
+
if (/\blucide/.test(attr(tag, "class") ?? "")) sets.add(`Lucide at stroke-width ${attr(tag, "stroke-width") ?? "2"}`);
|
|
785
|
+
}
|
|
786
|
+
for (const [tag] of body.matchAll(/<img\b[^>]*>/gi)) {
|
|
787
|
+
if (iconImg(tag)) sets.add("image icons");
|
|
788
|
+
}
|
|
789
|
+
if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
|
|
757
793
|
// Every rule on the deck's text. `exists(ref)` answers whether a local reference
|
|
758
794
|
// resolves (true, false or "outside"); `hosted: true` means there is no folder,
|
|
759
795
|
// so every local reference is an error naming upload_deck_asset, a hotlinked
|
|
@@ -773,6 +809,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
773
809
|
const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
|
|
774
810
|
const warnings = [];
|
|
775
811
|
if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
|
|
812
|
+
warnings.push(...lookWarnings(html));
|
|
776
813
|
// Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
|
|
777
814
|
const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
|
|
778
815
|
for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
|
package/src/commands.mjs
CHANGED
|
@@ -376,8 +376,9 @@ commands:
|
|
|
376
376
|
engagement <recipient|token> [--deck <slug>] [--json]
|
|
377
377
|
what one recipient watched per deck: narration heard, replays, drop-off, visits
|
|
378
378
|
gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--transparent] [--image <url>] [--size <s>]
|
|
379
|
-
[--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
|
|
380
|
-
default model gpt-image-2.5; --seed applies to nano-banana-2.1
|
|
379
|
+
[--quality <q>] [--count <n>] [--seed <n>] [--brand] [--slug <deck>] [--download <file>]
|
|
380
|
+
default model gpt-image-2.5; --seed applies to nano-banana-2.1;
|
|
381
|
+
--brand adds your brand's colours and "no text, no logos" to the prompt
|
|
381
382
|
image-job <jobId> [--json]
|
|
382
383
|
gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--loop] [--transparent] [--slug <deck>] [--name <name>] [--download <file>]
|
|
383
384
|
a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
|
|
@@ -390,8 +391,9 @@ commands:
|
|
|
390
391
|
a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
|
|
391
392
|
icons search <word...> [--limit 8] [--stroke-width 2] [--json]
|
|
392
393
|
real icons from the Lucide library as inline SVG in currentColor (free, offline)
|
|
393
|
-
icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
|
|
394
|
-
a style-consistent set of generated (illustrated) icons, one per subject (max 12)
|
|
394
|
+
icons <subject...> --preset line|duotone|sticker|clay | --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
|
|
395
|
+
a style-consistent set of generated (illustrated) icons, one per subject (max 12);
|
|
396
|
+
palette defaults to your brand's colours
|
|
395
397
|
brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
|
|
396
398
|
brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
|
|
397
399
|
[--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
|
|
@@ -477,7 +479,7 @@ async function genImage(pos, opts) {
|
|
|
477
479
|
const prompt = pos.join(" ").trim() || opts.prompt;
|
|
478
480
|
if (!prompt) {
|
|
479
481
|
throw new Error(
|
|
480
|
-
'usage: gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--seed <n>] [--slug <deck>] [--name <name>] [--download <file>]',
|
|
482
|
+
'usage: gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--seed <n>] [--brand] [--slug <deck>] [--name <name>] [--download <file>]',
|
|
481
483
|
);
|
|
482
484
|
}
|
|
483
485
|
let out = await ops.generateImage(opts, {
|
|
@@ -490,6 +492,7 @@ async function genImage(pos, opts) {
|
|
|
490
492
|
format: opts.format,
|
|
491
493
|
model: opts.model,
|
|
492
494
|
seed: opts.seed,
|
|
495
|
+
brand: opts.brand,
|
|
493
496
|
slug: opts.slug,
|
|
494
497
|
name: opts.name,
|
|
495
498
|
});
|
|
@@ -628,12 +631,13 @@ async function chart(pos, opts) {
|
|
|
628
631
|
}
|
|
629
632
|
|
|
630
633
|
async function icons(pos, opts) {
|
|
631
|
-
if (!pos.length || !opts.style) {
|
|
632
|
-
throw new Error('usage: icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--model nano-banana-2.1|gpt-image-2.5|gpt-image-2] [--slug <deck>] [--name <prefix>]');
|
|
634
|
+
if (!pos.length || (!opts.style && !opts.preset)) {
|
|
635
|
+
throw new Error('usage: icons <subject...> --preset line|duotone|sticker|clay | --style "<style>" [--palette "#hex,#hex"] [--model nano-banana-2.1|gpt-image-2.5|gpt-image-2] [--slug <deck>] [--name <prefix>]');
|
|
633
636
|
}
|
|
634
637
|
const queued = await ops.generateIconSet(opts, {
|
|
635
638
|
subjects: pos,
|
|
636
639
|
style: opts.style,
|
|
640
|
+
preset: opts.preset,
|
|
637
641
|
palette: opts.palette ? String(opts.palette).split(",").map((c) => c.trim()).filter(Boolean) : undefined,
|
|
638
642
|
model: opts.model,
|
|
639
643
|
slug: opts.slug,
|
package/src/files.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const FILES = {
|
|
|
17
17
|
"templates/layouts/number.html": () => new URL("../templates/layouts/number.html", import.meta.url),
|
|
18
18
|
"templates/layouts/product.html": () => new URL("../templates/layouts/product.html", import.meta.url),
|
|
19
19
|
"templates/layouts/phones.html": () => new URL("../templates/layouts/phones.html", import.meta.url),
|
|
20
|
+
"templates/layouts/split.html": () => new URL("../templates/layouts/split.html", import.meta.url),
|
|
20
21
|
"templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
|
|
21
22
|
"templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
|
|
22
23
|
"templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
|
package/src/guide.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { packageText } from "./files.mjs";
|
|
|
8
8
|
import { LAYOUTS, readLayout } from "./layouts.mjs";
|
|
9
9
|
import { fontPairingsMarkdown } from "./brand.mjs";
|
|
10
10
|
|
|
11
|
-
export const GUIDE_TOPICS = ["workflow", "format", "script", "scripts", "design", "layouts", "presenter", "transitions", "check"];
|
|
11
|
+
export const GUIDE_TOPICS = ["workflow", "format", "script", "scripts", "design", "look", "layouts", "presenter", "transitions", "check"];
|
|
12
12
|
const CLI_NOTE = "_Written for the CLI. On the hosted MCP there is no folder: use the tool names in `deck_guide` (workflow), hosted URLs for every file, and write_deck_source for edits._\n\n";
|
|
13
13
|
|
|
14
14
|
// One <!-- topic: name --> section of SKILL.md: its heading and body, up to the
|
package/src/layouts.mjs
CHANGED
|
@@ -5,8 +5,8 @@ import { packageText } from "./files.mjs";
|
|
|
5
5
|
|
|
6
6
|
export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
|
|
7
7
|
// The library, in a sensible deck order; `--layouts` takes any order and repeats.
|
|
8
|
-
export const LAYOUTS = ["hero", "number", "product", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
|
|
9
|
-
const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features" };
|
|
8
|
+
export const LAYOUTS = ["hero", "number", "product", "split", "phones", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
|
|
9
|
+
const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", "phone-row": "phones", comparison: "compare", "three-up": "features", "image-copy": "split", "image-text": "split" };
|
|
10
10
|
|
|
11
11
|
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12
12
|
|
package/src/loadingBlock.mjs
CHANGED
|
@@ -49,24 +49,55 @@ const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i
|
|
|
49
49
|
const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
|
|
50
50
|
const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
|
|
51
51
|
const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
|
|
52
|
+
// The block's pieces besides its style, on any player host: the two preloads and
|
|
53
|
+
// the frame flag (each kept once), and its own comment (matched only as a whole
|
|
54
|
+
// comment, so a person's comments in <head> are never touched).
|
|
55
|
+
const PIECES = [
|
|
56
|
+
/<link rel="modulepreload" href="[^"]*\/player\/v\d+\/player\.js">[ \t]*\n?/g,
|
|
57
|
+
/<link rel="preload" as="style" href="[^"]*\/player\/v\d+\/player\.css">[ \t]*\n?/g,
|
|
58
|
+
/<script>if \(self !== top \|\| [^<]*document\.documentElement\.dataset\.hdFrame = "";<\/script>[ \t]*\n?/g,
|
|
59
|
+
new RegExp(`${STYLE.source}[ \\t]*\\n?`, "gi"),
|
|
60
|
+
];
|
|
61
|
+
const BLOCK_COMMENT = /^<!-- Loading state \(keep verbatim/;
|
|
52
62
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
// <head>'s text with each piece kept `keep` times (0: all out, 1: the first stays),
|
|
64
|
+
// working outside comments only; the block's own comment counts as a piece too.
|
|
65
|
+
function trimPieces(head, keep) {
|
|
66
|
+
const seen = new Array(PIECES.length + 1).fill(0);
|
|
67
|
+
return head
|
|
68
|
+
.split(/(<!--[\s\S]*?-->[ \t]*\n?)/)
|
|
69
|
+
.map((part, i) => {
|
|
70
|
+
if (i % 2) return BLOCK_COMMENT.test(part) && ++seen[PIECES.length] > keep ? "" : part;
|
|
71
|
+
return PIECES.reduce((text, re, k) => text.replace(re, (m) => (++seen[k] > keep ? "" : m)), part);
|
|
72
|
+
})
|
|
73
|
+
.join("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The page with its loading state put right. Only a page with the player script,
|
|
77
|
+
// as the check asks only those. With no <style data-hd-loading> in <head>, the
|
|
78
|
+
// block goes in after <title> (or at the top of <head>), any of its pieces already
|
|
79
|
+
// there taken out first. With one, it becomes the template's (refreshed) and any
|
|
80
|
+
// second copy of the style or a piece goes (deduped). A person's comments are left
|
|
81
|
+
// alone. Returns { html, fixed: "inserted" | "refreshed" | "deduped" | null,
|
|
57
82
|
// skipped? }: a player page with no <head>…</head> is left alone, with `skipped` saying why.
|
|
58
83
|
export function withLoadingBlock(html) {
|
|
59
84
|
const live = html.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length)); // same offsets, comments blanked
|
|
60
85
|
const head = HEAD.exec(live);
|
|
61
86
|
if (!PLAYER.test(live)) return { html, fixed: null };
|
|
62
87
|
if (!head) return { html, fixed: null, skipped: "no <head>…</head> to put the loading state in" };
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
const rawHead = html.slice(head.index, head.index + head[0].length);
|
|
89
|
+
const splice = (text) => html.slice(0, head.index) + text + html.slice(head.index + head[0].length);
|
|
90
|
+
if (!STYLE.test(head[0])) {
|
|
91
|
+
const cleaned = trimPieces(rawHead, 0);
|
|
92
|
+
const title = /<\/title>/i.exec(cleaned);
|
|
93
|
+
const at = title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(cleaned)[0].length;
|
|
94
|
+
return { html: splice(`${cleaned.slice(0, at)}\n${LOADING_BLOCK}${cleaned.slice(at)}`), fixed: "inserted" };
|
|
68
95
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
const deduped = trimPieces(rawHead, 1);
|
|
97
|
+
// The one style left, outside comments, made the template's.
|
|
98
|
+
const blank = deduped.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length));
|
|
99
|
+
const style = STYLE.exec(blank);
|
|
100
|
+
const fresh = deduped.slice(0, style.index) + LOADING_STYLE + deduped.slice(style.index + style[0].length);
|
|
101
|
+
if (fresh === rawHead) return { html, fixed: null };
|
|
102
|
+
return { html: splice(fresh), fixed: deduped !== rawHead ? "deduped" : "refreshed" };
|
|
72
103
|
}
|
package/src/ops.mjs
CHANGED
|
@@ -78,6 +78,7 @@ export async function generateImage(opts, fields = {}) {
|
|
|
78
78
|
if (fields.format) args.format = String(fields.format);
|
|
79
79
|
if (fields.model) args.model = String(fields.model);
|
|
80
80
|
if (fields.seed != null && fields.seed !== "") args.seed = Number(fields.seed);
|
|
81
|
+
if (fields.brand) args.brand = true;
|
|
81
82
|
// Returns { images } for fast requests, or { jobId, status, count } for slow ones
|
|
82
83
|
// (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
|
|
83
84
|
// never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
|
|
@@ -179,8 +180,10 @@ export async function renderChart(opts, spec = {}) {
|
|
|
179
180
|
export async function generateIconSet(opts, fields = {}) {
|
|
180
181
|
const subjects = (fields.subjects || []).map(String).filter(Boolean);
|
|
181
182
|
if (!subjects.length) throw new Error("subjects is required (one or more things to draw)");
|
|
182
|
-
if (!fields.style) throw new Error("style is required");
|
|
183
|
-
const args = { subjects
|
|
183
|
+
if (!fields.style && !fields.preset) throw new Error("style or preset is required");
|
|
184
|
+
const args = { subjects };
|
|
185
|
+
if (fields.style) args.style = String(fields.style);
|
|
186
|
+
if (fields.preset) args.preset = String(fields.preset);
|
|
184
187
|
if (fields.palette?.length) args.palette = fields.palette.map(String);
|
|
185
188
|
if (fields.slug) args.slug = String(fields.slug);
|
|
186
189
|
if (fields.name) args.name = String(fields.name);
|
|
@@ -229,7 +232,7 @@ export async function requestVideoExport(opts, fields = {}) {
|
|
|
229
232
|
const c = await client(opts);
|
|
230
233
|
try {
|
|
231
234
|
const out = await c.mutation(api.videoExports.requestVideo, { slug, options });
|
|
232
|
-
return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a deck
|
|
235
|
+
return { ...out, next: `poll check_export { exportId: "${out.exportId}" } (or \`hyperdecks export status ${out.exportId}\`) every 20-30 s; a 4-minute deck takes about 12 minutes` };
|
|
233
236
|
} catch (e) {
|
|
234
237
|
// A backend from before the render worker has no requestVideo.
|
|
235
238
|
if (/Could not find public function/i.test(errorText(e))) throw new Error(videoFromCli(slug));
|
package/src/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.7";
|
|
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
|
|
@@ -191,7 +191,7 @@ export const api = {
|
|
|
191
191
|
},
|
|
192
192
|
};
|
|
193
193
|
|
|
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", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
|
|
194
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "brand", "no-logo", "pdf", "notes", "mp4", "gif", "video", "no-captions", "no-avatar", "title-card", "end-card"]);
|
|
195
195
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
196
196
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
197
197
|
// opts.email stays the last --email for the other commands (share new).
|
|
@@ -56,12 +56,17 @@
|
|
|
56
56
|
/* The type scale: one display size per slide at most, then title, sub (a title beside a visual), lead, body, label. */
|
|
57
57
|
--t-display: 128px; --t-title: 84px; --t-sub: 56px; --t-lead: 30px; --t-body: 24px; --t-label: 17px;
|
|
58
58
|
--hd-ground: var(--accent); --hd-ground-ink: var(--ink); /* the loading state: the first slide's colours */
|
|
59
|
+
/* Soft grounds (on-tint, on-tint-2, on-tint-3): washes of the accents, so slide changes read without going loud. */
|
|
60
|
+
--tint-1: color-mix(in srgb, var(--accent) 18%, var(--paper)); --tint-2: color-mix(in srgb, var(--accent-2) 20%, var(--paper)); --tint-3: color-mix(in srgb, var(--accent-3, var(--accent)) 24%, var(--paper));
|
|
59
61
|
}
|
|
60
62
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
61
63
|
body { color: var(--ink); font-family: var(--font); font-weight: 500; -webkit-font-smoothing: antialiased; --hd-font: var(--font); --hd-mono: var(--mono); }
|
|
62
64
|
.slide { display: flex; flex-direction: column; gap: 24px; padding: 64px 72px 56px; background: var(--paper); color: var(--ink); --hd-speech: #f3c9dc; }
|
|
63
65
|
.slide.on-accent { background: var(--accent); --hd-speech: var(--card); }
|
|
64
66
|
.slide.on-ink { background: var(--ink); color: var(--paper); --hd-speech: #d9ccff; }
|
|
67
|
+
.slide.on-tint { background: var(--tint-1); --hd-speech: var(--card); }
|
|
68
|
+
.slide.on-tint-2 { background: var(--tint-2); --hd-speech: var(--card); }
|
|
69
|
+
.slide.on-tint-3 { background: var(--tint-3); --hd-speech: var(--card); }
|
|
65
70
|
.kicker { font: var(--t-label) var(--mono); letter-spacing: .16em; text-transform: uppercase; }
|
|
66
71
|
.tt { font-family: var(--display, var(--font)); font-weight: 700; font-size: var(--t-title); line-height: .95; letter-spacing: -.045em; text-wrap: balance; }
|
|
67
72
|
.lead { font-size: var(--t-lead); line-height: 1.3; max-width: 28em; text-wrap: pretty; }
|
|
@@ -97,6 +102,34 @@
|
|
|
97
102
|
.slide.bg-dots::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 3px, transparent 3.6px) 0 0 / 36px 36px; -webkit-mask-image: linear-gradient(var(--dots-fade, 90deg), transparent 40%, #000 80%); mask-image: linear-gradient(var(--dots-fade, 90deg), transparent 40%, #000 80%); }
|
|
98
103
|
.slide > .deco { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
|
|
99
104
|
.slide > .deco > span { position: absolute; display: grid; place-items: center; width: 80px; height: 80px; border: 3px solid var(--ink); border-radius: 20px; background: var(--card); box-shadow: 8px 8px 0 var(--ink); color: var(--ink); font: 700 34px var(--font); }
|
|
105
|
+
/* The look kit (deck_guide topic look): one texture per deck, one icon set, real pictures.
|
|
106
|
+
Textures go on the section, behind the content, on a few slides: bg-lines, diagonal
|
|
107
|
+
speed lines fading out towards the text (--fade-at: where they are strongest, default
|
|
108
|
+
the right); bg-halftone, a corner of halftone dots (--fade-at, default top right);
|
|
109
|
+
bg-rays, rays from a point (--rays-at), for an opener or a closer. */
|
|
110
|
+
.slide:is(.bg-lines, .bg-halftone, .bg-rays) { isolation: isolate; overflow: hidden; }
|
|
111
|
+
.slide:is(.bg-lines, .bg-halftone, .bg-rays)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; }
|
|
112
|
+
.slide.bg-lines::before { background: repeating-linear-gradient(-22deg, transparent 0 30px, color-mix(in srgb, currentColor 10%, transparent) 30px 32px); -webkit-mask-image: radial-gradient(1000px 780px at var(--fade-at, 82% 52%), #000 12%, transparent 70%); mask-image: radial-gradient(1000px 780px at var(--fade-at, 82% 52%), #000 12%, transparent 70%); }
|
|
113
|
+
.slide.bg-halftone::before { background: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 2.6px, transparent 3px) 0 0 / 22px 22px; -webkit-mask-image: radial-gradient(640px 640px at var(--fade-at, 88% 14%), #000 25%, transparent 70%); mask-image: radial-gradient(640px 640px at var(--fade-at, 88% 14%), #000 25%, transparent 70%); }
|
|
114
|
+
.slide.bg-rays::before { background: repeating-conic-gradient(from 0deg at var(--rays-at, 72% 50%), color-mix(in srgb, currentColor 9%, transparent) 0deg 2.2deg, transparent 2.2deg 9deg); -webkit-mask-image: radial-gradient(circle at var(--rays-at, 72% 50%), transparent 10%, #000 45%); mask-image: radial-gradient(circle at var(--rays-at, 72% 50%), transparent 10%, #000 45%); }
|
|
115
|
+
/* Pieces. .sticker: a logo or icon tile, tilted (--tilt), ink edge, hard offset
|
|
116
|
+
shadow (.sticker.photo for a face or an app icon that fills it). .ticks: a short
|
|
117
|
+
list with accent-square bullets. .panel: a quiet card. .pill: a small tag. */
|
|
118
|
+
.sticker { --tilt: -3deg; display: inline-grid; place-items: center; flex: none; width: 96px; height: 96px; padding: 18px; border: 4px solid var(--ink); border-radius: 26px; background: var(--card); color: var(--ink); box-shadow: 6px 6px 0 var(--ink); transform: rotate(var(--tilt)); }
|
|
119
|
+
.sticker > :is(img, svg) { width: 100%; height: 100%; object-fit: contain; }
|
|
120
|
+
.sticker.photo { padding: 0; overflow: hidden; }
|
|
121
|
+
.sticker.photo > img { object-fit: cover; }
|
|
122
|
+
.ticks { list-style: none; display: flex; flex-direction: column; gap: 14px; }
|
|
123
|
+
.ticks li { position: relative; padding-left: 34px; font-size: var(--t-body); line-height: 1.3; font-weight: 600; }
|
|
124
|
+
.ticks li::before { content: ""; position: absolute; left: 0; top: .4em; width: 16px; height: 16px; border-radius: 5px; background: var(--accent); }
|
|
125
|
+
.on-accent .ticks li::before { background: var(--ink); }
|
|
126
|
+
.panel { padding: 24px 28px; border-radius: 22px; background: color-mix(in srgb, currentColor 6%, transparent); }
|
|
127
|
+
.pill { display: inline-flex; align-items: center; gap: 8px; width: fit-content; padding: 8px 16px; border-radius: 999px; background: var(--card); color: var(--ink); font: 700 var(--t-label) var(--mono); letter-spacing: .08em; text-transform: uppercase; box-shadow: 4px 4px 0 var(--accent); }
|
|
128
|
+
.on-accent .pill { box-shadow: 4px 4px 0 var(--ink); }
|
|
129
|
+
/* A laptop for a desktop screenshot or clip: a .frame.laptop holding an img.shot or video.shot. */
|
|
130
|
+
.slide .frame.laptop { position: relative; width: 860px; max-width: 100%; padding: 14px 14px 22px; border-radius: 20px 20px 6px 6px; background: #1a1a1d; box-shadow: 0 30px 70px color-mix(in srgb, var(--ink) 22%, transparent); }
|
|
131
|
+
.slide .frame.laptop::after { content: ""; position: absolute; left: -6%; right: -6%; bottom: -16px; height: 16px; border-radius: 0 0 16px 16px; background: linear-gradient(#d9d9de, #a9a9b0); }
|
|
132
|
+
.slide .frame.laptop > .shot { display: block; width: 100%; aspect-ratio: 16 / 10; border-radius: 6px; object-fit: cover; object-position: top; background: var(--paper); }
|
|
100
133
|
/* Phones: the slide is the 374×736 card; the presenter is a small head in
|
|
101
134
|
the corner, so keep the bottom 170px clear. */
|
|
102
135
|
@media (max-width: 650px) {
|
|
@@ -108,6 +141,17 @@
|
|
|
108
141
|
/* The text fills the card's width: the dots fade in towards the bottom, and the tiles stay off. */
|
|
109
142
|
.slide.bg-dots::before { background-size: 22px 22px; background-image: radial-gradient(color-mix(in srgb, currentColor 24%, transparent) 2px, transparent 2.5px); -webkit-mask-image: linear-gradient(180deg, transparent 45%, #000 85%); mask-image: linear-gradient(180deg, transparent 45%, #000 85%); }
|
|
110
143
|
.slide > .deco { display: none; }
|
|
144
|
+
/* Textures fade in towards the bottom, under the presenter, away from the text. */
|
|
145
|
+
.slide:is(.bg-lines, .bg-halftone)::before { -webkit-mask-image: linear-gradient(180deg, transparent 40%, #000 90%); mask-image: linear-gradient(180deg, transparent 40%, #000 90%); }
|
|
146
|
+
.slide.bg-halftone::before { background-size: 14px 14px; background-image: radial-gradient(color-mix(in srgb, currentColor 22%, transparent) 1.6px, transparent 2px); }
|
|
147
|
+
.sticker { width: 48px; height: 48px; padding: 9px; border-width: 2px; border-radius: 13px; box-shadow: 3px 3px 0 var(--ink); }
|
|
148
|
+
.ticks { gap: 6px; }
|
|
149
|
+
.ticks li { padding-left: 18px; }
|
|
150
|
+
.ticks li::before { width: 9px; height: 9px; border-radius: 3px; }
|
|
151
|
+
.panel { padding: 10px 12px; border-radius: 12px; }
|
|
152
|
+
.pill { padding: 4px 10px; box-shadow: 2px 2px 0 var(--accent); }
|
|
153
|
+
.slide .frame.laptop { padding: 5px 5px 9px; border-radius: 10px 10px 3px 3px; }
|
|
154
|
+
.slide .frame.laptop::after { bottom: -7px; height: 7px; border-radius: 0 0 8px 8px; }
|
|
111
155
|
}
|
|
112
156
|
</style>
|
|
113
157
|
<!-- LAYOUT STYLES -->
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
layout: split
|
|
3
|
+
summary: Image plus copy: the copy on the left (a sticker for the logo or icon, kicker, title, one line, three ticks), a real picture on the right in a phone card, on speed lines.
|
|
4
|
+
use: the workhorse for a product, a feature or an app: most slides of a good deck are this one. The phone's .shot is a placeholder check fails on: put in a phone screenshot (capture_url at 390×844) or a screen recording (upload_deck_asset) as <img class="shot"> or <video class="shot" autoplay muted loop playsinline>. For a desktop app, replace the card with <div class="frame laptop" id="__ID__-frame"><img class="shot" src="…" alt="…"></div>; for a 16:9 clip or photo, <img class="shot wide" src="…" alt="…" id="__ID__-frame">. Swap the sticker's icon for the product's logo (<span class="sticker photo"><img src="…" alt=""></span>) or one icon from the deck's set.
|
|
5
|
+
slot: bottom-left
|
|
6
|
+
presenter: stage
|
|
7
|
+
cues: [look: #__ID__-frame] when the narration turns to the picture; [go: #__ID__-t1], [go: #__ID__-t2], [go: #__ID__-t3] at the word that starts each tick, then [go: slot].
|
|
8
|
+
script: Here it is, doing the job. [look: #__ID__-frame] It starts with [go: #__ID__-t1] the first thing. Then [go: #__ID__-t2] the second. And [go: #__ID__-t3] the third. [go: slot]
|
|
9
|
+
-->
|
|
10
|
+
<style>
|
|
11
|
+
.l-split { display: grid; grid-template-columns: 680px 1fr; column-gap: 64px; align-items: center; }
|
|
12
|
+
.l-split .text { align-self: start; display: flex; flex-direction: column; gap: 22px; padding-top: 24px; }
|
|
13
|
+
.l-split .head { display: flex; align-items: center; gap: 22px; }
|
|
14
|
+
.l-split .tt { font-size: var(--t-sub); line-height: 1.02; }
|
|
15
|
+
.l-split .lead { font-size: var(--t-body); }
|
|
16
|
+
.l-split .ticks { margin-top: 6px; }
|
|
17
|
+
.l-split .media { display: grid; place-items: center; height: 100%; }
|
|
18
|
+
.l-split .frame.card { width: 300px; --tilt: 4deg; }
|
|
19
|
+
.l-split .shot.wide { display: block; width: 100%; aspect-ratio: 16 / 9; border-radius: 18px; object-fit: cover; box-shadow: 14px 14px 0 var(--accent); }
|
|
20
|
+
.on-accent.l-split .shot.wide { box-shadow: 14px 14px 0 var(--ink); }
|
|
21
|
+
@media (max-width: 650px) {
|
|
22
|
+
.l-split { display: flex; flex-direction: column; align-items: stretch; }
|
|
23
|
+
.l-split .text { gap: 10px; padding-top: 0; }
|
|
24
|
+
.l-split .head { gap: 12px; }
|
|
25
|
+
.l-split .media { height: auto; }
|
|
26
|
+
.l-split .frame.card { width: 128px; }
|
|
27
|
+
.l-split .shot.wide { border-radius: 10px; box-shadow: 6px 6px 0 var(--accent); }
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
30
|
+
<section class="slide l-split slot-left bg-lines" id="__ID__" data-chapter="The product" data-note="Picture: name the screen or clip and the date it was captured.">
|
|
31
|
+
<div class="text">
|
|
32
|
+
<div class="head">
|
|
33
|
+
<span class="sticker" id="__ID__-logo"><svg class="lucide-smartphone" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg></span>
|
|
34
|
+
<div class="kicker">The product · Live</div>
|
|
35
|
+
</div>
|
|
36
|
+
<h2 class="tt" data-title-mobile="Here it is.">Here it is, doing the job.</h2>
|
|
37
|
+
<p class="lead">One line on what the picture shows.</p>
|
|
38
|
+
<ul class="ticks">
|
|
39
|
+
<li id="__ID__-t1">The first thing it does</li>
|
|
40
|
+
<li id="__ID__-t2">The second thing</li>
|
|
41
|
+
<li id="__ID__-t3">The third thing</li>
|
|
42
|
+
</ul>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="media">
|
|
45
|
+
<div class="frame card" id="__ID__-frame"><div class="shot" role="img" aria-label="Phone screen placeholder"></div></div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="narration-slot"></div>
|
|
48
|
+
</section>
|