@humanforest/slidev-theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +577 -0
  3. package/assetUrl.js +38 -0
  4. package/components/Alert.vue +209 -0
  5. package/components/AreaBox.vue +152 -0
  6. package/components/AreaChart.vue +145 -0
  7. package/components/Avatar.vue +267 -0
  8. package/components/AvatarGroup.vue +94 -0
  9. package/components/BadgeStrip.vue +203 -0
  10. package/components/Bars.vue +207 -0
  11. package/components/BoroughShape.vue +127 -0
  12. package/components/BrowserFrame.vue +484 -0
  13. package/components/CalendarGrid.vue +480 -0
  14. package/components/Card.vue +70 -0
  15. package/components/Carousel.vue +266 -0
  16. package/components/ChartFrame.vue +426 -0
  17. package/components/ChordChart.vue +264 -0
  18. package/components/ComposedChart.vue +187 -0
  19. package/components/Contents.vue +86 -0
  20. package/components/DataTable.vue +244 -0
  21. package/components/DeckMap.vue +409 -0
  22. package/components/DeviceFrame.vue +331 -0
  23. package/components/DonutChart.vue +103 -0
  24. package/components/DottedMap.vue +126 -0
  25. package/components/Endorsement.vue +76 -0
  26. package/components/Grid.vue +169 -0
  27. package/components/GroupedBarChart.vue +141 -0
  28. package/components/IconTile.vue +116 -0
  29. package/components/Kpi.vue +188 -0
  30. package/components/LineChart.vue +133 -0
  31. package/components/Logo.vue +203 -0
  32. package/components/LondonMap.vue +348 -0
  33. package/components/MarkPair.vue +109 -0
  34. package/components/Marquee.vue +263 -0
  35. package/components/NestedDonutChart.vue +295 -0
  36. package/components/Placeholder.vue +88 -0
  37. package/components/SankeyChart.vue +223 -0
  38. package/components/ScatterChart.vue +142 -0
  39. package/components/StackedBarChart.vue +143 -0
  40. package/components/StatCard.vue +134 -0
  41. package/components/StatusTrack.vue +334 -0
  42. package/components/Timeline.vue +249 -0
  43. package/components/TimelineChart.vue +329 -0
  44. package/components/TreemapChart.vue +267 -0
  45. package/components/backgrounds.js +135 -0
  46. package/components/boroughGeometry.ts +59 -0
  47. package/components/chartData.ts +277 -0
  48. package/components/chartTip.ts +201 -0
  49. package/components/codeTheme.ts +239 -0
  50. package/components/deckPalette.ts +157 -0
  51. package/components/dottedMapGeometry.js +60 -0
  52. package/components/iconSlugs.js +55 -0
  53. package/components/iconUrl.js +51 -0
  54. package/components/mermaidTheme.ts +972 -0
  55. package/components/motionDuration.ts +41 -0
  56. package/components/tableRules.ts +257 -0
  57. package/confidential-mark.vue +151 -0
  58. package/custom-nav-controls.vue +79 -0
  59. package/endorsements/b-corp-black.svg +30 -0
  60. package/endorsements/b-corp-white.svg +30 -0
  61. package/global-top.vue +1662 -0
  62. package/icons/forest/bike-asterisk.svg +21 -0
  63. package/icons/forest/bike-down.svg +19 -0
  64. package/icons/forest/bike-off.svg +20 -0
  65. package/icons/forest/bike-up.svg +19 -0
  66. package/icons/forest/bike-x.svg +19 -0
  67. package/icons/forest/bike.svg +18 -0
  68. package/icons/forest/mark-fill.svg +6 -0
  69. package/icons/forest/mark.svg +6 -0
  70. package/icons/forest/parking.svg +3 -0
  71. package/icons/forest/star-fill.svg +4 -0
  72. package/icons/forest/star.svg +4 -0
  73. package/icons/forest/traffic-light-caution.svg +8 -0
  74. package/icons/forest/traffic-light-go.svg +8 -0
  75. package/icons/forest/traffic-light-lit.svg +8 -0
  76. package/icons/forest/traffic-light-stop.svg +8 -0
  77. package/icons/forest/traffic-light.svg +8 -0
  78. package/icons/lucide/battery.svg +1 -0
  79. package/icons/lucide/bike.svg +1 -0
  80. package/icons/lucide/chart-line.svg +1 -0
  81. package/icons/lucide/check.svg +1 -0
  82. package/icons/lucide/circle-check.svg +1 -0
  83. package/icons/lucide/clock.svg +1 -0
  84. package/icons/lucide/code.svg +1 -0
  85. package/icons/lucide/component.svg +1 -0
  86. package/icons/lucide/frame.svg +1 -0
  87. package/icons/lucide/layout-grid.svg +1 -0
  88. package/icons/lucide/map-pin.svg +1 -0
  89. package/icons/lucide/map.svg +1 -0
  90. package/icons/lucide/palette.svg +1 -0
  91. package/icons/lucide/table.svg +1 -0
  92. package/icons/lucide/triangle-alert.svg +1 -0
  93. package/icons/lucide/type.svg +1 -0
  94. package/icons/lucide/wrench.svg +1 -0
  95. package/layouts/bleed.vue +317 -0
  96. package/layouts/cover.vue +1514 -0
  97. package/layouts/default.vue +187 -0
  98. package/layouts/divider.vue +286 -0
  99. package/layouts/end.vue +384 -0
  100. package/layouts/quote.vue +218 -0
  101. package/layouts/split.vue +1875 -0
  102. package/layouts/stack.vue +944 -0
  103. package/layouts/statement.vue +67 -0
  104. package/manifest/chrome.ts +36 -0
  105. package/manifest/components.ts +49 -0
  106. package/manifest/index.ts +57 -0
  107. package/manifest/layouts.ts +37 -0
  108. package/manifest/manifest.json +6514 -0
  109. package/manifest/presets.ts +635 -0
  110. package/notch.js +458 -0
  111. package/notchPath.js +188 -0
  112. package/package.json +77 -0
  113. package/section.js +110 -0
  114. package/setup/mermaid-renderer.ts +2120 -0
  115. package/setup/mermaid.ts +30 -0
  116. package/setup/shiki.ts +44 -0
  117. package/setup/shortcuts.ts +28 -0
  118. package/slide-bottom.vue +305 -0
  119. package/slide-top.vue +268 -0
  120. package/styles/base.css +2611 -0
  121. package/styles/index.ts +71 -0
  122. package/styles/tokens.css +144 -0
  123. package/uno.config.ts +231 -0
  124. package/useNotchCuts.js +200 -0
@@ -0,0 +1,2611 @@
1
+ /* ───────────────────────────────────────────────────────────────────────────
2
+ Forest deck base — element styling for slide content.
3
+
4
+ Every value here resolves from styles/tokens.css. If a rule below wants a colour, a corner,
5
+ a duration or a size and there is no token for it, the token is the thing to add — not a
6
+ literal. That is the whole guardrail.
7
+
8
+ The type roles are the engine's, in the deck CONTEXT (--type-deck-*, generated by
9
+ scripts/type.ts). The mapping onto HTML is fixed here so an author writing plain markdown
10
+ lands on a role without knowing the role exists. Family/weight for every tier below come from
11
+ --type-deck-<role>-family/-weight, never a literal — that includes h1: it defaults to the
12
+ WORKING voice (GT Haptik Bold, sentence case — --type-deck-headline-family/-weight, text-
13
+ transform: none) so an ordinary content slide never has to ask for it. The four punctuation
14
+ layouts — divider, cover, statement, end — override their own :deep(h1) back to the BEAT voice
15
+ (Mohr Black uppercase — --type-deck-display-family/-weight), because that shout is a per-LAYOUT
16
+ choice, not a per-context one: see each layout's own <style> block. h2 is not layout-dependent —
17
+ it always speaks the GT Haptik shout, one tier down.
18
+
19
+ h1 display OR headline Mohr Black uppercase on the four beat layouts (divider/cover/
20
+ statement/end); GT Haptik bold sentence case everywhere else — the
21
+ slide's one shout, voice chosen by the layout
22
+ h2 headline GT Haptik bold, uppercase — a second shout, one tier down, always
23
+ h3 title GT Haptik bold, sentence case — the calm title under a shout
24
+ h4 heading-sm GT Haptik bold — a sub-head inside content
25
+ h5 label GT Haptik bold — the smallest labelled tier
26
+ h6 overline GT Haptik bold, uppercase, tracked — eyebrows and kickers
27
+ ─────────────────────────────────────────────────────────────────────────── */
28
+
29
+ /* ── The slide ────────────────────────────────────────────────────────────────────────────── */
30
+
31
+ .slidev-layout {
32
+ font-family: var(--type-deck-body-family);
33
+ font-size: var(--type-deck-body-size); /* deck context: 2xl on the 1920×1080 canvas */
34
+ line-height: var(--type-deck-body-line-height);
35
+ color: var(--deck-fg);
36
+ background: var(--deck-bg);
37
+ /* ── The text inset — one value, all four edges ──────────────────────────────────────────
38
+ task-14-brief.md, superseding the asymmetric-then-symmetric-80/60 history this comment used
39
+ to record (see below for that history — it is worth keeping, because the defect it describes
40
+ is exactly what requirement 6's debug overlay exists to catch by eye instead of by memory).
41
+
42
+ THE PRINCIPLE: text is inset for its MEASURE, media for its FRAME. A paragraph's own ragged
43
+ right edge already supplies visual air a photograph's hard edge does not, so text and media
44
+ do not want the same margin — --deck-inset-text (90px = grid × 2.25) is text's; media reads
45
+ --deck-inset-media (36px) horizontally instead, in whichever layout places a figure against
46
+ an edge (split.vue, bleed.vue). Chrome (the mark, the page number, the section/confidentiality
47
+ pills) is a THIRD value, --deck-inset-chrome (36px) uniform on all four sides — furniture, not
48
+ content. All three derive from --deck-grid; see scripts/deck.ts's own comment above INSET for
49
+ the full reasoning, including why chrome and media share a number but not a meaning, and why
50
+ 36 is deliberately off the space ladder (task-14b-brief.md revised these from the 40/40/100
51
+ first pass — the two-track PRINCIPLE didn't move, only the numbers).
52
+
53
+ slide-top.vue's/slide-bottom.vue's running chrome sits OUTSIDE this element (a sibling in
54
+ SlideWrapper.vue, not a descendant), so nothing here can see it or size around it dynamically
55
+ — but every layout that leaves this padding alone (all of them except bleed and split, which
56
+ each own their own vertical scheme for their own reasons) needs to leave room for it
57
+ regardless, because chrome is a per-SLIDE opt-out (`chrome: false`), not a per-LAYOUT one: any
58
+ layout can carry it.
59
+
60
+ ⚠ RE-MEASURED, AND THE ARITHMETIC THIS COMMENT USED TO CARRY IS STALE. It read: "the chrome
61
+ pill band … occupying canvas y 36–85 at the top … --deck-inset-text (90px) clears that band by
62
+ 5px at BOTH edges." That was true when the top band was a 49px pill row. It is not true now:
63
+ `0fd9b12` grew the running mark to 72px and nothing re-derived this. Measured live on every
64
+ chromed slide in all three decks:
65
+
66
+ .deck-chrome-mark 72 x 72, canvas y 36 → 108
67
+ .deck-chrome-pills 49 tall, centred on the mark, y 47.5 → 96.5
68
+ .deck-chrome-bottom 49 tall, y 995 → 1044
69
+
70
+ So 90px clears the BOTTOM band by 5px as claimed, and clears NEITHER top box: it under-clears
71
+ the pills by 6.5px and the mark by 18px. In practice no painted ink reaches the mark's own
72
+ square today, and the pill row overlaps only on the handful of slides whose layout starts a
73
+ heading at 90px rather than at `default`'s 130px — but the number below is no longer derived
74
+ from the band it is supposed to clear, and it should be treated as an OPEN geometry finding
75
+ rather than as a proof. It is left at 90 here deliberately: raising it moves every text box in
76
+ every deck, which is a re-typesetting decision, not a defect fix.
77
+
78
+ ★ THE DEFECT THIS REPLACED, kept in prose because the LESSON outlives the numbers. This rule
79
+ used to be padding-TOP only, on the reasoning that "the confidentiality pill sits inside the
80
+ existing bottom margin already, not competing with it". That reasoning counted the pill's
81
+ OFFSET from the edge and forgot its HEIGHT — the pill is the same ~49px BadgeStrip band as the
82
+ top one, so it needed offset + height of room, not just offset, and the bottom margin was
83
+ short by 13px. scripts/deckAudit.ts could not catch it: the audit measures OVERFLOW past the
84
+ canvas, and an element sitting inside the content box, merely too close to another element,
85
+ overflows nothing. Collision is a different property from overflow, and nothing measured it —
86
+ which is why requirement 6's red-line overlay exists: it draws the chrome bands themselves, so
87
+ a collision like this one is visible by eye on the slide that has it, not provable only by
88
+ redoing this arithmetic by hand. */
89
+ padding: var(--deck-inset-text);
90
+ /*
91
+ ★★ NOTHING LEAVES THE CANVAS BECAUSE IT COULD NOT FIND A BREAK.
92
+
93
+ Nothing in this theme set `overflow-wrap`, `word-break` or `hyphens` on anything — grepped, zero
94
+ hits — so an unbreakable run had no legal break point and simply painted out of its block. That
95
+ is not a hypothetical: deck-layouts slide 67's h1 paints from x=120 to x=6856.73, i.e. 4,937px
96
+ off the right of a 1920px canvas, with 63 of its 105 characters never reaching the screen and
97
+ the last visible one sliced through the middle.
98
+
99
+ It also renders as a CLEAN PASS in every geometry gate, and permanently will, because the gates
100
+ read `getBoundingClientRect()` on the ELEMENT: an overflowing inline run leaves the block's
101
+ border box at the column width, so the audit measures 120 → 1464 and reports "0 past the 90px
102
+ safe area" while the room sees the word run off the wall. A rule that cannot escape is worth
103
+ more here than a check that cannot see the escape.
104
+
105
+ `overflow-wrap` is INHERITED, so declaring it once on the layout root covers every heading,
106
+ paragraph, caption, table cell and chip on the slide — one rule rather than a list of selectors
107
+ that a new component can be added without joining.
108
+
109
+ `break-word`, not `anywhere`: both break a too-long word, but `anywhere` also feeds the break
110
+ opportunity into intrinsic (min-content) sizing, which would let every `auto`-sized grid track
111
+ and shrink-to-fit box in the theme collapse to one character. `break-word` changes only what
112
+ happens when a word genuinely does not fit.
113
+
114
+ It does not fight `white-space: nowrap` — a nowrap box (BadgeStrip's pills, .bars-label,
115
+ .bars-value) has no wrapping to do, so those keep their cap-and-ellipsis behaviour untouched.
116
+ */
117
+ overflow-wrap: break-word;
118
+ }
119
+
120
+ /* ── Headings ─────────────────────────────────────────────────────────────────────────────── */
121
+
122
+ /* h1 defaults to the WORKING voice — GT Haptik Bold, sentence case — so default/split/bleed (an
123
+ ordinary content slide) get the calm register with no CSS of their own. Size/line-height stay
124
+ on the display step deliberately (this task changes voice, not size): only family/weight/
125
+ transform move. The four beat layouts override this block's family/weight/transform back to
126
+ Mohr uppercase in their own <style> — see divider.vue/cover.vue/statement.vue/end.vue. */
127
+ .slidev-layout h1 {
128
+ font-family: var(--type-deck-headline-family);
129
+ font-weight: var(--type-deck-headline-weight);
130
+ font-size: var(--type-deck-display-size);
131
+ line-height: var(--type-deck-display-line-height);
132
+ letter-spacing: var(--type-deck-display-tracking);
133
+ text-transform: none;
134
+ color: var(--deck-fg);
135
+ text-wrap: balance;
136
+ /* The token, not a bare space step: the air above a title and the air below it are one decision,
137
+ and apps/deck budgets every paginated page from where content starts — so the number
138
+ has to be readable from deck.json rather than restated in two places. h2 keeps --deck-space-2:
139
+ it sits inside the content rather than starting it. */
140
+ margin: 0 0 var(--deck-heading-bottom-space) 0;
141
+ }
142
+
143
+ /* h2 is not a per-layout choice — always the GT Haptik shout, one tier down. Its own weight/
144
+ tracking, not h1's: the two used to share one rule keyed to --type-deck-display-weight, which
145
+ was silently correct only because both tiers clamped to the same 700 under Task 11's blanket
146
+ override. Now that h1 can diverge (Mohr 900 on the beat layouts), sharing that rule would hand
147
+ h2 a weight its own family never declares. */
148
+ .slidev-layout h2 {
149
+ font-family: var(--type-deck-headline-family);
150
+ font-weight: var(--type-deck-headline-weight);
151
+ font-size: var(--type-deck-headline-size);
152
+ line-height: var(--type-deck-headline-line-height);
153
+ letter-spacing: var(--type-deck-headline-tracking);
154
+ text-transform: uppercase;
155
+ color: var(--deck-fg);
156
+ text-wrap: balance;
157
+ margin: 0 0 var(--deck-space-2) 0;
158
+ }
159
+
160
+ .slidev-layout h3 {
161
+ font-family: var(--type-deck-title-family);
162
+ font-weight: var(--type-deck-title-weight);
163
+ font-size: var(--type-deck-title-size);
164
+ line-height: var(--type-deck-title-line-height);
165
+ letter-spacing: var(--type-deck-title-tracking);
166
+ color: var(--deck-fg);
167
+ text-wrap: balance;
168
+ margin: 0 0 var(--deck-space-2) 0;
169
+ }
170
+
171
+ .slidev-layout h4 {
172
+ font-family: var(--type-deck-heading-sm-family);
173
+ font-weight: var(--type-deck-heading-sm-weight);
174
+ font-size: var(--type-deck-heading-sm-size);
175
+ line-height: var(--type-deck-heading-sm-line-height);
176
+ color: var(--deck-fg);
177
+ margin: 0 0 var(--deck-space-1) 0;
178
+ }
179
+
180
+ .slidev-layout h5 {
181
+ font-family: var(--type-deck-label-family);
182
+ font-weight: var(--type-deck-label-weight);
183
+ font-size: var(--type-deck-label-size);
184
+ line-height: var(--type-deck-label-line-height);
185
+ letter-spacing: var(--type-deck-label-tracking);
186
+ color: var(--deck-fg);
187
+ margin: 0 0 var(--deck-space-1) 0;
188
+ }
189
+
190
+ /* The eyebrow's own vertical block — its line box plus the gap it holds to whatever follows it.
191
+ Published as a custom property, not left implicit in the two declarations below, because
192
+ split.vue has to hang an eyebrow ABOVE its anchor by exactly this much (see `.split-content >
193
+ .deck-eyebrow` there). A layout that needs to know how tall an eyebrow is must not restate 41px;
194
+ it reads the same arithmetic this rule is built from. */
195
+ /*
196
+ ★★ THE OTHER HALF OF `<Grid fill>`, AND IT HAS TO LIVE OUT HERE.
197
+
198
+ A filling grid means "take the space that is left", which only has a meaning inside a container
199
+ that lays its children out along an axis. Grid.vue cannot say that: a child cannot make its own
200
+ parent a flex container, and its <style scoped> could not reach the layout root even if it could.
201
+
202
+ Keyed on `:has()` so it fires for the layouts that actually hold one and for nothing else — every
203
+ other slide in the deck keeps the block flow it has always had, which matters because turning a
204
+ block container into a flex one stops adjacent margins collapsing and would re-space every
205
+ paragraph on it. Direct child only (`>`): a grid nested inside some other box is that box's
206
+ problem to size, and silently re-flexing an ancestor two levels up would be a surprise.
207
+
208
+ The pair was measured both ways. With `height: 100%` on the grid and no rule here, a grid under an
209
+ h1 ran to y 1112 against a 984 safe line. With this rule and `flex: 1 1 0%`, the same slide gives
210
+ the heading 136 → 232 and the grid 264 → 984 exactly, and a grid with no heading still fills
211
+ 136 → 984 as before.
212
+ */
213
+ .slidev-layout:has(> .deck-grid.is-fill) {
214
+ display: flex;
215
+ flex-direction: column;
216
+ }
217
+
218
+ /*
219
+ ★ A BARE FILLING CHART CLAIMS THE BODY THE SAME WAY A FILLING GRID DOES. `.deck-chart.is-fill`
220
+ asks for `height: 100%`, and a percentage resolves against a DEFINITE ancestor — under a heading
221
+ in normal flow there is none, so without the pair below the chart takes 100% of the LAYOUT and
222
+ overruns by exactly the heading's height. Measured: every XY chart slide ran to 1161 against a 984
223
+ safe line, an svg of 770 where 593 fits.
224
+
225
+ Its own rule rather than a comma on the grid's, because a static test pins that selector by exact
226
+ shape and a comma breaks it. Direct child only, for the reason the grid rule gives: a chart nested
227
+ inside some other box is that box's problem to size.
228
+ */
229
+ .slidev-layout:has(> .deck-chart.is-fill),
230
+ .slidev-layout:has(> .deck-map.is-fill) {
231
+ display: flex;
232
+ flex-direction: column;
233
+ }
234
+ .slidev-layout > .deck-chart.is-fill,
235
+ .slidev-layout > .deck-map.is-fill {
236
+ flex: 1 1 0%;
237
+ min-height: 0;
238
+ }
239
+
240
+ /*
241
+ ★ TWO GRIDS STACKED NEED THE AIR TWO CELLS SIDE BY SIDE GET. The dashboard shape is two grids as
242
+ siblings — a content-height row of KPIs, then a filling row for the chart — and they came out
243
+ TOUCHING: every gap inside each grid was 32, and the seam between them was 0, which reads as one
244
+ malformed grid rather than two.
245
+
246
+ Not `row-gap` on the flex column, which was the first thing tried: a flex gap ADDS to margins
247
+ rather than collapsing with them, and the h1 above already carries `margin-bottom: 32`, so the
248
+ heading would have been pushed to 64 while the seam went to 32. The adjacent-sibling selector
249
+ puts the space only where it is missing and leaves every other relationship alone.
250
+
251
+ `--deck-space-4` because that is what `gap: 'base'` resolves to — the seam between two grids is
252
+ the same beat as the gap inside one, which is the whole reason it looked wrong at 0.
253
+ */
254
+ .deck-grid + .deck-grid {
255
+ margin-top: var(--deck-space-4);
256
+ }
257
+
258
+ /*
259
+ ★★ A REGION HOLDING AN `::area-box` LAYS OUT AS A COLUMN, SO THE BOX CAN TAKE WHAT IS LEFT.
260
+
261
+ AreaBox fills its slot with `flex: 1 1 0%` and `align-self: stretch`, which covers a grid cell and
262
+ a flex column. It does not cover a BLOCK region that has something above the box: flex is inert
263
+ there, and the `height: 100%` this replaces resolved against the region's whole box rather than the
264
+ remainder, so the box started below its sibling and still claimed the full height.
265
+
266
+ Measured on the cover's area specimen: `.cover-content` 442-638 with `.cover-rule` plus margin
267
+ taking the first 40, and the box drawn 482-678 — forty pixels out of the bottom of the region it is
268
+ supposed to BE. The schematic ruler drew the region correctly and looked wrong for it.
269
+
270
+ Scoped with `:has(> .area-box)` so it touches only the specimen slides that draw regions as areas.
271
+ A real cover, quote or bleed keeps the block flow it has always had.
272
+ */
273
+ .slidev-layout :is(.cover-content, .split-content, .stack-strip, .bleed-content, .quote-content):has(> .area-box),
274
+ /*
275
+ ★ AND THE LAYOUT ROOT ITSELF, WHICH THE FIRST CUT OF THIS RULE MISSED.
276
+
277
+ `default` has no content region — its area box is a direct child of `.slidev-layout.default`,
278
+ which is `display: block`. So the list above (all of them content REGIONS) never reached it, flex
279
+ stayed inert, and with `height: 100%` gone the box fell to its own content height: measured 136-291,
280
+ 155px inside an 888px frame, where every other area slide fills its region.
281
+
282
+ The beat layouts (divider, statement, end, quote) are already flex columns and match this harmlessly
283
+ — their boxes measured correct before and after. Only `default` needed it.
284
+ */
285
+ .slidev-layout:has(> .area-box) {
286
+ display: flex;
287
+ flex-direction: column;
288
+ }
289
+
290
+ /*
291
+ ★★ A BROWSER WINDOW IS TOLD HOW MUCH ROOM IT HAS, AND THAT CANNOT COME FROM THE COMPONENT.
292
+
293
+ BrowserFrame derives its screen's height from its WIDTH and its ratio, so a window with no `width`
294
+ on the 1728px content band is 1080 of screen plus a bar — the whole canvas and then some. It was
295
+ fixed caller-side, by passing a width computed against the room, which every future caller would
296
+ have had to repeat: deck-template slide 25 did not, and hung 367px off the bottom of the canvas
297
+ until scripts/deckAudit.ts's canvas pass could see it again.
298
+
299
+ The component now caps its own width against the room's HEIGHT — `100cqh` of `.deck-browser-room`,
300
+ minus what the bar and the border take, times the screen's ratio. Which needs the room to HAVE a
301
+ height, and a height is only definite where something outside supplies one. Hence this rule and
302
+ the three hosts below, and hence `container-type: size` living here rather than in the component:
303
+ a size container with no external height is size-contained down to ZERO, and the cap would then
304
+ make the window vanish. So it is switched on exactly where the height is known and left off
305
+ everywhere else, where the cap falls back to `none` and the window is width-driven as before.
306
+
307
+ Same shape and same reasoning as `<Grid fill>` and `.area-box` above — a child that means "take
308
+ what is left" needs its parent laid out along an axis, and a child cannot flex its own parent. And
309
+ same cost, stated there: the room GROWS, so a slide that puts something after the window will find
310
+ it pushed to the bottom of the frame. That is the established behaviour of a filling child in this
311
+ theme rather than a new surprise, and `>` keeps it to windows the layout holds directly — the
312
+ specimen's window inside a grid cell, with its caption underneath, is untouched.
313
+ */
314
+ .slidev-layout:has(> .deck-browser-room) {
315
+ display: flex;
316
+ flex-direction: column;
317
+ }
318
+ /*
319
+ The three regions whose height is definite: a flex column's filling child (the layout root above,
320
+ split's figure cell, stack's panel band) takes it from `flex`, a block region sized by its grid row
321
+ (stack's other bands) from `height`. Both are declared because which one applies depends on the
322
+ host, and the one that does not apply is inert.
323
+ */
324
+ .slidev-layout > .deck-browser-room,
325
+ .split-figure.is-figure > .deck-browser-room,
326
+ .stack-band > .deck-browser-room {
327
+ flex: 1 1 0%;
328
+ min-height: 0;
329
+ height: 100%;
330
+ container-type: size;
331
+ }
332
+ /*
333
+ ★★ THE CAP IS SET ON THE CHILD, NOT ON THE ROOM, AND THAT IS A HARD REQUIREMENT RATHER THAN A
334
+ STYLE CHOICE. Declared on `.deck-browser-room` — as `--deck-browser-cap`, for the fit box to read
335
+ — the whole declaration came back EMPTY from getComputedStyle while every other declaration in the
336
+ same rule applied: a box asking its own size question is a cycle, and a cycle makes the value
337
+ invalid at computed-value time rather than an error anyone sees. Measured that way: the window
338
+ still rendered 1728x1142 in a room the room itself correctly reported as 679 tall.
339
+
340
+ One level down the same query is ordinary, so this is a plain `max-width` on the box it caps.
341
+ */
342
+ .slidev-layout > .deck-browser-room > .deck-browser-fit,
343
+ .split-figure.is-figure > .deck-browser-room > .deck-browser-fit,
344
+ .stack-band > .deck-browser-room > .deck-browser-fit {
345
+ max-width: calc((100cqh - var(--deck-browser-frame-h)) * var(--deck-browser-screen-ratio));
346
+ }
347
+
348
+ /*
349
+ ★★ THE HEADING RISES WHEN THE SECTION PILL IS NOT THERE — AND NARROWS, BECAUSE THE MARK STILL IS.
350
+
351
+ `default` holds its heading at --deck-inset-text + --deck-heading-top-space (96 + 40 = 136). With
352
+ a section pill in the top-left that reads correctly: the pill occupies y36-85 and the heading sits
353
+ 51 below it. With no pill the same 136 reads as the title floating, because the left half of the
354
+ chrome row is empty.
355
+
356
+ ★ WHAT THE 40 IS ACTUALLY FOR, which is the part that makes this more than a padding tweak. It is
357
+ not clearing the PILL. It is clearing the MARK, which is present either way:
358
+
359
+ mark y 36-108, x 1812-1884
360
+ heading band x 96-1824 → the two columns OVERLAP by 12px
361
+ at y136 clears the mark's bottom by 28
362
+
363
+ So dropping the space unconditionally would put a full-width heading under the running mark, on
364
+ every slide with no pill, and nothing would catch it — scripts/deckAudit.ts scores occlusion for
365
+ split/stack media surfaces only. The heading may only rise if it also stops short of the mark's
366
+ column, which is what the second rule does.
367
+
368
+ The reduction is derived, never written down:
369
+
370
+ max width = mark_left − gap − text_inset
371
+ = (canvas − chrome_inset − mark) − chrome_inset − text_inset
372
+ reduction = content_width − max_width
373
+ = mark + 2·chrome_inset − text_inset = 72 + 72 − 96 = 48px
374
+
375
+ 48 of 1728, and the gap left beside the mark is one --deck-inset-chrome, the same clearance the
376
+ mark keeps from the canvas edge.
377
+
378
+ ⚠ KEYED ON AN EMPTY PILL, NOT ON A MISSING ONE. slide-top.vue renders `.deck-chrome-pills`
379
+ unconditionally and puts the BadgeStrip inside it behind a `v-if`, so a slide with no section has
380
+ an element containing a single `<!--v-if-->` comment. `:empty` matches that (comments do not
381
+ defeat it, text nodes would) — verified in the browser rather than assumed, because the whole rule
382
+ is dead if it does not.
383
+
384
+ The second selector is the `chrome: false` case: no chrome row at all, so no mark to clear, so the
385
+ heading rises with NO cap. Written separately rather than folded in, because they want different
386
+ answers to the width question.
387
+ */
388
+ .slidev-page:has(.deck-chrome-pills:empty) .slidev-layout.default:not(.is-center),
389
+ .slidev-page:not(:has(.deck-chrome-top)) .slidev-layout.default:not(.is-center) {
390
+ padding-top: var(--deck-inset-text);
391
+ }
392
+ /*
393
+ ★★ `align: start` ON A SPLIT MUST CLEAR THE CHROME TOO — THE SAME PROBLEM, ONE LAYOUT OVER.
394
+
395
+ split's `align: start` collapses the anchor row so the content column starts at the grid box's own
396
+ top, which is the root's text inset: y 96. That is the right answer on a bare slide and the wrong
397
+ one under running chrome. Measured on the specimen's `align: start` rung, which carries a section
398
+ pill: heading at y 96, pill at y 36-85, an ELEVEN pixel gap, and the two overlap horizontally
399
+ (pill x 36-222, heading x 96-864). It reads as a title jammed under a label.
400
+
401
+ `default` already solves this with --deck-heading-top-space and clears its occupant by 28. This is
402
+ the same rule, and it is keyed on WHICH piece of chrome is actually above the column, because that
403
+ depends on which side the content is on:
404
+
405
+ · content on the RIGHT sits under the MARK, which is drawn whenever chrome is;
406
+ · content on the LEFT sits under the SECTION PILL, which is only there when a section is named —
407
+ so a pill-less slide keeps the tighter start rather than paying for clearance it does not need.
408
+
409
+ Same `:empty` trick as the default rule above, for the same reason: slide-top.vue always renders
410
+ the pills container and puts the badge inside behind a v-if.
411
+ */
412
+ /*
413
+ ★★ AND AN EYEBROW NEEDS THE RESERVE TO GROW, because it hangs ABOVE the thing the reserve protects.
414
+
415
+ The clearance below puts the HEADING at 136, clear of the pill. `.split-content`'s eyebrow rule
416
+ then pulls the eyebrow up by its own block so the heading keeps that anchor — which lands the
417
+ eyebrow at 95, back inside the exact 11px gap this whole rule was written to close. The heading was
418
+ protected and the thing above it was not.
419
+
420
+ So the reserve becomes the heading's PLUS the eyebrow's whenever the column carries one: the
421
+ eyebrow takes the line the heading would have had, and the heading sits its own block below,
422
+ anchored as before. Both keep their relationship; the pair just starts lower.
423
+
424
+ A variable rather than a second four-selector rule: the clearance below is already four selectors
425
+ because which chrome sits above the column depends on the side, and doubling that to eight to say
426
+ one thing about the eyebrow would bury the condition in the permutations.
427
+ */
428
+ .split-grid:has(.deck-eyebrow) {
429
+ --split-chrome-reserve: calc(var(--deck-heading-top-space) + var(--deck-eyebrow-block));
430
+ }
431
+ .slidev-page:has(.deck-chrome-top) .slidev-layout.split.is-right.is-align-start .split-grid,
432
+ .slidev-page:has(.deck-chrome-top) .slidev-layout.split.is-right.is-align-body-center .split-grid,
433
+ .slidev-page:has(.deck-chrome-pills:not(:empty)) .slidev-layout.split.is-left.is-align-start .split-grid,
434
+ .slidev-page:has(.deck-chrome-pills:not(:empty)) .slidev-layout.split.is-left.is-align-body-center .split-grid {
435
+ grid-template-rows: var(--split-chrome-reserve, var(--deck-heading-top-space)) minmax(0, 1fr) auto;
436
+ }
437
+
438
+ .slidev-page:has(.deck-chrome-pills:empty) .slidev-layout.default:not(.is-center) > h1,
439
+ .slidev-page:has(.deck-chrome-pills:empty) .slidev-layout.default:not(.is-center) > hgroup {
440
+ max-width: calc(100% - (var(--deck-chrome-mark) + 2 * var(--deck-inset-chrome) - var(--deck-inset-text)));
441
+ }
442
+
443
+ /*
444
+ A CENTRED `default` CENTRES ITS CONTENT UNDER ITS EYEBROW, NOT WITH IT.
445
+
446
+ default.vue's `align: 'center'` makes the layout a flex column, and the eyebrow is a sibling of the
447
+ slot — so centring moved the running head along with whatever the slide held. Measured on
448
+ apps/deck's type-ramp pages: y 263, 203 and 251 on three consecutive pages of one
449
+ catalogue section. The kicker is a running head; it has to hold still.
450
+
451
+ Auto margins take the free space before `justify-content` sees any, so the slot block centres in
452
+ what is left below the eyebrow while the eyebrow keeps the top of the flow. Nothing is taken out
453
+ of flow, which matters: all four corners belong to the running chrome, and a layout that pins a box
454
+ in one has to ask `chromeShows()` first (deckNotch.static.test.ts). This asks nothing, because it
455
+ never leaves the column.
456
+
457
+ ★ HERE RATHER THAN IN default.vue's OWN <style scoped>, because a scoped rule cannot see slot
458
+ content: Vue stamps the component's own elements with a data attribute and the slide's markdown
459
+ carries none, so `.deck-eyebrow + *` written there matches nothing at all. The eyebrow's own
460
+ margin-top stays in default.vue, where the element does belong to the component.
461
+
462
+ Guarded on the eyebrow existing — a centred slide without one is still centred by
463
+ `justify-content` alone, exactly as it was.
464
+ */
465
+ .slidev-layout.default.is-center > .deck-eyebrow + * {
466
+ margin-top: auto;
467
+ }
468
+ .slidev-layout.default.is-center:has(> .deck-eyebrow) > :last-child {
469
+ margin-bottom: auto;
470
+ }
471
+
472
+ /*
473
+ AND `body-center` CENTRES UNDER THE HEADING, WHICH IS THE SAME RULE ONE ELEMENT FURTHER IN.
474
+
475
+ The case it exists for: a wide mermaid diagram is a few hundred units tall on a 1080 canvas, so
476
+ `align: start` leaves the bottom half of the slide empty under it, and `align: center` fixes the
477
+ emptiness by dragging the heading into the middle — which costs the running rhythm every other
478
+ content slide keeps. Neither is right for a body that is short because it is WIDE.
479
+
480
+ So the heading takes the top exactly as it does on a start-anchored slide, and the free space is
481
+ split above and below whatever follows it. Auto margins again, for the same reason as the eyebrow
482
+ above: they absorb the free space before `justify-content` sees any, and nothing leaves the flow,
483
+ so no corner is claimed and `chromeShows()` never has to be asked.
484
+
485
+ `hgroup` is named beside `h1` because default.vue's own eyebrow prop and a hand-written kicker
486
+ produce different markup for the same slide — the eyebrow is a sibling `<p>`, a written one is an
487
+ `<hgroup>` wrapping the heading. Both are "the heading block", and the body centres under either.
488
+
489
+ ★ A SLIDE WITH NO HEADING FALLS BACK TO `start`, by construction rather than by a guard: the
490
+ sibling selector matches nothing and `:has()` is false, so no auto margin is handed out and the
491
+ column stays top-anchored. That is the right answer — there is no heading to hold the top, so
492
+ there is nothing to centre underneath.
493
+ */
494
+ /*
495
+ ★★ `.slidev-page` IS A SPECIFICITY COUNTERWEIGHT, NOT A SCOPE — it is here to win one exact tie.
496
+
497
+ A chart slide leads with a spec label, and that paragraph is styled by
498
+ `.slidev-layout p:has(> .deck-spec-label:only-child)`, which sets `margin: 0 0 … 0`. Its
499
+ specificity is (0,3,1): `:has()` contributes its own argument's, and `.deck-spec-label:only-child`
500
+ is a class plus a pseudo-class. Written without the ancestor, the rule below is (0,3,1) too — an
501
+ exact tie, decided by source order, and the spec-label rule is further down this file. So the
502
+ paragraph kept `margin-top: 0`, the auto never applied, and the free space went entirely below the
503
+ body: the slide still hugged the top while reporting `is-body-center` correctly.
504
+
505
+ MEASURED on the specimen's Status track slide: two autos live instead of three, 103.5px each below
506
+ the chart and nothing above the label. Setting the same margin inline made all three resolve to 69
507
+ and the block moved — which is how the tie was found rather than the selector blamed.
508
+
509
+ The mermaid slides never showed it: their first body element is a plain wrapper with no rule of
510
+ its own to tie against.
511
+ */
512
+ .slidev-page .slidev-layout.default.is-body-center > :is(h1, hgroup) + * {
513
+ margin-top: auto;
514
+ }
515
+ .slidev-layout.default.is-body-center:has(> :is(h1, hgroup)) > :last-child {
516
+ margin-bottom: auto;
517
+ }
518
+ /*
519
+ ★★ AND IT SITS ABOVE THE MIDDLE, BECAUSE THE MIDDLE LOOKS LOW.
520
+
521
+ Split evenly, the body is centred in a box the viewer cannot see — and the eye judges centring
522
+ against the FRAME. Measured on the specimen's Flowchart slide before this rule: the diagram's
523
+ centre landed 84px below the slide's own centre, because the heading occupies the top and the
524
+ split never knew. The two gaps a viewer actually perceives were 210 above and 274 below, off by
525
+ exactly the 64 that separates the heading's own 32px margin (which falls on the top side of the
526
+ split) from the container's 96px bottom inset (which falls on the bottom, and paints nothing).
527
+
528
+ ⚠ THE WEIGHTING CANNOT BE A NUMBER, because the free space is not one — it is whatever the body
529
+ leaves, and it changes with every diagram. Flexbox divides free space EQUALLY between auto
530
+ margins, so the ratio comes from HOW MANY there are rather than from a length: two autos give 1:1,
531
+ and a third below gives 1:2. The body then takes a third of the space above it and two thirds
532
+ below, i.e. it rides one sixth of the free space above centre.
533
+
534
+ That is why the correction is a pseudo-element and not a `calc()`. It also makes the rule
535
+ self-limiting for free: as a body grows the free space shrinks, the lift shrinks with it, and a
536
+ body tall enough to fill the slide converges on the plain centre. Nothing to clamp.
537
+
538
+ ★ GATED ON THE HEADING like the two rules above it, so a slide with no heading stays top-anchored
539
+ rather than gaining a lone auto margin with nothing to balance against.
540
+ */
541
+ .slidev-layout.default.is-body-center:has(> :is(h1, hgroup))::after {
542
+ content: '';
543
+ margin-top: auto;
544
+ }
545
+
546
+ /*
547
+ ★★ AND A THIRD TIME, ON stack's `frame: content` BAND — THE SAME 96 UNDER THE SAME CHROME.
548
+
549
+ Every other stack band is a SURFACE: `panel`, `photo` and `figure` all notch, so the band rises to
550
+ the 36 chrome inset and the measured cut in useNotchCuts.js takes the chrome out of its corners.
551
+ `frame: content` is the one that paints no ground, so it never notches, and nothing reserved
552
+ anything — it simply kept the plain text inset and started at 96.
553
+
554
+ Measured on the specimen's only such slide (45), and the numbers are the split rule's numbers
555
+ again almost exactly:
556
+
557
+ section pill y 36-85, x 36-222
558
+ band y 96, x 96-1824 → an ELEVEN pixel gap, overlapping horizontally
559
+ first text run y 94 → the painted line sits ABOVE its own box top
560
+
561
+ ★ AND THE 40 IS CLEARING THE MARK, NOT THE PILL — same as `default`, and it matters here more,
562
+ because a band is FULL WIDTH:
563
+
564
+ mark y 36-108, x 1812-1884
565
+ band x 96-1824 → the two columns overlap by 12px
566
+ at 136 → clears the mark's bottom by 28
567
+
568
+ ⚠ KEYED ON THE CHROME ROW, NOT ON A NON-EMPTY PILL, and that is the one place this rule departs
569
+ from `default`'s. `default` lets its heading rise to 96 on a pill-less slide and NARROWS it by 48
570
+ so it stops short of the mark's column. A band cannot take that trade: its full width IS the
571
+ layout's statement — it is the thing distinguishing a band from a paragraph — so narrowing it to
572
+ dodge the mark would change what the frame means. The mark is present whenever the chrome row is,
573
+ so the clearance is owed whenever the chrome row is, pill or no pill. A `chrome: false` slide has
574
+ no mark to clear and keeps the tighter 96 from stack.vue's own rule.
575
+ */
576
+ .slidev-page:has(.deck-chrome-top) .slidev-layout.stack .stack-band.is-content {
577
+ margin-top: calc(var(--deck-inset-text) + var(--deck-heading-top-space));
578
+ }
579
+
580
+ /*
581
+ ★★ A BLEEDING SURFACE HAS NO NOTCH, SO ITS CONTENT MUST CLEAR THE MARK ON ITS OWN.
582
+
583
+ Everywhere else the ring of ground around a figure is where the running chrome sits, and the notch
584
+ cuts it a seat. `bleed` gives up that ring on purpose, so the chrome is drawn ON the surface — which
585
+ is the documented trade for the SURFACE, and not a licence for the CONTENT to collide with it.
586
+
587
+ Measured on the specimen's bleeding panel band before this rule: the content's padding box began at
588
+ y 32, ABOVE the mark's own top at 36 and 76px above its bottom. The unbled band puts the same
589
+ content at 132, and the arithmetic behind that number is not the card padding — it is the notch:
590
+
591
+ chrome inset 36 where the mark's box starts
592
+ mark 72 its height
593
+ notch gap 24 the ring the cut leaves around it
594
+ ---
595
+ 132 which is exactly where a notched panel starts its content
596
+
597
+ So the bleeding surface reproduces it from the same three tokens rather than restating 132, and the
598
+ two move together if any of them is ever retuned.
599
+
600
+ ⚠ KEYED ON THE CHROME ROW, like the two rules above it. With `chrome: false` there is no mark to
601
+ clear and the surface keeps the tighter top the layouts set — a bleeding slide with no chrome would
602
+ otherwise carry 132px of empty ground for a collision that cannot happen.
603
+
604
+ `photo` is excluded on purpose: an edge-to-edge photograph is the point of bleeding one, and its
605
+ content IS the image. That is the one case where the chrome really does sit on the picture, and the
606
+ answer there is `chrome: false` or the `bleed` layout's scrim — not padding.
607
+
608
+ ★★ AND `spill` IS EXCLUDED, WHICH IS A DEFECT THIS RULE WAS CAUSING RATHER THAN A NEW OPTION.
609
+ stack.vue's own spill rule already says `padding-top: 0`, with a note recording exactly what this
610
+ reservation does to a spilling band: "measured, it started the window 132px below the band's own
611
+ top edge and the slide then cut a window that had never reached the top." That rule was losing.
612
+ `:has()` contributes its ARGUMENT's specificity, so this selector is (0,8,0) against the scoped
613
+ rule's (0,5,0) — the same trap this repo has hit before, and no amount of care inside stack.vue
614
+ fixes it because the two rules are not in the same file.
615
+
616
+ Excluded here rather than out-specified there, because the exclusion is the true statement: a
617
+ spilling band's content is meant to leave the frame, so it is not a surface the chrome can collide
618
+ with in the way this reservation assumes. Measured on the live-page spill slide — the band's own
619
+ top is y=300 and the chrome mark's bottom is y=108, so there was nothing to clear in the first
620
+ place.
621
+ */
622
+ .slidev-page:has(.deck-chrome-top) .slidev-layout.split .split-figure.is-bleed:not(.is-content):not(.is-photo):not(.is-spill),
623
+ .slidev-page:has(.deck-chrome-top) .slidev-layout.stack .stack-band.is-bleed:not(.is-content):not(.is-photo):not(.is-spill) {
624
+ padding-top: calc(var(--deck-inset-chrome) + var(--deck-chrome-mark) + var(--deck-notch-gap));
625
+ }
626
+
627
+ .slidev-layout {
628
+ --deck-eyebrow-block: calc(
629
+ var(--type-deck-overline-size) * var(--type-deck-overline-line-height) + var(--deck-space-1)
630
+ );
631
+ }
632
+
633
+ /* h6 IS the overline role — the eyebrow tier. */
634
+ .slidev-layout h6,
635
+ .slidev-layout .deck-eyebrow {
636
+ font-family: var(--type-deck-overline-family);
637
+ font-weight: var(--type-deck-overline-weight);
638
+ font-size: var(--type-deck-overline-size);
639
+ line-height: var(--type-deck-overline-line-height);
640
+ letter-spacing: var(--type-deck-overline-tracking);
641
+ text-transform: uppercase;
642
+ color: var(--deck-accent);
643
+ margin: 0 0 var(--deck-space-1) 0;
644
+ }
645
+
646
+ /* ── The editorial hgroup — a heading with its kicker ──────────────────────────────────────
647
+ Same pattern the docs site runs (apps/docs/src/assets/docs.css): an <hgroup> is a heading
648
+ plus the line that introduces it, and a bare <p> inside one IS that kicker — so it takes the
649
+ overline role automatically. An author writes two lines of HTML and gets the system's
650
+ eyebrow rhythm; nothing has to be classed. */
651
+ .slidev-layout hgroup {
652
+ display: flex;
653
+ flex-direction: column;
654
+ gap: var(--deck-space-1);
655
+ margin-bottom: var(--deck-space-3);
656
+ }
657
+ .slidev-layout hgroup > * {
658
+ margin-bottom: 0;
659
+ }
660
+ .slidev-layout hgroup > p {
661
+ font-family: var(--type-deck-overline-family);
662
+ font-weight: var(--type-deck-overline-weight);
663
+ font-size: var(--type-deck-overline-size);
664
+ line-height: var(--type-deck-overline-line-height);
665
+ letter-spacing: var(--type-deck-overline-tracking);
666
+ text-transform: uppercase;
667
+ color: var(--deck-accent);
668
+ }
669
+ /* A kicker over the big display line wants more air than one over a section title. */
670
+ .slidev-layout hgroup:has(h1) {
671
+ gap: var(--deck-space-2);
672
+ }
673
+
674
+ /* ── Measure ──────────────────────────────────────────────────────────────────────────────
675
+ ★★ RUNNING PROSE IS NOT CAPPED. A paragraph, a list item and a caption run the width of
676
+ whatever holds them — the slide's own text inset on a full-width layout, the column inside a
677
+ split or a stack band.
678
+
679
+ It was capped: body at 20em (≈39 characters), lists at 22em, captions at 23em. The case
680
+ against those numbers is not that they were wrong for reading — 39 characters is comfortably
681
+ inside the conventional 45–75 — it is that a SLIDE is not a page. The 45–75 finding is about
682
+ sustained reading, many lines and many return sweeps; a slide's paragraph is one to three
683
+ lines and is scanned. MEASURED on a full-width `default` carrying a heading, a paragraph, a
684
+ nested list, an ordered list and a blockquote:
685
+
686
+ capped at 39 chars paragraph 3 lines, blockquote 2 content ran to y 1079 — 95px PAST
687
+ the 984 inset
688
+ uncapped (70 chars) paragraph 2 lines, blockquote 1 the same content fits
689
+
690
+ That is the real cost, and it is the opposite of the one the cap was defending: capping trades
691
+ the dimension a slide has (width) for the dimension it runs out of (height). The right-hand
692
+ third it left empty was not composition either — nothing was placed there, it was leftover.
693
+
694
+ ⚠ THE BEAT LAYOUTS KEEP THEIR CAPS, and they are a different instrument: cover's 12.5em title,
695
+ quote's 17em, statement's 14em are display type being SHAPED — a deliberate two- or three-line
696
+ block — not running text being made readable. They are unaffected by any of this.
697
+
698
+ ★ AND THOSE CAPS ARE STILL IN `em`, NEVER `ch`, which is a brand-font fact rather than a
699
+ preference. MEASURED on the shipped faces:
700
+
701
+ GT Haptik 1ch = 0.778em average lowercase advance = 0.511em
702
+ MohrAlt 900 1ch = 0.789em average uppercase advance = 0.694em
703
+
704
+ The `ch` unit is the advance of "0", and in both faces that glyph is far wider than the average
705
+ letter — so a `max-width` in `ch` permits about 1.5× the characters it appears to promise.
706
+ `38ch` of GT Haptik is a 58-character line. Any cap added here takes <target characters> ×
707
+ <measured average advance>, using the uppercase figure for Mohr.
708
+ ───────────────────────────────────────────────────────────────────────────────────────── */
709
+
710
+ /* ── Body copy ────────────────────────────────────────────────────────────────────────────── */
711
+
712
+ .slidev-layout p {
713
+ margin: 0 0 var(--deck-space-2) 0;
714
+ }
715
+
716
+ /* The lead paragraph: the sans bridge directly under a Mohr shout. */
717
+ .slidev-layout .deck-lead {
718
+ font-size: var(--type-deck-subtitle-size);
719
+ line-height: var(--type-deck-subtitle-line-height);
720
+ color: var(--deck-fg-muted);
721
+ }
722
+
723
+ /* Caption / footnote tier — the quiet line under a figure or at the foot of a slide. */
724
+ .slidev-layout figure {
725
+ margin: 0;
726
+ }
727
+ .slidev-layout figcaption,
728
+ .slidev-layout .deck-caption {
729
+ font-size: var(--type-deck-caption-size);
730
+ line-height: var(--type-deck-caption-line-height);
731
+ color: var(--deck-fg-muted);
732
+ margin-top: var(--deck-space-2);
733
+ }
734
+ .slidev-layout .deck-note {
735
+ font-size: var(--type-deck-body-sm-size);
736
+ color: var(--deck-fg-subtle);
737
+ }
738
+
739
+ /*
740
+ ── A LABELLED SPECIMEN: a label, then the thing it names ────────────────────────────────────
741
+ `.deck-spec` marks a wrapper whose FIRST paragraph is the label for whatever follows it;
742
+ `.deck-spec-under` puts the label last instead, for swatches and bands where the colour leads.
743
+
744
+ The register is the OVERLINE role in muted ink — the same quiet furniture tier the running
745
+ chrome's own pills use. A label must never compete with what it labels, and on a colour
746
+ specimen it must not introduce a colour of its own. That is why this is not `.deck-caption`
747
+ (caption size, sentence case) and not `.deck-eyebrow` (overline, but the ACCENT): the gap
748
+ between those two is exactly this class, and it had no name until a deck needed one.
749
+
750
+ ★ TWO WAYS IN, and which one a deck uses depends on whether there is anything to GROUP.
751
+
752
+ `.deck-spec-label` is the label carrying its own class, written `[text]{.deck-spec-label}`. That
753
+ needs comark, which Slidev 52 ships and a deck opts into with `comark: true` in its headmatter —
754
+ apps/deck does. On 0.50 it was not available at all: markdown could not attach a class,
755
+ and the alternative was a 250-character inline `style` on every label, 279 of them in one
756
+ generated deck, which is what made that file unreadable.
757
+
758
+ `.deck-spec` / `.deck-spec-under` keep the class on a WRAPPER, and they are not a legacy fallback:
759
+ they group a label AND the specimen it names, and the rule below is what positions one against the
760
+ other. A wrapper is the right shape when there are two things in it. It was the wrong shape when
761
+ there was one, which is the case the inline class now covers.
762
+
763
+ The coupling is a contract either way, which is why the rule names `.deck-spec` and never a bare
764
+ `div > p`.
765
+
766
+ ★★ `.deck-spec-wide` IS THE OPT-IN, AND IT EXISTS BECAUSE A PANEL CENTRES ITS CHILDREN.
767
+
768
+ `panelAlign: title-start|title-center` lays the panel out as a column with `align-items: center`,
769
+ so every direct child SHRINK-WRAPS and is centred on the panel's midline. On the panelAlign ladder
770
+ that is exactly right — the rungs must shrink-wrap so the ladder moves one variable — but on a
771
+ register PAIR it is wrong twice over: the specimen in the panel is a different width AND a
772
+ different alignment from its partner in the content column, so a reader comparing two registers is
773
+ also comparing two layouts. Measured on the unfilled-badges slide: the column half ran 768px wide
774
+ from x 96, the panel half 275px centred on 1440.
775
+
776
+ A half that needs its full width asks for it, per split.vue's own note, rather than the panel
777
+ handing it out to everyone.
778
+ */
779
+ .deck-spec-wide {
780
+ align-self: stretch;
781
+ }
782
+ /*
783
+ ★ THE PARAGRAPH AROUND AN INLINE LABEL HAS TO TURN WITH IT. `[text]{.deck-spec-label}` on its own
784
+ line is a SPAN inside a <p>, and a line box is at least as tall as its parent's strut — so the
785
+ paragraph's body line-height won, the label's block came out taller than the overline it is set
786
+ in, and every chart budget derived from CAPTION_BLOCK (49px = a 33px overline line box plus
787
+ --deck-space-2) was short. MEASURED: charts to bottom 1098 on a 1080 canvas, 18px over, across 20
788
+ slides. The wrapper forms never had this — there the label IS the paragraph.
789
+ */
790
+ .slidev-layout p:has(> .deck-spec-label:only-child) {
791
+ /* ★ FONT-SIZE AS WELL AS LINE-HEIGHT, because the overline's line-height is UNITLESS (1.1) and a
792
+ unitless value multiplies the element's OWN font-size. Setting only line-height on the wrapping
793
+ paragraph computed 1.1 against the paragraph's 48px body size — 52.8px, a 69px block against
794
+ the 49px CAPTION_BLOCK every chart budget is derived from. With both, the paragraph's strut is
795
+ the label's own line box: 30 x 1.1 = 33, plus --deck-space-2, exactly 49. */
796
+ font-size: var(--type-deck-overline-size);
797
+ line-height: var(--type-deck-overline-line-height);
798
+ margin: 0 0 var(--deck-space-2) 0;
799
+ max-width: none;
800
+ }
801
+ .slidev-layout .deck-spec > p:first-child,
802
+ .slidev-layout .deck-spec-under > p:last-child,
803
+ .slidev-layout .deck-spec-label {
804
+ font-family: var(--type-deck-overline-family);
805
+ font-size: var(--type-deck-overline-size);
806
+ font-weight: var(--type-deck-overline-weight);
807
+ line-height: var(--type-deck-overline-line-height);
808
+ letter-spacing: var(--type-deck-overline-tracking);
809
+ text-transform: uppercase;
810
+ color: var(--deck-fg-muted);
811
+ max-width: none;
812
+ }
813
+ .slidev-layout .deck-spec > p:first-child { margin: 0 0 var(--deck-space-2) 0; }
814
+ /*
815
+ ★★ AN IDENTIFIER INSIDE A LABEL KEEPS ITS OWN CASE, and on a custom property that is correctness
816
+ rather than taste. The label register is an uppercase overline, which is right for the prose half
817
+ of a caption ("light register", "three cards, two clicks") and WRONG for a token: CSS custom
818
+ property names are case-sensitive, so `--DECK-SHADE-1` is a different property from the one the
819
+ swatch beside it is actually painting. A specimen book that misspells the thing it specimens is
820
+ worse than one with no label.
821
+
822
+ So a label says which half is which by writing the identifier as inline code, and this turns the
823
+ overline treatment off for that span only — case, tracking and family all come back. Sized in `em`
824
+ so it tracks whatever the label is set at rather than needing its own number, and left on the
825
+ label's own muted ink rather than the code chip's ground: a chip fill inside a 33px line box would
826
+ make the label a row of boxes.
827
+
828
+ ★★ AND IT MUST TURN OFF ALL OF THE CHIP, NOT JUST THE FILL. This zeroed `background` and `padding`
829
+ and stopped there, which was complete until the chip grew an EDGE and a RADIUS. After that a token
830
+ name in a label drew as a hollow lozenge: no fill, a 2px currentColor rule hugging the text at
831
+ `padding: 0`, and 16px of radius on a box the zeroed padding had shrunk to 40 canvas px — 65% of
832
+ its own height, where the chip rule budgets that same 16px as two fifths of a 65px chip.
833
+
834
+ The lesson is the shape rather than the value: a context that neutralises the chip has to
835
+ neutralise every property the chip rule sets, and the two are 250 lines apart. A gate in
836
+ packages/slidev-theme/test/ now checks that they agree.
837
+ */
838
+ .slidev-layout .deck-spec > p:first-child code,
839
+ .slidev-layout .deck-spec-under > p:last-child code,
840
+ .slidev-layout .deck-spec-label code {
841
+ font-family: var(--font-mono);
842
+ font-size: 0.92em;
843
+ font-weight: 400;
844
+ text-transform: none;
845
+ letter-spacing: normal;
846
+ background: none;
847
+ border: none;
848
+ border-radius: 0;
849
+ padding: 0;
850
+ color: inherit;
851
+ }
852
+
853
+ /*
854
+ ★★ A CELL HANDS A FILLING CHART ITS LEFTOVER, NOT ITS WHOLE HEIGHT. `.deck-spec` is a label
855
+ paragraph followed by the specimen, and a filling frame asks for `height: 100%` — of the box the
856
+ label is also in. Measured on Legend positions with the grid filling and the rows stretching: the
857
+ grid's own box ended at the 984 safe line while the second row's FRAMES ran to 1066, 82px past it,
858
+ because each cell was paying for its label twice. The ink pass saw none of it; the text inside
859
+ those frames stopped at 747, so only the boxes crossed.
860
+
861
+ A flex column fixes it at the cell: the label takes its line, the chart takes the rest. `flex-basis`
862
+ of 0 is what makes the chart's own `height: 100%` stop mattering — a flex item sizes from its
863
+ basis, so the two no longer fight.
864
+
865
+ Scoped with `:has()` so a cell only becomes a flex column when it actually holds a filling chart.
866
+ Every other cell in the deck — swatches, bands, code, tables — is untouched.
867
+ */
868
+ .slidev-layout .deck-spec:has(> .deck-chart.is-fill) {
869
+ display: flex;
870
+ flex-direction: column;
871
+ height: 100%;
872
+ }
873
+ .slidev-layout .deck-spec > .deck-chart.is-fill {
874
+ flex: 1 1 0%;
875
+ min-height: 0;
876
+ }
877
+ .slidev-layout .deck-spec-under > p:last-child { margin: var(--deck-space-2) 0 0 0; }
878
+
879
+ /* Emphasis is the brand green. */
880
+ .slidev-layout strong,
881
+ .slidev-layout b {
882
+ color: var(--deck-accent);
883
+ font-weight: 700;
884
+ }
885
+
886
+ /*
887
+ ── Emphasis is TWO channels, and naming the second one is the point of this rule ──────────────
888
+ `strong` spends the ACCENT. `em` spends the OBLIQUE. On a deck where the accent is the loudest
889
+ thing on the page that distinction is worth having: an author needs a way to emphasise a word
890
+ without reaching for the one colour the whole slide is built around.
891
+
892
+ Colour and weight are INHERITED deliberately. An `em` that also changed colour would just be a
893
+ second `strong`, and the deck would have two ways to say one thing.
894
+
895
+ This rule is close to a no-op today — the browser default already resolves to the right face —
896
+ and that is exactly why it is written down. Italic worked by accident: nothing in this theme said
897
+ it was sanctioned, no other deck had ever used it, and the first one that did found the face
898
+ arriving late (see styles/index.ts). A treatment the system relies on should be a decision.
899
+
900
+ `font-synthesis-style: none` makes the face a guarantee rather than a coincidence. The brand
901
+ licenses and ships real obliques (GTHaptik-RegularOblique / -BoldOblique, declared
902
+ `font-style: italic`), so nothing should ever be slanting the roman by matrix. If the oblique
903
+ ever stops shipping, this renders upright — visibly wrong — instead of silently faking a face
904
+ that is not the brand's.
905
+ */
906
+ .slidev-layout em,
907
+ .slidev-layout i {
908
+ font-style: italic;
909
+ font-synthesis-style: none;
910
+ }
911
+
912
+ /*
913
+ `.hl` WAS HERE — a highlighter mark, accent ink on accent-soft, with a negative margin so the tint
914
+ bled past the text and `box-decoration-break: clone` so a wrapped phrase got rounded ends on each
915
+ fragment. It worked; nothing ever used it. Zero occurrences in either deck, in the generators, in
916
+ any component or test, for the deck's whole life, and it carried no note explaining the decision —
917
+ the signature of a treatment added speculatively rather than because a slide asked for it.
918
+
919
+ Two things worth keeping if it ever comes back. `strong` already spends the accent (see the note
920
+ above `em`), so a highlight is a THIRD emphasis tier and needs a reason to exist beside the two
921
+ that do. And it must not be accent-on-accent-soft: that is the inline `code` chip's pairing, and
922
+ two meanings on one visual is a genuine ambiguity at projector distance.
923
+ */
924
+
925
+ /* ── Lists — the accent bullet ────────────────────────────────────────────────────────────── */
926
+
927
+ /*
928
+ ★ THE GAP BETWEEN ITEMS IS 0.3em, NOT 0.6, BECAUSE THE LINE BOX ALREADY CARRIES HALF OF IT.
929
+ Body sets `line-height: 1.6`, so a 48px item has a 77px line box — 29px of leading, half above the
930
+ glyph and half below. A 0.6em margin adds another 29px on top of that, and two consecutive items
931
+ ended up 58px apart against a 48px cap height: the list read as separate paragraphs rather than as
932
+ one list. Measured on apps/deck's prose slide, which is where it was reported. 0.3em puts
933
+ the total separation at about 43px — still clearly a break between items, no longer a stanza.
934
+ */
935
+ .slidev-layout ul {
936
+ list-style: none;
937
+ padding-left: 0;
938
+ }
939
+ .slidev-layout ul > li {
940
+ position: relative;
941
+ padding-left: 1.6em;
942
+ margin-bottom: 0.3em;
943
+ }
944
+ .slidev-layout ul > li::before {
945
+ content: '';
946
+ position: absolute;
947
+ /*
948
+ ★★ CENTRED ON THE LINE BOX, NOT ON A CONSTANT. This was `top: 0.62em`, which put the dot's
949
+ centre 0.87em below the item's top — measured on the specimen's own list, 41.8px against a
950
+ line-box centre of 34.8px, so it sat 7px low. The comment two rules up reasons from a body
951
+ line-height of 1.6 and the deck computes 1.45, which is how a hard-coded offset drifts: it is
952
+ independent of the thing it is supposed to centre against. `0.5lh - 0.25em` is half the line box
953
+ less half the dot, so it lands on the centre at any size and any tier and cannot drift again.
954
+
955
+ (For the record, 0.87em was almost exactly the X-HEIGHT centre, 40.8px — a real typographic
956
+ target, and the right one for lowercase-heavy text. Deck copy is sentence case and full of caps
957
+ and ascenders, so the eye reads the cap band instead and the dot looked low against it. Cap
958
+ centre is 36px, within 1.2px of the line box, so centring on the line box satisfies both.)
959
+
960
+ ★ AND IT SITS 0.55em IN RATHER THAN ON THE MARGIN. At `left: 0` the dot's right edge was 1.1em
961
+ from the text — more than twice the dot's own width — so the pair read as two objects rather
962
+ than one marker. Moving the dot rather than the text closes it to 0.55em and leaves the text
963
+ column where it is, which matters because a list usually sits under a paragraph and the two
964
+ share a left edge. What it gives up is the hanging bullet, where the dot aligns with the
965
+ paragraph margin and the text is indented past it.
966
+ */
967
+ left: 0.55em;
968
+ top: calc(0.5lh - 0.25em);
969
+ width: 0.5em;
970
+ height: 0.5em;
971
+ border-radius: var(--deck-radius-full);
972
+ background: var(--deck-accent);
973
+ }
974
+ /* `list-style-type: decimal` is not the browser default here — Slidev's own base styles (via
975
+ UnoCSS's Tailwind-style preflight) reset it to `none` on every list, the same normalisation
976
+ `ul` above deliberately keeps (it draws its own disc via `::before` instead of a native
977
+ marker). `ol` never got the equivalent restoration: found live, adding the first `<ol>` this
978
+ theme's own demo deck had ever rendered — `.slidev-layout ol > li::marker` two rules down has
979
+ existed since this file did, and had never had anything to colour, because `list-style-type:
980
+ none` means `::marker` generates no box at all regardless of what colours it. */
981
+ .slidev-layout ol {
982
+ list-style-type: decimal;
983
+ padding-left: 1.4em;
984
+ }
985
+ .slidev-layout ol > li {
986
+ margin-bottom: 0.3em;
987
+ }
988
+ .slidev-layout ol > li::marker {
989
+ color: var(--deck-accent);
990
+ font-weight: 700;
991
+ }
992
+ /*
993
+ ── THE CONTENTS PAGE ──────────────────────────────────────────────────────────────────────────
994
+
995
+ Every section and subsection the deck declares, in five columns. It is an INDEX — scanned for one
996
+ word, not read — so the whole treatment is about making a section findable and its subsections
997
+ legible as belonging to it.
998
+
999
+ ★★ THE TWO TIERS ARE RAMP RUNGS, AND DIFFER BY SIZE, WEIGHT, INDENT AND INK. This deck has two
1000
+ weights, so a hierarchy that needs a third of anything does not exist here: a section is
1001
+ `heading-sm` (48px, 700) and a subsection is `body-sm` (36px, 400) — two rungs the ramp already
1002
+ has, one bold and one not. Nothing is invented and nothing is a fraction of something else.
1003
+
1004
+ ★★ GREEN IS THE SUBJECT, WARM IS THE CONNECTIVE TISSUE. The sections carry `--deck-accent`, which
1005
+ is what the deck already means by "this is a thing you can go to". The warm family is spent on the
1006
+ parts that are not text — the rule a section's children hang from, and the ground a row takes when
1007
+ it is under the pointer — so the page reads as one green list on paper rather than two colours
1008
+ competing down a column. `--deck-ink-warm` appears only on hover, where it is the only warm INK on
1009
+ the page and therefore unmistakably the thing being pointed at.
1010
+
1011
+ ★ THE PROSE ROW MARGIN COMES OFF, AND IT IS WHAT MAKES THE PAGE FIT. `ol > li` above carries
1012
+ `margin-bottom: 0.3em` because consecutive prose items are separate thoughts; thirty-four of those
1013
+ is 250px. Measured against the 96px bottom inset the audit enforces: with the margin the page
1014
+ misses that line, without it it clears.
1015
+ */
1016
+ .slidev-layout .deck-contents {
1017
+ column-count: 5;
1018
+ column-gap: var(--deck-space-4);
1019
+ }
1020
+ .slidev-layout .deck-contents ol {
1021
+ list-style: none;
1022
+ padding-left: 0;
1023
+ }
1024
+ /*
1025
+ ⚠ NO BLANKET `margin-bottom: 0` HERE ANY MORE. One existed to cancel the prose row margin, and at
1026
+ (0,2,1) it also beat the per-tier gaps below at (0,2,0) — so the two rules that give this page its
1027
+ spacing silently did nothing and a wrapped row still read as two rows. Both tiers state their own
1028
+ gap instead, and each is (0,2,0) against the prose rule's (0,1,2), which they win on class count.
1029
+ */
1030
+ /* ★★ THE ROW IS THE UNBREAKABLE UNIT, NOT THE GROUP — AND THE PAGE ONLY FITS BECAUSE OF IT. Holding
1031
+ a whole section together was the first answer (measured at three columns, a nested list broke away
1032
+ from its own section and opened the next column alone, reading as a top-level entry), but a group
1033
+ the balancer may not split sets a floor no column count can get under: held whole, the longest
1034
+ section is 670px of a 720px column, so four columns could pack at most 827 against that budget and
1035
+ the page overflowed by construction as the deck grew. A wrapped ROW still may not split. What
1036
+ keeps a continuation reading as subordinate is the warm rule and the indent below, which travel
1037
+ with the row rather than with the column. */
1038
+ .slidev-layout .deck-contents-section {
1039
+ margin-bottom: var(--deck-space-5);
1040
+ }
1041
+ /*
1042
+ ★★ THREE GAPS, AND THEY HAVE TO BE IN THIS ORDER OR A WRAPPED ROW READS AS TWO ROWS. A subsection
1043
+ title long enough to wrap is the case that decides this page's spacing: with the ramp's own body-sm
1044
+ leading (1.5) and no gap between items, the space INSIDE a wrapped item was exactly the space
1045
+ BETWEEN two items, so "Device and browser / frames" read as two entries. Reported on sight.
1046
+
1047
+ So the three are separated deliberately, tightest first:
1048
+
1049
+ inside a wrapped row 1.2 line-height ~7px of leading
1050
+ between rows --deck-space-3 24px
1051
+ between sections --deck-space-5 40px
1052
+
1053
+ ★★ AND THE TWO OUTER GAPS ARE SET BY THE SPACE LEFT OVER, not by the ladder's next rung. Five
1054
+ columns of rows that may wrap took the page from 54px past the canvas to 121px short of the text
1055
+ inset, and a list that stops two-thirds down a slide reads as a page that ran out rather than one
1056
+ that was composed. MEASURED on the longer of the two contents slides, list bottom against the 984
1057
+ inset: 16/24 ends at 863, 24/40 at 927, 32/40 at 959, 32/48 at 975. 24/40 spends the slack and
1058
+ keeps 57px in hand for a face that sets wider; 32/48 is inside the noise.
1059
+
1060
+ ★ THE 1.2 IS NOT INVENTED — it is `--type-deck-label-line-height`, the ramp's own tight line box at
1061
+ this exact size (2.25rem). The ramp pairs that size with two leadings, 1.5 for running prose and
1062
+ 1.2 for a label, and an index is the second thing rather than the first.
1063
+
1064
+ ★ AND THE ROW IS WHERE `break-inside: avoid` LIVES, for the reason above: a row that wrapped across
1065
+ a column boundary would be the one thing on this page that genuinely cannot be read.
1066
+ */
1067
+ .slidev-layout .deck-contents-subsection {
1068
+ break-inside: avoid;
1069
+ margin-bottom: var(--deck-space-3);
1070
+ }
1071
+ .slidev-layout .deck-contents-subsection:last-child {
1072
+ margin-bottom: 0;
1073
+ }
1074
+
1075
+ .slidev-layout .deck-contents-link {
1076
+ /* The underline the theme gives every `a` is wrong on an index: thirty-four of them is a page of
1077
+ rules rather than a list of names. */
1078
+ text-decoration: none;
1079
+ transition: color var(--motion-fast) var(--motion-ease-out);
1080
+ }
1081
+ /*
1082
+ ★★ THE SIZE AND THE LINE BOX ARE SET ON THE ROW, NOT ON THE LINK. The link is inline, and an
1083
+ inline element does not own its line box — the row's own strut does, so a `line-height` on the
1084
+ `<a>` was simply outvoted by the `li`'s inherited 1.45 and every row stood a third taller than it
1085
+ was measured to. The page overflowed its bottom inset by 41px on exactly that.
1086
+ */
1087
+ .slidev-layout .deck-contents-section > .deck-contents-link.is-section,
1088
+ .slidev-layout li.deck-contents-section {
1089
+ font-size: var(--type-deck-heading-sm-size);
1090
+ line-height: var(--type-deck-heading-sm-line-height);
1091
+ }
1092
+ .slidev-layout .deck-contents-link.is-section {
1093
+ font-weight: 700;
1094
+ color: var(--deck-accent);
1095
+ }
1096
+ .slidev-layout li.deck-contents-subsection,
1097
+ .slidev-layout .deck-contents-subsection > .deck-contents-link {
1098
+ font-size: var(--type-deck-body-sm-size);
1099
+ line-height: var(--type-deck-label-line-height);
1100
+ }
1101
+ .slidev-layout .deck-contents-subsection > .deck-contents-link {
1102
+ font-weight: 400;
1103
+ color: var(--deck-fg-muted);
1104
+ }
1105
+
1106
+ /*
1107
+ ★ THE SUBSECTIONS HANG OFF A WARM RULE, which is the only thing on the page that says "these
1108
+ belong to the name above them". A rule rather than an indent alone: at four columns the longest
1109
+ title wraps, and a wrapped line under a bare indent reads as a new entry — against a rule it
1110
+ reads as a continuation. The inset is the rule's own offset plus its gap, so a retune moves both.
1111
+ */
1112
+ .slidev-layout .deck-contents ol.deck-contents-subsections {
1113
+ position: relative;
1114
+ margin-top: var(--deck-space-1);
1115
+ /*
1116
+ ★ THE WHOLE GROUP IS INSET, BAR INCLUDED. The bar is positioned against this box, so indenting
1117
+ the box moves the rule and its rows together — the alternative, nudging only the `::before`,
1118
+ would leave the rule floating inside the text block rather than hanging beside it. Flush at the
1119
+ column edge it read as a border on the column rather than a mark belonging to the section.
1120
+ */
1121
+ margin-inline-start: var(--deck-space-2);
1122
+ /*
1123
+ ★ THE SELECTOR CARRIES `ol` ON PURPOSE. `.deck-contents ol { padding-left: 0 }` above is
1124
+ (0,2,1) and strips the browser's list indent; a class-only rule here is (0,2,0) and loses to
1125
+ it, so the indent silently stayed at zero and the subsections hung under their section with
1126
+ nothing but weight telling them apart. Matched at (0,3,1) instead.
1127
+ */
1128
+ padding-left: var(--deck-space-2);
1129
+ }
1130
+ /*
1131
+ ★★ THE BAR IS A PSEUDO-ELEMENT, BECAUSE A `border-left` HAS NO ENDS. It was one, and a border is
1132
+ drawn to the edges of its box with square corners — there is no property that rounds only the two
1133
+ ends of one side. Absolutely positioned instead, so it can carry a radius and a width of its own
1134
+ without either touching the text's indent.
1135
+
1136
+ ★★ `--deck-bg-warm-mark`, NOT `--deck-bg-warm`. The fill is a GROUND — measured 1.30:1 against the
1137
+ page, right for a panel behind type and invisible as a rule. The family's `mark` role is the one
1138
+ meant to be seen on its own ground: 4.99:1 here. A rule that cannot be seen is worse than none,
1139
+ because the indent then reads as an accident.
1140
+ */
1141
+ .slidev-layout .deck-contents-subsections::before {
1142
+ content: '';
1143
+ position: absolute;
1144
+ inset-block: 0;
1145
+ inset-inline-start: 0;
1146
+ width: calc(var(--deck-space-1) / 2);
1147
+ border-radius: var(--deck-radius-full);
1148
+ background: var(--deck-bg-warm-mark);
1149
+ }
1150
+
1151
+ /*
1152
+ ★ HOVER AND FOCUS TOGETHER, ALWAYS. The pointer is not the only way into this page — a presenter
1153
+ tabs it, and an index whose affordance is pointer-only is an index half the room cannot use.
1154
+ */
1155
+ .slidev-layout .deck-contents-link:hover,
1156
+ .slidev-layout .deck-contents-link:focus-visible {
1157
+ color: var(--deck-ink-warm);
1158
+ }
1159
+
1160
+ /*
1161
+ Nested lists step down a tier rather than repeating the shout.
1162
+
1163
+ ★★ A TIER IS A SIZE, AND FOR A LONG TIME ONLY THE MARKER STEPPED. The rule below quietened the
1164
+ disc — subtle instead of accent, 0.4em instead of 0.5 — and the TEXT beside it stayed at the
1165
+ parent's size, so a nested item was a full-size line wearing a smaller bullet. Measured on
1166
+ apps/deck's prose slide: both tiers at 48px.
1167
+
1168
+ The step is the ramp's own next rung, `body-sm`, taken from the token rather than expressed as a
1169
+ fraction: the nested tier is not "a bit smaller", it is the role the type system already has for
1170
+ subordinate running text, and a `0.75em` would be that same number restated where a retuned ramp
1171
+ could not reach it. Its line-height comes with it, or the tighter type would sit in the taller
1172
+ tier's leading.
1173
+
1174
+ Everything the marker is sized in is `em`, so it follows the text down on its own: 0.4em of the
1175
+ nested 36px is a 14px disc where it was 19px at 48. The one number that does NOT follow is
1176
+ `top`, which was tuned against the parent tier's 1.6 line box and is re-tuned here for body-sm's
1177
+ 1.5.
1178
+ */
1179
+ .slidev-layout li ul,
1180
+ .slidev-layout li ol {
1181
+ font-size: var(--type-deck-body-sm-size);
1182
+ line-height: var(--type-deck-body-sm-line-height);
1183
+ margin-top: 0.3em;
1184
+ }
1185
+ /* The nested tier carries the same two corrections, against its own smaller dot: half the line box
1186
+ less half of 0.4em, and the same 0.55em inset. It runs at the dense type tier, whose line-height
1187
+ differs again — which is the whole reason neither number is written as a constant. */
1188
+ .slidev-layout li ul > li::before {
1189
+ background: var(--deck-fg-subtle);
1190
+ width: 0.4em;
1191
+ height: 0.4em;
1192
+ left: 0.55em;
1193
+ top: calc(0.5lh - 0.2em);
1194
+ border-radius: var(--deck-radius-full);
1195
+ }
1196
+
1197
+ /* ── Rules, links, code, quotes ───────────────────────────────────────────────────────────── */
1198
+
1199
+ .slidev-layout hr {
1200
+ border: none;
1201
+ height: 4px;
1202
+ width: var(--deck-space-7);
1203
+ background: var(--deck-accent);
1204
+ margin: var(--deck-space-3) 0;
1205
+ }
1206
+
1207
+ .slidev-layout a {
1208
+ color: var(--deck-accent);
1209
+ text-decoration: underline;
1210
+ text-underline-offset: 0.15em;
1211
+ text-decoration-thickness: 0.06em;
1212
+ transition: color var(--motion-fast) var(--motion-ease-out);
1213
+ }
1214
+ .slidev-layout a:hover {
1215
+ color: var(--deck-accent-strong);
1216
+ }
1217
+
1218
+ /* ⚠ NO `background` HERE, and the one that was is why this note exists. This set
1219
+ `var(--deck-accent-soft)` at (0,1,1), and the rule below re-points inline code to --deck-surface
1220
+ at (0,3,1) — so the declaration could never apply. It was left behind when the code block moved to
1221
+ Catppuccin and the chip went back to the deck surface; measured live, the chip's computed fill is
1222
+ the surface mix, identical to the block's. A dead declaration is worse than none: it reads as the
1223
+ answer to "what colour is an inline code chip" and is not. */
1224
+ .slidev-layout code {
1225
+ font-family: var(--font-mono);
1226
+ font-size: 0.85em;
1227
+ color: var(--deck-accent);
1228
+ padding: 0.05em 0.3em;
1229
+ /* ⚠ NO `border-radius` HERE, AND THE ONE THAT WAS NEVER APPLIED. This selector is (0,1,1) and
1230
+ @slidev/client's `.slidev-layout :not(pre) > code` is (0,2,1), so its `var(--slidev-code-radius)`
1231
+ won every time: the chip has been drawing at Slidev's 24px, not at the --deck-radius-item this
1232
+ rule asked for, since the theme shipped. Measured live before moving it. The radius now sits on
1233
+ the (0,3,1) rule below, beside the fill and the edge that had to go there for the same reason —
1234
+ the same trap the note above this rule records for `background`. */
1235
+ }
1236
+
1237
+ /*
1238
+ ★★ INLINE CODE KEEPS THE DECK SURFACE; ONLY THE BLOCK TAKES CATPPUCCIN'S GROUND.
1239
+
1240
+ Slidev paints both from --slidev-code-background (its own code.css), and that token is Catppuccin's
1241
+ base now — right for a block, wrong for a `code` chip sitting inside a sentence of Forest prose,
1242
+ which is deck furniture and should read as one. So the chip is re-pointed back at the surface it
1243
+ always had. The block below is the only thing that changes register-source.
1244
+ */
1245
+ /*
1246
+ ⚠ `:not(.shiki)` IS A SPECIFICITY COUNTERWEIGHT, NOT A FILTER, and it is here because the note in
1247
+ styles/tokens.css already measured this exact collision. @slidev/client's own rule is
1248
+ `.slidev-layout :not(pre) > code` at (0,2,1); repeating that selector verbatim TIES, and a tie is
1249
+ decided by whichever stylesheet the bundler happens to emit last — which is not a thing to leave a
1250
+ register-correct colour resting on. One more class-shaped pseudo makes it (0,3,1) and the tie is
1251
+ gone. An inline `<code>` is never `.shiki` (Shiki writes that class on the `<pre>`), so the
1252
+ selector matches exactly what it did before.
1253
+ */
1254
+ /*
1255
+ ★★ THE CHIP IS A WASH OF ITS OWN INK, NOT A FIXED SURFACE.
1256
+
1257
+ It was --deck-surface, which is the SLIDE's neutral surface mix and tracks the page rather than
1258
+ whatever the chip is actually sitting on. Inside a warm panel that put a grey chip with green type
1259
+ on a peach ground — three families in one word, and the thing that reads as wrong is the grey.
1260
+
1261
+ `currentColor` makes the fill and the edge derive from the chip's own colour, so the chip lands on
1262
+ every ground the deck has without a rule per ground: on the page it is a wash of the accent, on a
1263
+ named background it is a wash of that family's ink (below), and on the dark register it inverts
1264
+ with the ink it is mixed from. A ground this theme has not invented yet is handled too.
1265
+
1266
+ ⚠ MIXED WITH `transparent`, NOT WITH THE GROUND. The chip must not know what is behind it — it sits
1267
+ on panels, on cards and on the page, and a mix against a named ground would be wrong on two of the
1268
+ three. An alpha wash is right on all of them.
1269
+ */
1270
+ .slidev-note :not(pre) > code:not(.shiki),
1271
+ .slidev-layout :not(pre) > code:not(.shiki) {
1272
+ background: color-mix(in oklab, currentColor 8%, transparent);
1273
+ /*
1274
+ THE STRONG EDGE ALPHA, AT THE CHIP'S OWN WIDTH. This was 18%, BELOW the 20% the card's
1275
+ `hairline` edge takes — so the chip carried the faintest edge in the deck while being the
1276
+ smallest thing that has one, and read as a hairline at room scale. 40% is the card's `strong`
1277
+ alpha; the card spends 4px on it because it is a frame, and a chip inside a sentence spends 2.
1278
+ Measured over the chip's own 8% fill: 1.30:1 at 18%, 1.84:1 here.
1279
+ */
1280
+ border: 2px solid color-mix(in oklab, currentColor 40%, transparent);
1281
+ /*
1282
+ A SOFT CORNER, NOT A PILL. The chip measures 65 canvas px tall, so Slidev's --slidev-code-radius
1283
+ (24px, 39 canvas px) is 60% of its height and draws a lozenge. Twice the base rung is 26 canvas
1284
+ px, two fifths of the height — a rounded box, still a box. There is no NAMED step here:
1285
+ --deck-radius-nested is the same 16px today but is derived from the card radius and follows it,
1286
+ which is not a dependency a chip in a sentence should have.
1287
+ */
1288
+ border-radius: calc(var(--deck-radius) * 2);
1289
+ }
1290
+ .slidev-layout pre {
1291
+ font-family: var(--font-mono);
1292
+ background: var(--slidev-code-background);
1293
+ /* ★ THE EDGE FOLLOWS THE GROUND. This was --deck-border — deck-bg mixed 14% toward the ink — which
1294
+ tracked the SLIDE, not the block. With the ground moved to Catppuccin's base the two came apart:
1295
+ measured on the dark register, a #24443e border (hue 181, teal-green) around a #1e1e2e block
1296
+ (hue 282, violet), a hundred degrees of hue between a box and its own edge. */
1297
+ border: 1px solid var(--deck-code-border);
1298
+ /* ★★ ONE RADIUS FOR EVERY CODE BLOCK-SHAPED THING, and it is Slidev's own `--slidev-code-radius`
1299
+ rather than the nested step. `.slidev-code` already carries that value with an `!important` of
1300
+ Slidev's making, so a bare `<pre>`, a code GROUP and a magic-move well rounding at the nested
1301
+ 16px meant four objects that sit in the same place on a slide drew four different corners —
1302
+ visible the moment two of them appear on consecutive slides. The nested step stays where it
1303
+ means something: a `pre` INSIDE a card, which is genuinely one box in another. */
1304
+ border-radius: var(--slidev-code-radius);
1305
+ padding: var(--deck-space-3) var(--deck-space-4);
1306
+ }
1307
+ /* A code chip inside a block is already on the block's ground — drop the double fill. */
1308
+ /*
1309
+ ★★ THE FONT SIZE IS PART OF THE RESET. The rule above is for an inline code CHIP and shrinks it to
1310
+ 0.85em to sit in a line of prose; `code` inside a `pre` matches it too, so without this a block
1311
+ renders at 30.6px against the 36px its own token asks for.
1312
+
1313
+ It also caused the seams between lit lines: 1.5 x 30.6 is 45.9px, a fractional line box, so each
1314
+ row rounded to a device pixel on its own and a hairline of ground showed between backgrounds that
1315
+ the layout says are flush (measured gap: exactly 0). At 36px the box is 54px and they tile.
1316
+ */
1317
+ .slidev-layout pre code {
1318
+ background: none;
1319
+ padding: 0;
1320
+ color: inherit;
1321
+ font-size: inherit;
1322
+ }
1323
+
1324
+ /* ── The code register switch — a MEASURED fix, not a preference ──────────────────────────────
1325
+ Shiki in dual-theme mode writes both palettes onto every token span as `--shiki-light` and
1326
+ `--shiki-dark`, and @slidev/client/styles/code.css picks between them with:
1327
+
1328
+ html.dark .shiki span { color: var(--shiki-dark) }
1329
+ html:not(.dark) .shiki span { color: var(--shiki-light) }
1330
+
1331
+ ★★ `html.dark` IS NOT THIS DECK'S DARK. The deck runs LIGHT and marks individual slides
1332
+ `class: deck-dark` — dark is punctuation here, not the ground (deck.css says so at length) — so
1333
+ `html` stays `.light` all the way through a dark slide and Slidev's second rule keeps handing
1334
+ out the LIGHT palette over a dark code block. Measured on a live render, Go on a deck-dark
1335
+ slide, against the block's own #0c332d ground:
1336
+
1337
+ func #1e754f 2.43:1 <- the commonest token in the sample
1338
+ 0 #2f798a 2.77:1
1339
+ * #ab5959 2.82:1
1340
+ Fleet #2e8f82 3.51:1
1341
+ ( #999999 4.83:1 <- punctuation, the only scope that passed
1342
+
1343
+ A screenshot did not show it. At thumbnail size the eye reconstructs the glyphs; the DOM is what
1344
+ said the contrast was not there, which is why this was probed rather than looked at.
1345
+
1346
+ SPECIFICITY IS THE WHOLE MECHANISM, so it is spelled out. Slidev's rule scores (0,2,2) —
1347
+ `html` + `:not(.dark)` + `.shiki` + `span`. A plain `.deck-dark .shiki span` is (0,2,1) and
1348
+ LOSES, silently, leaving this section looking correct and changing nothing. Qualifying with the
1349
+ layout class makes it (0,3,1): three classes beats two whatever the element count, and it does
1350
+ not depend on which stylesheet the bundler happens to emit last.
1351
+
1352
+ ★★ AND THE CLASS CAN LAND IN THREE PLACES, WHICH IS WHY THERE ARE THREE SELECTORS RATHER THAN
1353
+ TWO. This listed the layout root (`class: deck-dark` in frontmatter) and an ancestor of it, and
1354
+ missed the one placement that is now a PROP: `panelDark` puts the class on a panel INSIDE the
1355
+ layout (split.vue, stack.vue), and styles/tokens.css has always said the class can land on "a
1356
+ wrapper inside a slide (a dark card band, a chart well)". Every role follows it there, because
1357
+ custom properties inherit; this rule did not, because it names its ancestor.
1358
+
1359
+ The failure was silent in the way this whole section is about: the panel's ground turned over,
1360
+ the code block's ground turned over with it (--slidev-code-background is --deck-surface, a role),
1361
+ and the TOKENS stayed on the light palette — the same 2.43:1 the table above measures, on a
1362
+ surface that now looks deliberate. All three placements score (0,3,1); the descendant one is not
1363
+ weaker for being a descendant. */
1364
+ .slidev-layout.deck-dark .shiki,
1365
+ .deck-dark .slidev-layout .shiki,
1366
+ .slidev-layout .deck-dark .shiki {
1367
+ color: var(--shiki-dark, inherit);
1368
+ }
1369
+ .slidev-layout.deck-dark .shiki span,
1370
+ .deck-dark .slidev-layout .shiki span,
1371
+ .slidev-layout .deck-dark .shiki span {
1372
+ color: var(--shiki-dark);
1373
+ }
1374
+
1375
+ /* ★★ RETIRED — a code block inside a Card no longer needs lifting off it.
1376
+ This raised the block to --deck-surface-strong because both it and the Card painted --deck-surface,
1377
+ so a block inside a card vanished into its container. The block's ground is Catppuccin's own base
1378
+ now (see tokens.css), which is a different colour from the Card on both registers — 1.04:1 apart on
1379
+ light and 1.19:1 on dark is not much, but the two are no longer the SAME value, which is what the
1380
+ rule existed for. Keeping it would have pushed the block a rung away from a ground it no longer
1381
+ shares, for no reason a reader could see. */
1382
+
1383
+ /* ── Code groups ──────────────────────────────────────────────────────────────────────────────
1384
+ The tab strip and the block under it are ONE surface, and Slidev draws them as two: the strip
1385
+ carries the code background and the block re-declares it, so at the seam a 1px inset shadow is
1386
+ all that joins them. On a projector that reads as a floating label. Border and corner come from
1387
+ the deck contract so the group matches every other container on the slide. */
1388
+ .slidev-layout .slidev-code-group {
1389
+ border: 1px solid var(--deck-border);
1390
+ /* The same radius a lone block takes — a group IS a block, with tabs. See the note on `pre`. */
1391
+ border-radius: var(--slidev-code-radius);
1392
+ overflow: hidden;
1393
+ }
1394
+ .slidev-layout .slidev-code-group .slidev-code {
1395
+ border: none;
1396
+ border-radius: 0 !important;
1397
+ }
1398
+ /* ★ A TAB IS A FILENAME, SO IT KEEPS ITS OWN CASE. Every other piece of furniture in this deck —
1399
+ the section pill, the eyebrow, the specimen label — is set in the overline role, which uppercases.
1400
+ A filename is not furniture, it is an IDENTIFIER: `Panel.vue` and `panel.vue` are two different
1401
+ files on a case-sensitive disk, and `PANEL.VUE` is neither. So the tab strip takes the overline's
1402
+ size and tracking (it is still small, quiet, room-scale furniture) and NOT its transform. */
1403
+ /*
1404
+ ── THE NUMBERED GUTTER ────────────────────────────────────────────────────────────────────────
1405
+
1406
+ Slidev draws it as a `::before` on every `.line` and styles it
1407
+ `--uno: w-4 mr-6 text-gray-400 dark-text-gray-600`. Three problems on this deck, all from the same
1408
+ cause — those utilities were written against Uno's default scale, and uno.config.ts REPLACES
1409
+ `theme.spacing` wholesale with the deck's own named ladder:
1410
+
1411
+ · `w-4` and `mr-6` are numeric steps that do not exist here, so they generate nothing and the
1412
+ gutter's width and gap fall back to whatever the preset happens to give.
1413
+ · `text-gray-400` is a grey belonging to no register in this deck, and it does not move between
1414
+ light and dark the way every other quiet ink does.
1415
+
1416
+ A line number is the one thing on a code slide nobody reads in sequence — it is an index you jump
1417
+ to when someone says "line four" — so it takes the muted role at the annotation rung, and its
1418
+ width is stated in `ch` because it is counting DIGITS, not spanning a box.
1419
+ */
1420
+ .slidev-layout .slidev-code-line-numbers .slidev-code code .line::before {
1421
+ width: 2ch;
1422
+ margin-right: var(--deck-space-4);
1423
+ color: var(--deck-fg-subtle);
1424
+ }
1425
+
1426
+ /* ★★ AND THE SAME STRIP SERVES A SINGLE BLOCK'S TITLE. Slidev draws ```go [fleet.go] with
1427
+ `.slidev-code-block-title` — the same header bar as a code group's tab row, with one label
1428
+ instead of a row of them — and this rule named only the group. A deck could put a filename on a
1429
+ lone block and get mermaid's… no: get SLIDEV's stock strip, sans-serif and square against a
1430
+ themed one three lines further down the same slide. Named together because they ARE the same
1431
+ piece of furniture. */
1432
+ /*
1433
+ ★★ THE TAB ROW IS SQUARED AND THE LONE TITLE IS LEFT ALONE, which is the opposite of where this
1434
+ started. Stock already rounds BOTH strips — `client/styles/code.css:54`, `border-radius: R R 0 0`
1435
+ — and this theme used to flatten both with a `border-radius: 0` that read as "keep Slidev's
1436
+ corners out". That was the bug behind the square-topped block on `A numbered block, named`: the
1437
+ `pre` rounds `0 0 R R` precisely because a strip sits above it, so suppressing the strip's corners
1438
+ left the object round along the bottom and cut square along the top. Deleting the override is the
1439
+ whole fix there — stock is right, and no rule for the title belongs here.
1440
+
1441
+ A GROUP is the other way round. `.slidev-code-group` is itself a rounded, clipping box, so stock's
1442
+ radius on its tab row is a second curve inside the first; with the two at different values it drew
1443
+ a visible seam — the group clipping on one arc, the row's grey ending on another, the group's 1px
1444
+ border tracing a third. Reported on `Code group`. The wrapper clips; the row stays square.
1445
+ */
1446
+ .slidev-layout .slidev-code-group-tabs {
1447
+ border-radius: 0;
1448
+ }
1449
+ /*
1450
+ ★★ NO HAIRLINE UNDER EITHER STRIP. Stock draws one as `box-shadow: inset 0 -1px` — a 1px rule
1451
+ between the strip and the code. It is redundant here: the strip already sits on
1452
+ `--deck-surface-strong` and the code on `--slidev-code-background`, two different grounds, so the
1453
+ seam is a change of fill and does not also need a line drawn on it. Removing it is what lets the
1454
+ filename centre (below), and it costs no height either way.
1455
+
1456
+ ★★ AND A LONE TITLE SPENDS THE TAB'S UNDERLINE AS PADDING RATHER THAN RESERVING IT. `border-bottom-width`
1457
+ above is a TAB's active marker; a strip with one label has nothing to mark. It cannot simply be
1458
+ dropped — a tab row is padding + label + that 8px, so a title without it stands 8px shorter than
1459
+ the row on the previous slide, and this book puts the two on consecutive slides. Nor can it stay:
1460
+ reserved at the BOTTOM it pushed the label up, measured 16px above and 24px below.
1461
+
1462
+ So the rung is taken off the border and added to the block padding, half at each end: same total
1463
+ height as a tab row, label centred in it. (It was briefly a transparent border, which held the
1464
+ height and kept the label 8px high — this is the same arithmetic with the space where it belongs.)
1465
+ */
1466
+ .slidev-layout .slidev-code-block-title,
1467
+ .slidev-layout .slidev-code-group-tabs {
1468
+ box-shadow: none;
1469
+ }
1470
+ .slidev-layout .slidev-code-block-title,
1471
+ .slidev-layout .slidev-code-group-tabs {
1472
+ font-family: var(--font-mono);
1473
+ /* ★ AND NOT THE OVERLINE'S TRACKING EITHER. This took the overline's letter-spacing along with
1474
+ its size, on the reasoning that the strip is small, quiet furniture. Tracking is opened up to
1475
+ make UPPERCASE furniture legible; a filename is neither uppercase (see above) nor furniture,
1476
+ and letter-spaced mono reads as a label of a file rather than as the file's name. */
1477
+ letter-spacing: normal;
1478
+ /* The strip is a header ON the block, not a row floating above it: its own raised ground and one
1479
+ divider, so the tabs and the code read as one object the way the design system's own code group
1480
+ does.
1481
+
1482
+ ★ THE DIVIDER IS STOCK'S, NOT A BORDER OF OURS. `client/styles/code.css:55` already draws it as
1483
+ `box-shadow: inset 0 -1px var(--slidev-code-tab-divider)`, and tokens.css points that variable
1484
+ at `--deck-border` — the same line, the same colour. Adding a real border drew it twice and,
1485
+ because a border occupies layout where an inset shadow does not, left the tab row exactly 1px
1486
+ taller than a lone title strip. That was the last of a 14px difference between two strips this
1487
+ book puts on consecutive slides. */
1488
+ background: var(--deck-surface-strong);
1489
+ }
1490
+ /*
1491
+ ★★ AND THE TWO STRIPS STAND THE SAME HEIGHT, which they did not: 70px against a tab row's 84.
1492
+ Slidev wraps a lone filename in its own `.leading-1em` div, so the label sat in a 1em line box
1493
+ while a tab sat in the inherited 1.45 one — same padding, same border, 14px apart, and the two
1494
+ are read on consecutive slides in this book. The utility is neutralised rather than the padding
1495
+ retuned, so both strips keep taking their height from the same three terms.
1496
+ */
1497
+ .slidev-layout .slidev-code-block-title > .leading-1em {
1498
+ line-height: inherit;
1499
+ }
1500
+ .slidev-layout .slidev-code-block-title,
1501
+ .slidev-layout .slidev-code-tab {
1502
+ padding: var(--deck-space-2) var(--deck-space-4);
1503
+ /* Stock draws a 2px underline. At 1920 across a room that is a hairline — the one thing on the
1504
+ strip that says which file you are looking at, drawn at the width of a border. */
1505
+ border-bottom-width: var(--deck-space-1);
1506
+ border-bottom-style: solid;
1507
+ /* Stock hard-codes `transition: color 0.25s`, which is off the ladder in both duration and
1508
+ easing, and is the same class of miss as the magic-move timing below. */
1509
+ transition:
1510
+ color var(--motion-base) var(--motion-ease-out),
1511
+ border-color var(--motion-base) var(--motion-ease-out);
1512
+ }
1513
+ /*
1514
+ ★ AFTER the rule above, deliberately: both selectors are (0,2,0), so this only wins on order. It
1515
+ was written before it once and silently lost — the strip kept the tab's 8px rung and its label
1516
+ stayed 8px high.
1517
+ */
1518
+ .slidev-layout .slidev-code-block-title {
1519
+ border-bottom-width: 0;
1520
+ padding-block: calc(var(--deck-space-2) + var(--deck-space-1) / 2);
1521
+ }
1522
+ /* The file-type mark. Slidev's TitleIcon renders it with `w-3.5 h-3.5`, and those utilities do not
1523
+ exist in this deck — uno.config.ts replaces `theme.spacing` wholesale with the deck's own named
1524
+ ladder, so a numeric `w-3.5` generates nothing at all and the icon fell back to whatever box the
1525
+ preset gave it. Sized here against the tab's own text instead: an editor draws this mark at
1526
+ roughly the cap height of the label beside it, and that proportion is what makes it read as a
1527
+ file mark rather than as a picture. */
1528
+ .slidev-layout .slidev-code-block-title > [class*='i-vscode-icons'],
1529
+ .slidev-layout .slidev-code-tab > [class*='i-vscode-icons'] {
1530
+ width: 1.1em;
1531
+ height: 1.1em;
1532
+ flex: none;
1533
+ }
1534
+
1535
+ /*
1536
+ ★ THE COPY BUTTON IS SIZED FOR A WEB PAGE, AND THIS IS A 1920 CANVAS.
1537
+
1538
+ Slidev renders it as `<svg class="slidev-icon p-2 w-8 h-8">` — a 32px icon, which is right at a
1539
+ reader's arm's length and is 1.7% of the width of a slide. Beside 36px mono it reads as a speck.
1540
+ MEASURED on apps/deck 9: a 32px icon in a button box 32 wide and SEVENTY tall, because
1541
+ the button inherits the slide's body line-height (48px x 1.45 = 70) and never sets its own — so
1542
+ most of what it occupies is leading around an icon that is not there.
1543
+
1544
+ Sized against the CODE rather than against the slide: the button takes the block's own font size,
1545
+ the icon takes 1.1em of that, and `line-height: 1` makes the box the icon again. Same reasoning
1546
+ and the same number as the file-type mark above — an editor draws its furniture at roughly the cap
1547
+ height of the text beside it, and that proportion is what makes it read as a control rather than
1548
+ as a picture.
1549
+
1550
+ It also sat flush in the block's corner, over the padding rather than inside it, so it crowded the
1551
+ first line's last token. Inset by the block's own padding variable, which is the same edge every
1552
+ other thing in the block is measured from.
1553
+
1554
+ Left at Slidev's own visibility — invisible until the block is hovered — deliberately: on a
1555
+ projector nothing is ever hovered, so this costs the room nothing, and the specimen is also read
1556
+ on a screen where copying a frontmatter sample is the point.
1557
+ */
1558
+ .slidev-layout .slidev-code-wrapper .slidev-code-copy {
1559
+ top: var(--slidev-code-padding);
1560
+ right: var(--slidev-code-padding);
1561
+ font-size: var(--slidev-code-font-size);
1562
+ line-height: 1;
1563
+ }
1564
+ /*
1565
+ ★★ AND IT MOVES INTO THE STRIP WHEN THERE IS ONE, because `top` above is measured from the
1566
+ WRAPPER and the wrapper starts at the title bar, not at the code. On a bare block one code padding
1567
+ down is the first line's last token, which is what that rule is for; put a 42px strip above the
1568
+ same block and the button lands ASTRIDE the divider — measured on the specimen's `A numbered
1569
+ block, named`: button 293.7→317.8, strip ending 316.7, so it hung 1px into the code below and cut
1570
+ the one line that separates the two halves of the object.
1571
+
1572
+ Centred on the strip rather than nudged: the strip is its own vertical padding plus a 1em label
1573
+ (`.leading-1em`), and the button is 1.1em, so half the difference is the whole correction and it
1574
+ follows any retune of either variable.
1575
+
1576
+ ★ A CODE GROUP NEEDS NOTHING HERE, and the obvious companion selector for it is DEAD — checked
1577
+ rather than assumed. A group's tab row hangs off `.slidev-code-group`, not `.slidev-code-wrapper`:
1578
+ the tabs sit beside `.slidev-code-group-blocks`, and each block inside keeps its OWN wrapper. So
1579
+ the copy button's wrapper never contains the tab row, `:has(.slidev-code-group-tabs)` matches
1580
+ nothing, and the button correctly stays one code padding inside the code it copies.
1581
+ */
1582
+ .slidev-layout .slidev-code-wrapper:has(.slidev-code-block-title) .slidev-code-copy {
1583
+ /*
1584
+ ★★ GIVEN THE LABEL'S OWN LINE BOX AND CENTRED IN IT, rather than offset by a number. This was
1585
+ `calc(var(--deck-space-2) - 0.05em)`, solved against the strip as it was then — 16px padding and
1586
+ a 1em label — and it silently stopped being centred the moment the strip took the tab's underline
1587
+ rung as padding: measured 14.2px above the button and 29.7px below.
1588
+
1589
+ Two terms, both the strip's own: `top` is exactly the strip's padding-block, so the button starts
1590
+ where the filename starts; `height` is the line box that filename sits in, so centring inside it
1591
+ centres the two against each other. Nothing here is a measured constant, so a retune of the
1592
+ padding rung or the tab font size moves both together.
1593
+ */
1594
+ top: calc(var(--deck-space-2) + var(--deck-space-1) / 2);
1595
+ height: calc(var(--type-deck-body-line-height) * var(--slidev-code-tab-font-size));
1596
+ display: flex;
1597
+ align-items: center;
1598
+ }
1599
+ .slidev-layout .slidev-code-wrapper .slidev-code-copy .slidev-icon {
1600
+ width: 1.1em;
1601
+ height: 1.1em;
1602
+ padding: 0;
1603
+ }
1604
+
1605
+ /* ── The marquee, frozen for export ───────────────────────────────────────────────────────────
1606
+ `slidev export` navigates to the /print route, which renders every slide inside
1607
+ `#print-container`, and calls `page.pdf()`. It also does `emulateMedia({ media: 'screen' })`
1608
+ first — so an `@media print` rule is dead here, and this ancestor class is the only hook there is.
1609
+
1610
+ Without it the PDF catches the band wherever the compositor had it: measured, the first item read
1611
+ "…thwark" where "Southwark" should be. The band is designed to be legible standing still, and
1612
+ translate(0) is the frame it must stand still ON — the same frame the engine's reduced-motion
1613
+ floor lands it on.
1614
+
1615
+ ★★ IT IS HERE RATHER THAN IN Marquee.vue BECAUSE A SCOPED BLOCK DESTROYS IT. Vue compiles
1616
+ `:global(#print-container) .deck-marquee-track { … }` down to `#print-container { … }` — the
1617
+ descendant is dropped and the declaration lands on the container itself, doing nothing. The
1618
+ component's own comment records that; this is where the rule actually lives. */
1619
+ #print-container .deck-marquee-track {
1620
+ animation: none;
1621
+ }
1622
+
1623
+ /* ── Magic move ───────────────────────────────────────────────────────────────────────────────
1624
+ shiki-magic-move animates each token to its new position, and its stylesheet times that on its
1625
+ own hard-coded durations. The deck has a motion ladder and a reduced-motion floor that every
1626
+ other transition on a slide obeys; a code animation is not the one exception. `move` is the long
1627
+ travel (a token crossing the block), `enter`/`leave` the short one. */
1628
+ /* ★ IT PAINTS THE CODE GROUND, NOT THE DECK SURFACE, and names the same two tokens `pre` does. The
1629
+ two roles are not the same colour: a copy of `pre`'s fill here drifts the moment the block's
1630
+ ground moves, and a magic-move slide then carries a different well from the slide before it. */
1631
+ .slidev-layout .shiki-magic-move-container {
1632
+ background: var(--slidev-code-background);
1633
+ border: 1px solid var(--deck-code-border);
1634
+ /* The same radius a lone block takes — the well stands where a block would. */
1635
+ border-radius: var(--slidev-code-radius);
1636
+ padding: var(--deck-space-3) var(--deck-space-4);
1637
+ }
1638
+ .slidev-layout .shiki-magic-move-container pre.shiki {
1639
+ background: none;
1640
+ border: none;
1641
+ padding: 0;
1642
+ }
1643
+ /* Magic move keeps shiki-magic-move's own timing. The --motion-* ladder is scaled for a control
1644
+ answering a click, where the ceiling is 300ms; this is choreography an audience follows, and the
1645
+ library's own 500ms default is scaled for that.
1646
+
1647
+ ⚠ An override here needs `!important`. The renderer sets the timing as an inline style on the
1648
+ container (`container.style.setProperty('--smm-duration', …)`), which outranks a plain declaration.
1649
+
1650
+ Reduced motion is handled by the global floor in @humanforest/tokens/motion: it allow-lists
1651
+ `transition-property`, so the travel and the container resize snap and the colour fade survives.
1652
+ Nothing is needed here for it. */
1653
+
1654
+
1655
+ /* ── Maths (KaTeX) ────────────────────────────────────────────────────────────────────────────
1656
+ The one built-in that arrived nearly right: KaTeX renders into ordinary DOM (no shadow root, no
1657
+ canvas), inherits `color` from the slide, and needs no palette of its own. What it does need is
1658
+ to be told the deck's SIZES, because its own are relative to a 16px web page.
1659
+
1660
+ ★ KATEX SETS 1.21em ON EVERY `.katex`, INLINE ONES INCLUDED. That multiplier is the TeX
1661
+ convention for DISPLAY maths, and KaTeX applies it to the class rather than to display mode — so
1662
+ measured on this deck, `$\frac{r}{v \cdot h}$` in a 48px paragraph rendered at 58.08px: maths
1663
+ 21% taller than the sentence carrying it, pushing the line box open around it. Inline maths
1664
+ belongs ON the line.
1665
+
1666
+ The 1.05em that replaces it is an OPTICAL correction, not a reversal to 1. KaTeX_Main is a
1667
+ Computer Modern cut with a noticeably smaller x-height than GT Haptik, so matched px-for-px it
1668
+ reads smaller than the words either side of it. Nothing here re-points the family: a maths face
1669
+ is a maths face, the brand does not have one, and setting GT Haptik on an equation would break
1670
+ the glyph metrics KaTeX positions everything against.
1671
+
1672
+ Display maths takes a real rung instead of a multiple of whatever it happens to sit in — an
1673
+ equation on its own line is the subject of the slide, so it is set one tier ABOVE body. */
1674
+ .slidev-layout .katex {
1675
+ font-size: 1.05em;
1676
+ }
1677
+ .slidev-layout .katex-display > .katex {
1678
+ font-size: var(--type-deck-title-size);
1679
+ }
1680
+ .slidev-layout .katex-display {
1681
+ margin: var(--deck-space-4) 0;
1682
+ /* A long equation cannot be re-broken — TeX has already set it — so the only honest options are
1683
+ scroll it or let it paint off the canvas. `visible` is what KaTeX ships, and an equation that
1684
+ leaves the slide is invisible to scripts/deckAudit.ts, which measures the ELEMENT's box. */
1685
+ overflow-x: auto;
1686
+ /* ★ AND THE SCROLL BOX CLIPS VERTICALLY TOO, which is a CSS rule rather than a choice: with
1687
+ `overflow-x: auto`, a `visible` overflow-y computes to `auto`, so the box clips on both axes
1688
+ whatever is written here. Tall constructs reach past their own line box — a summation's limits,
1689
+ an integral, a stacked fraction — and the audit caught exactly that: the `n` above a \sum was
1690
+ 31% hidden, 12px cut off the top, with no ellipsis to say so. The padding is the room those
1691
+ constructs need inside the box that now clips them. */
1692
+ overflow-y: hidden;
1693
+ padding-block: var(--deck-space-2);
1694
+ }
1695
+
1696
+ /* ── DIMMING: MATHS ONLY. CODE IS STOCK. ──────────────────────────────────────────────────────
1697
+ Slidev dims what is NOT under discussion to `opacity: 0.3` in both kinds of highlighting — a
1698
+ `$$ {1|3|all}` maths block (`.mord.dishonored`) and a ```go {4-6} code block
1699
+ (`.slidev-code-dishonored`). This theme used to override BOTH, on one measurement:
1700
+
1701
+ #a9b2ac on #f1ede6 1.86:1 (30 elements, the whole un-lit body of a code block)
1702
+ #aac6b8 on #f1ede6 1.57:1
1703
+ #d3bab4 on #f1ede6 1.57:1
1704
+
1705
+ The point of dimming a row is that the audience can still READ it and see it is not the one under
1706
+ discussion; at 1.6:1 across a room it is not dimmed, it is gone.
1707
+
1708
+ ★ MATHS STILL TAKES THE OVERRIDE, and takes it as a COLOUR rather than an opacity. An equation
1709
+ has one ink, so there is no hue to preserve, and a ground-relative mix is strictly better than an
1710
+ opacity: it is contrast-gated, and the muted role is the deck's own answer to "present but not
1711
+ the subject". The percentage table below is the tuning.
1712
+
1713
+ ★★ CODE NO LONGER DOES — 2026-09-06, deliberately, with the number above in view. See the block
1714
+ further down for what came out with it and why. This file's rules stop at `.mord.dishonored`.
1715
+ */
1716
+ /*
1717
+ ⚠ THE CODE BLOCK'S OWN GROUND RE-POINT CAME OUT WITH THE MIX. `.slidev-code` used to set
1718
+ `--deck-mermaid-label-bg: var(--slidev-code-background)` so the dimming mix was taken against the
1719
+ surface the code actually sits on rather than against the page — correct while code was dimmed by
1720
+ colour, and dead now that it is dimmed by opacity, which composites against whatever is behind it
1721
+ without being told. The maths branch keeps the variable and its page default.
1722
+ */
1723
+ .slidev-layout .slidev-katex-wrapper .mord.dishonored {
1724
+ opacity: 1;
1725
+ /*
1726
+ ★★ A MIX TOWARD THE GROUND, NOT A ROLE, AND THAT IS WHAT LETS IT BE TUNED AT ALL.
1727
+ `--deck-fg-subtle` is one colour for every ground, so its contrast is whatever each ground
1728
+ happens to give it — MEASURED across the five, dimmed against ground:
1729
+
1730
+ page 4.55 neutral 4.17 accent 4.02 warm 3.50 vivid 2.62
1731
+
1732
+ A flat role also has nowhere to go: the next step down the ramp is neutral-400, at 2.51 on the
1733
+ page and 1.44 on vivid. So "dim it further" was not available while the value was a role.
1734
+
1735
+ Ground-relative, the whole set moves together. MEASURED at each step, page → vivid:
1736
+
1737
+ 55% 4.55 4.42 4.33 4.11 3.63 ≈ where the flat role sat
1738
+ 50% 3.85 3.77 3.72 3.56 3.21 the last step clearing 3:1 everywhere
1739
+ 45% 3.31 3.23 3.21 3.08 2.82
1740
+ 40% 2.83 2.77 2.76 2.67 2.49 ← shipped
1741
+ 35% 2.45 2.41 2.39 2.33 2.18
1742
+
1743
+ ⚠ 40% IS BELOW THE 3:1 LARGE-TEXT FLOOR AND THAT IS DELIBERATE. This is the one place in the
1744
+ deck where the contrast that matters is not ink-against-ground but LIT-against-DIMMED: the whole
1745
+ mechanism exists to make one row stand out, and a dimmed row held at the reading floor does not
1746
+ dim enough to do that. The lit row is the subject and stays at full ink; these rows are context
1747
+ a viewer glances at, not text they read from the back of the room. Held to the floor, `1|2|3` is
1748
+ an equation with a slightly darker line in it.
1749
+
1750
+ The number is a judgement and the table is here so the next one is too — not a derivation.
1751
+
1752
+ The INK is the ground's own, not --deck-fg: on a toned panel those differ, and this is the same
1753
+ pair the mermaid edge label mutes against. The variable's name is mermaid's by history; what it
1754
+ carries is "the ground here and the ink that goes on it".
1755
+ */
1756
+ color: color-mix(
1757
+ in oklab,
1758
+ var(--deck-mermaid-label-ink, var(--deck-fg)) 40%,
1759
+ var(--deck-mermaid-label-bg, var(--deck-bg))
1760
+ );
1761
+ }
1762
+ /*
1763
+ ★★ CODE HIGHLIGHTING IS STOCK SLIDEV, DELIBERATELY, AND THAT IS A REVERSAL WORTH READING.
1764
+
1765
+ This theme used to own the whole mechanism: a ground-relative mix that flattened the syntax hues
1766
+ on dimmed rows, a neutral band on the lit row, and an accent bar in its gutter — each with a
1767
+ measured justification, all of them removed on 2026-09-06 in favour of `client/styles/code.css`:
1768
+
1769
+ .slidev-code .slidev-code-highlighted { } <- empty, in stock
1770
+ .slidev-code .slidev-code-dishonored { opacity: .3 }
1771
+
1772
+ So a lit row now gets NOTHING and the entire signal is that the others recede, which is stock's
1773
+ design. Two things recommend it. It is the same treatment inside a magic move, where the theme's
1774
+ `.line`-keyed rules never reached (magic-move stamps its class on every TOKEN, not on rows) — so
1775
+ the two forms of highlighting stopped disagreeing. And it takes the `.line` machinery with it: the
1776
+ inline-block, the negative-margin gutter reservation, and the `<code> { display: block }` that
1777
+ only existed to stop the inline-blocks accumulating whitespace into a scrollbar.
1778
+
1779
+ ⚠ THE COST IS ON THE RECORD. 0.3 over this deck's warm paper measures 1.47–1.51:1, and the note
1780
+ this block replaced put it plainly: at that ratio, across a room, a dimmed row is not dimmed, it
1781
+ is gone. The deck is sized for a room. Chosen anyway, with the numbers in view — if it reads badly
1782
+ from the back, the fix is a value on the rule below, not a return to the band and the bar.
1783
+ */
1784
+ .slidev-layout .slidev-katex-wrapper .mord.highlighted {
1785
+ color: var(--deck-fg);
1786
+ }
1787
+
1788
+ /* ── …and the lit EQUATION row does NOT get one, which is a finding rather than an omission ───
1789
+ The lit and dimmed rows here separate by 3.25:1 — the same ink-only gap a code block has, and
1790
+ for the same reason it is not quite enough on its own. A code block gets a bar in the gutter.
1791
+ Maths cannot have one, and it is worth saying why so nobody spends the afternoon again:
1792
+
1793
+ · Slidev classes the `.mord` CELL in every column of the aligned environment, so there are two
1794
+ per lit row and neither is the row.
1795
+ · The first column is `col-align-r`, so its cells' left edges move row by row with the width of
1796
+ the word in them — a bar hung there zigzags.
1797
+ · The two columns are ADJACENT. There is no gutter between them: a bar hung off either cell
1798
+ lands through the middle of the equation. Tried, and it drew straight through the word.
1799
+ · The `.vlist > span` row wrapper is a constant width at a constant x, which looks ideal, and is
1800
+ `height: 0` — so a bar there needs an invented height, and the top row's anchor sits above
1801
+ the block where `.katex-display`'s own `overflow-y: hidden` clips it. Rendered 8x66, accent
1802
+ coloured, and invisible.
1803
+ · Anchoring to `.katex-display` fails too: KaTeX marks several of its own spans `position:
1804
+ relative`, so an absolutely positioned marker resolves against one of those, not the block.
1805
+
1806
+ The ink gap is therefore what maths gets, and it is at the floor of what the token ladder allows:
1807
+ --deck-fg-subtle is 4.08:1 on paper and nothing lighter clears 3:1. The specimen slide states
1808
+ this rather than leaving a reader to wonder why the two treatments differ. */
1809
+ /* ── …AND THE LIT LINES NEED A SHAPE, NOT JUST A HUE ──────────────────────────────────────────
1810
+ Recolouring the dimmed lines fixed their legibility and broke the thing highlighting is FOR.
1811
+ Measured on this deck's own highlighted-code slide, after that change:
1812
+
1813
+ lit ink #002a24 dimmed ink #545252 between them: 1.99:1
1814
+
1815
+ Two inks that close are one lightness with two hues, and hue is the first thing to go from the
1816
+ back of a room — so every line looked the same and nothing read as lit.
1817
+
1818
+ ★★ A TINTED BAND CANNOT FIX IT, and that is a property of this token system rather than a
1819
+ missing value. Measured against the code ground (#f1ede6): --deck-accent-soft scores **1.02:1**,
1820
+ --deck-bg 1.06, --deck-surface-strong 1.09. The derived surface ladder is deliberately quiet —
1821
+ every step sits within about a tenth of the others — so there is no band here that a room can
1822
+ see. The same "paper on paper" finding the Callout tail and the Alert fill both produced.
1823
+
1824
+ So the marker is a BAR IN THE GUTTER: a shape, in the accent, at the start of every lit line.
1825
+ The same answer Timeline's present marker and Carousel's current indicator arrived at — size and
1826
+ shape survive distance, a ten-percent luminance step does not.
1827
+
1828
+ ★ EVERY LINE RESERVES THE GUTTER, lit or not (a transparent border on all of them), so the code
1829
+ does not shift sideways as the highlight moves between clicks. And the lines have to become
1830
+ `inline-block` to carry a border at all — shiki emits them inline, where a border wraps the text
1831
+ rather than the row. `width: 100%` with border-box keeps the row the width of the block. */
1832
+ .slidev-layout blockquote {
1833
+ border-left: 4px solid var(--deck-accent);
1834
+ color: var(--deck-fg-muted);
1835
+ padding-left: var(--deck-space-3);
1836
+ margin: var(--deck-space-3) 0;
1837
+ }
1838
+
1839
+ /* ── Tables ───────────────────────────────────────────────────────────────────────────────── */
1840
+
1841
+ .slidev-layout table {
1842
+ width: 100%;
1843
+ border-collapse: collapse;
1844
+ font-size: var(--type-deck-body-sm-size);
1845
+ }
1846
+ .slidev-layout th,
1847
+ .slidev-layout td {
1848
+ text-align: left;
1849
+ padding: var(--deck-space-1) var(--deck-space-2);
1850
+ border-bottom: 1px solid var(--deck-border);
1851
+ }
1852
+ .slidev-layout th {
1853
+ font-family: var(--font-sans);
1854
+ font-weight: 700;
1855
+ color: var(--deck-fg);
1856
+ border-bottom: 2px solid var(--deck-accent);
1857
+ }
1858
+ .slidev-layout td {
1859
+ color: var(--deck-fg-muted);
1860
+ }
1861
+
1862
+ /* ── Card surface ─────────────────────────────────────────────────────────────────────────
1863
+ The deck's one container, and the deck's copy of the web's .forest-card contract: a card is
1864
+ a quiet fill one step AWAY from the slide ground — lighter on light, lighter again on dark —
1865
+ never a shadow. Anything nested inside it picks up the concentric corner automatically.
1866
+
1867
+ ── THE EDGE IS THE CARD'S OWN INK, AND IT USED TO BE --deck-border ──────────────────────
1868
+ That token is derived from the SLIDE's bg/fg, so a card sitting on anything other than the slide
1869
+ ground drew an edge computed for a ground it is not on: measured on the warm panel, 1.07:1, which
1870
+ is no edge. `currentColor` follows whatever ink the card inherited and holds 1.42–1.91 on every
1871
+ fill a card can take. It also gave the four named backgrounds an edge they did not have — they
1872
+ set `border-color: transparent` on the grounds that the fill was the separation, which is true of
1873
+ vivid (1.74:1 off the slide ground) and false of accent (1.13) and neutral (1.09), and flatly
1874
+ wrong for `accent` on a dark slide, where the fill IS --deck-bg and the card disappeared.
1875
+
1876
+ `border` picks the weight, and `none` is the DEFAULT: a card is a fill, and the edge is a thing
1877
+ an author asks for. `hairline` is 1px at 20% (measured 1.42–1.91 against the fills a card can
1878
+ take), `strong` is 4px at 40% (2.12–2.50 light, 2.27–3.59 dark). At 4px it is a frame rather than
1879
+ an outline, which is the point — the two weights are different instruments, not two settings of
1880
+ one. The 1px transparent border is declared on every card so switching to `hairline` costs no
1881
+ reflow; only `strong` changes the box, by 3px a side.
1882
+
1883
+ ⚠ TWO BACKGROUNDS HAVE NOTHING ELSE. `transparent` at `border: none` is a box with no fill and
1884
+ no edge — legitimate (a padded, aligned cell in a grid) but it takes both props to say, so it is
1885
+ never an accident. `surface` sits 1.12:1 off the slide ground, a separation you can see on a
1886
+ screen and can lose in a room, so a surface card that has to read as a tile takes the hairline. */
1887
+ .slidev-layout .deck-card {
1888
+ background: var(--deck-surface);
1889
+ border: 1px solid transparent;
1890
+ border-radius: var(--deck-radius-card);
1891
+ padding: var(--deck-card-pad);
1892
+ }
1893
+ .slidev-layout .deck-card.is-border-hairline {
1894
+ border-color: color-mix(in oklab, currentColor 20%, transparent);
1895
+ }
1896
+ .slidev-layout .deck-card.is-border-strong {
1897
+ border-width: 4px;
1898
+ border-color: color-mix(in oklab, currentColor 40%, transparent);
1899
+ }
1900
+ .slidev-layout .deck-card > :last-child {
1901
+ margin-bottom: 0;
1902
+ }
1903
+ .slidev-layout .deck-card :where(.deck-card, pre, img, figure > img) {
1904
+ border-radius: var(--deck-radius-nested);
1905
+ }
1906
+ .slidev-layout .deck-card .deck-card :where(.deck-card, pre, img) {
1907
+ border-radius: var(--deck-radius-nested-2);
1908
+ }
1909
+ /* The transparent card — the container that claims no ground at all. It borrows whatever it lands
1910
+ on, and takes only the fill away: the edge above is already the card's own ink, which is the same
1911
+ reasoning BadgeStrip's `outline` reads --deck-ink-<name> rather than a pinned half. It is the one
1912
+ background where `border: none` leaves nothing at all, which is a legitimate thing to ask for
1913
+ (a padded, aligned cell in a grid) and never an accident, because it takes two props to say. */
1914
+ .slidev-layout .deck-card.is-transparent {
1915
+ background: none;
1916
+ }
1917
+
1918
+ /* A card on a named background — the one that carries a claim rather than a container. The
1919
+ fills are the FOLLOWING tokens, so the register picks the half; `dark` on the card adds
1920
+ `.deck-dark`, which re-declares them to the dark half (deck.css). The ink is set once on the
1921
+ card and every text role inside inherits it: a token pair is fine on its own and still fails
1922
+ when a heading keeps reading the page's --deck-fg on a fill that is not the page. */
1923
+ .slidev-layout .deck-card.bg-warm { background: var(--deck-bg-warm); color: var(--deck-on-bg-warm); }
1924
+ .slidev-layout .deck-card.bg-accent { background: var(--deck-bg-accent); color: var(--deck-on-bg-accent); }
1925
+ .slidev-layout .deck-card.bg-vivid { background: var(--deck-bg-vivid); color: var(--deck-on-bg-vivid); }
1926
+ .slidev-layout .deck-card.bg-neutral { background: var(--deck-bg-neutral); color: var(--deck-on-bg-neutral); }
1927
+ /*
1928
+ ★★ EVERY TEXT TIER ON A NAMED BACKGROUND READS THE PAIR'S INK, WHATEVER SURFACE IT IS. This was
1929
+ scoped to `.deck-card.bg-*` and so reached exactly one of the four surfaces that wear these
1930
+ classes. The others were wrong and only LOOKED right: a heading carries a hard `color:
1931
+ var(--deck-fg)` at (0,1,1) from the type rules above, which beats inheriting the pair's ink, so
1932
+ the h1 on a warm panel painted neutral-900 near-black while its own paragraphs painted warm-950
1933
+ brown. Measured on the specimen's full-field warm band — h1 rgb(0.096 0.088 0.090) against a band
1934
+ ink of rgb(0.249 0.144 0.117). It hid on the DARK half of every pair, where --deck-fg is
1935
+ neutral-0 and the pair's ink is neutral-0 too: right by coincidence, not by rule.
1936
+
1937
+ Keyed on the background classes rather than on any component, so a card, a split panel, a stack
1938
+ band, a full-field band and a `default` slide on a colour all behave the same, and the next
1939
+ surface to take a named background inherits the behaviour instead of having to remember it.
1940
+ */
1941
+ :is(.slidev-layout, .slidev-layout *):is(.bg-warm, .bg-accent, .bg-vivid, .bg-neutral) :where(h1, h2, h3, h4, h5, h6, strong, b, a),
1942
+ :is(.slidev-layout, .slidev-layout *):is(.bg-warm, .bg-accent, .bg-vivid, .bg-neutral) > .deck-eyebrow {
1943
+ color: inherit;
1944
+ }
1945
+ /* ⚠ `li` IS NOT IN THIS SET, and it was for one commit. `opacity` on the item applies to its
1946
+ ::before as well — a child cannot opt out of an ancestor's alpha — so dimming list text to 0.85
1947
+ dimmed the bullet with it and dropped the vivid pairing from 3.55 to 2.86, under the 3:1 floor.
1948
+ The audit caught it on the specimen's own vivid slide. A list item reads at full ink instead,
1949
+ which is right anyway: a list here is a set of short statements, not running prose. */
1950
+ :is(.slidev-layout, .slidev-layout *):is(.bg-warm, .bg-accent, .bg-vivid, .bg-neutral) :where(p, .deck-caption) {
1951
+ color: inherit;
1952
+ }
1953
+ /*
1954
+ ⚠ THE 0.85 IS A CARD IDIOM AND STAYS ON THE CARD. Generalising it to every named background — one
1955
+ commit — dimmed a paragraph that had stated its own colour on purpose: the Ink specimen draws each
1956
+ role as a `p` with an inline `color`, and an inline colour survives `color: inherit` but nothing
1957
+ survives an ancestor's alpha. --deck-fg-subtle on the accent panel's dark half fell from its own
1958
+ value to #636767 and scored 2.70:1, under the 3:1 floor. The audit named the slide.
1959
+
1960
+ A claim card sits its body copy back from its heading because the card is one short statement. A
1961
+ panel, a band or a full-field slide is a page, and its paragraphs are the page's body — they read
1962
+ at full ink like every other slide's.
1963
+ */
1964
+ .slidev-layout :is(.deck-card.bg-warm, .deck-card.bg-accent, .deck-card.bg-vivid, .deck-card.bg-neutral) :where(p, .deck-caption) {
1965
+ opacity: 0.85;
1966
+ }
1967
+
1968
+ /* ── Own-ground components — a Card on a named background ──────────────────────────────────────────────
1969
+ An own-ground component establishes its OWN fill/ink pair rather than reading the page's, and a
1970
+ Card on a named background is the last one: it paints --deck-bg-<name> with --deck-on-bg-<name>.
1971
+ The ink is set once on the component's root, so any descendant that plainly INHERITS colour reads
1972
+ correctly for free. The
1973
+ element rules above this comment do not inherit: `strong`/`b`/`a`/headings/`.deck-caption`/
1974
+ `.deck-note` all carry HARD colours at (0,2,0), which beat inheritance outright regardless of what
1975
+ ground they sit on or how deep they are nested.
1976
+
1977
+ ⚠ THE TWO COMPONENTS THIS WAS WRITTEN FOR ARE BOTH GONE — Chip, whose `tone` picked a saturated
1978
+ fill, and Callout, which painted its own paper. The measurements that produced the rule are kept
1979
+ because they are the evidence for it and it still applies: `<Chip>` at its default tone containing
1980
+ `**12%**` rendered the `<strong>` at 1.00:1 (--deck-accent text on a fill of the exact same
1981
+ value), and `<Card variant="accent"><Callout>…</Callout></Card>` rendered white body copy on
1982
+ Callout's own near-white paper at ~1.02:1. The second one is why the Card rules above exist.
1983
+
1984
+ Fixed HERE, in base.css, rather than in each component's own scoped `<style>`, for two reasons:
1985
+
1986
+ 1. A Vue SFC's scoped CSS and this global stylesheet land in the SAME (0,2,0) specificity band
1987
+ for a single-class selector, and which one wins a tie then depends on stylesheet injection
1988
+ order — unpredictable across dev and a production build. Landing the override in the same
1989
+ file as the rules it overrides turns that into a plain textual "declared later wins".
1990
+ 2. It also has to beat the NAMED-BACKGROUND CARD rule directly above (`:where(p, .deck-caption)`), which
1991
+ is a plain descendant selector with NO exemption for a nested component that establishes its
1992
+ own ground — so it wins regardless of how deep `.deck-chip`/`.deck-callout` is nested unless
1993
+ something here specifically out-ranks or out-orders it. Both rules below tie that rule's
1994
+ (0,2,0) specificity and are declared AFTER it, so the tie-break favours the component's own
1995
+ ground no matter how deeply it's nested inside a named-background card.
1996
+
1997
+ `currentColor` rather than a specific token, deliberately: it resolves to whatever ink the
1998
+ component actually set — Chip's per-tone ink, Callout's fixed `--deck-fg` — with ONE rule
1999
+ instead of one per tone, and it keeps working if a tone is ever added or renamed. `opacity: 1`
2000
+ is explicit rather than left to inherit the named-background card rule's 0.85 above: both components'
2001
+ own pairs are already independently measured against the body-text floor (see
2002
+ test/frontmatter.static.test.ts's "chip pairs clear the contrast floor" suite), and diluting
2003
+ that with an unrelated opacity would spend back margin the fix just put in.
2004
+
2005
+ The next component that establishes its own ground (rather than reading the page's) needs the
2006
+ same shape of rule, with its own root class beside `.deck-callout`. (`.deck-chip` was the other one until Chip
2007
+ was retired — its tones live on as BadgeStrip's and IconTile's.) */
2008
+ /* List markers need the SAME treatment but were missed the first time round: `ul > li::before`
2009
+ (the bullet disc) and `li ul > li::before` (its nested tier) paint through `background`, not
2010
+ `color`, so adding them to the `:where(...)` list above would do nothing — `color: currentColor`
2011
+ cannot reach a `background` declaration. Measured: `<Chip><ul><li>…</li></ul></Chip>` at the
2012
+ default tone (`forest`) painted its bullet in `--deck-accent` on a `--deck-bg-vivid-dark` fill of
2013
+ the identical value, 1.00:1 — the original Critical, unchanged, for markup a demo slide never
2014
+ exercised. `.deck-callout` gets the same fix for the same reason a component with its own
2015
+ ground always does (see the comment above), even though Callout's own ground/ink pair was never
2016
+ independently reachable-broken for a marker the way Chip's tone-fill was — no configuration
2017
+ pairs `--deck-accent` against a Callout ground dark enough to fail 3:1.
2018
+
2019
+ The nested tier stays QUIETER on purpose — `.slidev-layout li ul > li::before` above steps down
2020
+ to `--deck-fg-subtle` rather than repeating the accent, and that hierarchy is worth keeping
2021
+ rather than flattening both tiers to the same full-strength `currentColor`. `opacity: 0.6` is a
2022
+ measured choice, not a guess: blending each own-ground pair's ink over its own fill at candidate
2023
+ opacities and re-running contrast() against WCAG's 3:1 floor for a non-text/graphical object
2024
+ (not the 4.5:1 body-text floor the rules above target — a bullet is UI chrome, not a sentence)
2025
+ put the worst of the six pairs measured at the time at 3.37:1 at 0.6, clear of the floor — and
2026
+ that worst pair was the forest-700 fill, the SAME one Critical 1's text repro used, not the bark
2027
+ step (a first, uncomputed guess would have picked wrong: bark measures a comfortable 3.63:1 at
2028
+ 0.6). 0.5 was tried first and rejected — it drops forest-700 to 2.82:1 and the peach step to
2029
+ 2.76:1, both under the floor. (Those were the six poetic CHIP names, which no longer exist; the
2030
+ numbers are kept because they are the measurement this 0.6 rests on, and the fills they name are
2031
+ still in the table as `vivid-dark`, `warm-dark` and `warm-light`.)
2032
+
2033
+ ★ fix round 1, CRITICAL 1 re-measured this once the accent Card joined `.deck-own-ground`
2034
+ (below). That card painted `--deck-accent` with `--deck-on-accent`: light forest-700 / #ffffff,
2035
+ 3.37:1 at 0.6, and dark forest-300 / forest-950, 3.32:1.
2036
+
2037
+ ★★ RE-MEASURED AGAIN, ALL EIGHT, when that card became `background` × `dark` and started reading
2038
+ the named-backgrounds table instead. Neither of the two numbers above survives the move, and
2039
+ assuming they did was wrong twice over: the fills are now the table's, so five of the eight
2040
+ halves had never been measured here at all, and the ink is `--deck-on-bg-<name>` — neutral-0
2041
+ (#FEFBFC), not the #ffffff `--deck-on-accent` was. Blending each half's ink over its own fill,
2042
+ marker at 0.6 and body at 0.85:
2043
+
2044
+ warm-light warm-200 / warm-950 3.51 · 6.91 accent-light forest-100 / forest-950 4.01 · 8.61
2045
+ warm-dark warm-700 / neutral-0 3.57 · 5.44 accent-dark forest-950 / neutral-0 6.20 · 11.20
2046
+ vivid-light forest-300/ forest-950 3.32 · 6.11 neutral-light neutral-100/ neutral-900 4.49 · 10.33
2047
+ vivid-dark forest-700/ neutral-0 3.31 · 5.02 neutral-dark neutral-800/ neutral-0 6.21 · 10.94
2048
+
2049
+ Every half clears both floors, and the true worst case is vivid-dark at 3.31:1 — not the 3.32
2050
+ the old note named, and not the 3.37 it called the worst, which only reproduces with the retired
2051
+ pure-white ink. 0.85 bottoms out on the same half at 5.02:1 against the 4.5 body floor.
2052
+
2053
+ The top-tier bullet/marker rules below need no equivalent `opacity: 1` guard: `opacity` doesn't
2054
+ inherit, and nothing else in this file sets it on `li::before`/`li::marker`, so browser-default
2055
+ full strength is what they get without asking.
2056
+
2057
+ Specificity verified rather than assumed, because `li ul > li::before` carries one more element
2058
+ than `ul > li::before` does: `.slidev-layout .deck-own-ground ul > li::before` is (0,2,3) against
2059
+ the base rule's (0,1,3); `.slidev-layout .deck-own-ground li ul > li::before` is (0,2,4) against
2060
+ (0,1,4). Both win outright on specificity, unlike the text rules above — no reliance on source
2061
+ order.
2062
+
2063
+ ★★ fix round 1, CRITICAL 1 — this used to be `.deck-chip ul > li::before, .deck-callout ul >
2064
+ li::before` (and the two rules below it, same shape): an enumerated pair, one selector per
2065
+ component, exactly the thing "the next component that establishes its own ground needs the same
2066
+ rule" (this file's own words, two paragraphs up) warned would need REMEMBERING. It did not get
2067
+ remembered: the accent Card (today `.deck-card.bg-*`, "A card on a named background" above)
2068
+ established its own ground exactly like Chip and Callout did — `background: var(--deck-accent)`
2069
+ — and was never added here. A plain
2070
+ markdown bullet list written directly inside the accent Card (a documented API, used
2071
+ three times in apps/deck-template) painted its bullet in the GLOBAL `ul > li::before` rule's
2072
+ `background: var(--deck-accent)` — the exact same value as its own ground. 1.00:1, invisible, in
2073
+ both registers, and scripts/deckAudit.ts's rendered-contrast sweep could not see it either: its
2074
+ collector only ever scored elements with a direct text-node child, and a `::before`/`::marker`
2075
+ pseudo-element is neither an element nor a text node (fixed separately, see deckAudit.ts's own
2076
+ comment on `PSEUDO_SELECTORS`).
2077
+
2078
+ Fixed HERE, once, by construction rather than by memory: `.deck-own-ground` is a plain marker
2079
+ class with no styling of its own, added to the ROOT of every component that paints its own
2080
+ fill/ink pair (then Chip.vue and Callout.vue; today Card.vue on a named background) instead of
2081
+ reading the page's.
2082
+ The three rules below key off that ONE class, so the next own-ground component only has to add
2083
+ the class to its root — nothing here has to change, and nothing here can be forgotten to
2084
+ change. */
2085
+ /* ⚠ `:is(.slidev-layout.deck-own-ground, .slidev-layout .deck-own-ground)`, NOT the descendant
2086
+ alone. Every own-ground component so far has been a box INSIDE a slide, so a plain descendant
2087
+ selector reached all of them and nobody noticed it could not reach a LAYOUT that establishes its
2088
+ own ground — default.vue on a named `background` puts the class on `.slidev-layout` itself, which
2089
+ is the element, not a descendant of it. Measured before this line was widened: a bullet on a
2090
+ `background: vivid` slide painted --deck-accent, forest-300 on a forest-700 field. It passed the
2091
+ audit at 3.55:1 and was still the wrong colour — the one green mark on a slide whose ink is
2092
+ white — which is why the specimen draws a list on every one of those five slides. */
2093
+ :is(.slidev-layout.deck-own-ground, .slidev-layout .deck-own-ground) ul > li::before {
2094
+ background: currentColor;
2095
+ }
2096
+ /*
2097
+ ★★ EXCEPT ON A NAMED BACKGROUND, WHERE THE MARK TAKES THAT NAME'S OWN FAMILY. `currentColor` is
2098
+ the safe answer for an own-ground surface whose fill is arbitrary — a solid Alert paints a status
2099
+ colour, and a dot in some other hue on it would say the wrong thing. A NAMED background is not
2100
+ arbitrary: the engine publishes a mark for each one, `--deck-bg-<name>-mark`, which is the
2101
+ liveliest step of that family still clearing the 3:1 a graphical object is held to against the
2102
+ fill it lands on. Orange on peach and on bark, the vivid green on both accent fields, mint on the
2103
+ dark vivid one. See PANEL in scripts/deck.ts for how each was chosen and what it measures.
2104
+
2105
+ One rule per name and no register branch: the token is re-declared under `.deck-dark`, so a panel
2106
+ carrying that class picks up its own half without this file knowing which register it is in.
2107
+
2108
+ ★ WHY NOT --deck-accent EVERYWHERE, which was the first attempt. It measures fine on all eight
2109
+ halves, 3.55 worst. It also puts a forest dot on a peach panel and on a grey one, which is a
2110
+ second hue on a slide that had already chosen one. The deck's own ground keeps the accent, because
2111
+ there the accent IS the slide's colour.
2112
+ */
2113
+ /* A mermaid edge label knocks its own line out, so its backing takes whatever ground it is over —
2114
+ the page by default (--deck-bg, already per-register), or the named fill when it is on one. Read
2115
+ inside the SVG by setup/mermaid-renderer.ts; a custom property is what carries across that
2116
+ boundary, since the diagram cannot see the class its container wears. */
2117
+ :is(.slidev-layout, .slidev-layout *).bg-warm { --deck-mermaid-label-bg: var(--deck-bg-warm); --deck-mermaid-label-ink: var(--deck-ink-warm); }
2118
+ :is(.slidev-layout, .slidev-layout *).bg-accent { --deck-mermaid-label-bg: var(--deck-bg-accent); --deck-mermaid-label-ink: var(--deck-ink-accent); }
2119
+ :is(.slidev-layout, .slidev-layout *).bg-vivid { --deck-mermaid-label-bg: var(--deck-bg-vivid); --deck-mermaid-label-ink: var(--deck-ink-vivid); }
2120
+ :is(.slidev-layout, .slidev-layout *).bg-neutral { --deck-mermaid-label-bg: var(--deck-bg-neutral); --deck-mermaid-label-ink: var(--deck-ink-neutral); }
2121
+
2122
+ :is(.slidev-layout, .slidev-layout *).bg-warm ul > li::before { background: var(--deck-bg-warm-mark); }
2123
+ :is(.slidev-layout, .slidev-layout *).bg-accent ul > li::before { background: var(--deck-bg-accent-mark); }
2124
+ :is(.slidev-layout, .slidev-layout *).bg-vivid ul > li::before { background: var(--deck-bg-vivid-mark); }
2125
+ :is(.slidev-layout, .slidev-layout *).bg-neutral ul > li::before { background: var(--deck-bg-neutral-mark); }
2126
+
2127
+ /*
2128
+ ★★ AND THE CHIP'S INK FOLLOWS THE SAME GROUND, which is what makes the wash above land in the
2129
+ family rather than beside it. --deck-accent is the deck's green and is right on the page; on a
2130
+ named background it is a third family in a word that already has two.
2131
+
2132
+ IT IS THE FAMILY'S INK, NOT ITS MARK, AND THAT IS MEASURED. The mark tone carries the bullets one
2133
+ rule up, and bullets are a GRAPHICAL mark held to 3:1. Code is text and owes 4.5:1, which the mark
2134
+ misses on two of the four:
2135
+
2136
+ family ground mark mark:ground ink ink:ground
2137
+ warm #FFD6A6 #A75633 3.83 ✗ #44231C 10.26 ✓
2138
+ accent #CEF5DD #046C4C 5.45 ✓ #002A24 13.07 ✓
2139
+ vivid #3BD996 #075640 4.78 ✓ #002A24 8.51 ✓
2140
+ neutral #F3EFF0 #757273 4.18 ✗ #191617 15.76 ✓
2141
+
2142
+ So the chip takes --deck-ink-<name>, the same ink the prose around it takes. It stops being a
2143
+ different COLOUR from its sentence and stays a different THING: mono, boxed, edged. The fill and
2144
+ the border derive from this colour, so naming the ink moves all three.
2145
+ */
2146
+ :is(.slidev-layout, .slidev-layout *).bg-warm :not(pre) > code:not(.shiki) { color: var(--deck-ink-warm); }
2147
+ :is(.slidev-layout, .slidev-layout *).bg-accent :not(pre) > code:not(.shiki) { color: var(--deck-ink-accent); }
2148
+ :is(.slidev-layout, .slidev-layout *).bg-vivid :not(pre) > code:not(.shiki) { color: var(--deck-ink-vivid); }
2149
+ :is(.slidev-layout, .slidev-layout *).bg-neutral :not(pre) > code:not(.shiki) { color: var(--deck-ink-neutral); }
2150
+ :is(.slidev-layout.deck-own-ground, .slidev-layout .deck-own-ground) ol > li::marker {
2151
+ color: currentColor;
2152
+ }
2153
+ :is(.slidev-layout.deck-own-ground, .slidev-layout .deck-own-ground) li ul > li::before {
2154
+ background: currentColor;
2155
+ opacity: 0.6;
2156
+ }
2157
+
2158
+ /* ── The notch — ONE cut, worn by every figure that seats the running mark ────────────────
2159
+ task-15-brief.md built this shape; task-18-brief.md moved it here. It used to live inside
2160
+ split.vue's scoped <style>, which was correct while split was the only layout with a panel to
2161
+ bite out of. stack.vue's band is the second, cutting the same corner around the same mark, and a
2162
+ five-layer composited mask is not a thing this theme can afford two copies of — the geometry is
2163
+ the DESIGN, not one layout's implementation detail.
2164
+
2165
+ Each layout still owns what is genuinely its own: whether there is a cut at all (../notch.js's
2166
+ hasNotch(), which both call), which corner is the outer one (`data-notch-corner`, bound in the
2167
+ template because it flips with split's `side`), the margin that raises the figure to meet the
2168
+ mark, and the band a text-holding panel reserves from its own content.
2169
+
2170
+ ★★ NOT A STRETCHED MASK — the requirement that decides the implementation. Jose: "i mean, not
2171
+ just stretch a fixed mask". The wrong build authors one shape and scales it to whatever size the
2172
+ figure happens to be, so a 48px corner becomes an ellipse on a narrow panel and flattens on a
2173
+ wide one; across split's six ratios that is six different corner treatments where the design says
2174
+ there is one. So the mask is COMPOSED from gradients in ABSOLUTE units and composited: gradient
2175
+ geometry sized in px does not stretch with its box, and only the figure's own width and height
2176
+ differ between ratios. Nothing here is authored at an aspect ratio, so nothing here has one to
2177
+ distort — there is no viewBox, no `mask-size: 100% 100%` on a shape, and no reused data URI.
2178
+ Measured rather than asserted, at both extremes of the ratio ladder — see task-15-report.md, and
2179
+ task-18-report.md for the same measurement repeated after this move.
2180
+
2181
+ FIVE LAYERS, and what each one is. Written top-first (CSS layer order), composited bottom-up:
2182
+
2183
+ FULL the whole figure subtract (everything below)
2184
+ RETURN-A the convex fillet where the cut meets the figure's TOP edge add
2185
+ RETURN-B the convex fillet where the cut meets the figure's OUTER edge add
2186
+ RECT the cut itself: occupant + one gap, anchored in the corner subtract (the sliver below)
2187
+ SLIVER the concave corner's own curve, kept OUT of the cut add
2188
+
2189
+ So the removed region is (RECT − SLIVER) ∪ RETURN-A ∪ RETURN-B, and the figure is everything
2190
+ else. The three curved layers are the SAME shape — a square minus a disc of radius --notch-r
2191
+ pinned to one of its corners — placed three times. That single shape is what produces the
2192
+ S-curve: rounding the figure's two convex corners takes material away, rounding the cut's concave
2193
+ corner gives it back, and all three arcs share one radius.
2194
+
2195
+ Each curved layer overlaps its neighbour by --notch-overlap. Mask layers composite with
2196
+ source-over, so two ANTIALIASED edges that merely abut sum to ~0.75 alpha and leave a hairline
2197
+ seam; a 1px overlap removes the seam without moving any curve (the overlap extends into territory
2198
+ the neighbouring layer already covers). The 0.5px feather on each colour stop is the other half of
2199
+ the same concern: a hard gradient stop is rasterised without antialiasing and gives a stepped arc.
2200
+
2201
+ ⚠ MASKING CLIPS, IT DOES NOT REFLOW. The mask handles the SHAPE; the SPACE is each layout's own
2202
+ problem, because only the layout knows whether its figure holds a photograph (clipping it IS the
2203
+ effect) or words (which would silently lose a corner). See split.vue's `.is-panel.has-notch` and
2204
+ stack.vue's `.stack-band.is-panel`. */
2205
+ .deck-notch {
2206
+ /*
2207
+ ★ DERIVED FROM THE OCCUPANT, never pinned beside it — the property that makes this a system
2208
+ feature rather than a shape someone drew once. The occupant's box is the input and the cut is
2209
+ that box plus one gap, so changing the mark (--deck-chrome-mark, scripts/deck.ts) moves both
2210
+ with no second edit here, and a slide that widens the occupant — a forest+partner pair
2211
+ (`partner:`), an image, a content block — widens the cut by the same amount with no code change
2212
+ at all.
2213
+
2214
+ The default is written as var()'s FALLBACK rather than as a declaration in deck.css: a custom
2215
+ property resolves once, where it is declared, so anything derived from an occupant declared at
2216
+ :root would keep the :root answer even on a slide that overrode the occupant further down.
2217
+ Undeclared, the override reaches every reader — this rule and slide-top.vue's own mark box.
2218
+
2219
+ --notch-r is clamped to half the cut on each axis. At the shipped numbers (108px cut, 48px
2220
+ radius) the straight segments are 12px and the profile is very nearly a pure S; a SMALLER
2221
+ occupant would otherwise ask for arcs that overlap each other, and min() degrades that to a
2222
+ clean S-curve instead of a self-intersecting shape.
2223
+ */
2224
+ --notch-w: calc(var(--deck-notch-occupant-w, var(--deck-chrome-mark)) + var(--deck-notch-gap));
2225
+ --notch-h: calc(var(--deck-notch-occupant-h, var(--deck-chrome-mark)) + var(--deck-notch-gap));
2226
+ --notch-r: min(var(--deck-notch-radius), calc(var(--notch-w) / 2), calc(var(--notch-h) / 2));
2227
+ /*
2228
+ ★★ THE CUT IS A CLIP, NOT A MASK — and that is a correctness decision, not a preference.
2229
+
2230
+ This was five mask layers composited `subtract, add, add, subtract, add`. It drew the right
2231
+ shape and it leaked: a hairline of panel painted out in the ground, tracing the figure's edges
2232
+ and the cut rectangle's inner corner. A mask is ALPHA, so every layer rasterises independently
2233
+ and any boundary that lands on a fractional device pixel resolves to partial coverage —
2234
+ `subtract` then turns that into `1 - partial`, a few percent of panel left painted where the
2235
+ cut should be empty. It is a colour artifact, so no geometric gate could see it, and it moved
2236
+ with the window size, which made it read like a scaling bug.
2237
+
2238
+ Four attempts to patch it boundary-by-boundary all failed, and each is recorded in the history
2239
+ rather than quietly dropped: widening the layer overlap, bleeding the cut layers past the box,
2240
+ bleeding the base layer too, and promoting the element to its own compositing layer. Promotion
2241
+ cleared the outer edges and left the inner corner, which is the tell — there was no single bad
2242
+ boundary, the arithmetic itself was wrong for the job.
2243
+
2244
+ A clip is GEOMETRY. One path, rasterised once, antialiased once, with no compositing arithmetic
2245
+ to leave a residue behind. That removes the whole class of defect rather than the instances of
2246
+ it, which is why this is the shape of the fix.
2247
+
2248
+ VERIFIED NUMERICALLY, not by eye — every earlier visual judgement in this investigation was
2249
+ wrong at least once. clip-path participates in hit-testing, so the rendered region can be
2250
+ sampled directly: 3,481 points per corner, on both corners, checked against an independent
2251
+ analytic model of the intended profile. Zero mismatches. The check was proved able to fail
2252
+ first — shrinking the inner radius from 48 to 30 produced 34 mismatches.
2253
+
2254
+ The profile, corner-local, is exactly what the mask drew: a convex return of radius r meeting
2255
+ the top edge at (w + r, 0), a straight run down x = w, a concave quarter-circle of radius r
2256
+ centred on (w - r, h - r), and the mirrored return meeting the side edge at (0, h + r). The
2257
+ two corner variants below differ only by mirroring x and flipping every sweep.
2258
+ */
2259
+ }
2260
+ /*
2261
+ WHICH CORNER — always the figure's OUTER one: on a split, the corner against the slide edge and
2262
+ away from the text; on a stack's full-width band, the corner the mark is already in. That is where
2263
+ the mark can sit without crowding the content it shares the slide with.
2264
+
2265
+ The cut is anchored to the FIGURE, not to the slide: every offset above is measured from the
2266
+ figure's own edge, so it travels with the panel across split's six ratios (from half the row down
2267
+ to a quarter of it) and across stack's band heights with no per-ratio rule, and would still be
2268
+ correct if a seventh were added.
2269
+
2270
+ Three custom properties carry the corner, because a corner is not just a side: --notch-edge is
2271
+ which physical edge every offset is measured from, and the two *-x values move each disc's centre
2272
+ to the corresponding corner of its own tile. Physical (right/left), never logical: which side is
2273
+ "outer" flips with split's `side`, never with writing direction.
2274
+
2275
+ RIGHT is the default rather than a required attribute — it is where the running mark sits on every
2276
+ slide that has not mirrored — so a figure that forgets the attribute cuts a real corner rather
2277
+ than resolving --notch-edge to nothing and invalidating the whole mask declaration (which renders
2278
+ as an UNCUT figure: a silent failure, the exact shape this project keeps finding).
2279
+ */
2280
+ .deck-notch,
2281
+ .deck-notch[data-notch-corner='right'] {
2282
+ --notch-edge: right;
2283
+ clip-path: shape(
2284
+ from calc(100% - var(--notch-w) - var(--notch-r)) 0,
2285
+ hline to var(--deck-radius-card),
2286
+ arc to 0 var(--deck-radius-card) of var(--deck-radius-card) ccw,
2287
+ vline to calc(100% - var(--deck-radius-card)),
2288
+ arc to var(--deck-radius-card) 100% of var(--deck-radius-card) ccw,
2289
+ hline to calc(100% - var(--deck-radius-card)),
2290
+ arc to 100% calc(100% - var(--deck-radius-card)) of var(--deck-radius-card) ccw,
2291
+ vline to calc(var(--notch-h) + var(--notch-r)),
2292
+ arc to calc(100% - var(--notch-r)) var(--notch-h) of var(--notch-r) ccw,
2293
+ hline to calc(100% - var(--notch-w) + var(--notch-r)),
2294
+ arc to calc(100% - var(--notch-w)) calc(var(--notch-h) - var(--notch-r)) of var(--notch-r) cw,
2295
+ vline to var(--notch-r),
2296
+ arc to calc(100% - var(--notch-w) - var(--notch-r)) 0 of var(--notch-r) ccw,
2297
+ close);
2298
+ }
2299
+ .deck-notch[data-notch-corner='left'] {
2300
+ --notch-edge: left;
2301
+ clip-path: shape(
2302
+ from calc(var(--notch-w) + var(--notch-r)) 0,
2303
+ hline to calc(100% - var(--deck-radius-card)),
2304
+ arc to 100% var(--deck-radius-card) of var(--deck-radius-card) cw,
2305
+ vline to calc(100% - var(--deck-radius-card)),
2306
+ arc to calc(100% - var(--deck-radius-card)) 100% of var(--deck-radius-card) cw,
2307
+ hline to var(--deck-radius-card),
2308
+ arc to 0 calc(100% - var(--deck-radius-card)) of var(--deck-radius-card) cw,
2309
+ vline to calc(var(--notch-h) + var(--notch-r)),
2310
+ arc to var(--notch-r) var(--notch-h) of var(--notch-r) cw,
2311
+ hline to calc(var(--notch-w) - var(--notch-r)),
2312
+ arc to var(--notch-w) calc(var(--notch-h) - var(--notch-r)) of var(--notch-r) ccw,
2313
+ vline to var(--notch-r),
2314
+ arc to calc(var(--notch-w) + var(--notch-r)) 0 of var(--notch-r) cw,
2315
+ close);
2316
+ }
2317
+
2318
+ /* ── Motion ───────────────────────────────────────────────────────────────────────────────
2319
+ ★★ THE DECK CROSS-FADES THROUGH THE VIEW TRANSITIONS API, AND THAT IS A CONTRAST FIX RATHER
2320
+ THAN A FLOURISH. `transition: fade` (what this theme shipped) animates BOTH slides' opacity at
2321
+ once over two absolutely-positioned siblings whose own grounds are painted on the layout INSIDE
2322
+ them, so during the overlap neither is opaque and the page behind shows through. That page is
2323
+ white — #slide-content, #app and body all are — and the leak is the product of the two
2324
+ transparencies: measured stepping off a dark divider, 0.25/0.75 at 115ms of the 240ms fade, so
2325
+ 18.8% of pure white across the whole frame, peaking near 25% at the crossover. Invisible cream
2326
+ to cream. A flash on every dark slide, and a strobe when an author pages fast.
2327
+
2328
+ `view-transition` is the fix because the UA default composites the pair CORRECTLY: measured on a
2329
+ live transition, `::view-transition-image-pair(root)` carries `isolation: isolate` and both
2330
+ halves `mix-blend-mode: plus-lighter`, so the two opacities SUM to 1 instead of stacking one
2331
+ over the other. Proved on a throwaway page with a magenta backdrop, which never appeared.
2332
+
2333
+ What the API does not bring is the ladder: it ships 0.25s on its own curve and never reads
2334
+ --slidev-transition-duration. Hence the two rules below. The Vue-transition curves are kept
2335
+ because a slide can still opt back into `fade` from its own frontmatter, and Slidev hard-codes
2336
+ `ease` and `ease-in` there — ease-in is the curve the doctrine bans outright, since it starts
2337
+ slow and reads as unresponsive at exactly the moment the audience is watching the slide change. */
2338
+ ::view-transition-old(root),
2339
+ ::view-transition-new(root) {
2340
+ animation-duration: var(--motion-slow);
2341
+ animation-timing-function: var(--motion-ease-out);
2342
+ }
2343
+
2344
+ /* The global floor in packages/tokens/motion/motion.css cannot reach these: it targets
2345
+ `*, *::before, *::after`, and a view-transition pseudo-element is none of the three. Without
2346
+ this block a reduced-motion viewer gets the full cross-fade, which is the one case the floor
2347
+ exists to prevent. Not `animation: none` — removing the animation would leave the old snapshot
2348
+ painted over the new slide, so the pair still has to run, just imperceptibly. */
2349
+ @media (prefers-reduced-motion: reduce) {
2350
+ ::view-transition-old(root),
2351
+ ::view-transition-new(root) {
2352
+ animation-duration: 1ms !important;
2353
+ }
2354
+ }
2355
+
2356
+ .slidev-vue-transition-enter-active,
2357
+ .slidev-vue-transition-leave-active,
2358
+ .slidev-page-enter-active,
2359
+ .slidev-page-leave-active {
2360
+ transition-timing-function: var(--motion-ease-out) !important;
2361
+ }
2362
+
2363
+ /* ── Slidev's own chrome: the closed "go to slide" dialog ─────────────────────────────────
2364
+ UPSTREAM BUG, worked around here. Slidev hides #slidev-goto-dialog by parking it at a FIXED
2365
+ offset — `:class="showGotoDialog ? 'top-5' : '-top-20'"` (internals/Goto.vue) — so the closed
2366
+ dialog sits at top:-80px. That hides 80px. The dialog is not 80px tall: it also renders an
2367
+ autocomplete list under `v-if="result.length > 0"`, and in fuse.js 7.x `search('')` returns
2368
+ EVERY item, so with an empty input the list is every slide in the deck. MEASURED on this deck:
2369
+ 563px tall, 483px of it hanging into the canvas at opacity 1 with pointer-events auto — which
2370
+ is why it cannot be dismissed. There is nothing to dismiss; it was never open.
2371
+
2372
+ It scales with slide count, so it is invisible on a two-slide demo and unmissable on a real
2373
+ deck, and it is NOT dev-only: play.vue mounts <Controls> under `v-if="!isPrintMode"` with no
2374
+ __DEV__ guard, so a built deck ships it too. (Slidev here is 0.50.0; upstream is 52.x.)
2375
+
2376
+ Keyed off the class Slidev itself toggles, so the dialog still opens normally. */
2377
+ #slidev-goto-dialog:not(.top-5) {
2378
+ visibility: hidden;
2379
+ pointer-events: none;
2380
+ }
2381
+
2382
+ /* v-click reveals: a fade at the near-element bucket, no travel. */
2383
+ .slidev-vclick-target {
2384
+ transition:
2385
+ opacity var(--motion-base) var(--motion-ease-out),
2386
+ color var(--motion-base) var(--motion-ease-out);
2387
+ }
2388
+
2389
+ /* ── Reduced motion ───────────────────────────────────────────────────────────────────────
2390
+ Nothing here. The system-wide floor arrives with the ladder, in
2391
+ @humanforest/tokens/motion/motion.css (imported by styles/index.ts): it targets
2392
+ `*, *::before, *::after`, which already covers every slide.
2393
+
2394
+ This file used to carry its own copy, scoped to `.slidev-layout *` and `[class*='slidev-']`.
2395
+ That copy was not merely redundant, it was NARROWER — its allow-list omitted `box-shadow`, so a
2396
+ deck honouring reduced motion also lost shadow fades the product keeps. Deleting it is the point
2397
+ of importing the real thing. */
2398
+
2399
+ /*
2400
+ ── THE CHART TOOLTIP, THEMED AT THE ROOT BECAUSE IT IS MOUNTED ON THE BODY ────────────────────
2401
+
2402
+ ★★ A TOOLTIP INSIDE A SLIDE IS POSITIONED WRONG, and the error grows with distance. Slidev draws
2403
+ the 1920x1080 canvas by SCALING it — `matrix(0.579687, …)` at the size this deck was measured at —
2404
+ and Unovis positions its panel by writing screen pixels into `left`/`bottom`. Inside the scaled
2405
+ slide those pixels are then scaled AGAIN: measured on the Grouped bars slide, a pointer at x 700
2406
+ put the panel at x 264, several columns to its left. Near the origin it looks almost right, which
2407
+ is what makes it a late discovery rather than an obvious one.
2408
+
2409
+ `document.body` is the only ancestor the slide's transform does not reach, which is why the
2410
+ product mounts its tooltip there too — for a different reason (clamping inside a short chart), and
2411
+ arriving at the same place.
2412
+
2413
+ ⚠ THE COST, STATED: a panel in the body cannot inherit a slide's register, so a tooltip on a
2414
+ `deck-dark` slide is drawn in the light one. That is the right trade here — it reads as an overlay
2415
+ above the deck rather than a surface of it, and the alternative is a panel that points at the
2416
+ wrong column.
2417
+ */
2418
+ /*
2419
+ ★ AND IT IS SCALED BY HAND, because leaving the slide's transform also left its SIZE behind. The
2420
+ panel is themed at the deck's own rung — 36px type, room-scale padding — which is correct inside a
2421
+ 1920x1080 canvas and far too large sitting in the body beside a canvas drawn at 0.58. Slidev
2422
+ publishes the factor it is using as `--slidev-slide-scale`, so the panel takes the same one and
2423
+ ends up the size it would have been had it stayed inside the slide.
2424
+
2425
+ ★★ THE SCALE IS `zoom`, ON THE CONTENT, AND `transform` IS THE BUG IT REPLACES. Unovis places the
2426
+ panel from `this.element.offsetWidth/offsetHeight` — it subtracts the panel's own HEIGHT to sit it
2427
+ above the pointer, halves its WIDTH to centre it, and clamps both against the viewport. A
2428
+ transform paints a smaller box without changing either measurement, so the library reserved
2429
+ 397x204 for a panel that drew 230x118 and placed the difference as empty air: measured on the
2430
+ scatter slide, the panel floated 103px above the pointer — exactly `5 + 12 + 0.42 x 204` — and the
2431
+ clamp fired at x 191 for a box that was never 397 wide.
2432
+
2433
+ `zoom` is laid out rather than painted, so the element's own measurements come back at the size it
2434
+ draws (231x119, both) and the library's arithmetic is right again — including the flip to below
2435
+ the pointer near the top edge, and the clamp. It sits on the CONTENT because zoom on the panel
2436
+ itself would scale the `left`/`top` the library just wrote.
2437
+
2438
+ Radius is the one thing left outside the zoom, on the panel's own box, so it carries the factor by
2439
+ hand or a 24px corner arrives at full size on a panel drawn at 58%.
2440
+
2441
+ Matched on the class Unovis emits rather than on `body > div`, and on the deck's own root so it
2442
+ cannot reach a chart rendered anywhere else.
2443
+ */
2444
+ body > [class*='-tooltip'] > * {
2445
+ zoom: var(--slidev-slide-scale, 1);
2446
+ }
2447
+
2448
+ body > [class*='-tooltip'] {
2449
+
2450
+ /*
2451
+ ★★ DECLARED ON THE PANEL, NOT ON `:root`, AND THAT IS NOT TIDINESS. Unovis injects its own
2452
+ stylesheet at RUNTIME, after this one, and its defaults sit on `:root` too — so a declaration
2453
+ there ties on specificity and loses on order. Measured: the corner came out at Unovis' own 5px
2454
+ while this file asked for 24. Inside the chart the same variables worked, because ChartFrame is
2455
+ a nearer ancestor than `:root` and proximity settles an inherited value; moving the panel to the
2456
+ body took that advantage away. Set on the element itself, nothing inherited can reach it.
2457
+ */
2458
+ /*
2459
+ ★ THE FADE WAS OFF THE LADDER IN BOTH HALVES. Unovis' root sets `transition: opacity` with a
2460
+ 300ms default and no curve, so the panel ran the browser's `ease` — the same fault this file
2461
+ already corrects for Slidev's own slide transitions further up. The doctrine names this exact
2462
+ element: `fast` is "small / near elements — tooltips", and every curve in the system is
2463
+ ease-out. The shorthand in Unovis' emotion class is a single class, so declaring the curve here
2464
+ outranks it.
2465
+
2466
+ ⚠ ONE DURATION FOR BOTH DIRECTIONS, where the product's tooltip enters at `fast` and leaves at
2467
+ `instant`. Unovis carries the two states on emotion classes built WITHOUT a label — they render
2468
+ as bare hashes (`css-eec57t`, `css-38lglc`) that move on any version bump — so splitting the
2469
+ two would mean pinning a hash, which is the landmine removed from ScatterChart in e8a893c. The
2470
+ enter bucket is the one an audience sees; the exit runs 60ms long and is not worth that.
2471
+
2472
+ The fade itself survives `prefers-reduced-motion`: the floor's allow-list keeps `opacity`,
2473
+ because this is a colour change rather than travel. It reaches the panel at all only because
2474
+ that floor targets `*` — the deck-local copy this file deleted was scoped to `.slidev-layout *`,
2475
+ which a body-mounted panel is not inside.
2476
+ */
2477
+ --vis-tooltip-transition-duration: var(--motion-fast);
2478
+ transition-timing-function: var(--motion-ease-out);
2479
+
2480
+ /*
2481
+ ★ GLASS, THE SAME AS THE PRODUCT'S. packages/tokens/dataviz/unovis.css themes the product's
2482
+ chart tooltip as `color-mix(… --ui-bg-elevated 80%, transparent)` over `blur(12px)
2483
+ saturate(1.4)`, and this deck does not import that adapter — it only takes dataviz.css for the
2484
+ palette — so the treatment has to be restated here in deck tokens rather than inherited. It was
2485
+ a flat opaque surface until now, which is the one thing that made the panel read as belonging to
2486
+ a different system.
2487
+
2488
+ The blur is free here in a way it is not elsewhere: a tooltip only exists under a live pointer,
2489
+ so it never reaches an export, where a backdrop-filter over a WebGL canvas is the kind of thing
2490
+ that composites differently.
2491
+ */
2492
+ --vis-tooltip-background-color: color-mix(in oklab, var(--deck-surface-strong) 80%, transparent);
2493
+ --vis-tooltip-backdrop-filter: blur(12px) saturate(1.4);
2494
+ --vis-tooltip-border-color: var(--deck-border-strong);
2495
+ --vis-tooltip-text-color: var(--deck-fg);
2496
+ --vis-tooltip-border-radius: calc(var(--deck-radius) * 3 * var(--slidev-slide-scale, 1));
2497
+ --vis-tooltip-padding: 0;
2498
+ --vis-tooltip-title-color: var(--deck-fg);
2499
+ /* The separator is the panel's own edge, restated, so the register override reaches both.
2500
+ It sits inside the zoomed child, so the width divides the scale back out to land on the
2501
+ same device pixel the unzoomed panel border does. */
2502
+ --vis-tooltip-title-border-bottom: calc(1px / var(--slidev-slide-scale, 1)) solid
2503
+ var(--vis-tooltip-border-color);
2504
+ --vis-tooltip-title-font-size: var(--type-deck-label-size);
2505
+ --vis-tooltip-title-font-weight: var(--type-deck-label-weight);
2506
+ --vis-tooltip-title-padding: var(--deck-space-2) var(--deck-space-2) calc(var(--deck-space-2) / 2);
2507
+ --vis-tooltip-title-margin: 0 0 calc(var(--deck-space-2) / 2) 0;
2508
+ --vis-tooltip-title-text-transform: none;
2509
+ --vis-tooltip-label-color: var(--deck-fg-muted);
2510
+ --vis-tooltip-label-font-size: var(--type-deck-caption-size);
2511
+ --vis-tooltip-value-color: var(--deck-fg);
2512
+ --vis-tooltip-value-font-size: var(--type-deck-caption-size);
2513
+ --vis-tooltip-value-font-weight: var(--type-deck-label-weight);
2514
+ /* The product draws an 8px dot beside 14px type; this keeps that ratio against the deck's 36px,
2515
+ which the 8px default did not — it read as a speck. */
2516
+ --vis-tooltip-dot-size: calc(var(--type-deck-caption-size) * 4 / 7);
2517
+ --vis-tooltip-content-gap: calc(var(--deck-space-2) / 2) var(--deck-space-2);
2518
+ --vis-tooltip-content-padding: 0 var(--deck-space-2) var(--deck-space-2);
2519
+ }
2520
+
2521
+ /*
2522
+ ★★ AND IT FOLLOWS THE SLIDE'S REGISTER, WHICH A BODY-MOUNTED PANEL CANNOT DO ON ITS OWN. The
2523
+ block above notes the cost of mounting here: the tooltip is outside `.slidev-layout`, so a
2524
+ `deck-dark` slide used to be read through a light panel — near-black ink on cream, floating over a
2525
+ forest-950 ground.
2526
+
2527
+ ★ THE VISIBLE SLIDE IS SELECTABLE, which is what makes this CSS rather than a watcher. Slidev
2528
+ mounts every slide and hides all but one with an INLINE `display: none`, so
2529
+ `:not([style*="display: none"])` names the one on screen — measured, 213 hidden and 1 not.
2530
+
2531
+ ⚠ RE-POINTING THE DECK TOKENS, NOT THE --vis-* ONES, and the derived three have to be restated by
2532
+ hand. `--deck-surface-strong`, `--deck-border` and `--deck-border-strong` are color-mix results
2533
+ DECLARED at `:root, .dark, .deck-dark` — a custom property computes where it is declared, so
2534
+ setting `--deck-bg` here alone would leave them at their light values. Same trap deck.css records
2535
+ for a dark card. The mixes below are that file's own, at the same percentages.
2536
+
2537
+ Everything downstream then resolves for free: the `--vis-tooltip-*` declarations above read these
2538
+ on the SAME element, so they take whichever set is in force.
2539
+ */
2540
+ body:has(.slidev-page:not([style*="display: none"]) .slidev-layout.deck-dark) > [class*='-tooltip'] {
2541
+ --deck-bg: var(--forest-950);
2542
+ --deck-fg: var(--neutral-0);
2543
+ --deck-fg-muted: var(--neutral-400);
2544
+ --deck-surface-strong: color-mix(in oklab, var(--forest-950), var(--neutral-0) 10%);
2545
+ --deck-border: color-mix(in oklab, var(--forest-950), var(--neutral-0) 14%);
2546
+ --deck-border-strong: color-mix(in oklab, var(--forest-950), var(--neutral-0) 26%);
2547
+ }
2548
+
2549
+
2550
+
2551
+ /* ── `.deck-bare` — a chart slide stripped to its fills ───────────────────────────────────────
2552
+ A SLIDE CLASS, the same shape as `.deck-pairs`: the slide asks for it and every chart under it
2553
+ follows without knowing the class exists.
2554
+
2555
+ ★★ IT IS HERE RATHER THAN AS A PROP ON FOUR COMPONENTS, and the reason is what it is FOR. This
2556
+ is one specimen slide whose whole subject is whether the palettes re-point on the dark register —
2557
+ a colour test, where a tick label, a row label and a printed total are three text elements
2558
+ competing with the only thing being tested. That is a presentation choice about one slide, not a
2559
+ capability a chart should advertise: `axes: false` on LineChart would be a chart with no scale,
2560
+ which is a worse chart, and every call site would then have to be told not to use it.
2561
+
2562
+ `display: none` rather than `visibility` or opacity, deliberately — the row has to COLLAPSE, or
2563
+ the bars keep a label gutter with nothing in it and the marks stay where the text put them. It
2564
+ also takes these elements out of the audit's reach, which is correct: an element that is not
2565
+ painted is not ink, and scoring a hidden tick against a ground it never touches would be noise.
2566
+
2567
+ Legends, axis titles and the donut's central label are NOT here — those come off through each
2568
+ component's own props (`legend: false`, no `xLabel`, no `centralLabel`), which is where a real
2569
+ switch already exists. Only the three with no prop are swept. */
2570
+ /* The axes are NOT here — they come off through `axes: false` on the components themselves, and
2571
+ they have to: Unovis reserves the axis band whether or not it paints, so hiding one in CSS left
2572
+ the plot inset by a tick row and a label column that were no longer drawn. Only the two with no
2573
+ prop are swept. */
2574
+ .deck-bare [class*='-central-label'],
2575
+ .deck-bare .bars-label,
2576
+ .deck-bare .bars-value {
2577
+ display: none;
2578
+ }
2579
+
2580
+ /* ★★ AND THE BAR TRACK HAS TO BE TOLD ITS WIDTH ONCE THE TEXT IS GONE. `.bars` is
2581
+ `auto minmax(0, 1fr) auto`, so the track is a fraction of whatever width the grid has — and the
2582
+ grid had that width because the label and the value were IN it. Remove both and it shrink-wraps
2583
+ to nothing, `1fr` resolves to 0, and every bar draws at the 2px `min-width` its fill declares.
2584
+ Five vertical ticks down the left margin, which is what the first version of this rule shipped. */
2585
+ /* ★★ `.slidev-layout` IS A SPECIFICITY COUNTERWEIGHT, NOT A SCOPE. Bars.vue sets
2586
+ `grid-template-columns` in a SCOPED block, which compiles to `.bars[data-v-…]` — (0,2,0), the
2587
+ same as `.deck-bare .bars`, and the component's stylesheet is injected after this file, so the
2588
+ tie went to the component and the columns never changed. Three class-level selectors settle it.
2589
+ (The `display: none` rules above need no such help: nothing declares `display` on those. */
2590
+ .slidev-layout.deck-bare .bars {
2591
+ width: 100%;
2592
+ /* ★★ AND THE COLUMNS HAVE TO BE RE-DECLARED, not just the width. `.bars` is
2593
+ `auto minmax(0, 1fr) auto` and each row is a `subgrid` spanning all three, so the track is the
2594
+ MIDDLE column — middle because the label holds the first. Hide the label and the row has one
2595
+ child left, which lands in column 1: an `auto` track sizing to a bar with no intrinsic width.
2596
+ Measured: `.bars` at its full 1728px with a 1px track, so every bar drew at the 2px `min-width`
2597
+ its fill declares — five vertical ticks down the left margin. One `1fr` column is the fix; the
2598
+ subgrid rows adopt it and the bar is the only thing left to fill it. */
2599
+ grid-template-columns: minmax(0, 1fr);
2600
+ }
2601
+
2602
+ /* No hover, either. A tooltip is a reading aid, and this slide has nothing to read — the marks
2603
+ carry no labels and no scale, so a panel naming a value would be the only text on the slide and
2604
+ would be describing a chart the slide has deliberately stripped of its numbers. Killed at the
2605
+ POINTER rather than per component: it takes the crosshair with it (which would otherwise draw a
2606
+ rule and two circles over a bare line), and it needs no prop on four components. */
2607
+ .deck-bare .deck-chart,
2608
+ .deck-bare .bars,
2609
+ .deck-bare .deck-track {
2610
+ pointer-events: none;
2611
+ }