@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,944 @@
1
+ <script setup>
2
+ /*
3
+ The horizontal counterpart of `split` — a hero BAND across the top, the title demoted to a strip
4
+ below it. Everything else in this theme is either one region or a left/right split; nothing
5
+ divided top from bottom, and that was the single biggest gap the reference deck turned up.
6
+
7
+ ★ WHY IT EXISTS, from the source rather than from taste. The 65-page Forest template catalogue
8
+ (../../../.superpowers/sdd/2026-08-21-deck-layout-system/template-archetypes.md) uses this
9
+ arrangement NINE times — tied for the most-used interior structure in the whole document — for
10
+ numbered cards, icon cards, phone mockups, a data table, a milestone timeline and pricing tiers.
11
+ Its own §3 ranks it "the biggest gap", and its §5 records the rule that makes it a distinct layout
12
+ rather than a variant: "Two-band pages always subordinate the title. The content that matters
13
+ occupies the top ~65% colour band; title+body is pushed into a short bottom cream strip — the
14
+ reverse of normal top-down reading order, but consistent across all 9 instances."
15
+
16
+ That inversion is the whole point. On a `split` the title leads and the figure supports it; here
17
+ the hero content leads and the title captions it. A slide that wants the normal order already has
18
+ two layouts for it.
19
+
20
+ THE VOCABULARY IS SPLIT'S, deliberately — `ratio` content-relative, `frame` photo/panel/none, an
21
+ `eyebrow` — so an author who knows one knows the other, and the two read as one system rather than
22
+ as two people's ideas about slides. Only `flip` is missing, and only because a full-width band has
23
+ no side to sit on: its outer top corner IS the slide's, which is where the running mark already is.
24
+
25
+ ★★ WHY THERE IS NO ACCENT OR DARK BAND, and it is a MEASUREMENT, not a preference. The catalogue's
26
+ band is always the same mint whatever it holds ("never a different accent per content type", §5),
27
+ and the first pass here added the deck's two loud registers anyway on the grounds that `divider`
28
+ already proves both. Rendered, that is a defect: a full-width band rises to --deck-inset-chrome, so
29
+ the running chrome's PAGE PILL — which the notch cannot make room for, because its width is a
30
+ section name and therefore unbounded — lands ON the band. Measured pill fill (--deck-bg-vivid-dark,
31
+ forest-700) against each candidate ground:
32
+
33
+ soft mint, forest-100 5.45:1 ✓
34
+ subtle --deck-bg-subtle 5.28:1 ✓ (split's own panel fill, so the two can match)
35
+ accent forest-700 1.00:1 ✗ the pill is the ground; it disappears entirely
36
+ dark forest-950 2.40:1 ✗ under the 3:1 non-text floor
37
+
38
+ The chrome cannot adapt: slide-top.vue is forbidden from naming a layout (there is a test — see
39
+ frontmatter.static.test.ts, "names no layout, in any form" — because chrome that varies by layout
40
+ is a defect this project has already paid for). So the two loud grounds are not expressible here.
41
+ A slide that wants a full-field green or black cut has `divider`, which owns the whole canvas and
42
+ ships `chrome: false` on every occurrence in every deck.
43
+
44
+ ★★ `frame: photo` HAD the same risk in an unmeasurable form, and this comment used to answer it by
45
+ asking the author to remember something. A photograph's top-left corner can be any colour, so no
46
+ contrast row above can be written for it — and the notch only ever protected the MARK end, because
47
+ the chrome USED TO mirror, sending the pills to the text side, and a full-width band has none. Rendered
48
+ on the retired deck-layouts 28 that was a live defect, not a latent one: the mark sits in a clean cut and "28"
49
+ and "LAYOUTS" sit directly on a forest photograph.
50
+
51
+ A photo band therefore STANDS CLEAR of the running chrome rather than cutting into it — it starts
52
+ seated in a cut of its own — useNotchCuts measures every chrome element on the band, so both ends sit
53
+ on the slide's own cream ground and nothing depends on what is in the picture. The cost is stated
54
+ rather than discovered: those slides lose the mark's seat and 108px of band height. See
55
+ ../notch.js's retirement note for the full history; EVERY band notches now, because its
56
+ ground is one of the two tones measured in the table above.
57
+ */
58
+
59
+ // Same clamp discipline as split.vue and bleed.vue: Vue strips prop validators from a production
60
+ // build, so an out-of-range value (an AI's `ratio: '5:7'`, a typo'd background) must not reach the
61
+ // template raw and mint a class name with no matching CSS rule — an unstyled slide, invisible until
62
+ // something overflows. These lookups fall back to the documented default independent of the
63
+ // validators below.
64
+ //
65
+ // The band is the FIGURE and the strip is the CONTENT, so a `ratio` reads exactly as it does on a
66
+ // split: content first, figure second. '1:2' therefore gives the band two thirds of the usable
67
+ // height, which is the catalogue's own ~65%. Split's six are all here so nothing an author already
68
+ // knows is invalid, plus '1:3' — split has no such rung (its ladder is lopsided towards content:
69
+ // 3:1 exists, 1:3 does not) and a band that dominates harder than 2/3 is the one thing this layout
70
+ // specifically needs it for.
71
+ //
72
+ // ★★ AND '0:1' — THE BAND WITH NO STRIP, which is the panel filling the field. A ratio rather than a
73
+ // second layout because that is what it is: content 0, figure everything.
74
+ //
75
+ // ⚠ IT WAS SPELLED '1:0' AND THAT WAS BACKWARDS. The convention two paragraphs up is content first,
76
+ // figure second, and the sentence above defends the rung as "content 0, figure everything" — which
77
+ // is 0:1. The old name said the strip took everything and the band nothing, the exact inverse of
78
+ // what the rung does. Renamed while it was one release old. The band keeps every
79
+ // treatment it has at any other rung — the panel frame, the named background, `panelDark`, the card
80
+ // corner, the media inset — and, the point of it, THE NOTCH. `bleed` is the other way to make a band
81
+ // big and it gives the notch up, because at the canvas edge there is no ring of deck ground left to
82
+ // cut a seat out of. This rung keeps the ring: 36px all the way round, half margin and half cut-out,
83
+ // mark seated top-right and section pill bottom-left.
84
+ const RATIO_ROWS = {
85
+ '1:1': 'is-1-1',
86
+ '3:2': 'is-3-2',
87
+ '2:3': 'is-2-3',
88
+ '2:1': 'is-2-1',
89
+ '1:2': 'is-1-2',
90
+ '3:1': 'is-3-1',
91
+ '1:3': 'is-1-3',
92
+ '0:1': 'is-0-1',
93
+ };
94
+
95
+ // Split's own three, same meanings: a photo frame, a tinted panel, or nothing (the author brings
96
+ // their own ground and owns their own corner — including the notch, which is why `none` is not in
97
+ // PANEL_MEDIA).
98
+ /*
99
+ Split's `panelAlign` without its two `title-*` values. Those position a HEADING that sits beside
100
+ the panel; a stack's heading is in the strip BELOW the band, so there is nothing beside the band
101
+ for them to act on and they would be `start` and `center` under another name. Same subsetting the
102
+ `frame` and `ratio` docs above state for their own omissions.
103
+ */
104
+ const PANEL_ALIGN_CLASS = {
105
+ start: 'is-panel-start',
106
+ center: 'is-panel-center',
107
+ end: 'is-panel-end',
108
+ };
109
+
110
+ const FRAME_CLASS = {
111
+ photo: 'is-photo',
112
+ panel: 'is-panel',
113
+ code: 'is-code',
114
+ content: 'is-content',
115
+ };
116
+
117
+ // Two grounds, both light, both measured legible under the running chrome — see the ★★ note above
118
+ // for the two that were cut and the numbers that cut them.
119
+ /*
120
+ ★★ THE TONE VALUES ARE THE PANEL GROUNDS, AND THEY ARE HUES RATHER THAN TREATMENTS.
121
+
122
+ This read `soft | subtle | warm | vivid` — two treatment words and two hue words in one enum, and
123
+ half of them reaching past the roles into the raw ramp (`--warm-100`, `--forest-300`) so they did
124
+ not follow the register at all. A panel on a dark slide kept its light fill under light ink.
125
+
126
+ Each value is now a NAMED GROUND from the engine (see PANEL in scripts/deck.ts), which publishes a
127
+ fill AND its ink for BOTH registers. So the ink is paired rather than inherited, and every value
128
+ moves with the slide:
129
+
130
+ warm peach on light, bark on dark accent the quiet green field
131
+ vivid the loud green, MIRRORED neutral no hue at all
132
+
133
+ The renames are `soft` → `accent` (same forest-100 on light) and `subtle` → `neutral`. `warm` and
134
+ `vivid` keep their names; warm deepens one step (warm-100 → warm-200) to be a ground rather than a
135
+ tint of the page.
136
+
137
+ ★★ AND `dark` IS NOT ONE OF THEM, BECAUSE IT IS NOT A HUE — it is the OTHER axis. `background` says
138
+ which ground; `panelDark` says which register that ground is read in. They compose: every one of
139
+ the four has a light half and a dark half already, so a dark panel is `background` unchanged with the
140
+ register flipped, and the enum stays four values rather than five with one of them meaning
141
+ something different from the rest.
142
+ */
143
+ const TONE_CLASS = {
144
+ warm: 'bg-warm',
145
+ accent: 'bg-accent',
146
+ vivid: 'bg-vivid',
147
+ neutral: 'bg-neutral',
148
+ };
149
+
150
+ import { computed, inject, ref } from 'vue';
151
+ import { useNotchCuts } from '../useNotchCuts.js';
152
+ import { injectionCurrentPage, injectionFrontmatter } from '@slidev/client/constants.ts';
153
+ import deckTokens from '@humanforest/tokens/deck/deck.json';
154
+ import { chromeShows, coBrandFitted, hasNotch, notchShows } from '../notch.js';
155
+ import { assetUrl } from '../assetUrl.js';
156
+
157
+ const props = defineProps({
158
+ /** The strip's share of the slide against the band's — content-relative, exactly like `split`'s. The default gives the band two thirds, which is the proportion the reference template uses on every one of its nine two-band pages. */
159
+ ratio: {
160
+ type: String,
161
+ default: '1:2',
162
+ // Inlined rather than reading RATIO_ROWS: Vue hoists defineProps() out of setup() at build
163
+ // time, so a validator cannot close over a script-setup-local const — it compiles in dev and
164
+ // breaks the production build. The template clamps through RATIO_ROWS anyway, which is what
165
+ // actually protects a real deck.
166
+ validator: (r) => ['1:1', '3:2', '2:3', '2:1', '1:2', '3:1', '1:3', '0:1'].includes(r),
167
+ },
168
+ /**
169
+ * A CODE SURFACE — the panel IS the block, rather than a mat with a block sitting on it.
170
+ *
171
+ * ★★ IT EXISTS TO AVOID TWO NESTED WELLS. On `frame: panel` a code block draws its own ground and
172
+ * edge inside the panel's, so the slide is a card on a card — on the dark register a #191617 well
173
+ * inside a neutral-800 panel, 1.10:1 apart, which is two surfaces an audience cannot separate and
174
+ * one of them doing no work.
175
+ *
176
+ * Here the panel takes `--slidev-code-background` and the block inside it goes flat: no fill, no
177
+ * edge, no padding of its own. The magic-move container owns its surface the same way; this is
178
+ * that rule with a name.
179
+ *
180
+ * ★ AN EXPLICIT VALUE, NOT A `:has()` ON `panel`. A panel that silently changed ground because it
181
+ * happened to contain a code block is exactly the shape of `frame: none` — a positive behaviour
182
+ * named as an absence — which this layout already paid for once. It also makes the conflict
183
+ * visible: `background` names a tinted ground and a code frame has none, so the two do not compose, and
184
+ * an author reading `frame: code` can see that where a silent override would not tell them.
185
+ */
186
+ /** How the band is FRAMED: clipped as a photo, matted in a tinted panel, or not a figure at all — the author's own full-width block, with no ground and no notch. Split's `frame` without `figure`, which a full-width band has no use for. */
187
+ frame: { type: String, default: 'panel', validator: (f) => ['photo', 'panel', 'content', 'code'].includes(f) },
188
+ /**
189
+ * Take the band to the CANVAS EDGE instead of the 36px media inset — same frame, same fill, same
190
+ * background, same content, no ring of ground around it.
191
+ *
192
+ * A MODIFIER rather than a fourth `frame`, and the same one split takes: a photo band and a tinted
193
+ * panel band can each want the edge and each keeps its own treatment there. Inert on
194
+ * `frame: content`, which is the author's own block and keeps the text inset it already has.
195
+ *
196
+ * `inset` keeps the ring of ground around the band; `bleed` takes it to the canvas.
197
+ *
198
+ * ★ NAMED `edge`, NOT `bleed`, BECAUSE `bleed` IS ALSO A LAYOUT. One word cannot mean both "this
199
+ * slide is a full-canvas image" and "this figure reaches the edge" in the same frontmatter. The
200
+ * key carries the axis and the value carries the state, so `layout: bleed` keeps sole ownership
201
+ * of the word as a layout and `edge: bleed` is unambiguous beside it.
202
+ *
203
+ * `inset` is the deck's own word — --deck-inset-text, --deck-inset-media and --deck-inset-chrome
204
+ * are the three it is measured in — so naming the default costs an author no new concept. It is
205
+ * NOT called "notched": `notch: false` is a separate key that keeps the inset and drops only the
206
+ * cut, so a slide can be inset without being notched.
207
+ *
208
+ * ★ `bleed` SUPPRESSES THE NOTCH. The cut seats the running chrome in the ring of ground around
209
+ * the band; at inset 0 there is no ring. A band is full width, so BOTH ends of the chrome then
210
+ * sit on it — pair this with `chrome: false`, or use the `bleed` layout and its scrim.
211
+ */
212
+ /*
213
+ * ★★ `spill` IS `bleed` PLUS ONE MORE STEP — see split.vue's own note, which this shares. The
214
+ * band's box reaches the canvas either way; on `spill` what is INSIDE it is sized past the box and
215
+ * cropped by the slide. On this layout the crop is the BOTTOM edge, because the band is a strip
216
+ * across the frame: a browser window whose foot is below the canvas, three device frames standing
217
+ * on the bottom of the slide.
218
+ */
219
+ edge: { type: String, default: 'inset', validator: (e) => ['inset', 'bleed', 'spill'].includes(e) },
220
+ // ⚠ THIS DESCRIBED 'soft' AND 'subtle', WHICH THE ENUM NO LONGER HAS, and it ships: a prop
221
+ // description lands in manifest.json and is what an AI consumer reads, so a stale one is a stale
222
+ // contract rather than a stale comment. The band took loose fills once — forest-100 ('soft', the
223
+ // reference template's own field) and --deck-bg-subtle — and it now takes the same four names
224
+ // split.vue's panel does.
225
+ //
226
+ // The reason 'soft' went is worth keeping: its contrast passed, but rendered against this deck's
227
+ // own --warm-50 ground it is a COOL field on a WARM one, a clash no ratio can express and one the
228
+ // designer rejected on sight. `warm` is the default for that reason.
229
+ /** The tinted band's register, for `frame: panel` only — one of the four named backgrounds, each a fill and the ink published with it. */
230
+ background: { type: String, default: 'warm', validator: (t) => ['warm', 'accent', 'vivid', 'neutral'].includes(t) },
231
+ /**
232
+ * Run the PANEL in the dark register, leaving the rest of the slide light.
233
+ *
234
+ * ★★ A SECOND AXIS, NOT A FIFTH `background`. The four tones are HUES and each already publishes both
235
+ * halves (see PANEL in scripts/deck.ts): warm is peach on light and bark on dark, accent is the
236
+ * quiet green field either way. So "dark" is not a ground to pick, it is which half of the ground
237
+ * you picked gets used — and folding it into `background` would have made one value mean a register
238
+ * while the other four meant colours.
239
+ *
240
+ * It works by putting `.deck-dark` on the panel element itself. That class is a plain class in
241
+ * packages/tokens/deck/deck.css — bound alongside `.dark`, not scoped to a slide root — so every
242
+ * `--deck-*` role is re-declared AT the panel and everything inside inherits the dark values:
243
+ * prose, chips, alerts, charts, anything reading the roles. Nothing has to opt in a second time.
244
+ *
245
+ * ★ SCOPED TO THE FRAMES THAT BRING A FILL — `panel` and `code` — exactly as `background` is scoped to
246
+ * `panel`, and for a stronger reason. Those two paint a ground, so flipping the register repaints
247
+ * it under the ink. `photo`, `figure` and `content` bring no fill at all: the class would flip the
248
+ * INK to the dark register's near-white and leave it on the slide's own light ground.
249
+ *
250
+ * On `code` it is the whole point of a dark code surface: the ground is --slidev-code-background,
251
+ * which is register-aware, so the panel becomes the DARK well (--neutral-900) rather than a dark
252
+ * tint of the light one.
253
+ *
254
+ *
255
+ * ★★ AND THE RUNNING CHROME FOLLOWS IT, PER END. A bleeding dark panel leaves no ring of ground
256
+ * for the chrome to sit on, so the row lands directly on it and half the row is then over a dark
257
+ * ground while the other half is over a light one. Measured before that was answered: the page
258
+ * marker painted the LIGHT pill (forest-700) on forest-900, 1.09:1. `darkChromeParts()` in
259
+ * ../notch.js decides which end flips, from this same frontmatter, so the layout and the chrome
260
+ * cannot disagree about where the dark half is.
261
+ * The whole slide going dark is a different thing and has its own spelling: `dark: true`, which
262
+ * every layout that can carry a register now takes — `default`, `bleed`, `divider`, `split` and
263
+ * `stack`. `class: deck-dark` still works and is what the prop writes.
264
+ */
265
+ panelDark: { type: Boolean, default: false },
266
+ /**
267
+ * Draw this slide on the dark register. Writes `deck-dark` on the layout root — the same class and
268
+ * the same element split takes, said as a prop the layout documents rather than a raw class name
269
+ * an author had to know.
270
+ *
271
+ * Distinct from `panelDark`, which flips the BAND alone and leaves the strip below it on the light
272
+ * ground. This flips the whole slide, strip included, and the running chrome follows the slide
273
+ * rather than an end of it.
274
+ */
275
+ dark: { type: Boolean, default: false },
276
+ /**
277
+ * Mirror the layout: by default the band leads and the strip captions it from below; flipped, the
278
+ * strip is on top and the band sits under it.
279
+ *
280
+ * ★★ THE SAME KEY AND THE SAME DEFAULT AS `split`, which is the whole reason it is a Boolean. The
281
+ * two layouts invert deliberately, so any value naming a POSITION comes out opposite between them
282
+ * — content-first on split is content-last here. `false` means "this layout's own arrangement" on
283
+ * both, and that is the only thing they genuinely share.
284
+ *
285
+ * ⚠ IT MOVES THE CHROME, NOT JUST THE BOX. A band is full width, so whichever end of the slide it
286
+ * occupies is the end whose running chrome lands on it: unflipped that is the top row, and the
287
+ * notch cuts a seat for the mark; flipped it is the bottom row, and the seat is cut for the page
288
+ * marker instead. ../notch.js reads this same key so the layout and the chrome cannot disagree.
289
+ */
290
+ flip: { type: Boolean, default: false },
291
+ /**
292
+ * Where the band's content sits on the band's own vertical axis when it is shorter than the band.
293
+ * `center` is the default and what the band did before this was a prop.
294
+ *
295
+ * Split's two `title-*` values are deliberately absent — they align a heading beside the panel,
296
+ * and a stack's heading is in the strip below the band.
297
+ */
298
+ panelAlign: { type: String, default: 'center', validator: (a) => ['start', 'center', 'end'].includes(a) },
299
+ /** The figure's image URL. This is Slidev's own frontmatter key, used as Slidev means it: `extractImagesUsage()` harvests it and emits a `<link rel=preload as=image>`, so the URL belongs here rather than in an `<img>` a slide writes by hand. Slot content still wins when a slide provides its own figure. */
300
+ image: { type: String, default: '' },
301
+ /** Alt text — for `image` when there is one, and the label the `<Placeholder>` carries when there is not. */
302
+ alt: { type: String, default: '' },
303
+ /** Kicker above the heading, in the strip. */
304
+ eyebrow: { type: String, default: '' },
305
+ });
306
+
307
+ /*
308
+ The notch, and the two frontmatter keys that reach it — the same three questions split.vue asks,
309
+ through the same shared module, so the two layouts cannot disagree about whether this slide has a
310
+ cut or how wide it is. See ../notch.js.
311
+
312
+ Read the same two injections slide-top.vue reads, and for the same reasons documented there:
313
+ `injectionFrontmatter` is the RAW object (not a ref), and `injectionCurrentPage` — never
314
+ useNav().currentPage — is the only page number that is correct on the param-less /print and
315
+ /overview routes the PDF export renders through.
316
+ */
317
+ const frontmatter = inject(injectionFrontmatter, {});
318
+ const currentPage = inject(injectionCurrentPage, computed(() => 1));
319
+ /*
320
+ ★★ EVERY BAND NOTCHES, INCLUDING A PHOTOGRAPH — and it did not always.
321
+
322
+ task-26 made a full-width photo band stand CLEAR of the chrome instead of cutting into it, because
323
+ a band spans the whole canvas so both ends of the chrome land on it, and the cut of the day only
324
+ seated the mark: the page marker and the section pill sat directly on a photograph whose corner
325
+ can be any colour.
326
+
327
+ useNotchCuts.js retired that. It measures EVERY running-chrome element on the band and cuts the
328
+ corner each one sits in, so both ends are seated and nothing depends on what is in the picture.
329
+ MEASURED on apps/deck's `frame: photo` band: the clip opens at `M 234.508 0` — the
330
+ top-left cut for the section pill, mirroring the top-right cut for the mark — and the deck audit
331
+ reports 0 text elements painted under an opaque media surface across the whole deck.
332
+
333
+ See ../notch.js's retirement note for the full history.
334
+ */
335
+ /* `&& props.edge !== 'bleed'` here rather than inside hasNotch(), for the reason split.vue gives at
336
+ line: bleeding is a fact about this slide's band, not about the frame KIND, and that function's
337
+ arity is pinned by a test after it was once given an argument too many. */
338
+ const notched = computed(() =>
339
+ props.edge === 'inset'
340
+ &&
341
+ hasNotch(props.frame, chromeShows(frontmatter, currentPage.value), notchShows(frontmatter)),
342
+ );
343
+
344
+ // The cut, measured — see ../useNotchCuts.js. A full-width band is the case that forced this: it
345
+ // runs under BOTH top corners, so the mark and the section pill each land on it, and the pill's
346
+ // width is a section name that no token could ever have stated.
347
+ const bandEl = ref(null);
348
+ const { clip: notchClip } = useNotchCuts(bandEl, { enabled: () => notched.value });
349
+ const companion = computed(() => coBrandFitted(frontmatter, currentPage.value, deckTokens));
350
+ const bandStyle = computed(() =>
351
+ notched.value && companion.value
352
+ ? { '--deck-notch-occupant-w': `${companion.value.occupantPx}px` }
353
+ : undefined,
354
+ );
355
+ </script>
356
+
357
+ <template>
358
+ <div
359
+ class="slidev-layout stack h-full flex flex-col"
360
+ :class="[
361
+ RATIO_ROWS[ratio] ?? 'is-1-2',
362
+ FRAME_CLASS[frame] ?? 'is-panel',
363
+ edge === 'bleed' || edge === 'spill' ? 'is-bleed' : '',
364
+ edge === 'spill' ? 'is-spill' : '',
365
+ dark ? 'deck-dark' : '',
366
+ flip ? 'is-flipped' : '',
367
+ ]"
368
+ >
369
+ <div class="stack-grid">
370
+ <!--
371
+ ⚠ `deck-notch` WITHOUT `has-notch`, and that asymmetry is deliberate rather than an
372
+ oversight. `has-notch` is split.vue's own marker, and scripts/deckAudit.ts counts it: on any
373
+ non-split layout a `.has-notch` element is reported as "a notch cut with no panel to seat
374
+ it", so wearing the class here would fail the audit on every stack slide. The CUT is the
375
+ shared `deck-notch` rule in styles/base.css either way.
376
+
377
+ ★★ THE AUDIT HAS SINCE LEARNED ABOUT THIS LAYOUT, and the gap this note used to describe was
378
+ real for as long as it stood: because `has-notch` is 0 on every stack slide by design, the
379
+ only notch assertion a stack could reach was satisfied by construction, and all 15 stack
380
+ slides in the specimen went unmeasured while the summary read "0 notch-seating failure(s)".
381
+ deckAudit.ts now counts `.is-notched` as this layout's own marker and seats the band at BOTH
382
+ ends — mark right, section pill left, because a band is full width. Each marker is a defect
383
+ on the other layout, so the two cannot be crossed silently.
384
+
385
+ The background class rides on the BAND, not the root: it is the band's own ground, and the strip
386
+ below is cream by definition — the archetype is "colour band, cream title strip".
387
+ -->
388
+ <div
389
+ ref="bandEl"
390
+ class="stack-band"
391
+ :class="[
392
+ FRAME_CLASS[frame] ?? 'is-panel',
393
+ TONE_CLASS[background] ?? 'bg-warm',
394
+ PANEL_ALIGN_CLASS[panelAlign] ?? 'is-panel-center',
395
+ panelDark && (frame === 'panel' || frame === 'code') ? 'deck-dark' : '',
396
+ // A panel band paints its own fill, so markdown inside it needs the same bullet and
397
+ // marker protection every other own-ground surface gets — base.css keys those off this
398
+ // one class. Missing since the layout shipped, and invisible until a full-field band made
399
+ // a list on one the obvious thing to write: the bullet painted --deck-accent, which is
400
+ // the one green mark on a slide whose ink is the panel's.
401
+ // ⚠ NOT WHILE SPILLING. A spilling band is a STAGE, not a surface — the thing inside it
402
+ // leaves the band on one edge, so a fill behind it draws a box the object visibly ignores.
403
+ // `deck-own-ground` is what re-points bullets and markers to a panel's own ink, and with no
404
+ // fill there is no own ground to re-point to.
405
+ frame === 'panel' && edge !== 'spill' ? 'deck-own-ground' : '',
406
+ edge === 'bleed' || edge === 'spill' ? 'is-bleed' : '',
407
+ edge === 'spill' ? 'is-spill' : '',
408
+ notched ? 'deck-notch is-notched' : '',
409
+ ]"
410
+ data-notch-corner="right"
411
+ :style="[bandStyle, notchClip ? { clipPath: notchClip } : null]"
412
+ >
413
+ <slot name="band">
414
+ <img v-if="frame === 'photo' && image" :src="assetUrl(image)" :alt="alt">
415
+ <Placeholder v-else-if="frame === 'photo'" :label="alt || 'Image'" ratio="16:9" />
416
+ </slot>
417
+ </div>
418
+ <div class="stack-strip">
419
+ <p v-if="eyebrow" class="deck-eyebrow">{{ eyebrow }}</p>
420
+ <slot />
421
+ </div>
422
+ </div>
423
+ </div>
424
+ </template>
425
+
426
+ <style scoped>
427
+ /*
428
+ VERTICAL is owned here: the layout's top and bottom bounds are a function of whether there is a
429
+ notch, which `.slidev-layout`'s single uniform padding cannot express. Horizontally nothing
430
+ changes: padding-left/-right stay --deck-inset-text (base.css), so the strip gets the measure
431
+ every other text box in the theme gets and the band claws back the 54px difference to reach
432
+ --deck-inset-media.
433
+
434
+ ★ THIS USED TO SAY "exactly as split.vue owns its own", AND SPLIT NO LONGER DOES. Split zeroed its
435
+ root so a `calc(100% / 3)` anchor had the canvas to resolve against; that anchor is a pixel now
436
+ and split is an ordinary padded box. The two layouts arrived at the same reset for different
437
+ reasons, and only stack's reason survives — its band is full-bleed and has to reach the media
438
+ inset at the TOP as well, unconditionally, which a padded root cannot express without a negative
439
+ margin on every band rather than only a notched one.
440
+
441
+ The known cost is the one split just paid: a root with no vertical padding makes every region
442
+ inside responsible for its own edge clearance, and nothing enforces that. `.stack-strip` below
443
+ carries such a reserve. It is correct today because stack has no third region beneath the strip
444
+ for it to collide with — if one is ever added, that reserve is the first thing to re-derive.
445
+ */
446
+ .slidev-layout.stack {
447
+ padding-top: 0;
448
+ padding-bottom: 0;
449
+ }
450
+ .stack-grid {
451
+ flex: 1;
452
+ display: grid;
453
+ min-height: 0; /* lets the rows shrink instead of overflowing the slide */
454
+ /*
455
+ THE GAP between band and strip, and why it is NOT split's conditional gutter.
456
+
457
+ Split's rule is Jose's: "text one side, image other side: no gutter... both sides text or both
458
+ sides image: gutter 36px." A mixed HORIZONTAL pair needs no gutter because the text column
459
+ already carries --deck-inset-text on its own outer edge and a paragraph's ragged right supplies
460
+ the air on the inner one. Neither is true on the VERTICAL axis: a heading's box has no ragged
461
+ top, and a band's hard bottom edge would meet the cap-height of the h1 directly. So the mixed
462
+ pair takes a real gap here, and it is --deck-space-5 — one full grid unit, the same figure
463
+ --deck-heading-top-space uses for precisely this problem one layout over (air above a heading
464
+ that would otherwise read as flush).
465
+ */
466
+ row-gap: var(--deck-space-5);
467
+ /*
468
+ Two rows. Every ratio is content:figure — strip:band — so each rung names its two shares and the
469
+ TRACK ORDER is applied once, below: positional order is the flip's business and the ratio is not.
470
+ Written as two custom properties rather than a `grid-template-rows` pair per rung so that
471
+ mirroring costs one rule instead of a second copy of all eight.
472
+ */
473
+ --stack-band-fr: 2fr;
474
+ --stack-strip-fr: 1fr;
475
+ grid-template-rows: var(--stack-band-fr) var(--stack-strip-fr);
476
+ }
477
+ .is-1-1 .stack-grid { --stack-band-fr: 1fr; --stack-strip-fr: 1fr; }
478
+ .is-3-2 .stack-grid { --stack-band-fr: 2fr; --stack-strip-fr: 3fr; }
479
+ .is-2-3 .stack-grid { --stack-band-fr: 3fr; --stack-strip-fr: 2fr; }
480
+ .is-2-1 .stack-grid { --stack-band-fr: 1fr; --stack-strip-fr: 2fr; }
481
+ .is-1-2 .stack-grid { --stack-band-fr: 2fr; --stack-strip-fr: 1fr; }
482
+ .is-3-1 .stack-grid { --stack-band-fr: 1fr; --stack-strip-fr: 3fr; }
483
+ .is-1-3 .stack-grid { --stack-band-fr: 3fr; --stack-strip-fr: 1fr; }
484
+
485
+ /* ── THE MIRROR ────────────────────────────────────────────────────────────────────────────────
486
+ The strip takes row 1 and the band row 2, and the track pair reverses with them so each region
487
+ keeps the share its rung gave it. Everything below that names an outer edge — the band's margin,
488
+ the notch lift, the strip's own bound — swaps ends too, because the layout's outer edge is the
489
+ only thing that moved. */
490
+ .is-flipped .stack-grid { grid-template-rows: var(--stack-strip-fr) var(--stack-band-fr); }
491
+ .is-flipped .stack-band { grid-row: 2; }
492
+ .is-flipped .stack-strip { grid-row: 1; }
493
+ .is-flipped .stack-band { margin-top: 0; margin-bottom: var(--deck-inset-text); }
494
+ .is-flipped .stack-band.is-notched { margin-top: 0; margin-bottom: var(--deck-inset-chrome); }
495
+ .is-flipped .stack-strip { padding-bottom: 0; padding-top: var(--deck-inset-text); }
496
+ /* ★★ ONE ROW, NOT A 0fr SECOND ONE. `0fr` sizes a track by its own min-content, and the strip
497
+ carries `padding-bottom: --deck-inset-text` — so a zero-share strip would still reserve 96px and
498
+ the band would stop short of the field it is meant to fill. The strip is removed instead, and the
499
+ gap with it, since a gap only exists between two rows. */
500
+ .is-0-1 .stack-grid { grid-template-rows: 1fr; gap: 0; }
501
+ .is-0-1 .stack-strip { display: none; }
502
+ /* The band owns both ends now. `.is-notched` already lifts the top to the chrome inset so the mark's
503
+ outer corner and the band's are one point; the bottom needs the same ring for the section pill,
504
+ which at every other rung is the strip's business. */
505
+ .is-0-1 .stack-band { margin-bottom: var(--deck-inset-text); }
506
+ .is-0-1 .stack-band.is-notched { margin-bottom: var(--deck-inset-chrome); }
507
+
508
+ /*
509
+ THE BAND. Its own vertical bound is --deck-inset-text, the same 96px every figure in this theme
510
+ gets for free from the shared ancestor padding that this layout zeroed — restored as an explicit
511
+ MARGIN, not padding, so it positions the frame rather than shrinking the photograph inside it
512
+ (split.vue's figure makes the same distinction for the same reason).
513
+
514
+ Horizontally it claws back the 54px difference between the text inset it inherits and the media
515
+ inset a hard-edged figure wants — on BOTH edges, because a full-width band has two outer edges
516
+ where a split's figure has one. Negative margins rather than a wider box: the band sits in an
517
+ `fr`-sized grid row, so a margin shifts the rendered box without feeding back into track sizing.
518
+ */
519
+ .stack-band {
520
+ grid-row: 1;
521
+ min-height: 0;
522
+ margin-top: var(--deck-inset-text);
523
+ margin-inline: calc(-1 * (var(--deck-inset-text) - var(--deck-inset-media)));
524
+ }
525
+ /* `frame: content` is the author's own content, not a figure — it keeps the TEXT inset, because
526
+ escaping it to the media inset would narrow the measure on the very edge --deck-inset-text
527
+ exists to protect. Same carve-out, same reasoning, as split.vue's. */
528
+ .stack-band.is-content {
529
+ margin-inline: 0;
530
+ }
531
+ /* ★ THE BAND RISES TO MEET THE MARK — the notch's other half, and the reason chrome and media share
532
+ an inset (scripts/deck.ts, above INSET). At --deck-inset-text the mark and the band would overlap
533
+ by 18px, which is a nick rather than a seat; at --deck-inset-chrome the mark's outer corner and
534
+ the band's outer corner are the SAME POINT, with one uniform 36px ring — half slide margin, half
535
+ cut-out. Drops back to the text inset on `notch: false`, together with the cut and the
536
+ reservation, so an opted-out band has no raised top with nothing cut into it. */
537
+ .stack-band.is-notched {
538
+ margin-top: var(--deck-inset-chrome);
539
+ }
540
+ /*
541
+ ★★ `bleed` — THE SAME BAND, AT THE CANVAS EDGE. The band already claws back to --deck-inset-media
542
+ on BOTH outer edges, because a band has two of them where split's figure has one; this gives up the
543
+ whole text inset instead, so left, right and top are 0, 1920 and 0. The strip below is untouched
544
+ and keeps its text inset.
545
+
546
+ The frame keeps its treatment: a photo band still crops, a panel band still carries its background. Only
547
+ the edges and the corner radius move, which is what makes this a modifier rather than a frame kind.
548
+
549
+ ⚠ NOT ON `.is-content` — the author's own block, which wants the text inset it already has.
550
+
551
+ It never notches (see the prop's note), so this rule and `.is-notched` above can never both apply;
552
+ it is written after it anyway so the margin-top needs no specificity fight to win.
553
+ */
554
+ .stack-band.is-bleed:not(.is-content) {
555
+ margin-top: 0;
556
+ margin-inline: calc(-1 * var(--deck-inset-text));
557
+ border-radius: 0;
558
+ }
559
+ /*
560
+ ⚠ THE FLIPPED BAND KEEPS A BOTTOM MARGIN, so it stopped 96px short of the canvas — the rule above
561
+ zeroes the TOP, which is the edge an unflipped band bleeds by. Measured on the spill slides, which
562
+ are the first flipped bleeding bands in the book: the band ran to y 984 with cream under it, and
563
+ a window "cut by the bottom of the slide" was cut by a line inside it instead.
564
+ */
565
+ .is-flipped .stack-band.is-bleed:not(.is-content) { margin-bottom: 0; }
566
+ /*
567
+ ★★ THE BAND BLEEDS; THE CONTENT INSIDE IT MUST NOT. Same correction as split's, same reason: the
568
+ padding rode out with the box, so a bleeding panel band put its content at x 32 where the same band
569
+ unbled puts it at 68. Each bled edge gives the inset back, so the content lands where it would have
570
+ without the bleed and only the surface moves.
571
+
572
+ Only `panel` — a bleeding photo band is meant to be edge to edge, and `content` never bleeds.
573
+
574
+ ⚠ The top value here assumes no chrome above it; styles/base.css raises it when the chrome row is
575
+ drawn, because a scoped style cannot see `.slidev-page`.
576
+ */
577
+ /*
578
+ ★★ A BLEEDING PANEL TAKES THE REGION INSET (96), NOT THE CARD INSET (68).
579
+
580
+ These gave the bled edge its inset back as padding — card pad 32 + media inset 36 — so the content
581
+ landed exactly where the same panel puts it unbled. Correct arithmetic, wrong premise: it treats a
582
+ bleeding panel as a card that happens to reach the edge. It is not one. A card's interior is inset
583
+ from its own EDGES, which are drawn; a bleeding panel has no drawn edge left, so its interior is
584
+ measuring against the canvas and against the seam with the content column — which is exactly the
585
+ situation a text column is in, and --deck-inset-text is the number for it.
586
+
587
+ MEASURED on the register-comparison split (specimen 124, split) before this, which is the slide that found
588
+ it. Seam at 960:
589
+
590
+ left column 96 -> 864 96 from the canvas edge, 96 from the seam 768 wide
591
+ right panel 1028 -> 1852 68 from the seam, 68 from the canvas edge 824 wide
592
+
593
+ Both halves internally symmetric, and 56px apart from each other — so the same paragraph wrapped to
594
+ three lines on the left and two on the right, on a slide whose whole claim is that the register is
595
+ the only thing that differs. At the region inset both halves are 768 and the comparison is exact.
596
+
597
+ ⚠ Only when it BLEEDS. A panel sitting on the ground keeps --deck-card-pad: it has edges, and 96
598
+ inside a 924px card is a frame with a stamp in it. Jose asked whether the two sides should match and
599
+ what it would cost; the answer is 3 slides, all bleeding panels, because that is the only case where
600
+ a panel stops being a card.
601
+ */
602
+ .stack-band.is-bleed.is-panel {
603
+ padding-top: var(--deck-inset-text);
604
+ padding-inline: var(--deck-inset-text);
605
+ }
606
+ /*
607
+ ══ SPILL ══════════════════════════════════════════════════════════════════════════════════════
608
+
609
+ ★★ THE BAND IS ALREADY A STRIP ACROSS THE FRAME, so what spills here is its HEIGHT. `bleed` takes
610
+ the band's box to the canvas on three sides and keeps everything inside it; `spill` lets the thing
611
+ inside grow past the bottom of the box and be cut by the slide — a browser window whose foot is
612
+ below the canvas, three device frames standing on the bottom edge.
613
+
614
+ ⚠ THE STRIP BELOW IS UNTOUCHED, which is the whole reason this is a band modifier rather than a
615
+ full-bleed layout: the words keep their row and their inset, and the picture behind them runs out
616
+ of the frame.
617
+ */
618
+ /*
619
+ ⚠ THE SELECTOR CARRIES THE CLASSES IT HAS TO BEAT. Two rules outranked a plain `.stack-band.is-spill`
620
+ at equal specificity and source order decided both against it: `.stack-band.is-panel.bg-warm` kept
621
+ the band's peach fill, and `.stack-band.is-panel-center` kept the vertical centring — which put a
622
+ 900px row of device frames 54px ABOVE the band's own top and cropped their heads as well as their
623
+ feet. Named here rather than fought with `!important`.
624
+ */
625
+ .stack-band.is-spill.is-panel:not(.is-content),
626
+ .stack-band.is-spill.is-panel-center:not(.is-content),
627
+ .stack-band.is-spill:not(.is-content) {
628
+ /* The band stops clipping its own content — the SLIDE does the cropping, three rules down. */
629
+ overflow: visible;
630
+ align-items: flex-start;
631
+ /*
632
+ ★★ AND IT PAINTS NOTHING. Jose: "for the stack, no need to paint the panel". A band's fill says
633
+ "this strip is a surface"; a spilling band's content runs off the canvas, so the fill draws a box
634
+ the object plainly ignores — two edges that disagree about where the thing ends. The frame still
635
+ decides the CONTENT's treatment (a photo band still crops); only the surface goes.
636
+ */
637
+ background: none;
638
+ border: none;
639
+ /*
640
+ ⚠ THE BLEED'S PADDING COMES OFF THE BLOCK AXIS AND STAYS ON THE INLINE ONE, and the asymmetry is
641
+ the whole point of a spill. `is-bleed` gives the inset back as padding so the content lands where
642
+ it would have unbled — correct for a bleed, and on the TOP the exact opposite of what a spill
643
+ wants: measured, it started the window 132px below the band's own edge and the slide then cut a
644
+ window that had never reached the top.
645
+
646
+ ★ THE SIDES ARE NOT THE SAME CASE, WHICH TOOK GOING TOO FAR TO ESTABLISH. Zeroing all four ran
647
+ the window 0-1920 and it stopped reading as a window at all — Jose: "leave some padding at the
648
+ sides". A spill crops in ONE direction, and here that is downward; the inset on the other axis is
649
+ what keeps the object recognisable as an object rather than as a texture. So `padding-block`
650
+ rather than `padding`.
651
+
652
+ ★ THE TOP ALSO NEEDED A FIX ONE FILE AWAY. This declaration was being outranked by base.css's
653
+ chrome-clearance rule, whose `:has()` made it (0,8,0) against this rule's (0,5,0). That rule now
654
+ excludes `.is-spill` — see its own note — so this one is what decides the top again.
655
+ */
656
+ padding-block: 0;
657
+ }
658
+ /*
659
+ ★★ SPILL RELEASES THE CAPS; THE CONTENT STATES ITS OWN SIZE. Two sizing schemes were tried and both
660
+ were wrong for half the cases: `height: 128%` did nothing to a browser frame (it takes its height
661
+ from its own ratio — measured, the room stayed 433px in a 597px band), and `width: 128%` blew up a
662
+ row of device frames that were already taller than the band. What every spilling component has in
663
+ common is that it KNOWS its size — a map's `width`, a frame's `height` — and what the band was
664
+ doing was capping it. So the cap comes off and nothing is imposed.
665
+ */
666
+ .stack-band.is-spill > :deep(*) {
667
+ max-width: none;
668
+ max-height: none;
669
+ /* ⚠ AND IT STILL HAS TO FILL THE BAND. The band is a flex row, so a grid inside it shrink-wraps to
670
+ its own tracks the moment nothing stretches it — measured with the caps off: the row of three
671
+ phones came out 1428 wide against a 1728 band and sat hard left, 96 of air on one side and 396
672
+ on the other. Grid's own `justify: center` used to supply the stretch as a side effect; this
673
+ states it instead, so the centring below has the full band to centre in. */
674
+ width: 100%;
675
+ }
676
+ /*
677
+ ⚠ AND ONE CAP HAS TO BE BROKEN BY NAME. BrowserFrame sizes its window so the WHOLE of it fits the
678
+ room's height — that is the component doing its job, and it is computed as a max-width because a
679
+ height cap does not travel back through `aspect-ratio` (its own note says so at length). A spilling
680
+ band is the one state where the window is meant to be taller than the room, so the cap comes off —
681
+ and ONLY the cap. At the band's own width a 16:9 window is 972px tall against a band of 597, so the
682
+ ratio does the spilling and the window stays a recognisable window.
683
+
684
+ ⚠ 128% WAS THE FIRST TRY AND IT WAS WRONG, which is worth keeping: a window wider than the canvas
685
+ loses both of its side edges, and what is left reads as a horizontal band with a URL in it rather
686
+ than as a browser. `--spill` is still the knob for a slide that wants more.
687
+ */
688
+ .stack-band.is-spill :deep(.deck-browser-fit) {
689
+ max-width: none;
690
+ width: var(--spill, 100%);
691
+ }
692
+ /* ⚠ AND THE ROOM STOPS CENTRING IT. The room centres its window vertically, which is right when the
693
+ window fits and wrong the moment it does not: a 128% window centred in the band grew UPWARDS as
694
+ well, over the heading in the strip above. Anchored to the top, all of the growth leaves by the
695
+ bottom, which is the edge the slide is cropping. */
696
+ .stack-band.is-spill :deep(.deck-browser-room) { justify-content: flex-start; }
697
+ /*
698
+ ★★ AND A ROW OF FIXED-WIDTH OBJECTS CENTRES IN ITS TRACKS. Grid's own `justify` reaches INSIDE a
699
+ cell — `.is-justify-center > *` makes each cell a centred flex column — which is right when a cell
700
+ holds content and does nothing at all when the cell IS the object: with one device frame per cell,
701
+ that selector matches the phone itself and centres the phone's own children. Measured: three
702
+ frames flush against the left of their tracks, the group running 96 → 1724 in a 1920 frame, 96 of
703
+ air on one side and 196 on the other.
704
+
705
+ `justify-items` is the property that moves a fixed-width cell within its track, and it is scoped to
706
+ a spilling band rather than added to Grid because Grid's note argues against it for its own case:
707
+ a cell that shrink-wraps to its content leaves the content at the shrunken cell's edge. A device
708
+ frame does not shrink-wrap — its width comes from its height — so here it is exactly right.
709
+ */
710
+ .stack-band.is-spill :deep(.deck-grid) { justify-items: center; }
711
+ /*
712
+ ★★ THE SLIDE DOES THE CROPPING, AND IT HAS TO SAY SO — same note as split.vue's. Nothing on this
713
+ layout clipped before, so an overflowing band painted over the strip below it and past the canvas
714
+ into the deck's own page.
715
+ */
716
+ .stack.is-spill { overflow: hidden; }
717
+
718
+ /*
719
+ THE BAND OWNS ITS OWN TREATMENT, same contract as split's figure: an author writes the content and
720
+ nothing else and gets a filled, corner-clipped photo or a tinted panel. `object-fit: cover` rather
721
+ than a raw stretch is what makes filling safe for an <img> of any intrinsic ratio.
722
+ */
723
+ .stack-band.is-photo {
724
+ border-radius: var(--deck-radius-card);
725
+ overflow: hidden;
726
+ display: flex;
727
+ }
728
+ .stack-band.is-photo :deep(img),
729
+ .stack-band.is-photo :deep(video),
730
+ .stack-band.is-photo :deep(picture) {
731
+ width: 100%;
732
+ height: 100%;
733
+ object-fit: cover;
734
+ display: block;
735
+ }
736
+ .stack-band.is-panel {
737
+ border-radius: var(--deck-radius-card);
738
+ /*
739
+ ★★ THE INLINE PADDING PUTS CONTENT ON THE TEXT INSET, NOT ON THE CARD PAD. The band claws back
740
+ to --deck-inset-media, 36px, so a uniform card pad landed its content at 68 — 28px left of where
741
+ the strip below it, and every slide without a band, puts theirs. Two text columns on one slide
742
+ that do not share a left edge. `--deck-inset-text - --deck-inset-media` is the difference the
743
+ band gave up, handed back as padding, so the content lands at 96 exactly as it would with no
744
+ band at all and only the SURFACE reaches wider.
745
+
746
+ The bleeding rule below already did this for its own case (it gives back the whole text inset
747
+ because it gave up the whole thing); this is the same correction for the unbled band, which is
748
+ the common one. Vertical stays the card pad — the band's height is the ratio's business, and the
749
+ notched rule raises the top on its own.
750
+ */
751
+ padding: var(--deck-card-pad) calc(var(--deck-inset-text) - var(--deck-inset-media));
752
+ display: flex;
753
+ /* A COLUMN that stretches, where split's panel is a centred row. The difference is what the two
754
+ hold: split's panel is a mat for one figure, this band is the slide's main content region — a Grid
755
+ of cards, a table, a timeline — all of which want the full width and do their own internal
756
+ alignment.
757
+
758
+ The vertical placement of a block SHORTER than the band is `panelAlign`'s, in the three rules
759
+ below. It used to be a bare `justify-content: center` here, which was the default with no way
760
+ to say anything else. */
761
+ flex-direction: column;
762
+ align-items: stretch;
763
+ }
764
+
765
+ /* `panelAlign` — where a block shorter than the band sits on the band's own axis. Only meaningful
766
+ on a frame that HAS a band box to sit in; `content` brings its own and is unaffected. */
767
+ .stack-band.is-panel-start { justify-content: flex-start; }
768
+ .stack-band.is-panel-center { justify-content: center; }
769
+ .stack-band.is-panel-end { justify-content: flex-end; }
770
+ /* Media matted inside a panel takes the CONCENTRIC radius — the card's corner minus its padding —
771
+ and opts back OUT of the stretch above, because an <img> stretched to a band's full width is
772
+ distorted rather than filled. */
773
+ /*
774
+ ── A CODE SURFACE ────────────────────────────────────────────────────────────────────────────
775
+ The panel IS the block. It takes the code ground and its own edge, and the `pre` inside goes flat,
776
+ so a code slide is ONE surface rather than a well inside a mat. See the `frame` prop's own note for
777
+ why this is a named value rather than a `:has()` on `panel`.
778
+
779
+ The inner reset is the same three declarations the magic-move container has always used on its own
780
+ `pre.shiki`, for the same reason: a container that owns the surface must strip the one its child
781
+ would otherwise draw, or the two edges sit 4px apart and read as a mistake.
782
+ */
783
+ .stack-band.is-code {
784
+ background: var(--slidev-code-background);
785
+ border: 1px solid var(--deck-code-border);
786
+ border-radius: var(--deck-radius-card);
787
+ padding: var(--deck-card-pad);
788
+ display: flex;
789
+ flex-direction: column;
790
+ justify-content: center;
791
+ min-width: 0;
792
+ }
793
+ /*
794
+ ★★ THE PADDING IS ZEROED AT ITS SOURCE, NOT OVERRIDDEN — one cause, two effects.
795
+
796
+ The obvious way to flatten the block is `padding: 0 !important` (Slidev's own rule carries
797
+ `!important`, so nothing less wins). It works, and it leaves a 24px horizontal scrollbar on every
798
+ code frame, because a SECOND rule reads the same token: base.css sizes each line
799
+
800
+ width: calc(100% + space-1 + space-2 + var(--slidev-code-padding))
801
+
802
+ so the row overhangs to the right by exactly the block's own right padding, which is what lets a
803
+ lit band reach both padding edges. Flatten the padding without telling that rule and the overhang
804
+ has nothing left to fill: measured on this frame, scrollWidth 965 against clientWidth 941 — the
805
+ token, to the pixel.
806
+
807
+ Setting `--slidev-code-padding: 0px` collapses both at once: Slidev's padding resolves to zero with
808
+ no `!important` needed, and the line's overhang term goes with it. The surface's own inset is the
809
+ panel's, which is the whole point of the frame.
810
+ */
811
+ .stack-band.is-code {
812
+ --slidev-code-padding: 0px;
813
+ }
814
+ .stack-band.is-code :deep(.slidev-code-wrapper),
815
+ .stack-band.is-code :deep(pre.slidev-code) {
816
+ margin: 0 !important;
817
+ background: none;
818
+ border: none;
819
+ border-radius: 0;
820
+ }
821
+ /*
822
+ ★★ AN EDGE AND A CLIP CANNOT SHARE AN ELEMENT. `clip-path` cuts the rendered box, border included,
823
+ so a notched code surface drew its edge continuously on three sides and then simply lost it around
824
+ the cut — a frame that stops. Measured on the specimen's own pair: `border: 1px` and a clip-path on
825
+ the same figure.
826
+
827
+ The border is what a code surface has when it is a CARD on the slide. Notched, its outer corner is
828
+ the mark's seat rather than a corner; bleeding, there is no ground for it to sit on at all. Neither
829
+ is a card, so neither takes an edge. `panel` never hit this because it has no border to lose.
830
+ */
831
+ .stack-band.is-code.is-notched {
832
+ border: none;
833
+ padding-bottom: var(--deck-card-pad);
834
+ }
835
+ /* Bleeding, it is a REGION like every other bleeding panel — the region inset on all four sides, so
836
+ the code lines up with a content column across the seam. See the note above the panel's own. */
837
+ .stack-band.is-code.is-bleed {
838
+ border: none;
839
+ border-radius: 0;
840
+ padding: var(--deck-inset-text) var(--deck-card-pad);
841
+ }
842
+
843
+ /*
844
+ ⚠ `:not(.mark-pair-companion)`. A co-branding tile is not matted media: MarkPair clips the
845
+ partner's artwork to the MARK's corner — a ratio of the tile — and this rule outranked it
846
+ (`.stack-band.is-panel[data-v-…] img` beats a single class), so a pair dropped into a panel came out with
847
+ the panel's 16px nested radius on one half and the mark's on the other. Measured on the specimen's
848
+ own MarkPair slide: 31.49px on the light half, 16px on the dark one, same component, same size.
849
+ slide-top.vue carries the same exclusion for the same reason.
850
+ */
851
+ .stack-band.is-panel :deep(img:not(.mark-pair-companion)),
852
+ .stack-band.is-panel :deep(video),
853
+ .stack-band.is-panel :deep(picture) {
854
+ max-width: 100%;
855
+ align-self: center;
856
+ border-radius: var(--deck-radius-nested);
857
+ display: block;
858
+ }
859
+ /*
860
+ ⚠ MASKING CLIPS, IT DOES NOT REFLOW — so a band holding real content reserves the cut's height as
861
+ top padding, never less than the card padding it already had. Identical guarantee, identical
862
+ mechanism and identical reason as split.vue's `.is-panel.has-notch`: a band a mask has bitten into
863
+ would otherwise slice the corner off a card. `.is-photo` deliberately gets none of it — clipping
864
+ the photograph IS the effect.
865
+ */
866
+ /*
867
+ ★★ RESERVED AT BOTH ENDS, THE SAME WAY split's PANEL IS, AND FOR THE SAME MEASURED REASON.
868
+
869
+ The top reservation is the notch: content must clear the bite cut for the running chrome, so the
870
+ band keeps `--notch-h` (mark 72 + ring 24 = 96) above it. Only the top was reserved, leaving the
871
+ box 96/32 — so anything this band centres lands (96 - 32) / 2 = 32px LOW inside it. Measured across
872
+ five notched band slides, every one off by exactly 32:
873
+
874
+ slide 23 band 36-416 content 132-384 96 above, 32 below
875
+ slide 56 band 36-780 content 375-505 339 above, 275 below
876
+ slide 60 band 36-416 content 193-323 157 above, 93 below
877
+
878
+ ⚠ AND THE ARGUMENT FOR LEAVING THIS ONE ALONE WAS WRONG. It was "a band is full width and holds
879
+ full-width content, so it is less likely to look wrong" — which is about HORIZONTAL extent and has
880
+ nothing to do with a vertical offset. Slide 56 is the proof: 130px of content floating in a 744px
881
+ band is the most visible case in the deck, not the least.
882
+
883
+ The bottom padding buys nothing but symmetry — there is no notch down there — and that is the
884
+ point: the box this band centres in now shares its centre with the band a viewer sees.
885
+ */
886
+ .stack-band.is-panel.is-notched {
887
+ padding-top: max(var(--deck-card-pad), var(--notch-h));
888
+ padding-bottom: max(var(--deck-card-pad), var(--notch-h));
889
+ }
890
+
891
+ /* ── The band's two registers ─────────────────────────────────────────────────────────────
892
+ The four named backgrounds, each a fill AND the ink published with it — so a band can never put an
893
+ ink on a fill that was not measured against it. They are the same four split.vue's panel takes, so
894
+ a stack band and a split panel in the same deck read as the same object.
895
+
896
+ ⚠ THIS DESCRIBED A PALETTE THAT NO LONGER EXISTS. It named 'soft' (--deck-accent-soft), 'subtle'
897
+ (--deck-bg-subtle), --warm-100 and --forest-300, from when a band picked a loose fill and kept the
898
+ slide's own ink. The rules below take --deck-bg-<name> / --deck-on-bg-<name>: one table, four
899
+ names, fill and ink together. See the ★★ note at the top of this file for the grounds that are
900
+ deliberately not offered, and the contrast numbers behind it. */
901
+ .stack-band.is-panel.bg-warm { background: var(--deck-bg-warm); color: var(--deck-on-bg-warm); }
902
+ .stack-band.is-panel.bg-accent { background: var(--deck-bg-accent); color: var(--deck-on-bg-accent); }
903
+ .stack-band.is-panel.bg-vivid { background: var(--deck-bg-vivid); color: var(--deck-on-bg-vivid); }
904
+ .stack-band.is-panel.bg-neutral { background: var(--deck-bg-neutral); color: var(--deck-on-bg-neutral); }
905
+ /*
906
+ Two further grounds, added after the mint was judged to sit badly against the deck's ground — and
907
+ it does so for a reason the contrast table above could not show: --deck-bg is --warm-50, a WARM
908
+ cream, while forest-100 is a COOL mint. The table only ever asked "can the pill be seen", which
909
+ both pass; temperature is a separate question and the mint loses it.
910
+
911
+ 'warm' — --warm-100, the ground's own family one step up. Same temperature, so the band reads as
912
+ a deeper pour of the slide rather than a different material laid on top.
913
+ 'vivid' — --forest-300. This is as green as this layout can legally go: the running page pill is
914
+ --deck-bg-vivid-dark (forest-700, #046C4C) and lands ON the band, so the band is bounded
915
+ by the 3:1 non-text floor against that fill. Measured against the same pill:
916
+
917
+ warm-100 peach cream 5.32:1 ✓
918
+ forest-200 deeper mint 4.53:1 ✓
919
+ forest-300 emerald 3.55:1 ✓ <- 'vivid', the last rung that clears
920
+ forest-700 brand green 1.00:1 ✗ the pill IS the ground; it vanishes
921
+ forest-950 dark 2.40:1 ✗
922
+
923
+ So "just use the brand green" is not available here, and the reason is structural rather
924
+ than aesthetic: the chrome cannot re-tone itself per layout (frontmatter.static.test.ts,
925
+ "names no layout, in any form"). A slide that wants a full-field forest-700 has `divider`,
926
+ which owns the whole canvas and ships chrome: false.
927
+ */
928
+
929
+ /*
930
+ THE STRIP — the title, subordinated. Top-aligned in its own row (it captions the band above it, so
931
+ it belongs against it rather than floating in the middle of what is left), and carrying the same
932
+ --deck-inset-text bottom bound every other text box in the theme has, which is also what keeps it
933
+ clear of the confidentiality pill's band.
934
+ */
935
+ .stack-strip {
936
+ grid-row: 2;
937
+ min-width: 0;
938
+ min-height: 0;
939
+ padding-bottom: var(--deck-inset-text);
940
+ }
941
+ /* The strip is a caption-scale region, not a full slide of prose: two tiers of content in a third
942
+ of the canvas. The dense body tier, same as split's two columns, for the same reason. */
943
+ .stack-strip { font-size: var(--type-deck-body-sm-size); line-height: var(--type-deck-body-sm-line-height); }
944
+ </style>