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