@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.
Files changed (124) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +577 -0
  3. package/assetUrl.js +38 -0
  4. package/components/Alert.vue +209 -0
  5. package/components/AreaBox.vue +152 -0
  6. package/components/AreaChart.vue +145 -0
  7. package/components/Avatar.vue +267 -0
  8. package/components/AvatarGroup.vue +94 -0
  9. package/components/BadgeStrip.vue +203 -0
  10. package/components/Bars.vue +207 -0
  11. package/components/BoroughShape.vue +127 -0
  12. package/components/BrowserFrame.vue +484 -0
  13. package/components/CalendarGrid.vue +480 -0
  14. package/components/Card.vue +70 -0
  15. package/components/Carousel.vue +266 -0
  16. package/components/ChartFrame.vue +426 -0
  17. package/components/ChordChart.vue +264 -0
  18. package/components/ComposedChart.vue +187 -0
  19. package/components/Contents.vue +86 -0
  20. package/components/DataTable.vue +244 -0
  21. package/components/DeckMap.vue +409 -0
  22. package/components/DeviceFrame.vue +331 -0
  23. package/components/DonutChart.vue +103 -0
  24. package/components/DottedMap.vue +126 -0
  25. package/components/Endorsement.vue +76 -0
  26. package/components/Grid.vue +169 -0
  27. package/components/GroupedBarChart.vue +141 -0
  28. package/components/IconTile.vue +116 -0
  29. package/components/Kpi.vue +188 -0
  30. package/components/LineChart.vue +133 -0
  31. package/components/Logo.vue +203 -0
  32. package/components/LondonMap.vue +348 -0
  33. package/components/MarkPair.vue +109 -0
  34. package/components/Marquee.vue +263 -0
  35. package/components/NestedDonutChart.vue +295 -0
  36. package/components/Placeholder.vue +88 -0
  37. package/components/SankeyChart.vue +223 -0
  38. package/components/ScatterChart.vue +142 -0
  39. package/components/StackedBarChart.vue +143 -0
  40. package/components/StatCard.vue +134 -0
  41. package/components/StatusTrack.vue +334 -0
  42. package/components/Timeline.vue +249 -0
  43. package/components/TimelineChart.vue +329 -0
  44. package/components/TreemapChart.vue +267 -0
  45. package/components/backgrounds.js +135 -0
  46. package/components/boroughGeometry.ts +59 -0
  47. package/components/chartData.ts +277 -0
  48. package/components/chartTip.ts +201 -0
  49. package/components/codeTheme.ts +239 -0
  50. package/components/deckPalette.ts +157 -0
  51. package/components/dottedMapGeometry.js +60 -0
  52. package/components/iconSlugs.js +55 -0
  53. package/components/iconUrl.js +51 -0
  54. package/components/mermaidTheme.ts +972 -0
  55. package/components/motionDuration.ts +41 -0
  56. package/components/tableRules.ts +257 -0
  57. package/confidential-mark.vue +151 -0
  58. package/custom-nav-controls.vue +79 -0
  59. package/endorsements/b-corp-black.svg +30 -0
  60. package/endorsements/b-corp-white.svg +30 -0
  61. package/global-top.vue +1662 -0
  62. package/icons/forest/bike-asterisk.svg +21 -0
  63. package/icons/forest/bike-down.svg +19 -0
  64. package/icons/forest/bike-off.svg +20 -0
  65. package/icons/forest/bike-up.svg +19 -0
  66. package/icons/forest/bike-x.svg +19 -0
  67. package/icons/forest/bike.svg +18 -0
  68. package/icons/forest/mark-fill.svg +6 -0
  69. package/icons/forest/mark.svg +6 -0
  70. package/icons/forest/parking.svg +3 -0
  71. package/icons/forest/star-fill.svg +4 -0
  72. package/icons/forest/star.svg +4 -0
  73. package/icons/forest/traffic-light-caution.svg +8 -0
  74. package/icons/forest/traffic-light-go.svg +8 -0
  75. package/icons/forest/traffic-light-lit.svg +8 -0
  76. package/icons/forest/traffic-light-stop.svg +8 -0
  77. package/icons/forest/traffic-light.svg +8 -0
  78. package/icons/lucide/battery.svg +1 -0
  79. package/icons/lucide/bike.svg +1 -0
  80. package/icons/lucide/chart-line.svg +1 -0
  81. package/icons/lucide/check.svg +1 -0
  82. package/icons/lucide/circle-check.svg +1 -0
  83. package/icons/lucide/clock.svg +1 -0
  84. package/icons/lucide/code.svg +1 -0
  85. package/icons/lucide/component.svg +1 -0
  86. package/icons/lucide/frame.svg +1 -0
  87. package/icons/lucide/layout-grid.svg +1 -0
  88. package/icons/lucide/map-pin.svg +1 -0
  89. package/icons/lucide/map.svg +1 -0
  90. package/icons/lucide/palette.svg +1 -0
  91. package/icons/lucide/table.svg +1 -0
  92. package/icons/lucide/triangle-alert.svg +1 -0
  93. package/icons/lucide/type.svg +1 -0
  94. package/icons/lucide/wrench.svg +1 -0
  95. package/layouts/bleed.vue +317 -0
  96. package/layouts/cover.vue +1514 -0
  97. package/layouts/default.vue +187 -0
  98. package/layouts/divider.vue +286 -0
  99. package/layouts/end.vue +384 -0
  100. package/layouts/quote.vue +218 -0
  101. package/layouts/split.vue +1875 -0
  102. package/layouts/stack.vue +944 -0
  103. package/layouts/statement.vue +67 -0
  104. package/manifest/chrome.ts +36 -0
  105. package/manifest/components.ts +49 -0
  106. package/manifest/index.ts +57 -0
  107. package/manifest/layouts.ts +37 -0
  108. package/manifest/manifest.json +6514 -0
  109. package/manifest/presets.ts +635 -0
  110. package/notch.js +458 -0
  111. package/notchPath.js +188 -0
  112. package/package.json +77 -0
  113. package/section.js +110 -0
  114. package/setup/mermaid-renderer.ts +2120 -0
  115. package/setup/mermaid.ts +30 -0
  116. package/setup/shiki.ts +44 -0
  117. package/setup/shortcuts.ts +28 -0
  118. package/slide-bottom.vue +305 -0
  119. package/slide-top.vue +268 -0
  120. package/styles/base.css +2611 -0
  121. package/styles/index.ts +71 -0
  122. package/styles/tokens.css +144 -0
  123. package/uno.config.ts +231 -0
  124. package/useNotchCuts.js +200 -0
@@ -0,0 +1,334 @@
1
+ <script setup>
2
+ /*
3
+ ONE asset, read left to right as a row of buckets — a bike's week, a bay's ninety days.
4
+
5
+ ── THIS IS packages/charts/src/FStatusTrack.vue, RE-IMPLEMENTED ───────────────────────────────
6
+ The design system already ships this chart, and a deck cannot import it: FStatusTrack is built on
7
+ Nuxt UI (UTooltip) and Tailwind utility classes, and a Slidev deck compiles through UnoCSS with
8
+ the deck's own token layer. So the rule this repo already follows applies — share the VALUES and
9
+ the pure functions, re-implement the PRESENTATION — and the presentation has to be the same one,
10
+ or the deck is drawing a chart the product does not have.
11
+
12
+ What is copied, deliberately and structurally:
13
+ · ONE TRACK PER COMPONENT. `data` is a flat list of BUCKETS, not a list of lanes. Two assets is
14
+ two tracks, which is what puts their labels in a shared gutter and their segments on a shared
15
+ grid — a lane list would let each row divide its own width differently and quietly stop being
16
+ comparable.
17
+ · EQUAL-WIDTH SEGMENTS with a gap between them. Every bucket is the same span (a day, a week),
18
+ so the segment is a CELL rather than a bar positioned by value; the gap is what makes it read
19
+ as buckets rather than as one continuous band.
20
+ · THE ROW IS `label · track · summary`, with a start/end caption pair beneath and the legend
21
+ under that.
22
+ · TWO MODES. `state` colours by category from the status roles; `intensity` buckets a number
23
+ onto the sequential ramp. They are different questions — "what condition was it in" and "how
24
+ much of it was there" — and the palette follows: a state is never a shade and a quantity is
25
+ never a status colour.
26
+
27
+ ── WHAT THIS VERSION DOES DIFFERENTLY, AND WHY ────────────────────────────────────────────────
28
+ ★ ACCESSORS ARE KEY NAMES, NOT FUNCTIONS. FStatusTrack takes `state: (d) => …` and `value: (d) =>
29
+ …`. A slide cannot: a comark props block is YAML, so it carries data and never a closure. The
30
+ deck takes `state-key` and `value-key` instead, which reaches the same rows through the same
31
+ two modes.
32
+
33
+ ★ ROOM SCALE, BUT NOT UNIFORMLY SCALED. The size ladder's presentation step (cellSize.ts `lg`) is
34
+ 44px tall with a 3px gap and a 3px radius. Height and gap grow for the canvas; the RADIUS does
35
+ not. Scaling it too turned thirty buckets into a row of pills — the corner has to stay small
36
+ relative to the bucket or the row stops reading as one bar divided into days, which is the whole
37
+ picture a status track is. Stated as plain numbers because no deck token is a track height.
38
+
39
+ ★ THE TOOLTIP IS THE DECK'S PANEL, NOT UTooltip. FStatusTrack turns its segments into real
40
+ buttons and hands each one a Nuxt UI tooltip; a deck cannot, so the bucket carries a pointer
41
+ handler and `useDeckTip` drives the same panel every chart on this deck shows. The caption pair
42
+ stays — it names the track's ENDS, which is the reading a projected slide gives an audience
43
+ who cannot point at anything.
44
+ */
45
+ import { computed } from 'vue';
46
+ import ChartFrame from './ChartFrame.vue';
47
+ import { useDeckTip, tipFrame } from './chartTip';
48
+ import { formatValue, isPlottable, stateMessage } from './chartData';
49
+
50
+ // The deck's five status names, in the order Alert.vue lists them. The legend follows this order
51
+ // rather than first-appearance order, so the same five states read the same way on every slide of a
52
+ // deck instead of being re-sorted by whichever bucket happened to fail first.
53
+ const STATES = ['info', 'success', 'warning', 'danger', 'neutral'];
54
+ // The sequential ramp the engine emits, and the same five steps CalendarGrid buckets onto.
55
+ const SHADE_STEPS = 5;
56
+
57
+ const props = defineProps({
58
+ /** The buckets, left to right — one entry per span, every span the same width. In `state` mode
59
+ * each carries a status name under `stateKey`; in `intensity` mode a number under `valueKey`.
60
+ * A bucket with neither draws as the empty track colour, which is what "no reading" looks like. */
61
+ data: { type: Array, required: true },
62
+ /** `state` colours each bucket by category; `intensity` buckets a number onto the sequential ramp. */
63
+ mode: { type: String, default: 'state', validator: (m) => ['state', 'intensity'].includes(m) },
64
+ /** Which field carries the status name in `state` mode. */
65
+ stateKey: { type: String, default: 'state' },
66
+ /** Which field carries the number in `intensity` mode. */
67
+ valueKey: { type: String, default: 'value' },
68
+ /** Which field names a bucket in the tooltip — a date, a week ending. Buckets are equal spans in
69
+ * order and the component cannot know what a span IS, so a row that carries no name leaves the
70
+ * panel titled by the track instead of inventing a unit for it. */
71
+ bucketKey: { type: String, default: 'label' },
72
+ /** The leading gutter label — the asset this track is about. */
73
+ label: { type: String, default: '' },
74
+ /** A figure or phrase closing the row, after the track. */
75
+ summary: { type: String, default: '' },
76
+ /**
77
+ * Width of the label and summary gutters in px, the same on both sides.
78
+ *
79
+ * ★ FIXED, AND THAT IS THE DIFFERENCE FROM THE PRODUCT COMPONENT. FStatusTrack sizes its label to
80
+ * its own content (`shrink-0`), which is right for one track on a page. Stacking is the DECK's
81
+ * normal case — several assets read against each other — and a content-sized gutter starts each
82
+ * track at a different x, so the same bucket sits at a different place on every row and the
83
+ * columns stop being comparable. A fixed gutter is what puts day three above day three.
84
+ */
85
+ labelWidth: { type: Number, default: 220 },
86
+ /** Start and end captions under the track, e.g. `['Monday', 'Sunday']`. Both or neither. */
87
+ caption: { type: Array, default: null, validator: (c) => Array.isArray(c) && c.length === 2 },
88
+ /** Track height in px. A bucket should read TALLER than it is wide — that proportion is what
89
+ * makes a row of them a status bar rather than a row of tiles. At thirty buckets across a deck
90
+ * column each is about 44px, so this sits above it. */
91
+ height: { type: Number, default: 68 },
92
+ /** Space between buckets in px. It is what makes the row read as buckets rather than one band. */
93
+ segmentGap: { type: Number, default: 5 },
94
+ /** Corner radius of a bucket in px. Small: at 8 a thirty-bucket track reads as a row of pills
95
+ * rather than as a bar divided into days. */
96
+ radius: { type: Number, default: 3 },
97
+ /** Intensity mode: the domain the ramp is spread over. Set it when two tracks are read against
98
+ * each other, or each finds its own maximum and they stop being comparable. */
99
+ domain: { type: Array, default: null, validator: (d) => Array.isArray(d) && d.length === 2 },
100
+ /** Draw the legend. */
101
+ legend: { type: Boolean, default: true },
102
+ /** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
103
+ * or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
104
+ * caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
105
+ * full-width plot and pulled to an edge where a centred key would float in a gutter. */
106
+ legendPosition: {
107
+ type: String,
108
+ default: 'bottom-center',
109
+ validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
110
+ },
111
+ /** The sentence drawn when there is nothing to plot. */
112
+ empty: { type: String, default: 'No data' },
113
+ });
114
+
115
+ const rows = computed(() => (Array.isArray(props.data) ? props.data : []));
116
+
117
+ const stateOf = (d) => {
118
+ const raw = d?.[props.stateKey];
119
+ return STATES.includes(raw) ? raw : null;
120
+ };
121
+ const valueOf = (d) => {
122
+ const raw = d?.[props.valueKey];
123
+ return isPlottable(raw) ? raw : null;
124
+ };
125
+
126
+ /*
127
+ The intensity domain. Derived from the data unless one is given — and the prop exists because a
128
+ derived domain is per-track: two tracks side by side would each stretch their own ramp over their
129
+ own range, so an identical shade would mean two different numbers.
130
+ */
131
+ const bounds = computed(() => {
132
+ if (props.domain) return [Number(props.domain[0]), Number(props.domain[1])];
133
+ const values = rows.value.map(valueOf).filter((v) => v !== null);
134
+ if (!values.length) return [0, 0];
135
+ return [Math.min(...values, 0), Math.max(...values)];
136
+ });
137
+
138
+ /*
139
+ ★ ZERO IS NOT THE PALEST SHADE — the same rule CalendarGrid states. A bucket with no reading takes
140
+ the empty track colour and no ramp step at all; shading it --deck-shade-1 would say "a little" of
141
+ something that was never measured.
142
+ */
143
+ const shadeOf = (d) => {
144
+ const v = valueOf(d);
145
+ if (v === null) return null;
146
+ const [min, max] = bounds.value;
147
+ const span = max - min;
148
+ if (!(span > 0)) return 0;
149
+ // Clamped: `var(--deck-shade-6)` is undeclared, and an undefined custom property in a colour slot
150
+ // is not "ignored" — it invalidates the declaration at computed-value time.
151
+ return Math.min(SHADE_STEPS - 1, Math.max(0, Math.floor(((v - min) / span) * SHADE_STEPS)));
152
+ };
153
+
154
+ const colourOf = (d) => {
155
+ if (props.mode === 'intensity') {
156
+ const shade = shadeOf(d);
157
+ return shade === null ? 'var(--deck-surface)' : `var(--deck-shade-${shade + 1})`;
158
+ }
159
+ const state = stateOf(d);
160
+ // ★ THE MARK ROLE, NOT THE SOLID ONE. A bucket carries no text, so the 4.5:1 white-ink floor
161
+ // that holds `solid` at family-600 does not apply to it — and paying that floor anyway is what
162
+ // made warning read as a brown and danger as a brick. `mark` is family-500, which is the step the
163
+ // product itself reaches for (packages/ui/src/styles/forest.css, and apps/docs' own status track).
164
+ return state === null ? 'var(--deck-surface)' : `var(--deck-status-${state}-mark)`;
165
+ };
166
+
167
+ /*
168
+ A bucket names itself if the row gave it a name, and otherwise the panel is titled by the track —
169
+ which is the asset the reader is pointing at, and the only identity this component actually holds.
170
+ The row beneath it is the reading, under the word for the question the mode asks.
171
+
172
+ A bucket with no reading says so. That is the one thing the track's own drawing cannot: an empty
173
+ bucket and a bucket that was measured at the bottom of the ramp are both pale, and the caption
174
+ pair at the ends says nothing about either.
175
+ */
176
+ const READING = { state: 'State', intensity: 'Value' };
177
+ const tipFor = (d) => {
178
+ const named = d?.[props.bucketKey];
179
+ const value = props.mode === 'intensity'
180
+ ? (valueOf(d) === null ? 'No reading' : formatValue(valueOf(d)))
181
+ : (stateOf(d) === null ? 'No reading' : `${stateOf(d).charAt(0).toUpperCase()}${stateOf(d).slice(1)}`);
182
+ return tipFrame(named ?? props.label ?? '', [
183
+ { swatch: colourOf(d), label: named ? (props.label || READING[props.mode]) : READING[props.mode], value },
184
+ ]);
185
+ };
186
+
187
+ const tip = useDeckTip();
188
+
189
+ const segments = computed(() => rows.value.map((d, i) => ({ key: i, color: colourOf(d), row: d })));
190
+
191
+ /*
192
+ The legend names only what the track actually draws, in the fixed order above — a key listing five
193
+ states over a week that was green throughout is a key describing a different week.
194
+ */
195
+ const seriesList = computed(() => {
196
+ if (!rows.value.length) return [];
197
+ if (props.mode === 'intensity') {
198
+ const [min, max] = bounds.value;
199
+ const span = max - min;
200
+ return Array.from({ length: SHADE_STEPS }, (_, i) => ({
201
+ key: `shade-${i}`,
202
+ // The top edge of the step, so a reader decoding a shade gets a number rather than a rank.
203
+ name: span > 0 ? String(Math.round(min + ((i + 1) * span) / SHADE_STEPS)) : String(max),
204
+ color: `var(--deck-shade-${i + 1})`,
205
+ }));
206
+ }
207
+ const present = new Set(rows.value.map(stateOf).filter((s) => s !== null));
208
+ return STATES.filter((s) => present.has(s)).map((s) => ({
209
+ key: s,
210
+ name: `${s.charAt(0).toUpperCase()}${s.slice(1)}`,
211
+ color: `var(--deck-status-${s}-mark)`,
212
+ }));
213
+ });
214
+
215
+ const message = computed(() => {
216
+ if (!rows.value.length) return stateMessage({ kind: 'empty', reason: 'no-rows' }, props.empty);
217
+ const drawn = rows.value.some((d) => (props.mode === 'intensity' ? valueOf(d) !== null : stateOf(d) !== null));
218
+ return drawn ? null : stateMessage({ kind: 'empty', reason: 'no-values' }, props.empty);
219
+ });
220
+ </script>
221
+
222
+ <template>
223
+ <ChartFrame
224
+ :series="seriesList"
225
+ :height="height"
226
+ :legend="legend"
227
+ :legend-position="legendPosition"
228
+ :message="message"
229
+ >
230
+ <div class="deck-track" :style="{ '--track-gutter': `${labelWidth}px` }">
231
+ <div class="deck-track-row">
232
+ <span v-if="label" class="deck-track-label">{{ label }}</span>
233
+ <!-- `role="img"` with one label rather than a name per segment: the tooltip is a pointer
234
+ affordance, and ninety focusable buckets would be a worse reading of the same row than
235
+ the track's own label plus its caption pair. -->
236
+ <div
237
+ class="deck-track-segments"
238
+ role="img"
239
+ :aria-label="`${label ? `${label}: ` : ''}${segments.length} segments`"
240
+ :style="{ gap: `${segmentGap}px`, height: `${height}px` }"
241
+ @mouseleave="tip.hide()"
242
+ >
243
+ <span
244
+ v-for="s in segments"
245
+ :key="s.key"
246
+ class="deck-track-segment"
247
+ :style="{ background: s.color, borderRadius: `${radius}px` }"
248
+ @mousemove="tip.show(tipFor(s.row), $event)"
249
+ />
250
+ </div>
251
+ <span v-if="summary" class="deck-track-summary">{{ summary }}</span>
252
+ </div>
253
+ <div v-if="caption" class="deck-track-caption" :class="[label ? 'has-label' : '', summary ? 'has-summary' : '']">
254
+ <span>{{ caption[0] }}</span>
255
+ <span>{{ caption[1] }}</span>
256
+ </div>
257
+ </div>
258
+ </ChartFrame>
259
+ </template>
260
+
261
+ <style scoped>
262
+ .deck-track {
263
+ display: flex;
264
+ flex-direction: column;
265
+ gap: var(--deck-space-3);
266
+ width: 100%;
267
+ }
268
+
269
+ .deck-track-row {
270
+ display: flex;
271
+ align-items: center;
272
+ gap: var(--deck-space-4);
273
+ }
274
+
275
+ /* ★ A FIXED WIDTH ON BOTH GUTTERS, not `flex: none`. Content-sized gutters make every stacked
276
+ track start and end at a different x — measured on three assets whose names differ by six
277
+ characters, the same day sat about 90px apart across the rows, and a column of buckets that does
278
+ not line up is not a column. Fixed on BOTH sides because the summary shifts the right edge the
279
+ same way the label shifts the left. */
280
+ .deck-track-label,
281
+ .deck-track-summary {
282
+ flex: 0 0 var(--track-gutter);
283
+ overflow: hidden;
284
+ text-overflow: ellipsis;
285
+ font-size: var(--type-deck-label-size);
286
+ font-weight: var(--type-deck-label-weight);
287
+ line-height: var(--type-deck-label-line-height);
288
+ white-space: nowrap;
289
+ }
290
+ .deck-track-label {
291
+ color: var(--deck-fg);
292
+ }
293
+ .deck-track-summary {
294
+ color: var(--deck-fg-muted);
295
+ text-align: right;
296
+ }
297
+
298
+ .deck-track-segments {
299
+ display: flex;
300
+ flex: 1;
301
+ min-width: 0;
302
+ }
303
+ /* Equal width by construction — `flex: 1` with `min-width: 0`, so ninety buckets divide the row
304
+ evenly rather than each claiming its content width. This is the difference between a bucket
305
+ track and a bar chart: a segment's width is its SPAN, and every span here is the same. */
306
+ .deck-track-segment {
307
+ flex: 1;
308
+ min-width: 0;
309
+ }
310
+
311
+ /*
312
+ ★ THE CAPTION NAMES THE TRACK'S ENDS, SO IT IS INSET TO THE TRACK — not to the row. The row is
313
+ `label · segments · summary`, and a caption spanning the whole row put "90 days ago" under the
314
+ LABEL gutter, a whole column to the left of the first bucket it describes. It reads as a caption
315
+ for the names rather than for the days.
316
+
317
+ Both insets are the gutter PLUS the row's own gap, because that is where the segments actually
318
+ begin; and each is applied only when its gutter is rendered, since both the label and the summary
319
+ are conditional and a caption inset past an absent gutter is the same error mirrored.
320
+ */
321
+ .deck-track-caption {
322
+ display: flex;
323
+ justify-content: space-between;
324
+ font-size: var(--type-deck-body-sm-size);
325
+ line-height: var(--type-deck-body-sm-line-height);
326
+ color: var(--deck-fg-subtle);
327
+ }
328
+ .deck-track-caption.has-label {
329
+ padding-left: calc(var(--track-gutter) + var(--deck-space-4));
330
+ }
331
+ .deck-track-caption.has-summary {
332
+ padding-right: calc(var(--track-gutter) + var(--deck-space-4));
333
+ }
334
+ </style>
@@ -0,0 +1,249 @@
1
+ <script setup>
2
+ /*
3
+ A sequence of moments on one rule — the deck's answer to "how did we get here" and "what happens
4
+ next", which every Forest deck ends up drawing by hand out of a Grid and some bullets.
5
+
6
+ ★ TWO ORIENTATIONS, BECAUSE A ROOM IMPOSES A LIMIT A DESK DOES NOT. Horizontal reads best and is
7
+ what a timeline is FOR — time runs left to right — but each stop then gets 1/n of the canvas, and
8
+ at the deck's type scale a title stops fitting somewhere around five. Vertical trades the metaphor
9
+ for the room: time runs down and each stop gets the full measure.
10
+
11
+ ★★ AND THE VERTICAL CEILING IS LOWER THAN IT LOOKS — MEASURED, after this comment first claimed
12
+ "eight fit" and was wrong. Below an h1 and a caption the timeline starts at canvas y 307, a stop
13
+ with no body is 87.8px and the gutter is 32, so the SIXTH stop lands at 993.8 — past the 90px
14
+ safe area, which scripts/deckAudit.ts reports as a real failure. FIVE without a body, FOUR with
15
+ one (a body line takes a stop to about 137px). The body is the lever, not the orientation.
16
+
17
+ The choice is the author's because it depends on how many stops they have and how much each one
18
+ says, which is not something the component can see. The rule the deck already lives by applies to
19
+ both — if it does not fit, use more slides, never smaller type.
20
+
21
+ ★★ `current` IS A POSITION IN A JOURNEY, NOT A POINTER AT ONE STOP — everything before it is
22
+ DONE, and is drawn that way. This is the same model @nuxt/ui's own Timeline uses (its items carry
23
+ `state=completed` before the active index and `state=active` at it, and both the indicator and the
24
+ separator take the primary colour), so a designer who knows the Forest web Timeline finds the same
25
+ semantics on a slide.
26
+
27
+ It started as `highlight`, a single "we are here" marker, and that was wrong on its own data: a
28
+ timeline reading 2021 · 2023 · 2025 · 2026 with 2025 as the present drew 2021 and 2026 IDENTICALLY,
29
+ which throws away the one thing the sequence already knows. A stop that has happened and a stop
30
+ that has not are not the same stop.
31
+
32
+ Both ends of the range are real pictures, and they fall out of the same arithmetic (`i < current`):
33
+
34
+ current: -1 nothing done, nothing current — a plan nobody has started
35
+ current: n (the length) everything done, nothing current — a finished history
36
+
37
+ The MARKER and the RULE are the only places colour is spent. A completed stop fills with the
38
+ accent and the rule leading out of it fills too, so the accent traces the distance travelled; the
39
+ present is the same fill, ENLARGED. Labels stay as they are apart from the present's, because
40
+ colouring the text of every completed stop would say the same thing a second time and leave nothing
41
+ quieter for the stop that is actually the subject.
42
+ */
43
+ defineProps({
44
+ /** The stops, in order. Each is `{ label, title, body? }` — label is the WHEN (a year, a quarter,
45
+ * a month), title the WHAT, body an optional line of detail. */
46
+ items: {
47
+ type: Array,
48
+ required: true,
49
+ validator: (v) => Array.isArray(v) && v.length > 0 && v.every((i) => i && typeof i.title === 'string'),
50
+ },
51
+ /** 'horizontal' — time runs left to right, up to about five stops · 'vertical' — time runs down,
52
+ * each stop gets the full measure. */
53
+ orientation: {
54
+ type: String,
55
+ default: 'horizontal',
56
+ validator: (v) => ['horizontal', 'vertical'].includes(v),
57
+ },
58
+ /** How far along the sequence is: the index of the stop that is the present. Every stop BEFORE it
59
+ * is drawn as done. -1 (the default) is a plan nobody has started; the item count is a finished
60
+ * history, where everything happened and nothing is the present. */
61
+ current: { type: Number, default: -1 },
62
+ });
63
+ </script>
64
+
65
+ <template>
66
+ <div class="deck-timeline" :class="`is-${orientation}`">
67
+ <!-- `is-done` / `is-now` are this deck's names for @nuxt/ui Timeline's own `state=completed` /
68
+ `state=active`. Same model, same picture, deck vocabulary. -->
69
+ <div
70
+ v-for="(item, i) in items"
71
+ :key="i"
72
+ class="deck-timeline-stop"
73
+ :class="{ 'is-done': i < current, 'is-now': i === current }"
74
+ >
75
+ <!-- The rule and the dot are one element: the rule is the stop's own leading edge, so a
76
+ sequence of stops tiles into a continuous line with no separate track to keep in sync. -->
77
+ <div class="deck-timeline-rule" aria-hidden="true"><span class="deck-timeline-dot" /></div>
78
+ <div class="deck-timeline-body">
79
+ <p v-if="item.label" class="deck-timeline-label">{{ item.label }}</p>
80
+ <p class="deck-timeline-title">{{ item.title }}</p>
81
+ <p v-if="item.body" class="deck-timeline-detail">{{ item.body }}</p>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </template>
86
+
87
+ <style scoped>
88
+ .deck-timeline {
89
+ display: grid;
90
+ /* The dot's own diameter, and the rule's thickness. Declared once here rather than repeated down
91
+ the file, because the two orientations position the SAME marker on different axes and a marker
92
+ that differed between them would read as two components. */
93
+ --timeline-dot: 32px;
94
+ --timeline-rule: 6px;
95
+ }
96
+
97
+ /* ── Horizontal ─────────────────────────────────────────────────────────────────────────────── */
98
+ .deck-timeline.is-horizontal {
99
+ grid-auto-flow: column;
100
+ grid-auto-columns: 1fr;
101
+ gap: var(--deck-space-4);
102
+ }
103
+ .is-horizontal .deck-timeline-rule {
104
+ height: var(--timeline-dot);
105
+ display: flex;
106
+ align-items: center;
107
+ margin-bottom: var(--deck-space-3);
108
+ }
109
+ /* ★★ THE RULE HAS TO BRIDGE THE GUTTER, or the line is not a line. Each stop is a grid COLUMN and
110
+ its rule runs the column's own width — which stops one `gap` short of the next stop's dot, at
111
+ every joint. Measured: dots 222px apart, rule 206px long, so a 16px break four times over, read
112
+ as a dashed line rather than a timeline.
113
+
114
+ The negative margin carries it across the gutter to the next dot. A negative margin on a `flex: 1`
115
+ item adds to the space it may fill, so the rule really does extend past its own column rather than
116
+ being clipped to it. Same fix, same reason, as the vertical rule's own margin-bottom below. */
117
+ .is-horizontal .deck-timeline-rule::after {
118
+ content: '';
119
+ flex: 1;
120
+ height: var(--timeline-rule);
121
+ background: var(--deck-border);
122
+ margin-right: calc(-1 * var(--deck-space-4));
123
+ }
124
+ .is-horizontal .deck-timeline-stop:last-child .deck-timeline-rule::after {
125
+ display: none;
126
+ }
127
+
128
+ /* ── Vertical ───────────────────────────────────────────────────────────────────────────────── */
129
+ .deck-timeline.is-vertical {
130
+ grid-auto-flow: row;
131
+ gap: var(--deck-space-4);
132
+ }
133
+ .is-vertical .deck-timeline-stop {
134
+ display: grid;
135
+ grid-template-columns: var(--timeline-dot) 1fr;
136
+ gap: var(--deck-space-3);
137
+ }
138
+ .is-vertical .deck-timeline-rule {
139
+ display: flex;
140
+ flex-direction: column;
141
+ align-items: center;
142
+ /* Pull the dot up onto the first line's cap height rather than its line box, so it aligns with
143
+ the label beside it instead of floating above it. */
144
+ padding-top: calc((var(--type-deck-overline-size) * var(--type-deck-overline-line-height) - var(--timeline-dot)) / 2);
145
+ }
146
+ /* ★ THE RULE BRIDGES THE GAP BETWEEN STOPS. Each stop is a grid ROW, so a rule that stopped at the
147
+ row's own end would break at every gutter and the sequence would read as a stack of separate
148
+ items rather than as one line. The negative bottom margin carries it across the gap to the next
149
+ stop's dot. */
150
+ .is-vertical .deck-timeline-rule::after {
151
+ content: '';
152
+ flex: 1;
153
+ width: var(--timeline-rule);
154
+ background: var(--deck-border);
155
+ margin-bottom: calc(-1 * var(--deck-space-4));
156
+ }
157
+ .is-vertical .deck-timeline-stop:last-child .deck-timeline-rule::after {
158
+ display: none;
159
+ }
160
+
161
+ /* ── The marker ─────────────────────────────────────────────────────────────────────────────── */
162
+ /* ★ THE LINE RUNS UNDER THE MARKERS, IN BOTH ORIENTATIONS, and the z-index is what makes that safe.
163
+ A pseudo-element paints as its host's LAST child, so the rule would otherwise be drawn over the
164
+ dot — visible as a hairline across the present marker, which is scaled and so overhangs the layout
165
+ box the rule starts from. Lifting the dot one step lets the rule butt right up to it instead of
166
+ being held off by a margin, which is what makes the two orientations draw the same picture:
167
+ horizontal never had that margin and vertical did. */
168
+ .deck-timeline-dot {
169
+ width: var(--timeline-dot);
170
+ height: var(--timeline-dot);
171
+ border-radius: var(--deck-radius-full);
172
+ background: var(--deck-bg);
173
+ border: var(--timeline-rule) solid var(--deck-border-strong);
174
+ flex: none;
175
+ box-sizing: border-box;
176
+ position: relative;
177
+ z-index: 1;
178
+ }
179
+ /* ★ THE PRESENT IS BIGGER, not merely a different colour, and that is a room decision rather than a
180
+ taste one. A filled dot and an outlined dot of the same diameter are one shape at two weights, and
181
+ at the back of a room the weight is the first thing to go — the same reason Carousel's current
182
+ indicator is a LONGER bar rather than a darker one. Scaled 1.4x and filled, with a ring in the
183
+ page colour so it reads as lifted off the rule rather than as a blob sitting on it.
184
+
185
+ `--deck-accent-soft` was tried as a second ring and taken out: on warm paper it is a pale mint
186
+ about one step off the ground, so it contributed nothing at distance while adding a shape at
187
+ close range. */
188
+ /* ★★ SCALED, NOT RESIZED, and the difference is measurable. Growing the dot with width/height grows
189
+ it from its top-left corner: its own centre moves off the pitch and the flex item beside it
190
+ shortens to compensate. Measured on four evenly-spaced stops, dot centres came out 222 / 225.1 /
191
+ 218.8 apart — a marker 3px off the rhythm every other stop is drawn on, and it reads as a wobble
192
+ rather than as an emphasis.
193
+
194
+ `scale` grows about the centre and takes no part in layout at all, so the pitch stays constant and
195
+ the rule beside it does not move. The ring grows with it, which is the right behaviour: it is one
196
+ marker being made larger, not a dot with a separately-sized halo. */
197
+ /* ── DONE: the distance travelled ──────────────────────────────────────────────────────────────
198
+ A completed stop fills, and so does the rule LEADING OUT of it. Each stop's `::after` is the
199
+ segment between it and the next one, so `.is-done` on stop i colours exactly the leg i → i+1 —
200
+ and since i < current implies i+1 <= current, that leg really has been travelled. The present's
201
+ own `::after` stays in the border colour: the road ahead is not road behind. */
202
+ .is-done .deck-timeline-dot {
203
+ background: var(--deck-accent);
204
+ border-color: var(--deck-accent);
205
+ }
206
+ .is-done .deck-timeline-rule::after {
207
+ background: var(--deck-accent);
208
+ }
209
+
210
+ .is-now .deck-timeline-dot {
211
+ scale: 1.4;
212
+ background: var(--deck-accent);
213
+ border-color: var(--deck-accent);
214
+ box-shadow: 0 0 0 var(--deck-space-1) var(--deck-bg);
215
+ }
216
+
217
+ /* ── The type ───────────────────────────────────────────────────────────────────────────────── */
218
+ .deck-timeline-label {
219
+ font-family: var(--type-deck-overline-family);
220
+ font-size: var(--type-deck-overline-size);
221
+ font-weight: var(--type-deck-overline-weight);
222
+ line-height: var(--type-deck-overline-line-height);
223
+ letter-spacing: var(--type-deck-overline-tracking);
224
+ text-transform: uppercase;
225
+ color: var(--deck-fg-muted);
226
+ margin: 0 0 var(--deck-space-1) 0;
227
+ }
228
+ .is-now .deck-timeline-label {
229
+ color: var(--deck-accent);
230
+ }
231
+ .deck-timeline-title {
232
+ font-family: var(--type-deck-heading-xs-family);
233
+ font-size: var(--type-deck-heading-xs-size);
234
+ font-weight: var(--type-deck-heading-xs-weight);
235
+ line-height: var(--type-deck-heading-xs-line-height);
236
+ color: var(--deck-fg);
237
+ margin: 0;
238
+ /* The stop is already a column; a measure on top of that would leave a ragged gap inside a box
239
+ the grid has already sized. */
240
+ max-width: none;
241
+ }
242
+ .deck-timeline-detail {
243
+ font-size: var(--type-deck-body-sm-size);
244
+ line-height: var(--type-deck-body-sm-line-height);
245
+ color: var(--deck-fg-muted);
246
+ margin: var(--deck-space-1) 0 0 0;
247
+ max-width: none;
248
+ }
249
+ </style>