@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,348 @@
1
+ <script setup>
2
+ /*
3
+ All 33 London boroughs in one frame, in true relative position — the vector counterpart to
4
+ DottedMap.vue's grid.
5
+
6
+ ★ WHY THIS IS NOT BoroughShape THIRTY-THREE TIMES, which is the obvious thing to try and does not
7
+ work. BoroughShape re-frames every instance to its OWN bounding box (boroughGeometry.ts's
8
+ frameViewBox), which is exactly what makes it a glyph: Bromley's 288x295 and the City of London's
9
+ 49x30 draw the same height, because `size` says how tall to draw the MARK and never how big the
10
+ place is. Thirty-three of those are thirty-three unrelated marks. A map is the opposite operation
11
+ — every borough kept in the ONE coordinate space the generated file already puts them in.
12
+
13
+ So there is no projection maths here, and deliberately none: london-boroughs.json declares
14
+ `viewBox: [0, 0, 1000, 771]` and the union of all 33 paths fills it exactly. Drawing the city is
15
+ a matter of NOT re-framing. The viewBox is read from the file rather than restated as a literal,
16
+ so a regenerated projection moves this component with it.
17
+
18
+ ★ THE HAIRLINE IS NOT DECORATION. Boroughs tile with no gaps, so 33 filled paths with no
19
+ separator fuse into a single London silhouette and the whole point of the picture is lost. The
20
+ stroke is the slide's own ground colour, drawn at a non-scaling width so it stays one hairline at
21
+ every rendered size rather than thickening as the map grows.
22
+
23
+ TWO TIERS, matching DottedMap's own reading at presenting distance:
24
+ land every borough. Quiet, but not so quiet it dissolves on a projector.
25
+ highlight the accent. What the slide is actually about.
26
+
27
+ A highlight can carry a SHADE instead, 1-5 off the deck's sequential ramp, which turns the same
28
+ picture into a choropleth: the accent says "this one", a shade says "this much". They are
29
+ different instruments and the prop lets a slide pick one per borough — `'Southwark'` for the
30
+ accent, `{ borough: 'Southwark', shade: 4 }` for a quantity.
31
+
32
+ There is no river: packages/tokens/geo carries no vector Thames, only the dot grids' sampled one.
33
+ A slide that needs the river to say "London" wants DottedMap; this one says it with the outlines.
34
+
35
+ Geometry is the same generated file BoroughShape and the product's own maps read, so a deck map
36
+ and a product map are the same outlines. Lookup comes from boroughGeometry.ts — indexBoroughs()
37
+ directly rather than findBorough(), because this is the one caller with more than a handful of
38
+ keys to resolve and findBorough rebuilds the whole index on every call.
39
+ */
40
+ import { computed } from 'vue';
41
+ import boroughs from '@humanforest/tokens/geo/london-boroughs.json';
42
+ import { indexBoroughs } from './boroughGeometry';
43
+
44
+ const props = defineProps({
45
+ /** Rendered width in canvas px. Height follows from the map's own 1000 x 771 aspect, so this is the only size lever. */
46
+ width: { type: Number, default: 900 },
47
+ /**
48
+ * Boroughs to lift out of the land tier — ONS codes or names, matched exactly as BoroughShape
49
+ * matches a single one. Three instruments, one per entry:
50
+ *
51
+ * · `'Southwark'` the accent. "This one."
52
+ * · `{ borough, shade: 1-5 }` a step of the deck's SEQUENTIAL ramp. "This much."
53
+ * · `{ borough, series: 1-6 }` a step of the deck's CATEGORICAL palette. "This kind."
54
+ *
55
+ * ★★ SHADE AND SERIES ARE NOT INTERCHANGEABLE, AND THE DIFFERENCE IS THE WHOLE REASON BOTH EXIST.
56
+ * Five steps of one hue mean MORE; six unrelated hues mean DIFFERENT. A map of where the fleet is
57
+ * live, where it is coming and where a tender is open is three categories — drawn on the ramp it
58
+ * reads as a quantity, and a room takes "coming soon" for "more of whatever this is".
59
+ *
60
+ * An entry may carry one or the other. Given both, `series` wins and `shade` is ignored, because a
61
+ * category is the stronger claim: it says the map is not a choropleth at all.
62
+ */
63
+ highlight: { type: Array, default: () => [] },
64
+ /**
65
+ * What the map holds: the whole city, or only the boroughs in `highlight`.
66
+ *
67
+ * · `city` all 33, in the projection's own frame. The selection is coloured in place.
68
+ * · `selection` ONLY the selected boroughs, framed to the union of their own bounding boxes.
69
+ *
70
+ * ★★ IT DROPS THE REST AND RE-FRAMES, AND THE TWO GO TOGETHER. Drawn as one and not the other it
71
+ * is neither picture: re-framing while keeping the city is a zoom, and dropping the city while
72
+ * keeping the frame is a handful of shapes adrift in an empty London-sized box.
73
+ *
74
+ * ⚠ WHAT IT COSTS, so a slide chooses it rather than falls into it: without the other boroughs
75
+ * there is no coastline, no neighbour and no river to locate the selection against, so the picture
76
+ * only reads if the selection is itself a familiar shape. A contiguous service area survives it; a
77
+ * scattered set of three does not. The boroughs keep their true relative position either way —
78
+ * this is still the map's own coordinate space, which is the whole difference between it and
79
+ * `BoroughShape`, whose glyphs are each re-framed to themselves.
80
+ */
81
+ fit: { type: String, default: 'city', validator: (f) => ['city', 'selection'].includes(f) },
82
+ /** Assistive text for the whole map. With it the figure is announced as an image; without it it is decorative and hidden, the same call an icon makes. */
83
+ label: { type: String, default: null },
84
+ /**
85
+ * The key, and its SHAPE follows the instrument the highlights used.
86
+ *
87
+ * · with `shade` — the two ends of the scale: `['fewer trips', 'more trips']`. A graded strip,
88
+ * labelled at the ends only, because a ramp means "more" and its key has to show the order.
89
+ * · with `series` — one label per step, in series order: `['In service', 'Coming soon', 'In
90
+ * tender']`. Bullets, the way ChartFrame keys a categorical series, because the colours mean
91
+ * "different" and nothing is being compared along a scale.
92
+ *
93
+ * ★ THE STATUS WORDS ARE THE SLIDE'S, NOT THIS COMPONENT'S. "In service", "coming soon", "in
94
+ * tender" are commercial vocabulary that changes with the business; a component that baked them
95
+ * in would be a component that has to be edited when the business does. It owns colour ROLES; the
96
+ * slide says what they mean.
97
+ */
98
+ legend: { type: Array, default: () => [] },
99
+ });
100
+
101
+ const areas = boroughs.areas;
102
+
103
+ /*
104
+ Resolved to CODES, not to the strings the caller passed. `highlight` takes a name or a code and
105
+ matches case-insensitively, so 'southwark', 'Southwark' and 'E09000028' are one borough — comparing
106
+ the raw strings against area.name would light none of them.
107
+
108
+ An unresolvable key is dropped rather than thrown on, matching findBorough's own contract. The
109
+ generator is where a typo is caught: its builder refuses a highlight the geometry does not carry,
110
+ because here it could only ever be a borough that quietly failed to light up.
111
+
112
+ ★ THE SHADE IS CLAMPED HERE, NOT ONLY IN THE GENERATOR. An out-of-range step would build
113
+ `var(--deck-shade-9)`, which is not declared — and an undefined custom property in a `fill` does
114
+ not fall back to nothing, it resolves to currentColor. The borough would paint the slide's INK
115
+ colour and read as a deliberate near-black, which is the worst kind of wrong: it looks chosen.
116
+ */
117
+ const SHADE_STEPS = 5;
118
+ /* Six, not five: deck.css declares --deck-series-1 through -6. Clamped for the same reason the
119
+ shade is — an undeclared step resolves to currentColor, which paints the slide's ink and reads as
120
+ a deliberate near-black. */
121
+ const SERIES_STEPS = 6;
122
+ const step = (value, ceiling) => Math.min(ceiling, Math.max(1, Math.round(value)));
123
+ const selected = computed(() => {
124
+ const index = indexBoroughs(areas);
125
+ const out = new Map();
126
+ for (const item of props.highlight) {
127
+ const key = typeof item === 'string' ? item : item?.borough;
128
+ const area = index.get(String(key ?? '').trim().toLowerCase());
129
+ if (!area) continue;
130
+ const series = typeof item === 'string' ? null : item?.series;
131
+ const shade = typeof item === 'string' ? null : item?.shade;
132
+ // Series first: a category is the stronger claim, so an entry carrying both is not a choropleth.
133
+ const fill = series != null
134
+ ? `var(--deck-series-${step(series, SERIES_STEPS)})`
135
+ : shade != null
136
+ ? `var(--deck-shade-${step(shade, SHADE_STEPS)})`
137
+ : null;
138
+ out.set(area.code, { area, fill });
139
+ }
140
+ return out;
141
+ });
142
+ const fills = computed(() => new Map([...selected.value].map(([code, v]) => [code, v.fill])));
143
+
144
+ /*
145
+ ★★ THE UNION OF THE SELECTED BOUNDING BOXES, WITH NO PATH PARSED. Every area in the generated file
146
+ carries `x, y, w, h`, which is what frameViewBox already reads to frame ONE borough — this is the
147
+ same operation over several. The pad is a fraction of the union's longer side, so a wide selection
148
+ and a tall one get the same visual margin rather than the same number of user units.
149
+
150
+ Falls back to the city's own viewBox when nothing is selected: a `fit: selection` map with an empty
151
+ `highlight` has no selection to frame, and an empty box would render nothing at all.
152
+ */
153
+ const SELECTION_PAD = 0.06;
154
+ const viewBox = computed(() => {
155
+ if (props.fit !== 'selection' || selected.value.size === 0) return boroughs.viewBox.join(' ');
156
+ const boxes = [...selected.value.values()].map((v) => v.area);
157
+ const x0 = Math.min(...boxes.map((a) => a.x));
158
+ const y0 = Math.min(...boxes.map((a) => a.y));
159
+ const x1 = Math.max(...boxes.map((a) => a.x + a.w));
160
+ const y1 = Math.max(...boxes.map((a) => a.y + a.h));
161
+ const pad = Math.max(x1 - x0, y1 - y0) * SELECTION_PAD;
162
+ return `${x0 - pad} ${y0 - pad} ${x1 - x0 + pad * 2} ${y1 - y0 + pad * 2}`;
163
+ });
164
+
165
+ /* `selection` is the whole of the difference: the same list, filtered to what was highlighted. The
166
+ fallback matches the viewBox's — with nothing selected there is nothing to draw, and an empty map
167
+ reads as a broken component rather than as an empty state. */
168
+ const drawn = computed(() =>
169
+ (props.fit === 'selection' && selected.value.size > 0 ? areas.filter((a) => selected.value.has(a.code)) : areas));
170
+
171
+ /*
172
+ ★ THE LEGEND IS A GRADED STRIP, NOT A ROW OF BULLETS. ChartFrame's legend keys a CATEGORICAL
173
+ series — one bullet per name, unordered, because the colours mean "different". A ramp means
174
+ "more", and its key has to show the order: five swatches in sequence, labelled at the ends only.
175
+ Bulleting the five steps with five numbers would ask a room to match hues to values one at a
176
+ time, which is the reading a ramp exists to avoid.
177
+
178
+ Drawn only when both ends are named. A one-ended scale is not a scale, and a legend under a map
179
+ with no shaded borough in it keys nothing.
180
+ */
181
+ const legendSteps = Array.from({ length: SHADE_STEPS }, (_, i) => i + 1);
182
+ /*
183
+ ★ WHICH KEY IS DRAWN IS READ OFF THE HIGHLIGHTS, NOT OFF A PROP. The map already knows which
184
+ instrument it was handed; asking the caller to say so a second time is a second place for the two
185
+ to disagree, and the failure is silent — a categorical map under a graded strip.
186
+ */
187
+ const isCategorical = computed(() => props.highlight.some((h) => typeof h !== 'string' && h?.series != null));
188
+ const showRamp = computed(() => !isCategorical.value && props.legend.length === 2);
189
+ const showKeys = computed(() => isCategorical.value && props.legend.length > 0);
190
+ /* Paired with the labels in order, so the third label keys --deck-series-3. */
191
+ const keys = computed(() => props.legend.map((name, i) => ({ name, fill: `var(--deck-series-${step(i + 1, SERIES_STEPS)})` })));
192
+
193
+ const style = computed(() => ({ '--london-map-width': `${props.width}px` }));
194
+ </script>
195
+
196
+ <template>
197
+ <div class="london-map-figure" :style="style">
198
+ <svg
199
+ class="london-map"
200
+ :viewBox="viewBox"
201
+ :role="label ? 'img' : undefined"
202
+ :aria-label="label || undefined"
203
+ :aria-hidden="label ? undefined : 'true'"
204
+ >
205
+ <path
206
+ v-for="area in drawn"
207
+ :key="area.code"
208
+ :d="area.d"
209
+ class="london-map-area"
210
+ :class="{ 'is-highlight': fills.has(area.code) }"
211
+ :style="fills.get(area.code) ? { fill: fills.get(area.code) } : undefined"
212
+ vector-effect="non-scaling-stroke"
213
+ />
214
+ </svg>
215
+ <!--
216
+ One label per colour, in ChartFrame's own markup rather than a list of its own: a map key and a
217
+ chart key are the same object on the same slide, and the two should be one shape.
218
+
219
+ ⚠ SPANS, NOT `ul`/`li`, AND THAT IS NOT A STYLE PREFERENCE. Drawn as a list it inherited the
220
+ deck's own slide bullet — base.css paints `.slidev-layout ul > li::before` with a background,
221
+ which `list-style: none` cannot touch — so every key rendered with two dots: the deck's, then
222
+ the one naming the colour. A key is a row of labels, not a list of prose.
223
+ -->
224
+ <div v-if="showKeys" class="london-map-keys">
225
+ <span v-for="key in keys" :key="key.name" class="london-map-key">
226
+ <span class="london-map-key-dot" :style="{ background: key.fill }" />
227
+ {{ key.name }}
228
+ </span>
229
+ </div>
230
+ <div v-else-if="showRamp" class="london-map-legend">
231
+ <span class="london-map-legend-end">{{ legend[0] }}</span>
232
+ <span class="london-map-ramp" aria-hidden="true">
233
+ <span
234
+ v-for="step in legendSteps"
235
+ :key="step"
236
+ class="london-map-swatch"
237
+ :style="{ background: `var(--deck-shade-${step})` }"
238
+ />
239
+ </span>
240
+ <span class="london-map-legend-end">{{ legend[1] }}</span>
241
+ </div>
242
+ </div>
243
+ </template>
244
+
245
+ <style scoped>
246
+ /*
247
+ Sized on WIDTH, the DottedMap rule, not BoroughShape's height ladder: this is a landscape figure
248
+ whose aspect is fixed by the projection, and a slide budgets it across the column it sits in.
249
+ The width lands as an inline custom property for the reason BoroughShape's height does — a scoped
250
+ rule is 0-2-0 and outranks any utility class, so a class-based width would be silently inert.
251
+ */
252
+ /*
253
+ ⚠ THE WIDTH SIZES THE MAP, NOT THE FIGURE, and the difference is what the key looks like. With it
254
+ on the wrapper the categorical key inherited the map's width — 480 on the status slides — and three
255
+ labels at the body-sm tier wrapped to three rows, so a legend rendered as a LIST. The figure takes
256
+ the column; the drawing takes `width`; the key gets the column's own measure and fits on one row.
257
+ */
258
+ .london-map-figure {
259
+ display: flex;
260
+ flex-direction: column;
261
+ gap: var(--deck-space-3);
262
+ width: 100%;
263
+ }
264
+ /* ★ CENTRED IN WHATEVER BOX IT IS GIVEN, now that the figure takes the column rather than the map's
265
+ own width: a drawing narrower than its cell reads as misplaced against a key that spans the whole
266
+ of it. The ramp legend below centres with it, because a graded strip keys the map by sitting
267
+ directly under it. */
268
+ .london-map {
269
+ display: block;
270
+ width: var(--london-map-width, 900px);
271
+ max-width: 100%;
272
+ height: auto;
273
+ margin-inline: auto;
274
+ }
275
+ /* ★ THE RAMP KEEPS THE MAP'S OWN WIDTH, unlike the categorical key above. A graded strip is read
276
+ AGAINST the thing it keys — its five steps are the five fills on the map — so it spans the drawing
277
+ rather than the column. A bulleted key names colours one at a time and needs no such alignment. */
278
+ .london-map-legend {
279
+ width: var(--london-map-width, 900px);
280
+ max-width: 100%;
281
+ margin-inline: auto;
282
+ }
283
+ /* The label tier ChartFrame's legend uses, so a map key and a chart key read as the same furniture. */
284
+ .london-map-legend {
285
+ display: flex;
286
+ align-items: center;
287
+ gap: var(--deck-space-3);
288
+ font-size: var(--type-deck-body-sm-size);
289
+ line-height: var(--type-deck-body-sm-line-height);
290
+ color: var(--deck-fg);
291
+ }
292
+ /* The strip takes the space the two end labels do not, so the key spans the map's own width and a
293
+ room reads the ramp at the same scale as the thing it keys. */
294
+ .london-map-ramp {
295
+ display: flex;
296
+ flex: 1;
297
+ min-width: 0;
298
+ height: var(--deck-space-4);
299
+ border-radius: var(--deck-radius-item);
300
+ overflow: hidden;
301
+ }
302
+ .london-map-swatch { flex: 1; }
303
+ /*
304
+ The categorical key: ChartFrame's `.deck-chart-legend` shape, redrawn here because scoped CSS
305
+ cannot be shared. Same tier, same bullet, same gaps — a map key and a chart key on one slide are
306
+ the same furniture, and the alternative (a legend component of its own) is five gate edits for
307
+ twelve lines of flexbox.
308
+ */
309
+ .london-map-keys {
310
+ display: flex;
311
+ flex-wrap: wrap;
312
+ /*
313
+ ⚠ CENTRED ON THE DRAWING, WHICH IS NOT THE SAME AS SPANNING IT. The note above says a bulleted
314
+ key needs no width alignment with the map, and that stands — but this box is the figure's full
315
+ width while the `<svg>` inside it is centred by its own `margin-inline: auto`, so left-packed
316
+ items sat off the map's axis. Measured on the two-map status slide: the key's content ran
317
+ 96-857 against a drawing centred on 513, a 37px offset — small enough to read as a mistake
318
+ rather than as a choice, which is the worst distance to be off by.
319
+ */
320
+ justify-content: center;
321
+ gap: var(--deck-space-3) var(--deck-space-5);
322
+ font-size: var(--type-deck-body-sm-size);
323
+ line-height: var(--type-deck-body-sm-line-height);
324
+ color: var(--deck-fg);
325
+ }
326
+ .london-map-key {
327
+ display: inline-flex;
328
+ align-items: center;
329
+ gap: var(--deck-space-2);
330
+ }
331
+ /* 0.6em, ChartFrame's own number: a real disc at reading distance rather than the 9px speck the
332
+ chart libraries ship. In em so it tracks the label's tier rather than a space step. */
333
+ .london-map-key-dot {
334
+ width: 0.6em;
335
+ height: 0.6em;
336
+ border-radius: var(--deck-radius-full);
337
+ flex: none;
338
+ }
339
+ .london-map-legend-end { white-space: nowrap; }
340
+ .london-map-area {
341
+ fill: var(--deck-dot-land);
342
+ stroke: var(--deck-bg);
343
+ stroke-width: 2;
344
+ }
345
+ .london-map-area.is-highlight {
346
+ fill: var(--deck-accent);
347
+ }
348
+ </style>
@@ -0,0 +1,109 @@
1
+ <!--
2
+ Forest's mark, and a partner's beside it at the same height.
3
+
4
+ ★★ THIS EXISTS BECAUSE THE PAIR WAS DRAWN THREE TIMES AND THE THIRD ONE WAS WRONG. slide-top.vue
5
+ drew it for the running chrome, cover.vue drew it again in the inset's corner, and the poster drew
6
+ it a third time at 120px — where it inherited the chrome's FIXED numbers (`--deck-mark-radius`
7
+ 15.75px, `--deck-mark-ring-width` 3px, both computed for a 72px mark) and came out with a corner
8
+ and a ring meant for a tile 40% smaller. Jose caught it on the slide: "why are you not using the
9
+ fucking width of the same as the mark!!! and rounded corners!!!"
10
+
11
+ So the geometry is a RATIO of the tile here, never a px token:
12
+
13
+ ring 19 / 434.82 = 4.3696% of the tile
14
+ corner 95.0854 / 434.82 = 21.8678% of the tile
15
+ gap the chrome's own pair gap, as a fraction of the chrome's own mark
16
+
17
+ At the chrome's 72 those give 3.15 / 15.75 / 16 — the tokens the chrome already ships, to within a
18
+ seventh of a pixel. At any other size they follow, which is the whole point: `<Logo variant="mark">`
19
+ scales its artwork, and a partner's tile beside it has to scale the same way or the two stop being
20
+ one object.
21
+
22
+ ⚠ `cover`, NOT `contain`. Whatever the source is — an app icon, an avatar, a photograph, a wide
23
+ wordmark — it is COVERED into a mark-sized square and clipped to the mark's own corner, so the pair
24
+ reads as two marks of one family. Contain letterboxes a non-square source and puts back the ragged
25
+ silhouette the clip exists to remove. Jose's own rule: "the other logo should be anything (an
26
+ avatar or whatever), but the second one should be a rounded square like the mark".
27
+
28
+ ★ THE RING IS `currentColor`, because Logo.vue fills the mark's own ring path with `currentColor`.
29
+ An image has no artwork to carry an edge, so this draws one — and drawn in the inherited ink it
30
+ turns over with the register exactly as the mark's does, on any ground, without this file knowing
31
+ which register it is in. Only the WIDTH is a number: the artwork's ring is a shape, not a stroke.
32
+ -->
33
+ <script setup>
34
+ import { computed } from 'vue';
35
+ import { MARK_SIZE, MARK_CORNER, MARK_RING } from '@humanforest/tokens/logo';
36
+ import deckTokens from '@humanforest/tokens/deck/deck.json';
37
+ import { assetUrl } from '../assetUrl.js';
38
+
39
+ const props = defineProps({
40
+ /** The partner's artwork. With none, this is just the Forest mark — which is what the running chrome draws on a slide with no `companion:`. */
41
+ src: { type: String, default: '' },
42
+ /** Alt text for the partner's mark. Deliberately vague by default: naming the partner is the author's job, via `companionName`. */
43
+ label: { type: String, default: 'Companion mark' },
44
+ /** Height of BOTH tiles in px. Everything else — corner, ring, gap — is a ratio of it. */
45
+ size: { type: Number, default: deckTokens.chrome.markPx },
46
+ });
47
+
48
+ /* The chrome's gap expressed as a fraction of the chrome's mark, so the pair keeps its proportions
49
+ at the poster's scale instead of holding a 16px gap between two 120px tiles. */
50
+ const GAP_RATIO = deckTokens.chrome.pairGapPx / deckTokens.chrome.markPx;
51
+
52
+ const style = computed(() => ({
53
+ '--pair-tile': `${props.size}px`,
54
+ '--pair-radius': `${props.size * (MARK_CORNER / MARK_SIZE)}px`,
55
+ '--pair-ring': `${props.size * (MARK_RING / MARK_SIZE)}px`,
56
+ '--pair-gap': `${props.size * GAP_RATIO}px`,
57
+ }));
58
+ </script>
59
+
60
+ <template>
61
+ <div class="mark-pair" :style="style">
62
+ <!-- Forest first, always — FSubLogo's rule for a pair, and the order the running chrome signs in. -->
63
+ <Logo variant="mark" :size="size" />
64
+ <img v-if="src" class="mark-pair-companion" :src="assetUrl(src)" :alt="label">
65
+ </div>
66
+ </template>
67
+
68
+ <style scoped>
69
+ .mark-pair {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: var(--pair-gap);
73
+ }
74
+ /*
75
+ `flex: none` on both halves for the reason Logo.vue records at length: a masked wordmark has no
76
+ intrinsic size and absorbs any shortfall on its own, and a partner's artwork is never ours to
77
+ squash. It overflows instead, which is loud and catchable.
78
+ */
79
+ .mark-pair > :deep(*) { flex: none; }
80
+ .mark-pair-companion {
81
+ flex: none;
82
+ width: var(--pair-tile);
83
+ height: var(--pair-tile);
84
+ object-fit: cover;
85
+ object-position: center;
86
+ border-radius: var(--pair-radius);
87
+ /*
88
+ ★★ AN OUTLINE PULLED INWARD, NOT A BORDER — and the difference is a hairline Jose could see.
89
+ A border shrinks the CONTENT box, so the artwork is clipped to the padding box while the tile's
90
+ own background paints underneath it. The ring is a ratio of the tile, so its width is fractional
91
+ (3.15px at 72, 5.24 at 120), the padding-box edge lands mid-pixel, and the surface shows through
92
+ that seam as a pale line all the way round — cream between a black ring and a navy mark.
93
+
94
+ `outline` with a negative offset paints the same ring OVER the image instead: the picture fills
95
+ the whole box, the ring covers its outer edge, and there is no seam left to leak through. The
96
+ radius follows the element's own, so the two curves stay concentric.
97
+
98
+ ⚠ The background stays: a partner's artwork is often a shape on transparency, and the clipped
99
+ corners would otherwise hold nothing. It is simply no longer between the ring and the picture.
100
+ */
101
+ outline: var(--pair-ring) solid currentColor;
102
+ outline-offset: calc(-1 * var(--pair-ring));
103
+ box-sizing: border-box;
104
+ /* A companion's artwork is often a shape on transparency, and the clipped corners would otherwise
105
+ hold nothing. The surface, not the ground: the tile is an object on the slide, not a hole in it. */
106
+ background: var(--deck-surface);
107
+ display: block;
108
+ }
109
+ </style>