@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.
- package/LICENSE +67 -0
- package/README.md +577 -0
- package/assetUrl.js +38 -0
- package/components/Alert.vue +209 -0
- package/components/AreaBox.vue +152 -0
- package/components/AreaChart.vue +145 -0
- package/components/Avatar.vue +267 -0
- package/components/AvatarGroup.vue +94 -0
- package/components/BadgeStrip.vue +203 -0
- package/components/Bars.vue +207 -0
- package/components/BoroughShape.vue +127 -0
- package/components/BrowserFrame.vue +484 -0
- package/components/CalendarGrid.vue +480 -0
- package/components/Card.vue +70 -0
- package/components/Carousel.vue +266 -0
- package/components/ChartFrame.vue +426 -0
- package/components/ChordChart.vue +264 -0
- package/components/ComposedChart.vue +187 -0
- package/components/Contents.vue +86 -0
- package/components/DataTable.vue +244 -0
- package/components/DeckMap.vue +409 -0
- package/components/DeviceFrame.vue +331 -0
- package/components/DonutChart.vue +103 -0
- package/components/DottedMap.vue +126 -0
- package/components/Endorsement.vue +76 -0
- package/components/Grid.vue +169 -0
- package/components/GroupedBarChart.vue +141 -0
- package/components/IconTile.vue +116 -0
- package/components/Kpi.vue +188 -0
- package/components/LineChart.vue +133 -0
- package/components/Logo.vue +203 -0
- package/components/LondonMap.vue +348 -0
- package/components/MarkPair.vue +109 -0
- package/components/Marquee.vue +263 -0
- package/components/NestedDonutChart.vue +295 -0
- package/components/Placeholder.vue +88 -0
- package/components/SankeyChart.vue +223 -0
- package/components/ScatterChart.vue +142 -0
- package/components/StackedBarChart.vue +143 -0
- package/components/StatCard.vue +134 -0
- package/components/StatusTrack.vue +334 -0
- package/components/Timeline.vue +249 -0
- package/components/TimelineChart.vue +329 -0
- package/components/TreemapChart.vue +267 -0
- package/components/backgrounds.js +135 -0
- package/components/boroughGeometry.ts +59 -0
- package/components/chartData.ts +277 -0
- package/components/chartTip.ts +201 -0
- package/components/codeTheme.ts +239 -0
- package/components/deckPalette.ts +157 -0
- package/components/dottedMapGeometry.js +60 -0
- package/components/iconSlugs.js +55 -0
- package/components/iconUrl.js +51 -0
- package/components/mermaidTheme.ts +972 -0
- package/components/motionDuration.ts +41 -0
- package/components/tableRules.ts +257 -0
- package/confidential-mark.vue +151 -0
- package/custom-nav-controls.vue +79 -0
- package/endorsements/b-corp-black.svg +30 -0
- package/endorsements/b-corp-white.svg +30 -0
- package/global-top.vue +1662 -0
- package/icons/forest/bike-asterisk.svg +21 -0
- package/icons/forest/bike-down.svg +19 -0
- package/icons/forest/bike-off.svg +20 -0
- package/icons/forest/bike-up.svg +19 -0
- package/icons/forest/bike-x.svg +19 -0
- package/icons/forest/bike.svg +18 -0
- package/icons/forest/mark-fill.svg +6 -0
- package/icons/forest/mark.svg +6 -0
- package/icons/forest/parking.svg +3 -0
- package/icons/forest/star-fill.svg +4 -0
- package/icons/forest/star.svg +4 -0
- package/icons/forest/traffic-light-caution.svg +8 -0
- package/icons/forest/traffic-light-go.svg +8 -0
- package/icons/forest/traffic-light-lit.svg +8 -0
- package/icons/forest/traffic-light-stop.svg +8 -0
- package/icons/forest/traffic-light.svg +8 -0
- package/icons/lucide/battery.svg +1 -0
- package/icons/lucide/bike.svg +1 -0
- package/icons/lucide/chart-line.svg +1 -0
- package/icons/lucide/check.svg +1 -0
- package/icons/lucide/circle-check.svg +1 -0
- package/icons/lucide/clock.svg +1 -0
- package/icons/lucide/code.svg +1 -0
- package/icons/lucide/component.svg +1 -0
- package/icons/lucide/frame.svg +1 -0
- package/icons/lucide/layout-grid.svg +1 -0
- package/icons/lucide/map-pin.svg +1 -0
- package/icons/lucide/map.svg +1 -0
- package/icons/lucide/palette.svg +1 -0
- package/icons/lucide/table.svg +1 -0
- package/icons/lucide/triangle-alert.svg +1 -0
- package/icons/lucide/type.svg +1 -0
- package/icons/lucide/wrench.svg +1 -0
- package/layouts/bleed.vue +317 -0
- package/layouts/cover.vue +1514 -0
- package/layouts/default.vue +187 -0
- package/layouts/divider.vue +286 -0
- package/layouts/end.vue +384 -0
- package/layouts/quote.vue +218 -0
- package/layouts/split.vue +1875 -0
- package/layouts/stack.vue +944 -0
- package/layouts/statement.vue +67 -0
- package/manifest/chrome.ts +36 -0
- package/manifest/components.ts +49 -0
- package/manifest/index.ts +57 -0
- package/manifest/layouts.ts +37 -0
- package/manifest/manifest.json +6514 -0
- package/manifest/presets.ts +635 -0
- package/notch.js +458 -0
- package/notchPath.js +188 -0
- package/package.json +77 -0
- package/section.js +110 -0
- package/setup/mermaid-renderer.ts +2120 -0
- package/setup/mermaid.ts +30 -0
- package/setup/shiki.ts +44 -0
- package/setup/shortcuts.ts +28 -0
- package/slide-bottom.vue +305 -0
- package/slide-top.vue +268 -0
- package/styles/base.css +2611 -0
- package/styles/index.ts +71 -0
- package/styles/tokens.css +144 -0
- package/uno.config.ts +231 -0
- 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
|
+
```
|