@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
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>
|