| 1 | /* slides.css — the look of a slide. |
| 2 | |
| 3 | internal/slides/render builds the tree; this file decides where the |
| 4 | regions sit and how big the words are. Nothing here knows what a deck |
| 5 | says, and nothing in the renderer writes a rule, so a template's |
| 6 | layout changes here and only here. |
| 7 | |
| 8 | It is a separate stylesheet from tokens.css and theme.css on purpose. |
| 9 | Those two dress the app — the chrome around a deck, in the reader's |
| 10 | own light or dark. A slide is not chrome: it is a document with a |
| 11 | fixed look that has to be the same on the presenter's laptop and on |
| 12 | the projector, so its palette comes from the deck's theme |
| 13 | (internal/slides/render/themes.go, which says why it does not follow |
| 14 | the OS scheme) and arrives as custom properties inline on the |
| 15 | section. Everything below reads those; it never names a colour except |
| 16 | as a fallback for a section that arrived without them. |
| 17 | |
| 18 | ── How a slide scales ─────────────────────────────────────────────── |
| 19 | |
| 20 | The section is a container (`container-type: inline-size`) and every |
| 21 | size inside it is in `cqw`, so a slide is one design that works at any |
| 22 | width: 1920 on a projector, 900 in the editor, 380 on a phone. There |
| 23 | is no second stylesheet and no breakpoint doing the scaling. |
| 24 | |
| 25 | The type scale lives as custom properties ON the section and is used |
| 26 | BY its descendants. That order is load-bearing: `cqw` in a property |
| 27 | resolves against the element's own query container, and an element is |
| 28 | never its own container, so a `cqw` written directly on the section |
| 29 | would measure the page around it instead. Held in a custom property |
| 30 | it is substituted into the descendant's declaration and measured |
| 31 | there, against the section. The section's own padding is a percentage |
| 32 | for the same reason. |
| 33 | |
| 34 | And that is why the template grid is not on the section but on the |
| 35 | `[slides-frame]` inside it. `gap: var(--slides-gap)` is a `cqw` |
| 36 | length USED on the element it is written on, which is the one place |
| 37 | the rule above bites: written on the section it measured whatever |
| 38 | container was outside — the space between a heading and a body was |
| 39 | 49.92px on a 1920 screen, 20.27px in a 780px window, and a different |
| 40 | number again in the editor's stage. On the frame it is a descendant |
| 41 | declaration like every other one, so it is 2.6% of the section's |
| 42 | content box wherever the slide is drawn (43.68px at 1920), and |
| 43 | internal/slides/layout can state one number for it. |
| 44 | |
| 45 | ── The phone ──────────────────────────────────────────────────────── |
| 46 | |
| 47 | Three mechanisms, and they do different jobs: |
| 48 | |
| 49 | - `@container slide (max-width: 600px)` collapses the template grids |
| 50 | to one column, honours the `data-phone-*` layer, and puts a rem |
| 51 | floor under the type scale so a 320px reflow is still readable. |
| 52 | It fires for a narrow container anywhere — a phone, a two-up |
| 53 | editor, a preview pane. |
| 54 | - `[data-vp="phone"]`, which the renderer writes when it was asked |
| 55 | for a phone, drops the 16:9 frame on a flow slide so it grows as |
| 56 | tall as its content. A container query cannot do this: a query |
| 57 | styles a container's descendants, never the container itself. |
| 58 | - `@media (max-width: 600px)` does the same for a slide that was |
| 59 | rendered for a desktop and opened on a phone anyway — the |
| 60 | scriptless viewer has no way to know which it is serving, and §8's |
| 61 | 320px reflow is not optional. |
| 62 | |
| 63 | Modern CSS is the floor (rastrillo SKILL.md): container queries, |
| 64 | `oklch()`, `color-mix()` and `light-dark()` are all free here. |
| 65 | |
| 66 | ── The fonts ──────────────────────────────────────────────────────── |
| 67 | |
| 68 | The one thing this file does fetch: the twelve Liberation faces the |
| 69 | app serves itself, declared below. They are not decoration. The Go |
| 70 | layout engine (internal/slides/layout) measures a slide with these |
| 71 | exact font programs to produce the SVG thumbnail and the PDF, so a |
| 72 | browser laying the same slide out with whatever the machine calls |
| 73 | `system-ui` would break its lines somewhere else — and a deck that |
| 74 | paginates one way on screen and another way in its own export is two |
| 75 | decks. internal/slides/fonts holds the same twelve files for the |
| 76 | engine's side of that promise. |
| 77 | |
| 78 | Each URL carries the file's content hash so the asset handler serves |
| 79 | it immutable (rastrillo's Assets: a hashed name that matches the |
| 80 | current content is cacheable forever). A hash that has gone stale is |
| 81 | not a 404 — the handler strips it, finds the file and serves it |
| 82 | no-cache — so the failure mode is a slower fetch, not a missing |
| 83 | font. internal/slidestest's TestSlidesCSSFontURLsCarryTheirContentHash |
| 84 | is what keeps them current. */ |
| 85 | |
| 86 | /* ── @font-face ────────────────────────────────────────────────────── |
| 87 | Liberation Serif, Sans and Mono, SIL OFL 1.1 — the licence ships |
| 88 | beside them at /static/fonts/LICENSE-OFL-1.1.txt, as it requires. |
| 89 | TrueType rather than woff2 (about twice the bytes) because |
| 90 | converting needs a build step this repo's committed-artifact static |
| 91 | tree does not have; a browser fetches only the faces a page uses. |
| 92 | `swap`, because a slide with invisible text is worse for three |
| 93 | seconds than a slide that reflows once. */ |
| 94 | |
| 95 | @font-face { |
| 96 | font-family: "Liberation Sans"; |
| 97 | font-style: normal; |
| 98 | font-weight: 400; |
| 99 | font-display: swap; |
| 100 | src: url("fonts/LiberationSans-Regular.bade59d822652f76.ttf") format("truetype"); |
| 101 | } |
| 102 | @font-face { |
| 103 | font-family: "Liberation Sans"; |
| 104 | font-style: normal; |
| 105 | font-weight: 700; |
| 106 | font-display: swap; |
| 107 | src: url("fonts/LiberationSans-Bold.1b5f2da6f4cadce4.ttf") format("truetype"); |
| 108 | } |
| 109 | @font-face { |
| 110 | font-family: "Liberation Sans"; |
| 111 | font-style: italic; |
| 112 | font-weight: 400; |
| 113 | font-display: swap; |
| 114 | src: url("fonts/LiberationSans-Italic.93ff5260834141b6.ttf") format("truetype"); |
| 115 | } |
| 116 | @font-face { |
| 117 | font-family: "Liberation Sans"; |
| 118 | font-style: italic; |
| 119 | font-weight: 700; |
| 120 | font-display: swap; |
| 121 | src: url("fonts/LiberationSans-BoldItalic.352e9dc2be89f210.ttf") format("truetype"); |
| 122 | } |
| 123 | |
| 124 | @font-face { |
| 125 | font-family: "Liberation Serif"; |
| 126 | font-style: normal; |
| 127 | font-weight: 400; |
| 128 | font-display: swap; |
| 129 | src: url("fonts/LiberationSerif-Regular.9caef765d2e891c1.ttf") format("truetype"); |
| 130 | } |
| 131 | @font-face { |
| 132 | font-family: "Liberation Serif"; |
| 133 | font-style: normal; |
| 134 | font-weight: 700; |
| 135 | font-display: swap; |
| 136 | src: url("fonts/LiberationSerif-Bold.443a4dbd694b6e46.ttf") format("truetype"); |
| 137 | } |
| 138 | @font-face { |
| 139 | font-family: "Liberation Serif"; |
| 140 | font-style: italic; |
| 141 | font-weight: 400; |
| 142 | font-display: swap; |
| 143 | src: url("fonts/LiberationSerif-Italic.de18fe26337e0324.ttf") format("truetype"); |
| 144 | } |
| 145 | @font-face { |
| 146 | font-family: "Liberation Serif"; |
| 147 | font-style: italic; |
| 148 | font-weight: 700; |
| 149 | font-display: swap; |
| 150 | src: url("fonts/LiberationSerif-BoldItalic.4b2635d2ec4d9923.ttf") format("truetype"); |
| 151 | } |
| 152 | |
| 153 | @font-face { |
| 154 | font-family: "Liberation Mono"; |
| 155 | font-style: normal; |
| 156 | font-weight: 400; |
| 157 | font-display: swap; |
| 158 | src: url("fonts/LiberationMono-Regular.5883330d94debd99.ttf") format("truetype"); |
| 159 | } |
| 160 | @font-face { |
| 161 | font-family: "Liberation Mono"; |
| 162 | font-style: normal; |
| 163 | font-weight: 700; |
| 164 | font-display: swap; |
| 165 | src: url("fonts/LiberationMono-Bold.ddce09ee3483b2d5.ttf") format("truetype"); |
| 166 | } |
| 167 | @font-face { |
| 168 | font-family: "Liberation Mono"; |
| 169 | font-style: italic; |
| 170 | font-weight: 400; |
| 171 | font-display: swap; |
| 172 | src: url("fonts/LiberationMono-Italic.016743fc425a1b61.ttf") format("truetype"); |
| 173 | } |
| 174 | @font-face { |
| 175 | font-family: "Liberation Mono"; |
| 176 | font-style: italic; |
| 177 | font-weight: 700; |
| 178 | font-display: swap; |
| 179 | src: url("fonts/LiberationMono-BoldItalic.2ec6f3e5bd9b24c7.ttf") format("truetype"); |
| 180 | } |
| 181 | |
| 182 | [slides-slide] { |
| 183 | /* The palette. These values are what a section renders as when it |
| 184 | arrived without inline custom properties — a fragment pasted into |
| 185 | a page, a test harness. A real section overrides every one of them |
| 186 | from the deck's theme. */ |
| 187 | --slides-bg: #faf7f0; |
| 188 | --slides-fg: #1c1a17; |
| 189 | --slides-accent: #8a5410; |
| 190 | --slides-accent-2: #46617a; |
| 191 | --slides-font-heading: Liberation Serif, Georgia, serif; |
| 192 | --slides-font-body: Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; |
| 193 | /* Code is one face on every theme, so it is a variable here and |
| 194 | not a theme property. */ |
| 195 | --slides-font-code: Liberation Mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| 196 | |
| 197 | /* The type scale, in cqw so it is a fraction of the slide's own |
| 198 | width. Calibrated on a 1920-wide frame: 6.6cqw is ~127px, about a |
| 199 | 95pt title, and the body steps land near 32, 40 and 49pt — slide |
| 200 | sizes, not document sizes. */ |
| 201 | --slides-t-1: 6.6cqw; |
| 202 | --slides-t-2: 5cqw; |
| 203 | --slides-t-3: 4cqw; |
| 204 | --slides-body-l: 3.4cqw; |
| 205 | --slides-body-m: 2.8cqw; |
| 206 | --slides-body-s: 2.2cqw; |
| 207 | --slides-code: 2.3cqw; |
| 208 | |
| 209 | --slides-gap: 2.6cqw; |
| 210 | --slides-rule: 0.5cqw; |
| 211 | /* Percentage, not cqw: see the header. 6.25% of a 16:9 frame is |
| 212 | 120px at 1920, the margin a slide template usually keeps. */ |
| 213 | --slides-pad: 6.25%; |
| 214 | |
| 215 | box-sizing: border-box; |
| 216 | container-type: inline-size; |
| 217 | container-name: slide; |
| 218 | aspect-ratio: 16 / 9; |
| 219 | inline-size: 100%; |
| 220 | margin: 0; |
| 221 | padding: var(--slides-pad); |
| 222 | background-color: var(--slides-bg); |
| 223 | color: var(--slides-fg); |
| 224 | font-family: var(--slides-font-body); |
| 225 | line-height: 1.3; |
| 226 | text-wrap: pretty; |
| 227 | /* A slide that overflows scrolls rather than clipping. Losing the |
| 228 | end of a sentence to `overflow: hidden` is a WCAG 1.4.10 failure |
| 229 | nobody would ever see reported; a scrollbar is visible, and it is |
| 230 | the estilo allowance §8 already makes for the canvas stage. */ |
| 231 | overflow: auto; |
| 232 | |
| 233 | /* One cell, which the frame fills. The template grid is on the |
| 234 | frame (see the header); all the section does is give it the |
| 235 | content box to be laid out in. */ |
| 236 | display: grid; |
| 237 | grid-template-columns: minmax(0, 1fr); |
| 238 | grid-template-rows: minmax(0, 1fr); |
| 239 | } |
| 240 | |
| 241 | [slides-slide] * { |
| 242 | box-sizing: border-box; |
| 243 | } |
| 244 | |
| 245 | /* The frame: the template grid, one cell of the section, inside its |
| 246 | padding. Every `cqw` written here is a descendant's and resolves |
| 247 | against the section, which is the whole reason it exists. A canvas |
| 248 | slide has no frame — its blocks are placed by hand on the section |
| 249 | itself, and there is no gap to get wrong. */ |
| 250 | [slides-frame] { |
| 251 | display: grid; |
| 252 | gap: var(--slides-gap); |
| 253 | grid-template-columns: minmax(0, 1fr); |
| 254 | align-content: start; |
| 255 | min-inline-size: 0; |
| 256 | min-block-size: 0; |
| 257 | } |
| 258 | |
| 259 | /* A slide's own backdrop (§2.2): a colour arrives as --slides-bg and |
| 260 | needs no rule; an image arrives as a URL to paint. */ |
| 261 | [slides-slide][data-background="image"] { |
| 262 | background-image: var(--slides-bg-image); |
| 263 | background-size: cover; |
| 264 | background-position: center; |
| 265 | } |
| 266 | |
| 267 | /* ── Regions ───────────────────────────────────────────────────────── |
| 268 | A region is a grid area and a column of blocks. A region a template |
| 269 | has no area for falls through to auto placement rather than |
| 270 | disappearing, which is what makes an aside on a title slide still |
| 271 | readable. */ |
| 272 | |
| 273 | [slides-region] { |
| 274 | display: flex; |
| 275 | flex-direction: column; |
| 276 | gap: var(--slides-gap); |
| 277 | min-inline-size: 0; |
| 278 | min-block-size: 0; |
| 279 | } |
| 280 | |
| 281 | [slides-region][data-region="heading"] { |
| 282 | grid-area: heading; |
| 283 | } |
| 284 | [slides-region][data-region="body"] { |
| 285 | grid-area: body; |
| 286 | } |
| 287 | [slides-region][data-region="aside"] { |
| 288 | grid-area: aside; |
| 289 | } |
| 290 | [slides-region][data-region="media"] { |
| 291 | grid-area: media; |
| 292 | } |
| 293 | |
| 294 | /* ── Templates ─────────────────────────────────────────────────────── |
| 295 | Each is a grid, and each names its areas so the region rules above |
| 296 | land in the right cell. */ |
| 297 | |
| 298 | /* title — the opening slide: everything centred, in the middle. */ |
| 299 | [slides-slide][data-template="title"] > [slides-frame] { |
| 300 | grid-template-areas: "heading" "body"; |
| 301 | grid-template-rows: auto auto; |
| 302 | align-content: center; |
| 303 | justify-items: center; |
| 304 | text-align: center; |
| 305 | } |
| 306 | [slides-slide][data-template="title"] [slides-region] { |
| 307 | align-items: center; |
| 308 | max-inline-size: 80%; |
| 309 | } |
| 310 | |
| 311 | /* title-body — the workhorse: a heading, then everything else. */ |
| 312 | [slides-slide][data-template="title-body"] > [slides-frame] { |
| 313 | grid-template-areas: "heading" "body"; |
| 314 | grid-template-rows: auto 1fr; |
| 315 | } |
| 316 | |
| 317 | /* two-column — the heading spans, body and aside sit side by side. */ |
| 318 | [slides-slide][data-template="two-column"] > [slides-frame] { |
| 319 | grid-template-areas: |
| 320 | "heading heading" |
| 321 | "body aside"; |
| 322 | grid-template-columns: 1fr 1fr; |
| 323 | grid-template-rows: auto 1fr; |
| 324 | column-gap: calc(var(--slides-gap) * 1.6); |
| 325 | } |
| 326 | |
| 327 | /* section — a chapter break: one heading, centred, over an accent bar |
| 328 | that is the only decoration in the whole stylesheet. */ |
| 329 | [slides-slide][data-template="section"] > [slides-frame] { |
| 330 | grid-template-areas: "heading"; |
| 331 | align-content: center; |
| 332 | } |
| 333 | [slides-slide][data-template="section"] [data-region="heading"] { |
| 334 | align-items: center; |
| 335 | } |
| 336 | [slides-slide][data-template="section"] [data-region="heading"]::after { |
| 337 | content: ""; |
| 338 | inline-size: 18cqw; |
| 339 | block-size: var(--slides-rule); |
| 340 | background-color: var(--slides-accent); |
| 341 | border-radius: var(--slides-rule); |
| 342 | } |
| 343 | |
| 344 | /* image-full — the media fills the frame and the heading sits over its |
| 345 | bottom-left corner, on a scrim so the words survive a light picture. |
| 346 | The heading is still first in the document: the picture is not what |
| 347 | the slide is about. */ |
| 348 | [slides-slide][data-template="image-full"] { |
| 349 | /* No padding, so one cqw on this template is 19.2px and not 16.8 |
| 350 | — internal/slides/layout carries both scales for it. */ |
| 351 | padding: 0; |
| 352 | } |
| 353 | [slides-slide][data-template="image-full"] > [slides-frame] { |
| 354 | grid-template-areas: "media"; |
| 355 | grid-template-rows: 1fr; |
| 356 | gap: 0; |
| 357 | } |
| 358 | [slides-slide][data-template="image-full"] [data-region="heading"] { |
| 359 | grid-area: media; |
| 360 | z-index: 1; |
| 361 | align-self: end; |
| 362 | justify-self: start; |
| 363 | max-inline-size: 80%; |
| 364 | margin: var(--slides-pad); |
| 365 | padding: calc(var(--slides-gap) / 2) var(--slides-gap); |
| 366 | border-radius: 1cqw; |
| 367 | /* The scrim is the slide's own background, mixed towards |
| 368 | transparent: it belongs to whichever theme is on. */ |
| 369 | background-color: color-mix(in oklab, var(--slides-bg) 82%, transparent); |
| 370 | backdrop-filter: blur(0.6cqw); |
| 371 | } |
| 372 | [slides-slide][data-template="image-full"] [data-region="media"], |
| 373 | [slides-slide][data-template="image-full"] [data-block="image"] { |
| 374 | block-size: 100%; |
| 375 | min-block-size: 0; |
| 376 | } |
| 377 | |
| 378 | /* quote — one pull quote, centred, and nothing competing with it. */ |
| 379 | [slides-slide][data-template="quote"] > [slides-frame] { |
| 380 | grid-template-areas: "body"; |
| 381 | align-content: center; |
| 382 | justify-items: center; |
| 383 | } |
| 384 | [slides-slide][data-template="quote"] [slides-region] { |
| 385 | align-items: center; |
| 386 | max-inline-size: 84%; |
| 387 | text-align: center; |
| 388 | } |
| 389 | |
| 390 | /* blank — a single body region, stacked from the top. It is also what |
| 391 | a canvas slide reflowed onto a phone becomes (§2.3). */ |
| 392 | [slides-slide][data-template="blank"] > [slides-frame] { |
| 393 | grid-template-areas: "body"; |
| 394 | } |
| 395 | |
| 396 | /* ── Blocks ────────────────────────────────────────────────────────── |
| 397 | Semantic elements, sized off the scale, with the browser's own |
| 398 | margins removed: spacing on a slide is the region's gap, so two |
| 399 | blocks are never a gap plus a margin. */ |
| 400 | |
| 401 | [slides-block] { |
| 402 | margin: 0; |
| 403 | min-inline-size: 0; |
| 404 | } |
| 405 | |
| 406 | [slides-block][data-block="heading"] { |
| 407 | font-family: var(--slides-font-heading); |
| 408 | font-weight: 600; |
| 409 | line-height: 1.1; |
| 410 | text-wrap: balance; |
| 411 | letter-spacing: -0.01em; |
| 412 | } |
| 413 | [slides-block][data-level="1"] { |
| 414 | font-size: var(--slides-t-1); |
| 415 | } |
| 416 | [slides-block][data-level="2"] { |
| 417 | font-size: var(--slides-t-2); |
| 418 | } |
| 419 | [slides-block][data-level="3"] { |
| 420 | font-size: var(--slides-t-3); |
| 421 | color: var(--slides-accent); |
| 422 | } |
| 423 | [slides-slide][data-template="section"] [data-block="heading"] { |
| 424 | text-align: center; |
| 425 | } |
| 426 | |
| 427 | [slides-block][data-block="text"], |
| 428 | [slides-block][data-block="list"] { |
| 429 | font-size: var(--slides-body-m); |
| 430 | } |
| 431 | [slides-block][data-size="s"] { |
| 432 | font-size: var(--slides-body-s); |
| 433 | } |
| 434 | [slides-block][data-size="l"] { |
| 435 | font-size: var(--slides-body-l); |
| 436 | } |
| 437 | [slides-block][data-align="center"] { |
| 438 | text-align: center; |
| 439 | } |
| 440 | [slides-block][data-align="end"] { |
| 441 | text-align: end; |
| 442 | } |
| 443 | [slides-block][data-align="justify"] { |
| 444 | text-align: justify; |
| 445 | } |
| 446 | |
| 447 | [slides-block] p { |
| 448 | margin: 0; |
| 449 | } |
| 450 | [slides-block] p + p { |
| 451 | margin-block-start: 0.6em; |
| 452 | } |
| 453 | |
| 454 | [slides-block][data-block="list"] { |
| 455 | display: flex; |
| 456 | flex-direction: column; |
| 457 | gap: 0.4em; |
| 458 | padding-inline-start: 1.4em; |
| 459 | } |
| 460 | [slides-block][data-block="list"][data-indent="1"] { |
| 461 | padding-inline-start: 3em; |
| 462 | } |
| 463 | [slides-block][data-block="list"][data-indent="2"] { |
| 464 | padding-inline-start: 4.6em; |
| 465 | } |
| 466 | [slides-block][data-block="list"][data-indent="3"] { |
| 467 | padding-inline-start: 6.2em; |
| 468 | } |
| 469 | [slides-block][data-block="list"] li::marker { |
| 470 | color: var(--slides-accent); |
| 471 | } |
| 472 | |
| 473 | [slides-block][data-block="quote"] { |
| 474 | font-size: var(--slides-body-l); |
| 475 | display: flex; |
| 476 | flex-direction: column; |
| 477 | gap: 0.6em; |
| 478 | } |
| 479 | [slides-block][data-block="quote"] blockquote { |
| 480 | margin: 0; |
| 481 | font-style: italic; |
| 482 | text-wrap: balance; |
| 483 | } |
| 484 | [slides-block][data-block="quote"] figcaption { |
| 485 | font-size: 0.62em; |
| 486 | font-style: normal; |
| 487 | color: var(--slides-accent); |
| 488 | } |
| 489 | [slides-block][data-block="quote"] figcaption::before { |
| 490 | content: "— "; |
| 491 | } |
| 492 | [slides-block][data-block="quote"] cite { |
| 493 | font-style: normal; |
| 494 | } |
| 495 | |
| 496 | [slides-block][data-block="image"] { |
| 497 | display: flex; |
| 498 | min-block-size: 0; |
| 499 | } |
| 500 | [slides-block][data-block="image"] img { |
| 501 | inline-size: 100%; |
| 502 | block-size: 100%; |
| 503 | max-inline-size: 100%; |
| 504 | border-radius: var(--slides-radius, 0); |
| 505 | object-fit: cover; |
| 506 | } |
| 507 | [slides-block][data-block="image"][data-fit="contain"] img { |
| 508 | object-fit: contain; |
| 509 | } |
| 510 | /* An image with no alt yet is not an error the reader should meet, but |
| 511 | the editor stage wants to see it. The outline shows only where the |
| 512 | host page asks for it. */ |
| 513 | [slides-editing] [slides-block] img[data-missing-alt] { |
| 514 | outline: var(--slides-rule) dashed var(--slides-accent); |
| 515 | outline-offset: calc(var(--slides-rule) * -1); |
| 516 | } |
| 517 | /* A block nothing reaches — named as a child by a columns block that |
| 518 | is itself inside one of its children, a shape the ops refuse but a |
| 519 | merge of two people's edits can still make (codex 7). The renderer |
| 520 | puts it after everything else instead of dropping it; the content is |
| 521 | what the reader sees, and the editor stage sees the outline too. The |
| 522 | wrapper is a region with no grid area, so it lands in a fresh row |
| 523 | under the template's own. */ |
| 524 | [slides-region][data-region="unreached"] { |
| 525 | grid-column: 1 / -1; |
| 526 | } |
| 527 | [slides-editing] [slides-block][data-unreached] { |
| 528 | outline: var(--slides-rule) dashed var(--slides-accent-2); |
| 529 | outline-offset: calc(var(--slides-rule) * -1); |
| 530 | } |
| 531 | |
| 532 | [slides-block][data-block="columns"] { |
| 533 | display: grid; |
| 534 | gap: var(--slides-gap); |
| 535 | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| 536 | } |
| 537 | [slides-block][data-block="columns"][data-count="3"] { |
| 538 | grid-template-columns: repeat(3, minmax(0, 1fr)); |
| 539 | } |
| 540 | |
| 541 | [slides-block][data-block="code"] { |
| 542 | font-size: var(--slides-code); |
| 543 | font-family: var(--slides-font-code); |
| 544 | line-height: 1.45; |
| 545 | padding: 1em; |
| 546 | border-radius: 0.6em; |
| 547 | overflow-x: auto; |
| 548 | tab-size: 2; |
| 549 | /* A panel a shade off the slide, in whichever direction the theme |
| 550 | runs — no second colour needed, and no assumption about light or |
| 551 | dark. */ |
| 552 | background-color: color-mix(in oklab, var(--slides-fg) 8%, var(--slides-bg)); |
| 553 | } |
| 554 | [slides-block][data-block="code"] code { |
| 555 | font: inherit; |
| 556 | } |
| 557 | [slides-block] :not(pre) > code { |
| 558 | font-family: var(--slides-font-code); |
| 559 | font-size: 0.88em; |
| 560 | padding: 0.1em 0.3em; |
| 561 | border-radius: 0.25em; |
| 562 | background-color: color-mix(in oklab, var(--slides-fg) 10%, var(--slides-bg)); |
| 563 | } |
| 564 | |
| 565 | [slides-block] mark { |
| 566 | background-color: color-mix(in oklab, var(--slides-accent) 26%, var(--slides-bg)); |
| 567 | color: inherit; |
| 568 | padding: 0 0.15em; |
| 569 | border-radius: 0.2em; |
| 570 | } |
| 571 | [slides-block] a { |
| 572 | color: var(--slides-accent); |
| 573 | /* Never colour alone (§8): a link is underlined as well. */ |
| 574 | text-decoration: underline; |
| 575 | text-underline-offset: 0.15em; |
| 576 | text-decoration-thickness: 0.07em; |
| 577 | } |
| 578 | |
| 579 | [slides-block][data-block="table"] { |
| 580 | inline-size: 100%; |
| 581 | font-size: var(--slides-body-s); |
| 582 | border-collapse: collapse; |
| 583 | } |
| 584 | [slides-block][data-block="table"] th, |
| 585 | [slides-block][data-block="table"] td { |
| 586 | text-align: start; |
| 587 | vertical-align: top; |
| 588 | padding: 0.45em 0.7em; |
| 589 | border-block-end: 1px solid color-mix(in oklab, var(--slides-fg) 22%, var(--slides-bg)); |
| 590 | } |
| 591 | [slides-block][data-block="table"] th { |
| 592 | font-weight: 600; |
| 593 | border-block-end-color: var(--slides-accent); |
| 594 | } |
| 595 | |
| 596 | [slides-block][data-block="divider"] { |
| 597 | border: 0; |
| 598 | inline-size: 100%; |
| 599 | block-size: var(--slides-rule); |
| 600 | background-color: color-mix(in oklab, var(--slides-fg) 30%, var(--slides-bg)); |
| 601 | border-radius: var(--slides-rule); |
| 602 | } |
| 603 | |
| 604 | [slides-block][data-block="spacer"] { |
| 605 | block-size: var(--slides-gap); |
| 606 | flex: none; |
| 607 | } |
| 608 | [slides-block][data-block="spacer"][data-size="s"] { |
| 609 | block-size: calc(var(--slides-gap) / 2); |
| 610 | } |
| 611 | [slides-block][data-block="spacer"][data-size="l"] { |
| 612 | block-size: calc(var(--slides-gap) * 2); |
| 613 | } |
| 614 | |
| 615 | [slides-block][data-block="shape"] { |
| 616 | inline-size: 100%; |
| 617 | block-size: auto; |
| 618 | min-block-size: 8cqw; |
| 619 | overflow: visible; |
| 620 | } |
| 621 | |
| 622 | /* ── Canvas ────────────────────────────────────────────────────────── |
| 623 | Blocks placed by hand (§2.3). The boxes are percentages of the frame, |
| 624 | so a canvas slide needs no breakpoint at all: it scales as a whole, |
| 625 | text included, because the type scale is in cqw. */ |
| 626 | |
| 627 | [slides-slide][data-layout="canvas"] { |
| 628 | display: block; |
| 629 | position: relative; |
| 630 | padding: 0; |
| 631 | overflow: hidden; |
| 632 | } |
| 633 | [slides-slide][data-layout="canvas"] > [slides-block] { |
| 634 | position: absolute; |
| 635 | display: flex; |
| 636 | flex-direction: column; |
| 637 | /* Top-aligned and never clipped: a box is where text STARTS, not a |
| 638 | viewport onto it. Centring plus overflow: hidden cut the first and |
| 639 | last lines of any block taller than its box (seen on the sample |
| 640 | deck's canvas heading, 2026-09-07) — the same WCAG 1.4.10 failure |
| 641 | the slide-level rule above refuses. Text that outgrows its box |
| 642 | runs past it; the person shrinks the type or grows the box. */ |
| 643 | justify-content: flex-start; |
| 644 | overflow: visible; |
| 645 | } |
| 646 | [slides-slide][data-layout="canvas"] > [data-block="shape"] { |
| 647 | display: block; |
| 648 | block-size: 100%; |
| 649 | min-block-size: 0; |
| 650 | overflow: hidden; |
| 651 | } |
| 652 | [slides-slide][data-layout="canvas"] > [data-block="image"] { |
| 653 | display: block; |
| 654 | overflow: hidden; |
| 655 | } |
| 656 | |
| 657 | /* ── The phone layer ───────────────────────────────────────────────── |
| 658 | Everything below is scoped to a narrow container, and it is last in |
| 659 | the file so a phone rule beats the desktop rule it ties with. */ |
| 660 | |
| 661 | @container slide (max-width: 600px) { |
| 662 | /* A rem floor under the scale: at 380px a slide's 2.8cqw body would |
| 663 | be 10px, and §8 takes 320px reflow with no exemptions. The floors |
| 664 | are set on the descendants rather than on the section, because a |
| 665 | container query styles a container's contents and never the |
| 666 | container itself. */ |
| 667 | [slides-frame], |
| 668 | [slides-region], |
| 669 | [slides-slide] > [slides-block] { |
| 670 | --slides-t-1: max(1.6rem, 9cqw); |
| 671 | --slides-t-2: max(1.35rem, 7cqw); |
| 672 | --slides-t-3: max(1.15rem, 5.6cqw); |
| 673 | --slides-body-l: max(1.1rem, 4.6cqw); |
| 674 | --slides-body-m: max(1rem, 4cqw); |
| 675 | --slides-body-s: max(0.9rem, 3.4cqw); |
| 676 | --slides-code: max(0.85rem, 3.2cqw); |
| 677 | --slides-gap: max(0.8rem, 4cqw); |
| 678 | } |
| 679 | |
| 680 | /* Every template collapses to one column, in document order. Both |
| 681 | halves matter: `grid-area: auto` alone is not enough, because a |
| 682 | template that still carries an explicit multi-column track (only |
| 683 | `two-column` does, at 1fr 1fr) auto-places its items into that |
| 684 | track instead of stacking them — the region rule below undoes the |
| 685 | named placement, this one undoes the columns underneath it. |
| 686 | |
| 687 | The region rule has to be `[slides-region][data-region]`, not |
| 688 | `[slides-region]`: the named placements above are (0,2,0), and a |
| 689 | (0,1,0) rule here lost to them however late it came, so every |
| 690 | region kept an area the collapsed grid no longer has and all of |
| 691 | them landed in one implicit cell — the heading drawn over the |
| 692 | body on every flow slide (polish audit B4). Equal specificity and |
| 693 | later in the file is what wins; the browser drive |
| 694 | TestBrowserPhoneStacksFlowRegions holds it. */ |
| 695 | [slides-slide][data-layout="flow"] > [slides-frame] { |
| 696 | grid-template-columns: 1fr; |
| 697 | grid-template-areas: none; |
| 698 | } |
| 699 | [slides-region][data-region] { |
| 700 | grid-area: auto; |
| 701 | } |
| 702 | |
| 703 | /* image-full stops overlaying: the heading goes under the picture, |
| 704 | where a phone-sized scrim would have been unreadable anyway. */ |
| 705 | [slides-slide][data-template="image-full"] [data-region="heading"] { |
| 706 | grid-area: auto; |
| 707 | max-inline-size: none; |
| 708 | margin: 0; |
| 709 | background: none; |
| 710 | backdrop-filter: none; |
| 711 | } |
| 712 | [slides-slide][data-template="image-full"] [data-block="image"] img { |
| 713 | block-size: auto; |
| 714 | object-fit: contain; |
| 715 | } |
| 716 | |
| 717 | [slides-slide][data-template="title"] [slides-region], |
| 718 | [slides-slide][data-template="quote"] [slides-region] { |
| 719 | max-inline-size: none; |
| 720 | } |
| 721 | |
| 722 | /* A columns block is columns no longer. */ |
| 723 | [slides-block][data-block="columns"] { |
| 724 | grid-template-columns: minmax(0, 1fr); |
| 725 | } |
| 726 | |
| 727 | /* §2.5, the phone layer. `hidden` and `size` are attributes the |
| 728 | selector can read; `order` and `box` are values, because CSS |
| 729 | cannot pull a number out of a data attribute — the renderer sends |
| 730 | those as custom properties on the same element. */ |
| 731 | [slides-block][data-phone-hidden] { |
| 732 | display: none; |
| 733 | } |
| 734 | [slides-block] { |
| 735 | order: var(--slides-phone-order, 0); |
| 736 | } |
| 737 | [slides-block][data-phone-size="s"] { |
| 738 | font-size: var(--slides-body-s); |
| 739 | } |
| 740 | [slides-block][data-phone-size="m"] { |
| 741 | font-size: var(--slides-body-m); |
| 742 | } |
| 743 | [slides-block][data-phone-size="l"] { |
| 744 | font-size: var(--slides-body-l); |
| 745 | } |
| 746 | [slides-slide][data-layout="canvas"] > [slides-block][data-phone-box] { |
| 747 | left: var(--slides-phone-left); |
| 748 | top: var(--slides-phone-top); |
| 749 | width: var(--slides-phone-width); |
| 750 | height: var(--slides-phone-height); |
| 751 | } |
| 752 | } |
| 753 | |
| 754 | /* A flow slide rendered for a phone (§2.3's reflow, and every template |
| 755 | at a phone width) is as tall as what is on it. The 16:9 frame is a |
| 756 | projector's shape, not a page's, and keeping it here would mean |
| 757 | either a scrollbar per slide or words falling off the bottom. |
| 758 | |
| 759 | Two selectors for one rule, because there are two ways to arrive: |
| 760 | the renderer was told it was a phone, or a desktop render was opened |
| 761 | on one — which is every scriptless viewer, since a server rendering |
| 762 | HTML has no honest way to know the screen. */ |
| 763 | [slides-slide][data-vp="phone"]:not([data-layout="canvas"]) { |
| 764 | aspect-ratio: auto; |
| 765 | overflow: visible; |
| 766 | } |
| 767 | |
| 768 | @media (max-width: 600px) { |
| 769 | [slides-slide]:not([data-layout="canvas"]) { |
| 770 | aspect-ratio: auto; |
| 771 | overflow: visible; |
| 772 | } |
| 773 | } |
| 774 | |