| 1 | package render |
| 2 | |
| 3 | import ( |
| 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. |
| 65 | var serifStack = fonts.Serif.Stack() |
| 66 | |
| 67 | var 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. |
| 74 | type 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). |
| 83 | var 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. |
| 95 | const 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. |
| 100 | var 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. |
| 162 | var 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. |
| 172 | func 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. |
| 192 | const ( |
| 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. |
| 200 | func 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. |
| 223 | func 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. |
| 236 | func 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. |
| 256 | func 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. |
| 280 | func 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 | |