@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,263 @@
1
+ <script setup>
2
+ /*
3
+ A band of words moving slowly across the slide — city names, values, partner names, the fleet's
4
+ vehicle types. It is the one piece of MOTION this deck owns as content rather than as a
5
+ transition, and it earns its place for a reason that only applies in a room: a static list of
6
+ twenty place names is a wall of small type, while the same twenty moving through a single band
7
+ are read one at a time, at full size, by an audience that is looking at the presenter anyway.
8
+
9
+ ★★ THE TRACK IS DUPLICATED, AND THAT IS THE WHOLE MECHANISM. One copy of the items translated
10
+ from 0% to -100% snaps back to a gap; two identical copies translated by exactly -50% of the pair
11
+ put copy B where copy A started, so the loop has no seam. `aria-hidden` on the second copy stops a
12
+ screen reader announcing every item twice.
13
+
14
+ ★ IT MUST BE LEGIBLE STANDING STILL, which is not a nicety — it is the state this thing is in
15
+ whenever it matters most:
16
+
17
+ · REDUCED MOTION. The engine's floor (@humanforest/tokens/motion/motion.css) sets
18
+ `animation-duration: 1ms` and `animation-iteration-count: 1` on everything, so the band lands
19
+ on its first frame and stops. That frame is the track at translate(0), i.e. the items in
20
+ order from the left — which is why the animation runs 0 -> -50% and never the reverse. A
21
+ marquee written to start mid-cycle would honour reduced motion by freezing on a half-word.
22
+ · EXPORT. `slidev export` drives a real browser and screenshots it, so a PDF would otherwise
23
+ catch the band wherever it happened to be. Slidev wraps the printed deck in `#print-container`
24
+ — the animation is switched off inside it, so an exported slide shows the same first frame.
25
+
26
+ No `background` or `variant`. A marquee is a band of text on the slide's own ground; a coloured strip
27
+ behind it is a Card's job, and a deck that wants one wraps this in one.
28
+ */
29
+ import { computed, onMounted, ref } from 'vue';
30
+ import { iconUrlOrFallback } from './iconUrl';
31
+ import { assetUrl } from '../assetUrl.js';
32
+
33
+ const props = defineProps({
34
+ /**
35
+ * The items, in order. Short — each one is read on its own as it passes. Either a plain string,
36
+ * or `{ label?, icon?, image? }`:
37
+ * · `icon` — a namespaced slug (`forest:bike`, `lucide:battery`), drawn as a CSS mask in the
38
+ * deck's own ink, so it inherits the band's colour like the words beside it do.
39
+ * · `image` — a URL, for artwork this repo does not own the vector of (a partner mark, a photo).
40
+ * A mark with no `label` is aria-hidden in BOTH copies rather than only the second: an icon with
41
+ * no words is decoration, and a screen reader announcing "image" eleven times is worse than
42
+ * silence.
43
+ */
44
+ items: {
45
+ type: Array,
46
+ required: true,
47
+ validator: (v) =>
48
+ Array.isArray(v) &&
49
+ v.length > 0 &&
50
+ v.every((i) =>
51
+ typeof i === 'string'
52
+ ? i.trim().length > 0
53
+ : !!i && typeof i === 'object' && [i.label, i.icon, i.image].some((f) => typeof f === 'string' && f.trim()),
54
+ ),
55
+ },
56
+ /** Seconds for one full cycle. Longer is slower; the default is tuned so a word stays readable
57
+ * for about as long as it takes to say it. */
58
+ duration: { type: Number, default: 40 },
59
+ /** Run right-to-left instead. Two marquees at opposite settings is the classic pair — it reads as
60
+ * a texture rather than as a list going somewhere. */
61
+ reverse: { type: Boolean, default: false },
62
+ /**
63
+ * Height of an `image` item, in px. 0 (the default) sizes it against the band's own type instead,
64
+ * which is right when a picture travels BESIDE a word and wrong when it travels alone: a band of
65
+ * pictures with no labels has no type to be in proportion to, and inherits a height set for a
66
+ * mark sitting next to a title. Icons are deliberately not covered — a mark is punctuation for
67
+ * the word beside it and stays locked to it.
68
+ */
69
+ imageHeight: { type: Number, default: 0 },
70
+ });
71
+
72
+ /*
73
+ ★★ THE BAND DOES NOT PAINT UNTIL ITS OWN METRICS ARE FINAL, and that is a layout fact rather than
74
+ a polish one. Every item is `inline-flex` inside a `width: max-content` track, so each width is
75
+ its own content's and a late arrival re-widths the whole row — and because the row is a row, the
76
+ deltas ACCUMULATE: item two moves by item one's change, item three by the sum of both. Measured on
77
+ the specimen's own three bands, with each cause isolated:
78
+
79
+ · the webfont swap the eighth word of the city band lands 123px from where the fallback face
80
+ put it (packages/fonts sets `font-display: swap`, so the fallback really is
81
+ rendered first).
82
+ · a remote picture 2114px by the tenth item of the image band. An <img> whose height is CSS
83
+ and whose src is a URL is a REAL height and a ZERO width until it lands, so
84
+ the pictures shove each other along the track as they arrive one by one.
85
+
86
+ Hidden, never unmounted, and that is the whole reason this is `visibility` rather than `v-if` or
87
+ `display: none`: the band's HEIGHT is CSS in every case (a line box, or --marquee-image-h), never
88
+ content, so the space is already the right size and nothing below the band moves when it appears.
89
+ The track keeps animating while hidden, so it reveals mid-scroll rather than from a standing
90
+ start — the first frame contract above is about a STOPPED marquee, which is a different state.
91
+ */
92
+ const band = ref(null);
93
+ const ready = ref(false);
94
+ onMounted(async () => {
95
+ const root = band.value;
96
+ /* The band's OWN face, asked for by name rather than waiting on document.fonts.ready alone:
97
+ `ready` resolves whenever font loading happens to be idle, which at mount can be before this
98
+ slide's faces have been requested at all. */
99
+ const item = root?.querySelector('.deck-marquee-item');
100
+ const settled = Promise.all([
101
+ item && document.fonts ? document.fonts.load(`1em ${getComputedStyle(item).fontFamily}`).catch(() => {}) : null,
102
+ ...[...(root?.querySelectorAll('img') ?? [])].map((img) => img.decode().catch(() => {})),
103
+ ]);
104
+ /* ★ THE HOLD HAS A CEILING, and it is the same 3s font-display: block would have used. Catching
105
+ every rejection covers a 404 picture and a face that fails to resolve; it does NOT cover a
106
+ request that simply never answers, which is the projector-on-hotel-wifi case — and a band that
107
+ stays blank for the length of the talk is a worse failure than the jump it was hiding. */
108
+ await Promise.race([settled, new Promise((resolve) => setTimeout(resolve, 3000))]);
109
+ ready.value = true;
110
+ });
111
+
112
+ /* One shape for the template to iterate, so the string form stays the shorthand it always was and
113
+ the template never branches on the item's TYPE — only on which parts it carries. */
114
+ const normalised = computed(() =>
115
+ props.items.map((item) => {
116
+ const it = typeof item === 'string' ? { label: item } : item;
117
+ return {
118
+ label: typeof it.label === 'string' ? it.label : '',
119
+ image: typeof it.image === 'string' ? it.image : '',
120
+ // Resolved here, not in the template: iconUrlOrFallback falls back to the Forest mark, so a
121
+ // slug that never resolves is visibly the wrong glyph rather than an invisible gap in the
122
+ // band — which is what an unresolved mask leaves, with no console error and geometry the
123
+ // audit still measures as fine.
124
+ mask: it.icon ? `url("${iconUrlOrFallback(it.icon)}")` : '',
125
+ };
126
+ }),
127
+ );
128
+ </script>
129
+
130
+ <template>
131
+ <div
132
+ ref="band"
133
+ class="deck-marquee"
134
+ :class="{ 'is-reverse': reverse, 'is-ready': ready }"
135
+ :style="{ '--marquee-duration': `${duration}s`, ...(imageHeight ? { '--marquee-image-h': `${imageHeight}px` } : {}) }"
136
+ >
137
+ <div class="deck-marquee-track">
138
+ <!-- Copy A: the real one, and the one a stopped marquee shows. -->
139
+ <span
140
+ v-for="(item, i) in normalised"
141
+ :key="`a-${i}`"
142
+ class="deck-marquee-item"
143
+ :aria-hidden="item.label ? undefined : 'true'"
144
+ >
145
+ <img v-if="item.image" class="deck-marquee-image" :src="assetUrl(item.image)" :alt="item.label" decoding="async" >
146
+ <span v-else-if="item.mask" class="deck-marquee-glyph" :style="{ maskImage: item.mask, webkitMaskImage: item.mask }" />
147
+ <span v-if="item.label" class="deck-marquee-label">{{ item.label }}</span>
148
+ </span>
149
+ <!-- Copy B: the seam filler. Hidden from assistive tech — same items, no second reading. -->
150
+ <span v-for="(item, i) in normalised" :key="`b-${i}`" class="deck-marquee-item" aria-hidden="true">
151
+ <img v-if="item.image" class="deck-marquee-image" :src="assetUrl(item.image)" alt="" decoding="async" >
152
+ <span v-else-if="item.mask" class="deck-marquee-glyph" :style="{ maskImage: item.mask, webkitMaskImage: item.mask }" />
153
+ <span v-if="item.label" class="deck-marquee-label">{{ item.label }}</span>
154
+ </span>
155
+ </div>
156
+ </div>
157
+ </template>
158
+
159
+ <style scoped>
160
+ .deck-marquee {
161
+ overflow: hidden;
162
+ /* The band is a full-bleed device — it wants the slide's whole width, not the text measure. The
163
+ layout's own padding is escaped by the deck's inset so the words really do run off both edges,
164
+ which is what makes it read as a band rather than as a wide paragraph. */
165
+ width: 100%;
166
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
167
+ mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
168
+ }
169
+
170
+ .deck-marquee-track {
171
+ display: flex;
172
+ width: max-content;
173
+ align-items: center;
174
+ animation: deck-marquee-scroll var(--marquee-duration) linear infinite;
175
+ }
176
+ /* See the `ready` block in the script — the track holds its space and its animation, and paints
177
+ only once the face and the pictures that set its widths have both landed. */
178
+ .deck-marquee:not(.is-ready) .deck-marquee-track {
179
+ visibility: hidden;
180
+ }
181
+ .is-reverse .deck-marquee-track {
182
+ animation-direction: reverse;
183
+ }
184
+
185
+ /* ★ -50%, NOT -100%. The track holds TWO copies of the list, so half its width is one full cycle,
186
+ and at -50% copy B sits exactly where copy A began. Animating `translate` rather than `transform`
187
+ deliberately: in this system `transform` is a separate property and naming the wrong one animates
188
+ nothing here while leaving the element still transformed elsewhere. */
189
+ @keyframes deck-marquee-scroll {
190
+ from { translate: 0 0; }
191
+ to { translate: -50% 0; }
192
+ }
193
+
194
+ /* ★★ THE EXPORT FREEZE IS NOT HERE, AND IT CANNOT BE. It lives in styles/base.css, unscoped,
195
+ because Vue's scoped-CSS compiler DESTROYS the rule this component needs:
196
+
197
+ written :global(#print-container) .deck-marquee-track { animation: none }
198
+ compiled #print-container { animation: none }
199
+
200
+ The descendant is dropped and the declaration lands on the print container itself, which is a
201
+ no-op — so the band kept animating and the exported PDF caught it mid-word ("…thwark" where
202
+ "Southwark" should be). Verified by reading the compiled stylesheet out of dist/, after the PDF
203
+ showed it. A rule whose subject is an ANCESTOR outside this component belongs with the other
204
+ Slidev-integration rules, not in a scoped block that will silently rewrite it. */
205
+
206
+ .deck-marquee-item {
207
+ /* inline-flex, so a mark and its word sit on one baseline-centred row. The dot below is a flex
208
+ child now rather than an inline box — which is why it is centred by `align-items` here and not
209
+ by its own `vertical-align`. */
210
+ display: inline-flex;
211
+ align-items: center;
212
+ gap: var(--deck-space-3);
213
+ font-family: var(--type-deck-title-family);
214
+ font-size: var(--type-deck-title-size);
215
+ font-weight: var(--type-deck-title-weight);
216
+ line-height: 1.1;
217
+ color: var(--deck-fg);
218
+ white-space: nowrap;
219
+ padding-inline: var(--deck-space-5);
220
+ }
221
+
222
+ /* A mark is sized against the TYPE it travels with, in `em`, not against a spacing rung: the band's
223
+ whole premise is that an item is read at title size as it passes, and a glyph pinned to a fixed
224
+ px would drift out of proportion the moment the type ramp moves. */
225
+ .deck-marquee-glyph {
226
+ display: block;
227
+ flex: none;
228
+ width: 1.1em;
229
+ height: 1.1em;
230
+ /* currentColor, so a mark inherits the band's ink exactly as the words beside it do — the mask
231
+ technique's one real advantage over an <img> for artwork this repo owns the vector of. */
232
+ background-color: currentColor;
233
+ mask-repeat: no-repeat;
234
+ mask-position: center;
235
+ mask-size: contain;
236
+ -webkit-mask-repeat: no-repeat;
237
+ -webkit-mask-position: center;
238
+ -webkit-mask-size: contain;
239
+ }
240
+
241
+ /* An image cannot take the band's ink, so it keeps its own colour — correct for a partner mark,
242
+ which is exactly the case that cannot be a mask. Height-constrained and width-auto so logos of
243
+ different aspect ratios all sit on the same optical line. */
244
+ .deck-marquee-image {
245
+ display: block;
246
+ flex: none;
247
+ height: var(--marquee-image-h, 1.4em);
248
+ width: auto;
249
+ object-fit: contain;
250
+ }
251
+ /* The separator is a dot in the accent, drawn between items rather than after them — an item is
252
+ the thing being read, the dot is only there so two words do not run together. */
253
+ .deck-marquee-item::after {
254
+ content: '';
255
+ display: inline-block;
256
+ width: 0.28em;
257
+ height: 0.28em;
258
+ border-radius: var(--deck-radius-full);
259
+ background: var(--deck-accent);
260
+ vertical-align: 0.28em;
261
+ margin-inline-start: var(--deck-space-5);
262
+ }
263
+ </style>
@@ -0,0 +1,295 @@
1
+ <script setup>
2
+ /*
3
+ A nested donut sized for a room — one whole, split, and each part split again.
4
+
5
+ Reach for this over DonutChart.vue only when the second level is the POINT. A donut is already
6
+ read by area rather than angle, and a second ring asks the audience to compare areas inside areas;
7
+ if the inner split is incidental, it costs the outer comparison and buys nothing. Two rings is the
8
+ honest ceiling, and the geometry below says why: a third leaves no hole for the total.
9
+
10
+ THE OUTER RING IS THE CHART. It carries the five-colour palette and the legend, and inherits the
11
+ ceiling whole — chartData.ts's `shareState` is handed one row per top-level group, so six groups
12
+ draws the over-capacity message rather than a sixth colour that does not exist. The inner ring
13
+ carries no colours of its own: each segment is a TINT of its parent, so the family reads as a
14
+ family and the inner ring can hold as many slices as the arcs can fit.
15
+
16
+ ★ THE LIBRARY'S OWN TINT RAMP IS UNUSABLE AT ROOM SCALE, which is why `segmentColor` below is
17
+ passed rather than left to default. With no accessor, @unovis/ts inherits the parent's fill and
18
+ fades it with `scaleLinear().domain([-1, n]).range([1, 0])` (components/nested-donut/index.js),
19
+ so child i of n lands at alpha 1 − (i+1)/(n+1): five children give 0.83, 0.67, 0.5, 0.33, 0.17.
20
+ The last is the slide ground with a rumour of colour on it — a dashboard's idea of a subtle
21
+ distinction, and the same defect class as a 2px line on a projector. The mix here runs 85% of the
22
+ base down to 55% instead, and mixes toward `--deck-bg` rather than toward transparency, so the
23
+ ramp runs the same direction on a light slide and a dark one and stays a `var()` reference that
24
+ re-resolves on a theme flip. Both ends of that range are load-bearing — see `TINT_CEILING`.
25
+
26
+ ROW ORDER IS THE RAMP ORDER. Unovis leaves `sort` undefined and d3's pie keeps input order, so
27
+ the first sub-row of a group is the strongest tint. A lightness ramp reads as an ordering whether
28
+ or not one was meant, so order the rows by the quantity, or by something the audience already
29
+ knows the order of.
30
+
31
+ Real @unovis/vue mark, ChartFrame.vue for ceremony, chartData.ts for shaping.
32
+ */
33
+ import { computed } from 'vue';
34
+ import { VisSingleContainer, VisNestedDonut, VisTooltip, VisNestedDonutSelectors } from '@unovis/vue';
35
+ import { deckTooltip, tipFrame } from './chartTip';
36
+ import ChartFrame from './ChartFrame.vue';
37
+ import { formatValue, isPlottable, scatterGroups, shareState, stateMessage } from './chartData';
38
+
39
+ const props = defineProps({
40
+ /** Rows, one per leaf: `[{ <outer field>, <inner field>, value }]`. Flat — the rings are derived, not nested. A row that does not name EVERY ring cannot be drawn on all of them, so it is dropped from the chart, the legend and the centre total alike. */
41
+ data: { type: Array, required: true },
42
+ /** Field names, OUTERMOST FIRST — Unovis' default `inwards` direction puts the first layer on the outer radius. Omit and every string field of the first row becomes a ring, in declaration order. */
43
+ layers: { type: Array, default: null },
44
+ /** The figure in the hole. Omit and the groups' own total is used; pass an empty string for no centre at all. */
45
+ centralLabel: { type: String, default: null },
46
+ /** The word under that figure — what the total counts. One line: it does not wrap, and the hole is 208px wide at the default geometry, so a phrase runs out over the arcs. */
47
+ centralSubLabel: { type: String, default: null },
48
+ /** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
49
+ * which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
50
+ * so a falsy height makes the container measure itself. Pass a number where the box has no
51
+ * definite height of its own — a tiled grid cell, a figure beside copy. */
52
+ height: { type: Number, default: 0 },
53
+ /** Thickness of ONE ring in px — DonutChart's `arcWidth` is the same measurement for its single ring. The hole is what is left: `min(width, height) / 2 − rings × ringWidth − (rings − 1) × 8`. At the default height with two rings that is a 208px hole, which holds a 60px figure over a 36px sub-label; three rings at the same height leaves 32px and needs a taller chart or a thinner ring. The library's own default (`radius × 0.75 / rings`, a 120px hole at two rings) does not. */
54
+ ringWidth: { type: Number, default: 64 },
55
+ /** Name the arcs in place. Off by default: Unovis measures a `perpendicular` label against the RING WIDTH and hides what overflows, so a 36px label inside a 64px ring is hidden on nearly every arc. `along` is the alignment that fits, and it rotates the text — worth switching on only for a handful of short names. */
56
+ segmentLabels: { type: Boolean, default: false },
57
+ /** Draw the group legend. It names the outer ring; nothing names the inner one but `segmentLabels`. */
58
+ legend: { type: Boolean, default: true },
59
+ /** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
60
+ * or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
61
+ * caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
62
+ * full-width plot and pulled to an edge where a centred key would float in a gutter. */
63
+ legendPosition: {
64
+ type: String,
65
+ default: 'bottom-center',
66
+ validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
67
+ },
68
+ /** Formats the default centre figure. Ignored when `centralLabel` is set. */
69
+ format: { type: Function, default: formatValue },
70
+ /** The sentence drawn when there is nothing to plot — including a set of groups that all sum to zero. */
71
+ empty: { type: String, default: 'No data' },
72
+ });
73
+
74
+ const layerKeys = computed(() => {
75
+ if (props.layers?.length) return props.layers;
76
+ const first = props.data?.[0] ?? {};
77
+ return Object.keys(first).filter((k) => typeof first[k] === 'string');
78
+ });
79
+
80
+ /*
81
+ ★★ A ROW MUST NAME EVERY RING, AND THIS IS THE ONLY LIST ANYTHING READS — the legend, the centre
82
+ figure and the data the mark is handed all come from here, which is the whole reason it exists.
83
+
84
+ Unovis keeps a segment only when it has a key AND its parent has a value
85
+ (`descendants().filter(d => d.parent?.value && d.data.key)`, components/nested-donut/index.js).
86
+ That drops an empty-keyed node but NOT its children. Passing the raw rows while filtering only the
87
+ totals therefore paints the one picture this chart must never paint: a row with no outer field is
88
+ absent from the legend and from the centre, its own outer arc is dropped for an empty key — and
89
+ its inner-ring child survives, inheriting the fill that dropped parent was handed. That fill comes
90
+ out of Unovis' own --vis-color ramp by index, because `getColor` falls back whenever
91
+ `segmentColor` cannot resolve a root it has never seen: replayed over six rows with one such
92
+ orphan, the leaf drew at `var(--vis-color2)` and 0.5 opacity — half-strength series 3, sitting
93
+ under a hole in the outer ring, for a group in neither the legend nor the total. The centre read
94
+ 29 while the geometry divided 32. Filtering ONCE, here, is what keeps the three agreeing about
95
+ what the whole is.
96
+ */
97
+ const rows = computed(() => {
98
+ const keys = layerKeys.value;
99
+ if (!keys.length) return [];
100
+ return (props.data ?? []).filter((row) => keys.every((k) => String(row?.[k] ?? '') !== ''));
101
+ });
102
+
103
+ /*
104
+ One row per top-level group, which is the shape `shareState` already decides on — so the ceiling,
105
+ the empty cases and the wording all come from chartData.ts rather than being restated here.
106
+ `scatterGroups` is the distinct-values-in-first-seen-order helper; it is named for the scatter
107
+ because that is what first needed it, but nothing in it is about points.
108
+ */
109
+ const groupTotals = computed(() => {
110
+ const [outer] = layerKeys.value;
111
+ if (!outer) return [];
112
+ return scatterGroups(rows.value, outer).map((name) => ({
113
+ label: name,
114
+ value: rows.value.reduce(
115
+ (sum, row) => sum + (String(row[outer]) === name && isPlottable(row.value) ? row.value : 0),
116
+ 0,
117
+ ),
118
+ }));
119
+ });
120
+
121
+ const state = computed(() => shareState(groupTotals.value));
122
+ const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
123
+
124
+ /* The legend key is the group NAME rather than `shareState`'s own. That default is `String(value)`,
125
+ and the values handed to it here are group SUMS — two groups that happen to sum alike would carry
126
+ the same key through ChartFrame's `v-for`. Names cannot collide: `scatterGroups` returns each one
127
+ once. */
128
+ const seriesList = computed(() =>
129
+ state.value.kind === 'ready' ? state.value.series.map((s) => ({ ...s, key: s.name })) : [],
130
+ );
131
+
132
+ const total = computed(() => groupTotals.value.reduce((sum, g) => sum + g.value, 0));
133
+ const centre = computed(() => props.centralLabel ?? props.format(total.value));
134
+
135
+ /** Group name → its palette colour, so an arc and its legend key are tied by NAME. Position would
136
+ * also work today — d3's pie leaves unsorted children in data order — but a name cannot drift. */
137
+ const tone = computed(() => new Map(seriesList.value.map((s) => [s.name, s.color])));
138
+
139
+ const layerAccessors = computed(() => layerKeys.value.map((k) => (d) => String(d[k] ?? '')));
140
+
141
+ /* ★ AN ACCESSOR, NOT THE SETTINGS OBJECT ITSELF. The @unovis/vue wrapper declares
142
+ `layerSettings: { type: [Function, null] }` (components/nested-donut/index.js), and Vue's
143
+ `assertType` reads `Function` as a simple type: an object fails `value instanceof Function`, and
144
+ the `null` arm wants `value === null`. A plain object matches neither, so every mount of every
145
+ nested-donut slide logs `Invalid prop: type check failed for prop "layerSettings"`. The value
146
+ does still reach the config — validation only warns — but a deck that cries wolf on its own
147
+ components is a deck whose console nobody reads. `getValue(layerId, layerSettings)` calls a
148
+ function accessor and returns anything else unchanged, so the two forms are identical downstream
149
+ and this costs one closure. */
150
+ const layerSettings = computed(() => () => ({ width: props.ringWidth, labelAlignment: 'along' }));
151
+
152
+ const value = (d) => (isPlottable(d.value) ? d.value : 0);
153
+
154
+ /** The ends of the tint ramp, as a percentage of the parent's own palette colour.
155
+ *
156
+ * The top is not 100. `color-mix(in oklab, <base> 100%, …)` IS the base, so a ramp starting there
157
+ * hands the first — and largest — child of every group its parent's exact fill: two rings of one
158
+ * hue meeting edge to edge, which is the reading the 8px of layer padding is there to break, and
159
+ * more than 8px on a 1920px canvas can break on its own.
160
+ *
161
+ * The bottom is not lower because below roughly half, a mid-tone palette colour mixed into the
162
+ * ground stops reading as that colour at all. */
163
+ const TINT_CEILING = 85;
164
+ const TINT_FLOOR = 55;
165
+
166
+ /* Every node carries `data.root` — the key of its top-level ancestor, filled in by Unovis' own
167
+ breadth-first pass — so the base colour is one lookup at any depth rather than a parent walk. */
168
+ function segmentColor(segment) {
169
+ const base = tone.value.get(segment.data.root);
170
+ if (!base) return null;
171
+ if (segment.depth === 1) return base;
172
+ const siblings = segment.parent?.children ?? [];
173
+ const step = siblings.length > 1 ? siblings.indexOf(segment) / (siblings.length - 1) : 0;
174
+ const mix = Math.round(TINT_CEILING - step * (TINT_CEILING - TINT_FLOOR));
175
+ return `color-mix(in oklab, ${base} ${mix}%, var(--deck-bg))`;
176
+ }
177
+
178
+ /* Passed rather than left to the library's brightness test, which cannot run here: it parses the
179
+ fill with d3-color, and d3-color returns null for a `color-mix()` string, so every tinted arc
180
+ would score brightness 0 and take the light-on-dark ink whatever it is actually sitting on.
181
+ The rule is simpler than the test anyway — depth 1 is the palette colour at full strength, so the
182
+ ground is the right ink for it; every depth-2 arc is that colour moved TOWARDS the ground, 85% of
183
+ it at the very most, so the ground is the wrong ink for those. The two cases cannot meet in the
184
+ middle precisely because the ramp starts below 100%. */
185
+ const segmentLabelColor = (segment) => (segment.depth === 1 ? 'var(--deck-bg)' : 'var(--deck-fg)');
186
+
187
+ /*
188
+ A nested donut's segment carries its own ring, so the tooltip names the value and leaves the
189
+ hierarchy to the drawing — which ring a segment sits in is the one thing the picture already says
190
+ unambiguously.
191
+ */
192
+ const tip = (d) => tipFrame(String(d?.data?.key ?? d?.key ?? d?.data?.label ?? ''), [
193
+ { label: 'Value', value: props.format(d?.value ?? 0) },
194
+ ]);
195
+ </script>
196
+
197
+ <template>
198
+ <ChartFrame
199
+ class="deck-nested-donut"
200
+ :series="seriesList"
201
+ :height="height"
202
+ :legend="legend"
203
+ :legend-position="legendPosition"
204
+ :message="message"
205
+ >
206
+ <VisSingleContainer :data="rows" :height="height" :duration="0">
207
+ <!-- 8px between rings, on top of the 4px ground stroke each arc already carries: the stroke
208
+ separates neighbours WITHIN a ring, but two rings of the same hue meeting edge to edge
209
+ read as one thick arc, which is the one relationship this chart exists to show.
210
+
211
+ ★ `central-sub-label-wrap` OFF, because the library wraps that text against the wrong
212
+ radius here. `_render` calls `wrapSVGText(this.centralSubLabel, layers[0]._innerRadius *
213
+ 1.9)`, and `_getLayerSettings` REVERSES the layer array for the `inwards` direction — the
214
+ default, and the one that puts the first layer on the outside — so `layers[0]` is the
215
+ outermost ring rather than the hole. At the documented geometry (height 480, ringWidth 64,
216
+ layerPadding 8) that is 176 × 1.9 = 334px of wrap width inside a 208px hole: a sub-label
217
+ long enough to wrap breaks out over the arcs instead of staying in the middle. VisDonut
218
+ has no such problem — its `layers[0]` inner radius IS the hole. Off, a sub-label that does
219
+ not fit overruns in one line the author can see and shorten. -->
220
+ <VisNestedDonut
221
+ :value="value"
222
+ :layers="layerAccessors"
223
+ :layer-settings="layerSettings"
224
+ :layer-padding="8"
225
+ :segment-color="segmentColor"
226
+ :segment-label-color="segmentLabelColor"
227
+ :show-segment-labels="segmentLabels"
228
+ :corner-radius="8"
229
+ :central-label="centre"
230
+ :central-sub-label="centralSubLabel ?? undefined"
231
+ :central-sub-label-wrap="false"
232
+ />
233
+ <VisTooltip v-bind="deckTooltip()" :triggers="{ [VisNestedDonutSelectors.segment]: tip }" />
234
+ </VisSingleContainer>
235
+ </ChartFrame>
236
+ </template>
237
+
238
+ <style scoped>
239
+ /*
240
+ The nested donut has its OWN variable namespace, and it is not the donut's: `--vis-nested-donut-*`
241
+ against `--vis-donut-*`, with `sublabel` where the donut writes `sub-label`. None of ChartFrame's
242
+ donut block reaches this mark, so the type ramp and the arc separator are restated here — same
243
+ values, same reasoning, different names.
244
+
245
+ Declared on the frame's own element rather than `:root`, for both of the reasons ChartFrame
246
+ records: Unovis injects its `:root` defaults at runtime and would win on order, and a custom
247
+ property computes where it is declared, so a reference resolved here flips with a `deck-dark`
248
+ slide and one resolved at `:root` would not. The `--vis-dark-nested-donut-*` twins are left alone
249
+ for the same reason — the deck's tokens already carry the dark values.
250
+
251
+ Left at the library default: `--vis-nested-donut-background-color`, which only paints under
252
+ `showBackground`, and that stays off.
253
+ */
254
+ .deck-nested-donut {
255
+ /* The arc separator is the SLIDE GROUND, so segments read as separated rather than outlined.
256
+ The library's default is its own background lilac at 1px — an off-brand hairline. */
257
+ --vis-nested-donut-segment-stroke-color: var(--deck-bg);
258
+ --vis-nested-donut-segment-stroke-width: 4px;
259
+
260
+ /* The centre. Title tier for the figure, small body for what it counts — the same pair
261
+ ChartFrame sets for the donut, and the pair its offset correction below is measured against. */
262
+ --vis-nested-donut-central-label-font-size: var(--type-deck-title-size);
263
+ --vis-nested-donut-central-label-font-weight: var(--type-deck-title-weight);
264
+ --vis-nested-donut-central-label-text-color: var(--deck-fg);
265
+ --vis-nested-donut-central-sublabel-font-size: var(--type-deck-body-sm-size);
266
+ --vis-nested-donut-central-sublabel-font-weight: var(--type-deck-body-sm-weight);
267
+ --vis-nested-donut-central-sublabel-text-color: var(--deck-fg-muted);
268
+
269
+ /* Caption tier for an arc label. This is a step DOWN from the library's `1em`, which inherits the
270
+ slide's body (3rem / 48px) and fits no arc at all — but it is not a step below the axis ticks:
271
+ caption, body-sm and label all resolve to 2.25rem / 36px on this deck (packages/tokens/type/
272
+ type.deck.css), and the only smaller role in the ramp is overline, which is an eyebrow rather
273
+ than a label. 36px is why `segmentLabels` is off by default: it fits the long arcs and nothing
274
+ else. */
275
+ --vis-nested-donut-segment-label-font-size: var(--type-deck-caption-size);
276
+ }
277
+
278
+ /*
279
+ ★★ CHARTFRAME'S CENTRE CORRECTION ONLY HALF-REACHES THIS MARK, and half is worse than none.
280
+
281
+ Both donuts place the pair at dy ∓0.55em of their OWN unequal font sizes, so both hang above the
282
+ ring's true centre and both want the same downward nudge. ChartFrame applies it with
283
+ `[class*='-central-label']`, which works there because @unovis/ts's donut labels BOTH texts
284
+ `central-label` (components/donut/style.js). The nested donut labels its second text
285
+ `central-sub-label` — and `-central-sub-label` does not contain the substring `-central-label`.
286
+ Unpatched, the figure moves 16.8px and the sub-label stays, splitting a pair the correction exists
287
+ to keep together.
288
+
289
+ Same expression as ChartFrame's, deliberately: it is one measurement, not two, and writing it
290
+ again as a literal would let the halves drift apart the moment either type role is retuned.
291
+ */
292
+ :deep([class*='-central-sub-label']) {
293
+ translate: 0 calc((var(--type-deck-title-size) - var(--type-deck-body-sm-size)) * 0.7);
294
+ }
295
+ </style>
@@ -0,0 +1,88 @@
1
+ <script setup>
2
+ /*
3
+ "An image goes here." Deliberately obvious rather than decorative — a placeholder that looks like
4
+ a real image invites someone to ship it.
5
+
6
+ Drawn from --deck-* roles: no network, no raster asset, no licensing question, and identical
7
+ dimensions on every run. That last property is what keeps scripts/deckAudit.ts meaningful — a real
8
+ photo's intrinsic size varies and would make overflow results non-repeatable.
9
+
10
+ ★★ HATCHING, NOT AN ICON, AND THE REASON IS WHAT THE THING IS FOR. This drew a small picture-frame
11
+ glyph — a rect, a circle and a mountain path, hand-drawn here rather than taken from any icon set,
12
+ which made it the only hand-authored pictogram in the theme and a fourth place to maintain one.
13
+ Worse, it read as CONTENT: a tidy centred icon on a clean fill is what an empty state looks like,
14
+ and an empty state is a thing that is finished. Diagonal hatching cannot be mistaken for finished
15
+ work — it is the universal "nothing here yet" of a drawing, and it survives being glanced at from
16
+ the back of a room, which is the only viewing condition this deck has.
17
+
18
+ The stripes are a repeating gradient rather than an SVG <pattern> because a gradient scales with
19
+ the box for free: the same 12px pitch holds at every ratio and every size without a viewBox to
20
+ keep in sync.
21
+ */
22
+ const RATIOS = { '16:9': 16 / 9, '4:3': 4 / 3, '1:1': 1, '3:4': 3 / 4 };
23
+
24
+ defineProps({
25
+ /** Aspect ratio of the frame. Matches how the layouts reserve space for real media. */
26
+ ratio: {
27
+ type: String,
28
+ default: '16:9',
29
+ // Vue hoists defineProps() out of setup() at build time, so this validator cannot close over
30
+ // the RATIOS const above (that only compiles in dev, not in a production build) — it repeats
31
+ // the same four keys inline instead.
32
+ validator: (r) => ['16:9', '4:3', '1:1', '3:4'].includes(r),
33
+ },
34
+ /** Short caption naming what belongs here, e.g. "Rider on a Forest bike". */
35
+ label: { type: String, default: 'Image' },
36
+ });
37
+ </script>
38
+
39
+ <template>
40
+ <div class="deck-placeholder" :style="{ aspectRatio: String(RATIOS[ratio] ?? 16 / 9) }" role="img" :aria-label="`Placeholder: ${label}`">
41
+ <span class="deck-placeholder-label">{{ label }}</span>
42
+ </div>
43
+ </template>
44
+
45
+ <style scoped>
46
+ .deck-placeholder {
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ width: 100%;
51
+ /*
52
+ The hatch sits on the subtle fill rather than replacing it, so the box still reads as a surface
53
+ when the stripes are too fine to resolve at a distance. `--deck-border` (not -strong) keeps the
54
+ stripes quieter than the dashed edge that frames them: the border says "this is a box", the
55
+ hatch says "and it is empty", and the border has to win that hierarchy or the panel turns into
56
+ texture with no shape.
57
+ */
58
+ background-color: var(--deck-surface);
59
+ background-image: repeating-linear-gradient(
60
+ -45deg,
61
+ var(--deck-border) 0,
62
+ var(--deck-border) 1.5px,
63
+ transparent 1.5px,
64
+ transparent 12px
65
+ );
66
+ /* 4px, not 2. CSS derives the dash LENGTH from the border width, so a thin dashed border is also
67
+ a finely dashed one — at 2px on a 1920 canvas the dashes fall under a pixel once the slide is
68
+ scaled to any real screen and the edge reads as a plain hairline. The dashes have to survive
69
+ the room, same as the hatch. */
70
+ border: 4px dashed var(--deck-border-strong);
71
+ border-radius: var(--deck-radius-card);
72
+ color: var(--deck-fg-muted);
73
+ }
74
+ .deck-placeholder-label {
75
+ /*
76
+ The label needs its own ground: set straight onto the hatch it is read through the stripes, and
77
+ at caption size the 1.5px lines cross the letterforms. The chip is the fill without the hatch,
78
+ which is why it uses the same token rather than a new one.
79
+ */
80
+ padding: var(--deck-space-1) var(--deck-space-2);
81
+ background: var(--deck-surface);
82
+ border-radius: var(--deck-radius-nested);
83
+ font-size: var(--type-deck-caption-size);
84
+ font-family: var(--font-sans);
85
+ text-align: center;
86
+ max-width: 20em;
87
+ }
88
+ </style>