npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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:

  1. Declares --font-display/sans/mono. These normally live in @humanforest/ui's forest.css, which the deck deliberately does not pull — it carries the whole Nuxt UI preset.
  2. Mirrors the two foundation contracts that forest.css owns for the web: the radius lever and the motion ladder. ⚠ These are a mirror, not an import. If forest.css moves a duration or the radius lever, this file moves with it.
  3. Maps --forest-* / --neutral-* ramps onto semantic --deck-* roles.
  4. 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 divider slide's title (or its section: override), derived from the deck's own slide list. Three scopes turn it off, narrowest first: section: false on a slide, section: false on a divider (that divider and its whole run), and sectionPill: false in 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 own defaults: 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 (outside defaults:) 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 getting defaults: 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 with currentColor so 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 where sentiment has declared which way is better. Prop names match FKpi, and resolveDelta() is imported from @humanforest/ui's source rather than restated. A figure that is already text rather than a number goes through the metric slot: <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; dark renders the last two in the dark register. border adds an edge — none by default, or hairline/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 an items array 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/vue marks; <ChartFrame> carries the ceremony (the deck type ramp mapped onto Unovis' --vis-* properties, the legend, the empty state) and components/chartData.ts carries 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…5 and 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 in chartData.ts with 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 same seriesVar() 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 with currentColor. Same generated geometry the product's own borough shapes read, so a deck outline and a product outline are the same outline. size steps its HEIGHT (sm 240 · md 400 · lg 640 · xl 880), md being the natural size a slide that says nothing gets. The step lands as an inline custom property, not a class or an inline height: a scoped rule (.borough-shape[data-v-…]) outranks any UnoCSS h-* 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. xl needs a slide with no heading — 880 plus an h1 clears 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-tabular treatment (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.ts caught 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 a i-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; items is 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 keeps scripts/deckAudit.ts meaningful, 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.json

It 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 accept lucide:<name>. Only the names actually referenced anywhere in the repo (every slides.md, this theme's own layouts/components, and the preset prose in manifest/presets.ts) are extracted into packages/slidev-theme/icons/lucide/*.svg — a small, committed, generated set, the same shape packages/ui/icons/forest already is — rather than bundling the full ~1,774-icon, 543 KB @iconify-json/lucide collection into every deck's build. Regenerate with:

    bun run deck:icons

    This 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.json fails 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 swapfont-display: swap means 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.md

Debugging 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-media horizontally / --deck-inset-text vertically — 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: split slide 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-kind or gutter 0 · mixed). The gutter's own on-screen WIDTH depends on ratio/flip and 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
---