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
internal/slides/render/render.go+1136 −0internal/slides/render/render_test.go+866 −0internal/slides/render/testdata/blank-desktop.golden.html+43 −0internal/slides/render/testdata/blank-phone.golden.html+43 −0internal/slides/render/testdata/canvas-desktop.golden.html+9 −0internal/slides/render/testdata/canvas-phone.golden.html+9 −0internal/slides/render/testdata/canvas-reflow-desktop.golden.html+13 −0internal/slides/render/testdata/canvas-reflow-phone.golden.html+15 −0internal/slides/render/testdata/image-full-desktop.golden.html+10 −0internal/slides/render/testdata/image-full-phone.golden.html+10 −0internal/slides/render/testdata/quote-desktop.golden.html+15 −0internal/slides/render/testdata/quote-phone.golden.html+15 −0internal/slides/render/testdata/section-desktop.golden.html+5 −0internal/slides/render/testdata/section-phone.golden.html+5 −0internal/slides/render/testdata/title-body-desktop.golden.html+18 −0internal/slides/render/testdata/title-body-phone.golden.html+18 −0internal/slides/render/testdata/title-desktop.golden.html+10 −0internal/slides/render/testdata/title-phone.golden.html+10 −0internal/slides/render/testdata/two-column-desktop.golden.html+23 −0internal/slides/render/testdata/two-column-phone.golden.html+23 −0internal/slides/render/themes.go+278 −0internal/slides/static/slides.css+583 −0
Intent
<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>
<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(&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: "&", 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;") { |
| + t.Errorf("a literal & 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("hi <there>") |
| +}</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("hi <there>") |
| +}</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> <not markup>.</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> <not markup>.</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 ' 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; |
| + } |
| +} |