oficina / slides Public

feat: HTML slide renderer, six themes, slides.css

internal/slides/render turns a slide from the core's JSON view into
one <section slides-slide>: the template's regions or the canvas
frame, blocks as semantic elements, the theme as custom properties
inline, and the phone layer as data-phone-* attributes the stylesheet
honours (spec §2.3 to §2.6, §4.1).

- render.Slide, render.Text, render.Notes, render.Themes,
  render.ThemeVars. Slide fails only on a bad index or a nil deck;
  anything a half-written document contains renders as something.
- Six fixed-look themes, plain hex rather than light-dark(), because a
  deck is the same on the presenter's laptop and the projector — and
  because that is what makes contrast decidable. TestThemeContrast
  holds every theme to 4.5:1 on body and 3:1 on each accent.
- A slide background colour or a deck accent that would be unreadable
  is not emitted: the renderer recomputes a foreground that clears
  §8's floor, or drops the override (WCAG 2.2 AA, no exemptions).
- Links limited to http, https and mailto; a refused href renders the
  words with no anchor. Colours, font stacks and asset ids pass an
  allowlist before they reach a style attribute.
- Golden tests over every template and every block type in both
  viewports, regenerable with -update.
- slides.css scales one design with cqw inside a container-type
  section; the container query collapses the grids and honours the
  phone layer, and data-vp plus a media query drop the 16:9 frame for
  a flow slide on a phone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 85633a0e2a971ad98141c652e7d9502e1a685f75 parent 6823fbc
22 files changed, +3157 −0
  • internal/slides/render/render.go +1136 −0
  • internal/slides/render/render_test.go +866 −0
  • internal/slides/render/testdata/blank-desktop.golden.html +43 −0
  • internal/slides/render/testdata/blank-phone.golden.html +43 −0
  • internal/slides/render/testdata/canvas-desktop.golden.html +9 −0
  • internal/slides/render/testdata/canvas-phone.golden.html +9 −0
  • internal/slides/render/testdata/canvas-reflow-desktop.golden.html +13 −0
  • internal/slides/render/testdata/canvas-reflow-phone.golden.html +15 −0
  • internal/slides/render/testdata/image-full-desktop.golden.html +10 −0
  • internal/slides/render/testdata/image-full-phone.golden.html +10 −0
  • internal/slides/render/testdata/quote-desktop.golden.html +15 −0
  • internal/slides/render/testdata/quote-phone.golden.html +15 −0
  • internal/slides/render/testdata/section-desktop.golden.html +5 −0
  • internal/slides/render/testdata/section-phone.golden.html +5 −0
  • internal/slides/render/testdata/title-body-desktop.golden.html +18 −0
  • internal/slides/render/testdata/title-body-phone.golden.html +18 −0
  • internal/slides/render/testdata/title-desktop.golden.html +10 −0
  • internal/slides/render/testdata/title-phone.golden.html +10 −0
  • internal/slides/render/testdata/two-column-desktop.golden.html +23 −0
  • internal/slides/render/testdata/two-column-phone.golden.html +23 −0
  • internal/slides/render/themes.go +278 −0
  • internal/slides/static/slides.css +583 −0

Intent

Human

<task-notification> <task-id>a1919e97d96a079a3</task-id> <tool-use-id>toolu_01TcYQkraAK48WC8wMpiR8qW</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a1919e97d96a079a3.output</output-file> <status>completed</status> <summary>Agent "Task 3 fix round 1" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Working tree is clean (no untouched files left uncommitted, nothing else staged). Status: Complete. Commit: `6823fbc` — fix: decks review round 1 — settings row and document commit together Tests: `TestSettingsRowAndDocumentChangeTogether` PASS; full `go test ./...` and `make ci` (vet, tests, migration check) all green. Concerns: None outstanding — the two-writes-that-should-be-one shape was unique to `saveDeckSettings`; `createDeck`/`seedSampleDeck` only pass `nil` rowUpdates (behavior unchanged); `deleteDeck` untouched (single-row delete, no race). Report: `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-1-live/task-3-report.md` (gitignored, workspace-only, as in round 1).</result> <usage><subagent_tokens>115976</subagent_tokens><tool_uses>74</tool_uses><duration_ms>324720</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>af3f71da572469175</task-id> <tool-use-id>toolu_01Ux9ETHtryDFX4GjoT5ueLX</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/af3f71da572469175.output</output-file> <status>completed</status> <summary>Agent "Re-review Task 3 fix round 1" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>### Findings - **Important — settings row and document commit together**: ADDRESSED — `internal/slides/decks.go:283-309` (`applyOps`) merges `rowUpdates` into the same `fields` map used by the seq-bump `Updates` call, executed via `tx.Model(&amp;Deck{}).Where("id = ? AND seq = ?", d.ID, d.Seq).Updates(fields)` — scoped to the transaction's `tx`, never `a.db.G`, and restricted to named columns supplied by the caller (an allowlist by construction of the map, not a literal `.Select()`, but equivalent in effect since GORM's map-form `Updates` only ever touches the listed keys). `saveDeckSettings` (`internal/slides/decks.go:938-945`) no longer makes a second, separate `Updates` call — that code is deleted and replaced by a single `editDeck(..., rowUpdates)` call. Test `TestSettingsRowAndDocumentChangeTogether` (`internal/slides/decks_test.go:20-79`) forces the row update to fail (an unknown column) inside `applyOps`' transaction and asserts both the reloaded row's title/seq and the document's `meta.Title` (read back via `openDeck`) are unchanged. Verified locally: `go test ./internal/slides/... -run TestSettingsRowAndDocumentChangeTogether -v` → PASS. - **Optional Minor — `(*app).Close()` closing the `core.Runtime`**: ADDRESSED — `internal/slides/handlers.go` adds `func (a *app) Close() error` (nil-safe on `a`/`a.core`) calling `a.core.Close(context.Background())`, which matches `core.Runtime.Close(ctx context.Context) error` at `internal/slides/core/core.go:145`. `slides.New`'s signature changed to `(*http.ServeMux, *app, error)` (`internal/slides/app.go`), and every call site was updated to hold and close it: `cmd/slides/main.go:225` + `defer a.Close()`, `internal/slidestest/harness_test.go:117` + `t.Cleanup`, `internal/slidestest/browser_test.go:46` + `t.Cleanup`, `internal/slidestest/signin_test.go:275` + `t.Cleanup` (the fourth call site, `signin_test.go:258`, is the refusal-path test where `New` errors and returns `nil, nil, err`, so there is nothing to close — correctly left alone). ### New Breakage in Fix Diff (Critical/Important only) None found. All four `slides.New(` call sites in the tree were updated for the new three-value signature (confirmed by repo-wide grep); all `applyOps`/`editDeck` call sites were updated for the new `rowUpdates` parameter (confirmed by repo-wide grep); the transaction callback in `applyOps` uses only `tx`, never `a.db.G` (satisfies rastrillo SKILL.md §3's writer-hang risk). `defer a.Close()` in `main.go` is placed after the `err != nil` early return, so no nil-pointer risk on the startup-refusal path. ### Out-of-Scope Observations The unrelated `internal/slides/render` package currently fails to build (`no non-test Go files`) when running `go test ./internal/slides/...` — per the brief this is another agent's in-progress work under `internal/slides/render/` and was ignored as instructed; it did not affect the targeted test run for `internal/slides`. ### Verdict All findings addressed</result> <usage><subagent_tokens>62758</subagent_tokens><tool_uses>6</tool_uses><duration_ms>73333</duration_ms></usage> </task-notification>

Attributed to this commit: 15.6 g CO₂e (1823479 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-slice-1, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/internal/slides/render/render.go b/internal/slides/render/render.go
new file mode 100644
index 0000000..39e74d3
--- /dev/null
+++ b/internal/slides/render/render.go
@@ -0,0 +1,1136 @@
+// Package render turns a slide from the core's JSON view (the deck
+// package's types) into the HTML every live surface shows: the viewer,
+// the presenter, the remote's thumbnails and the editor stage. Spec
+// §4.1.
+//
+// One slide is one `<section slides-slide>`. The theme arrives as CSS
+// custom properties inline on that section, the template's regions and
+// the canvas frame arrive as attributes, and the phone layer arrives as
+// data-phone-* attributes that internal/slides/static/slides.css
+// honours. Nothing here writes a style rule; the stylesheet holds the
+// looks, and this package holds the tree.
+//
+// The one rule (§0.1) is the shape of the whole file: a slide is one
+// block tree rendered many ways, so Slide takes a Viewport and never a
+// second document. The phone layer only ever adds — a block hidden on a
+// phone is still in the output, carrying the attribute the stylesheet
+// hides it by, because the notes, the search index and the presenter
+// still want its words.
+//
+// ── Why the markup is built rather than templated ─────────────────────
+//
+// Everything a person typed goes through html/template: Text renders
+// runs and links through a real template, so link marks get contextual
+// URL filtering on top of this package's own scheme allowlist. The
+// surrounding elements are assembled by hand because html/template
+// cannot take a dynamic tag name (`<{{.Tag}}`) or a dynamic attribute
+// name, and eleven block types across seven elements would otherwise
+// mean a template per element with the attribute block copied into each
+// one. The invariants that make it safe are narrow enough to state:
+//
+// - Tag and attribute names are string literals in this file. Nothing
+// from a deck ever becomes one.
+// - Every attribute VALUE goes through template.HTMLEscapeString and
+// sits in double quotes.
+// - Values that land in CSS or in a URL — colours, font stacks, asset
+// ids, hrefs — pass an allowlist first (safeColor, safeFontStack,
+// safeAssetID, safeHref), which is stricter than escaping: an
+// unsafe value is dropped, not neutered.
+// - Every run of text goes through esc or through the template.
+package render
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/url"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+
+ "amadan.net/oficina/slides/internal/slides/deck"
+)
+
+// Viewport is which of the two shapes a slide is being rendered for.
+// There are exactly two because there are exactly two in the model: the
+// desktop layout is the base and the phone layer adds to it (§2.5).
+type Viewport int
+
+const (
+ // Desktop is the base: the 16:9 frame, the template's grid as the
+ // template drew it, canvas boxes where they were put.
+ Desktop Viewport = iota
+ // Phone marks the section data-vp="phone" and, on a canvas slide
+ // whose phone is "reflow", lays the blocks out as a single column.
+ Phone
+)
+
+func (v Viewport) String() string {
+ if v == Phone {
+ return "phone"
+ }
+ return "desktop"
+}
+
+// Slide renders d's slide at index for vp: exactly one <section>, with
+// no surrounding whitespace, ready to drop into a page.
+//
+// It fails only on a caller mistake — no deck, or an index that is not
+// a slide. Everything a deck can contain renders as something: an
+// unknown theme falls back to the default, an unknown block type
+// renders nothing rather than breaking the slide around it, and a prop
+// with the wrong type takes its default. A half-written document that
+// arrived from a peer mid-edit is the ordinary case, not an error.
+func Slide(d *deck.Deck, index int, vp Viewport) (template.HTML, error) {
+ if d == nil {
+ return "", fmt.Errorf("render: no deck")
+ }
+ if index < 0 || index >= len(d.Slides) {
+ return "", fmt.Errorf("render: slide %d is outside a deck of %d", index, len(d.Slides))
+ }
+ s := d.Slides[index]
+
+ layout := s.Layout
+ if layout != "canvas" {
+ layout = "flow"
+ }
+ tmpl := s.Template
+ if !slices.Contains(templates, tmpl) {
+ tmpl = "blank"
+ }
+
+ // §2.3: a canvas slide set to phone: reflow becomes a blank flow
+ // slide on a phone, its blocks in z then top-left reading order.
+ // The slide says it was a canvas so the editor stage and the
+ // presenter can tell what they are looking at.
+ reflowed := false
+ if layout == "canvas" && vp == Phone && s.Phone == "reflow" {
+ layout, tmpl, reflowed = "flow", "blank", true
+ }
+
+ c := &renderer{deck: d, canvas: layout == "canvas"}
+
+ attrs := []attr{
+ {name: "slides-slide", bare: true},
+ {name: "data-index", value: strconv.Itoa(index)},
+ {name: "data-id", value: s.ID},
+ {name: "data-layout", value: layout},
+ {name: "data-template", value: tmpl},
+ {name: "data-theme", value: c.themeID(d.Meta.Theme)},
+ }
+ if kind := s.Background.Kind; kind == "color" || kind == "image" {
+ attrs = append(attrs, attr{name: "data-background", value: kind})
+ }
+ if t := s.Transition; t != "" && t != "none" {
+ attrs = append(attrs, attr{name: "data-transition", value: t})
+ }
+ if vp == Phone {
+ attrs = append(attrs, attr{name: "data-vp", value: "phone"})
+ }
+ if reflowed {
+ attrs = append(attrs, attr{name: "data-reflowed", value: "canvas"})
+ }
+
+ return elBlock("section", attrs, c.sectionStyle(s), c.body(s, reflowed)), nil
+}
+
+// Text renders the §2.6 text schema: one <p> per paragraph, runs inside
+// carrying <strong>, <em>, <code>, <mark> and <a>. It is the seam every
+// text-bearing block goes through, and the only place a link is made.
+func Text(t deck.Text) template.HTML {
+ if len(t) == 0 {
+ return ""
+ }
+ out := make([]template.HTML, 0, len(t))
+ for _, p := range t {
+ out = append(out, elInline("p", nil, "", inline(p)))
+ }
+ return join(out)
+}
+
+// Notes renders speaker notes — the presenter's pane, the scriptless
+// deck page's notes under the slide, the printed handout.
+//
+// It is Text under another name, and a function rather than an alias
+// because the two surfaces are not the same surface: notes are the one
+// text a viewer is never supposed to see, and having a name for them
+// means a later change (a heading, a lang attribute, stripping links
+// from a projected pane) has one place to land instead of touching
+// every caller of Text.
+func Notes(t deck.Text) template.HTML { return Text(t) }
+
+// templates is the flow template vocabulary (§2.2). Anything else is
+// rendered as blank, which has a single body region and always works.
+var templates = []string{"title", "title-body", "two-column", "section", "image-full", "quote", "blank"}
+
+// regionOrder is the order regions appear in the section, whatever
+// order the blocks arrived in. It is the reading order the phone
+// collapses to, so it is also the order a screen reader hears: the
+// heading, then the body, then the aside, then the media.
+//
+// image-full puts the heading over the media, and does it with a grid
+// area and a z-index in the stylesheet rather than by moving the
+// heading down the document — the picture is not the first thing the
+// slide is about.
+var regionOrder = []string{"heading", "body", "aside", "media"}
+
+// renderer is one Slide call's state: the deck it is reading, and
+// whether this slide's blocks carry boxes. The viewport is not on it —
+// by the time a block is being rendered every viewport-dependent
+// decision has already been made (the section's data-vp, and whether a
+// canvas slide was reflowed), and keeping it around would invite a
+// second, contradictory one further down.
+type renderer struct {
+ deck *deck.Deck
+ canvas bool
+}
+
+// themeID resolves a deck's theme name to one this build has.
+func (c *renderer) themeID(name string) string {
+ if _, ok := themeVars[name]; ok {
+ return name
+ }
+ return defaultThemeID
+}
+
+// sectionStyle is the theme's custom properties, plus the three things
+// a deck may override: meta.accent, meta.fonts, and the slide's own
+// background. Each override is allowlisted before it is written, and a
+// value that does not pass is dropped — the theme's own value stands.
+//
+// Colour overrides pass a second test after the allowlist: the result
+// has to be readable. §8 takes WCAG 2.2 AA with no exemptions, and a
+// deck that sets a plum background on a theme whose text is near-black
+// would otherwise render an unreadable slide with nothing to say so. A
+// background colour therefore brings a foreground and accents that
+// clear the thresholds against it, and an accent a deck names is used
+// only if it clears 3:1 against the background it will actually sit on.
+func (c *renderer) sectionStyle(s deck.Slide) string {
+ vars := themeVars[c.themeID(c.deck.Meta.Theme)]
+ out := make(map[string]string, len(vars)+1)
+ for k, v := range vars {
+ out[k] = v
+ }
+ if f := c.deck.Meta.Fonts; f != nil {
+ if h := safeFontStack(f.Heading); h != "" {
+ out["--slides-font-heading"] = h
+ }
+ if b := safeFontStack(f.Body); b != "" {
+ out["--slides-font-body"] = b
+ }
+ }
+ // The slide's own backdrop (§2.2). A colour replaces the theme's
+ // background outright — replaces, rather than being appended as a
+ // second --slides-bg that happens to win, so the style attribute
+ // says one thing about the background and not two. Hex only: a
+ // colour keyword is a colour this package cannot measure, and an
+ // unmeasurable background cannot be given a readable foreground.
+ var image string
+ switch s.Background.Kind {
+ case "color":
+ if bg := safeHexColor(s.Background.Value); bg != "" && bg != out["--slides-bg"] {
+ out["--slides-bg"] = bg
+ // The theme's own text first, then the colour it was
+ // sitting on (the light half of a dark theme, and the
+ // other way round), then plain black or white.
+ out["--slides-fg"] = readableOn(bg, bodyContrast,
+ vars["--slides-fg"], vars["--slides-bg"], "#000000", "#ffffff")
+ // An accent that cannot be seen on the new background
+ // collapses to the foreground, so the slide reads even
+ // though it has lost its second colour. A deck that wants
+ // an accent back on a background of its own sets
+ // meta.accent, which is judged below.
+ for _, k := range []string{"--slides-accent", "--slides-accent-2"} {
+ out[k] = readableOn(bg, accentContrast, vars[k], out["--slides-fg"])
+ }
+ }
+ case "image":
+ // An image becomes a custom property the stylesheet paints. It
+ // points at /assets/<id>, which slice 2 will serve — see
+ // imageBlock for the same placeholder. Unquoted url() because
+ // safeAssetID has already ruled out everything that would need
+ // quoting.
+ if id := safeAssetID(s.Background.Value); id != "" {
+ image = "--slides-bg-image:url(" + assetPath(id) + ")"
+ }
+ }
+
+ // The deck's accent, last, so it is judged against the background
+ // this slide actually has rather than against the theme's.
+ if accent := safeHexColor(c.deck.Meta.Accent); accent != "" &&
+ contrastRatio(accent, out["--slides-bg"]) >= accentContrast {
+ out["--slides-accent"] = accent
+ }
+
+ parts := make([]string, 0, len(varOrder)+1)
+ for _, k := range varOrder {
+ parts = append(parts, k+":"+out[k])
+ }
+ if image != "" {
+ parts = append(parts, image)
+ }
+ return strings.Join(parts, ";")
+}
+
+// body is the section's children: region wrappers on a flow slide,
+// bare blocks on a canvas one.
+func (c *renderer) body(s deck.Slide, reflowed bool) []template.HTML {
+ blocks := s.Blocks
+ if reflowed {
+ blocks = reflowOrder(blocks)
+ }
+
+ // A columns block's children render inside it, in the order
+ // props.children names them, and nowhere else (§2.4).
+ byID := make(map[string]deck.Block, len(blocks))
+ claimed := make(map[string]bool)
+ for _, b := range blocks {
+ byID[b.ID] = b
+ }
+ for _, b := range blocks {
+ if b.Type != "columns" {
+ continue
+ }
+ for _, id := range childIDs(b) {
+ if _, ok := byID[id]; ok {
+ claimed[id] = true
+ }
+ }
+ }
+
+ // done is shared across the whole slide, so a block renders at most
+ // once however many columns blocks name it. claimed decides WHERE
+ // it renders; done makes sure that is the only place.
+ done := map[string]bool{}
+
+ if c.canvas {
+ out := make([]template.HTML, 0, len(blocks))
+ for _, b := range blocks {
+ if claimed[b.ID] {
+ continue
+ }
+ if h := c.block(b, byID, done); h != "" {
+ out = append(out, h)
+ }
+ }
+ return out
+ }
+
+ // Flow: group into regions, canonical regions first, then any
+ // region name the core let through, in first-appearance order.
+ grouped := map[string][]template.HTML{}
+ var extra []string
+ for _, b := range blocks {
+ if claimed[b.ID] {
+ continue
+ }
+ name := b.Region
+ switch {
+ case name == "":
+ name = "body"
+ case !slices.Contains(regionOrder, name) && !slices.Contains(extra, name):
+ extra = append(extra, name)
+ }
+ if h := c.block(b, byID, done); h != "" {
+ grouped[name] = append(grouped[name], h)
+ }
+ }
+
+ out := make([]template.HTML, 0, len(regionOrder)+len(extra))
+ for _, name := range append(slices.Clone(regionOrder), extra...) {
+ if len(grouped[name]) == 0 {
+ continue
+ }
+ out = append(out, elBlock("div", []attr{
+ {name: "slides-region", bare: true},
+ {name: "data-region", value: name},
+ }, "", grouped[name]))
+ }
+ return out
+}
+
+// reflowOrder is §2.3's ordering for a canvas slide reflowed onto a
+// phone: z, then top, then left. Stable, so two blocks stacked at the
+// same point keep the order the array had.
+func reflowOrder(blocks []deck.Block) []deck.Block {
+ out := slices.Clone(blocks)
+ slices.SortStableFunc(out, func(a, b deck.Block) int {
+ if a.Z != b.Z {
+ return a.Z - b.Z
+ }
+ ax, ay := corner(a.Box)
+ bx, by := corner(b.Box)
+ switch {
+ case ay < by:
+ return -1
+ case ay > by:
+ return 1
+ case ax < bx:
+ return -1
+ case ax > bx:
+ return 1
+ }
+ return 0
+ })
+ return out
+}
+
+// corner is a box's top-left, or the origin for a block that has none.
+func corner(b *deck.Box) (x, y float64) {
+ if b == nil {
+ return 0, 0
+ }
+ return b.X, b.Y
+}
+
+// block renders one block. done is the set already rendered on this
+// slide: it stops a block appearing twice when two columns blocks name
+// it, and it stops a columns block that names a columns block that
+// names it back — the core forbids a block naming itself, not a cycle
+// through two.
+func (c *renderer) block(b deck.Block, byID map[string]deck.Block, done map[string]bool) template.HTML {
+ if done[b.ID] {
+ return ""
+ }
+ done[b.ID] = true
+
+ switch b.Type {
+ case "heading":
+ return c.headingBlock(b)
+ case "text":
+ return c.textBlock(b)
+ case "list":
+ return c.listBlock(b)
+ case "quote":
+ return c.quoteBlock(b)
+ case "image":
+ return c.imageBlock(b)
+ case "columns":
+ return c.columnsBlock(b, byID, done)
+ case "code":
+ return c.codeBlock(b)
+ case "shape":
+ return c.shapeBlock(b)
+ case "table":
+ return c.tableBlock(b)
+ case "divider":
+ return elVoid("hr", c.attrs(b), c.style(b))
+ case "spacer":
+ return elInline("div", c.attrs(b,
+ attr{name: "data-size", value: sizeProp(b.Props, "l")},
+ attr{name: "aria-hidden", value: "true"}), c.style(b), "")
+ }
+ // An unknown type is a block from a newer build of the core.
+ // Rendering nothing keeps the rest of the slide standing.
+ return ""
+}
+
+// attrs is the attribute set every block carries: the marker the
+// stylesheet selects on, the type, the id the editor addresses it by,
+// then whatever the caller adds, then the placement (region or z) and
+// the phone layer.
+func (c *renderer) attrs(b deck.Block, extra ...attr) []attr {
+ out := []attr{
+ {name: "slides-block", bare: true},
+ {name: "data-block", value: b.Type},
+ {name: "data-id", value: b.ID},
+ }
+ out = append(out, extra...)
+ if c.canvas {
+ out = append(out, attr{name: "data-z", value: strconv.Itoa(b.Z)})
+ } else if b.Region != "" {
+ out = append(out, attr{name: "data-region", value: b.Region})
+ }
+
+ // The phone layer, always emitted whichever viewport this is: the
+ // stylesheet honours it under the phone container query, and the
+ // editor's desktop/phone switcher (§3.2) reads it back.
+ p := b.Phone
+ if p.Hidden {
+ out = append(out, attr{name: "data-phone-hidden", value: "true"})
+ }
+ if p.Order != nil {
+ out = append(out, attr{name: "data-phone-order", value: strconv.Itoa(*p.Order)})
+ }
+ if p.Size != "" {
+ out = append(out, attr{name: "data-phone-size", value: sizeStep(p.Size, "m")})
+ }
+ if p.Box != nil && c.canvas {
+ out = append(out, attr{name: "data-phone-box", value: fracs(*p.Box)})
+ }
+ return out
+}
+
+// style is a block's inline style: nothing at all on a flow slide, and
+// on a canvas slide the box as percentages of the frame plus the z.
+// A phone box rides along as custom properties, which is what lets the
+// stylesheet move the block under its container query without this
+// package needing to know how wide the screen turned out to be.
+//
+// --slides-phone-order is here for the same reason: CSS cannot read an
+// integer out of a data attribute, so the order the phone layer asks
+// for has to arrive as a value.
+func (c *renderer) style(b deck.Block) string {
+ var parts []string
+ if c.canvas && b.Box != nil {
+ parts = append(parts,
+ "left:"+pct(b.Box.X),
+ "top:"+pct(b.Box.Y),
+ "width:"+pct(b.Box.W),
+ "height:"+pct(b.Box.H),
+ "z-index:"+strconv.Itoa(b.Z),
+ )
+ if p := b.Phone.Box; p != nil {
+ parts = append(parts,
+ "--slides-phone-left:"+pct(p.X),
+ "--slides-phone-top:"+pct(p.Y),
+ "--slides-phone-width:"+pct(p.W),
+ "--slides-phone-height:"+pct(p.H),
+ )
+ }
+ }
+ if o := b.Phone.Order; o != nil {
+ parts = append(parts, "--slides-phone-order:"+strconv.Itoa(*o))
+ }
+ if r := numProp(b.Props, "radius", 0); b.Type == "image" && r > 0 {
+ parts = append(parts, "--slides-radius:"+num(r)+"px")
+ }
+ return strings.Join(parts, ";")
+}
+
+func (c *renderer) headingBlock(b deck.Block) template.HTML {
+ level := int(numProp(b.Props, "level", 1))
+ if level < 1 || level > 3 {
+ level = 1
+ }
+ // A heading is one line by the schema; a stray second paragraph
+ // becomes a line break rather than a second heading.
+ lines := make([]template.HTML, 0, len(b.Text))
+ for _, p := range b.Text {
+ lines = append(lines, inline(p))
+ }
+ return elInline("h"+strconv.Itoa(level), c.attrs(b,
+ attr{name: "data-level", value: strconv.Itoa(level)}),
+ c.style(b), joinWith("<br>", lines))
+}
+
+func (c *renderer) textBlock(b deck.Block) template.HTML {
+ extra := []attr{{name: "data-size", value: sizeProp(b.Props, "m")}}
+ if a := alignProp(b.Props); a != "" {
+ extra = append(extra, attr{name: "data-align", value: a})
+ }
+ paras := make([]template.HTML, 0, len(b.Text))
+ for _, p := range b.Text {
+ paras = append(paras, elInline("p", nil, "", inline(p)))
+ }
+ return elBlock("div", c.attrs(b, extra...), c.style(b), paras)
+}
+
+func (c *renderer) listBlock(b deck.Block) template.HTML {
+ style, tag := "bullet", "ul"
+ if s, _ := b.Props["style"].(string); s == "number" {
+ style, tag = "number", "ol"
+ }
+ extra := []attr{
+ {name: "data-style", value: style},
+ {name: "data-size", value: sizeProp(b.Props, "m")},
+ }
+ // A nested list is a second list block with indent 1 (§2.6), not a
+ // nested <ul>: the schema has no nesting, so neither does this.
+ if n := int(numProp(b.Props, "indent", 0)); n > 0 {
+ if n > 3 {
+ n = 3
+ }
+ extra = append(extra, attr{name: "data-indent", value: strconv.Itoa(n)})
+ }
+ items := make([]template.HTML, 0, len(b.Text))
+ for _, p := range b.Text {
+ items = append(items, elInline("li", nil, "", inline(p)))
+ }
+ return elBlock(tag, c.attrs(b, extra...), c.style(b), items)
+}
+
+func (c *renderer) quoteBlock(b deck.Block) template.HTML {
+ paras := make([]template.HTML, 0, len(b.Text))
+ for _, p := range b.Text {
+ paras = append(paras, elInline("p", nil, "", inline(p)))
+ }
+ // figure + figcaption rather than a <cite> loose inside the
+ // blockquote: the attribution is about the quote, it is not part of
+ // what was said.
+ children := []template.HTML{elBlock("blockquote", nil, "", paras)}
+ if cite, _ := b.Props["cite"].(string); cite != "" {
+ children = append(children, elInline("figcaption", nil, "",
+ elInline("cite", nil, "", esc(cite))))
+ }
+ return elBlock("figure", c.attrs(b), c.style(b), children)
+}
+
+func (c *renderer) imageBlock(b deck.Block) template.HTML {
+ id := safeAssetID(stringProp(b.Props, "asset"))
+ fit := "cover"
+ if f, _ := b.Props["fit"].(string); f == "contain" {
+ fit = "contain"
+ }
+ extra := []attr{{name: "data-fit", value: fit}}
+ if id == "" {
+ // No asset, or an id that is not an id: an empty frame the
+ // editor can show as unfinished, never a broken <img>.
+ return elInline("figure", append(c.attrs(b, extra...),
+ attr{name: "data-missing-asset", value: "true"}), c.style(b), "")
+ }
+
+ alt, missing := c.altFor(b, id)
+ img := []attr{
+ {name: "src", value: assetPath(id)},
+ {name: "alt", value: alt},
+ }
+ if a, ok := c.deck.Assets[id]; ok && a.Width > 0 && a.Height > 0 {
+ img = append(img,
+ attr{name: "width", value: strconv.Itoa(a.Width)},
+ attr{name: "height", value: strconv.Itoa(a.Height)})
+ }
+ if missing {
+ // §2.4 makes alt required. The attribute is never omitted — an
+ // <img> with no alt is read out as its filename — so a missing
+ // one is an empty alt plus a mark the editor can surface.
+ img = append(img, attr{name: "data-missing-alt", value: "true"})
+ }
+ return elBlock("figure", c.attrs(b, extra...), c.style(b),
+ []template.HTML{elVoid("img", img, "")})
+}
+
+// altFor resolves an image's alternative text: the block's own alt, the
+// asset's registered alt, or empty. Decorative means empty on purpose
+// (§2.4) and is not reported as missing.
+func (c *renderer) altFor(b deck.Block, id string) (alt string, missing bool) {
+ if v, ok := b.Props["alt"].(string); ok && v != "" {
+ return v, false
+ }
+ if dec, _ := b.Props["decorative"].(bool); dec {
+ return "", false
+ }
+ if a, ok := c.deck.Assets[id]; ok && a.Alt != "" {
+ return a.Alt, false
+ }
+ return "", true
+}
+
+func (c *renderer) columnsBlock(b deck.Block, byID map[string]deck.Block, done map[string]bool) template.HTML {
+ count := int(numProp(b.Props, "count", 2))
+ if count != 3 {
+ count = 2
+ }
+ var children []template.HTML
+ for _, id := range childIDs(b) {
+ child, ok := byID[id]
+ if !ok {
+ continue
+ }
+ if h := c.block(child, byID, done); h != "" {
+ children = append(children, h)
+ }
+ }
+ return elBlock("div", c.attrs(b,
+ attr{name: "data-count", value: strconv.Itoa(count)}), c.style(b), children)
+}
+
+func (c *renderer) codeBlock(b deck.Block) template.HTML {
+ extra := []attr{{name: "data-size", value: sizeProp(b.Props, "m")}}
+ code := []attr{}
+ if lang := stringProp(b.Props, "language"); langPattern.MatchString(lang) {
+ extra = append(extra, attr{name: "data-language", value: lang})
+ code = append(code, attr{name: "class", value: "language-" + lang})
+ }
+ // No highlighter: the text is the text, escaped, one paragraph per
+ // line (§2.4). Nothing inside a <pre> gets extra whitespace.
+ return elInline("pre", c.attrs(b, extra...), c.style(b),
+ elInline("code", code, "", esc(b.Text.Plain())))
+}
+
+// shapeKinds is the §2.4 vocabulary. Anything else draws a rectangle,
+// which is the shape that says least.
+var shapeKinds = []string{"rect", "ellipse", "line", "arrow"}
+
+func (c *renderer) shapeBlock(b deck.Block) template.HTML {
+ kind := stringProp(b.Props, "kind")
+ if !slices.Contains(shapeKinds, kind) {
+ kind = "rect"
+ }
+ fill := safeColor(stringProp(b.Props, "fill"))
+ if fill == "" {
+ fill = "none"
+ }
+ stroke := safeColor(stringProp(b.Props, "stroke"))
+ if stroke == "" {
+ stroke = "currentColor"
+ }
+ width := num(clamp(numProp(b.Props, "strokeWidth", 1), 0, 50))
+ radius := num(clamp(numProp(b.Props, "radius", 0), 0, 50))
+
+ // A 0..100 viewBox stretched to the block's box, with non-scaling
+ // strokes so a wide shape does not get a fat left edge and a thin
+ // top one. Geometry only — the colours come from props, the size
+ // from the box or the region.
+ geom := []attr{{name: "fill", value: fill}, {name: "stroke", value: stroke},
+ {name: "stroke-width", value: width}, {name: "vector-effect", value: "non-scaling-stroke"}}
+ var shape template.HTML
+ switch kind {
+ case "ellipse":
+ shape = elClosed("ellipse", append([]attr{
+ {name: "cx", value: "50"}, {name: "cy", value: "50"},
+ {name: "rx", value: "49"}, {name: "ry", value: "49"}}, geom...))
+ case "line":
+ shape = elClosed("line", append([]attr{
+ {name: "x1", value: "1"}, {name: "y1", value: "50"},
+ {name: "x2", value: "99"}, {name: "y2", value: "50"}}, geom...))
+ case "arrow":
+ line := elClosed("line", append([]attr{
+ {name: "x1", value: "1"}, {name: "y1", value: "50"},
+ {name: "x2", value: "88"}, {name: "y2", value: "50"}}, geom...))
+ head := elClosed("path", []attr{
+ {name: "d", value: "M99 50 L86 42 L86 58 Z"},
+ {name: "fill", value: stroke}, {name: "stroke", value: "none"}})
+ shape = join([]template.HTML{line, head})
+ default:
+ shape = elClosed("rect", append([]attr{
+ {name: "x", value: "1"}, {name: "y", value: "1"},
+ {name: "width", value: "98"}, {name: "height", value: "98"},
+ {name: "rx", value: radius}}, geom...))
+ }
+
+ extra := []attr{
+ {name: "data-kind", value: kind},
+ {name: "viewBox", value: "0 0 100 100"},
+ {name: "preserveAspectRatio", value: "none"},
+ {name: "focusable", value: "false"},
+ }
+ children := []template.HTML{shape}
+ // A shape with a label is a picture with a name; one without is
+ // decoration, and is kept out of the accessibility tree rather than
+ // announced as "graphic".
+ if label := b.Text.Plain(); label != "" {
+ extra = append(extra, attr{name: "role", value: "img"})
+ children = append([]template.HTML{elInline("title", nil, "", esc(label))}, children...)
+ } else {
+ extra = append(extra, attr{name: "aria-hidden", value: "true"})
+ }
+ return elBlock("svg", c.attrs(b, extra...), c.style(b), children)
+}
+
+func (c *renderer) tableBlock(b deck.Block) template.HTML {
+ cells := cellsProp(b.Props)
+ cols := int(numProp(b.Props, "cols", 0))
+ if cols < 1 {
+ cols = len(cells)
+ }
+ if cols < 1 {
+ return elInline("table", c.attrs(b), c.style(b), "")
+ }
+ header, _ := b.Props["header"].(bool)
+
+ var rows [][]deck.Text
+ for i := 0; i < len(cells); i += cols {
+ end := min(i+cols, len(cells))
+ rows = append(rows, cells[i:end])
+ }
+
+ row := func(cs []deck.Text, head bool) template.HTML {
+ out := make([]template.HTML, 0, len(cs))
+ for _, cell := range cs {
+ // A cell is the same text schema, but short: paragraphs
+ // become line breaks rather than <p>, so a one-line cell is
+ // one line high.
+ lines := make([]template.HTML, 0, len(cell))
+ for _, p := range cell {
+ lines = append(lines, inline(p))
+ }
+ body := joinWith("<br>", lines)
+ if head {
+ out = append(out, elInline("th", []attr{{name: "scope", value: "col"}}, "", body))
+ } else {
+ out = append(out, elInline("td", nil, "", body))
+ }
+ }
+ return elBlock("tr", nil, "", out)
+ }
+
+ var children []template.HTML
+ if header && len(rows) > 0 {
+ children = append(children, elBlock("thead", nil, "", []template.HTML{row(rows[0], true)}))
+ rows = rows[1:]
+ }
+ body := make([]template.HTML, 0, len(rows))
+ for _, r := range rows {
+ body = append(body, row(r, false))
+ }
+ if len(body) > 0 {
+ children = append(children, elBlock("tbody", nil, "", body))
+ }
+ return elBlock("table", c.attrs(b), c.style(b), children)
+}
+
+// ── Text ──────────────────────────────────────────────────────────────
+
+// runTemplate is the one real html/template in this package, and it
+// covers the one place a deck's content decides markup: a run's marks
+// and its link. The nesting is fixed — link outside, then strong, em,
+// mark, code — so the same run always produces the same bytes.
+//
+// rel="noopener" on every link (§4.1's brief). No target: a slide is
+// not a menu, and a link that opened a tab the reader did not ask for
+// would take them out of a presentation mid-sentence.
+var runTemplate = template.Must(template.New("run").Parse(
+ `{{define "run"}}{{if .Link}}<a href="{{.Href}}" rel="noopener">{{template "marks" .}}</a>` +
+ `{{else}}{{template "marks" .}}{{end}}{{end}}` +
+ `{{define "marks"}}{{if .B}}<strong>{{end}}{{if .I}}<em>{{end}}{{if .H}}<mark>{{end}}{{if .C}}<code>{{end}}` +
+ `{{.T}}` +
+ `{{if .C}}</code>{{end}}{{if .H}}</mark>{{end}}{{if .I}}</em>{{end}}{{if .B}}</strong>{{end}}{{end}}`))
+
+// runView is one run as the template sees it: the marks, the text, and
+// a href that has already passed safeHref.
+type runView struct {
+ T string
+ B, I, C, H bool
+ Href string
+ Link bool
+}
+
+// inline renders one paragraph's runs, with no wrapping element — the
+// caller decides whether they sit in a <p>, an <li>, a <th> or a
+// heading.
+func inline(runs []deck.Run) template.HTML {
+ var buf bytes.Buffer
+ for _, r := range runs {
+ href := safeHref(r.Href)
+ v := runView{T: r.T, B: r.B, I: r.I, C: r.C, H: r.H, Href: href, Link: href != ""}
+ if err := runTemplate.ExecuteTemplate(&buf, "run", v); err != nil {
+ // Executing a template over a struct of strings and bools
+ // has no failure mode; the fallback exists so a future one
+ // degrades to plain escaped words rather than to markup.
+ return esc(plainRuns(runs))
+ }
+ }
+ return template.HTML(buf.String())
+}
+
+func plainRuns(runs []deck.Run) string {
+ var b strings.Builder
+ for _, r := range runs {
+ b.WriteString(r.T)
+ }
+ return b.String()
+}
+
+// linkSchemes is what a link mark may point at. Everything else — a
+// javascript: URL, a data: document, a bare relative path somebody
+// pasted — renders as the text it was, with no anchor: a link that goes
+// nowhere useful is worse than words, and a neutered href still looks
+// clickable.
+var linkSchemes = []string{"http", "https", "mailto"}
+
+func safeHref(raw string) string {
+ if raw == "" || strings.TrimSpace(raw) != raw {
+ return ""
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return ""
+ }
+ if !slices.Contains(linkSchemes, strings.ToLower(u.Scheme)) {
+ return ""
+ }
+ return raw
+}
+
+// ── Elements ──────────────────────────────────────────────────────────
+
+// attr is one attribute, in the order the renderer writes it. A bare
+// attribute (slides-slide, slides-block, slides-region) is written with
+// no value: it is a marker for a CSS selector, not data.
+type attr struct {
+ name string
+ value string
+ bare bool
+}
+
+// openTag writes `<tag a="b" style="…">`. Names are literals from this
+// file; values are escaped; style has already been assembled from
+// allowlisted parts.
+func openTag(tag string, attrs []attr, style string) string {
+ var b strings.Builder
+ b.WriteString("<")
+ b.WriteString(tag)
+ for _, a := range attrs {
+ b.WriteString(" ")
+ b.WriteString(a.name)
+ if a.bare {
+ continue
+ }
+ b.WriteString(`="`)
+ b.WriteString(template.HTMLEscapeString(a.value))
+ b.WriteString(`"`)
+ }
+ if style != "" {
+ b.WriteString(` style="`)
+ b.WriteString(template.HTMLEscapeString(style))
+ b.WriteString(`"`)
+ }
+ b.WriteString(">")
+ return b.String()
+}
+
+// elInline writes an element whose content is inline: no whitespace is
+// added, because inside a <p>, a <pre> or a heading it would be read.
+func elInline(tag string, attrs []attr, style string, body template.HTML) template.HTML {
+ return template.HTML(openTag(tag, attrs, style) + string(body) + "</" + tag + ">")
+}
+
+// elBlock writes an element whose children are blocks, one per line —
+// the newlines are what makes a golden file readable, and they are
+// collapsed away wherever the stylesheet cares (grid and flex ignore
+// whitespace between items).
+func elBlock(tag string, attrs []attr, style string, children []template.HTML) template.HTML {
+ open := openTag(tag, attrs, style)
+ if len(children) == 0 {
+ return template.HTML(open + "</" + tag + ">")
+ }
+ return template.HTML(open + "\n" + string(join(children)) + "\n</" + tag + ">")
+}
+
+// elVoid writes an HTML void element: <img>, <hr>. There is no closing
+// tag to write, and a trailing slash would be noise.
+func elVoid(tag string, attrs []attr, style string) template.HTML {
+ return template.HTML(openTag(tag, attrs, style))
+}
+
+// elClosed writes a self-closing element: <rect …/>. Only for the SVG
+// primitives, and the slash is load-bearing — inside <svg> the HTML
+// parser is in foreign content, where nothing is void and `<line>` with
+// no slash swallows whatever follows it as a child. An arrow drawn that
+// way loses its head, because a shape element's children never paint.
+func elClosed(tag string, attrs []attr) template.HTML {
+ return template.HTML(strings.TrimSuffix(openTag(tag, attrs, ""), ">") + " />")
+}
+
+func esc(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) }
+
+func join(parts []template.HTML) template.HTML { return joinWith("\n", parts) }
+
+func joinWith(sep string, parts []template.HTML) template.HTML {
+ ss := make([]string, len(parts))
+ for i, p := range parts {
+ ss[i] = string(p)
+ }
+ return template.HTML(strings.Join(ss, sep))
+}
+
+// ── Props ─────────────────────────────────────────────────────────────
+//
+// props is free-form (deck.Block's own comment says why), so every read
+// takes a default and every enumerated value is checked against its
+// vocabulary. A prop of the wrong type is a peer mid-edit, not a bug to
+// propagate.
+
+func stringProp(props map[string]any, key string) string {
+ s, _ := props[key].(string)
+ return s
+}
+
+func numProp(props map[string]any, key string, def float64) float64 {
+ switch v := props[key].(type) {
+ case float64:
+ return v
+ case int:
+ return float64(v)
+ case json.Number:
+ if f, err := v.Float64(); err == nil {
+ return f
+ }
+ }
+ return def
+}
+
+// sizeProp reads props.size as a size step; sizeStep is the same check
+// for a step that arrived somewhere else, like the phone layer's.
+func sizeProp(props map[string]any, def string) string {
+ s, _ := props["size"].(string)
+ return sizeStep(s, def)
+}
+
+// sizeStep is §2.4's s, m, l — defaulted rather than passed through, so
+// an unknown step never reaches a CSS selector that has no rule for it
+// and leaves a block at whatever size it inherited.
+func sizeStep(s, def string) string {
+ switch s {
+ case "s", "m", "l":
+ return s
+ }
+ return def
+}
+
+func alignProp(props map[string]any) string {
+ switch a, _ := props["align"].(string); a {
+ case "start", "center", "end", "justify":
+ return a
+ }
+ return ""
+}
+
+// childIDs is a columns block's props.children (§2.4), as they arrive
+// from JSON.
+func childIDs(b deck.Block) []string {
+ switch v := b.Props["children"].(type) {
+ case []string:
+ return v
+ case []any:
+ out := make([]string, 0, len(v))
+ for _, c := range v {
+ if s, ok := c.(string); ok {
+ out = append(out, s)
+ }
+ }
+ return out
+ }
+ return nil
+}
+
+// cellsProp reads a table's props.cells: a row-major array of texts
+// (core/src/view.rs). It goes back through JSON rather than being
+// walked by hand — the view built it with encoding/json's shapes, and a
+// round trip is both shorter and harder to get wrong than five type
+// assertions deep.
+func cellsProp(props map[string]any) []deck.Text {
+ raw, ok := props["cells"]
+ if !ok {
+ return nil
+ }
+ b, err := json.Marshal(raw)
+ if err != nil {
+ return nil
+ }
+ var out []deck.Text
+ if err := json.Unmarshal(b, &out); err != nil {
+ return nil
+ }
+ return out
+}
+
+// ── Values that land in CSS or a URL ──────────────────────────────────
+
+var (
+ // colorPattern is a hex colour or a plain CSS colour keyword.
+ // Deliberately no functions: oklch() and color-mix() are free in
+ // this family's own stylesheets (rastrillo SKILL.md), but a value
+ // somebody typed into a deck is not a stylesheet, and a syntax that
+ // takes arbitrary nested tokens is a syntax to keep out of a style
+ // attribute.
+ colorPattern = regexp.MustCompile(`^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,24})$`)
+
+ // fontPattern is a font-family list: names, quotes, commas, and the
+ // generic families. No parentheses, so no url() and no expression().
+ fontPattern = regexp.MustCompile(`^[A-Za-z0-9 ,'._-]{1,200}$`)
+
+ // idPattern is an asset id. The core mints them as 12 lowercase
+ // base32 characters; this is wider so an id from a later scheme
+ // still resolves, and narrow enough that no id can be a path.
+ idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
+
+ // langPattern is a code block's language, which becomes a class.
+ langPattern = regexp.MustCompile(`^[A-Za-z0-9+#._-]{1,32}$`)
+)
+
+// safeColor takes a hex colour or a CSS colour keyword. It is for the
+// values whose contrast nothing has to compute: a shape's fill and
+// stroke, which sit on whatever the author put behind them.
+func safeColor(v string) string {
+ v = strings.TrimSpace(v)
+ if !colorPattern.MatchString(v) {
+ return ""
+ }
+ if strings.HasPrefix(v, "#") {
+ switch len(v) {
+ case 4, 5, 7, 9: // #rgb, #rgba, #rrggbb, #rrggbbaa
+ default:
+ return ""
+ }
+ }
+ return v
+}
+
+// safeHexColor is safeColor narrowed to opaque hex — #rgb or #rrggbb.
+// It is for the two colours whose readability this package has to
+// promise: a slide's background and a deck's accent. A keyword or an
+// alpha channel is dropped rather than guessed at, because a colour
+// with no measurable luminance cannot be checked against §8's floor.
+func safeHexColor(v string) string {
+ v = safeColor(v)
+ if _, ok := parseHexColor(v); !ok {
+ return ""
+ }
+ return v
+}
+
+func safeFontStack(v string) string {
+ v = strings.TrimSpace(v)
+ if !fontPattern.MatchString(v) {
+ return ""
+ }
+ return v
+}
+
+func safeAssetID(v string) string {
+ if !idPattern.MatchString(v) {
+ return ""
+ }
+ return v
+}
+
+// assetPath is where an asset's bytes will be served from. The route
+// itself lands with uploads in slice 2 (§2.7); until then this is a
+// placeholder that resolves to a 404 rather than to nothing, which is
+// the honest state of an image in a deck nobody can upload to yet.
+func assetPath(id string) string { return "/assets/" + id }
+
+// ── Numbers ───────────────────────────────────────────────────────────
+
+// pct turns a box fraction into a percentage with no floating-point
+// tail: 0.06 is "6%", not "6.000000000000001%".
+func pct(v float64) string { return trimNum(strconv.FormatFloat(v*100, 'f', 4, 64)) + "%" }
+
+// fracs is a box as the four decimals §2.3 stores, for data-phone-box —
+// a value the editor reads back, not one CSS uses.
+func fracs(b deck.Box) string {
+ return strings.Join([]string{
+ strconv.FormatFloat(b.X, 'f', 4, 64),
+ strconv.FormatFloat(b.Y, 'f', 4, 64),
+ strconv.FormatFloat(b.W, 'f', 4, 64),
+ strconv.FormatFloat(b.H, 'f', 4, 64),
+ }, ",")
+}
+
+// num is a prop number as CSS or SVG wants it.
+func num(v float64) string { return trimNum(strconv.FormatFloat(v, 'f', 4, 64)) }
+
+func trimNum(s string) string {
+ if strings.Contains(s, ".") {
+ s = strings.TrimRight(s, "0")
+ s = strings.TrimSuffix(s, ".")
+ }
+ if s == "" || s == "-" || s == "-0" {
+ return "0"
+ }
+ return s
+}
+
+func clamp(v, lo, hi float64) float64 {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
diff --git a/internal/slides/render/render_test.go b/internal/slides/render/render_test.go
new file mode 100644
index 0000000..e097fbb
--- /dev/null
+++ b/internal/slides/render/render_test.go
@@ -0,0 +1,866 @@
+package render_test
+
+import (
+ "flag"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/slides/internal/slides/deck"
+ "amadan.net/oficina/slides/internal/slides/render"
+)
+
+// update rewrites the golden files instead of comparing against them:
+//
+// go test ./internal/slides/render -update
+//
+// Read the diff before committing a regenerated golden. The goldens are
+// the renderer's contract with the viewer, the presenter and the editor
+// stage, so a change to one is a change somebody should have meant to
+// make.
+var update = flag.Bool("update", false, "rewrite the golden files")
+
+// txt is one paragraph of unmarked text — the shape most fixture blocks
+// want.
+func txt(s string) deck.Text { return deck.Text{{{T: s}}} }
+
+// paras is several unmarked paragraphs: list items, code lines, a quote
+// that runs on.
+func paras(ss ...string) deck.Text {
+ t := make(deck.Text, 0, len(ss))
+ for _, s := range ss {
+ t = append(t, []deck.Run{{T: s}})
+ }
+ return t
+}
+
+// box is a canvas block's frame, spelled positionally so a fixture
+// slide reads as a picture rather than as four labelled fields.
+func box(x, y, w, h float64) *deck.Box { return &deck.Box{X: x, Y: y, W: w, H: h} }
+
+func intp(n int) *int { return &n }
+
+// fixture is the deck the golden tests render: one slide per flow
+// template, one canvas slide, one canvas slide set to reflow, and every
+// block type in §2.4 somewhere among them.
+//
+// Hand-built rather than driven through the core: the goldens are about
+// what the renderer does with a view, and a literal deck is that view
+// with the wasm boundary taken out of the loop. internal/slides/sample.go
+// is the same deck said in ops, and this fixture follows its shape.
+func fixture() *deck.Deck {
+ return &deck.Deck{
+ Meta: deck.Meta{Title: "Renderer fixture", Theme: "paper", Aspect: "16:9"},
+ Assets: map[string]deck.Asset{
+ "asset0001aaa": {Ref: "blob:0001", Name: "chart.png", Width: 1600, Height: 900, Alt: "A bar chart with no bars"},
+ "asset0002bbb": {Ref: "blob:0002", Name: "texture.png", Width: 64, Height: 64},
+ },
+ Slides: []deck.Slide{
+ // 0 — title
+ {
+ ID: "slide001aaaa", Layout: "flow", Template: "title",
+ Background: deck.Background{Kind: "theme"},
+ Notes: txt("The title template: one heading and one line under it."),
+ Blocks: []deck.Block{
+ {ID: "b001aaaaaaaa", Type: "heading", Region: "heading",
+ Props: map[string]any{"level": float64(1)}, Text: txt("Renderer fixture")},
+ {ID: "b002aaaaaaaa", Type: "text", Region: "body",
+ Props: map[string]any{"size": "l"}, Text: txt("One section per slide, one element per block.")},
+ },
+ },
+ // 1 — title-body, with every inline mark and both list styles
+ {
+ ID: "slide002aaaa", Layout: "flow", Template: "title-body",
+ Background: deck.Background{Kind: "theme"},
+ Blocks: []deck.Block{
+ {ID: "b011aaaaaaaa", Type: "heading", Region: "heading",
+ Props: map[string]any{"level": float64(1)}, Text: txt("Text and marks")},
+ {ID: "b012aaaaaaaa", Type: "text", Region: "body",
+ Props: map[string]any{"size": "m", "align": "start"},
+ Text: deck.Text{{
+ {T: "Marks: "},
+ {T: "bold", B: true},
+ {T: ", "},
+ {T: "italic", I: true},
+ {T: ", "},
+ {T: "code", C: true},
+ {T: ", "},
+ {T: "highlight", H: true},
+ {T: ", "},
+ {T: "all four", B: true, I: true, C: true, H: true},
+ {T: " and a "},
+ {T: "link", Href: "https://amadan.net/oficina/slides"},
+ {T: " <not markup>."},
+ }}},
+ {ID: "b013aaaaaaaa", Type: "list", Region: "body",
+ Props: map[string]any{"style": "bullet", "size": "m"},
+ Text: paras("One item per paragraph", "Bullets or numbers")},
+ {ID: "b014aaaaaaaa", Type: "list", Region: "body",
+ Props: map[string]any{"style": "number", "size": "s", "indent": float64(1)},
+ Text: paras("First", "Second")},
+ },
+ },
+ // 2 — two-column, and a columns block whose children must not
+ // also render at the top level
+ {
+ ID: "slide003aaaa", Layout: "flow", Template: "two-column",
+ Background: deck.Background{Kind: "theme"},
+ Blocks: []deck.Block{
+ {ID: "b021aaaaaaaa", Type: "heading", Region: "heading",
+ Props: map[string]any{"level": float64(2)}, Text: txt("Regions, not coordinates")},
+ {ID: "b022aaaaaaaa", Type: "text", Region: "body",
+ Text: txt("A flow slide names regions and blocks stack inside the one they are in.")},
+ {ID: "b023aaaaaaaa", Type: "columns", Region: "body",
+ Props: map[string]any{"count": float64(2), "children": []any{"b025aaaaaaaa", "b024aaaaaaaa"}}},
+ {ID: "b024aaaaaaaa", Type: "text", Region: "body", Text: txt("Second column, first in the array.")},
+ {ID: "b025aaaaaaaa", Type: "text", Region: "body", Text: txt("First column, second in the array.")},
+ {ID: "b026aaaaaaaa", Type: "text", Region: "aside",
+ Text: txt("The aside is the second column.")},
+ },
+ },
+ // 3 — section, on a slide background the deck set by colour
+ {
+ ID: "slide004aaaa", Layout: "flow", Template: "section",
+ Background: deck.Background{Kind: "color", Value: "#2b1030"},
+ Blocks: []deck.Block{
+ {ID: "b031aaaaaaaa", Type: "heading", Region: "heading",
+ Props: map[string]any{"level": float64(1)}, Text: txt("Part two")},
+ },
+ },
+ // 4 — image-full: the alt that comes from the asset
+ {
+ ID: "slide005aaaa", Layout: "flow", Template: "image-full",
+ Background: deck.Background{Kind: "image", Value: "asset0002bbb"},
+ Blocks: []deck.Block{
+ {ID: "b041aaaaaaaa", Type: "image", Region: "media",
+ Props: map[string]any{"asset": "asset0001aaa", "fit": "cover", "radius": float64(8)}},
+ {ID: "b042aaaaaaaa", Type: "heading", Region: "heading",
+ Props: map[string]any{"level": float64(2)}, Text: txt("A picture would go here")},
+ },
+ },
+ // 5 — quote, with and without an attribution
+ {
+ ID: "slide006aaaa", Layout: "flow", Template: "quote",
+ Background: deck.Background{Kind: "theme"},
+ Blocks: []deck.Block{
+ {ID: "b051aaaaaaaa", Type: "quote", Region: "body",
+ Props: map[string]any{"cite": "The design spec"},
+ Text: txt("A deck is a document, not a drawing.")},
+ {ID: "b052aaaaaaaa", Type: "quote", Region: "body",
+ Text: txt("Said by nobody in particular.")},
+ },
+ },
+ // 6 — blank, carrying the block types the other slides do not
+ {
+ ID: "slide007aaaa", Layout: "flow", Template: "blank",
+ Background: deck.Background{Kind: "theme"},
+ Blocks: []deck.Block{
+ {ID: "b061aaaaaaaa", Type: "code", Region: "body",
+ Props: map[string]any{"language": "go", "size": "m"},
+ Text: paras("func main() {", "\tfmt.Println(\"hi <there>\")", "}")},
+ {ID: "b062aaaaaaaa", Type: "table", Region: "body",
+ Props: map[string]any{
+ "rows": float64(2), "cols": float64(2), "header": true,
+ "cells": []any{
+ []any{[]any{map[string]any{"t": "Region"}}},
+ []any{[]any{map[string]any{"t": "Holds"}}},
+ []any{[]any{map[string]any{"t": "heading"}}},
+ []any{[]any{map[string]any{"t": "the title"}}},
+ },
+ }},
+ {ID: "b063aaaaaaaa", Type: "divider", Region: "body"},
+ {ID: "b064aaaaaaaa", Type: "spacer", Region: "body",
+ Props: map[string]any{"size": "l"}},
+ {ID: "b065aaaaaaaa", Type: "shape", Region: "body",
+ Props: map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": float64(2), "radius": float64(12)},
+ Text: txt("A labelled rectangle")},
+ {ID: "b066aaaaaaaa", Type: "shape", Region: "body",
+ Props: map[string]any{"kind": "ellipse", "fill": "#cfd9e8", "stroke": "#5a6f8a", "strokeWidth": float64(2)}},
+ {ID: "b067aaaaaaaa", Type: "shape", Region: "body",
+ Props: map[string]any{"kind": "line", "stroke": "#5a6f8a", "strokeWidth": float64(3)}},
+ {ID: "b068aaaaaaaa", Type: "shape", Region: "body",
+ Props: map[string]any{"kind": "arrow", "stroke": "currentColor"}},
+ {ID: "b069aaaaaaaa", Type: "image", Region: "body",
+ Props: map[string]any{"asset": "asset0002bbb", "fit": "contain", "decorative": true, "alt": ""}},
+ {ID: "b070aaaaaaaa", Type: "image", Region: "body",
+ Props: map[string]any{"asset": "asset0002bbb", "fit": "contain"}},
+ },
+ },
+ // 7 — canvas, with a phone override on one block
+ {
+ ID: "slide008aaaa", Layout: "canvas", Template: "blank", Phone: "inherit",
+ Background: deck.Background{Kind: "theme"},
+ Blocks: []deck.Block{
+ {ID: "b081aaaaaaaa", Type: "heading", Box: box(0.06, 0.08, 0.88, 0.16), Z: 3,
+ Props: map[string]any{"level": float64(1)}, Text: txt("Canvas: put it where you want it")},
+ {ID: "b082aaaaaaaa", Type: "shape", Box: box(0.08, 0.34, 0.36, 0.44), Z: 1,
+ Props: map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": float64(2), "radius": float64(12)}},
+ {ID: "b083aaaaaaaa", Type: "text", Box: box(0.58, 0.36, 0.36, 0.4), Z: 4,
+ Phone: deck.PhoneOverride{Order: intp(0), Size: "s", Box: box(0.05, 0.5, 0.9, 0.45)},
+ Text: txt("Every box is a fraction of the 16:9 frame.")},
+ },
+ },
+ // 8 — canvas with phone: reflow, whose z order is not its
+ // array order, and one block hidden on phones
+ {
+ ID: "slide009aaaa", Layout: "canvas", Template: "blank", Phone: "reflow", Transition: "fade",
+ Background: deck.Background{Kind: "theme"},
+ Notes: txt("The phone layer only ever adds to the desktop layout."),
+ Blocks: []deck.Block{
+ {ID: "b091aaaaaaaa", Type: "heading", Box: box(0.06, 0.1, 0.88, 0.16), Z: 1,
+ Props: map[string]any{"level": float64(1)}, Text: txt("On a phone this one reflows")},
+ {ID: "b092aaaaaaaa", Type: "shape", Box: box(0.64, 0.32, 0.3, 0.44), Z: 2,
+ Phone: deck.PhoneOverride{Hidden: true},
+ Props: map[string]any{"kind": "rect", "fill": "#e0d7e8", "stroke": "#75608a", "strokeWidth": float64(2), "radius": float64(12)},
+ Text: txt("Desktop only")},
+ {ID: "b093aaaaaaaa", Type: "text", Box: box(0.06, 0.32, 0.52, 0.44), Z: 2,
+ Text: txt("Setting phone to reflow lays the blocks out as one column.")},
+ {ID: "b094aaaaaaaa", Type: "text", Box: box(0.06, 0.8, 0.88, 0.12), Z: 3,
+ Text: txt("A footnote, last by z.")},
+ },
+ },
+ },
+ }
+}
+
+// goldenNames pairs a fixture slide index with the golden it writes.
+var goldenNames = []struct {
+ name string
+ index int
+}{
+ {"title", 0},
+ {"title-body", 1},
+ {"two-column", 2},
+ {"section", 3},
+ {"image-full", 4},
+ {"quote", 5},
+ {"blank", 6},
+ {"canvas", 7},
+ {"canvas-reflow", 8},
+}
+
+var viewports = []struct {
+ name string
+ vp render.Viewport
+}{
+ {"desktop", render.Desktop},
+ {"phone", render.Phone},
+}
+
+func TestSlideGoldens(t *testing.T) {
+ d := fixture()
+ for _, g := range goldenNames {
+ for _, v := range viewports {
+ name := g.name + "-" + v.name
+ t.Run(name, func(t *testing.T) {
+ got, err := render.Slide(d, g.index, v.vp)
+ if err != nil {
+ t.Fatalf("render.Slide(%d, %s): %v", g.index, v.name, err)
+ }
+ path := filepath.Join("testdata", name+".golden.html")
+ body := string(got) + "\n"
+ if *update {
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write golden: %v", err)
+ }
+ return
+ }
+ want, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read golden (run with -update to create it): %v", err)
+ }
+ if body != string(want) {
+ t.Errorf("%s does not match the golden.\n got: %s\nwant: %s", name, body, want)
+ }
+ })
+ }
+ }
+}
+
+// TestSlideRejectsAnIndexOutsideTheDeck: a caller asking for slide 9 of
+// nine gets an error, not a panic and not an empty section.
+func TestSlideRejectsAnIndexOutsideTheDeck(t *testing.T) {
+ d := fixture()
+ for _, i := range []int{-1, len(d.Slides), 1000} {
+ if _, err := render.Slide(d, i, render.Desktop); err == nil {
+ t.Errorf("render.Slide(%d) returned no error", i)
+ }
+ }
+ if _, err := render.Slide(nil, 0, render.Desktop); err == nil {
+ t.Error("render.Slide(nil deck) returned no error")
+ }
+}
+
+// TestColumnsChildrenRenderOnceInsideTheColumns: a block named by a
+// columns block's props.children renders in the columns element, in the
+// order children names, and nowhere else.
+func TestColumnsChildrenRenderOnceInsideTheColumns(t *testing.T) {
+ got, err := render.Slide(fixture(), 2, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(got)
+ for _, id := range []string{"b024aaaaaaaa", "b025aaaaaaaa"} {
+ if n := strings.Count(s, id); n != 1 {
+ t.Errorf("child %s appears %d times, want 1", id, n)
+ }
+ }
+ first, second := strings.Index(s, "b025aaaaaaaa"), strings.Index(s, "b024aaaaaaaa")
+ if first < 0 || second < 0 || first > second {
+ t.Errorf("children are not in props.children order: b025 at %d, b024 at %d", first, second)
+ }
+ cols := strings.Index(s, `data-block="columns"`)
+ if cols < 0 || cols > first {
+ t.Errorf("the children do not sit inside the columns element")
+ }
+}
+
+// TestABlockNamedTwiceRendersOnce: nothing stops two columns blocks
+// naming the same child, and a block rendered twice would put the same
+// data-id on two elements — which is the id the editor and the drive
+// API address a block by. First claim wins.
+func TestABlockNamedTwiceRendersOnce(t *testing.T) {
+ d := &deck.Deck{
+ Meta: deck.Meta{Theme: "paper"},
+ Slides: []deck.Slide{{
+ ID: "s", Layout: "flow", Template: "blank",
+ Blocks: []deck.Block{
+ {ID: "colA", Type: "columns", Region: "body",
+ Props: map[string]any{"children": []any{"kid"}}},
+ {ID: "colB", Type: "columns", Region: "body",
+ Props: map[string]any{"children": []any{"kid"}}},
+ {ID: "kid", Type: "text", Region: "body", Text: txt("Claimed twice")},
+ },
+ }},
+ }
+ out, err := render.Slide(d, 0, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := strings.Count(string(out), `data-id="kid"`); n != 1 {
+ t.Errorf("the shared child rendered %d times, want 1: %s", n, out)
+ }
+
+ // And a cycle through two columns blocks terminates rather than
+ // recurring until the stack runs out.
+ cyclic := &deck.Deck{
+ Meta: deck.Meta{Theme: "paper"},
+ Slides: []deck.Slide{{
+ ID: "s", Layout: "flow", Template: "blank",
+ Blocks: []deck.Block{
+ {ID: "one", Type: "columns", Region: "body",
+ Props: map[string]any{"children": []any{"two"}}},
+ {ID: "two", Type: "columns", Region: "body",
+ Props: map[string]any{"children": []any{"one"}}},
+ },
+ }},
+ }
+ if _, err := render.Slide(cyclic, 0, render.Desktop); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestHrefSchemesAreFiltered: a link mark carries a URL somebody typed.
+// http, https and mailto become links; everything else renders as the
+// text it was, with no anchor at all — never a stripped href, never a
+// javascript: URL that html/template merely neutered.
+func TestHrefSchemesAreFiltered(t *testing.T) {
+ linked := []string{
+ "https://amadan.net",
+ "http://example.test/path?q=1#f",
+ "HTTPS://Amadan.net/Shouting",
+ "mailto:paul@tito.io",
+ "MailTo:paul@tito.io",
+ }
+ for _, href := range linked {
+ out := string(render.Text(deck.Text{{{T: "click", Href: href}}}))
+ if !strings.Contains(out, "<a href=") {
+ t.Errorf("href %q rendered no link: %s", href, out)
+ }
+ if !strings.Contains(out, `rel="noopener"`) {
+ t.Errorf("href %q rendered a link with no rel: %s", href, out)
+ }
+ if !strings.Contains(out, "click") {
+ t.Errorf("href %q lost the text: %s", href, out)
+ }
+ }
+ refused := []string{
+ "javascript:alert(1)",
+ "JaVaScRiPt:alert(1)",
+ " javascript:alert(1)",
+ "data:text/html;base64,PHNjcmlwdD4=",
+ "vbscript:msgbox",
+ "file:///etc/passwd",
+ "ftp://example.test/x",
+ "/decks/1",
+ "decks/1",
+ "//evil.test",
+ "",
+ }
+ for _, href := range refused {
+ out := string(render.Text(deck.Text{{{T: "click", Href: href}}}))
+ if strings.Contains(out, "<a ") || strings.Contains(out, "href") {
+ t.Errorf("href %q was rendered as a link: %s", href, out)
+ }
+ if !strings.Contains(out, "click") {
+ t.Errorf("href %q lost the text: %s", href, out)
+ }
+ }
+}
+
+// TestTextEscapesEverything: the text schema is somebody's typing, and
+// none of it is markup.
+func TestTextEscapesEverything(t *testing.T) {
+ out := string(render.Text(deck.Text{{
+ {T: `<script>alert("x")</script>`},
+ {T: "&amp;", B: true},
+ }}))
+ for _, bad := range []string{"<script>", `alert("x")`} {
+ if strings.Contains(out, bad) {
+ t.Errorf("output carries %q verbatim: %s", bad, out)
+ }
+ }
+ if !strings.Contains(out, "&amp;amp;") {
+ t.Errorf("a literal &amp; was not escaped again: %s", out)
+ }
+}
+
+// TestNotesRenderLikeText: the notes surfaces (presenter view, the
+// scriptless deck page) get the same paragraphs the slide does.
+func TestNotesRenderLikeText(t *testing.T) {
+ n := fixture().Slides[0].Notes
+ if got, want := string(render.Notes(n)), string(render.Text(n)); got != want {
+ t.Errorf("Notes and Text disagree:\n got %s\nwant %s", got, want)
+ }
+ if !strings.HasPrefix(string(render.Notes(n)), "<p>") {
+ t.Errorf("notes did not render a paragraph: %s", render.Notes(n))
+ }
+}
+
+// TestPhoneReflowOfCanvasSlideOrdersByZThenPosition: slide 8's array
+// order is heading, shape, text, footnote; its z order is heading (1),
+// then the shape and the text tied at 2, then the footnote (3). The tie
+// breaks on top, then left — the text's box is further left than the
+// shape's — so the phone render must read heading, text, shape,
+// footnote, which is not the array order.
+func TestPhoneReflowOfCanvasSlideOrdersByZThenPosition(t *testing.T) {
+ d := fixture()
+ phone, err := render.Slide(d, 8, render.Phone)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(phone)
+
+ want := []string{"b091aaaaaaaa", "b093aaaaaaaa", "b092aaaaaaaa", "b094aaaaaaaa"}
+ at := make([]int, len(want))
+ for i, id := range want {
+ if at[i] = strings.Index(s, id); at[i] < 0 {
+ t.Fatalf("block %s is missing from the reflowed slide: %s", id, s)
+ }
+ if i > 0 && at[i-1] > at[i] {
+ t.Errorf("block %s comes after %s; want z then top-left order", want[i-1], id)
+ }
+ }
+
+ // Reflowed means flow: the blank template, no absolute boxes.
+ if !strings.Contains(s, `data-layout="flow"`) || !strings.Contains(s, `data-template="blank"`) {
+ t.Errorf("a reflowed canvas slide is not a blank flow slide: %s", s)
+ }
+ if strings.Contains(s, "z-index") || strings.Contains(s, "left:") {
+ t.Errorf("a reflowed canvas slide still carries canvas positions: %s", s)
+ }
+ // The hidden block is still in the output, carrying the attribute
+ // the stylesheet hides it by: the phone layer adds, it never
+ // deletes, and the presenter's notes still need the words.
+ if !strings.Contains(s, "data-phone-hidden") {
+ t.Errorf("the phone override was dropped: %s", s)
+ }
+
+ // The desktop render of the same slide is still a canvas.
+ desktop, err := render.Slide(d, 8, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(desktop), `data-layout="canvas"`) {
+ t.Errorf("the desktop render lost its canvas: %s", desktop)
+ }
+ // A canvas slide left on phone: inherit keeps its boxes on a phone.
+ inherit, err := render.Slide(d, 7, render.Phone)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(inherit), `data-layout="canvas"`) {
+ t.Errorf("phone: inherit reflowed anyway: %s", inherit)
+ }
+}
+
+// TestThemes: six themes, stable ids in the app's picker order, a name
+// each, and every one answers ThemeVars.
+func TestThemes(t *testing.T) {
+ want := []string{"paper", "ink", "sunset", "forest", "slate", "candy"}
+ got := render.Themes()
+ if len(got) != len(want) {
+ t.Fatalf("Themes() returned %d themes, want %d: %+v", len(got), len(want), got)
+ }
+ for i, th := range got {
+ if th.ID != want[i] {
+ t.Errorf("theme %d is %q, want %q", i, th.ID, want[i])
+ }
+ if th.Name == "" {
+ t.Errorf("theme %q has no name", th.ID)
+ }
+ if _, ok := render.ThemeVars(th.ID); !ok {
+ t.Errorf("ThemeVars(%q) reported unknown", th.ID)
+ }
+ }
+ if _, ok := render.ThemeVars("no-such-theme"); ok {
+ t.Error("ThemeVars accepted a theme that does not exist")
+ }
+}
+
+// TestThemeContrast holds the accessibility floor §8 sets (WCAG 2.2 AA,
+// no exemptions): body text on the slide background at 4.5:1, and each
+// accent on it at 3:1 — the large-text and non-text threshold, which is
+// what an accent is used for here (headings, rules, the section bar).
+func TestThemeContrast(t *testing.T) {
+ for _, th := range render.Themes() {
+ vars, ok := render.ThemeVars(th.ID)
+ if !ok {
+ t.Fatalf("ThemeVars(%q) reported unknown", th.ID)
+ }
+ bg, err := parseHex(vars["--slides-bg"])
+ if err != nil {
+ t.Fatalf("theme %q background: %v", th.ID, err)
+ }
+ for _, c := range []struct {
+ name string
+ min float64
+ }{
+ {"--slides-fg", 4.5},
+ {"--slides-accent", 3},
+ {"--slides-accent-2", 3},
+ } {
+ fg, err := parseHex(vars[c.name])
+ if err != nil {
+ t.Fatalf("theme %q %s: %v", th.ID, c.name, err)
+ }
+ if got := contrast(fg, bg); got < c.min {
+ t.Errorf("theme %q: %s on --slides-bg is %.2f:1, want at least %.1f:1",
+ th.ID, c.name, got, c.min)
+ }
+ }
+ for _, key := range []string{"--slides-font-heading", "--slides-font-body"} {
+ if vars[key] == "" {
+ t.Errorf("theme %q has no %s", th.ID, key)
+ }
+ }
+ }
+}
+
+// parseHex reads #rgb or #rrggbb into 0..1 sRGB channels. The themes
+// are fixed hex on purpose (see themes.go), which is what lets this
+// test compute their contrast at all.
+func parseHex(s string) ([3]float64, error) {
+ var out [3]float64
+ s = strings.TrimSpace(s)
+ if !strings.HasPrefix(s, "#") {
+ return out, fmt.Errorf("%q is not a hex colour", s)
+ }
+ h := s[1:]
+ if len(h) == 3 {
+ h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
+ }
+ if len(h) != 6 {
+ return out, fmt.Errorf("%q is not #rgb or #rrggbb", s)
+ }
+ for i := range out {
+ v, err := strconv.ParseUint(h[i*2:i*2+2], 16, 8)
+ if err != nil {
+ return out, fmt.Errorf("%q: %w", s, err)
+ }
+ out[i] = float64(v) / 255
+ }
+ return out, nil
+}
+
+// luminance is WCAG 2.2's relative luminance.
+func luminance(c [3]float64) float64 {
+ lin := func(v float64) float64 {
+ if v <= 0.04045 {
+ return v / 12.92
+ }
+ return math.Pow((v+0.055)/1.055, 2.4)
+ }
+ return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2])
+}
+
+// contrast is WCAG 2.2's contrast ratio, 1:1 to 21:1.
+func contrast(a, b [3]float64) float64 {
+ la, lb := luminance(a), luminance(b)
+ if la < lb {
+ la, lb = lb, la
+ }
+ return (la + 0.05) / (lb + 0.05)
+}
+
+// TestContrastFunctionItself: the contrast helper is the only thing
+// standing between a theme and an unreadable slide, so it is checked
+// against ratios that are known independently of it.
+func TestContrastFunctionItself(t *testing.T) {
+ black, _ := parseHex("#000000")
+ white, _ := parseHex("#ffffff")
+ if got := contrast(black, white); math.Abs(got-21) > 0.01 {
+ t.Errorf("black on white is %.4f:1, want 21:1", got)
+ }
+ if got := contrast(white, white); math.Abs(got-1) > 0.0001 {
+ t.Errorf("white on white is %.4f:1, want 1:1", got)
+ }
+ // The canonical AA boundary: #767676 on white is 4.54:1.
+ grey, _ := parseHex("#767676")
+ if got := contrast(grey, white); got < 4.5 || got > 4.6 {
+ t.Errorf("#767676 on white is %.4f:1, want ~4.54:1", got)
+ }
+ // A pair that must fail, so a broken helper cannot pass everything.
+ pale, _ := parseHex("#eeeeee")
+ if got := contrast(pale, white); got > 1.2 {
+ t.Errorf("#eeeeee on white is %.4f:1, want ~1.1:1", got)
+ }
+}
+
+// TestMetaOverridesAreSanitised: meta.accent, meta.fonts and a slide's
+// background are typed by a person and land in a style attribute. A
+// value that is not a plain colour or a plain font stack is ignored,
+// never emitted.
+func TestMetaOverridesAreSanitised(t *testing.T) {
+ d := fixture()
+ d.Meta.Accent = "#123456"
+ d.Meta.Fonts = &deck.Fonts{Heading: "Georgia, serif", Body: "Helvetica, sans-serif"}
+ out, err := render.Slide(d, 0, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"--slides-accent:#123456", "--slides-font-heading:Georgia, serif", "--slides-font-body:Helvetica, sans-serif"} {
+ if !strings.Contains(string(out), want) {
+ t.Errorf("a valid override was dropped: want %q in %s", want, out)
+ }
+ }
+
+ bad := fixture()
+ bad.Meta.Accent = "red;background:url(https://evil.test/x)"
+ bad.Meta.Fonts = &deck.Fonts{Heading: `x"><script>alert(1)</script>`, Body: "expression(alert(1))"}
+ bad.Slides[3].Background.Value = "url(https://evil.test/x)"
+ bad.Slides[4].Background.Value = "../../etc/passwd"
+ for _, i := range []int{0, 3, 4} {
+ out, err := render.Slide(bad, i, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, gone := range []string{"evil.test", "<script", "expression(", "etc/passwd"} {
+ if strings.Contains(string(out), gone) {
+ t.Errorf("slide %d: an unsafe value survived: %q in %s", i, gone, out)
+ }
+ }
+ }
+}
+
+// varsOf pulls the custom properties back out of a rendered section's
+// style attribute, so a test can hold the renderer to the same contrast
+// floor TestThemeContrast holds the themes to.
+func varsOf(t *testing.T, html string) map[string]string {
+ t.Helper()
+ i := strings.Index(html, ` style="`)
+ if i < 0 {
+ t.Fatalf("no style attribute: %s", html)
+ }
+ rest := html[i+len(` style="`):]
+ j := strings.Index(rest, `"`)
+ if j < 0 {
+ t.Fatalf("unterminated style attribute: %s", html)
+ }
+ out := map[string]string{}
+ for _, decl := range strings.Split(rest[:j], ";") {
+ k, v, ok := strings.Cut(decl, ":")
+ if ok {
+ out[k] = v
+ }
+ }
+ return out
+}
+
+// TestSlideBackgroundStaysReadable: a slide that sets its own
+// background colour gets a foreground and accents that clear §8's
+// thresholds against it. The fixture's section slide is deliberately a
+// deep plum under `paper`, whose own text is near-black — the pairing
+// that would be unreadable if the renderer just wrote the theme's
+// foreground out beside the deck's background.
+func TestSlideBackgroundStaysReadable(t *testing.T) {
+ d := fixture()
+ for _, value := range []string{"#2b1030", "#ffffff", "#000000", "#7f7f7f", "#0f2418", "#faf7f0"} {
+ d.Slides[3].Background = deck.Background{Kind: "color", Value: value}
+ out, err := render.Slide(d, 3, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ vars := varsOf(t, string(out))
+ if vars["--slides-bg"] != value {
+ t.Errorf("background %s was not used: %q", value, vars["--slides-bg"])
+ continue
+ }
+ bg, err := parseHex(vars["--slides-bg"])
+ if err != nil {
+ t.Fatalf("background %s: %v", value, err)
+ }
+ for _, c := range []struct {
+ key string
+ min float64
+ }{{"--slides-fg", 4.5}, {"--slides-accent", 3}, {"--slides-accent-2", 3}} {
+ fg, err := parseHex(vars[c.key])
+ if err != nil {
+ t.Fatalf("background %s, %s: %v", value, c.key, err)
+ }
+ if got := contrast(fg, bg); got < c.min {
+ t.Errorf("background %s: %s is %.2f:1, want at least %.1f:1", value, c.key, got, c.min)
+ }
+ }
+ }
+
+ // A background this package cannot measure is not used at all: the
+ // theme's own pairing, which is known to pass, stands instead.
+ d.Slides[3].Background = deck.Background{Kind: "color", Value: "rebeccapurple"}
+ out, err := render.Slide(d, 3, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := varsOf(t, string(out))["--slides-bg"]; got != "#faf7f0" {
+ t.Errorf("an unmeasurable background colour was used: %q", got)
+ }
+}
+
+// TestDeckAccentIsRefusedWhenItWouldNotBeVisible: meta.accent is a
+// person's colour pick, and a pick that disappears into the background
+// is not honoured — the theme's accent stands and the slide stays
+// legible.
+func TestDeckAccentIsRefusedWhenItWouldNotBeVisible(t *testing.T) {
+ d := fixture() // paper: a warm white background
+ d.Meta.Accent = "#fdfcf8"
+ out, err := render.Slide(d, 0, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := varsOf(t, string(out))["--slides-accent"]; got != "#8a5410" {
+ t.Errorf("an invisible accent was used: %q", got)
+ }
+}
+
+// TestShapeColoursAreSanitised: fill and stroke are props on a block
+// anybody with edit rights can write, and they land in SVG presentation
+// attributes. Only a plain colour gets through.
+func TestShapeColoursAreSanitised(t *testing.T) {
+ d := fixture()
+ d.Slides[6].Blocks[4].Props["fill"] = "url(#x) onload=alert(1)"
+ d.Slides[6].Blocks[4].Props["stroke"] = "rgb(0,0,0);behavior:url(x)"
+ d.Slides[6].Blocks[4].Props["strokeWidth"] = "4;fill:url(#y)"
+ out, err := render.Slide(d, 6, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, gone := range []string{"url(#x)", "onload", "behavior", "url(#y)"} {
+ if strings.Contains(string(out), gone) {
+ t.Errorf("an unsafe shape prop survived: %q in %s", gone, out)
+ }
+ }
+}
+
+// TestImagesAlwaysCarryAnAlt: an image whose alt is missing renders an
+// empty alt plus data-missing-alt, so a screen reader never reads a
+// filename and the editor can show the block as unfinished. §2.4 makes
+// alt required; the renderer never omits the attribute.
+func TestImagesAlwaysCarryAnAlt(t *testing.T) {
+ out, err := render.Slide(fixture(), 6, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(out)
+ if n := strings.Count(s, "<img "); n != 2 {
+ t.Fatalf("want 2 images on the blank slide, got %d: %s", n, s)
+ }
+ if n := strings.Count(s, " alt="); n != 2 {
+ t.Errorf("an image rendered without an alt attribute: %s", s)
+ }
+ if n := strings.Count(s, "data-missing-alt"); n != 1 {
+ t.Errorf("want exactly one data-missing-alt (the undecorated one), got %d: %s", n, s)
+ }
+
+ // The image-full slide takes its alt from the registered asset.
+ full, err := render.Slide(fixture(), 4, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(full), `alt="A bar chart with no bars"`) {
+ t.Errorf("the asset's alt did not reach the img: %s", full)
+ }
+ if strings.Contains(string(full), "data-missing-alt") {
+ t.Errorf("an image with an alt was marked as missing one: %s", full)
+ }
+}
+
+// TestShapesAreLabelledOrHidden: a shape with a label is an image with
+// a name; a shape without one is decoration and is hidden from the
+// accessibility tree rather than announced as "graphic".
+func TestShapesAreLabelledOrHidden(t *testing.T) {
+ out, err := render.Slide(fixture(), 6, render.Desktop)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(out)
+ if !strings.Contains(s, `role="img"`) || !strings.Contains(s, "<title>A labelled rectangle</title>") {
+ t.Errorf("the labelled shape is not an img with a title: %s", s)
+ }
+ if n := strings.Count(s, `aria-hidden="true"`); n < 3 {
+ t.Errorf("want the three unlabelled shapes hidden, got %d: %s", n, s)
+ }
+}
+
+// TestEveryBlockTypeRenders: the §2.4 vocabulary, all of it, somewhere
+// in the fixture — the guard against a new block type quietly rendering
+// as nothing.
+func TestEveryBlockTypeRenders(t *testing.T) {
+ d := fixture()
+ seen := map[string]bool{}
+ for i := range d.Slides {
+ for _, v := range viewports {
+ out, err := render.Slide(d, i, v.vp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, b := range d.Slides[i].Blocks {
+ if strings.Contains(string(out), b.ID) {
+ seen[b.Type] = true
+ }
+ }
+ }
+ }
+ for _, kind := range []string{"heading", "text", "list", "quote", "image", "columns", "code", "shape", "table", "divider", "spacer"} {
+ if !seen[kind] {
+ t.Errorf("no fixture slide rendered a %q block", kind)
+ }
+ }
+}
+
+// TestPhoneViewportIsMarked: the section says which viewport it was
+// rendered for, so the stylesheet and the editor stage can both tell.
+func TestPhoneViewportIsMarked(t *testing.T) {
+ d := fixture()
+ desktop, _ := render.Slide(d, 0, render.Desktop)
+ phone, _ := render.Slide(d, 0, render.Phone)
+ if strings.Contains(string(desktop), "data-vp") {
+ t.Errorf("the desktop render carries data-vp: %s", desktop)
+ }
+ if !strings.Contains(string(phone), `data-vp="phone"`) {
+ t.Errorf("the phone render is not marked: %s", phone)
+ }
+}
diff --git a/internal/slides/render/testdata/blank-desktop.golden.html b/internal/slides/render/testdata/blank-desktop.golden.html
new file mode 100644
index 0000000..5f86d69
--- /dev/null
+++ b/internal/slides/render/testdata/blank-desktop.golden.html
@@ -0,0 +1,43 @@
+<section slides-slide data-index="6" data-id="slide007aaaa" data-layout="flow" data-template="blank" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="body">
+<pre slides-block data-block="code" data-id="b061aaaaaaaa" data-size="m" data-language="go" data-region="body"><code class="language-go">func main() {
+ fmt.Println(&#34;hi &lt;there&gt;&#34;)
+}</code></pre>
+<table slides-block data-block="table" data-id="b062aaaaaaaa" data-region="body">
+<thead>
+<tr>
+<th scope="col">Region</th>
+<th scope="col">Holds</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>heading</td>
+<td>the title</td>
+</tr>
+</tbody>
+</table>
+<hr slides-block data-block="divider" data-id="b063aaaaaaaa" data-region="body">
+<div slides-block data-block="spacer" data-id="b064aaaaaaaa" data-size="l" aria-hidden="true" data-region="body"></div>
+<svg slides-block data-block="shape" data-id="b065aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" role="img" data-region="body">
+<title>A labelled rectangle</title>
+<rect x="1" y="1" width="98" height="98" rx="12" fill="#e8e2d6" stroke="#8a7f6a" stroke-width="2" vector-effect="non-scaling-stroke" />
+</svg>
+<svg slides-block data-block="shape" data-id="b066aaaaaaaa" data-kind="ellipse" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-region="body">
+<ellipse cx="50" cy="50" rx="49" ry="49" fill="#cfd9e8" stroke="#5a6f8a" stroke-width="2" vector-effect="non-scaling-stroke" />
+</svg>
+<svg slides-block data-block="shape" data-id="b067aaaaaaaa" data-kind="line" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-region="body">
+<line x1="1" y1="50" x2="99" y2="50" fill="none" stroke="#5a6f8a" stroke-width="3" vector-effect="non-scaling-stroke" />
+</svg>
+<svg slides-block data-block="shape" data-id="b068aaaaaaaa" data-kind="arrow" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-region="body">
+<line x1="1" y1="50" x2="88" y2="50" fill="none" stroke="currentColor" stroke-width="1" vector-effect="non-scaling-stroke" />
+<path d="M99 50 L86 42 L86 58 Z" fill="currentColor" stroke="none" />
+</svg>
+<figure slides-block data-block="image" data-id="b069aaaaaaaa" data-fit="contain" data-region="body">
+<img src="/assets/asset0002bbb" alt="" width="64" height="64">
+</figure>
+<figure slides-block data-block="image" data-id="b070aaaaaaaa" data-fit="contain" data-region="body">
+<img src="/assets/asset0002bbb" alt="" width="64" height="64" data-missing-alt="true">
+</figure>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/blank-phone.golden.html b/internal/slides/render/testdata/blank-phone.golden.html
new file mode 100644
index 0000000..8c1c45f
--- /dev/null
+++ b/internal/slides/render/testdata/blank-phone.golden.html
@@ -0,0 +1,43 @@
+<section slides-slide data-index="6" data-id="slide007aaaa" data-layout="flow" data-template="blank" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="body">
+<pre slides-block data-block="code" data-id="b061aaaaaaaa" data-size="m" data-language="go" data-region="body"><code class="language-go">func main() {
+ fmt.Println(&#34;hi &lt;there&gt;&#34;)
+}</code></pre>
+<table slides-block data-block="table" data-id="b062aaaaaaaa" data-region="body">
+<thead>
+<tr>
+<th scope="col">Region</th>
+<th scope="col">Holds</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>heading</td>
+<td>the title</td>
+</tr>
+</tbody>
+</table>
+<hr slides-block data-block="divider" data-id="b063aaaaaaaa" data-region="body">
+<div slides-block data-block="spacer" data-id="b064aaaaaaaa" data-size="l" aria-hidden="true" data-region="body"></div>
+<svg slides-block data-block="shape" data-id="b065aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" role="img" data-region="body">
+<title>A labelled rectangle</title>
+<rect x="1" y="1" width="98" height="98" rx="12" fill="#e8e2d6" stroke="#8a7f6a" stroke-width="2" vector-effect="non-scaling-stroke" />
+</svg>
+<svg slides-block data-block="shape" data-id="b066aaaaaaaa" data-kind="ellipse" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-region="body">
+<ellipse cx="50" cy="50" rx="49" ry="49" fill="#cfd9e8" stroke="#5a6f8a" stroke-width="2" vector-effect="non-scaling-stroke" />
+</svg>
+<svg slides-block data-block="shape" data-id="b067aaaaaaaa" data-kind="line" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-region="body">
+<line x1="1" y1="50" x2="99" y2="50" fill="none" stroke="#5a6f8a" stroke-width="3" vector-effect="non-scaling-stroke" />
+</svg>
+<svg slides-block data-block="shape" data-id="b068aaaaaaaa" data-kind="arrow" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-region="body">
+<line x1="1" y1="50" x2="88" y2="50" fill="none" stroke="currentColor" stroke-width="1" vector-effect="non-scaling-stroke" />
+<path d="M99 50 L86 42 L86 58 Z" fill="currentColor" stroke="none" />
+</svg>
+<figure slides-block data-block="image" data-id="b069aaaaaaaa" data-fit="contain" data-region="body">
+<img src="/assets/asset0002bbb" alt="" width="64" height="64">
+</figure>
+<figure slides-block data-block="image" data-id="b070aaaaaaaa" data-fit="contain" data-region="body">
+<img src="/assets/asset0002bbb" alt="" width="64" height="64" data-missing-alt="true">
+</figure>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/canvas-desktop.golden.html b/internal/slides/render/testdata/canvas-desktop.golden.html
new file mode 100644
index 0000000..96f0c09
--- /dev/null
+++ b/internal/slides/render/testdata/canvas-desktop.golden.html
@@ -0,0 +1,9 @@
+<section slides-slide data-index="7" data-id="slide008aaaa" data-layout="canvas" data-template="blank" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<h1 slides-block data-block="heading" data-id="b081aaaaaaaa" data-level="1" data-z="3" style="left:6%;top:8%;width:88%;height:16%;z-index:3">Canvas: put it where you want it</h1>
+<svg slides-block data-block="shape" data-id="b082aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-z="1" style="left:8%;top:34%;width:36%;height:44%;z-index:1">
+<rect x="1" y="1" width="98" height="98" rx="12" fill="#e8e2d6" stroke="#8a7f6a" stroke-width="2" vector-effect="non-scaling-stroke" />
+</svg>
+<div slides-block data-block="text" data-id="b083aaaaaaaa" data-size="m" data-z="4" data-phone-order="0" data-phone-size="s" data-phone-box="0.0500,0.5000,0.9000,0.4500" style="left:58%;top:36%;width:36%;height:40%;z-index:4;--slides-phone-left:5%;--slides-phone-top:50%;--slides-phone-width:90%;--slides-phone-height:45%;--slides-phone-order:0">
+<p>Every box is a fraction of the 16:9 frame.</p>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/canvas-phone.golden.html b/internal/slides/render/testdata/canvas-phone.golden.html
new file mode 100644
index 0000000..faac93c
--- /dev/null
+++ b/internal/slides/render/testdata/canvas-phone.golden.html
@@ -0,0 +1,9 @@
+<section slides-slide data-index="7" data-id="slide008aaaa" data-layout="canvas" data-template="blank" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<h1 slides-block data-block="heading" data-id="b081aaaaaaaa" data-level="1" data-z="3" style="left:6%;top:8%;width:88%;height:16%;z-index:3">Canvas: put it where you want it</h1>
+<svg slides-block data-block="shape" data-id="b082aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-z="1" style="left:8%;top:34%;width:36%;height:44%;z-index:1">
+<rect x="1" y="1" width="98" height="98" rx="12" fill="#e8e2d6" stroke="#8a7f6a" stroke-width="2" vector-effect="non-scaling-stroke" />
+</svg>
+<div slides-block data-block="text" data-id="b083aaaaaaaa" data-size="m" data-z="4" data-phone-order="0" data-phone-size="s" data-phone-box="0.0500,0.5000,0.9000,0.4500" style="left:58%;top:36%;width:36%;height:40%;z-index:4;--slides-phone-left:5%;--slides-phone-top:50%;--slides-phone-width:90%;--slides-phone-height:45%;--slides-phone-order:0">
+<p>Every box is a fraction of the 16:9 frame.</p>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/canvas-reflow-desktop.golden.html b/internal/slides/render/testdata/canvas-reflow-desktop.golden.html
new file mode 100644
index 0000000..4a3f593
--- /dev/null
+++ b/internal/slides/render/testdata/canvas-reflow-desktop.golden.html
@@ -0,0 +1,13 @@
+<section slides-slide data-index="8" data-id="slide009aaaa" data-layout="canvas" data-template="blank" data-theme="paper" data-transition="fade" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<h1 slides-block data-block="heading" data-id="b091aaaaaaaa" data-level="1" data-z="1" style="left:6%;top:10%;width:88%;height:16%;z-index:1">On a phone this one reflows</h1>
+<svg slides-block data-block="shape" data-id="b092aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" role="img" data-z="2" data-phone-hidden="true" style="left:64%;top:32%;width:30%;height:44%;z-index:2">
+<title>Desktop only</title>
+<rect x="1" y="1" width="98" height="98" rx="12" fill="#e0d7e8" stroke="#75608a" stroke-width="2" vector-effect="non-scaling-stroke" />
+</svg>
+<div slides-block data-block="text" data-id="b093aaaaaaaa" data-size="m" data-z="2" style="left:6%;top:32%;width:52%;height:44%;z-index:2">
+<p>Setting phone to reflow lays the blocks out as one column.</p>
+</div>
+<div slides-block data-block="text" data-id="b094aaaaaaaa" data-size="m" data-z="3" style="left:6%;top:80%;width:88%;height:12%;z-index:3">
+<p>A footnote, last by z.</p>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/canvas-reflow-phone.golden.html b/internal/slides/render/testdata/canvas-reflow-phone.golden.html
new file mode 100644
index 0000000..c62beaa
--- /dev/null
+++ b/internal/slides/render/testdata/canvas-reflow-phone.golden.html
@@ -0,0 +1,15 @@
+<section slides-slide data-index="8" data-id="slide009aaaa" data-layout="flow" data-template="blank" data-theme="paper" data-transition="fade" data-vp="phone" data-reflowed="canvas" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="body">
+<h1 slides-block data-block="heading" data-id="b091aaaaaaaa" data-level="1">On a phone this one reflows</h1>
+<div slides-block data-block="text" data-id="b093aaaaaaaa" data-size="m">
+<p>Setting phone to reflow lays the blocks out as one column.</p>
+</div>
+<svg slides-block data-block="shape" data-id="b092aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" role="img" data-phone-hidden="true">
+<title>Desktop only</title>
+<rect x="1" y="1" width="98" height="98" rx="12" fill="#e0d7e8" stroke="#75608a" stroke-width="2" vector-effect="non-scaling-stroke" />
+</svg>
+<div slides-block data-block="text" data-id="b094aaaaaaaa" data-size="m">
+<p>A footnote, last by z.</p>
+</div>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/image-full-desktop.golden.html b/internal/slides/render/testdata/image-full-desktop.golden.html
new file mode 100644
index 0000000..b335c0c
--- /dev/null
+++ b/internal/slides/render/testdata/image-full-desktop.golden.html
@@ -0,0 +1,10 @@
+<section slides-slide data-index="4" data-id="slide005aaaa" data-layout="flow" data-template="image-full" data-theme="paper" data-background="image" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif;--slides-bg-image:url(/assets/asset0002bbb)">
+<div slides-region data-region="heading">
+<h2 slides-block data-block="heading" data-id="b042aaaaaaaa" data-level="2" data-region="heading">A picture would go here</h2>
+</div>
+<div slides-region data-region="media">
+<figure slides-block data-block="image" data-id="b041aaaaaaaa" data-fit="cover" data-region="media" style="--slides-radius:8px">
+<img src="/assets/asset0001aaa" alt="A bar chart with no bars" width="1600" height="900">
+</figure>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/image-full-phone.golden.html b/internal/slides/render/testdata/image-full-phone.golden.html
new file mode 100644
index 0000000..8106976
--- /dev/null
+++ b/internal/slides/render/testdata/image-full-phone.golden.html
@@ -0,0 +1,10 @@
+<section slides-slide data-index="4" data-id="slide005aaaa" data-layout="flow" data-template="image-full" data-theme="paper" data-background="image" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif;--slides-bg-image:url(/assets/asset0002bbb)">
+<div slides-region data-region="heading">
+<h2 slides-block data-block="heading" data-id="b042aaaaaaaa" data-level="2" data-region="heading">A picture would go here</h2>
+</div>
+<div slides-region data-region="media">
+<figure slides-block data-block="image" data-id="b041aaaaaaaa" data-fit="cover" data-region="media" style="--slides-radius:8px">
+<img src="/assets/asset0001aaa" alt="A bar chart with no bars" width="1600" height="900">
+</figure>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/quote-desktop.golden.html b/internal/slides/render/testdata/quote-desktop.golden.html
new file mode 100644
index 0000000..6ff63d0
--- /dev/null
+++ b/internal/slides/render/testdata/quote-desktop.golden.html
@@ -0,0 +1,15 @@
+<section slides-slide data-index="5" data-id="slide006aaaa" data-layout="flow" data-template="quote" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="body">
+<figure slides-block data-block="quote" data-id="b051aaaaaaaa" data-region="body">
+<blockquote>
+<p>A deck is a document, not a drawing.</p>
+</blockquote>
+<figcaption><cite>The design spec</cite></figcaption>
+</figure>
+<figure slides-block data-block="quote" data-id="b052aaaaaaaa" data-region="body">
+<blockquote>
+<p>Said by nobody in particular.</p>
+</blockquote>
+</figure>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/quote-phone.golden.html b/internal/slides/render/testdata/quote-phone.golden.html
new file mode 100644
index 0000000..fb70d47
--- /dev/null
+++ b/internal/slides/render/testdata/quote-phone.golden.html
@@ -0,0 +1,15 @@
+<section slides-slide data-index="5" data-id="slide006aaaa" data-layout="flow" data-template="quote" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="body">
+<figure slides-block data-block="quote" data-id="b051aaaaaaaa" data-region="body">
+<blockquote>
+<p>A deck is a document, not a drawing.</p>
+</blockquote>
+<figcaption><cite>The design spec</cite></figcaption>
+</figure>
+<figure slides-block data-block="quote" data-id="b052aaaaaaaa" data-region="body">
+<blockquote>
+<p>Said by nobody in particular.</p>
+</blockquote>
+</figure>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/section-desktop.golden.html b/internal/slides/render/testdata/section-desktop.golden.html
new file mode 100644
index 0000000..50d96ab
--- /dev/null
+++ b/internal/slides/render/testdata/section-desktop.golden.html
@@ -0,0 +1,5 @@
+<section slides-slide data-index="3" data-id="slide004aaaa" data-layout="flow" data-template="section" data-theme="paper" data-background="color" style="--slides-bg:#2b1030;--slides-fg:#faf7f0;--slides-accent:#faf7f0;--slides-accent-2:#faf7f0;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="heading">
+<h1 slides-block data-block="heading" data-id="b031aaaaaaaa" data-level="1" data-region="heading">Part two</h1>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/section-phone.golden.html b/internal/slides/render/testdata/section-phone.golden.html
new file mode 100644
index 0000000..f46e53d
--- /dev/null
+++ b/internal/slides/render/testdata/section-phone.golden.html
@@ -0,0 +1,5 @@
+<section slides-slide data-index="3" data-id="slide004aaaa" data-layout="flow" data-template="section" data-theme="paper" data-background="color" data-vp="phone" style="--slides-bg:#2b1030;--slides-fg:#faf7f0;--slides-accent:#faf7f0;--slides-accent-2:#faf7f0;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="heading">
+<h1 slides-block data-block="heading" data-id="b031aaaaaaaa" data-level="1" data-region="heading">Part two</h1>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/title-body-desktop.golden.html b/internal/slides/render/testdata/title-body-desktop.golden.html
new file mode 100644
index 0000000..18ae349
--- /dev/null
+++ b/internal/slides/render/testdata/title-body-desktop.golden.html
@@ -0,0 +1,18 @@
+<section slides-slide data-index="1" data-id="slide002aaaa" data-layout="flow" data-template="title-body" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="heading">
+<h1 slides-block data-block="heading" data-id="b011aaaaaaaa" data-level="1" data-region="heading">Text and marks</h1>
+</div>
+<div slides-region data-region="body">
+<div slides-block data-block="text" data-id="b012aaaaaaaa" data-size="m" data-align="start" data-region="body">
+<p>Marks: <strong>bold</strong>, <em>italic</em>, <code>code</code>, <mark>highlight</mark>, <strong><em><mark><code>all four</code></mark></em></strong> and a <a href="https://amadan.net/oficina/slides" rel="noopener">link</a> &lt;not markup&gt;.</p>
+</div>
+<ul slides-block data-block="list" data-id="b013aaaaaaaa" data-style="bullet" data-size="m" data-region="body">
+<li>One item per paragraph</li>
+<li>Bullets or numbers</li>
+</ul>
+<ol slides-block data-block="list" data-id="b014aaaaaaaa" data-style="number" data-size="s" data-indent="1" data-region="body">
+<li>First</li>
+<li>Second</li>
+</ol>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/title-body-phone.golden.html b/internal/slides/render/testdata/title-body-phone.golden.html
new file mode 100644
index 0000000..7622cd2
--- /dev/null
+++ b/internal/slides/render/testdata/title-body-phone.golden.html
@@ -0,0 +1,18 @@
+<section slides-slide data-index="1" data-id="slide002aaaa" data-layout="flow" data-template="title-body" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="heading">
+<h1 slides-block data-block="heading" data-id="b011aaaaaaaa" data-level="1" data-region="heading">Text and marks</h1>
+</div>
+<div slides-region data-region="body">
+<div slides-block data-block="text" data-id="b012aaaaaaaa" data-size="m" data-align="start" data-region="body">
+<p>Marks: <strong>bold</strong>, <em>italic</em>, <code>code</code>, <mark>highlight</mark>, <strong><em><mark><code>all four</code></mark></em></strong> and a <a href="https://amadan.net/oficina/slides" rel="noopener">link</a> &lt;not markup&gt;.</p>
+</div>
+<ul slides-block data-block="list" data-id="b013aaaaaaaa" data-style="bullet" data-size="m" data-region="body">
+<li>One item per paragraph</li>
+<li>Bullets or numbers</li>
+</ul>
+<ol slides-block data-block="list" data-id="b014aaaaaaaa" data-style="number" data-size="s" data-indent="1" data-region="body">
+<li>First</li>
+<li>Second</li>
+</ol>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/title-desktop.golden.html b/internal/slides/render/testdata/title-desktop.golden.html
new file mode 100644
index 0000000..fbde455
--- /dev/null
+++ b/internal/slides/render/testdata/title-desktop.golden.html
@@ -0,0 +1,10 @@
+<section slides-slide data-index="0" data-id="slide001aaaa" data-layout="flow" data-template="title" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="heading">
+<h1 slides-block data-block="heading" data-id="b001aaaaaaaa" data-level="1" data-region="heading">Renderer fixture</h1>
+</div>
+<div slides-region data-region="body">
+<div slides-block data-block="text" data-id="b002aaaaaaaa" data-size="l" data-region="body">
+<p>One section per slide, one element per block.</p>
+</div>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/title-phone.golden.html b/internal/slides/render/testdata/title-phone.golden.html
new file mode 100644
index 0000000..3bb1ab0
--- /dev/null
+++ b/internal/slides/render/testdata/title-phone.golden.html
@@ -0,0 +1,10 @@
+<section slides-slide data-index="0" data-id="slide001aaaa" data-layout="flow" data-template="title" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="heading">
+<h1 slides-block data-block="heading" data-id="b001aaaaaaaa" data-level="1" data-region="heading">Renderer fixture</h1>
+</div>
+<div slides-region data-region="body">
+<div slides-block data-block="text" data-id="b002aaaaaaaa" data-size="l" data-region="body">
+<p>One section per slide, one element per block.</p>
+</div>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/two-column-desktop.golden.html b/internal/slides/render/testdata/two-column-desktop.golden.html
new file mode 100644
index 0000000..5850314
--- /dev/null
+++ b/internal/slides/render/testdata/two-column-desktop.golden.html
@@ -0,0 +1,23 @@
+<section slides-slide data-index="2" data-id="slide003aaaa" data-layout="flow" data-template="two-column" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="heading">
+<h2 slides-block data-block="heading" data-id="b021aaaaaaaa" data-level="2" data-region="heading">Regions, not coordinates</h2>
+</div>
+<div slides-region data-region="body">
+<div slides-block data-block="text" data-id="b022aaaaaaaa" data-size="m" data-region="body">
+<p>A flow slide names regions and blocks stack inside the one they are in.</p>
+</div>
+<div slides-block data-block="columns" data-id="b023aaaaaaaa" data-count="2" data-region="body">
+<div slides-block data-block="text" data-id="b025aaaaaaaa" data-size="m" data-region="body">
+<p>First column, second in the array.</p>
+</div>
+<div slides-block data-block="text" data-id="b024aaaaaaaa" data-size="m" data-region="body">
+<p>Second column, first in the array.</p>
+</div>
+</div>
+</div>
+<div slides-region data-region="aside">
+<div slides-block data-block="text" data-id="b026aaaaaaaa" data-size="m" data-region="aside">
+<p>The aside is the second column.</p>
+</div>
+</div>
+</section>
diff --git a/internal/slides/render/testdata/two-column-phone.golden.html b/internal/slides/render/testdata/two-column-phone.golden.html
new file mode 100644
index 0000000..b33d284
--- /dev/null
+++ b/internal/slides/render/testdata/two-column-phone.golden.html
@@ -0,0 +1,23 @@
+<section slides-slide data-index="2" data-id="slide003aaaa" data-layout="flow" data-template="two-column" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
+<div slides-region data-region="heading">
+<h2 slides-block data-block="heading" data-id="b021aaaaaaaa" data-level="2" data-region="heading">Regions, not coordinates</h2>
+</div>
+<div slides-region data-region="body">
+<div slides-block data-block="text" data-id="b022aaaaaaaa" data-size="m" data-region="body">
+<p>A flow slide names regions and blocks stack inside the one they are in.</p>
+</div>
+<div slides-block data-block="columns" data-id="b023aaaaaaaa" data-count="2" data-region="body">
+<div slides-block data-block="text" data-id="b025aaaaaaaa" data-size="m" data-region="body">
+<p>First column, second in the array.</p>
+</div>
+<div slides-block data-block="text" data-id="b024aaaaaaaa" data-size="m" data-region="body">
+<p>Second column, first in the array.</p>
+</div>
+</div>
+</div>
+<div slides-region data-region="aside">
+<div slides-block data-block="text" data-id="b026aaaaaaaa" data-size="m" data-region="aside">
+<p>The aside is the second column.</p>
+</div>
+</div>
+</section>
diff --git a/internal/slides/render/themes.go b/internal/slides/render/themes.go
new file mode 100644
index 0000000..e40208a
--- /dev/null
+++ b/internal/slides/render/themes.go
@@ -0,0 +1,278 @@
+package render
+
+import (
+ "math"
+ "strconv"
+)
+
+// themes.go is the six looks a deck can wear (spec §2.1, the picker in
+// internal/slides/decks.go). A theme is five custom properties and a
+// second accent, nothing more: the stylesheet does the rest, so adding
+// a theme is adding an entry here and a catalog key beside it.
+//
+// ── Why these are plain hex and not light-dark() ──────────────────────
+//
+// The rest of this family's CSS is light-dark() throughout, because a
+// UI belongs to the person reading it and should follow their machine.
+// A slide does not. A deck is a fixed look that two people look at on
+// two screens at the same moment — a presenter's laptop and a room's
+// projector, or a phone in the audience — and a theme that inverted on
+// one of them would be a different deck, not a preference honoured. The
+// PDF and the SVG thumbnail have no scheme to follow at all, and they
+// must match the HTML. So each theme commits to one scheme: `paper` and
+// `candy` are light, the other four are dark, and every one of them is
+// the same on every screen.
+//
+// The consequence that matters is that contrast is decidable from these
+// values alone, and render_test.go's TestThemeContrast decides it: body
+// text on the background at 4.5:1, each accent on it at 3:1 (§8's WCAG
+// 2.2 AA, no exemptions). A theme that misses either does not compile
+// past the test.
+//
+// ── Why there are two accents ─────────────────────────────────────────
+//
+// `candy` is specified as "white, charcoal, pink and violet", so at
+// least one theme needs a second accent. Giving every theme one keeps
+// the variable set uniform — a stylesheet that reaches for
+// --slides-accent-2 gets an answer under all six — and gives the
+// section rule, a two-colour chart and a link-versus-highlight pair
+// somewhere to come from without inventing a colour at the call site.
+
+// serifStack is `paper`'s heading face: an old-style serif where one is
+// installed, Georgia everywhere else, and never a download — the CSS
+// fetches nothing (rastrillo's SKILL.md, §"Modern CSS is the floor").
+//
+// Unquoted, because these stacks are written into a style attribute and
+// CSS takes a family name as a sequence of identifiers: quoting would
+// only mean a run of &#39; in every section's markup for no gain. A
+// family whose name needs quotes (one starting with a digit, say) would
+// have to be quoted, and safeFontStack allows that for the deck-level
+// override.
+const serifStack = "Iowan Old Style, Palatino Linotype, Georgia, serif"
+
+// systemStack is every other theme's heading face and every theme's
+// body face: whatever the machine calls its own interface font.
+const systemStack = "system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
+
+// Theme is one entry in the picker: the id stored on the deck and the
+// English name. The app's own picker labels themes from the catalog
+// (slides.theme.<id>) rather than from Name, which is here so a caller
+// with no request in hand — a test, the drive API, a CLI — still has
+// something to print.
+type Theme struct {
+ ID string
+ Name string
+}
+
+// themeList is the six, in the order the picker shows them. It matches
+// internal/slides/decks.go's themeIDs, and the first entry is the
+// default a deck gets when nobody chose (core/src/schema.rs's
+// DEFAULT_THEME).
+var themeList = []Theme{
+ {ID: "paper", Name: "Paper"},
+ {ID: "ink", Name: "Ink"},
+ {ID: "sunset", Name: "Sunset"},
+ {ID: "forest", Name: "Forest"},
+ {ID: "slate", Name: "Slate"},
+ {ID: "candy", Name: "Candy"},
+}
+
+// defaultThemeID is what a deck naming a theme nobody has heard of
+// renders as. Never an error and never an unstyled slide: a deck whose
+// theme was renamed out from under it still reads.
+const defaultThemeID = "paper"
+
+// themeVars is each theme's palette. Read through ThemeVars, which
+// copies — this map is package state and a caller that mutated it would
+// change every slide the process renders afterwards.
+var themeVars = map[string]map[string]string{
+ // paper — warm white, near-black, ochre. The default: a printed
+ // page, and the one theme with a serif heading.
+ "paper": {
+ "--slides-bg": "#faf7f0",
+ "--slides-fg": "#1c1a17",
+ "--slides-accent": "#8a5410",
+ "--slides-accent-2": "#46617a",
+ "--slides-font-heading": serifStack,
+ "--slides-font-body": systemStack,
+ },
+ // ink — near-black, off-white, cyan. The dark twin of paper.
+ "ink": {
+ "--slides-bg": "#121417",
+ "--slides-fg": "#f2f4f6",
+ "--slides-accent": "#5fd3e6",
+ "--slides-accent-2": "#f0a35e",
+ "--slides-font-heading": systemStack,
+ "--slides-font-body": systemStack,
+ },
+ // sunset — deep plum, cream, coral.
+ "sunset": {
+ "--slides-bg": "#2b1030",
+ "--slides-fg": "#f7ead9",
+ "--slides-accent": "#ff8a6b",
+ "--slides-accent-2": "#ffd28a",
+ "--slides-font-heading": systemStack,
+ "--slides-font-body": systemStack,
+ },
+ // forest — dark green, pale, lime.
+ "forest": {
+ "--slides-bg": "#0f2418",
+ "--slides-fg": "#e8f2e6",
+ "--slides-accent": "#a8e05f",
+ "--slides-accent-2": "#e6c98a",
+ "--slides-font-heading": systemStack,
+ "--slides-font-body": systemStack,
+ },
+ // slate — cool grey-blue, white, amber.
+ "slate": {
+ "--slides-bg": "#2c3742",
+ "--slides-fg": "#ffffff",
+ "--slides-accent": "#f0b429",
+ "--slides-accent-2": "#7fd1f0",
+ "--slides-font-heading": systemStack,
+ "--slides-font-body": systemStack,
+ },
+ // candy — white, charcoal, pink and violet. The only theme whose
+ // two accents are both load-bearing.
+ "candy": {
+ "--slides-bg": "#ffffff",
+ "--slides-fg": "#26232b",
+ "--slides-accent": "#c2185b",
+ "--slides-accent-2": "#6a1b9a",
+ "--slides-font-heading": systemStack,
+ "--slides-font-body": systemStack,
+ },
+}
+
+// varOrder is the order Slide writes the custom properties in, so the
+// style attribute of two slides on the same theme is byte-identical and
+// a golden file is stable.
+var varOrder = []string{
+ "--slides-bg",
+ "--slides-fg",
+ "--slides-accent",
+ "--slides-accent-2",
+ "--slides-font-heading",
+ "--slides-font-body",
+}
+
+// Themes is the six themes in picker order.
+func Themes() []Theme {
+ out := make([]Theme, len(themeList))
+ copy(out, themeList)
+ return out
+}
+
+// ── Contrast ─────────────────────────────────────────────────────────
+//
+// A deck can set a slide's background to a colour of its own (§2.2) and
+// an accent of its own (§2.1), and either can land on a theme whose
+// text colour then disappears into it. §8 admits no exemptions, so the
+// renderer works out what is readable rather than emitting what it was
+// handed: readableOn below is the same WCAG 2.2 arithmetic
+// render_test.go checks the six themes with, and the reason a deck
+// colour must be hex — a colour this package cannot measure is a
+// colour it cannot promise anything about, so it is dropped.
+
+// bodyContrast is WCAG 2.2 AA for body text (1.4.3), and accentContrast
+// is the large-text and non-text threshold (1.4.11) an accent is used
+// at: headings, the section rule, a divider.
+const (
+ bodyContrast = 4.5
+ accentContrast = 3.0
+)
+
+// parseHexColor reads #rgb or #rrggbb into 0..1 sRGB channels. An alpha
+// channel is refused rather than ignored: a colour that is partly the
+// thing behind it has no contrast this package can compute.
+func parseHexColor(s string) ([3]float64, bool) {
+ if len(s) == 0 || s[0] != '#' {
+ return [3]float64{}, false
+ }
+ h := s[1:]
+ if len(h) == 3 {
+ h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
+ }
+ if len(h) != 6 {
+ return [3]float64{}, false
+ }
+ var out [3]float64
+ for i := range out {
+ v, err := strconv.ParseUint(h[i*2:i*2+2], 16, 8)
+ if err != nil {
+ return [3]float64{}, false
+ }
+ out[i] = float64(v) / 255
+ }
+ return out, true
+}
+
+// relLuminance is WCAG 2.2's relative luminance.
+func relLuminance(c [3]float64) float64 {
+ lin := func(v float64) float64 {
+ if v <= 0.04045 {
+ return v / 12.92
+ }
+ return math.Pow((v+0.055)/1.055, 2.4)
+ }
+ return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2])
+}
+
+// contrastRatio is WCAG 2.2's ratio, 1 to 21. Two colours it cannot
+// parse have no ratio, and 0 is below every threshold, which is the
+// answer that makes callers drop the pair.
+func contrastRatio(a, b string) float64 {
+ ca, ok := parseHexColor(a)
+ if !ok {
+ return 0
+ }
+ cb, ok := parseHexColor(b)
+ if !ok {
+ return 0
+ }
+ la, lb := relLuminance(ca), relLuminance(cb)
+ if la < lb {
+ la, lb = lb, la
+ }
+ return (la + 0.05) / (lb + 0.05)
+}
+
+// readableOn picks the first candidate that clears min against bg, and
+// failing all of them the one that comes closest — never nothing, so a
+// slide with an eccentric background is still legible rather than
+// blank.
+func readableOn(bg string, min float64, candidates ...string) string {
+ best, bestRatio := "", -1.0
+ for _, c := range candidates {
+ if c == "" {
+ continue
+ }
+ r := contrastRatio(c, bg)
+ if r >= min {
+ return c
+ }
+ if r > bestRatio {
+ best, bestRatio = c, r
+ }
+ }
+ return best
+}
+
+// ThemeVars is id's custom properties, ready to put on a wrapper
+// element so a whole page — a viewer with its chrome, the presenter's
+// two panes, a thumbnail strip — inherits the deck's palette without
+// every section repeating it. Slide writes the same set inline on the
+// section, so either surface alone is enough.
+//
+// The second result is false for a theme this build does not have.
+func ThemeVars(id string) (map[string]string, bool) {
+ vars, ok := themeVars[id]
+ if !ok {
+ return nil, false
+ }
+ out := make(map[string]string, len(vars))
+ for k, v := range vars {
+ out[k] = v
+ }
+ return out, true
+}
diff --git a/internal/slides/static/slides.css b/internal/slides/static/slides.css
new file mode 100644
index 0000000..21b3a92
--- /dev/null
+++ b/internal/slides/static/slides.css
@@ -0,0 +1,583 @@
+/* slides.css — the look of a slide.
+
+ internal/slides/render builds the tree; this file decides where the
+ regions sit and how big the words are. Nothing here knows what a deck
+ says, and nothing in the renderer writes a rule, so a template's
+ layout changes here and only here.
+
+ It is a separate stylesheet from tokens.css and theme.css on purpose.
+ Those two dress the app — the chrome around a deck, in the reader's
+ own light or dark. A slide is not chrome: it is a document with a
+ fixed look that has to be the same on the presenter's laptop and on
+ the projector, so its palette comes from the deck's theme
+ (internal/slides/render/themes.go, which says why it does not follow
+ the OS scheme) and arrives as custom properties inline on the
+ section. Everything below reads those; it never names a colour except
+ as a fallback for a section that arrived without them.
+
+ ── How a slide scales ───────────────────────────────────────────────
+
+ The section is a container (`container-type: inline-size`) and every
+ size inside it is in `cqw`, so a slide is one design that works at any
+ width: 1920 on a projector, 900 in the editor, 380 on a phone. There
+ is no second stylesheet and no breakpoint doing the scaling.
+
+ The type scale lives as custom properties ON the section and is used
+ BY its descendants. That order is load-bearing: `cqw` in a property
+ resolves against the element's own query container, and an element is
+ never its own container, so a `cqw` written directly on the section
+ would measure the page around it instead. Held in a custom property
+ it is substituted into the descendant's declaration and measured
+ there, against the section. The section's own padding is a percentage
+ for the same reason.
+
+ ── The phone ────────────────────────────────────────────────────────
+
+ Three mechanisms, and they do different jobs:
+
+ - `@container slide (max-width: 600px)` collapses the template grids
+ to one column, honours the `data-phone-*` layer, and puts a rem
+ floor under the type scale so a 320px reflow is still readable.
+ It fires for a narrow container anywhere — a phone, a two-up
+ editor, a preview pane.
+ - `[data-vp="phone"]`, which the renderer writes when it was asked
+ for a phone, drops the 16:9 frame on a flow slide so it grows as
+ tall as its content. A container query cannot do this: a query
+ styles a container's descendants, never the container itself.
+ - `@media (max-width: 600px)` does the same for a slide that was
+ rendered for a desktop and opened on a phone anyway — the
+ scriptless viewer has no way to know which it is serving, and §8's
+ 320px reflow is not optional.
+
+ Modern CSS is the floor (rastrillo SKILL.md): container queries,
+ `oklch()`, `color-mix()` and `light-dark()` are all free here. The
+ file fetches nothing — no import, no webfont, no remote image. */
+
+[slides-slide] {
+ /* The palette. These values are what a section renders as when it
+ arrived without inline custom properties — a fragment pasted into
+ a page, a test harness. A real section overrides every one of them
+ from the deck's theme. */
+ --slides-bg: #faf7f0;
+ --slides-fg: #1c1a17;
+ --slides-accent: #8a5410;
+ --slides-accent-2: #46617a;
+ --slides-font-heading: Iowan Old Style, Palatino Linotype, Georgia, serif;
+ --slides-font-body: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+
+ /* The type scale, in cqw so it is a fraction of the slide's own
+ width. Calibrated on a 1920-wide frame: 6.6cqw is ~127px, about a
+ 95pt title, and the body steps land near 32, 40 and 49pt — slide
+ sizes, not document sizes. */
+ --slides-t-1: 6.6cqw;
+ --slides-t-2: 5cqw;
+ --slides-t-3: 4cqw;
+ --slides-body-l: 3.4cqw;
+ --slides-body-m: 2.8cqw;
+ --slides-body-s: 2.2cqw;
+ --slides-code: 2.3cqw;
+
+ --slides-gap: 2.6cqw;
+ --slides-rule: 0.5cqw;
+ /* Percentage, not cqw: see the header. 6.25% of a 16:9 frame is
+ 120px at 1920, the margin a slide template usually keeps. */
+ --slides-pad: 6.25%;
+
+ box-sizing: border-box;
+ container-type: inline-size;
+ container-name: slide;
+ aspect-ratio: 16 / 9;
+ inline-size: 100%;
+ margin: 0;
+ padding: var(--slides-pad);
+ background-color: var(--slides-bg);
+ color: var(--slides-fg);
+ font-family: var(--slides-font-body);
+ line-height: 1.3;
+ text-wrap: pretty;
+ /* A slide that overflows scrolls rather than clipping. Losing the
+ end of a sentence to `overflow: hidden` is a WCAG 1.4.10 failure
+ nobody would ever see reported; a scrollbar is visible, and it is
+ the estilo allowance §8 already makes for the canvas stage. */
+ overflow: auto;
+
+ display: grid;
+ gap: var(--slides-gap);
+ grid-template-columns: 1fr;
+ align-content: start;
+}
+
+[slides-slide] * {
+ box-sizing: border-box;
+}
+
+/* A slide's own backdrop (§2.2): a colour arrives as --slides-bg and
+ needs no rule; an image arrives as a URL to paint. */
+[slides-slide][data-background="image"] {
+ background-image: var(--slides-bg-image);
+ background-size: cover;
+ background-position: center;
+}
+
+/* ── Regions ─────────────────────────────────────────────────────────
+ A region is a grid area and a column of blocks. A region a template
+ has no area for falls through to auto placement rather than
+ disappearing, which is what makes an aside on a title slide still
+ readable. */
+
+[slides-region] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--slides-gap);
+ min-inline-size: 0;
+ min-block-size: 0;
+}
+
+[slides-region][data-region="heading"] {
+ grid-area: heading;
+}
+[slides-region][data-region="body"] {
+ grid-area: body;
+}
+[slides-region][data-region="aside"] {
+ grid-area: aside;
+}
+[slides-region][data-region="media"] {
+ grid-area: media;
+}
+
+/* ── Templates ───────────────────────────────────────────────────────
+ Each is a grid, and each names its areas so the region rules above
+ land in the right cell. */
+
+/* title — the opening slide: everything centred, in the middle. */
+[slides-slide][data-template="title"] {
+ grid-template-areas: "heading" "body";
+ grid-template-rows: auto auto;
+ align-content: center;
+ justify-items: center;
+ text-align: center;
+}
+[slides-slide][data-template="title"] [slides-region] {
+ align-items: center;
+ max-inline-size: 80%;
+}
+
+/* title-body — the workhorse: a heading, then everything else. */
+[slides-slide][data-template="title-body"] {
+ grid-template-areas: "heading" "body";
+ grid-template-rows: auto 1fr;
+}
+
+/* two-column — the heading spans, body and aside sit side by side. */
+[slides-slide][data-template="two-column"] {
+ grid-template-areas:
+ "heading heading"
+ "body aside";
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: auto 1fr;
+ column-gap: calc(var(--slides-gap) * 1.6);
+}
+
+/* section — a chapter break: one heading, centred, over an accent bar
+ that is the only decoration in the whole stylesheet. */
+[slides-slide][data-template="section"] {
+ grid-template-areas: "heading";
+ align-content: center;
+}
+[slides-slide][data-template="section"] [data-region="heading"] {
+ align-items: center;
+}
+[slides-slide][data-template="section"] [data-region="heading"]::after {
+ content: "";
+ inline-size: 18cqw;
+ block-size: var(--slides-rule);
+ background-color: var(--slides-accent);
+ border-radius: var(--slides-rule);
+}
+
+/* image-full — the media fills the frame and the heading sits over its
+ bottom-left corner, on a scrim so the words survive a light picture.
+ The heading is still first in the document: the picture is not what
+ the slide is about. */
+[slides-slide][data-template="image-full"] {
+ grid-template-areas: "media";
+ grid-template-rows: 1fr;
+ padding: 0;
+ gap: 0;
+}
+[slides-slide][data-template="image-full"] [data-region="heading"] {
+ grid-area: media;
+ z-index: 1;
+ align-self: end;
+ justify-self: start;
+ max-inline-size: 80%;
+ margin: var(--slides-pad);
+ padding: calc(var(--slides-gap) / 2) var(--slides-gap);
+ border-radius: 1cqw;
+ /* The scrim is the slide's own background, mixed towards
+ transparent: it belongs to whichever theme is on. */
+ background-color: color-mix(in oklab, var(--slides-bg) 82%, transparent);
+ backdrop-filter: blur(0.6cqw);
+}
+[slides-slide][data-template="image-full"] [data-region="media"],
+[slides-slide][data-template="image-full"] [data-block="image"] {
+ block-size: 100%;
+ min-block-size: 0;
+}
+
+/* quote — one pull quote, centred, and nothing competing with it. */
+[slides-slide][data-template="quote"] {
+ grid-template-areas: "body";
+ align-content: center;
+ justify-items: center;
+}
+[slides-slide][data-template="quote"] [slides-region] {
+ align-items: center;
+ max-inline-size: 84%;
+ text-align: center;
+}
+
+/* blank — a single body region, stacked from the top. It is also what
+ a canvas slide reflowed onto a phone becomes (§2.3). */
+[slides-slide][data-template="blank"] {
+ grid-template-areas: "body";
+}
+
+/* ── Blocks ──────────────────────────────────────────────────────────
+ Semantic elements, sized off the scale, with the browser's own
+ margins removed: spacing on a slide is the region's gap, so two
+ blocks are never a gap plus a margin. */
+
+[slides-block] {
+ margin: 0;
+ min-inline-size: 0;
+}
+
+[slides-block][data-block="heading"] {
+ font-family: var(--slides-font-heading);
+ font-weight: 600;
+ line-height: 1.1;
+ text-wrap: balance;
+ letter-spacing: -0.01em;
+}
+[slides-block][data-level="1"] {
+ font-size: var(--slides-t-1);
+}
+[slides-block][data-level="2"] {
+ font-size: var(--slides-t-2);
+}
+[slides-block][data-level="3"] {
+ font-size: var(--slides-t-3);
+ color: var(--slides-accent);
+}
+[slides-slide][data-template="section"] [data-block="heading"] {
+ text-align: center;
+}
+
+[slides-block][data-block="text"],
+[slides-block][data-block="list"] {
+ font-size: var(--slides-body-m);
+}
+[slides-block][data-size="s"] {
+ font-size: var(--slides-body-s);
+}
+[slides-block][data-size="l"] {
+ font-size: var(--slides-body-l);
+}
+[slides-block][data-align="center"] {
+ text-align: center;
+}
+[slides-block][data-align="end"] {
+ text-align: end;
+}
+[slides-block][data-align="justify"] {
+ text-align: justify;
+}
+
+[slides-block] p {
+ margin: 0;
+}
+[slides-block] p + p {
+ margin-block-start: 0.6em;
+}
+
+[slides-block][data-block="list"] {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4em;
+ padding-inline-start: 1.4em;
+}
+[slides-block][data-block="list"][data-indent="1"] {
+ padding-inline-start: 3em;
+}
+[slides-block][data-block="list"][data-indent="2"] {
+ padding-inline-start: 4.6em;
+}
+[slides-block][data-block="list"][data-indent="3"] {
+ padding-inline-start: 6.2em;
+}
+[slides-block][data-block="list"] li::marker {
+ color: var(--slides-accent);
+}
+
+[slides-block][data-block="quote"] {
+ font-size: var(--slides-body-l);
+ display: flex;
+ flex-direction: column;
+ gap: 0.6em;
+}
+[slides-block][data-block="quote"] blockquote {
+ margin: 0;
+ font-style: italic;
+ text-wrap: balance;
+}
+[slides-block][data-block="quote"] figcaption {
+ font-size: 0.62em;
+ font-style: normal;
+ color: var(--slides-accent);
+}
+[slides-block][data-block="quote"] figcaption::before {
+ content: "— ";
+}
+[slides-block][data-block="quote"] cite {
+ font-style: normal;
+}
+
+[slides-block][data-block="image"] {
+ display: flex;
+ min-block-size: 0;
+}
+[slides-block][data-block="image"] img {
+ inline-size: 100%;
+ block-size: 100%;
+ max-inline-size: 100%;
+ border-radius: var(--slides-radius, 0);
+ object-fit: cover;
+}
+[slides-block][data-block="image"][data-fit="contain"] img {
+ object-fit: contain;
+}
+/* An image with no alt yet is not an error the reader should meet, but
+ the editor stage wants to see it. The outline shows only where the
+ host page asks for it. */
+[slides-editing] [slides-block] img[data-missing-alt] {
+ outline: var(--slides-rule) dashed var(--slides-accent);
+ outline-offset: calc(var(--slides-rule) * -1);
+}
+
+[slides-block][data-block="columns"] {
+ display: grid;
+ gap: var(--slides-gap);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+[slides-block][data-block="columns"][data-count="3"] {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+[slides-block][data-block="code"] {
+ font-size: var(--slides-code);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ line-height: 1.45;
+ padding: 1em;
+ border-radius: 0.6em;
+ overflow-x: auto;
+ tab-size: 2;
+ /* A panel a shade off the slide, in whichever direction the theme
+ runs — no second colour needed, and no assumption about light or
+ dark. */
+ background-color: color-mix(in oklab, var(--slides-fg) 8%, var(--slides-bg));
+}
+[slides-block][data-block="code"] code {
+ font: inherit;
+}
+[slides-block] :not(pre) > code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.88em;
+ padding: 0.1em 0.3em;
+ border-radius: 0.25em;
+ background-color: color-mix(in oklab, var(--slides-fg) 10%, var(--slides-bg));
+}
+
+[slides-block] mark {
+ background-color: color-mix(in oklab, var(--slides-accent) 26%, var(--slides-bg));
+ color: inherit;
+ padding: 0 0.15em;
+ border-radius: 0.2em;
+}
+[slides-block] a {
+ color: var(--slides-accent);
+ /* Never colour alone (§8): a link is underlined as well. */
+ text-decoration: underline;
+ text-underline-offset: 0.15em;
+ text-decoration-thickness: 0.07em;
+}
+
+[slides-block][data-block="table"] {
+ inline-size: 100%;
+ font-size: var(--slides-body-s);
+ border-collapse: collapse;
+}
+[slides-block][data-block="table"] th,
+[slides-block][data-block="table"] td {
+ text-align: start;
+ vertical-align: top;
+ padding: 0.45em 0.7em;
+ border-block-end: 1px solid color-mix(in oklab, var(--slides-fg) 22%, var(--slides-bg));
+}
+[slides-block][data-block="table"] th {
+ font-weight: 600;
+ border-block-end-color: var(--slides-accent);
+}
+
+[slides-block][data-block="divider"] {
+ border: 0;
+ inline-size: 100%;
+ block-size: var(--slides-rule);
+ background-color: color-mix(in oklab, var(--slides-fg) 30%, var(--slides-bg));
+ border-radius: var(--slides-rule);
+}
+
+[slides-block][data-block="spacer"] {
+ block-size: var(--slides-gap);
+ flex: none;
+}
+[slides-block][data-block="spacer"][data-size="s"] {
+ block-size: calc(var(--slides-gap) / 2);
+}
+[slides-block][data-block="spacer"][data-size="l"] {
+ block-size: calc(var(--slides-gap) * 2);
+}
+
+[slides-block][data-block="shape"] {
+ inline-size: 100%;
+ block-size: auto;
+ min-block-size: 8cqw;
+ overflow: visible;
+}
+
+/* ── Canvas ──────────────────────────────────────────────────────────
+ Blocks placed by hand (§2.3). The boxes are percentages of the frame,
+ so a canvas slide needs no breakpoint at all: it scales as a whole,
+ text included, because the type scale is in cqw. */
+
+[slides-slide][data-layout="canvas"] {
+ display: block;
+ position: relative;
+ padding: 0;
+ overflow: hidden;
+}
+[slides-slide][data-layout="canvas"] > [slides-block] {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ overflow: hidden;
+}
+[slides-slide][data-layout="canvas"] > [data-block="shape"] {
+ display: block;
+ block-size: 100%;
+ min-block-size: 0;
+}
+[slides-slide][data-layout="canvas"] > [data-block="image"] {
+ display: block;
+}
+
+/* ── The phone layer ─────────────────────────────────────────────────
+ Everything below is scoped to a narrow container, and it is last in
+ the file so a phone rule beats the desktop rule it ties with. */
+
+@container slide (max-width: 600px) {
+ /* A rem floor under the scale: at 380px a slide's 2.8cqw body would
+ be 10px, and §8 takes 320px reflow with no exemptions. The floors
+ are set on the descendants rather than on the section, because a
+ container query styles a container's contents and never the
+ container itself. */
+ [slides-region],
+ [slides-slide] > [slides-block] {
+ --slides-t-1: max(1.6rem, 9cqw);
+ --slides-t-2: max(1.35rem, 7cqw);
+ --slides-t-3: max(1.15rem, 5.6cqw);
+ --slides-body-l: max(1.1rem, 4.6cqw);
+ --slides-body-m: max(1rem, 4cqw);
+ --slides-body-s: max(0.9rem, 3.4cqw);
+ --slides-code: max(0.85rem, 3.2cqw);
+ --slides-gap: max(0.8rem, 4cqw);
+ }
+
+ /* Every template collapses to one column, in document order. */
+ [slides-region] {
+ grid-area: auto;
+ }
+
+ /* image-full stops overlaying: the heading goes under the picture,
+ where a phone-sized scrim would have been unreadable anyway. */
+ [slides-slide][data-template="image-full"] [data-region="heading"] {
+ grid-area: auto;
+ max-inline-size: none;
+ margin: 0;
+ background: none;
+ backdrop-filter: none;
+ }
+ [slides-slide][data-template="image-full"] [data-block="image"] img {
+ block-size: auto;
+ object-fit: contain;
+ }
+
+ [slides-slide][data-template="title"] [slides-region],
+ [slides-slide][data-template="quote"] [slides-region] {
+ max-inline-size: none;
+ }
+
+ /* A columns block is columns no longer. */
+ [slides-block][data-block="columns"] {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ /* §2.5, the phone layer. `hidden` and `size` are attributes the
+ selector can read; `order` and `box` are values, because CSS
+ cannot pull a number out of a data attribute — the renderer sends
+ those as custom properties on the same element. */
+ [slides-block][data-phone-hidden] {
+ display: none;
+ }
+ [slides-block] {
+ order: var(--slides-phone-order, 0);
+ }
+ [slides-block][data-phone-size="s"] {
+ font-size: var(--slides-body-s);
+ }
+ [slides-block][data-phone-size="m"] {
+ font-size: var(--slides-body-m);
+ }
+ [slides-block][data-phone-size="l"] {
+ font-size: var(--slides-body-l);
+ }
+ [slides-slide][data-layout="canvas"] > [slides-block][data-phone-box] {
+ left: var(--slides-phone-left);
+ top: var(--slides-phone-top);
+ width: var(--slides-phone-width);
+ height: var(--slides-phone-height);
+ }
+}
+
+/* A flow slide rendered for a phone (§2.3's reflow, and every template
+ at a phone width) is as tall as what is on it. The 16:9 frame is a
+ projector's shape, not a page's, and keeping it here would mean
+ either a scrollbar per slide or words falling off the bottom.
+
+ Two selectors for one rule, because there are two ways to arrive:
+ the renderer was told it was a phone, or a desktop render was opened
+ on one — which is every scriptless viewer, since a server rendering
+ HTML has no honest way to know the screen. */
+[slides-slide][data-vp="phone"]:not([data-layout="canvas"]) {
+ aspect-ratio: auto;
+ overflow: visible;
+}
+
+@media (max-width: 600px) {
+ [slides-slide]:not([data-layout="canvas"]) {
+ aspect-ratio: auto;
+ overflow: visible;
+ }
+}