@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
package/global-top.vue ADDED
@@ -0,0 +1,1662 @@
1
+ <script setup>
2
+ /*
3
+ task-14-brief.md, requirement 6 — the layout-debug ("red-line") overlay.
4
+
5
+ WHY THIS EXISTS. Every inset the two-track system introduces is a CSS custom property, and a
6
+ custom property is invisible: nobody can see 40px versus 100px by looking at a finished slide.
7
+ That invisibility is exactly how the bottom-chrome overlap this task's brief opens with survived
8
+ a written comment asserting it was fine — the arithmetic was wrong, nothing rendered the frame it
9
+ described, and scripts/deckAudit.ts measures OVERFLOW past the canvas, not COLLISION inside it.
10
+ This overlay draws the frames the tokens only describe, so a person or an agent reviewing a
11
+ layout can check "does this slide honour the inset it claims to" by eye, not by redoing the sums.
12
+
13
+ Documented as a review tool in README.md's own "Debugging layout" section — read that first for
14
+ how to turn it on; this file is the mechanism, not the manual.
15
+
16
+ MOUNTED ONCE, GLOBALLY — via Slidev's own `global-top.vue` naming convention (getComponent in
17
+ @slidev/cli's node/virtual/global-layers.ts), a DIFFERENT mount point from slide-top.vue/
18
+ slide-bottom.vue, which SlideWrapper.vue re-mounts PER SLIDE. A debug HUD that persists across
19
+ navigation without a per-slide remount is the better fit here, and — unlike slide-top.vue's own
20
+ chrome — this is not a per-slide, frontmatter-gated concern at all: it must draw identically
21
+ whether or not the CURRENT slide opts out of running chrome with `chrome: false`, because the
22
+ whole point is to let a reviewer check ANY slide's geometry, including the ones that hide chrome.
23
+
24
+ Slidev renders GlobalTop INSIDE the same absolutely-positioned, exactly-1920×1080
25
+ `.slidev-slide-content` box every slide renders inside (SlideContainer.vue scales that ONE box
26
+ with a CSS transform; nothing inside it needs to know the scale) — confirmed by reading
27
+ SlideContainer.vue/SlidesShow.vue rather than assumed, because a debug overlay whose own geometry
28
+ is wrong would be worse than no overlay at all. That is what makes plain px-via-token absolute
29
+ positioning below line up with real canvas coordinates regardless of window size or zoom — and it
30
+ is exactly what makes `top: var(--deck-anchor-third)` below land on a genuine 360px: the token is
31
+ a CANVAS coordinate, and this element's own box IS the canvas, so it needs no correction. A rule
32
+ inside a padded box does — see split.vue's `.split-grid`, which subtracts its own inset from the
33
+ anchor to reach the same line.
34
+
35
+ task-14b-brief.md added two more things this overlay has to show, both CURRENT-SLIDE-dependent
36
+ rather than layout-agnostic like the three frames and two bands below: the y=360 anchor line
37
+ split.vue's text column is pinned to, and split.vue's conditional gutter (96px between two
38
+ same-kind columns, 0 between text and a real figure). See the `isSplit`/`gutterLabel` computeds
39
+ below for how a globally-mounted component reaches the CURRENT slide's own frontmatter to know
40
+ which state applies.
41
+
42
+ ★ TWO INDEPENDENT GUARDS AGAINST LEAKING INTO AN EXPORT, not one:
43
+ 1. `layoutDebugEnabled` (debug/layoutDebug.js) defaults to `false` and is never persisted — a
44
+ fresh page load (exactly what `slidev export`'s Playwright run performs) starts false and
45
+ nothing in that module can start it any other way. See that file's own comment.
46
+ 2. `isPrintMode`, read HERE, straight from `useNav()` — Slidev's own signal for "this route is
47
+ `/export` or carries a `?print` query" (composables/useNav.ts). `show` below is `false`
48
+ whenever EITHER guard says so, so even a hypothetical future bug that flipped the ref true
49
+ mid-export (a bad shortcut binding, a shared link, a race) still renders nothing: this
50
+ component has no code path that paints while `isPrintMode` is true. Verified live against
51
+ both `?print` navigation and a real `slidev export` run — see task-14-report.md.
52
+ Belt AND braces, deliberately: guard 1 alone relies on nobody ever adding a persistence path to
53
+ the ref; guard 2 alone would still let an interactively-toggled overlay leak into a screen-share
54
+ export triggered from the SAME tab without a route change. Neither guard is trusted alone.
55
+ */
56
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
57
+ import { useNav } from '@slidev/client';
58
+ import { layoutDebugEnabled, layoutDebugTiers, toggleTier } from './debug/layoutDebug.js';
59
+ import deckTokens from '@humanforest/tokens/deck/deck.json';
60
+
61
+ const { isPrintMode, slides, currentPage } = useNav();
62
+ const show = computed(() => layoutDebugEnabled.value && !isPrintMode.value);
63
+
64
+ /*
65
+ task-14b-brief.md — the overlay now also has to show split.vue's Y-ANCHOR and its CONDITIONAL
66
+ gutter, and unlike the three edge insets, both depend on the CURRENT SLIDE's own frontmatter
67
+ (`layout`, and for a split, `frame`). GlobalTop is mounted OUTSIDE SlideWrapper.vue's per-slide
68
+ `provideLocal` scope (see this file's own top comment), so it cannot `inject(injectionFrontmatter)`
69
+ the way slide-top.vue/slide-bottom.vue do — it reads the deck's own slide list off `useNav()`
70
+ instead, the same source section.js already reads for the running chrome's section pill.
71
+
72
+ `useNav().currentPage` is the ROUTER value slide-top.vue's own comment warns is wrong on the
73
+ print/export route (no `route.params.no` there, so it reads `1` for every slide at once) — but
74
+ that bug cannot reach here: `show` above already forces this component to render NOTHING on that
75
+ exact route, and on every route where it CAN paint, `route.params.no` exists and `currentPage` is
76
+ correct. Using the injected version instead would need a provide/inject wiring this component does
77
+ not have and does not need.
78
+ */
79
+ const currentFrontmatter = computed(() => slides.value[currentPage.value - 1]?.meta?.slide?.frontmatter ?? {});
80
+ const isSplit = computed(() => currentFrontmatter.value.layout === 'split');
81
+ /*
82
+ Mirrors split.vue's own `.is-content` selector: 'content' means the far slot is a second CONTENT
83
+ column (same-kind, the 96px --deck-split-gutter-same); 'photo', 'panel' and 'figure' all mean a
84
+ real figure (mixed, no gutter). The default matches split.vue's own prop default.
85
+
86
+ ★★ THIS READ `frontmatter.media ?? 'none'` AND WENT ON WORKING, WRONGLY. The prop is `frame` now
87
+ (3b8499f), so `.media` was always undefined, the fallback always won, and the ruler labelled every
88
+ split "gutter 36px · same-kind" — including the ones with no gutter at all. Nothing failed: an
89
+ absent frontmatter key is undefined, not an error, and the classRefs gate only checks CSS class
90
+ literals, not frontmatter reads.
91
+
92
+ The old comment here claimed this shape was the one "a new media kind cannot silently fall out
93
+ of". That was true of a new VALUE and said nothing about a renamed KEY, which is what happened.
94
+ Reading a key by name is exactly as fragile as reading a class by name.
95
+ */
96
+ const splitFrame = computed(() => currentFrontmatter.value.frame ?? 'figure');
97
+ /*
98
+ ★★ THE NUMBER IS READ, NOT WRITTEN. This said "gutter 36px" as a literal, so when the same-kind
99
+ gutter became 96 (9fc077b) the ruler went on announcing 36 over a slide measuring 96 — the second
100
+ time in one session this overlay stated something it had not looked up. An overlay whose whole
101
+ job is reporting measurements must not carry any of them in a string.
102
+ */
103
+ const gutterLabel = computed(() =>
104
+ splitFrame.value === 'content' ? `gutter ${deckTokens.anchors.splitGutterSame.px}px · same-kind` : 'gutter 0 · mixed');
105
+
106
+ /*
107
+ ★★ THE SPLIT REGIONS ARE MEASURED, NOT RE-DERIVED — which is the same rule the gutter label above
108
+ follows, applied to the thing the label was standing in for.
109
+
110
+ That label exists because drawing the seam as a geometric line would mean redoing split.vue's
111
+ grid-track-and-negative-margin arithmetic a second time, and this file's own history says a
112
+ duplicated sum drifts out of sync with the real one. All true — but the boxes themselves need no
113
+ arithmetic at all. They are in the DOM. Reading their rects back reports what the browser actually
114
+ laid out, so this overlay can never disagree with the layout: if it draws the wrong box, the box
115
+ IS wrong.
116
+
117
+ Each region gets its own outline, header band included, because "where does the second column
118
+ start" and "does the header really span both" are exactly the questions a split slide raises and
119
+ the single anchor line could not answer.
120
+
121
+ ⚠ AND THE ANCHOR LINE IS NOW CONDITIONAL, TWICE OVER. split.vue sizes row 1 to its band on a
122
+ slide with a `::header::`, so there the content starts at the ordinary content top and NOT on the
123
+ third — an anchor line drawn there points at nothing and reads as a layout bug that is not there.
124
+ It draws only where it is still the rule: a split without a header. And beside a real figure it
125
+ now spans only the CONTENT column, because that is the only half the anchor governs — see
126
+ `anchorSpan`.
127
+ */
128
+ /*
129
+ EVERY LAYOUT'S OWN REGIONS, not just split's.
130
+
131
+ The overlay knew about one layout, which made it a split tool with three general frames attached
132
+ rather than a layout tool. A stack's band and strip, a bleed's media and content and overlay, a
133
+ quote's body — each is a box some rule positions, and "is that box where I think it is" is the
134
+ same question on every one of them.
135
+
136
+ Kept as ONE table rather than a branch per layout: a new layout with a named region joins by
137
+ adding a row, and a region that is renamed stops drawing rather than drawing the wrong box —
138
+ which is the failure mode worth having, because a missing outline is visible and a stale one is
139
+ not. Selectors are the layouts' own class names, and nothing here re-derives a position.
140
+
141
+ `kind` drives the outline weight, so the same KIND of region reads the same across layouts: a
142
+ content column looks like a content column whether it is split's or bleed's.
143
+ */
144
+ const TIERS = [
145
+ { id: 'grid', label: 'grid', note: 'the lattice everything else sits on — --deck-grid, 24px at the time of writing' },
146
+ { id: 'frame', label: 'frame', note: 'canvas rules, from tokens' },
147
+ { id: 'region', label: 'region', note: 'a box a LAYOUT positions' },
148
+ { id: 'element', label: 'element', note: 'a box CONTENT occupies' },
149
+ { id: 'gap', label: 'gap', note: 'the space between two boxes' },
150
+ { id: 'hazard', label: 'hazard', note: 'a collision, or an off-ladder value' },
151
+ ];
152
+
153
+ /*
154
+ EVERY LAYOUT'S OWN REGIONS. The table covered four of nine layouts, which made this a split tool
155
+ with some frames attached; cover, divider, statement, end and default drew nothing at all.
156
+
157
+ `.slidev-layout` is the highest-value row and the reason the rest is cheap: drawn as its CONTENT
158
+ box (inset by its own computed padding, not its border box, which is always the canvas), one row
159
+ gives every layout its real inset with no token guessed — 130 on a `default` that is not centred,
160
+ 120 at the sides of every beat layout, 96 everywhere else.
161
+
162
+ ★ ROLE IS DERIVED AT MEASURE TIME, NOT STORED. A `.split-figure` at `frame: content` is a second
163
+ content column, and a `.stack-band` at `frame: panel` — the default — is a padded panel rather
164
+ than a figure, so a role written into this table is wrong for the layout's own default on two of
165
+ its rows. Reading it off the element's live classList also kills a staleness bug: `splitFrame` was
166
+ not in the watch list, so a cached role could outlive the slide it described.
167
+
168
+ ★★ AND IT IS DERIVED ON THOSE TWO ROWS ONLY — `frameRole` — because the frame class is not only
169
+ on the figure slot. split.vue and stack.vue both put `FRAME_CLASS[frame]` on their LAYOUT ROOT as
170
+ well (a state hook their own descendant selectors key off: `.is-content .split-grid`), and the
171
+ derivation read any element's classList, so the root matched `is-panel` and took the panel role
172
+ from a class that describes what its figure holds, not what the root is. Measured with the ruler
173
+ on specimen slide 18 (`frame: panel`): the root box drew `is-panel` — 2px solid, the panel weight
174
+ — where `.deck-debug-region.is-root`'s own 1px is the only thing separating the outermost box on
175
+ the slide from the boxes inside it. Both layouts default their frame, so this was every split and
176
+ every stack slide, not an edge case. The label was never wrong (it is static); only the weight
177
+ was, which is why it survived a reading of the labels.
178
+ */
179
+ const REGIONS = [
180
+ { selector: '.slidev-layout', label: 'root', role: 'root', box: 'content' },
181
+ { selector: '.split-grid', label: 'grid', role: 'grid' },
182
+ { selector: '.split-content', label: 'content', role: 'column' },
183
+ /* ★ LABELLED `aside`, WHICH IS WHAT THE REGION IS CALLED — not `figure`, which is only what it
184
+ sometimes HOLDS. Jose, reading the ruler on a `frame: content` slide: "why this is called
185
+ figure?". It was a static label, so the overlay called a second column of prose a figure. The
186
+ ROLE below still varies (that is what colours the box); the NAME is the slot's name now. */
187
+ { selector: '.split-figure', label: 'aside', role: 'figure', frameRole: true },
188
+ { selector: '.split-footer', label: 'footer', role: 'band' },
189
+ { selector: '.stack-band', label: 'band', role: 'figure', frameRole: true },
190
+ { selector: '.stack-strip', label: 'strip', role: 'column' },
191
+ { selector: '.bleed-media', label: 'media', role: 'figure' },
192
+ { selector: '.bleed-content', label: 'content', role: 'column' },
193
+ { selector: '.bleed-overlay', label: 'overlay', role: 'band' },
194
+ { selector: '.quote-content', label: 'quote', role: 'column' },
195
+ { selector: '.quote-figure', label: 'portrait', role: 'figure' },
196
+ { selector: '.quote-body', label: 'body', role: 'band' },
197
+ { selector: '.quote-attribution', label: 'attrib', role: 'band' },
198
+ { selector: '.cover-content', label: 'cover', role: 'column' },
199
+ { selector: '.cover-rule', label: 'rule', role: 'band' },
200
+ { selector: '.cover-meta', label: 'meta', role: 'band' },
201
+ { selector: '.divider-index', label: 'index', role: 'band' },
202
+ { selector: '.divider-rule', label: 'rule', role: 'band' },
203
+ { selector: '.end-logo', label: 'logo', role: 'band' },
204
+ ];
205
+
206
+ /*
207
+ THE ELEMENT TIER — content boxes, as distinct from the regions a layout positions.
208
+
209
+ Two closed lists rather than one wildcard, because the theme's own naming is not uniform (`deck-`,
210
+ bare, and `forest-` prefixes all appear) so nothing can be enumerated by pattern.
211
+
212
+ ⚠ NO INLINE ELEMENTS, EVER — no `strong`, `em`, `a`, `code`. getBoundingClientRect on a wrapped
213
+ inline run returns the UNION of its line fragments, which is a rectangle that exists nowhere on
214
+ the slide. Drawing it would be the overlay inventing geometry, which is the one thing a debug tool
215
+ must never do. They are absent by construction rather than filtered later.
216
+ */
217
+ const ELEMENT_PROSE = 'h1, h2, h3, h4, h5, h6, hgroup, p, ul, ol, li, hr, pre, blockquote, table, figure, figcaption';
218
+ const ELEMENT_COMPONENT = [
219
+ '.deck-card', '.deck-chart', '.deck-grid', '.deck-alert',
220
+ '.deck-icon-tile', '.deck-placeholder', '.badge-strip', '.deck-data-table', '.london-map-figure',
221
+ /* ★★ FOUR OF THESE WERE DEAD, and nothing said so. The overlay queried `.dotted-map`, `.deck-kpi`,
222
+ `.deck-stat-card` and `.deck-bars`; the components emit `dotmap`, `kpi`, `stat-card` and `bars`.
223
+ Zero matches for any of the four, so the element tier has never once drawn a KPI, a stat card,
224
+ a bar chart or a dot map — it just found nothing and moved on, which is what a querySelectorAll
225
+ does when it is wrong.
226
+
227
+ Three of the four are the same slip: a `deck-` prefix the component does not carry. That is the
228
+ uneven naming causing a real defect rather than an aesthetic one — and it is why the fix here is
229
+ paired with a GATE (test/classRefs.static.test.ts) rather than with a rename. Renaming the
230
+ components would not have prevented this; a test that resolves every hardcoded selector does. */
231
+ '.borough-shape', '.dotmap', '.kpi', '.stat-card', '.bars',
232
+ ].join(', ');
233
+
234
+ /*
235
+ THE CONTAINERS WHOSE CHILDREN HAVE SEAMS BETWEEN THEM. A gap is not a thing in the DOM — it is the
236
+ rectangle left over between two boxes — so it can only be computed per container, over children
237
+ that actually sit next to each other.
238
+ */
239
+ /*
240
+ ★ `.stack-grid` WAS MISSING, AND IT IS THE ONE STACK REGION WITH A SEAM. stack.vue sets
241
+ `row-gap: var(--deck-space-5)` on `.stack-grid` — the 40px between the band and the strip, and the
242
+ only gap that layout declares. `.stack-strip` was listed (it is a gap CONTAINER for its own
243
+ children) but the grid that holds the two apart was not, so the overlay drew every seam in the
244
+ deck except stack's own.
245
+ */
246
+ const GAP_CONTAINERS = '.slidev-layout, .split-grid, .split-content, .stack-grid, .stack-strip, .bleed-content, .cover-content, .deck-grid, .quote-content, .quote-attribution, .badge-strip, hgroup, ul, ol';
247
+
248
+ /*
249
+ ★★ WHOSE INSET IS WHOSE — the question the overlay could not answer, and the reason `split` looked
250
+ like a layout with no vertical inset at all.
251
+
252
+ Every layout reserves the same edge. They do not all reserve it in the same PLACE. Most put the
253
+ text inset of padding on their root; `stack` puts ZERO there and re-adds it on its children,
254
+ because a region that has to escape past the inset can only do so from a box that does not
255
+ already carry it.
256
+
257
+ `split` used to do the same, for a reason that has since gone: its anchor was `calc(100% / 3)`,
258
+ and a percentage measured inside a padded box is a third of what is LEFT rather than a third of
259
+ the canvas. With the anchor a canvas pixel, split is an ordinary padded root and its figure
260
+ escapes with a NEGATIVE margin instead.
261
+
262
+ The overlay drew padding only, so a child's margin was invisible and a root's zero read as "this
263
+ layout has no inset". Both halves are drawn now, told apart by their label, so the schematic
264
+ reports where an inset actually lives rather than only where it happens to be padding.
265
+
266
+ ★ `.deck-eyebrow` IS A SUBJECT, AND IT IS THE ONE THAT IS NOT A FRAME. Every other entry here is
267
+ a box a layout POSITIONS; the eyebrow is a line of text. It is here because it carries the only
268
+ other deliberate negative margin in the theme — `.split-content > .deck-eyebrow:first-child` is
269
+ lifted by its own block (split.vue), so the heading after it starts on the anchor whether the
270
+ eyebrow is there or not. That lift is invisible in every other tier: the column's own box does
271
+ not move, so the region outlines are identical with and without it, and the thing that DID move
272
+ is the h1 the overlay draws no rule for. Measured on deck-template, 12 of its split slides carry
273
+ one at -41. Left out, the escape pass reported three `.split-figure` escapes on those slides and
274
+ stayed silent about the fourth negative on the same slide.
275
+ */
276
+ const INSET_SUBJECTS = `${GAP_CONTAINERS}, .split-figure, .stack-band, .quote-figure, .bleed-media, .cover-rule, .divider-rule, .deck-eyebrow`;
277
+
278
+ /*
279
+ ★ THE LADDER, REVERSED. A gap's number is only half the answer — "is 40 a number this system
280
+ actually has" is the other half, and it is the question a schematic is for. A match prints the
281
+ token that owns the value; a miss prints the number with a cross and goes to the hazard tier.
282
+
283
+ Measured in canvas units against a 1px tolerance, because a rect difference at a scaled viewport
284
+ carries sub-pixel rounding that has nothing to do with the CSS.
285
+ */
286
+ /*
287
+ ★★ DERIVED FROM THE GENERATED MANIFEST, NOT WRITTEN OUT. This was a hand-kept table of literal
288
+ pixels — [90, 'inset-text'], [96, 'notch-h'] and so on — which is correct only for the grid it was
289
+ typed against. Move GRID_PX and every row silently becomes a lie about a different token: at grid
290
+ 24 with text at x4 the text inset IS 96, so the schematic matched the stale [96, 'notch-h'] row
291
+ and labelled the deck's text frame NOTCH-H. Jose, reading it off the slide: "i dont see the text
292
+ inset label in the schematic rulers". It had not vanished — it was wearing another token's name.
293
+
294
+ Same failure as the step-index constants in scripts/deck.ts: a number chosen for what it happened
295
+ to equal, rather than for what it means. Reading deck.json makes the ladder true at any grid, and
296
+ a token that is renamed or removed drops out of the schematic instead of mislabelling something.
297
+ */
298
+ const LADDER = (() => {
299
+ const sp = deckTokens.spacing;
300
+ const ins = deckTokens.insets.values;
301
+ const rows = [[sp.gridPx, 'grid']];
302
+ for (const [name, v] of Object.entries(sp.steps)) rows.push([v.px, name]);
303
+ for (const [name, v] of Object.entries(ins)) rows.push([v.px, `inset-${name}`]);
304
+ rows.push([deckTokens.chrome.markPx, 'chrome-mark'], [deckTokens.chrome.bandPx, 'chrome-band']);
305
+ // card-pad names a STEP rather than a length, so it has to be resolved through the ladder it sits on.
306
+ const cardPad = sp.steps[deckTokens.radius.cardPadStep]?.px;
307
+ if (cardPad) rows.push([cardPad, 'card-pad']);
308
+ // Also a sum rather than a token: default.vue adds the heading top space to the text inset on a
309
+ // slide that is not centred.
310
+ const headingTop = deckTokens.anchors.headingTopSpace?.px;
311
+ if (headingTop) rows.push([ins.text.px + headingTop, 'inset-text + heading-top']);
312
+ return rows.filter(([px]) => Number.isFinite(px)).sort((x, y) => x[0] - y[0]);
313
+ })();
314
+ /*
315
+ ★ SEVERAL TOKENS SHARE A VALUE, AND THE LABEL SAYS SO. --deck-inset-chrome and --deck-inset-media
316
+ are both 36; --deck-space-3 and --deck-notch-gap are both 24. Naming only the first match makes
317
+ the schematic assert which token a number came from, which it cannot know from a measurement — and
318
+ it named a FIGURE's media inset "inset-chrome" on every split slide. Every match is listed instead,
319
+ so the reader picks from a true set rather than trusting a guess.
320
+ */
321
+ /*
322
+ ★★ THE NOTCH ROWS ARE CONDITIONAL, because they are only an ANSWER on a slide that has a notch.
323
+
324
+ The notch is derived — the mark (72) plus one gap (24) — so `notch-h` is 96, and split.vue pads a
325
+ notched panel to `max(card-pad, notch-h)`. On a panel that is the right name for the number. On
326
+ every other slide it is a coincidence, and the ladder matches on VALUE, so it was volunteering
327
+ "notch-h" as an explanation for measurements that have nothing to do with a notch. That went
328
+ unnoticed while the text inset was 90 and the notch 96; the moment the grid made both 96 the root
329
+ padding of every slide started being labelled with it. Jose: "wtf is that notch-h".
330
+
331
+ A ladder that names a token nobody used is worse than one that says the number is off-ladder — the
332
+ first sends you looking for a notch, the second sends you looking at the number.
333
+ */
334
+ const notchRows = () => [
335
+ [deckTokens.notch.gapPx, 'notch-gap'],
336
+ [deckTokens.notch.heightPx, 'notch-h'],
337
+ ];
338
+
339
+ const ladderName = (n, opts = {}) => {
340
+ const table = opts.notched ? [...LADDER, ...notchRows()] : LADDER;
341
+ const hits = table.filter(([px]) => Math.abs(px - n) <= 1).map(([, name]) => name);
342
+ return hits.length ? `${Math.round(n)} · ${hits.join(' / ')}` : `${Math.round(n * 10) / 10} ✗`;
343
+ };const overlayEl = ref(null);
344
+ const regions = ref([]);
345
+ const elements = ref([]);
346
+ const gaps = ref([]);
347
+ const notes = ref([]);
348
+ /*
349
+ ★★ THE GUTTER LABEL IS MEASURED NOW, BECAUSE BOTH OF ITS OLD ANCHORS WERE WRONG.
350
+
351
+ It names split's conditional gutter — 0 between text and a real figure, 96 between two same-kind
352
+ columns. It was pinned `left: var(--deck-inset-chrome)` (36) and hung off --deck-anchor-third
353
+ (360), and neither is where the thing it describes is:
354
+
355
+ · HORIZONTALLY it sat at the slide's left edge while the gutter it names is the boundary between
356
+ the two columns. Measured on specimen 18: label at x 36-182, boundary at x 672. A caption 636px
357
+ from its subject, which is how it reads — a chip floating in the content column for no reason.
358
+ · VERTICALLY it hung off the anchor, which only holds at `align: anchor`. The anchor LINE was
359
+ taught to read `align`; this was not, so on `start`, `centre` and `end` the label pinned itself
360
+ to a line that is not drawn — and on `align: start`, where the column begins at 136, it landed
361
+ on the text.
362
+
363
+ Both are now read off the two columns themselves, so the label sits at the seam it names and above
364
+ whatever the column's real top turns out to be, on every value of `align`.
365
+ */
366
+ const gutterAt = ref(null);
367
+ const tierOn = (id) => layoutDebugTiers[id];
368
+
369
+ /*
370
+ THE SLIDE'S OWN PROPS, beside the key that explains the drawing.
371
+
372
+ Two lists, because they answer two different questions and conflating them is how you end up
373
+ debugging a value nobody set:
374
+
375
+ AUTHORED — exactly what this slide's frontmatter says. What you wrote.
376
+ APPLIED — the state classes the layout actually rendered (`is-1-2`, `is-right`, `is-photo`,
377
+ `bg-warm`, `is-h-end`). What the layout DID, including every default that filled
378
+ in for a prop the frontmatter never mentions.
379
+
380
+ ★ THE APPLIED HALF IS READ OFF THE DOM, NOT FROM THE MANIFEST. manifest.json carries every
381
+ layout's prop defaults and would give a prettier list — and it is 160KB, which is a real cost on
382
+ every deck for a panel that is off by default and never ships. The rendered classes are already
383
+ there, cost nothing, and are strictly better evidence: they are what the layout resolved, not what
384
+ it says it would resolve.
385
+
386
+ `layout`, `class` and `clicks` are dropped from the authored list — the first is the panel's own
387
+ title, the last two are Slidev's rather than the theme's.
388
+ */
389
+ const SKIP_KEYS = new Set(['layout', 'class', 'clicks', 'preload', 'transition', 'level', 'title', 'src']);
390
+ /*
391
+ Long values — an `alt` sentence, an `image` URL — are cut by CSS at the right, and the whole value
392
+ stays in the row's `title`, which is reachable because this panel is the one part of the overlay
393
+ that takes pointer events.
394
+
395
+ ⚠ NOT a path-aware rule keeping the last segment. That was the first version and it is wrong for
396
+ this deck's own URLs: the identifying part of
397
+ `assets.forest.bike/images/person-smiling-beside-green-bike/web.webp` is the DIRECTORY, and the
398
+ filename it kept was a generic `web.webp` that names nothing. One rule that cuts the end beats two
399
+ rules where the clever one guesses the wrong end.
400
+ */
401
+ const slideProps = computed(() => {
402
+ const fm = currentFrontmatter.value;
403
+ return Object.entries(fm)
404
+ .filter(([k, v]) => !SKIP_KEYS.has(k) && v !== undefined && v !== '')
405
+ .map(([k, v]) => ({ k, v: typeof v === 'string' ? v : JSON.stringify(v) }));
406
+ });
407
+ const appliedState = ref([]);
408
+ /*
409
+ ★★ THE ANCHOR LINE IS PART OF THE FRAME TIER AND MUST BE COUNTED. `frame` was the literal 6 while
410
+ every other row counted what it draws, so a split drawing the anchor showed 7 lines under a key
411
+ saying 6 — wrong in the one row a reader consults to check the overlay is drawing what they think.
412
+
413
+ ⚠ THE ORIGINAL WORDING HERE WAS "a split WITHOUT a header draws it", and cited two slides by
414
+ number. That was true of a deck with a `::header::` slot; split.vue has since removed the slot
415
+ entirely, so the sentence described a condition that could no longer occur AND the code below
416
+ never implemented it — `isSplit` alone was the whole test. Both halves are corrected: the story
417
+ is the `align` prop now, and the gate reads it.
418
+
419
+ Declared once here and used by the markup too, so the condition cannot drift from the count.
420
+ */
421
+ /*
422
+ ★★ AND IT MUST READ `align`, WHICH IT DID NOT. The comment above tells a story about a `::header::`
423
+ slot — "a split WITHOUT a header draws it" — and that slot no longer exists (split.vue removed it;
424
+ see its own note). What actually suppresses the anchor today is split's `align` prop: `anchor` is
425
+ the default and the only value that keeps row 1 at the 360 line. `center` and `end` collapse it to
426
+ 0 and place the column against its own content height; `start` collapses it too, and base.css then
427
+ re-sizes it to the heading top space under chrome.
428
+
429
+ So on three of the four values this painted a 3px line across y 360 pointing at nothing, and
430
+ counted it in the frame tier while doing so. The condition the comment claims was implemented was
431
+ never written — `isSplit` alone was the whole test.
432
+ */
433
+ const splitAlign = computed(() => currentFrontmatter.value.align ?? 'anchor');
434
+ const anchorShown = computed(() => isSplit.value && splitAlign.value === 'anchor');
435
+ /*
436
+ Live counts for the key, so every row states how much of the thing it names is actually drawn.
437
+ FRAME_STATIC is the four inset frames plus the two centre lines — genuinely fixed markup, not a
438
+ measurement, and it must be kept in step with the `tierOn('frame')` elements in the template.
439
+ */
440
+ const FRAME_STATIC = 6;
441
+ /*
442
+ ★★ THE GAP ROW COUNTED SEAMS AND THE GAP TOGGLE DRAWS PADS TOO.
443
+
444
+ `tierOn('gap')` guards two passes: the seam pass (`tier: 'gap'`) and the inset pass, which pushes
445
+ every padding and margin band as `tier: 'pad'`. Only the first was counted, so the row read `gap 1`
446
+ on a slide drawing nine bands under that one toggle — measured on specimen 5: one `40 · space-5`
447
+ seam and eight `pad`/`mar` bands, under a key claiming 1.
448
+
449
+ A key exists so a reader can check the overlay is drawing what they think. A row that says 1 where
450
+ 9 are on screen makes the instrument the thing you have to check, which is the one job it cannot do.
451
+
452
+ ★ AND `grid` HAD NO COUNT AT ALL. It rendered an empty <dd> beside a comment claiming every row
453
+ states what it draws. The grid tier is a GROUND — one wash carrying the lattice, plus the two
454
+ centre rules — so it is countable, and counting it is cheaper than explaining why it is the one
455
+ row that does not.
456
+ */
457
+ const CENTRE_RULES = 2;
458
+ const tierCount = computed(() => ({
459
+ grid: 1 + CENTRE_RULES,
460
+ frame: FRAME_STATIC + (anchorShown.value ? 1 : 0),
461
+ region: regions.value.length,
462
+ element: elements.value.length,
463
+ gap: gaps.value.filter((g) => g.tier === 'gap' || g.tier === 'pad').length,
464
+ hazard: gaps.value.filter((g) => g.tier === 'hazard').length + notes.value.filter((n) => n.tier === 'hazard').length,
465
+ }));
466
+ // Where the text column actually begins, measured — used to place the gutter label on a header
467
+ // split, where the anchor it used to hang from is no longer where anything starts.
468
+ // Read off the generated tokens the frames are actually drawn from, so the key cannot state a
469
+ // number the lines do not use. `getComputedStyle` on the overlay resolves them in the same cascade
470
+ // the frames resolve in.
471
+ const tokenPx = (name, axis = 'x') => {
472
+ if (typeof window === 'undefined' || !overlayEl.value) return '';
473
+ /*
474
+ ★ RESOLVED BY MEASURING, not by parsing. getPropertyValue returns a custom property's raw TEXT,
475
+ and every inset in this system is an expression — `--deck-inset-chrome` is `calc(var(--deck-grid) * 1.5)`.
476
+ parseFloat("calc(...") is NaN, and the key printed "NaNpx" for all three. Assigning the token to
477
+ a real length and reading the resolved value back makes the browser do the arithmetic, which is
478
+ also the only way this stays correct if an inset ever becomes a clamp() or a min().
479
+ */
480
+ const probe = document.createElement('div');
481
+ /*
482
+ ★ AND THE AXIS MATTERS — for the tokens where it still can. `--deck-anchor-third` was
483
+ `calc(100% / 3)`, so resolving it against a probe's WIDTH measured a third of 1920 and the key
484
+ printed 640px beside a line the overlay draws at y=360. That token is a plain pixel now and
485
+ reads the same either way, but the parameter stays: a percentage or a `min()` against the
486
+ viewport has no meaning until you say of what, and this is the only place that can say it.
487
+ */
488
+ probe.style.cssText = axis === 'y'
489
+ ? `position:absolute;visibility:hidden;width:0;height:var(${name})`
490
+ : `position:absolute;visibility:hidden;height:0;width:var(${name})`;
491
+ overlayEl.value.appendChild(probe);
492
+ const px = axis === 'y' ? getComputedStyle(probe).height : getComputedStyle(probe).width;
493
+ probe.remove();
494
+ const n = parseFloat(px);
495
+ return Number.isFinite(n) ? `${Math.round(n)}px` : '';
496
+ };
497
+ const tokenTick = ref(0);
498
+ const insetChrome = computed(() => (tokenTick.value, tokenPx('--deck-inset-chrome')));
499
+ const insetMedia = computed(() => (tokenTick.value, tokenPx('--deck-inset-media')));
500
+ const insetText = computed(() => (tokenTick.value, tokenPx('--deck-inset-text')));
501
+ const insetDisplay = computed(() => (tokenTick.value, tokenPx('--deck-inset-display')));
502
+ const anchorY = computed(() => (tokenTick.value, tokenPx('--deck-anchor-third', 'y')));
503
+ const contentRegion = computed(() => regions.value.find((r) => r.selector === '.split-content') ?? null);
504
+
505
+ /*
506
+ ★★ THE MEDIA FRAME'S VERTICAL BOUND IS NOT ONE NUMBER, and drawing it as one put the rule 60px
507
+ off the figure on every notched slide in the deck.
508
+
509
+ scripts/deck.ts says it above INSET, and the generated deck.css repeats it over the token: a
510
+ figure's top and bottom follow the NOTCH, together. Notched, both edges are --deck-inset-media
511
+ (36) — the cut is what keeps the running mark clear, and the mark's own corner and the figure's
512
+ are then the same point. Un-notched (`notch: false`, `chrome: false`, or a `media: none` second
513
+ content column), both are --deck-inset-text (96), the pre-notch geometry the escape hatch is
514
+ documented to restore.
515
+
516
+ This drew --deck-inset-text unconditionally, under a comment that cited that same deck.ts passage
517
+ as authority for "media's vertical bound IS text's" — the one reading it does not support. So the
518
+ overlay reproduced, as a drawn rule, exactly the half-state deck.ts records as already fixed: 36
519
+ on the sides, 96 top and bottom. Measured with the ruler on specimen slide 18 (`frame: panel`,
520
+ notched): the rule sat at 96/96 while the figure it bounds measured 36 on all four sides, and the
521
+ escape pass printed `div.split-figure top escape -60` beside it — the overlay reporting the
522
+ 60px in one tier and denying it in another.
523
+
524
+ ONE LINE, NOT TWO. Both candidate values already have a rule on screen: 36 is the chrome frame's
525
+ inset on all four edges and 96 is the text frame's, so a second line at either lands exactly on
526
+ one that is already drawn — the same "two rectangles on the same point" this file collapses to a
527
+ note for bleed's media and overlay. The tokens are what differ, and the key already prints all
528
+ four for comparison.
529
+ */
530
+ const figureNotched = ref(false);
531
+ const mediaVBound = computed(() => (figureNotched.value ? insetMedia.value : insetText.value));
532
+
533
+ /*
534
+ ★★ THE ANCHOR LINE SPANS WHAT THE ANCHOR ACTUALLY GOVERNS, which is not always the canvas.
535
+
536
+ It was drawn `left: 0; right: 0` on every split without a header — a FRAME-tier line, which is
537
+ this overlay's way of saying "a fact about the slide". That was only ever true at `media: none`,
538
+ where both columns are content and both start on the third. Beside a real figure — photo, panel,
539
+ or the component kind — the figure spans rows 1-2 and starts at the top, so a full-width line
540
+ claims a rule the far half of the slide does not obey, and points at nothing where it crosses it.
541
+
542
+ Jose, looking at exactly that: "why the blue goes all the way crossing the whole slide when it is
543
+ only for the part that have text?"
544
+
545
+ Measured off the CONTENT region rather than computed from ratio and side: the box is already
546
+ measured for the region outlines, and re-deriving it here would be a second implementation of the
547
+ column arithmetic that could disagree with the first. Same discipline as the header case above —
548
+ the line draws only where it is still the rule, and now also only ACROSS where it is still the
549
+ rule.
550
+ */
551
+ const anchorSpan = computed(() => {
552
+ if (splitFrame.value === 'content') return null; // both columns obey it — the canvas-wide line is true
553
+ const c = contentRegion.value?.style;
554
+ return c ? { left: c.left, width: c.width, right: 'auto' } : null;
555
+ });
556
+
557
+ function measureRegions() {
558
+ const host = overlayEl.value;
559
+ if (!host || !show.value) { regions.value = []; return; }
560
+ /*
561
+ ★★ THE PAGE IS PICKED BY NUMBER, NOT BY BEING VISIBLE — and that distinction is the whole
562
+ "rulers lag one slide" bug.
563
+
564
+ Slidev keeps EVERY slide mounted (128 of them in the specimen) and runs a real, non-zero CSS
565
+ transition between them. So there are two moments where "the first .slidev-page with a box" is
566
+ wrong, and they fail in opposite directions:
567
+
568
+ · before any navigation, every page except the active one measures 0x0, and the FIRST in
569
+ document order is not the active one — the overlay drew nothing at all;
570
+ · mid-transition BOTH the outgoing and incoming slides have boxes, and the outgoing one comes
571
+ first whenever you are moving forwards — so the rulers measured the slide you just left.
572
+
573
+ The second is what a reader sees as a one-slide lag: the watcher fires on `currentPage`, waits a
574
+ single `nextTick`, and measures while the leave-transition is still running. Nothing re-measures
575
+ when it finishes, so the stale boxes stand until the NEXT navigation measures the slide you were
576
+ on a moment ago. Forever one behind.
577
+
578
+ `data-slidev-no` is Slidev's own 1-based page attribute, so asking for the number the router is
579
+ on cannot pick up a neighbour whatever the transition is doing. The visible-box scan stays as a
580
+ fallback for the case the attribute ever moves, and the zero-box guard below still skips a page
581
+ that is mounted but not laid out.
582
+ */
583
+ const page = document.querySelector(`.slidev-page[data-slidev-no="${currentPage.value}"]`)
584
+ ?? [...document.querySelectorAll('.slidev-page')].find((el) => {
585
+ const b = el.getBoundingClientRect();
586
+ return b.width > 0 && b.height > 0;
587
+ });
588
+ const root = host.getBoundingClientRect();
589
+ // The overlay's own box IS the unpadded canvas (see this file's top comment), so a rect measured
590
+ // against it needs no scale factor — both are in the same transformed space.
591
+ // Canvas units, not pixels of a scaled viewport: the deck is authored against 1920x1080 and every
592
+ // number in the theme's comments, tokens and audit is in that space. A reader comparing the
593
+ // overlay against --deck-inset-text (90) must see 90, whatever size the window happens to be.
594
+ const toCanvas = (px, axis) => Math.round((px / (axis === 'x' ? root.width : root.height)) * (axis === 'x' ? 1920 : 1080));
595
+ const pct = (b) => ({
596
+ left: `${((b.left - root.left) / root.width) * 100}%`,
597
+ top: `${((b.top - root.top) / root.height) * 100}%`,
598
+ width: `${(b.width / root.width) * 100}%`,
599
+ height: `${(b.height / root.height) * 100}%`,
600
+ });
601
+ /* A rect in CANVAS units, which is the only space any number in this theme is quoted in. */
602
+ const canvas = (b) => ({
603
+ left: toCanvas(b.left - root.left, 'x'),
604
+ top: toCanvas(b.top - root.top, 'y'),
605
+ right: toCanvas(b.right - root.left, 'x'),
606
+ bottom: toCanvas(b.bottom - root.top, 'y'),
607
+ width: toCanvas(b.width, 'x'),
608
+ height: toCanvas(b.height, 'y'),
609
+ });
610
+ const name = (el) => el.tagName.toLowerCase()
611
+ + (typeof el.className === 'string' && el.className.trim()
612
+ ? `.${el.className.trim().split(/\s+/)[0]}` : '');
613
+ const seen = (el) => {
614
+ const cs = getComputedStyle(el);
615
+ return cs.visibility !== 'hidden' && cs.opacity !== '0';
616
+ };
617
+
618
+ const nextRegions = [];
619
+ const nextNotes = [];
620
+ /*
621
+ ★ CHIPS GET PACKED, because two regions that start at the same corner put two labels on the same
622
+ pixel — `band` and `root` were unreadable on top of each other on every stack slide.
623
+
624
+ A greedy shelf packer in canvas units: try the chip's own row, and if something already occupies
625
+ that horizontal span, drop a row and try again. Sorted by area DESCENDING before packing, so the
626
+ big boxes keep their true top edge and the small ones move — a chip that has moved is still
627
+ attached to its own outline, and the big box is the one you are usually reading.
628
+ */
629
+ /*
630
+ ★ MEASURED, NOT GUESSED: a chip renders 26.5 canvas px tall, so shelves of 26 overlapped by half
631
+ a pixel even when the packer worked. 30 leaves a hair of daylight between rows.
632
+ */
633
+ const LABEL_H = 30;
634
+ /*
635
+ ★★ A CHIP ONLY MOVES IF SOMETHING IS ACTUALLY IN ITS WAY. This rounded every chip onto a shelf
636
+ lattice — `Math.round(y / LABEL_H)` — BEFORE testing for a clash, so a label with nothing near
637
+ it was still snapped to the nearest shelf and left floating off the corner it names. Measured on
638
+ the specimen's two-chart slide: `content` and `aside` clash with nothing and sat 6px below their
639
+ own boxes, because 264 is not a multiple of 30. A chip that does not touch its own outline is
640
+ the one thing this label must never do — on a dense slide it is the only way to tell which of
641
+ four nested boxes it belongs to.
642
+
643
+ So: try the true corner, and only then step down a row at a time. Rectangles rather than shelf
644
+ buckets, because once a chip can sit at an arbitrary y the buckets no longer line up with each
645
+ other and two "different" shelves can overlap.
646
+ */
647
+ const placed = [];
648
+ const placeChip = (x, y, width) => {
649
+ for (let n = 0; n < 8; n += 1) {
650
+ const top = y + n * LABEL_H;
651
+ const clash = placed.some((p) => x < p.x + p.w && p.x < x + width && top < p.y + LABEL_H && p.y < top + LABEL_H);
652
+ if (!clash) {
653
+ placed.push({ x, y: top, w: width });
654
+ return top - y; // 0 when it kept its own corner, which is the common case
655
+ }
656
+ }
657
+ return null;
658
+ };
659
+
660
+ for (const r of REGIONS) {
661
+ const els = page ? [...page.querySelectorAll(r.selector)] : [];
662
+ if (!els.length) {
663
+ // ★ ABSENT, COLLAPSED AND UNMEASURABLE ARE THREE DIFFERENT STATEMENTS, and all three used to
664
+ // render as nothing at all — so a row that matched no element looked exactly like a row that
665
+ // did not exist. Only the conditional regions are worth saying this about.
666
+ if (r.selector === '.split-footer' || r.selector === '.quote-figure') {
667
+ nextNotes.push({ tier: 'region', text: `${r.label} — absent` });
668
+ }
669
+ continue;
670
+ }
671
+ for (const el of els) {
672
+ if (!seen(el)) continue;
673
+ const cs = getComputedStyle(el);
674
+ let b = el.getBoundingClientRect();
675
+ if (b.width === 0 || b.height === 0) {
676
+ nextNotes.push({ tier: 'region', text: `${r.label} — collapsed to zero` });
677
+ continue;
678
+ }
679
+ /*
680
+ The ROOT is drawn as its CONTENT box. Its border box is always the canvas, which is a
681
+ rectangle every slide has and nobody needs drawn — the interesting number is where its own
682
+ padding actually starts, which differs per layout and is the thing no token states.
683
+ */
684
+ if (r.box === 'content') {
685
+ /*
686
+ ★★ CANVAS UNITS IN, SCREEN PIXELS OUT — the same trap as the padding bands, and it bit
687
+ here first. `b` came from getBoundingClientRect and is in SCREEN pixels; a computed
688
+ padding is in CANVAS units, because a transform does not scale computed values. Subtracting
689
+ one from the other inset the root by 90 screen pixels where the edge is 54, and the ROOT
690
+ chip landed in the middle of the slide, under the heading, instead of on the content's own
691
+ top-left corner. Reported as "what is that root thing in the middle of the slide?".
692
+ */
693
+ const sx = root.width / 1920;
694
+ const sy = root.height / 1080;
695
+ const p = (k, s2) => (parseFloat(cs.getPropertyValue(k)) || 0) * s2;
696
+ const padL = p('padding-left', sx) + p('border-left-width', sx);
697
+ const padR = p('padding-right', sx) + p('border-right-width', sx);
698
+ const padT = p('padding-top', sy) + p('border-top-width', sy);
699
+ const padB = p('padding-bottom', sy) + p('border-bottom-width', sy);
700
+ b = new DOMRect(b.left + padL, b.top + padT, b.width - padL - padR, b.height - padT - padB);
701
+ }
702
+ const c = canvas(b);
703
+ /*
704
+ ★ A BOX THAT IS THE WHOLE CANVAS IS NOT A REGION. bleed's media and its overlay are both
705
+ `inset: 0`, so they drew two identical full-canvas rectangles with two labels on the same
706
+ point. Collapsed to a note instead of a box nobody can learn anything from.
707
+ */
708
+ if (c.width >= 1918 && c.height >= 1078 && r.box !== 'content') {
709
+ nextNotes.push({ tier: 'region', text: `${r.label} — full canvas` });
710
+ continue;
711
+ }
712
+ // Role read off the LIVE element, never stored, and only on a FIGURE SLOT — see the REGIONS
713
+ // comment for both halves, and for what the root drew before `frameRole` gated this.
714
+ const role = !r.frameRole ? r.role
715
+ : el.classList.contains('is-content') ? 'column'
716
+ : el.classList.contains('is-panel') ? 'panel'
717
+ // `figure` and `photo` draw the same way — both are a real figure filling the cell.
718
+ : (el.classList.contains('is-photo') || el.classList.contains('is-figure')) ? 'figure'
719
+ : r.role;
720
+ nextRegions.push({
721
+ /* ★★ THE SELECTOR IS CARRIED, because the KEY cannot be looked up by. It is
722
+ `selector.slice(1)` PLUS an index — `.split-content` becomes `split-content-1` — so
723
+ `find(r => r.key === 'split-content')` matches nothing, ever. A `contentTop` computed
724
+ did exactly that and had been silently null since it was written; the readout meant to
725
+ print it showed nothing, and nobody noticed a number that was never there. It and its
726
+ `.deck-debug-label.is-measured` rule are both gone now — a dead computed feeding a rule
727
+ nothing stamps is two pieces of scaffolding for a feature that was never wired. */
728
+ selector: r.selector,
729
+ key: `${r.selector.slice(1)}-${nextRegions.length}`,
730
+ role,
731
+ label: r.label,
732
+ readout: `${c.left},${c.top} · ${c.width}x${c.height}`,
733
+ rect: c,
734
+ area: c.width * c.height,
735
+ style: pct(b),
736
+ });
737
+ }
738
+ }
739
+ /* Pack the chips after every box is known, biggest first — see placeChip. */
740
+ for (const r of [...nextRegions].sort((a, z) => z.area - a.area)) {
741
+ const offset = placeChip(r.rect.left, r.rect.top, r.label.length * 11 + 16);
742
+ if (offset === null) {
743
+ r.chipStyle = { display: 'none' };
744
+ nextNotes.push({ tier: 'region', text: `${r.label} — chip dropped, no room` });
745
+ continue;
746
+ }
747
+ /*
748
+ A chip is positioned inside its own region box, so a percentage `top` would be a fraction of
749
+ the REGION's height — which is a different number for every region and none of them the canvas
750
+ the offset was computed in. Translated instead.
751
+
752
+ ★★ IN CANVAS PX, NOT SCREEN PX, AND THAT ONE CONVERSION WAS THE WHOLE BUG. This read
753
+ `(offset / 1080) * root.height`, converting the canvas offset into screen pixels. But the chip
754
+ lives INSIDE the slide, which Slidev has already scaled — a `px` in here IS a canvas px — so
755
+ the conversion applied the scale a second time and every shelf collapsed to roughly half its
756
+ height. Measured on a split with a header: root, grid and header chips landed 15 canvas px
757
+ apart while each one is 26.5 tall, so all three printed on top of each other and over the
758
+ section pill. The packer was working perfectly and being handed a shrunken ruler.
759
+ */
760
+ r.chipStyle = { transform: `translateY(${offset}px)` };
761
+ }
762
+ regions.value = nextRegions;
763
+
764
+ /*
765
+ The seam between the two columns, and the top of the one the label belongs above. Taken from the
766
+ rendered boxes rather than from `side`, so a layout that ever places them differently moves the
767
+ label with it instead of stranding it.
768
+ */
769
+ {
770
+ const gc = page?.querySelector('.split-content');
771
+ const gf = page?.querySelector('.split-figure');
772
+ if (gc && gf) {
773
+ const c = gc.getBoundingClientRect();
774
+ const f = gf.getBoundingClientRect();
775
+ // Whichever inner edge the two share — content-left-of-figure, or the mirror of it.
776
+ const seam = c.left < f.left ? c.right : f.right;
777
+ gutterAt.value = {
778
+ left: `${((seam - root.left) / root.width) * 100}%`,
779
+ top: `${((c.top - root.top) / root.height) * 100}%`,
780
+ };
781
+ } else {
782
+ gutterAt.value = null;
783
+ }
784
+ }
785
+
786
+ /*
787
+ The layout's own state classes — every `is-*` / `bg-*` it rendered, minus the layout name
788
+ itself and the shared `slidev-layout`. This is the half of the props panel that shows a default
789
+ doing its work.
790
+ */
791
+ const layoutEl = page?.querySelector('.slidev-layout');
792
+ appliedState.value = layoutEl
793
+ ? [...layoutEl.classList].filter((c) => c.startsWith('is-') || c.startsWith('bg-'))
794
+ : [];
795
+ /*
796
+ Read off the slide rather than from `currentFrontmatter.notch`, because a notch is not one
797
+ prop: notchShows() (../notch.js) folds `notch:`, `chrome:` and the frame into one answer, and
798
+ the class the layouts stamp is that answer. The two layouts spell it differently — split marks
799
+ its figure `.has-notch`, stack marks its band `.is-notched` — so both are asked for.
800
+ */
801
+ figureNotched.value = !!page?.querySelector('.split-figure.has-notch, .stack-band.is-notched');
802
+
803
+ /*
804
+ THE ELEMENT TIER. Hairlines, no labels — identity is legible from the content underneath, and
805
+ forty labels would bury the boxes they name. Capped, and the cap is VISIBLE: a silently
806
+ truncated schematic is a schematic that lies about how much it looked at.
807
+ */
808
+ const nextElements = [];
809
+ if (tierOn('element') && page) {
810
+ const els = [...page.querySelectorAll(`${ELEMENT_PROSE}, ${ELEMENT_COMPONENT}`)];
811
+ if (els.length > 150) {
812
+ nextNotes.push({ tier: 'hazard', text: `elements ${els.length} · capped at 150` });
813
+ }
814
+ for (const el of els.slice(0, 150)) {
815
+ if (!seen(el)) continue;
816
+ const b = el.getBoundingClientRect();
817
+ if (b.width === 0 || b.height === 0) continue;
818
+ nextElements.push({
819
+ key: `el-${nextElements.length}`,
820
+ kind: el.matches(ELEMENT_COMPONENT) ? 'component' : 'prose',
821
+ style: pct(b),
822
+ });
823
+ }
824
+ }
825
+ elements.value = nextElements;
826
+
827
+ /*
828
+ ★★ A GAP IS NOT IN THE DOM. It is the rectangle left between two boxes, so it can only be
829
+ computed — per container, over children that actually sit beside each other.
830
+
831
+ Three mechanisms, and only the first is a rect difference:
832
+ · a SEAM between consecutive siblings (a real grid/flex gap, or a margin);
833
+ · PADDING, which is not a gap at all and is drawn hatched, because filling it flat would
834
+ assert a gutter the layout does not have;
835
+ · a NEGATIVE seam, which is an overlap, which is a hazard.
836
+
837
+ The declared `gap` wins over the measured band where the container states one: at a scaled
838
+ viewport a rect difference carries rounding that has nothing to do with the CSS, and a
839
+ schematic that calls 40px "39.4 ✗" is worse than one that says nothing.
840
+ */
841
+ const nextGaps = [];
842
+ if (tierOn('gap') && page) {
843
+ for (const container of page.querySelectorAll(GAP_CONTAINERS)) {
844
+ const cs = getComputedStyle(container);
845
+ const kids = [...container.children]
846
+ .filter((el) => seen(el) && el.getBoundingClientRect().width > 0 && el.getBoundingClientRect().height > 0)
847
+ .map((el) => ({ el, r: el.getBoundingClientRect() }))
848
+ .sort((a, z) => (a.r.top - z.r.top) || (a.r.left - z.r.left));
849
+ const declaredRow = parseFloat(cs.rowGap);
850
+ const declaredCol = parseFloat(cs.columnGap);
851
+ /*
852
+ ★★ PAIRS ARE FOUND BY ADJACENCY, NOT BY SORT ORDER.
853
+
854
+ This walked `kids` sorted by (top, left) and compared i with i+1, which assumes
855
+ consecutive-in-sort means neighbours-in-layout. That holds only while every cell in a row
856
+ shares a top — which `align: stretch` gives and `align: start` and `center` do not, the
857
+ moment the cells have unequal heights.
858
+
859
+ Measured on the Grid align specimen's centre row: cells at x 96-651, 683-1237, 1269-1824
860
+ with tops 604, 554, 579. Sorted by top the order is 2, 3, 1, so the pairs became (2,3) and
861
+ (3,1) — the second being opposite ends of the row, drawn as a 618 x 60 "gap" with cell two
862
+ sitting inside it. And because (1,2) were never consecutive, their real 32px seam was never
863
+ drawn at all: one band that was a lie, one that was missing, from the same wrong assumption.
864
+
865
+ So each box looks for its actual neighbour: the nearest box to its right that shares a
866
+ vertical span, and the nearest below that shares a horizontal one. Pairs are recorded both
867
+ ways round and de-duplicated, so a seam is emitted once. O(n²) over one container's
868
+ children, which is a handful of boxes.
869
+ */
870
+ const pairs = [];
871
+ const takenPair = new Set();
872
+ for (let i = 0; i < kids.length; i++) {
873
+ for (const axis of ['x', 'y']) {
874
+ let best = null;
875
+ for (let j = 0; j < kids.length; j++) {
876
+ if (i === j) continue;
877
+ const A = kids[i].r; const B = kids[j].r;
878
+ const shares = axis === 'x'
879
+ ? Math.min(A.bottom, B.bottom) - Math.max(A.top, B.top) > 1
880
+ : Math.min(A.right, B.right) - Math.max(A.left, B.left) > 1;
881
+ if (!shares) continue;
882
+ const after = axis === 'x' ? B.left >= A.right - 1 : B.top >= A.bottom - 1;
883
+ if (!after) continue;
884
+ const distance = axis === 'x' ? B.left - A.right : B.top - A.bottom;
885
+ if (!best || distance < best.distance) best = { j, distance };
886
+ }
887
+ if (!best) continue;
888
+ const key = `${Math.min(i, best.j)}-${Math.max(i, best.j)}-${axis}`;
889
+ if (takenPair.has(key)) continue;
890
+ takenPair.add(key);
891
+ pairs.push([kids[i].r, kids[best.j].r]);
892
+ }
893
+ }
894
+ for (const [a, z] of pairs) {
895
+ const ox = Math.min(a.right, z.right) - Math.max(a.left, z.left);
896
+ const oy = Math.min(a.bottom, z.bottom) - Math.max(a.top, z.top);
897
+ let band = null; let axis = null;
898
+ if (ox > 0 && oy <= 0) {
899
+ axis = 'y';
900
+ band = new DOMRect(Math.max(a.left, z.left), Math.min(a.bottom, z.bottom),
901
+ Math.min(a.right, z.right) - Math.max(a.left, z.left), Math.max(a.top, z.top) - Math.min(a.bottom, z.bottom));
902
+ } else if (oy > 0 && ox <= 0) {
903
+ axis = 'x';
904
+ band = new DOMRect(Math.min(a.right, z.right), Math.max(a.top, z.top),
905
+ Math.max(a.left, z.left) - Math.min(a.right, z.right), Math.min(a.bottom, z.bottom) - Math.max(a.top, z.top));
906
+ }
907
+ /*
908
+ ★★ THE OVERLAP ARM, WHICH WAS UNREACHABLE — AND IT IS THE ONE THIS FILE EXISTS FOR.
909
+
910
+ Two rects overlap in 2-D when ox > 0 AND oy > 0. Neither branch above matches that: the
911
+ first wants `oy <= 0`, the second `ox <= 0`. So a real collision fell through to
912
+ `if (!band) continue` and was dropped in silence — never drawn, never labelled, never
913
+ counted in the hazard tier.
914
+
915
+ The old guard for it could not fire either. It tested `sizePx < 0`, but on the y branch
916
+ sizePx is `max(a.top, z.top) - min(a.bottom, z.bottom)`, which is exactly `-oy` with
917
+ `oy <= 0`, so it is >= 0 by construction; the x branch is `-ox` under `ox <= 0`, the same.
918
+ Dead code guarding a case that arrived by a path that never ran.
919
+
920
+ That is the whole stated point of the overlay — see the note at the top of this file:
921
+ scripts/deckAudit.ts measures OVERFLOW past the canvas, not COLLISION inside it. The
922
+ instrument built to catch collisions by eye could not draw one.
923
+
924
+ Reported on the SHALLOWER axis, because that is the direction a reader would move something
925
+ to separate the pair, and the intersection rect is what gets painted.
926
+ */
927
+ /*
928
+ ★★ A SUB-PIXEL POSITIVE IS ADJACENCY, NOT A COLLISION — and testing `> 0` made this fire on
929
+ most of the deck the day it was written.
930
+
931
+ Two boxes that share an edge should give ox === 0 exactly. They do not: the rects come from
932
+ getBoundingClientRect on a transform-scaled slide, so an edge both sides agree on comes back
933
+ a fraction apart, and `ox > 0` passes. Measured on specimen 18 the moment the arm went in —
934
+ one hazard, box 0.0 x 295.4 screen px, labelled `overlap 0`: split's two columns touching,
935
+ reported as a collision. Every split has that pair.
936
+
937
+ So the test is a depth you could SEE, in canvas units, on both axes. A hazard tier that
938
+ cries wolf is worth exactly as much as the one that never fired — which is what this arm
939
+ replaced, and the reason to get the threshold right rather than ship the inverse defect.
940
+ */
941
+ const oxCanvas = toCanvas(ox, 'x');
942
+ const oyCanvas = toCanvas(oy, 'y');
943
+ if (oxCanvas >= 1 && oyCanvas >= 1) {
944
+ const depth = Math.min(ox, oy);
945
+ const collisionAxis = ox < oy ? 'x' : 'y';
946
+ nextGaps.push({
947
+ key: `gap-${nextGaps.length}`,
948
+ tier: 'hazard',
949
+ axis: collisionAxis,
950
+ vertical: collisionAxis === 'x',
951
+ label: `overlap ${toCanvas(depth, collisionAxis)}`,
952
+ style: pct(new DOMRect(Math.max(a.left, z.left), Math.max(a.top, z.top), ox, oy)),
953
+ });
954
+ continue;
955
+ }
956
+ if (!band) continue;
957
+ /*
958
+ ★★ A SEAM HAS NOTHING IN IT, AND WITHOUT THIS CHECK IT OFTEN DID.
959
+
960
+ The pairs above come from `kids` sorted by (top, left) and compared consecutively — which
961
+ assumes consecutive-in-sort means adjacent-in-layout. It does not the moment two cells in
962
+ the same row have different tops, which is exactly what `align: center` and `align: start`
963
+ produce on cells of unequal height. The sort then interleaves the row and pairs cells that
964
+ are not neighbours at all.
965
+
966
+ Measured on the Grid align specimen, the centre row: cells at x 96-651, 683-1237 and
967
+ 1269-1824, tops 604, 554 and 579. Sorted by top the order becomes 2, 3, 1 — so the last
968
+ pair is cell THREE against cell ONE, opposite ends of the row, and the band drawn between
969
+ them was 618 x 60 with cell two sitting inside it. A `gap` label on a rectangle containing
970
+ a cell.
971
+
972
+ Rejecting a band that another child intersects is the whole fix, and it is the definition
973
+ rather than a heuristic: the space between two boxes is empty, or it is not a gap.
974
+ */
975
+ const bandLeft = band.x, bandTop = band.y;
976
+ const bandRight = band.x + Math.abs(band.width), bandBottom = band.y + Math.abs(band.height);
977
+ const occupied = kids.some(({ r }) =>
978
+ r !== a && r !== z
979
+ && Math.min(r.right, bandRight) - Math.max(r.left, bandLeft) > 1
980
+ && Math.min(r.bottom, bandBottom) - Math.max(r.top, bandTop) > 1);
981
+ if (occupied) continue;
982
+ const sizePx = axis === 'y' ? band.height : band.width;
983
+ const sizeCanvas = axis === 'y' ? toCanvas(sizePx, 'y') : toCanvas(sizePx, 'x');
984
+ const declared = axis === 'y' ? declaredRow : declaredCol;
985
+ const value = Number.isFinite(declared) && declared > 0 ? declared : sizeCanvas;
986
+ if (sizeCanvas <= 0) continue;
987
+ /*
988
+ ★★ LEFTOVER SPACE IS NOT AN OFF-LADDER VALUE, and without this every centred slide reported
989
+ a hazard for working correctly.
990
+
991
+ A layout that centres with AUTO MARGINS hands its children whatever space is left over.
992
+ That number is a RESULT — it moves with the content's own height — so it lands on a spacing
993
+ rung only by coincidence, and `ladderName` marked all of it `✗`. Measured on the specimen's
994
+ `body-center` diagram slides: a 210px band under the heading, drawn in the hazard tier, on
995
+ a slide with nothing wrong with it. A warning that fires on every correct use of a feature
996
+ is worse than no warning, because it is the one people learn to scroll past.
997
+
998
+ The layout declares this with `data-deck-distributes` (see default.vue) rather than being
999
+ sniffed for it: `getComputedStyle` resolves `margin-top: auto` to its used pixel value, so
1000
+ after the fact an auto margin and a hardcoded one are the same number.
1001
+
1002
+ ⚠ ONLY ON THE AXIS NAMED, and only where the container did not declare the gap itself. A
1003
+ distributing layout can still hold a real grid gap, and that one IS authored spacing and
1004
+ stays on the ladder.*/
1005
+ const distributes = container.closest(`[data-deck-distributes~="${axis}"]`);
1006
+ const leftover = !!distributes && !(Number.isFinite(declared) && declared > 0);
1007
+ const name = leftover
1008
+ ? `${Math.round(sizeCanvas)} · leftover`
1009
+ : ladderName(value, { notched: !!container.closest('.has-notch') });
1010
+ nextGaps.push({
1011
+ key: `gap-${nextGaps.length}`,
1012
+ tier: name.endsWith('✗') ? 'hazard' : 'gap',
1013
+ axis,
1014
+ // A band between two side-by-side boxes is TALL AND NARROW, so its label has to turn with
1015
+ // it — set horizontally it either overflows the band or gets clipped by it.
1016
+ vertical: axis === 'x',
1017
+ label: name,
1018
+ style: pct(band),
1019
+ });
1020
+ }
1021
+ }
1022
+ }
1023
+ /*
1024
+ THE INSET PASS — padding AND margin, for every subject whose own edge is worth reading.
1025
+
1026
+ ★★ A COMPUTED STYLE IS ALREADY IN CANVAS UNITS. A slide is transform-SCALED, and a transform
1027
+ does not touch computed values — so `paddingLeft` reads 96 while the same edge measures 58
1028
+ screen pixels. Running it through toCanvas() scaled it a second time and the schematic
1029
+ reported the deck's own 90px text inset as `pad 150 ✗`, off the ladder, on every slide. A tool
1030
+ whose job is to say "is this value one the system has" cannot be the thing inventing values.
1031
+
1032
+ So the number is used as-is for the LABEL, and converted the other way — canvas to screen — to
1033
+ draw the band, because the rect it is drawn against came from getBoundingClientRect.
1034
+
1035
+ ⚠ A NEGATIVE MARGIN IS NOT AN INSET. `.split-figure` and `.stack-band` carry one deliberately,
1036
+ to escape their own grid track and reach the media inset; drawing it as a band would put a
1037
+ rectangle outside the element it belongs to. Noted instead of drawn.
1038
+ */
1039
+ if (tierOn('gap') && page) {
1040
+ const toScreenX = (n) => (n / 1920) * root.width;
1041
+ const toScreenY = (n) => (n / 1080) * root.height;
1042
+ for (const el of page.querySelectorAll(INSET_SUBJECTS)) {
1043
+ if (!seen(el)) continue;
1044
+ const cs = getComputedStyle(el);
1045
+ const cb = el.getBoundingClientRect();
1046
+ if (!(cb.width > 0) || !(cb.height > 0)) continue;
1047
+ for (const [kind, prop] of [['pad', 'padding'], ['mar', 'margin']]) {
1048
+ for (const side of ['top', 'right', 'bottom', 'left']) {
1049
+ const v = parseFloat(cs[`${prop}${side[0].toUpperCase()}${side.slice(1)}`]);
1050
+ if (!Number.isFinite(v) || v <= 1) {
1051
+ if (kind === 'mar' && v < -1) {
1052
+ nextNotes.push({ tier: 'region', text: `${name(el)} ${side} escape ${Math.round(v)}` });
1053
+ }
1054
+ continue;
1055
+ }
1056
+ const vert = side === 'top' || side === 'bottom';
1057
+ const px = vert ? toScreenY(v) : toScreenX(v);
1058
+ // A margin sits OUTSIDE the border box; padding sits inside it.
1059
+ const r = kind === 'pad'
1060
+ ? (side === 'top' ? new DOMRect(cb.left, cb.top, cb.width, px)
1061
+ : side === 'bottom' ? new DOMRect(cb.left, cb.bottom - px, cb.width, px)
1062
+ : side === 'left' ? new DOMRect(cb.left, cb.top, px, cb.height)
1063
+ : new DOMRect(cb.right - px, cb.top, px, cb.height))
1064
+ : (side === 'top' ? new DOMRect(cb.left, cb.top - px, cb.width, px)
1065
+ : side === 'bottom' ? new DOMRect(cb.left, cb.bottom, cb.width, px)
1066
+ : side === 'left' ? new DOMRect(cb.left - px, cb.top, px, cb.height)
1067
+ : new DOMRect(cb.right, cb.top, px, cb.height));
1068
+ nextGaps.push({
1069
+ key: `${kind}-${nextGaps.length}`,
1070
+ tier: 'pad',
1071
+ axis: side,
1072
+ vertical: side === 'left' || side === 'right',
1073
+ label: `${kind} ${ladderName(v, { notched: !!el.closest('.has-notch') })}`,
1074
+ style: pct(r),
1075
+ });
1076
+ }
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ gaps.value = nextGaps;
1082
+ notes.value = nextNotes;
1083
+ tokenTick.value += 1;
1084
+ }
1085
+
1086
+ /*
1087
+ ★★ OBSERVED, NOT TIMED. A slide keeps moving after the navigation that brought it in — the route
1088
+ transition is a real CSS animation, the webfont swap reflows every column, and an icon or an image
1089
+ resolving late changes the height of whatever holds it. Every one of those happens AFTER the tick
1090
+ the watcher fires on.
1091
+
1092
+ MEASURED, and this is what made a timeout obviously the wrong tool: on the `Band ratio 3:2` slide
1093
+ the overlay read `1848x280` while the band's own box was `1848x380`. Same slide, no navigation,
1094
+ nothing to wait a fixed number of milliseconds for — the band simply grew a hundred pixels after
1095
+ the reading was taken, and a deadline tuned to the transition would have missed it just as
1096
+ completely as no deadline at all.
1097
+
1098
+ So a ResizeObserver watches the page being measured and re-measures whenever anything in it
1099
+ settles. That is one mechanism instead of three guesses, and it cannot be stale by construction:
1100
+ if the box moves, the reading moves. `document.fonts.ready` stays as the one nudge an observer
1101
+ cannot give — a swap that changes glyph widths without changing the observed box.
1102
+
1103
+ The observer is re-pointed on every slide change rather than left on the first page, because
1104
+ Slidev keeps all of them mounted and the box that matters is the one the router is on.
1105
+ */
1106
+ let pageObserver = null;
1107
+ const observePage = () => {
1108
+ if (typeof ResizeObserver === 'undefined') return;
1109
+ pageObserver?.disconnect();
1110
+ const page = document.querySelector(`.slidev-page[data-slidev-no="${currentPage.value}"]`);
1111
+ if (!page || !show.value) return;
1112
+ pageObserver = new ResizeObserver(() => measureRegions());
1113
+ pageObserver.observe(page);
1114
+ for (const el of page.querySelectorAll('.stack-band, .split-figure, .split-content')) {
1115
+ pageObserver.observe(el);
1116
+ }
1117
+ };
1118
+
1119
+ watch([show, currentPage, isSplit], async () => {
1120
+ await nextTick();
1121
+ measureRegions();
1122
+ observePage();
1123
+ if (typeof document !== 'undefined' && document.fonts?.ready) {
1124
+ document.fonts.ready.then(() => nextTick().then(measureRegions)).catch(() => {});
1125
+ }
1126
+ }, { immediate: true });
1127
+
1128
+ onBeforeUnmount(() => pageObserver?.disconnect());
1129
+ </script>
1130
+
1131
+ <template>
1132
+ <div v-if="show" ref="overlayEl" class="deck-debug-overlay" aria-hidden="true">
1133
+ <!--
1134
+ ★ THE WASH, and it does two jobs for no DOM at all.
1135
+
1136
+ This overlay is mounted OUTSIDE the slide, so it never inherits `.deck-dark` — which means one
1137
+ palette had to read on warm paper and on a near-black divider alike. Rather than probe the
1138
+ slide's luminance and carry two colour sets, lay 58% warm paper over everything first (it was 84%
1139
+ and read as a rumour — see the ⚠ on `.deck-debug-wash` below): the
1140
+ ground under the schematic is then the same on every slide, and the slide survives as a ghost
1141
+ — enough to see what a line falls on, not enough to compete with it.
1142
+
1143
+ The gradients are the --deck-grid lattice, heavy every fifth line. That IS most of what a
1144
+ schematic is for: an off-ladder value shows up as a box that does not land on a line, with no
1145
+ label needed.
1146
+ -->
1147
+ <div v-if="tierOn('grid')" class="deck-debug-wash" />
1148
+ <!-- CHROME frame — inset --deck-inset-chrome, all four edges. Dotted: the loosest weight,
1149
+ because this is the outermost, least-specific boundary (chrome may sit anywhere inside
1150
+ it, not fill it). -->
1151
+ <div v-if="tierOn('frame')" class="deck-debug-frame deck-debug-frame--chrome" />
1152
+ <!-- MEDIA frame — --deck-inset-media horizontally, and vertically whichever of the two the
1153
+ slide's own notch selects: see `mediaVBound` for the rule and for what this drew before.
1154
+ Bound inline rather than declared, because it is the one frame whose value is a fact about
1155
+ THIS slide instead of a constant; the stylesheet keeps the un-notched pair as the standing
1156
+ declaration, which is also what renders before the token probe has a box to measure in.
1157
+ Dashed, medium weight. -->
1158
+ <div
1159
+ v-if="tierOn('frame')"
1160
+ class="deck-debug-frame deck-debug-frame--media"
1161
+ :style="{ top: mediaVBound, bottom: mediaVBound }"
1162
+ />
1163
+ <!-- TEXT frame — inset --deck-inset-text, all four edges. Solid, heaviest weight: this is the
1164
+ one every slide's own content actually sits inside, via .slidev-layout's own padding. -->
1165
+ <div v-if="tierOn('frame')" class="deck-debug-frame deck-debug-frame--text" />
1166
+ <!-- The DISPLAY margin — the 120px beat inset five layouts use and none of them drew. -->
1167
+ <div v-if="tierOn('frame')" class="deck-debug-frame deck-debug-frame--display" />
1168
+ <!-- Centre lines. The cheapest consistency check there is: is this actually centred, or nearly. -->
1169
+ <div v-if="tierOn('frame')" class="deck-debug-centre deck-debug-centre--x" />
1170
+ <div v-if="tierOn('frame')" class="deck-debug-centre deck-debug-centre--y" />
1171
+ <!-- The chrome BANDS — where slide-top.vue's/slide-bottom.vue's pill rows actually paint.
1172
+ Filled, not just outlined: collision with these two rectangles is the specific failure
1173
+ this overlay exists to make visible (see this file's own doc comment above), so they read
1174
+ as a hazard region, not a fourth measuring line to compare against the others. Height is
1175
+ computed from the SAME tokens BadgeStrip/`.deck-page-dot` use (slide-top.vue), not a
1176
+ hand-typed constant — if that ladder ever moves, this band moves with it instead of
1177
+ silently drawing the wrong hazard. -->
1178
+ <div v-if="tierOn('hazard')" class="deck-debug-band deck-debug-band--top" />
1179
+ <div v-if="tierOn('hazard')" class="deck-debug-band deck-debug-band--bottom" />
1180
+ <!-- task-14b-brief.md — THE Y-ANCHOR. split.vue's text column always starts here, one third of
1181
+ the canvas down; only meaningful on a split slide, so it only draws on one. `top: var(
1182
+ --deck-anchor-third)` here resolves against THIS element's own box, which — per this file's
1183
+ own top comment — genuinely IS the full, unpadded 1920x1080 canvas, so this line and
1184
+ split.vue's own anchor land on the identical canvas position by construction, not by two
1185
+ people doing the same sum twice. Solid and thicker than the inset frames: this is the one
1186
+ line every split slide's heading position is actually PINNED to, not merely bounded by. -->
1187
+ <div v-if="tierOn('frame') && anchorShown" class="deck-debug-anchor" :style="anchorSpan" />
1188
+ <!-- THE SPLIT REGIONS, measured off the DOM rather than re-derived — see the script comment.
1189
+ One box per region so "where does the second column start" and "does the header really span
1190
+ both" are answerable by looking, which the single anchor line could not do. -->
1191
+ <!-- GAPS sit UNDER everything measured: they are the only tier that fills, and a fill over an
1192
+ outline would hide the box the outline is drawn around. -->
1193
+ <template v-if="tierOn('gap')">
1194
+ <div v-for="g in gaps" :key="g.key" class="deck-debug-gap" :class="[`is-${g.tier}`, g.vertical ? 'is-vertical' : '']" :style="g.style">
1195
+ <span class="deck-debug-gap-label">{{ g.label }}</span>
1196
+ </div>
1197
+ </template>
1198
+ <!-- ELEMENT hairlines. No labels by design — identity is legible from the content underneath,
1199
+ and forty labels would bury the boxes they name. -->
1200
+ <template v-if="tierOn('element')">
1201
+ <div v-for="e in elements" :key="e.key" class="deck-debug-element" :class="`is-${e.kind}`" :style="e.style" />
1202
+ </template>
1203
+ <!-- REGIONS. A short chip, not the old `x,y · w×h` readout — that was ~200 canvas px of label on
1204
+ a ~50px box, and on a dense slide the labels collided with each other rather than with the
1205
+ geometry. The numbers moved to the parts list in the key, where there is room for them. -->
1206
+ <template v-if="tierOn('region')">
1207
+ <div v-for="r in regions" :key="r.key" class="deck-debug-region" :class="`is-${r.role}`" :style="r.style">
1208
+ <span class="deck-debug-region-label" :style="r.chipStyle">{{ r.label }}</span>
1209
+ </div>
1210
+ </template>
1211
+ <!-- THE KEY. Three dash patterns and two filled bands are not self-describing, and an overlay
1212
+ whose own vocabulary has to be looked up in a source file is a worse tool than one that
1213
+ says what it means. Sits bottom-RIGHT — see the note on `.deck-debug-key` below for why it
1214
+ moved off bottom-left, and what it now has to clear. -->
1215
+ <!--
1216
+ THE PROPS PANEL. Bottom-LEFT, opposite the key: one panel says what the drawing means, the
1217
+ other says what this slide asked for. The corner is free now that the key moved right.
1218
+ -->
1219
+ <dl class="deck-debug-props">
1220
+ <div class="deck-debug-props-title">{{ currentFrontmatter.layout || 'default' }}</div>
1221
+ <div v-for="p in slideProps" :key="p.k" :title="p.v"><dt>{{ p.k }}</dt><dd>{{ p.v }}</dd></div>
1222
+ <div v-if="!slideProps.length" class="deck-debug-props-none">no props set</div>
1223
+ <div v-if="appliedState.length" class="deck-debug-props-applied">
1224
+ <dt>applied</dt><dd>{{ appliedState.join(' ') }}</dd>
1225
+ </div>
1226
+ </dl>
1227
+ <!--
1228
+ THE KEY — generated from TIERS, never hand-written. It listed five literal rows while the
1229
+ overlay drew eleven kinds of thing, so the region outlines, their three weights and the gutter
1230
+ label had no entry at all.
1231
+
1232
+ Each tier row is a BUTTON: the element tier is unreadable on a dense slide and the hazard band
1233
+ fires on every chromed slide, so they have to be switchable — and the thing that already
1234
+ explains a tier is the right thing to switch it. `.stop` because a bare click advances the
1235
+ slide.
1236
+
1237
+ Moved to bottom-RIGHT. It used to be anchored bottom-left at the same inset as the bottom
1238
+ chrome band, so it sat on top of the hazard it was there to explain.
1239
+ -->
1240
+ <div class="deck-debug-key">
1241
+ <div v-for="t in TIERS" :key="t.id" class="deck-debug-key-tier">
1242
+ <button type="button" :class="tierOn(t.id) ? '' : 'is-off'" @click.stop="toggleTier(t.id)">
1243
+ <span class="k" :class="`k--${t.id}`" />
1244
+ <span class="deck-debug-key-label">{{ t.label }}</span>
1245
+ <span class="deck-debug-key-count">{{ tierCount[t.id] }}</span>
1246
+ </button>
1247
+ </div>
1248
+ <div class="deck-debug-key-rule">
1249
+ <span>{{ insetChrome }} · {{ insetMedia }} · {{ insetText }} · {{ insetDisplay }}</span>
1250
+ <span v-if="isSplit">anchor {{ anchorY }}</span>
1251
+ </div>
1252
+ <!-- ★ ABSENT, COLLAPSED AND FULL-CANVAS EACH GET A LINE. All three used to draw nothing, so a
1253
+ rule that matched no element was indistinguishable from a rule that did not exist. -->
1254
+ <div v-for="(n, i) in notes" :key="`n${i}`" class="deck-debug-key-note" :class="`is-${n.tier}`">{{ n.text }}</div>
1255
+ </div>
1256
+ <!-- THE CONDITIONAL GUTTER. Its on-screen WIDTH depends on `ratio`/`side`, which would need
1257
+ re-deriving split.vue's own grid-track-and-negative-margin arithmetic a second time to draw
1258
+ as a geometric line — exactly the kind of duplicated sum this codebase's own history (see
1259
+ this file's top comment) shows drifts silently out of sync with the real one. A text label
1260
+ states the computed VALUE instead: honest about what this overlay actually knows, rather
1261
+ than a seam it has not actually measured. -->
1262
+ <!-- Pinned to the anchor when the anchor is the rule, and to the measured content top when it is
1263
+ not — on a header split the content starts at the ordinary content top and a label floating
1264
+ at the third would be captioning empty canvas.
1265
+
1266
+ ★★ AND IT FOLLOWS THE `gap` TIER. It was `v-if="isSplit"` with no tier gate at all, so it
1267
+ drew whenever the overlay was on no matter what had been switched off — Jose, with every
1268
+ tier but the grid turned off: "why this still here when everything toggled off?". A gutter
1269
+ is a measurement of the space between two columns, which is exactly what the gap tier is,
1270
+ so it belongs to that switch like every other measured thing.
1271
+
1272
+ The two panels below stay ungated on purpose and are not the same case: the props readout
1273
+ states what the slide DECLARED rather than measuring anything, and the key is where the
1274
+ switches live — a legend that vanished with the tiers could not be used to bring them
1275
+ back. -->
1276
+ <div
1277
+ v-if="isSplit && tierOn('gap') && gutterAt"
1278
+ class="deck-debug-label"
1279
+ :style="gutterAt"
1280
+ >{{ gutterLabel }}</div>
1281
+ </div>
1282
+ </template>
1283
+
1284
+ <style scoped>
1285
+ /*
1286
+ ★★ COLOUR IS THE TIER. DASH IS THE MEMBER. FILL MEANS SPACE.
1287
+
1288
+ This file used to argue against a palette, and it was right at the time: every line meant "an
1289
+ inset", and they differed only in WHICH one, so a colour per line would have implied a difference
1290
+ in kind that was not there. That reasoning does not survive the overlay drawing four genuinely
1291
+ different kinds of thing — a token-derived rule, a measured layout box, a content box and an
1292
+ empty space are not four flavours of the same statement.
1293
+
1294
+ ⚠ AND IT WAS ALREADY BROKEN. `--deck-debug-line` is declared NOWHERE in the repo, so its five
1295
+ users fell back to #e11d48 while eight other rules used var(--tier-frame) — two reds, no meaning attached
1296
+ to the difference, and the key's own swatches a different colour from the lines they explained.
1297
+
1298
+ One closed set, declared here rather than in deck.css: a debug tool is not part of the deck's
1299
+ token contract and has no brand meaning to alias.
1300
+
1301
+ Colour is never the only carrier. Blue is only ever a canvas-spanning rule and teal only ever a
1302
+ small hairline, so the deuteranopia-risky pair is never confusable positionally; rose and amber
1303
+ are outline against fill.
1304
+ */
1305
+ .deck-debug-overlay {
1306
+ --tier-frame: #1d4ed8;
1307
+ --tier-region: #e11d48;
1308
+ --tier-element: #0e7490;
1309
+ --tier-gap: #b45309;
1310
+ --tier-hazard: #a16207;
1311
+ /* The BOTTOM chrome band's own depth, declared once so the two panels can sit clear of it instead
1312
+ of each restating the formula. Same shape slide-bottom.vue's page marker is built from — that
1313
+ is where `.deck-page-dot` lives, not slide-top.vue as this said. */
1314
+ --debug-band-h: calc(var(--type-deck-overline-size) * var(--type-deck-overline-line-height) + var(--deck-space-1) * 2);
1315
+ /*
1316
+ ★★ THE TOP BAND IS A DIFFERENT DEPTH, AND SHARING ONE WAS A REAL UNDER-DRAW.
1317
+
1318
+ Both bands took the pill formula above — 49px. Correct at the foot, where the row IS the page
1319
+ marker. Wrong at the head: slide-top.vue sets that row's `min-height: var(--deck-chrome-mark)`
1320
+ (72), and its own comment says so — "The row is the MARK's height (72), the section pill is 49".
1321
+ So the top hazard rectangle stopped at y 85 while the mark occupies y 36-108, and anything
1322
+ landing in that 23px strip collided with the running mark while sitting clear of the band drawn
1323
+ to warn about it. A hazard marker that under-draws the hazard is worse than none.
1324
+
1325
+ --deck-chrome-band is the token for exactly this (inset + mark = 108, the depth the chrome
1326
+ reaches from the canvas edge); the band starts AT the inset, so its own height is the mark.
1327
+ */
1328
+ --debug-band-top-h: var(--deck-chrome-mark);
1329
+ position: absolute;
1330
+ inset: 0;
1331
+ /* Above every layout's own content AND above slide-top.vue/slide-bottom.vue's chrome (z-index
1332
+ 20) — the overlay has to sit on top of the bands it is drawing a hazard region for, or the
1333
+ chrome pill would paint over its own hazard marker. */
1334
+ z-index: 999;
1335
+ pointer-events: none;
1336
+ box-sizing: border-box;
1337
+ }
1338
+ /*
1339
+ Warm paper at 58%, then the --deck-grid lattice with every fifth line heavier. One ground for the
1340
+ whole schematic whatever register the slide is in, and a ruler under everything so an off-ladder
1341
+ box shows up as a box that does not land on a line.
1342
+
1343
+ ⚠ THE FIRST PASS WAS 84% AND THAT WAS TOO MUCH: the slide under it read as a rumour, which
1344
+ defeats the tool — you are checking whether THIS heading sits where you think, and you have to be
1345
+ able to see the heading. 58% still flattens a dark divider and a light default into one ground.
1346
+ */
1347
+ .deck-debug-wash {
1348
+ position: absolute;
1349
+ inset: 0;
1350
+ z-index: 0;
1351
+ background:
1352
+ repeating-linear-gradient(to right, rgb(17 24 39 / 7%) 0 1px, transparent 1px var(--deck-grid)),
1353
+ repeating-linear-gradient(to bottom, rgb(17 24 39 / 7%) 0 1px, transparent 1px var(--deck-grid)),
1354
+ repeating-linear-gradient(to right, rgb(17 24 39 / 14%) 0 1px, transparent 1px calc(var(--deck-grid) * 5)),
1355
+ repeating-linear-gradient(to bottom, rgb(17 24 39 / 14%) 0 1px, transparent 1px calc(var(--deck-grid) * 5)),
1356
+ color-mix(in oklab, var(--warm-50), transparent 42%);
1357
+ }
1358
+ .deck-debug-frame {
1359
+ position: absolute;
1360
+ box-sizing: border-box;
1361
+ border-color: var(--tier-frame);
1362
+ z-index: 5;
1363
+ }
1364
+ .deck-debug-frame--display {
1365
+ top: 0;
1366
+ bottom: 0;
1367
+ left: var(--deck-inset-display);
1368
+ right: var(--deck-inset-display);
1369
+ border-left: 1px dashed var(--tier-frame);
1370
+ border-right: 1px dashed var(--tier-frame);
1371
+ }
1372
+ .deck-debug-centre {
1373
+ position: absolute;
1374
+ z-index: 5;
1375
+ opacity: 0.3;
1376
+ }
1377
+ .deck-debug-centre--x { top: 0; bottom: 0; left: 50%; border-left: 1px solid var(--tier-frame); }
1378
+ .deck-debug-centre--y { left: 0; right: 0; top: 50%; border-top: 1px solid var(--tier-frame); }
1379
+ .deck-debug-frame--chrome {
1380
+ inset: var(--deck-inset-chrome);
1381
+ border: 1px dotted var(--tier-frame);
1382
+ }
1383
+ /* The vertical pair here is the UN-NOTCHED case only, and it is overridden inline on a slide whose
1384
+ figure carries the cut — see `mediaVBound`. It stays declared because it is a real value (it is
1385
+ what `notch: false` restores) and because the inline binding resolves to '' until the token probe
1386
+ has a laid-out box to measure in, on the first render after mount. */
1387
+ .deck-debug-frame--media {
1388
+ top: var(--deck-inset-text);
1389
+ bottom: var(--deck-inset-text);
1390
+ left: var(--deck-inset-media);
1391
+ right: var(--deck-inset-media);
1392
+ border: 1px dashed var(--tier-frame);
1393
+ }
1394
+ .deck-debug-frame--text {
1395
+ inset: var(--deck-inset-text);
1396
+ border: 2px solid var(--tier-frame);
1397
+ }
1398
+ /* Each band is the depth of the chrome it marks, and the two are NOT the same — see the note on
1399
+ --debug-band-top-h above. Both are read off tokens rather than written down, so a change to the
1400
+ mark's size or the overline ramp moves the band with the thing it warns about. */
1401
+ .deck-debug-band {
1402
+ position: absolute;
1403
+ left: var(--deck-inset-chrome);
1404
+ right: var(--deck-inset-chrome);
1405
+ height: var(--debug-band-h);
1406
+ background: rgb(255 26 26 / 12%);
1407
+ border-top: 1px solid var(--tier-frame);
1408
+ border-bottom: 1px solid var(--tier-frame);
1409
+ }
1410
+ .deck-debug-band--top {
1411
+ top: var(--deck-inset-chrome);
1412
+ height: var(--debug-band-top-h);
1413
+ }
1414
+ .deck-debug-band--bottom {
1415
+ bottom: var(--deck-inset-chrome);
1416
+ }
1417
+ /* Thicker and solid, deliberately distinct from the three measuring frames above: this is not a
1418
+ bound content sits somewhere inside, it is the one point split.vue's text column is PINNED to. */
1419
+ .deck-debug-anchor {
1420
+ position: absolute;
1421
+ left: 0;
1422
+ right: 0;
1423
+ top: var(--deck-anchor-third);
1424
+ border-top: 3px solid var(--tier-frame);
1425
+ }
1426
+ /*
1427
+ THE REGION BOXES. Outlined, never filled: a fill would hide the very content the box is drawn
1428
+ around, and the two chrome bands above are filled precisely because THEY mark a hazard region
1429
+ rather than a measurement. Dash pattern tells the three apart at a glance the same way the inset
1430
+ frames do, and each carries its own name so a screenshot of the overlay is readable without this
1431
+ file open beside it.
1432
+ */
1433
+ /*
1434
+ THE KEY, and the region weights it explains.
1435
+
1436
+ `kind` rather than region name drives the weight, so a content column reads as a content column
1437
+ whether it is split's, stack's strip or bleed's — the overlay teaches ONE vocabulary instead of
1438
+ ten. Same discipline as the three inset frames, which are already told apart by dash pattern
1439
+ rather than by inventing a colour per line.
1440
+ */
1441
+ /*
1442
+ ★ LIFTED CLEAR OF THE BOTTOM CHROME BAND, not merely moved to the other corner.
1443
+
1444
+ The key used to be anchored at the same inset as that band and painted on top of the hazard it
1445
+ exists to explain. Moving it right did NOT fix that — the band runs `left: inset-chrome` to
1446
+ `right: inset-chrome`, i.e. the full width, so both corners are on it. The offset has to be
1447
+ vertical, and it is the band's own declared depth so the two cannot drift apart.
1448
+
1449
+ `pointer-events: auto` against the overlay root's `none`, because the tier rows are switches.
1450
+ */
1451
+ .deck-debug-key {
1452
+ position: absolute;
1453
+ right: var(--deck-inset-chrome);
1454
+ bottom: calc(var(--deck-inset-chrome) + var(--debug-band-h) + var(--deck-space-2));
1455
+ z-index: 7;
1456
+ pointer-events: auto;
1457
+ max-height: 52%;
1458
+ overflow: hidden;
1459
+ margin: 0;
1460
+ display: grid;
1461
+ gap: 2px;
1462
+ padding: 6px 8px;
1463
+ background: color-mix(in oklab, var(--deck-bg), transparent 8%);
1464
+ border: 1px solid var(--tier-region);
1465
+ border-radius: var(--deck-radius-item);
1466
+ font-family: var(--type-deck-overline-family);
1467
+ font-size: calc(var(--type-deck-overline-size) * 0.42);
1468
+ letter-spacing: var(--type-deck-overline-tracking);
1469
+ text-transform: uppercase;
1470
+ color: var(--deck-fg);
1471
+ }
1472
+ .deck-debug-key > div { display: flex; align-items: center; gap: 6px; }
1473
+ .deck-debug-key-count { margin-left: auto; opacity: 0.65; }
1474
+ /*
1475
+ ★ THE SWATCH IS THE TIER'S OWN COLOUR AND ITS OWN TREATMENT. The key used to draw its swatches in
1476
+ a colour none of the lines used, which is the specific way a legend stops being one.
1477
+ */
1478
+ .deck-debug-key-tier button {
1479
+ display: flex;
1480
+ align-items: center;
1481
+ gap: 6px;
1482
+ width: 100%;
1483
+ padding: 0;
1484
+ border: 0;
1485
+ background: none;
1486
+ font: inherit;
1487
+ color: inherit;
1488
+ cursor: pointer;
1489
+ text-align: left;
1490
+ }
1491
+ .deck-debug-key-tier button.is-off { opacity: 0.35; }
1492
+ .deck-debug-key .k { width: 18px; height: 0; border-top: 2px solid currentcolor; flex: none; }
1493
+ .deck-debug-key .k--frame { border-top: 2px dashed var(--tier-frame); }
1494
+ .deck-debug-key .k--region { border-top: 2px dashed var(--tier-region); }
1495
+ .deck-debug-key .k--element { border-top: 1px solid var(--tier-element); }
1496
+ .deck-debug-key .k--gap {
1497
+ height: 8px;
1498
+ border: 0;
1499
+ background: color-mix(in oklab, var(--tier-gap), transparent 80%);
1500
+ }
1501
+ .deck-debug-key .k--hazard {
1502
+ height: 8px;
1503
+ border: 0;
1504
+ background: repeating-linear-gradient(45deg, rgb(17 24 39 / 30%) 0 3px, color-mix(in oklab, var(--tier-hazard), transparent 45%) 3px 6px);
1505
+ }
1506
+ .deck-debug-key-rule,
1507
+ .deck-debug-key-note {
1508
+ display: flex;
1509
+ gap: 8px;
1510
+ justify-content: space-between;
1511
+ opacity: 0.7;
1512
+ padding-top: 2px;
1513
+ }
1514
+ .deck-debug-key-note.is-hazard { color: var(--tier-hazard); opacity: 1; }
1515
+ /* Same furniture as the key, mirrored — a reader should not have to learn two panels. */
1516
+ .deck-debug-props {
1517
+ position: absolute;
1518
+ left: var(--deck-inset-chrome);
1519
+ bottom: calc(var(--deck-inset-chrome) + var(--debug-band-h) + var(--deck-space-2));
1520
+ z-index: 7;
1521
+ margin: 0;
1522
+ display: grid;
1523
+ gap: 2px;
1524
+ padding: 6px 8px;
1525
+ /* A quarter of the canvas. It was 34% and still ran to the width cap on any slide carrying an
1526
+ `alt` or an `image`, which is most of them — the ellipsis was working and the panel was simply
1527
+ allowed to be too big for what it says. */
1528
+ max-width: 24%;
1529
+ max-height: 52%;
1530
+ overflow: hidden;
1531
+ background: color-mix(in oklab, var(--deck-bg), transparent 8%);
1532
+ border: 1px solid var(--tier-region);
1533
+ border-radius: var(--deck-radius-item);
1534
+ font-family: var(--type-deck-overline-family);
1535
+ font-size: calc(var(--type-deck-overline-size) * 0.42);
1536
+ letter-spacing: var(--type-deck-overline-tracking);
1537
+ text-transform: uppercase;
1538
+ color: var(--deck-fg);
1539
+ }
1540
+ .deck-debug-props > div { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
1541
+ .deck-debug-props dt { margin: 0; opacity: 0.65; flex: none; }
1542
+ /* The value column is the one that has to give: a key is a word, a value can be a sentence. */
1543
+ /* ★ VALUES ARE VERBATIM. The panel's type is the overline role, which is uppercase — fine for a
1544
+ key, wrong for a value: a URL is case-sensitive, and a debug panel that transforms what it
1545
+ reports is quietly lying about the thing you opened it to check. */
1546
+ .deck-debug-props dd {
1547
+ margin: 0 0 0 auto;
1548
+ text-align: right;
1549
+ text-transform: none;
1550
+ min-width: 0;
1551
+ overflow: hidden;
1552
+ text-overflow: ellipsis;
1553
+ white-space: nowrap;
1554
+ }
1555
+ .deck-debug-props-applied dd { white-space: normal; overflow: visible; }
1556
+ .deck-debug-props-title {
1557
+ color: var(--tier-region);
1558
+ border-bottom: 1px solid color-mix(in oklab, var(--tier-region), transparent 70%);
1559
+ padding-bottom: 2px;
1560
+ margin-bottom: 2px;
1561
+ }
1562
+ .deck-debug-props-none { opacity: 0.5; }
1563
+ .deck-debug-props-applied {
1564
+ border-top: 1px solid color-mix(in oklab, var(--tier-region), transparent 70%);
1565
+ padding-top: 2px;
1566
+ margin-top: 2px;
1567
+ opacity: 0.7;
1568
+ }
1569
+ /*
1570
+ GAPS are the only tier that FILLS, which is what keeps a stack of boxes readable: two overlapping
1571
+ fills still sit under every outline. Padding is HATCHED rather than flat, deliberately — drawing a
1572
+ container's own inset as a flat band would assert a gutter the layout does not have.
1573
+ */
1574
+ .deck-debug-gap {
1575
+ position: absolute;
1576
+ z-index: 2;
1577
+ background: color-mix(in oklab, var(--tier-gap), transparent 86%);
1578
+ outline: 1px solid color-mix(in oklab, var(--tier-gap), transparent 55%);
1579
+ outline-offset: -1px;
1580
+ }
1581
+ .deck-debug-gap.is-pad {
1582
+ outline-color: color-mix(in oklab, var(--tier-gap), transparent 80%);
1583
+ background: repeating-linear-gradient(
1584
+ 45deg,
1585
+ color-mix(in oklab, var(--tier-gap), transparent 88%) 0 4px,
1586
+ transparent 4px 10px
1587
+ );
1588
+ }
1589
+ .deck-debug-gap.is-hazard {
1590
+ background: repeating-linear-gradient(45deg, rgb(17 24 39 / 22%) 0 6px, color-mix(in oklab, var(--tier-hazard), transparent 55%) 6px 12px);
1591
+ outline-color: var(--tier-hazard);
1592
+ z-index: 2.5;
1593
+ }
1594
+ .deck-debug-gap-label {
1595
+ position: absolute;
1596
+ top: 50%;
1597
+ left: 50%;
1598
+ translate: -50% -50%;
1599
+ white-space: nowrap;
1600
+ font-family: var(--type-deck-overline-family);
1601
+ font-size: calc(var(--type-deck-overline-size) * 0.38);
1602
+ letter-spacing: var(--type-deck-overline-tracking);
1603
+ text-transform: uppercase;
1604
+ color: var(--tier-gap);
1605
+ }
1606
+ .deck-debug-gap.is-hazard .deck-debug-gap-label { color: var(--tier-hazard); }
1607
+ /* ★ `rotate`, a property of its own, so it COMPOSES with the centring translate rather than
1608
+ replacing it — a `transform: rotate()` here would drop the -50%/-50% and hang the label off the
1609
+ band's bottom-right corner. Same trap as the donut's central label. */
1610
+ .deck-debug-gap.is-vertical .deck-debug-gap-label { rotate: -90deg; }
1611
+ /* The numerous tier, so it runs quiet: fifty hairlines should read as texture under the region
1612
+ outlines, not compete with them. */
1613
+ .deck-debug-element {
1614
+ position: absolute;
1615
+ z-index: 3;
1616
+ outline: 1px dashed var(--tier-element);
1617
+ outline-offset: -1px;
1618
+ opacity: 0.55;
1619
+ }
1620
+ .deck-debug-element.is-component { outline-style: solid; }
1621
+ .deck-debug-region {
1622
+ position: absolute;
1623
+ pointer-events: none;
1624
+ z-index: 4;
1625
+ outline: 2px dashed var(--tier-region);
1626
+ outline-offset: -1px;
1627
+ }
1628
+ .deck-debug-region.is-band { outline-style: solid; }
1629
+ .deck-debug-region.is-figure { outline-style: dotted; }
1630
+ .deck-debug-region.is-column { outline-style: dashed; }
1631
+ .deck-debug-region.is-panel { outline-style: solid; }
1632
+ .deck-debug-region.is-grid { outline-style: double; outline-width: 3px; }
1633
+ .deck-debug-region.is-root { outline-width: 1px; outline-style: solid; }
1634
+ .deck-debug-region-label {
1635
+ position: absolute;
1636
+ top: 0;
1637
+ left: 0;
1638
+ display: flex;
1639
+ gap: 6px;
1640
+ align-items: baseline;
1641
+ white-space: nowrap;
1642
+ padding: 2px 6px;
1643
+ font-family: var(--type-deck-overline-family);
1644
+ font-size: calc(var(--type-deck-overline-size) * 0.5);
1645
+ letter-spacing: var(--type-deck-overline-tracking);
1646
+ text-transform: uppercase;
1647
+ color: #fff;
1648
+ background: var(--tier-region);
1649
+ }
1650
+ .deck-debug-label {
1651
+ position: absolute;
1652
+ /* `left` and `top` arrive measured (see `gutterAt`); this only says how the chip hangs off that
1653
+ point — centred on the seam, and lifted clear so it sits ABOVE the column rather than on its
1654
+ first line, which is what the old anchor-relative `bottom` was reaching for. */
1655
+ transform: translate(-50%, calc(-100% - var(--deck-space-1)));
1656
+ font: 700 14px/1.2 var(--font-mono, monospace);
1657
+ color: var(--tier-frame);
1658
+ background: rgb(255 255 255 / 85%);
1659
+ padding: 2px 6px;
1660
+ border-radius: 2px;
1661
+ }
1662
+ </style>