@aotoki/slidev-theme-terraforming
v0.7.2
Published
[](https://www.npmjs.com/package/@aotoki/slidev-theme-terraforming)
Readme
Slidev Theme - Terraforming
A personal theme for Slidev by Aotokitsuruya.
Install
Add the following frontmatter to your slides.md. Start Slidev then it will prompt you to install the theme automatically.
Learn more about how to use a theme.
Canvas
The theme draws on a 1920×1080 canvas, matching the design file it is derived from. A deck can go back to another size from its own headmatter:
---
canvasWidth: 980
---The root font size is 16px, so a px measurement from the design file divided by 16 is
its rem value, and divided by 4 is its UnoCSS spacing number — 72px is p-18.
Mermaid sizes its diagrams from its own text metrics, so its base type is set to the
body step to keep a diagram proportionate to the canvas. An individual block can still
take {scale: n} when it needs to be larger or smaller than that.
Transition
Slides cross over with tf-fade, the theme's own transition. Only the content fades:
the sidebar is the same on every slide, so fading the whole page would dissolve it
into an identical copy of itself and put motion in the one part of the canvas that
never changes. It runs for --slidev-transition-duration, the same variable Slidev's
own transitions read.
A deck can name any of Slidev's builtins instead:
---
transition: slide-left
---Note that the builtins fade or slide the whole page, sidebar included.
Design Tokens
Tokens are defined in two layers in styles/tokens.css. The first layer names values
for what they are, using the Tailwind v4 @theme namespaces:
| Namespace | Tokens |
|----------------|----------------------------------------------------------------|
| --color-* | kon-500 gunJyo-500 tamago-500 jinZamOmi-500 gray-200 gray-500 white black |
| --text-* | title 64 · heading 48 · body 36 · sub 32 · note 16 |
| --leading-* | none 1 · heading 1.25 · body 1.5 |
| --font-* | sans mono |
| --tf-motion-*| travel 600ms · settle 500ms · touch 200ms · delay 300ms · stagger 100ms · ease-move · ease-fade · rise 20 |
| --spacing-* | line 5 · shadow 10 · sidebar 296 · slash 100 · block-w 270 · block-h 162 |
| --container-*| content 1380 |
Leading answers a question about the text rather than about its size. none is for a
line laid out as a single line, where the line box has to equal the type size for the
box around it to measure out; body is for anything that may wrap; heading sits
between and hands the extra back through a negative margin, so a one-line heading
still lands where the design file's 100% put it. Space between blocks is always a
margin, never leading.
The second layer names them for the role they play: --slidev-theme-primary,
--slidev-theme-secondary, --slidev-theme-accent, --slidev-theme-neutral,
--slidev-theme-text, --slidev-theme-surface, --slidev-theme-on-surface.
Both layers are wired into the UnoCSS theme, so slides can use them as utilities:
<div class="bg-gunJyo-500 text-white text-sub p-8">A block of 群青</div>
<p class="text-note text-neutral">A source link</p>The utilities emit var() references rather than literals, so overriding a token in a
deck's own style.css changes both the stylesheet and the utilities:
:root {
--color-gunJyo-500: #3a8fc4;
}Fonts
The sans stack lives in --font-sans and resolves to locally installed faces, so no
webfont is fetched for it:
Fetched from Google Fonts:
- JetBrains Mono for code
Bundled with the theme:
- Senobi Gothic for the name in
About
Loaded from a CDN when Mermaid is used:
- Excalifont and XiaolaiSC
Layouts
default— content starts at the top edgecoverintrosectionstatementcenter— centred vertically, text centreddiagram— centred on both axes, for a page whose content is the figure itself
Components
What the audience reads is written as children; what it does not — geometry, data, a resource, a presentational choice — is a prop. So a deck writes its content in the order it is read, and where each part lands is the theme's to decide.
About
The speaker introduction, written as the lines it is made of.
<About>
<Name>蒼時弦也</Name>
<Title>Software Architect</Title>
<Contact>https://blog.aotoki.me/</Contact>
<Contact>@elct9620</Contact>
</About>| Component | Props |
|-----------|-------|
| About | avatar, alt |
| Name | — |
| Title | — |
| Contact | — |
Write a Title for each role the talk gives the speaker and they are taken in turn, a
click apart — the opening that says what the speaker used to be. The page learns its
length from them, so there is no clicks: to write. All of them are laid out in the same
place, so the column stays as wide as the longest and neither it nor the portrait beside
it moves; the role fades rather than cuts.
<About>
<Name>蒼時弦也</Name>
<Title>Associate AI Engineer</Title>
<Title>AI Engineer</Title>
<Contact>https://blog.aotoki.me/</Contact>
</About>Each kind counts its own turns, so a Title that changes on the first click is a
sequence of its own — nothing about it decides when any other part of the introduction
may change.
avatar is a prop because a portrait is a resource the page is given rather than
something read off it. It carries no alt of its own: the name is written beside it, and
repeating it would have a screen reader say it twice. alt is there for the deck that
puts a face beside no name at all.
Diagram vocabulary
Every diagram is built from four ideas — a flat fill, a focus frame, a drawn stroke, and absence expressed as opacity. Nothing scales or bolds, so a figure can animate across clicks without the layout shifting underneath it.
| Component | Props |
|-----------|-------|
| Stage | column, gap, fit |
| Block | color (gunJyo | tamago | jinZamOmi | gray), name, sub, hidden |
| Group | name, column, gap, hidden |
| Stroke | dir (right | left | both | up | down | both-y | none), label, labels, name, flip, length, hidden |
| Focus | steps, of, color |
| Caption | — |
| Line | — |
Focus is the selection box. It is positioned absolutely and takes no space, so it
never disturbs what it frames, and it transitions its position and size rather than
fading — moving the focus reads as one rectangle travelling and resizing.
Name the pieces and let steps say what the frame holds at each click. Several names
give the box that contains them all, so neighbours read as a range; null frames
nothing, which is how the box waits offstage until the click it belongs to. There are
no coordinates and no indices, so inserting a block renumbers nothing.
<Stage>
<Block name="proxy" color="tamago">Proxy</Block>
<Stroke :label="$clicks === 1 ? 'call' : ''" :labels="['call', 'result']" />
<Block name="object" color="gunJyo">Object</Block>
<Focus :steps="['proxy', ['proxy', 'object']]" />
</Stage>
<Caption>The real <strong>object</strong> lives on the far side.</Caption>The list is what tells the slide how long it is — see Clicks — so the walk
through a diagram is written in one place instead of being split between a frontmatter
count and a chain of comparisons. Entries are addressed by absolute click number, so
steps[0] is the state before the first click.
A caption that says something new at each click writes a Line for each. They are taken
in turn a click apart, and all of them occupy one place, so the narration fades from one
to the next and the diagram above it never moves:
<Caption>
<Line>The real <strong>object</strong> lives on the far side.</Line>
<Line>Calling the proxy sends the request <strong>across the wire</strong>.</Line>
</Caption>of is the alternative, for a box driven from a $clicks expression of your own. It
takes the same names as one entry of steps and the page then has to declare its own
clicks. To frame something the stage does not own, leave both out and place the box by
hand — Slidev's v-drag can position it in the preview and write the result back into
the markdown:
<Focus v-drag="[420, 180, 660, 220]" />Stroke takes its length from the widest entry in labels, so changing or clearing
the label never resizes the line. Supply labels whenever the text changes across
clicks. It is drawn as SVG so its weight survives any transform the slide applies;
Slidev's own <Arrow> remains available for annotating a slide by coordinates.
Stage spaces its pieces with gap — the pieces keep the size the design file gives
them and the breathing room is what adapts. A row holds about four blocks before it
runs past the content width; past that, prefer column or a second page. fit is an
escape hatch that scales one stage down to fit, at the cost of that page's blocks no
longer matching the rest of the deck.
Charts
| Component | Props |
|-----------|-------|
| Bars | max, steps, active, reveal, log, axisStart, axisEnd |
| Bar | value, text, via, name |
| Axis | start, end |
| Map2D | xStart, xEnd, yStart, yEnd, steps, active, reveal |
| Point | x, y, name, tone (gunJyo | gray) |
Both charts are written as the marks on them. What a mark is of is its content; value
and x/y say where it goes, and the rest qualifies that one datum — text is the
figure to be read aloud, units included, and via is the annotation saying how it came
about, the second line a Block's sub is.
<Bars log axis-start="1 μs" axis-end="10 ms" :steps="[null, 'wasm']">
<Bar :value="1200" text="1.2 ms" via="fork">Process</Bar>
<Bar name="wasm" :value="12" text="12 μs" via="linear memory">WebAssembly</Bar>
</Bars>A bar has no length of its own — it is a share of the track, and no row can know its share until every other row has said how long it is. That is what the chart is for: it collects what its rows say and answers each of them, so a row added anywhere remeasures the lot.
The two ends of an axis are start and end. Where the axis is one part of a chart
rather than the whole of it, they say which axis: axisStart and axisEnd on Bars,
xStart and yStart on Map2D. They stay props because they name the space a chart is
read in rather than anything plotted in it.
A Map2D is written as the marks on it. x and y are relative positions from 0 to 100
and there are no ticks: the chart is an argument about where things sit in relation to
each other, and printed coordinates would be taken for measurements.
<Map2D x-start="Hard to use" x-end="Easy to use"
y-start="Weak isolation" y-end="Strong isolation"
:steps="[null, 'wasm']">
<Point :x="88" :y="4">eval</Point>
<Point name="wasm" :x="66" :y="80" tone="gunJyo">WebAssembly</Point>
</Map2D>Both charts take the same red frame as a block, and move it with steps the way a
Focus does — each entry naming the row or mark under discussion, null for none. A
Bar and a Point are named the way a Block is, so steps refers to one without
depending on what it says.
active is the alternative for a chart driven from a $clicks expression, taking the
row's or mark's place and -1 for none.
Bars accepts log for data spanning orders of magnitude; using it obliges you to
label both ends of the axis, since lengths on a log scale otherwise invite a linear
reading. Every row shares one set of columns through subgrid, so each text column ends up
as wide as the widest entry in it — long labels need nothing said about them, and the
annotation column takes no more than its own gap when no row carries a via.
Arrival
A figure's pieces land in the order they are read rather than all at once, so the
narration has a moment on each before the next one arrives. A Stage deals its
pieces out along the row; Bars and Map2D deal out their rows and points. A Group
arrives whole — it exists so that several pieces read as one thing.
Nothing is asked of the deck: the arrival plays when the slide is reached, and the first click ends it — asking for the next step says you are past being shown the slide, so whatever is still on its way is simply there. Export and print show every figure whole; nothing arrives on a page being captured on a timer.
The focus frame waits for the pieces it frames, since a frame around a space nothing has arrived in reads as a mistake rather than as emphasis.
reveal hands the pacing to the speaker instead, one row or point per click:
<Bars reveal>
<Bar :value="1200" text="1.2 ms">Process</Bar>
<Bar :value="220" text="220 μs">Container</Bar>
</Bars>The prop is on the charts alone because they own their pieces. A Stage's pieces are
written by the deck, so a deck paces those with v-click or hidden — see
Clicks.
Motion
How long a change takes is a question about the change rather than about what is changing, so there are three answers and every rule in the theme picks one. The lengths are Material 3's duration tokens rather than numbers chosen by eye; a slide is read from further away and with less urgency than an interface, so each takes the slower end of what M3 offers.
| Token | | |
|-------|--|--|
| --tf-motion-travel | 600ms · M3 long4 | something moving or resizing, which has ground to cover — the focus frame crossing a diagram, the red frame stepping down a chart |
| --tf-motion-settle | 500ms · M3 long2 | something arriving or leaving in place, which has none. Slidev's slide transition follows it |
| --tf-motion-touch | 200ms · M3 short4 | direct feedback to the pointer, where anything slower reads as lag |
| --tf-motion-delay | 300ms · M3 medium2 | how long a figure waits before arriving, so it does not run while the page is still fading in |
| --tf-motion-stagger | 100ms · M3 short2 | the wait between one arriving piece and the next |
| --tf-motion-rise | 20 | how far an arriving piece travels — set it to 0 for a plain fade |
There are two curves, because a curve describes how ground is covered and not every change covers any:
| Token | | |
|-------|--|--|
| --tf-motion-ease-move | M3 emphasized.decelerate | position, size, the rise a piece makes — off the mark quickly and then settling, so it reads as coming to rest where it belongs |
| --tf-motion-ease-fade | ease-in-out | opacity and colour, which have no ground to cover. The move curve spends a fade in a twentieth of the time it was given, so the duration stops meaning anything |
They reach UnoCSS too, so motion a deck adds itself keeps time with the theme's:
duration-travel, duration-settle, duration-touch, ease-move, ease-fade.
Clicks
Slidev works out how long a slide is from what registers with it — v-click,
v-clicks, v-switch, v-click-gap — while reading $clicks in an expression
registers nothing. Slidev's own v-motion reads clicks the same way, so this is the
division of labour rather than an oversight in it.
steps puts a component on the declaring side: giving one to a Focus, Bars or
Map2D is enough on its own, and a page whose only content is that figure will walk
through every entry before moving on.
Anything driven from a $clicks expression — of, active, hidden, a Stroke's
label — only reads, so a page built that way has to say how long it is. Often a
v-switch in the narration already covers it; otherwise the frontmatter says so:
---
clicks: 3
---That count overrides every registration on the page rather than adding to it, so a
slide already carrying steps should leave it out — declaring both cuts the slide to
whatever the frontmatter says.
Contributing
pnpm installpnpm devto previewexample.md, which exercises every layout and componentpnpm testfor the click and arrival protocols,pnpm test:watchwhile working on thempnpm test:deckto walkexample.mdthrough Slidev in a real browserpnpm typecheckfor the theme's own filespnpm lint, orpnpm lint:fixto have the style applied for youpnpm exportto generate the preview PDFpnpm screenshotto generate the preview PNG
What a test can answer here stops short of what a slide looks like. The unit suite covers
what a component decides — the length it declares, whether it thinks it is arriving, where
it puts its frame — and the deck suite covers what Slidev makes of that, by walking
example.md in a browser and asking how long each page turned out to be. How a rule
cascades and how long a transition takes are still read off a running deck.
package.json lists what ships. A new file at the top level that Slidev is meant to load
has to be added there, or it will be missing from the published package while working
perfectly in this repository.
