@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/notch.js ADDED
@@ -0,0 +1,458 @@
1
+ // The notch, and the chrome mirror it implies — the shared decisions, split out of the three
2
+ // components that have to agree on them so they are one rule rather than three copies.
3
+ //
4
+ // WHAT THE NOTCH IS. A split slide's figure side is a PANEL — a photo frame or a tint — and its
5
+ // outer top corner is cut INWARD so the running Forest mark can sit in the cut-out. The panel's
6
+ // edge then reads convex → concave → convex around the mark, which is what makes the mark look
7
+ // seated INTO the panel rather than parked on top of it. split.vue owns the cut (a mask, see its
8
+ // own `.has-notch` comment); slide-top.vue owns the mark. Neither may decide on its own whether
9
+ // there is a notch, because a cut with nothing in it is a hole and a mark with nothing cut for it
10
+ // is the collision this replaced.
11
+ //
12
+ // WHY A PLAIN MODULE. packages/slidev-theme's suite runs under plain Node with no DOM
13
+ // (vitest.config.ts: `environment: 'node'`) and never mounts a component, so a computed buried in
14
+ // slide-top.vue's <script setup> is unreachable from a test. Same move, same reason, as section.js
15
+ // and backgrounds.js: the decision is a pure function taking plain data.
16
+ //
17
+ // ★ THE MIRROR, AND THE ONE JUDGEMENT CALL IN IT. Jose: "this should work on either order... if
18
+ // text is left or right, the notch position should adapt (and move the page to the other side,
19
+ // along with the confidenciality chip)". The rule in one sentence: CHROME SITS ON THE TEXT SIDE,
20
+ // THE MARK SITS ON THE PANEL SIDE. Everything else falls out of split's existing `flip` prop.
21
+ //
22
+ // `flip` is frontmatter, so slide-top.vue and slide-bottom.vue can read it exactly the way they
23
+ // already read `chrome` and `confidential` — no new plumbing, no prop drilling, no coupling to the
24
+ // layout component.
25
+ //
26
+ // The call: the chrome mirrors on `layout === 'split'` AND `flip === true`, never on `flip`
27
+ // alone. `flip` is a plain frontmatter key, so any slide can carry one — a `default` slide with a
28
+ // stray `flip: true` (copy-pasted frontmatter, an AI's guess) has no panel and no second column,
29
+ // and swinging its page number across the canvas would be a silent, unexplainable move. Gating on
30
+ // the layout that actually splits means the mirror only fires where there is something to mirror
31
+ // AROUND. bleed is the same case for the same reason: it fills the canvas, has no panel side, and
32
+ // must keep the default arrangement and grow no notch.
33
+
34
+ /** The one layout whose `flip` means "which half is the text" — the only one the chrome mirrors for. */
35
+ export const SPLIT_LAYOUT = 'split';
36
+
37
+ /** The one layout whose figure spans the WHOLE canvas width, so both ends of the chrome sit over it. */
38
+ export const STACK_LAYOUT = 'stack';
39
+
40
+ /** `frame` values that make split's figure side a real PANEL (rather than a second content column). */
41
+ /*
42
+ * The frame kinds that are a REAL FIGURE — one that fills its cell top to bottom, so the running
43
+ * mark would be drawn over it and the corner has to be cut.
44
+ *
45
+ * ★ `figure` joined `photo` and `panel` when it was added: it is the kind for a COMPONENT figure (a
46
+ * live map, a chart) and it fills the cell exactly as the other two do. Measured before adding it —
47
+ * a full-height component on the figure side runs its top corner straight under the Forest mark,
48
+ * which is the collision this list exists to describe. `none` stays out because that side is a
49
+ * second column of TEXT, which the chrome sits above rather than on.
50
+ */
51
+ export const PANEL_FRAMES = ['photo', 'panel', 'figure', 'code'];
52
+
53
+ /*
54
+ ★★ THE CHROME NO LONGER MIRRORS, AND THE NOTCH IS WHAT PAID FOR IT.
55
+
56
+ `isMirrored(layout, side)` and `chromeSides(mirrored)` lived here. A `flip: true` split puts the
57
+ figure on the LEFT, so the notch was cut from the figure's top-left and the mark travelled there
58
+ to sit in it, with the pills pushed to the far end. The mark and the cut were one mechanism: the
59
+ bite exists so the mark has somewhere to sit.
60
+
61
+ Jose, looking at a rendered mirrored slide: "i dont think we want to mirror the chrome". So the
62
+ mark is fixed top-right and the row never reverses.
63
+
64
+ ★★ AND THE NOTCH SURVIVES IT, WHICH THE FIRST ATTEMPT AT THIS GOT WRONG. That attempt reasoned
65
+ that the cut exists to seat the MARK, so a figure the mark can no longer reach must not be bitten,
66
+ and gated hasNotch on `flip`. That is false, and ./useNotchCuts.js says so in its own first line:
67
+ it measures whatever running chrome is sitting on the panel — the mark, each section pill item,
68
+ the page marker, the confidentiality pill — assigns each to the corner it lands in, and cuts all
69
+ of them. Jose: "ffs there could still be a section pill... dont kill the notch".
70
+
71
+ On a `flip: true` split the figure is on the LEFT, which is exactly where the pills now are. So
72
+ the cut is still needed there; what changed is only which occupant it seats. Nothing in hasNotch
73
+ had to know about sides at all.
74
+
75
+ Not left as a function returning false. A predicate that always answers the same way reads as a
76
+ live decision to the next person and is a branch nobody can ever exercise; the arrangement is
77
+ fixed in slide-top.vue's and slide-bottom.vue's own CSS instead. scripts/deckAudit.ts still
78
+ measures the chrome's corners per slide, so a mirror reappearing is caught by the rendering rather
79
+ than trusted to this comment.
80
+ */
81
+
82
+ /**
83
+ * Is the running chrome actually drawn on this slide? The same predicate slide-top.vue has always
84
+ * used for its own `v-if`, lifted here because split.vue now needs the SAME answer: a notch is only
85
+ * ever correct when there is a mark to put in it, and `chrome: false` (or slide 1, which carries no
86
+ * page number worth reading) means there is not.
87
+ *
88
+ * ★★ A DIVIDER CARRIES NO CHROME UNLESS IT ASKS FOR SOME. It is the one slide that takes over the
89
+ * whole field, so the running furniture is the thing it is taking the field FROM — and the pill it
90
+ * would draw names the section this very slide is announcing, which is the heading already filling
91
+ * the middle of it. Every divider in both decks passed `chrome: false` by hand; that is a default,
92
+ * written nine times.
93
+ *
94
+ * ⚠ AND IT WAS NOT ONLY REDUNDANT, IT COLLIDED. The section pill paints the deck's own green, and
95
+ * the section rank now paints that same green full-bleed — measured on the rank specimen, the pill
96
+ * vanished into the ground it sat on while still passing every contrast check, because white text
97
+ * on green scores 6.27 whether or not the chip behind it is visible.
98
+ *
99
+ * `??`, so an explicit `chrome: true` still brings it back — the default moves, the lever does not.
100
+ *
101
+ * ★ SPLIT IN TWO because the two chrome rows do not ask the same question: the bottom row has never
102
+ * carried the `currentPage > 1` clause, since slide 1 has no page number worth reading at the TOP
103
+ * and that says nothing about the row underneath. `chromeWanted` is the half they share, and it is
104
+ * the only place in this theme where a layout name decides VISIBILITY rather than position.
105
+ *
106
+ * @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
107
+ * @param {number} currentPage Slidev's own one-based page number.
108
+ */
109
+ /*
110
+ ★★ A COVER JOINS THE DIVIDER, AND FOR A SHARPER REASON. A divider suppresses the chrome because
111
+ the chrome would repeat what the slide is already shouting. A cover suppresses it because the
112
+ chrome is a RUNNING mark — a page number, a section pill, the identity — and an opener is the
113
+ slide none of those has started running on yet. It also carried the Forest mark twice: once as the
114
+ running identity top-right, once as the lockup cover.vue signs with.
115
+
116
+ ⚠ THE CONFIDENTIALITY MARKING IS NOT PART OF THIS. slide-bottom.vue draws it on its own condition
117
+ rather than through `chromeShows`, precisely so a title slide can still carry its marking — which
118
+ is the one piece of chrome a real deck wants on a cover, and the reason this is a default here
119
+ rather than `chrome: false` written on every cover by hand.
120
+ */
121
+ /* ★ `end` JOINED THE LIST RATHER THAN CARRYING `chrome: false` ON EVERY SLIDE. Jose: "end layouts
122
+ should not have chrome never". A closer is the last frame of a deck — a page number and a running
123
+ section pill on it are furniture for a deck that is still going. Written as a default here, an
124
+ author who genuinely wants the chrome back can still say `chrome: true`; written as frontmatter on
125
+ every closing slide, it is a line nobody remembers. */
126
+ const CHROMELESS_LAYOUTS = ['divider', 'cover', 'end'];
127
+
128
+ export const chromeWanted = (frontmatter) => frontmatter?.chrome ?? !CHROMELESS_LAYOUTS.includes(frontmatter?.layout);
129
+
130
+ export const chromeShows = (frontmatter, currentPage) => chromeWanted(frontmatter) && currentPage > 1;
131
+
132
+ /**
133
+ * ★ THE OPT-OUT (task-18-brief.md). `notch: false` on a slide's own frontmatter.
134
+ *
135
+ * The notch is theme-wide by construction — every `frame: photo`/`frame: panel` figure in every
136
+ * deck grows one — which is what made it visible without editing a single slide, and is also why it
137
+ * needs an escape hatch: it changed the look of ~50 committed slides and there was no way to say
138
+ * "not this one". A photograph whose subject sits in that corner, a panel that must keep its full
139
+ * height, a slide being compared against an older export: all real, none of them a bug in the cut.
140
+ *
141
+ * ⚠ WHAT OPTING OUT ACTUALLY RESTORES, stated plainly rather than discovered later. Every part of
142
+ * the notch keys off the same predicate — the mask, the panel's rise to the chrome inset, and the
143
+ * band of content reserved from a text panel — so `notch: false` drops all three together and the
144
+ * figure returns EXACTLY to its pre-notch geometry: full corner, --deck-inset-text top margin, no
145
+ * reserved band. That is the clean removal the brief asks for (no gap left where the cut was), and
146
+ * it is also, unavoidably, the 18px mark-over-corner overlap the notch was built to fix — the mark
147
+ * is inset 36px and the un-notched figure starts at 90px. The escape hatch's whole job is to give
148
+ * that old look back on a chosen slide; a slide that wants neither can also carry `chrome: false`.
149
+ *
150
+ * Separate from `chrome: false` on purpose: that hides the mark AND the pills AND the
151
+ * confidentiality watermark. A slide that wants its running chrome but not the cut has no way to
152
+ * say so through `chrome:`.
153
+ *
154
+ * @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
155
+ * @returns {boolean} false only when the slide explicitly wrote `notch: false`.
156
+ */
157
+ export const notchShows = (frontmatter) => frontmatter?.notch !== false;
158
+
159
+ /**
160
+ * Does this figure cut a notch?
161
+ *
162
+ * All three are required, and each one alone is a defect: without a panel there is no material to
163
+ * cut (a `frame: content` figure side is the author's own second CONTENT column, which owns its own
164
+ * corner), without the chrome the cut has no occupant, and with `notch: false` the author has said
165
+ * not to.
166
+ *
167
+ * Called by split.vue for its figure side and by stack.vue for its band — both are the same
168
+ * question, which is why `frame` is the parameter rather than a layout name.
169
+ *
170
+ * @param {string | undefined} frame the layout's `frame` prop.
171
+ * @param {boolean} chromeShowing `chromeShows()`'s answer for this slide.
172
+ * @param {boolean} [notchShowing] `notchShows()`'s answer. Defaults to true so a caller that has no
173
+ * frontmatter to consult still gets the theme-wide behaviour rather than silently no cut.
174
+ */
175
+ export const hasNotch = (frame, chromeShowing, notchShowing = true) =>
176
+ chromeShowing && notchShowing && PANEL_FRAMES.includes(frame);
177
+
178
+ /**
179
+ * ★ CO-BRANDING — "maybe two logo marks for forest+companion" (Jose), task-18-brief.md.
180
+ *
181
+ * task-15 proved the cut SIZES to any occupant (measured 72px → 160px of occupant, corner radius
182
+ * unmoved) but nothing ever drew a second mark. This is the missing half: one pure function, so the
183
+ * component that DRAWS the lockup and the layout that CUTS room for it compute the same width from
184
+ * the same frontmatter and cannot part company. That is the same reason `hasNotch` lives here
185
+ * rather than inside either component.
186
+ *
187
+ * ★★ THE PARTNER IS A SQUARE, THE SAME SIZE AND SHAPE AS THE MARK. Jose: "the other logo should be
188
+ * anything (an avatar or whatever), but the second one should be a rounded square like the mark".
189
+ * The companion's artwork can be any ratio — an avatar, a wordmark, a photograph — and it is COVERED
190
+ * into a mark-sized square clipped to the mark's own corner, rather than being drawn at its own
191
+ * proportions beside it.
192
+ *
193
+ * No author input reaches the size, and that is what makes the cut right on the first frame: an
194
+ * image's intrinsic ratio is unknown until it loads, so the occupant is `markPx + pairGapPx +
195
+ * markPx` — 160px — known before anything loads, identical on every slide, and inside even the
196
+ * narrowest ratio's ceiling of 220.5px at 3:1. The clamp below cannot fire for a co-brand; it stays
197
+ * because `notchBudgetPx()` still guards the general `--deck-notch-occupant-w` override.
198
+ *
199
+ * The lockup's own composition follows packages/ui/src/logo/FSubLogo.vue's rule — one shared
200
+ * height, the lead first, a measured gap between — rather than inventing a second co-brand
201
+ * grammar. It does NOT import it: FSubLogo composes from the glyph atlas through Nuxt UI/Tailwind,
202
+ * neither of which exists on a slide.
203
+ *
204
+ * @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
205
+ * @param {{ markPx: number, pairGapPx: number }} chrome deck.json's `chrome` block — the mark's own
206
+ * size and the lockup gap, read rather than restated.
207
+ * @returns {{ src: string, companionPx: number, occupantPx: number } | null} null when the slide
208
+ * declares no companion, in which case NEITHER reader may declare --deck-notch-occupant-w: leaving
209
+ * it undeclared is what keeps the documented `class:`-based override reaching both of them.
210
+ */
211
+ export const coBrand = (frontmatter, chrome, budgetPx = Infinity) => {
212
+ const src = frontmatter?.companion;
213
+ if (typeof src !== 'string' || !src.trim()) return null;
214
+ // Always the mark's own size — see the note above. No author input reaches this, which is what
215
+ // makes the occupant deterministic and the first frame correct without measuring anything.
216
+ const declaredPx = chrome.markPx;
217
+ // The lockup's fixed part: the Forest mark plus the pair gap. Whatever the budget is, this much of
218
+ // it is already spent before a companion's mark is drawn at all.
219
+ const lead = chrome.markPx + chrome.pairGapPx;
220
+ const room = Number.isFinite(budgetPx) ? Math.max(0, budgetPx - lead) : Infinity;
221
+ const companionPx = Math.min(declaredPx, room);
222
+ return {
223
+ src: src.trim(),
224
+ companionPx,
225
+ occupantPx: lead + companionPx,
226
+ /** What the slide asked for, kept so a clamp is inspectable rather than invisible. */
227
+ declaredCompanionPx: declaredPx,
228
+ /** True when the panel could not hold the declared width — see notchBudgetPx(). */
229
+ clamped: companionPx < declaredPx,
230
+ };
231
+ };
232
+
233
+ /**
234
+ * ★★ THE BUDGET, AND THE THING THAT SPENDS AGAINST IT.
235
+ *
236
+ * `deck.json → notch.fit.maxOccupantPx` is 208.5px and `scripts/deck.ts` defines `NOTCH_PAIR_PX` as
237
+ * 160, the widest occupant. Both are ceilings this function applies rather than assertions about
238
+ * defaults: anything a slide can widen the cut with is measured against the panel it is cut into.
239
+ *
240
+ * Rendered against the real theme CSS on a 3:1 panel (489px wide):
241
+ *
242
+ * occupant 72 (default) → cut 108 correct seat
243
+ * occupant 288 (shipped) → cut 324 66% of the panel width: an L-shaped step, not a bite;
244
+ * the convex → concave → convex read is gone
245
+ * occupant 600 → cut 636 the mask subtracts the ENTIRE top 108px of the panel and
246
+ * the mark floats above it with nothing cut for it
247
+ *
248
+ * So the ceiling is now per-RATIO, read from the generated table rather than restated, and
249
+ * `coBrand()` clamps against it. A clamped companion still DRAWS — its `<img>` is `object-fit:
250
+ * contain`, so it renders smaller inside a narrower box rather than distorted or dropped — because
251
+ * a co-brand silently disappearing is worse than one that is visibly too small for its slide.
252
+ *
253
+ * Every caller must pass the SAME budget or the cut and its occupant part company again, which is
254
+ * why `coBrandFitted()` below exists and is what the three components actually call.
255
+ *
256
+ * @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
257
+ * @param {number} currentPage Slidev's own one-based page number.
258
+ * @param {{ defaultRatio: string, byRatio: Record<string, { figurePx: number, maxOccupantPx: number }> }} fit
259
+ * deck.json's `notch.fit` block.
260
+ * @returns {number} the widest occupant this slide's panel can seat — `Infinity` when there is no
261
+ * cut at all, because then nothing is being spent and clamping would only shrink a lockup for no
262
+ * geometric reason.
263
+ */
264
+ export const notchBudgetPx = (frontmatter, currentPage, fit) => {
265
+ const frame = frontmatter?.frame;
266
+ const chromeShowing = chromeShows(frontmatter, currentPage);
267
+ // Every reason there is no cut is a reason not to clamp: no panel, opted out, no chrome, slide 1.
268
+ // Shrinking a lockup that is merely being DRAWN would be an arbitrary haircut.
269
+ const cutting = hasNotch(frame, chromeShowing, notchShows(frontmatter));
270
+ if (!cutting) return Infinity;
271
+ const byRatio = fit?.byRatio ?? {};
272
+ const entry =
273
+ frontmatter?.layout === STACK_LAYOUT
274
+ ? byRatio.stack
275
+ : byRatio[frontmatter?.ratio] ?? byRatio[fit?.defaultRatio];
276
+ // An unknown ratio resolves to the layout's own default (split.vue clamps `ratio` through
277
+ // RATIO_CLASS for exactly the same reason); a table with no entry at all means the generator and
278
+ // this module have parted company, and an unbounded occupant is the safer failure than a
279
+ // zero-width one — it is visible.
280
+ return Number.isFinite(entry?.maxOccupantPx) ? entry.maxOccupantPx : Infinity;
281
+ };
282
+
283
+ /**
284
+ * The co-brand lockup, already fitted to the panel it will be cut into — the ONE call slide-top.vue,
285
+ * split.vue and stack.vue all make, so the mark that is DRAWN and the cut made FOR it are the same
286
+ * arithmetic on the same inputs rather than three call sites that must remember to pass the same
287
+ * third argument.
288
+ *
289
+ * @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
290
+ * @param {number} currentPage Slidev's own one-based page number.
291
+ * @param {{ chrome: { markPx: number, pairGapPx: number }, notch: { fit: any } }} deck deck.json.
292
+ */
293
+ export const coBrandFitted = (frontmatter, currentPage, deck) =>
294
+ coBrand(frontmatter, deck.chrome, notchBudgetPx(frontmatter, currentPage, deck.notch.fit));
295
+
296
+ /*
297
+ ★★ RETIRED — task-26's `clearsChrome()` is gone, and this note is what it left behind.
298
+
299
+ It made a full-width PHOTO band stand clear of the running chrome rather than notch into it. The
300
+ reasoning was sound at the time: the chrome then MIRRORED, routing the pills to the text side and
301
+ the mark to the panel side, which is complete for `split` but not for `stack`, whose band spans
302
+ the whole canvas so BOTH ends land on it. (The mirror is gone now — see the note above hasNotch —
303
+ but that is not what retired this rule.) The mark got its notch; the page marker and the section pill got
304
+ nothing and sat directly on a photograph whose corner can be any colour.
305
+
306
+ ★ WHAT RETIRED IT: useNotchCuts.js measures EVERY running-chrome element on the panel — the mark,
307
+ each pill, the page marker, the confidentiality pill — assigns each to the corner it sits in, and
308
+ cuts all of them. The premise "the pills get nothing" stopped being true when that landed, and
309
+ nothing re-checked the rule built on it.
310
+
311
+ MEASURED, on apps/deck's `frame: photo` band with the clearance removed: the band's clip
312
+ path opens at `M 234.508 0`, i.e. the top-LEFT is cut for the section pill exactly as the top-right
313
+ is cut for the mark, and the deck audit's occlusion pass reports 0 text elements painted under an
314
+ opaque media surface across all 126 slides. Both ends are seated, which is what the rule wanted
315
+ and could not get.
316
+ */
317
+
318
+ /*
319
+ ★★ WHICH END OF THE RUNNING CHROME SITS ON A DARK PANEL.
320
+
321
+ `panelDark` re-points a panel's ground to the dark register (split.vue, stack.vue). The running
322
+ chrome is a SIBLING of `.slidev-layout`, so it cannot inherit that — and when the panel BLEEDS
323
+ there is no ring of ground left for the chrome to sit on, so it lands directly on it.
324
+
325
+ Measured on the specimen's alert slide before this existed: the page marker painted
326
+ the chrome pill (--deck-accent, forest-700 on light) on a forest-900 panel, 1.09:1. That is the exact
327
+ collision deckNotch.static.test.ts's ground table already predicts for a pinned pill on a dark
328
+ ground; the table's own dark column is only survivable because the pill turns over with the
329
+ register, and here the register turned over under half the slide while the pill did not.
330
+
331
+ So each end is answered separately, from the same frontmatter both layouts read:
332
+
333
+ split the panel is the FIGURE side, which is the opposite of `flip`. The chrome does not
334
+ mirror — mark and page marker are always at the right end, section and confidentiality
335
+ pills always at the left — so one end lands on the panel and the other on the content
336
+ column, whichever way `flip` points.
337
+ stack the band spans the full width, so BOTH ends of the row it occupies land on it and neither
338
+ end of the other does: the strip facing it is cream by definition. `flip` moves which row
339
+ that is — unflipped the band is row 1, flipped it is row 2.
340
+
341
+ Panels at `edge: inset` are deliberately absent. They keep the media inset, and the chrome then
342
+ sits on the slide's own ground with the notch cutting a seat for it — which is what the notch is
343
+ for.
344
+ */
345
+ /*
346
+ ── THE ONE GROUND THE FRONTMATTER DOES NOT STATE ──────────────────────────────────────────────
347
+
348
+ ★★ A COVER CAN CHOOSE ITS OWN, AND THE CHROME CANNOT SEE IT. `cover.vue` resolves
349
+ `background: auto` — the default — from its VARIANT: the mosaic opens on the vivid pair's dark
350
+ half because that is the register the brand draws a field of photographs on. That resolution
351
+ happens inside the component and lands as classes on `.slidev-layout`, which the chrome is a
352
+ SIBLING of, not a descendant.
353
+
354
+ So a slide whose frontmatter states neither `dark` nor a background still renders dark, and both
355
+ chrome rows read the frontmatter to decide their register. MEASURED on the specimen's mosaic
356
+ cover with a `confidential:` added: the marking painted --deck-fg-subtle (neutral-500) on
357
+ forest-700 at 1.36:1 — the row had no idea it was on a dark ground.
358
+
359
+ The table lives HERE, and cover.vue imports it, because this is the module the "chrome names no
360
+ layout" gate exempts (see darkChromeParts below, which names split and stack for the same
361
+ reason). One table, two readers, no drift.
362
+
363
+ ⚠ "UNSTATED" IS SPELLED DIFFERENTLY ON EACH SIDE and that is not sloppiness: cover.vue reads
364
+ PROPS, where an absent boolean arrives as `null` from its declared default, and this reads
365
+ FRONTMATTER, where it is simply missing. Same question, two vocabularies.
366
+ */
367
+ /* Two, not six — see the `background` prop in cover.vue for why an opener does not take the named
368
+ pairs. Kept in step with that validator; the clamp below and the layout's own must agree.
369
+
370
+ ★ BEAT_, NOT COVER_, since `end` took the same contract. The two bookends of a deck answer the
371
+ same question — which ground does this open (or close) on when nobody said — and answering it
372
+ twice is how they would come to differ. Named for the pair of layouts rather than for one. */
373
+ export const BEAT_GROUNDS = ['auto', 'paper'];
374
+ /*
375
+ ★★ ONE GROUND FOR EVERY VARIANT, NOT A TABLE KEYED BY ONE. It began as `{ mosaic: {...} }` — the
376
+ brand cover choosing its own green while the other six fell back to paper — and that made the
377
+ default ground a property of the SHAPE, so two openers in the same deck opened on different
378
+ colours depending on which composition an author reached for. A cover is where a deck states its
379
+ identity; the identity does not change with the arrangement of the picture.
380
+ */
381
+ export const BEAT_GROUND = { background: 'vivid', dark: true };
382
+ /*
383
+ The cover variants whose ground props describe a PANEL rather than the slide — cover.vue reads the
384
+ same list. Their page keeps its paper however the panel is painted, so the chrome on one of them
385
+ is on the light register no matter what `dark` says.
386
+ */
387
+ export const COVER_PANEL_SHAPED = ['inset'];
388
+ /*
389
+ ★★ THE VARIANT WHOSE GROUND IS A PHOTOGRAPH, so `background` and `dark` have nothing to paint: a
390
+ poster covers the canvas edge to edge and lays a black scrim over it. Every one of them is light
391
+ ink on a dark picture whatever the frontmatter says, and drawing the same composition once per
392
+ named ground gave four slides that were pixel-identical apart from a caption — which is how this
393
+ was caught. The register is FIXED here rather than chosen, and both readers have to agree on it
394
+ or the confidentiality marking comes back dark on a dark photograph.
395
+ */
396
+ export const COVER_PHOTO_GROUND = ['poster'];
397
+ /* The same fact about the closer: `photo` fills the canvas with a photograph under a scrim, so its
398
+ register is fixed light-on-dark whatever the frontmatter says. One list per layout rather than one
399
+ shared list, because these name VARIANTS and the two layouts' enums are their own. */
400
+ export const END_PHOTO_GROUND = ['photo'];
401
+ /*
402
+ ★★ THE CLOSER TAKES THE NAMED PAIRS AND THE OPENER DOES NOT, and that asymmetry is deliberate
403
+ rather than drift. cover.vue's own note argues an opener down to two grounds: a deck states its
404
+ identity there, and the identity does not change with the arrangement. A closer is the last frame
405
+ and is regularly the one slide a deck is allowed to be loud on — Jose asked for a peach ground with
406
+ brown words the day the layout gained its variants — so it reads the same four-name table
407
+ `default`, `split`'s panel, `stack`'s band and a Card already share.
408
+
409
+ `auto` stays first because it is the default, and it means the same thing here as there: the brand
410
+ pair, resolved from BEAT_GROUND.
411
+ */
412
+ export const END_GROUNDS = ['auto', 'paper', 'warm', 'accent', 'vivid', 'neutral'];
413
+ /* The layouts that resolve an unstated ground from BEAT_GROUND. Everything else renders on the
414
+ deck's paper unless it says otherwise, which is what `dark` and a `deck-dark` class are for. */
415
+ const BEAT_LAYOUTS = ['cover', 'end'];
416
+
417
+ /**
418
+ * Does this slide RENDER on the dark register? `dark: true` and a hand-written `deck-dark` class are
419
+ * the two ways a slide says so itself; the third is a cover letting its variant choose.
420
+ */
421
+ export function darkRegister(frontmatter) {
422
+ const fm = frontmatter ?? {};
423
+ if (fm.layout === 'cover' && COVER_PANEL_SHAPED.includes(fm.variant)) return false;
424
+ if (fm.layout === 'cover' && COVER_PHOTO_GROUND.includes(fm.variant)) return true;
425
+ if (fm.layout === 'end' && END_PHOTO_GROUND.includes(fm.variant)) return true;
426
+ if (fm.dark === true) return true;
427
+ if (String(fm.class ?? '').split(/\s+/).includes('deck-dark')) return true;
428
+ if (!BEAT_LAYOUTS.includes(fm.layout) || fm.dark !== undefined) return false;
429
+ // Clamped the same way the layout clamps it, so a misspelt ground resolves here exactly as it
430
+ // does there — `auto` — rather than the two disagreeing about which slide is dark.
431
+ //
432
+ // ⚠ AND EACH LAYOUT'S OWN LIST DOES THE CLAMPING. `end` takes the four named pairs as well; read
433
+ // against the opener's two, a closer on `warm` fell through to `auto` here and the marking on it
434
+ // was drawn for a dark register on a peach ground.
435
+ const grounds = fm.layout === 'end' ? END_GROUNDS : BEAT_GROUNDS;
436
+ const stated = grounds.includes(fm.background) ? fm.background : 'auto';
437
+ return stated === 'auto' && BEAT_GROUND.dark === true;
438
+ }
439
+
440
+ const NO_DARK_CHROME = { top: 'none', bottom: 'none' };
441
+ export function darkChromeParts(frontmatter) {
442
+ const fm = frontmatter ?? {};
443
+ if (fm.panelDark !== true || fm.edge !== 'bleed') return NO_DARK_CHROME;
444
+ if (fm.layout === 'split') {
445
+ if ((fm.frame ?? 'figure') !== 'panel') return NO_DARK_CHROME;
446
+ const figureSide = fm.flip === true ? 'left' : 'right';
447
+ return { top: figureSide, bottom: figureSide };
448
+ }
449
+ if (fm.layout === 'stack') {
450
+ if ((fm.frame ?? 'panel') !== 'panel') return NO_DARK_CHROME;
451
+ /* A band is full width, so BOTH ends of whichever row it occupies land on it and neither end of
452
+ the other does — the region facing it is cream by definition. `flip` moves which row that is. */
453
+ return fm.flip === true ? { top: 'none', bottom: 'both' } : { top: 'both', bottom: 'none' };
454
+ }
455
+ return NO_DARK_CHROME;
456
+ }
457
+ /** True when the given end of a chrome row sits on a dark panel. `part` is 'left' or 'right'. */
458
+ export const darkChromeAt = (parts, part) => parts === 'both' || parts === part;
package/notchPath.js ADDED
@@ -0,0 +1,188 @@
1
+ // The panel outline, as one geometric path — a rounded rectangle with a notch bitten out of any of
2
+ // its four corners.
3
+ //
4
+ // WHY THIS IS A FUNCTION AND NOT CSS. The first notch was a `clip-path: shape()` written by hand,
5
+ // once per corner, with the occupant's size baked in as a token. That worked while exactly one
6
+ // thing (the Forest mark, 72px, known ahead of time) ever sat on a panel. It does not survive what
7
+ // the chrome actually became:
8
+ //
9
+ // · the section pill sits on a stack band's other top corner, and its width is a section NAME;
10
+ // · the page marker sits on a split panel's bottom corner, and its width grows with the digit
11
+ // count — "9", "19", "199" are three different boxes;
12
+ // · a co-branded slide widens the mark's own occupant to a pair.
13
+ //
14
+ // Written as CSS that would be four corners x two mirror states x per-corner sizes, and every one
15
+ // of those variants would be a separate hand-derived path that could drift from the others. Written
16
+ // as one function it is a single derivation, unit-testable in node against an independent model,
17
+ // and the callers only have to say which corners are occupied and by what.
18
+ //
19
+ // THE PROFILE, corner-local, is unchanged from the hand-written version it replaces — a convex
20
+ // return of radius r meeting the edge at (w + r, 0), a straight run down x = w, a concave quarter
21
+ // circle centred on (w - r, h - r), and the mirrored return meeting the side edge at (0, h + r).
22
+ // See styles/base.css for why the cut is a clip and not a mask (a mask is alpha, and `subtract`
23
+ // leaves a hairline of panel painted into the ground wherever a boundary lands on a fractional
24
+ // device pixel).
25
+
26
+ /** The four corners, named as they are addressed everywhere else in the theme: physical, not logical. */
27
+ export const CORNERS = ['tl', 'tr', 'br', 'bl'];
28
+
29
+ /**
30
+ * Resolve a notch's TWO radii, which the first version wrongly treated as one.
31
+ *
32
+ * A notch has a concave inner corner and two convex returns, and they answer to different masters:
33
+ *
34
+ * · the CONCAVE corner must be CONCENTRIC with the occupant it wraps — the occupant's own corner
35
+ * plus the ring — or the gap around the occupant is not constant, which is visible as soon as
36
+ * the occupant is round;
37
+ * · the CONVEX returns are only the transition back to the panel's edge. Nothing is seated in
38
+ * them, so their radius is free.
39
+ *
40
+ * Sharing one radius forces a false choice. Measured on a 49px-tall page marker with a 24px ring:
41
+ * concentric wants 48.5, but the cut is only 73 tall, so `min(r, h/2)` clamped it to 36.5 and the
42
+ * cut stopped being concentric — its concave corner curved about (58.2, 971.5) where the pill's
43
+ * curved about (46.2, 983.5). Reported by eye as "not perfect", and it was.
44
+ *
45
+ * Clamped independently instead: the concave radius only has to fit inside the cut, and the return
46
+ * radius only has to leave the concave corner room to exist. The mark is unaffected (96px cut, 39.75
47
+ * concentric — the return still resolves to 39.75, exactly what it drew before).
48
+ *
49
+ * @returns {{ r: number, rr: number }} concave radius, convex return radius
50
+ */
51
+ export const notchRadii = (r, w, h) => {
52
+ const concave = Math.max(0, Math.min(r, w, h));
53
+ // What is left on each axis once the concave corner has taken its share.
54
+ const ret = Math.max(0, Math.min(concave, h - concave, w - concave));
55
+ return { r: concave, rr: ret };
56
+ };
57
+
58
+ /**
59
+ * @typedef {{ w: number, h: number, r: number }} Notch
60
+ * w/h — the CUT, i.e. the occupant plus its ring, not the occupant itself. r — the cut's own
61
+ * corner radius, ideally concentric with the occupant's (the occupant's corner plus the ring).
62
+ */
63
+
64
+ /**
65
+ * Build the panel outline.
66
+ *
67
+ * @param {object} spec
68
+ * @param {number} spec.w panel width in px
69
+ * @param {number} spec.h panel height in px
70
+ * @param {number} spec.radius the card corner radius, used on every corner that has no notch
71
+ * @param {Partial<Record<'tl'|'tr'|'br'|'bl', Notch>>} [spec.notches]
72
+ * @returns {string} an SVG path, ready for `clip-path: path('...')`
73
+ */
74
+ export const notchPath = ({ w, h, radius, notches = {} }) => {
75
+ const R = Math.max(0, Math.min(radius, w / 2, h / 2));
76
+ const n = (corner) => {
77
+ const it = notches[corner];
78
+ if (!it || !(it.w > 0) || !(it.h > 0)) return null;
79
+ // A cut cannot be larger than the panel it is bitten out of.
80
+ const cw = Math.min(it.w, w);
81
+ const ch = Math.min(it.h, h);
82
+ return { w: cw, h: ch, ...notchRadii(it.r, cw, ch) };
83
+ };
84
+
85
+ const seg = [];
86
+ const A = (rx, x, y, sweep) => seg.push(`A ${rx} ${rx} 0 0 ${sweep} ${x} ${y}`);
87
+ const L = (x, y) => seg.push(`L ${x} ${y}`);
88
+
89
+ // Walked CLOCKWISE from the top-left corner. Each corner either turns through the card radius or
90
+ // bites inward through its notch; the notch profiles below are the same shape rotated, which is
91
+ // why they read as four near-copies rather than four derivations.
92
+ const tl = n('tl'), tr = n('tr'), br = n('br'), bl = n('bl');
93
+
94
+ // ── start: just past the top-left corner, on the top edge ──────────────────────────────────
95
+ seg.push(`M ${tl ? tl.w + tl.rr : R} 0`);
96
+
97
+ // ── top edge → top-right corner ────────────────────────────────────────────────────────────
98
+ if (tr) {
99
+ L(w - tr.w - tr.rr, 0);
100
+ A(tr.rr, w - tr.w, tr.rr, 1); // convex return, into the cut
101
+ L(w - tr.w, tr.h - tr.r);
102
+ A(tr.r, w - tr.w + tr.r, tr.h, 0); // concave inner corner — concentric with the occupant
103
+ L(w - tr.rr, tr.h);
104
+ A(tr.rr, w, tr.h + tr.rr, 1); // convex return, back to the side edge
105
+ } else {
106
+ L(w - R, 0);
107
+ A(R, w, R, 1);
108
+ }
109
+
110
+ // ── right edge → bottom-right corner ───────────────────────────────────────────────────────
111
+ if (br) {
112
+ L(w, h - br.h - br.rr);
113
+ A(br.rr, w - br.rr, h - br.h, 1);
114
+ L(w - br.w + br.r, h - br.h);
115
+ A(br.r, w - br.w, h - br.h + br.r, 0);
116
+ L(w - br.w, h - br.rr);
117
+ A(br.rr, w - br.w - br.rr, h, 1);
118
+ } else {
119
+ L(w, h - R);
120
+ A(R, w - R, h, 1);
121
+ }
122
+
123
+ // ── bottom edge → bottom-left corner ───────────────────────────────────────────────────────
124
+ if (bl) {
125
+ L(bl.w + bl.rr, h);
126
+ A(bl.rr, bl.w, h - bl.rr, 1);
127
+ L(bl.w, h - bl.h + bl.r);
128
+ A(bl.r, bl.w - bl.r, h - bl.h, 0);
129
+ L(bl.rr, h - bl.h);
130
+ A(bl.rr, 0, h - bl.h - bl.rr, 1);
131
+ } else {
132
+ L(R, h);
133
+ A(R, 0, h - R, 1);
134
+ }
135
+
136
+ // ── left edge → back to the start ──────────────────────────────────────────────────────────
137
+ if (tl) {
138
+ L(0, tl.h + tl.rr);
139
+ A(tl.rr, tl.rr, tl.h, 1);
140
+ L(tl.w - tl.r, tl.h);
141
+ A(tl.r, tl.w, tl.h - tl.r, 0);
142
+ L(tl.w, tl.rr);
143
+ A(tl.rr, tl.w + tl.rr, 0, 1);
144
+ } else {
145
+ L(0, R);
146
+ A(R, R, 0, 1);
147
+ }
148
+
149
+ seg.push('Z');
150
+ return seg.join(' ');
151
+ };
152
+
153
+ /**
154
+ * Is a chrome box actually sitting on this panel, and if so how far into which corner?
155
+ *
156
+ * Returns the cut a corner needs to seat that box — the occupant's own extent from the corner, plus
157
+ * one ring — or null when the box does not overlap the panel at all, which is the common case: on a
158
+ * split, half the running chrome sits on the text side and must not cut anything.
159
+ *
160
+ * The overlap test is deliberately generous by `slack`: a box that stops a pixel short of the panel
161
+ * still needs the cut, because the ring is what the reader sees, not the overlap.
162
+ *
163
+ * @param {DOMRect|null} box the chrome element's rect
164
+ * @param {DOMRect} panel the panel's rect
165
+ * @param {number} ring the breathing gap between occupant and panel
166
+ * @param {number} slack how close counts as touching
167
+ * @returns {{ corner: 'tl'|'tr'|'br'|'bl', w: number, h: number } | null}
168
+ */
169
+ export const occupantFor = (box, panel, ring, slack = 2) => {
170
+ if (!box || !(box.width > 0) || !(box.height > 0)) return null;
171
+ const overlaps =
172
+ box.right > panel.left - slack && box.left < panel.right + slack &&
173
+ box.bottom > panel.top - slack && box.top < panel.bottom + slack;
174
+ if (!overlaps) return null;
175
+
176
+ // Which corner is it in? Whichever edges it is nearer to — a chrome box always sits in a corner,
177
+ // never mid-edge, so the nearer edge on each axis names the corner without ambiguity.
178
+ const nearLeft = Math.abs(box.left - panel.left) <= Math.abs(panel.right - box.right);
179
+ const nearTop = Math.abs(box.top - panel.top) <= Math.abs(panel.bottom - box.bottom);
180
+ const corner = `${nearTop ? 't' : 'b'}${nearLeft ? 'l' : 'r'}`;
181
+
182
+ // The cut is measured from the panel's own corner to the far side of the box, plus the ring. Using
183
+ // the box's FAR edge rather than its width is what makes a box that overhangs the panel's edge
184
+ // still produce a cut deep enough to clear it.
185
+ const w = (nearLeft ? box.right - panel.left : panel.right - box.left) + ring;
186
+ const h = (nearTop ? box.bottom - panel.top : panel.bottom - box.top) + ring;
187
+ return { corner, w: Math.max(0, w), h: Math.max(0, h) };
188
+ };