feat(deck): add skill to scaffold Lerian-branded presentations

feat(systemplane): refactor migration skill for lib-commons v5 API
docs(plugin): update skill counts and keywords for new deck skill
chore(plans): remove obsolete caching and dev-cycle plan documents
This commit is contained in:
Fred Amaral 2026-04-19 21:07:09 -03:00
parent 8a3708615b
commit 563de9a6a5
No known key found for this signature in database
GPG key ID: ADFE56C96F4AC56A
38 changed files with 7573 additions and 5585 deletions

View file

@ -8,7 +8,7 @@
"plugins": [
{
"name": "ring-default",
"description": "Core skills library for the Lerian Team: TDD, debugging, collaboration patterns, and proven techniques. Features parallel 10-reviewer code review system, systematic debugging, and workflow orchestration. 23 essential skills for software engineering excellence.",
"description": "Core skills library for the Lerian Team: TDD, debugging, collaboration patterns, and proven techniques. Features parallel 10-reviewer code review system, systematic debugging, and workflow orchestration. 24 essential skills for software engineering excellence.",
"version": "1.25.2",
"source": "./default",
"homepage": "https://github.com/lerianstudio/ring/tree/default",
@ -24,7 +24,9 @@
"orchestration",
"validation",
"code-review",
"parallel-review"
"parallel-review",
"presentation",
"deck"
]
},
{

View file

@ -472,7 +472,7 @@ Ring is a comprehensive skills library and workflow system for AI agents that en
**Active Plugins:**
- **ring-default**: 23 core skills, 10 specialized agents
- **ring-default**: 24 core skills, 10 specialized agents
- **ring-dev-team**: 33 development skills, 15 developer agents (Backend Go, Backend TypeScript, DevOps, Frontend TypeScript, Frontend Designer, Frontend Engineer, Helm, Performance Reviewer, QA Backend, QA Frontend, SRE, UI Engineer, Prompt Quality Reviewer, Multi-Tenant Reviewer, lib-commons Reviewer)
- **ring-pm-team**: 16 product management skills, 4 research agents (includes delivery planning + status tracking + Product Designer + Lerian Map Management)
- **ring-pmo-team**: 9 PMO skills, 6 PMO agents (Portfolio Manager, Resource Planner, Risk Analyst, Governance Specialist, Executive Reporter, Delivery Reporter)
@ -481,7 +481,7 @@ Ring is a comprehensive skills library and workflow system for AI agents that en
**Note:** Plugin versions are managed in `.claude-plugin/marketplace.json`
**Total: 94 skills (23 + 33 + 16 + 9 + 7 + 6) across 6 plugins**
**Total: 95 skills (24 + 33 + 16 + 9 + 7 + 6) across 6 plugins**
**Total: 41 agents (10 + 15 + 4 + 6 + 3 + 3) across 6 plugins**
The architecture uses markdown-based skill definitions with YAML frontmatter, auto-discovered at session start via hooks, and executed through Claude Code's native Skill/Task tools.
@ -502,7 +502,7 @@ See [README.md](README.md#installation) for detailed installation instructions.
| Plugin | Path | Contents |
| ---------------- | -------------- | -------------------------------- |
| ring-default | `default/` | 23 skills, 10 agents |
| ring-default | `default/` | 24 skills, 10 agents |
| ring-dev-team | `dev-team/` | 33 skills, 15 agents |
| ring-pm-team | `pm-team/` | 16 skills, 4 agents |
| ring-pmo-team | `pmo-team/` | 9 skills, 6 agents |
@ -634,7 +634,7 @@ The system loads at SessionStart (from `default/` plugin):
- Repository: Monorepo marketplace with multiple plugin collections
- Active plugins: 6 (`ring-default`, `ring-dev-team`, `ring-pm-team`, `ring-pmo-team`, `ring-finops-team`, `ring-tw-team`)
- Plugin versions: See `.claude-plugin/marketplace.json`
- Core plugin: `default/` (23 skills, 10 agents)
- Core plugin: `default/` (24 skills, 10 agents)
- Developer agents: `dev-team/` (33 skills, 15 agents)
- Product planning: `pm-team/` (16 skills, 4 agents)
- PMO specialists: `pmo-team/` (9 skills, 6 agents)

View file

@ -1,6 +1,6 @@
# Ring Marketplace Manual
Quick reference guide for the Ring skills library and workflow system. This monorepo provides 6 plugins with 94 skills and 41 agents for enforcing proven software engineering practices across the entire software delivery value chain.
Quick reference guide for the Ring skills library and workflow system. This monorepo provides 6 plugins with 95 skills and 41 agents for enforcing proven software engineering practices across the entire software delivery value chain.
---
@ -13,7 +13,7 @@ Quick reference guide for the Ring skills library and workflow system. This mono
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ ring-default │ │ ring-dev-team │ │ ring-pm-team │ │ring-finops- │ │
│ │ Skills(23) │ │ Skills(33) │ │ Skills(16) │ │ team │ │
│ │ Skills(24) │ │ Skills(33) │ │ Skills(16) │ │ team │ │
│ │ Agents(10) │ │ Agents(15) │ │ Agents(4) │ │ Skills(7) │ │
│ │ │ │ │ │ │ │ Agents(3) │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘ │
@ -74,7 +74,7 @@ Ring is auto-loaded at session start. Two ways to invoke Ring capabilities:
## 💡 About Skills
Skills (94) are the primary invocation mechanism for Ring. They can be invoked directly by users (`Skill tool: "ring:skill-name"`) or applied automatically by Claude Code when it detects they're applicable. They handle testing, debugging, verification, planning, code review enforcement, and more.
Skills (95) are the primary invocation mechanism for Ring. They can be invoked directly by users (`Skill tool: "ring:skill-name"`) or applied automatically by Claude Code when it detects they're applicable. They handle testing, debugging, verification, planning, code review enforcement, and more.
Examples: ring:test-driven-development, ring:systematic-debugging, ring:codereview, ring:production-readiness-audit (44-dimension audit, up to 10 explorers per batch, incremental report 0-430, max 440 with multi-tenant; see [default/skills/production-readiness-audit/SKILL.md](default/skills/production-readiness-audit/SKILL.md)), etc.
@ -267,6 +267,22 @@ Runs in parallel:
Consolidated report with recommendations
```
### Creating a Lerian-branded presentation
`ring:deck` scaffolds a self-contained Node project with dev server, presenter view, mobile remote, and PDF export. Use for board decks, investor updates, conference talks, all-hands presentations.
**Trigger phrases:** "make a deck", "board deck", "investor deck", "slide deck"
**Example:**
> **User:** Build me a board deck for Q2 — 15 slides, strategic overview + financials + product roadmap.
>
> **Claude:** [invokes ring:deck, scaffolds `2026-q2-board/`, composes 15 slides across cover/agenda/act-divider/content/content-dark/appendix archetypes, embeds speaker notes, prints next-steps]
**Output:** `<deck-name>/deck.html` + tooling. `pnpm dev` to present; `pnpm export` for PDF.
See [default/skills/deck/SKILL.md](default/skills/deck/SKILL.md) for the full reference.
---
## 🎓 Mandatory Rules
@ -342,7 +358,7 @@ These enforce quality standards:
### Session Startup
1. SessionStart hook runs automatically
2. All 94 skills are auto-discovered and available
2. All 95 skills are auto-discovered and available
3. `ring:using-ring` workflow is activated (skill checking is now mandatory)
### Agent Dispatching

View file

@ -325,7 +325,7 @@ No "should work" → Only "does work" with proof
- `ring:gandalf-webhook` - Send tasks to Gandalf (AI team member) via webhook for Slack, Google Workspace, and Jira interactions
**Session & Learning (5):**
**Session & Learning (6):**
- `ring:explore-codebase` - Two-phase codebase exploration
- `ring:release-guide` - Generate Ops Update Guide from git diff analysis
@ -544,7 +544,7 @@ ring/ # Monorepo root
├── .claude-plugin/
│ └── marketplace.json # Multi-plugin marketplace config (6 active plugins)
├── default/ # Core Ring plugin (ring-default)
│ ├── skills/ # 23 core skills
│ ├── skills/ # 24 core skills
│ │ ├── skill-name/
│ │ │ └── SKILL.md # Skill definition with frontmatter
│ │ └── shared-patterns/ # Universal patterns (15 patterns)

View file

@ -0,0 +1,244 @@
---
name: ring:deck
description: Scaffold Lerian-branded HTML presentation projects with live-reload dev server, presenter view on second screen, phone-as-remote over WebSocket, and Puppeteer PDF export. Use when the user asks for a deck, presentation, board deck, investor deck, or slide deck.
trigger: |
- User asks to create a deck, presentation, board deck, investor deck, conference deck, all-hands deck, or any Lerian-branded slide deliverable
- User says "make a deck", "build a deck", "new presentation", "slide deck"
- User needs a self-contained, locally-served deliverable with speaker notes, remote control, and PDF export
skip_when: |
- User is editing an already-scaffolded deck — edit the deck's deck.html directly
- User wants a non-presentational document (memo, one-pager, email) — those use different formats
- User wants a single static HTML visualization with no tooling — use ring:visualize instead
prerequisites:
- node_installed (>=18.0.0)
- pnpm_installed OR npm_installed
verification: |
- Scaffolded project directory exists with deck.html, presenter.html, remote.html, assets/, scripts/, package.json, LICENSE, README.md
- `pnpm install` (or npm) completes without error
- `pnpm dev` boots dev server on port 7007
- Main deck loads at http://localhost:7007/
- Presenter view loads at http://localhost:7007/presenter
- Remote loads at http://<LAN-IP>:7007/remote from a phone on same network
- Keyboard navigation in main deck propagates to presenter + remote
- `pnpm export` produces deck.pdf with correct fonts (Poppins + IBM Plex Serif — no system-font substitution)
---
# ring:deck — Lerian Editorial Deck Scaffolder
Scaffold a self-contained Node project for a Lerian-branded HTML presentation. Every deck ships with a live-reload dev server, a presenter view designed for a second screen, a phone-as-remote over WebSocket, and Puppeteer-driven PDF export. The output is a working local project the user runs themselves — not a single HTML file, not a hosted site.
## When to use this skill
Use whenever the user asks for a branded slide deliverable: board deck, investor update, conference talk, all-hands, customer pitch, internal review. The trigger is "deck-shaped output" — sequenced sections, speaker notes, something a human will present live.
- "Make me a board deck for Q2"
- "Build an investor deck for the Series A conversation"
- "I need a conference talk deck"
- "Scaffold a new presentation for next week's all-hands"
- "New slide deck for the product review"
## When NOT to use this skill
- **Editing an already-scaffolded deck** — edit `deck.html` inside that project directly. The scaffold is a one-shot bootstrap, not an editor.
- **Non-presentational documents** — memos, one-pagers, exec emails, PR descriptions. These are written content, not sequenced slides.
- **Single static HTML visualization** — a diagram, dashboard, or comparison table is `ring:visualize`. That skill produces one `.html` file; this skill produces a Node project.
## Two Lerian Design Systems Side by Side
**HARD GATE.** `ring:deck` uses editorial tokens (Amarelo `#FEED02`, Poppins + IBM Plex Serif, JetBrains Mono) intentionally separate from `ring:visualize`'s product-console tokens (`#FDCB28` sunglow, Inter). Both systems are canonical Lerian. MUST NOT cross-mix tokens. A deck with `ring:visualize`'s Inter + sunglow is wrong; a diagram with `ring:deck`'s Poppins + Amarelo is wrong. Keep the systems pure.
## Mandatory Reading — HARD GATE
Before writing any slide content, MUST read:
1. `references/design-tokens.md` — all colors, fonts, spacing, radii
2. `references/layout-rules.md`**THE critical craft discipline** (vertical-canvas model, `flex: 1; min-height: 0`, no fixed-height cards, 24px text floor, dynamic pagination)
3. `references/slide-archetypes.md` — when to use each of 9 archetypes
4. `references/ui-primitives.md` — pill, kpi-tile, ticks-list, numbered-list, eyebrow, data-grid-table
5. `references/chart-primitives.md` — stacked-horizontal-bar, vertical-bar-chart, 2x2-matrix, funnel
6. `references/speaker-notes.md` — JSON schema + oral-delivery writing guidance
Server + export references (read when tooling questions come up):
7. `references/server.md` — dev server, WebSocket protocol, port override, trust model
8. `references/export.md` — Puppeteer PDF export flow
Skipping any of refs 1-6 before writing slides is the #1 failure mode. The layout rules in particular encode years of craft discipline — reading the description below is NOT a substitute for reading the file.
## Skill Workflow
### Phase 1: Gather minimum-viable requirements
MUST ask the user (skip questions already answered):
1. **Deck title** — e.g., "Lerian Board Meeting Q2 2026"
2. **Audience** — board, investors, conference, team, external partner
3. **Rough slide count** — 10 / 20 / 30+
4. **Directory name** — defaults to kebab-cased title
MUST NOT ask about: tokens, fonts, layout, runtime, export. Those are fixed by the skill — asking is validation theater.
### Phase 2: Read mandatory references
See Mandatory Reading above. HARD GATE — MUST NOT write slides without reading refs 1-6.
### Phase 3: Scaffold project
**Sanitize substitution variables first.** Before copying any template, compute:
- `DECK_TITLE` = the user-supplied title verbatim (e.g., `"Q2 2026 Board Deck"`). Used inside HTML text nodes — HTML-escape `<`, `>`, `&` if the title contains them.
- `DECK_NAME` = `DECK_TITLE.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')`. MUST be kebab-case — it's the project directory name AND the `"name"` field in `package.json`, which rejects spaces, quotes, and most punctuation. Example: `"Q2 2026 Board Deck" → "q2-2026-board-deck"`.
- `YEAR` = current 4-digit year.
- `COPYRIGHT_HOLDER` = `"Lerian Studio"` unless the user specifies otherwise.
Naive `str.replace` on an unsanitized title breaks `package.json` (quotes, newlines) and fails filesystem validation. Sanitize first, substitute second.
Then create `<DECK_NAME>/` directory with these files (copy from the skill's `templates/`, `assets/`, and `scripts/` directories):
| Source (skill) | Destination (scaffolded deck) |
| ---------------------------------- | -------------------------------------------- |
| `templates/deck.html` | `deck.html` (substitute `{{DECK_TITLE}}`) |
| `templates/presenter.html` | `presenter.html` |
| `templates/remote.html` | `remote.html` |
| `templates/package.json.tmpl` | `package.json` (substitute `{{DECK_NAME}}`) |
| `assets/*` | `assets/*` |
| `scripts/*` | `scripts/*` |
| `templates/LICENSE.tmpl` | `LICENSE` (substitute `{{YEAR}}`, `{{COPYRIGHT_HOLDER}}` — default `Lerian Studio`) |
| `templates/README.md.tmpl` | `README.md` (substitute `{{DECK_TITLE}}`, `{{DECK_NAME}}`) |
### Phase 4: Compose slides in deck.html
For each slide:
1. Pick an archetype from `references/slide-archetypes.md`
2. Inline the archetype body from `templates/slide-<name>.html`
3. Replace placeholder content with actual content
4. Obey `references/layout-rules.md` (`flex: 1`, `min-height: 0`, no fixed-height cards, 24px text floor)
5. Use primitives from `references/ui-primitives.md`
6. Use charts from `references/chart-primitives.md` where applicable
### Phase 5: Write speaker notes
- Flat JSON array of strings
- One entry per `<section>`, zero-indexed matching `<section>` order
- `\n\n` for paragraph breaks
- Speaking voice (not written voice) — concrete data first, then context
- See `references/speaker-notes.md` for full guidance
### Phase 6: Hand off to user
Print the Handoff Message Template (below). Do NOT run `pnpm install` or `pnpm dev` for the user — the deck is theirs to run.
## Anti-Rationalization Table
| Rationalization | Why It's WRONG | Required Action |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| "Content is simple, I can skip `layout-rules.md`" | Simple content still breaks with fixed-height cards. Skipping the gate is the #1 failure mode. | **MUST read layout-rules.md** |
| "I'll pick colors that look nicer than Amarelo" | `#FEED02` Amarelo is Lerian brand primary, not a style choice. Swapping is a rebrand, not a tweak. | **MUST use tokens from design-tokens.md** |
| "I can skip speaker notes" | Notes surface in presenter view — presenters rely on them. Empty notes = unusable presenter view. | **MUST write notes for every slide** |
| "Deck already has fonts — I'll skip the Google Fonts block" | Every scaffolded deck is self-contained. The fonts block in `deck.html` is mandatory. | **MUST preserve the Google Fonts import block** |
| "User said 'just a quick deck' — I can skip archetypes" | Archetypes encode the layout discipline. "Quick" does not mean "worse." | **MUST use archetypes from slide-archetypes.md** |
| "I'll use Chart.js for richer charts" | v1 is pure CSS/HTML. Chart.js is v2 work. | **MUST use only the 4 chart primitives** |
| "I'll mix deck tokens and visualize tokens" | Editorial and product-console design systems are deliberately separate. | **MUST keep deck tokens pure** |
| "I'll hardcode the slide count in pagination" | `<section>` count is dynamic. `deck-stage.js` fills pagination at runtime. | **MUST use `<span class="page-num">` + `<span class="page-total">` pattern** |
| "Puppeteer install is slow — I'll skip it" | Export is a first-class feature. Install is a one-time cost. | **MUST include puppeteer in package.json** |
| "Reading the reference summary in this SKILL.md is enough" | Summaries are lossy. Token values, exact class names, and pattern structure live in the reference files. | **MUST use the Read tool to open each reference file** |
## Pressure Resistance
| User Says | Your Response |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| "Just make a simple HTML file, no Node project" | "Cannot: dev server + presenter + remote + export require Node. If you want a single static HTML without tooling, use `ring:visualize` instead — different skill, different purpose." |
| "Skip the presenter view, I don't need it" | "Generated anyway — costs nothing, adds value if needed later. Zero-config removal: just don't open the /presenter URL." |
| "Use Inter instead of Poppins" | "Cannot: `#FEED02` Amarelo + Poppins + IBM Plex Serif is Lerian editorial brand. For Inter/sunglow tokens, use `ring:visualize`." |
| "Skip the license file" | "Cannot: Lerian open-source commitment is a third rail. Apache 2.0 license ships with every scaffold." |
| "Don't watch files, just write the deck once" | "Cannot: dev server + chokidar is the scaffold default. For a static export, run `pnpm export` and distribute the PDF." |
| "Host the deck online" | "Out of scope. Scaffolded deck is local-network. Hosting is the user's choice — any static host (Vercel, Cloudflare Pages) serves `deck.html` + `assets/` + `scripts/` (minus `dev-server.mjs`). Document if the user asks." |
| "Build me a timeline/quote/org-chart slide" | "Not in v1 archetype set. Options: (a) use `content` or `content-accent` with a close-enough layout, (b) flag as v2 candidate and proceed without it." |
## Blocker Criteria — STOP and Report
STOP and report to the user if:
| Decision Type | Blocker Condition | Required Action |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Missing Runtime | Node not installed, or Node version < 18.0.0 | STOP user must install Node 18+ before proceeding |
| Directory Collision | Target directory exists and is non-empty | STOP and ask: overwrite, rename, or cancel |
| Unsupported Archetype | User asks for timeline, quote, org-chart, image-hero | STOP — report these are v2 candidates; offer alternatives from the 9 archetypes |
| Unsupported Export | User asks for PPTX export | STOP — report v1 is PDF-only; PPTX is deferred (lossy for CSS charts + absolute pos) |
| Theme Customization | User asks for accent override, density toggle, font swap | STOP — report v1 is locked to Lerian editorial tokens; customization is v2 |
| Auth on Remote | User asks for PIN or auth on the remote control | STOP — report v1 is local-network trust model; PIN auth is v2 |
HARD BLOCK — cannot proceed:
- If the skill's `references/` directory is missing any mandatory file (any of the 8 refs) → report which is missing; instruct user to re-clone the plugin repo.
- If the skill's `templates/` is missing any archetype or `deck.html` → same remediation.
## Severity Calibration
| Severity | Examples | Action |
| -------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| CRITICAL | Fixed-height cards used; hardcoded pagination `NN/17`; wrong color tokens; wrong fonts | MUST fix before completing |
| HIGH | Text smaller than 24px floor; chart without `aria-label`; speaker notes missing | SHOULD fix; warn user if shipping as-is |
| MEDIUM | Placeholder content not replaced; too many `content-accent` slides (more than 3) | Warn user; user decides |
| LOW | Minor typography drift (e.g., 80px hero where 96px would fit) | Mention; user decides |
## Cannot Be Overridden (Non-Negotiable)
- **Lerian brand tokens** — Amarelo `#FEED02`, Poppins + IBM Plex Serif, JetBrains Mono
- **Apache 2.0 license** on scaffolded deck
- **Dynamic pagination** via `<span class="page-num">` + `<span class="page-total">`
- **`flex: 1; min-height: 0`** on main content grids (layout-rules.md HARD GATE)
- **Speaker-notes JSON structure** — flat array of strings
- **WebSocket protocol** — 5 message types (`nav`, `blank`, `state`, `hello`, `reload`) in v1. `state` and `nav` carry a `total` field so the remote can render `N / M` once the main deck has announced totals. See `references/server.md` for the full protocol.
- **Self-contained scaffold** — every deck is an independent Node project; no shared workspace dependency
## Known Limitations (v1)
- **Remote shows `N / ?`** for slide-total until the first nav event fires. Server doesn't know total until main deck broadcasts. Fine for most flows; users rarely notice.
- **No auth on remote** — local network trust model. Document in handoff.
- **Puppeteer bundled Chromium** weighs ~200MB per scaffolded deck. `pnpm export:chrome` uses system Chrome instead.
- **Google Fonts online dependency** — scaffolded deck fetches fonts from Google CDN. Offline presentations need a pre-cached browser.
## Handoff Message Template
After scaffolding, print this to the user:
```
Deck scaffolded at: ./<deck-name>/
Next steps:
cd <deck-name>
pnpm install # or: npm install
pnpm dev # boots server on http://localhost:7007
During the presentation:
- Main deck: http://localhost:7007
- Presenter: http://localhost:7007/presenter (open on second screen)
- Remote: http://<LAN-IP>:7007/remote (open on phone, same Wi-Fi)
Keyboard controls (main deck):
→/Space next slide
← previous slide
F fullscreen
S toggle speaker notes overlay
G go to slide number
B blank screen
Export to PDF:
pnpm export # uses bundled Chromium (~200MB first time)
pnpm export:chrome # uses system Chrome (~50MB, faster)
Local network only — no auth. Don't expose port 7007 publicly.
```
## Future Work (v2 Candidates)
- PPTX export via pptxgenjs with graceful degradation for CSS charts
- Data-driven mode: YAML/JSON content files + template binding
- Theme customization: accent override, density toggle
- Additional archetypes: timeline, quote, org-chart, image-hero, photo-grid
- Remote auth: short-lived rotating PIN displayed on main screen
- Chart.js opt-in for richer analytics
- Self-contained HTML bundle export (no-CDN offline package)
- Multi-deck workspace (shared deps)

View file

@ -0,0 +1,304 @@
'use strict';
(function () {
var state = {
slides: [],
paginated: [],
index: 0,
blank: false,
notes: [],
notesOpen: false,
exporting: false,
sync: null,
suppressSend: false,
helloTotal: 0,
};
function qs(sel, root) { return (root || document).querySelector(sel); }
function qsa(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
function readNotes() {
var el = document.getElementById('speaker-notes');
if (!el) return [];
try { return JSON.parse(el.textContent || '[]'); }
catch (e) {
console.warn('[deck] speaker-notes JSON parse failed:', e);
return [];
}
}
function fillPagination() {
state.paginated = state.slides.filter(function (s) {
return s.querySelector('.page-num');
});
var total = state.paginated.length;
state.paginated.forEach(function (section, i) {
qsa('.page-num', section).forEach(function (n) { n.textContent = String(i + 1); });
qsa('.page-total', section).forEach(function (n) { n.textContent = String(total); });
});
// Safety: if slide count changed since last hello (e.g., hot-reload),
// re-announce total so the server-side state.total stays in sync.
if (state.sync && state.slides.length !== state.helloTotal) {
sendHello();
}
}
function sendHello() {
if (!state.sync) return;
state.sync.send({ type: 'hello', total: state.slides.length });
state.helloTotal = state.slides.length;
}
function applyStagger(section) {
if (state.exporting) return;
section.classList.remove('slide-enter');
// reflow then re-add so animation restarts
void section.offsetWidth;
section.classList.add('slide-enter');
var children = qsa(':scope > *', section);
children.forEach(function (child, i) {
child.style.animationDelay = (i * 80) + 'ms';
});
}
function show(index) {
if (index < 0) index = 0;
if (index > state.slides.length - 1) index = state.slides.length - 1;
state.slides.forEach(function (section, i) {
section.style.display = i === index ? '' : 'none';
section.setAttribute('aria-hidden', i === index ? 'false' : 'true');
});
state.index = index;
var current = state.slides[index];
if (current) applyStagger(current);
updateNotesPanel();
updateHash();
}
function updateHash() {
if (state.exporting) return;
try {
var h = '#slide=' + (state.index + 1);
if (location.hash !== h) history.replaceState(null, '', h);
} catch (e) {
// ignore: history.replaceState fails in sandboxed iframe / data: URL
}
}
function readHashSlide() {
var m = /#slide=(\d+)/.exec(location.hash || '');
if (!m) return null;
var n = parseInt(m[1], 10);
if (isNaN(n)) return null;
return Math.max(0, Math.min(state.slides.length - 1, n - 1));
}
function doubleRaf() {
return new Promise(function (resolve) {
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
});
}
function gotoIndex(n, opts) {
opts = opts || {};
if (n === state.index) {
return state.exporting ? doubleRaf() : Promise.resolve();
}
show(n);
if (!opts.silent && state.sync && !state.suppressSend) {
// Include total as belt-and-suspenders: server uses it to refresh state.total if changed.
state.sync.send({ type: 'nav', slide: state.index, total: state.slides.length });
}
// Double rAF: Puppeteer must await until the slide is actually painted,
// not just flagged display:block. Two frames guarantees layout + paint.
return state.exporting ? doubleRaf() : Promise.resolve();
}
function setBlank(on, opts) {
opts = opts || {};
state.blank = !!on;
var overlay = qs('#deck-blank-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'deck-blank-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9998;display:none;';
document.body.appendChild(overlay);
}
overlay.style.display = state.blank ? 'block' : 'none';
if (!opts.silent && state.sync && !state.suppressSend) {
state.sync.send({ type: 'blank', on: state.blank });
}
}
function ensureNotesPanel() {
var panel = qs('#deck-notes-panel');
if (panel) return panel;
panel = document.createElement('div');
panel.id = 'deck-notes-panel';
panel.style.cssText = [
'position:fixed', 'left:0', 'right:0', 'bottom:0', 'height:25vh',
'background:rgba(0,0,0,0.88)', 'color:#fff',
"font-family:'IBM Plex Serif', Georgia, serif", 'font-size:22px', 'line-height:1.4',
'padding:24px 32px', 'overflow-y:auto', 'z-index:9997', 'display:none',
'box-sizing:border-box'
].join(';') + ';';
document.body.appendChild(panel);
return panel;
}
function updateNotesPanel() {
if (!state.notesOpen) return;
var panel = ensureNotesPanel();
var raw = state.notes[state.index] || '';
var paragraphs = String(raw).split(/\n\n+/).map(function (p) {
var d = document.createElement('p');
d.textContent = p;
d.style.margin = '0 0 12px 0';
return d;
});
panel.innerHTML = '';
paragraphs.forEach(function (p) { panel.appendChild(p); });
}
function toggleNotes() {
state.notesOpen = !state.notesOpen;
var panel = ensureNotesPanel();
panel.style.display = state.notesOpen ? 'block' : 'none';
if (state.notesOpen) updateNotesPanel();
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
var p = document.documentElement.requestFullscreen && document.documentElement.requestFullscreen();
if (p && p.catch) p.catch(function () {
// ignore: fullscreen unavailable or denied by user gesture policy
});
} else {
if (document.exitFullscreen) document.exitFullscreen().catch(function () {
// ignore: fullscreen unavailable or denied by user gesture policy
});
}
}
function promptGoto() {
var input = window.prompt('Go to slide (1..' + state.slides.length + '):');
if (input == null || input === '') return;
var n = parseInt(input, 10);
if (isNaN(n)) return;
gotoIndex(Math.max(0, Math.min(state.slides.length - 1, n - 1)));
}
function onKey(e) {
if (e.defaultPrevented) return;
if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
switch (e.key) {
case 'ArrowRight':
case ' ':
case 'Spacebar':
gotoIndex(state.index + 1); e.preventDefault(); break;
case 'ArrowLeft':
gotoIndex(state.index - 1); e.preventDefault(); break;
case 'f': case 'F':
toggleFullscreen(); e.preventDefault(); break;
case 's': case 'S':
toggleNotes(); e.preventDefault(); break;
case 'g': case 'G':
promptGoto(); e.preventDefault(); break;
case 'b': case 'B':
setBlank(!state.blank); e.preventDefault(); break;
case 'Escape':
if (document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().catch(function () {
// ignore: fullscreen unavailable or denied by user gesture policy
});
}
if (state.notesOpen) toggleNotes();
if (state.blank) setBlank(false);
break;
}
}
function onHashChange() {
var n = readHashSlide();
if (n != null && n !== state.index) gotoIndex(n, { silent: true });
}
function onPostMessage(event) {
if (event.origin !== window.location.origin) return;
var msg = event.data;
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'nav' && Number.isInteger(msg.slide)) {
gotoIndex(msg.slide, { silent: true });
}
}
function init() {
state.slides = qsa('deck-stage > section');
state.notes = readNotes();
state.exporting = new URLSearchParams(location.search).get('export') === 'true';
if (state.slides.length === 0) {
console.error('[deck-stage] No <deck-stage> > <section> elements found — deck.html is empty or malformed.');
return;
}
if (state.exporting) {
document.body.classList.add('exporting');
}
fillPagination();
if (state.notes.length > 0 && state.notes.length !== state.slides.length) {
console.warn('[deck-stage] Speaker-notes length ' + state.notes.length + ' != slide count ' + state.slides.length + ' — presenter will show "(no notes)" for missing slides.');
}
var initial = readHashSlide();
show(initial == null ? 0 : initial);
window.__deck = {
goto: function (n) { return gotoIndex(n); },
next: function () { return gotoIndex(state.index + 1); },
prev: function () { return gotoIndex(state.index - 1); },
total: function () { return state.slides.length; },
current: function () { return state.index; },
blank: function (on) { setBlank(on); },
};
window.addEventListener('message', onPostMessage);
window.addEventListener('hashchange', onHashChange);
if (state.exporting) return;
document.addEventListener('keydown', onKey);
state.sync = window.DeckSync.connect('/ws', {
onOpen: function () {
sendHello();
},
onNav: function (msg) {
if (!Number.isInteger(msg.slide)) return;
state.suppressSend = true;
gotoIndex(msg.slide, { silent: true });
state.suppressSend = false;
},
onBlank: function (msg) {
state.suppressSend = true;
setBlank(!!msg.on, { silent: true });
state.suppressSend = false;
},
onState: function (msg) {
state.suppressSend = true;
if (typeof msg.slide === 'number') gotoIndex(msg.slide, { silent: true });
setBlank(!!msg.blank, { silent: true });
state.suppressSend = false;
},
onReload: function () { location.reload(); },
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 1090.88 280" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M619.42,29.69c0,15.43-10.87,27.93-27.93,27.93s-27.93-12.51-27.93-27.93,10.86-27.93,27.92-27.93,27.93,12.51,27.93,27.93h0ZM158.64,242.4v33.54H12.13v-33.55h48.36c3.16,0,5.73-2.57,5.73-5.73V87.77c0-3.16-2.57-5.73-5.73-5.73H7.85v-34.31h98.33v188.94c0,3.16,2.57,5.73,5.73,5.73h46.74ZM168.85,179.86c0,58.52,37.29,98.33,99.47,98.33,49.15,0,79.7-30.6,86.27-67.08h-40.69c-6.29,24.54-26.97,33.91-48.61,33.91-48.85,0-53.41-45.18-53.83-55-.04-1.02.77-1.87,1.79-1.87h143.61c.76-5.26,1.13-10.42,1.13-15.44,0-32.71-20.25-91.92-95.33-91.92-57.04,0-93.8,41.26-93.81,99.07ZM314.47,158.39l-99.49.02c-1.08,0-1.94-.96-1.78-2.02,4.49-31.28,30.98-42.82,50.98-42.82,33.88,0,48.71,21.59,52.08,42.79.17,1.08-.69,2.04-1.78,2.04h0ZM867.2,83.16h0s0,0,0,0h0ZM826.87,83.16h40.33v192.83h-40.32v-24.53c0-3.09-3.92-4.41-5.79-1.95-.18.23-.35.47-.53.71h0s0,.02,0,.02h0c-.11.16-.23.32-.35.47-7.92,10.56-20.68,27.53-56.24,27.53-60.83,0-91.19-46.33-91.19-98.7,0-61.42,40.37-98.72,91.17-98.72,36.73,0,48.44,15.73,56.64,26.75l.49.66c1.86,2.49,5.81,1.18,5.81-1.93v-23.14ZM770.75,115.48c34.78,0,56.14,27.81,56.14,64.8h0c0,42-26.32,63.68-56.14,63.68-34.83,0-56.14-27.43-56.14-64.43,0-42.07,26.11-64.05,56.14-64.05ZM1063.39,101.7c14.42,14.06,21.64,36.67,21.64,67.8v106.25h-41.06v-103.99c0-43.98-22.35-56.14-45.96-56.14-31.99,0-51.6,23.22-51.6,62.92v97.21h-40.68V83.15h39.93v25.07c0,3.11,3.95,4.4,5.82,1.92,8.55-11.4,26.72-29.52,55.98-29.52,37.55,0,55.75,20.88,55.93,21.08ZM657.42,275.94v-33.54h0s-37.37,0-37.37,0c-3.16,0-5.73-2.57-5.73-5.73V83.14h-81.34v34.31h37.54c3.16,0,5.73,2.57,5.73,5.73v113.48c0,3.16-2.57,5.73-5.73,5.73h-38.14v33.55h125.03ZM499.27,119.27c0,.13-.1.23-.23.23h0s-36.31,0-36.31,0c-28.68,0-28.65,24.99-28.64,43.62,0,.46,0,.92,0,1.37v111.24c0,.13-.1.23-.23.23h-41.01c-.13,0-.23-.1-.23-.23V83.27c0-.13.1-.23.23-.23h36.49c.13,0,.23.1.23.23v24.03c0,4.05,5.24,5.65,7.49,2.28.22-.32.43-.64.65-.97,8.18-12.25,18.71-28.02,52.51-28.02h9.05v38.68Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,224 @@
'use strict';
// Presenter view controller. HTML-first: presenter.html owns the DOM + CSS
// (including Amarelo accent, font stack). This file wires behaviour only —
// it queries existing elements and populates them. It MUST NOT rebuild DOM
// or inject colors/fonts from JS; the template is the source of truth.
(function () {
var state = {
notes: [],
total: null, // null until first state message (with server-provided total) arrives
index: 0,
blank: false,
startedAt: Date.now(),
timerHandle: null,
thumbsInitialized: false,
sync: null,
};
// DOM handles resolved at init()
var dom = {
notesBody: null,
notesIndex: null,
notesTotal: null,
timer: null,
thumbCurrent: null,
thumbNext: null,
thumbCurrentWrap: null,
thumbNextWrap: null,
body: null,
};
function extractNotes(html) {
var re = /<script[^>]+id=["']speaker-notes["'][^>]*>([\s\S]*?)<\/script>/i;
var m = re.exec(html);
if (!m) {
showNotesError('speaker-notes <script id="speaker-notes"> block not found in /deck.html');
return [];
}
// Strip HTML comments before parsing — the export build sometimes wraps JSON in comments.
var body = m[1].replace(/<!--[\s\S]*?-->/g, '').trim();
try {
return JSON.parse(body);
} catch (e) {
showNotesError('Speaker notes parse failed: ' + e.message + ' (check deck.html speaker-notes JSON)');
return [];
}
}
function showNotesError(msg) {
console.warn('[presenter]', msg);
if (dom.notesBody) dom.notesBody.textContent = msg;
}
// Count slides via DOMParser. Scoped to `deck-stage > section` so that
// <section> inside comments, string literals in inline scripts, or nested
// constructs don't inflate the count.
function countSlides(html) {
try {
var doc = new DOMParser().parseFromString(html, 'text/html');
return doc.querySelectorAll('deck-stage > section').length;
} catch (e) {
console.warn('[presenter] countSlides DOMParser failed:', e);
return 0;
}
}
function renderNotes() {
if (!dom.notesBody) return;
var total = (state.total == null) ? '' : String(state.total);
if (dom.notesIndex) dom.notesIndex.textContent = String(state.index + 1);
if (dom.notesTotal) dom.notesTotal.textContent = total;
var raw = state.notes[state.index];
if (!raw) {
dom.notesBody.textContent = '(no notes)';
return;
}
// Split on blank lines → paragraphs. Plain text; no HTML injection.
dom.notesBody.textContent = '';
String(raw).split(/\n\n+/).forEach(function (p) {
var el = document.createElement('p');
el.textContent = p;
dom.notesBody.appendChild(el);
});
}
// Thumbnail iframes: initialise src once, then navigate via postMessage.
// deck-stage.js (loaded inside each iframe) listens for {type:'nav', slide:N}.
// Fallback: if the iframe isn't ready on first nav, we still have the hash
// set in src, so it will land on the right slide.
function updateThumbs() {
var curIdx = state.index + 1;
var nextIdx = (state.total != null)
? Math.min(state.total, state.index + 2)
: state.index + 2;
if (!state.thumbsInitialized) {
if (dom.thumbCurrent) dom.thumbCurrent.src = '/?export=true#slide=' + curIdx;
if (dom.thumbNext) dom.thumbNext.src = '/?export=true#slide=' + nextIdx;
state.thumbsInitialized = true;
return;
}
postNav(dom.thumbCurrent, curIdx);
postNav(dom.thumbNext, nextIdx);
// Dim the "next" thumbnail when we're on the last slide
if (dom.thumbNextWrap) {
var atEnd = (state.total != null && state.index >= state.total - 1);
dom.thumbNextWrap.classList.toggle('dimmed', atEnd);
dom.thumbNextWrap.classList.toggle('end', atEnd);
}
}
function postNav(iframe, slide1) {
if (!iframe) return;
var win = iframe.contentWindow;
if (!win) return;
try {
win.postMessage({ type: 'nav', slide: slide1 }, '*');
} catch (e) {
// Fallback: reset src. Rare; postMessage to a same-origin child is safe.
iframe.src = '/?export=true#slide=' + slide1;
}
}
function applyBlank() {
if (dom.body) dom.body.style.opacity = state.blank ? '0.5' : '1';
}
function formatElapsed(ms) {
var s = Math.floor(ms / 1000);
var h = Math.floor(s / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
function pad(n) { return n < 10 ? '0' + n : String(n); }
return pad(h) + ':' + pad(m) + ':' + pad(sec);
}
function tickTimer() {
if (dom.timer) dom.timer.textContent = formatElapsed(Date.now() - state.startedAt);
}
function resetTimer() {
state.startedAt = Date.now();
tickTimer();
}
function onKey(e) {
if (e.key === 'r' || e.key === 'R') {
resetTimer();
e.preventDefault();
}
}
function resolveDom() {
dom.body = document.body;
dom.notesBody = document.querySelector('#notes-body');
dom.notesIndex = document.querySelector('#notes-index');
dom.notesTotal = document.querySelector('#notes-total');
dom.timer = document.querySelector('#timer');
dom.thumbCurrent = document.querySelector('#thumb-current');
dom.thumbNext = document.querySelector('#thumb-next');
dom.thumbCurrentWrap = document.querySelector('#thumb-current-wrap');
dom.thumbNextWrap = document.querySelector('#thumb-next-wrap');
}
function init() {
resolveDom();
fetch('/deck.html', { cache: 'no-store' })
.then(function (r) {
if (!r.ok) {
throw new Error('HTTP ' + r.status + ' fetching /deck.html');
}
return r.text();
})
.then(function (html) {
state.notes = extractNotes(html);
// Local fallback count from the HTML. Authoritative total will arrive
// via the WS `state` message (server-provided) once connected.
var localTotal = countSlides(html);
if (localTotal > 0 && state.total == null) state.total = localTotal;
renderNotes();
updateThumbs();
state.timerHandle = setInterval(tickTimer, 1000);
tickTimer();
document.addEventListener('keydown', onKey);
state.sync = window.DeckSync.connect('/ws', {
onNav: function (msg) {
if (!Number.isInteger(msg.slide)) return;
state.index = msg.slide;
renderNotes();
updateThumbs();
},
onBlank: function (msg) {
state.blank = !!msg.on;
applyBlank();
},
onState: function (msg) {
if (Number.isInteger(msg.slide)) state.index = msg.slide;
state.blank = !!msg.blank;
if (Number.isInteger(msg.total)) state.total = msg.total;
renderNotes();
updateThumbs();
applyBlank();
},
onReload: function () { location.reload(); },
});
})
.catch(function (e) {
console.error('[presenter] failed to load /deck.html:', e);
showNotesError('Failed to load /deck.html: ' + e.message);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -0,0 +1,204 @@
'use strict';
// Remote-control view controller. HTML-first: remote.html owns the DOM + CSS
// (including Amarelo accent, Poppins/JetBrains Mono fonts, safe-area insets,
// button layout). This file wires behaviour only — it queries existing
// elements and attaches listeners. It MUST NOT rebuild DOM or inject colors/
// fonts from JS; the template is the source of truth.
(function () {
var state = {
index: 0,
total: null, // null until server sends a state message with total
blank: false,
sync: null,
wakeLock: null,
};
// DOM handles resolved at init()
var dom = {
banner: null,
currentSlide: null,
totalSlides: null,
btnPrev: null,
btnNext: null,
btnBlank: null,
btnGoto: null,
btnFirst: null,
btnLast: null,
wakeLockStatus: null,
};
function buzz() {
if (navigator.vibrate) try { navigator.vibrate(20); } catch (e) { /* iOS silently disallows */ }
}
function updateReadout() {
if (dom.currentSlide) {
dom.currentSlide.textContent = (state.total != null) ? String(state.index + 1) : '';
}
if (dom.totalSlides) {
dom.totalSlides.textContent = (state.total != null) ? String(state.total) : '';
}
if (dom.btnBlank) {
dom.btnBlank.textContent = state.blank ? 'Unblank' : 'Blank';
dom.btnBlank.classList.toggle('active', state.blank);
}
// First/Last only meaningful when total is known
var totalKnown = (state.total != null);
if (dom.btnFirst) dom.btnFirst.disabled = !totalKnown;
if (dom.btnLast) dom.btnLast.disabled = !totalKnown;
if (dom.btnPrev) dom.btnPrev.disabled = !totalKnown;
if (dom.btnNext) dom.btnNext.disabled = !totalKnown;
if (dom.btnGoto) dom.btnGoto.disabled = !totalKnown;
}
function navigateDelta(delta) {
if (state.total == null) return;
var next = Math.max(0, Math.min(state.total - 1, state.index + delta));
if (next === state.index) return;
sendNav(next);
}
function sendNav(n) {
if (!state.sync) return;
var ok = state.sync.send({ type: 'nav', slide: n });
if (ok) buzz();
}
function toggleBlank() {
if (!state.sync) return;
var ok = state.sync.send({ type: 'blank', on: !state.blank });
if (ok) buzz();
}
function promptGoto() {
if (state.total == null) return;
var input = window.prompt('Go to slide (1..' + state.total + '):');
if (input == null || input === '') return;
var n = parseInt(input, 10);
if (isNaN(n)) return;
var idx = Math.max(0, Math.min(state.total - 1, n - 1));
sendNav(idx);
}
function gotoFirst() {
if (state.total == null) return;
sendNav(0);
}
function gotoLast() {
if (state.total == null) return;
sendNav(state.total - 1);
}
function setBanner(text, kind) {
if (!dom.banner) return;
if (!text) {
dom.banner.classList.remove('visible', 'error', 'ok');
dom.banner.textContent = '';
return;
}
dom.banner.textContent = text;
dom.banner.classList.remove('error', 'ok');
if (kind) dom.banner.classList.add(kind);
dom.banner.classList.add('visible');
}
function flashBanner(text, kind, ms) {
setBanner(text, kind);
setTimeout(function () { setBanner('', ''); }, ms);
}
function setWakeLockStatus(msg) {
if (dom.wakeLockStatus) dom.wakeLockStatus.textContent = msg || '';
}
// Wake Lock API: only available on HTTPS (secure context). On LAN HTTP —
// the default deployment for a phone remote — request() rejects with
// NotAllowedError. Show a subtle indicator so the user knows to keep the
// screen awake manually.
async function requestWakeLock() {
if (!('wakeLock' in navigator)) {
setWakeLockStatus('screen-sleep: on (no wake lock api)');
return;
}
try {
state.wakeLock = await navigator.wakeLock.request('screen');
setWakeLockStatus('');
if (state.wakeLock.addEventListener) {
state.wakeLock.addEventListener('release', function () {
state.wakeLock = null;
setWakeLockStatus('screen-sleep: on');
});
}
} catch (e) {
// HTTP / insecure context, or missing user gesture.
setWakeLockStatus('screen-sleep: on');
}
}
function resolveDom() {
dom.banner = document.querySelector('#status-banner');
dom.currentSlide = document.querySelector('#current-slide');
dom.totalSlides = document.querySelector('#total-slides');
dom.btnPrev = document.querySelector('#btn-prev');
dom.btnNext = document.querySelector('#btn-next');
dom.btnBlank = document.querySelector('#btn-blank');
dom.btnGoto = document.querySelector('#btn-goto');
dom.btnFirst = document.querySelector('#btn-first');
dom.btnLast = document.querySelector('#btn-last');
dom.wakeLockStatus = document.querySelector('#wakelock-status');
}
function wireButtons() {
if (dom.btnPrev) dom.btnPrev.addEventListener('click', function () { navigateDelta(-1); });
if (dom.btnNext) dom.btnNext.addEventListener('click', function () { navigateDelta(1); });
if (dom.btnBlank) dom.btnBlank.addEventListener('click', toggleBlank);
if (dom.btnGoto) dom.btnGoto.addEventListener('click', promptGoto);
if (dom.btnFirst) dom.btnFirst.addEventListener('click', gotoFirst);
if (dom.btnLast) dom.btnLast.addEventListener('click', gotoLast);
}
function init() {
resolveDom();
wireButtons();
updateReadout();
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && !state.wakeLock) requestWakeLock();
});
document.body.addEventListener('click', function once() {
if (!state.wakeLock) requestWakeLock();
document.body.removeEventListener('click', once);
});
requestWakeLock();
state.sync = window.DeckSync.connect('/ws', {
onOpen: function () { flashBanner('connected', 'ok', 1200); },
onClose: function () { setBanner('reconnecting…', 'error'); },
onNav: function (msg) {
if (!Number.isInteger(msg.slide)) return;
state.index = msg.slide;
// MUST NOT mutate state.total from nav — total is authoritative from state messages only.
updateReadout();
},
onBlank: function (msg) {
state.blank = !!msg.on;
updateReadout();
},
onState: function (msg) {
if (Number.isInteger(msg.slide)) state.index = msg.slide;
state.blank = !!msg.blank;
if (Number.isInteger(msg.total)) state.total = msg.total;
updateReadout();
},
onReload: function () { location.reload(); },
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -0,0 +1,110 @@
'use strict';
(function (root) {
function isExport() {
try {
return new URLSearchParams(root.location.search).get('export') === 'true';
} catch (e) {
return false;
}
}
function stubConnection() {
// Export mode runs under Puppeteer. No live-reload, no external sync —
// Puppeteer must drive navigation deterministically with goto().
return {
send: function () {},
close: function () {},
};
}
function connect(url, handlers) {
if (isExport()) return stubConnection();
handlers = handlers || {};
var ws = null;
var closed = false;
var attempt = 0;
var reconnectTimer = null;
function resolveUrl(u) {
if (/^wss?:\/\//i.test(u)) return u;
var proto = root.location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + root.location.host + (u.charAt(0) === '/' ? u : '/' + u);
}
function scheduleReconnect() {
if (closed) return;
// Cap the exponent so delay tops out at ~10s even under sustained failure,
// and add jitter so N clients reconnecting after a server restart don't
// stampede in lockstep. Cap attempt itself to prevent unbounded growth.
var capped = Math.min(attempt, 10);
var base = Math.min(10000, 1000 * Math.pow(2, capped));
var delay = base + Math.floor(Math.random() * 500);
attempt = Math.min(attempt + 1, 30);
reconnectTimer = setTimeout(open, delay);
}
function dispatch(msg) {
switch (msg && msg.type) {
case 'nav': handlers.onNav && handlers.onNav(msg); break;
case 'blank': handlers.onBlank && handlers.onBlank(msg); break;
case 'state': handlers.onState && handlers.onState(msg); break;
case 'reload': handlers.onReload && handlers.onReload(msg); break;
}
}
function open() {
try {
ws = new WebSocket(resolveUrl(url));
} catch (e) {
console.warn('[DeckSync] WebSocket construct failed:', e);
scheduleReconnect();
return;
}
ws.addEventListener('open', function () {
attempt = 0;
handlers.onOpen && handlers.onOpen();
});
ws.addEventListener('message', function (ev) {
// Browsers deliver Blob/ArrayBuffer for binary frames — server only
// sends JSON text, so anything non-string is noise. Drop silently.
if (typeof ev.data !== 'string') return;
var msg;
try { msg = JSON.parse(ev.data); } catch (e) {
console.warn('[DeckSync] bad message:', ev.data);
return;
}
dispatch(msg);
});
ws.addEventListener('close', function () {
handlers.onClose && handlers.onClose();
scheduleReconnect();
});
ws.addEventListener('error', function (e) {
console.warn('[DeckSync] socket error:', e && e.message ? e.message : e);
});
}
open();
return {
send: function (msg) {
if (!ws || ws.readyState !== 1) return false;
try { ws.send(JSON.stringify(msg)); return true; }
catch (e) { console.warn('[DeckSync] send failed:', e); return false; }
},
close: function () {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) try { ws.close(); } catch (e) {}
},
};
}
root.DeckSync = { connect: connect };
if (typeof module !== 'undefined' && module.exports) {
module.exports = root.DeckSync;
}
})(typeof window !== 'undefined' ? window : this);

View file

@ -0,0 +1,554 @@
# Chart Primitives
Four chart types, all pure CSS/HTML — no Chart.js, no D3, no build step. Charts render identically in browser, PDF, and presenter preview.
**v1 scope:** four primitives cover ~80% of board/investor/conference deck needs.
**v2 candidate:** Chart.js opt-in for richer analytical slides (scatter plots, multi-series line charts, stacked vertical bars with annotations, radar, treemap).
**HARD GATE: Accessibility.** Every chart MUST declare `role="img"` and `aria-label="..."` on the chart container. The reference deck had zero accessibility annotations on its charts — that is the gap this doc closes. Color MUST NOT be the only signal; labels and numeric values MUST be visible on segments wherever space allows.
## Table of Contents
| # | Primitive | Typical use |
| --- | --- | --- |
| 1 | [stacked-horizontal-bar](#stacked-horizontal-bar) | Cap table, ownership, budget split |
| 2 | [vertical-bar-chart](#vertical-bar-chart) | Time-series: MRR, headcount, revenue |
| 3 | [2x2-matrix](#2x2-matrix) | Competitive map, strategic framework |
| 4 | [funnel](#funnel) | Pipeline, conversion stages |
---
## stacked-horizontal-bar
**Purpose:** Render a single 100% bar split into proportional segments, paired with a legend table that shows each segment's label, color swatch, and exact percentage.
**When to use:**
- Cap table / ownership breakdown (the canonical use).
- Budget allocation — single period, multi-category.
- Revenue mix by product line (single snapshot).
- Any "shares of 100%" relationship with 48 segments.
**When NOT to use:**
- More than 8 segments — labels collide in the legend; merge the smallest into "Others".
- Segments smaller than 3% — visually unreadable in the bar; fold into "Others" or annotate with a leader line (complexity cost; avoid).
- Comparing two distributions side-by-side — use two bars or a grouped bar, NOT two independent stacked bars the audience has to eyeball-compare.
- Time series — this is a snapshot. For evolution, use vertical-bar-chart.
**Reference example:** Lerian board deck, slide 10 — Cap Table & ESOP. Extracted verbatim from `lerian-ppt-example.html` lines 11431168.
**HTML:**
```html
<div class="chart-stacked-hbar" role="img"
aria-label="Cap table fully diluted: Fred Amaral 31.6%, Maya Capital 19.4%, Supera Capital 10.6%, Quartzo 8.6%, Norte Ventures 8.9%, Kevin Efrusy Accel 5.0%, ESOP granted 6.0%, Others 9.9%">
<div class="eyebrow" style="margin-bottom: 20px;">Ownership · fully diluted</div>
<!-- The bar -->
<div class="bar">
<div style="flex: 31.6; background: var(--c-ink);"></div>
<div style="flex: 19.4; background: var(--c-accent-2);"></div>
<div style="flex: 10.6; background: #737373;"></div>
<div style="flex: 8.6; background: #a3a3a3;"></div>
<div style="flex: 8.9; background: #d4d4d4;"></div>
<div style="flex: 5.0; background: #0E7490;"></div>
<div style="flex: 6.0; background: var(--c-accent); border: 2px solid var(--c-ink); box-sizing: border-box;"></div>
<div style="flex: 9.9; background: #f5f5f4;"></div>
</div>
<!-- Legend -->
<div class="legend">
<div class="row"><span><span class="sw" style="background: var(--c-ink);"></span>Fred Amaral</span><span class="num">31.6%</span></div>
<div class="row"><span><span class="sw" style="background: var(--c-accent-2);"></span>Maya Capital</span><span class="num">19.4%</span></div>
<div class="row"><span><span class="sw" style="background: #737373;"></span>Supera Capital</span><span class="num">10.6%</span></div>
<div class="row"><span><span class="sw" style="background: #a3a3a3;"></span>Norte Ventures</span><span class="num">8.9%</span></div>
<div class="row"><span><span class="sw" style="background: #d4d4d4;"></span>Quartzo</span><span class="num">8.6%</span></div>
<div class="row"><span><span class="sw" style="background: #0E7490;"></span>Kevin Efrusy · Accel</span><span class="num">5.0%</span></div>
<div class="row"><span><span class="sw" style="background: var(--c-accent); border: 1px solid var(--c-ink);"></span><strong>ESOP granted</strong></span><span class="num">6.0%</span></div>
<div class="row muted"><span><span class="sw" style="background: #f5f5f4; border: 1px solid var(--c-rule);"></span>Others</span><span class="num">9.9%</span></div>
</div>
</div>
```
**CSS:**
```css
.chart-stacked-hbar { display: flex; flex-direction: column; }
.chart-stacked-hbar .bar {
display: flex;
width: 100%;
height: 64px;
border-radius: 4px;
overflow: hidden;
margin-bottom: 28px;
}
.chart-stacked-hbar .legend {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 20px;
flex: 1;
justify-content: space-between;
}
.chart-stacked-hbar .legend .row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--c-rule);
}
.chart-stacked-hbar .legend .row:last-child { border-bottom: none; }
.chart-stacked-hbar .legend .row.muted { color: var(--c-ink-3); }
.chart-stacked-hbar .legend .row.muted .num { color: var(--c-ink-3); }
.chart-stacked-hbar .sw {
display: inline-block;
width: 14px; height: 14px;
margin-right: 12px;
vertical-align: middle;
}
.chart-stacked-hbar .num {
font-family: 'JetBrains Mono', monospace;
color: var(--c-ink);
}
```
**Design calls:**
- **No in-bar labels.** The reference puts labels in the legend below, not inside the segments. Rationale: at 8 segments, most segments are too narrow (<10% of 1920px-ish width) for readable Poppins-20px text. Legend pattern is canonical; segment-label pattern is v2 territory.
- **Segment color ramp.** The reference uses a grayscale ramp (`#191A1B → #d4d4d4#f5f5f4`) for stakeholder rank, plus two accent segments (`--c-accent-2` Verde for the top external investor, `--c-accent` Amarelo-with-border for the ESOP highlight). Followers of the archetype SHOULD keep this pattern: grayscale for default segments, Amarelo + Preto border for the one segment the slide is about.
**Accessibility:**
- `role="img"` + `aria-label` listing every segment with its percentage — this is the HARD GATE.
- Legend duplicates color as text + numeric percentage — audiences who can't distinguish the grayscale ramp still read the table.
**Anti-patterns:**
- More than 8 segments — labels collide in the legend row padding. Fold smaller segments into "Others".
- Segments < 3% width visually ambiguous; merge or annotate.
- Missing `aria-label` — fails accessibility gate.
- Hard-coded percentages in pixels instead of `flex:` values — breaks if canvas resizes for print-export.
- Using color alone to distinguish segments (no numeric label, no legend) — fails WCAG 1.4.1.
---
## vertical-bar-chart
**Purpose:** Time-series bars over an ordered category axis (months, quarters, years). One bar per period, height proportional to value, with the latest/highlighted bar in `--c-accent`.
**When to use:**
- MRR / ARR / revenue evolution.
- Headcount growth over time.
- Any monthly/quarterly metric where "trajectory" is the message.
**When NOT to use:**
- More than ~8 bars on 1920×1080 — bars get skinny; switch to a line chart (v2 Chart.js territory).
- Multi-series comparison — this is single-series only. v2 handles grouped/stacked vertical.
- Negative values — no zero line; all bars grow from the baseline up. For mixed pos/neg, use a table.
- Non-ordered categories — a bar chart implies sequence. For category comparison, use a table with a bar-in-cell pattern.
**Reference example:** Lerian board deck, slide 8 — Financial KPIs (MRR evolution, dark variant). Extracted from lines 9851020.
**HTML:**
```html
<div class="chart-vbar" role="img"
aria-label="MRR evolution in thousands of reais per month: May 2025 R$30K, Aug 2025 R$65K, Oct 2025 R$90K, Jan 2026 R$167K, Mar 2026 R$207K">
<div class="eyebrow" style="color: rgba(255,255,255,0.55); margin-bottom: 8px;">MRR evolution · R$ thousand / month</div>
<div class="caption">R$ 30K &nbsp;·&nbsp; May 25 &nbsp;&nbsp; R$ 207K &nbsp;·&nbsp; Mar 26</div>
<div class="bars">
<div class="bar"><div class="v">30</div> <div class="fill" style="height: 14%;"></div></div>
<div class="bar"><div class="v">65</div> <div class="fill" style="height: 31%;"></div></div>
<div class="bar"><div class="v">90</div> <div class="fill" style="height: 43%;"></div></div>
<div class="bar"><div class="v">167</div> <div class="fill" style="height: 81%;"></div></div>
<div class="bar highlight"><div class="v">207</div><div class="fill" style="height: 100%;"></div></div>
</div>
<div class="axis">
<div>May 25</div><div>Aug 25</div><div>Oct 25</div><div>Jan 26</div><div>Mar 26</div>
</div>
</div>
```
**CSS:**
```css
.chart-vbar { display: flex; flex-direction: column; }
.chart-vbar .caption {
color: var(--c-ink-inv);
font-size: 22px;
margin-bottom: 28px;
}
.chart-vbar .bars {
flex: 1;
display: grid;
grid-template-columns: repeat(5, 1fr); /* match column count to bar count */
gap: 18px;
align-items: end;
padding-bottom: 60px;
position: relative;
border-bottom: 1px solid rgba(255,255,255,0.18);
}
.chart-vbar .bar {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}
.chart-vbar .bar .v {
font-family: 'JetBrains Mono', monospace;
font-size: 20px;
color: var(--c-ink-inv);
margin-bottom: 12px;
}
.chart-vbar .bar .fill {
width: 100%;
background: rgba(255,255,255,0.85);
border-radius: 2px 2px 0 0;
}
.chart-vbar .bar.highlight .fill { background: var(--c-accent); }
.chart-vbar .axis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 18px;
padding-top: 14px;
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
color: rgba(255,255,255,0.55);
text-align: center;
}
/* Light-variant override: chart on .slide (not .slide.dark) */
.slide:not(.dark) .chart-vbar .bars { border-bottom-color: var(--c-rule); }
.slide:not(.dark) .chart-vbar .bar .fill { background: var(--c-ink); }
.slide:not(.dark) .chart-vbar .bar .v { color: var(--c-ink); }
.slide:not(.dark) .chart-vbar .axis { color: var(--c-ink-3); }
.slide:not(.dark) .chart-vbar .caption { color: var(--c-ink); }
```
**Design calls:**
- **Percentage heights set manually in inline `style`.** Author computes `height = (value / max) * 100%` by hand — no JS. This is the chart-primitives contract: zero computation at render time.
- **Highlight-last-bar convention.** The rightmost (or current-period) bar uses `--c-accent` by default to signal "this is where we are now." Override by moving `.highlight` to any bar that carries the slide's message.
- **Dark variant is default in the reference** because slide 8 is a `.slide.dark` KPI wall. The CSS above includes a light-variant override so the primitive works on both `.slide` and `.slide.dark`.
**Accessibility:**
- `role="img"` + `aria-label` listing every period and value.
- Numeric value visible above each bar (`.v`) — audiences do not need to estimate heights.
- Month labels below — no decoding of bar position required.
**Anti-patterns:**
- More than 8 bars — switch to line chart (v2).
- Bars without numeric labels — audiences squint to estimate; defeats the editorial floor (24px body text principle).
- Grid column count not matching bar count — axis labels misalign. `grid-template-columns: repeat(N, 1fr)` MUST be duplicated on `.bars` and `.axis` with the same N.
- Using `.slide.accent` (Amarelo background) — chart readability collapses. See "Using charts inside archetypes" below.
---
## 2x2-matrix
**Purpose:** Position entities on a two-axis strategic map to show competitive/strategic relationships. Classic consulting framework (BCG matrix, Ansoff, etc.), adapted to editorial visuals.
**When to use:**
- Competitive positioning (you vs. competitors on two dimensions).
- Strategic framework (effort vs. impact, risk vs. reward).
- Portfolio segmentation (stars/dogs/cash-cows/question-marks).
**When NOT to use:**
- More than ~6 entities on the matrix — label collision becomes a visual mess.
- Axes that aren't genuinely 2-dimensional — if one axis is a function of the other, it's a scatter plot, not a matrix.
- Numeric positioning — this is a qualitative framework. Audiences read "upper-right" / "lower-left", not `(0.82, 0.82)`. If numbers matter, use a table.
- When you don't know where competitors sit — do the homework first; a matrix with shaky positioning is worse than no matrix.
**Reference example:** Lerian board deck, slide 7 — Competitive Positioning. Extracted from lines 848886.
**HTML:**
```html
<div class="chart-2x2" role="img"
aria-label="Competitive positioning on composability (y-axis) and deployment flexibility (x-axis): Thought Machine low-low, Mambu low-low, Matera middle, Lerian high-high">
<!-- Y-axis label (rotated) -->
<div class="y-axis">Composability →</div>
<!-- X-axis label -->
<div class="x-axis">Deployment flexibility →</div>
<!-- Plot area -->
<div class="plot">
<!-- Quadrant grid lines -->
<div class="v-rule"></div>
<div class="h-rule"></div>
<!-- Entities — positions set manually via inline style (left: X%, bottom: Y%) -->
<div class="entity" style="left: 12%; bottom: 18%;">
<div class="dot"></div>
<div class="name">Thought Machine</div>
<div class="meta">1 product · SaaS only</div>
</div>
<div class="entity" style="left: 22%; bottom: 28%;">
<div class="dot"></div>
<div class="name">Mambu</div>
<div class="meta">1 product · SaaS only</div>
</div>
<div class="entity" style="left: 55%; bottom: 38%;">
<div class="dot"></div>
<div class="name">Matera</div>
<div class="meta">Monolithic · SaaS + BYOC</div>
</div>
<div class="entity highlight" style="left: 82%; bottom: 82%;">
<div class="dot"></div>
<div class="name">Lerian</div>
<div class="meta strong">8 primitives + plugins</div>
<div class="meta">BYOC · SaaS · Cloud</div>
</div>
</div>
</div>
```
**CSS:**
```css
.chart-2x2 {
position: relative;
padding: 0 0 60px 60px;
display: flex;
flex-direction: column;
}
.chart-2x2 .y-axis {
position: absolute;
left: 0; top: 50%;
transform: translateY(-50%) rotate(-90deg);
transform-origin: left center;
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--c-ink-3);
white-space: nowrap;
}
.chart-2x2 .x-axis {
position: absolute;
left: 60px; right: 0; bottom: 0;
text-align: center;
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--c-ink-3);
}
.chart-2x2 .plot {
position: relative;
flex: 1;
min-height: 440px;
border-left: 1px solid var(--c-ink);
border-bottom: 1px solid var(--c-ink);
}
.chart-2x2 .v-rule {
position: absolute;
left: 50%; top: 0; bottom: 0;
border-left: 1px dashed var(--c-rule);
}
.chart-2x2 .h-rule {
position: absolute;
left: 0; right: 0; top: 50%;
border-top: 1px dashed var(--c-rule);
}
.chart-2x2 .entity {
position: absolute;
transform: translate(-50%, 50%); /* dot centered on (left, bottom) coords */
}
.chart-2x2 .entity .dot {
width: 20px; height: 20px;
background: var(--c-ink-3);
border-radius: 999px;
}
.chart-2x2 .entity .name {
font-family: 'Poppins', sans-serif;
font-weight: 500;
font-size: 22px;
margin-top: 8px;
white-space: nowrap;
}
.chart-2x2 .entity .meta {
font-size: 16px;
color: var(--c-ink-3);
}
.chart-2x2 .entity .meta.strong {
color: var(--c-ink);
font-weight: 500;
font-size: 18px;
}
.chart-2x2 .entity.highlight .dot {
width: 36px; height: 36px;
background: var(--c-accent);
border: 3px solid var(--c-ink);
}
.chart-2x2 .entity.highlight .name {
font-weight: 600;
font-size: 28px;
margin-top: 10px;
}
```
**Design calls:**
- **Inline `left`/`bottom` percentages for positioning.** Authors place entities manually — this is declarative, deterministic, and doesn't require a JS pass. Percentages are relative to the `.plot` box (`position: relative`).
- **Coordinate convention.** `left: X%` = distance from left edge (0% = far left, 100% = far right). `bottom: Y%` = distance from bottom edge (0% = bottom axis, 100% = top). The CSS translates `(-50%, 50%)` so the dot center sits exactly on the declared coords.
- **Dashed quadrant lines over solid axes.** The outer L-shape (`border-left` + `border-bottom` on `.plot`) is solid — that's the axes. The inner `+` cross is dashed — that's the quadrant divider. This hierarchy is intentional: axes are chart structure, quadrants are annotation.
- **Highlight styling for "you".** The entity the slide is about gets `.highlight` — bigger dot, Amarelo fill with Preto border, larger name. Everyone else is muted gray. This is the editorial voice — the matrix exists to frame a single claim.
- **Label placement.** Labels sit below-right of the dot (natural reading order). When two entities are close together, authors MUST manually nudge `left`/`bottom` by a few percent to avoid overlap. There is no auto-layout.
**Accessibility:**
- `role="img"` + `aria-label` with every entity and its rough quadrant ("low-low", "upper-right", etc.).
- Axis labels are text (not graphic) — screen readers read them.
- Dot color is not the only signal: labels and `.meta` sit alongside.
**Anti-patterns:**
- Overlapping labels — nudge positions manually; if they still collide, you have too many entities. Cut.
- Axes without labels ("Composability" / "Deployment flexibility" in the reference) — an unlabeled 2x2 is indecipherable. Labels are MANDATORY.
- Numeric axis ticks on a qualitative matrix — implies precision the framework doesn't have. Keep axes text-only with an arrow (`→`).
- Positioning Lerian at exactly `(100%, 100%)` — dot gets clipped by the plot border. Keep all entities within 1090% range.
- Missing quadrant dashed lines — audiences can't parse "upper-right" without the divider cross.
---
## funnel
**Purpose:** Show stages of a pipeline or conversion with decreasing counts from left to right. Each stage is a labeled cell with its count; later stages can use a `--c-accent` or dark background to mark commitment.
**When to use:**
- Sales pipeline (Hunting → SAL → SQL → Negotiation → Proposal → Signed).
- Hiring funnel (Applied → Screened → Interviewed → Offer → Hired).
- Conversion funnel (Visitor → Signup → Activated → Paid).
- Any ordered reduction where absolute counts matter more than conversion rates.
**When NOT to use:**
- When conversion rates between stages are the message — use a table with stage-to-stage conversion percentages instead, or a Sankey diagram (v2).
- When stages are weighted by revenue, not count — the equal-column-width layout misleads. Use a table with a revenue column, OR scale the column `flex` by dollar value.
- More than 7 stages — cells shrink below the 24px type floor. Collapse adjacent early-funnel stages.
- Unordered categories — a funnel implies sequence. Don't use it for a segmentation.
**Reference example:** Lerian board deck, slide 6 — Active Pipeline (full funnel). Extracted from lines 737763.
**HTML:**
```html
<div class="chart-funnel" role="img"
aria-label="Sales pipeline: Hunting 42, SAL 28, SQL 15, Negotiation 8, Proposal 4, Contract Signed this month 2">
<div class="stages">
<div class="stage">
<div class="label">Hunting</div>
<div class="count">42</div>
</div>
<div class="stage">
<div class="label">SAL</div>
<div class="count">28</div>
</div>
<div class="stage">
<div class="label">SQL</div>
<div class="count">15</div>
</div>
<div class="stage warm">
<div class="label">Negotiation</div>
<div class="count strong">8</div>
</div>
<div class="stage warm">
<div class="label">Proposal</div>
<div class="count strong">4</div>
</div>
<div class="stage hot">
<div class="label">Contract signed this month</div>
<div class="count strong">2</div>
</div>
</div>
</div>
```
**CSS:**
```css
.chart-funnel .stages {
display: grid;
grid-template-columns: repeat(6, 1fr); /* one column per stage */
gap: 0;
border-top: 1px solid var(--c-ink);
border-bottom: 1px solid var(--c-ink);
}
.chart-funnel .stage {
padding: 44px 28px;
border-right: 1px solid var(--c-rule);
}
.chart-funnel .stage:last-child { border-right: none; }
.chart-funnel .stage .label {
font-family: 'JetBrains Mono', monospace;
font-size: 12px; /* chrome — below 24px floor is OK per layout-rules */
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--c-ink-3);
}
.chart-funnel .stage .count {
font-family: 'Poppins', sans-serif;
font-size: 72px;
font-weight: 500;
letter-spacing: -0.02em;
margin-top: 14px;
font-variant-numeric: tabular-nums;
}
.chart-funnel .stage .count.strong { font-weight: 600; }
/* Stage temperature — warm = near-close, hot = signed */
.chart-funnel .stage.warm { background: #F0EFE9; }
.chart-funnel .stage.hot {
background: var(--c-ink);
color: var(--c-ink-inv);
}
.chart-funnel .stage.hot .label { color: var(--c-accent); }
```
**Design calls:**
- **Equal-width columns, not decreasing trapezoids.** The reference uses a `repeat(6, 1fr)` grid — every stage gets the same horizontal real estate. Rationale: the editorial reading is the *number* in each stage, not the *shape* of the funnel. A true narrowing-trapezoid funnel is a classic chart library feature (v2 Chart.js) and adds no information the numbers don't already carry. The grid-strip is cleaner and legible at projection distance.
- **Temperature progression.** Early stages are default chrome (white bg, gray label). Middle stages (`.warm`) get a warm-paper tint (`#F0EFE9`) to signal "close to close." The final stage (`.hot`) inverts to Preto + Amarelo label — this is the slide's payoff. Authors override the cutoff per deck.
- **Labels at 12px mono, counts at 72px Poppins.** This violates the 24px body-floor from `layout-rules.md` — deliberate and documented: mono chrome labels are allowed below 24px (see layout-rules "Minimum Text Size" exceptions). The count is the content; the label is chrome.
**Accessibility:**
- `role="img"` + `aria-label` listing every stage and count in order — critical because the visual reduction isn't conveyed by labels alone.
- Counts are text (not graphic) — screen readers read them.
- Color (`.warm`, `.hot`) is not the only signal: labels + counts still carry the meaning.
**Anti-patterns:**
- More than 7 stages — cells shrink, counts wrap. Collapse.
- Counts that increase left-to-right — this is not a funnel. If the metric genuinely grows through stages, use `vertical-bar-chart` instead.
- Missing stage labels — a row of numbers is not a funnel, it's a number parade.
- Using `.hot` styling on a non-final stage — breaks the temperature metaphor. Audiences expect the darkest cell at the far right.
---
## Using charts inside archetypes
- **`slide-content`** and **`slide-content-paper`** are the default home for charts. Light backgrounds, clear axes, full color contrast.
- **`slide-content-dark`** — charts work and have explicit light-on-dark variants documented above (`vertical-bar-chart` ships dark-first; other primitives include `.slide.dark` overrides in archetype CSS). When authoring dark-variant charts:
- Text: `--c-ink-inv` (white).
- Bars/segments: `rgba(255,255,255,0.85)` default, `--c-accent` (Amarelo) for highlight.
- Rules/axes: `rgba(255,255,255,0.18)`.
- **`slide-content-accent`** (Amarelo background) — charts MUST NOT be placed here. Amarelo backgrounds are for headline statements only; the accent color is the chart's highlight signal, and placing a chart on Amarelo collapses the highlight contrast.
- **Max one chart per slide.** Multiple charts = split into multiple slides. The editorial rhythm is "one idea per slide" — two charts means two ideas means two slides.
- **Charts consume `.body`'s flex.** Every chart primitive's outer container is `display: flex; flex-direction: column; flex: 1; min-height: 0;` when placed as the main body element. Follow `layout-rules.md` — the chart expands into the canvas, chrome (meta bar, footer) pins.
## Future work (v2)
- **Chart.js opt-in** — for scatter, multi-series line, stacked vertical with annotations, radar, treemap. Loaded via `package.json.tmpl` dependency and a `chart-js.html` partial. All opt-in; v1 primitives remain the default.
- **Responsive chart sizing** — current primitives are tuned to the 1920×1080 fixed canvas. If the deck runtime adds a responsive mode (16:9 aspect-fit to viewport), font sizes and gaps MUST scale with `clamp()` or an archetype-level CSS var.
- **Animation on slide-enter** — staggered bar grow-in, funnel stage cascade, matrix dot fade-in. Triggered by a `deck-stage` slide-change event; out of v1 scope because PDF export ignores transitions anyway.
- **Leader-line labels** — for stacked-horizontal-bar segments that need in-bar labels at <3% widths. Adds SVG complexity; deferred.
- **Grouped vertical bars** — side-by-side comparison (e.g., BYOC vs. PaaS per month). Currently handled by splitting into two slides or by the secondary KPI row below the primary chart (see reference slide 8 lines 10221031 for the split-below-chart pattern).

View file

@ -0,0 +1,269 @@
# Design Tokens — Lerian Editorial Deck
The canonical token set for every `ring:deck` output. These tokens are **editorial** — optimized for 1920×1080 projected/screened decks viewed from 15+ feet — and are **deliberately distinct** from `ring:visualize`'s product-console palette (sunglow `#FDCB28`, Inter). Ring canonicalizes two Lerian design systems side by side; MUST NOT cross-pollinate.
## Color Palette
Every color used by the reference. MUST declare all of these on `:root`. Naming follows the Portuguese source labels ("Amarelo", "Preto", etc.) where the reference uses them.
### Ink (text + neutrals)
| Token | Hex | Role |
| --- | --- | --- |
| `--c-ink` | `#191A1B` | Preto — primary text, near-black |
| `--c-ink-2` | `#3E3C37` | Cinza Escuro — secondary text / body paragraphs |
| `--c-ink-3` | `#8B877C` | Cinza Médio — tertiary / meta / eyebrow |
| `--c-ink-inv` | `#FFFFFF` | Inverse ink — text on dark panels |
### Surfaces
| Token | Hex | Role |
| --- | --- | --- |
| `--c-bg` | `#FFFFFF` | Page / default slide background |
| `--c-bg-2` | `#F2F2F2` | Cinza Claro — secondary surface (paper slide variant) |
| `--c-card` | `#FFFFFF` | Card background |
| `--c-panel` | `#191A1B` | Dark panel background (act dividers, inverted slides) |
### Rules (dividers)
| Token | Hex | Role |
| --- | --- | --- |
| `--c-rule` | `#CECECE` | Cinza — primary hairline |
| `--c-rule-2` | `#F2F2F2` | Cinza Claro — soft divider |
### Accent — Lerian Amarelo (brand primary)
| Token | Hex | Role |
| --- | --- | --- |
| `--c-accent` | `#FEED02` | **Amarelo — Lerian brand primary.** REQUIRED as the single chromatic anchor. |
| `--c-accent-ink` | `#191A1B` | Text on accent — Preto. |
| `--c-accent-2` | `#50F769` | Verde — supporting signal, row highlights, positive deltas. |
### Signal palette
| Token | Hex | Role |
| --- | --- | --- |
| `--c-green` | `#50F769` | Positive / go / shipped |
| `--c-red` | `#FF6760` | Negative / block / risk |
| `--c-blue` | `#2ED8FE` | Informational / neutral signal |
## Typography
Three families. MUST import via the same Google Fonts URL as the reference. MUST NOT add display fonts beyond this set without updating this document.
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=IBM+Plex+Serif:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
```
| Family | Weights | Role |
| --- | --- | --- |
| Poppins | 400, 500, 600, 700 | Display — headlines (h1, h2, h3), KPI values, wordmark. Letter-spacing `-0.02em` to `-0.04em`. |
| IBM Plex Serif | 400, 500, 600 | Body — paragraphs, ledes, table cells. The editorial voice of the deck. |
| JetBrains Mono | 400, 500 | Meta — eyebrows, meta bar, footer, table headers, numeric cells, pills. Letter-spacing `0.04em` to `0.14em`, UPPERCASE. |
```css
body {
font-family: 'IBM Plex Serif', 'Inter', -apple-system, sans-serif;
font-feature-settings: "tnum";
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { font-family: 'Poppins', -apple-system, sans-serif; font-weight: 600; letter-spacing: -0.02em; }
.eyebrow, .meta, .footer, code, .mono { font-family: 'JetBrains Mono', monospace; }
```
## Type Scale
Ranges cover hero/display through meta. The reference's `:root` defines the default set; archetypes MUST choose values within these ranges. Minimum text floor: **24px** on any body-weight content (deck is read from ≥15ft).
| Role | Range | Reference default | Usage |
| --- | --- | --- | --- |
| Hero / mega | 180220px | 240px (`--t-mega`), 180px (act dividers) | Cover + act-divider numbers |
| Display | 120150px | 150px (`--t-display`) | One-number-per-slide KPI walls |
| h1 | 6488px | 88px (`--t-h1`) | Primary slide headline, line-height 0.98 |
| h2 | 4864px | 56px (`--t-h2`) | Sub-headlines, line-height 1.02 |
| h3 | 4048px | 44px (`--t-h3`) | Card titles, section openers |
| Lede | 2834px | 34px (`--t-body-lg`) | Subtitle paragraph under h1 |
| Body | 2428px | 28px (`--t-body`) | Paragraphs, table cells, list items — **24px floor** |
| Small | 22px | 22px (`--t-small`) | Secondary table cells, captions |
| Eyebrow | 1322px | 22px (`--t-eyebrow`) | UPPERCASE labels above h1; mono; letter-spacing `0.14em` |
| KPI value | 72120px | 96px (`.kpi .value`) | Single-figure tiles, line-height 0.95 |
**HARD GATE:** body and list text MUST NOT fall below 24px. If content is too long to fit, split the slide — do not shrink the type.
## Spacing Scale
The reference uses two canvas-margin variables plus ad-hoc flex gaps. Archetypes MUST reuse them.
| Token | Value | Role |
| --- | --- | --- |
| `--pad-x` | `100px` | Horizontal canvas margin (80px in `[data-density="compact"]`) |
| `--pad-y` | `90px` | Vertical canvas margin (72px in compact) |
| Slide padding | `64px var(--pad-x) 48px` | Top 64px, sides `--pad-x`, bottom 48px |
| Meta → body | `padding-top: 32px` | `.body` top inset |
| Body → footer | `padding-bottom: 20px` | `.body` bottom inset |
| Gap — large | `60px` | Between major columns |
| Gap — medium | `36px` | Between related blocks |
| Gap — small | `20px` | Within a block (card internals) |
| Row gap | `1420px` | Table row padding |
## Radii
The reference uses three radii. Sharp-to-soft, editorial to chrome.
| Radius | Use |
| --- | --- |
| `0` (none) | Default — slide chrome, tables, rules |
| `2px` | Tick marks, small inline badges |
| `4px` | Cards, deployment blocks, dashed service rails |
| `999px` | Pills, dots, wordmark accent dot, act-divider chip tags |
REQUIRED: no intermediate (`8px`, `12px`, `16px`) radii. The aesthetic is flat editorial, not rounded product UI.
## Meta Bar (top strip)
Every content slide MUST render this strip at the top. Three variants — default (light), `.slide.dark`, `.slide.accent`.
```html
<div class="meta">
<div class="left">
<span class="wordmark">lerian<span class="dot"></span></span>
<span>· Act 01 — Traction & Product</span>
</div>
<div class="right">
<span>Board Meeting № 01</span>
<span class="num">03 / 17</span>
</div>
</div>
```
```css
.meta {
display: flex; align-items: center; justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: var(--t-eyebrow);
letter-spacing: 0.04em;
color: var(--c-ink-3);
text-transform: uppercase;
}
.meta .num { font-weight: 500; color: var(--c-ink); }
.slide.dark .meta { color: rgba(255,255,255,0.55); }
.slide.accent .meta { color: rgba(25,26,27,0.62); }
```
- `.meta .num` — current/total slide index. MUST be populated by runtime (no hardcoded `NN / 17`). See `layout-rules.md`.
- `.meta .dot` — 8×8 `border-radius: 999px` dot. Amarelo (`--c-accent`) on light variants.
## Footer Strip
Every content slide MUST render this strip at the bottom.
```html
<div class="footer">
<div>Confidential — Lerian Board</div>
<div>April 22, 2026</div>
</div>
```
```css
.footer {
display: flex; justify-content: space-between; align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
letter-spacing: 0.04em;
color: var(--c-ink-3);
text-transform: uppercase;
border-top: 1px solid var(--c-rule);
padding-top: 16px;
flex-shrink: 0;
}
.slide.dark .footer { color: rgba(255,255,255,0.42); border-color: rgba(255,255,255,0.12); }
.slide.accent .footer { color: rgba(25,26,27,0.55); border-color: rgba(25,26,27,0.18); }
```
- Left: document context ("Confidential — Lerian Board").
- Right: date (static or runtime-injected). Format: Month DD, YYYY.
- `flex-shrink: 0` — footer MUST NOT compress when content fills the slide.
## Canonical `:root` Block
Every deck template MUST paste this block verbatim at the top of its `<style>`.
```css
:root {
/* Ink */
--c-ink: #191A1B;
--c-ink-2: #3E3C37;
--c-ink-3: #8B877C;
--c-ink-inv: #FFFFFF;
/* Surfaces */
--c-bg: #FFFFFF;
--c-bg-2: #F2F2F2;
--c-card: #FFFFFF;
--c-panel: #191A1B;
/* Rules */
--c-rule: #CECECE;
--c-rule-2: #F2F2F2;
/* Accent — Lerian Amarelo */
--c-accent: #FEED02;
--c-accent-ink:#191A1B;
--c-accent-2: #50F769;
/* Signal */
--c-green: #50F769;
--c-red: #FF6760;
--c-blue: #2ED8FE;
/* Type scale (1920x1080) */
--t-eyebrow: 22px;
--t-small: 22px;
--t-body: 28px;
--t-body-lg: 34px;
--t-h3: 44px;
--t-h2: 56px;
--t-h1: 88px;
--t-display: 150px;
--t-mega: 240px;
/* Spacing */
--pad-x: 100px;
--pad-y: 90px;
}
[data-density="compact"] {
--pad-x: 80px;
--pad-y: 72px;
--t-body: 26px;
--t-body-lg:30px;
--t-h2: 48px;
--t-h1: 72px;
}
```
## Design Rationale (footnote)
Claude Design proposed a warm-paper + lime `#D6F24B` palette. **Rejected:** `#FEED02` Amarelo is Lerian brand primary in the reference. Layout discipline + type-scale ranges from the same directive are incorporated — palette is not.
## Wordmark Sync
The Lerian wordmark has two canonical copies and two usage patterns inside the deck itself.
**Canonical copies (must stay byte-identical):**
1. `default/skills/deck/assets/lerian-wordmark.svg` — standalone file served over `/assets/lerian-wordmark.svg`.
2. `default/skills/visualize/templates/standard.html` (lines 629633) — visualize's inline copy inside `<header class="lerian-header">`.
**Two patterns inside the deck skill — both equally valid:**
- **`<img>` pattern (archetype reuse).** `templates/slide-cover.html` uses `<img src="assets/lerian-wordmark.svg" alt="Lerian" …>`. Prefer this when the template is being reused as a detached archetype and the asset is served alongside it.
- **Inline SVG pattern (self-contained baseline).** `templates/deck.html` inlines the `<svg viewBox="0 0 1090.88 280" …>` directly in its own cover slide. Prefer this when shipping a single-file baseline deck that must render without an external asset pipeline.
If Lerian rebrands, MUST update all three locations (the standalone SVG file, `deck.html`'s inline cover SVG, and visualize's inline copy). The viewBox (`0 0 1090.88 280`) and path data MUST match byte-for-byte across all three. Any drift is a compliance failure.
## Provisional Home
Canonical source should move to Figma once Lerian has a design-system repo. Until then, this file is the source of truth. Drift from here is drift from Lerian brand.

View file

@ -0,0 +1,235 @@
# PDF Export
PDF export converts the live deck to a paginated PDF at pixel-perfect 1920×1080. The export script drives a headless Chromium via Puppeteer, navigates through every slide, and appends each page to a combined document via `pdf-lib`.
Scope: **PDF only in v1.** See the PPTX footnote at the end.
## Entry Point
```bash
npm run export # runs scripts/export.mjs
```
Prerequisites:
- Dev server MUST be running on `http://localhost:${PORT:-7007}`.
- All Google Fonts MUST load (confirmed by the font-ready await — see HARD GATE below).
Output: `./deck.pdf` in the project root. On success, the script prints the absolute path:
```
✓ Exported 25 slides → /Users/…/project/deck.pdf
```
## Launch Configuration
```javascript
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
await page.setViewport({
width: 1920,
height: 1080,
deviceScaleFactor: 1,
});
```
**Why these args:**
| Arg | Reason |
| --- | --- |
| `--no-sandbox` | Required for CI runners (Docker, GitHub Actions) where the user namespace is already restricted. |
| `--disable-dev-shm-usage` | `/dev/shm` is 64MB in many containers; Chromium falls over on 1920×1080 screenshots without this. |
| `deviceScaleFactor: 1` | MUST stay 1. Higher scales bloat the PDF and drift type rendering from the live deck. |
## Query-Param Trick
The export script navigates to `http://localhost:7007/?export=true`. The deck runtime respects this flag by:
- **Disabling the WebSocket client** — no live-reload race condition between chokidar and `page.pdf()`.
- **Disabling CSS animations/transitions** — avoids capturing mid-animation frames.
- **Exposing a stable `window.__deck` interface** — six methods, documented below.
### `window.__deck` — runtime contract
All six methods are public contract. Puppeteer uses `goto(n)` and `total()`; the remaining four support runtime/keyboard/remote integration and are safe to call from any client (presenter iframe, manual console debugging, post-message bridge).
| Method | Signature | Role |
| --- | --- | --- |
| `goto(n)` | `(n: integer) => Promise<void>` | Jumps to zero-based slide `n`. Resolves after the slide is painted (double-rAF in export mode). Primary Puppeteer contract. |
| `total()` | `() => integer` | Returns the slide count (`<deck-stage> > <section>` length). Primary Puppeteer contract. |
| `next()` | `() => Promise<void>` | Advances one slide. Used by keyboard nav, remote "Next". |
| `prev()` | `() => Promise<void>` | Steps back one slide. Used by keyboard nav, remote "Previous". |
| `current()` | `() => integer` | Returns the current zero-based slide index. Used by presenter view + remote to hydrate UI. |
| `blank(on)` | `(on: boolean) => void` | Toggles the blank overlay. Used by remote "Blank" button and `B` keypress. |
```javascript
// In assets/deck-stage.js:
const params = new URLSearchParams(location.search);
const exportMode = params.get('export') === 'true';
if (!exportMode) {
connectWebSocket();
}
if (exportMode) {
document.documentElement.style.setProperty('--anim-duration', '0s');
}
window.__deck = {
goto: (n) => { /* switch to slide n, sync; returns Promise */ },
next: () => { /* goto(current + 1); returns Promise */ },
prev: () => { /* goto(current - 1); returns Promise */ },
total: () => document.querySelectorAll('deck-stage > section').length,
current: () => /* zero-based index */,
blank: (on) => { /* toggle blank overlay */ },
};
```
The `?export=true` contract is MANDATORY. Deck runtimes MUST implement it; export scripts MUST set it. Runtime contract also includes a same-origin `postMessage` listener — parent frames can `iframe.contentWindow.postMessage({ type: 'nav', slide: N }, '*')` and deck-stage routes that to `gotoIndex(N, { silent: true })`. Cross-origin messages are ignored. This is how `presenter.html` drives its thumbnail iframes without WebSocket echo.
## Per-Slide Loop
```javascript
import { PDFDocument } from 'pdf-lib';
await page.goto('http://localhost:7007/?export=true', {
waitUntil: 'networkidle0',
});
await page.evaluateHandle('document.fonts.ready');
const total = await page.evaluate(() => window.__deck.total());
const combined = await PDFDocument.create();
for (let i = 0; i < total; i++) {
await page.evaluate((n) => window.__deck.goto(n), i);
await page.evaluateHandle('document.fonts.ready'); // HARD GATE — see below
const buffer = await page.pdf({
width: '1920px',
height: '1080px',
printBackground: true,
pageRanges: '1',
});
const slideDoc = await PDFDocument.load(buffer);
const [copiedPage] = await combined.copyPages(slideDoc, [0]);
combined.addPage(copiedPage);
}
const outBytes = await combined.save();
const outPath = path.resolve('./deck.pdf');
await fs.writeFile(outPath, outBytes);
console.log(`✓ Exported ${total} slides → ${outPath}`);
await browser.close();
```
## HARD GATE — Font-Ready Await
```javascript
await page.evaluateHandle('document.fonts.ready');
```
**MUST call this before every `page.pdf()`**, not just once after initial load.
**Why:** Google Fonts load async. On the first slide, `document.fonts.ready` resolves after Poppins + IBM Plex Serif + JetBrains Mono have arrived. On subsequent slides, a font file request can re-open if a new weight/style is needed (e.g., Poppins 700 on one slide only). Skipping the per-slide await on the suspicion "fonts already loaded" produces a PDF where one slide renders with system-font fallbacks — the kind of bug nobody catches until the deck is on screen.
FORBIDDEN pattern:
```javascript
// WRONG — loads fonts once, then assumes.
await page.evaluateHandle('document.fonts.ready');
for (let i = 0; i < total; i++) {
await page.evaluate((n) => window.__deck.goto(n), i);
await page.pdf({ … }); // fonts may substitute mid-loop
}
```
REQUIRED pattern:
```javascript
for (let i = 0; i < total; i++) {
await page.evaluate((n) => window.__deck.goto(n), i);
await page.evaluateHandle('document.fonts.ready'); // every iteration
await page.pdf({ … });
}
```
## PDF Parameters
| Parameter | Value | Why |
| --- | --- | --- |
| `width` | `'1920px'` | MUST match viewport. String-with-unit, not number. |
| `height` | `'1080px'` | MUST match viewport. |
| `printBackground: true` | REQUIRED — otherwise `.slide.dark` and `.slide.accent` render white. |
| `pageRanges: '1'` | Captures only the current slide. Prevents Chromium's auto-paginator from inserting extra blanks on tall content. |
| `preferCSSPageSize` | NOT used. Explicit width/height wins. |
| `margin` | omitted (defaults to zero) |
## Output Location
Default: `./deck.pdf` in the project root. MUST print the absolute path on success so users can pipe it to `open` / `xdg-open` / their file manager.
Override via env or CLI (Wave 2 implementation detail):
```bash
OUT=./handouts/board-v2.pdf npm run export
# or
npm run export -- --out ./handouts/board-v2.pdf
```
## Puppeteer Weight (footnote)
Puppeteer bundles its own Chromium (~200MB post-install). This is the default in v1 because it gives zero-config UX — `npm install && npm run export` works without the user having Chrome installed.
**Alternative:** `puppeteer-core` + `channel: 'chrome'` uses the system Chrome (~50MB install). Revisit if install weight becomes a complaint. Swapping is a two-line change:
```javascript
// Before
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ … });
// After
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.launch({ channel: 'chrome', … });
```
## PPTX Deferral (footnote)
**PPTX is explicitly out of scope for v1.** HTML → PPTX is lossy for:
- **Absolute positioning**`position: absolute` and nested flex layouts don't map to PowerPoint's slide-layout primitives.
- **Custom grid layouts** — CSS grid with `align-items: stretch` becomes a manual table rebuild.
- **CSS-based charts** — any chart drawn in divs or SVG becomes a flattened image, losing the native PowerPoint chart editability users expect.
V2 may investigate `pptxgenjs` with graceful degradation:
- KPI tiles → text boxes.
- 2×2 matrix → scatter chart.
- Tables → native PowerPoint tables.
- Everything else → flattened PNG per slide.
Until then, PDF is the contract. If the user asks for PPTX, the correct answer is "PDF now, PPTX in v2."
## Export Checklist
```
Before shipping the export script:
[ ] 1. Launch args include --no-sandbox and --disable-dev-shm-usage?
[ ] 2. Viewport is exactly 1920×1080 with deviceScaleFactor:1?
[ ] 3. Navigation URL includes ?export=true?
[ ] 4. document.fonts.ready awaited BEFORE every page.pdf() (not just once)?
[ ] 5. page.pdf() uses width:'1920px', height:'1080px', printBackground:true, pageRanges:'1'?
[ ] 6. Combined PDF uses pdf-lib copyPages — no per-slide separate files?
[ ] 7. Output path is printed absolute on success?
[ ] 8. Browser closed in a finally block so crashes don't leak Chromium?
If any checkbox is no → Export is incomplete. Fix before shipping.
```

View file

@ -0,0 +1,262 @@
# Layout Rules — Editorial Deck Discipline
**HARD GATE.** Every archetype, every slide, every card MUST obey these rules. Violations surface visually on the 1920×1080 canvas — cramped tiles, scrollbars, orphaned footers, ragged type. No rationalization clears a violation; the canvas is the referee.
## Vertical-Canvas Rule
Every slide is exactly **1920×1080 pixels**. No scrolling. No overflow. No wasted space.
```css
deck-stage > section {
width: 1920px;
height: 1080px;
display: flex;
flex-direction: column;
position: relative;
}
.slide {
padding: 64px var(--pad-x) 48px;
height: 1080px;
box-sizing: border-box;
overflow: hidden;
}
```
- `overflow: hidden` is REQUIRED — a scrollbar on the projected canvas is a bug.
- `height: 1080px` is REQUIRED — not `min-height`, not `100vh`. Fixed.
- Content fills the canvas between meta bar and footer via flex. See below.
## Flex Pattern — Body Expands, Chrome Pins
The meta bar at top and footer at bottom are fixed-height. The body between them flexes.
```css
.slide {
display: flex;
flex-direction: column;
}
.body {
flex: 1; /* expand into available vertical space */
display: flex;
flex-direction: column;
padding-top: 32px;
padding-bottom: 20px;
min-height: 0; /* REQUIRED — allows children to shrink */
overflow: hidden;
}
.footer { flex-shrink: 0; } /* pin to bottom, never compress */
```
**Good — body stretches:**
```html
<section class="slide">
<div class="meta"></div>
<div class="body">
<div class="eyebrow">Portfolio</div>
<h1>Complete portfolio for core banking.</h1>
<div style="flex: 1; display: flex; align-items: stretch;">
<!-- content grid that consumes all remaining vertical space -->
</div>
</div>
<div class="footer"></div>
</section>
```
**Bad — body leaves dead space:**
```html
<!-- MISSING flex:1 on .body → content clings to top, bottom is white void -->
<section class="slide">
<div class="meta"></div>
<div class="body">
<h1>Something</h1>
<p>Two bullets.</p>
</div>
<div class="footer"></div>
</section>
```
## Main Content Grids
Use `flex: 1; min-height: 0` on the primary grid so it inherits the `.body` stretch.
```css
.body > .grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 36px;
align-items: stretch; /* columns match tallest sibling */
}
```
Row stacks distribute remaining space with `justify-content: space-between`:
```css
.body > .stack {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between; /* spread rows across vertical axis */
}
```
## Fixed-Height Cards — FORBIDDEN
Content decides card height. The canvas adapts to content, not the other way around.
**FORBIDDEN:**
```css
.card { height: 360px; } /* hard-coded */
.kpi { min-height: 280px; } /* faux-dynamic */
.row { height: calc(100% / 3); } /* evenly-split by fiat */
```
**REQUIRED:**
```css
.card { display: flex; flex-direction: column; gap: 14px; } /* content-sized */
.grid { align-items: stretch; } /* siblings match */
```
If a card looks cramped, **the content is wrong, not the card.** Rewrite the content. Split the slide. Do not force height.
## Table Padding
Tables are first-class editorial surfaces. Generous breathing room REQUIRED.
| Density | Row padding | Font size |
| --- | --- | --- |
| Default | `18px 20px` | `var(--t-small)` (22px) |
| Compact | `10px 16px` | 22px |
| Compact-tight | `7px 14px` | 18px |
```css
table.grid th, table.grid td {
text-align: left;
padding: 18px 20px;
border-top: 1px solid var(--c-rule);
vertical-align: top;
color: var(--c-ink-2);
}
```
Row padding **MUST** fall in `1420px` for default density. Anything tighter reads as web-UI, not editorial. Anything looser blows the slide height budget.
## Hero Numbers
When a slide's job is "here is one number," give the number the real estate.
| Context | Range | Example |
| --- | --- | --- |
| KPI tile value | 72120px | `5.4M ARR` in a 4-tile row |
| Full-slide single number | 180240px | Act divider ("01"), cover statistic |
| Inline display number | 120150px | `--t-display`, 3-column breakdown |
```css
.kpi .value {
font-family: 'Poppins', sans-serif;
font-weight: 500;
font-size: 96px;
line-height: 0.95;
letter-spacing: -0.03em;
}
```
MUST NOT shrink hero numbers to fit a caption. Shrink the caption.
## Dynamic Pagination
**HARD GATE: No hardcoded slide counts.** The total is computed at runtime from `<section>` count.
```javascript
// In the deck runtime (assets/deck-stage.js):
const sections = document.querySelectorAll('deck-stage > section');
const total = sections.length;
sections.forEach((section, i) => {
const numEl = section.querySelector('.meta .num');
if (numEl) {
const current = String(i + 1).padStart(2, '0');
const totalStr = String(total).padStart(2, '0');
numEl.textContent = `${current} / ${totalStr}`;
}
});
```
**FORBIDDEN** in hand-authored slide HTML:
```html
<!-- HARD GATE VIOLATION — hardcoded 17 -->
<span class="num">03 / 17</span>
```
**REQUIRED** in hand-authored slide HTML:
```html
<!-- Runtime fills this in -->
<span class="num"></span>
```
Authors MAY leave the `.num` span empty OR include a placeholder; the runtime overwrites it on load.
## Content Density Heuristic
The craft rule for every slide:
> **If content is sparse, make it larger. If dense, split into columns or stack with breathing room.**
| Symptom | Response |
| --- | --- |
| "This slide feels empty" | Headline → 88px. Lede → 34px. Add a mega number. |
| "This slide won't fit" | Split into two columns. Or cut content. Never shrink type. |
| "The card is cramped" | Remove half the content from the card. Or split into sibling cards. |
| "The footer is touching the content" | `.body` lost `flex: 1` or `min-height: 0`. Fix the flex, not the padding. |
| "The number feels small" | It is. Hero numbers are 72240px — check the range. |
## Minimum Text Size
**HARD GATE: 24px floor on body-weight text.** The deck is viewed from 15+ feet. Below 24px, audiences squint.
Exceptions (MUST be used sparingly):
| Allowed <24px | Where | Why |
| --- | --- | --- |
| 1822px | JetBrains Mono meta/caption text in `.pill`, `.footer`, small labels | Mono reads legibly at smaller sizes; these are chrome not content |
| 1618px | Footer strip, in-table monospace row captions | Ambient chrome, audiences don't read these |
| 1114px | Tag chips inside act-divider "card" pills | Incidental context only |
If a body paragraph or list item is below 24px, **the content is too long.** Trim.
## Slide Chrome Variants
The reference ships three slide backgrounds. Archetypes MUST pick one.
| Class | Background | Text color | Use |
| --- | --- | --- | --- |
| `.slide` | `--c-bg` (#FFF) | `--c-ink` | Default — most content slides |
| `.slide.paper` | `--c-bg-2` (#F2F2F2) | `--c-ink` | Paper variant — secondary surface for visual pacing |
| `.slide.dark` | `--c-panel` (#191A1B) | `--c-ink-inv` | Dark panel — act dividers, KPI walls, statement slides |
| `.slide.accent` | `--c-accent` (#FEED02) | `--c-accent-ink` | Amarelo accent — reserved for act openers and section breaks, sparingly |
`.slide.accent` is the rarest — use it for punctuation, not paragraphs.
## Canvas Self-Test
Before shipping any slide, walk this checklist:
```
[ ] 1. Is the slide exactly 1920×1080 with overflow hidden?
[ ] 2. Does .body have flex:1 and min-height:0?
[ ] 3. Does the footer sit at the bottom with flex-shrink:0?
[ ] 4. Is every body text ≥24px?
[ ] 5. Is the slide number placeholder empty (runtime fills it)?
[ ] 6. Are all card heights content-driven (no px height, no %)?
[ ] 7. Does the content fill the vertical canvas (no dead white space)?
[ ] 8. Is the meta bar present and populated with left+right variants?
[ ] 9. If the slide looks cramped, did you split instead of shrink?
If any checkbox is no → The slide is incomplete. Fix before shipping.
```

View file

@ -0,0 +1,282 @@
# Dev Server, Presenter View, and Remote Control
The deck runtime ships three surfaces served by one small Express + WebSocket server:
- **Deck**`/` — the main projected screen.
- **Presenter**`/presenter` — second screen with slide thumbnails, current/next preview, speaker notes, timer.
- **Remote**`/remote` — phone-friendly controller to advance slides, go back, blank the screen.
All three are coordinated by a single WebSocket channel.
## Express Routes
```javascript
import express from 'express';
import path from 'path';
const app = express();
const root = path.resolve('.');
app.get('/', (req, res) => res.sendFile(path.join(root, 'deck.html')));
app.get('/deck.html', (req, res) => res.sendFile(path.join(root, 'deck.html')));
app.get('/presenter', (req, res) => res.sendFile(path.join(root, 'presenter.html')));
app.get('/remote', (req, res) => res.sendFile(path.join(root, 'remote.html')));
app.use('/assets', express.static(path.join(root, 'assets')));
```
| Route | Serves | Notes |
| --- | --- | --- |
| `GET /` | `deck.html` | Main canvas |
| `GET /deck.html` | `deck.html` | Same file — presenter view fetches this to extract speaker notes (see `speaker-notes.md`) |
| `GET /presenter` | `presenter.html` | Second-screen view |
| `GET /remote` | `remote.html` | Phone-friendly controller |
| `GET /assets/*` | static files from `./assets/` | Wordmark SVG, deck.js, fonts (if self-hosted) |
**Why `/deck.html` is aliased:** the presenter view fetches the deck HTML as plain text to regex-extract the speaker-notes JSON block. Serving it as a static file at a predictable URL is simpler than exposing a separate `/api/notes` endpoint. See [`speaker-notes.md`](speaker-notes.md) for the extraction contract (including the `</script>` substring ban in note strings).
## WebSocket Endpoint
Single endpoint: `/ws`. Handled by the `ws` package on the same HTTP server.
```javascript
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ server, path: '/ws' });
```
## File Watching (Chokidar)
```javascript
import chokidar from 'chokidar';
chokidar.watch(
['deck.html', 'presenter.html', 'remote.html', 'assets/**', 'scripts/**'],
{ ignoreInitial: true }
).on('all', (event, filePath) => {
console.log(`[watch] ${event} ${filePath} — broadcasting reload`);
broadcast({ type: 'reload' });
});
```
- Watches: `deck.html`, `presenter.html`, `remote.html`, `assets/**`, `scripts/**`.
- Ignores: `node_modules/**`, `deck.pdf`, `.git/**` (chokidar's defaults cover most).
- On any change, broadcast `{ type: "reload" }` to all WebSocket clients. Clients hard-reload the page on receipt.
## WebSocket Message Schema
**Five message types**, all JSON-encoded strings. No versioning in v1 — if the schema evolves, bump to v2 explicitly. Two directions: client → server (`hello`, `nav`, `blank`) and server → client (`state`, `nav`, `blank`, `reload`). `nav` and `blank` travel both directions — the server rebroadcasts what clients send.
### `hello` — announce slide count (client → server)
Direction: `deck-stage.js` → server, once per WebSocket `open`.
```json
{ "type": "hello", "total": 17 }
```
- `total`: positive integer, the count of `<deck-stage> > <section>` elements in `deck.html`.
- Only the deck client knows the authoritative slide count — presenter and remote learn it from the server. `hello` is how the server learns it too.
- On receipt, server stores `state.total` AND rebroadcasts `{ type: 'state', … }` so presenter/remote update their `N / M` pagination.
- `deck-stage.js` also re-sends `hello` when its slide count changes across a hot-reload (see `fillPagination`).
### `nav` — navigate to a slide
Direction: any client → server → broadcast to all clients.
```json
// client → server
{ "type": "nav", "slide": 4, "total": 17 }
// server → all clients (rebroadcast, clamped)
{ "type": "nav", "slide": 4, "total": 17 }
```
- `slide`: zero-indexed section position.
- `total` (optional, client → server): deck-stage piggybacks current slide count as belt-and-suspenders re-assertion. Server updates `state.total` if the payload is a positive integer.
- Server clamps `slide` to `[0, total - 1]` when `total` is known; when `total === null` (no `hello` yet), only clamps below 0.
- Emitted by deck on keyboard nav, presenter on click, remote on next/prev.
- Rebroadcast to every connected client with the (possibly clamped) slide and the current `state.total` (may be `null`).
### `blank` — toggle blank screen
Direction: any client → server → broadcast to all clients.
```json
{ "type": "blank", "on": true }
```
- `on`: `true` = blank the main canvas to solid black; `false` = restore.
- Emitted by remote "Blank" button or `B` keypress on deck.
- Useful when the speaker wants the audience to look at them, not the slide.
### `state` — hydrate / rebroadcast current state
Direction: server → clients.
```json
{ "type": "state", "slide": 4, "blank": false, "total": 17 }
```
- Sent **once** when a WebSocket client connects, carrying the authoritative `{ slide, blank, total }`.
- Sent **again** to everyone after the server processes a `hello` — so presenter/remote learn `total` the moment deck-stage announces it.
- `total` MAY be `null` until a deck client has sent `hello`. Clients MUST treat `total === null` as "unknown" (render `?` or `` for pagination; dim "next" controls in an ambiguous end-of-deck state).
### `reload` — file changed
Direction: server → all clients.
```json
{ "type": "reload" }
```
- Triggered by chokidar on any watched file change (debounced ~50ms to collapse editor-save bursts).
- Clients MUST hard-reload the page on receipt.
## Server State
The server holds minimal in-memory state:
```javascript
const state = {
slide: 0, // current slide index
blank: false, // blank-on-main-canvas flag
total: null, // null until a deck-stage client announces via hello
};
```
On `hello`, update `state.total` and rebroadcast `state` to all clients. On `nav` or `blank`, update state THEN broadcast. On new-client connect, send `state` with the current values (including `total`, possibly `null`). No persistence — restart resets to slide 0 and total `null`.
## WebSocket Handshake Policy
`verifyClient` enforces a minimal CSWSH (cross-site WebSocket hijacking) defense. The allowed origin set is computed once at boot and checked on every handshake:
| Origin header | Behavior |
| --- | --- |
| `http://localhost:{PORT}` | Accept |
| `http://127.0.0.1:{PORT}` | Accept |
| `http://{LAN-IP}:{PORT}` (detected via `detectLanIp`) | Accept |
| Any other origin | Reject (handshake returns 401) |
| No `Origin` header (Puppeteer, `curl`, Node scripts) | Accept |
Puppeteer's embedded Chromium issues same-origin connections but non-browser Node WebSocket clients typically omit `Origin` — we accept those rather than force export scripts to synthesize headers. This keeps the export path working while blocking drive-by JavaScript in a random tab on the same machine from driving your deck.
## Per-Connection Rate Limit (v1 backstop)
Each WebSocket connection has a token bucket: capacity **10 tokens**, refill **10 tokens/sec**. Every inbound message consumes one token; when the bucket is empty, messages are **dropped silently** (the connection is NOT closed). This is an intentional v1 backstop against a runaway remote/presenter/deck client spamming `nav`, not an auth mechanism.
- Silent drop, not disconnect — avoids tearing down a slow client during a transient burst.
- No token refund on rejection.
- Bucket is per-socket and resets on reconnect.
If a legitimate workflow trips the limit (e.g., scripted demos), raise `BUCKET_CAPACITY` or `BUCKET_REFILL_PER_MS` in `scripts/dev-server.mjs`; don't remove the limiter.
## Trust Model — Local Network Only
**REQUIRED: document this in the server startup log.**
The server has **no authentication**. Anyone on the same LAN can connect to `/ws` and send `nav` or `blank` messages. This is acceptable because:
- Decks are ephemeral — run during a single presentation, then stopped.
- The attack surface is the people physically near the presenter.
- Adding auth adds friction (pairing phone-remote requires typing a code) for a benefit the threat model doesn't justify.
**CANNOT deploy this server to the public internet.** It is strictly a localhost + LAN tool. Any CI that spins up the server MUST bind to `127.0.0.1` only, not `0.0.0.0`.
**Document this explicitly** in the startup banner:
```
Ring Deck Server
deck: http://localhost:7007/
presenter: http://localhost:7007/presenter
remote: http://192.168.1.42:7007/remote
⚠ local network only — no authentication
```
## Port & Host Binding
```javascript
const PORT = parseInt(process.env.PORT || '7007', 10);
const HOST = process.env.HOST || '0.0.0.0';
server.listen(PORT, HOST);
```
| Setting | Default | Override | Reason |
| --- | --- | --- | --- |
| Port | `7007` | `PORT=<n>` env | Unused by most dev tooling; memorable. |
| Host | `0.0.0.0` | `HOST=<addr>` env | Binds all interfaces so phone on LAN can reach `/remote`. |
**Security implication of `0.0.0.0`:** anyone on the LAN reaches the server. Accept the implication (see Trust Model) or set `HOST=127.0.0.1` and give up phone-remote.
## Phone Remote URL
The phone connects to `http://<machine-ip>:7007/remote`. The startup banner auto-detects and prints the LAN IP so users don't have to look it up:
```javascript
import os from 'os';
const TUNNEL_IFACE_RE = /^(utun|ppp|tun|wg)\d*/i;
function detectLanIp() {
const candidates = [];
for (const [name, list] of Object.entries(os.networkInterfaces())) {
if (!list) continue;
for (const iface of list) {
if (iface.family !== 'IPv4' || iface.internal) continue;
candidates.push({ name, address: iface.address });
}
}
// Prefer non-tunnel interfaces so the phone on the same Wi-Fi can reach them.
const nonTunnel = candidates.find((c) => !TUNNEL_IFACE_RE.test(c.name));
if (nonTunnel) return nonTunnel.address;
if (candidates.length > 0) return candidates[0].address;
return null;
}
```
If the machine has no LAN IP, fall back to `localhost` — the remote URL won't work from another device, but the deck still runs locally.
### Troubleshooting: Phone can't reach the remote URL
1. **Tunnel interfaces are filtered.** `detectLanIp` ignores `utun*`, `ppp*`, `tun*`, `wg*` interfaces because VPN tunnels (Cisco AnyConnect, corporate VPNs) typically aren't reachable from a phone on the same Wi-Fi. The printed IP prefers `en0`/`eth0`-style physical interfaces.
2. **VPN clients that don't match the filter can still win.** Tailscale (`tailscale0`), Cloudflare WARP, and ZeroTier may expose an IPv4 not covered by the tunnel regex. If the printed IP looks like Tailscale's `100.x.x.x` range or otherwise isn't on your Wi-Fi subnet, disconnect the VPN and restart the server, or apply a manual override:
```bash
# macOS
ipconfig getifaddr en0
# Linux
hostname -I | awk '{print $1}'
# Then force the bind and use that IP on the phone:
HOST=0.0.0.0 PORT=7007 npm run dev
```
3. **Firewall on the host machine.** macOS (System Settings → Network → Firewall) and Windows Defender may block inbound connections on port 7007. Allow the Node process or open the port for local subnets.
4. **Phone on a different SSID / guest network.** Many home routers isolate guest Wi-Fi from the primary LAN. Join the same SSID as the host machine before troubleshooting routing.
## V2 Candidate — Rotating PIN Auth
Not in v1. Recorded here so the decision isn't relitigated every release.
- Server generates a short-lived PIN (46 digits, rotates every 10 minutes).
- Main screen displays the PIN as a small chrome element.
- Phone-remote must enter the PIN before its WebSocket `nav`/`blank` messages are accepted.
- Deck and presenter clients (connecting over localhost) bypass the check.
Adds friction; defeats drive-by LAN shenanigans. Revisit if any user reports an incident.
## Startup Checklist
```
Before shipping the server script:
[ ] 1. Routes for /, /deck.html, /presenter, /remote, /assets/* all wired?
[ ] 2. WebSocket endpoint at /ws with verifyClient origin allow-list?
[ ] 3. Chokidar watches deck.html, presenter.html, remote.html, assets/**, scripts/** (with reload debounce)?
[ ] 4. Five WS message types implemented (hello, nav, blank, state, reload) with the rebroadcast-after-hello flow?
[ ] 5. state message sent on new-client connect AND after each hello (so presenter/remote learn total)?
[ ] 6. Per-connection token bucket (10 msg/s, capacity 10, silent drop) applied to inbound messages?
[ ] 7. Startup banner prints deck/presenter/remote URLs with LAN IP auto-detected (tunnels filtered)?
[ ] 8. Startup banner includes "local network only — no authentication" warning?
[ ] 9. PORT and HOST env overrides respected?
If any checkbox is no → The server is incomplete. Fix before shipping.
```

View file

@ -0,0 +1,371 @@
# Slide Archetypes
Nine archetypes cover the Lerian editorial deck vocabulary. Each is a self-contained `<section>` pattern. The scaffolded `deck.html` includes one example of each as a starter kit.
**MUST read [layout-rules.md](layout-rules.md) and [design-tokens.md](design-tokens.md) before composing slides.** These archetypes compose primitives from [ui-primitives.md](ui-primitives.md) and charts from [chart-primitives.md](chart-primitives.md).
**Canonicity:** when the distilled `../templates/slide-*.html` file differs from the reference deck (`lerian-ppt-example.html`), the template file is canonical. Reference-deck line ranges are cited to show real usage in the wild; templates represent the minimum viable pattern.
---
## Archetype Selection Decision Tree
```
Opening the deck? → cover
Showing the agenda? → agenda
Major section break? → act-divider (Amarelo, sparingly)
Standard content slide on white? → content
Same content on paper background? → content-paper (visual pacing)
Hero statement + supporting metrics? → content-dark
Big declarative statement? → content-accent (Amarelo, sparingly)
Transitioning into appendix? → appendix-intro
Appendix reference material? → appendix-content
```
## Table of Contents
| # | Archetype | Typical use | File |
| --- | --- | --- | --- |
| 1 | [cover](#cover) | Deck opening: wordmark + title + roster | [`../templates/slide-cover.html`](../templates/slide-cover.html) |
| 2 | [agenda](#agenda) | Slide 2: Act x Theme x Time x Format table | [`../templates/slide-agenda.html`](../templates/slide-agenda.html) |
| 3 | [act-divider](#act-divider) | Amarelo section break with giant act number | [`../templates/slide-act-divider.html`](../templates/slide-act-divider.html) |
| 4 | [content](#content) | Default white workhorse: eyebrow + h1 + grid | [`../templates/slide-content.html`](../templates/slide-content.html) |
| 5 | [content-paper](#content-paper) | Paper (#F2F2F2) variant — strategic discussions | [`../templates/slide-content-paper.html`](../templates/slide-content-paper.html) |
| 6 | [content-dark](#content-dark) | Near-black statement + KPI strip | [`../templates/slide-content-dark.html`](../templates/slide-content-dark.html) |
| 7 | [content-accent](#content-accent) | Amarelo full-slide declaration | [`../templates/slide-content-accent.html`](../templates/slide-content-accent.html) |
| 8 | [appendix-intro](#appendix-intro) | Amarelo transition into appendix | [`../templates/slide-appendix-intro.html`](../templates/slide-appendix-intro.html) |
| 9 | [appendix-content](#appendix-content) | Letter-paginated reference cards | [`../templates/slide-appendix-content.html`](../templates/slide-appendix-content.html) |
---
## Editorial Discipline
### Use Amarelo sparingly
`.slide.accent` and `.slide.act-divider` both paint the full canvas Amarelo. In a 20-slide deck, **max 23 Amarelo slides total** — act dividers plus at most one `content-accent` moment plus the `appendix-intro`. Overuse fatigues the eye; the accent only pops when used rarely.
Per `ui-primitives.md`, `table.grid` is FORBIDDEN on `.slide.accent` (Amarelo bg eats the hairlines) and charts MUST NOT sit on Amarelo backgrounds. If the content needs either, pick `content` or `content-dark`.
### Pagination convention
Two numbering systems run in parallel:
| Range | Format | Example | Runtime behavior |
| --- | --- | --- | --- |
| Main deck | `NN / NN` numeric | `04 / 17` | `deck-stage.js` fills `.page-num` + `.page-total` spans |
| Appendix | `AN / N` letter | `A1 / 8` | Static — runtime does NOT overwrite. Hard-coded in HTML. |
**MUST NOT hardcode main-deck numeric pagination.** `.page-num` and `.page-total` spans stay empty in the source; runtime populates them.
**MUST hardcode appendix letter pagination.** Place `<span>A1 / 8</span>` in `meta.right` without the `.page-num` class so the runtime leaves it alone.
### Compact density
Apply `data-density="compact"` on individual `<section>` elements when content is denser than the default token spacing allows. This narrows `--pad-x` from 100px to 80px, drops `--t-h1` from 88px to 72px, and shrinks body text to 26px. Use when an appendix card grid has 4+ columns, or when a dense table needs more horizontal room. MUST NOT apply as a global default — the deck is designed at default density.
### Slide chrome invariants
Every content archetype (not cover, not act-divider, not appendix-intro) MUST include:
1. `.meta` top strip — wordmark + context on left, meeting label + page index on right
2. `.body``flex: 1; min-height: 0; display: flex; flex-direction: column` wrapper for content
3. `.footer` bottom strip — "Confidential — Lerian Board" + date, `flex-shrink: 0`
The hero archetypes (`cover`, `act-divider`, `appendix-intro`, `content-accent`, `content-dark` statement layouts) skip `.body` and put a `flex: 1; justify-content: center` wrapper directly inside `<section>`. Meta + footer still apply.
---
## cover
**Purpose:** the first impression. Wordmark, meeting identifier, deck title, directors/observers roster.
**When to use:**
- Deck opening. Always slide index 0.
- Once per deck — one cover, no "sub-covers" for acts (those are [act-divider](#act-divider)).
- When the audience needs to know who's in the room before who's presenting.
**When NOT to use:**
- For recurring section opens — that's act-divider territory.
- For internal working drafts where a cover slows the reading — start from agenda.
- As a placeholder — if the deck isn't ready for a cover, leave slide 1 blank and the runtime will surface "speaker-notes length mismatch."
**Composition pattern:**
- `meta` left: wordmark `<span>` + confidentiality label
- `meta` right: date
- Centered column: eyebrow → inline Lerian SVG (220px tall) → Poppins 72px deck title
- Bottom border-top strip: two columns (Directors / Observers) with role annotations
**Reference example:** `lerian-ppt-example.html` lines 344380. Template canonical: `../templates/slide-cover.html` (43 lines).
**Common mistakes:**
- Using `<img src="...">` for the wordmark instead of inline SVG — adds an extra request and invites FOUC. `deck.html` inlines the SVG for this reason.
- Omitting the directors/observers strip — the cover is also a roster; without it, the board doesn't see who's accountable for what gets discussed.
- Shrinking the wordmark below 180px — at projection distance, anything smaller reads as "indecisive header" instead of "identity."
- Hardcoding `data-label="Cover"` and then manually numbering later slides in `.meta` — cover is slide index 0 but not paginated; runtime starts counting from slide 2 (first `.body` archetype).
- Pairing the cover with `.slide.dark` — works visually but breaks the reference convention; the cover is the one slide where the white canvas + inline wordmark is the signature.
---
## agenda
**Purpose:** the table of contents. A `table.grid` of acts × themes × time × format, with the debate row highlighted.
**When to use:**
- Second slide (index 1) after the cover.
- Once per deck.
- When the audience benefits from seeing how the time is budgeted — typical for board meetings, not for sales decks.
**When NOT to use:**
- Sales pitches where structure is less important than momentum — drop the agenda, go straight to the story.
- When there are fewer than three acts — a two-row agenda reads as underbaked; merge into a lead slide instead.
- Webinars where the agenda is already in the invite — repeating it kills pacing.
**Composition pattern:**
- Eyebrow: "Agenda"
- h1: time commitment in Poppins with a softened second clause ("Ninety minutes. Half of it on decisions, not reports.")
- `table.grid` with columns: Act | Theme | Time | Format
- `tr.hl` on the row where the real debate happens (e.g., strategic discussions) — this is the visual promise to the audience
**Reference example:** `lerian-ppt-example.html` lines 382418. Template canonical: `../templates/slide-agenda.html` (68 lines).
**Common mistakes:**
- No `tr.hl` row — the Amarelo highlight is the signal that says "here's where you lean in." Without it, the agenda reads as filler.
- Every row using `.num` for ordinals but the "Time" column not — creates visual inconsistency. Both numeric columns use `td.num`.
- Using `<ol>` or `<ul>` instead of `table.grid` — loses the column rhythm that makes the time budget scannable.
- Wide first column — keep "Act" at `width: 100px` so the theme column gets the space.
---
## act-divider
**Purpose:** section break between major acts. Giant Amarelo canvas, huge act number, pill row of upcoming slides.
**When to use:**
- Between main acts of a structured deck. A 5-act deck has 5 act-dividers.
- Once per act.
- When the audience needs a reset — a moment to breathe between dense reports.
**When NOT to use:**
- For every sub-section — act-dividers are for acts, not sections. If every third slide is an Amarelo divider, the Amarelo loses all signal.
- For appendix transitions — that's [appendix-intro](#appendix-intro), which uses "Appendix" instead of `NN / NN` in meta.right.
- For sales decks under 10 slides — act-dividers buy pacing, but a 6-slide deck doesn't need pacing.
**Composition pattern:**
- `.slide.accent` variant (Amarelo background)
- `meta` right shows act-of-acts (`01 / 05`), NOT deck-of-deck pagination
- Hairline rule between "Act NN" mono label and "~N min · N slides" right-aligned
- Act number/title in Poppins 180px
- Lede paragraph in 78%-ink (`rgba(25,26,27,0.78)`)
- Pill row of the next 35 slide titles using scoped `.act-pill` class (solid-ink pill + inverted number chip)
- Max 6 pills per row — the reference uses 5. Above 6, the rhythm breaks into chip-noise.
**Reference example:** `lerian-ppt-example.html` lines 420463 (Act 01 — Traction). Template canonical: `../templates/slide-act-divider.html` (83 lines).
**Common mistakes:**
- Using `.page-num`/`.page-total` in meta.right — act dividers show act-of-acts, not deck-of-deck. Hardcode `01 / 05`.
- Spelling out the act title in the pill row ("Portfolio — Where We Stand") — pills are short tags, not headlines. Use one or two words.
- Placing act-dividers before the cover — the cover is always slide 1; act-dividers punctuate the body.
- More than 3 act-dividers in a 20-slide deck — the Amarelo becomes noise. Merge small acts.
- Using `table.grid` on an act-divider — FORBIDDEN per `ui-primitives.md` (Amarelo bg eats hairlines).
---
## content
**Purpose:** the workhorse. White background, eyebrow + h1 + lede, then a 2- or 3-column grid of cards, lists, pills, KPIs, or a chart.
**When to use:**
- Any report slide that isn't a hero statement — portfolio breakdowns, pricing tables, client rosters, pipeline funnels, competitive matrices.
- 6080% of a typical deck's body.
- When the content needs room to breathe but isn't a single declarative statement.
**When NOT to use:**
- For single-number hero slides — use [content-dark](#content-dark) or [content-accent](#content-accent).
- For strategic discussions where pacing benefits from a paper tint — use [content-paper](#content-paper).
- For appendix reference material — use [appendix-content](#appendix-content) for letter-paginated chrome.
**Composition pattern:**
- `.meta` with act context ("Act 01 — Traction & Product") in left, page index in right
- `.body` wrapper with `flex: 1; min-height: 0`
- Eyebrow → h1 (up to `max-width: 1600px`) → optional lede
- Main grid: `flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(3, 1fr)` OR `repeat(2, 1fr)` with ratios like `1fr 1.15fr`
- HARD GATE: the grid MUST have `flex: 1` so it consumes remaining canvas space; `align-items: stretch` so sibling columns match heights.
**Reference example:** `lerian-ppt-example.html` lines 465549 (Portfolio). Template canonical: `../templates/slide-content.html` (104 lines).
**Common mistakes:**
- Missing `flex: 1; min-height: 0` on `.body` — content clings to the top, leaving dead space above the footer. This is the single most common layout failure; `layout-rules.md` calls it out as the canonical bug.
- Hardcoding card heights — breaks the stretch. Let content decide height; let `align-items: stretch` match siblings.
- Using more than 3 columns — 4+ columns at 1920px divide to ~420px each before gaps; body text at 2428px wraps awkwardly. Split across two slides instead.
- Skipping the eyebrow — the eyebrow is the editorial anchor. Missing eyebrow = orphan headline (see `ui-primitives.md` composition rules).
- Placing a `table.grid` and a `.kpi` grid on the same slide — two ideas per slide breaks the editorial rhythm. One primitive per role per slide.
---
## content-paper
**Purpose:** paper-background variant for visual pacing. Identical grid mechanics to `content`, but the `#F2F2F2` tint signals "pause and discuss."
**When to use:**
- Strategic discussion slides — the reference uses this exclusively for Act 04's four debates.
- When a run of white `content` slides has gone on too long and the audience needs a visual reset.
- Paired with a dark inline "Questions for the board" card — the paper/ink contrast is the signature.
**When NOT to use:**
- For straight report content — use `content` (white).
- For Amarelo moments — use `content-accent`.
- As the default for all body slides — if every slide is paper, the pacing signal is gone.
**Composition pattern:**
- `.slide.paper` variant
- Same `.meta` / `.body` / `.footer` scaffold as `content`
- Two-column grid: `1fr 1.15fr` with Context column (ticks list) on left and dark "Questions for the board" card on right
- Dark card uses scoped `.qblock`, `.qnum`, `.qtitle`, `.qbody` classes — numbered rows with Amarelo numerals, Poppins 24px title, IBM Plex Serif 17px body on 75%-white
- Eyebrow-timer pattern at top: "Discussion 01" — hairline — "~12 min"
**Reference example:** `lerian-ppt-example.html` lines 14571512 (Discussion 01 — Cloud). Also used for Discussions 02, 03, 04 (lines 15131688). Template canonical: `../templates/slide-content-paper.html` (98 lines).
**Common mistakes:**
- Using `.slide.paper` without the dark Questions card — the paper tint alone doesn't signal discussion. The contrast between paper bg and inline ink card is what makes the archetype read as "decision needed."
- More than three numbered questions — the card grows too tall and crowds the context column. Split into a follow-up slide.
- Using `ul.numbered` class inside the Questions card — the class exists (see `ui-primitives.md`) but the reference uses the scoped `.qblock` pattern for the Questions-card-on-dark composition. Keep them distinct: `ul.numbered` for light slides, `.qblock` inline pattern for dark cards on paper.
- Putting `content-paper` early in the deck — paper belongs in the discussion act, not in the report acts where it breaks the rhythm.
---
## content-dark
**Purpose:** near-black statement slides. High-impact moments — financial KPI walls, capital strategy, single declarative question.
**When to use:**
- Financial KPI walls (4-up horizontal `.kpi` row on dark panel)
- Capital strategy open questions (hero statement in Poppins 110px + bottom strip of 4 metrics)
- Any moment where the content warrants "stop and listen" — `.slide.dark` is the loudest variant without using Amarelo.
**When NOT to use:**
- For standard report content — use `content` (white).
- For multi-column grid content that needs hairline rules — the dark variant's `border-color: rgba(255,255,255,0.18)` works but loses definition. Use `content-paper` when you need rules and room to breathe.
- More than 3 times in a 20-slide deck — `.slide.dark` is punctuation; a run of dark slides exhausts the audience.
**Composition pattern:**
- `.slide.dark` variant
- Eyebrow auto-renders in Amarelo (`.slide.dark .eyebrow { color: var(--c-accent); }`)
- Hero statement region: `flex: 1; justify-content: center` wrapper with Poppins 88150px text
- Bottom KPI strip: `flex-shrink: 0` row of 34 metrics, border-top at 15% white
- Uses scoped `.dark-kpi` class (Poppins 34px inverse-ink) for bottom-strip metrics — distinct from the `.kpi` primitive
**Reference example:** `lerian-ppt-example.html` lines 9561039 (Financial KPIs) and 17191743 (Capital Strategy). Template canonical: `../templates/slide-content-dark.html` (65 lines).
**Common mistakes:**
- Using `.pill` (default outline) on dark — the outline disappears against the dark background. Use `.pill.accent` only, per `ui-primitives.md` pairing table.
- Putting a `table.grid` on dark without inverting rule colors — hairlines invisible on near-black. The reference doesn't use tables on dark slides; if you need one, override `border-color: rgba(255,255,255,0.18)` explicitly.
- Mega text on dark at 240px — on a single-statement dark slide, 88110px is the sweet spot. 150px feels shouty, 240px feels like a cover.
- Forgetting `.kpi .value` auto-goes white on dark — no explicit color override needed; the base CSS handles it.
---
## content-accent
**Purpose:** Amarelo full-canvas declarative slide. The loudest moment in the deck.
**When to use:**
- One — at most two — times per deck, for a single message that the board must remember.
- When a statement is bigger than any chart or metric could carry ("Billing Day One. Reported revenue = real cash.").
- As a cliffhanger before an appendix or at the pivot of the deck.
**When NOT to use:**
- For anything supported by data — data slides belong on `content`, `content-paper`, or `content-dark`.
- Back-to-back with another Amarelo slide (act-divider → content-accent → act-divider chains) — the Amarelo burn-in destroys the effect of both slides.
- For discussion openers — Amarelo is "declaration," not "question." Discussions open on `content-paper`.
**Composition pattern:**
- `.slide.accent` variant
- Eyebrow in 72%-ink (`rgba(25,26,27,0.72)`) — auto-applied
- Hero statement in Poppins 150px, `--c-accent-ink` (black) — uses `flex: 1; justify-content: center` to fill canvas
- Optional supporting lede at 78%-ink
- Bottom proof-point strip: 3 `.accent-kpi` tiles (Poppins 72px) + `.accent-cap` mono captions, 18%-ink border-top
**Reference example:** This archetype was **synthesized in Wave 2A** — the reference deck (`lerian-ppt-example.html`) does not contain a pure `content-accent` slide. The closest neighbors are the act dividers (lines 420463, 919954, 11941225, 14161455, 16901717) and `appendix-intro` (lines 17451761). The archetype extends the `.slide.accent` pattern to a content-bearing statement slide. Template canonical: `../templates/slide-content-accent.html` (67 lines).
**Common mistakes:**
- Adding a `table.grid` or chart — FORBIDDEN per `ui-primitives.md` and `chart-primitives.md`. Amarelo bg eats hairlines and collapses chart highlight contrast.
- Using it as a report slide — if the content requires more than one statement, it's not `content-accent`; it's `content` with an Amarelo eyebrow callout.
- Running it next to an act-divider — both Amarelo canvases back-to-back neutralize each other's signal.
- Hero text under 88px — at 1920×1080, Amarelo-on-black needs presence. 150px is the reference default, 110px is the floor.
- Placing `.pill.accent` on `.slide.accent` — Amarelo pill on Amarelo background is invisible. Use `.pill.solid`.
---
## appendix-intro
**Purpose:** transition from main deck to appendix. Amarelo canvas like an act-divider, but signals "what follows is reference material, not presentation flow."
**When to use:**
- Once per deck, between the last main-deck slide and the first appendix slide.
- When the deck has ≥3 appendix slides worth pre-announcing.
- When the appendix contains material the board may want to navigate during Q&A — signaling its availability is the point.
**When NOT to use:**
- For decks without an appendix — no appendix, no `appendix-intro`.
- For "Next Steps" or "Questions" closer slides — those aren't appendix entries.
- If the appendix is a single slide — a single reference slide doesn't warrant a transition.
**Composition pattern:**
- `.slide.accent` variant (same as act-divider — but different meta right)
- `meta` right shows the literal string `Appendix` — NOT `NN / NN`, NOT a letter index
- Eyebrow: "Appendix — A1 through AN" (substitute actual range)
- Giant Poppins 180px title ("Context, on demand.")
- Lede paragraph enumerating the appendix sections
**Reference example:** `lerian-ppt-example.html` lines 17451761. Template canonical: `../templates/slide-appendix-intro.html` (29 lines).
**Common mistakes:**
- Using `NN / NN` in meta.right — runtime would happily fill it, but the visual convention is "Appendix" as a standalone word, signaling the role change.
- Making it look different from act-dividers — the audience reads Amarelo-canvas as "section break." `appendix-intro` intentionally reuses the act-divider chrome; the only signal difference is the meta.right word and the absence of a pill-list of upcoming slides.
- Pill row of appendix slides — the appendix is browsed, not presented. No pill row.
- Placing it before an act-divider instead of after the main-deck closer — breaks reading order. Main deck → closer → appendix-intro → A1 → A2 …
---
## appendix-content
**Purpose:** appendix body slides. Same grid discipline as `content`, but with letter-paginated meta and denser card layouts.
**When to use:**
- Any appendix entry with reference-material density — side letters, cap table detail, roadmap, risks, org chart, security posture.
- When the audience is likely to read alone during Q&A rather than during presentation.
- When the content benefits from card grids (3-up, 4-up) rather than hero layouts.
**When NOT to use:**
- For main-deck content — appendix is reference-only. If it belongs in the presentation flow, it belongs on `content`.
- For Amarelo statement slides — the appendix is informational, not declarative.
- For charts — charts belong on main-deck `content` slides where the presenter walks through them. Appendix readers don't get a presenter.
**Composition pattern:**
- `.slide` variant (white)
- `meta` left: wordmark + "· Appendix A1"
- `meta` right: STATIC letter pagination (`<span>A1 / 8</span>`) — NOT `.page-num` / `.page-total` classes. The runtime does NOT overwrite this.
- Eyebrow: "Appendix A1" (matches meta left)
- h1 describes the appendix section
- 3-up or 4-up card grid using scoped `.appendix-card` class (paper-bg summary blocks) with `.appendix-card-title` for Poppins 2432px body copy
- `data-density="compact"` OPTIONAL — apply when content density exceeds the default token spacing
**Reference example:** `lerian-ppt-example.html` lines 17631791 (A1 Side Letters), plus A2A8 at lines 17932153. Template canonical: `../templates/slide-appendix-content.html` (63 lines).
**Common mistakes:**
- Using `.page-num` / `.page-total` classes on meta.right — the runtime would overwrite `A1 / 8` with `09 / 17`. Plain `<span>A1 / 8</span>` with no runtime-sensitive class is REQUIRED.
- Hero layouts — the appendix is cards and tables, not statements. If the content wants to be a hero, promote it to the main deck.
- Rough letter-pagination (`A1`, `A2`, `A3` inconsistently numbered) — total is fixed. `A1 / 8` through `A8 / 8`, no skips, no gaps.
- Skipping the "· Appendix A1" breadcrumb in meta.left — readers navigating appendix during Q&A lose context without it.
- Putting speaker notes on appendix slides — the array length MUST still match `<section>` count (per `speaker-notes.md`), but notes for appendix slides can be a single short sentence (`"Appendix A1 — side letters. Refer to printed terms."`). Don't write 100-word notes for slides nobody presents.
---
## Related
- Tokens: [`design-tokens.md`](design-tokens.md)
- Canvas + flex discipline: [`layout-rules.md`](layout-rules.md)
- UI primitives: [`ui-primitives.md`](ui-primitives.md)
- Chart primitives: [`chart-primitives.md`](chart-primitives.md)
- Speaker notes schema: [`speaker-notes.md`](speaker-notes.md)
- Dev server runtime: [`server.md`](server.md)
- PDF export pipeline: [`export.md`](export.md)

View file

@ -0,0 +1,167 @@
# Speaker Notes
Speaker notes are embedded in the deck HTML as a single JSON block and surfaced to the presenter view via a regex extraction. This document specifies the schema, the embedding pattern, and the writing voice.
## JSON Schema
The speaker-notes block is a **flat array of strings**. Each array element is the full speaker copy for one slide. Array index corresponds to slide order: `notes[0]` is the cover slide, `notes[1]` is slide 2, and so on.
```json
[
"<speaker copy for slide 1>",
"<speaker copy for slide 2>",
"<speaker copy for slide 3>"
]
```
**Array length SHOULD match `<section>` count inside `<deck-stage>`.** The runtime warns in the console (`[deck-stage] Speaker-notes length N != slide count M`) if the counts differ, but it does NOT throw — missing indices render as `(no notes)` in the presenter view and the deck keeps working. Treat the warning as a soft gate: match exactly for production decks; during authoring it's normal to be one off.
### Embedded Entry Shape
- **Type:** string (not object).
- **Delimiter within a string:** `\n\n` for paragraph breaks. Single `\n` breaks render as spaces in presenter view.
- **Content:** plain prose. No Markdown. No HTML. No bullets.
- **Escape:** standard JSON string escaping (`\"`, `\\`, `\n`).
- **FORBIDDEN substring:** `</script>` anywhere inside a note string. The presenter extraction uses a regex that terminates on the first `</script>` — an embedded occurrence closes the inline JSON block prematurely and corrupts every note after it. `presenter-view.js` strips HTML comments before `JSON.parse`, but the `</script>` boundary is unforgeable. If you must reference the tag in speaker copy, break it: `"</scr" + "ipt>"` in source, or just write "script tag" in prose.
## Embedding Pattern
The block lives in `<head>` of `deck.html`:
```html
<head>
<meta charset="utf-8" />
<title>… deck title …</title>
<script type="application/json" id="speaker-notes">
[
"Welcome everyone. This is our first formal board meeting. …",
"Quick map of where we're going. Five acts. …",
]
</script>
</head>
```
**REQUIRED attributes on the `<script>` tag:**
| Attribute | Value | Why |
| --- | --- | --- |
| `type` | `"application/json"` | Prevents the browser from executing the block as script |
| `id` | `"speaker-notes"` | Stable selector for the presenter-view fetch |
## Presenter View Extraction
Presenter view (opened on the second screen) fetches `/deck.html` and regex-extracts the JSON block — it does NOT iframe the deck, because that would run the deck's own WebSocket client and double-subscribe.
```javascript
// In presenter.html runtime:
async function loadNotes() {
const res = await fetch('/deck.html');
const html = await res.text();
const match = html.match(
/<script type="application\/json" id="speaker-notes">([\s\S]*?)<\/script>/
);
if (!match) throw new Error('speaker-notes block not found in /deck.html');
return JSON.parse(match[1]);
}
```
**Why regex, not DOMParser:** parsing 1920×1080 deck HTML in a second tab wastes memory and re-executes inline scripts. The regex is deterministic because the block has fixed attributes.
## Writing Voice
Speaker notes are **the script the presenter reads**, not a slide summary. Tone is first-person presenter voice, direct, concrete, ready to speak aloud.
### Rules
| Rule | Reason |
| --- | --- |
| Speaking voice, not bullets | The presenter reads this aloud. Fragments break cadence. |
| One paragraph per slide, `\n\n` separators between paragraphs if >1 | Presenter scans paragraph-by-paragraph during delivery. |
| Named concrete data first, abstractions second | "ARR is $4.2M" before "growth is strong" — the audience gets the fact before the gloss. |
| Target ~90120 words per slide | ~30 seconds of speech at a conference pace. |
| Second-person tense for the audience; first-person for the speaker | "You'll see…" / "I want to flag…". Never third-person ("the presenter walks through"). |
| No self-reference to the slide | "Let me show you…" beats "On this slide…". The slide is the prop, not the subject. |
### Word Budget — Why 90120
- Public-speaking cadence ≈ 130150 words/minute.
- Slide-bound delivery ≈ 100120 words/minute (pauses, gestures).
- 30 seconds per slide × 3 words/second ≈ 90 words as the lower floor.
- Above 120 words, the presenter reads instead of presents — hand the audience the deck and save 89 minutes.
## Gold-Standard Examples
Three entries from the reference deck (`lerian-ppt-example.html`) with commentary on why they work.
### Example 1 — Portfolio slide (index 2)
```text
Let me show you what's actually shipping. Six products in production — Midaz,
Matcher, Tracer, Flowker, Fetcher, Reporter — the full core-banking stack.
Underwriter is the seventh — credit decisioning — fully spec'd, build underway
in the repo today; that closes the loop for credit workflows. Plus six plugins
live today, including Pix Direto and TED Direto. The pipeline is the regulated
Brazilian stuff that takes time: Credit, Payments, BC Correios, SIMBA, BacenJud
Direto. The point of this slide: the 'you don't have product X' objection is
dead. We have the portfolio.
```
**Why it works:**
- Opens with "Let me show you" — presenter voice, active verb.
- Names every product concretely before stating the thesis.
- Ends with the takeaway ("the objection is dead") — the thing the audience should remember.
- 98 words — inside the target band.
### Example 2 — Q1 P&L (index 7)
```text
Q1 P&L. The number that will raise eyebrows is AI spend — three hundred fifteen
to four hundred forty thousand reais a month. That is deliberate. It's the bet
behind discussion number three. Runway is twenty months at gross burn,
thirty-two months net of revenue — comfortably long either way. One nuance
worth knowing: we bill day one — revenue equals cash, no grace period,
no deferred.
```
**Why it works:**
- Names the concrete number ("three hundred fifteen to four hundred forty thousand") before the abstraction ("deliberate").
- Cross-references the strategic discussion ("discussion number three") — gives the presenter a natural hand-off.
- Numbers are written out as words — MUST for anything the presenter says aloud. Digits interrupt reading flow.
- 72 words — short but complete. This is a factual slide; not every note needs 120 words.
### Example 3 — Discussion one (index 12)
```text
Discussion one. We've been calling it Vercel for financial services. Client
deploys our products plus their own apps on Lerian infrastructure — three
tiers, managed to SaaS. Seventy percent of new logos want this already. The
question isn't whether. It's timing, positioning, and whether we should
reframe the company around it. Where are we wrong?
```
**Why it works:**
- Immediately names the framing ("Vercel for financial services") before unpacking it.
- Ends with the board-facing question ("Where are we wrong?") — invites the debate the slide is built for.
- 60 words — low end of the range. A discussion-opener slide is a prompt, not a monologue; the actual content is the board response.
## Authoring Checklist
```
Before committing speaker-notes JSON:
[ ] 1. Array length matches <section> count inside <deck-stage>? (Soft gate — mismatch warns but doesn't throw; match for production.)
[ ] 2. Every string is speaking voice (first-person presenter, not slide summary)?
[ ] 3. Every slide has named data before abstraction?
[ ] 4. Numbers expected to be spoken aloud are written as words, not digits?
[ ] 5. Word count per slide is 60120 (hit the target for content slides; shorter for discussion openers)?
[ ] 6. Zero "on this slide…" / "in this diagram…" self-references?
[ ] 7. Zero literal `</script>` substrings anywhere in any note (breaks inline-JSON extraction)?
[ ] 8. JSON parses cleanly (test with JSON.parse in browser console)?
If any checkbox is no → Rewrite the note. Speaker notes ship once; redoing live is expensive.
```

View file

@ -0,0 +1,529 @@
# UI Primitives
Six primitives that compose Lerian editorial slide content. All primitives use tokens from [`design-tokens.md`](design-tokens.md); don't reinvent colors, fonts, or spacing. Layout discipline (canvas size, flex, 24px floor) comes from [`layout-rules.md`](layout-rules.md) — primitives do not override it.
**HARD GATE:** primitives MUST NOT be redefined with new base styles. Inline `style=""` tweaks are allowed for one-off positioning (margin, width, color override) but the class-level rules below are canon.
## Table of Contents
| # | Primitive | When to use |
| --- | --- | --- |
| 1 | [eyebrow](#eyebrow) | Small uppercase mono label above every h1, every section opener, every card title |
| 2 | [pill](#pill) | Rounded tag for deployment type, status, segment, time-box on a row |
| 3 | [kpi](#kpi) | Stacked label + big Poppins number + sub — the single-metric tile |
| 4 | [ticks](#ticks) | Bulleted list with 10×10 Amarelo squares instead of dots |
| 5 | [numbered](#numbered) | Ordinal list with mono `01/02/03` gutter — steps, discussion questions |
| 6 | [table.grid](#tablegrid) | Editorial data tables — agendas, P&L rows, side-by-side comps |
---
## eyebrow
**Purpose:** small uppercase monospace label that sits above every h1, every card title, every chart caption. It's the editorial anchor that tells the eye "this is a new unit."
**When to use:**
- Above the headline on every content slide (`.eyebrow` → `h1`)
- As a section label inside a card or column ("Context", "Ownership", "Channels")
- As a caption above a chart, table, or micro-data block
- Inside a meta-row (discussion timer pattern: `<div class="eyebrow">Discussion 01</div> ... <div class="eyebrow">~12 min</div>`)
**When NOT to use:**
- As a standalone headline — it is support text, not a title
- Below the h1 (reverses the reading order)
- For body copy — use IBM Plex Serif at `--t-body` instead
- At sizes other than `--t-eyebrow` (22px) without deliberate override; sub-eyebrows at 14px are used inline in the reference for deep labels
**HTML:**
```html
<div class="eyebrow">Portfolio — Where We Stand</div>
```
**CSS:**
```css
.eyebrow {
font-family: 'JetBrains Mono', monospace;
font-size: var(--t-eyebrow);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--c-ink-3);
}
.slide.dark .eyebrow { color: var(--c-accent); }
.slide.accent .eyebrow { color: rgba(25,26,27,0.72); }
```
**Variants:**
| Variant | How | Where observed |
| --- | --- | --- |
| Default | `<div class="eyebrow">…</div>` | Every content slide headline |
| Dark slide | auto (`.slide.dark` → Amarelo text) | Financial KPI slide, dark panels |
| Accent slide | auto (`.slide.accent` → 72%-black) | Act divider openers |
| Amarelo highlight | `style="color: var(--c-accent)"` | "Ask for the board", "Questions for the board", "Framing" — signals a callout |
| Verde highlight | `style="color: var(--c-accent-2)"` | "Services layer · wraps both" — supporting signal |
| In-card (small) | `style="font-size: 14px; color: var(--c-ink)"` | Client-tile headers ("Live in production") |
**Composition example:**
```html
<div class="eyebrow">GTM Strategy</div>
<h1 style="margin-top: 28px; max-width: 1600px;">Engineering-led. Founder-led. Channel-light.</h1>
```
**Timer-row pattern (paired eyebrows with hairline between):**
```html
<div style="display: flex; align-items: center; gap: 18px;">
<div class="eyebrow">Discussion 01</div>
<div style="height: 1px; flex: 1; background: var(--c-rule);"></div>
<div class="eyebrow">~12 min</div>
</div>
```
The `.eyebrow` primitive is demonstrated in every content archetype — `../templates/slide-content.html`, `../templates/slide-content-paper.html`, `../templates/slide-content-dark.html`, `../templates/slide-content-accent.html`, `../templates/slide-agenda.html`, `../templates/slide-appendix-intro.html`, `../templates/slide-appendix-content.html` — and in `../templates/slide-cover.html` for the eyebrow above the title. The timer-row pattern above is built on the same primitive and is ready to drop into any content slide.
---
## pill
**Purpose:** rounded tag for deployment type, segment, status, or short time-box label. Sits inline next to a Poppins name or inside a row flex.
**When to use:**
- Tagging a client row with deployment type ("BYOC", "SaaS/PaaS")
- Marking new items in a list ("New")
- Deployment-callout captions ("Bring-your-own-cloud", "70% of new logos")
- Funnel-stage labels beside a headline ("Top of Funnel", "Cycle Mechanics")
**When NOT to use:**
- As the primary headline — it is chrome, not content
- As a button (deck is a projection surface; there are no clicks)
- For multi-word copy that wraps — pills are single-line
- More than ~6 in a single row (the `act-divider` uses 5; seven starts reading as chips, not pills)
**HTML:**
```html
<span class="pill">BYOC</span>
<span class="pill accent">New</span>
<span class="pill solid">SaaS/PaaS</span>
```
**CSS:**
```css
.pill {
display: inline-flex; align-items: center; gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 18px; letter-spacing: 0.04em; text-transform: uppercase;
padding: 8px 14px; border-radius: 999px;
border: 1px solid var(--c-rule); background: transparent; color: var(--c-ink-2);
}
.pill.solid { background: var(--c-ink); color: var(--c-ink-inv); border-color: transparent; }
.pill.accent { background: var(--c-accent); color: var(--c-accent-ink); border-color: transparent; }
```
**Variants:**
| Variant | Class | Background | Text | Use |
| --- | --- | --- | --- | --- |
| Default (outline) | `.pill` | transparent | `--c-ink-2` | Neutral tag — "BYOC", "Top of Funnel" |
| Solid (dark) | `.pill.solid` | `--c-ink` | white | Emphatic tag — "SaaS/PaaS", funnel stage |
| Accent (Amarelo) | `.pill.accent` | `--c-accent` | `--c-accent-ink` | "New", brand-signal tag |
**Observed inline override:** one-off pill with accent background + `white-space: nowrap` for a stat caption ("70% of new logos"). Prefer `.pill.accent` for that case going forward; the inline form is not a new variant.
**Composition example — act-divider chip row (5 pills, wraps if tight):**
```html
<div style="margin-top: 56px; display: flex; gap: 14px; flex-wrap: wrap;">
<span class="pill solid">01 · Portfolio</span>
<span class="pill solid">02 · Pricing</span>
<span class="pill solid">03 · Clients</span>
<span class="pill solid">04 · Pipeline</span>
<span class="pill solid">05 · Competitive</span>
</div>
```
**Composition example — client row (name + pills):**
```html
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span style="font-family: 'Poppins'; font-weight: 500; font-size: 26px;">SRM Asset</span>
<span class="pill">BYOC</span>
<span class="pill accent">New</span>
</div>
```
`.pill.solid` is demonstrated in `../templates/slide-act-divider.html` — the act's chip row uses the dark-pill pattern (local class `.act-pill`) with an inverted number chip, which is the canonical reference rendering of the `.pill.solid` shape. The outline (`.pill`) and accent (`.pill.accent`) variants are not instantiated in the current archetype set; they're defined in the base stylesheet and available for reuse in any content archetype that needs an inline tag next to a Poppins name (e.g., a future clients-row template).
---
## kpi
**Purpose:** the single-metric tile. Stacks a mono label over a big Poppins number over a small sub-caption. Composes into 3- or 4-column KPI walls.
**When to use:**
- KPI wall slides (34 metrics on one slide)
- Inside a left column paired with a chart on the right (financial-KPI slide)
- Dark-panel summary rows on statement slides
**When NOT to use:**
- For numbers that need to sit inline with sentence prose — use a Poppins `<span>` instead
- For ultra-hero single numbers (180240px) — those are standalone, not tiles; see `layout-rules.md` Hero Numbers
- When the caption is longer than ~80 chars — kpi.sub is designed for one short line
**HTML:**
```html
<div class="kpi">
<div class="label">ARR run rate</div>
<div class="value">R$ 2.5M</div>
<div class="sub">MRR R$ 207K · ↑ 590% since May/25 · ↑ 24% MoM</div>
</div>
```
**CSS:**
```css
.kpi { display: flex; flex-direction: column; gap: 10px; }
.kpi .label {
font-family: 'JetBrains Mono', monospace;
font-size: 18px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--c-ink-3);
}
.kpi .value {
font-family: 'Poppins', sans-serif;
font-weight: 500; font-size: 96px; line-height: 0.95; letter-spacing: -0.03em;
color: var(--c-ink);
}
.kpi .sub { font-size: 22px; color: var(--c-ink-3); }
.slide.dark .kpi .value { color: var(--c-ink-inv); }
.slide.dark .kpi .label,
.slide.dark .kpi .sub { color: rgba(255,255,255,0.55); }
```
**Variants:**
| Variant | How | Where observed |
| --- | --- | --- |
| Default | `<div class="kpi">` with 96px value | Financial-KPI slide, generic tiles |
| Accent value | `style="color: var(--c-accent)"` on `.value` | Primary KPI on a dark slide ("R$ 2.5M") |
| Reduced value | `style="font-size: 76px"` (or 60px) on `.value` | When three tiles stack vertically and 96px blows the column |
| Dark-slide auto | auto via `.slide.dark` | Value goes white, label/sub drop to 55% opacity |
| Inline sub-span | `<span style="font-size: 26px; font-weight: 400; color: rgba(255,255,255,0.55);">target</span>` inside `.value` | "NDR 130% target" — appendix to the hero number |
**Composition example — 4-up KPI row on a dark panel:**
```html
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 40px;">
<div class="kpi">
<div class="label">Stage</div>
<div class="value" style="font-size: 76px;">Post-Seed</div>
</div>
<div class="kpi">
<div class="label">Runway</div>
<div class="value" style="font-size: 76px;">20 32 mo</div>
</div>
<div class="kpi">
<div class="label">Pipeline</div>
<div class="value" style="font-size: 76px;">R$ 5.9M active</div>
</div>
<div class="kpi">
<div class="label" style="color: var(--c-accent);">Cloud maturity</div>
<div class="value" style="font-size: 76px;">Inflection</div>
</div>
</div>
```
The dark-panel variant of this primitive is demonstrated in `../templates/slide-content-dark.html` — the local `.dark-kpi` class in that template is the canonical `.slide.dark .kpi` rendering (white value, 55%-white label/sub). The light-panel `.kpi` shape has no dedicated archetype in the current set; reuse it inside any `.slide` or `.slide.paper` content archetype when you need a metric tile or a 3-/4-up KPI row.
**HARD GATE:** when three `.kpi` tiles stack vertically inside a column, reduce `.value` to 6076px. The 96px default assumes one row of tiles, not three stacked.
---
## ticks
**Purpose:** custom-bulleted list. 10×10 Amarelo square marker replaces the dot bullet — Lerian's list fingerprint.
**When to use:**
- "Context" columns on strategic-discussion slides
- Evidence blocks under a headline
- Any 36 item editorial list where each item is 13 lines
**When NOT to use:**
- For step-ordered content — use [`numbered`](#numbered) instead
- For >6 items — split into columns or trim; long lists dilute the square rhythm
- For single-line tag sequences — use [`pill`](#pill) in a flex row
**HTML:**
```html
<ul class="ticks">
<li><strong>70% of new logos</strong> enter directly through SaaS (vs. 100% BYOC 12 months ago).</li>
<li>Tenant-manager live since Q1/26. Clients deploy Lerian products <strong>and their own apps</strong> on Lerian infra.</li>
<li><strong>No competitor offers this</strong> capability today.</li>
<li>BYOC clients already requesting migration to managed tiers.</li>
</ul>
```
**CSS:**
```css
ul.ticks { list-style: none; padding: 0; margin: 0; }
ul.ticks li {
position: relative; padding-left: 28px;
font-size: 22px; line-height: 1.5; margin-bottom: 18px;
}
ul.ticks li::before {
content: ""; width: 10px; height: 10px;
background: var(--c-accent);
display: block; border-radius: 2px;
transform: translateY(12px);
}
```
**Variants:**
- Default is the only variant observed in the reference. No dark-slide override is defined — inherit body color from `.slide.dark p, .slide.dark li { color: rgba(255,255,255,0.78); }` in the base; the Amarelo square reads against both light and dark surfaces without a variant.
- Common inline adjustment: `style="flex: 1;"` on the `<ul>` so it stretches when the parent column is a flex container (discussion slides).
**Note on 22px font-size:** this is below the 24px body floor from `layout-rules.md`. The reference uses 22px intentionally for list density. When shipping new slides, prefer 24px. If sticking to 22px, treat it as a chrome-density exception (documented in `layout-rules.md` Minimum Text Size table, row "1822px … small labels") and MUST NOT go lower.
**Composition example — Context column on a paper-variant slide:**
```html
<div class="eyebrow" style="margin-bottom: 24px; color: var(--c-ink);">Context</div>
<ul class="ticks" style="flex: 1;">
<li>Brazil's regulatory complexity is the <strong>ultimate proof of adaptability</strong>.</li>
<li><strong>La Finteca</strong> global CEO visited the office — organic LATAM demand.</li>
<li><strong>Lerian LLC</strong> Delaware incorporated.</li>
<li>Reference model: <strong>Tractian</strong> — BR-born, US expansion via physical presence.</li>
</ul>
```
The canonical instance lives in `../templates/slide-content-paper.html` — the "Context" column uses `<ul class="ticks">` against the paper surface. Reuse the primitive in any content archetype that needs a 36-item evidence list; the Amarelo square reads against `.slide` (white), `.slide.paper`, and `.slide.dark` without variant overrides.
---
## numbered
**Purpose:** ordinal list. Mono `01 / 02 / 03` gutter on the left, Poppins/Serif content on the right. For steps, questions, priorities where order matters.
**When to use:**
- "Questions for the board" blocks
- Step-by-step process lists
- Ranked priorities
**When NOT to use:**
- Unordered lists — use [`ticks`](#ticks)
- When the numerals themselves need to be huge (20150px) — use a hero-number layout, not a list gutter
**HTML (canonical form, using the `ul.numbered` class):**
```html
<ul class="numbered">
<li>
<span class="n">01</span>
<div>Target profile &amp; reference case. Enterprise gravitates hybrid, mid-market accepts full SaaS.</div>
</li>
<li>
<span class="n">02</span>
<div>GTM timing. 70% already choose SaaS. Is this the signal to go-to-market with Cloud now?</div>
</li>
<li>
<span class="n">03</span>
<div>Pricing strategy. Clients running their apps on Lerian infra creates platform lock-in.</div>
</li>
</ul>
```
**CSS:**
```css
ul.numbered {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 24px;
}
ul.numbered li {
display: grid; grid-template-columns: 60px 1fr; gap: 28px; align-items: baseline;
font-size: var(--t-body); line-height: 1.4;
}
ul.numbered li .n {
font-family: 'JetBrains Mono', monospace;
font-size: 18px; letter-spacing: 0.06em;
color: var(--c-ink-3); padding-top: 8px;
}
```
**Variants:**
| Variant | How | Where observed |
| --- | --- | --- |
| Default (light) | `ul.numbered` on `.slide` | Canonical form per CSS |
| Dark-panel, Amarelo numerals | Inline-flex blocks on dark card, `.n` in Amarelo, Poppins title + Serif sub | "Questions for the board" blocks on all four Discussion slides |
**Two forms shipped.** (a) `ul.numbered` — canonical list form for simple ordered lists on light slides. (b) Inline dark-panel variant — dark `<div>` card with `display: flex` rows, `<span>` numeral in `JetBrains Mono` + Amarelo, `<div>` title/sub. Both are canonical. Prefer the class form for plain ordered lists; use the inline dark variant when each item needs title + body on a dark card (e.g., "Questions for the board").
**Composition example — dark "Questions for the board" card (inline variant from reference):**
```html
<div style="background: var(--c-ink); color: var(--c-ink-inv); padding: 40px 44px;
border-radius: 4px; display: flex; flex-direction: column; gap: 24px;
justify-content: space-between;">
<div class="eyebrow" style="color: var(--c-accent);">Questions for the board</div>
<div style="display: flex; gap: 14px;">
<span style="font-family: 'JetBrains Mono'; color: var(--c-accent);
font-size: 16px; flex-shrink: 0; padding-top: 8px;">01</span>
<div>
<div style="font-family: 'Poppins'; font-size: 24px; font-weight: 500;
color: var(--c-ink-inv); line-height: 1.25;">Target profile &amp; reference case</div>
<div style="font-size: 17px; line-height: 1.5;
color: rgba(255,255,255,0.75); margin-top: 6px;">
Enterprise gravitates hybrid, mid-market accepts full SaaS.
</div>
</div>
</div>
<!-- 02, 03 … -->
</div>
```
The dark-card "Questions for the board" pattern is demonstrated in `../templates/slide-content-paper.html` — the right-column dark card composes the inline numbered variant (Amarelo numeral + Poppins title + Serif sub) on top of `background: var(--c-ink)`. That template is the reference rendering of this primitive's inline form. The light-slide `ul.numbered` class form is not instantiated in the current archetype set; use it inside any `.slide` content archetype when you need a ranked-step list.
---
## table.grid
**Purpose:** the editorial data grid. Hairline rules top and bottom, generous row padding, JetBrains Mono column headers, tabular-nums for numeric cells, Amarelo highlight row for the emphatic line.
**When to use:**
- Agenda tables (act × theme × time × format)
- P&L snapshots and financial grids
- Side-by-side comparisons with ≥3 columns
- Any data set where row rhythm carries meaning
**When NOT to use:**
- 1-column or 2-column lists where an editorial row would read as overengineered — use [`ticks`](#ticks) or a `div` row
- For layouts that need fixed column heights — `table.grid` is content-sized (see `layout-rules.md` Fixed-Height Cards — FORBIDDEN)
- Inside a scrollable container — the canvas is 1080px tall; rows MUST fit
**HTML:**
```html
<table class="grid" style="margin-top: 72px;">
<thead>
<tr>
<th style="width: 100px;">Act</th>
<th>Theme</th>
<th style="width: 160px; text-align: right;">Time</th>
<th style="width: 220px; text-align: right;">Format</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">01</td>
<td style="font-size: 28px; font-family: 'Poppins'; color: var(--c-ink);">Traction, Product &amp; Competitive Positioning</td>
<td class="num" style="text-align: right;">20 min</td>
<td style="text-align: right; color: var(--c-ink-3);">Report</td>
</tr>
<tr class="hl">
<td class="num">04</td>
<td style="font-size: 28px; font-family: 'Poppins';">Four Strategic Discussions
<span style="opacity: 0.62; font-weight: 400;">— the core of this board</span></td>
<td class="num" style="text-align: right;">45 min</td>
<td style="text-align: right; font-weight: 600;">Debate</td>
</tr>
</tbody>
</table>
```
**CSS:**
```css
table.grid {
width: 100%;
border-collapse: collapse;
font-size: var(--t-small);
}
table.grid th, table.grid td {
text-align: left; padding: 18px 20px;
border-top: 1px solid var(--c-rule);
color: var(--c-ink-2);
vertical-align: top;
}
table.grid th {
font-family: 'JetBrains Mono', monospace;
font-size: 16px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--c-ink-3); font-weight: 500;
border-top: none;
padding-bottom: 14px;
}
table.grid tr:last-child td { border-bottom: 1px solid var(--c-rule); }
table.grid td.num { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; color: var(--c-ink); }
table.grid tr.hl td { background: var(--c-accent); color: var(--c-accent-ink); font-weight: 500; }
table.grid tr.hl td.num { color: var(--c-accent-ink); }
table.grid.compact th,
table.grid.compact td { padding: 10px 16px; }
table.grid.compact th { font-size: 14px; padding-bottom: 8px; }
table.grid.compact.tight th,
table.grid.compact.tight td { padding: 7px 14px; font-size: 18px; }
table.grid.compact.tight td:last-child { font-size: 17px; color: var(--c-ink-2); }
```
**Variants:**
| Variant | Class | Row padding | Font | Use |
| --- | --- | --- | --- | --- |
| Default | `table.grid` | `18px 20px` | `--t-small` (22px) | Agendas, P&L, most editorial grids |
| Highlight row | `tr.hl` (on any density) | inherits | bg Amarelo, ink preto | The emphatic line ("Debate", "Net income") |
| Numeric cell | `td.num` | inherits | JetBrains Mono, tabular-nums | Ordinal + financial figures |
| Compact | `table.grid.compact` | `10px 16px` | 14px th / 22px td | Higher-density grids (defined; unused in reference) |
| Compact-tight | `table.grid.compact.tight` | `7px 14px` | 18px th/td, 17px last col | Dense grids with an axis-label last column (defined; unused in reference) |
**Density variants — compact and compact-tight.** `compact` (10px 16px padding, 14px/22px type) for grids where default density would overflow the canvas. `compact-tight` (7px 14px, 18px type, 17px last col) for dense grids with an axis-label last column — e.g., P&L with Q1..Q4 + YoY.
**Composition example — P&L grid with a highlight Net income row:**
```html
<table class="grid">
<thead><tr>
<th>R$ thousand</th>
<th style="text-align: right;">Jan/26</th>
<th style="text-align: right;">Feb/26</th>
<th style="text-align: right;">Mar/26</th>
</tr></thead>
<tbody>
<tr><td style="font-size: 22px; font-family: 'Poppins'; color: var(--c-ink);">Revenue (MRR)</td>
<td class="num" style="text-align: right;">167</td>
<td class="num" style="text-align: right;">207</td>
<td class="num" style="text-align: right;">207</td></tr>
<tr><td style="color: var(--c-ink-3);">COGS</td>
<td class="num" style="text-align: right;">(1,160)</td>
<td class="num" style="text-align: right;">(1,240)</td>
<td class="num" style="text-align: right;">(1,370)</td></tr>
<tr class="hl"><td style="font-size: 22px; font-family: 'Poppins';">Net income</td>
<td class="num" style="text-align: right;">(1,590)</td>
<td class="num" style="text-align: right;">(1,520)</td>
<td class="num" style="text-align: right; font-weight: 600;">(1,390)</td></tr>
</tbody>
</table>
```
The canonical instance — including the `tr.hl` highlight row — lives in `../templates/slide-agenda.html`. Reuse the primitive in any content archetype that needs a P&L, side-by-side comparison, or ≥3-column data grid; the variant classes (`compact`, `compact.tight`) are defined in the base stylesheet and ready to use when a denser grid is needed.
**HARD GATE:** no `height`, no `min-height` on rows. Row rhythm comes from padding, not fiat. Fixed cell heights are forbidden per `layout-rules.md`.
---
## Composition Rules
- **eyebrow is the anchor.** Every content slide has at least one `.eyebrow`. Headline goes right under it. Missing eyebrow = orphan headline.
- **One primitive per role per slide.** A slide MUST NOT mix `ul.ticks` and `ul.numbered` in the same column — the bullet grammar conflicts. Split into siblings if you need both.
- **Pills cluster, kpis breathe.** Pill rows flex-wrap tight (`gap: 1014px`); kpi walls use `3680px` column gaps. MUST NOT use pill density on kpis or vice versa.
- **Max 6 pills per row.** The act-divider pattern uses 5. Beyond 6, the rhythm breaks into chip-noise; split the slide or drop the pill.
- **Max 6 ticks per list.** Above 6, the square-bullet rhythm dilutes. Split into two columns (Context | Evidence) or trim.
- **KPI value stack discipline.** Three stacked `.kpi` tiles → reduce `.value` to 6076px. Four-up horizontal row → keep 96px default or reduce to 76px only if copy is long ("R$ 5.9M active").
- **table.grid rules stay hairline.** MUST NOT add double borders, zebra stripes, or solid backgrounds beyond `tr.hl`. The rhythm is the white space between rows, not the lines.
- **Dark-slide kpi wins.** `.slide.dark` + `.kpi` is the canonical summary layout (capital-strategy slide). Eyebrows go Amarelo automatically; `.value` goes white.
- **Accent-slide discipline.** `.slide.accent` pairs with eyebrow + big Poppins number + pill row (the act-divider template). MUST NOT put a table.grid on `.slide.accent` — the Amarelo background eats the hairline rules.
- **Eyebrow color is editorial signal.** Default ink-3 = "label." Amarelo = "callout incoming" (Framing, Ask, Questions for the board). Verde = "supporting signal." MUST NOT use Amarelo eyebrow for neutral labels — it loses meaning.
- **numbered belongs in dark cards or as canonical light lists.** The Questions-for-the-board pattern is the reference's de-facto `numbered` use. Light-slide `ul.numbered` is available per the CSS; use it for ranked steps.
## Dark vs Light Pairing
| Primitive | Works on `.slide` | Works on `.slide.paper` | Works on `.slide.dark` | Works on `.slide.accent` |
| --- | --- | --- | --- | --- |
| eyebrow | yes | yes | yes (auto Amarelo) | yes (auto 72% ink) |
| pill | yes (all variants) | yes | `.pill.accent` only (outline disappears) | `.pill.solid` only (accent on accent = invisible) |
| kpi | yes | yes | yes (preferred — hero summaries) | avoid (Amarelo on Amarelo loses the value) |
| ticks | yes | yes | yes (inherits 78% white body) | yes, but the Amarelo square on Amarelo bg is invisible — swap bullet to ink inline |
| numbered | yes (class form) | yes | yes (the inline Questions pattern — numeral Amarelo, title white, sub 75% white) | avoid (same Amarelo-on-Amarelo problem) |
| table.grid | yes | yes | needs inverted rules (`border-color: rgba(255,255,255,0.18)`) — not in reference canon | **FORBIDDEN** — Amarelo bg eats hairlines |
**HARD GATE:** if the pairing table says "avoid" or "FORBIDDEN," the archetype MUST pick a different primitive or a different slide variant. Do not patch with inline color overrides.
## Related
- Tokens: [`design-tokens.md`](design-tokens.md)
- Canvas + flex discipline: [`layout-rules.md`](layout-rules.md)
- Archetype templates (what actually ships today): `../templates/slide-cover.html`, `../templates/slide-agenda.html`, `../templates/slide-act-divider.html`, `../templates/slide-content.html`, `../templates/slide-content-paper.html`, `../templates/slide-content-dark.html`, `../templates/slide-content-accent.html`, `../templates/slide-appendix-intro.html`, `../templates/slide-appendix-content.html`

View file

@ -0,0 +1,239 @@
import express from 'express';
import { WebSocketServer } from 'ws';
import chokidar from 'chokidar';
import { createServer } from 'http';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import os from 'os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const PORT = parseInt(process.env.PORT || '7007', 10);
const HOST = process.env.HOST || '0.0.0.0';
// state.total stays null until the first deck-stage client announces slide count
// via {type:'hello', total:N}. Remote/presenter clients render "?" while null.
const state = { slide: 0, blank: false, total: null };
function sendFile(res, relPath, contentType = 'text/html; charset=utf-8') {
try {
const body = readFileSync(join(ROOT, relPath));
res.setHeader('Content-Type', contentType);
// No-cache so live-reload actually reloads — browsers otherwise serve stale HTML.
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.send(body);
} catch (err) {
// Distinguish error classes instead of masking everything as 404.
if (err.code === 'ENOENT') {
res.status(404).send(`Not found: ${relPath}`);
} else if (err.code === 'EACCES') {
console.error(`[sendFile] permission denied: ${relPath}`, err);
res.status(403).send(`Forbidden: ${relPath}`);
} else if (err.code === 'EISDIR') {
console.error(`[sendFile] path is a directory: ${relPath}`, err);
res.status(500).send(`Server misconfiguration: ${relPath}`);
} else {
console.error(`[sendFile] unexpected error for ${relPath}:`, err);
res.status(500).send(`Server error: ${relPath}`);
}
}
}
// Tunnel interfaces (VPN, Tailscale, Cloudflare WARP, WireGuard) typically
// aren't reachable from a phone on the same Wi-Fi. Filter them so the LAN
// URL printed in the banner is something the remote can actually connect to.
// Declared above detectLanIp() usage to avoid Temporal Dead Zone on module init.
const TUNNEL_IFACE_RE = /^(utun|ppp|tun|wg)\d*/i;
const lanIp = detectLanIp();
// Allowed Origins for WS handshake (CSWSH defense). Computed once; non-browser
// WS clients (Puppeteer, curl) omit Origin entirely and are accepted below.
const allowedOrigins = new Set([
`http://localhost:${PORT}`,
`http://127.0.0.1:${PORT}`,
]);
if (lanIp) allowedOrigins.add(`http://${lanIp}:${PORT}`);
const app = express();
app.get('/health', (_req, res) => res.json({ ok: true }));
app.get('/', (_req, res) => sendFile(res, 'deck.html'));
app.get('/deck.html', (_req, res) => sendFile(res, 'deck.html'));
app.get('/presenter', (_req, res) => sendFile(res, 'presenter.html'));
app.get('/remote', (_req, res) => sendFile(res, 'remote.html'));
// Dev server — disable caching on assets so live-reload always fetches fresh.
app.use(
'/assets',
express.static(join(ROOT, 'assets'), {
setHeaders: (res) => res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'),
})
);
// Note: /scripts is deliberately NOT mounted — no browser code fetches server
// source, and exposing it over LAN leaked dev-server.mjs + export-pdf.mjs.
const server = createServer(app);
const wss = new WebSocketServer({
server,
path: '/ws',
verifyClient: ({ origin }) => {
// No Origin header → non-browser WS client (Puppeteer, Node scripts). Accept.
if (!origin) return true;
return allowedOrigins.has(origin);
},
});
function broadcast(msg) {
const payload = JSON.stringify(msg);
for (const client of wss.clients) {
if (client.readyState === 1 /* OPEN */) {
client.send(payload);
}
}
}
// Per-connection token bucket: 10 msgs/sec with 10-token burst. Exceeded
// messages are silently dropped (not disconnected) to avoid tearing down a
// slow client during transient bursts.
const BUCKET_CAPACITY = 10;
const BUCKET_REFILL_PER_MS = 10 / 1000; // 10 tokens per 1000ms
function allowMessage(ws) {
const now = Date.now();
const bucket = ws._bucket;
const elapsed = now - bucket.last;
bucket.tokens = Math.min(BUCKET_CAPACITY, bucket.tokens + elapsed * BUCKET_REFILL_PER_MS);
bucket.last = now;
if (bucket.tokens < 1) return false;
bucket.tokens -= 1;
return true;
}
wss.on('connection', (ws) => {
ws._bucket = { tokens: BUCKET_CAPACITY, last: Date.now() };
// Send full state (including total — may be null if no deck-stage connected yet).
ws.send(JSON.stringify({ type: 'state', slide: state.slide, blank: state.blank, total: state.total }));
ws.on('message', (raw) => {
if (!allowMessage(ws)) return; // Rate-limited; drop.
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return; // Ignore malformed frames.
}
if (msg.type === 'hello' && Number.isInteger(msg.total) && msg.total > 0) {
// deck-stage announcing slide count. Update cached total and let everyone know.
state.total = msg.total;
broadcast({ type: 'state', slide: state.slide, blank: state.blank, total: state.total });
} else if (msg.type === 'nav' && Number.isInteger(msg.slide)) {
// Optional total piggyback — deck-stage may re-assert total on nav.
if (Number.isInteger(msg.total) && msg.total > 0) {
state.total = msg.total;
}
// Clamp to known bounds. When total is null (unknown), only clamp below 0.
const upper = state.total != null ? state.total - 1 : Infinity;
state.slide = Math.max(0, Math.min(msg.slide, upper));
broadcast({ type: 'nav', slide: state.slide, total: state.total });
} else if (msg.type === 'blank' && typeof msg.on === 'boolean') {
state.blank = msg.on;
broadcast({ type: 'blank', on: state.blank });
}
// Unknown types silently ignored per schema.
});
});
// Debounce file-change bursts (editors often emit 2-4 events per save).
let reloadTimer = null;
function scheduleReload(event, filePath) {
if (reloadTimer) return;
reloadTimer = setTimeout(() => {
reloadTimer = null;
console.log(`[watch] ${event} ${filePath} — broadcasting reload`);
broadcast({ type: 'reload' });
}, 50);
}
const watcher = chokidar.watch(
[
join(ROOT, 'deck.html'),
join(ROOT, 'presenter.html'),
join(ROOT, 'remote.html'),
join(ROOT, 'assets'),
join(ROOT, 'scripts'),
],
{ ignoreInitial: true, ignored: /(^|[\\/])(node_modules|\.git|deck\.pdf)([\\/]|$)/ }
);
watcher.on('all', scheduleReload);
function detectLanIp() {
const ifaces = os.networkInterfaces();
const candidates = [];
for (const [name, list] of Object.entries(ifaces)) {
if (!list) continue;
for (const iface of list) {
if (iface.family !== 'IPv4' || iface.internal) continue;
candidates.push({ name, address: iface.address });
}
}
const nonTunnel = candidates.find((c) => !TUNNEL_IFACE_RE.test(c.name));
if (nonTunnel) return nonTunnel.address;
if (candidates.length > 0) return candidates[0].address;
return null;
}
function printBanner() {
const lan = lanIp || 'localhost';
const lines = [
'',
'Ring Deck Server',
` Deck: http://localhost:${PORT}`,
` Presenter: http://localhost:${PORT}/presenter`,
` Remote: http://${lan}:${PORT}/remote ← open this on your phone`,
'',
' Watching deck.html, presenter.html, remote.html, assets/, scripts/',
' Local network only — no authentication.',
'',
];
if (HOST === '0.0.0.0') {
lines.push(
' ⚠ Bound to 0.0.0.0 (all LAN interfaces). Anyone on your Wi-Fi can reach this server.',
' For localhost-only, set HOST=127.0.0.1.',
''
);
}
for (const line of lines) console.log(line);
}
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(
`Port ${PORT} is already in use. Another deck server may be running.\n` +
`Try: PORT=${PORT + 1} npm run dev`
);
process.exit(1);
}
console.error('Server error:', err);
process.exit(1);
});
server.listen(PORT, HOST, () => {
printBanner();
});
async function shutdown(signal) {
console.log(`\n[${signal}] shutting down...`);
await watcher.close();
for (const client of wss.clients) client.terminate();
wss.close();
server.close(() => process.exit(0));
// Force-exit if close hangs (stuck sockets).
setTimeout(() => process.exit(0), 2000).unref();
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

View file

@ -0,0 +1,219 @@
// Tested up to ~100 slides; memory usage scales linearly (~1.5MB/slide in V8 heap
// via pdf-lib's in-memory DOM). For decks >200 slides, consider streaming merge.
import puppeteer from 'puppeteer';
import { PDFDocument } from 'pdf-lib';
import { spawn } from 'child_process';
import { writeFileSync } from 'fs';
import { resolve } from 'path';
// Requires Node >=18 for global fetch and AbortController.
const PORT = parseInt(process.env.PORT || '7007', 10);
const BASE = `http://localhost:${PORT}`;
const USE_SYSTEM_CHROME = process.argv.includes('--chrome');
const OUT_PATH = resolve(process.cwd(), process.env.OUT || './deck.pdf');
let devServer = null;
let browser = null;
let serverBootError = null;
function log(msg) {
process.stdout.write(`${msg}\n`);
}
async function waitForHealth(timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// Fail fast if the child process has already errored or exited non-zero.
if (serverBootError) {
throw new Error(`Dev server failed to start: ${serverBootError.message}`);
}
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 500);
const res = await fetch(`${BASE}/health`, { signal: ctrl.signal });
clearTimeout(t);
if (res.ok) return;
} catch {
// Server not up yet — keep polling.
}
await new Promise((r) => setTimeout(r, 100));
}
if (serverBootError) {
throw new Error(`Dev server failed to start: ${serverBootError.message}`);
}
throw new Error(
`Dev server did not respond at ${BASE}/health within ${timeoutMs}ms. ` +
`Check that scripts/dev-server.mjs runs without errors.`
);
}
function startDevServer() {
devServer = spawn('node', ['scripts/dev-server.mjs'], {
cwd: process.cwd(),
// Capture stderr so boot failures (syntax errors, EADDRINUSE, etc.) surface
// immediately instead of hiding behind a cryptic 10s health-check timeout.
stdio: ['ignore', 'ignore', 'pipe'],
detached: false,
env: { ...process.env, PORT: String(PORT) },
});
devServer.stderr.on('data', (chunk) => {
process.stderr.write('[dev-server] ' + chunk);
});
devServer.on('error', (err) => {
serverBootError = err;
console.error('Failed to spawn dev server:', err.message);
});
devServer.on('exit', (code) => {
if (code !== 0 && code !== null) {
serverBootError = new Error('dev-server exited with code ' + code);
}
});
}
function killDevServer() {
if (devServer && !devServer.killed) {
try { devServer.kill('SIGTERM'); } catch { /* already gone */ }
}
}
async function launchBrowser() {
const opts = {
// Puppeteer 22+ defaults `true` to the new headless mode; 'new' is deprecated.
headless: true,
args: [
// safe: export mode navigates only to localhost; WS is stubbed via ?export=true;
// no attacker-controlled content reaches this browser.
'--no-sandbox',
'--disable-dev-shm-usage',
],
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
};
if (USE_SYSTEM_CHROME) opts.channel = 'chrome';
try {
return await puppeteer.launch(opts);
} catch (err) {
console.error('Puppeteer failed to launch:', err.message);
if (!USE_SYSTEM_CHROME) {
console.error(
'Hint: on Linux CI, missing sandbox libs are common. ' +
'Try installing dependencies or rerun with: npm run export:chrome'
);
} else {
console.error(
'Hint: --chrome requires Google Chrome specifically (not Chromium, Brave, or Edge) ' +
'installed at the default channel path. Either install Google Chrome, or omit --chrome ' +
'to use Puppeteer\'s bundled Chromium.'
);
}
throw err;
}
}
async function main() {
const startedAt = Date.now();
log('Starting dev server...');
startDevServer();
await waitForHealth();
log('Launching headless Chromium...');
browser = await launchBrowser();
const page = await browser.newPage();
await page.goto(`${BASE}/?export=true`, { waitUntil: 'networkidle0' });
await page.evaluateHandle('document.fonts.ready');
// networkidle0 doesn't wait for DOMContentLoaded listeners to finish wiring
// window.__deck. Poll until deck-stage.js has initialized before checking.
try {
await page.waitForFunction(
'typeof window.__deck?.total === "function"',
{ timeout: 10_000 }
);
} catch {
throw new Error(
'deck-stage.js did not initialize window.__deck within 10s — ' +
'check /assets/deck-stage.js is loaded, and Google Fonts are accessible.'
);
}
const hasDeck = await page.evaluate(() => typeof window.__deck?.total === 'function');
if (!hasDeck) {
throw new Error(
'deck.html is malformed — window.__deck is not defined. ' +
'Ensure assets/deck-stage.js is loaded and initialized.'
);
}
const total = await page.evaluate(() => window.__deck.total());
if (!Number.isInteger(total) || total <= 0) {
throw new Error(`window.__deck.total() returned ${total}; expected positive integer.`);
}
log(`Exporting ${total} slides at 1920x1080...`);
const combined = await PDFDocument.create();
// NOTE: Serial per-slide export is intentional — parallelization risks font-state
// race conditions between slides. Correctness > speed. Do not "optimize" into a
// worker pool unless you've verified font.ready determinism per page.
for (let i = 0; i < total; i++) {
log(`[${i + 1}/${total}] exporting slide ${i + 1}...`);
await page.evaluate((n) => window.__deck.goto(n), i);
// Per-slide font-ready await — weights can be fetched lazily on navigation.
await page.evaluateHandle('document.fonts.ready');
const buf = await page.pdf({
width: '1920px',
height: '1080px',
printBackground: true,
pageRanges: '1',
preferCSSPageSize: false,
});
try {
const slideDoc = await PDFDocument.load(buf);
const [copied] = await combined.copyPages(slideDoc, [0]);
combined.addPage(copied);
} catch (e) {
// Fail fast with a clear slide-index hint — a half-merged PDF is worse than none.
console.error(
'[export] Failed to merge slide ' + (i + 1) + '/' + total + ': ' + e.message
);
throw e;
}
}
const bytes = await combined.save();
writeFileSync(OUT_PATH, bytes);
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
log(`\n✓ Exported ${total} slides → ${OUT_PATH}`);
log(` Elapsed: ${elapsed}s`);
}
async function cleanup() {
if (browser) {
try { await browser.close(); } catch { /* already closed */ }
browser = null;
}
killDevServer();
}
process.on('exit', () => {
// Synchronous best-effort kill — async cleanup already ran in finally.
killDevServer();
});
process.on('SIGINT', async () => { await cleanup(); process.exit(130); });
process.on('SIGTERM', async () => { await cleanup(); process.exit(143); });
try {
await main();
await cleanup();
process.exit(0);
} catch (err) {
console.error(`\nExport failed: ${err.message}`);
await cleanup();
process.exit(1);
}

View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {{YEAR}} {{COPYRIGHT_HOLDER}}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,54 @@
# {{DECK_TITLE}}
Lerian-branded editorial presentation. Scaffolded with [`ring:deck`](https://github.com/LerianStudio/ring).
## Run
```bash
cd {{DECK_NAME}}
pnpm install # or: npm install
pnpm dev # boots http://localhost:7007
```
## Present
- **Main deck:** <http://localhost:7007>
- **Presenter view:** <http://localhost:7007/presenter> (open on second screen)
- **Remote:** `http://<your-LAN-IP>:7007/remote` (open on phone, same Wi-Fi)
### Keyboard controls (main deck)
| Key | Action |
| --- | --- |
| `→` or `Space` | Next slide |
| `←` | Previous slide |
| `F` | Toggle fullscreen |
| `S` | Toggle speaker notes overlay |
| `G` | Go to slide number |
| `B` | Blank screen |
| `Esc` | Exit fullscreen / close overlay |
### Phone remote
Find your machine's LAN IP (`ipconfig` on Windows, `ifconfig` on macOS/Linux), open `http://<ip>:7007/remote` on a phone connected to the same Wi-Fi. Buttons: previous, next, blank, go-to.
**Note:** local network only, no authentication. Do not expose port 7007 publicly.
## Export to PDF
```bash
pnpm export # uses bundled Chromium (~200MB first run)
pnpm export:chrome # uses system Chrome (~50MB, faster)
```
Output: `./deck.pdf` at 1920×1080 per page.
## Editing
- **Content:** edit `deck.html`. Save triggers live reload on all connected clients.
- **Speaker notes:** update the `<script type="application/json" id="speaker-notes">` block in `deck.html`. One entry per slide, indexed in `<section>` order.
- **Slides:** each `<section class="slide">` is one slide. Archetypes documented in `ring:deck`'s `references/slide-archetypes.md`.
## License
Apache License 2.0. See [LICENSE](./LICENSE).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
{
"name": "{{DECK_NAME}}",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"dev": "node scripts/dev-server.mjs",
"export": "node scripts/export-pdf.mjs",
"export:chrome": "node scripts/export-pdf.mjs --chrome"
},
"dependencies": {
"express": "^5.2.1",
"ws": "^8.20.0",
"chokidar": "^5.0.0",
"puppeteer": "^24.41.0",
"pdf-lib": "^1.17.1"
}
}

View file

@ -0,0 +1,144 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Presenter View</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=IBM+Plex+Serif:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root { color-scheme: dark; }
html, body {
margin: 0; height: 100vh;
background: #0A0B0C; color: #FFF;
font-family: 'IBM Plex Serif', Georgia, serif;
overflow: hidden;
}
.status-banner {
position: fixed; top: 0; left: 0; right: 0;
padding: 8px; text-align: center;
font-family: 'JetBrains Mono', monospace;
font-size: 12px; letter-spacing: 2px; text-transform: uppercase;
z-index: 10;
transition: transform 200ms;
transform: translateY(-100%);
}
.status-banner.visible { transform: translateY(0); }
.status-banner.error { background: #FF6760; color: #191A1B; }
.status-banner.ok { background: #50F769; color: #191A1B; }
.layout {
display: grid;
grid-template-columns: 60% 40%;
height: 100vh;
}
/* Left: current slide notes */
.notes-panel {
padding: 48px;
overflow-y: auto;
font-size: 32px;
line-height: 1.5;
font-weight: 500;
}
.notes-panel p { margin: 0 0 1em; }
.notes-panel .label {
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
font-size: 13px; letter-spacing: 2px;
color: #FEED02;
margin-bottom: 16px;
}
.notes-panel .label .dim { color: #8B877C; }
/* Right: current + next thumbnails + timer */
.right-panel {
display: grid;
grid-template-rows: 1fr 1fr auto;
gap: 16px;
padding: 24px;
border-left: 1px solid #222;
}
.thumb-wrap {
position: relative;
overflow: hidden;
border: 1px solid #222;
background: #000;
}
.thumb-wrap .thumb-label {
position: absolute; top: 12px; left: 12px;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
font-size: 11px; letter-spacing: 2px;
color: #FEED02;
background: rgba(10,11,12,0.8);
padding: 4px 8px;
z-index: 2;
}
.thumb-wrap iframe {
border: 0;
/* 1920 * 0.25 = 480 (fits typical presenter pane) — upscale via scale(0.25) */
width: 400%;
height: 400%;
transform: scale(0.25);
transform-origin: top left;
pointer-events: none;
background: #FFF;
}
.thumb-wrap.dimmed { opacity: 0.5; transition: opacity 200ms; }
.thumb-wrap.end .thumb-label { color: #8B877C; }
.timer {
font-family: 'JetBrains Mono', monospace;
font-size: 72px; font-weight: 500;
text-align: center;
padding: 24px;
letter-spacing: 4px;
border-top: 1px solid #222;
}
.timer .hint {
display: block;
margin-top: 10px;
font-size: 12px; letter-spacing: 2px;
color: #8B877C;
text-transform: uppercase;
}
</style>
</head>
<body>
<div class="status-banner" id="status-banner"></div>
<div class="layout">
<div class="notes-panel" id="notes-panel">
<div class="label">
Speaker Notes &middot;
Slide <span id="notes-index">1</span>
<span class="dim">/ <span id="notes-total">?</span></span>
</div>
<div id="notes-body">Loading notes from /deck.html &hellip;</div>
</div>
<div class="right-panel">
<div class="thumb-wrap" id="thumb-current-wrap">
<div class="thumb-label">Current</div>
<iframe id="thumb-current" src="/?export=true" title="Current slide"></iframe>
</div>
<div class="thumb-wrap" id="thumb-next-wrap">
<div class="thumb-label">Next</div>
<iframe id="thumb-next" src="/?export=true" title="Next slide"></iframe>
</div>
<div class="timer">
<span id="timer">00:00:00</span>
<span class="hint">Press R to reset</span>
</div>
</div>
</div>
<script src="/assets/sync-client.js"></script>
<script src="/assets/presenter-view.js"></script>
</body>
</html>

View file

@ -0,0 +1,175 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Deck Remote</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root { color-scheme: dark; }
html, body {
margin: 0; min-height: 100vh;
background: #0A0B0C; color: #FFF;
font-family: 'Poppins', -apple-system, sans-serif;
touch-action: manipulation;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
}
.app {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100dvh;
padding:
env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left);
box-sizing: border-box;
}
header {
padding: 16px 20px;
border-bottom: 1px solid #222;
display: flex;
justify-content: space-between;
align-items: center;
}
header .title {
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
font-size: 12px; letter-spacing: 2px;
color: #8B877C;
}
header .indicator {
font-family: 'JetBrains Mono', monospace;
font-size: 14px; font-weight: 500;
}
header .indicator .current {
color: #FEED02;
font-size: 18px;
}
main {
display: grid;
grid-template-rows: 1fr 1fr;
gap: 16px;
padding: 16px;
}
button {
font-family: 'Poppins', sans-serif;
border: 1px solid #222;
border-radius: 4px;
font-weight: 600;
font-size: 20px;
color: #FFF;
background: #191A1B;
cursor: pointer;
transition: transform 80ms, background 120ms;
min-height: 96px;
-webkit-appearance: none;
appearance: none;
}
button:active { transform: scale(0.97); background: #222; }
button:focus-visible { outline: 2px solid #FEED02; outline-offset: 2px; }
button.primary {
background: #FEED02;
color: #191A1B;
border-color: #FEED02;
}
button.primary:active { background: #FFE000; }
button.danger.active {
background: #FF6760;
color: #191A1B;
border-color: #FF6760;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
footer {
padding: 12px 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
border-top: 1px solid #222;
}
footer button {
min-height: 56px;
font-size: 16px;
}
.status-banner {
position: fixed;
top: 0; left: 0; right: 0;
padding: 12px;
text-align: center;
font-family: 'JetBrains Mono', monospace;
font-size: 12px; letter-spacing: 2px; text-transform: uppercase;
z-index: 10;
transition: transform 200ms;
transform: translateY(-100%);
}
.status-banner.visible { transform: translateY(calc(env(safe-area-inset-top) + 0px)); }
.status-banner.error { background: #FF6760; color: #191A1B; }
.status-banner.ok { background: #50F769; color: #191A1B; }
button[disabled] { opacity: 0.4; cursor: not-allowed; }
#wakelock-status {
display: block;
margin-top: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
color: #8B877C;
min-height: 12px;
}
</style>
</head>
<body>
<div class="status-banner" id="status-banner"></div>
<div class="app">
<header>
<div class="title">
Lerian Deck Remote
<span id="wakelock-status" aria-live="polite"></span>
</div>
<div class="indicator">
<span class="current" id="current-slide">&mdash;</span>
/ <span id="total-slides">&mdash;</span>
</div>
</header>
<main>
<div class="row">
<button type="button" id="btn-prev" aria-label="Previous slide">&larr; Prev</button>
<button type="button" id="btn-next" class="primary" aria-label="Next slide">Next &rarr;</button>
</div>
<div class="row">
<button type="button" id="btn-blank" class="danger" aria-label="Blank screen">Blank</button>
<button type="button" id="btn-goto" aria-label="Go to slide number">Go to&hellip;</button>
</div>
</main>
<footer>
<button type="button" id="btn-first" aria-label="Go to first slide">&#8676; First</button>
<button type="button" id="btn-last" aria-label="Go to last slide">Last &#8677;</button>
</footer>
</div>
<script src="/assets/sync-client.js"></script>
<script src="/assets/remote-control.js"></script>
</body>
</html>

View file

@ -0,0 +1,55 @@
<!-- ARCHETYPE: act-divider -->
<!-- Use for: Transition between major sections. Amarelo accent background, giant act title, -->
<!-- pill list of the slides coming up in this act. Meta.right shows act-of-acts (not deck-of-deck). -->
<!-- Used sparingly: one per act. Count on one hand per deck. -->
<section data-label="Act 01 &mdash; Traction" class="slide accent">
<div class="meta">
<div class="left"><span class="wordmark">lerian<span class="dot"></span></span></div>
<div class="right"><span>01 / 05</span></div>
</div>
<div style="flex: 1; display: flex; flex-direction: column; justify-content: center;">
<div style="display: flex; align-items: baseline; gap: 18px; margin-bottom: 28px;">
<div style="font-family: 'JetBrains Mono', monospace; font-size: 13px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--c-ink);">Act 01</div>
<div style="flex: 1; height: 1px; background: var(--c-ink); opacity: 0.25;"></div>
<div style="font-family: 'JetBrains Mono', monospace; font-size: 13px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--c-ink); opacity: 0.55;">~20 min &middot; 5 slides</div>
</div>
<div style="font-family: 'Poppins', sans-serif; font-size: 180px; font-weight: 500; line-height: 0.95; letter-spacing: -0.04em; color: var(--c-ink);">Traction &amp;<br/>Product.</div>
<p class="lede" style="margin-top: 40px; max-width: 1400px; color: rgba(25,26,27,0.78);">
Where the portfolio stands, what ships, how clients deploy. Establishes the "we have product" baseline for the rest of the meeting.
</p>
<!-- Act-divider pill list uses canonical .pill.solid + inline number chip. -->
<!-- Override default .pill padding/typography to embed the JetBrains Mono number pill on the left. -->
<div style="margin-top: 56px; display: flex; gap: 14px; flex-wrap: wrap;">
<div class="pill solid" style="padding: 10px 18px 10px 12px; font-family: 'Poppins', sans-serif; font-size: 17px; text-transform: none; letter-spacing: normal;">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; background: var(--c-ink-inv); color: var(--c-ink); padding: 3px 7px; border-radius: 999px; letter-spacing: 0.08em; font-weight: 600;">03</span>
Portfolio
</div>
<div class="pill solid" style="padding: 10px 18px 10px 12px; font-family: 'Poppins', sans-serif; font-size: 17px; text-transform: none; letter-spacing: normal;">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; background: var(--c-ink-inv); color: var(--c-ink); padding: 3px 7px; border-radius: 999px; letter-spacing: 0.08em; font-weight: 600;">04</span>
Pricing
</div>
<div class="pill solid" style="padding: 10px 18px 10px 12px; font-family: 'Poppins', sans-serif; font-size: 17px; text-transform: none; letter-spacing: normal;">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; background: var(--c-ink-inv); color: var(--c-ink); padding: 3px 7px; border-radius: 999px; letter-spacing: 0.08em; font-weight: 600;">05</span>
Clients
</div>
<div class="pill solid" style="padding: 10px 18px 10px 12px; font-family: 'Poppins', sans-serif; font-size: 17px; text-transform: none; letter-spacing: normal;">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; background: var(--c-ink-inv); color: var(--c-ink); padding: 3px 7px; border-radius: 999px; letter-spacing: 0.08em; font-weight: 600;">06</span>
Pipeline
</div>
<div class="pill solid" style="padding: 10px 18px 10px 12px; font-family: 'Poppins', sans-serif; font-size: 17px; text-transform: none; letter-spacing: normal;">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; background: var(--c-ink-inv); color: var(--c-ink); padding: 3px 7px; border-radius: 999px; letter-spacing: 0.08em; font-weight: 600;">07</span>
Competitive
</div>
</div>
</div>
<div class="footer">
<div>Confidential &mdash; Lerian Board</div>
<div>April 22, 2026</div>
</div>
</section>
<!-- END ARCHETYPE: act-divider -->

View file

@ -0,0 +1,68 @@
<!-- ARCHETYPE: agenda -->
<!-- Use for: Second slide. Act x Theme x Time x Format table. -->
<!-- Once per deck. Highlight the "debate" row (.hl) to show where decisions happen. -->
<section data-label="Agenda" class="slide">
<div class="meta">
<div class="left"><span class="wordmark">lerian<span class="dot"></span></span></div>
<div class="right">
<span>Board Meeting &#8470; 01</span>
<span class="num"><span class="page-num"></span> / <span class="page-total"></span></span>
</div>
</div>
<div class="body">
<div class="eyebrow">Agenda</div>
<h1 style="margin-top: 28px; max-width: 1500px;">
Ninety minutes. <span style="color: var(--c-ink-3);">Half of it on decisions, not reports.</span>
</h1>
<table class="grid" style="margin-top: 72px;">
<thead>
<tr>
<th style="width: 100px;">Act</th>
<th>Theme</th>
<th style="width: 160px; text-align: right;">Time</th>
<th style="width: 220px; text-align: right;">Format</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">01</td>
<td style="font-size: 28px; font-family: 'Poppins'; color: var(--c-ink);">Traction, Product &amp; Competitive Positioning</td>
<td class="num" style="text-align: right;">20 min</td>
<td style="text-align: right; color: var(--c-ink-3);">Report</td>
</tr>
<tr>
<td class="num">02</td>
<td style="font-size: 28px; font-family: 'Poppins'; color: var(--c-ink);">Financials</td>
<td class="num" style="text-align: right;">10 min</td>
<td style="text-align: right; color: var(--c-ink-3);">Report</td>
</tr>
<tr>
<td class="num">03</td>
<td style="font-size: 28px; font-family: 'Poppins'; color: var(--c-ink);">Go-to-Market</td>
<td class="num" style="text-align: right;">10 min</td>
<td style="text-align: right; color: var(--c-ink-3);">Report</td>
</tr>
<tr class="hl">
<td class="num">04</td>
<td style="font-size: 28px; font-family: 'Poppins';">Four Strategic Discussions <span style="opacity: 0.62; font-weight: 400;">&mdash; the core of this board</span></td>
<td class="num" style="text-align: right;">45 min</td>
<td style="text-align: right; font-weight: 600;">Debate</td>
</tr>
<tr>
<td class="num">05</td>
<td style="font-size: 28px; font-family: 'Poppins'; color: var(--c-ink);">Capital Strategy</td>
<td class="num" style="text-align: right;">5 min</td>
<td style="text-align: right; color: var(--c-ink-3);">Open question</td>
</tr>
</tbody>
</table>
</div>
<div class="footer">
<div>Confidential &mdash; Lerian Board</div>
<div>April 22, 2026</div>
</div>
</section>
<!-- END ARCHETYPE: agenda -->

View file

@ -0,0 +1,63 @@
<!-- ARCHETYPE: appendix-content -->
<!-- Use for: Appendix body slides. Same grid discipline as slide-content.html, but: -->
<!-- meta.right reads "A1 / 8" (letter-indexed, not integer) and meta.left prefixes "Appendix A1". -->
<!-- Lettered pagination is STATIC; runtime does NOT overwrite this one. -->
<!-- Typical content: 3-up card grid of dense summaries. -->
<section data-label="A1 Side Letters" class="slide">
<div class="meta">
<div class="left">
<span class="wordmark">lerian<span class="dot"></span></span>
<span>&middot; Appendix A1</span>
</div>
<div class="right"><span>A1 / 8</span></div>
</div>
<div class="body">
<div class="eyebrow">Appendix A1</div>
<h1 style="margin-top: 28px; max-width: 1600px;">Side letters &mdash; detailed terms.</h1>
<!-- 3-card grid. flex:1 + min-height:0 + align-items:stretch = equal-height cards sized by content. -->
<div style="flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; margin-top: 64px; align-items: stretch;">
<div class="appendix-card">
<div class="eyebrow" style="margin-bottom: 18px;">Maya Capital</div>
<div class="appendix-card-title" style="font-size: 32px;">Board seat + Observer + Major Investor rights locked.</div>
</div>
<div class="appendix-card">
<div class="eyebrow" style="margin-bottom: 18px;">Norte Ventures</div>
<div class="appendix-card-title" style="font-size: 28px;">Put option at $1.00 &middot; ROFO &middot; Co-sale rights.</div>
</div>
<div class="appendix-card">
<div class="eyebrow" style="margin-bottom: 18px;">BTG</div>
<div class="appendix-card-title" style="font-size: 24px;">Put vs Company &amp; founders &middot; Credit line match &gt; $200K &middot; Anti-dilution &middot; Tag-along &middot; ICC S&atilde;o Paulo arbitration.</div>
</div>
</div>
</div>
<div class="footer">
<div>Confidential &mdash; Lerian Board</div>
<div>April 22, 2026</div>
</div>
<!-- Archetype-local classes: prefix with archetype name to avoid collision -->
<style>
/* Appendix-card: paper-background summary block. Content drives height. */
.appendix-card {
background: var(--c-bg-2);
padding: 40px 36px;
border-radius: 4px;
display: flex;
flex-direction: column;
}
.appendix-card-title {
font-family: 'Poppins', sans-serif;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--c-ink);
}
</style>
</section>
<!-- END ARCHETYPE: appendix-content -->

View file

@ -0,0 +1,28 @@
<!-- ARCHETYPE: appendix-intro -->
<!-- Use for: The "Context, on demand" transition between the main deck and the appendix. -->
<!-- Amarelo background like an act divider, but the meta.right reads "Appendix" not "NN / NN". -->
<!-- Once per deck. Signals: what follows is reference material, not presentation flow. -->
<section data-label="Appendix" class="slide accent">
<div class="meta">
<div class="left"><span class="wordmark">lerian<span class="dot"></span></span></div>
<div class="right"><span>Appendix</span></div>
</div>
<div style="flex: 1; display: flex; flex-direction: column; justify-content: center;">
<div class="eyebrow" style="margin-bottom: 32px;">Appendix &mdash; A1 through A8</div>
<div style="font-family: 'Poppins', sans-serif; font-size: 180px; font-weight: 500; line-height: 0.95; letter-spacing: -0.04em;">
Context,<br/>on demand.
</div>
<p class="lede" style="margin-top: 40px; max-width: 1400px; color: rgba(25,26,27,0.75);">
Not presented &mdash; available for questions. Eight sections: side letters, ESOP, pipeline detail, security, roadmap, org, risks, governance.
</p>
</div>
<div class="footer">
<div>Confidential &mdash; Lerian Board</div>
<div>April 22, 2026</div>
</div>
</section>
<!-- END ARCHETYPE: appendix-intro -->

View file

@ -0,0 +1,47 @@
<!-- ARCHETYPE: content-accent -->
<!-- Use for: Punctuation slides. Amarelo background, black text. Full-slide single statement. -->
<!-- Used sparingly. The reference uses .slide.accent almost exclusively for act dividers; -->
<!-- this archetype extends the pattern to a full content slide when the moment deserves it. -->
<section data-label="Appendix Transition" class="slide accent">
<div class="meta">
<div class="left"><span class="wordmark">lerian<span class="dot"></span></span></div>
<div class="right"><span class="num"><span class="page-num"></span> / <span class="page-total"></span></span></div>
</div>
<div class="body">
<div class="eyebrow">Strategic Moment</div>
<!-- Hero statement filling the canvas. flex:1 on the wrapper expands into .body. -->
<div style="flex: 1; display: flex; flex-direction: column; justify-content: center;">
<div style="font-family: 'Poppins', sans-serif; font-size: 150px; font-weight: 500; line-height: 0.95; letter-spacing: -0.04em; color: var(--c-accent-ink);">
Billing Day One.<br/>Reported revenue = real cash.
</div>
<p class="lede" style="margin-top: 48px; max-width: 1400px; color: rgba(25,26,27,0.78);">
No grace period. No deferred recognition. Every client we sign starts paying the month the contract executes.
</p>
</div>
<!-- Three supporting proof points in a row. Content-sized, no forced heights. -->
<!-- Uses canonical .kpi.accent modifier — defined in deck.html primitives. -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; margin-top: 40px; padding-top: 32px; border-top: 1px solid rgba(25,26,27,0.18); flex-shrink: 0;">
<div class="kpi accent">
<div class="value">100%</div>
<div class="cap">of contracts billed Day One</div>
</div>
<div class="kpi accent">
<div class="value">0 days</div>
<div class="cap">Grace period &middot; deferred revenue</div>
</div>
<div class="kpi accent">
<div class="value">20&ndash;32 mo</div>
<div class="cap">Runway &middot; gross to net-of-revenue</div>
</div>
</div>
</div>
<div class="footer">
<div>Confidential &mdash; Lerian Board</div>
<div>April 22, 2026</div>
</div>
</section>
<!-- END ARCHETYPE: content-accent -->

View file

@ -0,0 +1,53 @@
<!-- ARCHETYPE: content-dark -->
<!-- Use for: Statement slides + KPI walls. Near-black panel, Amarelo eyebrow, inverse text. -->
<!-- Reserved for high-impact moments: financial KPIs, capital strategy, single big question. -->
<section data-label="Capital" class="slide dark">
<div class="meta">
<div class="left">
<span class="wordmark">lerian<span class="dot"></span></span>
<span>&middot; Act 05 &mdash; Capital Strategy</span>
</div>
<div class="right"><span class="num"><span class="page-num"></span> / <span class="page-total"></span></span></div>
</div>
<div class="body">
<div class="eyebrow">Capital Strategy &middot; Open Question</div>
<!-- Hero statement: 110px Poppins. Content drives height; flex:1 fills canvas. -->
<div style="flex: 1; display: flex; flex-direction: column; justify-content: center; max-width: 1700px;">
<div style="font-family: 'Poppins', sans-serif; font-size: 110px; font-weight: 500; line-height: 1.0; letter-spacing: -0.03em; color: var(--c-ink-inv);">
When does the next round make sense<span style="color: var(--c-accent);">&mdash;</span>given the current pipeline and the Cloud thesis?
</div>
<p class="lede" style="margin-top: 48px; color: rgba(255,255,255,0.7);">
Advisory tone. Not an ask. We are not raising today &mdash; we're asking for your perspective on timing.
</p>
</div>
<!-- Bottom KPI strip: 4 metrics, last one accented. flex-shrink:0 so it pins to bottom. -->
<!-- Uses canonical .kpi.dark modifier — defined in deck.html primitives. -->
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px; margin-top: 40px; padding-top: 32px; border-top: 1px solid rgba(255,255,255,0.15); flex-shrink: 0;">
<div>
<div class="eyebrow" style="color: rgba(255,255,255,0.55);">Stage</div>
<div class="kpi dark">Post-Seed</div>
</div>
<div>
<div class="eyebrow" style="color: rgba(255,255,255,0.55);">Runway</div>
<div class="kpi dark">20 &ndash; 32 mo</div>
</div>
<div>
<div class="eyebrow" style="color: rgba(255,255,255,0.55);">Pipeline</div>
<div class="kpi dark">R$ 5.9M active</div>
</div>
<div>
<div class="eyebrow" style="color: var(--c-accent);">Cloud maturity</div>
<div class="kpi dark">Inflection</div>
</div>
</div>
</div>
<div class="footer">
<div>Confidential &mdash; Lerian Board</div>
<div>April 22, 2026</div>
</div>
</section>
<!-- END ARCHETYPE: content-dark -->

View file

@ -0,0 +1,73 @@
<!-- ARCHETYPE: content-paper -->
<!-- Use for: Discussion / reflection slides. Paper background (#F2F2F2) + inline black -->
<!-- question card for visual pacing. Identical grid mechanics to slide-content.html. -->
<!-- In the reference this carries the 4 strategic debates in Act 04. -->
<section data-label="Discussion 01 &mdash; Cloud" class="slide paper">
<div class="meta">
<div class="left">
<span class="wordmark">lerian<span class="dot"></span></span>
<span>&middot; Act 04 &mdash; Strategic Discussion 01/04</span>
</div>
<div class="right"><span class="num"><span class="page-num"></span> / <span class="page-total"></span></span></div>
</div>
<div class="body">
<!-- Eyebrow + timing rule -->
<div style="display: flex; align-items: center; gap: 18px;">
<div class="eyebrow">Discussion 01</div>
<div style="height: 1px; flex: 1; background: var(--c-rule);"></div>
<div class="eyebrow">~12 min</div>
</div>
<h1 style="margin-top: 28px; max-width: 1700px;">Lerian Cloud &mdash; "Vercel for financial services."</h1>
<div style="display: grid; grid-template-columns: 1fr 1.15fr; gap: 56px; margin-top: 56px; flex: 1; min-height: 0;">
<!-- Context column -->
<div style="display: flex; flex-direction: column; padding-top: 4px;">
<div class="eyebrow" style="margin-bottom: 24px; color: var(--c-ink);">Context</div>
<ul class="ticks" style="flex: 1;">
<li><strong>70% of new logos</strong> enter directly through SaaS (vs. 100% BYOC 12 months ago).</li>
<li>Tenant-manager live since Q1/26. Clients deploy Lerian products <strong>and their own apps</strong> on Lerian infra &mdash; stickiness beyond traditional SaaS.</li>
<li><strong>No competitor offers this</strong> capability today.</li>
<li>BYOC clients already requesting migration to managed tiers &mdash; organic upsell motion.</li>
</ul>
</div>
<!-- Questions card (dark on paper) — uses canonical ul.numbered.questions -->
<div style="background: var(--c-ink); color: var(--c-ink-inv); padding: 40px 44px; border-radius: 4px; display: flex; flex-direction: column; gap: 24px; justify-content: space-between;">
<div class="eyebrow" style="color: var(--c-accent);">Questions for the board</div>
<ul class="numbered questions">
<li>
<span class="n">01</span>
<div>
<div class="t">Target profile &amp; reference case</div>
<div class="b">Enterprise gravitates hybrid, mid-market accepts full SaaS. Which segment builds the reference case for the other?</div>
</div>
</li>
<li>
<span class="n">02</span>
<div>
<div class="t">GTM timing</div>
<div class="b">70% already choose SaaS. BYOC migrating up. Is this the signal to go-to-market with Cloud now &mdash; or do we need a larger proof base?</div>
</div>
</li>
<li>
<span class="n">03</span>
<div>
<div class="t">Pricing strategy</div>
<div class="b">Clients running their apps on Lerian infra creates platform lock-in. How do we price platform vs. product without cannibalizing either during transition?</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="footer">
<div>Confidential &mdash; Lerian Board</div>
<div>April 22, 2026</div>
</div>
</section>
<!-- END ARCHETYPE: content-paper -->

View file

@ -0,0 +1,105 @@
<!-- ARCHETYPE: content (default / white) -->
<!-- Use for: The workhorse. White background, eyebrow + h1 + lede, then a 2- or 3-column grid. -->
<!-- Vast majority of deck body. Swap in KPIs, lists, tables as the content demands. -->
<!-- HARD GATE: .body uses flex:1, grid uses flex:1 and min-height:0 so it fills the canvas. -->
<section data-label="Portfolio" class="slide">
<div class="meta">
<div class="left">
<span class="wordmark">lerian<span class="dot"></span></span>
<span>&middot; Act 01 &mdash; Traction &amp; Product</span>
</div>
<div class="right"><span class="num"><span class="page-num"></span> / <span class="page-total"></span></span></div>
</div>
<div class="body">
<div class="eyebrow">Portfolio &mdash; Where We Stand</div>
<h1 style="margin-top: 28px; max-width: 1600px;">Complete portfolio for core banking in Brazil.</h1>
<p class="lede" style="margin-top: 24px; max-width: 1400px;">Eliminates the "you don't have product X" barrier in enterprise sales.</p>
<!-- 3-column portfolio matrix. flex:1 ensures the grid fills the remaining canvas. -->
<div style="flex: 1; min-height: 0; margin-top: 56px; display: flex; flex-direction: column;">
<div style="flex: 1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; border-top: 1px solid var(--c-ink); border-bottom: 1px solid var(--c-rule); align-items: stretch;">
<!-- Column 1: Core Products -->
<div style="padding: 22px 28px 22px 0; border-right: 1px solid var(--c-rule); display: flex; flex-direction: column;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 22px;">
<div style="font-family: 'Poppins', sans-serif; font-size: 72px; font-weight: 500; line-height: 1; letter-spacing: -0.03em;">6<span style="color: var(--c-accent-2);">.</span></div>
<div>
<div style="font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--c-accent-2);">&#9679; Core products</div>
<div style="font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--c-ink-3); margin-top: 2px;">Live in production</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 11px; font-size: 22px;">
<div><strong>Midaz</strong> <span class="prod-tag">Ledger</span></div>
<div><strong>Matcher</strong> <span class="prod-tag">Reconciliation</span></div>
<div><strong>Tracer</strong> <span class="prod-tag">Risk</span></div>
<div><strong>Flowker</strong> <span class="prod-tag">Orchestration</span></div>
<div><strong>Fetcher</strong> <span class="prod-tag">Ingestion</span></div>
<div><strong>Reporter</strong> <span class="prod-tag">Regulatory</span></div>
</div>
</div>
<!-- Column 2: Plugins -->
<div style="padding: 22px 28px; border-right: 1px solid var(--c-rule); display: flex; flex-direction: column;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 22px;">
<div style="font-family: 'Poppins', sans-serif; font-size: 72px; font-weight: 500; line-height: 1; letter-spacing: -0.03em;">6<span style="color: var(--c-accent-2);">.</span></div>
<div>
<div style="font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--c-accent-2);">&#9679; Plugins</div>
<div style="font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--c-ink-3); margin-top: 2px;">Shipping today</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 11px; font-size: 22px;">
<div><strong>Pix Direto</strong> <span class="prod-tag">Lerian &middot; JD</span></div>
<div><strong>Pix Indireto</strong> <span class="prod-tag">via BTG</span></div>
<div><strong>TED Direto</strong> <span class="prod-tag">Lerian &middot; JD</span></div>
<div><strong>CRM</strong></div>
<div><strong>Fees</strong></div>
<div><strong>Identity &amp; Auth</strong></div>
</div>
</div>
<!-- Column 3: Pipeline -->
<div style="padding: 22px 0 22px 28px; display: flex; flex-direction: column;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 22px;">
<div style="font-family: 'Poppins', sans-serif; font-size: 72px; font-weight: 500; line-height: 1; letter-spacing: -0.03em; color: var(--c-ink-2);">9<span style="color: var(--c-ink-3);">.</span></div>
<div>
<div style="font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--c-ink-3);">&#9675; In pipeline</div>
<div style="font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--c-ink-3); margin-top: 2px;">Regulated BR stack</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; font-size: 22px; color: var(--c-ink-2);">
<div><strong style="color: var(--c-ink-2); font-weight: 600;">Credit</strong> <span class="prod-tag">Decisioning, via 3rd parties</span></div>
<div><strong style="color: var(--c-ink-2); font-weight: 600;">Payments</strong> <span class="prod-tag">Rails</span></div>
<div><strong style="color: var(--c-ink-2); font-weight: 600;">BC Correios</strong> <span class="prod-tag">BACEN</span></div>
<div><strong style="color: var(--c-ink-2); font-weight: 600;">SIMBA</strong> <span class="prod-tag">COAF</span></div>
<div><strong style="color: var(--c-ink-2); font-weight: 600;">BacenJud Direto</strong> <span class="prod-tag">Judicial</span></div>
</div>
</div>
</div>
<!-- Caption strip -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 18px; font-family: 'JetBrains Mono', monospace; font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--c-ink-3);">
<div>21 building blocks &middot; one platform</div>
<div>Core banking coverage <span style="color: var(--c-accent-2);">&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;&#9679;</span></div>
</div>
</div>
</div>
<div class="footer">
<div>Confidential &mdash; Lerian Board</div>
<div>April 22, 2026</div>
</div>
<!-- Archetype-local classes: prefix with archetype name to avoid collision -->
<style>
/* Inline product-category tag used inside list items. JetBrains Mono micro-label. */
.prod-tag {
color: var(--c-ink-3);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
</style>
</section>
<!-- END ARCHETYPE: content -->

View file

@ -0,0 +1,46 @@
<!-- ARCHETYPE: cover -->
<!-- Use for: Deck opening. Wordmark, meeting context, title, directors/observers roster. -->
<!-- Once per deck. MUST reference /assets/lerian-wordmark.svg (not .png). -->
<!-- Asset pattern: archetype uses <img src="/assets/..."> (absolute path, cacheable, simple). -->
<!-- deck.html's cover slide inlines the SVG instead — both patterns are acceptable; pick one per deck. -->
<section data-label="Cover" class="slide" style="padding: 64px 100px 40px;">
<div class="meta">
<div class="left">
<span class="dot"></span>
<span>Confidential</span>
</div>
<div class="right"><span>April 22, 2026</span></div>
</div>
<div style="flex: 1; display: flex; flex-direction: column; justify-content: center; padding-top: 60px;">
<div class="eyebrow" style="margin-bottom: 40px;">Board Meeting &#8470; 01 &mdash; Session Materials</div>
<div style="display: flex; align-items: flex-end; gap: 56px;">
<img src="/assets/lerian-wordmark.svg" alt="Lerian" style="height: 220px; width: auto; display: block;" />
</div>
<div style="font-family: 'Poppins', sans-serif; font-weight: 500; font-size: 72px; line-height: 1.02; letter-spacing: -0.03em; margin-top: 56px; max-width: 1500px;">
Where we are, where we are going, and the four decisions we need your pushback on.
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 40px; padding-top: 40px; border-top: 1px solid var(--c-rule);">
<div>
<div class="eyebrow" style="margin-bottom: 14px;">Directors</div>
<div style="font-size: 26px; color: var(--c-ink); line-height: 1.4;">
Fred Amaral<span style="color: var(--c-ink-3);"> &middot; CEO</span>&nbsp;&nbsp;
Monica Saggioro<span style="color: var(--c-ink-3);"> &middot; Maya Capital</span>&nbsp;&nbsp;
<span style="color: var(--c-ink-3);">3rd Director TBD</span>
</div>
</div>
<div>
<div class="eyebrow" style="margin-bottom: 14px;">Observers</div>
<div style="font-size: 26px; color: var(--c-ink); line-height: 1.4;">
Kevin Efrusy<span style="color: var(--c-ink-3);"> &middot; Ordinary</span>&nbsp;&nbsp;
Diego<span style="color: var(--c-ink-3);"> &middot; Supera</span>&nbsp;&nbsp;
Pedro Teo<span style="color: var(--c-ink-3);"> &middot; Norte</span>
</div>
</div>
</div>
</section>
<!-- END ARCHETYPE: cover -->

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff