@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
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// A preset is a named, ready-to-use RECIPE built on top of one layout: a fixed prop assignment
|
|
2
|
+
// plus editorial guidance for when to reach for it and when not to — the vocabulary Task 8's
|
|
3
|
+
// showcase generator (and any other AI author) picks from, one level above "here are eight raw
|
|
4
|
+
// layouts and thirteen raw components, go compose something".
|
|
5
|
+
//
|
|
6
|
+
// Task 6 owns the SHAPE — the `Preset` type and the validation `scripts/deckManifest.ts` runs
|
|
7
|
+
// every row through. This file owns the CONTENT — the 46 rows below.
|
|
8
|
+
//
|
|
9
|
+
// THE REAL SURFACE, not the spec's original prose: the spec (docs/superpowers/specs/
|
|
10
|
+
// 2026-08-20-deck-layout-system-design.md §4) names `map`, `chart`, `device`, `timeline` and
|
|
11
|
+
// others as if dedicated layouts existed for them. They do not, and never will — the layout set is
|
|
12
|
+
// closed at nine (`bleed cover default divider end quote split stack statement`), and every row
|
|
13
|
+
// below is written against exactly those nine. A preset is a LAYOUT plus PROPS plus a CONTENT
|
|
14
|
+
// SHAPE, never a new layout — `map` is `split` with a `::dotted-map` in its own `aside` slot, not a
|
|
15
|
+
// tenth file.
|
|
16
|
+
//
|
|
17
|
+
// COMPONENTS ARE NAMED IN COMARK FORM (`::stat-card`, `::grouped-bar-chart`), because that is how
|
|
18
|
+
// the decks are authored. Two rules an author copying from this prose depends on: a slot marker
|
|
19
|
+
// carries colons on BOTH sides (`::aside::`), and in a props block every NON-STRING value is
|
|
20
|
+
// colon-prefixed (`:data:`, `:current:`, `:overlaid:`) while string props go bare
|
|
21
|
+
// (`y-label: 'Trips'`). Drop that prefix and the value arrives as a string — an array plots
|
|
22
|
+
// nothing, and a `false` reads as truthy.
|
|
23
|
+
//
|
|
24
|
+
// FIVE GROUPS, in the order they appear below:
|
|
25
|
+
// - the STRUCTURAL NINE (rows 1-9) — one preset per layout, named for that layout, at its
|
|
26
|
+
// plain default shape. These exist so the manifest's preset table is a complete, standalone
|
|
27
|
+
// entry point on its own: an AI author who only ever reads `presets` (never `layouts`) can
|
|
28
|
+
// still reach every one of the nine, not just the idioms built on top of them.
|
|
29
|
+
// - SEVENTEEN CONTENT SHAPES (rows 10-26) — the spec's own named list (`agenda`, `metrics`,
|
|
30
|
+
// `stat-hero`, `timeline`, `steps`, `roadmap`, `team`, `logos`, `pricing`, `map`, `chart`,
|
|
31
|
+
// `dashboard`, `device`, `testimonial`, `checklist`, `contact`), each recast onto one of the
|
|
32
|
+
// real nine layouts with a real prop assignment and a component composition described in
|
|
33
|
+
// `fill`, plus `tags` (`BadgeStrip` is live — it is what the running chrome's confidentiality
|
|
34
|
+
// pill is built from — and had no preset naming it at all).
|
|
35
|
+
// - SIX ALIASES (rows 27-32) — the spec's own "`kpis-left`-style aliases are legitimate here — a
|
|
36
|
+
// row, zero new architecture, deletable" idea, applied for real: a mirrored `flip`, a flipped
|
|
37
|
+
// `background`, a re-weighted `ratio`. Each one names the preset it is an alias OF.
|
|
38
|
+
// - NINE MARKS AND STATES (rows 33-41) — the content shapes the charting, table, map and status
|
|
39
|
+
// components carry, added when those components shipped without a recipe naming them.
|
|
40
|
+
// - THREE MOVEMENT AND TIME SHAPES (rows 43-45) — `flow`, `schedule` and `seasonality`, for the
|
|
41
|
+
// three arguments the marks above genuinely cannot make: a quantity MOVING, a subject read
|
|
42
|
+
// ACROSS time, and a year read at once as intensity.
|
|
43
|
+
// - TWO LATER ARRIVALS that belong to no group above: `notice` (row 42), a statement carrying a
|
|
44
|
+
// state, and `layout-areas` (row 46), a frame shown as its own named regions. Named here rather
|
|
45
|
+
// than folded into a neighbouring group, because a group whose count no longer matches its
|
|
46
|
+
// members is how this header came to say 44 while the file held 46.
|
|
47
|
+
//
|
|
48
|
+
// ★ A ROW'S PROPS SAY WHAT IT CHANGES ABOUT ITS LAYOUT, AND NOTHING ELSE. A prop set to the value
|
|
49
|
+
// the layout already defaults to renders identically with the line deleted, and reads to anyone
|
|
50
|
+
// following the recipe as though it were part of it. Twelve such lines were removed across
|
|
51
|
+
// eleven rows (`flip: false`, `ratio: '1:1'`, `frame: 'figure'`, `background: 'accent'`); the empty
|
|
52
|
+
// `props: {}` several rows now carry is the honest shape for "this preset is its layout, plainly".
|
|
53
|
+
// manifest.static.test.ts fails on a new one.
|
|
54
|
+
//
|
|
55
|
+
// ONE ROW PER CONTENT SHAPE. Where two marks differ only in the CLAIM they make, they share a row
|
|
56
|
+
// and `fill` states the switch: `trend` carries both `::line-chart` and `::area-chart`,
|
|
57
|
+
// `comparison` carries grouped, stacked AND composed bars; `share` carries the donut, the treemap
|
|
58
|
+
// and the nested donut; `flow` carries the sankey and the chord. Splitting those into
|
|
59
|
+
// a row per component would make the table a component index with prose attached — and seventeen
|
|
60
|
+
// of these rows are already `default` with empty props, which is as far as that should ever go.
|
|
61
|
+
//
|
|
62
|
+
// `role` here is NOT the layout's own `role` (packages/slidev-theme/manifest/layouts.ts) restated
|
|
63
|
+
// — it is the preset's own reason to exist, e.g. "one hard number with a verdict attached" versus
|
|
64
|
+
// the underlying `default` layout's own "the workhorse" role. A preset that only restated its
|
|
65
|
+
// layout's role would carry no information a manifest reader couldn't already get from `layout`.
|
|
66
|
+
//
|
|
67
|
+
// EVERY ROW CARRIES A REAL `avoid`, not a generic caution: the trade-off names a specific
|
|
68
|
+
// neighbour preset (or, where the neighbour is a raw layout/component rather than another preset,
|
|
69
|
+
// that) and says what goes wrong if you reach for this one instead. "Avoid for dense data" tells
|
|
70
|
+
// an author nothing they can act on; "avoid when the figures need comparing — `metrics` puts them
|
|
71
|
+
// on one baseline, `stat-hero` does not" tells them exactly which door to try next.
|
|
72
|
+
//
|
|
73
|
+
// Every `props` value below is checked against its own layout's real, source-derived enum by
|
|
74
|
+
// `validatePreset` (below) at generation time — see packages/slidev-theme/test/
|
|
75
|
+
// manifest.static.test.ts for the proof that check actually rejects an out-of-enum value, not
|
|
76
|
+
// just an unknown prop name.
|
|
77
|
+
export type Preset = {
|
|
78
|
+
/** Short, stable identifier — kebab-case, unique across every preset. */
|
|
79
|
+
name: string;
|
|
80
|
+
/** This preset's own reason to exist — see the file doc comment above for why it must not just
|
|
81
|
+
* restate its `layout`'s role. */
|
|
82
|
+
role: string;
|
|
83
|
+
/** Which Forest layout (packages/slidev-theme/layouts/*.vue, by filename without `.vue`) this
|
|
84
|
+
* preset builds on. Validated against LAYOUT_ROLES' own keys — see validatePreset below. */
|
|
85
|
+
layout: string;
|
|
86
|
+
/** Frontmatter/prop values this preset fixes. Every key is validated against the layout's own
|
|
87
|
+
* prop names (from scripts/deckManifest.ts's source-derived layout entries) — a preset can never
|
|
88
|
+
* silently reference a prop that doesn't exist, or one a rename has already dropped. */
|
|
89
|
+
props: Record<string, string | number | boolean>;
|
|
90
|
+
/** What the author still has to supply — the slide's own content, in prose. */
|
|
91
|
+
fill: string;
|
|
92
|
+
/** When to reach for this preset. */
|
|
93
|
+
use: string;
|
|
94
|
+
/** When NOT to — the companion to `use`, so an AI author can tell "this is close but wrong"
|
|
95
|
+
* apart from "this is the one". */
|
|
96
|
+
avoid: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const PRESETS: Preset[] = [
|
|
100
|
+
// ---------------------------------------------------------------------------------------------
|
|
101
|
+
// The structural nine — one preset per layout, at its plain default shape.
|
|
102
|
+
// ---------------------------------------------------------------------------------------------
|
|
103
|
+
{
|
|
104
|
+
name: 'bleed',
|
|
105
|
+
role: 'A single photo or piece of media filling the whole frame, with a short claim laid over it.',
|
|
106
|
+
layout: 'bleed',
|
|
107
|
+
props: {},
|
|
108
|
+
fill: 'The claim as the default slot content — a `::card` on a named background (`background: \'vivid\'`, `dark: true`) when it has to stay legible over busy artwork, its solid fill doing what a scrim cannot promise on every photo — plus a real photo or video via the `frame` slot; the built-in placeholder stands in until art lands.',
|
|
109
|
+
use: 'A full-bleed photo moment — a section-opening image, a hero shot before the deck moves into detail.',
|
|
110
|
+
avoid: 'A slide that also needs a figure AND real body copy side by side — `split` keeps both legible; `bleed` only has room for a short over-image claim before the scrim stops doing its job.',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'cover',
|
|
114
|
+
role: 'The deck\'s own opener — one shout, signed with the full lockup, exactly once per deck.',
|
|
115
|
+
layout: 'cover',
|
|
116
|
+
props: {},
|
|
117
|
+
fill: 'The deck or talk title as the default slot content (an `<h1>`), `eyebrow` for what kind of thing this is, `meta` for presenter and date.',
|
|
118
|
+
use: 'The very first slide of a deck.',
|
|
119
|
+
avoid: 'A section opener partway through the deck — `divider` is the punctuation mark for that; reaching for `cover` mid-deck plants a second, competing "start" and undercuts the lockup\'s own signal that this is THE opening.',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'default',
|
|
123
|
+
role: 'The bare content slide — body copy, a list, or one component filling the frame, nothing else.',
|
|
124
|
+
layout: 'default',
|
|
125
|
+
props: {},
|
|
126
|
+
fill: 'Whatever the slide is actually about: prose, a bullet list, or a single component (`::grid`, `::bars`, `::dotted-map`) as the default slot content.',
|
|
127
|
+
use: 'Any slide whose content does not need a figure beside it, a full-bleed image, or one of the deck\'s punctuation registers — the fallback when no other layout\'s shape fits.',
|
|
128
|
+
avoid: 'Content that pairs naturally with a figure — a screenshot, a photo, a map — `split` keeps the two side by side instead of stacking a `::placeholder` under a paragraph.',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'divider',
|
|
132
|
+
role: 'The deck\'s own punctuation — the deepest ground it publishes, announcing a new part.',
|
|
133
|
+
layout: 'divider',
|
|
134
|
+
/* No props: `rank: section` is the layout's own default, and a preset that restates a default
|
|
135
|
+
is no different thing at all — the gate in manifest.static.test.ts names divider's old
|
|
136
|
+
`background: accent` as exactly this. The rank is stated in the role instead, where a reader
|
|
137
|
+
sees it. */
|
|
138
|
+
props: {},
|
|
139
|
+
fill: 'The part title as the default slot content, and a standfirst under it — one line saying what the part is FOR, which `rank: section` requires. No kicker number; the rank carries the weight.',
|
|
140
|
+
use: 'The start of a new act in the deck — the loud cut between sections.',
|
|
141
|
+
avoid: 'A pivot INSIDE a section — a before/after, a problem statement — where the loud green field reads as a new chapter rather than a turn; `pivot` (the dark-register alias of this same layout) is the cold cut for that.',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'end',
|
|
145
|
+
role: 'The deck\'s own sign-off — the full brand lockup, and nothing competing with it.',
|
|
146
|
+
layout: 'end',
|
|
147
|
+
props: {},
|
|
148
|
+
fill: 'A closing line as the default slot content — "Thank you", a single takeaway, or nothing at all beyond the lockup.',
|
|
149
|
+
use: 'The very last slide of the deck, exactly once.',
|
|
150
|
+
avoid: 'A last slide that also needs to leave contact details or a call to action — `contact` is this same layout with that content shape already worked out; reaching for plain `end` there means the audience leaves with no way to follow up.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'quote',
|
|
154
|
+
role: 'A pull quote in the deck\'s own sans voice — somebody else speaking, not the brand shouting.',
|
|
155
|
+
layout: 'quote',
|
|
156
|
+
props: {},
|
|
157
|
+
fill: 'The quoted sentence as the default slot content, `attribution` for who said it, `source` for their role or company.',
|
|
158
|
+
use: 'A short, quotable line worth setting apart from body copy — a value statement, an internal quote, a line worth lingering on.',
|
|
159
|
+
avoid: 'A quote whose whole point is that a named customer said it — `testimonial` is this same layout with that attribution expectation built into its own guidance; leaving `attribution`/`source` empty on a customer-facing quote makes it read as the deck\'s own voice, which undercuts the proof it is meant to carry.',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'split',
|
|
163
|
+
role: 'The other workhorse — body copy beside a figure, at whichever share of the row the figure earns.',
|
|
164
|
+
layout: 'split',
|
|
165
|
+
props: {},
|
|
166
|
+
fill: 'Heading and body copy as the default slot content; the figure side is filled via the `aside` slot in every case — `frame` sets how that side FRAMES whatever `aside` holds, not only what an unfilled slot falls back to: `photo` clips it to the card radius with `object-fit: cover` (a real `<img>`/`<video>`/`<picture>`, or — left empty — a `::placeholder` mock-up at the same shape); `panel` mats it inside a tinted box, the media itself taking the concentric inner radius; `none` applies no treatment at all, so a caller supplying their own already-styled figure keeps exactly what they wrote. Leaving `aside` empty on `frame: panel` renders a blank tinted box across half the row.',
|
|
167
|
+
use: 'Any slide pairing prose with a photo, a diagram, or a second block of content — the general-purpose two-up.',
|
|
168
|
+
avoid: 'Three or more things needing equal weight in a row — `default` plus a `::grid` handles up to four columns; beyond that, split the content across two slides rather than crowding a fifth item into `split`\'s two sides.',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
// task-18 — the ninth structural preset, for the ninth layout. The group above is documented as
|
|
172
|
+
// "one preset per layout, named for that layout" so an author who only ever reads `presets` can
|
|
173
|
+
// still reach every layout; a layout landing without its row would silently break that promise
|
|
174
|
+
// for the one arrangement the reference template uses most.
|
|
175
|
+
name: 'stack',
|
|
176
|
+
role: 'The inverted two-up — a hero band across the top carrying the content that matters, the title subordinated to a strip beneath it.',
|
|
177
|
+
layout: 'stack',
|
|
178
|
+
props: {},
|
|
179
|
+
fill: 'The hero content via the `band` slot — a `::grid` of `::card`/`::stat-card`, a row of `::icon-tile`s, a chart, a table; heading and body copy as the default slot content, in the strip below. `frame` frames the band the way it frames `split`\'s figure: `panel` tints it (the register set by `background` — `soft` is the mint the reference template uses on every one of its nine two-band pages), `photo` clips it to the card radius, `none` leaves the author\'s own block untreated and un-notched. `ratio` is strip:band, so the default `1:2` gives the band two thirds of the height.',
|
|
180
|
+
use: 'A slide whose CONTENT is the point and whose title is a caption — numbered or icon cards, a pricing table, a milestone row, a set of figures that has to be read before the sentence explaining it.',
|
|
181
|
+
avoid: 'A slide whose heading leads and whose figure supports it — that is `split`, and using `stack` for it inverts the reading order the audience expects. A band holding one photograph with a title under it is `split` with `frame: photo` too; the band earns its full width only when it holds several things.',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'statement',
|
|
185
|
+
role: 'One sentence, the whole slide — a shout one tier below a section divider.',
|
|
186
|
+
layout: 'statement',
|
|
187
|
+
props: {},
|
|
188
|
+
fill: 'The one claim as the default slot content (an `<h1>`), with `**strong**` marking the word that carries it.',
|
|
189
|
+
use: 'A single, standalone claim that needs to land on its own — a stat framed as a sentence, a thesis statement.',
|
|
190
|
+
avoid: 'A claim that IS a bare number — `stat-hero` puts the figure through `::kpi`\'s own numeral treatment, which a hand-typed number in a heading never gets.',
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------------------------
|
|
194
|
+
// Sixteen content shapes — the spec's own named list, recast onto the real eight layouts.
|
|
195
|
+
// ---------------------------------------------------------------------------------------------
|
|
196
|
+
{
|
|
197
|
+
name: 'agenda',
|
|
198
|
+
role: 'What the deck covers, and in what order, before it starts covering it.',
|
|
199
|
+
layout: 'default',
|
|
200
|
+
props: {},
|
|
201
|
+
fill: 'A numbered or bulleted list of the topics ahead, one line each — plain markdown, or a single-column `::grid` of short phrases. `eyebrow` usually reads "Agenda".',
|
|
202
|
+
use: 'An orientation slide near the start of a talk, setting expectations for what is coming.',
|
|
203
|
+
avoid: 'A forward-looking plan with dates or phases attached — `roadmap` carries that time dimension; `agenda` is only the list of topics for THIS talk, with no timeline implied.',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'metrics',
|
|
207
|
+
role: 'Several figures on one baseline, read as a set.',
|
|
208
|
+
layout: 'default',
|
|
209
|
+
props: {},
|
|
210
|
+
fill: 'A `::grid` (cols 3 or 4) of `::stat-card` tiles, each the same shape — figure, label, optional delta — so the row reads as one comparison.',
|
|
211
|
+
use: 'Reporting several related numbers at once — a quarterly scorecard, a set of KPIs that need to be read against each other.',
|
|
212
|
+
avoid: 'One number that matters more than the others — `stat-hero` gives a single figure the whole frame and a verdict; putting it in a `metrics` row flattens it to the same size as everything beside it.',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'stat-hero',
|
|
216
|
+
role: 'One hard number with a verdict attached, not a list of many.',
|
|
217
|
+
layout: 'default',
|
|
218
|
+
props: { align: 'center' },
|
|
219
|
+
fill: 'A single `::kpi` (or `::stat-card`) centred in the frame — figure and label, plus `delta` if the number has moved (the row only appears once `delta` itself is set; `sentiment` alone colours nothing — it decides the ARROW\'s colour once a delta already exists).',
|
|
220
|
+
use: 'The one number the whole slide exists to land — a headline result, the number the rest of the talk explains.',
|
|
221
|
+
avoid: 'More than one figure worth comparing — `metrics` puts several `::stat-card` tiles on one baseline; a second figure dropped beside this one competes for the single-number attention this preset is built to give.',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'timeline',
|
|
225
|
+
role: 'A history read whole — every stop already behind us, the accent tracing how far the deck has come.',
|
|
226
|
+
layout: 'default',
|
|
227
|
+
props: {},
|
|
228
|
+
fill: 'A `::timeline` as the slide\'s own content: `orientation: \'horizontal\'` (time runs left to right, and four or five stops is the ceiling before a title stops fitting at deck type sizes), `:items` a list of `{ label, title, body? }` with `label` the WHEN — a year, a quarter, a month — and `title` the WHAT. `:current` set to the ITEM COUNT, so every stop draws as done: a history has a distance travelled and no present. `eyebrow` carries the kicker above it.',
|
|
229
|
+
use: 'The backstory a claim rests on — launches, funding rounds, the years behind a number — where the audience should take the whole sequence in at one look.',
|
|
230
|
+
avoid: 'Phases that have not happened yet: `roadmap` is this same `::timeline` with `:current` pointing at the phase in flight, so the stops ahead stay open. A history\'s fully-filled rule would draw next year exactly like last year, which is the one distinction the sequence already knows.',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'steps',
|
|
234
|
+
role: 'A procedure walked at the presenter\'s pace — one instruction on screen at a time, and the room told how many are left.',
|
|
235
|
+
layout: 'default',
|
|
236
|
+
props: {},
|
|
237
|
+
fill: 'A `::carousel`: `:items` a list of `{ title, body? }` in order, `title` the imperative (\'Scan the QR\'), `body` the detail. Leave `at` at its `\'+1\'` default — it already means "after whatever came before me", so the cards fall in behind any earlier `v-click` on the slide; pass an absolute number only to pin the first card to a specific click. N items spend N-1 clicks, because the first card is already showing when the slide arrives — and the stage is the height of the tallest card, so the heading above it does not jump as the presenter advances.',
|
|
238
|
+
use: 'An onboarding flow, a rider journey, a how-it-works — a sequence the presenter narrates one beat at a time, and wants the room arriving at each beat with them.',
|
|
239
|
+
avoid: 'A sequence the audience has to hold whole: `roadmap` keeps every stop on one rule and `checklist` keeps every item on screen, where a carousel hides all but one card by design. And a set with no order at all — the position indicator promises a progression, and reading \'2 of 5\' on peers that could be shown in any sequence is a claim the content does not support.',
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'contents',
|
|
243
|
+
role: 'Where everything is, in a deck long enough that a reader cannot hold it — derived from the dividers rather than written out.',
|
|
244
|
+
layout: 'default',
|
|
245
|
+
props: {},
|
|
246
|
+
fill: 'A `::contents`, and nothing else — it takes no props and no content. It reads the deck it sits in: every `divider` slide with a title becomes a row, `rank: section` at the top level and `rank: subsection` indented under the section above it. Nothing needs listing by hand, so the page cannot fall out of step with the deck the way a written one does the first time a section moves. A divider that is a specimen of the layout rather than a real opener sets `hideInToc: true` to stay out.',
|
|
247
|
+
use: 'A long reference deck — a specimen book, a handbook, an all-hands with eight parts — where the audience needs to know how much is left and the presenter needs a number to jump to.',
|
|
248
|
+
avoid: 'A deck short enough to hold in the head: under about twenty slides the page spends a whole slide telling the room what the next two minutes already will, and an `agenda` covers that case in prose the presenter actually speaks. And a deck with no `divider` slides at all — `::contents` derives its rows from them, so it renders empty rather than wrong, which is the right failure but still a blank slide.',
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'roadmap',
|
|
252
|
+
role: 'Where a plan actually stands — what is done, what is in flight, what is still open, in one picture.',
|
|
253
|
+
layout: 'default',
|
|
254
|
+
props: {},
|
|
255
|
+
fill: 'A `::timeline` with `orientation: \'vertical\'` — each phase carries a `body` line and vertical is what gives it the full measure (four stops with a body is the measured ceiling, five without) — `:items` of `{ label, title, body }` with `label` the quarter or milestone, and `:current` set to the index of the phase in flight. Every stop before it fills with the accent; the ones after stay open.',
|
|
256
|
+
use: 'A quarter-by-quarter plan whose whole point is how far along it already is.',
|
|
257
|
+
avoid: 'A finished history — `timeline` sets `:current` to the item count and fills the entire rule, the one picture a roadmap must not draw. And a plan nobody has started: leave `:current` at its default rather than pointing it at stop 0, which claims the first phase is already under way.',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: 'team',
|
|
261
|
+
role: 'A row of people, not one figure or one photo.',
|
|
262
|
+
layout: 'default',
|
|
263
|
+
props: {},
|
|
264
|
+
fill: 'A `::grid` (cols 3 or 4) of `::avatar`, one per person, each with `image`, `name` and `role` — the component draws the circle, the ring and the two lines of caption, so the grid item is the person and nothing else. Leave `size` at its `md` default for three or four across and step down to `sm` for a row of six or more; a face smaller than that stops being recognisable at the back of a room, which is the only reason to show it. An `::avatar` with no `image` falls back to that person\'s initials, so a team whose photographs have not all arrived still draws as one row rather than as faces beside empty circles.',
|
|
265
|
+
use: 'Introducing several people at once — the people behind the work, a panel, a founding team, a month\'s new starters.',
|
|
266
|
+
avoid: 'One person who needs the full frame — a single hire, a keynote speaker: `introduction` gives them the slide at `size: \'lg\'`, where a `team` grid cell buries them among tiles the same size. And a row of ROLES rather than of people — four `::avatar` with names nobody in the room will recognise is a headcount chart drawn as portraits; a `::grid` of `::card` says the same thing without promising the faces matter.',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'introduction',
|
|
270
|
+
role: 'One person, given the whole slide — their face is the content, not an illustration beside it.',
|
|
271
|
+
layout: 'statement',
|
|
272
|
+
props: { align: 'center' },
|
|
273
|
+
fill: 'A single `::avatar` at `size: \'lg\'` with `image`, `name` and `role`, and nothing else in the slot — the caption the component already draws IS the introduction, so a heading above it would only print the same name twice. `align: \'center\'` is the preset\'s own, because `statement` is ragged-left by default and a lone circle hanging off the left edge reads as a missing column. The context line — \'New this month\', \'Speaking next\' — goes in the layout\'s `eyebrow`, which is the one other thing this layout draws.',
|
|
274
|
+
use: 'A person the room is about to hear from or work with — a new starter announced by name, the speaker taking the next section, the author of the quote on the slide before.',
|
|
275
|
+
avoid: 'Several people: `team` puts them in a `::grid` at one size, which is the honest picture of a group. Running `introduction` two slides in a row to introduce two people gives the second one a whole slide to say what one row of a team grid says better, and the audience reads the pair as a ranking.',
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: 'head-count',
|
|
279
|
+
role: 'How many people, not which people — the faces as one object beside the number they add up to.',
|
|
280
|
+
layout: 'default',
|
|
281
|
+
props: { align: 'body-center' },
|
|
282
|
+
fill: 'A heading carrying the figure, and one `::avatar-group` under it: `:people` a list of `{ image, name }` and nothing else on the slide. No names are drawn — `name` is the alt text — so the row reads as a count rather than as a roster, and `overlap` decides how much of one: `snug` for a long row that still reads face by face, the `base` default for a clear group, `tight` where the row is a texture and the number is the only reading. Leave `size` at its `sm` default unless the row is short.',
|
|
283
|
+
use: 'A figure that is made of people — headcount, everyone who shipped it, the size of a community — where the slide\'s claim is the number and the faces are what makes it land.',
|
|
284
|
+
avoid: 'Introducing anybody: nothing is captioned here, so a face nobody recognises carries no information at all. `team` is the recipe that names people, and `introduction` the one that gives a single person the slide. And a count small enough to list — under about six, the overlap has nothing to buy and a `team` grid says the same thing with names on it.',
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'logos',
|
|
288
|
+
role: 'A row of marks that are not this brand\'s own.',
|
|
289
|
+
layout: 'default',
|
|
290
|
+
props: {},
|
|
291
|
+
fill: 'A `::grid` (cols 4) of `::placeholder` with `ratio: \'16:9\'` or `"4:3"` tiles, each standing in for a partner, customer, or press logo, swapped for real artwork later.',
|
|
292
|
+
use: 'Proof-of-adoption slides — who uses, backs, or covers this — where the logos ARE the content and need no further commentary.',
|
|
293
|
+
avoid: 'This brand\'s own identity — `::logo` is a component with its own set of forms for exactly that; `logos` is the recipe for THIRD-PARTY marks laid out as a grid, never for Forest\'s own.',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: 'pricing',
|
|
297
|
+
role: 'Two to four packages, compared side by side.',
|
|
298
|
+
layout: 'split',
|
|
299
|
+
props: { frame: 'content' },
|
|
300
|
+
fill: 'A `::grid` of pricing tiers in the default slot content, continuing into the `aside` slot for a wider comparison — 2 to 4 tiers total.',
|
|
301
|
+
use: 'A package or plan comparison — the tiers ARE the slide, nothing else competes with them.',
|
|
302
|
+
avoid: 'More than four tiers — a `::grid` that wide stops reading as a comparison and starts reading as a table; use a real table (or split the comparison across two slides) instead of forcing a fifth tile into this preset\'s two-column frame.',
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: 'map',
|
|
306
|
+
role: 'A geographic claim the map itself carries — the figure takes the larger share of the row and the copy only explains it.',
|
|
307
|
+
layout: 'split',
|
|
308
|
+
props: { ratio: '2:3' },
|
|
309
|
+
fill: 'Heading and supporting copy in the default slot content; the map in the `aside` slot, at or near its default `:width: 900` — the figure column is three fifths of the row, about 1080px on the 1920 canvas, so 900 sits inside it. Two marks, and the slide\'s own sentence picks between them: `::london-map` when the copy NAMES boroughs — `:highlight` takes their names or ONS codes (\'Southwark\', \'E09000028\', matched case-insensitively) and lifts each one into the accent, and `label` announces the figure; `::dotted-map` when the slide means London as a whole, or is pinning something that is not a borough at all — its `:highlight` takes `{ lng, lat }` or grid `[col, row]` and nothing else, and it is the only one of the two that draws the Thames. `highlight` needs the `:` key prefix in the props block or it arrives as a string and lights nothing; `width` carries the prefix too, the way every numeric prop in the deck\'s own props blocks is written. A THIRD mark exists for the case neither abstraction can carry: `::deck-map` is a live Mapbox basemap on Forest\'s house style, for a slide whose argument is the STREETS — a route, a catchment, why a bay sits where it does. It costs a network and a token, so reach for it only where a dot grid would be lying by omission.',
|
|
310
|
+
use: 'Any claim that is fundamentally geographic — coverage, expansion, a borough-by-borough comparison — where the map is doing real work rather than decorating the slide.',
|
|
311
|
+
avoid: 'Boroughs carrying a QUANTITY rather than a mention: `choropleth` gives shaded highlights and their ramp key the whole frame, because five steps and a legend cannot be told apart across two fifths of a row. And when the argument is made in prose with the map only backing it up, `map-inset` flips the ratio so the copy carries more of the row.',
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: 'chart',
|
|
315
|
+
role: 'One series ranked across categories — the only chart mark that survives half a row, because each of its bars carries its own label beside it.',
|
|
316
|
+
layout: 'split',
|
|
317
|
+
props: { frame: 'content' },
|
|
318
|
+
fill: 'Heading and the one-line takeaway as the default slot content; a `::bars` in the `aside` slot, `:data:` sorted the way the takeaway reads. ONE series — `::bars` carries no axis, no grid and no legend, labels every row in its own column and prints each value at the end of its own bar, which is what lets it live in half a deck row where a clustered column chart\'s legend and 36px x labels cannot. `:monochrome: true` when the rows are one quantity split by category and the colour would be decoration; `:highlight:` takes a row INDEX and steps every other row back to carry the point.',
|
|
319
|
+
use: 'A ranking or a single-series comparison across categories — boroughs biggest to smallest, this quarter\'s figures by product — where the shape of the bars IS the argument and the copy beside it says what to take from it.',
|
|
320
|
+
avoid: 'Two or more series per category — `comparison` hands the mark the whole frame, clustered or stacked, because a legend plus clustered 36px x labels does not fit `chart`\'s half row. A single number with no categories to compare is `stat-hero`, which puts the figure through `::kpi`\'s numeral treatment instead of an axis it does not need.',
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: 'dashboard',
|
|
324
|
+
role: 'Several DIFFERENT kinds of reading at once — figures beside a figure with a shape, not one form repeated across a uniform row.',
|
|
325
|
+
layout: 'default',
|
|
326
|
+
props: {},
|
|
327
|
+
fill: 'A `::grid` (`:cols: 2` or 3) mixing two or three `::stat-card` tiles with one figure tile — a status-board read, not a single chart type repeated. `::bars` labels every row and prints its own value, and `::dotted-map` draws no key at all, so neither needs anything around it; a tile whose figure carries several series does need a key, and every Unovis mark gets one from `::chart-frame`, which is where the `height`, `legend` and `legend-position` each mark forwards land (the empty state reaches it as the mark\'s own `empty`). Two of the frame\'s defaults are wrong inside a cell: the 480px plot is deeper than a grid cell is wide, and a bottom legend eats what is left of it — set `:height:` down and `legend-position: \'top\'`, or `:legend: false` and label the marks. Reach for `::chart-frame` directly only when the tile\'s figure is not one of the shipped marks: it is the one thing in the deck that puts a room-scale series key, and the honest empty well, under a figure built by hand.',
|
|
328
|
+
use: 'An operating-review slide that needs to show the state of several different things on one screen, the way a real dashboard does.',
|
|
329
|
+
avoid: 'Several figures of the SAME kind that should read as one comparison — `metrics` keeps every tile the same shape on one baseline; mixing figure types into that row is exactly what `dashboard` is for, and exactly what breaks a `metrics` row\'s own promise.',
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: 'device',
|
|
333
|
+
role: 'Product-in-hand — a screen mockup as the slide\'s own figure.',
|
|
334
|
+
layout: 'split',
|
|
335
|
+
props: { ratio: '2:3' },
|
|
336
|
+
fill: 'Heading and copy in the default slot content; a `::device-frame` on the figure side — a real phone shell, `device` naming a model from the registry (default `iphone-17-pro-max`) and `height` deciding how much of the row it takes. Whatever the frame holds lays out at that model\'s TRUE logical points and scales with the shell, so a mock drawn for a 440pt phone stays a 440pt phone.',
|
|
337
|
+
use: 'Introducing or walking through a real screen — the app, the product, whatever the audience needs to actually see running.',
|
|
338
|
+
avoid: 'A screenshot that needs to sit on the OTHER side of the row — `device-right` is this same recipe with `flip: true`; picking the wrong one fights whatever reading order the surrounding slides have already established.',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: 'testimonial',
|
|
342
|
+
role: 'A quote whose credibility depends on a named, real person having said it.',
|
|
343
|
+
layout: 'quote',
|
|
344
|
+
props: {},
|
|
345
|
+
fill: 'The quoted line as the default slot content; `attribution` and `source` both filled in — either missing reads as unverifiable.',
|
|
346
|
+
use: 'Social proof — a customer, a user, a partner, on the record, saying something in their own words.',
|
|
347
|
+
avoid: 'A one-line claim in the brand\'s OWN voice, with no external source — `statement` is that; running it through `quote` with no `attribution` makes an unattributed line look like it is quoting somebody who was never named.',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
name: 'checklist',
|
|
351
|
+
role: 'A set of things that are all true, or all required, with no order between them.',
|
|
352
|
+
layout: 'default',
|
|
353
|
+
props: {},
|
|
354
|
+
fill: 'A `::grid` (cols 1 or 2) of items, each pairing an `::icon-tile` with `icon: \'lucide:check\'` (Forest\'s own icon set has no tick — this one draws from Lucide instead, alongside it, not in place of it) with a short line of what it confirms.',
|
|
355
|
+
use: 'Requirements, inclusions, or "what\'s covered" — a set read in any order, where the point is completeness, not sequence.',
|
|
356
|
+
avoid: 'Anything the audience does IN ORDER — `steps` numbers its `::grid` items for exactly that reason; a checklist with implied ordering hides the one thing that actually matters, which step comes first.',
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: 'tags',
|
|
360
|
+
role: 'A short, flat row of labels — no figure, no numbers, just what applies.',
|
|
361
|
+
layout: 'default',
|
|
362
|
+
props: {},
|
|
363
|
+
fill: 'A `::badge-strip`, optionally under a heading — `items` a plain array of short label strings, `background` for which chip pair paints every tile.',
|
|
364
|
+
use: 'Naming a set of topics, technologies, or categories in one row — a tech stack, the themes a talk touches, keywords attached to a case study.',
|
|
365
|
+
avoid: 'Anything that needs to read as CONFIRMED or completed — `checklist` pairs each item with an affirming icon for exactly that; a `::badge-strip` tile is a flat label with no such signal, so reaching for `tags` on a list of requirements reads as a set of topics, not proof any of them are done.',
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: 'endorsed',
|
|
369
|
+
role: 'A closing or opening slide that carries a certification the company actually holds, beside the mark that certifies it.',
|
|
370
|
+
layout: 'cover',
|
|
371
|
+
props: {},
|
|
372
|
+
fill: 'A `::endorsement` at the foot of a `cover`, `tone: \'positive\'` on a light ground and `\'reverse\'` on a dark one or over a photograph. Nothing else — the mark is the content, and the slide around it is whatever opener the deck already uses.',
|
|
373
|
+
use: 'A board pack, an investor deck or a tender response, where the certification is part of what is being claimed and belongs on the slide rather than in a footnote.',
|
|
374
|
+
avoid: 'Every slide — a certification mark repeated down a deck reads as furniture and stops being read at all; `cover` carries it once. And tinting it to match a ground: the two colourways are the only sanctioned ones, which is why `::endorsement` takes `tone` and not a colour.',
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: 'co-brand',
|
|
378
|
+
role: 'A slide that names a partner by putting their mark beside ours, at one height.',
|
|
379
|
+
layout: 'default',
|
|
380
|
+
props: {},
|
|
381
|
+
fill: 'A `::mark-pair` with `src` at the partner\'s artwork and `label` naming them, under a heading that says what the partnership IS. One pair, not a row of them.',
|
|
382
|
+
use: 'Announcing a single partnership, or opening the section of a deck that belongs to one — where the two marks together are the claim.',
|
|
383
|
+
avoid: 'More than one partner — a second `::mark-pair` on the slide reads as two competing lockups rather than one relationship; `logos` is the recipe for a set of third-party marks laid out as a grid. And any slide whose chrome already draws the pair in its corner: `split` and `cover` both seat one there from the slide\'s own frontmatter, so a `::mark-pair` on one of those says it twice.',
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
name: 'contact',
|
|
387
|
+
role: 'The last slide, when it needs to leave a way to follow up.',
|
|
388
|
+
layout: 'end',
|
|
389
|
+
props: {},
|
|
390
|
+
fill: 'Contact details — email, site, socials — as short lines in the default slot content.',
|
|
391
|
+
use: 'The deck\'s final slide when the audience needs an actual next action, not just a sign-off.',
|
|
392
|
+
avoid: 'A close that needs nothing further from the audience — the plain `end` preset is the bare lockup; adding contact details to a talk that already made its ask elsewhere just clutters the one slide meant to be quiet.',
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------------------------
|
|
396
|
+
// Six aliases — a mirrored `flip`, a flipped `background`, a re-weighted `ratio`: zero new
|
|
397
|
+
// architecture, each naming the preset it is an alias of.
|
|
398
|
+
// ---------------------------------------------------------------------------------------------
|
|
399
|
+
{
|
|
400
|
+
name: 'pivot',
|
|
401
|
+
role: 'One rank down — a pivot or a before/after inside a section, not a new act.',
|
|
402
|
+
layout: 'divider',
|
|
403
|
+
props: { rank: 'subsection' },
|
|
404
|
+
fill: 'The turn itself as the default slot content — "But then...", a problem statement, whatever the pivot is.',
|
|
405
|
+
use: 'A turn inside a section that still needs the deck\'s own punctuation weight, without reading as the start of a whole new part.',
|
|
406
|
+
avoid: 'An actual new act — `divider` is `rank: section`, the deepest ground and the largest headline; opening one at subsection rank undersells a genuine section start as a mere pause.',
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: 'device-right',
|
|
410
|
+
role: 'The `device` recipe, mirrored — the screen sits on the left of the row.',
|
|
411
|
+
layout: 'split',
|
|
412
|
+
props: { ratio: '2:3', flip: true },
|
|
413
|
+
fill: 'Same as `device`: heading and copy in the default slot content, a `::device-frame` as the figure — mirrored so the phone lands on the left.',
|
|
414
|
+
use: 'A `device` slide whose figure needs to sit on the LEFT instead of the right — the one direction plain `device`, `map` and `chart` do not offer, since all three default their own figure to the right.',
|
|
415
|
+
avoid: 'The first device slide in a sequence, with nothing around it to match — reach for plain `device` (figure on the right) as the default; only flip when a neighbouring slide gives a real reason to.',
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: 'map-inset',
|
|
419
|
+
role: 'A map that is supporting evidence — the copy makes the argument, the map confirms it.',
|
|
420
|
+
layout: 'split',
|
|
421
|
+
props: { ratio: '3:2' },
|
|
422
|
+
fill: 'Same shape as `map` with the ratio flipped: copy in the default slot content, `::london-map` (or `::dotted-map`, chosen by the same rule) in the `aside` slot. At two fifths of the row the map wants a SHORT highlight set — three or four boroughs read at this size, twenty become texture — and no ramp: a shaded `:legend:` strip this narrow keys nothing a room can match. Where the slide is about ONE borough the audience already places, `::borough-shape` takes the same slot instead — `borough` by name or ONS code, `size` at `lg` (640px; `md` is the natural 400px and reads small in this column), and `label` set, since an outline alone does not announce which borough it is. It draws in the deck accent and only the accent, pinned in the component, so a text utility on the tag is inert.',
|
|
423
|
+
use: 'A slide whose point is made in prose, with the map there to show the audience it is true.',
|
|
424
|
+
avoid: 'A slide that IS about geography — `map` gives the figure the bigger share of the row for exactly that case, and using this one starves the only thing the slide is meant to show. And never several `::borough-shape` marks side by side as a stand-in for a map: each re-frames to its own bounds, so Bromley and the City of London draw the same height and the row says nothing about size or position.',
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
name: 'chart-right',
|
|
428
|
+
role: 'The `chart` recipe, mirrored — the bars sit on the left of the row.',
|
|
429
|
+
layout: 'split',
|
|
430
|
+
props: { frame: 'content', flip: true },
|
|
431
|
+
fill: 'Same as `chart`: heading and takeaway as the default slot content, a single-series `::bars` in the `aside` slot — `flip: true` sits the CONTENT on the right and the figure takes the other side, so the bars land on the left. A pure mirror: `side` never changes either column\'s share.',
|
|
432
|
+
use: 'A `chart` slide whose figure needs to sit on the LEFT instead of the right — the same reason `device-right` exists, and the same three (plain `chart`, `map`, `device`) that default their own figure to the right instead.',
|
|
433
|
+
avoid: 'A standalone chart slide with nothing to match — reach for plain `chart` (bars on the right) first; only mirror it when a real neighbouring slide asks for it. Mirroring also buys no room: this is still a half row, so a clustered or stacked chart still belongs in `comparison`.',
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: 'cover-center',
|
|
437
|
+
role: 'The opener, centred rather than ranged left.',
|
|
438
|
+
layout: 'cover',
|
|
439
|
+
props: { align: 'center' },
|
|
440
|
+
fill: 'Same as `cover`: title, `eyebrow`, `meta` — centred instead of ragged left.',
|
|
441
|
+
use: 'A quieter, more formal opener — an internal all-hands, a board pack — where a centred title reads calmer than the ragged-left default.',
|
|
442
|
+
avoid: 'A talk that leads with energy — the ragged-left `cover` default carries more forward motion; centring it for a pitch or a launch deck mutes exactly the momentum those need.',
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
name: 'pricing-highlight',
|
|
446
|
+
role: 'A pricing comparison with one recommended tier given more room than the rest.',
|
|
447
|
+
layout: 'split',
|
|
448
|
+
props: { ratio: '3:2', frame: 'content' },
|
|
449
|
+
fill: 'The recommended tier\'s own `::stat-card` or `::card` with `background: \'vivid\'` and `dark: true` in the (wider) default slot content; the remaining tiers in the `aside` slot.',
|
|
450
|
+
use: 'A pricing slide that wants to visually steer towards one plan, not present all tiers as equally weighted options.',
|
|
451
|
+
avoid: 'A neutral comparison where no tier should look favoured — `pricing`\'s even 1:1 split is the one that does not editorialise; `pricing-highlight`\'s wider column is a real steer, not a layout accident.',
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
// ---------------------------------------------------------------------------------------------
|
|
455
|
+
// Marks and states — the content shapes the charting, table, map and status components carry.
|
|
456
|
+
// ---------------------------------------------------------------------------------------------
|
|
457
|
+
{
|
|
458
|
+
name: 'trend',
|
|
459
|
+
role: 'A quantity moving through time, with the one sentence that says what the movement means sitting beside it.',
|
|
460
|
+
layout: 'split',
|
|
461
|
+
props: { ratio: '2:3' },
|
|
462
|
+
fill: 'Heading and a single-sentence reading of the movement in the default slot content; the chart in the `aside` slot — `::line-chart` when the series are independent trajectories to be compared, `::area-chart` when they sum to one whole (its default stack makes exactly that claim; pass `:overlaid: true` when they do not). Every `label` in `data` is a point on a real sequence, in order. In the props block every NON-STRING value is colon-prefixed — `:data:`, `:series:`, `:overlaid:`, `:height:`, `:max-ticks:` — while string props go bare (`y-label: \'Trips\'`, `legend-position: \'top\'`); drop the colon and the value arrives as a string, so an array plots nothing and a `false` reads as truthy. Set `yLabel` unless the heading already carries the unit, and leave `height` at its 480 default: with the heading in the content column rather than a `::header::`, the `frame: content` figure column starts at the 360px vertical anchor and split.vue caps it at 630px, which 480 plus a legend row just fits. (A `::header::` would size row 1 to the 130 band instead and free the 592 a full-width chart gets — a different recipe, not this one.)',
|
|
463
|
+
use: 'A slide whose claim is a direction — growth, a decline, a curve that flattened — where the audience needs to see the shape and read the takeaway in the same breath.',
|
|
464
|
+
avoid: 'A comparison across categories: `chart`\'s bars are the mark for that, and a line drawn across category labels claims a rate of change between things that have no \'between\'. Also avoid it for two points and a delta — that is `stat-hero`, and a line through two dots dresses a single subtraction up as a trajectory.',
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: 'correlation',
|
|
468
|
+
role: 'Two measurements read against each other — the one chart slide whose answer is the shape of a cloud, not a figure anyone can read off an axis.',
|
|
469
|
+
layout: 'default',
|
|
470
|
+
props: {},
|
|
471
|
+
fill: 'The takeaway as the slide\'s heading, and `::scatter-chart` under it filling the frame — `data` rows of `{ x, y }`, plus `size` (scaled by `sizeRange`) for a third measure and `group` when the dots split into named sets, which is the only case where the legend says anything. In the props block every NON-STRING value is colon-prefixed — `:data:`, `:height:`, `:labels:`, `:size-range:` — while string props go bare (`x-label: \'Parking bays\'`, `legend-position: \'top\'`); drop the colon and the value arrives as a string, so an array plots nothing and `labels: false` reads as truthy. Set both `xLabel` and `yLabel` — neither axis of a scatter is self-evident — turn `:labels: true` on only when there are few enough dots to name, about six before they collide, and set `:height: 592`, which is what every full-width chart under a heading in the specimen deck uses; the 480 default is sized for a split\'s figure column and leaves the foot of the slide empty here.',
|
|
472
|
+
use: 'A relationship between two measures — parking bays against trips, price against usage — where the point is whether the dots line up at all, and how tightly.',
|
|
473
|
+
avoid: 'Anything measured over time: `trend` is that, and plotting (date, value) pairs as dots throws away the ordering a line\'s own segments carry, leaving the audience to re-sequence the cloud by eye. And do not move this into `trend`\'s `split` figure column — that column caps at 630px and narrows the x range, pressing the cloud into a blob, which is the one compression a correlation slide cannot survive.',
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
name: 'comparison',
|
|
477
|
+
role: 'Several series measured in every category, read by LENGTH off one shared zero.',
|
|
478
|
+
layout: 'default',
|
|
479
|
+
props: {},
|
|
480
|
+
fill: 'Heading and the one-line takeaway as the default slot content; one bar chart filling the rest of the frame. Which mark depends on the CLAIM, not the data shape: `::grouped-bar-chart` clusters independent series that happen to share a unit and a zero; `::stacked-bar-chart` stacks series that genuinely sum to their column, so each column height IS the total. Stacking independent series fabricates a total nobody can defend, and clustering parts throws away the total the slide is about. Either way `:data:` is one row per category, `:series:` names each one in reading order (left to right inside a cluster, bottom to top in a stack), `legend` stays on (neither mark is readable without a key), and `:height:` rises past the 480 default now the chart owns the frame. The palette stops at five series and the chart refuses past it. A cluster costs horizontal room: three or four categories by three or four series is a slide, ten by five is a spreadsheet. A THIRD mark joins them where the slide has two different QUANTITIES rather than two series: `::composed-chart` draws volume as bars with a rate over it as a line, and its own doc states when one shared axis stops being honest.',
|
|
481
|
+
use: 'This year against last, trips by vehicle type per borough, revenue by line per quarter — a handful of categories along the bottom, each carrying two to five measured series.',
|
|
482
|
+
avoid: 'One series only — `chart` sits it beside the copy at half a row instead of taking the frame. One whole with no categories along the bottom is `share`, which states the total exactly in the hole, something a stack of a single column cannot do.',
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
name: 'share',
|
|
486
|
+
role: 'One whole split into a handful of parts, with the total stated exactly in the hole — the number no other part-to-whole mark can show.',
|
|
487
|
+
layout: 'split',
|
|
488
|
+
props: { frame: 'content' },
|
|
489
|
+
fill: 'Heading and the takeaway as the default slot content; a `::donut-chart` in the `aside` slot — `:data:` at most five slices, in the order they should read clockwise, plus `central-label` and `central-sub-label` for the total and what it counts (omit `central-label` and the slices\' own sum is drawn instead; pass an empty string for no centre at all). Five is a hard ceiling, not a guideline: a sixth slice would need a sixth colour that does not exist, so the chart draws "6 series — this palette carries 5" instead — aggregate the tail into a "Rest" slice at the call site, which is a decision about the argument rather than about the chart. Leave `legend` on — a donut with neither a legend nor direct labels says nothing — and keep `:arc-width:` near its 96 default, since thinner reads as a dial once the mark is only half a row wide. TWO MARKS SHARE THIS ROW when the ring cannot carry the split: `::treemap-chart` when there are more parts than five or they are wildly uneven, because area survives what a thin arc cannot; `::nested-donut-chart` when each part breaks down again and the second level is the point. Both keep the same ceiling on the OUTER level and the same rule about aggregating the tail.',
|
|
490
|
+
use: 'A single split worth naming — the fleet by vehicle type, revenue by segment — where the total is a headline in its own right and the parts are few enough to tell apart.',
|
|
491
|
+
avoid: 'More than about five parts, or the same split repeated across categories — a donut is read by arc AREA, which the eye estimates badly; `comparison` lays those columns out side by side where the reading becomes a length. A total with no split behind it is `stat-hero`, not a one-slice ring.',
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
name: 'table',
|
|
495
|
+
role: 'Exact figures on more than one dimension at once — the reading no mark can give, because the numbers themselves are what the audience came for.',
|
|
496
|
+
layout: 'default',
|
|
497
|
+
props: {},
|
|
498
|
+
fill: 'A heading, then `::data-table` under it. `rows` is one plain object per row, keyed by column key; `columns` is optional — omitted, the row keys become the columns in first-appearance order, their labels humanised (`tripsPerBike` reads "Trips per bike"), numeric columns detected from the data itself and end-aligned so the digits line up under one another. Declare `columns` — `[{ key, label, align, numeric, format, precision, unit }]` — when a header needs its own wording, or a pre-formatted string column needs the tabular treatment anyway. The first column is the row\'s NAME and draws as the row header. `:max-rows:` defaults to 5, which is what fits under a heading; 7 is the ceiling and needs a slide with no heading at all, and every row past the cut is withheld with the count stated on the slide, never sliced silently. Five columns is a hard ceiling — past it the table refuses and draws a sentence instead, because dropping a column changes what the table claims. `format: \'compact\'` for figures past four digits, and `note` for the source line ("TfL journey data, Q2 2026"), which renders under the rows beside the truncation notice.',
|
|
499
|
+
use: 'A top five, a per-borough breakdown, a plan-by-plan comparison — a set of rows each carrying several figures, where the precise numbers are the point rather than the shape they make. It is also the door `pricing`\'s own avoid already sends an author to when a tier comparison outgrows a two-column grid.',
|
|
500
|
+
avoid: '`metrics` — four figures sharing one unit and one baseline read as `::stat-card` tiles from twice the distance, and a table drawn for them buries them in cells. And never as a tile inside `dashboard`: DataTable\'s ceilings are measured against a full content column (a 104px row, five columns, the caption beneath), so a third-of-a-row cell squeezes columns the component has no way to drop — it refuses past five, never for width.',
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
name: 'choropleth',
|
|
504
|
+
role: 'One quantity per borough, read as a ramp — the map answering "how much", not "which ones".',
|
|
505
|
+
layout: 'default',
|
|
506
|
+
props: {},
|
|
507
|
+
fill: 'A heading naming the metric and the period, one line of takeaway, then a `::london-map` alone beneath them. Every `:highlight` entry is an object — `{ borough: \'Southwark\', shade: 4 }` — with `shade` 1–5 off the deck\'s sequential ramp, and `:legend` set to exactly two strings, the two ends of the scale (`[\'fewer trips\', \'more trips\']`): any other length draws no key at all. `label` names the whole figure. Keep `:width` at its default 900 or below — height follows the map\'s fixed 1000×771 aspect, so 900 wide is 694 tall and the ramp strip sits under that, which is already most of what a slide carrying a heading has left. Do not mix plain-string highlights into the same array: the accent is forest-700, which lands between shade 4 and shade 5 on the light ramp, so an accented borough reads as one more step of the quantity instead of as \'this one\'.',
|
|
508
|
+
use: 'Trips, riders, revenue or uptake compared ACROSS boroughs, where the shape of the distribution is the point and no single borough is the answer.',
|
|
509
|
+
avoid: 'A slide whose point is WHICH boroughs — `map` puts them in the accent, which is a stronger "this one" than any step of a ramp, and leaves the row for copy. And never as one tile inside `dashboard`\'s grid: the ramp key shrinks to a strip a room cannot match against 33 shapes, so the shading stops meaning anything.',
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: 'roll-call',
|
|
513
|
+
role: 'A set whose SIZE is the message — too many names to read as a list, so they pass one at a time at full size.',
|
|
514
|
+
layout: 'default',
|
|
515
|
+
props: {},
|
|
516
|
+
fill: 'A `::marquee`: `:items` a flat array of short strings (boroughs served, vehicle types, partner names), `:duration` the seconds for one cycle — 40 is the tuned default, lower is faster. A second `::marquee` beneath it with `:reverse: true` and a shorter `:duration` is the treatment: one band reads as a list going somewhere, a pair reads as texture. Nothing is lost when it stops — reduced motion and `slidev export` both land the band on its first frame, items in order from the left.',
|
|
517
|
+
use: 'Every borough on the map, every vehicle type in the fleet, the whole customer list — twenty short names that would be a wall of small type in a grid and are read one at a time in a band.',
|
|
518
|
+
avoid: 'A short set the audience has to compare or carry away: `tags` puts four or five labels in a `::badge-strip` that stays put, where a marquee carries each word out of frame before anyone can hold two of them together. And real artwork rather than words — `logos` grids the marks at a fixed size; a marquee carries text only, and a mark scrolling past is unreadable.',
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
name: 'status',
|
|
522
|
+
role: 'Several things at once, each carrying its own state, where the colour is the reading and the words are the detail.',
|
|
523
|
+
layout: 'default',
|
|
524
|
+
props: {},
|
|
525
|
+
fill: 'A `::grid` (`:cols: 3`) of `::alert` panels, every one `variant: \'subtle\'`, each with its own `status` — `success`, `warning`, `danger`, `info`, `neutral` — `title` naming the thing and the panel\'s own slot carrying one line on why. The lamp states the status a second time as a shape, so the board still reads for anyone who cannot separate the hues; `:icon: false` drops it only where the title already says the state in words.',
|
|
526
|
+
use: 'A RAG board — workstreams, launch gates, integrations, regions — where the audience should see which are fine and which are not before reading a word.',
|
|
527
|
+
avoid: '`variant: \'solid\'` across the row: saturated blocks shout at each other and nothing reads as the urgent one — `notice` is the solid alert, alone on its own slide. And a set where every item is TRUE rather than mixed: `checklist` is the tick-per-line shape, where a grid of five identical `::alert` panels spends the whole status palette saying one thing.',
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
name: 'browser-mock',
|
|
531
|
+
role: 'A web page shown as a page — the window is the signal, and it costs almost nothing to draw.',
|
|
532
|
+
layout: 'default',
|
|
533
|
+
props: {},
|
|
534
|
+
fill: 'A `::browser-frame` as the default slot content — `url` for the address pill, `ratio` shaping the PAGE area rather than the window, `dark` for a dark-mode page. The slot holds the mock itself; left empty the window renders as an empty page on the deck\'s own ground.',
|
|
535
|
+
use: 'A marketing page, a dashboard, a booking flow — anything where the audience should read the thing as a website rather than as a diagram.',
|
|
536
|
+
avoid: 'A phone screen (`device` carries the hardware), and a chart or table that is not really a web page — the chrome then claims a context the content does not have, and `default` shows the same figure without the claim.',
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
name: 'notice',
|
|
540
|
+
role: 'The one thing the audience must not read past — a statement with a state attached.',
|
|
541
|
+
layout: 'default',
|
|
542
|
+
props: { align: 'center' },
|
|
543
|
+
fill: 'A single `::alert` with `variant: \'solid\'` — the saturated block in the family\'s own 700 — `status` naming the state (`warning` for a risk, `danger` for a blocker, `success` for a gate cleared), `title` carrying the sentence and the panel\'s slot one line of consequence. `align: \'center\'` sits it in the middle of the frame vertically, the same anchoring `stat-hero` gives its one figure.',
|
|
544
|
+
use: 'The caveat, dependency, blocker or green light the rest of the deck hangs on — a slide whose entire job is that one state.',
|
|
545
|
+
avoid: 'A claim with no state behind it: `statement` sets the sentence in the deck\'s 96px display voice, where an alert is body type inside a coloured block — reaching for `notice` there spends a status colour on something that has no status. And an aside about something else on the slide: a solid alert points at nothing and is the loudest object in the frame by construction, so it cannot comment on a neighbour — put the aside in the body copy, or give it its own quiet slide.',
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: 'flow',
|
|
549
|
+
role: 'A quantity moving — through stages, or among peers — where the width of the connection IS the amount.',
|
|
550
|
+
layout: 'default',
|
|
551
|
+
props: {},
|
|
552
|
+
fill: 'Heading and the one-line reading as the default slot content; the diagram filling the rest of the frame. The two marks answer different questions and `fill` is where the choice is made: `::sankey-chart` when the quantity moves THROUGH STAGES and every part of it lands somewhere — a total that arrives, splits, and is conserved; `::chord-chart` when it moves AMONG PEERS, so there are no stages and the same set is both source and destination. Both take `:nodes:` and `:links:` as separate tables, both give every node a STRING id, and both stop at five nodes, because colour here is IDENTITY — it is how a room traces one flow across the picture, and a sixth would tell the audience two different things are the same thing.',
|
|
553
|
+
use: 'Where the trips go after the first mile, how a budget divides, which boroughs exchange riders with which — an argument about movement rather than about magnitude.',
|
|
554
|
+
avoid: 'A split that does not move anywhere: `share` states one whole and its parts, and a ribbon drawn between two categories that have no flow between them invents a relationship. And a sequence of dated milestones is `timeline`, which is a chronology rather than a quantity.',
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
name: 'schedule',
|
|
558
|
+
role: 'One row per subject, read across time — when each thing ran, or what condition it was in.',
|
|
559
|
+
layout: 'default',
|
|
560
|
+
props: {},
|
|
561
|
+
fill: 'Heading and the takeaway as the default slot content; the track filling the rest of the frame. Which mark depends on what a segment MEANS: `::timeline-chart` when a bar is a DURATION and the question is when things ran and what overlapped; `::status-track` when a segment is a STATE and the question is what condition each subject was in. That difference decides the colour too, and it is not a preference — `::timeline-chart` takes the categorical series palette because its rows are things, while `::status-track` takes the status roles because its segments are states, and a state drawn in a category colour says the wrong kind of thing. They also differ in SHAPE, which decides how many subjects one slide can carry: `::timeline-chart` takes every row at once and positions each bar by its own start and end, while `::status-track` is ONE asset per component, divided into equal-width buckets — so several assets is several tracks stacked, sharing a label gutter and a grid. `::status-track` also has an `intensity` mode, for a bucket carrying a QUANTITY rather than a condition, which shades from the sequential ramp instead.',
|
|
562
|
+
use: 'A rollout across boroughs, four workstreams against one quarter, or a fleet\'s week read as available / degraded / offline.',
|
|
563
|
+
avoid: 'A handful of milestones with nothing spanning between them — `timeline` puts those on one rule, and a bar chart of instants is a row of slivers. One subject rather than several is a sentence, not a track.',
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
name: 'seasonality',
|
|
567
|
+
role: 'A year read at once, as intensity — which weeks were busy, not what any one day was.',
|
|
568
|
+
layout: 'default',
|
|
569
|
+
props: {},
|
|
570
|
+
fill: 'Heading and the takeaway as the default slot content; a `::calendar-grid` filling the rest of the frame, `:data:` one row per day as `{ date, value }`. It shades from the SEQUENTIAL ramp rather than the categorical palette — the roles are ordered, so a darker cell means more, which is the one claim a categorical colour cannot make. The legend states the top edge of each step, so a reader decoding a shade gets a number rather than a rank.',
|
|
571
|
+
use: 'Demand across a year, weather-shaped usage, the shape of a season — where the pattern is the argument and no single day is.',
|
|
572
|
+
avoid: 'A number anyone needs to READ: a cell is a few pixels on this canvas and carries a bucket, not a value. `trend` plots the same year as a line when the movement is the point, and `stat-hero` states one figure when one figure is what matters.',
|
|
573
|
+
},
|
|
574
|
+
// ---------------------------------------------------------------------------------------------
|
|
575
|
+
// Drawing the frame itself, rather than putting something in it.
|
|
576
|
+
// ---------------------------------------------------------------------------------------------
|
|
577
|
+
{
|
|
578
|
+
name: 'layout-areas',
|
|
579
|
+
role: 'A frame shown as its own named regions — what areas a layout has and how much of the slide each one gets.',
|
|
580
|
+
layout: 'split',
|
|
581
|
+
props: { ratio: '3:2', frame: 'panel' },
|
|
582
|
+
fill: 'One `::area-box` per slot the layout exposes, each `name`d as the layout itself names it and `note`d with the track share it is given — `content` and the `aside` slot at minimum, plus `header` and `footer` where the frame has them. `background` separates the three kinds of promise a frame makes: `content` for the column that holds the reading, `figure` for the cell that holds a figure, `chrome` for the furniture around both. Nothing else goes on the slide: the moment real copy appears the eye reads the copy instead of the shape, which is the one thing this recipe is for.',
|
|
583
|
+
use: 'Documenting or reviewing the frames themselves — proving a ratio lands where it claims to, or showing a deck author what a layout will give them before they write into it.',
|
|
584
|
+
avoid: 'Any slide with something to say — reach for `split` itself, or `default`, and put the real content in. This recipe draws the container and never the contents, so a slide that needs both a point and a diagram of its own frame is two slides.',
|
|
585
|
+
},
|
|
586
|
+
];
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* What `validatePreset` needs to know about every real layout: each prop's NAME, and — for a prop
|
|
590
|
+
* with a closed set of values — that set. `null` for a prop means "not a closed enum" (a free-text
|
|
591
|
+
* prop, e.g. Kpi.vue's `label`): only its name is checked, there is nothing to check a value
|
|
592
|
+
* against. Built by scripts/deckManifest.ts from the SAME source-derived layout entries the
|
|
593
|
+
* manifest itself ships, never restated.
|
|
594
|
+
*/
|
|
595
|
+
export type LayoutPropSchema = ReadonlyMap<string, ReadonlyMap<string, readonly string[] | null>>;
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* One preset's own errors — a preset referencing a layout that isn't one of Forest's eight, a prop
|
|
599
|
+
* name that layout doesn't declare, or (FIX ROUND 1, IMPORTANT 4) a VALUE that prop's own closed
|
|
600
|
+
* set doesn't contain, is exactly the "manifest as a false contract" failure mode task-6-brief.md's
|
|
601
|
+
* re-brief warns about, one level up from a source-parsing mistake.
|
|
602
|
+
*
|
|
603
|
+
* The value check matters on its own: checking only the NAME lets `{ layout: 'split', props: {
|
|
604
|
+
* ratio: '3:1' } }` pass — `ratio` is real — while split.vue's own `RATIO_CLASS` lookup has no
|
|
605
|
+
* '3:1' entry and falls back to '1:1' (its validator, which WOULD have caught this, is stripped
|
|
606
|
+
* from production). Every slide built from that preset renders 50/50 while the manifest promises
|
|
607
|
+
* 3:1 — a document contradicting itself, silently, because the check that could have caught it
|
|
608
|
+
* only ever looked at the key.
|
|
609
|
+
*
|
|
610
|
+
* Pure and synchronous so it can be unit-tested against synthetic data without needing PRESETS to
|
|
611
|
+
* be non-empty (see packages/slidev-theme/test/manifest.static.test.ts) — the exact "prove the
|
|
612
|
+
* gate can go red before you trust it" shape this project requires of every gate it ships.
|
|
613
|
+
*/
|
|
614
|
+
export const validatePreset = (preset: Preset, layoutProps: LayoutPropSchema): string[] => {
|
|
615
|
+
const errors: string[] = [];
|
|
616
|
+
const props = layoutProps.get(preset.layout);
|
|
617
|
+
if (!props) {
|
|
618
|
+
errors.push(`preset '${preset.name}': layout '${preset.layout}' is not one of Forest's own layouts`);
|
|
619
|
+
return errors; // no layout to check props against
|
|
620
|
+
}
|
|
621
|
+
for (const [key, value] of Object.entries(preset.props)) {
|
|
622
|
+
if (!props.has(key)) {
|
|
623
|
+
errors.push(`preset '${preset.name}': layout '${preset.layout}' has no prop '${key}'`);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const allowed = props.get(key);
|
|
627
|
+
if (allowed && !allowed.includes(String(value))) {
|
|
628
|
+
errors.push(
|
|
629
|
+
`preset '${preset.name}': layout '${preset.layout}' prop '${key}' does not accept ` +
|
|
630
|
+
`'${value}' — must be one of ${allowed.join(', ')}`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return errors;
|
|
635
|
+
};
|