@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,169 @@
1
+ <script setup>
2
+ /*
3
+ The deck's one grid.
4
+
5
+ Gutters are a SYSTEM decision, not an author decision — before this existed the demo deck had
6
+ drifted to three different gutters (gap-8, gap-12, gap-16) across four grids in twelve slides.
7
+ So the gap is a closed set of three names mapped onto the deck space scale, and there is no
8
+ arbitrary value.
9
+
10
+ `items` is the array-driven path: Slidev binds YAML arrays and objects straight to props, so a
11
+ stat row, an icon row and a badge strip are all this component with different slot content
12
+ rather than three components.
13
+ */
14
+ defineProps({
15
+ /** Number of columns in the row: 1 to 4. A value outside that range is clamped to the nearest valid one, never rejected. */
16
+ cols: {
17
+ type: Number,
18
+ default: 3,
19
+ // Vue strips prop validators from production builds, so this one is dev-only — the template's
20
+ // own Math.min(4, Math.max(1, cols)) is what actually enforces the range at runtime, which is
21
+ // why the doc above says "clamped", not "validated".
22
+ validator: (n) => n >= 1 && n <= 4,
23
+ },
24
+ /** tight = --deck-space-3 · base = --deck-space-4 · loose = --deck-space-6 */
25
+ gap: { type: String, default: 'base', validator: (g) => ['tight', 'base', 'loose'].includes(g) },
26
+ /**
27
+ * Where each cell's OWN CONTENT sits on the inline axis: `start` or `center`.
28
+ *
29
+ * ★★ NOT THE SAME AXIS AS `align`, AND NOT THE SAME THING AS CENTRING THE CELLS. `align` places
30
+ * the children in the row; this reaches INSIDE each one. The grid's tracks are untouched, so a
31
+ * cell still spans its full column — what moves is what the cell holds.
32
+ *
33
+ * The difference matters because the obvious alternative does the wrong thing: `justify-items:
34
+ * center` on the grid would shrink each cell to its content and centre THAT, which leaves the
35
+ * content at the shrunken cell's left edge. Measured on the specimen's logo slides before this
36
+ * prop existed: the cell was centred at 1154 to 1726 and the mark sat at 1154, against a half
37
+ * whose own centre is 1440. The cell was centred; the thing inside it was not.
38
+ *
39
+ * Flex rather than `text-align`, because a cell's content is any display type — the logo forms
40
+ * alone span an inline svg, a masked block and two inline-flex lockups, and only a flex parent
41
+ * centres all of them.
42
+ */
43
+ justify: {
44
+ type: String,
45
+ default: 'start',
46
+ validator: (j) => ['start', 'center'].includes(j),
47
+ },
48
+ /** Cross-axis alignment of the row's children: start, center, or stretch. */
49
+ align: {
50
+ type: String,
51
+ default: 'start',
52
+ // A prop rather than a utility class: `.deck-grid` is scoped, so its `[data-v-…]` selector
53
+ // outranks a plain `items-*` utility passed at the tag, which would otherwise be silently inert.
54
+ validator: (a) => ['start', 'center', 'stretch'].includes(a),
55
+ },
56
+ /**
57
+ * Make the grid fill its frame rather than sizing to its own content, so the cells DIVIDE the
58
+ * space instead of sitting in it.
59
+ *
60
+ * Off by default, and the default is the honest one: a grid is a component you put inside a
61
+ * layout, not a layout. Its rows are content-sized, which is what you want for a stat row or a
62
+ * badge strip — the thing that would look wrong stretched to 848px.
63
+ *
64
+ * It exists because the deck's frames stop at two regions on purpose (split is a content column
65
+ * beside a figure; there is no third track to ask for), so three columns or a two-by-two is this
66
+ * component's job — and for THAT job the cells have to divide the frame or the arrangement does
67
+ * not read. Measured on the specimen: 3 cells go from 155px tall to 848, a 2x2 to two equal 408px
68
+ * rows, both landing exactly on the safe line.
69
+ *
70
+ * ★★ IT TAKES TWO RULES, NOT ONE, and the one-rule version is a slide the audit fails. `height:
71
+ * 100%` alone resolves against the frame's WHOLE height rather than what is left of it, so under a
72
+ * heading the grid starts below the heading and still claims all of it: measured, heading 136-232
73
+ * and grid 264-1112, 128px past the safe line. Fill has to mean the space that is LEFT, which only
74
+ * a flex column expresses — so this class is `flex: 1 1 0%` and a companion `:has()` rule in
75
+ * styles/base.css makes the holding layout a flex column. A child cannot make its own parent a
76
+ * flex container, so neither half works alone; frontmatter.static.test.ts asserts them as a pair.
77
+ */
78
+ fill: { type: Boolean, default: false },
79
+ /** Optional data. When present the default slot renders once per item with { item, index }. */
80
+ items: { type: Array, default: undefined },
81
+ });
82
+ </script>
83
+
84
+ <template>
85
+ <div
86
+ class="deck-grid"
87
+ :class="[
88
+ `is-${gap}`,
89
+ `is-cols-${Math.min(4, Math.max(1, cols))}`,
90
+ `is-${align}`,
91
+ `is-justify-${justify}`,
92
+ { 'is-fill': fill },
93
+ ]"
94
+ >
95
+ <template v-if="items">
96
+ <slot v-for="(item, index) in items" :key="index" :item="item" :index="index" />
97
+ </template>
98
+ <slot v-else />
99
+ </div>
100
+ </template>
101
+
102
+ <style scoped>
103
+ .deck-grid {
104
+ display: grid;
105
+ }
106
+ .is-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
107
+ .is-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
108
+ .is-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
109
+ .is-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
110
+ /* `justify` reaches into each cell — see the prop for why this is not `justify-items`. `start` is
111
+ the default and paints nothing, so a cell keeps whatever flow its own content had.
112
+
113
+ ★ `align-self: stretch` is part of the same idea rather than a second one: centring across a box
114
+ that has shrink-wrapped to its own content centres nothing. As a flex item — which is what a grid
115
+ inside a panel is — it would take only the width its cells need, so the stretch is what gives the
116
+ centring something to happen in. Inert everywhere else: in normal flow a block already fills. */
117
+ .is-justify-center {
118
+ align-self: stretch;
119
+ }
120
+ .is-justify-center > :deep(*) {
121
+ display: flex;
122
+ flex-direction: column;
123
+ align-items: center;
124
+ }
125
+ .is-tight { gap: var(--deck-space-3); }
126
+ .is-base { gap: var(--deck-space-4); }
127
+ .is-loose { gap: var(--deck-space-6); }
128
+ /*
129
+ ★★ FLEX-GROW, NOT `height: 100%`, AND THE DIFFERENCE IS WHETHER A HEADING IS ALLOWED.
130
+
131
+ This shipped as `height: 100%`, which is right for exactly one arrangement: a grid that is the
132
+ only thing on the slide. A percentage height resolves against the parent's WHOLE content box, so
133
+ the moment the grid has a sibling — an `# h1` above it, which is the single most likely thing an
134
+ author writes — the grid still claims the full height and starts below the heading. Measured: the
135
+ heading ran 136 → 232 and the grid 264 → 1112, which is 128px past the safe line and a slide the
136
+ audit fails.
137
+
138
+ "Fill" has to mean the space that is LEFT, and only a flex column can express that. The companion
139
+ rule lives in styles/base.css (a child cannot make its own parent a flex container) and is keyed
140
+ by `:has()` on this exact class, so it applies to the layouts that hold a filling grid and to
141
+ nothing else. Neither half works alone — see the paired assertion in frontmatter.static.test.ts.
142
+
143
+ `min-height: 0` because a flex item's default `min-height: auto` refuses to shrink below its
144
+ content, which would put the overflow back the first time the cells are taller than the space.
145
+ */
146
+ .is-fill {
147
+ flex: 1 1 0%;
148
+ min-height: 0;
149
+ /*
150
+ ★★ AND THE ROWS HAVE TO DIVIDE IT, or the grid's height is decoration. `grid-auto-rows` is
151
+ `auto` by default, which sizes each row to its CONTENT — and a filling chart inside asks for
152
+ 100% of a row that is measuring the chart, so the pair settles on the chart's own intrinsic size
153
+ and the rows overflow the box they were given. Measured on Charts on light: the grid ran 273 to
154
+ 880 while its two rows came out 311 and 300 with a 32px gap — 643 in a 607 box, so the second
155
+ row ended at 916 and collided with the track grid starting at 912.
156
+
157
+ ★★ `minmax(0, 1fr)`, NOT `1fr`, AND THE DIFFERENCE IS THE WHOLE FIX. A bare `1fr` is
158
+ `minmax(auto, 1fr)`, and that `auto` floor is the item's MIN-CONTENT height — so a row can still
159
+ refuse to shrink below what its contents claim. Measured with `1fr`: rows resolved to
160
+ `300px 300px` in a 607px box, still 25px over, because Unovis' own 300px default container is
161
+ what a chart reports as its minimum. Zero as the floor lets the row be whatever share it is
162
+ given, which is what "fill" has to mean one level down.
163
+ */
164
+ grid-auto-rows: minmax(0, 1fr);
165
+ }
166
+ .is-start { align-items: start; }
167
+ .is-center { align-items: center; }
168
+ .is-stretch { align-items: stretch; }
169
+ </style>
@@ -0,0 +1,141 @@
1
+ <script setup>
2
+ /*
3
+ A grouped bar chart sized for a room: each category gets a cluster of bars, one per series, all
4
+ measured from the same zero. Real @unovis/vue marks, ChartFrame.vue for ceremony, chartData.ts
5
+ for shaping.
6
+
7
+ Grouping is the honest mark when the series are INDEPENDENT quantities that share an axis — this
8
+ borough against that one, this month against the same month last year. Every bar starts at the
9
+ same baseline, so any two of them can be compared directly, which is exactly what a stack gives
10
+ up. When the series really do sum to a whole, StackedBarChart.vue says so.
11
+
12
+ A cluster costs horizontal room, so this chart degrades badly past a handful of categories times a
13
+ handful of series. Three or four of each is a slide; ten by five is a spreadsheet.
14
+ */
15
+ import { computed } from 'vue';
16
+ import { VisXYContainer, VisAxis, VisGroupedBar, 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, … }]`. One row is one cluster; `label` names it. */
24
+ data: { type: Array, required: true },
25
+ /** Series to plot, left to right within each cluster and in legend order: `[{ key, name }]`. Omit and every numeric field on the first row becomes a series. */
26
+ series: { type: Array, default: null },
27
+ /** Plot height in px. 0 FILLS, and is the default: the chart takes whatever box it is given,
28
+ * which is what a chart under a heading wants. Unovis resolves `config.height || containerHeight`,
29
+ * so a falsy height makes the container measure itself. Pass a number where the box has no
30
+ * definite height of its own — a tiled grid cell, a figure beside copy. */
31
+ height: { type: Number, default: 0 },
32
+ /** Draw the series legend. */
33
+ legend: { type: Boolean, default: true },
34
+ /** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
35
+ * or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
36
+ * caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
37
+ * full-width plot and pulled to an edge where a centred key would float in a gutter. */
38
+ legendPosition: {
39
+ type: String,
40
+ default: 'bottom-center',
41
+ validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
42
+ },
43
+ /** Title under the x axis. Omit when the labels already say what they are. */
44
+ xLabel: { type: String, default: null },
45
+ /** Title beside the y axis. Omit when the unit is in the slide's heading. */
46
+ yLabel: { type: String, default: null },
47
+ /** Formats the y tick labels. Defaults to British grouping, compact past four digits. */
48
+ format: { type: Function, default: formatValue },
49
+ /** Ceiling on x tick labels. Deck labels are 36px, so more than a handful collide. */
50
+ maxTicks: { type: Number, default: 8 },
51
+ /** Draw the two axes. Off is a SHAPE reading — the marks alone, no scale — for a chart that is
52
+ * being shown rather than read: a specimen, a register test, a sparkline beside a figure.
53
+ *
54
+ * ★ IT REMOVES THEM, IT DOES NOT HIDE THEM. Unovis reserves the axis band whether or not the
55
+ * axis paints, so hiding one in CSS leaves the plot inset by a tick row and a label column that
56
+ * are no longer there — measured at a 150px plot, the marks drew 22px of a 63px box. Not
57
+ * rendering the component is what gives the room back.
58
+ *
59
+ * ⚠ A chart carrying VALUES needs its scale. Turning this off says the reading is the shape. */
60
+ axes: { type: Boolean, default: true },
61
+ /** The sentence drawn when there is nothing to plot. */
62
+ empty: { type: String, default: 'No data' },
63
+ });
64
+
65
+ const state = computed(() => chartState(props.data, props.series));
66
+ const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
67
+ const seriesList = computed(() => (state.value.kind === 'ready' ? state.value.series : []));
68
+
69
+ const x = (_d, i) => i;
70
+ const y = computed(() => seriesList.value.map((s) => (d) => d[s.key]));
71
+ const colors = computed(() => seriesList.value.map((s) => s.color));
72
+
73
+ // Half a cluster of outer gutter, as in StackedBarChart.vue and packages/charts' FChartFrame.
74
+ const xDomain = computed(() => [-0.5, (props.data?.length ?? 0) - 0.5]);
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
98
+ :data="data"
99
+ :height="height"
100
+ :duration="0"
101
+ :x-domain="xDomain"
102
+ :margin="{ top: 16, right: 24, bottom: 0, left: 0 }"
103
+ >
104
+ <!-- groupPadding separates one cluster from the next; barPadding separates bars WITHIN a
105
+ cluster and stays small, because a cluster has to read as one thing. -->
106
+ <VisGroupedBar
107
+ :x="x"
108
+ :y="y"
109
+ :color="colors"
110
+ :rounded-corners="8"
111
+ :group-padding="0.25"
112
+ :bar-padding="0.05"
113
+ />
114
+ <VisAxis
115
+ v-if="axes"
116
+ type="x"
117
+ :grid-line="false"
118
+ :tick-line="false"
119
+ :tick-values="xTicks"
120
+ :tick-format="xFormat"
121
+ :tick-padding="16"
122
+ :label="xLabel"
123
+ :label-margin="24"
124
+ tick-text-hide-overlapping
125
+ />
126
+ <VisAxis
127
+ v-if="axes"
128
+ type="y"
129
+ :tick-format="format"
130
+ :num-ticks="4"
131
+ :tick-line="false"
132
+ :domain-line="false"
133
+ :tick-padding="16"
134
+ :label="yLabel"
135
+ :label-margin="24"
136
+ />
137
+ <VisCrosshair :duration="crosshairMs" :hideWhenFarFromPointer="CROSSHAIR_HIDE_WHEN_FAR" :circleRadius="CROSSHAIR_CIRCLE_R" :x="x" :y="y" :template="tip" :color="colors" />
138
+ <VisTooltip v-bind="deckTooltip()" />
139
+ </VisXYContainer>
140
+ </ChartFrame>
141
+ </template>
@@ -0,0 +1,116 @@
1
+ <script setup>
2
+ /*
3
+ A single Forest icon on a rounded warm tile — for putting one glyph on a slide with real
4
+ presence, rather than a bare `i-forest-* text-6xl` div dropped straight onto the page.
5
+
6
+ The icon is drawn as a CSS mask, not through UnoCSS's `i-forest-*` utility. That utility only
7
+ exists once UnoCSS's static scanner sees the literal class string in source — and an `icon` prop
8
+ means the class would have to be assembled at runtime (`i-forest-${icon}`), a string the scanner
9
+ never sees, so the rule for it is silently never generated. (The same trap Bars.vue documents for
10
+ `--deck-series-${i}`, worked around there with an inline style instead of a class.) Masking the
11
+ SVG straight from the shared icon source sidesteps the scanner entirely — the technique Logo.vue
12
+ already uses for the wordmark.
13
+
14
+ The tone is fixed, not a prop: this is the one warm icon tile in the vocabulary, and it takes
15
+ `warm-light` — the pale end of the warm row. Its colour is resolved through the SAME
16
+ generator-derived lookup as BadgeStrip's `background` (backgrounds.js), not hard-coded as a literal
17
+ `var(--deck-bg-warm-light)` in this file's own CSS — the earlier version did exactly that, twice,
18
+ and renaming that tone in scripts/deck.ts left both references pointing
19
+ at a var() that no longer exists: every IconTile went invalid-at-computed-value-time, an
20
+ invisible 80x80 blank, with every other gate still green. Routing through `backgroundStyle` means a
21
+ rename instead falls back to whatever tone IS current — visibly wrong at worst, never invisible.
22
+ */
23
+ import { computed } from 'vue';
24
+ import { backgroundStyle } from './backgrounds';
25
+ import { ICON_SLUGS } from './iconSlugs';
26
+ // The mask-URL resolution lives beside this file rather than in it: Marquee draws icons the same
27
+ // way, and the UnoCSS scanner trap it works around is invisible when a second copy gets it wrong.
28
+ import { iconUrlOrFallback } from './iconUrl';
29
+
30
+
31
+ const props = defineProps({
32
+ /**
33
+ * Namespaced icon reference, "<namespace>:<name>" — e.g. `forest:bike` or `lucide:check`. The
34
+ * namespace is never optional: Forest's own set and Lucide's each have a `bike`, a `star` and a
35
+ * `parking` of their own, so a bare, unprefixed name would be a real collision between the two
36
+ * sets, not a hypothetical one, and this component has no sensible way to guess which one an
37
+ * author meant. `forest:<name>` is the filename in packages/ui/icons/forest, without its
38
+ * extension; `lucide:<name>` is a name from Lucide's own set (icon-sets.iconify.design/lucide)
39
+ * that packages/slidev-theme/icons/lucide/*.svg has actually been generated for — run
40
+ * `bun run deck:icons` after adding a new one. A slug this repo doesn't recognise degrades to
41
+ * the Forest mark (see FALLBACK_ICON in ./iconUrl.js) rather than an invisible blank tile.
42
+ */
43
+ icon: {
44
+ type: String,
45
+ required: true,
46
+ // Imported, not inlined — ICON_SLUGS is a module-level import, so it survives defineProps()
47
+ // being hoisted out of setup() at build time (the same reason Chip.vue's own `tone` validator
48
+ // defers to BACKGROUNDS rather than an inline array). See iconSlugs.js for why this reads the
49
+ // generated set rather than restating the sixteen (or ~1,790) names here.
50
+ //
51
+ // `import.meta.env.DEV`-gated, as a TERNARY rather than an `if` inside the function body:
52
+ // ICON_SLUGS is ~1,790 bare strings once Lucide's names are in it, and Vue only ever calls a
53
+ // prop validator outside production anyway — left ungated, this array would still ship in
54
+ // every deck's PRODUCTION bundle purely to back a check nothing runs there, exactly the kind
55
+ // of bloat task-8b's own brief warns against (its own check: `zodiac-virgo`, a Lucide-only
56
+ // name nothing here uses, absent from a built dist/). Vite's dead-code elimination drops the
57
+ // dev branch, and — since that's ICON_SLUGS' only remaining reference in this file — the
58
+ // import along with it. It MUST stay a single ternary expression, not a multi-statement `if`
59
+ // block: test/props.ts's extractEnum() finds an `enumRef` by matching the substring
60
+ // `=> IDENTIFIER.includes` in the validator's own source text, and a block body hides that
61
+ // reference from it — silently reopening task-7-fix-1.md's own `enum: null` hole, the exact
62
+ // defect ICON_SLUGS exists to close.
63
+ validator: import.meta.env.DEV ? (v) => ICON_SLUGS.includes(v) : () => true,
64
+ },
65
+ /** Accessible label for the icon. A bare glyph carries no text of its own — omit only when the icon is purely decorative next to a labelled sibling. */
66
+ label: { type: String, default: '' },
67
+ });
68
+
69
+ // Vite's dev-mode `?url` on an SVG resolves to a data: URI (its production build emits a real
70
+ // hashed asset URL instead — both are valid CSS url() targets), and that data: URI is built from
71
+ // the SVG's own markup verbatim: single-quoted attributes (`width='24'`), parens from any `url(#…)`
72
+ // reference, none of it percent-encoded. A CSS url() token written UNQUOTED cannot contain `'`,
73
+ // `(` or `)` at all per the CSS syntax spec — the browser drops the whole declaration on sight, no
74
+ // console warning. Wrapping the URL in a CSS string (`url("…")`) sidesteps this: it's the same fix
75
+ // packages/frames/src/art.ts's device art never needed only because it never generates a mask.
76
+ const iconUrl = computed(() => iconUrlOrFallback(props.icon) ?? '');
77
+ const maskCss = computed(() => `url("${iconUrl.value}")`);
78
+ const tileStyle = backgroundStyle('warm-light');
79
+ </script>
80
+
81
+ <template>
82
+ <div
83
+ class="deck-icon-tile"
84
+ :style="{ background: tileStyle.background }"
85
+ :role="label ? 'img' : undefined"
86
+ :aria-label="label || undefined"
87
+ :aria-hidden="label ? undefined : 'true'"
88
+ >
89
+ <span
90
+ class="deck-icon-tile-glyph"
91
+ :style="{ maskImage: maskCss, webkitMaskImage: maskCss, backgroundColor: tileStyle.color }"
92
+ />
93
+ </div>
94
+ </template>
95
+
96
+ <style scoped>
97
+ .deck-icon-tile {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ width: var(--deck-space-7);
102
+ height: var(--deck-space-7);
103
+ border-radius: var(--deck-radius-control);
104
+ }
105
+ .deck-icon-tile-glyph {
106
+ display: block;
107
+ width: var(--deck-space-5);
108
+ height: var(--deck-space-5);
109
+ mask-repeat: no-repeat;
110
+ mask-position: center;
111
+ mask-size: contain;
112
+ -webkit-mask-repeat: no-repeat;
113
+ -webkit-mask-position: center;
114
+ -webkit-mask-size: contain;
115
+ }
116
+ </style>
@@ -0,0 +1,188 @@
1
+ <script setup>
2
+ /*
3
+ A KPI on a slide — one figure, what it is, and optionally how it has moved.
4
+
5
+ This is the deck's tile, not a second definition of a KPI. The rule that actually matters is
6
+ the DELTA rule, and that rule is imported, not restated:
7
+
8
+ the arrow carries literal DIRECTION; the colour carries JUDGEMENT, and only when the
9
+ author has declared which way is better. A bare number that has gone up is not, on its
10
+ own, good news — so `sentiment` defaults to neutral and the delta renders grey.
11
+
12
+ resolveDelta() below is the same function packages/ui/src/kpi/FKpi.vue runs, reached by
13
+ relative path rather than by package specifier — the same trick styles/index.ts uses for the
14
+ font files, and for the same reason: @humanforest/ui's entry drags in Nuxt UI, reka-ui and
15
+ mapbox-gl, none of which a slide needs. kpiDelta.ts and kpiFormat.ts are dependency-free
16
+ leaves, so importing them costs two small modules and keeps one definition of the semantics.
17
+
18
+ Prop names deliberately match FKpi (label / metric / unit / period / caveat / sentiment /
19
+ delta) so a deck author and a product engineer describe a KPI the same way.
20
+ */
21
+ import { computed } from 'vue'
22
+ import { resolveDelta } from '@humanforest/ui/kpi/delta'
23
+ import { formatMetric } from '@humanforest/ui/kpi/format'
24
+
25
+ const props = defineProps({
26
+ /** The KPI's name, shown above the figure. */
27
+ label: { type: String, required: true },
28
+ /** The number. Pre-formatted text goes through the `metric` slot instead. */
29
+ metric: { type: Number, default: null },
30
+ /** How the figure is notated: 'full' spells out every digit, 'compact' abbreviates (1.2m, 999k). */
31
+ format: { type: String, default: 'compact' },
32
+ /** Decimal places to force on the formatted figure; omit to let the format's own rounding decide. */
33
+ precision: { type: Number, default: undefined },
34
+ /** Locale the figure and its delta are formatted in, e.g. 'en-GB'. */
35
+ locale: { type: String, default: 'en-GB' },
36
+ /** Suffix rendered immediately after the figure, e.g. '%' or 'km'. */
37
+ unit: { type: String, default: '' },
38
+ /** Required for an aggregate ("last 7 days"); omit for an instantaneous reading. */
39
+ period: { type: String, default: '' },
40
+ /** Rendered, never tooltip-only — a figure with a caveat has the caveat on the slide. */
41
+ caveat: { type: String, default: '' },
42
+ /** 'higher-is-better' | 'lower-is-better' | 'neutral' */
43
+ sentiment: { type: String, default: 'neutral' },
44
+ /** { value, unit: 'percent'|'pp'|'absolute', comparisonLabel, previous?, display? } */
45
+ delta: { type: Object, default: undefined },
46
+ })
47
+
48
+ const figure = computed(() =>
49
+ props.metric === null
50
+ ? null
51
+ : formatMetric(props.metric, {
52
+ locale: props.locale,
53
+ precision: props.precision,
54
+ format: props.format,
55
+ }),
56
+ )
57
+
58
+ const resolved = computed(() =>
59
+ resolveDelta(props.delta, {
60
+ sentiment: props.sentiment,
61
+ locale: props.locale,
62
+ precision: props.precision,
63
+ }),
64
+ )
65
+
66
+ const ARROW = { up: '↑', down: '↓', flat: '→' }
67
+ </script>
68
+
69
+ <template>
70
+ <div class="kpi">
71
+ <div class="kpi-label">{{ label }}</div>
72
+
73
+ <div class="kpi-figure">
74
+ <slot name="metric">{{ figure ?? '—' }}</slot><span v-if="unit" class="kpi-unit">{{ unit }}</span>
75
+ </div>
76
+
77
+ <div v-if="resolved && !resolved.hidden" class="kpi-delta" :class="`is-${resolved.intent}`">
78
+ <!-- The arrow is direction and nothing else, so it is aria-hidden: the whole movement is
79
+ spoken once, in words, by the visually-hidden sentence below. -->
80
+ <span aria-hidden="true" class="kpi-arrow">{{ ARROW[resolved.direction] }}</span>
81
+ <span>{{ resolved.text }}</span>
82
+ <span class="kpi-comparison">{{ delta.comparisonLabel }}</span>
83
+ <span class="kpi-sr">{{ resolved.sentence }}</span>
84
+ </div>
85
+
86
+ <div v-if="period || caveat" class="kpi-foot">
87
+ <span v-if="period">{{ period }}</span>
88
+ <span v-if="caveat" class="kpi-caveat">{{ caveat }}</span>
89
+ </div>
90
+ </div>
91
+ </template>
92
+
93
+ <style scoped>
94
+ .kpi-label {
95
+ font-family: var(--type-deck-overline-family);
96
+ font-size: var(--type-deck-overline-size);
97
+ font-weight: var(--type-deck-overline-weight);
98
+ letter-spacing: var(--type-deck-overline-tracking);
99
+ line-height: var(--type-deck-overline-line-height);
100
+ text-transform: uppercase;
101
+ color: var(--deck-fg-muted);
102
+ margin-bottom: var(--deck-space-2);
103
+ }
104
+
105
+ /* The figure runs the display face — this is the one place on a content slide that shouts. */
106
+ .kpi-figure {
107
+ font-family: var(--font-display);
108
+ font-weight: var(--type-deck-display-weight);
109
+ font-size: var(--type-deck-text-8xl);
110
+ line-height: 1;
111
+ letter-spacing: var(--type-deck-display-tracking);
112
+ color: var(--deck-accent);
113
+ }
114
+ .kpi-unit {
115
+ font-size: 0.5em;
116
+ margin-left: 0.08em;
117
+ }
118
+
119
+ .kpi-delta {
120
+ display: flex;
121
+ align-items: baseline;
122
+ gap: 0.35em;
123
+ /* space-1, not space-2. The comparison label WRAPS in a narrow card — "vs. prev. 30 days" is two
124
+ lines at 408px, 115px of line box against a 57.6px line — so a KPI on a second row runs 4px
125
+ past the 96px text inset and scripts/deckAudit.ts fails it (deck-template slide 13). The rung
126
+ below buys 8px, twice the overrun, and the figure still clears its delta by a full step. */
127
+ margin-top: var(--deck-space-1);
128
+ font-size: var(--type-deck-body-sm-size);
129
+ font-weight: 700;
130
+ /* Judgement colour. Neutral is the default and the honest one. */
131
+ color: var(--deck-fg-muted);
132
+ }
133
+ /* MEASURED, not the raw dataviz series colour straight — task-8-brief.md CONTROLLER ADDENDUM 2's
134
+ rendered-contrast sweep found live: --dataviz-categorical-vivid-green as TEXT scores 2.81:1 on a
135
+ Card's own --deck-surface fill (StatCard, the exact composition this role is built for) and
136
+ 3.14:1 even on the bare page --deck-bg — under the 4.5:1 body floor either way, and under 3:1 in
137
+ the Card case. The dataviz palette's own CVD/CIEDE2000 gates score it as a chart SERIES fill
138
+ (bars, dots, a 3:1 non-text floor against a chart's own track), never as small deck TEXT directly
139
+ on a Forest surface — a different job with a different floor, so this is not a defect in that
140
+ palette to fix there.
141
+ color-mix(in oklab, …, var(--deck-fg) 40%) — the SAME house technique deck.css's own derived
142
+ roles already use — pulls the series colour toward the register's own ink: --deck-fg is
143
+ near-black in light (DARKENS the green/red, adding contrast against a light surface) and
144
+ near-white in dark (LIGHTENS it, adding contrast against a dark ground) — self-adjusting for
145
+ both registers from one declaration. 40%, not the 30% that already clears light on its own,
146
+ because 30% against light lands at exactly 4.50:1 on the Card-surface case — rounding-distance
147
+ from the floor it is meant to clear, not a real margin. Verified both registers, both roles, at
148
+ 40%: green 5.32:1 (surface) / 5.94:1 (bg) light, 7.79:1 dark; red 6.84:1 / 7.63:1 light, 6.38:1
149
+ dark — comfortable margin throughout. */
150
+ .kpi-delta.is-success {
151
+ color: color-mix(in oklab, var(--dataviz-categorical-vivid-green), var(--deck-fg) 40%);
152
+ }
153
+ .kpi-delta.is-error {
154
+ color: color-mix(in oklab, var(--dataviz-categorical-vivid-red), var(--deck-fg) 40%);
155
+ }
156
+ .kpi-arrow {
157
+ font-family: var(--font-sans);
158
+ }
159
+ .kpi-comparison {
160
+ font-weight: 400;
161
+ color: var(--deck-fg-subtle);
162
+ }
163
+
164
+ .kpi-foot {
165
+ display: flex;
166
+ flex-wrap: wrap;
167
+ gap: 0 0.6em;
168
+ margin-top: var(--deck-space-1);
169
+ font-size: var(--type-deck-caption-size);
170
+ color: var(--deck-fg-subtle);
171
+ }
172
+ .kpi-caveat {
173
+ font-style: italic;
174
+ }
175
+
176
+ /* Direction is drawn as an arrow and spoken as a sentence — never only as a colour. */
177
+ .kpi-sr {
178
+ position: absolute;
179
+ width: 1px;
180
+ height: 1px;
181
+ padding: 0;
182
+ margin: -1px;
183
+ overflow: hidden;
184
+ clip: rect(0, 0, 0, 0);
185
+ white-space: nowrap;
186
+ border: 0;
187
+ }
188
+ </style>