@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/LICENSE ADDED
@@ -0,0 +1,67 @@
1
+ Forest Design System — Licence
2
+ Copyright (c) 2026 HumanForest Ltd. All rights reserved.
3
+
4
+ This software is published openly so that people building Forest products can install it
5
+ without access to its source repository. It is not open-source software.
6
+
7
+
8
+ 1. GRANT
9
+
10
+ You may install, use, copy and modify this software, and incorporate it into applications,
11
+ prototypes, documents and presentations, for the purpose of building or operating Forest
12
+ products, services and communications.
13
+
14
+ This grant is free of charge and requires no separate agreement. It runs to anyone who
15
+ obtains the software from a public registry.
16
+
17
+
18
+ 2. RESERVED
19
+
20
+ All rights not granted above are reserved. In particular, you may not:
21
+
22
+ (a) redistribute this software, modified or unmodified, as a design system, component
23
+ library, template or theme of your own;
24
+
25
+ (b) use the HumanForest and Forest names, logos, wordmarks, sub-brand marks or any
26
+ confusingly similar mark. Nothing here grants any trademark right. The brand marks
27
+ included in these packages are provided to render Forest's own products correctly,
28
+ not as artwork you may adopt;
29
+
30
+ (c) represent your product as being made, endorsed or approved by HumanForest.
31
+
32
+
33
+ 3. THIRD-PARTY MATERIAL
34
+
35
+ Parts of these packages are not HumanForest's to license, and the grant in section 1 does
36
+ not extend to them. Each keeps its own terms:
37
+
38
+ TYPEFACES — Mohr and GT Haptik are licensed to HumanForest by their foundries and are NOT
39
+ sublicensed to you. No font binary is included in any published package. The stylesheets
40
+ reference https://assets.forest.bike/fonts/, which serves them to those entitled to use
41
+ them. If you are not covered by Forest's licences, substitute your own faces. JetBrains
42
+ Mono is licensed under the SIL Open Font License 1.1 and may be used under those terms.
43
+
44
+ GLYPH OUTLINES — packages/tokens ships `glyphs/glyphs.json`, letterforms of the above
45
+ typefaces outlined as vector paths so sub-brand lockups can be composed at runtime. It is
46
+ covered by the same foundry terms as the typefaces themselves, not by section 1.
47
+
48
+ DEVICE ARTWORK — packages/frames ships device renders from Wikimedia Commons under the
49
+ Creative Commons Attribution-ShareAlike 4.0 International licence
50
+ (https://creativecommons.org/licenses/by-sa/4.0/). They are unmodified. Attribution is a
51
+ condition of that licence: see ATTRIBUTION.md in that package, and keep it with the art
52
+ wherever the art travels. Works derived from the artwork must themselves be CC BY-SA 4.0.
53
+
54
+ DEPENDENCIES — third-party packages installed alongside these keep their own licences.
55
+
56
+
57
+ 4. NO WARRANTY
58
+
59
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
60
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
61
+ PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
62
+ FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
63
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
64
+ DEALINGS IN THE SOFTWARE.
65
+
66
+
67
+ Questions about use beyond this grant: design@forest.bike
package/README.md ADDED
@@ -0,0 +1,577 @@
1
+ # @humanforest/slidev-theme
2
+
3
+ The Forest brand theme for [Slidev](https://sli.dev). It is the **guardrail**: every layout and
4
+ component references only the engine's design tokens, so a deck is on-brand by construction —
5
+ there is no field in which an author can type an off-brand colour, font, corner or duration.
6
+
7
+ ## How the seam works
8
+
9
+ ```
10
+ colourEngine.ts → @humanforest/tokens (ramps.css, type.css, type.deck.css, dataviz.css, geo/)
11
+ @humanforest/fonts (MohrAlt, GT Haptik, JetBrains Mono)
12
+ │ imported by
13
+ styles/index.ts → styles/tokens.css ← DECLARES: --deck-* roles + contracts
14
+ → styles/base.css ← STYLES: elements, using only those
15
+ │ consumed by
16
+ layouts/ + components/ ← reference only --deck-* / --type-deck-*
17
+ ```
18
+
19
+ The style layer is split in two on purpose. `tokens.css` only declares; `base.css` only styles.
20
+ A literal colour or corner in `base.css` is therefore visible as a rule that skipped the token
21
+ layer — the guardrail is checkable by reading, not just by convention.
22
+
23
+ `tokens.css` does four things:
24
+
25
+ 1. Declares `--font-display/sans/mono`. These normally live in `@humanforest/ui`'s `forest.css`,
26
+ which the deck deliberately does not pull — it carries the whole Nuxt UI preset.
27
+ 2. Mirrors the two foundation contracts that `forest.css` owns for the web: the **radius lever**
28
+ and the **motion ladder**. ⚠ These are a mirror, not an import. If `forest.css` moves a
29
+ duration or the radius lever, this file moves with it.
30
+ 3. Maps `--forest-*` / `--neutral-*` ramps onto semantic `--deck-*` roles.
31
+ 4. Re-points Slidev's own `--slidev-*` vars, which otherwise ship a teal primary and a stock
32
+ grey code theme straight through the brand.
33
+
34
+ Change a colour at the engine, run `bun run build`, and every deck inherits it.
35
+
36
+ ## Light is the deck. Dark is punctuation.
37
+
38
+ The deck runs light: a `--warm-50` ("Ice Cream") paper ground and near-black ink, with the accent
39
+ doing the shouting. Boldness comes from the Mohr display tier, the full-bleed accent sections and the
40
+ scale of the type — not from inverting everything.
41
+
42
+ Dark is a per-slide instrument. Add `class: deck-dark` to a slide's frontmatter and every
43
+ semantic role re-points underneath it — background, ink, accent (which lifts to `forest-300` to
44
+ clear the contrast floor on `forest-950`), borders, chart series and map dots:
45
+
46
+ ```yaml
47
+ ---
48
+ layout: default
49
+ class: deck-dark
50
+ ---
51
+ ```
52
+
53
+ The whole-deck `html.dark` toggle still works and shares the same mapping.
54
+
55
+ ## Typography is sized for a room
56
+
57
+ The deck type context (`--type-deck-*`, generated by `scripts/type.ts`) is calibrated against
58
+ the slide-height percentage every legibility source reduces to, measured at 1080px:
59
+
60
+ | Source | Says | At 1080px |
61
+ | --- | --- | --- |
62
+ | 8H rule | text ≥ 1/50 of screen height | ~30px floor |
63
+ | PowerPoint convention | 18pt never-below, 24pt recommended body | 36px / 48px |
64
+ | BrightCarbon | ~2% of slide height read-along, ~6.5% presented | 22px / 70px |
65
+ | Kawasaki | the 30pt rule | 60px |
66
+
67
+ (A 16∶9 slide is 13.333in wide, so a 1920px canvas is 144ppi and 1pt = 2px.)
68
+
69
+ Body lands on **48px (24pt)**; nothing sits below 15pt. The cost is deliberate: at this size a
70
+ slide fits four or five two-line bullets, which is the amount of text a slide should have. The
71
+ `body-sm` tier (36px / 18pt) is the dense register — two-column layouts and tables take it
72
+ automatically.
73
+
74
+ ### Measure is set in `em`, never `ch`
75
+
76
+ Measured on the shipped faces: **`1ch` = 0.78em, but the average lowercase advance is 0.51em.**
77
+ `ch` is the advance of "0", which is far wider than the average letter in both Mohr and GT
78
+ Haptik — so a `max-width` in `ch` permits about **1.5× the characters it appears to promise**.
79
+ `38ch` of GT Haptik is a 58-character line. Line lengths here are `<target characters> ×
80
+ <measured average advance>`; Mohr headings use the uppercase figure, 0.694em.
81
+
82
+ ## Spacing: text gets a measure, media gets a frame
83
+
84
+ A slide's content sits inside four different margins, not one — the distinction is what the
85
+ content actually IS:
86
+
87
+ | Token | Value | Edges | What it's for |
88
+ | --- | --- | --- | --- |
89
+ | `--deck-inset-text` | 96px (grid × 4) | all four | body copy, headings, lists — anything with a measure |
90
+ | `--deck-inset-media` | 36px (grid × 1.5) | left/right always; top/bottom too on a notched figure | photos and illustrations — an un-notched figure keeps `--deck-inset-text` vertically |
91
+ | `--deck-inset-chrome` | 36px (grid × 1.5) | all four | the running mark, page number and section/confidentiality pills |
92
+ | `--deck-inset-display` | 120px (grid × 5) | left/right only | the beat layouts (cover, quote, statement, divider, end) — a margin read against 96–128px display type, not 48px body copy |
93
+
94
+ Text needs a comfortable line length and a margin that reads as "paper" around copy — a
95
+ paragraph's own ragged right edge already supplies visual air. A photograph has a hard edge, and
96
+ the margin that reads correctly around text reads too far around a photo; editorial layout has
97
+ always run figures wider than body copy for exactly this reason. Chrome is a third value again:
98
+ furniture, not content, the same distance from every edge.
99
+
100
+ `--deck-inset-chrome` and `--deck-inset-media` share a VALUE (36px) without sharing a MEANING —
101
+ chrome is a margin from every edge, media is a margin from the left/right edges only. 36px is
102
+ deliberately off the space ladder (8/16/24/32/40/60/80) — Jose's own call, not a rounding accident,
103
+ kept that way on purpose.
104
+
105
+ A figure's own top/bottom bound FOLLOWS THE NOTCH. Going to the chrome inset would run a photo
106
+ straight under the mark and the page-number pill — unless something cuts room for them, which is
107
+ exactly what the notch does. So a NOTCHED figure takes `--deck-inset-media` on all four sides (36px
108
+ uniform, which is the rule as stated), and an un-notched one — `notch: false`, `chrome: false`, or a
109
+ `media: none` second content column — takes `--deck-inset-text` top and bottom, the pre-notch
110
+ geometry the escape hatch is documented to restore. Both vertical margins move together; the
111
+ shipped half-state, where the top rose on a notch and the bottom did not, left every media figure in
112
+ every deck 54px shallower at the bottom than at the other three edges. `split.vue`'s figure side is
113
+ the one place this shows up as code — it
114
+ escapes `.slidev-layout`'s own text-inset padding on its OUTER edge only, with a negative margin,
115
+ leaving the content side untouched. `bleed.vue` is the one layout that opts out of all three: its
116
+ media fills the canvas at inset 0 on purpose ("full bleed" means exactly that), and its own content
117
+ box re-establishes the text inset over the scrim rather than inheriting `.slidev-layout`'s.
118
+
119
+ All four tokens derive from `--deck-grid` (`scripts/deck.ts`); nothing here is a hand-picked px.
120
+
121
+ ### split.vue: a fixed anchor and a conditional gutter
122
+
123
+ Two more tokens exist only for `split.vue`:
124
+
125
+ | Token | Value | What it's for |
126
+ | --- | --- | --- |
127
+ | `--deck-anchor-third` | `360px` — a CANVAS coordinate, so a rule inside a padded box subtracts its own inset | the text column's fixed vertical START |
128
+ | `--deck-split-gutter-same` | 96px | the column gap when both sides are the SAME kind |
129
+
130
+ The text column always starts one third of the way down the canvas — "so the headings dont jump
131
+ between slides" (Jose). Centring hid that problem by moving the heading around depending on how
132
+ much text there was; a fixed start means long text now runs downward past the fold instead, which
133
+ is the intended trade — `scripts/deckAudit.ts` catches any slide that genuinely can't hold its
134
+ content at this anchor.
135
+
136
+ The anchor applies UNIFORMLY, and that is a ruling rather than an implementation detail: on a
137
+ `media: none` split — a second column of CONTENT — both columns start on the 360 line. They used to
138
+ start 270px apart (90 vs 360), which defeats the anchor's own purpose the moment a heading sits in
139
+ each one. A real figure (`photo`/`panel`) still fills its cell top to bottom, because a photograph
140
+ floating with dead space above it is a different and worse thing. The trade is real and measured: an
141
+ anchored second column is 630px tall instead of 900px, so a card grid that used to fit may now need
142
+ splitting across two slides.
143
+
144
+ The content column is THE MEASURE. Running prose carries no `max-width` anywhere in this theme — a
145
+ paragraph, a list item and a caption run the width of whatever holds them, which inside a split is
146
+ the column and on a full-width layout is the slide's own text inset. Split needed an escape hatch
147
+ for this while `base.css` still capped body copy at 20em; it does not any more, and the class is
148
+ gone. The beat layouts (cover, quote, statement) keep caps of their own, which are display type
149
+ being shaped rather than running text being made readable.
150
+ `stack` deliberately does NOT do this — its strip is the full canvas, so the cap does real work
151
+ there exactly as it does on `default`.
152
+
153
+ The gutter between the two columns is conditional on `split.vue`'s own `media` prop: `photo`/`panel`
154
+ (a real figure facing text) get **no gutter** — the figure runs to the text; `none` (a second
155
+ content column, text meeting text) gets the **36px** gutter, the same figure as the slide margin,
156
+ reused rather than restated.
157
+
158
+ ### Non-split slides: a heading needs breathing space
159
+
160
+ `--deck-heading-top-space` (40px, `--deck-space-5`) adds extra air above a top-anchored heading
161
+ (`default.vue`'s `align: 'start'`, the default) so it reads as set apart from the edge rather than
162
+ flush against it — on top of the standard 96px text inset, for 136px total. Centred layouts
163
+ (`cover`/`divider`/`statement`/`quote`/`end`) and `split`/`bleed` (which already own their own
164
+ vertical schemes) don't get it: none of them have the flush-top problem it solves.
165
+
166
+ ## Layouts
167
+
168
+ | Layout | Use |
169
+ | --- | --- |
170
+ | `cover` | The opener — light ground, one Mohr shout, the full lockup signing it |
171
+ | `default` | The workhorse — a bare content slide, plus the optional eyebrow → shout → content rhythm |
172
+ | `divider` | The deck's punctuation — one slide takes over the whole field. `rank: section` (default), `subsection` or `topic`: the ground, the headline and the rule step down together |
173
+ | `statement` | One sentence, the whole slide — a shout on the deck's own ground, one tier below a divider |
174
+ | `quote` | A pull quote — stays in GT Haptik, because it is someone else speaking |
175
+ | `split` | The other workhorse — content beside a figure (`::aside::` slot); optional `::footer::`; `align` moves the column off its anchor; drops to the dense body tier |
176
+ | `stack` | `split` turned through ninety degrees — a hero band across the top (`::band::` slot), the title demoted to a strip below it. The one arrangement where the figure leads and the title captions it |
177
+ | `bleed` | Full-bleed media with content laid over it — the only layout that escapes the slide's padding; optional `::overlay::` |
178
+ | `end` | The closer — signs off with the full brand lockup |
179
+
180
+ ### Where a photograph goes
181
+
182
+ **`image:` in the frontmatter, and nothing in the slot.** `split`, `stack` and `bleed` each take an
183
+ `image:` (the URL) and an `alt:` (what it shows), and render the `<img>` themselves:
184
+
185
+ ```yaml
186
+ ---
187
+ layout: split
188
+ media: photo
189
+ image: 'https://images.example.com/a-street.jpg'
190
+ alt: A cyclist on a London street
191
+ ---
192
+ ```
193
+
194
+ `image` is Slidev's own frontmatter key and means exactly this — its built-in `image-right` layout
195
+ takes the same one. Using it as Slidev does buys the preload for free: `extractImagesUsage()` reads
196
+ it and emits a `<link rel=preload as=image>`, which an `<img>` hand-written into a slot never gets.
197
+ Set neither and `<Placeholder>` stands in, carrying the `alt:` as its label.
198
+
199
+ The `::aside::` / `::band::` / `::media::` slots are still there, and still win when a slide
200
+ provides them — they are for a figure that is not a photograph (a `<DottedMap>`, a chart, a panel).
201
+ Never write an EMPTY slot marker to "leave the figure alone": an empty slot counts as provided, so
202
+ Vue renders it (nothing) instead of the layout's own figure, and the image silently disappears.
203
+
204
+ Every prop each layout takes — its type, default and enum — is generated, not written here: see
205
+ [the manifest](#the-manifest-and-its-presets) below. A hand-typed second copy of that table is
206
+ exactly the drift this README used to carry (`split`'s and `bleed`'s own prop lists went stale
207
+ more than once before the manifest existed to check them against).
208
+
209
+ ### Why nine, and not more
210
+
211
+ The set is closed on purpose, and Task 3 of this theme's build settled it: a small number of
212
+ *structural* shapes, never a new file for a new *idea*. The original spec named things like `map`, `timeline`,
213
+ `dashboard` and `device` as if each needed its own layout. None of them do — a "map" slide is
214
+ `split` with `<DottedMap>` in its `::aside::` slot, not a new file. New ideas become **presets** (a
215
+ layout plus fixed props plus a content shape, see below) instead of new layouts, which is what
216
+ keeps this file short enough to actually hold in your head, and keeps re-branding the deck a matter
217
+ of moving the token engine, not chasing however-many layout files.
218
+
219
+ **Three columns, or a two-by-two, is not a missing layout.** It is the question the closed set
220
+ invites, and the answer is not a tenth file. `split` is two regions by construction — a content
221
+ column beside a figure, with `ratio` dividing them and `flip` deciding which is which — so there is
222
+ no third track to ask for, and adding one would make both props mean something different on every
223
+ slide that used it. n equal cells is a different shape of problem with its own answer: `<Grid>`
224
+ (`::grid`), a COMPONENT that composes inside whichever of the nine you are already in.
225
+
226
+ ~~~md
227
+ ::grid
228
+ ```yaml [props]
229
+ :cols: 3
230
+ :fill: true
231
+ ```
232
+ :::area-box
233
+ …one per cell…
234
+ :::
235
+ ::
236
+ ~~~
237
+
238
+ `cols` is 1–4. `fill: true` makes the cells divide the frame; without it the grid sizes to its own
239
+ content, which is what you want for a stat row and not for an arrangement. The specimen book draws
240
+ both cases as areas, right after the nine frames.
241
+
242
+ `stack` is the one addition the rule allows, and the only one made: it is a STRUCTURE the other
243
+ eight cannot express, not an idea built on top of one. Everything else divides the canvas left/right
244
+ or takes all of it; nothing divided top from bottom. A 65-page Forest template catalogue uses that
245
+ arrangement nine times — tied for its most-used interior structure — and its own analysis
246
+ (`.superpowers/sdd/2026-08-21-deck-layout-system/template-archetypes.md`, §3) ranks it "the biggest
247
+ gap". If a tenth is ever proposed, this is the bar: name the structure the nine cannot draw.
248
+
249
+ ### The trap: a deleted or renamed layout does not go away
250
+
251
+ Five of these eight names — `cover`, `default`, `end`, `quote`, `statement` — are also the names of
252
+ Slidev's own *stock* layouts, shipped inside `@slidev/client`. Slidev resolves `layout: <name>` in
253
+ the order deck-local → theme → addon → its own built-ins, so as long as this theme's own file
254
+ exists, it wins. **Delete or rename one of these five and nothing errors** — the slide silently
255
+ falls through to Slidev's plain, unbranded version of the same name. It is still a valid slide (it
256
+ won't overflow, and `scripts/deckAudit.ts` will happily pass it) — it is just quietly off-brand,
257
+ and nothing at build time tells you. `packages/slidev-theme/test/frontmatter.static.test.ts` pins
258
+ the exact file list on disk for this reason; if you rename a layout, that test — and every
259
+ `layout:` reference to the old name across every deck — has to move with it, in the same commit.
260
+
261
+ Chrome is two files, `slide-top.vue` and `slide-bottom.vue`, each mounted PER SLIDE with the
262
+ route's frontmatter injected — not a global overlay switching on which layout is showing. That
263
+ shape is deliberate: the alternative (a switch keyed on layout name) would need updating every time
264
+ a layout is added, renamed, or a new one wants chrome suppressed on its own terms. Reading
265
+ frontmatter directly means a new layout never needs to be added anywhere to keep working, and a
266
+ slide can opt in or out of chrome regardless of which layout it uses.
267
+
268
+ - **Top-left**: the page-number pill, always, plus the section pill — the nearest PRECEDING
269
+ `divider` slide's title (or its `section:` override), derived from the deck's own slide list.
270
+ Three scopes turn it off, narrowest first: `section: false` on a slide, `section: false` on a
271
+ divider (that divider and its whole run), and `sectionPill: false` in headmatter (the deck)
272
+ rather than typed onto each slide. Empty before the deck's first divider.
273
+ - **Top-right**: the Forest mark, `<Logo variant="mark">` at running-chrome size.
274
+ - **Bottom-left**: the confidentiality pill, from `confidential: '<text>'` in a slide's own
275
+ frontmatter. Absent by default. To mark the whole deck rather than one slide, put it inside
276
+ headmatter's own `defaults:` block — `defaults:` is Slidev's own headmatter-to-every-slide
277
+ mechanism, and this is what actually cascades:
278
+
279
+ ```yaml
280
+ ---
281
+ theme: '@humanforest/slidev-theme'
282
+ defaults:
283
+ confidential: 'Private & Confidential'
284
+ ---
285
+ ```
286
+
287
+ A BARE `confidential:` written directly in headmatter (outside `defaults:`) is only ever slide
288
+ 1's own frontmatter — it marks the cover, nothing else. Measured live, both forms, because this
289
+ is easy to get backwards: the theme reads whichever slide's own frontmatter it is handed and
290
+ does no lookup of its own, so the deck-wide form is entirely on the author getting `defaults:`
291
+ right.
292
+
293
+ Both files hide their page-number/section/mark row on slide 1 automatically (a cover carries no
294
+ page count worth reading and can never have a divider behind it), and any slide can opt out of
295
+ ALL of it with `chrome: false` in its own frontmatter:
296
+
297
+ ```yaml
298
+ ---
299
+ layout: divider
300
+ chrome: false
301
+ ---
302
+ ```
303
+
304
+ Every divider, quote, statement and bleed slide in the demo deck sets it — a beat, or full-bleed
305
+ media, is not the place for a page counter in the corner. The confidentiality pill is the one
306
+ exception: it reads only `chrome`, not the page-1 gate, because a real deck often wants that
307
+ watermark on its title slide too.
308
+
309
+ ### Co-branding, and opting out of the notch
310
+
311
+ A split's figure and a stack's band cut an inverted corner — a notch — for the running mark to sit
312
+ in. Four frontmatter keys steer it, all read by injection (none is a layout prop):
313
+
314
+ | Key | What it does |
315
+ | --- | --- |
316
+ | `companion: /octopus-energy.png` | draws a second mark beside the Forest one at the same height, and widens the cut to match. ONE computation (`notch.js`'s `coBrand()`), called by the chrome that DRAWS the lockup and by the layout that CUTS for it, so the two cannot disagree. The artwork is covered into a mark-sized square, so it is any square-croppable image — a company's logo, an app icon, an avatar |
317
+ | `companionName: 'Octopus Energy'` | the companion mark's alt text |
318
+ | `notch: false` | keeps the running chrome but drops the cut. The mask, the raised top margin and the reserved content band all key off one predicate, so the figure returns to exactly its pre-notch geometry — including the 18px mark-over-corner overlap the notch was built to fix, which is the look this escape hatch exists to give back |
319
+ | `section: false` | drops the SECTION PILL on this slide only, leaving the mark and the page marker alone. The notched panel or band gets its plain `--deck-radius-card` corner back with no other change — nothing is told, the cut is measured from whatever chrome is actually on the panel, so a pill that is not drawn is simply not found. Distinct from its two neighbours: `section: '…'` on a DIVIDER renames the pill for that whole run of slides, and `chrome: false` removes the mark and page marker too |
320
+ | `sectionPill: false` | HEADMATTER ONLY (slide one) — drops the section pill from the WHOLE deck. Checked before every other rule, so nothing out-ranks it. Written on any other slide it is silently inert, which a test in `frontmatter.static.test.ts` fails the build over. Do not reach for `defaults: { section: false }` instead: a `defaults:` block merges into every slide, but a slide's own frontmatter beats a default and every divider declares `section:`, so dividers keep their pill — measured at 3 leaks across 224 slides |
321
+
322
+ ```yaml
323
+ ---
324
+ layout: split
325
+ media: panel
326
+ companion: /octopus-energy.png
327
+ ---
328
+ ```
329
+
330
+ A Forest+companion pair is 72 + 16 + 72 = 160px wide, and the narrowest ratio (`3:1`, a 489px figure)
331
+ has room for 208.5px of occupant — so there is no threshold to degrade at. `scripts/deck.ts` emits
332
+ that arithmetic as data and a test gates it, so pushing the mark past the budget goes red naming the
333
+ ratio rather than shipping a cramped notch.
334
+
335
+ Both pills route through `<BadgeStrip>` (below) rather than a second, hand-rolled fill/ink
336
+ implementation, so the pairing guarantee applies here too. They take the ACCENT pair rather than one
337
+ of the table's pinned halves, because a pinned fill is register-invariant by design — right for an
338
+ object sitting ON the deck, wrong for chrome that lands on whatever ground the slide gives it.
339
+ Pinned to forest-700, every dark panel ground failed the 3:1 floor beneath it: bark 1.09:1,
340
+ forest-900 1.77:1, neutral-800 2.33:1 — against 3.86, 6.28 and 8.28 once it followed the register.
341
+
342
+ ## Components (auto-imported)
343
+
344
+ - **`<Logo>`** — the Forest identity in the four forms the brand ships: mark, wordmark, lockup,
345
+ lockup-vertical. The mark carries its own ground; the wordmark is drawn as a **mask** filled with
346
+ `currentColor` so it takes the slide's ink instead of the one baked into the file; the lockup
347
+ composes the two so the mark keeps its fixed green while the ink follows.
348
+ - **`<Kpi>`** — one figure and how it moved. The arrow carries **direction**, the colour carries
349
+ **judgement**, and only where `sentiment` has declared which way is better. Prop names match
350
+ `FKpi`, and `resolveDelta()` is imported from `@humanforest/ui`'s source rather than restated.
351
+ A figure that is already text rather than a number goes through the `metric` slot:
352
+ `<Kpi label="Riders"><template #metric>480k</template></Kpi>`. (This replaces the retired
353
+ `<Metric>`, whose entire body was that one line.)
354
+ - **`<Card>`** — the deck's one container: no fill at all (`background: transparent`, an edge in
355
+ the card's own ink), a quiet fill one step away from the slide ground (`background: surface`), or
356
+ one of the four named backgrounds; `dark` renders the last two in the dark register. `border`
357
+ adds an edge — `none` by default, or `hairline`/`strong` — always drawn in the card's own ink
358
+ rather than a slide-derived token, so it reads wherever the card lands. Concentric corners,
359
+ 48 → 16 → 8.
360
+ - **`<Grid>`** — the deck's one grid, so gutters stay a closed system decision rather than an
361
+ author's arbitrary value. When an `items` array is passed, the default slot renders once per
362
+ item — a stat row, an icon row and a badge strip are all this component with different slot
363
+ content.
364
+ - **`<LineChart>` `<AreaChart>` `<StackedBarChart>` `<GroupedBarChart>` `<DonutChart>`
365
+ `<ScatterChart>`** — the Unovis chart set, sized for a room. Real `@unovis/vue` marks;
366
+ `<ChartFrame>` carries the ceremony (the deck type ramp mapped onto Unovis' `--vis-*`
367
+ properties, the legend, the empty state) and `components/chartData.ts` carries the pure data
368
+ shaping. Axis labels are 36px, not the library's 12px, because the deck is read from the back of
369
+ a room. Colours come from `--deck-series-1…5` and **stop at five** — a chart handed a sixth
370
+ series draws a message saying so rather than inventing a colour or repeating one.
371
+
372
+ Direct `@unovis/vue` + `@unovis/ts`, **not** `@humanforest/charts`: that package peer-depends on
373
+ `@nuxt/ui`, bun auto-installs peer dependencies, and Nuxt UI is Tailwind — which does not coexist
374
+ with the UnoCSS pipeline a deck runs on. Measured: adding it to this package's dependencies pulls
375
+ Nuxt UI and Tailwind into the install tree (326 top-level entries / 306 MB, against 204 / 199 MB
376
+ without). The pure functions it owns (`fromCategories`, the palette accessors) are
377
+ re-implemented in `chartData.ts` with a comment naming them as the source of truth.
378
+ - **`<Bars>`** — a horizontal bar chart sized for a room: bars start at zero, coloured from the
379
+ deck's own series roles. Still hand-drawn rather than an Unovis mark, because what it does — a
380
+ direct value label at the end of every bar, no axis at all — is a different figure from
381
+ `<StackedBarChart>`, not a worse version of one. Its fills resolve through the same
382
+ `seriesVar()` the Unovis charts use, so there is one place that knows what a series colour is.
383
+ - **`<DottedMap>`** — London as a dot grid, sampled from real borough geometry at build time. Offline,
384
+ no token, no canvas to wait on.
385
+ - **`<BoroughShape>`** — one London borough as a styleable glyph: a single outline, framed to its
386
+ own bounds, filled with `currentColor`. Same generated geometry the product's own borough shapes
387
+ read, so a deck outline and a product outline are the same outline. `size` steps its HEIGHT
388
+ (`sm` 240 · `md` 400 · `lg` 640 · `xl` 880), `md` being the natural size a slide that says
389
+ nothing gets. The step lands as an inline **custom property**, not a class or an inline `height`:
390
+ a scoped rule (`.borough-shape[data-v-…]`) outranks any UnoCSS `h-*` utility, so a class-based
391
+ size was silently inert and the only way past it was `!important`. A custom property is not in
392
+ the specificity contest at all. `xl` needs a slide with no heading — 880 plus an `h1` clears the
393
+ bottom of the canvas.
394
+ - **`<DataTable>`** — a table sized for a room: a ~104px row, a 30px uppercase header over an
395
+ accent rule, and figures in the engine's own `.type-tabular` treatment (which swaps the family to
396
+ GT Haptik, because Mohr ships no tabular figure set — a column of Mohr numerals cannot line up,
397
+ whatever CSS is applied). `<Kpi>` makes the opposite call for the same reason and both are right:
398
+ one shout has nothing to align against, a column has nothing but. Formatting is shared —
399
+ `formatMetric()`, the function `<Kpi>` formats with.
400
+
401
+ Both axes have a limit, and they **degrade differently on purpose**. Rows truncate and say so on
402
+ the slide ("Showing 5 of 12 rows — 7 not shown"), because a top-five table is a real figure.
403
+ Columns REFUSE past five and draw a sentence instead, because dropping a column does not shorten
404
+ a table, it changes what the table claims. The row numbers are measured, not derived: 5 fits
405
+ under a heading, 7 needs a slide with no heading, and `scripts/deckAudit.ts` caught the first
406
+ pass 30px past the bottom inset.
407
+ - **`<StatCard>`** — `<Kpi>`'s figure inside `<Card>`'s framing.
408
+ - **`<IconTile>`** — a single icon on a fixed peach tile, drawn as a CSS mask read straight from
409
+ the shared icon source (not a `i-forest-*`/`i-lucide-*` utility class, which can't be assembled
410
+ from a runtime prop — UnoCSS never sees the resulting class name in source). Accepts both Forest
411
+ and Lucide icons — see [Icons](#icons) below.
412
+ - **`<BadgeStrip>`** — an array-driven row of named-background tiles; `items` is plain strings, one
413
+ tile per entry.
414
+ - **`<Placeholder>`** — "an image goes here", deliberately obvious rather than decorative, so a
415
+ placeholder that looked like a real photo never accidentally ships. Drawn as inline SVG: no
416
+ network, no raster asset, no licensing question, and identical dimensions on every run — which is
417
+ what keeps `scripts/deckAudit.ts` meaningful, since a real photo's intrinsic size varies and
418
+ would make overflow results non-repeatable.
419
+
420
+ Every prop each component takes lives in the manifest, the same as layouts — not restated here.
421
+
422
+ ## The manifest and its presets
423
+
424
+ `packages/slidev-theme/manifest/manifest.json` is a generated, machine-readable description of
425
+ every layout, component and piece of running chrome this theme ships — every prop, its type,
426
+ default, enum and description; every slot — plus the preset vocabulary built on top of them. It is
427
+ committed and gated by a CI freshness check (rebuild and diff, the same idiom the layer and fonts
428
+ artefacts already use), so what's committed always matches a fresh regeneration:
429
+
430
+ ```
431
+ bun run deck:manifest # regenerates packages/slidev-theme/manifest/manifest.json
432
+ ```
433
+
434
+ It is the **authoritative** reference for "what props does this layout take, and what does each
435
+ one accept" — this README deliberately does not carry a second copy of that table. Two things feed
436
+ it: everything mechanically derivable is parsed straight from `layouts/*.vue`/`components/*.vue`
437
+ source (`packages/slidev-theme/test/props.ts`); the one thing no parser can infer — a short,
438
+ human-facing statement of what each thing is *for* — is hand-authored in
439
+ `packages/slidev-theme/manifest/{layouts,components,chrome}.ts`. The generator refuses to write the
440
+ file at all while any entry would be false or incomplete, rather than shipping a partial one.
441
+
442
+ A **preset** is a named, ready-to-use recipe built on top of one layout: a fixed prop assignment
443
+ plus editorial "use this / avoid this" guidance, one level above "here are eight raw layouts and
444
+ thirteen raw components, go compose something". They fall into three groups, in
445
+ `packages/slidev-theme/manifest/presets.ts`: one preset per layout at its plain default shape (so
446
+ the preset table alone is a complete entry point); a set of named content shapes recast onto one of
447
+ the real eight layouts — this is where `map`, `timeline`, `dashboard` and the rest of the original
448
+ spec's vocabulary actually live, each one a real prop assignment plus a described component
449
+ composition, never a ninth layout file; and a handful of aliases (a mirrored `flip`, a flipped background)
450
+ that are a data row, not new architecture. Every preset's `props` are validated against its own
451
+ layout's real, source-derived prop enum at generation time, so a preset can never reference a value
452
+ a layout doesn't actually accept.
453
+
454
+ `scripts/deckSpecimen.ts` (the 147-slide `apps/deck`) reads the manifest
455
+ directly to build that deck. Any other author of a Forest deck — human or AI — should read
456
+ `manifest.json` first, before this README: a missing entry there means "go look at the source"; a
457
+ wrong one in a hand-written prose copy would send an author confidently down a dead end.
458
+
459
+ ## Icons
460
+
461
+ `uno.config.ts` registers two icon collections:
462
+
463
+ - **Forest** — the design system's own set, from `packages/ui/icons/forest`, so a deck icon is the
464
+ same artwork the product draws:
465
+
466
+ ```html
467
+ <div class="i-forest-bike text-6xl" />
468
+ ```
469
+
470
+ - **Lucide** — for everything Forest doesn't draw. `<IconTile icon="lucide:check">` and slide
471
+ content both accept `lucide:<name>`. Only the names actually referenced anywhere in the repo
472
+ (every `slides.md`, this theme's own layouts/components, and the preset prose in
473
+ `manifest/presets.ts`) are extracted into `packages/slidev-theme/icons/lucide/*.svg` — a small,
474
+ committed, generated set, the same shape `packages/ui/icons/forest` already is — rather than
475
+ bundling the full ~1,774-icon, 543 KB `@iconify-json/lucide` collection into every deck's build.
476
+ Regenerate with:
477
+
478
+ ```
479
+ bun run deck:icons
480
+ ```
481
+
482
+ This is also gated by a CI freshness check: an icon that stops being referenced anywhere is
483
+ deleted from disk on the next run, not left stale next to the ones still in use, and a reference
484
+ to a name that doesn't exist in Lucide's own `icons.json` fails the generator rather than
485
+ shipping a blank tile.
486
+
487
+ ## Auditing a deck
488
+
489
+ Every gate described so far is a text match over source — a prop parser, a coverage count, a
490
+ freshness diff. All of them can be green while a slide is actually broken: blank, overflowing the
491
+ canvas, or a component nesting inside another in a way that breaks contrast only once it is
492
+ actually rendered. `scripts/deckAudit.ts` is the one gate that renders every slide in a real browser
493
+ (Playwright) and checks the three things none of the others can see:
494
+
495
+ - **Geometry** — nothing escapes the 1920×1080 canvas, measured against the slide's own rendered
496
+ frame rather than assumed from source.
497
+ - **The webfont swap** — `font-display: swap` means the fallback face paints first and MohrAlt/GT
498
+ Haptik are both measurably wider, so text can reflow once the real face loads. The audit measures
499
+ a probe span's width and height before and after the swap, for both faces, and treats "the font
500
+ never actually swapped during this run" as its own failure — not a silent pass with nothing to
501
+ report.
502
+ - **Rendered contrast** — every text-bearing element and every list-marker pseudo-element
503
+ (`::before`/`::marker`) is scored against its ACTUAL composited ground: real computed colour, real
504
+ opacity chain, real ancestor backgrounds — not a token pair checked in isolation. A token pair can
505
+ be fine on its own and still fail once one component nests inside another at runtime; that is
506
+ exactly the class of defect this sweep exists to catch, and has caught — both of its finds are on
507
+ components since retired: a chip's text and list markers rendering at 1.00:1 on their own fill, and
508
+ a callout at ~1.02:1 inside what was then the accent card. Invisible to every other gate here.
509
+
510
+ CI audits the **built** deck, `slidev build` output served as static files, never a live dev
511
+ server — a dev server's hot-reload state has previously caused this exact harness to under-report
512
+ its own slide count and silently skip one, which a built deck cannot do. The deck (`apps/deck`) is
513
+ audited on every push, in its own CI job so a failure there neither blocks nor hides the fast unit
514
+ suite, and vice versa.
515
+
516
+ **A failure names the slide, the element and the measurement** — e.g. `slide 4: contrast FAIL
517
+ .deck-card strong — 1.02:1 (needs 4.5:1) — text #1a1a1a on #1f1f1f`. Start there: it is a real
518
+ defect on that slide, not a flaky harness. `--slides=<path>` is required and not optional — the
519
+ audit derives its own expected slide count from that file's `---` separators, so it never silently
520
+ trusts a stale count from anywhere else.
521
+
522
+ To run it by hand while iterating, against a dev server:
523
+
524
+ ```
525
+ slidev --open false --port 3030 &
526
+ bun ../../scripts/deckAudit.ts --port=3030 --slides=slides.md
527
+ ```
528
+
529
+ ## Debugging layout — the red-line overlay
530
+
531
+ Every gate above is a text match or a rendered measurement; none of them show you the SHAPE of a
532
+ slide's spacing. The inset system means several different margins are live on any given
533
+ slide (see "Spacing" above), and a token is invisible — nobody can see 100px versus 40px by
534
+ looking at a finished slide. That invisibility is exactly how a real bottom-chrome overlap once
535
+ survived a written comment asserting it was fine: the audit measures overflow past the canvas, not
536
+ collision inside it, and nothing rendered the frame the comment described. This overlay does.
537
+
538
+ **What it draws**, on top of the current slide, in red hairlines:
539
+
540
+ - a **dotted** rectangle at `--deck-inset-chrome` — where the corner furniture sits
541
+ - a **dashed** rectangle at `--deck-inset-media` horizontally / `--deck-inset-text` vertically —
542
+ where a figure may reach
543
+ - a **solid** rectangle at `--deck-inset-text` — where text content is bounded
544
+ - two filled **hazard bands**, top and bottom, at the real height of slide-top.vue's/
545
+ slide-bottom.vue's own pill row — collision with these is the specific failure this tool exists
546
+ to catch
547
+ - on a `layout: split` slide only: a thick **anchor line** at `--deck-anchor-third` (y = 360) —
548
+ where the text column's own content always starts — and a small **text label** stating the
549
+ computed gutter for that slide (`gutter 36px · same-kind` or `gutter 0 · mixed`). The gutter's
550
+ own on-screen WIDTH depends on `ratio`/`flip` and is not drawn geometrically, deliberately: doing
551
+ so would mean re-deriving split.vue's own grid-track arithmetic a second time inside the overlay,
552
+ which could silently drift out of sync with the real one. The label states the computed value
553
+ instead of a seam it hasn't actually measured.
554
+
555
+ **Toggle it with the `l` key** — no deck file to edit, no rebuild, works on any dev server or
556
+ built deck. It is a review instrument, not a debugging leftover: reach for it whenever you're
557
+ checking that a layout actually honours the frame it claims to, the same way you'd reach for
558
+ `deckAudit.ts` to check overflow.
559
+
560
+ It is **off by default, always**, and cannot appear in an export or a screen-share: the toggle is
561
+ an in-memory ref with no persistence (a fresh page load — exactly what `slidev export`'s
562
+ Playwright run performs — always starts at `false`), and the overlay's own render condition is
563
+ additionally gated on Slidev's `isPrintMode` (true for the `/export` route and for any `?print`
564
+ URL), independently of the toggle. Both guards have to agree for anything to paint. See
565
+ `debug/layoutDebug.js` and `global-top.vue` for the mechanism; `test/deckInsets.static.test.ts`
566
+ gates the off-by-default half in source, on every run.
567
+
568
+ ## Usage
569
+
570
+ In a deck's `slides.md` headmatter:
571
+
572
+ ```yaml
573
+ ---
574
+ theme: '@humanforest/slidev-theme'
575
+ canvasWidth: 1920
576
+ ---
577
+ ```