@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
package/assetUrl.js ADDED
@@ -0,0 +1,38 @@
1
+ /*
2
+ A public-asset URL, resolved against the deck's own base path.
3
+
4
+ ★★ A DECK BUILT WITH `--base` REWRITES ITS HTML AND NOT ITS PROPS. Vite prefixes `/foo` wherever it
5
+ can see one at build time — `index.html`, CSS `url()`, an `<img src="/x">` written literally in a
6
+ template — and it CANNOT see one that arrives as a runtime value. `image: '/avatars/olivia.jpg'`
7
+ in a slide's props block is a string the component receives and hands to `:src`, so it stays
8
+ `/avatars/olivia.jpg` while every asset around it moved to `/design-system/avatars/olivia.jpg`.
9
+
10
+ Measured on this deck built with `--base /design-system/`: index.html asked for
11
+ `/design-system/assets/index-*.js` while eight avatars and the partner mark still asked for `/…`.
12
+ Every one 404s, and a missing image renders as a blank box rather than an error — the same silent
13
+ class the alt-text rule exists for. Nothing in the build reports it.
14
+
15
+ ⚠ ROOT-RELATIVE ONLY. A protocol URL (the photography on assets.forest.bike), a data: URI, and a
16
+ relative path are all returned untouched — the first two are already absolute and the third is
17
+ resolved by the browser against the page, which is what a caller writing one wants.
18
+
19
+ ⚠ AND IT IS IDEMPOTENT, because it has to be: `import.meta.env.BASE_URL` is `/` in dev, so a
20
+ helper that blindly concatenated would double the slash on every local asset in the dev server.
21
+ */
22
+ const base = () => {
23
+ // `import.meta.env` exists under Vite (dev and build); the fallback keeps this callable from a
24
+ // plain Node context — a test, or a generator that imports the theme for its values.
25
+ const b = import.meta.env?.BASE_URL;
26
+ return typeof b === 'string' && b ? b : '/';
27
+ };
28
+
29
+ export function assetUrl(url) {
30
+ if (typeof url !== 'string' || !url) return url;
31
+ // Not root-relative: absolute, protocol-relative, a data/blob URI, or relative to the page.
32
+ if (!url.startsWith('/') || url.startsWith('//')) return url;
33
+ const b = base();
34
+ if (b === '/') return url;
35
+ // Already carries the base — a caller that resolved it themselves, or a second pass.
36
+ if (url.startsWith(b)) return url;
37
+ return b.replace(/\/$/, '') + url;
38
+ }
@@ -0,0 +1,209 @@
1
+ <script setup>
2
+ /*
3
+ A status panel — the deck's answer to Nuxt UI's UAlert: a block that names a state, with an icon,
4
+ a title and optional detail, at one of two VOLUMES.
5
+
6
+ ★ TWO VARIANTS, AND THE CHOICE IS ABOUT WHAT ELSE IS ON THE SLIDE. `subtle` is a tint you put
7
+ BESIDE other content — a note in a column, one of a row of four, an aside under a chart. `solid`
8
+ is the same five states when the alert IS the slide: a saturated block in the family's own 700,
9
+ the way a Chip is, for the one thing an audience must not read past. A row of solid alerts would
10
+ be five slabs of colour shouting at each other, which is why the default is the quiet one.
11
+
12
+ AN ALERT IS THE STATEMENT, NOT AN ASIDE. It carries a state and its colour IS the message, so it
13
+ is the loudest object on its slide by construction — it cannot comment on a neighbour, and it is
14
+ not the shape to reach for when something merely needs a remark beside it.
15
+
16
+ ⚠ This used to be written as "NOT a Callout", against a speech-bubble component that has since
17
+ been retired: the deck kept both, an aside and a statement, and only ever needed the statement.
18
+ The distinction is worth keeping without the comparison, because the pull is toward using an alert
19
+ for any set-apart text, which spends a status colour on something that has no status.
20
+
21
+ ★ THE STATUS IS A NAME, NEVER A COLOUR, and the four names are not this component's invention.
22
+ They are the alias contract the web product already publishes (forest-preset.ts): success->forest,
23
+ error->maple, info->river, warning->amber. A deck that picked its own families would be a second
24
+ opinion about what "warning" looks like, free to drift from the product the first time either
25
+ moved. Each name resolves to a SET of four generated tokens — fill, ink, icon, edge — all four
26
+ built off that name's semantic role in scripts/deck.ts and recomputed by the static suite.
27
+
28
+ ★★ `subtle` IS UAlert'S OWN CONSTRUCTION, WEAKNESS INCLUDED. Stock draws it as
29
+ `bg-<color>/10 text-<color> ring ring-inset ring-<color>/25` — one semantic role at two alpha
30
+ stops, with no `dark:` class anywhere in its theme; it changes register because the role does.
31
+ This component used to hold a deck-local pin instead (fill-100 under ink-900, an opaque
32
+ family-500 edge), which measured far better — 9.64 to 10.35 for the ink against 2.76 to 4.05 now —
33
+ and was a second opinion about the same five words. A specimen that draws a more legible alert
34
+ than the product ships is the more expensive kind of wrong.
35
+
36
+ What it costs is recorded in scripts/deck.ts and on the specimen slides: two of the five states
37
+ sit under the 3:1 this component's 36px body is held to on the light register, and the edge falls
38
+ from 3.06-4.55:1 to 1.29-1.36. The dark register clears its floor at 4.81-5.82.
39
+
40
+ Only the edge's WIDTH stays the deck's own — see the border rule below.
41
+ */
42
+ import { computed } from 'vue';
43
+
44
+ const STATUSES = ['info', 'success', 'warning', 'danger', 'neutral'];
45
+
46
+ // The default lamp per status. A caller can override with `icon`, but never has to choose one to
47
+ // get a correct alert — the status already implies which glyph belongs to it.
48
+ const STATUS_ICON = {
49
+ info: 'i-lucide-info',
50
+ success: 'i-lucide-circle-check',
51
+ warning: 'i-lucide-triangle-alert',
52
+ danger: 'i-lucide-octagon-alert',
53
+ neutral: 'i-lucide-message-square',
54
+ };
55
+
56
+ const props = defineProps({
57
+ /** Which state this panel names. A NAME, never a colour — each resolves to a measured fill/ink/icon/edge set. */
58
+ status: {
59
+ type: String,
60
+ default: 'info',
61
+ // Inlined rather than closed over STATUSES above: defineProps() is hoisted out of setup() at
62
+ // build time, the same trap Grid and Placeholder both document.
63
+ validator: (s) => ['info', 'success', 'warning', 'danger', 'neutral'].includes(s),
64
+ },
65
+ /** 'subtle' — a tint beside other content · 'solid' — the saturated block, when the alert IS the
66
+ * slide. */
67
+ variant: {
68
+ type: String,
69
+ default: 'subtle',
70
+ // Inlined for the same reason `status` is: defineProps() is hoisted out of setup() at build
71
+ // time, so a module const closed over here would be undefined by the time it runs.
72
+ validator: (v) => ['subtle', 'solid'].includes(v),
73
+ },
74
+ /** The headline. Optional — an alert with only body copy is still a valid alert. */
75
+ title: { type: String, default: '' },
76
+ /** Overrides the status's own lamp — a UnoCSS icon class, not a slug, the same vocabulary the chrome uses. `:icon="false"` draws no lamp at all. */
77
+ icon: { type: [String, Boolean], default: '' },
78
+ });
79
+
80
+ // Clamped in the CLASS, not only in the validator: a validator is stripped from a production build,
81
+ // and an unknown status would mint a class with no rule — a panel with no fill, no ink and no edge,
82
+ // which is a blank rectangle rather than a visible error.
83
+ const safeStatus = computed(() => (STATUSES.includes(props.status) ? props.status : STATUSES[0]));
84
+ // Clamped in the class for the same reason the status is — a validator is stripped from a
85
+ // production build, and an unknown variant would leave the panel with neither treatment.
86
+ const safeVariant = computed(() => (props.variant === 'solid' ? 'solid' : 'subtle'));
87
+ const statusClass = computed(() => [`is-${safeStatus.value}`, `is-${safeVariant.value}`]);
88
+ /*
89
+ ★ AN ALERT CAN BE ICONLESS: `:icon="false"`.
90
+
91
+ The lamp states the status twice — once as a hue, once as a shape — and the second telling earns
92
+ its default because hue alone fails a colour-blind reader. But it is not always wanted: a row of
93
+ alerts read as a legend, or one whose title already carries the word, is quieter without it.
94
+
95
+ ONE PROP, not two. A `no-icon` boolean beside `icon` could contradict it
96
+ (`icon="i-lucide-check" no-icon`) and there is no sensible answer to that; one prop cannot
97
+ disagree with itself.
98
+
99
+ And a BOOLEAN false rather than a reserved string. `icon="none"` also works mechanically — "none"
100
+ is not a valid UnoCSS class, so nothing legitimate is shadowed — but it reads as a value the
101
+ component might look up, and the author has to know it is special. `false` says off in the
102
+ language Vue already has, next to the `:cols="2"` and `:height="600"` bindings a deck writes
103
+ everywhere else.
104
+ */
105
+ const showIcon = computed(() => props.icon !== false);
106
+ const iconClass = computed(() =>
107
+ typeof props.icon === 'string' && props.icon ? props.icon : STATUS_ICON[safeStatus.value],
108
+ );
109
+ </script>
110
+
111
+ <template>
112
+ <div class="deck-alert" :class="[statusClass, { 'deck-own-ground': variant === 'solid' }]" role="note">
113
+ <div v-if="showIcon" class="deck-alert-icon" :class="iconClass" aria-hidden="true" />
114
+ <div class="deck-alert-text">
115
+ <p v-if="title" class="deck-alert-title">{{ title }}</p>
116
+ <div class="deck-alert-body"><slot /></div>
117
+ </div>
118
+ </div>
119
+ </template>
120
+
121
+ <style scoped>
122
+ /*
123
+ One custom property per slot, set by the status class and read by everything below. Without it the
124
+ icon and the edge become two more places to remember
125
+ which state is in play, and a panel whose lamp disagrees with its fill is the exact defect the
126
+ measured token sets exist to make unexpressible.
127
+ */
128
+ .deck-alert {
129
+ --alert-fill: var(--deck-status-info-fill);
130
+ --alert-ink: var(--deck-status-info-ink);
131
+ --alert-icon: var(--deck-status-info-icon);
132
+ --alert-edge: var(--deck-status-info-edge);
133
+
134
+ display: flex;
135
+ gap: var(--deck-space-3);
136
+ align-items: flex-start;
137
+ background: var(--alert-fill);
138
+ color: var(--alert-ink);
139
+ /* 4px, twice the callout's, and the extra 2 is paying for the edge's own colour. It used to be an
140
+ opaque family-500 reading 3.06 - 4.55:1 against the cream; following the product's
141
+ `ring-<color>/25` drops the same hue to 1.29 - 1.36, which no width recovers. What width does
142
+ buy is that the outline reads as drawn rather than as an artefact of the fill — at presenting
143
+ size a hairline is most of a millimetre, and a hairline this quiet is nothing at all. */
144
+ border: calc(var(--deck-space-1) / 2) solid var(--alert-edge);
145
+ border-radius: var(--deck-radius-card);
146
+ padding: var(--deck-card-pad);
147
+ }
148
+ .deck-alert.is-success { --alert-fill: var(--deck-status-success-fill); --alert-ink: var(--deck-status-success-ink); --alert-icon: var(--deck-status-success-icon); --alert-edge: var(--deck-status-success-edge); }
149
+ .deck-alert.is-warning { --alert-fill: var(--deck-status-warning-fill); --alert-ink: var(--deck-status-warning-ink); --alert-icon: var(--deck-status-warning-icon); --alert-edge: var(--deck-status-warning-edge); }
150
+ .deck-alert.is-danger { --alert-fill: var(--deck-status-danger-fill); --alert-ink: var(--deck-status-danger-ink); --alert-icon: var(--deck-status-danger-icon); --alert-edge: var(--deck-status-danger-edge); }
151
+ .deck-alert.is-neutral { --alert-fill: var(--deck-status-neutral-fill); --alert-ink: var(--deck-status-neutral-ink); --alert-icon: var(--deck-status-neutral-icon); --alert-edge: var(--deck-status-neutral-edge); }
152
+
153
+ /* ── SOLID: the same five states, said loudly ──────────────────────────────────────────────────
154
+ Only the four custom properties change — every rule below reads them, so the layout, the lamp's
155
+ optical centring and the type all stay exactly as they are. The fill is the family's own 700 (the
156
+ level the subtle panel's LAMP uses, so the two volumes are one family rather than two palettes)
157
+ and the ink is the paired white the engine emits beside it, measured 6.45-10.41:1 across the five.
158
+
159
+ ★ THE LAMP AND THE EDGE JOIN THE INK HERE. On the subtle panel they are separate roles because
160
+ they sit on a pale tint and each needs its own measurement; on a saturated block there is one
161
+ ground, so a lamp in family-700 would be invisible on family-700 and an edge in family-500 would
162
+ read as a pale halo. One ground, one ink. */
163
+ .deck-alert.is-solid {
164
+ --alert-fill: var(--deck-status-info-solid);
165
+ --alert-ink: var(--deck-on-status-info);
166
+ --alert-icon: var(--deck-on-status-info);
167
+ --alert-edge: var(--deck-status-info-solid);
168
+ }
169
+ .deck-alert.is-solid.is-success { --alert-fill: var(--deck-status-success-solid); --alert-ink: var(--deck-on-status-success); --alert-icon: var(--deck-on-status-success); --alert-edge: var(--deck-status-success-solid); }
170
+ .deck-alert.is-solid.is-warning { --alert-fill: var(--deck-status-warning-solid); --alert-ink: var(--deck-on-status-warning); --alert-icon: var(--deck-on-status-warning); --alert-edge: var(--deck-status-warning-solid); }
171
+ .deck-alert.is-solid.is-danger { --alert-fill: var(--deck-status-danger-solid); --alert-ink: var(--deck-on-status-danger); --alert-icon: var(--deck-on-status-danger); --alert-edge: var(--deck-status-danger-solid); }
172
+ .deck-alert.is-solid.is-neutral { --alert-fill: var(--deck-status-neutral-solid); --alert-ink: var(--deck-on-status-neutral); --alert-icon: var(--deck-on-status-neutral); --alert-edge: var(--deck-status-neutral-solid); }
173
+
174
+ /* A solid panel paints its own saturated ground, so anything markdown puts inside it needs the same
175
+ protection a Card on a named background carries — base.css keys those rules off `.deck-own-ground`, and the
176
+ class is applied from the template rather than duplicated here. */
177
+
178
+ /* The lamp is the ONE thing not in the ink colour: it is the state stated twice, once as a shape
179
+ and once as a hue, so it takes the icon role (5.45-5.72 on the fill) rather than the body ink. */
180
+ .deck-alert-icon {
181
+ flex: none;
182
+ width: var(--deck-space-5);
183
+ height: var(--deck-space-5);
184
+ color: var(--alert-icon);
185
+ background-color: currentColor;
186
+ /*
187
+ ★ CENTRED ON THE TITLE'S FIRST LINE, not flush with the top of its box. `align-items:
188
+ flex-start` puts both at the same y, but the glyph is --deck-space-5 (40px) and the title's line
189
+ box is 1lh (54px at the dense tier) — MEASURED, the icon's optical centre sat 7px above the
190
+ title's and read as slightly lifted. Half the difference puts them on one line.
191
+
192
+ `1lh` rather than a repeated 54: the line box is whatever the tier in play makes it, and a
193
+ hard-coded half-difference would be wrong the moment an alert appears in a column that changes
194
+ the tier. Clamped at 0 so a title bigger than the glyph never pulls the icon upward.
195
+ */
196
+ margin-block-start: max(0px, calc((1lh - var(--deck-space-5)) / 2));
197
+ }
198
+ .deck-alert-text { min-width: 0; }
199
+ .deck-alert-title {
200
+ margin: 0;
201
+ font-weight: var(--type-deck-body-weight-strong, 700);
202
+ max-width: none;
203
+ }
204
+ .deck-alert-body :deep(p) { margin: 0; max-width: none; }
205
+ .deck-alert-body :deep(p + p) { margin-top: var(--deck-space-2); }
206
+ .deck-alert-title + .deck-alert-body { margin-top: var(--deck-space-1); }
207
+ /* Two columns halve the measure, so an alert in one takes the dense tier with everything else. */
208
+ .deck-alert, .deck-alert-body { font-size: var(--type-deck-body-sm-size); line-height: var(--type-deck-body-sm-line-height); }
209
+ </style>
@@ -0,0 +1,152 @@
1
+ <script setup>
2
+ /*
3
+ A NAMED REGION OF A LAYOUT, DRAWN AS ITSELF.
4
+
5
+ This exists so a layout can be shown as its AREAS rather than as its content: drop one of these
6
+ into every slot a layout exposes and the slide stops being a page about e-bikes and becomes a
7
+ diagram of the frame it is sitting in.
8
+
9
+ ★★ IT IS NOT A PICTURE OF A LAYOUT — IT IS THE LAYOUT. The obvious way to draw this section was a
10
+ miniature: an SVG of a slide with rectangles in it, sized from the ratio. That would have been a
11
+ second copy of the truth, and the first time a grid-template changed the diagram would go on
12
+ confidently showing the old one. Here the boxes are placed by the real layout, in the real slots,
13
+ at the real canvas size — split's own grid decides where the figure column starts, not this file.
14
+ A layout that is implemented wrongly draws itself wrongly, which is the entire point.
15
+
16
+ Deliberately NOT the schematic ruler in global-top.vue. That instrument answers "how far is this
17
+ from the edge" and speaks in dashed and dotted frames over live content. This one answers "what
18
+ region am I looking at, and how much of the slide does it get", and speaks in filled, named
19
+ blocks. Same family, different question — so the ruler keeps dashes and dots, and this keeps
20
+ fills and labels, and neither is mistaken for the other when both are on screen.
21
+ */
22
+ const TONES = ['content', 'figure'];
23
+
24
+ defineProps({
25
+ /** The area's name, as the layout itself calls it — 'content', 'band', 'aside'. */
26
+ name: { type: String, required: true },
27
+ /**
28
+ * What the layout gives this area: a track share ('3fr'), a slot name, a rule of thumb.
29
+ * Optional — a region whose size is not the point does not need a number under it.
30
+ */
31
+ note: { type: String, default: '' },
32
+ /**
33
+ * Which kind of region this is. TWO, because a frame only makes two kinds of promise: the column
34
+ * that holds the reading, and the cell that holds a figure. A third — 'chrome', for a header or
35
+ * footer — was written and removed: the deck's furniture is drawn by slide-top/slide-bottom for
36
+ * every slide alike, so it is not one of the layout's areas and drawing it as one says the frame
37
+ * owns something it does not.
38
+ */
39
+ tone: {
40
+ type: String,
41
+ default: 'content',
42
+ // Repeated inline: defineProps() is hoisted out of setup() at build time, so this validator
43
+ // cannot close over TONES above (that compiles in dev and not in a production build).
44
+ validator: (t) => ['content', 'figure'].includes(t),
45
+ },
46
+ /**
47
+ * Where the label sits inside the region. 'center' is right for a region that stands alone;
48
+ * 'top' is for a region something else is drawn ON TOP OF — bleed's media fills the whole frame
49
+ * and the content sits over it, so two centred labels land in the same place and overprint each
50
+ * other. They are siblings in the DOM (the media is absolutely positioned), so no CSS selector
51
+ * can spot the overlap and move one out of the way on its own.
52
+ */
53
+ align: {
54
+ type: String,
55
+ default: 'center',
56
+ validator: (a) => ['center', 'top'].includes(a),
57
+ },
58
+ });
59
+ </script>
60
+
61
+ <template>
62
+ <div class="area-box" :class="[`is-${tone}`, `is-${align}`]">
63
+ <span class="area-box-name">{{ name }}</span>
64
+ <span v-if="note" class="area-box-note">{{ note }}</span>
65
+ </div>
66
+ </template>
67
+
68
+ <style scoped>
69
+ .area-box {
70
+ /*
71
+ Fills whatever cell it is handed, and refuses to be the reason a track is a given size:
72
+ min-height/min-width 0 so a grid row can shrink it rather than overflow the slide. A diagram
73
+ that changed the layout it is diagramming would be worse than no diagram.
74
+ */
75
+ display: flex;
76
+ flex-direction: column;
77
+ align-items: center;
78
+ justify-content: center;
79
+ gap: var(--deck-space-1);
80
+ width: 100%;
81
+ /*
82
+ ★ flex: 1 AND align-self: stretch, NOT height: 100%. These land in two different kinds of parent
83
+ — a grid cell (split's figure column) and a flex column (the default slot, under a heading) —
84
+ and `height: 100%` is only correct in the first. In the second it resolves against the WHOLE
85
+ column while a heading is already occupying part of it, so the box overran its own slot and sat
86
+ on top of the footer. Measured on the first draft of this section: the content area overlapped
87
+ the footer area by about a third of its height, which read as a layout defect and was mine.
88
+ So both are set, and the flex BASIS is 0 rather than auto — that is the part that makes them
89
+ coexist. In a flex parent, basis 0 + grow fills exactly what is left and the height is ignored;
90
+ in a block parent (split-content is `display: block`) the flex properties are inert and the
91
+ height fills the column. Measured: with `flex: 1 1 auto` alone the box came out 75px inside a
92
+ 266px column, because a block parent does not read flex at all.
93
+
94
+ ★★ AND `height: 100%` CAME BACK OUT, BECAUSE THAT LAST SENTENCE IS ONLY TRUE OF AN ONLY CHILD.
95
+
96
+ A percentage height resolves against the parent's whole content box, not against what is left of
97
+ it. In a block parent with a SIBLING above, the box therefore starts below the sibling and still
98
+ claims the full height — overflowing by exactly the sibling's height. Measured on the cover's own
99
+ area specimen: `.cover-content` is 195 tall and block, `.cover-rule` plus its margin takes 40 at
100
+ the top, and the box ran 482 → 678 against a region ending at 638. Forty pixels out of its own
101
+ region, which the schematic ruler drew correctly and which read as the ruler being wrong.
102
+
103
+ So the sizing is `flex` and `align-self` only, and the containers that hold one lay out as a
104
+ column (see the `:has(> .area-box)` rule in styles/base.css). Both parents this was written for
105
+ still work: a grid cell fills through `align-self: stretch`, a flex column through `flex: 1 1 0%`.
106
+ What is gone is the third case nobody checked — block, with something above it.
107
+ */
108
+ flex: 1 1 0%;
109
+ align-self: stretch;
110
+ min-width: 0;
111
+ min-height: 0;
112
+ padding: var(--deck-space-2);
113
+ border-radius: var(--deck-radius-item);
114
+ text-align: center;
115
+ /*
116
+ The tint and the edge are the SAME role at two strengths, so a region reads as one block rather
117
+ than as a box with a border round it.
118
+
119
+ ★★ MIXED TOWARD --deck-bg, NOT TOWARD transparent, and the difference is legibility rather than
120
+ taste. A translucent fill takes whatever is behind it: on the divider slide that is the accent
121
+ green, where the label came out at 2.79:1 and its note at 1.20:1 against a 3:1 floor; on the
122
+ bleed slide it is the media, where the contrast sweep could not resolve a ground at all and
123
+ reported UNRESOLVED rather than a pass. Mixing toward the register's own background instead
124
+ gives an opaque box that still self-adjusts — --deck-bg is light on light slides and dark on
125
+ dark ones — so the same declaration reads on every ground the deck has.
126
+ */
127
+ background: color-mix(in oklab, var(--area-role), var(--deck-bg) 84%);
128
+ box-shadow: inset 0 0 0 3px color-mix(in oklab, var(--area-role), var(--deck-bg) 40%);
129
+ color: var(--deck-fg);
130
+ }
131
+ /* Two roles, far enough apart in hue to be told apart from the back of a room, which is the only
132
+ viewing distance this deck has. */
133
+ .area-box.is-top { justify-content: flex-start; }
134
+
135
+ .area-box.is-content { --area-role: var(--deck-accent); }
136
+ .area-box.is-figure { --area-role: var(--deck-series-3); }
137
+
138
+ .area-box-name {
139
+ font-family: var(--font-sans);
140
+ font-size: var(--type-deck-label-size);
141
+ font-weight: var(--type-deck-label-weight);
142
+ letter-spacing: var(--type-deck-label-tracking);
143
+ text-transform: uppercase;
144
+ }
145
+ /* The share sits under the name in the tabular treatment, because these are numbers a reader is
146
+ comparing down a ladder of slides — 3fr against 2fr against 1fr — not prose. */
147
+ .area-box-note {
148
+ font-family: var(--font-mono);
149
+ font-size: var(--type-deck-caption-size);
150
+ color: var(--deck-fg-muted);
151
+ }
152
+ </style>
@@ -0,0 +1,145 @@
1
+ <script setup>
2
+ /*
3
+ An area chart sized for a room. Same stack as LineChart.vue — real @unovis/vue marks,
4
+ ChartFrame.vue for ceremony, chartData.ts for shaping — with the fill doing the arguing.
5
+
6
+ STACKED BY DEFAULT, and that is a claim about the data, not a style. Stacked areas say "these
7
+ parts sum to a whole"; overlaid ones say "these are independent quantities that happen to share
8
+ an axis". Passing `overlaid` switches the claim, and the mark changes with it: Unovis stacks when
9
+ a single <VisArea> is handed an ARRAY of y accessors, and overlays when each series gets its own
10
+ mark. Nothing here fakes one with the other.
11
+
12
+ Overlaid areas are drawn semi-transparent with their own top line, because three opaque fills on
13
+ top of each other hide two of them.
14
+ */
15
+ import { computed } from 'vue';
16
+ import { VisXYContainer, VisAxis, VisArea, VisCrosshair, VisTooltip } from '@unovis/vue';
17
+ import { deckTooltip, seriesTip } from './chartTip';
18
+ import { motionDuration } from './motionDuration';
19
+ import ChartFrame from './ChartFrame.vue';
20
+ import { chartState, formatValue, labelAt, stateMessage, tickIndices, CROSSHAIR_CIRCLE_R, CROSSHAIR_HIDE_WHEN_FAR } from './chartData';
21
+
22
+ const props = defineProps({
23
+ /** Rows: `[{ label, <seriesKey>: number, … }]`. `label` names the point on the x axis. */
24
+ data: { type: Array, required: true },
25
+ /** Series to plot, in stacking and legend order: `[{ key, name }]`. Omit and every numeric field on the first row becomes a series. */
26
+ series: { type: Array, default: null },
27
+ /** Draw the series as independent translucent layers instead of stacking them. Use when they do not sum to a whole. */
28
+ overlaid: { type: Boolean, default: false },
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
+ /** Draw the series legend. */
35
+ legend: { type: Boolean, default: true },
36
+ /** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
37
+ * or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
38
+ * caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
39
+ * full-width plot and pulled to an edge where a centred key would float in a gutter. */
40
+ legendPosition: {
41
+ type: String,
42
+ default: 'bottom-center',
43
+ validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
44
+ },
45
+ /** Title under the x axis. Omit when the labels already say what they are. */
46
+ xLabel: { type: String, default: null },
47
+ /** Title beside the y axis. Omit when the unit is in the slide's heading. */
48
+ yLabel: { type: String, default: null },
49
+ /** Formats the y tick labels. Defaults to British grouping, compact past four digits. */
50
+ format: { type: Function, default: formatValue },
51
+ /** Ceiling on x tick labels. Deck labels are 36px, so more than a handful collide. */
52
+ maxTicks: { type: Number, default: 6 },
53
+ /** Draw the two axes. Off is a SHAPE reading — the marks alone, no scale — for a chart that is
54
+ * being shown rather than read: a specimen, a register test, a sparkline beside a figure.
55
+ *
56
+ * ★ IT REMOVES THEM, IT DOES NOT HIDE THEM. Unovis reserves the axis band whether or not the
57
+ * axis paints, so hiding one in CSS leaves the plot inset by a tick row and a label column that
58
+ * are no longer there — measured at a 150px plot, the marks drew 22px of a 63px box. Not
59
+ * rendering the component is what gives the room back.
60
+ *
61
+ * ⚠ A chart carrying VALUES needs its scale. Turning this off says the reading is the shape. */
62
+ axes: { type: Boolean, default: true },
63
+ /** The sentence drawn when there is nothing to plot. */
64
+ empty: { type: String, default: 'No data' },
65
+ });
66
+
67
+ const state = computed(() => chartState(props.data, props.series));
68
+ const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
69
+ const seriesList = computed(() => (state.value.kind === 'ready' ? state.value.series : []));
70
+
71
+ const x = (_d, i) => i;
72
+ const y = computed(() => seriesList.value.map((s) => (d) => d[s.key]));
73
+ const colors = computed(() => seriesList.value.map((s) => s.color));
74
+
75
+ const xTicks = computed(() => tickIndices(props.data?.length ?? 0, props.maxTicks));
76
+ const xFormat = (v) => labelAt(props.data ?? [], v);
77
+
78
+ /*
79
+ The crosshair carries the tooltip: every series has a value at the hovered x, so the target is the
80
+ COLUMN rather than any one mark — which is also the only target anyone can hit from a lectern.
81
+ `forestTooltip` titles the row from its first property, and the deck's rows lead with `label`.
82
+ */
83
+ /* Read at mount so `prefers-reduced-motion` is honoured; see motionDuration.ts. */
84
+ const crosshairMs = motionDuration('instant');
85
+
86
+ const tip = computed(() => seriesTip(seriesList.value, props.format));
87
+ </script>
88
+
89
+ <template>
90
+ <ChartFrame
91
+ :series="seriesList"
92
+ :height="height"
93
+ :legend="legend"
94
+ :legend-position="legendPosition"
95
+ :message="message"
96
+ >
97
+ <VisXYContainer :data="data" :height="height" :duration="0" :margin="{ top: 16, right: 24, bottom: 0, left: 0 }">
98
+ <!-- The array-of-accessors form IS the stack: Unovis routes every accessor through d3's
99
+ stacking the moment `y` is an array. Overlaid series are separate marks, one each. -->
100
+ <VisArea v-if="!overlaid" :x="x" :y="y" :color="colors" />
101
+ <template v-else>
102
+ <VisArea
103
+ v-for="(yAcc, i) in y"
104
+ :key="seriesList[i].key"
105
+ :x="x"
106
+ :y="yAcc"
107
+ :color="colors[i]"
108
+ :opacity="0.35"
109
+ :line-width="6"
110
+ line
111
+ />
112
+ </template>
113
+ <VisAxis
114
+ v-if="axes"
115
+ type="x"
116
+ :grid-line="false"
117
+ :tick-line="false"
118
+ :tick-values="xTicks"
119
+ :tick-format="xFormat"
120
+ :tick-padding="16"
121
+ :label="xLabel"
122
+ :label-margin="24"
123
+ tick-text-hide-overlapping
124
+ />
125
+ <VisAxis
126
+ v-if="axes"
127
+ type="y"
128
+ :tick-format="format"
129
+ :num-ticks="4"
130
+ :tick-line="false"
131
+ :domain-line="false"
132
+ :tick-padding="16"
133
+ :label="yLabel"
134
+ :label-margin="24"
135
+ />
136
+ <!-- ★★ `y-stacked`, NOT `y`, WHEREVER THE MARKS STACK. The crosshair plots a circle per
137
+ series, and given `y` it plots each at its RAW value — which on a stacked chart is a
138
+ position nothing is drawn at: the second series' circle lands inside the first band
139
+ rather than on the boundary between them. `yStacked` is Unovis' own accumulator, so the
140
+ circles sit where the bands actually meet. -->
141
+ <VisCrosshair :duration="crosshairMs" :hideWhenFarFromPointer="CROSSHAIR_HIDE_WHEN_FAR" :circleRadius="CROSSHAIR_CIRCLE_R" :x="x" :y="overlaid ? y : undefined" :yStacked="overlaid ? undefined : y" :template="tip" :color="colors" />
142
+ <VisTooltip v-bind="deckTooltip()" />
143
+ </VisXYContainer>
144
+ </ChartFrame>
145
+ </template>