@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,244 @@
1
+ <script setup>
2
+ /*
3
+ A table sized for a room.
4
+
5
+ The 65-page Forest template catalogue puts a data table on two of its pages, and this theme had
6
+ no analog — which meant a slide wanting one had to hand-roll `<table>` markup, the exact thing a
7
+ brand guardrail exists to prevent. So: a small number of rows and columns, a row tall enough to
8
+ track across 1920px, figures in the tabular treatment, and a header that reads from the back.
9
+
10
+ ── FIGURES ────────────────────────────────────────────────────────────────────────────────────
11
+ Mohr ships no tabular figure set — measured in packages/tokens/type/type.css, its "1" is 13.01px
12
+ against "0" at 23.66px, and font-variant-numeric moves neither — so a column of Mohr numerals
13
+ cannot line up, whatever CSS is applied. That is why `.type-tabular` (the engine's own treatment,
14
+ used here rather than restated) swaps the family to --font-sans as well as switching the figure
15
+ set on. This component composes that class onto its numeric cells and never declares a
16
+ font-family on a cell of its own, so the class is what actually wins.
17
+
18
+ Kpi.vue makes the OPPOSITE call for the same reason, and both are right: its figure is a single
19
+ shout with nothing to align against, so it keeps the display face; a table's whole job is
20
+ alignment down a column, so it takes the sans. The formatting itself is shared — cellParts()
21
+ runs formatMetric(), the same function Kpi.vue formats with — so a figure in a table and the same
22
+ figure in a KPI are notated identically.
23
+
24
+ ── THE TWO CEILINGS ───────────────────────────────────────────────────────────────────────────
25
+ Rows truncate and say so; columns refuse to draw at all. components/tableRules.ts's own header
26
+ carries the full reasoning and the measurement behind ROW_CEILING — the short version is that
27
+ dropping a row is a shorter list, and dropping a column is a different claim.
28
+
29
+ Not a sortable, scrollable, paginated table. A slide has no cursor, and a presenter cannot hover.
30
+ */
31
+ import { computed } from 'vue';
32
+ import { cellParts, tableMessage, tableState, truncationNotice } from './tableRules';
33
+
34
+ const props = defineProps({
35
+ /** The rows, in the order they should read — one plain object per row, keyed by column key. */
36
+ rows: { type: Array, required: true },
37
+ /** Columns in draw order: `[{ key, label, align, numeric, format, precision, unit }]`. Omit it and the columns are read off the rows' own keys. */
38
+ columns: { type: Array, default: undefined },
39
+ /** How many rows to draw before the rest are withheld and counted on the slide. Clamped to 1…7; the default 5 is what fits under a heading, and 7 needs a slide with no heading at all. */
40
+ maxRows: { type: Number, default: 5 },
41
+ /** Default notation for every numeric column: 'full' spells out every digit, 'compact' abbreviates (1.2m, 999k). A column's own `format` overrides it. */
42
+ format: { type: String, default: 'full', validator: (f) => ['full', 'compact'].includes(f) },
43
+ /** Locale the figures are formatted in, e.g. 'en-GB'. */
44
+ locale: { type: String, default: 'en-GB' },
45
+ /** A source line under the table, e.g. "TfL journey data, Q2 2026". Rendered, never a tooltip. */
46
+ note: { type: String, default: '' },
47
+ /** The sentence drawn in place of the table when there is nothing to draw. */
48
+ empty: { type: String, default: 'No data' },
49
+ });
50
+
51
+ const state = computed(() => tableState(props.rows, props.columns, props.maxRows, props.format));
52
+ const message = computed(() => (state.value.kind === 'ready' ? null : tableMessage(state.value, props.empty)));
53
+ const notice = computed(() =>
54
+ state.value.kind === 'ready' ? truncationNotice(state.value.total, state.value.rows.length) : null,
55
+ );
56
+
57
+ /* The whole table, resolved once: a row of `{ column, text, unit }` per data row. Built here rather
58
+ than called from the template so cellParts() runs once per cell instead of once per interpolation
59
+ — and so the shape the template walks is the same shape the tests assert on. */
60
+ const grid = computed(() =>
61
+ state.value.kind === 'ready'
62
+ ? state.value.rows.map((row) =>
63
+ state.value.columns.map((column) => ({ column, ...cellParts(row?.[column.key], column, props.locale) })),
64
+ )
65
+ : [],
66
+ );
67
+ </script>
68
+
69
+ <template>
70
+ <table v-if="state.kind === 'ready'" class="deck-data-table">
71
+ <!-- A real <caption>, not a sibling <div>: it belongs to the table, so a screen reader reaches
72
+ it with the table and it cannot drift away from the figure it qualifies. `caption-side`
73
+ puts it under the rows, which is where a source line and a truncation notice read. -->
74
+ <caption v-if="notice || note" class="deck-data-table-caption">
75
+ <span v-if="notice" class="deck-data-table-truncation">{{ notice }}</span>
76
+ <span v-if="notice && note" class="deck-data-table-sep" aria-hidden="true"> · </span>
77
+ <span v-if="note" class="deck-data-table-note">{{ note }}</span>
78
+ </caption>
79
+
80
+ <thead>
81
+ <tr>
82
+ <th
83
+ v-for="column in state.columns"
84
+ :key="column.key"
85
+ scope="col"
86
+ class="deck-data-table-head"
87
+ :class="`is-${column.align}`"
88
+ >{{ column.label }}</th>
89
+ </tr>
90
+ </thead>
91
+
92
+ <tbody>
93
+ <tr v-for="(cells, index) in grid" :key="index">
94
+ <!-- The first cell is the row's NAME, so it is a <th scope="row"> — the rest are data.
95
+ Semantics, not decoration: it is what lets a figure be announced with the row it
96
+ belongs to instead of on its own. No whitespace between a value and its unit — the
97
+ 0.1em optical gap is set in CSS, because "12 %" is wrong in en-GB and a markup space
98
+ cannot be taken back. A multi-letter unit ("km") reads better named in the header. -->
99
+ <template v-for="(cell, position) in cells" :key="cell.column.key">
100
+ <th
101
+ v-if="position === 0"
102
+ scope="row"
103
+ class="deck-data-table-row-head"
104
+ :class="[`is-${cell.column.align}`, cell.column.numeric ? 'type-tabular' : null]"
105
+ >{{ cell.text }}<span v-if="cell.unit" class="deck-data-table-unit">{{ cell.unit }}</span></th>
106
+ <td
107
+ v-else
108
+ class="deck-data-table-cell"
109
+ :class="[`is-${cell.column.align}`, cell.column.numeric ? 'type-tabular' : null]"
110
+ >{{ cell.text }}<span v-if="cell.unit" class="deck-data-table-unit">{{ cell.unit }}</span></td>
111
+ </template>
112
+ </tr>
113
+ </tbody>
114
+ </table>
115
+
116
+ <!-- Same well ChartFrame.vue draws for a chart with nothing to plot: the honest empty state
117
+ occupies space rather than collapsing, so a slide does not silently look finished. -->
118
+ <div v-else class="deck-data-table-empty">{{ message }}</div>
119
+ </template>
120
+
121
+ <style scoped>
122
+ /* Every rule here has to out-rank base.css's own `.slidev-layout table/th/td` block, which is sized
123
+ for a markdown table in a paragraph — 8px of padding, muted cell ink. A scoped compound selector
124
+ (`.deck-data-table th` → `.deck-data-table th[data-v-…]`, 0-2-1) clears `.slidev-layout th`
125
+ (0-1-1) on specificity alone, so nothing here needs !important and nothing depends on order. */
126
+ .deck-data-table {
127
+ width: 100%;
128
+ /* `separate` with zero spacing, not `collapse`: collapse resolves adjacent borders against each
129
+ other and the header's accent rule loses half its weight to the first row's own border. */
130
+ border-collapse: separate;
131
+ border-spacing: 0;
132
+ /* Declared once, on the table — never on a cell. A numeric cell carries `.type-tabular`, whose
133
+ own font-family beats an INHERITED value but would lose to a scoped per-cell declaration. */
134
+ font-family: var(--type-deck-body-sm-family);
135
+ font-size: var(--type-deck-body-sm-size);
136
+ line-height: var(--type-deck-body-sm-line-height);
137
+ color: var(--deck-fg);
138
+ }
139
+
140
+ .deck-data-table th,
141
+ .deck-data-table td {
142
+ /* The row height. 24px above and below 36px type at 1.5 measures 104px including the rule — a
143
+ band the eye can track across 1920px, and the figure ROW_CEILING is derived from. */
144
+ padding-block: var(--deck-space-3);
145
+ padding-inline: var(--deck-space-4);
146
+ vertical-align: baseline;
147
+ }
148
+ /* Flush at both edges, so the table's first character sits on the same vertical as the slide's
149
+ heading. The gutter is BETWEEN columns; it is not a margin on the block. */
150
+ .deck-data-table tr > :first-child { padding-inline-start: 0; }
151
+ .deck-data-table tr > :last-child { padding-inline-end: 0; }
152
+
153
+ .deck-data-table .is-start { text-align: start; }
154
+ .deck-data-table .is-center { text-align: center; }
155
+ .deck-data-table .is-end { text-align: end; }
156
+
157
+ .deck-data-table-head {
158
+ font-size: var(--type-deck-overline-size);
159
+ font-weight: var(--type-deck-overline-weight);
160
+ letter-spacing: var(--type-deck-overline-tracking);
161
+ line-height: var(--type-deck-overline-line-height);
162
+ text-transform: uppercase;
163
+ color: var(--deck-fg-muted);
164
+ /* 3px, not base.css's 2: this is the one rule in the table that has to read as a division rather
165
+ than a separator, and a 2px line at presenting scale is a hairline. */
166
+ border-bottom: 3px solid var(--deck-accent);
167
+ white-space: nowrap;
168
+ }
169
+
170
+ .deck-data-table tbody tr td,
171
+ .deck-data-table tbody tr th {
172
+ border-bottom: 2px solid var(--deck-border);
173
+ }
174
+
175
+ .deck-data-table-row-head {
176
+ font-weight: var(--type-deck-label-weight);
177
+ color: var(--deck-fg);
178
+ }
179
+
180
+ .deck-data-table-cell {
181
+ /* Full ink, not base.css's --deck-fg-muted: a muted figure is a readable choice in a document and
182
+ the wrong one across a room, where the cell IS the message. */
183
+ color: var(--deck-fg);
184
+ }
185
+ /* Figures carry weight — the column an audience is actually reading. */
186
+ .deck-data-table-cell.type-tabular {
187
+ font-weight: var(--type-deck-label-weight);
188
+ }
189
+ /* Full size, NOT the 0.5em shrink Kpi.vue gives its own unit, and the difference is the tier: Kpi's
190
+ figure is 96px, so a half-size unit is still 48px and reads as a considered pair. At a 36px cell
191
+ the same move produced a 25px glyph that read as a typo — rendered and looked at. The unit is
192
+ distinguished by ink here instead, which costs the figure no height and no alignment. */
193
+ .deck-data-table-unit {
194
+ margin-inline-start: 0.1em;
195
+ color: var(--deck-fg-muted);
196
+ font-weight: var(--type-deck-body-sm-weight);
197
+ }
198
+
199
+ .deck-data-table-caption {
200
+ caption-side: bottom;
201
+ text-align: start;
202
+ /* space-2, not space-3. The caption's own height budget below is measured against the safe-area
203
+ floor, and the same arithmetic applies above it: at space-3 the truncation notice sat 92px from
204
+ the bottom edge, which cleared the old 90px text inset and misses the current 96px one by 4px.
205
+ scripts/deckAudit.ts caught it on slides 50, 51 and 52. 16px still separates the caption from
206
+ the last row by a full rung and buys 8px of clearance. */
207
+ padding-top: var(--deck-space-2);
208
+ font-size: var(--type-deck-caption-size);
209
+ line-height: var(--type-deck-caption-line-height);
210
+ }
211
+ /* ONE wrapping line, not a stacked pair, and that is a measured decision rather than a preference:
212
+ a caption line costs 50px, and 307 (heading) + 68 (header row) + 5 x 104 (rows) + two caption
213
+ lines is 1019 against a 990 safe-area floor — scripts/deckAudit.ts caught exactly that, 30px past
214
+ the bottom inset, on the first pass of this component. Inline spans on one line cost 74px total
215
+ and still wrap honestly on a narrow column, where the overflow would be visible rather than
216
+ silent. Nothing here sets `display`: a <caption> is a table-caption box, and forcing it to flex
217
+ takes it out of the table's own caption flow. */
218
+ /* The truncation notice takes the accent and the row-label weight, one step louder than the source
219
+ line beside it: "there is more data than this" is a statement about the figure itself, not a
220
+ footnote about where it came from. */
221
+ .deck-data-table-truncation {
222
+ font-weight: var(--type-deck-label-weight);
223
+ color: var(--deck-accent);
224
+ }
225
+ .deck-data-table-sep,
226
+ .deck-data-table-note {
227
+ color: var(--deck-fg-subtle);
228
+ }
229
+
230
+ /* Lifted from ChartFrame.vue's own empty state, deliberately identical: a table with nothing to
231
+ draw and a chart with nothing to plot are the same failure and should look like it. */
232
+ .deck-data-table-empty {
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ text-align: center;
237
+ padding: var(--deck-space-5);
238
+ background: var(--deck-surface);
239
+ border-radius: var(--deck-radius-nested);
240
+ color: var(--deck-fg-muted);
241
+ font-size: var(--type-deck-body-size);
242
+ line-height: var(--type-deck-body-line-height);
243
+ }
244
+ </style>
@@ -0,0 +1,409 @@
1
+ <script setup>
2
+ /*
3
+ A LIVE Mapbox GL map on a slide, on Forest's own house style.
4
+
5
+ ── WHY LIVE, AND NOT A PICTURE ────────────────────────────────────────────────────────────────
6
+ The Static Images API was tried first and DROPPED: on the Forest style it answers HTTP 200 with a
7
+ valid PNG that draws nothing — the style imports Standard, and Standard's imports do not compose
8
+ through the static renderer. A screenshot pasted into a deck goes stale the day the style is
9
+ republished, and cannot carry the marks a slide wants to put on it. So this is the real GL map.
10
+
11
+ ── THE THREE THINGS THAT MAKE A GL MAP SAFE IN A DECK ─────────────────────────────────────────
12
+
13
+ ★★ `preserveDrawingBuffer: true`, OR THE EXPORT IS BLANK. `slidev export` drives a real browser
14
+ and screenshots it. WebGL is allowed to discard the drawing buffer after each composite, and by
15
+ the time the PDF renderer reads the canvas back there is nothing in it — measured: a map that is
16
+ correct on screen prints as an empty rectangle, with no error anywhere. The flag costs a little
17
+ memory and is the whole reason a map can be in a deck at all.
18
+
19
+ ★ NOT INTERACTIVE BY DEFAULT. A presenter scrolling past a slide should not leave the map at some
20
+ other city for the rest of the talk, and a stray trackpad nudge during a talk is unrecoverable in
21
+ front of a room. `interactive` opts back in for a live demo.
22
+
23
+ ★ AN HONEST EMPTY STATE. A map with no token is a blank rectangle that looks like a bug in the
24
+ deck. Without `VITE_MAPBOX_TOKEN` — which is the case in CI, deliberately, since no token is
25
+ committed — this draws a panel saying exactly that instead. It keeps the audit deterministic: the
26
+ gate measures the same geometry on every run rather than depending on a network and a secret.
27
+
28
+ ── THE TOKEN ─────────────────────────────────────────────────────────────────────────────────
29
+ Read from `import.meta.env.VITE_MAPBOX_TOKEN`, the same name the rest of this repo uses, or passed
30
+ per-slide as `access-token`. A Mapbox `pk.…` token is a PUBLIC token and is designed to ship in a
31
+ client bundle — but note it BAKES INTO `dist/` at build time, so a deck built with one and then
32
+ handed to somebody carries it. Scope and restrict the token by URL in the Mapbox account; do not
33
+ reach for a secret `sk.…` token here, which must never be in a bundle.
34
+
35
+ The style is imported rather than restated: packages/ui/src/map/style.ts is a pure module with no
36
+ imports of its own, so the deck can share the VALUE without pulling in the product's component
37
+ layer — the same reach across the package boundary Kpi.vue and IconTile.vue already make.
38
+ */
39
+ import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
40
+ import mapboxgl from 'mapbox-gl';
41
+ import 'mapbox-gl/dist/mapbox-gl.css';
42
+ import { FOREST_MAP_STYLE } from '@humanforest/ui/map/style';
43
+
44
+ const props = defineProps({
45
+ /** Centre longitude. Defaults to central London, which is the only city this deck is ever about. */
46
+ lng: { type: Number, default: -0.1005 },
47
+ /** Centre latitude. */
48
+ lat: { type: Number, default: 51.5155 },
49
+ /** Zoom. 10 holds most of Greater London; 13 is a borough; 15 is a street. */
50
+ zoom: { type: Number, default: 11 },
51
+ /** Tilt in degrees. Above 45 the horizon enters the frame and a slide loses its edge. */
52
+ pitch: { type: Number, default: 0 },
53
+ /** Rotation in degrees. */
54
+ bearing: { type: Number, default: 0 },
55
+ /** Mapbox style URL. Defaults to Forest's own house style. */
56
+ mapStyle: { type: String, default: FOREST_MAP_STYLE },
57
+ /**
58
+ * How the basemap is lit: `day` or `night`. This is what makes a map read as dark — the style is
59
+ * the same Forest basemap either way, so a dark slide keeps the brand map rather than swapping
60
+ * to a second one that would have to be kept in sync.
61
+ *
62
+ * ★ TWO VALUES, WHERE THE BASEMAP OFFERS FOUR. Mapbox also lights `dawn` and `dusk`, and
63
+ * packages/ui's FMap passes all four through because a product surface can want a time of day.
64
+ * A deck has one axis and it is the REGISTER: a slide is light or it is `deck-dark`, and there is
65
+ * no dusk register for a dusk map to belong to. Narrowed here rather than passed through, because
66
+ * a specimen book has to draw every value it claims and two of those four would be drawn to
67
+ * satisfy a gate rather than to show anything.
68
+ *
69
+ * ★ NO `auto` EITHER, where FMap has one. That follows the APP's colour mode; a deck has none —
70
+ * the register belongs to the slide — so a slide that wants a dark map says which one.
71
+ *
72
+ * ★★ APPLIED AT CREATION, NOT AFTER `style.load`. Setting it later paints the map in day and
73
+ * corrects it a beat afterwards, which is a visible flash of the wrong register on every load —
74
+ * and on an exported slide it is whichever frame the capture happened to catch.
75
+ */
76
+ lightPreset: {
77
+ type: String,
78
+ default: 'day',
79
+ validator: (v) => ['day', 'night'].includes(v),
80
+ },
81
+ /** Public `pk.…` token. Falls back to `VITE_MAPBOX_TOKEN` from the build environment. */
82
+ accessToken: { type: String, default: '' },
83
+ /** Plot height in px. */
84
+ /** Height in px. **0 fills the parent** — see `.deck-map.is-fill` below for when to reach for it. */
85
+ height: { type: Number, default: 560 },
86
+ /** Let the presenter pan and zoom. Off by default — see the note above. */
87
+ interactive: { type: Boolean, default: false },
88
+ /**
89
+ * Points to draw, `[{ lng, lat }]`. Rendered as one GeoJSON circle layer in the deck accent
90
+ * rather than as N `mapboxgl.Marker` DOM nodes: markers are absolutely-positioned HTML that the
91
+ * geometry pass would have to measure and the export would have to composite separately, and a
92
+ * layer is one paint inside the canvas the rest of the slide already accounts for.
93
+ */
94
+ marks: { type: Array, default: () => [] },
95
+ /** Sentence drawn in place of the map when it cannot be shown. */
96
+ empty: { type: String, default: 'No Mapbox token in this build — set VITE_MAPBOX_TOKEN to draw the map.' },
97
+ });
98
+
99
+ const token = props.accessToken || import.meta.env?.VITE_MAPBOX_TOKEN || mapboxgl.accessToken || '';
100
+ const holder = ref(null);
101
+ const map = shallowRef(null);
102
+ /* A style that never arrives leaves a blank rectangle: a revoked token, a bad style id, a room with
103
+ no network. Held as a message so the frame can say WHICH rather than showing nothing. */
104
+ const ready = ref(false);
105
+ const failure = ref(token ? null : props.empty);
106
+
107
+ const MARK_SOURCE = 'deck-map-marks';
108
+
109
+ function addMarks(m) {
110
+ if (!props.marks.length) return;
111
+ m.addSource(MARK_SOURCE, {
112
+ type: 'geojson',
113
+ data: {
114
+ type: 'FeatureCollection',
115
+ features: props.marks.map((p) => ({
116
+ type: 'Feature',
117
+ geometry: { type: 'Point', coordinates: [p.lng, p.lat] },
118
+ properties: {},
119
+ })),
120
+ },
121
+ });
122
+ m.addLayer({
123
+ id: `${MARK_SOURCE}-circle`,
124
+ type: 'circle',
125
+ source: MARK_SOURCE,
126
+ /*
127
+ STACKING ORDER, and only that — the black marks below were a lighting problem, not this one.
128
+ The Forest style imports Mapbox Standard, which is SLOTTED: a layer added with no slot sits
129
+ low in the stack and the basemap's own labels can print over it. `top` is the value
130
+ packages/ui names MAP_SLOT.mark — "every mark: vehicles, places, counted bays, clusters,
131
+ selection rings" — spelled as a literal here rather than imported, for the same reason the
132
+ rest of this deck shares VALUES with the product and not runtime.
133
+ */
134
+ slot: 'top',
135
+ paint: {
136
+ // Read off the deck's own custom properties at mount. A GL paint property is a value in a
137
+ // WebGL program, not a CSS declaration, so it cannot be a var() reference and does not follow
138
+ // a register flip on its own — which is why a deck map is a light-register device.
139
+ 'circle-radius': 10,
140
+ 'circle-color': readVar('--deck-accent', '#046c4c'),
141
+ 'circle-stroke-width': 4,
142
+ 'circle-stroke-color': readVar('--deck-bg', '#fff9f2'),
143
+ /*
144
+ ★★ EMISSIVE, OR THE NIGHT PRESET LIGHTS THE MARK INTO THE GROUND. Mapbox Standard is a LIT
145
+ style: it carries a 3D lighting model, and a layer at the default emissive strength of 0 is
146
+ shaded by the scene like anything else in it. Under `lightPreset: night` the ambient light
147
+ is nearly black, so a mark declared #3bd996 rendered rgb(0,14,24) — measured at all four
148
+ projected centres, while `getPaintProperty` cheerfully reported the bright green it had been
149
+ given. The colour was never wrong and the slot was never wrong; the mark was being LIT.
150
+
151
+ 1 means "emit this colour, ignore the scene", which is what a mark is: it belongs to the
152
+ deck's palette rather than to the basemap's world, and it has to read the same on both
153
+ presets. Costs nothing on `day`, where the ambient light is already near white.
154
+ */
155
+ 'circle-emissive-strength': 1,
156
+ },
157
+ });
158
+ }
159
+
160
+ /*
161
+ ★★ A DECK COLOUR REACHES MAPBOX ONLY AFTER BEING CONVERTED OUT OF P3. This design system serves
162
+ its palette in Display P3 — `getComputedStyle` hands back `color(display-p3 .1815 .4169 .3069)` —
163
+ and Mapbox's style spec parser accepts hex, rgb(), hsl() and the named colours, nothing else. It
164
+ does not merely ignore what it cannot parse: `addLayer` THROWS, the style never finishes loading,
165
+ and the whole map is blank. Measured, and it is the exact error this component's own panel
166
+ reported: `layers.deck-map-marks-circle.paint.circle-color: color expected,
167
+ "color(display-p3 .1815 .4169 .3069)" found`.
168
+
169
+ The canvas 2D context is the conversion, and it needs no library. That narrowing is a real loss —
170
+ a P3 green outside the sRGB gamut comes back clamped — but it is the right loss here, because a
171
+ WebGL basemap is being asked for an sRGB value either way.
172
+
173
+ ★★ READING `fillStyle` BACK IS NO LONGER THE CONVERSION, AND THAT CHANGED UNDER US. This used to
174
+ assign the colour and return `ctx.fillStyle`, on the documented behaviour that the property
175
+ normalises what it stores to `#rrggbb`. Current Chrome PRESERVES the `color()` form: measured,
176
+ assigning `color(display-p3 0.1815 0.4169 0.3069)` reads back byte-identical, not as a hex. So
177
+ this handed Mapbox the very string the block above says it cannot parse, `addLayer` threw, and
178
+ the marks silently stopped drawing — `getSource` still returned the source, because a source
179
+ carries no colour, and `getLayer` returned undefined. Nothing was logged: the throw happens
180
+ inside the `load` handler, and the component's own error path only reports what Mapbox emits.
181
+
182
+ RASTERISING is the conversion that survives: fill one pixel and read it back, which forces the
183
+ paint through sRGB whatever the serialization does. Alpha is carried through as `rgba()` because
184
+ the style spec takes that and a hex would drop it.
185
+ */
186
+ function toSrgb(value, fallback) {
187
+ try {
188
+ const canvas = document.createElement('canvas');
189
+ canvas.width = 1;
190
+ canvas.height = 1;
191
+ const ctx = canvas.getContext('2d');
192
+ if (!ctx) return fallback;
193
+ // A context's initial fillStyle is '#000000'; if the assignment is rejected as unparseable the
194
+ // property keeps its previous value, so an unchanged black is how a failure shows up here.
195
+ ctx.fillStyle = '#000000';
196
+ ctx.fillStyle = value;
197
+ if (ctx.fillStyle === '#000000' && value.trim() !== '#000000') return fallback;
198
+ ctx.fillRect(0, 0, 1, 1);
199
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
200
+ const hex = (n) => n.toString(16).padStart(2, '0');
201
+ return a === 255 ? `#${hex(r)}${hex(g)}${hex(b)}` : `rgba(${r}, ${g}, ${b}, ${Number((a / 255).toFixed(3))})`;
202
+ } catch {
203
+ return fallback;
204
+ }
205
+ }
206
+
207
+ function readVar(name, fallback) {
208
+ if (typeof window === 'undefined' || !holder.value) return fallback;
209
+ const v = getComputedStyle(holder.value).getPropertyValue(name).trim();
210
+ return v ? toSrgb(v, fallback) : fallback;
211
+ }
212
+
213
+ let sizeWatcher = null;
214
+
215
+ onMounted(() => {
216
+ if (!token || !holder.value) return;
217
+ mapboxgl.accessToken = token;
218
+ let m;
219
+ try {
220
+ m = new mapboxgl.Map({
221
+ container: holder.value,
222
+ style: props.mapStyle,
223
+ center: [props.lng, props.lat],
224
+ zoom: props.zoom,
225
+ pitch: props.pitch,
226
+ bearing: props.bearing,
227
+ interactive: props.interactive,
228
+ config: { basemap: { lightPreset: props.lightPreset } },
229
+ // ★★ See the header. Without this the exported PDF is an empty rectangle.
230
+ preserveDrawingBuffer: true,
231
+ // A deck is never a place for the Mapbox logo to be clicked, but attribution is a licence
232
+ // term rather than a design choice — it stays, compact.
233
+ attributionControl: true,
234
+ });
235
+ } catch (e) {
236
+ failure.value = `Map could not start — ${String(e?.message ?? e)}`;
237
+ return;
238
+ }
239
+ map.value = m;
240
+ /*
241
+ ★★ ONLY A PRE-LOAD ERROR IS FATAL, and getting this wrong DESTROYS A WORKING MAP. Mapbox emits
242
+ `error` for plenty of things that are not the map failing: a blocked telemetry beacon
243
+ (events.mapbox.com answering with status 0 behind a content blocker — measured, that is exactly
244
+ what happens in the audit's own browser), a single tile that 404s, a sprite that arrives late.
245
+ Treating every one of them as fatal set `failure`, which swapped the holder out for the panel —
246
+ and because the holder is the map's CONTAINER, a live map with its style loaded and its tiles
247
+ painted was torn out of the document because a beacon was refused.
248
+
249
+ After `load` the map is a map. An error past that point is logged for whoever opens the console
250
+ and changes nothing on the slide.
251
+ */
252
+ m.on('error', (e) => {
253
+ const message = String(e?.error?.message ?? 'style or token rejected');
254
+ if (ready.value || failure.value) {
255
+ console.warn(`DeckMap: non-fatal Mapbox error after load — ${message}`);
256
+ return;
257
+ }
258
+ failure.value = `Map failed to load — ${message}`;
259
+ });
260
+ m.on('load', () => {
261
+ ready.value = true;
262
+ addMarks(m);
263
+ // Slidev mounts neighbouring slides off-screen, where a container can measure zero. One resize
264
+ // once the style is up is what stops a map arriving at 0x0 and staying there.
265
+ m.resize();
266
+ });
267
+
268
+ /*
269
+ ★★ AND A RESIZE OBSERVER, BECAUSE ONE RESIZE ON `load` IS NOT ENOUGH — measured, not guessed.
270
+ Slidev mounts neighbouring slides, so this map is usually built while its slide is off-screen and
271
+ the container measures zero. Mapbox does not wait: it falls back to its default 400x300 canvas.
272
+ The `load` handler above then fires while the container is STILL zero, so the one corrective
273
+ resize corrects nothing and the canvas keeps 400x300 for the life of the slide.
274
+
275
+ Reproduced by arriving the way a presenter does, transitioning in from the slide before rather
276
+ than loading the URL directly: container 1740x520, canvas 400x300. A direct load looks perfect,
277
+ which is exactly why this survived — the map is only wrong on the path nobody tests.
278
+
279
+ On screen that is a strip of tiles filling the left quarter of a full-width grey box, which
280
+ reads as a half-loaded map rather than as a sizing bug.
281
+
282
+ So watch the box instead of guessing when it settles. The guard is on a NON-ZERO measurement:
283
+ the observer fires for the 0x0 state too, and resizing to zero is what put us here. No loop is
284
+ possible — resize() changes the CANVAS, never the holder this observes.
285
+ */
286
+ if (typeof ResizeObserver !== 'undefined') {
287
+ sizeWatcher = new ResizeObserver(() => {
288
+ const el = holder.value;
289
+ if (!el || !map.value) return;
290
+ if (el.clientWidth > 0 && el.clientHeight > 0) map.value.resize();
291
+ });
292
+ sizeWatcher.observe(holder.value);
293
+ }
294
+ });
295
+
296
+ onBeforeUnmount(() => {
297
+ sizeWatcher?.disconnect();
298
+ sizeWatcher = null;
299
+ map.value?.remove();
300
+ map.value = null;
301
+ });
302
+ </script>
303
+
304
+ <template>
305
+ <div class="deck-map" :class="{ 'is-fill': !height }" :style="height ? { height: `${height}px` } : undefined">
306
+ <div v-if="failure" class="deck-map-empty">{{ failure }}</div>
307
+ <div v-else ref="holder" class="deck-map-canvas" />
308
+ </div>
309
+ </template>
310
+
311
+ <style scoped>
312
+ /*
313
+ ★★ THE NOTCH HAS TO BE HANDED DOWN TO THE CANVAS, because an ancestor does not clip one.
314
+
315
+ split.vue cuts its figure's corner with a `clip-path` so the running Forest mark sits in the
316
+ cut-out. That works for every other figure and does nothing here: a composited WebGL canvas is not
317
+ clipped by an ancestor's clip-path, so the mark was drawn straight over a live map while the
318
+ element around it was correctly cut. The same reason the map's own corner RADIUS has to land on
319
+ `.mapboxgl-map` rather than on anything above it.
320
+
321
+ `inherit` rather than a copy, so there is one path and this file never computes a shape. It walks
322
+ the chain a step at a time — the clip is on the figure, the grandparent — and it is safe when
323
+ there is no notch, because inheriting nothing computes to `none`. It is only correct because the
324
+ three boxes are the same size: measured 347x302 at figure, .deck-map and .mapboxgl-map alike,
325
+ which is what `is-fill` guarantees. A map given a fixed height inside a notched figure would be
326
+ clipped against a path drawn for a taller box.
327
+ */
328
+ .deck-map {
329
+ clip-path: inherit;
330
+ }
331
+ .deck-map :deep(.mapboxgl-map) {
332
+ clip-path: inherit;
333
+ }
334
+
335
+ /*
336
+ `height: 0` means FILL — take whatever the parent gives, rather than a number the slide had to
337
+ guess. The same convention Carousel's `width: 0` already uses for its column.
338
+
339
+ ★ It exists for `split` at `media: figure`, where the cell height is the layout's to decide and a
340
+ slide that wrote its own pixel figure would be encoding the anchor arithmetic by hand. With a
341
+ number, the map is that tall and the cell's leftover space sits empty below it.
342
+ */
343
+ .deck-map.is-fill {
344
+ height: 100%;
345
+ }
346
+ .deck-map {
347
+ width: 100%;
348
+ border-radius: var(--deck-radius-nested);
349
+ /* ★ THE RADIUS HAS TO CLIP A COMPOSITED CANVAS. A WebGL canvas is composited on its own layer, so
350
+ a rounded corner on an ANCESTOR does not clip it — the map paints square corners over the
351
+ rounding and the slide looks broken at four points. `overflow: hidden` here plus the same
352
+ radius on mapbox's own container below is what actually cuts it. */
353
+ overflow: hidden;
354
+ background: var(--deck-surface);
355
+ }
356
+ .deck-map-canvas {
357
+ width: 100%;
358
+ height: 100%;
359
+ border-radius: var(--deck-radius-nested);
360
+ overflow: hidden;
361
+ }
362
+ .deck-map-canvas :deep(.mapboxgl-map) {
363
+ border-radius: var(--deck-radius-nested);
364
+ }
365
+
366
+ .deck-map-empty {
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: center;
370
+ text-align: center;
371
+ height: 100%;
372
+ padding: var(--deck-space-5);
373
+ color: var(--deck-fg-muted);
374
+ font-size: var(--type-deck-body-size);
375
+ line-height: var(--type-deck-body-line-height);
376
+ }
377
+
378
+ /* Mapbox's own chrome is sized for a browser window. The attribution is a licence term and stays,
379
+ but at 10px on a projector it is noise rather than a credit — the deck's smallest real tier is
380
+ what it takes instead.
381
+
382
+ ★★ IT NEEDS ITS OWN GROUND, AND THAT IS A DETERMINISM FIX, NOT A STYLING ONE. Text sitting
383
+ straight on the basemap is measured by scripts/deckAudit.ts against whatever is PAINTED beneath
384
+ it, and a live map paints asynchronously over the network — so the same slide scored 12 contrast
385
+ failures on one run and none on the next, purely on how far the tiles had got. A near-opaque
386
+ ground under the credit makes the pairing a constant: the audit measures ink against a known
387
+ surface instead of against a race. */
388
+ .deck-map :deep(.mapboxgl-ctrl-attrib) {
389
+ background: var(--deck-bg);
390
+ border-radius: var(--deck-radius-control);
391
+ padding: 0 var(--deck-space-2);
392
+ }
393
+ /* ★ A FIXED px SIZE, AND DELIBERATELY NOT A DECK TYPE ROLE. This took
394
+ `var(--type-deck-caption-size)` first, on the reasoning that every other piece of text in the
395
+ deck takes a role — and the roles are sized for a ROOM, so the credit rendered at caption tier
396
+ and ran straight out of the map box, wider than the picture it was crediting.
397
+
398
+ The attribution is the one string on a slide that is a LICENCE TERM rather than content: Mapbox
399
+ requires it to be legible and present, and requires nothing more than that. It cannot be removed,
400
+ and it should not be room-scale either — 16px on the 1920 canvas is readable up close, which is
401
+ the standard the term is written to, and invisible from the back, which is where the map is the
402
+ subject and the credit is not. */
403
+ .deck-map :deep(.mapboxgl-ctrl-attrib),
404
+ .deck-map :deep(.mapboxgl-ctrl-attrib a) {
405
+ font-size: 16px;
406
+ line-height: 1.4;
407
+ color: var(--deck-fg-muted);
408
+ }
409
+ </style>