@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,331 @@
1
+ <script setup>
2
+ /*
3
+ A phone, drawn around a live screen — the deck's own re-implementation of the design system's
4
+ FDeviceFrame.
5
+
6
+ ★★ THE DATA IS IMPORTED, THE PRESENTATION IS REBUILT, AND THAT SPLIT IS FORCED RATHER THAN CHOSEN.
7
+
8
+ `@humanforest/frames` already owns all of this: the registry of 25 devices with their true logical
9
+ point sizes, the measured screen apertures, the Apple squircle clip path, and the bundled Wikimedia
10
+ renders. None of that should be copied — the package EXISTS because docs and playground had each
11
+ carried their own copy of the frame and the two had already drifted.
12
+
13
+ What cannot come across is FDeviceFrame.vue itself. It is built on Nuxt UI's semantic classes —
14
+ `bg-default`, `bg-elevated`, `bg-highlighted`, `border-default`, `text-default`, `text-dimmed`,
15
+ `text-highlighted`, `text-muted` — and this deck has none of them. Nuxt UI is Tailwind; the deck is
16
+ UnoCSS with the Forest deck tokens. Dropped in as-is, every one of those classes matches nothing:
17
+ the chrome renders invisible and the screen renders transparent, silently, on a slide that still
18
+ lays out. So the VALUES and the PURE FUNCTIONS are imported and the PRESENTATION is written here
19
+ against `--deck-*`, which is the standing rule for anything this deck borrows from the system.
20
+
21
+ ★ THE GEOMETRY IS NOT INVENTED. Every number below is derived exactly as FDeviceFrame derives it,
22
+ and the derivation is the only thing duplicated:
23
+
24
+ shell width = screen.w / (1 - (inset.left + inset.right) / 100)
25
+ shell height = shell width * art.aspect
26
+
27
+ The insets are percentages of the SVG's own viewBox, measured in a browser when the registry was
28
+ built. Re-deriving rather than hardcoding means a corrected inset in the package moves this frame
29
+ too. Measured for the default (iPhone 17 Pro Max): 440 / 0.92579 = 475.3 wide, x 2.06439 = 981.2
30
+ tall, which is why `height` exists — at natural size the shell is 91% of a 1080 canvas.
31
+
32
+ ⚠ ATTRIBUTION IS A LICENCE CONDITION, NOT A COURTESY. The renders are unmodified Wikimedia Commons
33
+ vectors under CC BY-SA 4.0. `ART_CREDITS` is re-exported here so a deck can print the list, and the
34
+ specimen does. The art must never reach a public channel — both this theme and the frames package
35
+ are `private: true`, and the theme is not carried by the giget layer at all.
36
+ */
37
+ import { computed, useId } from 'vue';
38
+ import { DEFAULT_DEVICE, findDevice } from '@humanforest/frames/devices';
39
+ import { artUrl } from '@humanforest/frames/art';
40
+ import { assetUrl } from '../assetUrl.js';
41
+
42
+ const props = defineProps({
43
+ /**
44
+ * Which model to draw, by registry id — `iphone-17-pro-max`, `pixel-10-pro`, `iphone-se-3`. Free
45
+ * string rather than an enum: the registry is the source of truth and restating 25 ids here would
46
+ * be a second copy that goes stale the day a device is added. An unknown id throws from
47
+ * findDevice() at build time rather than rendering a frame with no hardware.
48
+ */
49
+ device: { type: String, default: DEFAULT_DEVICE },
50
+ /**
51
+ * How tall the shell should be drawn, in canvas pixels. The screen inside still lays out at the
52
+ * model's TRUE logical points and the whole thing is scaled — so a mock authored for a 440pt phone
53
+ * stays a 440pt phone, at whatever size the slide has room for.
54
+ */
55
+ height: { type: Number, default: 820 },
56
+ /** Alt text for the hardware render, e.g. "Forest app on an iPhone 17 Pro Max". */
57
+ label: { type: String, default: '' },
58
+ /**
59
+ * A screenshot or still to fill the screen with, as a URL. The same one-rule convention the
60
+ * layouts use for their own figures — `image` in the frontmatter rather than a hand-written
61
+ * `<img>` in a slot — because these decks are written by people who are not front-end engineers.
62
+ *
63
+ * Cropped with `object-fit: cover`, so a still whose aspect does not match the model's is trimmed
64
+ * rather than squashed. The slot still wins where the screen holds something that is not a
65
+ * picture: a mock built from components, a live map, or a `<video>`.
66
+ */
67
+ image: { type: String, default: '' },
68
+ /**
69
+ * A LIVE PAGE to put in the screen, as a URL — rendered in an `<iframe>` at the model's TRUE
70
+ * logical points, which is the whole reason to do it on a phone.
71
+ *
72
+ * ★★ THE SITE SEES A REAL PHONE VIEWPORT. The shell is `transform: scale()`d, and a transform
73
+ * does not change the layout box — so an iPhone 17 Pro Max hands the page a 440x956 viewport and
74
+ * the page serves its MOBILE layout, then the whole thing is drawn at whatever height the slide
75
+ * has room for. A screenshot of a desktop page shrunk into a phone is the thing this avoids.
76
+ *
77
+ * ★ SLIDEV SUPPORTS IFRAMES ITSELF — `layout: iframe`, `iframe-left`, `iframe-right` upstream —
78
+ * and this is the same mechanism with hardware around it. See BrowserFrame's own `src` note for
79
+ * the network hazard and the sandbox; both components make the same calls for the same reasons.
80
+ */
81
+ src: { type: String, default: '' },
82
+ /**
83
+ * Whether a `src` page can be scrolled and clicked. True, and a phone is where it earns its keep:
84
+ * a mobile page is TALLER than its viewport by design, so a frozen one shows its hero and nothing
85
+ * else. See BrowserFrame's own `interactive` note for the keyboard-focus caveat and when to pass
86
+ * `false`.
87
+ */
88
+ interactive: { type: Boolean, default: true },
89
+ /** Alt text for `image`, and the accessible title of a `src` frame. Required by the gate when `image` is set. */
90
+ alt: { type: String, default: '' },
91
+ });
92
+
93
+ const model = computed(() => findDevice(props.device));
94
+ const art = computed(() => model.value.art);
95
+
96
+ /* The hardware's natural size, from the screen it has to hold and the aperture the render leaves. */
97
+ const shell = computed(() => {
98
+ const a = art.value;
99
+ const w = model.value.screen.w / (1 - (a.inset.left + a.inset.right) / 100);
100
+ return { w, h: w * a.aspect };
101
+ });
102
+
103
+ const scale = computed(() => props.height / shell.value.h);
104
+
105
+ /*
106
+ ★ SCALED WITH `transform`, NEVER `zoom` — the same rule FFitFrame states for the same reason. `zoom`
107
+ changes the LAYOUT box, so anything that sizes a canvas from clientWidth (a Mapbox map in a mock)
108
+ gets the factor applied twice and paints dead strips down two edges. A transform leaves the layout
109
+ box alone. The cost is that the element still occupies its unscaled box, which is what the outer
110
+ wrapper's explicit width and height are for.
111
+ */
112
+ const shellStyle = computed(() => ({
113
+ width: `${shell.value.w}px`,
114
+ height: `${shell.value.h}px`,
115
+ transform: `scale(${scale.value})`,
116
+ }));
117
+
118
+ const boxStyle = computed(() => ({
119
+ width: `${shell.value.w * scale.value}px`,
120
+ height: `${props.height}px`,
121
+ }));
122
+
123
+ /*
124
+ ★★ THE CUTOUT, WHICH THE FIRST DRAFT SIMPLY DID NOT DRAW. The registry records one per device and
125
+ this component ignored it, so an iPhone 17 Pro Max — `cutout: 'island'` — rendered with a blank
126
+ screen and no Dynamic Island. Nothing caught it: the audit measures ink and geometry, and a missing
127
+ black pill is neither.
128
+
129
+ The three shapes and their numbers are FDeviceFrame's, not invented here:
130
+
131
+ island 30% of the screen width, 36 tall, 10 down from the top — a floating pill
132
+ notch 42% of the screen width, 30 tall, hard against the top, 18px bottom corners
133
+ punch-hole a 14px circle, 12 down, centred
134
+
135
+ ⚠ THE NOTCH IS DRAWN ONLY WHEN THERE IS NO CLIP PATH, and that condition is carried across
136
+ deliberately rather than simplified away. On the models whose render has a measured screen outline,
137
+ the notch is already BITTEN OUT of that outline — the aperture itself has the shape — so drawing a
138
+ black bar on top of it would paint a second notch below the real one. `cutout: 'home'` and `'none'`
139
+ draw nothing, which is why this is a lookup rather than an if/else chain with a fallback.
140
+ */
141
+ const cutout = computed(() => {
142
+ const d = model.value;
143
+ const w = d.screen.w;
144
+ if (d.cutout === 'island') return { kind: 'island', w: Math.round(w * 0.3), h: 36, top: 10, radius: '9999px' };
145
+ if (d.cutout === 'notch' && !d.art.clip) return { kind: 'notch', w: Math.round(w * 0.42), h: 30, top: 0, radius: '0 0 18px 18px' };
146
+ if (d.cutout === 'punch-hole') return { kind: 'punch-hole', w: 14, h: 14, top: 12, radius: '9999px' };
147
+ return null;
148
+ });
149
+
150
+ /*
151
+ The device's own safe-area insets, published as custom properties on the screen. A mock phone has no
152
+ real cutout for the browser to report, so `env(safe-area-inset-*)` resolves to 0 inside it and any
153
+ content that respects the safe area would tuck straight under the island. An author who wants to
154
+ clear it reads these; one drawing a full-bleed screen ignores them, which is why they are offered
155
+ rather than applied.
156
+ */
157
+ const safeVars = computed(() => ({
158
+ '--deck-device-safe-top': `${model.value.safe.top}px`,
159
+ '--deck-device-safe-bottom': `${model.value.safe.bottom}px`,
160
+ }));
161
+
162
+ // A clip path is document-global, so two phones on one slide would share an id and the second would
163
+ // clip to the first's outline. Vue's own useId() is unique per instance and stable across SSR.
164
+ const clipId = `deck-screenclip-${useId()}`;
165
+
166
+ const screenStyle = computed(() => {
167
+ const a = art.value;
168
+ return {
169
+ top: `${a.inset.top}%`,
170
+ left: `${a.inset.left}%`,
171
+ right: `${a.inset.right}%`,
172
+ bottom: `${a.inset.bottom}%`,
173
+ // The Apple screens carry a measured squircle; the Pixels and the SE are plain rounded rects, so
174
+ // they fall back to the registry's own radius percentage rather than to a number invented here.
175
+ ...(a.clip
176
+ ? { clipPath: `url(#${clipId})` }
177
+ : { borderRadius: `${(model.value.screen.w * (a.radiusPct ?? 0)) / 100}px` }),
178
+ };
179
+ });
180
+ </script>
181
+
182
+ <template>
183
+ <div class="deck-device" :style="boxStyle">
184
+ <div class="deck-device-shell" :style="shellStyle">
185
+ <img
186
+ :src="artUrl(model)"
187
+ :alt="label || `${model.name} device frame`"
188
+ class="deck-device-art"
189
+ draggable="false"
190
+ >
191
+ <!-- objectBoundingBox units, so the one path fits whatever size the screen is drawn at. -->
192
+ <svg v-if="art.clip" class="deck-device-clip" aria-hidden="true">
193
+ <defs>
194
+ <clipPath :id="clipId" clipPathUnits="objectBoundingBox"><path :d="art.clip" /></clipPath>
195
+ </defs>
196
+ </svg>
197
+ <div class="deck-device-screen" :style="[screenStyle, safeVars]">
198
+ <!-- `image` fills the screen; the slot is the escape hatch for everything that is not a
199
+ still — a component mock, a live map, a <video>. Both render, so a caller can lay copy
200
+ over a screenshot. -->
201
+ <img v-if="image" :src="assetUrl(image)" :alt="alt" class="deck-device-media" draggable="false">
202
+ <!-- Lazy, sandboxed to scripts alone, and no referrer — the three calls BrowserFrame's own
203
+ iframe comment explains. Below the cutout in the DOM so the island still floats over
204
+ the page the way it does over an app. -->
205
+ <iframe
206
+ v-if="src"
207
+ class="deck-device-live"
208
+ :class="interactive ? '' : 'is-frozen'"
209
+ :src="assetUrl(src)"
210
+ :title="alt || label || `A live page on a ${model.name}`"
211
+ :inert="!interactive"
212
+ loading="lazy"
213
+ sandbox="allow-scripts"
214
+ referrerpolicy="no-referrer"
215
+ />
216
+ <slot />
217
+ <!-- Above the slot in the DOM as well as in z-order: the island floats OVER an app's own
218
+ header exactly as it does on the hardware. -->
219
+ <div
220
+ v-if="cutout"
221
+ class="deck-device-cutout"
222
+ :style="{ width: `${cutout.w}px`, height: `${cutout.h}px`, top: `${cutout.top}px`, borderRadius: cutout.radius }"
223
+ />
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </template>
228
+
229
+ <style scoped>
230
+ /*
231
+ The outer box occupies the SCALED size; the shell inside is at natural size and transformed. Both
232
+ are needed: a transform does not change layout, so without the outer box a scaled-down phone would
233
+ still reserve its full 981px and push everything below it off the slide.
234
+ */
235
+ .deck-device {
236
+ position: relative;
237
+ flex: none;
238
+ }
239
+ .deck-device-shell {
240
+ position: absolute;
241
+ top: 0;
242
+ left: 0;
243
+ transform-origin: top left;
244
+ }
245
+ .deck-device-art {
246
+ position: absolute;
247
+ inset: 0;
248
+ width: 100%;
249
+ height: 100%;
250
+ pointer-events: none;
251
+ user-select: none;
252
+ }
253
+ /* Present only to carry the <clipPath>; it must take no space and paint nothing. */
254
+ .deck-device-clip {
255
+ position: absolute;
256
+ width: 0;
257
+ height: 0;
258
+ }
259
+ /*
260
+ Black, not a token: this is a hole in the hardware, not a surface in the palette. It stays black on
261
+ the dark register too, which is the one case where a `--deck-*` role would be actively wrong.
262
+ */
263
+ .deck-device-cutout {
264
+ position: absolute;
265
+ left: 50%;
266
+ z-index: 30;
267
+ translate: -50% 0;
268
+ background: #000;
269
+ pointer-events: none;
270
+ }
271
+ /*
272
+ ★★ ANY MEDIA IN THE SCREEN COVERS IT, WHETHER IT ARRIVED BY PROP OR BY SLOT.
273
+
274
+ A screen is a fixed aperture at the model's own logical points, and a still is whatever shape it
275
+ was captured at. `cover` trims the difference; `contain` would letterbox it against the screen's
276
+ own ground, which reads as a bug rather than as a phone.
277
+
278
+ `video` is named alongside `img` deliberately. A demo reel in the screen is the case this is for,
279
+ and it needs no support beyond this rule — Slidev's own `<SlidevVideo>` is the one to reach for,
280
+ because it holds a poster and a timestamp for PRINT, where a plain `<video>` exports as a blank
281
+ rectangle. The deck audit is unbothered either way: its settle fingerprints geometry and text
282
+ colour and waits on `document.getAnimations()`, and a playing video is none of those.
283
+ */
284
+ /*
285
+ The live page fills the aperture at the model's own logical points — it is INSIDE the transformed
286
+ shell, so its viewport is 440x956 whatever height the phone is drawn at.
287
+
288
+ ⚠ THE POINTER LANDS WHERE THE PHONE IS DRAWN, NOT WHERE THE FRAME LAYS OUT. The shell is
289
+ `transform: scale()`d, and a transform moves hit-testing with it — so a scroll over the visible
290
+ phone reaches the page even though the iframe's layout box is the unscaled 440x956. Worth stating
291
+ because the opposite is the usual `zoom` vs `transform` trap, and here the transform is what makes
292
+ interaction work at all.
293
+
294
+ White rather than the deck's ground, so a page that has not painted reads as a blank page rather
295
+ than as a cream screen.
296
+ */
297
+ .deck-device-live {
298
+ position: absolute;
299
+ inset: 0;
300
+ width: 100%;
301
+ height: 100%;
302
+ border: 0;
303
+ background: #fff;
304
+ }
305
+ /* `interactive: false` — the CSS half of `inert`. See BrowserFrame's own copy of this rule. */
306
+ .deck-device-live.is-frozen {
307
+ pointer-events: none;
308
+ }
309
+ .deck-device-screen :deep(img),
310
+ .deck-device-screen :deep(video),
311
+ .deck-device-screen :deep(picture) {
312
+ display: block;
313
+ width: 100%;
314
+ height: 100%;
315
+ object-fit: cover;
316
+ }
317
+ .deck-device-media {
318
+ display: block;
319
+ width: 100%;
320
+ height: 100%;
321
+ object-fit: cover;
322
+ }
323
+ .deck-device-screen {
324
+ position: absolute;
325
+ overflow: hidden;
326
+ /* The deck's own ground, so an empty screen reads as a screen rather than as a hole, and a mock
327
+ that brings no background of its own sits on the register the rest of the slide is using. */
328
+ background: var(--deck-bg);
329
+ color: var(--deck-fg);
330
+ }
331
+ </style>
@@ -0,0 +1,103 @@
1
+ <script setup>
2
+ /*
3
+ A donut sized for a room — up to five slices of one whole, with the total in the middle.
4
+
5
+ A donut is read by AREA, not by angle, which is why it survives only a handful of slices and why
6
+ this component inherits the palette's five-colour ceiling as a hard limit: a sixth slice would
7
+ need a sixth colour that does not exist, and chartData.ts stops the chart rather than inventing
8
+ one or repeating a colour. If the data has a long tail, aggregate it into a "Rest" slice at the
9
+ call site — that is a decision about the argument, not about the chart.
10
+
11
+ The hole is not decoration. It is where the TOTAL goes, which is the one number a part-to-whole
12
+ chart cannot show any other way, and it is the reason to reach for this over a pie.
13
+
14
+ Real @unovis/vue mark, ChartFrame.vue for ceremony, chartData.ts for shaping.
15
+ */
16
+ import { computed } from 'vue';
17
+ import { VisSingleContainer, VisDonut, VisTooltip, VisDonutSelectors } from '@unovis/vue';
18
+ import { deckTooltip, tipFrame } from './chartTip';
19
+ import ChartFrame from './ChartFrame.vue';
20
+ import { formatValue, shareState, shareValues, stateMessage } from './chartData';
21
+
22
+ const props = defineProps({
23
+ /** Rows: `[{ label, value }]`, one per slice, in the order they should read clockwise. */
24
+ data: { type: Array, required: true },
25
+ /** The figure in the hole. Omit and the slices' own total is used; pass an empty string for no centre at all. */
26
+ centralLabel: { type: String, default: null },
27
+ /** The word under that figure — what the total counts. */
28
+ centralSubLabel: { type: String, default: null },
29
+ /** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
30
+ * which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
31
+ * so a falsy height makes the container measure itself. Pass a number where the box has no
32
+ * definite height of its own — a tiled grid cell, a figure beside copy. */
33
+ height: { type: Number, default: 0 },
34
+ /** Ring thickness in px. Thicker reads as a donut, thinner as a dial. */
35
+ arcWidth: { type: Number, default: 96 },
36
+ /** Draw the slice legend. A donut with no legend and no direct labels says nothing. */
37
+ legend: { type: Boolean, default: true },
38
+ /** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
39
+ * or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
40
+ * caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
41
+ * full-width plot and pulled to an edge where a centred key would float in a gutter. */
42
+ legendPosition: {
43
+ type: String,
44
+ default: 'bottom-center',
45
+ validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
46
+ },
47
+ /** Formats the default centre figure. Ignored when `centralLabel` is set. */
48
+ format: { type: Function, default: formatValue },
49
+ /** The sentence drawn when there is nothing to plot — including a set of slices that all sum to zero. */
50
+ empty: { type: String, default: 'No data' },
51
+ });
52
+
53
+ const state = computed(() => shareState(props.data));
54
+ const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
55
+ const seriesList = computed(() => (state.value.kind === 'ready' ? state.value.series : []));
56
+
57
+ // The container holds a plain number column whose order matches the slices; the mark reads each
58
+ // number straight back out. Gaps count as zero here, and only here: a slice with no reading has no
59
+ // share of the whole, which is the one place a missing value and a nought genuinely agree.
60
+ const values = computed(() => shareValues(props.data ?? []));
61
+ const colors = computed(() => seriesList.value.map((s) => s.color));
62
+ const total = computed(() => values.value.reduce((a, b) => a + b, 0));
63
+ const centre = computed(() => props.centralLabel ?? props.format(total.value));
64
+
65
+ /*
66
+ A donut has no shared x to hover, so the tooltip hangs off the SEGMENT rather than a crosshair.
67
+ Unovis hands the arc, whose `index` is the slice's place in the data — the same order the legend
68
+ and the colours use, so the name and the swatch come from one lookup rather than three.
69
+
70
+ The share is the reason a donut tooltip earns its place: the arc already shows it approximately,
71
+ and the number is what a reader asks for out loud.
72
+ */
73
+ const tip = (d, i) => {
74
+ const idx = Number.isFinite(d?.index) ? d.index : i;
75
+ const s = seriesList.value[idx];
76
+ const v = values.value[idx] ?? 0;
77
+ const share = total.value > 0 ? Math.round((v / total.value) * 100) : 0;
78
+ return tipFrame(s?.name ?? '', [{ swatch: s?.color, label: `${share}%`, value: props.format(v) }]);
79
+ };
80
+ </script>
81
+
82
+ <template>
83
+ <ChartFrame
84
+ :series="seriesList"
85
+ :height="height"
86
+ :legend="legend"
87
+ :legend-position="legendPosition"
88
+ :message="message"
89
+ >
90
+ <VisSingleContainer :data="values" :height="height" :duration="0">
91
+ <VisDonut
92
+ :value="(d) => d"
93
+ :color="colors"
94
+ :arc-width="arcWidth"
95
+ :corner-radius="8"
96
+ :pad-angle="0.02"
97
+ :central-label="centre"
98
+ :central-sub-label="centralSubLabel ?? undefined"
99
+ />
100
+ <VisTooltip v-bind="deckTooltip()" :triggers="{ [VisDonutSelectors.segment]: tip }" />
101
+ </VisSingleContainer>
102
+ </ChartFrame>
103
+ </template>
@@ -0,0 +1,126 @@
1
+ <script setup>
2
+ /*
3
+ London as a dot grid — the design system's own map primitive, drawn as plain SVG.
4
+
5
+ Why this and not a real map on a slide: Mapbox needs a token, a network round-trip and a
6
+ canvas that has to finish painting before the slide is presentable. The dot grid is the
7
+ system's stylised London, sampled at build time from the real borough geometry, and it ships
8
+ as coordinates in @humanforest/tokens/geo — so a slide gets a recognisable London that is on
9
+ the ramp, offline, and identical to the one the product draws.
10
+
11
+ Coordinates in the file are [column, row] indices over a width×height grid, plus the bbox the
12
+ grid was sampled in. That bbox is what makes `highlight` able to take real lng/lat: a point
13
+ bins to a dot with the same equirectangular projection scripts/londonDots.ts used, restated
14
+ in `binOf` below.
15
+
16
+ THREE TIERS, and the split matters at presenting distance:
17
+ land the shape. Quiet, but not so quiet it dissolves on a projector.
18
+ river the Thames, in the palette's own river pair rather than a grey — it is the one
19
+ feature that tells a room which city this is, so it is not a background detail.
20
+ highlight the accent, AND a larger dot. At coarse density a borough is a single cell, and
21
+ a single cell at the land weight is invisible from the back row.
22
+ */
23
+ import { computed } from 'vue'
24
+
25
+ import coarse from '@humanforest/tokens/geo/london-dots-coarse.json'
26
+ import medium from '@humanforest/tokens/geo/london-dots-medium.json'
27
+ import fine from '@humanforest/tokens/geo/london-dots-fine.json'
28
+ import { highlightedCells } from './dottedMapGeometry.js'
29
+
30
+ const GRIDS = { coarse, medium, fine }
31
+
32
+ const props = defineProps({
33
+ /** coarse 57×44 · medium 75×58 · fine 96×74 (measured from the generated grids, scripts/londonDots.ts — not the round numbers an earlier draft of this doc guessed at). Coarser reads better at presenting distance. */
34
+ density: {
35
+ type: String,
36
+ default: 'coarse',
37
+ validator: (v) => ['coarse', 'medium', 'fine'].includes(v),
38
+ },
39
+ /** Rendered width in canvas px. Height follows from the grid's aspect ratio. */
40
+ width: { type: Number, default: 900 },
41
+ /** How LAND and RIVER cells are drawn: a filled circle or a square. Highlighted cells (see `highlight`) are always drawn as a circle regardless of this prop — the mark is a pin, not a grid cell, and stays a distinct shape from the base grid even when the base grid itself is drawn as squares. */
42
+ shape: { type: String, default: 'dot', validator: (v) => ['dot', 'square'].includes(v) },
43
+ /** Whether to draw the Thames beneath the land dots, in the palette's own river colour. */
44
+ river: { type: Boolean, default: true },
45
+ /**
46
+ * Dots to lift into the accent. Accepts either grid indices ([col, row]) or real coordinates
47
+ * ({ lng, lat }) — mixing the two in one array is fine.
48
+ */
49
+ highlight: { type: Array, default: () => [] },
50
+ /** Fraction of the dot cell the mark fills. Below ~0.5 the grid reads as texture, above as a field. */
51
+ weight: { type: Number, default: 0.62 },
52
+ })
53
+
54
+ const grid = computed(() => GRIDS[props.density])
55
+ const height = computed(() => (props.width * grid.value.height) / grid.value.width)
56
+
57
+ /* Binning + validation live in dottedMapGeometry.js (unit-tested there — see that module's own doc
58
+ comment for the two silent failures this fix closes: a bad highlight input producing NaN
59
+ instead of an error, and a correctly-binned RIVER point never drawing because the old `onDots`
60
+ only ever looked in the LAND list). */
61
+ const onCells = computed(() => highlightedCells(props.highlight, grid.value))
62
+ const onKeys = computed(() => new Set(onCells.value.map(([c, r]) => `${c},${r}`)))
63
+
64
+ /* Land and highlights are split into separate lists rather than one list with a class, so the
65
+ highlighted dot can carry its own radius — a class can change colour but not geometry. */
66
+ const isOn = (col, row) => onKeys.value.has(`${col},${row}`)
67
+ const landDots = computed(() => grid.value.dots.filter(([c, r]) => !isOn(c, r)))
68
+ /* Drawn from the validated highlight cells directly, NOT `grid.dots.filter(isOn)` — a highlight
69
+ that bins to a river cell is still real and must still draw; it was never in `grid.dots` (the
70
+ LAND list) to begin with. */
71
+ const onDots = computed(() => onCells.value)
72
+
73
+ const r = computed(() => props.weight / 2)
74
+ /* A highlight fills its cell and a little of its neighbours' — 1.7× the land radius. Enough to
75
+ read as a marker rather than a slightly darker dot, without closing the grid up. */
76
+ const rOn = computed(() => (props.weight / 2) * 1.7)
77
+ </script>
78
+
79
+ <template>
80
+ <svg
81
+ :viewBox="`0 0 ${grid.width} ${grid.height}`"
82
+ :width="width"
83
+ :height="height"
84
+ class="dotmap"
85
+ role="img"
86
+ aria-label="Map of London"
87
+ >
88
+ <!-- The Thames sits under the land dots: it is the orienting shape, not a data layer. -->
89
+ <g v-if="river" class="dotmap-river">
90
+ <template v-for="([col, row], i) in grid.river" :key="`r${i}`">
91
+ <circle v-if="shape === 'dot'" :cx="col + 0.5" :cy="row + 0.5" :r="r" />
92
+ <rect v-else :x="col + (1 - weight) / 2" :y="row + (1 - weight) / 2" :width="weight" :height="weight" />
93
+ </template>
94
+ </g>
95
+
96
+ <g class="dotmap-land">
97
+ <template v-for="([col, row], i) in landDots" :key="`d${i}`">
98
+ <circle v-if="shape === 'dot'" :cx="col + 0.5" :cy="row + 0.5" :r="r" />
99
+ <rect v-else :x="col + (1 - weight) / 2" :y="row + (1 - weight) / 2" :width="weight" :height="weight" />
100
+ </template>
101
+ </g>
102
+
103
+ <g class="dotmap-on">
104
+ <circle v-for="([col, row], i) in onDots" :key="`h${i}`" :cx="col + 0.5" :cy="row + 0.5" :r="rOn" />
105
+ </g>
106
+ </svg>
107
+ </template>
108
+
109
+ <style scoped>
110
+ .dotmap {
111
+ display: block;
112
+ max-width: 100%;
113
+ height: auto;
114
+ }
115
+ /* Nothing here picks a colour — the three tiers are roles, so the map re-points itself on a
116
+ dark slide along with everything else. */
117
+ .dotmap-land :where(circle, rect) {
118
+ fill: var(--deck-dot-land);
119
+ }
120
+ .dotmap-river :where(circle, rect) {
121
+ fill: var(--deck-dot-river);
122
+ }
123
+ .dotmap-on circle {
124
+ fill: var(--deck-accent);
125
+ }
126
+ </style>
@@ -0,0 +1,76 @@
1
+ <!--
2
+ A third-party certification mark. One today: Certified B Corporation.
3
+
4
+ ★★ NAMED FOR THE CATEGORY, NOT THE MARK, AND NOT ONLY FOR ROOM TO GROW. `BCorp` was the obvious
5
+ name and it is unusable here: `kebab()` turns it into `bcorp` (its regex needs a lowercase before
6
+ an uppercase, and `BC` has none), comark emits `<bcorp>`, and Vue resolves that to `Bcorp` — which
7
+ is not the registered `BCorp`. The tag would have rendered nothing at all, silently. Any component
8
+ whose name opens with two capitals has the same fault; `Endorsement` round-trips.
9
+
10
+ ★★ THIS IS NOT OUR IDENTITY, AND THAT IS THE WHOLE CONTRACT. Every other mark this theme draws is
11
+ generated from the tokens primitives — the Forest mark is a path built in packages/tokens, its ink
12
+ is `currentColor`, and it takes whatever ground it lands on. This one is owned by B Lab and
13
+ governed by B Lab's rules: it ships as a fixed vendor SVG, used as supplied, never redrawn and
14
+ never recoloured. apps/docs states the same rule on the endorsements page it publishes.
15
+
16
+ ★ SO THERE IS NO COLOUR PROP, AND THERE MUST NOT BE. The mark has exactly two sanctioned
17
+ colourways and the component SWAPS FILES between them — it does not tint one. A `color` or a
18
+ `mask` here would be the tinting the licence forbids, and it is the obvious thing to reach for in
19
+ a theme where everything else follows the ink, which is why the absence is written down.
20
+
21
+ ★ Verra is deliberately NOT a value of `mark`. Its artwork needs written approval
22
+ (verra.org/trademarks) and the docs page renders a placeholder rather than the real thing; a
23
+ second value here would be a second place for that to go wrong. The enum is where it joins, once
24
+ it is approved.
25
+ -->
26
+ <script setup>
27
+ import { computed } from 'vue';
28
+ /*
29
+ Both colourways are imported, so the bundler carries two files and the component picks between
30
+ them at render. The alternative — one file plus a filter — is the recolouring the licence forbids.
31
+ */
32
+ import positiveUrl from '../endorsements/b-corp-black.svg?url';
33
+ import reverseUrl from '../endorsements/b-corp-white.svg?url';
34
+
35
+ const props = defineProps({
36
+ /**
37
+ * Which certification. One value today — the enum exists so Verra can join it when its artwork is
38
+ * approved, rather than the component being renamed at that point.
39
+ */
40
+ mark: { type: String, default: 'b-corp', validator: (m) => ['b-corp'].includes(m) },
41
+ /**
42
+ * Which sanctioned colourway to draw. `positive` is the black mark, for light grounds; `reverse`
43
+ * is the white one, for dark grounds and photographs. These are the two files B Lab supplies —
44
+ * the prop chooses between them and nothing tints either.
45
+ */
46
+ tone: { type: String, default: 'positive', validator: (t) => ['positive', 'reverse'].includes(t) },
47
+ /**
48
+ * Drawn height in canvas px. The mark is taller than it is wide (77 × 128 in the vendor file), so
49
+ * height is the honest dimension to size it by and the width follows the aspect.
50
+ */
51
+ size: { type: Number, default: 96 },
52
+ });
53
+
54
+ const src = computed(() => (props.tone === 'reverse' ? reverseUrl : positiveUrl));
55
+ </script>
56
+
57
+ <template>
58
+ <img
59
+ class="deck-endorsement"
60
+ :src="src"
61
+ :style="{ height: `${size}px` }"
62
+ alt="Certified B Corporation"
63
+ >
64
+ <!--
65
+ `alt` names the certification rather than the picture — a reader who cannot see it needs to know
66
+ the deck carries the certification, which is what the mark is there to say.
67
+ -->
68
+ </template>
69
+
70
+ <style scoped>
71
+ .deck-endorsement {
72
+ /* Width follows the vendor aspect; nothing else is set, because nothing else may be. */
73
+ width: auto;
74
+ display: block;
75
+ }
76
+ </style>