oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package render
2
3import (
4 "math"
5 "strconv"
6
7 "amadan.net/oficina/slides/internal/slides/fonts"
8)
9
10// themes.go is the six looks a deck can wear (spec §2.1, the picker in
11// internal/slides/decks.go). A theme is five custom properties and a
12// second accent, nothing more: the stylesheet does the rest, so adding
13// a theme is adding an entry here and a catalog key beside it.
14//
15// ── Why these are plain hex and not light-dark() ──────────────────────
16//
17// The rest of this family's CSS is light-dark() throughout, because a
18// UI belongs to the person reading it and should follow their machine.
19// A slide does not. A deck is a fixed look that two people look at on
20// two screens at the same moment — a presenter's laptop and a room's
21// projector, or a phone in the audience — and a theme that inverted on
22// one of them would be a different deck, not a preference honoured. The
23// PDF and the SVG thumbnail have no scheme to follow at all, and they
24// must match the HTML. So each theme commits to one scheme: `paper` and
25// `candy` are light, the other four are dark, and every one of them is
26// the same on every screen.
27//
28// The consequence that matters is that contrast is decidable from these
29// values alone, and render_test.go's TestThemeContrast decides it: body
30// text on the background at 4.5:1, each accent on it at 3:1 (§8's WCAG
31// 2.2 AA, no exemptions). A theme that misses either does not compile
32// past the test.
33//
34// ── Why there are two accents ─────────────────────────────────────────
35//
36// `candy` is specified as "white, charcoal, pink and violet", so at
37// least one theme needs a second accent. Giving every theme one keeps
38// the variable set uniform — a stylesheet that reaches for
39// --slides-accent-2 gets an answer under all six — and gives the
40// section rule, a two-colour chart and a link-versus-highlight pair
41// somewhere to come from without inventing a colour at the call site.
42
43// serifStack is `paper`'s heading face and systemStack is every other
44// theme's heading face and every theme's body face. Both name a face
45// this app serves itself (internal/slides/fonts, declared in
46// slides.css) ahead of the machine's own, and that order is the point:
47// the Go layout engine measures a slide with those exact font programs
48// to make the SVG and the PDF, so a browser setting the same slide in
49// whatever it calls `system-ui` would break its lines somewhere else.
50// The fallbacks are what a reader sees for the moment before the
51// download lands, and they are chosen to be near enough that the
52// reflow is not a jolt.
53//
54// They come from fonts rather than being spelled here so that one
55// place decides what a family is called; fonts.FamilyForStack reads
56// them back, which is how the layout engine turns a theme's CSS
57// property into a face to measure with.
58//
59// Unquoted, because these stacks are written into a style attribute and
60// CSS takes a family name as a sequence of identifiers: quoting would
61// only mean a run of ' in every section's markup for no gain. A
62// family whose name needs quotes (one starting with a digit, say) would
63// have to be quoted, and safeFontStack allows that for the deck-level
64// override.
65var serifStack = fonts.Serif.Stack()
66
67var systemStack = fonts.Sans.Stack()
68
69// Theme is one entry in the picker: the id stored on the deck and the
70// English name. The app's own picker labels themes from the catalog
71// (slides.theme.<id>) rather than from Name, which is here so a caller
72// with no request in hand — a test, the drive API, a CLI — still has
73// something to print.
74type Theme struct {
75 ID string
76 Name string
77}
78
79// themeList is the six, in the order the picker shows them. It matches
80// internal/slides/decks.go's themeIDs, and the first entry is the
81// default a deck gets when nobody chose (core/src/schema.rs's
82// DEFAULT_THEME).
83var themeList = []Theme{
84 {ID: "paper", Name: "Paper"},
85 {ID: "ink", Name: "Ink"},
86 {ID: "sunset", Name: "Sunset"},
87 {ID: "forest", Name: "Forest"},
88 {ID: "slate", Name: "Slate"},
89 {ID: "candy", Name: "Candy"},
90}
91
92// defaultThemeID is what a deck naming a theme nobody has heard of
93// renders as. Never an error and never an unstyled slide: a deck whose
94// theme was renamed out from under it still reads.
95const defaultThemeID = "paper"
96
97// themeVars is each theme's palette. Read through ThemeVars, which
98// copies — this map is package state and a caller that mutated it would
99// change every slide the process renders afterwards.
100var themeVars = map[string]map[string]string{
101 // paper — warm white, near-black, ochre. The default: a printed
102 // page, and the one theme with a serif heading.
103 "paper": {
104 "--slides-bg": "#faf7f0",
105 "--slides-fg": "#1c1a17",
106 "--slides-accent": "#8a5410",
107 "--slides-accent-2": "#46617a",
108 "--slides-font-heading": serifStack,
109 "--slides-font-body": systemStack,
110 },
111 // ink — near-black, off-white, cyan. The dark twin of paper.
112 "ink": {
113 "--slides-bg": "#121417",
114 "--slides-fg": "#f2f4f6",
115 "--slides-accent": "#5fd3e6",
116 "--slides-accent-2": "#f0a35e",
117 "--slides-font-heading": systemStack,
118 "--slides-font-body": systemStack,
119 },
120 // sunset — deep plum, cream, coral.
121 "sunset": {
122 "--slides-bg": "#2b1030",
123 "--slides-fg": "#f7ead9",
124 "--slides-accent": "#ff8a6b",
125 "--slides-accent-2": "#ffd28a",
126 "--slides-font-heading": systemStack,
127 "--slides-font-body": systemStack,
128 },
129 // forest — dark green, pale, lime.
130 "forest": {
131 "--slides-bg": "#0f2418",
132 "--slides-fg": "#e8f2e6",
133 "--slides-accent": "#a8e05f",
134 "--slides-accent-2": "#e6c98a",
135 "--slides-font-heading": systemStack,
136 "--slides-font-body": systemStack,
137 },
138 // slate — cool grey-blue, white, amber.
139 "slate": {
140 "--slides-bg": "#2c3742",
141 "--slides-fg": "#ffffff",
142 "--slides-accent": "#f0b429",
143 "--slides-accent-2": "#7fd1f0",
144 "--slides-font-heading": systemStack,
145 "--slides-font-body": systemStack,
146 },
147 // candy — white, charcoal, pink and violet. The only theme whose
148 // two accents are both load-bearing.
149 "candy": {
150 "--slides-bg": "#ffffff",
151 "--slides-fg": "#26232b",
152 "--slides-accent": "#c2185b",
153 "--slides-accent-2": "#6a1b9a",
154 "--slides-font-heading": systemStack,
155 "--slides-font-body": systemStack,
156 },
157}
158
159// varOrder is the order Slide writes the custom properties in, so the
160// style attribute of two slides on the same theme is byte-identical and
161// a golden file is stable.
162var varOrder = []string{
163 "--slides-bg",
164 "--slides-fg",
165 "--slides-accent",
166 "--slides-accent-2",
167 "--slides-font-heading",
168 "--slides-font-body",
169}
170
171// Themes is the six themes in picker order.
172func Themes() []Theme {
173 out := make([]Theme, len(themeList))
174 copy(out, themeList)
175 return out
176}
177
178// ── Contrast ─────────────────────────────────────────────────────────
179//
180// A deck can set a slide's background to a colour of its own (§2.2) and
181// an accent of its own (§2.1), and either can land on a theme whose
182// text colour then disappears into it. §8 admits no exemptions, so the
183// renderer works out what is readable rather than emitting what it was
184// handed: readableOn below is the same WCAG 2.2 arithmetic
185// render_test.go checks the six themes with, and the reason a deck
186// colour must be hex — a colour this package cannot measure is a
187// colour it cannot promise anything about, so it is dropped.
188
189// bodyContrast is WCAG 2.2 AA for body text (1.4.3), and accentContrast
190// is the large-text and non-text threshold (1.4.11) an accent is used
191// at: headings, the section rule, a divider.
192const (
193 bodyContrast = 4.5
194 accentContrast = 3.0
195)
196
197// parseHexColor reads #rgb or #rrggbb into 0..1 sRGB channels. An alpha
198// channel is refused rather than ignored: a colour that is partly the
199// thing behind it has no contrast this package can compute.
200func parseHexColor(s string) ([3]float64, bool) {
201 if len(s) == 0 || s[0] != '#' {
202 return [3]float64{}, false
203 }
204 h := s[1:]
205 if len(h) == 3 {
206 h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
207 }
208 if len(h) != 6 {
209 return [3]float64{}, false
210 }
211 var out [3]float64
212 for i := range out {
213 v, err := strconv.ParseUint(h[i*2:i*2+2], 16, 8)
214 if err != nil {
215 return [3]float64{}, false
216 }
217 out[i] = float64(v) / 255
218 }
219 return out, true
220}
221
222// relLuminance is WCAG 2.2's relative luminance.
223func relLuminance(c [3]float64) float64 {
224 lin := func(v float64) float64 {
225 if v <= 0.04045 {
226 return v / 12.92
227 }
228 return math.Pow((v+0.055)/1.055, 2.4)
229 }
230 return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2])
231}
232
233// contrastRatio is WCAG 2.2's ratio, 1 to 21. Two colours it cannot
234// parse have no ratio, and 0 is below every threshold, which is the
235// answer that makes callers drop the pair.
236func contrastRatio(a, b string) float64 {
237 ca, ok := parseHexColor(a)
238 if !ok {
239 return 0
240 }
241 cb, ok := parseHexColor(b)
242 if !ok {
243 return 0
244 }
245 la, lb := relLuminance(ca), relLuminance(cb)
246 if la < lb {
247 la, lb = lb, la
248 }
249 return (la + 0.05) / (lb + 0.05)
250}
251
252// readableOn picks the first candidate that clears min against bg, and
253// failing all of them the one that comes closest — never nothing, so a
254// slide with an eccentric background is still legible rather than
255// blank.
256func readableOn(bg string, min float64, candidates ...string) string {
257 best, bestRatio := "", -1.0
258 for _, c := range candidates {
259 if c == "" {
260 continue
261 }
262 r := contrastRatio(c, bg)
263 if r >= min {
264 return c
265 }
266 if r > bestRatio {
267 best, bestRatio = c, r
268 }
269 }
270 return best
271}
272
273// ThemeVars is id's custom properties, ready to put on a wrapper
274// element so a whole page — a viewer with its chrome, the presenter's
275// two panes, a thumbnail strip — inherits the deck's palette without
276// every section repeating it. Slide writes the same set inline on the
277// section, so either surface alone is enough.
278//
279// The second result is false for a theme this build does not have.
280func ThemeVars(id string) (map[string]string, bool) {
281 vars, ok := themeVars[id]
282 if !ok {
283 return nil, false
284 }
285 out := make(map[string]string, len(vars))
286 for k, v := range vars {
287 out[k] = v
288 }
289 return out, true
290}
291