@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,1875 @@
1
+ <script setup>
2
+ /*
3
+ The workhorse. Content on one side, a figure on the other — five of the reference deck's eleven
4
+ archetypes are this layout with different slot content.
5
+
6
+ ★★ THE FIGURE IS `image` (A URL) + `alt` (THE TEXT), AND `image` IS SLIDEV'S OWN KEY.
7
+
8
+ It is not `src`: that is in Slidev's FRONTMATTER_FIELDS and would be stripped before frontmatter
9
+ ever became a prop. `image` was briefly used for the ALT TEXT, which broke on Slidev 52 — its
10
+ extractImagesUsage() harvests frontmatter `image` as a URL and emits a <link rel=preload
11
+ as=image>, so all ten of the specimen deck's alt strings became preloads for paths like
12
+ `/A woman cycling on a city road in daylight`.
13
+
14
+ The fix was to use the key the way Slidev does, not to dodge it: `image` holds the URL, this
15
+ layout renders the <img>, and the preload is then correct rather than a collision. `image` is
16
+ therefore NOT on test/props.ts's reserved list — it is the name to use, not one to avoid.
17
+
18
+ ★ ONE image prop is the ceiling, and it is Slidev's, not a preference. extractImagesUsage()
19
+ (@slidev/parser core.mjs) iterates the literal list ["image", "backgroundImage", "background"]
20
+ and guards on `typeof val === 'string'`. MEASURED against the installed parser: a second key
21
+ (`image2`) is preloaded 0 times, and an `images: [a, b]` ARRAY fails the typeof guard and
22
+ preloads NEITHER — it silently costs the first image its preload too. A slide that needs a second
23
+ photograph writes markdown `![alt](url)` in the slot, which IS harvested (unconditionally, so a
24
+ query string survives) and does preload.
25
+ */
26
+
27
+ // Vue strips prop validators from a production build, so an out-of-range value (e.g. `ratio:
28
+ // '5:7'`, an AI's typo) would otherwise reach the template raw and build a class name (`is-5-7`)
29
+ // with no matching CSS rule — an unstyled grid, invisible until an overflow shows up in the audit.
30
+ // This lookup clamps the value the template actually renders, independent of the validator below.
31
+ const RATIO_CLASS = {
32
+ '1:1': 'is-1-1',
33
+ '3:2': 'is-3-2',
34
+ '2:3': 'is-2-3',
35
+ '2:1': 'is-2-1',
36
+ '1:2': 'is-1-2',
37
+ '3:1': 'is-3-1',
38
+ };
39
+
40
+ /*
41
+ ★ THE MEDIA KIND IS A FACT ABOUT THE SLIDE, not only about the figure — so it is stamped on the
42
+ ROOT as well as on the figure, and clamped through a lookup for the same reason `ratio` is.
43
+
44
+ This is a fix, not a tidy-up. task-14b's conditional gutter is written `.is-content .split-grid` — a
45
+ DESCENDANT selector — but `is-content` was only ever put on `.split-figure`, which is a descendant of
46
+ `.split-grid`, not an ancestor. The rule therefore never matched anything, and every same-kind
47
+ split has been rendering with the mixed-kind zero gutter since. Measured live on
48
+ apps/deck-template's own two-column slide: two 870px columns in a 1740px row, i.e. no gap at all,
49
+ where the same-kind rule asks for a real gutter (36px at the time; the token is 96 now). Static
50
+ tests could not see it — deckInsets.static.test.ts asserts the rule EXISTS in the source, which it
51
+ did, correctly written, against a class that was never in the right place for it to match.
52
+ */
53
+ /*
54
+ ★ FOUR VALUES, AND `anchor` IS ONE OF THEM RATHER THAN THE ABSENCE OF THE OTHER THREE. Split's
55
+ text column has always started at the one-third line (see --deck-anchor-third and the note on
56
+ .split-grid) so headings do not jump between slides. That is the right default and stays it, but
57
+ it is not `start`, so it needs a name of its own — otherwise the enum would have three values and
58
+ a fourth unnamed state you get by omitting the prop, which is exactly the shape `frame: none` was.
59
+ */
60
+ const ALIGN_CLASS = {
61
+ anchor: 'is-align-anchor',
62
+ start: 'is-align-start',
63
+ center: 'is-align-center',
64
+ 'body-center': 'is-align-body-center',
65
+ end: 'is-align-end',
66
+ };
67
+ /* `anchor` has no meaning on a panel — it is the deck's one-third line, which the text column
68
+ stands on and a filled frame does not — so this vocabulary is the other three. */
69
+ const PANEL_ALIGN_CLASS = {
70
+ start: 'is-panel-align-start',
71
+ 'title-start': 'is-panel-align-title-start',
72
+ center: 'is-panel-align-center',
73
+ 'title-center': 'is-panel-align-title-center',
74
+ end: 'is-panel-align-end',
75
+ };
76
+ /* The two rungs that hold a heading's height back before aligning what is left. */
77
+ const BANDED_ALIGNS = new Set(['title-start', 'title-center']);
78
+
79
+ /*
80
+ The panel's register, and the same four stack's band takes. Split did not have this axis at all —
81
+ its panel was one hardcoded fill — so `frame: 'panel'` meant a different surface in each layout.
82
+ The values, the class names and the four rules below are stack's, read from the same tokens.
83
+ */
84
+ /*
85
+ ★★ THE TONE VALUES ARE THE PANEL GROUNDS, AND THEY ARE HUES RATHER THAN TREATMENTS.
86
+
87
+ This read `soft | subtle | warm | vivid` — two treatment words and two hue words in one enum, and
88
+ half of them reaching past the roles into the raw ramp (`--warm-100`, `--forest-300`) so they did
89
+ not follow the register at all. A panel on a dark slide kept its light fill under light ink.
90
+
91
+ Each value is now a NAMED GROUND from the engine (see PANEL in scripts/deck.ts), which publishes a
92
+ fill AND its ink for BOTH registers. So the ink is paired rather than inherited, and every value
93
+ moves with the slide:
94
+
95
+ warm peach on light, bark on dark accent the quiet green field
96
+ vivid the loud green, MIRRORED neutral no hue at all
97
+
98
+ The renames are `soft` → `accent` (same forest-100 on light) and `subtle` → `neutral`. `warm` and
99
+ `vivid` keep their names; warm deepens one step (warm-100 → warm-200) to be a ground rather than a
100
+ tint of the page.
101
+
102
+ ★★ AND `dark` IS NOT ONE OF THEM, BECAUSE IT IS NOT A HUE — it is the OTHER axis. `background` says
103
+ which ground; `panelDark` says which register that ground is read in. They compose: every one of
104
+ the four has a light half and a dark half already, so a dark panel is `background` unchanged with the
105
+ register flipped, and the enum stays four values rather than five with one of them meaning
106
+ something different from the rest.
107
+ */
108
+ const TONE_CLASS = {
109
+ warm: 'bg-warm',
110
+ accent: 'bg-accent',
111
+ vivid: 'bg-vivid',
112
+ neutral: 'bg-neutral',
113
+ };
114
+
115
+ /*
116
+ The text tier the two columns run at. `base` is the deck's body role, the same 48px every other
117
+ layout sets; `sm` steps the whole grid down to `body-sm` for a slide that genuinely has more to
118
+ say than the column holds at full size.
119
+ */
120
+ const BODY_CLASS = {
121
+ base: 'is-body-base',
122
+ sm: 'is-body-sm',
123
+ };
124
+
125
+ const FRAME_CLASS = {
126
+ photo: 'is-photo',
127
+ panel: 'is-panel',
128
+ code: 'is-code',
129
+ figure: 'is-figure',
130
+ content: 'is-content',
131
+ };
132
+
133
+ import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue';
134
+ import { useNotchCuts } from '../useNotchCuts.js';
135
+ import { injectionCurrentPage, injectionFrontmatter } from '@slidev/client/constants.ts';
136
+ import deckTokens from '@humanforest/tokens/deck/deck.json';
137
+ import { chromeShows, coBrandFitted, hasNotch, notchShows } from '../notch.js';
138
+ import { assetUrl } from '../assetUrl.js';
139
+
140
+ const props = defineProps({
141
+ /** Content's share of the row against the figure's — e.g. '3:2' gives content the bigger half, '2:3' gives the figure the bigger half. Content-relative: independent of `flip`, so swapping sides never changes which one is bigger. The ladder stops at `3:1` because the share IS the content column's measure here, and the rung below that is narrower than a line wants. `stack` carries two rungs this does not — `1:3`, a band that dominates harder than two thirds, and `0:1`, the band with no strip at all — because a full-width band is the shape that needs them. */
142
+ ratio: {
143
+ type: String,
144
+ default: '1:1',
145
+ // Inlined rather than reading RATIO_CLASS: Vue hoists defineProps() out of setup() at build
146
+ // time, so a validator cannot close over a script-setup-local const — it compiles in dev and
147
+ // breaks the production build. The template clamps through RATIO_CLASS anyway, which is what
148
+ // actually protects a real deck, since validators are stripped from production builds.
149
+ validator: (r) => ['1:1', '3:2', '2:3', '2:1', '1:2', '3:1'].includes(r),
150
+ },
151
+ /**
152
+ * Mirror the layout: by default the content takes the left column and the figure the right; flipped, they swap.
153
+ * A pure mirror — it does not affect either one's share (see `ratio`), only where each appears.
154
+ *
155
+ * ★★ A BOOLEAN, AND SHARED WITH `stack` — which is the only shape that gives the two layouts one
156
+ * key with one DEFAULT. They invert deliberately (split leads with the title, stack subordinates
157
+ * it), so any value that names a POSITION comes out opposite between them: content-first on one
158
+ * is content-last on the other. `false` means "this layout's own arrangement" on both, and that
159
+ * is the only thing they genuinely share — there is no word for "figure right" and "band top" at
160
+ * once. It replaced `flip`, which had no third value in prospect either.
161
+ *
162
+ * ⚠ IT IS RELATIVE, WHICH IS THE TRADE. `flip: true` said where the content went; `flip` says
163
+ * "the other way from the default", so reading a slide needs the default in hand — and reversing
164
+ * a layout's default would silently change every flipped slide. The defaults are doctrine and do
165
+ * not move, which is what makes that acceptable.
166
+ */
167
+ flip: { type: Boolean, default: false },
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
+ /**
187
+ * How the far side is FRAMED: clipped as a photo, matted in a tinted panel, left bare for a
188
+ * figure centred in the cell, or not a figure at all — a second column of content.
189
+ *
190
+ * ★★ THIS PROP WAS CALLED `frame`, AND HALF ITS VALUES WERE NOT MEDIA. `content` is a second
191
+ * column of the author's own prose and `figure` is any component; only `photo` and `panel` frame
192
+ * media at all. Worse, `content` was called `none` — a positive behaviour (36px gutter, dropped
193
+ * to row 2, header spanning both columns) named as an absence, which cost a real bug: a live map
194
+ * declared `frame: content`, inherited the prose treatment, and began a third of the way down the
195
+ * slide with the space above it empty. `figure` had to be invented to separate the two jobs.
196
+ *
197
+ * Under the honest names the four are plainly four things: `photo` fills and crops to the card
198
+ * shape, `panel` centres at natural size inside a tinted card, `figure` is CAPPED to the cell and
199
+ * centred with no treatment at all, and `content` is not a figure at all.
200
+ *
201
+ * ⚠ THAT LINE USED TO READ "`figure` fills with no treatment", WHICH IS THE ONE THING IT DOES NOT
202
+ * DO — and it is the sentence an author reads when choosing. `.is-figure` centres its child and
203
+ * caps it on both axes (see the rule and its note further down); a child that wants the whole
204
+ * cell has to ask with its own height, and even then the cap is what it is measured against.
205
+ * Measured on the map-as-evidence slide: at `figure` a map declaring `height: 100%` drew 816px in
206
+ * a 1008px cell, centred with 96 above and below; at `photo` it drew 1008. The difference that
207
+ * matters when picking between the two is CROPPING — `photo` fills and cuts what does not fit,
208
+ * `figure` shows the whole thing and letterboxes.
209
+ *
210
+ * ★★ THE DEFAULT WAS `content`, AND IT WAS WRONG FOR EVERY PRESET THAT RELIED ON IT. Four do —
211
+ * `split` ("body copy beside a figure"), `map`, `map-inset` and `trend` — and all four want a
212
+ * figure. Measured on deck-template before this changed: every one rendered `is-content`, took
213
+ * the 36px same-kind gutter, and dropped its figure to grid row 2 starting at canvas y 360. That
214
+ * is the prose treatment, and a map or a chart starting a third of the way down with empty space
215
+ * above it is the exact failure the `figure` value was invented to fix — shipping by default on
216
+ * the recipes most likely to be copied.
217
+ *
218
+ * `figure` is the neutral one: it is centred in its cell and given no treatment. A layout called `split`
219
+ * that is given no further instruction should put something beside the words, not assume the
220
+ * far side is more words — that is the case worth stating explicitly, and six presets do.
221
+ *
222
+ * ★★ AND THE SLOT IS `aside`, NOT `right`, FOR THE SAME REASON THIS PROP IS NOT CALLED `media`.
223
+ * `right` named a POSITION the slot does not control: `flip` decides which half each column
224
+ * takes, so on `flip: true` the content moves right and the slot called `right` renders on the
225
+ * LEFT. Measured on deck-template's `chart-right`, which is exactly that combination — the
226
+ * ::right:: slot came out at grid column 1, left edge 96, while the content sat at 978.
227
+ *
228
+ * `figure` was the obvious replacement and is wrong the same way one level down: it would name a
229
+ * CONTENT KIND the slot does not control either, and at `frame: content` that slot holds a second
230
+ * column of prose. `aside` names the REGION, which is the one thing that never moves — split is
231
+ * always two columns and this is always the one the content is not in.
232
+ */
233
+ frame: { type: String, default: 'figure', validator: (f) => ['photo', 'panel', 'figure', 'content', 'code'].includes(f) },
234
+ /**
235
+ * Take the figure to the CANVAS EDGE instead of the 36px media inset — same frame, same fill, same
236
+ * content, no ring of ground around it.
237
+ *
238
+ * A MODIFIER, not a fifth `frame`, because it is orthogonal to what the figure IS. A photo, a
239
+ * tinted panel and a component figure can each want the edge, and each keeps its own treatment
240
+ * when it goes there; a fifth enum value would have had to pick one of the three and would have
241
+ * made the other two unreachable. Inert on `frame: content`, which is a column of the author's own
242
+ * prose and wants the text inset it already has.
243
+ *
244
+ * `inset` keeps the ring of ground around the figure; `bleed` takes it to the canvas.
245
+ *
246
+ * ⚠ IT IS ONLY VISIBLE ON A FRAME THAT PAINTS A GROUND. What this gives up is the RING of deck
247
+ * ground around the figure, so on `photo`, `panel` and `code` — the three that paint one — the
248
+ * difference is the whole point of the slide. On this layout's DEFAULT frame, `figure`, there is
249
+ * no ground to give up: the box still reaches the canvas, but nothing about it is drawn, so the
250
+ * two values look nearly alike. Measured on a specimen pair that took the default and had to be
251
+ * moved to `panel` before it showed anything. Choose the frame before blaming the prop.
252
+ *
253
+ * ★ NAMED `edge`, NOT `bleed`, BECAUSE `bleed` IS ALSO A LAYOUT. One word cannot mean both "this
254
+ * slide is a full-canvas image" and "this figure reaches the edge" in the same frontmatter. The
255
+ * key carries the axis and the value carries the state, so `layout: bleed` keeps sole ownership
256
+ * of the word as a layout and `edge: bleed` is unambiguous beside it.
257
+ *
258
+ * `inset` is the deck's own word — --deck-inset-text, --deck-inset-media and --deck-inset-chrome
259
+ * are the three it is measured in — so naming the default costs an author no new concept. It is
260
+ * NOT called "notched": `notch: false` is a separate key that keeps the inset and drops only the
261
+ * cut, so a slide can be inset without being notched.
262
+ *
263
+ * ★ `bleed` SUPPRESSES THE NOTCH, and that is arithmetic rather than a preference. The cut exists
264
+ * to seat the running mark in the ring of ground around the figure; at inset 0 there is no ring,
265
+ * so there is nothing to bite. The chrome is then drawn OVER the figure — pair it with
266
+ * `chrome: false`, or use the `bleed` LAYOUT, whose scrim solves that across the whole canvas.
267
+ */
268
+ /*
269
+ * ★★ `spill` IS `bleed` PLUS ONE MORE STEP, AND THE STEP IS THE POINT. `bleed` takes the figure's
270
+ * BOX to the canvas and then gives the inset back as padding, so the content lands exactly where
271
+ * it would have unbled — the frame changes, the thing inside it does not. `spill` takes the box
272
+ * there and lets what is inside run PAST it: a map drawn at half again the column's width, a
273
+ * render whose front wheel leaves the slide, a browser window whose foot is below the canvas.
274
+ *
275
+ * The cover's mosaic proved the move — a grid of pictures cut by two edges reads as a field the
276
+ * slide is a window onto, where the same pictures matted read as a gallery. This is that, for a
277
+ * figure a deck already has.
278
+ *
279
+ * ⚠ IT IS A CROP, SO WHAT IT CROPS HAS TO BE WORTH CROPPING. A photograph spills badly: the
280
+ * subject is what the picture is FOR, and pushing it off the edge is just a smaller photograph.
281
+ * What spills well is a thing with no single subject — a map, a repeating grid, a product render
282
+ * whose silhouette is legible from a third of it, a device or browser frame whose chrome is the
283
+ * shape being shown.
284
+ */
285
+ edge: { type: String, default: 'inset', validator: (e) => ['inset', 'bleed', 'spill'].includes(e) },
286
+ /**
287
+ * The panel's register, for `frame: panel` only — exactly as on `stack`, whose band takes the same
288
+ * four. 'warm' is the deck's own mat; 'subtle' is the quiet grey fill; 'soft' is the accent tint;
289
+ * 'vivid' is the saturated one.
290
+ *
291
+ * ★ IT HAS NO EFFECT ON ANY OTHER FRAME, and that is scoped in the CSS rather than guarded here:
292
+ * every rule is `.split-figure.is-panel.tone-*`, so a photo or a figure carrying a tone renders
293
+ * exactly as it would without one. stack states the same thing about its own.
294
+ */
295
+ background: { type: String, default: 'warm', validator: (t) => ['warm', 'accent', 'vivid', 'neutral'].includes(t) },
296
+ /**
297
+ * The text tier both columns run at — `base` (the deck's body role, 48px) or `sm` (`body-sm`,
298
+ * 36px, the dense tier).
299
+ *
300
+ * ★ THE DEFAULT IS `base`, AND IT USED TO BE THE ONLY BEHAVIOUR — the wrong one. This grid was
301
+ * pinned to body-sm on the reasoning that "two columns halve the measure, so both sides take the
302
+ * dense body tier". The measure being halved is exactly why the TIER should not be: a slide's
303
+ * body is 48px because that is what carries a room, and a column is not a reason for the back row
304
+ * to get 36. A narrow column simply wraps sooner, which is what a column is for.
305
+ *
306
+ * `sm` is the escape hatch for the case that argument does not cover: a slide whose column really
307
+ * does hold more than 48px allows and cannot be split in two. It is a deliberate, per-slide
308
+ * decision now rather than something every split inherited without asking.
309
+ */
310
+ body: { type: String, default: 'base', validator: (b) => ['base', 'sm'].includes(b) },
311
+ /**
312
+ * Where the CONTENT column sits vertically. `anchor` is the deck's own one-third line — the
313
+ * default, and the reason headings do not jump between consecutive split slides. `start`, `center`
314
+ * and `end` are the vocabulary `bleed` and `statement` already use.
315
+ *
316
+ * `body-center` is the fifth and moves the column in two pieces rather than as a block: the
317
+ * HEADING keeps the top and everything under it centres in what is left. It is default.vue's mode
318
+ * of the same name, and it is the COLUMN'S HALF OF A PAIR — set it beside `panelAlign:
319
+ * title-center` and both sides of the slide read the same way, a title at the top with the
320
+ * substance centred in the space below it on each side. See the CSS rule for the auto margins
321
+ * that do it and why there are three of them.
322
+ *
323
+ * ★ THE FIGURE IS UNAFFECTED BY ALL FIVE: it spans `grid-row: 1 / 3`, so it fills the frame
324
+ * whatever the content does. Aligning what sits INSIDE the figure is `panelAlign`, a separate prop
325
+ * for a separate box — its own doc says why they cannot be one.
326
+ */
327
+ align: {
328
+ type: String,
329
+ default: 'anchor',
330
+ validator: (a) => ['anchor', 'start', 'center', 'body-center', 'end'].includes(a),
331
+ },
332
+ /**
333
+ * Where the PANEL's own content sits vertically — `align` for the other half of the slide.
334
+ *
335
+ * Two of the five hold a band the height of the slide's h1 back before aligning what is left, so
336
+ * the panel reads against the column's BODY rather than against the whole column: `title-start`
337
+ * begins where that body begins, and `title-center` centres in the space under the band. Both
338
+ * work on every panel — plain, notched, bleeding — because the band is a margin on the panel's
339
+ * content rather than an addition to a top padding four different rules can decide, and both
340
+ * measure the real heading rather than deriving one, so a two-line title moves them too.
341
+ *
342
+ * There is no `title-end`: a top margin cannot move an item already pinned to the bottom, so it
343
+ * would be `end` under another name.
344
+ *
345
+ * `title-center` has a companion on the other half: `align: body-center` gives the COLUMN the same
346
+ * reading — heading at the top, body centred in what is left — and the two are meant to be set
347
+ * together on a slide that wants both sides to say it.
348
+ *
349
+ * ★★ IT IS A SECOND PROP BECAUSE THEY ARE TWO BOXES, NOT ONE. `align` moves the content column
350
+ * inside the grid band; a panel holds its children inside its own padding box, and on a bleeding
351
+ * panel that box starts lower than the band because the frame reaches the canvas edge and pads to
352
+ * clear the chrome. Two boxes with the same bottom and different tops give two different centres —
353
+ * 18px apart, measured — so a slide whose halves read as ROWS across the seam cannot get there by
354
+ * centring both. Setting both to `end` stands them on the bottom edge they already share, which
355
+ * needs no number and survives one half carrying a heading the other does not.
356
+ *
357
+ * Folding it into `align` was the alternative and would have moved every panel split in the deck,
358
+ * on a prop whose own documentation promises the figure is left alone.
359
+ *
360
+ * ★ SCOPED TO `panel`, as `background` is: it is the frame that holds content in a box of its own. A
361
+ * `code` frame stacks its children in a column, where the vertical axis is `justify-content`
362
+ * rather than `align-items`, and `photo`/`figure`/`content` have nothing to align.
363
+ */
364
+ panelAlign: { type: String, default: 'center', validator: (a) => ['start', 'title-start', 'center', 'title-center', 'end'].includes(a) },
365
+ /**
366
+ * Run the PANEL in the dark register, leaving the rest of the slide light.
367
+ *
368
+ * ★★ A SECOND AXIS, NOT A FIFTH `background`. The four tones are HUES and each already publishes both
369
+ * halves (see PANEL in scripts/deck.ts): warm is peach on light and bark on dark, accent is the
370
+ * quiet green field either way. So "dark" is not a ground to pick, it is which half of the ground
371
+ * you picked gets used — and folding it into `background` would have made one value mean a register
372
+ * while the other four meant colours.
373
+ *
374
+ * It works by putting `.deck-dark` on the panel element itself. That class is a plain class in
375
+ * packages/tokens/deck/deck.css — bound alongside `.dark`, not scoped to a slide root — so every
376
+ * `--deck-*` role is re-declared AT the panel and everything inside inherits the dark values:
377
+ * prose, chips, alerts, charts, anything reading the roles. Nothing has to opt in a second time.
378
+ *
379
+ * ★ SCOPED TO THE FRAMES THAT BRING A FILL — `panel` and `code` — exactly as `background` is scoped to
380
+ * `panel`, and for a stronger reason. Those two paint a ground, so flipping the register repaints
381
+ * it under the ink. `photo`, `figure` and `content` bring no fill at all: the class would flip the
382
+ * INK to the dark register's near-white and leave it on the slide's own light ground.
383
+ *
384
+ * On `code` it is the whole point of a dark code surface: the ground is --slidev-code-background,
385
+ * which is register-aware, so the panel becomes the DARK well (--neutral-900) rather than a dark
386
+ * tint of the light one.
387
+ *
388
+ *
389
+ * ★★ AND THE RUNNING CHROME FOLLOWS IT, PER END. A bleeding dark panel leaves no ring of ground
390
+ * for the chrome to sit on, so the row lands directly on it and half the row is then over a dark
391
+ * ground while the other half is over a light one. Measured before that was answered: the page
392
+ * marker painted the LIGHT pill (forest-700) on forest-900, 1.09:1. `darkChromeParts()` in
393
+ * ../notch.js decides which end flips, from this same frontmatter, so the layout and the chrome
394
+ * cannot disagree about where the dark half is.
395
+ * The whole slide going dark is a different thing and has its own spelling: `dark: true`, which
396
+ * every layout that can carry a register now takes — `default`, `bleed`, `divider`, `split` and
397
+ * `stack`. `class: deck-dark` still works and is what the prop writes.
398
+ */
399
+ panelDark: { type: Boolean, default: false },
400
+ /**
401
+ * Draw this slide on the dark register. Writes `deck-dark` on the layout root — the same class,
402
+ * the same element, said as a prop the layout documents rather than a raw class name an author
403
+ * had to know. `default` and `bleed` already spelled it this way; split did not, so a split slide
404
+ * was the one place in the deck where the register had to be written as `class: 'deck-dark'`.
405
+ *
406
+ * ★★ NOT `panelDark`, WHICH IS THE PROP ABOVE AND A DIFFERENT AXIS. That one darkens the PANEL's
407
+ * own fill on an otherwise light slide — a dark card on cream. This darkens the SLIDE, and the
408
+ * panel follows it like everything else. Both can be set; they answer different questions.
409
+ *
410
+ * ⚠ A LIVE MAP DOES NOT FOLLOW IT. The register is CSS custom properties and a Mapbox basemap is
411
+ * painted in WebGL from its own style, so a `dark` split slide carrying a map also needs
412
+ * `light-preset: night` on the map. DeckMap's own prop doc says the same from the other side.
413
+ */
414
+ dark: { type: Boolean, default: false },
415
+ /** 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. */
416
+ image: { type: String, default: '' },
417
+ /** Alt text — for `image` when there is one, and the label the `<Placeholder>` carries when there is not. */
418
+ alt: { type: String, default: '' },
419
+ /**
420
+ * TWO pictures instead of a picture and a column of prose — `[{ image, alt }, { image, alt }]`,
421
+ * content side first. One `image` is a figure; two here is a PAIR, and the content column becomes
422
+ * the second one.
423
+ *
424
+ * ★★ THE SAME KEY SHAPE THE MOSAIC USES, rather than a directional `imageLeft`. "Left" flips with
425
+ * `flip`, so a slide that set it and then mirrored would have had its two pictures swap meaning
426
+ * silently; content-first is the order every other prop on this layout is read in (`ratio` is
427
+ * content-first, and so is `frame`).
428
+ *
429
+ * ⚠ TWO, THREE OR FOUR. Two is a picture per column; three stacks the extra in the figure column;
430
+ * four is two and two. The split is `floor(n / 2)` to the content side, so the figure column — the
431
+ * one with no measure to protect — takes the odd one. One entry is `image` with extra steps and a
432
+ * fifth has nowhere to go without a grid this layout does not have, so the list clamps at four.
433
+ *
434
+ * ★ THE SLOT STILL WINS where it is written: a pair is what an author gets when they hand the
435
+ * layout two pictures and no words, which is what a diptych IS. Text in the default slot alongside
436
+ * a pair renders over the second picture, and that is the author's own decision to make.
437
+ */
438
+ images: { type: Array, default: () => [] },
439
+ /**
440
+ * How the pictures of a set are separated: by the layout's own seam (`gap`, the default) or not at
441
+ * all (`flush`), where they meet and the set reads as one field cut into pieces.
442
+ *
443
+ * ★★ A SECOND AXIS, NOT A THIRD `edge` VALUE. `edge` is about the figure and the CANVAS — where
444
+ * the outer edges land. This is about the pictures and EACH OTHER. They cross: a flush set still
445
+ * insets from the canvas by default, and `edge: bleed` with `seam: gap` is four pictures at the
446
+ * canvas edge with the deck's own seam still between them.
447
+ *
448
+ * ⚠ FLUSH TAKES THE INNER CORNERS OFF AND KEEPS THE OUTER ONES. A rounded corner where two
449
+ * pictures meet draws a lens of ground between them and the set stops reading as one field; the
450
+ * corners on the OUTSIDE are the set's own edge against the slide, and squaring those makes a
451
+ * bleeding rectangle of a thing that has not bled. Inert unless `images` holds a set — a single
452
+ * figure has nothing to be flush WITH.
453
+ */
454
+ seam: { type: String, default: 'gap', validator: (g) => ['gap', 'flush'].includes(g) },
455
+ /** Kicker above the heading. */
456
+ eyebrow: { type: String, default: '' },
457
+ });
458
+
459
+ /*
460
+ task-15-brief.md — THE NOTCH IS CONDITIONAL ON ITS OCCUPANT EXISTING.
461
+
462
+ The cut only happens when the running chrome is actually drawing a mark to put in it: a notch on
463
+ a `chrome: false` slide is a hole with nothing in it, which is the brief's own named failure case.
464
+ The predicate is ../notch.js's, not a second copy — slide-top.vue gates its own render on the
465
+ same function, so the two cannot disagree about whether there is a mark on this slide.
466
+
467
+ Read the same two injections slide-top.vue reads, and for the same reasons documented there:
468
+ `injectionFrontmatter` is the RAW object (not a ref), and `injectionCurrentPage` — never
469
+ useNav().currentPage — is the only page number that is correct on the param-less /print and
470
+ /overview routes the PDF export renders through.
471
+ */
472
+ const frontmatter = inject(injectionFrontmatter, {});
473
+ const currentPage = inject(injectionCurrentPage, computed(() => 1));
474
+ // task-18 — `notch: false` joins the two existing conditions. All three live in ../notch.js so this
475
+ // layout, stack.vue and the chrome cannot disagree about whether there is a cut on this slide.
476
+ /*
477
+ ★ `&& props.edge !== 'bleed'` IS APPLIED HERE RATHER THAN PASSED INTO hasNotch(), deliberately. That
478
+ function answers one question — "does this frame kind sit under the chrome" — for both layouts, and
479
+ it has already been given one argument too many once (a `flip` that made it wrong); a test pins its
480
+ arity at three for that reason. Bleeding is a property of THIS slide's frame, not of the frame KIND,
481
+ so the layout that knows about it is the one that applies it.
482
+ */
483
+ /* ⚠ DECLARED BEFORE THE NOTCH INSTANCES BELOW, and that is load-bearing rather than tidy:
484
+ useNotchCuts() evaluates its `enabled` callback during setup, so a `pairColumns` declared after it
485
+ threw "Cannot access 'pairColumns' before initialization" and the slide rendered EMPTY — layout
486
+ gone, no fallback, one console line. */
487
+ /*
488
+ Content-first, clamped to four: see the prop. `pair` is the switch every rule below reads, so the
489
+ layout asks "is this a set of pictures" once rather than in five places, and the two columns are
490
+ derived from it rather than sliced at each use.
491
+ */
492
+ const pair = computed(() => (props.images.length >= 2 ? props.images.slice(0, 4) : null))
493
+ const pairColumns = computed(() => {
494
+ if (!pair.value) return null
495
+ const n = pair.value.length
496
+ // floor, so an odd picture lands in the FIGURE column — the one with no measure to protect.
497
+ const cut = Math.floor(n / 2)
498
+ return { content: pair.value.slice(0, cut), figure: pair.value.slice(cut) }
499
+ })
500
+ const notched = computed(() =>
501
+ props.edge === 'inset'
502
+ && hasNotch(props.frame, chromeShows(frontmatter, currentPage.value), notchShows(frontmatter)),
503
+ );
504
+
505
+ // The cut, measured. `notched` above still decides WHETHER this panel is cut — chrome showing, a
506
+ // real media side, `notch: false` not set — and this decides what shape the cut is, from whatever
507
+ // running chrome actually lands on the panel: the mark or a co-brand pair at one top corner, the
508
+ // page marker at the bottom, a section pill on a band. styles/base.css's own single-notch rule is
509
+ // what paints the first frame; this replaces it on mount. See ../useNotchCuts.js.
510
+ const figureEl = ref(null);
511
+ const { clip: notchClip } = useNotchCuts(figureEl, { enabled: () => notched.value });
512
+ /*
513
+ ★★ THE PAIR'S OTHER PICTURE IS A FIGURE TOO, SO IT GETS ITS OWN CUT. Jose: "it should have the same
514
+ behaviour of the right side... same padding and all... and should notch". A picture standing where
515
+ the prose would is not a content column with an image in it — it is the second half of one object,
516
+ and half an object that keeps its square corner under the running mark is the tell.
517
+
518
+ Both instances are enabled on the same condition: useNotchCuts seats whatever actually TOUCHES the
519
+ box and returns null when nothing does, so the left column cuts for the section pill at its bottom
520
+ corner and the right one for the mark at its top, without either being told which is which.
521
+ */
522
+ const pairEl = ref(null);
523
+ const { clip: pairClip } = useNotchCuts(pairEl, { enabled: () => notched.value && !!pairColumns.value });
524
+
525
+ /*
526
+ ── `panelAlign: title-start` — the band, MEASURED ─────────────────────────────────────────────
527
+ The same trade useNotchCuts.js makes above, for the same reason: the number cannot be derived.
528
+
529
+ The CSS rule states a heading's height from the display role's own tokens, and that is right for a
530
+ one-line title on a plain panel. It is wrong twice over otherwise — a two-line title is a line
531
+ short, and a panel's top reservation is decided by whichever of four rules wins (card-pad, the
532
+ notch's max(), a bleeding panel's inset-text, or base.css's chrome clearance), which no expression
533
+ on this side can know. What the rung actually wants is "begin where the column's body begins", and
534
+ only the DOM can answer that.
535
+
536
+ ★ MEASURED OFF THE HEADING, NEVER OFF THE BODY. This margin is what moves the body, so reading the
537
+ body back would be circular — the second measurement would include the first.
538
+
539
+ ★ THE CSS VALUE IS THE FALLBACK, not a duplicate. It paints the first frame and it is what an
540
+ export gets if this never runs, so the rung degrades to the derived band rather than to nothing.
541
+
542
+ The scale is read off the figure itself (rect width against offset width) because the slide is
543
+ drawn inside a transform: rects come back in painted pixels and the margin is authored in canvas
544
+ ones, and mixing the two silently scales the band.
545
+ */
546
+ const contentEl = ref(null);
547
+ const band = ref(null);
548
+ /*
549
+ ⚠ THE HEADING IS OBSERVED, NOT THE COLUMN, and the difference is the whole reliability of this.
550
+ `.split-content` is a grid item in a `minmax(0, 1fr)` row, so its BOX does not change when the
551
+ heading inside it rewraps — a ResizeObserver on the column never fires. Caught by driving a
552
+ one-line title to four in the running deck: the band stayed at its one-line value and the panel
553
+ sat 288px above the body it was supposed to meet.
554
+ */
555
+ let observedHeading = null;
556
+
557
+ const measureBand = () => {
558
+ const fig = figureEl.value;
559
+ const col = contentEl.value;
560
+ if (!fig || !col || !BANDED_ALIGNS.has(props.panelAlign) || props.frame !== 'panel') {
561
+ band.value = null;
562
+ return;
563
+ }
564
+ const heading = col.querySelector('h1, hgroup');
565
+ if (!heading) {
566
+ band.value = null;
567
+ return;
568
+ }
569
+ if (heading !== observedHeading) {
570
+ if (observedHeading) observer?.unobserve(observedHeading);
571
+ observer?.observe(heading);
572
+ observedHeading = heading;
573
+ }
574
+ const scale = fig.getBoundingClientRect().width / fig.offsetWidth || 1;
575
+ const figPad = parseFloat(getComputedStyle(fig).paddingTop) || 0;
576
+ const headingGap = parseFloat(getComputedStyle(heading).marginBottom) || 0;
577
+ const toBody = (heading.getBoundingClientRect().bottom - fig.getBoundingClientRect().top) / scale;
578
+ band.value = Math.max(0, Math.round(toBody + headingGap - figPad));
579
+ };
580
+
581
+ let observer = null;
582
+ onMounted(() => {
583
+ observer = new ResizeObserver(measureBand);
584
+ if (figureEl.value) observer.observe(figureEl.value);
585
+ if (contentEl.value) observer.observe(contentEl.value);
586
+ measureBand();
587
+ });
588
+ onBeforeUnmount(() => {
589
+ observer?.disconnect();
590
+ observer = null;
591
+ observedHeading = null;
592
+ });
593
+ watch(() => [props.panelAlign, props.frame, props.ratio, props.edge], measureBand);
594
+
595
+ const bandStyle = computed(() => (band.value === null ? null : { '--panel-align-band': `${band.value}px` }));
596
+
597
+ /*
598
+ CO-BRANDING. When the slide declares a `companion:`, the running chrome draws a wider lockup and
599
+ this cut has to be exactly that much wider. Both sides call the same pure function on the same
600
+ frontmatter (../notch.js's coBrandFitted()), so the occupant and the cut cannot be pinned to
601
+ different numbers.
602
+
603
+ That function CLAMPS the occupant to what this ratio's panel can actually seat, which is why it
604
+ takes the page number and the whole token file rather than just `chrome`. See ../notch.js's
605
+ notchBudgetPx().
606
+
607
+ Set INLINE, and only when there is a companion: `--deck-notch-occupant-w` is deliberately left
608
+ undeclared otherwise so a `class:` override on the slide still reaches the mask's own
609
+ var(…, var(--deck-chrome-mark)) fallback. See the same note in slide-top.vue.
610
+ */
611
+ const companion = computed(() => coBrandFitted(frontmatter, currentPage.value, deckTokens));
612
+ const figureStyle = computed(() =>
613
+ notched.value && companion.value
614
+ ? { '--deck-notch-occupant-w': `${companion.value.occupantPx}px` }
615
+ : undefined,
616
+ );
617
+ </script>
618
+
619
+ <template>
620
+ <div
621
+ class="slidev-layout split h-full flex flex-col"
622
+ :class="[
623
+ RATIO_CLASS[ratio] ?? 'is-1-1',
624
+ flip ? 'is-right' : 'is-left',
625
+ FRAME_CLASS[frame] ?? 'is-figure',
626
+ ALIGN_CLASS[align] ?? 'is-align-anchor',
627
+ PANEL_ALIGN_CLASS[panelAlign] ?? 'is-panel-align-center',
628
+ BODY_CLASS[body] ?? 'is-body-base',
629
+ edge === 'bleed' || edge === 'spill' ? 'is-bleed' : '',
630
+ edge === 'spill' ? 'is-spill' : '',
631
+ pair && seam === 'flush' ? 'is-flush' : '',
632
+ dark ? 'deck-dark' : '',
633
+ ]"
634
+ >
635
+ <!--
636
+ Header and footer are GRID ITEMS, not siblings of the grid — see `.split-grid`'s own comment
637
+ for the anchor arithmetic that requires it.
638
+ -->
639
+ <div class="split-grid">
640
+ <!--
641
+ ⚠ A FIGURE, NOT A CONTENT COLUMN WITH PICTURES IN IT. It carries the figure's own classes so
642
+ every rule that shapes the other half — the claw-back to the media inset, the card corner,
643
+ the cover crop, the cut — applies to this one by construction rather than by being restated.
644
+ `is-pair` moves it into the CONTENT column's track and mirrors the one thing that is
645
+ handed: which of its edges is the outer one.
646
+ -->
647
+ <div
648
+ v-if="pairColumns"
649
+ ref="pairEl"
650
+ class="split-figure is-photo is-pair"
651
+ :class="[
652
+ edge === 'bleed' || edge === 'spill' ? 'is-bleed' : '',
653
+ edge === 'spill' ? 'is-spill' : '',
654
+ notched ? 'has-notch deck-notch' : '',
655
+ ]"
656
+ :data-notch-corner="notched ? (flip ? 'right' : 'left') : undefined"
657
+ :style="pairClip ? { clipPath: pairClip } : null"
658
+ >
659
+ <img v-for="(p, i) in pairColumns.content" :key="i" :src="assetUrl(p.image)" :alt="p.alt">
660
+ </div>
661
+ <div v-else ref="contentEl" class="split-content">
662
+ <p v-if="eyebrow" class="deck-eyebrow">{{ eyebrow }}</p>
663
+ <slot />
664
+ </div>
665
+ <!--
666
+ `has-notch` is this LAYOUT's marker (its own margin/reservation rules key off it, and
667
+ scripts/deckAudit.ts counts it); `deck-notch` is the SHARED cut, one rule in styles/base.css
668
+ that stack.vue's band wears too. `data-notch-corner` is which of the figure's own corners is
669
+ the outer one — physical, never logical, because "outer" flips with `flip`.
670
+ -->
671
+ <div
672
+ ref="figureEl"
673
+ class="split-figure"
674
+ :class="[FRAME_CLASS[frame] ?? 'is-figure', TONE_CLASS[background] ?? 'bg-warm', edge === 'bleed' || edge === 'spill' ? 'is-bleed' : '', edge === 'spill' ? 'is-spill' : '', panelDark && (frame === 'panel' || frame === 'code') ? 'deck-dark' : '', notched ? 'has-notch deck-notch' : '']"
675
+ :data-notch-corner="notched ? (flip ? 'left' : 'right') : undefined"
676
+ :style="[figureStyle, notchClip ? { clipPath: notchClip } : null, bandStyle]"
677
+ >
678
+ <slot name="aside">
679
+ <!-- ⚠ NO WRAPPING TEMPLATE TAG HERE, and that is about a gate rather than about Vue. The
680
+ anchor test slices this file from the opening template tag to the first CLOSING one to
681
+ find the footer inside the grid, so an inner one ends the slice early and the gate
682
+ reports the footer as missing from a layout that has not moved it. (Spelling the tag
683
+ out in this comment does it too — which is why it is described rather than written.)
684
+ A `v-for` over an empty list renders nothing, so the three cases sit as siblings. -->
685
+ <img v-for="(p, i) in (pairColumns?.figure ?? [])" :key="i" :src="assetUrl(p.image)" :alt="p.alt">
686
+ <img v-if="!pairColumns && frame === 'photo' && image" :src="assetUrl(image)" :alt="alt">
687
+ <Placeholder v-if="!pairColumns && frame === 'photo' && !image" :label="alt || 'Image'" />
688
+ </slot>
689
+ </div>
690
+ <div v-if="$slots.footer" class="split-footer"><slot name="footer" /></div>
691
+ </div>
692
+ </div>
693
+ </template>
694
+
695
+ <style scoped>
696
+ /*
697
+ ★★ NO VERTICAL PADDING RESET ANY MORE. This used to zero `padding-top`/`padding-bottom` so that
698
+ `--deck-anchor-third`, then written `calc(100% / 3)`, resolved against a full-canvas box. The
699
+ token is a canvas pixel now, so the box it sits in no longer has to be the canvas — and the layout
700
+ keeps the same four-sided inset every other layout gets from base.css.
701
+
702
+ That reset was the root of a whole class of bug: with the root carrying no vertical padding, every
703
+ region inside had to re-add its own edge clearance, and nothing enforced that it be right.
704
+ `.split-content` reserved 96px of bottom clearance unconditionally, so the first slide to use the
705
+ footer slot had a column 96px shorter than it needed sitting 120px above its own footer. Regions
706
+ no longer own edge clearance at all; the grid box IS the safe area.
707
+ */
708
+ .slidev-layout.split {
709
+ /*
710
+ Which grid COLUMN each half lands in. Declared once here, on the root, rather than as an
711
+ `order` on each item: `order` only works on auto-placed items, and header/footer now have to be
712
+ placed EXPLICITLY (see `.split-grid` below), which means the content and figure columns have to
713
+ be nameable from those rules too. Physical column numbers, not logical ones — which track is
714
+ "the content's" flips with `flip`, never with writing direction.
715
+ */
716
+ --split-content-col: 1;
717
+ --split-figure-col: 2;
718
+ }
719
+ .slidev-layout.split.is-right {
720
+ --split-content-col: 2;
721
+ --split-figure-col: 1;
722
+ }
723
+ .split-grid {
724
+ flex: 1;
725
+ display: grid;
726
+ column-gap: 0; /* task-14b-brief.md — the DEFAULT is "no gutter"; `.is-content` below restores one */
727
+ min-height: 0; /* lets the row shrink instead of overflowing the slide */
728
+ /*
729
+ task-14b-brief.md — THE VERTICAL ANCHOR. Jose: "text side should always start at 2/3 of the
730
+ slide vertically, not centered, so the headings dont jump between slides" — confirmed as the
731
+ one-third line, y = 360 on the 1080 canvas. Replaces the old `align-content: center` (which, on
732
+ the single `minmax(0, 1fr)` row task-13-brief.md pinned to full height, had nothing left to
733
+ centre — a row that already fills 100% of the container can't be moved within it by
734
+ `align-content` at all; this task looked closer and found that rule was dead code, not merely
735
+ the wrong VALUE).
736
+
737
+ Two explicit rows instead of one:
738
+ row 1 the remainder a spacer — nothing is placed in it directly.
739
+ row 2 minmax(0, 1fr) `.split-content` lives here, so its box starts exactly where
740
+ row 1 ends.
741
+
742
+ ★★ ROW 1 IS THE ANCHOR MINUS THE ROOT'S OWN INSET, and the subtraction is the whole point.
743
+ `--deck-anchor-third` is a canvas coordinate — 360px, measured from the top of the SLIDE. The
744
+ grid box does not start there; it starts at the text inset, because this layout is an ordinary
745
+ padded box now. So the spacer only has to cover what is left between them. Naming the anchor
746
+ bare here would place the column at 456.
747
+
748
+ It was bare, and correct, while the token was `calc(100% / 3)` and this layout zeroed its own
749
+ vertical padding to make the grid box and the canvas the same thing. Both halves of that went
750
+ together: a percentage needs the canvas underneath it, and a layout with no padding makes every
751
+ region inside it responsible for its own edge clearance. See the note at the top of this
752
+ stylesheet for the bug that came out of it.
753
+
754
+ `.split-figure` spans BOTH rows (below) rather than living in one alongside the spacer, so it
755
+ still fills its own cell top to bottom — a photo must not float with dead space above it just
756
+ because the TEXT column has a fixed start. It needs no margin of its own to do that: the cell's
757
+ top edge already sits at the safe line. Where it reaches PAST that line — a notched figure going
758
+ to the chrome inset — it does so with a negative MARGIN, not padding, which would draw inside
759
+ `.is-photo`'s own border-radius/overflow box and shrink the visible photograph rather than
760
+ positioning the frame in its cell (and cannot be negative at all).
761
+
762
+ `column-gap` only, never the `gap` shorthand: `gap` sets row-gap too, and a row-gap between rows
763
+ 1 and 2 would push row 2's start past the anchor, throwing off the one figure this whole
764
+ mechanism exists to hit exactly.
765
+
766
+ ★★ THE HEADER/FOOTER DEFECT, and why there is a THIRD row now (task-15, fixing what task-14b
767
+ shipped). Back when the anchor was `calc(100% / 3)` it resolved against THIS grid's box — not
768
+ against the canvas. task-14b arranged for those to be the same thing by zeroing the layout's
769
+ vertical padding, but only for a bare split: `::footer::` used to render as a flex SIBLING of
770
+ this grid, below it, so on a slide carrying one the grid was shorter than the canvas and a third
771
+ of it landed somewhere else entirely — the text column started at y ~ 400, not 360. The anchor
772
+ exists so "the headings dont jump between slides" (Jose); the token was a contract that held
773
+ only over the input it was tested on.
774
+
775
+ A pixel anchor is immune to that particular failure, but the row placement below is NOT
776
+ decoration left over from it: the footer is still a grid item because an `auto` row is the only
777
+ thing that keeps a footer's height off the flexible row above it.
778
+
779
+ The fix is to place the slot INSIDE the grid instead of beside it, so nothing can shorten it:
780
+
781
+ row 1 --deck-anchor-third empty now that there is no header slot. Kept as a fixed length
782
+ because it IS the anchor: `.split-content` starts where this row
783
+ ends, and `align` is the prop that collapses it on purpose.
784
+ row 2 minmax(0, 1fr) `.split-content` — its box starts exactly at the anchor.
785
+ row 3 auto the footer, spanning both columns. An `auto` row takes its
786
+ height from the FLEXIBLE row above it, never from row 1's fixed
787
+ length, so a footer cannot move the anchor either.
788
+ */
789
+ /*
790
+ ★ THE ANCHOR MINUS THE INSET, because the grid now starts at the padded origin rather than at
791
+ the canvas edge. `--deck-anchor-third` is a CANVAS position (360) — that is what the ruler draws
792
+ and what scripts/deckAudit.ts asserts — so inside a box already inset by the text inset, row 1
793
+ is the difference. Written as the subtraction rather than as 264 so retuning either token moves
794
+ it, and so the reader can see which two numbers it is between.
795
+ */
796
+ grid-template-rows: calc(var(--deck-anchor-third) - var(--deck-inset-text)) minmax(0, 1fr) auto;
797
+ }
798
+ /*
799
+ ★★ THERE IS NO `header` SLOT, AND THE REMOVAL IS THE DESIGN DECISION IT LOOKS LIKE. It existed to
800
+ put a title in its own band above the content row, and it did two different things depending on
801
+ `frame` — spanning both columns at `content`, confined to the content column otherwise.
802
+
803
+ Both jobs turned out to be covered better elsewhere. A full-width title over an evenly divided
804
+ body is `default` plus a two-column `::grid`: measured against the split version, same title
805
+ position, same column top, and grid's columns 18px wider for having the narrower gutter — and the
806
+ h1 spans because an h1 spans, not because of a conditional rule. A title above the content column
807
+ with a figure running full height beside it is `align: start`: measured on the same content and the
808
+ same map, the two differed by 40px of title position and nothing else.
809
+
810
+ It also carried real weight for one slide's use: a conditional span, a row-sizing rule, a branch in
811
+ the audit's anchor gate, and a constant in that gate predicting where the content would start —
812
+ which assumed a one-line title, failed on a two-line one, and cost a misdiagnosis that outlived it.
813
+ */
814
+ .split-footer {
815
+ grid-row: 3;
816
+ grid-column: 1 / -1;
817
+ }
818
+ /*
819
+ THE CONDITIONAL GUTTER. `frame` tells split.vue which case this is — `photo`/`panel`/`figure`
820
+ mean a real figure (mixed: text meets media, no gutter, the default above, with the clearance
821
+ living in the content column's padding instead); `content` means the far slot is a second CONTENT
822
+ column (same-kind: text meets text), which takes a real gutter because there is no figure edge to
823
+ separate the two.
824
+
825
+ ★ 96, NOT THE 36 task-14b ASKED FOR. Jose's original rule was "both sides text or both sides
826
+ image: gutter 36px", and it stood until the ruler made the pairing visible: 36px is 1.00em at the
827
+ deck's 36px body, while text beside a figure was getting 2.67em. The case with no edge between
828
+ the columns had less than half the air of the case that has one. Both are `--deck-inset-text`
829
+ now — see the token's own note in scripts/deck.ts.
830
+ */
831
+ .is-content .split-grid {
832
+ column-gap: var(--deck-split-gutter-same);
833
+ }
834
+ /*
835
+ ── WHERE THE CONTENT COLUMN SITS VERTICALLY ───────────────────────────────────────────────────
836
+ Row 1 is the anchor: at `anchor` it is the one-third line and the content starts there. The other
837
+ three collapse it to 0 so row 2 spans the whole frame, and then `align-self` decides where in that
838
+ space the column sits. Three of the four therefore share one row change and differ by one line.
839
+
840
+ ★ THE FIGURE DOES NOT MOVE, and that is what makes this safe to add. `.split-figure` spans
841
+ `grid-row: 1 / 3`, so it fills the frame no matter what row 1 is — a map stays full-height beside
842
+ a heading pushed to the bottom. Measured on all four values before this was committed.
843
+
844
+ */
845
+ .is-align-center .split-grid,
846
+ .is-align-end .split-grid {
847
+ grid-template-rows: 0 minmax(0, 1fr) auto;
848
+ }
849
+ /*
850
+ ★★ `start` TAKES THE TEXT INSET, NOT 0, AND THE FIRST CUT GOT THIS WRONG. Collapsing row 1 to
851
+ zero for all three put the heading at canvas y 0 — hard against the edge with no clearance at
852
+ all — because split's ROOT carries no vertical padding: the anchor row was the only thing holding
853
+ the column off the top, so removing it removed the inset too. Measured before the fix: heading top
854
+ 0 against the 96px every other edge of the same column keeps.
855
+
856
+ center and end are unaffected by that trap: they push the column away from the top edge anyway,
857
+ and its own padding-bottom holds the other end.
858
+ */
859
+ /*
860
+ ★ ZERO, NOT THE TEXT INSET — the grid already starts at it. This was `var(--deck-inset-text)` back
861
+ when split's root had no vertical padding and row 1 was the only thing holding the column off the
862
+ edge. The root pads now, so naming the inset here applied it twice and put the column at 192.
863
+ `start` means "no anchor row at all"; the clearance is the root's, like every other layout's.
864
+ */
865
+ /*
866
+ `body-center` collapses the anchor row for the same reason `start` does, and needs it more: its
867
+ whole premise is that the HEADING holds the top, so leaving the one-third row in place starts the
868
+ column 224px down and the free space it means to distribute is spent before it begins. Measured
869
+ before this rule: the column opened at 360 and its last block ran to 1014, 30px past the safe line.
870
+ */
871
+ .is-align-start .split-grid,
872
+ .is-align-body-center .split-grid {
873
+ grid-template-rows: 0 minmax(0, 1fr) auto;
874
+ }
875
+ /*
876
+ ★ `safe`, BECAUSE PLAIN `center` OVERFLOWS BOTH ENDS. Row 2 is `minmax(0, 1fr)` against a fixed
877
+ frame, so it never grows to fit the column: a column taller than 888 centres by running half its
878
+ surplus PAST THE TOP INSET, and the h1's own border box is the first thing outside the safe area.
879
+ Caught on the bled-code slide, whose three-line heading makes the column 892.75 — 2.375px out at
880
+ each end, which the audit prints as "2px past the top inset". `safe` falls back to `start` only
881
+ when the subject overflows, so a column that fits is untouched (measured: fitting column, top 244
882
+ either way; overflowing column, -2.375 unsafe against 0 safe).
883
+
884
+ ★ THE SURPLUS THEN ALL GOES TO THE BOTTOM, where the last block's own margin absorbs it — 16px on
885
+ a `p`, which is what every one of these slides ends in. A column ending in a block with NO bottom
886
+ margin would put the whole surplus past 984 rather than half of it.
887
+ */
888
+ .is-align-center .split-content { align-self: safe center; }
889
+ .is-align-end .split-content { align-self: end; }
890
+ /*
891
+ ★★ `body-center` — THE HEADING HOLDS THE TOP AND EVERYTHING UNDER IT CENTRES IN WHAT IS LEFT. It is
892
+ default.vue's mode of the same name, on this layout's own column, and it pairs with the panel's
893
+ `title-center`: set both and the two halves of a split read the same way — a title at the top of
894
+ the slide, the substance centred in the space below it on each side.
895
+
896
+ The three autos are default.vue's, and so is the reason for the third. Flexbox splits free space
897
+ EQUALLY between auto margins, so the ratio comes from HOW MANY there are rather than from a
898
+ length: two give 1:1, and the `::after` makes it 1:2, which rides the body one sixth of the free
899
+ space above centre. The eye judges centring against the FRAME, and the heading occupying the top
900
+ is what a plain split cannot know about.
901
+
902
+ ★ THE COLUMN MUST STRETCH, which is why no `align-self` is set here. The two rungs above pin the
903
+ column to a point in the row, leaving it its own height; auto margins need the row's full height
904
+ or there is no free space for them to absorb and the mode silently degrades to `start`.
905
+
906
+ ★ GATED ON A HEADING, by construction rather than by a guard: with no `h1` the sibling selector
907
+ matches nothing, `:has()` is false, no auto is handed out, and the column stays top-anchored —
908
+ which is right, because there is no heading holding the top to centre underneath.
909
+ */
910
+ .is-align-body-center .split-content {
911
+ display: flex;
912
+ flex-direction: column;
913
+ }
914
+ .is-align-body-center .split-content > :is(h1, hgroup) + * { margin-top: auto; }
915
+ .is-align-body-center .split-content:has(> :is(h1, hgroup)) > :last-child { margin-bottom: auto; }
916
+ .is-align-body-center .split-content:has(> :is(h1, hgroup))::after {
917
+ content: '';
918
+ margin-top: auto;
919
+ }
920
+ /* The panel's own vertical alignment — see `panelAlign` for why it is a second prop rather than a
921
+ fifth value of `align`. All four are written out, and the base `.is-panel` rule below no longer
922
+ carries `align-items`, so this is the ONE place a panel's cross-axis is decided. */
923
+ .is-panel-align-start .split-figure.is-panel { align-items: flex-start; }
924
+ .is-panel-align-center .split-figure.is-panel { align-items: center; }
925
+ .is-panel-align-end .split-figure.is-panel { align-items: flex-end; }
926
+ /*
927
+ ★★ THE TWO TITLE RUNGS ARE ONE CONSTRUCTION, and the only difference is what they do with the
928
+ space under the band. The panel becomes a COLUMN: the band is a `::before` item, then
929
+ `title-start` parks what follows at the top of it, while `title-center` hands the remainder to
930
+ three auto margins that flexbox splits equally — 1:2, the same sixth of a lift `align: body-center`
931
+ takes, which is the whole reason those two are usable as a pair.
932
+
933
+ ★ THE BAND IS MEASURED; the value here is the FALLBACK. It paints the first frame and is what an
934
+ export gets if the script never runs — see the script's own note for why it cannot be derived.
935
+
936
+ ⚠ COLUMN MEANS THE AXES SWAP. Horizontal alignment moves from `justify-content` to `align-items`,
937
+ and the autos need a main axis to absorb, so both are restated here rather than left to the base
938
+ rule, which is a row.
939
+
940
+ ⚠ HORIZONTAL IS UNTOUCHED — these rungs centre like every other panel. Making them `stretch`
941
+ instead was a real defect and the ladder is what caught it: the three plain rungs shrink-wrap their
942
+ occupant to 206px and these two blew it out to the full 860, so a ladder meant to move ONE variable
943
+ moved position AND width, and a reader could not tell which they were seeing. A half that needs to
944
+ fill its width asks for it where that belongs — Grid's `justify`, which takes the width it needs to
945
+ centre across.
946
+
947
+ There is no `title-end`: the band would sit above an item already pinned to the bottom, so it would
948
+ render identically to `end`. Simulated before ruling it out.
949
+ */
950
+ .is-panel-align-title-start .split-figure.is-panel,
951
+ .is-panel-align-title-center .split-figure.is-panel {
952
+ flex-direction: column;
953
+ align-items: center;
954
+ justify-content: flex-start;
955
+ }
956
+ .is-panel-align-title-start .split-figure.is-panel::before,
957
+ .is-panel-align-title-center .split-figure.is-panel::before {
958
+ content: '';
959
+ flex: none;
960
+ height: var(--panel-align-band, calc(
961
+ var(--type-deck-display-size) * var(--type-deck-display-line-height) + var(--deck-heading-bottom-space)
962
+ ));
963
+ }
964
+ .is-panel-align-title-center .split-figure.is-panel > * {
965
+ margin-top: auto;
966
+ margin-bottom: auto;
967
+ }
968
+ .is-panel-align-title-center .split-figure.is-panel::after {
969
+ content: '';
970
+ margin-top: auto;
971
+ }
972
+ .split-content {
973
+ /* Row 2 only — this is what makes the text column start exactly at the anchor line above. */
974
+ grid-row: 2;
975
+ grid-column: var(--split-content-col);
976
+ }
977
+ /*
978
+ The text column's INNER inset, against a figure.
979
+
980
+ Jose's rule is two clauses, and only the first was implemented: "text one side, image other side:
981
+ no gutter, and text side should have 90px margin horizontally". The zero gutter is a property of
982
+ the GRID (column-gap: 0, above); it says nothing about what the text column owes on its own edges.
983
+ The outer edge gets --deck-inset-text for free from the layout root's horizontal padding, so the
984
+ omission was invisible there — but the inner edge sits on the grid column boundary, which is
985
+ exactly the figure's edge. Measured on deck-template 18 before this rule: the figure ends at x=786
986
+ and `.split-content` began at x=786, so every line of body copy started flush against the
987
+ photograph while the same paragraph's right edge held a clean 90.
988
+
989
+ ★★ WRITTEN AS `:not(.is-content)`, NOT AS A LIST OF THE FIGURE KINDS, and that is the whole
990
+ point of the rule rather than a tidier way to spell it. It WAS a list — `.is-photo, .is-panel` —
991
+ and `.is-figure` was simply never added to it, so text ran flush into a map with nothing between
992
+ them: content padding 0, and 0px from the last letter to the figure's edge, against the 96px every
993
+ photo and panel got. It hid while `content` was split's default, because those slides took the
994
+ 36px same-kind gutter instead, which looked like clearance and is a different mechanism; making
995
+ `figure` the default (ae99c03) is what exposed it.
996
+
997
+ An allow-list of variants is a thing a new variant falls off, silently, and this one did. Every
998
+ other layout in this theme is immune to the same mistake by construction: stack puts its 40px
999
+ between band and strip on the GRID as a row-gap, quote puts its 120px on the flex container, and a
1000
+ gap on the parent is inherited by every variant there will ever be. Split cannot use a gap — the
1001
+ gutter is zero for a figure precisely BECAUSE the text column carries the clearance itself — so
1002
+ the next best thing is to state the ONE exception and let everything else be covered by default.
1003
+
1004
+ The exception is real: a `frame: content` split is text meeting text and already has a genuine
1005
+ 96px gutter doing this job. Adding an inset there too would put 96 + 96 + 96 between two columns
1006
+ of prose — nearly eight ems, which is a chasm rather than a gutter.
1007
+ */
1008
+ .is-left:not(.is-content) .split-content { padding-right: var(--deck-inset-text); }
1009
+ .is-right:not(.is-content) .split-content { padding-left: var(--deck-inset-text); }
1010
+ /*
1011
+ ★★ THE ANCHOR ANCHORS THE HEADING, NOT THE COLUMN — and until this rule it only did the latter.
1012
+
1013
+ `.split-content`'s own top is y=360 on all 142 split slides in the three decks; the token is
1014
+ correct and always was. But an `eyebrow:` renders INSIDE that column, above the heading, so on a
1015
+ slide that carries one the HEADING landed 41px lower. Measured across deck-showcase's split
1016
+ slides: 112 with no eyebrow put their h1 at y=360, 12 with an eyebrow put it at y=401. Slides 13
1017
+ → 16 and 41/42 → 43 are near-consecutive, so the jump is visible on the click-through. The
1018
+ anchor's stated reason is Jose's — "so the headings dont jump between slides" — and on an eyebrow
1019
+ slide they still jumped. The mechanism held; the purpose failed. (scripts/deckAudit.ts asserts
1020
+ `.split-content`'s own top and nothing else, which is why it reported 0 anchor failures.)
1021
+
1022
+ The eyebrow is therefore HUNG ABOVE THE ANCHOR: it is pulled up by exactly its own block
1023
+ (--deck-eyebrow-block, base.css — line box + the gap it holds below itself), so the element after
1024
+ it starts at the anchor whether it is there or not. Two properties this deliberately keeps:
1025
+
1026
+ · `.split-content`'s own box top is UNMOVED at 360 — the negative margin is on the eyebrow, not
1027
+ on the column — so every existing measurement of the anchor still reads 360.
1028
+ · The eyebrow does not move when the heading changes size, because the lift is the eyebrow's own
1029
+ height, not the heading's.
1030
+
1031
+ */
1032
+ .split-content > .deck-eyebrow:first-child {
1033
+ margin-top: calc(-1 * var(--deck-eyebrow-block));
1034
+ }
1035
+ .split-figure {
1036
+ /*
1037
+ ★★ A DIAGRAM IN HERE GETS THE COLUMN'S HEIGHT, NOT THE DEFAULT LAYOUT'S. mermaid's fitToColumn
1038
+ caps a tall diagram against a height budget it cannot measure for itself, so it reads this
1039
+ variable and falls back to the 720 a `default` slide leaves under its heading. A figure column
1040
+ carries no heading above it: 1080 canvas less the inset at each end. For a height-bound diagram
1041
+ — every sequence diagram is one — that is a quarter more of the whole drawing.
1042
+ */
1043
+ --deck-diagram-max-h: calc(1080px - 2 * var(--deck-inset-text));
1044
+ /* Rows 1-2 — see the maths note on `.split-grid` above for why this is what "fills its cell top
1045
+ to bottom" actually means once the text column has its own fixed start. Explicitly `1 / 3`,
1046
+ not `1 / -1`: with the footer's own row now in the template, `-1` is the line AFTER it, and a
1047
+ figure spanning into the footer's row would be drawn straight over any footer content. */
1048
+ grid-row: 1 / 3;
1049
+ grid-column: var(--split-figure-col);
1050
+ /*
1051
+ ★★ ZERO, BECAUSE THE ROOT'S OWN PADDING IS THE CLEARANCE NOW. These were both
1052
+ `var(--deck-inset-text)` — the figure paying its own way in a layout whose root had no vertical
1053
+ padding. With the root inset like every other layout, repeating it here would inset the figure
1054
+ twice and land it at 192.
1055
+ */
1056
+ margin-top: 0;
1057
+ margin-bottom: 0;
1058
+ }
1059
+ /*
1060
+ ★★ THE ANCHOR APPLIES UNIFORMLY — task-18-brief.md's ruling, replacing task-15's header-only
1061
+ scoping.
1062
+
1063
+ A `frame: content` figure side is documented as "nothing (a second content column)" — an author's own
1064
+ content, not a figure. It used to span rows 1-2 like a real photo, which put its top at
1065
+ --deck-inset-text (90px) while the text column beside it started at the anchor (360px): two
1066
+ columns of the same KIND, on the same slide, starting 270px apart.
1067
+
1068
+ task-15 built this fix and then reverted it to the header-only case, because the anchor caps a
1069
+ `frame: content` column at 630px instead of 900px and apps/deck-showcase #188 (a card grid in the
1070
+ figure slot) runs to y 1218 with the cap on, y 990 without — probed live, both ways. The ruling
1071
+ reverses that: the anchor exists so "the headings dont jump between slides" (Jose), and a rule
1072
+ whose position depends on what happens to be in the OTHER slot fails that purpose on its own
1073
+ terms. A heading is at 360 or it is not; "360 unless the neighbour is a card grid" is not an
1074
+ anchor. The overflow is one slide's content being too tall for the slot, which is a content
1075
+ problem with a content fix, and is deliberately NOT resolved by weakening the rule here.
1076
+
1077
+ Real photos and panels are unaffected — they still span rows 1-2 and fill their cell, because a
1078
+ photograph floating with dead space above it is a different (and wrong) thing. This is only ever
1079
+ about a second column of CONTENT.
1080
+
1081
+ */
1082
+ .is-content .split-figure {
1083
+ /*
1084
+ The row is the whole rule now. It also said `margin-top: 0`, which was live while `.split-figure`
1085
+ carried the text inset — and is dead the moment the base is 0 too: the only rule that raises a
1086
+ figure is `.has-notch`, and hasNotch() requires a PANEL frame, so it can never match this
1087
+ selector. A declaration that restates the value it is already inheriting reads as a live override
1088
+ to the next person who moves the base, which is worse than not being there.
1089
+ */
1090
+ grid-row: 2;
1091
+ }
1092
+ .is-1-1 .split-grid { grid-template-columns: 1fr 1fr; }
1093
+ /*
1094
+ Every other ratio is content:figure, and the grid's two tracks are POSITIONAL — track 1 is
1095
+ whichever element `order` (below) places there, not "the content one". So the fr values have
1096
+ to swap with `flip` for the share documented on `ratio` to stay attached to CONTENT rather than
1097
+ to a screen position.
1098
+
1099
+ Verified live before this fix: three of the six (ratio, side) combinations that differ were
1100
+ backwards — `ratio="2:3"` at the default `side="left"` put content in the WIDER track (the
1101
+ opposite of "2:3" read as content:figure), because a single un-keyed `.is-2-3` rule fixed the
1102
+ track sizes regardless of which element `order` had moved into them.
1103
+ */
1104
+ .is-3-2.is-left .split-grid { grid-template-columns: 3fr 2fr; }
1105
+ .is-3-2.is-right .split-grid { grid-template-columns: 2fr 3fr; }
1106
+ .is-2-3.is-left .split-grid { grid-template-columns: 2fr 3fr; }
1107
+ .is-2-3.is-right .split-grid { grid-template-columns: 3fr 2fr; }
1108
+ .is-2-1.is-left .split-grid { grid-template-columns: 2fr 1fr; }
1109
+ .is-2-1.is-right .split-grid { grid-template-columns: 1fr 2fr; }
1110
+ .is-1-2.is-left .split-grid { grid-template-columns: 1fr 2fr; }
1111
+ .is-1-2.is-right .split-grid { grid-template-columns: 2fr 1fr; }
1112
+ .is-3-1.is-left .split-grid { grid-template-columns: 3fr 1fr; }
1113
+ .is-3-1.is-right .split-grid { grid-template-columns: 1fr 3fr; }
1114
+ /* `flip` is a pure mirror, and it is expressed ONLY as the two column numbers on the root above —
1115
+ there is no `order` here any more. `order` re-sequences AUTO-placed items, and header/footer are
1116
+ now placed explicitly by line (they have to be: they belong to particular rows), so a mixture of
1117
+ the two would have placed some items by order and others by line against the same tracks. */
1118
+ /*
1119
+ task-14-brief.md, numbers revised by task-14b-brief.md — THE FIGURE ESCAPES TO THE MEDIA INSET,
1120
+ HORIZONTALLY, ON ITS OUTER EDGE ONLY.
1121
+
1122
+ Text is inset for its measure (--deck-inset-text, 90px, from `.slidev-layout`'s own padding —
1123
+ see base.css); media is inset for its frame (--deck-inset-media, 36px) — a photo's hard edge
1124
+ reads as inset too far at the value that reads correctly around a paragraph. `.split-grid` is a
1125
+ descendant of `.slidev-layout` and inherits that 90px padding HORIZONTALLY (vertical is zeroed
1126
+ above, for the anchor), same as the content column, so the figure needs to CLAW BACK the 54px
1127
+ difference on its own outer edge only — the inner edge (against the gap that separates it from
1128
+ content) stays put, and the CONTENT side never moves at all.
1129
+
1130
+ A negative margin is the mechanism: `.split-figure` sits inside a `fr`-sized grid track, so a
1131
+ margin shifts the rendered box without feeding back into track sizing (unlike an `auto` track,
1132
+ where a margin would resize the column itself). `margin-right`/`margin-left`, not `margin-inline
1133
+ -end/-start`: which PHYSICAL side is "outer" flips with `flip` (see `--split-figure-col` on the
1134
+ root) but never with writing direction, so the physical property is correct and the logical one
1135
+ would be wrong here.
1136
+
1137
+ Scoped to the three FIGURE kinds — `.is-photo`, `.is-panel`, `.is-figure` — and NOT `.is-content`:
1138
+ `frame: content` is documented as "nothing (a second content column)" — an author's own content, not
1139
+ a figure, and content wants the TEXT inset it already has. Escaping it to the media inset would
1140
+ narrow its measure on the very edge the whole point of `--deck-inset-text` is to protect.
1141
+ */
1142
+ .is-left .split-figure.is-photo,
1143
+ .is-left .split-figure.is-panel,
1144
+ .is-left .split-figure.is-code,
1145
+ .is-left .split-figure.is-figure {
1146
+ margin-right: calc(-1 * (var(--deck-inset-text) - var(--deck-inset-media)));
1147
+ }
1148
+ .is-right .split-figure.is-photo,
1149
+ .is-right .split-figure.is-panel,
1150
+ .is-right .split-figure.is-code,
1151
+ .is-right .split-figure.is-figure {
1152
+ margin-left: calc(-1 * (var(--deck-inset-text) - var(--deck-inset-media)));
1153
+ }
1154
+
1155
+ /*
1156
+ ★★ `bleed` — THE SAME FIGURE, AT THE CANVAS EDGE. FOUR DECLARATIONS, AND NO TREATMENT OF ITS OWN.
1157
+
1158
+ Everything above claws back the DIFFERENCE between the text inset and the media inset, and stops at
1159
+ 36. This gives up the whole text inset instead, so the figure's outer, top and bottom edges are the
1160
+ canvas edges: 0, 0 and 1080. The frame keeps whatever it already was — a photo still crops, a panel
1161
+ still carries its fill and padding, a component figure is still centred and capped — which is the
1162
+ point of making this a modifier rather than a fifth `frame` value.
1163
+
1164
+ The card radius comes off because a radius is what a thing sitting ON something has; flush to the
1165
+ canvas there is no ground for it to sit on and the corner would read as a mistake.
1166
+
1167
+ ⚠ SCOPED AWAY FROM `.is-content`. That kind is a second column of the author's PROSE, and running
1168
+ body copy to the canvas edge is the one thing this must never do — measured on a first draft that
1169
+ did exactly that, the paragraph ran off the right of the slide and under the running mark.
1170
+
1171
+ The INNER edge is untouched: it still meets the content column's gutter, because a figure that bled
1172
+ inwards would run under the text rather than beside it.
1173
+ */
1174
+ .is-left .split-figure.is-bleed:not(.is-content) { margin-right: calc(-1 * var(--deck-inset-text)); }
1175
+ .is-right .split-figure.is-bleed:not(.is-content) { margin-left: calc(-1 * var(--deck-inset-text)); }
1176
+ .split-figure.is-bleed:not(.is-content) {
1177
+ border-radius: 0;
1178
+ margin-top: calc(-1 * var(--deck-inset-text));
1179
+ }
1180
+ /* Scoped the way the notch's bottom escape is: a `::footer::` occupies the row below, so a figure
1181
+ bleeding past it would run under the footer rather than to the canvas. */
1182
+ .split-grid:not(:has(.split-footer)) .split-figure.is-bleed:not(.is-content) {
1183
+ margin-bottom: calc(-1 * var(--deck-inset-text));
1184
+ }
1185
+
1186
+ /*
1187
+ ★★ THE SURFACE BLEEDS; THE CONTENT INSIDE IT MUST NOT.
1188
+
1189
+ Giving up the inset moves the box, and the padding rides along with it — so a bleeding panel put
1190
+ its content 36px closer to the slide edge than the same panel unbled, and a bleeding figure put its
1191
+ component right on the canvas edge. Measured, `frame: panel` at ratio 1:1: notched, the content box
1192
+ runs to x 1852; bleeding, to 1888. The frame is supposed to look identical apart from its own
1193
+ edges, and it did not.
1194
+
1195
+ So each bled edge gives the inset back as PADDING. The surface still reaches the canvas; the
1196
+ content lands exactly where it would have without the bleed. Written per frame because the two
1197
+ start from different places — a panel already insets its content by --deck-card-pad and a figure
1198
+ by nothing — and adding the same number to both would put the figure 32px in from where its
1199
+ unbled version sits.
1200
+
1201
+ `photo` is deliberately absent: an edge-to-edge photograph is the whole point of bleeding one, and
1202
+ padding it would show the ground behind. `content` is absent because it never bleeds at all.
1203
+
1204
+ ⚠ THE TOP IS ONLY HALF-ANSWERED HERE. These values assume nothing is above the surface. When the
1205
+ chrome row IS drawn there is a mark to clear, and the rule that raises this lives in styles/base.css
1206
+ — a layout's own scoped CSS cannot see `.slidev-page`, which is where the chrome is.
1207
+ */
1208
+ /*
1209
+ ★★ A BLEEDING PANEL TAKES THE REGION INSET (96), NOT THE CARD INSET (68).
1210
+
1211
+ These gave the bled edge its inset back as padding — card pad 32 + media inset 36 — so the content
1212
+ landed exactly where the same panel puts it unbled. Correct arithmetic, wrong premise: it treats a
1213
+ bleeding panel as a card that happens to reach the edge. It is not one. A card's interior is inset
1214
+ from its own EDGES, which are drawn; a bleeding panel has no drawn edge left, so its interior is
1215
+ measuring against the canvas and against the seam with the content column — which is exactly the
1216
+ situation a text column is in, and --deck-inset-text is the number for it.
1217
+
1218
+ MEASURED on the register-comparison split (specimen 124) before this, which is the slide that found
1219
+ it. Seam at 960:
1220
+
1221
+ left column 96 -> 864 96 from the canvas edge, 96 from the seam 768 wide
1222
+ right panel 1028 -> 1852 68 from the seam, 68 from the canvas edge 824 wide
1223
+
1224
+ Both halves internally symmetric, and 56px apart from each other — so the same paragraph wrapped to
1225
+ three lines on the left and two on the right, on a slide whose whole claim is that the register is
1226
+ the only thing that differs. At the region inset both halves are 768 and the comparison is exact.
1227
+
1228
+ ⚠ Only when it BLEEDS. A panel sitting on the ground keeps --deck-card-pad: it has edges, and 96
1229
+ inside a 924px card is a frame with a stamp in it. Jose asked whether the two sides should match and
1230
+ what it would cost; the answer is 3 slides, all bleeding panels, because that is the only case where
1231
+ a panel stops being a card.
1232
+ */
1233
+ .split-figure.is-bleed.is-panel { padding-top: var(--deck-inset-text); }
1234
+ .split-figure.is-bleed.is-figure { padding-top: var(--deck-inset-media); }
1235
+ /*
1236
+ ★★ A BLEEDING PANEL PADS BOTH SIDES EQUALLY, WHICH IS NOT WHAT "GIVE THE INSET BACK" ALONE GIVES.
1237
+
1238
+ The rule above is per-EDGE, so only the bled edge got the inset back and the panel's interior came
1239
+ out lopsided: measured at ratio 1:1, content ran 992-1852, i.e. 32px from the seam and 68px from
1240
+ the canvas edge. Both of those are hard edges of the same box — one against the slide, one against
1241
+ the content column — and a fill with twice as much air on one side as the other reads as a mistake
1242
+ rather than as a bleed.
1243
+
1244
+ So the panel takes the bled value on BOTH sides. It is the panel, not the figure, because a panel
1245
+ is a visible box whose interior is read as a box: `is-figure` brings no fill, its inner edge meets
1246
+ the content column's own 96px, and padding it again would push a map 68px off a seam that is not
1247
+ drawn.
1248
+
1249
+ The cost is 36px of panel width, paid on the inner edge. Jose asked the question that found it.
1250
+ */
1251
+ .split-figure.is-bleed.is-panel { padding-left: var(--deck-inset-text); padding-right: var(--deck-inset-text); }
1252
+ .is-left .split-figure.is-bleed.is-figure { padding-right: var(--deck-inset-media); }
1253
+ .is-right .split-figure.is-bleed.is-figure { padding-left: var(--deck-inset-media); }
1254
+ /* Bottom only where the bottom actually bled — same `::footer::` scope as the margin above. */
1255
+ .split-grid:not(:has(.split-footer)) .split-figure.is-bleed.is-panel { padding-bottom: var(--deck-inset-text); }
1256
+ .split-grid:not(:has(.split-footer)) .split-figure.is-bleed.is-figure { padding-bottom: var(--deck-inset-media); }
1257
+
1258
+ /*
1259
+ A COMPONENT FIGURE — centred in its cell, capped to it, and brings no surface of its own.
1260
+
1261
+ It needs no grid-row of its own: `.split-figure` already spans rows 1-2, and the only thing that
1262
+ ever moved it was `.is-content`'s deliberate drop to row 2. What it does need is for the component
1263
+ inside it to take the height, which a plain block child will not do on its own.
1264
+
1265
+ ★ NO MATTE, and that is the difference from `panel`. A panel is a mounting board — a tinted card
1266
+ with the media centred inside its padding, which is right for a screenshot and wrong for a map
1267
+ that has its own ground and its own corner radius.
1268
+
1269
+ ★★ AND IT CLAWS BACK THE 54px LIKE THE OTHER TWO, above. It is a FIGURE, so its outer edge takes
1270
+ --deck-inset-media (36px), not the text inset the layout pads with — measured before this was
1271
+ added, the map sat 90px from the canvas edge while its own top and bottom were at 36px, which is
1272
+ the inset system applied to three of a box's four sides.
1273
+ */
1274
+ /*
1275
+ ★★ CENTRED AND FITTED, NOT STRETCHED — and the difference only shows on a figure that keeps its
1276
+ own aspect. The child used to take `flex: 1`, so it grew to the full height of the cell; a map or
1277
+ a chart that will not distort then drew itself at its own aspect inside that stretched box and sat
1278
+ at the TOP of it. Measured on the specimen's `frame: figure` slide: cell 924x1008, child stretched
1279
+ to 1008, and the map's own svg 780x601 against the cell's top edge with 407px of void beneath it.
1280
+
1281
+ Centring the CELL could not have fixed that, because the child already filled the cell — the empty
1282
+ space was inside the child. The stretch had to go first.
1283
+
1284
+ So the child takes its natural size, is capped to the cell on both axes, and is centred in what is
1285
+ left. A figure that genuinely wants the whole cell asks for it the same way it always did, with
1286
+ its own `height` — `::deck-map` and every chart already do, and they are unaffected because their
1287
+ height is declared rather than inherited from a stretch.
1288
+ */
1289
+ .split-figure.is-figure {
1290
+ display: flex;
1291
+ flex-direction: column;
1292
+ align-items: center;
1293
+ justify-content: center;
1294
+ }
1295
+ .split-figure.is-figure > :deep(*) {
1296
+ /*
1297
+ `width: 100%` so it can GROW, both maxes so it can never spill. Capping alone stopped a figure
1298
+ overflowing and left it at whatever size it declared — the borough map sat 780 wide in a 924
1299
+ cell and simply never used the other 144.
1300
+ */
1301
+ width: 100%;
1302
+ max-width: 100%;
1303
+ max-height: 100%;
1304
+ min-height: 0;
1305
+ }
1306
+ /*
1307
+ ══ SPILL ══════════════════════════════════════════════════════════════════════════════════════
1308
+
1309
+ ★★ THE THREE LINES ABOVE ARE WHAT SPILL HAS TO UNDO. `width: 100%` plus both maxes is the rule
1310
+ that makes a figure FIT: it grows a small drawing up to the cell and stops a large one escaping.
1311
+ A spilling figure is the deliberate opposite — it is sized past its cell and cropped by the canvas
1312
+ — so it needs all three released, not one. Released and then RE-anchored: with the maxes gone and
1313
+ nothing saying which way the overflow leaves, a centred child overflows both sides equally and the
1314
+ figure reads as badly cropped rather than as running off one edge.
1315
+
1316
+ ⚠ WHICH EDGE IT LEAVES BY IS THE COLUMN'S, NOT THE SLIDE'S. `.is-left` puts the FIGURE on the
1317
+ right (that is why its bleed rule pulls `margin-right`), so its content anchors to the inner edge
1318
+ and overflows outwards, and `.is-right` mirrors it. Anchored the other way round, the drawing runs
1319
+ under the text column instead of off the canvas.
1320
+ */
1321
+ /*
1322
+ ★★ THE CAPS COME OFF AND NOTHING IS IMPOSED. A spilling component knows its own size — a map's
1323
+ `width`, a device frame's `height` — and what the figure was doing was capping it. Sizing the child
1324
+ here instead (a flat 150%) made the map's own `width` prop silently inert, which is the same defect
1325
+ in the other direction: a number an author states and the layout overrides.
1326
+ */
1327
+ /*
1328
+ ⚠ THE WIDTH IS STATED HERE, and two other answers were measured before this one. `auto` made the
1329
+ map fall back to its intrinsic SVG size and render SMALLER than unspilled (350px in a 780px
1330
+ column). Leaving it alone kept the base rule's `width: 100%`, so the figure filled its cell exactly
1331
+ as it always had and nothing spilled at all (751px, right edge at 1884, inside the canvas). The
1332
+ base rule has to be beaten, so the ratio lives here — and a component's own width prop is then
1333
+ inert on a spilling figure, which is why the specimen's map states none.
1334
+ */
1335
+ .split-figure.is-spill.is-figure > :deep(*) {
1336
+ width: var(--spill, 150%);
1337
+ max-width: none;
1338
+ max-height: none;
1339
+ }
1340
+ /*
1341
+ ⚠ THE ROOM STOPS CENTRING ITS WINDOW, AND THAT IS THE WHOLE OF IT — the cap stays on, unlike the
1342
+ band's case in stack.vue.
1343
+
1344
+ The rule above widens the figure's direct child, which for a browser window is
1345
+ `.deck-browser-room`; the WINDOW is a grandchild, so it kept the room's horizontal centring.
1346
+ Measured on the right-edge spill: the room ran 672-2490 as asked and the window sat 827-2335
1347
+ inside it, so 155px of ground opened between the copy and a window that was supposed to start at
1348
+ the column's inner edge. Jose, on the ruler overlay: "whats with this padding here?"
1349
+
1350
+ ★★ AND BREAKING THE CAP AS WELL WAS THE OVER-CORRECTION. `max-width: none; width: 100%` filled the
1351
+ room — and the room is 150% of a column, so at 16:9 the window came out 1085px tall in a 1008px
1352
+ box and left the canvas at the TOP as well as the sides. A split spills SIDEWAYS; the cap is what
1353
+ keeps the other axis honest, and BrowserFrame computes it from the room's own height for exactly
1354
+ this. Jose: "can you put padding top bottom? so it doesnt overflow at the top?" — the padding was
1355
+ always there, the window was ignoring it.
1356
+
1357
+ So the window is as big as the height allows and hard against the inner edge, and whatever that
1358
+ leaves over goes out the outer side, which is what `spill` means. A band spills DOWNWARD and so
1359
+ breaks the same cap; the two layouts differ because their crop axes do.
1360
+ */
1361
+ .split-figure.is-spill :deep(.deck-browser-room) {
1362
+ align-items: flex-start;
1363
+ }
1364
+ /* ⚠ MEDIA IS THE EXCEPTION, because a photograph has no size of its own worth honouring — an <img>
1365
+ released from its caps is its intrinsic 8917px. A spilling photo frame oversizes by a RATIO of the
1366
+ column and keeps the cover crop. */
1367
+ .split-figure.is-spill.is-photo > :deep(img) {
1368
+ width: var(--spill, 150%);
1369
+ max-width: none;
1370
+ height: 100%;
1371
+ object-fit: cover;
1372
+ }
1373
+ /*
1374
+ ★★ AND THE CROP KEEPS THE FAR SIDE OF THE PICTURE. Jose, on the flipped slide: "if the edge spill
1375
+ and flip true, the image should be put showing the right side of the image... otherwise it will cut
1376
+ off the wrong part".
1377
+
1378
+ ⚠ TWO PROPERTIES, AND ONLY ONE OF THEM IS `object-position` — which is the trap. It picks which
1379
+ part of the SOURCE fills the img's box; it says nothing about which part of that BOX survives the
1380
+ figure's clip. Set alone it looked right in the CSS and wrong on the slide: measured on the flipped
1381
+ spill, the figure ran 0 → 787 with the img at 0 → 1181, so the img hung off the figure's RIGHT edge
1382
+ and the visible 787 was its left end — the exact half the composition is meant to lose.
1383
+
1384
+ So the box is anchored first: on a figure that leaves by the LEFT edge the img is pushed to the
1385
+ figure's right, and the overflow goes left. Then `object-position` sends the source's own crop the
1386
+ same way, so the side that stays on the slide is the side that stays in the picture.
1387
+ */
1388
+ .is-right .split-figure.is-spill.is-photo { justify-content: flex-end; }
1389
+ .is-left .split-figure.is-spill.is-photo { justify-content: flex-start; }
1390
+ .is-right .split-figure.is-spill.is-photo > :deep(img) { object-position: right center; }
1391
+ .is-left .split-figure.is-spill.is-photo > :deep(img) { object-position: left center; }
1392
+ .is-left .split-figure.is-spill.is-figure { align-items: flex-start; }
1393
+ .is-right .split-figure.is-spill.is-figure { align-items: flex-end; }
1394
+ /*
1395
+ ★ AND THE VERTICAL ANCHOR IS THE TOP, so a figure taller than its cell — a browser window whose
1396
+ foot is meant to be below the canvas, three device frames standing on the bottom edge — grows
1397
+ downwards rather than centring and losing its head as well as its feet.
1398
+ */
1399
+ .split-figure.is-spill.is-figure { justify-content: flex-start; }
1400
+ /*
1401
+ ★★ THE SLIDE DOES THE CROPPING, AND IT HAS TO SAY SO. Nothing on this layout clipped before —
1402
+ a figure that fits needs no clip and the canvas is not a clipping context by default, so the
1403
+ overflow simply painted over the chrome and past the slide into the deck's own page. `overflow`
1404
+ on the root is what turns "sized past its cell" into "cropped by the slide edge".
1405
+ */
1406
+ .split.is-spill { overflow: hidden; }
1407
+
1408
+ /*
1409
+ ★★ AND THE DRAWING FITS ON WHICHEVER AXIS BINDS FIRST. An <svg> carrying a viewBox has an
1410
+ intrinsic ratio, so at width/height 100% its own default preserveAspectRatio="xMidYMid meet" does
1411
+ exactly what object-fit: contain does for a photo — grows until one axis touches, centres on the
1412
+ other, never distorts. That is the whole behaviour, and it comes from the SVG spec rather than
1413
+ from arithmetic here, so there is no ratio for this file to compute or keep in sync.
1414
+
1415
+ Scoped to the figure kind: `panel` mattes its content at natural size on purpose, and `photo`
1416
+ already crops with object-fit: cover.
1417
+ */
1418
+ .split-figure.is-figure :deep(svg) {
1419
+ max-width: 100%;
1420
+ max-height: 100%;
1421
+ }
1422
+ /*
1423
+ THE FIGURE SIDE OWNS ITS OWN TREATMENT. An author writes the content and nothing else:
1424
+
1425
+ ::aside::
1426
+ <img src="/warehouse.jpg" />
1427
+
1428
+ and gets a filled, corner-clipped photo. Before this, `.is-photo` carried NO CSS at all — the
1429
+ rounding and the fill lived only inside <Placeholder>, so the frame vanished the moment a real
1430
+ image replaced the placeholder and the author had to rebuild it by hand with utility classes.
1431
+ `frame`'s own description promises "a photo frame"; this is that frame.
1432
+
1433
+ `object-fit: cover` rather than a raw stretch is what makes filling safe for an <img> of any
1434
+ intrinsic ratio. Same rule and same reasoning as bleed.vue's `.bleed-media :deep(> *)`.
1435
+
1436
+ Scoped to real MEDIA, not `:deep(> *)`: a <Placeholder> is a <div> and keeps its own
1437
+ aspect-ratio behaviour, so `frame: photo` with no slot content still mocks up at its declared
1438
+ ratio instead of being stretched to the cell.
1439
+
1440
+ ESCAPE HATCH, so this cannot silently defeat a caller who wants something else: use
1441
+ `frame: content`, which carries no treatment at all, and bring your own figure.
1442
+ */
1443
+ .split-figure.is-photo {
1444
+ border-radius: var(--deck-radius-card);
1445
+ overflow: hidden;
1446
+ display: flex;
1447
+ min-height: 0;
1448
+ }
1449
+ .split-figure.is-photo :deep(img),
1450
+ .split-figure.is-photo :deep(video),
1451
+ .split-figure.is-photo :deep(picture) {
1452
+ width: 100%;
1453
+ height: 100%;
1454
+ object-fit: cover;
1455
+ display: block;
1456
+ }
1457
+ /*
1458
+ ══ THE PAIR ══════════════════════════════════════════════════════════════════════════════════
1459
+
1460
+ ★★ THE CONTENT COLUMN TAKES THE FIGURE'S OWN TREATMENT, WORD FOR WORD, when it is holding the
1461
+ first of two pictures — the same corner, the same crop, the same clip. A diptych only reads if
1462
+ the two halves are the same KIND of object: one radius against none, or a cover crop against an
1463
+ image sized to its own ratio, and the slide is a photograph beside a picture of a photograph.
1464
+
1465
+ ⚠ SCOPED TO `.is-picture`, WHICH THE LAYOUT ONLY ADDS WHEN `images` HOLDS TWO. Written as a bare
1466
+ `.split-content :deep(img)` it would have cropped every inline image in every prose column in the
1467
+ deck to the height of its box — a diagram, a mark, an emoji-sized glyph.
1468
+ */
1469
+ /*
1470
+ ★★ THE PAIR'S OTHER PICTURE SITS IN THE CONTENT COLUMN'S TRACK AND IS OTHERWISE A FIGURE. It
1471
+ carries `.split-figure.is-photo`, so the corner, the crop, the clip and the claw-back to the media
1472
+ inset are the rules that already shape the other half rather than a second set written to match
1473
+ them. Two things are its own: which track it sits in, and which of its edges is the outer one.
1474
+
1475
+ ⚠ THE MIRRORED CLAW-BACK IS THE WHOLE OF THAT SECOND THING. Every `.split-figure` rule keys its
1476
+ outer edge off `.is-left`/`.is-right`, which name where the FIGURE is — so applied unchanged to a
1477
+ box in the other column they clawed the inner edge and left the outer one at the text inset: one
1478
+ picture 96 from the canvas beside another at 36, which is what "it looks off" was.
1479
+ */
1480
+ .split-figure.is-pair {
1481
+ grid-column: var(--split-content-col);
1482
+ grid-row: 1 / 3;
1483
+ }
1484
+ .is-left .split-figure.is-pair { margin-right: 0; margin-left: calc(-1 * (var(--deck-inset-text) - var(--deck-inset-media))); }
1485
+ .is-right .split-figure.is-pair { margin-left: 0; margin-right: calc(-1 * (var(--deck-inset-text) - var(--deck-inset-media))); }
1486
+ /*
1487
+ ★★ ONE SEAM, USED EVERYWHERE IN THE SET. The gutter between the two columns and the gap between
1488
+ two pictures stacked in one column are the same number — Jose: "the space in the middle should be
1489
+ the same separator". They were not: the grid's own column-gap is 0 on a mixed split (the figure's
1490
+ edge does the separating) and --deck-split-gutter-same (96) when two columns are the same KIND,
1491
+ while a stack inside a column used the grid gutter of 32. Three values in one picture set.
1492
+
1493
+ ⚠ AND IT IS THE GRID GUTTER, NOT THE SAME-KIND TOKEN. 96 is the air two columns of DIFFERENT
1494
+ content need between them; a picture set is one object cut into pieces, and the seam inside an
1495
+ object is the same 32 a `::grid` of tiles uses.
1496
+ */
1497
+ .split-grid:has(.split-figure.is-pair) { column-gap: var(--deck-space-4); }
1498
+ /*
1499
+ ══ FLUSH ══════════════════════════════════════════════════════════════════════════════════════
1500
+
1501
+ ★★ THE INNER CORNERS GO AND THE OUTER ONES STAY. Squaring all of them — which is what this did
1502
+ first — draws a hard rectangle in the middle of a slide that has not bled, and the set reads as a
1503
+ photograph someone forgot to finish rather than as one field. Jose: "flush doesn't look good, take
1504
+ a look at the images". The corners on the OUTSIDE are the set's own edge against the deck ground,
1505
+ and they are the same card corner every other figure on this layout carries.
1506
+
1507
+ ⚠ WHICH CORNERS ARE OUTER DEPENDS ON THE COLUMN, AND THE COLUMN DEPENDS ON `flip`. `.is-pair` is
1508
+ the content-column picture, so it is the LEFT column on `.is-left` and the right one on
1509
+ `.is-right`; the figure is the mirror. Written in logical corners — `start-start` is top-left in
1510
+ this deck's writing mode — so each rule names the side rather than the corner twice.
1511
+ */
1512
+ /*
1513
+ ★★ THE SET'S OWN CORNER IS CAPTURED BEFORE THE TOKEN IS ZEROED. The rule below has to zero
1514
+ --deck-radius-card on the figure so useNotchCuts draws a square outline — and the picture corners
1515
+ read that same token, so zeroing it took the set's outer rounding with it: measured, all four
1516
+ pictures at 0 and the set a hard rectangle again. Held on the ROOT, which does not override the
1517
+ token, so the corners keep the card radius while the boxes under them go square.
1518
+ */
1519
+ .split.is-flush { --set-corner: var(--deck-radius-card); }
1520
+ .split.is-flush .split-grid { column-gap: 0; }
1521
+ .split.is-flush .split-figure.is-photo {
1522
+ gap: 0;
1523
+ border-radius: 0;
1524
+ /*
1525
+ ⚠ AND THE NOTCH'S OWN RADIUS GOES WITH IT. useNotchCuts reads --deck-radius-card off the panel's
1526
+ computed style and draws the whole outline — so the clip put a 48px arc back on EVERY corner,
1527
+ including the two at the seam, and a flush set had a bite taken out of the middle of it at the
1528
+ top and the bottom. `border-radius: 0` above cannot reach the clip; the token can, and the
1529
+ rounding the set does keep is drawn on the pictures rather than on the box.
1530
+ */
1531
+ --deck-radius-card: 0px;
1532
+ }
1533
+ .split.is-flush .split-figure.is-photo > :deep(img) { border-radius: 0; }
1534
+ /* The left-hand column, whichever of the two boxes that is: first picture keeps the top-left corner,
1535
+ last keeps the bottom-left. A single picture in the column is both, so `:first-child` and
1536
+ `:last-child` both match it and it rounds the whole left side. */
1537
+ .split.is-flush.is-left .split-figure.is-pair > :deep(img):first-child,
1538
+ .split.is-flush.is-right .split-figure:not(.is-pair) > :deep(img):first-child {
1539
+ border-start-start-radius: var(--set-corner);
1540
+ }
1541
+ .split.is-flush.is-left .split-figure.is-pair > :deep(img):last-child,
1542
+ .split.is-flush.is-right .split-figure:not(.is-pair) > :deep(img):last-child {
1543
+ border-end-start-radius: var(--set-corner);
1544
+ }
1545
+ /* And the right-hand column, mirrored. */
1546
+ .split.is-flush.is-left .split-figure:not(.is-pair) > :deep(img):first-child,
1547
+ .split.is-flush.is-right .split-figure.is-pair > :deep(img):first-child {
1548
+ border-start-end-radius: var(--set-corner);
1549
+ }
1550
+ .split.is-flush.is-left .split-figure:not(.is-pair) > :deep(img):last-child,
1551
+ .split.is-flush.is-right .split-figure.is-pair > :deep(img):last-child {
1552
+ border-end-end-radius: var(--set-corner);
1553
+ }
1554
+ /*
1555
+ ⚠ EXCEPT AT THE CANVAS EDGE, where there is no ground left for a corner to sit against: a bleeding
1556
+ flush set is the whole frame in pieces, and a rounded corner there cuts a notch out of the slide.
1557
+
1558
+ ⚠⚠ AND IT HAS TO MATCH THE FOUR RULES ABOVE ON SPECIFICITY, WHICH IS WHY THE CHILD SELECTORS ARE
1559
+ REPEATED HERE. Written as a plain descendant it is one class short of them — corner longhands are
1560
+ resolved per property, so `border-start-start-radius` from a 5-class rule beat `border-radius: 0`
1561
+ from a 4-class one and the bleeding set kept all four corners. Measured on the flush+bleed slide:
1562
+ 48px at every corner, against a canvas the pictures already reached.
1563
+ */
1564
+ .split.is-flush.is-bleed .split-figure.is-photo > :deep(img):first-child,
1565
+ .split.is-flush.is-bleed .split-figure.is-photo > :deep(img):last-child { border-radius: 0; }
1566
+ /*
1567
+ ★★ AND THE SET BLEEDS AS ONE OBJECT, WHICH TOOK A SECOND MIRROR. `edge` was bound to the figure
1568
+ element alone, so a bleeding set had one column at the canvas and the other still inset: measured
1569
+ on the flush+bleed slide, the figure ran 960 → 1920 and 0 → 1080 while the pair sat at 36 → 960 and
1570
+ 96 → 984. The vertical claw-backs are side-agnostic and apply to both as written; the horizontal
1571
+ one names `.is-left`/`.is-right`, which is where the FIGURE is, so the pair takes the mirror.
1572
+ */
1573
+ .is-left .split-figure.is-pair.is-bleed { margin-right: 0; margin-left: calc(-1 * var(--deck-inset-text)); }
1574
+ .is-right .split-figure.is-pair.is-bleed { margin-left: 0; margin-right: calc(-1 * var(--deck-inset-text)); }
1575
+ /*
1576
+ ★★ EACH COLUMN IS A STACK, because a column can hold two. Three pictures is one and two; four is
1577
+ two and two. The gutter between two pictures in a column is the same one the layout puts between
1578
+ its columns — `--deck-space-4` is `.split-grid`'s own gap — so a 2x2 set reads as one grid rather
1579
+ than as two columns that happen to be beside each other.
1580
+ */
1581
+ .split-figure.is-photo:has(> img + img) {
1582
+ flex-direction: column;
1583
+ gap: var(--deck-space-4);
1584
+ }
1585
+ .split-figure.is-photo > :deep(img) {
1586
+ /* `flex: 1` rather than `height: 100%`: two pictures in one column share it, one takes all of it. */
1587
+ flex: 1 1 0;
1588
+ width: 100%;
1589
+ min-height: 0;
1590
+ object-fit: cover;
1591
+ display: block;
1592
+ border-radius: var(--deck-radius-card);
1593
+ }
1594
+ /*
1595
+ ★★ `--warm-100`, THE SAME MAT stack's BAND TAKES, because `frame: 'panel'` has to mean one thing.
1596
+
1597
+ This was `--deck-bg-subtle` — `color-mix(--deck-bg, --deck-fg 8%)`, the page ground pushed toward
1598
+ the ink — which renders grey. stack's band at the same `frame: 'panel'` defaults to `background: 'warm'`
1599
+ and renders `--warm-100`. So one prop name gave two different surfaces depending on which layout
1600
+ you were in, and this layout's own doc for the prop calls it "a TINTED box", which the grey was
1601
+ not. Jose, on a panel split: "why is the color neutral? isn't the default the warm color?"
1602
+
1603
+ The AXIS came with it: `background` below is stack's prop, its four values, and its four rules read from
1604
+ the same tokens — so the two layouts now agree about what a panel is and what can be done to one,
1605
+ rather than sharing a prop name and meaning different things by it.
1606
+ */
1607
+ .split-figure.is-panel {
1608
+ border-radius: var(--deck-radius-card);
1609
+ padding: var(--deck-card-pad);
1610
+ display: flex;
1611
+ /* The cross axis is `panelAlign`'s, above. */
1612
+ justify-content: center;
1613
+ }
1614
+ /*
1615
+ The four registers, scoped to `.is-panel` so a background on any other frame is inert. The same four
1616
+ named backgrounds stack's bands take — one table, read by both — because a panel and a band of the
1617
+ same name should be the same colour, and two copies of that list would not stay so.
1618
+
1619
+ ⚠ This named `--deck-accent-soft`, `--deck-bg-subtle`, `--warm-100` and `--forest-300`, which was
1620
+ the loose-fill palette that predates the one table.
1621
+ */
1622
+ .split-figure.is-panel.bg-warm { background: var(--deck-bg-warm); color: var(--deck-on-bg-warm); }
1623
+ .split-figure.is-panel.bg-accent { background: var(--deck-bg-accent); color: var(--deck-on-bg-accent); }
1624
+ .split-figure.is-panel.bg-vivid { background: var(--deck-bg-vivid); color: var(--deck-on-bg-vivid); }
1625
+ .split-figure.is-panel.bg-neutral { background: var(--deck-bg-neutral); color: var(--deck-on-bg-neutral); }
1626
+
1627
+ /*
1628
+ ── A CODE SURFACE ────────────────────────────────────────────────────────────────────────────
1629
+ The panel IS the block. It takes the code ground and its own edge, and the `pre` inside goes flat,
1630
+ so a code slide is ONE surface rather than a well inside a mat. See the `frame` prop's own note for
1631
+ why this is a named value rather than a `:has()` on `panel`.
1632
+
1633
+ The inner reset is the same three declarations the magic-move container has always used on its own
1634
+ `pre.shiki`, for the same reason: a container that owns the surface must strip the one its child
1635
+ would otherwise draw, or the two edges sit 4px apart and read as a mistake.
1636
+ */
1637
+ .split-figure.is-code {
1638
+ background: var(--slidev-code-background);
1639
+ border: 1px solid var(--deck-code-border);
1640
+ border-radius: var(--deck-radius-card);
1641
+ padding: var(--deck-card-pad);
1642
+ display: flex;
1643
+ flex-direction: column;
1644
+ justify-content: center;
1645
+ min-width: 0;
1646
+ }
1647
+ /*
1648
+ ★★ THE PADDING IS ZEROED AT ITS SOURCE, NOT OVERRIDDEN — one cause, two effects.
1649
+
1650
+ The obvious way to flatten the block is `padding: 0 !important` (Slidev's own rule carries
1651
+ `!important`, so nothing less wins). It works, and it leaves a 24px horizontal scrollbar on every
1652
+ code frame, because a SECOND rule reads the same token: base.css sizes each line
1653
+
1654
+ width: calc(100% + space-1 + space-2 + var(--slidev-code-padding))
1655
+
1656
+ so the row overhangs to the right by exactly the block's own right padding, which is what lets a
1657
+ lit band reach both padding edges. Flatten the padding without telling that rule and the overhang
1658
+ has nothing left to fill: measured on this frame, scrollWidth 965 against clientWidth 941 — the
1659
+ token, to the pixel.
1660
+
1661
+ Setting `--slidev-code-padding: 0px` collapses both at once: Slidev's padding resolves to zero with
1662
+ no `!important` needed, and the line's overhang term goes with it. The surface's own inset is the
1663
+ panel's, which is the whole point of the frame.
1664
+ */
1665
+ .split-figure.is-code {
1666
+ --slidev-code-padding: 0px;
1667
+ }
1668
+ .split-figure.is-code :deep(.slidev-code-wrapper),
1669
+ .split-figure.is-code :deep(pre.slidev-code) {
1670
+ margin: 0 !important;
1671
+ background: none;
1672
+ border: none;
1673
+ border-radius: 0;
1674
+ }
1675
+ /*
1676
+ ★★ AN EDGE AND A CLIP CANNOT SHARE AN ELEMENT. `clip-path` cuts the rendered box, border included,
1677
+ so a notched code surface drew its edge continuously on three sides and then simply lost it around
1678
+ the cut — a frame that stops. Measured on the specimen's own pair: `border: 1px` and a clip-path on
1679
+ the same figure.
1680
+
1681
+ The border is what a code surface has when it is a CARD on the slide. Notched, its outer corner is
1682
+ the mark's seat rather than a corner; bleeding, there is no ground for it to sit on at all. Neither
1683
+ is a card, so neither takes an edge. `panel` never hit this because it has no border to lose.
1684
+ */
1685
+ /*
1686
+ ★★ A NOTCHED CODE SURFACE RESERVES THE TOP AND NOT THE BOTTOM, which is where it parts company
1687
+ with a notched panel.
1688
+
1689
+ The shared reservation below puts `max(card-pad, notch-h)` at BOTH ends, and its own note is honest
1690
+ about why: the bottom "buys nothing but symmetry", so that media CENTRED in the panel shares the
1691
+ centre a viewer sees. A code surface is not centred media — it is a block that starts at the top of
1692
+ its box — so the mirrored 96px at the foot is 64px of code height bought for a reason that does not
1693
+ apply here. The top keeps it, because that is the mark's seat and it is structural.
1694
+
1695
+ The horizontal stays at --deck-card-pad, which is where a plain code block already insets its text,
1696
+ so a code frame and a code block agree on the one edge they can be compared across.
1697
+ */
1698
+ .split-figure.is-code.has-notch {
1699
+ border: none;
1700
+ padding-top: max(var(--deck-card-pad), var(--notch-h));
1701
+ padding-bottom: var(--deck-card-pad);
1702
+ }
1703
+ /*
1704
+ ★★ BLEEDING, IT KEEPS THE CODE'S OWN INSET HORIZONTALLY RATHER THAN THE REGION'S.
1705
+
1706
+ Every other bleeding panel takes --deck-inset-text on all four sides, so its content lines up with
1707
+ the content column across the seam — that is the rule the panel's own note argues for, and it is
1708
+ right for PROSE, which is what a content column holds.
1709
+
1710
+ A code surface is not prose. Its neighbour on the other side of the seam is a paragraph; its
1711
+ neighbour on every other slide is a code block, and a code block insets its text by
1712
+ --deck-card-pad. Matching the block is worth more than matching the column, because the reader
1713
+ compares code to code. So the horizontal follows the notched code surface above, and the vertical
1714
+ keeps the region inset it needs to stand off the canvas edge.
1715
+ */
1716
+ .split-figure.is-code.is-bleed {
1717
+ border: none;
1718
+ border-radius: 0;
1719
+ padding: var(--deck-inset-text) var(--deck-card-pad);
1720
+ }
1721
+
1722
+ /* Media matted inside a panel takes the CONCENTRIC radius — the card's corner minus its padding —
1723
+ so the inner curve sits parallel to the outer one instead of fighting it. */
1724
+ /*
1725
+ ⚠ `:not(.mark-pair-companion)`. A co-branding tile is not matted media: MarkPair clips the
1726
+ partner's artwork to the MARK's corner — a ratio of the tile — and this rule outranked it
1727
+ (`.split-figure.is-panel[data-v-…] img` beats a single class), so a pair dropped into a panel came out with
1728
+ the panel's 16px nested radius on one half and the mark's on the other. Measured on the specimen's
1729
+ own MarkPair slide: 31.49px on the light half, 16px on the dark one, same component, same size.
1730
+ slide-top.vue carries the same exclusion for the same reason.
1731
+ */
1732
+ .split-figure.is-panel :deep(img:not(.mark-pair-companion)),
1733
+ .split-figure.is-panel :deep(video),
1734
+ .split-figure.is-panel :deep(picture) {
1735
+ max-width: 100%;
1736
+ border-radius: var(--deck-radius-nested);
1737
+ display: block;
1738
+ }
1739
+ /* ── THE NOTCH ──────────────────────────────────────────────────────────────────────────────────
1740
+ task-15-brief.md. The panel's outer top corner is cut INWARD so the running Forest mark sits in
1741
+ the cut-out, and the panel's edge reads convex → concave → convex around it.
1742
+
1743
+ ★ THE CUT ITSELF IS NOT HERE. It is one shared rule, `.deck-notch` in styles/base.css, because
1744
+ task-18 gave the theme a SECOND notched figure: stack.vue's band cuts the same corner around the
1745
+ same mark. Two copies of a five-layer composited mask is exactly the drift this codebase keeps
1746
+ re-discovering the hard way, so the shape moved out and both layouts wear the class. What stays
1747
+ here is what is genuinely split's: which corner is the outer one (bound in the template, since it
1748
+ flips with `flip`), the margin that raises the panel to meet the mark, and the band a notched
1749
+ PANEL reserves from its own content.
1750
+
1751
+ ★ THE PANEL RISES TO MEET THE MARK. Without this, there is nothing to notch: the mark is inset
1752
+ --deck-inset-chrome (36px) from the top, the figure's own vertical bound is --deck-inset-text
1753
+ (90px), and the two boxes overlap by only 18px — a bite that shallow is a nick, not a seat, and
1754
+ what actually renders without it is a 72px mark parked on top of a photograph's corner. On a
1755
+ notched panel the TOP margin therefore drops to the chrome inset, which is also exactly why chrome
1756
+ and media share that number (scripts/deck.ts, above INSET): the mark's outer corner and the
1757
+ panel's outer corner become the SAME POINT, so the mark reads as seated in the corner with one
1758
+ clean ring of clearance — 36px of slide margin outside it, 36px of cut-out inside it.
1759
+
1760
+ ★★ AND THE BOTTOM COMES WITH IT (task-26). This rule used to move the TOP only, on the reasoning
1761
+ that "there is no chrome in the panel's bottom corner to make room for". True, and it is an
1762
+ argument for not needing a notch at the bottom — not for keeping the bottom 54px further in than
1763
+ the other three sides. Measured, every one of the 86 media figures in the three decks rendered
1764
+
1765
+ outer edge 36 · top 36 · bottom 90
1766
+
1767
+ i.e. a frame hanging 54px higher off the bottom edge than off the other three, on every media
1768
+ slide in the deck, with deck.css's own token comment calling the asymmetry intended. Jose's rule
1769
+ is 36 on every side for media; the notch is what makes 36 safe at the top, and nothing ever made
1770
+ it unsafe at the bottom — the only bottom chrome is the `confidential:` pill, which renders only
1771
+ when a slide declares one and sits on the pills side, i.e. the opposite side of the slide from the
1772
+ figure on every split — the chrome no longer mirrors at all (see ../notch.js above hasNotch).
1773
+
1774
+ So a notched figure is 36 on all four sides and an un-notched one is 90 top and bottom — the
1775
+ vertical pair moves TOGETHER with the cut, which is what makes `notch: false`'s documented promise
1776
+ ("EXACTLY its pre-notch geometry") true of both edges rather than one.
1777
+
1778
+ ⚠ ALL OF IT DROPS TOGETHER ON `notch: false`. The mask (via the class), these margins and the
1779
+ reservation below all key off the same `notched` computed, so an opted-out figure returns to
1780
+ exactly its pre-notch geometry rather than keeping a raised top with no cut in it — see
1781
+ notchShows() in ../notch.js for what that restores and why the escape hatch exists.
1782
+ */
1783
+ .split-figure.has-notch {
1784
+ /*
1785
+ The panel's own outer corner moves up to the mark's. See the note above.
1786
+
1787
+ ★ NEGATIVE NOW, AND FOR THE SAME REASON THE HORIZONTAL CLAW-BACK IS: these used to be absolute
1788
+ positions (36 from a canvas edge the root did not pad). The root pads by the text inset today, so
1789
+ reaching the chrome/media inset is the DIFFERENCE between the two — exactly the shape the outer
1790
+ edge already uses. Same two numbers, expressed as the gap between them rather than restated.
1791
+ */
1792
+ margin-top: calc(-1 * (var(--deck-inset-text) - var(--deck-inset-chrome)));
1793
+ }
1794
+ /*
1795
+ ★★ THE BOTTOM ESCAPE ONLY EXISTS WHEN THERE IS NOTHING BELOW IT. A figure spans rows 1-2 and
1796
+ claws past the grid's bottom edge to reach the media inset — which is right when row 2 ends at the
1797
+ safe line, and wrong the moment a `::footer::` occupies row 3: the escape then reaches 60px PAST
1798
+ the row boundary and lands 36px inside the footer. Measured on the footer specimen: figure bottom
1799
+ 971 against a footer starting at 935.
1800
+
1801
+ Scoped with `:not(:has(.split-footer))` rather than fixed by trimming the number, because the
1802
+ number is right — a figure with nothing under it should reach the media inset. It is the presence
1803
+ of a footer that removes the edge to escape to.
1804
+ */
1805
+ .split-grid:not(:has(.split-footer)) .split-figure.has-notch {
1806
+ margin-bottom: calc(-1 * (var(--deck-inset-text) - var(--deck-inset-media)));
1807
+ }
1808
+ /*
1809
+ THE SPACE, as opposed to the shape. A mask clips; it does not reflow, so a tinted panel holding
1810
+ real content would have that content's corner sliced off — the asymmetry where a photo panel
1811
+ looks perfect and a text panel silently truncates a word. The cut's own height is reserved as top
1812
+ padding (never less than the card padding the panel already had), which keeps every child clear
1813
+ of the notch band regardless of what the panel holds or how it is aligned inside it.
1814
+
1815
+ A band rather than a corner-shaped exclusion: `.is-panel` centres its content with flex, and a
1816
+ float with `shape-outside` — the only way to actually wrap text around a corner — does not apply
1817
+ to a flex item at all. The band is the honest version of the same guarantee.
1818
+
1819
+ `.is-photo` deliberately gets none of this: clipping the photograph IS the effect.
1820
+ */
1821
+ /*
1822
+ ★★ RESERVED AT BOTH ENDS, SO WHAT SITS IN A NOTCHED PANEL IS ACTUALLY CENTRED.
1823
+
1824
+ The top reservation is the notch: content must clear the bite cut out of the corner for the running
1825
+ mark, so the panel keeps `--notch-h` (mark 72 + ring 24 = 96) above it. Only the top was reserved,
1826
+ which left the box 96/32 — and anything centred in it lands (96 - 32) / 2 = 32px LOW inside the
1827
+ panel. Measured on the video slide: phone y 142-1002 in a panel y 36-1044, dead centre of the
1828
+ CONTENT box and 32 off the centre of the panel. Jose spotted it as "the iphone is not centered
1829
+ vertically".
1830
+
1831
+ ⚠ THE BOTTOM RESERVATION BUYS NOTHING BUT SYMMETRY, and that is the whole point of it. There is no
1832
+ notch down there and nothing to clear; the padding exists so the box a figure is centred in has the
1833
+ same centre as the panel a viewer sees. It costs 64px of usable height on every notched panel split
1834
+ — the honest price, paid once in the frame, rather than a magic offset on each slide that happens
1835
+ to hold something centred.
1836
+ */
1837
+ .split-figure.is-panel.has-notch,
1838
+ /*
1839
+ ★ `.is-figure` TAKES THE SAME RESERVATION, WITHOUT THE FILL. A naked figure had no padding at all,
1840
+ so it sat flush to the cell — which puts its top edge inside the notch's own bite and lets a wide
1841
+ one run under the running mark. Jose: "can we still give the padding to the naked version, so it
1842
+ doesnt overlap... similar treatment as panel, just without the bg."
1843
+
1844
+ The same expression rather than a bare `--notch-h`, so the two kinds cannot drift: whatever a
1845
+ notched panel reserves, a notched figure reserves. What `.is-figure` still does NOT get is the
1846
+ background, the radius and the horizontal card padding — it is a figure sitting on the page, not a
1847
+ matted one, and only the vertical clearance was ever the shared problem.
1848
+ */
1849
+ .split-figure.is-figure.has-notch {
1850
+ padding-top: max(var(--deck-card-pad), var(--notch-h));
1851
+ padding-bottom: max(var(--deck-card-pad), var(--notch-h));
1852
+ }
1853
+ .split-content { min-width: 0; }
1854
+ .split-figure { min-width: 0; }
1855
+ /*
1856
+ ★ THE SPLIT COLUMN RUNS AT THE BODY TIER BY DEFAULT, THE SAME AS EVERY OTHER SLIDE.
1857
+
1858
+ It used to step down to body-sm on the argument that "two columns halve the measure, so both sides
1859
+ take the dense body tier". The measure is halved and that is exactly why the tier should NOT be:
1860
+ a slide's body copy is 48px because that is what carries a room, and a column is not a reason for
1861
+ the audience at the back to get a smaller one. The measure is already handled where it belongs —
1862
+ a narrow column simply wraps sooner, which is what a column is for.
1863
+
1864
+ The cost is real and is paid deliberately: at 48px a split column fits about a third less copy
1865
+ than it did, so a slide that was full is now over. That is the deck telling the author the column
1866
+ holds less, which is a better failure than shrinking the type until it fits.
1867
+ */
1868
+ .is-body-base .split-grid { font-size: var(--type-deck-body-size); line-height: var(--type-deck-body-line-height); }
1869
+ /* The dense tier, opted into per slide — see the `body` prop for when that is the honest answer. */
1870
+ .is-body-sm .split-grid { font-size: var(--type-deck-body-sm-size); line-height: var(--type-deck-body-sm-line-height); }
1871
+ /* No margin-bottom here: the h1 inside the band already carries `margin: 0 0 var(--deck-space-3)`
1872
+ (base.css), and against a row fixed at 360 the second one was invisible slack. With the row sized
1873
+ to its band it would double the title-to-body gap to 48 where `default` gives 24. */
1874
+ .split-footer { margin-top: var(--deck-space-3); }
1875
+ </style>