@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.
- package/LICENSE +67 -0
- package/README.md +577 -0
- package/assetUrl.js +38 -0
- package/components/Alert.vue +209 -0
- package/components/AreaBox.vue +152 -0
- package/components/AreaChart.vue +145 -0
- package/components/Avatar.vue +267 -0
- package/components/AvatarGroup.vue +94 -0
- package/components/BadgeStrip.vue +203 -0
- package/components/Bars.vue +207 -0
- package/components/BoroughShape.vue +127 -0
- package/components/BrowserFrame.vue +484 -0
- package/components/CalendarGrid.vue +480 -0
- package/components/Card.vue +70 -0
- package/components/Carousel.vue +266 -0
- package/components/ChartFrame.vue +426 -0
- package/components/ChordChart.vue +264 -0
- package/components/ComposedChart.vue +187 -0
- package/components/Contents.vue +86 -0
- package/components/DataTable.vue +244 -0
- package/components/DeckMap.vue +409 -0
- package/components/DeviceFrame.vue +331 -0
- package/components/DonutChart.vue +103 -0
- package/components/DottedMap.vue +126 -0
- package/components/Endorsement.vue +76 -0
- package/components/Grid.vue +169 -0
- package/components/GroupedBarChart.vue +141 -0
- package/components/IconTile.vue +116 -0
- package/components/Kpi.vue +188 -0
- package/components/LineChart.vue +133 -0
- package/components/Logo.vue +203 -0
- package/components/LondonMap.vue +348 -0
- package/components/MarkPair.vue +109 -0
- package/components/Marquee.vue +263 -0
- package/components/NestedDonutChart.vue +295 -0
- package/components/Placeholder.vue +88 -0
- package/components/SankeyChart.vue +223 -0
- package/components/ScatterChart.vue +142 -0
- package/components/StackedBarChart.vue +143 -0
- package/components/StatCard.vue +134 -0
- package/components/StatusTrack.vue +334 -0
- package/components/Timeline.vue +249 -0
- package/components/TimelineChart.vue +329 -0
- package/components/TreemapChart.vue +267 -0
- package/components/backgrounds.js +135 -0
- package/components/boroughGeometry.ts +59 -0
- package/components/chartData.ts +277 -0
- package/components/chartTip.ts +201 -0
- package/components/codeTheme.ts +239 -0
- package/components/deckPalette.ts +157 -0
- package/components/dottedMapGeometry.js +60 -0
- package/components/iconSlugs.js +55 -0
- package/components/iconUrl.js +51 -0
- package/components/mermaidTheme.ts +972 -0
- package/components/motionDuration.ts +41 -0
- package/components/tableRules.ts +257 -0
- package/confidential-mark.vue +151 -0
- package/custom-nav-controls.vue +79 -0
- package/endorsements/b-corp-black.svg +30 -0
- package/endorsements/b-corp-white.svg +30 -0
- package/global-top.vue +1662 -0
- package/icons/forest/bike-asterisk.svg +21 -0
- package/icons/forest/bike-down.svg +19 -0
- package/icons/forest/bike-off.svg +20 -0
- package/icons/forest/bike-up.svg +19 -0
- package/icons/forest/bike-x.svg +19 -0
- package/icons/forest/bike.svg +18 -0
- package/icons/forest/mark-fill.svg +6 -0
- package/icons/forest/mark.svg +6 -0
- package/icons/forest/parking.svg +3 -0
- package/icons/forest/star-fill.svg +4 -0
- package/icons/forest/star.svg +4 -0
- package/icons/forest/traffic-light-caution.svg +8 -0
- package/icons/forest/traffic-light-go.svg +8 -0
- package/icons/forest/traffic-light-lit.svg +8 -0
- package/icons/forest/traffic-light-stop.svg +8 -0
- package/icons/forest/traffic-light.svg +8 -0
- package/icons/lucide/battery.svg +1 -0
- package/icons/lucide/bike.svg +1 -0
- package/icons/lucide/chart-line.svg +1 -0
- package/icons/lucide/check.svg +1 -0
- package/icons/lucide/circle-check.svg +1 -0
- package/icons/lucide/clock.svg +1 -0
- package/icons/lucide/code.svg +1 -0
- package/icons/lucide/component.svg +1 -0
- package/icons/lucide/frame.svg +1 -0
- package/icons/lucide/layout-grid.svg +1 -0
- package/icons/lucide/map-pin.svg +1 -0
- package/icons/lucide/map.svg +1 -0
- package/icons/lucide/palette.svg +1 -0
- package/icons/lucide/table.svg +1 -0
- package/icons/lucide/triangle-alert.svg +1 -0
- package/icons/lucide/type.svg +1 -0
- package/icons/lucide/wrench.svg +1 -0
- package/layouts/bleed.vue +317 -0
- package/layouts/cover.vue +1514 -0
- package/layouts/default.vue +187 -0
- package/layouts/divider.vue +286 -0
- package/layouts/end.vue +384 -0
- package/layouts/quote.vue +218 -0
- package/layouts/split.vue +1875 -0
- package/layouts/stack.vue +944 -0
- package/layouts/statement.vue +67 -0
- package/manifest/chrome.ts +36 -0
- package/manifest/components.ts +49 -0
- package/manifest/index.ts +57 -0
- package/manifest/layouts.ts +37 -0
- package/manifest/manifest.json +6514 -0
- package/manifest/presets.ts +635 -0
- package/notch.js +458 -0
- package/notchPath.js +188 -0
- package/package.json +77 -0
- package/section.js +110 -0
- package/setup/mermaid-renderer.ts +2120 -0
- package/setup/mermaid.ts +30 -0
- package/setup/shiki.ts +44 -0
- package/setup/shortcuts.ts +28 -0
- package/slide-bottom.vue +305 -0
- package/slide-top.vue +268 -0
- package/styles/base.css +2611 -0
- package/styles/index.ts +71 -0
- package/styles/tokens.css +144 -0
- package/uno.config.ts +231 -0
- 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>
|