decklet — agent authoring skill
You produce a model (JSON). The toolchain produces a deck (one .html file) that a human can drag, retype, present and print. Your job is to get the model right; the validator and verifier tell you when you haven't.
content + format + style → slide plan → model.json → validate → create → verify → hand-off
All commands run from the repo root with plain Node ≥ 22. Installed as a Claude Code plugin, the repo root is ${CLAUDE_PLUGIN_ROOT}; installed with npx skills add, it is the skill's own directory. Only verify (and import-html) need the optional playwright devDependency.
INPUTS
1. Content — anything
Outline, markdown, meeting notes, a transcript, a spreadsheet, a brief. You distil it; nothing is pasted verbatim. One idea per slide. Numbers become Stat rows, lists become 2–4 short Body rows or tiles, sequences become boxes with arrows, comparisons become two columns.
2. Format — one of
| format |
canvas (model px) |
print page |
status |
slides |
960×540 (or 1600×900 via --space 1600x900) |
Letter, slide zoomed to page width |
supported |
carousel |
1080×1080 (1:1) |
Letter |
experimental |
carousel-4x5 |
1080×1350 (4:5) |
Letter |
experimental |
document-letter |
816×1056 (8.5×11in) |
Letter, zoom 1 |
experimental |
document-a4 |
794×1123 (210×297mm) |
A4, zoom 1 |
experimental |
Experimental means: canvas sizing, editing, contact sheet and PDF all work; text does not flow across pages — every page is a slide you lay out yourself, and a document longer than its page is your problem to split. Treat a document as N fixed pages.
3. Style — a style guide, an inferred brand, or the neutral fallback
style.json = {tokens, roles, pad} (STYLE CONTRACT below). Obtain it in this order:
- Style guide / brand file given → map its palette to
tokens, its type scale to the eight roles. Fonts must be installed on the viewer's machine or be system stacks — the deck loads no webfonts. Put the brand font first, a system fallback after.
- URL or screenshots given → infer: background, ink, muted ink, one accent, a card surface, a hairline. Headline family (serif/sans/mono), body family. Build
tokens + roles from that. Say in the hand-off what you inferred.
- Nothing given → omit
--style; the template's neutral dark scale is used.
Sizes in roles are in model pixels for the chosen canvas. Rough scale factors: 960-wide ×1, 1600-wide ×1.67, 1080 carousel ×1.9 (viewed small), 816 document ×0.75.
PROCESS
Step 1 — slide plan (write it down before any JSON)
For each slide: name · layout · supertitle · title · body elements (kind + count). Cap: ~60 words of Body per 16:9 slide, 3–4 tiles per row, 5 bars per chart, 4 boxes per flow. A content-slide title is one line at H1; a cover or closing headline uses Title (the display size), two lines at most.
Step 2 — model rows
Discipline, in order of importance:
- Role discipline. Every text row has a
role (or a slot whose layout slot has one). A row never sets font, size, lh, ls or mono — the validator rejects it. Rows may set weight, color, tt, italic, align.
- Slot discipline. Supertitle and title geometry lives in
layouts.<name>; the slide row is {slot:'title', text:'…'} with no x/y/w. Define one layout per slide family (title, content; add section, two-col as needed).
- Master discipline. Anything that appears on every slide (footer, rule, mark) is a
master row, once — chrome is deck-wide and never varies per layout. Exactly one master row has footer:1; the engine renders the page counter inside it, with its right edge on styles.margin. Never type 3 / 9 into a row.
- Text-fit. A label that must stay on one line gets
nowrap:1 and enough w (≈ cw × size × chars — the role's measured glyph width, 0.46 for the neutral sans, 0.69 for the mono Label), or w:'auto' to hug. Chips/pills: w:'auto' + p:'chip' (+ bg/bd/radius); one that sits on a right edge takes right: instead of x. Body copy gets a w that yields ≤ 3 lines at the role's size.
- Charts are rows. A bar or line chart is one
chart row (CHART ROW below) that create expands into bars, lines, dots and Label rows with the drawing rules applied — write the data, not the geometry. By hand, the same shape: bars {x,y,w,h,bg,bar:1} bottom-aligned on a baseline line; value labels as Label rows above, axis labels below. Donut: {x,y,w,donut:72} + a Stat row centred on it. Tiles: {x,y,w,h,tile:1,role:'Stat',text} + a Label row beneath.
- Cards are groups. There is no container row: a card is a tile plus its rows sharing one
group — {x,y,w,h,bg,bd,radius,group:'card1'} and each text row inside it with group:'card1', every row at its own canvas x/y. The human drags the card and the rows come along; you still position each row, once.
- Colour. Use
var(--accent), var(--fg), var(--muted), var(--line), var(--card) so a style swap re-themes the deck; literal hex only for chart series.
Step 3 — validate (no browser)
node bin/validate.mjs model.json --style style.json # 0 errors required; read every warning
node bin/validate.mjs model.json --style style.json --strict # warnings fail too — use before hand-off
Always pass the same --style you will pass to create. Text fit is only meaningful against the scale the deck will actually wear: without it the model is measured against the template's neutral roles, so validate can report 0 warnings on a model create --style then floods with overflow — and verify fails on. Omit --style only when there is none.
Step 4 — create
node bin/create.mjs --model model.json [--style style.json] --out deck.html --format slides [--space 1600x900] [--title "…"] [--from prev.html]
Refuses an invalid model (--force to override while iterating). Stamps deck.id (born once, from the first model — the browser's storage namespace, stable across versions), deck.rev (this build's content hash), a slide.id on every slide and a row id on every row that has none — deterministic (s1, s2… / r1, r2…), so the same model builds byte-identically.
Revising a deck a human has touched — --from is mandatory. The deck file carries the human's edits (/*LOG*/) and its version history (/*VERSIONS*/). Run, in this order:
node bin/edits.mjs deck.html # 1. READ what the human changed — before you touch the model
node bin/create.mjs --model model.json --out deck.html --from deck.html # 2. write the new version LAST
node bin/verify.mjs deck.html # 3. verify the result
--from inherits the deck id and the slide/row ids (by content, then by position — carry ids in model.json to make it exact), replays every logged edit onto the new model (human wins; a key you also changed is reported as a conflict and the human's value stays — put yours in the log's conflict if it matters), and pushes the file's previous state into the version history, so nothing the human did can be lost by a rebuild. The diff-and-migrate is the last thing you do, never the first: read the log, keep their geometry and text, then regenerate.
The deck names itself. --title wins, else the model's own title, else decklet; the winner is written into the model and the runtime titles the document from it. One short, human name — you are the one who writes it — becomes the browser tab, the ⤓ PDF filename and the ⌘S save-a-copy filename.
Step 5 — verify (mandatory)
node bin/verify.mjs deck.html [--refs shots/] [--out verify-out/] [--threshold 0.5] [--strict]
- Contract — always.
- Air — always, in
validate, with no browser: every row's declared box owes its neighbours styles.gap (default 4px). Two boxes closer than that, or overlapping, fail the contract unless one is wholly inside a painted box (containment), they share a group, or one says over:1. A text row with no h is estimated from its line count and a w:'auto' row from cw × chars — a collision resting on an estimate is a ~ warning, not an error. This is the gate that refuses the layout before it exists; parity below measures what actually rendered.
- Layout parity — always (needs Playwright): no text row overflows its box, every
nowrap row renders one line, imported rows render their source line count, every element is inside the canvas, no painted row is drawn through a text row, zero page errors.
Five shapes — four measured on real geometry (glyph rects and sampled strokes, never bounding boxes), the fifth asked of the compositor:
- ink through text — a line, curve or rule crossing a label's glyphs;
- text straddling a container — a label crossing a box/tile border, or hanging half out of the box meant to hold it;
- an arrow head inside a fill — a connector aimed at a target's centre instead of stopped on its edge (fix with
to:);
- text over text — a title landing on a caption;
- text under paint (occlusion) — a text row hidden by an opaque row painted later in
els (a tinted box, an image, a bar). The compositor is asked, not the geometry: elementFromPoint at five samples per line rect. Reported as slide 2: row 3 (Label 'kicker') under row 9 (box) — reorder els (paint first) or move the box. It fails like the other four; a hidden row is never a warning.
Containment is not collision: text on a tile, a label inside a box, a slide backdrop all pass. A tint with no border is a backdrop and a circle/pill outline is decoration — neither is a container edge. A headless stroke crossing a card is routing, not a landing. over:1 opts a row out of all five.
- AE pixel diff — when
--refs exists (needs ImageMagick): < 0.5% of pixels differ at 2% fuzz. AE alone passes wrapped labels; parity is what catches them — that is why parity is not optional.
Fix the model, not the output. Re-run until VERIFY PASS. Attach verify-out/results.json to your report.
Step 6 — hand-off notes for the human editor
Say, in this order:
- Where the file is and that it opens from disk in any browser, no install, no network.
- HUD (the full set, left to right), four groups after the spacer, a hairline between groups; every control names itself and its key on hover (
data-tip, instant — never the OS title delay): prev / next (chevrons; ← →) · save state: versions (history icon wearing the autosave dot as a badge: green = the file has everything · amber = saved in this browser, N edits not in the file yet · red = nothing persists; click opens the versions menu, whose first row is Save ⌘S · pin · restore) · edit: + (Text / Box / Slide) · duplicate (copy-plus icon: the selected rows, offset 16px, the copies become the selection · else the slide, after itself · in the sheet, the selected slides) · grid + guides + snap (grip icon; G; off by default, dims when off: a 16px dot grid over the slide and this slide's layout lines — slot edges, the content margin, the canvas mid-lines — of which only the line being held shows; a drag, a resize or a connector nib snaps to a line within 10px, else to the grid; a circle snaps by its centre; a connector end also snaps to the eight 45° rays out of its other end; never in present, print, the sheet, the PDF or the saved file) · spellcheck (spell-check icon; on by default, dims when off: the words the build's dictionary refused — nspell + dictionary-en at create, optional — are underlined on every slide through the CSS Highlight API, so the marks show in Chrome, Safari and an embedded pane alike; the browser's own checker adds the row you type in; never present, print or PDF; a name goes in the model's spell.ignore) · file: save a copy (only when the browser blocks storage) · PDF (⤓) · view: contact sheet (grid icon; C or Esc) · full screen (F) · shortcuts (ⓘ; a three-column popover: slides · rows · file; ⌘P and ⌘D are not shortcuts — the PDF and duplicate buttons are the only doors).This manifest is a contract: the gate compares it against the template, so the HUD cannot gain or lose a control without this line changing. While presenting, the HUD peeks back as a centred pill above the bottom edge — never over the page counter in the corner. On the contact sheet the HUD stays, pinned above the thumbnails, with prev/next/present disabled (the sheet is the navigator) and + adding a slide after the current one. Drag to move (a connector travels whole — both ends and every control point; a group travels whole and wears one dashed box), ⌘-click to multi-select (or to take one member of a group alone), drag from empty canvas for a marquee that takes every row it wholly contains (⇧ adds to the selection), double-click to retype, corner nib to resize, a connector's point nibs move one end (or a curve's control point) while a drag on its shaft moves it whole, ⌘Z to undo (persists across reloads), ⌘B / ⌘I / ⌘U mark a text selection or a whole selected row. Selecting text shows a floating toolbar: role segment, B / I / U / S̶ / link (marks never change size; the link takes http, https or mailto — an empty field unlinks), the deck's own colours as swatches, "Apply to all slides".
- Contact sheet: live thumbnails 3-across; click / ⌘ / shift select, double-click opens, grab-and-drag reorders (mouse or touch — the other cells slide aside), ⌫ deletes (never the last), ⌘C ⌘V ⌘D ⌘Z. It also opens in present mode.
- PDF: links become real
/Link annotations, so a LinkedIn document post is clickable. Agents make PDFs one way: node bin/pdf.mjs deck.html [out.pdf] — the deck's own print pipeline through Chromium's print engine with a pixel @page, so every page is the slide's size (1 px = 0.75 pt), text stays text, fonts embed, and the HUD is hidden by the deck's @media print (never by an ad-hoc override). It gates itself (exit 1): pages == slides, page ratio == W/H, #hud computed display:none under print media, link annotations == linked rows. Screenshots stitched into a PDF (Playwright + ImageMagick) are an anti-pattern: raster text blurs on every rescale, carries no links, and shipped the HUD once. The PDF button takes the same route in Chromium — @page{size:W H px} + zoom:1 injected, then the browser's print dialog, where the user picks "Save as PDF"; afterprint removes the rule. Safari ignores px @page sizes, so there the button keeps the in-file rasteriser: each slide from its live DOM (SVG foreignObject → canvas at 3× → lossless RGB FlateDecode via CompressionStream; a page whose deflate exceeds 6 MB drops to JPEG .92) onto a W×H pt page — no letterboxing, no JPEG ringing; verified in WebKit. If rasterising throws or the canvas is tainted, it falls back to print(). ⌘P is the paper path: one page per slide on a named page size — Letter (A4 for document-a4) — the slide zoomed to the printable width.
- Presenting: F or the fullscreen button; chrome hides, backdrop = current slide's background, HUD peeks back when the pointer rests at the bottom edge and stays pinned while the + menu, the text toolbar or the contact sheet is open. Arrow keys / space advance; Esc opens the contact sheet to jump.
- Persistence, honestly. Every edit autosaves to the browser's storage under
deck.id, and is appended to the in-file edit log (slide id, row id, keys before → after). ⌘S saves the deck file itself: in Chrome/Edge the first ⌘S asks for the file once (File System Access, remembered per browser), then every save — ⌘S and autosave alike — rewrites it in place with the model, the log and the versions inside, so an agent reading the file sees exactly what the human did. The dot is green only when the file has everything; amber counts the edits that have not reached it. Safari (and any browser without File System Access) cannot write the file: there ⌘S downloads a self-contained copy, and on file:// Safari blocks storage entirely — the dot goes red at load and the Save-a-copy button appears. A live text edit or a drag in flight is committed when the tab hides or unloads, so a refresh mid-edit loses nothing.
Position: a new window opens on slide 1; a refresh, and a new version of the file, keep the slide you were on — by slide id, so inserting slides above does not move you. A stored working copy is trusted only on the same rev; a newer file wins as the base and the browser replays its own edit log onto it (human wins, conflicts flagged on the entries), so an agent's rewrite neither hides its changes nor drops the human's.
Versions: the history control lists every version in the file — one per agent write (create --from), one per ⌘S, one per pin — and restores any of them (the state you leave is pinned first, so a restore is reversible). Capped at 20.
- To revise an edited deck, run
node bin/edits.mjs deck.html to read the log, then create --from deck.html (Step 4). Everything a human applies in the editor — geometry, text, links, arrows — round-trips that way; the console copy(JSON.stringify(deck)) still works for a raw model.
- What you inferred (style, layout choices) and anything marked experimental.
MODEL CONTRACT
Top level:
| prop |
type |
default |
example |
w, h |
number |
from format |
960, 540 |
format |
enum |
slides |
"carousel" |
page |
letter|a4 |
from format |
set by create |
title |
string |
decklet |
"Q3 update" — tab title + ⤓/⌘S filename; --title overwrites it |
spell |
{ignore: ["decklet", …]} |
none |
words the build's dictionary must not flag (a product, a name); case-blind. The build (nspell + dictionary-en, optional) writes every refused word into the file and the editor underlines them on every slide — see HUD · spellcheck |
counter |
0 |
on |
0 draws no page counter anywhere — canvas, print, PDF. For a letter or a one-page document, where 1 / 1 is noise |
lang |
BCP 47 tag |
en |
"de" — the dictionary the browser's spellcheck uses on the canvas; optional |
id |
string |
content hash, by create |
the deck's identity: the browser's storage namespace, kept across versions by --from |
rev |
string |
content hash, by create |
this build; the browser trusts a stored working copy only when its rev matches the file's |
styles.roles |
{Role: treatment} |
template neutral |
see STYLE CONTRACT |
styles.margin |
number |
round(w × 0.06) |
content inset chrome sits on: the footer counter's right edge = w − margin |
styles.pad |
{token: css} |
{chip:'3px 8px', pill:'5px 12px'} |
p:'chip' on a row |
styles.gap |
number |
4 |
the air every row owes its neighbours, in px; validate fails two declared boxes closer than this (see VERIFY → Air) |
slots |
{slot: geometry} |
{} |
deck-scope slots under every layout ({supertitle:{x:60,y:52,w:840,role:'Supertitle'}}) |
layouts |
{name: {slot: geometry}} |
{} |
{content:{title:{x:60,y:76,w:840,role:'H1'}}} |
master |
row[] with id |
[] |
[{id:'foot',footer:1,…}] |
slides |
slide[] (≥1) |
— |
|
Slot geometry: {x, y, w, h?, role} — or right in place of x; a slotted row's own right overrides the slot's x the way its own x would.
Slide: {id?, name?, layout?, bg?, hide?: masterId[], els: row[]} — id (unique per deck; s1, s2… when create stamps it) is how a tab remembers the slide it was on and how the edit log addresses a slide. Rows carry id (unique per slide; r1, r2…) for the same reason and for to:/from:.
Row — every prop optional; a row is whatever its props make it:
| prop |
type |
default |
meaning |
x,y |
number |
0 |
top-left, model px |
right |
number |
— |
the row's right edge N px from the canvas right edge (canvas-space, like x); x is derived at render from the measured width, so a w:'auto' chip needs no guessed x. Exclusive with x (validate errors on both). A drag or nudge in the editor writes right, so the anchor survives edits |
w |
number | 'auto' |
0 |
width; 'auto' hugs content |
h |
number |
content |
height; required for bar/tile/box-with-height |
slot |
string |
— |
inherit geometry + role from the layout/deck slot; own x/y/w/h are overrides |
role |
string |
slot's role |
text treatment from styles.roles — required for text |
text |
string |
— |
plain text; \n = line break |
html |
string |
— |
inline runs: <b> <i> <u> <s> <span style="color:…"> <a href="…"> only; no size/family/leading |
weight |
number |
role |
font-weight override |
color |
css |
role |
text colour (var(--accent) etc.) |
tt |
css |
role |
uppercase / none |
italic |
1 |
— |
|
align |
css |
left |
center, right |
valign |
middle | bottom |
top |
vertical seat of the text inside a row that carries h — a label over a painted button, a floor caption; box/tile rows centre already |
nowrap |
1 |
— |
single line, never wraps (parity checks it) |
ws |
css |
— |
pre-wrap etc. (\n in text already pre-wraps) |
p |
token | css |
— |
padding: 'chip', 'pill', '4px 10px', or a number |
bg |
css |
— |
background (box/bar/rect) |
bd,bt,br,bb,bl |
css |
— |
border / per-side border |
radius |
number | css |
— |
corner radius |
shadow |
css |
— |
box-shadow |
op |
0–1 |
— |
opacity |
box |
1 |
— |
outlined card chrome (padding 6/8, radius 8, centred, pre-wrap) |
tile |
1 |
— |
filled card chrome (card bg, hairline, centred, flex-centred vertically) |
bar |
1 |
— |
bar: rounded top; needs h + bg |
line |
[x2,y2] |
— |
straight line from (x,y) to (x2,y2); h = thickness (3), bg = colour |
curve |
[c1x,c1y,c2x,c2y,x2,y2] |
— |
cubic bezier connector from (x,y); absolute coords like line; h = thickness, bg = colour |
arrow |
start|end|both |
— |
arrow head on a line or a curve — never hand-build one out of three lines. The head IS the terminus: its tip lands on the stated end point and the stroke is shortened to make room, so a connector draws exactly as long as it was authored |
to, from |
row id | row index |
— |
terminate a connector against another row: the engine clips where the stroke crosses that row's box and backs off gap, so the tip stops clear of the border. Aim at the target, not at a hand-computed standoff. Prefer an id — indices shift when a row is inserted |
gap |
number |
10 |
the air to:/from: leaves between the tip and the target's border. 0 is flush (situational — K1) |
head |
triangle|chevron|dot|bar |
triangle |
what is drawn at the arrow ends. arrow says which ends, head says what — centred on the stroke axis by construction |
dash |
1 | [on,off] |
— |
dashed stroke, quantised to the run so it always begins and ends on a whole dash (measured along arc length on a curve). Keeps its head |
waive |
1 |
— |
this connector breaks a shape rule on purpose — validate stays quiet about it (the over:1 of connector geometry) |
over |
1 |
— |
declares a deliberate overlay: validate's gap gate and verify's collision check leave this row (and what it crosses) alone |
href |
url |
— |
http/https/mailto only. One inset anchor over the whole row (a painted CTA box + its label each carry it); presenting: a click opens it in a new tab; editing: a click selects the row, ⌘-click (Ctrl-click off Mac) opens it and the hover hint says so; a real /Link annotation in the ⤓ PDF and an anchor on every ⌘P page |
donut |
0–100 |
— |
ring, w = diameter, color = fill |
svg |
string |
— |
inline SVG markup (no script, no external href) |
icon |
name |
— |
a Lucide icon by name (see GRAPHICS; --icons lists them) — expands to an svg row at create, color paints it |
img |
data: URI |
— |
image; fit, pos = object-fit/position |
anim |
rise|fade|pop|wipe |
— |
entrance motion on slide entry, staggered 120 ms in model order (see MOTION) |
chart |
{mark, data, …} |
— |
a bar or line chart drawn into this row's x/y/w/h at create time (CHART ROW) |
css |
string |
— |
raw CSS escape hatch — validator warns |
group |
string |
— |
rows on a slide sharing one group are one unit in the editor: drag, nudge and marquee move them together, the selection draws one box round them, ⌘-click takes a member alone. Every row keeps canvas-space x/y — nothing is relative. A chart row's expansion shares one group; the library's kpi tiles, steps, timeline events and cta button are born grouped |
override |
masterId |
— |
partial row: only the props it carries replace the master's on this slide |
footer |
1 |
— |
master only: the page counter renders inline here |
id |
string |
— |
master only, unique |
Resolution order for any row: slot geometry ← master row (for override rows) ← the row's own props ← role treatment. The role fills whatever the row left unset and always wins font/size/lh/ls — a row can never change family, size, leading or tracking.
STYLE CONTRACT (style.json)
{
"tokens": { "bg": "#111315", "fg": "#F3F4F6", "muted": "#9CA3AF", "accent": "#5B9CF6", "card": "#1A1D21", "line": "#2C3138", "sel": "#5B9CF6", "box": "#20262E" },
"roles": {
"Title": { "font": "…", "size": 64, "weight": 800, "lh": 68, "ls": -1.5, "color": "var(--fg)" },
"Supertitle": { "font": "ui-monospace,Menlo,monospace", "size": 12, "weight": 500, "lh": 16, "ls": 1.5, "color": "var(--accent)", "tt": "uppercase" },
"H1": { "font": "…", "size": 34, "weight": 800, "lh": 40, "ls": -0.5, "color": "var(--fg)" },
"H2": { "font": "…", "size": 22, "weight": 600, "lh": 28, "ls": -0.3, "color": "var(--fg)" },
"Body": { "font": "…", "size": 16, "weight": 400, "lh": 24, "ls": 0, "color": "var(--fg)" },
"Caption": { "font": "…", "size": 13, "weight": 400, "lh": 18, "ls": 0, "color": "var(--muted)" },
"Label": { "font": "ui-monospace,Menlo,monospace", "size": 11, "weight": 500, "lh": 14, "ls": 1, "color": "var(--muted)", "tt": "uppercase" },
"Stat": { "font": "…", "size": 40, "weight": 800, "lh": 44, "ls": -1, "color": "var(--accent)" }
},
"pad": { "chip": "3px 8px", "pill": "5px 12px" },
"margin": 60,
"gap": 4
}
tokens → CSS custom properties on :root. bg is the editor chrome behind the slide; card is the slide surface; box the outlined-box fill; sel the selection colour.
- The eight roles are the whole type system — exactly these names:
Title (display headline for cover/closing slides), Supertitle (kicker), H1 (content-slide title), H2, Body, Caption, Label (mono, uppercase — chips, axis labels, footer), Stat. No H3, no Subtitle. One allowance: Stat2, an optional ninth role for the KPI tile — a hero "63%" and a card "$1.2M" cannot share one size. A style may define it; when it does not, create derives it from Stat at 0.6 (size, line height, tracking) and only when a row or slot asks for it, so a deck that never uses Stat2 never carries it. The kpi-grid tiles wear it; the hero stat layout keeps Stat. A role is a complete treatment: font, size, weight, lh, ls, color, optional tt. One font and one size per role — never two sizes of "Body". A row may add weight, color, tt, italic; it can never carry font, size, lh, ls or mono (the validator rejects it, the engine ignores it).
margin is the content inset the chrome sits on (footer counter's right edge, default 6% of w). Set it to match your layouts' left edge.
gap is the air every row owes its neighbours (default 4px, the offset the chart library itself uses between a bar and its value). Raise it for a roomier deck; validate enforces it on declared boxes.
cw on a role is the measured average glyph width in em (width / chars / size of a representative sentence in that font, weight and case). validate sizes w:'auto' rows and line counts with it; without it the blanket 0.55 stands, which runs ~20% wide on a sans and ~20% narrow on an uppercase mono. Measure once per brand font in Chromium and put it in style.json; the neutral roles carry theirs.
- The model's own
styles.roles win over style.json per role; a model with no roles inherits the template's neutral scale.
LAYOUTS (slots)
layouts.<name>.<slot> = {x, y, w, h?, role}; a slide opts in with layout:'<name>'.
slots.<slot> (deck scope) applies under every layout — use it for a supertitle shared by all.
- A slotted row may carry local x/y/w/h overrides; edit the slot in the model to move every slide at once.
- Conventional slot names:
supertitle, title, body, body2. Conventional layouts: title (cover), content, section.
- A slot may carry any row treatment besides geometry and role —
tile:1, bg, p:'chip', align, nowrap, italic, even line — and the engine spreads it onto the bound row. A roleless slot with h is paint or media: {slot:'rule'} alone draws it.
LAYOUT LIBRARY
Thirty-one named layouts ship with the engine (lib/layouts.mjs), in the same shape as a layouts entry. Name one on a slide the deck does not define and create merges it into deck.layouts, scaled from its 960×540 cut to the canvas (1600×900 = ×1.67). Print the catalogue — name, group, density, use, slots — with:
node bin/validate.mjs --layouts
Read that instead of inventing geometry. The library is an accelerant, never a fence: a slide may use a library layout, a deck-defined layout, or free rows, and may mix library slots with extra free rows on the same slide (layout:'kpi-grid' plus a caption and a rule at y 400 is a normal slide). A deck-defined layout of the same name wins. A slotted row still takes its own x/y/w/h — the override path — so a brand with a display role taller than the neutral scale (Title 64/68, Stat 40/44 — what the library is cut for) nudges a slot without redefining the layout. An unknown name is still an error, and the error lists the library.
| group |
layout |
density |
slots |
| openers |
cover |
speaker |
supertitle · title (Title) · body · caption |
|
agenda |
reading |
supertitle · title · n1–n5 (Label) + item1–item5 (Body) |
|
section |
speaker |
number (Label) · title (Title) · body |
| chrome |
content |
reading |
supertitle · title (H1) — the template library's title chrome, the canvas free |
|
title |
speaker |
supertitle · title (Title, lower half) — a cover or divider |
| text |
statement |
speaker |
title (Title) · caption |
|
fact |
speaker |
stat (Stat) · label · body |
|
quote |
speaker |
quote (H2, italic) · attribution (Caption) |
|
two-cols |
reading |
supertitle · title · left · right (Body) |
|
two-cols-header |
reading |
supertitle · title · header (H2) · left · right |
| visuals |
image-left |
reading |
image (400×320 media) · supertitle · title · body |
|
image-right |
reading |
supertitle · title · body · image |
| modern |
bento-grid |
reading |
supertitle · title · hero (paint) · hero-label · hero-value (Title) · hero-chart (376×160 media) · card1–2 (paint) + card1–2-label + card1–2-value (Stat) · action (accent paint) · action-label · action-body |
|
image-hero-overlay |
speaker |
image (full-bleed media, give it img) · scrim (paint) · label · title (Title) · caption — hide the footer on the slide |
|
image-split |
speaker |
image (440×540 media, right) · label · title (H1) · body · button (paint, give it href) · button-label (Label, same href) |
|
annotated-shot |
reading |
supertitle · title · shot (520×270 media) · callout1–3 (paint) + callout1–3-text (Body) + -leader (line) + -dot |
|
three-up-cards |
reading |
supertitle · title · card1–3 (paint) + card1–3-number (Label) + -head (H2) + -rule + -body |
|
dashboard-composite |
reading |
supertitle · title · kpi1–4 (paint) + kpi1–4-value (Stat) + -label · chart (500×180 media) · panel (paint) · panel-label · panel-body |
|
table-insight |
reading |
supertitle · title · table (500×210 media frame: place the table's rows inside it) · panel (paint) · panel-label · panel-body · panel-rule · panel-next (Label) |
|
proof-strip |
reading |
supertitle · title · logo1–5 (paint, give them img) + logo1–5-name (Label) · rule · stat1–3 (Stat) + stat1–3-label |
|
team-grid |
reading |
supertitle · title · photo1–4 (195×180 media, give them img) + name1–4 (H2) + role1–4 (Caption) |
| numbers |
kpi-grid |
reading |
supertitle · title · kpi1–3 (tiles) · kpi1–3-delta (chips) · kpi1–3-label · body |
|
kpi-grid-4 |
reading |
the same with four 195px tiles |
|
stat |
speaker |
supertitle · title · stat (the deck Stat size, centred) · caption |
|
chart |
reading |
supertitle · title · chart (840×276 media) · takeaway (Body) · source (Caption) |
|
comparison |
reading |
supertitle · title · left-head · right-head (H2) · left · right |
| diagrams |
process-steps |
reading |
supertitle · title · n1–n4 (Label) · step1–step4 (tiles, Body) · body |
|
diagram |
reading |
supertitle · title · figure (840×320 media frame: place the figure's rows inside it) · caption (Caption, the claim) |
| plans |
timeline |
reading |
supertitle · title · rule (paint) · d1–d4 (dots) · t1–t4 (Label) · e1–e4 (Body) |
| closers |
cta |
speaker |
title (Title) · body · button (paint, give it href) · button-label (Body, same href) |
|
end |
speaker |
title (Title) · body · caption |
Density is defined in DENSITY below: a speaker (fluffy) slide carries ≤ 3 points, a reading (dense) slide up to 8 with its own context. Every layout with H1 title chrome also carries the four dense slots subtitle · note · source · legend (unbound they draw nothing). Delta chips are Label on a chip pad in var(--box), coloured var(--ok, var(--accent)); a falling delta sets color:'var(--bad, var(--accent))' on the row — the deck's ok/bad tokens if the style defines them, else the accent.
Picking a layout by what the content is:
| the slide's content is… |
layout |
| the deck's name and promise |
cover; end closes |
| what the deck will cover |
agenda; section between parts |
| one claim |
statement; with a number in it → fact; the number alone → stat |
| someone's words |
quote |
| two bodies of text, a lede over two columns |
two-cols, two-cols-header |
| a picture and a paragraph |
image-left / image-right |
| three or four numbers with movement |
kpi-grid / kpi-grid-4 |
| a series with real numbers |
chart — no numbers, no chart layout |
| A against B |
comparison |
| steps in order |
process-steps; dated → timeline |
| the ask |
cta |
TEMPLATE LIBRARY
Fifty-nine finished slides ship with the engine (lib/templates.mjs, sources in lib/templates/cat-*.mjs): the candidate sheet surveyed across the open-source slide catalogs and promoted whole (2026-09-07). A template is a layout PLUS sample rows — an issue tree, a Sankey, a scorecard, a bento grid — so the agent binds content instead of drawing. Print the catalogue — id · tier · density · note, then every text key with its sample — with:
node bin/validate.mjs --templates
A slide names one and fills its keys: {template: 'three-up-cards', fill: {t1: 'What you get', t2: 'Three things, one price.', t3: '01', …}}. Every text row of the template is a key, t1…tn in row order; a key left out keeps the sample text (so fill them all before shipping). create expands the slide into the template's rows, scaled from the 960×540 cut to the canvas, sets the slide's layout to the template's chrome (content or title from the library — a deck-defined layout of that name wins) and its density, and keeps any free els after the template rows. An unknown template or fill key is a validate error listing what exists. A template is an accelerant like a layout: edit the rows it produced, add rows beside them, or draw free — nothing here is a fence.
Tiers: core (in 4+ surveyed catalogs), standard (consulting catalogs), fringe (dataviz literature, rare on slides). Categories: Narrative · Numbers · Comparison · Frameworks · Process · Charts · Modern.
DENSITY
Two named densities, defined in lib/layouts.mjs (DENSITY) so "dense" and "fluffy" build the same deck every time. A deck says density: 'speaker' | 'reading'; a slide may carry its own. validate warns on every slide that misses its density's shape; an unknown density is an error.
| density |
aka |
for |
the slide carries |
max |
speaker |
fluffy |
a presented deck — the speaker carries the rest |
supertitle · title · one figure, number or ≤ 3 points · a caption at most |
3 points · 40 words |
reading |
dense |
a leave-behind read without a speaker — the slide carries its own context |
supertitle · title · subtitle (the claim in one sentence) · the figure or the points · note (what to make of it) · source · legend · footer naming the deck |
8 points · 140 words |
The dense chrome is four optional slots every H1-titled library layout carries (DENSE): subtitle (H2, muted, under the title), note (Body, muted, two lines above the foot), source (Caption, left foot), legend (Label, right foot via right); the subtitle is nowrap, one sentence, and parity fails a wrap. A layout that cannot seat one says so (dense: {note: false} on chart, whose takeaway is the note; all four on diagram). A reading slide binds at least one; a speaker slide leaves them alone. "Points" are the text rows that are not chrome (not supertitle/title/subtitle/note/source/legend/caption, not Label). The template library carries a density per template (--templates prints it): the speaker ones are covers, dividers, quotes, statements, hero numbers, the donut and gauge, the cycle and the tree; everything with a table, a grid or a series is reading. |
|
|
|
|
GRAPHICS
Five kinds of picture, one rule each. Colour is always a token; nothing loads from the network.
- Icons —
{icon: 'shield-check', x, y, w: 24, h: 24, color: 'var(--accent)'}. The set is Lucide (ISC), 169 names, node bin/validate.mjs --icons prints them; create inlines the svg (stroke currentColor, so color paints it) and only the icons a deck uses reach the file. One icon per point, on the 24 grid (24/32/40 px), left of its label on the label's cap line, the same weight throughout. An icon says what the label says — never decoration, never a second idea, never a filled emoji.
- Glyphs — a number in a circle, a letter chip, a status dot: rows, not images. A step glyph is a
dot (or a radius:'50%' box) with a Label centred in it (valign:'middle'); a status dot is a 10px bg circle in var(--ok)/var(--bad). Group the glyph with its text.
- Images —
img is a data: URI, fit:'cover', explicit w/h, one per slide, never stretched, never behind text unless a tint band (bg with op) sits between. Budget ≈ 100 KB an image, the file under ~1 MB. Photographs carry the image-left/image-right/image-hero-overlay/image-split layouts; a screenshot gets annotated-shot with callout rows.
- Figures — nodes, connectors, timelines, trees as rows (the diagram helper, the
diagram layout, the framework and process templates), so every box and edge stays editable. An svg row is for a fill the engine has no primitive for (a Sankey ribbon, an area band) — never for text, never for a whole figure.
- Clips — a short
…(truncated)
1---2name: decklet3description: Author presentations, carousels and one-page documents as a decklet JSON model and build them into ONE self-contained, editable HTML file — zero dependencies, zero network, verified layout. Use when asked to "make a deck / slides / presentation / carousel / one-pager" from any content (outline, notes, markdown, transcript, data), when converting finished HTML pages into an editable deck, or when a deck must be brand-true and hand-editable by a human afterwards. Not for interactive web apps or PPTX/Google Slides output.4---56# decklet — agent authoring skill78You produce a **model** (JSON). The toolchain produces a **deck** (one `.html` file) that a human can drag, retype, present and print. Your job is to get the model right; the validator and verifier tell you when you haven't.910```11content + format + style → slide plan → model.json → validate → create → verify → hand-off12```1314All commands run from the repo root with plain Node ≥ 22. Installed as a Claude Code plugin, the repo root is `${CLAUDE_PLUGIN_ROOT}`; installed with `npx skills add`, it is the skill's own directory. Only `verify` (and `import-html`) need the optional `playwright` devDependency.1516---1718## INPUTS1920### 1. Content — anything21Outline, markdown, meeting notes, a transcript, a spreadsheet, a brief. You distil it; nothing is pasted verbatim. One idea per slide. Numbers become `Stat` rows, lists become 2–4 short `Body` rows or tiles, sequences become boxes with arrows, comparisons become two columns.2223### 2. Format — one of24| format | canvas (model px) | print page | status |25|---|---|---|---|26| `slides` | 960×540 (or 1600×900 via `--space 1600x900`) | Letter, slide zoomed to page width | **supported** |27| `carousel` | 1080×1080 (1:1) | Letter | experimental |28| `carousel-4x5` | 1080×1350 (4:5) | Letter | experimental |29| `document-letter` | 816×1056 (8.5×11in) | Letter, zoom 1 | experimental |30| `document-a4` | 794×1123 (210×297mm) | A4, zoom 1 | experimental |3132Experimental means: canvas sizing, editing, contact sheet and PDF all work; **text does not flow across pages** — every page is a slide you lay out yourself, and a document longer than its page is your problem to split. Treat a document as N fixed pages.3334### 3. Style — a style guide, an inferred brand, or the neutral fallback35`style.json` = `{tokens, roles, pad}` (STYLE CONTRACT below). Obtain it in this order:361. **Style guide / brand file given** → map its palette to `tokens`, its type scale to the eight `roles`. Fonts must be installed on the viewer's machine or be system stacks — the deck loads no webfonts. Put the brand font first, a system fallback after.372. **URL or screenshots given** → infer: background, ink, muted ink, one accent, a card surface, a hairline. Headline family (serif/sans/mono), body family. Build `tokens` + `roles` from that. Say in the hand-off what you inferred.383. **Nothing given** → omit `--style`; the template's neutral dark scale is used.3940Sizes in `roles` are in **model pixels** for the chosen canvas. Rough scale factors: 960-wide ×1, 1600-wide ×1.67, 1080 carousel ×1.9 (viewed small), 816 document ×0.75.4142---4344## PROCESS4546### Step 1 — slide plan (write it down before any JSON)47For each slide: `name · layout · supertitle · title · body elements (kind + count)`. Cap: ~60 words of `Body` per 16:9 slide, 3–4 tiles per row, 5 bars per chart, 4 boxes per flow. A content-slide title is one line at `H1`; a cover or closing headline uses `Title` (the display size), two lines at most.4849### Step 2 — model rows50Discipline, in order of importance:51- **Role discipline.** Every text row has a `role` (or a `slot` whose layout slot has one). A row never sets `font`, `size`, `lh`, `ls` or `mono` — the validator rejects it. Rows may set `weight`, `color`, `tt`, `italic`, `align`.52- **Slot discipline.** Supertitle and title geometry lives in `layouts.<name>`; the slide row is `{slot:'title', text:'…'}` with no x/y/w. Define one layout per slide family (`title`, `content`; add `section`, `two-col` as needed).53- **Master discipline.** Anything that appears on every slide (footer, rule, mark) is a `master` row, once — chrome is deck-wide and never varies per layout. Exactly one master row has `footer:1`; the engine renders the page counter inside it, with its right edge on `styles.margin`. Never type `3 / 9` into a row.54- **Text-fit.** A label that must stay on one line gets `nowrap:1` and enough `w` (≈ `cw` × size × chars — the role's measured glyph width, 0.46 for the neutral sans, 0.69 for the mono Label), or `w:'auto'` to hug. Chips/pills: `w:'auto'` + `p:'chip'` (+ `bg`/`bd`/`radius`); one that sits on a right edge takes `right:` instead of `x`. Body copy gets a `w` that yields ≤ 3 lines at the role's size.55- **Charts are rows.** A bar or line chart is one `chart` row (CHART ROW below) that `create` expands into bars, lines, dots and `Label` rows with the drawing rules applied — write the data, not the geometry. By hand, the same shape: bars `{x,y,w,h,bg,bar:1}` bottom-aligned on a baseline `line`; value labels as `Label` rows above, axis labels below. Donut: `{x,y,w,donut:72}` + a `Stat` row centred on it. Tiles: `{x,y,w,h,tile:1,role:'Stat',text}` + a `Label` row beneath.56- **Cards are groups.** There is no container row: a card is a tile plus its rows sharing one `group` — `{x,y,w,h,bg,bd,radius,group:'card1'}` and each text row inside it with `group:'card1'`, every row at its own canvas x/y. The human drags the card and the rows come along; you still position each row, once.57- **Colour.** Use `var(--accent)`, `var(--fg)`, `var(--muted)`, `var(--line)`, `var(--card)` so a style swap re-themes the deck; literal hex only for chart series.5859### Step 3 — validate (no browser)60```61node bin/validate.mjs model.json --style style.json # 0 errors required; read every warning62node bin/validate.mjs model.json --style style.json --strict # warnings fail too — use before hand-off63```64**Always pass the same `--style` you will pass to `create`.** Text fit is only meaningful against the scale the deck will actually wear: without it the model is measured against the template's neutral roles, so `validate` can report 0 warnings on a model `create --style` then floods with overflow — and `verify` fails on. Omit `--style` only when there is none.6566### Step 4 — create67```68node bin/create.mjs --model model.json [--style style.json] --out deck.html --format slides [--space 1600x900] [--title "…"] [--from prev.html]69```70Refuses an invalid model (`--force` to override while iterating). Stamps `deck.id` (born once, from the first model — the browser's storage namespace, stable across versions), `deck.rev` (this build's content hash), a `slide.id` on every slide and a row `id` on every row that has none — deterministic (`s1, s2… / r1, r2…`), so the same model builds byte-identically.7172**Revising a deck a human has touched — `--from` is mandatory.** The deck file carries the human's edits (`/*LOG*/`) and its version history (`/*VERSIONS*/`). Run, in this order:73```74node bin/edits.mjs deck.html # 1. READ what the human changed — before you touch the model75node bin/create.mjs --model model.json --out deck.html --from deck.html # 2. write the new version LAST76node bin/verify.mjs deck.html # 3. verify the result77```78`--from` inherits the deck id and the slide/row ids (by content, then by position — carry ids in `model.json` to make it exact), replays every logged edit onto the new model (**human wins**; a key you also changed is reported as a conflict and the human's value stays — put yours in the log's `conflict` if it matters), and pushes the file's previous state into the version history, so nothing the human did can be lost by a rebuild. The diff-and-migrate is the last thing you do, never the first: read the log, keep their geometry and text, then regenerate.7980**The deck names itself.** `--title` wins, else the model's own `title`, else `decklet`; the winner is written into the model and the runtime titles the document from it. One short, human name — you are the one who writes it — becomes the browser tab, the `⤓` PDF filename and the `⌘S` save-a-copy filename.8182### Step 5 — verify (mandatory)83```84node bin/verify.mjs deck.html [--refs shots/] [--out verify-out/] [--threshold 0.5] [--strict]85```86- **Contract** — always.87- **Air** — always, in `validate`, with no browser: every row's declared box owes its neighbours `styles.gap` (default 4px). Two boxes closer than that, or overlapping, fail the contract unless one is wholly inside a painted box (containment), they share a `group`, or one says `over:1`. A text row with no `h` is estimated from its line count and a `w:'auto'` row from `cw` × chars — a collision resting on an estimate is a `~` warning, not an error. This is the gate that refuses the layout before it exists; parity below measures what actually rendered.88- **Layout parity** — always (needs Playwright): no text row overflows its box, every `nowrap` row renders one line, imported rows render their source line count, every element is inside the canvas, **no painted row is drawn through a text row**, zero page errors.89 Five shapes — four measured on real geometry (glyph rects and sampled strokes, never bounding boxes), the fifth asked of the compositor:90 1. **ink through text** — a line, curve or rule crossing a label's glyphs;91 2. **text straddling a container** — a label crossing a box/tile border, or hanging half out of the box meant to hold it;92 3. **an arrow head inside a fill** — a connector aimed at a target's centre instead of stopped on its edge (fix with `to:`);93 4. **text over text** — a title landing on a caption;94 5. **text under paint** (occlusion) — a text row hidden by an opaque row painted later in `els` (a tinted box, an image, a bar). The compositor is asked, not the geometry: `elementFromPoint` at five samples per line rect. Reported as `slide 2: row 3 (Label 'kicker') under row 9 (box)` — reorder `els` (paint first) or move the box. It fails like the other four; a hidden row is never a warning.95 Containment is not collision: text on a tile, a label inside a box, a slide backdrop all pass. A tint with no border is a backdrop and a circle/pill outline is decoration — neither is a container edge. A headless stroke crossing a card is routing, not a landing. `over:1` opts a row out of all five.96- **AE pixel diff** — when `--refs` exists (needs ImageMagick): `< 0.5%` of pixels differ at 2% fuzz. AE alone passes wrapped labels; parity is what catches them — that is why parity is not optional.9798Fix the model, not the output. Re-run until `VERIFY PASS`. Attach `verify-out/results.json` to your report.99100### Step 6 — hand-off notes for the human editor101Say, in this order:1021. Where the file is and that it opens from disk in any browser, no install, no network.1032. **HUD (the full set, left to right), four groups after the spacer, a hairline between groups; every control names itself and its key on hover (`data-tip`, instant — never the OS title delay):** prev / next (chevrons; ← →) · **save state:** versions (history icon wearing the autosave dot as a badge: green = the file has everything · amber = saved in this browser, N edits not in the file yet · red = nothing persists; click opens the versions menu, whose first row is `Save ⌘S` · pin · restore) · **edit:** `+` (Text / Box / Slide) · duplicate (copy-plus icon: the selected rows, offset 16px, the copies become the selection · else the slide, after itself · in the sheet, the selected slides) · grid + guides + snap (grip icon; **G**; off by default, dims when off: a 16px dot grid over the slide and this slide's layout lines — slot edges, the content margin, the canvas mid-lines — of which only the line being held shows; a drag, a resize or a connector nib snaps to a line within 10px, else to the grid; a circle snaps by its centre; a connector end also snaps to the eight 45° rays out of its other end; never in present, print, the sheet, the PDF or the saved file) · spellcheck (spell-check icon; on by default, dims when off: the words the build's dictionary refused — nspell + dictionary-en at create, optional — are underlined on every slide through the CSS Highlight API, so the marks show in Chrome, Safari and an embedded pane alike; the browser's own checker adds the row you type in; never present, print or PDF; a name goes in the model's `spell.ignore`) · **file:** save a copy (only when the browser blocks storage) · PDF (⤓) · **view:** contact sheet (grid icon; **C** or Esc) · full screen (**F**) · shortcuts (ⓘ; a three-column popover: slides · rows · file; ⌘P and ⌘D are not shortcuts — the PDF and duplicate buttons are the only doors).104 <!-- HUD: prev next vers autosave addbtn dup snap spell savecopy pdf grid-btn fs help -->105 This manifest is a contract: the gate compares it against the template, so the HUD cannot gain or lose a control without this line changing. While presenting, the HUD peeks back as a centred pill above the bottom edge — never over the page counter in the corner. On the contact sheet the HUD stays, pinned above the thumbnails, with prev/next/present disabled (the sheet is the navigator) and `+` adding a slide after the current one. Drag to move (a connector travels whole — both ends and every control point; a `group` travels whole and wears one dashed box), ⌘-click to multi-select (or to take one member of a group alone), drag from empty canvas for a marquee that takes every row it wholly contains (⇧ adds to the selection), double-click to retype, corner nib to resize, a connector's **point nibs** move one end (or a curve's control point) while a drag on its shaft moves it whole, ⌘Z to undo (persists across reloads), ⌘B / ⌘I / ⌘U mark a text selection or a whole selected row. Selecting text shows a floating toolbar: role segment, **B / I / U / S̶ / link** (marks never change size; the link takes http, https or mailto — an empty field unlinks), the deck's own colours as swatches, "Apply to all slides".1063. **Contact sheet:** live thumbnails 3-across; click / ⌘ / shift select, double-click opens, grab-and-drag reorders (mouse or touch — the other cells slide aside), ⌫ deletes (never the last), ⌘C ⌘V ⌘D ⌘Z. It also opens in present mode.1074. **PDF:** links become real `/Link` annotations, so a LinkedIn document post is clickable. **Agents make PDFs one way: `node bin/pdf.mjs deck.html [out.pdf]`** — the deck's own print pipeline through Chromium's print engine with a pixel `@page`, so every page is the slide's size (1 px = 0.75 pt), text stays text, fonts embed, and the HUD is hidden by the deck's `@media print` (never by an ad-hoc override). It gates itself (exit 1): pages == slides, page ratio == W/H, `#hud` computed `display:none` under print media, link annotations == linked rows. Screenshots stitched into a PDF (Playwright + ImageMagick) are an anti-pattern: raster text blurs on every rescale, carries no links, and shipped the HUD once. **The PDF button** takes the same route in Chromium — `@page{size:W H px}` + `zoom:1` injected, then the browser's print dialog, where the user picks "Save as PDF"; `afterprint` removes the rule. **Safari** ignores px `@page` sizes, so there the button keeps the in-file rasteriser: each slide from its live DOM (SVG `foreignObject` → canvas at 3× → lossless RGB `FlateDecode` via `CompressionStream`; a page whose deflate exceeds 6 MB drops to JPEG .92) onto a W×H pt page — no letterboxing, no JPEG ringing; verified in WebKit. If rasterising throws or the canvas is tainted, it falls back to `print()`. **⌘P is the paper path:** one page per slide on a **named** page size — Letter (A4 for `document-a4`) — the slide zoomed to the printable width.1085. **Presenting:** F or the fullscreen button; chrome hides, backdrop = current slide's background, HUD peeks back when the pointer rests at the bottom edge and stays pinned while the + menu, the text toolbar or the contact sheet is open. Arrow keys / space advance; Esc opens the contact sheet to jump.1096. **Persistence, honestly.** Every edit autosaves to the browser's storage under `deck.id`, and is appended to the in-file **edit log** (slide id, row id, keys before → after). **⌘S saves the deck file itself:** in Chrome/Edge the first ⌘S asks for the file once (File System Access, remembered per browser), then every save — ⌘S and autosave alike — rewrites it in place with the model, the log and the versions inside, so an agent reading the file sees exactly what the human did. The dot is green only when the file has everything; amber counts the edits that have not reached it. Safari (and any browser without File System Access) cannot write the file: there ⌘S downloads a self-contained copy, and on `file://` Safari blocks storage entirely — the dot goes red at load and the Save-a-copy button appears. A live text edit or a drag in flight is committed when the tab hides or unloads, so a refresh mid-edit loses nothing.110 **Position:** a new window opens on slide 1; a refresh, and a new version of the file, keep the slide you were on — by slide id, so inserting slides above does not move you. A stored working copy is trusted only on the same `rev`; a newer file wins as the base and the browser replays its own edit log onto it (human wins, conflicts flagged on the entries), so an agent's rewrite neither hides its changes nor drops the human's.111 **Versions:** the history control lists every version in the file — one per agent write (`create --from`), one per ⌘S, one per pin — and restores any of them (the state you leave is pinned first, so a restore is reversible). Capped at 20.1127. To revise an edited deck, run `node bin/edits.mjs deck.html` to read the log, then `create --from deck.html` (Step 4). Everything a human applies in the editor — geometry, text, links, arrows — round-trips that way; the console `copy(JSON.stringify(deck))` still works for a raw model.1138. What you inferred (style, layout choices) and anything marked experimental.114115---116117## MODEL CONTRACT118119Top level:120| prop | type | default | example |121|---|---|---|---|122| `w`, `h` | number | from format | `960`, `540` |123| `format` | enum | `slides` | `"carousel"` |124| `page` | `letter`\|`a4` | from format | set by create |125| `title` | string | `decklet` | `"Q3 update"` — tab title + `⤓`/`⌘S` filename; `--title` overwrites it |126| `spell` | `{ignore: ["decklet", …]}` | none | words the build's dictionary must not flag (a product, a name); case-blind. The build (nspell + dictionary-en, optional) writes every refused word into the file and the editor underlines them on every slide — see HUD · spellcheck |127| `counter` | `0` | on | `0` draws no page counter anywhere — canvas, print, PDF. For a letter or a one-page document, where `1 / 1` is noise |128| `lang` | BCP 47 tag | `en` | `"de"` — the dictionary the browser's spellcheck uses on the canvas; optional |129| `id` | string | content hash, by create | the deck's identity: the browser's storage namespace, kept across versions by `--from` |130| `rev` | string | content hash, by create | this build; the browser trusts a stored working copy only when its `rev` matches the file's |131| `styles.roles` | `{Role: treatment}` | template neutral | see STYLE CONTRACT |132| `styles.margin` | number | `round(w × 0.06)` | content inset chrome sits on: the footer counter's right edge = `w − margin` |133| `styles.pad` | `{token: css}` | `{chip:'3px 8px', pill:'5px 12px'}` | `p:'chip'` on a row |134| `styles.gap` | number | `4` | the air every row owes its neighbours, in px; `validate` fails two declared boxes closer than this (see VERIFY → Air) |135| `slots` | `{slot: geometry}` | `{}` | deck-scope slots under every layout (`{supertitle:{x:60,y:52,w:840,role:'Supertitle'}}`) |136| `layouts` | `{name: {slot: geometry}}` | `{}` | `{content:{title:{x:60,y:76,w:840,role:'H1'}}}` |137| `master` | row[] with `id` | `[]` | `[{id:'foot',footer:1,…}]` |138| `slides` | slide[] (≥1) | — | |139140Slot geometry: `{x, y, w, h?, role}` — or `right` in place of `x`; a slotted row's own `right` overrides the slot's `x` the way its own `x` would.141142Slide: `{id?, name?, layout?, bg?, hide?: masterId[], els: row[]}` — `id` (unique per deck; `s1, s2…` when create stamps it) is how a tab remembers the slide it was on and how the edit log addresses a slide. Rows carry `id` (unique per slide; `r1, r2…`) for the same reason and for `to:`/`from:`.143144Row — every prop optional; a row is whatever its props make it:145| prop | type | default | meaning |146|---|---|---|---|147| `x`,`y` | number | `0` | top-left, model px |148| `right` | number | — | the row's right edge N px from the canvas right edge (canvas-space, like `x`); x is derived at render from the measured width, so a `w:'auto'` chip needs no guessed x. Exclusive with `x` (validate errors on both). A drag or nudge in the editor writes `right`, so the anchor survives edits |149| `w` | number \| `'auto'` | `0` | width; `'auto'` hugs content |150| `h` | number | content | height; required for bar/tile/box-with-height |151| `slot` | string | — | inherit geometry + role from the layout/deck slot; own x/y/w/h are overrides |152| `role` | string | slot's role | text treatment from `styles.roles` — **required for text** |153| `text` | string | — | plain text; `\n` = line break |154| `html` | string | — | inline runs: `<b> <i> <u> <s> <span style="color:…"> <a href="…">` only; no size/family/leading |155| `weight` | number | role | font-weight override |156| `color` | css | role | text colour (`var(--accent)` etc.) |157| `tt` | css | role | `uppercase` / `none` |158| `italic` | 1 | — | |159| `align` | css | `left` | `center`, `right` |160| `valign` | `middle` \| `bottom` | top | vertical seat of the text inside a row that carries `h` — a label over a painted button, a floor caption; `box`/`tile` rows centre already |161| `nowrap` | 1 | — | single line, never wraps (parity checks it) |162| `ws` | css | — | `pre-wrap` etc. (`\n` in text already pre-wraps) |163| `p` | token \| css | — | padding: `'chip'`, `'pill'`, `'4px 10px'`, or a number |164| `bg` | css | — | background (box/bar/rect) |165| `bd`,`bt`,`br`,`bb`,`bl` | css | — | border / per-side border |166| `radius` | number \| css | — | corner radius |167| `shadow` | css | — | box-shadow |168| `op` | 0–1 | — | opacity |169| `box` | 1 | — | outlined card chrome (padding 6/8, radius 8, centred, pre-wrap) |170| `tile` | 1 | — | filled card chrome (card bg, hairline, centred, flex-centred vertically) |171| `bar` | 1 | — | bar: rounded top; needs `h` + `bg` |172| `line` | `[x2,y2]` | — | straight line from (x,y) to (x2,y2); `h` = thickness (3), `bg` = colour |173| `curve` | `[c1x,c1y,c2x,c2y,x2,y2]` | — | cubic bezier connector from (x,y); absolute coords like `line`; `h` = thickness, `bg` = colour |174| `arrow` | `start`\|`end`\|`both` | — | arrow head on a `line` or a `curve` — never hand-build one out of three lines. **The head IS the terminus:** its tip lands on the stated end point and the stroke is shortened to make room, so a connector draws exactly as long as it was authored |175| `to`, `from` | row id \| row index | — | terminate a connector **against another row**: the engine clips where the stroke crosses that row's box and backs off `gap`, so the tip stops clear of the border. Aim at the target, not at a hand-computed standoff. Prefer an id — indices shift when a row is inserted |176| `gap` | number | `10` | the air `to:`/`from:` leaves between the tip and the target's border. `0` is flush (situational — K1) |177| `head` | `triangle`\|`chevron`\|`dot`\|`bar` | `triangle` | what is drawn at the arrow ends. `arrow` says *which* ends, `head` says *what* — centred on the stroke axis by construction |178| `dash` | `1` \| `[on,off]` | — | dashed stroke, **quantised to the run** so it always begins and ends on a whole dash (measured along arc length on a curve). Keeps its head |179| `waive` | 1 | — | this connector breaks a shape rule on purpose — `validate` stays quiet about it (the `over:1` of connector geometry) |180| `over` | 1 | — | declares a deliberate overlay: `validate`'s gap gate and `verify`'s collision check leave this row (and what it crosses) alone |181| `href` | url | — | http/https/mailto only. One inset anchor over the whole row (a painted CTA box + its label each carry it); presenting: a click opens it in a new tab; editing: a click selects the row, ⌘-click (Ctrl-click off Mac) opens it and the hover hint says so; a real `/Link` annotation in the `⤓` PDF and an anchor on every ⌘P page |182| `donut` | 0–100 | — | ring, `w` = diameter, `color` = fill |183| `svg` | string | — | inline SVG markup (no script, no external href) |184| `icon` | name | — | a Lucide icon by name (see GRAPHICS; `--icons` lists them) — expands to an `svg` row at create, `color` paints it |185| `img` | data: URI | — | image; `fit`, `pos` = object-fit/position |186| `anim` | `rise`\|`fade`\|`pop`\|`wipe` | — | entrance motion on slide entry, staggered 120 ms in model order (see MOTION) |187| `chart` | `{mark, data, …}` | — | a bar or line chart drawn into this row's x/y/w/h at create time (CHART ROW) |188| `css` | string | — | raw CSS escape hatch — validator warns |189| `group` | string | — | rows on a slide sharing one `group` are one unit in the editor: drag, nudge and marquee move them together, the selection draws one box round them, ⌘-click takes a member alone. Every row keeps canvas-space x/y — nothing is relative. A `chart` row's expansion shares one group; the library's kpi tiles, steps, timeline events and cta button are born grouped |190| `override` | masterId | — | partial row: only the props it carries replace the master's on this slide |191| `footer` | 1 | — | master only: the page counter renders inline here |192| `id` | string | — | master only, unique |193194Resolution order for any row: slot geometry ← master row (for `override` rows) ← the row's own props ← role treatment. The role fills whatever the row left unset **and always wins** `font`/`size`/`lh`/`ls` — a row can never change family, size, leading or tracking.195196## STYLE CONTRACT (`style.json`)197```json198{199 "tokens": { "bg": "#111315", "fg": "#F3F4F6", "muted": "#9CA3AF", "accent": "#5B9CF6", "card": "#1A1D21", "line": "#2C3138", "sel": "#5B9CF6", "box": "#20262E" },200 "roles": {201 "Title": { "font": "…", "size": 64, "weight": 800, "lh": 68, "ls": -1.5, "color": "var(--fg)" },202 "Supertitle": { "font": "ui-monospace,Menlo,monospace", "size": 12, "weight": 500, "lh": 16, "ls": 1.5, "color": "var(--accent)", "tt": "uppercase" },203 "H1": { "font": "…", "size": 34, "weight": 800, "lh": 40, "ls": -0.5, "color": "var(--fg)" },204 "H2": { "font": "…", "size": 22, "weight": 600, "lh": 28, "ls": -0.3, "color": "var(--fg)" },205 "Body": { "font": "…", "size": 16, "weight": 400, "lh": 24, "ls": 0, "color": "var(--fg)" },206 "Caption": { "font": "…", "size": 13, "weight": 400, "lh": 18, "ls": 0, "color": "var(--muted)" },207 "Label": { "font": "ui-monospace,Menlo,monospace", "size": 11, "weight": 500, "lh": 14, "ls": 1, "color": "var(--muted)", "tt": "uppercase" },208 "Stat": { "font": "…", "size": 40, "weight": 800, "lh": 44, "ls": -1, "color": "var(--accent)" }209 },210 "pad": { "chip": "3px 8px", "pill": "5px 12px" },211 "margin": 60,212 "gap": 4213}214```215- `tokens` → CSS custom properties on `:root`. `bg` is the editor chrome behind the slide; `card` is the slide surface; `box` the outlined-box fill; `sel` the selection colour.216- The eight roles are the whole type system — exactly these names: `Title` (display headline for cover/closing slides), `Supertitle` (kicker), `H1` (content-slide title), `H2`, `Body`, `Caption`, `Label` (mono, uppercase — chips, axis labels, footer), `Stat`. No H3, no Subtitle. One allowance: **`Stat2`**, an optional ninth role for the KPI tile — a hero "63%" and a card "$1.2M" cannot share one size. A style may define it; when it does not, `create` derives it from `Stat` at **0.6** (size, line height, tracking) and only when a row or slot asks for it, so a deck that never uses `Stat2` never carries it. The `kpi-grid` tiles wear it; the hero `stat` layout keeps `Stat`. A role is a complete treatment: `font`, `size`, `weight`, `lh`, `ls`, `color`, optional `tt`. One font and one size per role — never two sizes of "Body". A row may add `weight`, `color`, `tt`, `italic`; it can never carry `font`, `size`, `lh`, `ls` or `mono` (the validator rejects it, the engine ignores it).217- `margin` is the content inset the chrome sits on (footer counter's right edge, default 6% of `w`). Set it to match your layouts' left edge.218- `gap` is the air every row owes its neighbours (default 4px, the offset the chart library itself uses between a bar and its value). Raise it for a roomier deck; `validate` enforces it on declared boxes.219- `cw` on a role is the measured average glyph width in em (`width / chars / size` of a representative sentence in that font, weight and case). `validate` sizes `w:'auto'` rows and line counts with it; without it the blanket 0.55 stands, which runs ~20% wide on a sans and ~20% narrow on an uppercase mono. Measure once per brand font in Chromium and put it in `style.json`; the neutral roles carry theirs.220- The model's own `styles.roles` win over `style.json` per role; a model with no roles inherits the template's neutral scale.221222## LAYOUTS (slots)223- `layouts.<name>.<slot> = {x, y, w, h?, role}`; a slide opts in with `layout:'<name>'`.224- `slots.<slot>` (deck scope) applies under every layout — use it for a supertitle shared by all.225- A slotted row may carry local x/y/w/h overrides; edit the slot in the model to move every slide at once.226- Conventional slot names: `supertitle`, `title`, `body`, `body2`. Conventional layouts: `title` (cover), `content`, `section`.227- A slot may carry any row treatment besides geometry and role — `tile:1`, `bg`, `p:'chip'`, `align`, `nowrap`, `italic`, even `line` — and the engine spreads it onto the bound row. A roleless slot with `h` is paint or media: `{slot:'rule'}` alone draws it.228229## LAYOUT LIBRARY230231Thirty-one named layouts ship with the engine (`lib/layouts.mjs`), in the same shape as a `layouts` entry. Name one on a slide the deck does not define and `create` merges it into `deck.layouts`, scaled from its 960×540 cut to the canvas (1600×900 = ×1.67). Print the catalogue — name, group, density, use, slots — with:232```233node bin/validate.mjs --layouts234```235Read that instead of inventing geometry. The library is an accelerant, never a fence: a slide may use a library layout, a deck-defined layout, or free rows, and may mix library slots with extra free rows on the same slide (`layout:'kpi-grid'` plus a caption and a rule at y 400 is a normal slide). A deck-defined layout of the same name wins. A slotted row still takes its own x/y/w/h — the `override` path — so a brand with a display role taller than the neutral scale (Title 64/68, Stat 40/44 — what the library is cut for) nudges a slot without redefining the layout. An unknown name is still an error, and the error lists the library.236237| group | layout | density | slots |238|---|---|---|---|239| openers | `cover` | speaker | supertitle · title (Title) · body · caption |240| | `agenda` | reading | supertitle · title · n1–n5 (Label) + item1–item5 (Body) |241| | `section` | speaker | number (Label) · title (Title) · body |242| chrome | `content` | reading | supertitle · title (H1) — the template library's title chrome, the canvas free |243| | `title` | speaker | supertitle · title (Title, lower half) — a cover or divider |244| text | `statement` | speaker | title (Title) · caption |245| | `fact` | speaker | stat (Stat) · label · body |246| | `quote` | speaker | quote (H2, italic) · attribution (Caption) |247| | `two-cols` | reading | supertitle · title · left · right (Body) |248| | `two-cols-header` | reading | supertitle · title · header (H2) · left · right |249| visuals | `image-left` | reading | image (400×320 media) · supertitle · title · body |250| | `image-right` | reading | supertitle · title · body · image |251| modern | `bento-grid` | reading | supertitle · title · hero (paint) · hero-label · hero-value (Title) · hero-chart (376×160 media) · card1–2 (paint) + card1–2-label + card1–2-value (Stat) · action (accent paint) · action-label · action-body |252| | `image-hero-overlay` | speaker | image (full-bleed media, give it `img`) · scrim (paint) · label · title (Title) · caption — `hide` the footer on the slide |253| | `image-split` | speaker | image (440×540 media, right) · label · title (H1) · body · button (paint, give it `href`) · button-label (Label, same `href`) |254| | `annotated-shot` | reading | supertitle · title · shot (520×270 media) · callout1–3 (paint) + callout1–3-text (Body) + -leader (line) + -dot |255| | `three-up-cards` | reading | supertitle · title · card1–3 (paint) + card1–3-number (Label) + -head (H2) + -rule + -body |256| | `dashboard-composite` | reading | supertitle · title · kpi1–4 (paint) + kpi1–4-value (Stat) + -label · chart (500×180 media) · panel (paint) · panel-label · panel-body |257| | `table-insight` | reading | supertitle · title · table (500×210 media frame: place the table's rows inside it) · panel (paint) · panel-label · panel-body · panel-rule · panel-next (Label) |258| | `proof-strip` | reading | supertitle · title · logo1–5 (paint, give them `img`) + logo1–5-name (Label) · rule · stat1–3 (Stat) + stat1–3-label |259| | `team-grid` | reading | supertitle · title · photo1–4 (195×180 media, give them `img`) + name1–4 (H2) + role1–4 (Caption) |260| numbers | `kpi-grid` | reading | supertitle · title · kpi1–3 (tiles) · kpi1–3-delta (chips) · kpi1–3-label · body |261| | `kpi-grid-4` | reading | the same with four 195px tiles |262| | `stat` | speaker | supertitle · title · stat (the deck Stat size, centred) · caption |263| | `chart` | reading | supertitle · title · chart (840×276 media) · takeaway (Body) · source (Caption) |264| | `comparison` | reading | supertitle · title · left-head · right-head (H2) · left · right |265| diagrams | `process-steps` | reading | supertitle · title · n1–n4 (Label) · step1–step4 (tiles, Body) · body |266| | `diagram` | reading | supertitle · title · figure (840×320 media frame: place the figure's rows inside it) · caption (Caption, the claim) |267| plans | `timeline` | reading | supertitle · title · rule (paint) · d1–d4 (dots) · t1–t4 (Label) · e1–e4 (Body) |268| closers | `cta` | speaker | title (Title) · body · button (paint, give it `href`) · button-label (Body, same `href`) |269| | `end` | speaker | title (Title) · body · caption |270271Density is defined in DENSITY below: a **speaker** (fluffy) slide carries ≤ 3 points, a **reading** (dense) slide up to 8 with its own context. Every layout with H1 title chrome also carries the four dense slots `subtitle` · `note` · `source` · `legend` (unbound they draw nothing). Delta chips are `Label` on a `chip` pad in `var(--box)`, coloured `var(--ok, var(--accent))`; a falling delta sets `color:'var(--bad, var(--accent))'` on the row — the deck's `ok`/`bad` tokens if the style defines them, else the accent.272273Picking a layout by what the content is:274275| the slide's content is… | layout |276|---|---|277| the deck's name and promise | `cover`; `end` closes |278| what the deck will cover | `agenda`; `section` between parts |279| one claim | `statement`; with a number in it → `fact`; the number alone → `stat` |280| someone's words | `quote` |281| two bodies of text, a lede over two columns | `two-cols`, `two-cols-header` |282| a picture and a paragraph | `image-left` / `image-right` |283| three or four numbers with movement | `kpi-grid` / `kpi-grid-4` |284| a series with real numbers | `chart` — no numbers, no chart layout |285| A against B | `comparison` |286| steps in order | `process-steps`; dated → `timeline` |287| the ask | `cta` |288289## TEMPLATE LIBRARY290Fifty-nine finished slides ship with the engine (`lib/templates.mjs`, sources in `lib/templates/cat-*.mjs`): the candidate sheet surveyed across the open-source slide catalogs and promoted whole (2026-09-07). A template is a layout PLUS sample rows — an issue tree, a Sankey, a scorecard, a bento grid — so the agent binds content instead of drawing. Print the catalogue — id · tier · density · note, then every text key with its sample — with:291```292node bin/validate.mjs --templates293```294A slide names one and fills its keys: `{template: 'three-up-cards', fill: {t1: 'What you get', t2: 'Three things, one price.', t3: '01', …}}`. Every text row of the template is a key, `t1`…`tn` in row order; a key left out keeps the sample text (so fill them all before shipping). `create` expands the slide into the template's rows, scaled from the 960×540 cut to the canvas, sets the slide's `layout` to the template's chrome (`content` or `title` from the library — a deck-defined layout of that name wins) and its `density`, and keeps any free `els` after the template rows. An unknown template or fill key is a validate error listing what exists. A template is an accelerant like a layout: edit the rows it produced, add rows beside them, or draw free — nothing here is a fence.295Tiers: **core** (in 4+ surveyed catalogs), **standard** (consulting catalogs), **fringe** (dataviz literature, rare on slides). Categories: Narrative · Numbers · Comparison · Frameworks · Process · Charts · Modern.296## DENSITY297Two named densities, defined in `lib/layouts.mjs` (`DENSITY`) so "dense" and "fluffy" build the same deck every time. A deck says `density: 'speaker' | 'reading'`; a slide may carry its own. `validate` warns on every slide that misses its density's shape; an unknown density is an error.298| density | aka | for | the slide carries | max |299|---|---|---|---|---|300| `speaker` | **fluffy** | a presented deck — the speaker carries the rest | supertitle · title · one figure, number or ≤ 3 points · a caption at most | 3 points · 40 words |301| `reading` | **dense** | a leave-behind read without a speaker — the slide carries its own context | supertitle · title · `subtitle` (the claim in one sentence) · the figure or the points · `note` (what to make of it) · `source` · `legend` · footer naming the deck | 8 points · 140 words |302The dense chrome is four optional slots every H1-titled library layout carries (`DENSE`): `subtitle` (H2, muted, under the title), `note` (Body, muted, two lines above the foot), `source` (Caption, left foot), `legend` (Label, right foot via `right`); the subtitle is `nowrap`, one sentence, and parity fails a wrap. A layout that cannot seat one says so (`dense: {note: false}` on `chart`, whose `takeaway` is the note; all four on `diagram`). A reading slide binds at least one; a speaker slide leaves them alone. "Points" are the text rows that are not chrome (not supertitle/title/subtitle/note/source/legend/caption, not Label). The template library carries a density per template (`--templates` prints it): the speaker ones are covers, dividers, quotes, statements, hero numbers, the donut and gauge, the cycle and the tree; everything with a table, a grid or a series is reading.303## GRAPHICS304Five kinds of picture, one rule each. Colour is always a token; nothing loads from the network.305- **Icons** — `{icon: 'shield-check', x, y, w: 24, h: 24, color: 'var(--accent)'}`. The set is Lucide (ISC), 169 names, `node bin/validate.mjs --icons` prints them; `create` inlines the svg (stroke `currentColor`, so `color` paints it) and only the icons a deck uses reach the file. One icon per point, on the 24 grid (24/32/40 px), left of its label on the label's cap line, the same weight throughout. An icon says what the label says — never decoration, never a second idea, never a filled emoji.306- **Glyphs** — a number in a circle, a letter chip, a status dot: rows, not images. A step glyph is a `dot` (or a `radius:'50%'` box) with a `Label` centred in it (`valign:'middle'`); a status dot is a 10px `bg` circle in `var(--ok)`/`var(--bad)`. Group the glyph with its text.307- **Images** — `img` is a data: URI, `fit:'cover'`, explicit `w`/`h`, one per slide, never stretched, never behind text unless a tint band (`bg` with `op`) sits between. Budget ≈ 100 KB an image, the file under ~1 MB. Photographs carry the `image-left`/`image-right`/`image-hero-overlay`/`image-split` layouts; a screenshot gets `annotated-shot` with callout rows.308- **Figures** — nodes, connectors, timelines, trees as rows (the diagram helper, the `diagram` layout, the framework and process templates), so every box and edge stays editable. An `svg` row is for a fill the engine has no primitive for (a Sankey ribbon, an area band) — never for text, never for a whole figure.309- **Clips** — a short310311…(truncated)