@humanforest/slidev-theme
v0.4.0
Published
Forest brand theme for Slidev — the guardrail. Layouts + components wired to @humanforest/tokens, so any deck is on-brand by construction. Do not hand-pick colours/fonts here; everything aliases the engine tokens.
Maintainers
Readme
@humanforest/slidev-theme
The Forest brand theme for Slidev. It is the guardrail: every layout and component references only the engine's design tokens, so a deck is on-brand by construction — there is no field in which an author can type an off-brand colour, font, corner or duration.
How the seam works
colourEngine.ts → @humanforest/tokens (ramps.css, type.css, type.deck.css, dataviz.css, geo/)
@humanforest/fonts (MohrAlt, GT Haptik, JetBrains Mono)
│ imported by
styles/index.ts → styles/tokens.css ← DECLARES: --deck-* roles + contracts
→ styles/base.css ← STYLES: elements, using only those
│ consumed by
layouts/ + components/ ← reference only --deck-* / --type-deck-*The style layer is split in two on purpose. tokens.css only declares; base.css only styles.
A literal colour or corner in base.css is therefore visible as a rule that skipped the token
layer — the guardrail is checkable by reading, not just by convention.
tokens.css does four things:
- Declares
--font-display/sans/mono. These normally live in@humanforest/ui'sforest.css, which the deck deliberately does not pull — it carries the whole Nuxt UI preset. - Mirrors the two foundation contracts that
forest.cssowns for the web: the radius lever and the motion ladder. ⚠ These are a mirror, not an import. Ifforest.cssmoves a duration or the radius lever, this file moves with it. - Maps
--forest-*/--neutral-*ramps onto semantic--deck-*roles. - Re-points Slidev's own
--slidev-*vars, which otherwise ship a teal primary and a stock grey code theme straight through the brand.
Change a colour at the engine, run bun run build, and every deck inherits it.
Light is the deck. Dark is punctuation.
The deck runs light: a --warm-50 ("Ice Cream") paper ground and near-black ink, with the accent
doing the shouting. Boldness comes from the Mohr display tier, the full-bleed accent sections and the
scale of the type — not from inverting everything.
Dark is a per-slide instrument. Add class: deck-dark to a slide's frontmatter and every
semantic role re-points underneath it — background, ink, accent (which lifts to forest-300 to
clear the contrast floor on forest-950), borders, chart series and map dots:
---
layout: default
class: deck-dark
---The whole-deck html.dark toggle still works and shares the same mapping.
Typography is sized for a room
The deck type context (--type-deck-*, generated by scripts/type.ts) is calibrated against
the slide-height percentage every legibility source reduces to, measured at 1080px:
| Source | Says | At 1080px | | --- | --- | --- | | 8H rule | text ≥ 1/50 of screen height | ~30px floor | | PowerPoint convention | 18pt never-below, 24pt recommended body | 36px / 48px | | BrightCarbon | ~2% of slide height read-along, ~6.5% presented | 22px / 70px | | Kawasaki | the 30pt rule | 60px |
(A 16∶9 slide is 13.333in wide, so a 1920px canvas is 144ppi and 1pt = 2px.)
Body lands on 48px (24pt); nothing sits below 15pt. The cost is deliberate: at this size a
slide fits four or five two-line bullets, which is the amount of text a slide should have. The
body-sm tier (36px / 18pt) is the dense register — two-column layouts and tables take it
automatically.
Measure is set in em, never ch
Measured on the shipped faces: 1ch = 0.78em, but the average lowercase advance is 0.51em.
ch is the advance of "0", which is far wider than the average letter in both Mohr and GT
Haptik — so a max-width in ch permits about 1.5× the characters it appears to promise.
38ch of GT Haptik is a 58-character line. Line lengths here are <target characters> ×
<measured average advance>; Mohr headings use the uppercase figure, 0.694em.
Spacing: text gets a measure, media gets a frame
A slide's content sits inside four different margins, not one — the distinction is what the content actually IS:
| Token | Value | Edges | What it's for |
| --- | --- | --- | --- |
| --deck-inset-text | 96px (grid × 4) | all four | body copy, headings, lists — anything with a measure |
| --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 |
| --deck-inset-chrome | 36px (grid × 1.5) | all four | the running mark, page number and section/confidentiality pills |
| --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 |
Text needs a comfortable line length and a margin that reads as "paper" around copy — a paragraph's own ragged right edge already supplies visual air. A photograph has a hard edge, and the margin that reads correctly around text reads too far around a photo; editorial layout has always run figures wider than body copy for exactly this reason. Chrome is a third value again: furniture, not content, the same distance from every edge.
--deck-inset-chrome and --deck-inset-media share a VALUE (36px) without sharing a MEANING —
chrome is a margin from every edge, media is a margin from the left/right edges only. 36px is
deliberately off the space ladder (8/16/24/32/40/60/80) — Jose's own call, not a rounding accident,
kept that way on purpose.
A figure's own top/bottom bound FOLLOWS THE NOTCH. Going to the chrome inset would run a photo
straight under the mark and the page-number pill — unless something cuts room for them, which is
exactly what the notch does. So a NOTCHED figure takes --deck-inset-media on all four sides (36px
uniform, which is the rule as stated), and an un-notched one — notch: false, chrome: false, or a
media: none second content column — takes --deck-inset-text top and bottom, the pre-notch
geometry the escape hatch is documented to restore. Both vertical margins move together; the
shipped half-state, where the top rose on a notch and the bottom did not, left every media figure in
every deck 54px shallower at the bottom than at the other three edges. split.vue's figure side is
the one place this shows up as code — it
escapes .slidev-layout's own text-inset padding on its OUTER edge only, with a negative margin,
leaving the content side untouched. bleed.vue is the one layout that opts out of all three: its
media fills the canvas at inset 0 on purpose ("full bleed" means exactly that), and its own content
box re-establishes the text inset over the scrim rather than inheriting .slidev-layout's.
All four tokens derive from --deck-grid (scripts/deck.ts); nothing here is a hand-picked px.
split.vue: a fixed anchor and a conditional gutter
Two more tokens exist only for split.vue:
| Token | Value | What it's for |
| --- | --- | --- |
| --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 |
| --deck-split-gutter-same | 96px | the column gap when both sides are the SAME kind |
The text column always starts one third of the way down the canvas — "so the headings dont jump
between slides" (Jose). Centring hid that problem by moving the heading around depending on how
much text there was; a fixed start means long text now runs downward past the fold instead, which
is the intended trade — scripts/deckAudit.ts catches any slide that genuinely can't hold its
content at this anchor.
The anchor applies UNIFORMLY, and that is a ruling rather than an implementation detail: on a
media: none split — a second column of CONTENT — both columns start on the 360 line. They used to
start 270px apart (90 vs 360), which defeats the anchor's own purpose the moment a heading sits in
each one. A real figure (photo/panel) still fills its cell top to bottom, because a photograph
floating with dead space above it is a different and worse thing. The trade is real and measured: an
anchored second column is 630px tall instead of 900px, so a card grid that used to fit may now need
splitting across two slides.
The content column is THE MEASURE. Running prose carries no max-width anywhere in this theme — a
paragraph, a list item and a caption run the width of whatever holds them, which inside a split is
the column and on a full-width layout is the slide's own text inset. Split needed an escape hatch
for this while base.css still capped body copy at 20em; it does not any more, and the class is
gone. The beat layouts (cover, quote, statement) keep caps of their own, which are display type
being shaped rather than running text being made readable.
stack deliberately does NOT do this — its strip is the full canvas, so the cap does real work
there exactly as it does on default.
The gutter between the two columns is conditional on split.vue's own media prop: photo/panel
(a real figure facing text) get no gutter — the figure runs to the text; none (a second
content column, text meeting text) gets the 36px gutter, the same figure as the slide margin,
reused rather than restated.
Non-split slides: a heading needs breathing space
--deck-heading-top-space (40px, --deck-space-5) adds extra air above a top-anchored heading
(default.vue's align: 'start', the default) so it reads as set apart from the edge rather than
flush against it — on top of the standard 96px text inset, for 136px total. Centred layouts
(cover/divider/statement/quote/end) and split/bleed (which already own their own
vertical schemes) don't get it: none of them have the flush-top problem it solves.
Layouts
| Layout | Use |
| --- | --- |
| cover | The opener — light ground, one Mohr shout, the full lockup signing it |
| default | The workhorse — a bare content slide, plus the optional eyebrow → shout → content rhythm |
| 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 |
| statement | One sentence, the whole slide — a shout on the deck's own ground, one tier below a divider |
| quote | A pull quote — stays in GT Haptik, because it is someone else speaking |
| 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 |
| 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 |
| bleed | Full-bleed media with content laid over it — the only layout that escapes the slide's padding; optional ::overlay:: |
| end | The closer — signs off with the full brand lockup |
Where a photograph goes
image: in the frontmatter, and nothing in the slot. split, stack and bleed each take an
image: (the URL) and an alt: (what it shows), and render the <img> themselves:
---
layout: split
media: photo
image: 'https://images.example.com/a-street.jpg'
alt: A cyclist on a London street
---image is Slidev's own frontmatter key and means exactly this — its built-in image-right layout
takes the same one. Using it as Slidev does buys the preload for free: extractImagesUsage() reads
it and emits a <link rel=preload as=image>, which an <img> hand-written into a slot never gets.
Set neither and <Placeholder> stands in, carrying the alt: as its label.
The ::aside:: / ::band:: / ::media:: slots are still there, and still win when a slide
provides them — they are for a figure that is not a photograph (a <DottedMap>, a chart, a panel).
Never write an EMPTY slot marker to "leave the figure alone": an empty slot counts as provided, so
Vue renders it (nothing) instead of the layout's own figure, and the image silently disappears.
Every prop each layout takes — its type, default and enum — is generated, not written here: see
the manifest below. A hand-typed second copy of that table is
exactly the drift this README used to carry (split's and bleed's own prop lists went stale
more than once before the manifest existed to check them against).
Why nine, and not more
The set is closed on purpose, and Task 3 of this theme's build settled it: a small number of
structural shapes, never a new file for a new idea. The original spec named things like map, timeline,
dashboard and device as if each needed its own layout. None of them do — a "map" slide is
split with <DottedMap> in its ::aside:: slot, not a new file. New ideas become presets (a
layout plus fixed props plus a content shape, see below) instead of new layouts, which is what
keeps this file short enough to actually hold in your head, and keeps re-branding the deck a matter
of moving the token engine, not chasing however-many layout files.
Three columns, or a two-by-two, is not a missing layout. It is the question the closed set
invites, and the answer is not a tenth file. split is two regions by construction — a content
column beside a figure, with ratio dividing them and flip deciding which is which — so there is
no third track to ask for, and adding one would make both props mean something different on every
slide that used it. n equal cells is a different shape of problem with its own answer: <Grid>
(::grid), a COMPONENT that composes inside whichever of the nine you are already in.
::grid
```yaml [props]
:cols: 3
:fill: true
```
:::area-box
…one per cell…
:::
::cols is 1–4. fill: true makes the cells divide the frame; without it the grid sizes to its own
content, which is what you want for a stat row and not for an arrangement. The specimen book draws
both cases as areas, right after the nine frames.
stack is the one addition the rule allows, and the only one made: it is a STRUCTURE the other
eight cannot express, not an idea built on top of one. Everything else divides the canvas left/right
or takes all of it; nothing divided top from bottom. A 65-page Forest template catalogue uses that
arrangement nine times — tied for its most-used interior structure — and its own analysis
(.superpowers/sdd/2026-08-21-deck-layout-system/template-archetypes.md, §3) ranks it "the biggest
gap". If a tenth is ever proposed, this is the bar: name the structure the nine cannot draw.
The trap: a deleted or renamed layout does not go away
Five of these eight names — cover, default, end, quote, statement — are also the names of
Slidev's own stock layouts, shipped inside @slidev/client. Slidev resolves layout: <name> in
the order deck-local → theme → addon → its own built-ins, so as long as this theme's own file
exists, it wins. Delete or rename one of these five and nothing errors — the slide silently
falls through to Slidev's plain, unbranded version of the same name. It is still a valid slide (it
won't overflow, and scripts/deckAudit.ts will happily pass it) — it is just quietly off-brand,
and nothing at build time tells you. packages/slidev-theme/test/frontmatter.static.test.ts pins
the exact file list on disk for this reason; if you rename a layout, that test — and every
layout: reference to the old name across every deck — has to move with it, in the same commit.
Chrome is two files, slide-top.vue and slide-bottom.vue, each mounted PER SLIDE with the
route's frontmatter injected — not a global overlay switching on which layout is showing. That
shape is deliberate: the alternative (a switch keyed on layout name) would need updating every time
a layout is added, renamed, or a new one wants chrome suppressed on its own terms. Reading
frontmatter directly means a new layout never needs to be added anywhere to keep working, and a
slide can opt in or out of chrome regardless of which layout it uses.
Top-left: the page-number pill, always, plus the section pill — the nearest PRECEDING
dividerslide's title (or itssection:override), derived from the deck's own slide list. Three scopes turn it off, narrowest first:section: falseon a slide,section: falseon a divider (that divider and its whole run), andsectionPill: falsein headmatter (the deck) rather than typed onto each slide. Empty before the deck's first divider.Top-right: the Forest mark,
<Logo variant="mark">at running-chrome size.Bottom-left: the confidentiality pill, from
confidential: '<text>'in a slide's own frontmatter. Absent by default. To mark the whole deck rather than one slide, put it inside headmatter's owndefaults:block —defaults:is Slidev's own headmatter-to-every-slide mechanism, and this is what actually cascades:--- theme: '@humanforest/slidev-theme' defaults: confidential: 'Private & Confidential' ---A BARE
confidential:written directly in headmatter (outsidedefaults:) is only ever slide 1's own frontmatter — it marks the cover, nothing else. Measured live, both forms, because this is easy to get backwards: the theme reads whichever slide's own frontmatter it is handed and does no lookup of its own, so the deck-wide form is entirely on the author gettingdefaults:right.
Both files hide their page-number/section/mark row on slide 1 automatically (a cover carries no
page count worth reading and can never have a divider behind it), and any slide can opt out of
ALL of it with chrome: false in its own frontmatter:
---
layout: divider
chrome: false
---Every divider, quote, statement and bleed slide in the demo deck sets it — a beat, or full-bleed
media, is not the place for a page counter in the corner. The confidentiality pill is the one
exception: it reads only chrome, not the page-1 gate, because a real deck often wants that
watermark on its title slide too.
Co-branding, and opting out of the notch
A split's figure and a stack's band cut an inverted corner — a notch — for the running mark to sit in. Four frontmatter keys steer it, all read by injection (none is a layout prop):
| Key | What it does |
| --- | --- |
| 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 |
| companionName: 'Octopus Energy' | the companion mark's alt text |
| 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 |
| 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 |
| 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 |
---
layout: split
media: panel
companion: /octopus-energy.png
---A Forest+companion pair is 72 + 16 + 72 = 160px wide, and the narrowest ratio (3:1, a 489px figure)
has room for 208.5px of occupant — so there is no threshold to degrade at. scripts/deck.ts emits
that arithmetic as data and a test gates it, so pushing the mark past the budget goes red naming the
ratio rather than shipping a cramped notch.
Both pills route through <BadgeStrip> (below) rather than a second, hand-rolled fill/ink
implementation, so the pairing guarantee applies here too. They take the ACCENT pair rather than one
of the table's pinned halves, because a pinned fill is register-invariant by design — right for an
object sitting ON the deck, wrong for chrome that lands on whatever ground the slide gives it.
Pinned to forest-700, every dark panel ground failed the 3:1 floor beneath it: bark 1.09:1,
forest-900 1.77:1, neutral-800 2.33:1 — against 3.86, 6.28 and 8.28 once it followed the register.
Components (auto-imported)
<Logo>— the Forest identity in the four forms the brand ships: mark, wordmark, lockup, lockup-vertical. The mark carries its own ground; the wordmark is drawn as a mask filled withcurrentColorso it takes the slide's ink instead of the one baked into the file; the lockup composes the two so the mark keeps its fixed green while the ink follows.<Kpi>— one figure and how it moved. The arrow carries direction, the colour carries judgement, and only wheresentimenthas declared which way is better. Prop names matchFKpi, andresolveDelta()is imported from@humanforest/ui's source rather than restated. A figure that is already text rather than a number goes through themetricslot:<Kpi label="Riders"><template #metric>480k</template></Kpi>. (This replaces the retired<Metric>, whose entire body was that one line.)<Card>— the deck's one container: no fill at all (background: transparent, an edge in the card's own ink), a quiet fill one step away from the slide ground (background: surface), or one of the four named backgrounds;darkrenders the last two in the dark register.borderadds an edge —noneby default, orhairline/strong— always drawn in the card's own ink rather than a slide-derived token, so it reads wherever the card lands. Concentric corners, 48 → 16 → 8.<Grid>— the deck's one grid, so gutters stay a closed system decision rather than an author's arbitrary value. When anitemsarray is passed, the default slot renders once per item — a stat row, an icon row and a badge strip are all this component with different slot content.<LineChart><AreaChart><StackedBarChart><GroupedBarChart><DonutChart><ScatterChart>— the Unovis chart set, sized for a room. Real@unovis/vuemarks;<ChartFrame>carries the ceremony (the deck type ramp mapped onto Unovis'--vis-*properties, the legend, the empty state) andcomponents/chartData.tscarries the pure data shaping. Axis labels are 36px, not the library's 12px, because the deck is read from the back of a room. Colours come from--deck-series-1…5and stop at five — a chart handed a sixth series draws a message saying so rather than inventing a colour or repeating one.Direct
@unovis/vue+@unovis/ts, not@humanforest/charts: that package peer-depends on@nuxt/ui, bun auto-installs peer dependencies, and Nuxt UI is Tailwind — which does not coexist with the UnoCSS pipeline a deck runs on. Measured: adding it to this package's dependencies pulls Nuxt UI and Tailwind into the install tree (326 top-level entries / 306 MB, against 204 / 199 MB without). The pure functions it owns (fromCategories, the palette accessors) are re-implemented inchartData.tswith a comment naming them as the source of truth.<Bars>— a horizontal bar chart sized for a room: bars start at zero, coloured from the deck's own series roles. Still hand-drawn rather than an Unovis mark, because what it does — a direct value label at the end of every bar, no axis at all — is a different figure from<StackedBarChart>, not a worse version of one. Its fills resolve through the sameseriesVar()the Unovis charts use, so there is one place that knows what a series colour is.<DottedMap>— London as a dot grid, sampled from real borough geometry at build time. Offline, no token, no canvas to wait on.<BoroughShape>— one London borough as a styleable glyph: a single outline, framed to its own bounds, filled withcurrentColor. Same generated geometry the product's own borough shapes read, so a deck outline and a product outline are the same outline.sizesteps its HEIGHT (sm240 ·md400 ·lg640 ·xl880),mdbeing the natural size a slide that says nothing gets. The step lands as an inline custom property, not a class or an inlineheight: a scoped rule (.borough-shape[data-v-…]) outranks any UnoCSSh-*utility, so a class-based size was silently inert and the only way past it was!important. A custom property is not in the specificity contest at all.xlneeds a slide with no heading — 880 plus anh1clears the bottom of the canvas.<DataTable>— a table sized for a room: a ~104px row, a 30px uppercase header over an accent rule, and figures in the engine's own.type-tabulartreatment (which swaps the family to GT Haptik, because Mohr ships no tabular figure set — a column of Mohr numerals cannot line up, whatever CSS is applied).<Kpi>makes the opposite call for the same reason and both are right: one shout has nothing to align against, a column has nothing but. Formatting is shared —formatMetric(), the function<Kpi>formats with.Both axes have a limit, and they degrade differently on purpose. Rows truncate and say so on the slide ("Showing 5 of 12 rows — 7 not shown"), because a top-five table is a real figure. Columns REFUSE past five and draw a sentence instead, because dropping a column does not shorten a table, it changes what the table claims. The row numbers are measured, not derived: 5 fits under a heading, 7 needs a slide with no heading, and
scripts/deckAudit.tscaught the first pass 30px past the bottom inset.<StatCard>—<Kpi>'s figure inside<Card>'s framing.<IconTile>— a single icon on a fixed peach tile, drawn as a CSS mask read straight from the shared icon source (not ai-forest-*/i-lucide-*utility class, which can't be assembled from a runtime prop — UnoCSS never sees the resulting class name in source). Accepts both Forest and Lucide icons — see Icons below.<BadgeStrip>— an array-driven row of named-background tiles;itemsis plain strings, one tile per entry.<Placeholder>— "an image goes here", deliberately obvious rather than decorative, so a placeholder that looked like a real photo never accidentally ships. Drawn as inline SVG: no network, no raster asset, no licensing question, and identical dimensions on every run — which is what keepsscripts/deckAudit.tsmeaningful, since a real photo's intrinsic size varies and would make overflow results non-repeatable.
Every prop each component takes lives in the manifest, the same as layouts — not restated here.
The manifest and its presets
packages/slidev-theme/manifest/manifest.json is a generated, machine-readable description of
every layout, component and piece of running chrome this theme ships — every prop, its type,
default, enum and description; every slot — plus the preset vocabulary built on top of them. It is
committed and gated by a CI freshness check (rebuild and diff, the same idiom the layer and fonts
artefacts already use), so what's committed always matches a fresh regeneration:
bun run deck:manifest # regenerates packages/slidev-theme/manifest/manifest.jsonIt is the authoritative reference for "what props does this layout take, and what does each
one accept" — this README deliberately does not carry a second copy of that table. Two things feed
it: everything mechanically derivable is parsed straight from layouts/*.vue/components/*.vue
source (packages/slidev-theme/test/props.ts); the one thing no parser can infer — a short,
human-facing statement of what each thing is for — is hand-authored in
packages/slidev-theme/manifest/{layouts,components,chrome}.ts. The generator refuses to write the
file at all while any entry would be false or incomplete, rather than shipping a partial one.
A preset is a named, ready-to-use recipe built on top of one layout: a fixed prop assignment
plus editorial "use this / avoid this" guidance, one level above "here are eight raw layouts and
thirteen raw components, go compose something". They fall into three groups, in
packages/slidev-theme/manifest/presets.ts: one preset per layout at its plain default shape (so
the preset table alone is a complete entry point); a set of named content shapes recast onto one of
the real eight layouts — this is where map, timeline, dashboard and the rest of the original
spec's vocabulary actually live, each one a real prop assignment plus a described component
composition, never a ninth layout file; and a handful of aliases (a mirrored flip, a flipped background)
that are a data row, not new architecture. Every preset's props are validated against its own
layout's real, source-derived prop enum at generation time, so a preset can never reference a value
a layout doesn't actually accept.
scripts/deckSpecimen.ts (the 147-slide apps/deck) reads the manifest
directly to build that deck. Any other author of a Forest deck — human or AI — should read
manifest.json first, before this README: a missing entry there means "go look at the source"; a
wrong one in a hand-written prose copy would send an author confidently down a dead end.
Icons
uno.config.ts registers two icon collections:
Forest — the design system's own set, from
packages/ui/icons/forest, so a deck icon is the same artwork the product draws:<div class="i-forest-bike text-6xl" />Lucide — for everything Forest doesn't draw.
<IconTile icon="lucide:check">and slide content both acceptlucide:<name>. Only the names actually referenced anywhere in the repo (everyslides.md, this theme's own layouts/components, and the preset prose inmanifest/presets.ts) are extracted intopackages/slidev-theme/icons/lucide/*.svg— a small, committed, generated set, the same shapepackages/ui/icons/forestalready is — rather than bundling the full ~1,774-icon, 543 KB@iconify-json/lucidecollection into every deck's build. Regenerate with:bun run deck:iconsThis is also gated by a CI freshness check: an icon that stops being referenced anywhere is deleted from disk on the next run, not left stale next to the ones still in use, and a reference to a name that doesn't exist in Lucide's own
icons.jsonfails the generator rather than shipping a blank tile.
Auditing a deck
Every gate described so far is a text match over source — a prop parser, a coverage count, a
freshness diff. All of them can be green while a slide is actually broken: blank, overflowing the
canvas, or a component nesting inside another in a way that breaks contrast only once it is
actually rendered. scripts/deckAudit.ts is the one gate that renders every slide in a real browser
(Playwright) and checks the three things none of the others can see:
- Geometry — nothing escapes the 1920×1080 canvas, measured against the slide's own rendered frame rather than assumed from source.
- The webfont swap —
font-display: swapmeans the fallback face paints first and MohrAlt/GT Haptik are both measurably wider, so text can reflow once the real face loads. The audit measures a probe span's width and height before and after the swap, for both faces, and treats "the font never actually swapped during this run" as its own failure — not a silent pass with nothing to report. - Rendered contrast — every text-bearing element and every list-marker pseudo-element
(
::before/::marker) is scored against its ACTUAL composited ground: real computed colour, real opacity chain, real ancestor backgrounds — not a token pair checked in isolation. A token pair can be fine on its own and still fail once one component nests inside another at runtime; that is exactly the class of defect this sweep exists to catch, and has caught — both of its finds are on components since retired: a chip's text and list markers rendering at 1.00:1 on their own fill, and a callout at ~1.02:1 inside what was then the accent card. Invisible to every other gate here.
CI audits the built deck, slidev build output served as static files, never a live dev
server — a dev server's hot-reload state has previously caused this exact harness to under-report
its own slide count and silently skip one, which a built deck cannot do. The deck (apps/deck) is
audited on every push, in its own CI job so a failure there neither blocks nor hides the fast unit
suite, and vice versa.
A failure names the slide, the element and the measurement — e.g. slide 4: contrast FAIL
.deck-card strong — 1.02:1 (needs 4.5:1) — text #1a1a1a on #1f1f1f. Start there: it is a real
defect on that slide, not a flaky harness. --slides=<path> is required and not optional — the
audit derives its own expected slide count from that file's --- separators, so it never silently
trusts a stale count from anywhere else.
To run it by hand while iterating, against a dev server:
slidev --open false --port 3030 &
bun ../../scripts/deckAudit.ts --port=3030 --slides=slides.mdDebugging layout — the red-line overlay
Every gate above is a text match or a rendered measurement; none of them show you the SHAPE of a slide's spacing. The inset system means several different margins are live on any given slide (see "Spacing" above), and a token is invisible — nobody can see 100px versus 40px by looking at a finished slide. That invisibility is exactly how a real bottom-chrome overlap once survived a written comment asserting it was fine: the audit measures overflow past the canvas, not collision inside it, and nothing rendered the frame the comment described. This overlay does.
What it draws, on top of the current slide, in red hairlines:
- a dotted rectangle at
--deck-inset-chrome— where the corner furniture sits - a dashed rectangle at
--deck-inset-mediahorizontally /--deck-inset-textvertically — where a figure may reach - a solid rectangle at
--deck-inset-text— where text content is bounded - two filled hazard bands, top and bottom, at the real height of slide-top.vue's/ slide-bottom.vue's own pill row — collision with these is the specific failure this tool exists to catch
- on a
layout: splitslide only: a thick anchor line at--deck-anchor-third(y = 360) — where the text column's own content always starts — and a small text label stating the computed gutter for that slide (gutter 36px · same-kindorgutter 0 · mixed). The gutter's own on-screen WIDTH depends onratio/flipand is not drawn geometrically, deliberately: doing so would mean re-deriving split.vue's own grid-track arithmetic a second time inside the overlay, which could silently drift out of sync with the real one. The label states the computed value instead of a seam it hasn't actually measured.
Toggle it with the l key — no deck file to edit, no rebuild, works on any dev server or
built deck. It is a review instrument, not a debugging leftover: reach for it whenever you're
checking that a layout actually honours the frame it claims to, the same way you'd reach for
deckAudit.ts to check overflow.
It is off by default, always, and cannot appear in an export or a screen-share: the toggle is
an in-memory ref with no persistence (a fresh page load — exactly what slidev export's
Playwright run performs — always starts at false), and the overlay's own render condition is
additionally gated on Slidev's isPrintMode (true for the /export route and for any ?print
URL), independently of the toggle. Both guards have to agree for anything to paint. See
debug/layoutDebug.js and global-top.vue for the mechanism; test/deckInsets.static.test.ts
gates the off-by-default half in source, on every run.
Usage
In a deck's slides.md headmatter:
---
theme: '@humanforest/slidev-theme'
canvasWidth: 1920
---