@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,331 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A phone, drawn around a live screen — the deck's own re-implementation of the design system's
|
|
4
|
+
FDeviceFrame.
|
|
5
|
+
|
|
6
|
+
★★ THE DATA IS IMPORTED, THE PRESENTATION IS REBUILT, AND THAT SPLIT IS FORCED RATHER THAN CHOSEN.
|
|
7
|
+
|
|
8
|
+
`@humanforest/frames` already owns all of this: the registry of 25 devices with their true logical
|
|
9
|
+
point sizes, the measured screen apertures, the Apple squircle clip path, and the bundled Wikimedia
|
|
10
|
+
renders. None of that should be copied — the package EXISTS because docs and playground had each
|
|
11
|
+
carried their own copy of the frame and the two had already drifted.
|
|
12
|
+
|
|
13
|
+
What cannot come across is FDeviceFrame.vue itself. It is built on Nuxt UI's semantic classes —
|
|
14
|
+
`bg-default`, `bg-elevated`, `bg-highlighted`, `border-default`, `text-default`, `text-dimmed`,
|
|
15
|
+
`text-highlighted`, `text-muted` — and this deck has none of them. Nuxt UI is Tailwind; the deck is
|
|
16
|
+
UnoCSS with the Forest deck tokens. Dropped in as-is, every one of those classes matches nothing:
|
|
17
|
+
the chrome renders invisible and the screen renders transparent, silently, on a slide that still
|
|
18
|
+
lays out. So the VALUES and the PURE FUNCTIONS are imported and the PRESENTATION is written here
|
|
19
|
+
against `--deck-*`, which is the standing rule for anything this deck borrows from the system.
|
|
20
|
+
|
|
21
|
+
★ THE GEOMETRY IS NOT INVENTED. Every number below is derived exactly as FDeviceFrame derives it,
|
|
22
|
+
and the derivation is the only thing duplicated:
|
|
23
|
+
|
|
24
|
+
shell width = screen.w / (1 - (inset.left + inset.right) / 100)
|
|
25
|
+
shell height = shell width * art.aspect
|
|
26
|
+
|
|
27
|
+
The insets are percentages of the SVG's own viewBox, measured in a browser when the registry was
|
|
28
|
+
built. Re-deriving rather than hardcoding means a corrected inset in the package moves this frame
|
|
29
|
+
too. Measured for the default (iPhone 17 Pro Max): 440 / 0.92579 = 475.3 wide, x 2.06439 = 981.2
|
|
30
|
+
tall, which is why `height` exists — at natural size the shell is 91% of a 1080 canvas.
|
|
31
|
+
|
|
32
|
+
⚠ ATTRIBUTION IS A LICENCE CONDITION, NOT A COURTESY. The renders are unmodified Wikimedia Commons
|
|
33
|
+
vectors under CC BY-SA 4.0. `ART_CREDITS` is re-exported here so a deck can print the list, and the
|
|
34
|
+
specimen does. The art must never reach a public channel — both this theme and the frames package
|
|
35
|
+
are `private: true`, and the theme is not carried by the giget layer at all.
|
|
36
|
+
*/
|
|
37
|
+
import { computed, useId } from 'vue';
|
|
38
|
+
import { DEFAULT_DEVICE, findDevice } from '@humanforest/frames/devices';
|
|
39
|
+
import { artUrl } from '@humanforest/frames/art';
|
|
40
|
+
import { assetUrl } from '../assetUrl.js';
|
|
41
|
+
|
|
42
|
+
const props = defineProps({
|
|
43
|
+
/**
|
|
44
|
+
* Which model to draw, by registry id — `iphone-17-pro-max`, `pixel-10-pro`, `iphone-se-3`. Free
|
|
45
|
+
* string rather than an enum: the registry is the source of truth and restating 25 ids here would
|
|
46
|
+
* be a second copy that goes stale the day a device is added. An unknown id throws from
|
|
47
|
+
* findDevice() at build time rather than rendering a frame with no hardware.
|
|
48
|
+
*/
|
|
49
|
+
device: { type: String, default: DEFAULT_DEVICE },
|
|
50
|
+
/**
|
|
51
|
+
* How tall the shell should be drawn, in canvas pixels. The screen inside still lays out at the
|
|
52
|
+
* model's TRUE logical points and the whole thing is scaled — so a mock authored for a 440pt phone
|
|
53
|
+
* stays a 440pt phone, at whatever size the slide has room for.
|
|
54
|
+
*/
|
|
55
|
+
height: { type: Number, default: 820 },
|
|
56
|
+
/** Alt text for the hardware render, e.g. "Forest app on an iPhone 17 Pro Max". */
|
|
57
|
+
label: { type: String, default: '' },
|
|
58
|
+
/**
|
|
59
|
+
* A screenshot or still to fill the screen with, as a URL. The same one-rule convention the
|
|
60
|
+
* layouts use for their own figures — `image` in the frontmatter rather than a hand-written
|
|
61
|
+
* `<img>` in a slot — because these decks are written by people who are not front-end engineers.
|
|
62
|
+
*
|
|
63
|
+
* Cropped with `object-fit: cover`, so a still whose aspect does not match the model's is trimmed
|
|
64
|
+
* rather than squashed. The slot still wins where the screen holds something that is not a
|
|
65
|
+
* picture: a mock built from components, a live map, or a `<video>`.
|
|
66
|
+
*/
|
|
67
|
+
image: { type: String, default: '' },
|
|
68
|
+
/**
|
|
69
|
+
* A LIVE PAGE to put in the screen, as a URL — rendered in an `<iframe>` at the model's TRUE
|
|
70
|
+
* logical points, which is the whole reason to do it on a phone.
|
|
71
|
+
*
|
|
72
|
+
* ★★ THE SITE SEES A REAL PHONE VIEWPORT. The shell is `transform: scale()`d, and a transform
|
|
73
|
+
* does not change the layout box — so an iPhone 17 Pro Max hands the page a 440x956 viewport and
|
|
74
|
+
* the page serves its MOBILE layout, then the whole thing is drawn at whatever height the slide
|
|
75
|
+
* has room for. A screenshot of a desktop page shrunk into a phone is the thing this avoids.
|
|
76
|
+
*
|
|
77
|
+
* ★ SLIDEV SUPPORTS IFRAMES ITSELF — `layout: iframe`, `iframe-left`, `iframe-right` upstream —
|
|
78
|
+
* and this is the same mechanism with hardware around it. See BrowserFrame's own `src` note for
|
|
79
|
+
* the network hazard and the sandbox; both components make the same calls for the same reasons.
|
|
80
|
+
*/
|
|
81
|
+
src: { type: String, default: '' },
|
|
82
|
+
/**
|
|
83
|
+
* Whether a `src` page can be scrolled and clicked. True, and a phone is where it earns its keep:
|
|
84
|
+
* a mobile page is TALLER than its viewport by design, so a frozen one shows its hero and nothing
|
|
85
|
+
* else. See BrowserFrame's own `interactive` note for the keyboard-focus caveat and when to pass
|
|
86
|
+
* `false`.
|
|
87
|
+
*/
|
|
88
|
+
interactive: { type: Boolean, default: true },
|
|
89
|
+
/** Alt text for `image`, and the accessible title of a `src` frame. Required by the gate when `image` is set. */
|
|
90
|
+
alt: { type: String, default: '' },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const model = computed(() => findDevice(props.device));
|
|
94
|
+
const art = computed(() => model.value.art);
|
|
95
|
+
|
|
96
|
+
/* The hardware's natural size, from the screen it has to hold and the aperture the render leaves. */
|
|
97
|
+
const shell = computed(() => {
|
|
98
|
+
const a = art.value;
|
|
99
|
+
const w = model.value.screen.w / (1 - (a.inset.left + a.inset.right) / 100);
|
|
100
|
+
return { w, h: w * a.aspect };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const scale = computed(() => props.height / shell.value.h);
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
★ SCALED WITH `transform`, NEVER `zoom` — the same rule FFitFrame states for the same reason. `zoom`
|
|
107
|
+
changes the LAYOUT box, so anything that sizes a canvas from clientWidth (a Mapbox map in a mock)
|
|
108
|
+
gets the factor applied twice and paints dead strips down two edges. A transform leaves the layout
|
|
109
|
+
box alone. The cost is that the element still occupies its unscaled box, which is what the outer
|
|
110
|
+
wrapper's explicit width and height are for.
|
|
111
|
+
*/
|
|
112
|
+
const shellStyle = computed(() => ({
|
|
113
|
+
width: `${shell.value.w}px`,
|
|
114
|
+
height: `${shell.value.h}px`,
|
|
115
|
+
transform: `scale(${scale.value})`,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
const boxStyle = computed(() => ({
|
|
119
|
+
width: `${shell.value.w * scale.value}px`,
|
|
120
|
+
height: `${props.height}px`,
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
/*
|
|
124
|
+
★★ THE CUTOUT, WHICH THE FIRST DRAFT SIMPLY DID NOT DRAW. The registry records one per device and
|
|
125
|
+
this component ignored it, so an iPhone 17 Pro Max — `cutout: 'island'` — rendered with a blank
|
|
126
|
+
screen and no Dynamic Island. Nothing caught it: the audit measures ink and geometry, and a missing
|
|
127
|
+
black pill is neither.
|
|
128
|
+
|
|
129
|
+
The three shapes and their numbers are FDeviceFrame's, not invented here:
|
|
130
|
+
|
|
131
|
+
island 30% of the screen width, 36 tall, 10 down from the top — a floating pill
|
|
132
|
+
notch 42% of the screen width, 30 tall, hard against the top, 18px bottom corners
|
|
133
|
+
punch-hole a 14px circle, 12 down, centred
|
|
134
|
+
|
|
135
|
+
⚠ THE NOTCH IS DRAWN ONLY WHEN THERE IS NO CLIP PATH, and that condition is carried across
|
|
136
|
+
deliberately rather than simplified away. On the models whose render has a measured screen outline,
|
|
137
|
+
the notch is already BITTEN OUT of that outline — the aperture itself has the shape — so drawing a
|
|
138
|
+
black bar on top of it would paint a second notch below the real one. `cutout: 'home'` and `'none'`
|
|
139
|
+
draw nothing, which is why this is a lookup rather than an if/else chain with a fallback.
|
|
140
|
+
*/
|
|
141
|
+
const cutout = computed(() => {
|
|
142
|
+
const d = model.value;
|
|
143
|
+
const w = d.screen.w;
|
|
144
|
+
if (d.cutout === 'island') return { kind: 'island', w: Math.round(w * 0.3), h: 36, top: 10, radius: '9999px' };
|
|
145
|
+
if (d.cutout === 'notch' && !d.art.clip) return { kind: 'notch', w: Math.round(w * 0.42), h: 30, top: 0, radius: '0 0 18px 18px' };
|
|
146
|
+
if (d.cutout === 'punch-hole') return { kind: 'punch-hole', w: 14, h: 14, top: 12, radius: '9999px' };
|
|
147
|
+
return null;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
The device's own safe-area insets, published as custom properties on the screen. A mock phone has no
|
|
152
|
+
real cutout for the browser to report, so `env(safe-area-inset-*)` resolves to 0 inside it and any
|
|
153
|
+
content that respects the safe area would tuck straight under the island. An author who wants to
|
|
154
|
+
clear it reads these; one drawing a full-bleed screen ignores them, which is why they are offered
|
|
155
|
+
rather than applied.
|
|
156
|
+
*/
|
|
157
|
+
const safeVars = computed(() => ({
|
|
158
|
+
'--deck-device-safe-top': `${model.value.safe.top}px`,
|
|
159
|
+
'--deck-device-safe-bottom': `${model.value.safe.bottom}px`,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
// A clip path is document-global, so two phones on one slide would share an id and the second would
|
|
163
|
+
// clip to the first's outline. Vue's own useId() is unique per instance and stable across SSR.
|
|
164
|
+
const clipId = `deck-screenclip-${useId()}`;
|
|
165
|
+
|
|
166
|
+
const screenStyle = computed(() => {
|
|
167
|
+
const a = art.value;
|
|
168
|
+
return {
|
|
169
|
+
top: `${a.inset.top}%`,
|
|
170
|
+
left: `${a.inset.left}%`,
|
|
171
|
+
right: `${a.inset.right}%`,
|
|
172
|
+
bottom: `${a.inset.bottom}%`,
|
|
173
|
+
// The Apple screens carry a measured squircle; the Pixels and the SE are plain rounded rects, so
|
|
174
|
+
// they fall back to the registry's own radius percentage rather than to a number invented here.
|
|
175
|
+
...(a.clip
|
|
176
|
+
? { clipPath: `url(#${clipId})` }
|
|
177
|
+
: { borderRadius: `${(model.value.screen.w * (a.radiusPct ?? 0)) / 100}px` }),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<template>
|
|
183
|
+
<div class="deck-device" :style="boxStyle">
|
|
184
|
+
<div class="deck-device-shell" :style="shellStyle">
|
|
185
|
+
<img
|
|
186
|
+
:src="artUrl(model)"
|
|
187
|
+
:alt="label || `${model.name} device frame`"
|
|
188
|
+
class="deck-device-art"
|
|
189
|
+
draggable="false"
|
|
190
|
+
>
|
|
191
|
+
<!-- objectBoundingBox units, so the one path fits whatever size the screen is drawn at. -->
|
|
192
|
+
<svg v-if="art.clip" class="deck-device-clip" aria-hidden="true">
|
|
193
|
+
<defs>
|
|
194
|
+
<clipPath :id="clipId" clipPathUnits="objectBoundingBox"><path :d="art.clip" /></clipPath>
|
|
195
|
+
</defs>
|
|
196
|
+
</svg>
|
|
197
|
+
<div class="deck-device-screen" :style="[screenStyle, safeVars]">
|
|
198
|
+
<!-- `image` fills the screen; the slot is the escape hatch for everything that is not a
|
|
199
|
+
still — a component mock, a live map, a <video>. Both render, so a caller can lay copy
|
|
200
|
+
over a screenshot. -->
|
|
201
|
+
<img v-if="image" :src="assetUrl(image)" :alt="alt" class="deck-device-media" draggable="false">
|
|
202
|
+
<!-- Lazy, sandboxed to scripts alone, and no referrer — the three calls BrowserFrame's own
|
|
203
|
+
iframe comment explains. Below the cutout in the DOM so the island still floats over
|
|
204
|
+
the page the way it does over an app. -->
|
|
205
|
+
<iframe
|
|
206
|
+
v-if="src"
|
|
207
|
+
class="deck-device-live"
|
|
208
|
+
:class="interactive ? '' : 'is-frozen'"
|
|
209
|
+
:src="assetUrl(src)"
|
|
210
|
+
:title="alt || label || `A live page on a ${model.name}`"
|
|
211
|
+
:inert="!interactive"
|
|
212
|
+
loading="lazy"
|
|
213
|
+
sandbox="allow-scripts"
|
|
214
|
+
referrerpolicy="no-referrer"
|
|
215
|
+
/>
|
|
216
|
+
<slot />
|
|
217
|
+
<!-- Above the slot in the DOM as well as in z-order: the island floats OVER an app's own
|
|
218
|
+
header exactly as it does on the hardware. -->
|
|
219
|
+
<div
|
|
220
|
+
v-if="cutout"
|
|
221
|
+
class="deck-device-cutout"
|
|
222
|
+
:style="{ width: `${cutout.w}px`, height: `${cutout.h}px`, top: `${cutout.top}px`, borderRadius: cutout.radius }"
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</template>
|
|
228
|
+
|
|
229
|
+
<style scoped>
|
|
230
|
+
/*
|
|
231
|
+
The outer box occupies the SCALED size; the shell inside is at natural size and transformed. Both
|
|
232
|
+
are needed: a transform does not change layout, so without the outer box a scaled-down phone would
|
|
233
|
+
still reserve its full 981px and push everything below it off the slide.
|
|
234
|
+
*/
|
|
235
|
+
.deck-device {
|
|
236
|
+
position: relative;
|
|
237
|
+
flex: none;
|
|
238
|
+
}
|
|
239
|
+
.deck-device-shell {
|
|
240
|
+
position: absolute;
|
|
241
|
+
top: 0;
|
|
242
|
+
left: 0;
|
|
243
|
+
transform-origin: top left;
|
|
244
|
+
}
|
|
245
|
+
.deck-device-art {
|
|
246
|
+
position: absolute;
|
|
247
|
+
inset: 0;
|
|
248
|
+
width: 100%;
|
|
249
|
+
height: 100%;
|
|
250
|
+
pointer-events: none;
|
|
251
|
+
user-select: none;
|
|
252
|
+
}
|
|
253
|
+
/* Present only to carry the <clipPath>; it must take no space and paint nothing. */
|
|
254
|
+
.deck-device-clip {
|
|
255
|
+
position: absolute;
|
|
256
|
+
width: 0;
|
|
257
|
+
height: 0;
|
|
258
|
+
}
|
|
259
|
+
/*
|
|
260
|
+
Black, not a token: this is a hole in the hardware, not a surface in the palette. It stays black on
|
|
261
|
+
the dark register too, which is the one case where a `--deck-*` role would be actively wrong.
|
|
262
|
+
*/
|
|
263
|
+
.deck-device-cutout {
|
|
264
|
+
position: absolute;
|
|
265
|
+
left: 50%;
|
|
266
|
+
z-index: 30;
|
|
267
|
+
translate: -50% 0;
|
|
268
|
+
background: #000;
|
|
269
|
+
pointer-events: none;
|
|
270
|
+
}
|
|
271
|
+
/*
|
|
272
|
+
★★ ANY MEDIA IN THE SCREEN COVERS IT, WHETHER IT ARRIVED BY PROP OR BY SLOT.
|
|
273
|
+
|
|
274
|
+
A screen is a fixed aperture at the model's own logical points, and a still is whatever shape it
|
|
275
|
+
was captured at. `cover` trims the difference; `contain` would letterbox it against the screen's
|
|
276
|
+
own ground, which reads as a bug rather than as a phone.
|
|
277
|
+
|
|
278
|
+
`video` is named alongside `img` deliberately. A demo reel in the screen is the case this is for,
|
|
279
|
+
and it needs no support beyond this rule — Slidev's own `<SlidevVideo>` is the one to reach for,
|
|
280
|
+
because it holds a poster and a timestamp for PRINT, where a plain `<video>` exports as a blank
|
|
281
|
+
rectangle. The deck audit is unbothered either way: its settle fingerprints geometry and text
|
|
282
|
+
colour and waits on `document.getAnimations()`, and a playing video is none of those.
|
|
283
|
+
*/
|
|
284
|
+
/*
|
|
285
|
+
The live page fills the aperture at the model's own logical points — it is INSIDE the transformed
|
|
286
|
+
shell, so its viewport is 440x956 whatever height the phone is drawn at.
|
|
287
|
+
|
|
288
|
+
⚠ THE POINTER LANDS WHERE THE PHONE IS DRAWN, NOT WHERE THE FRAME LAYS OUT. The shell is
|
|
289
|
+
`transform: scale()`d, and a transform moves hit-testing with it — so a scroll over the visible
|
|
290
|
+
phone reaches the page even though the iframe's layout box is the unscaled 440x956. Worth stating
|
|
291
|
+
because the opposite is the usual `zoom` vs `transform` trap, and here the transform is what makes
|
|
292
|
+
interaction work at all.
|
|
293
|
+
|
|
294
|
+
White rather than the deck's ground, so a page that has not painted reads as a blank page rather
|
|
295
|
+
than as a cream screen.
|
|
296
|
+
*/
|
|
297
|
+
.deck-device-live {
|
|
298
|
+
position: absolute;
|
|
299
|
+
inset: 0;
|
|
300
|
+
width: 100%;
|
|
301
|
+
height: 100%;
|
|
302
|
+
border: 0;
|
|
303
|
+
background: #fff;
|
|
304
|
+
}
|
|
305
|
+
/* `interactive: false` — the CSS half of `inert`. See BrowserFrame's own copy of this rule. */
|
|
306
|
+
.deck-device-live.is-frozen {
|
|
307
|
+
pointer-events: none;
|
|
308
|
+
}
|
|
309
|
+
.deck-device-screen :deep(img),
|
|
310
|
+
.deck-device-screen :deep(video),
|
|
311
|
+
.deck-device-screen :deep(picture) {
|
|
312
|
+
display: block;
|
|
313
|
+
width: 100%;
|
|
314
|
+
height: 100%;
|
|
315
|
+
object-fit: cover;
|
|
316
|
+
}
|
|
317
|
+
.deck-device-media {
|
|
318
|
+
display: block;
|
|
319
|
+
width: 100%;
|
|
320
|
+
height: 100%;
|
|
321
|
+
object-fit: cover;
|
|
322
|
+
}
|
|
323
|
+
.deck-device-screen {
|
|
324
|
+
position: absolute;
|
|
325
|
+
overflow: hidden;
|
|
326
|
+
/* The deck's own ground, so an empty screen reads as a screen rather than as a hole, and a mock
|
|
327
|
+
that brings no background of its own sits on the register the rest of the slide is using. */
|
|
328
|
+
background: var(--deck-bg);
|
|
329
|
+
color: var(--deck-fg);
|
|
330
|
+
}
|
|
331
|
+
</style>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
A donut sized for a room — up to five slices of one whole, with the total in the middle.
|
|
4
|
+
|
|
5
|
+
A donut is read by AREA, not by angle, which is why it survives only a handful of slices and why
|
|
6
|
+
this component inherits the palette's five-colour ceiling as a hard limit: a sixth slice would
|
|
7
|
+
need a sixth colour that does not exist, and chartData.ts stops the chart rather than inventing
|
|
8
|
+
one or repeating a colour. If the data has a long tail, aggregate it into a "Rest" slice at the
|
|
9
|
+
call site — that is a decision about the argument, not about the chart.
|
|
10
|
+
|
|
11
|
+
The hole is not decoration. It is where the TOTAL goes, which is the one number a part-to-whole
|
|
12
|
+
chart cannot show any other way, and it is the reason to reach for this over a pie.
|
|
13
|
+
|
|
14
|
+
Real @unovis/vue mark, ChartFrame.vue for ceremony, chartData.ts for shaping.
|
|
15
|
+
*/
|
|
16
|
+
import { computed } from 'vue';
|
|
17
|
+
import { VisSingleContainer, VisDonut, VisTooltip, VisDonutSelectors } from '@unovis/vue';
|
|
18
|
+
import { deckTooltip, tipFrame } from './chartTip';
|
|
19
|
+
import ChartFrame from './ChartFrame.vue';
|
|
20
|
+
import { formatValue, shareState, shareValues, stateMessage } from './chartData';
|
|
21
|
+
|
|
22
|
+
const props = defineProps({
|
|
23
|
+
/** Rows: `[{ label, value }]`, one per slice, in the order they should read clockwise. */
|
|
24
|
+
data: { type: Array, required: true },
|
|
25
|
+
/** The figure in the hole. Omit and the slices' own total is used; pass an empty string for no centre at all. */
|
|
26
|
+
centralLabel: { type: String, default: null },
|
|
27
|
+
/** The word under that figure — what the total counts. */
|
|
28
|
+
centralSubLabel: { type: String, default: null },
|
|
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
|
+
/** Ring thickness in px. Thicker reads as a donut, thinner as a dial. */
|
|
35
|
+
arcWidth: { type: Number, default: 96 },
|
|
36
|
+
/** Draw the slice legend. A donut with no legend and no direct labels says nothing. */
|
|
37
|
+
legend: { type: Boolean, default: true },
|
|
38
|
+
/** Where the legend sits: `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`
|
|
39
|
+
* or `bottom-right`. The EDGE is reading order — above is read before the marks, below is a
|
|
40
|
+
* caption read after them; the ALIGNMENT is for the column the chart sits in, centred under a
|
|
41
|
+
* full-width plot and pulled to an edge where a centred key would float in a gutter. */
|
|
42
|
+
legendPosition: {
|
|
43
|
+
type: String,
|
|
44
|
+
default: 'bottom-center',
|
|
45
|
+
validator: (p) => ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(p),
|
|
46
|
+
},
|
|
47
|
+
/** Formats the default centre figure. Ignored when `centralLabel` is set. */
|
|
48
|
+
format: { type: Function, default: formatValue },
|
|
49
|
+
/** The sentence drawn when there is nothing to plot — including a set of slices that all sum to zero. */
|
|
50
|
+
empty: { type: String, default: 'No data' },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const state = computed(() => shareState(props.data));
|
|
54
|
+
const message = computed(() => (state.value.kind === 'ready' ? null : stateMessage(state.value, props.empty)));
|
|
55
|
+
const seriesList = computed(() => (state.value.kind === 'ready' ? state.value.series : []));
|
|
56
|
+
|
|
57
|
+
// The container holds a plain number column whose order matches the slices; the mark reads each
|
|
58
|
+
// number straight back out. Gaps count as zero here, and only here: a slice with no reading has no
|
|
59
|
+
// share of the whole, which is the one place a missing value and a nought genuinely agree.
|
|
60
|
+
const values = computed(() => shareValues(props.data ?? []));
|
|
61
|
+
const colors = computed(() => seriesList.value.map((s) => s.color));
|
|
62
|
+
const total = computed(() => values.value.reduce((a, b) => a + b, 0));
|
|
63
|
+
const centre = computed(() => props.centralLabel ?? props.format(total.value));
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
A donut has no shared x to hover, so the tooltip hangs off the SEGMENT rather than a crosshair.
|
|
67
|
+
Unovis hands the arc, whose `index` is the slice's place in the data — the same order the legend
|
|
68
|
+
and the colours use, so the name and the swatch come from one lookup rather than three.
|
|
69
|
+
|
|
70
|
+
The share is the reason a donut tooltip earns its place: the arc already shows it approximately,
|
|
71
|
+
and the number is what a reader asks for out loud.
|
|
72
|
+
*/
|
|
73
|
+
const tip = (d, i) => {
|
|
74
|
+
const idx = Number.isFinite(d?.index) ? d.index : i;
|
|
75
|
+
const s = seriesList.value[idx];
|
|
76
|
+
const v = values.value[idx] ?? 0;
|
|
77
|
+
const share = total.value > 0 ? Math.round((v / total.value) * 100) : 0;
|
|
78
|
+
return tipFrame(s?.name ?? '', [{ swatch: s?.color, label: `${share}%`, value: props.format(v) }]);
|
|
79
|
+
};
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<ChartFrame
|
|
84
|
+
:series="seriesList"
|
|
85
|
+
:height="height"
|
|
86
|
+
:legend="legend"
|
|
87
|
+
:legend-position="legendPosition"
|
|
88
|
+
:message="message"
|
|
89
|
+
>
|
|
90
|
+
<VisSingleContainer :data="values" :height="height" :duration="0">
|
|
91
|
+
<VisDonut
|
|
92
|
+
:value="(d) => d"
|
|
93
|
+
:color="colors"
|
|
94
|
+
:arc-width="arcWidth"
|
|
95
|
+
:corner-radius="8"
|
|
96
|
+
:pad-angle="0.02"
|
|
97
|
+
:central-label="centre"
|
|
98
|
+
:central-sub-label="centralSubLabel ?? undefined"
|
|
99
|
+
/>
|
|
100
|
+
<VisTooltip v-bind="deckTooltip()" :triggers="{ [VisDonutSelectors.segment]: tip }" />
|
|
101
|
+
</VisSingleContainer>
|
|
102
|
+
</ChartFrame>
|
|
103
|
+
</template>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
London as a dot grid — the design system's own map primitive, drawn as plain SVG.
|
|
4
|
+
|
|
5
|
+
Why this and not a real map on a slide: Mapbox needs a token, a network round-trip and a
|
|
6
|
+
canvas that has to finish painting before the slide is presentable. The dot grid is the
|
|
7
|
+
system's stylised London, sampled at build time from the real borough geometry, and it ships
|
|
8
|
+
as coordinates in @humanforest/tokens/geo — so a slide gets a recognisable London that is on
|
|
9
|
+
the ramp, offline, and identical to the one the product draws.
|
|
10
|
+
|
|
11
|
+
Coordinates in the file are [column, row] indices over a width×height grid, plus the bbox the
|
|
12
|
+
grid was sampled in. That bbox is what makes `highlight` able to take real lng/lat: a point
|
|
13
|
+
bins to a dot with the same equirectangular projection scripts/londonDots.ts used, restated
|
|
14
|
+
in `binOf` below.
|
|
15
|
+
|
|
16
|
+
THREE TIERS, and the split matters at presenting distance:
|
|
17
|
+
land the shape. Quiet, but not so quiet it dissolves on a projector.
|
|
18
|
+
river the Thames, in the palette's own river pair rather than a grey — it is the one
|
|
19
|
+
feature that tells a room which city this is, so it is not a background detail.
|
|
20
|
+
highlight the accent, AND a larger dot. At coarse density a borough is a single cell, and
|
|
21
|
+
a single cell at the land weight is invisible from the back row.
|
|
22
|
+
*/
|
|
23
|
+
import { computed } from 'vue'
|
|
24
|
+
|
|
25
|
+
import coarse from '@humanforest/tokens/geo/london-dots-coarse.json'
|
|
26
|
+
import medium from '@humanforest/tokens/geo/london-dots-medium.json'
|
|
27
|
+
import fine from '@humanforest/tokens/geo/london-dots-fine.json'
|
|
28
|
+
import { highlightedCells } from './dottedMapGeometry.js'
|
|
29
|
+
|
|
30
|
+
const GRIDS = { coarse, medium, fine }
|
|
31
|
+
|
|
32
|
+
const props = defineProps({
|
|
33
|
+
/** coarse 57×44 · medium 75×58 · fine 96×74 (measured from the generated grids, scripts/londonDots.ts — not the round numbers an earlier draft of this doc guessed at). Coarser reads better at presenting distance. */
|
|
34
|
+
density: {
|
|
35
|
+
type: String,
|
|
36
|
+
default: 'coarse',
|
|
37
|
+
validator: (v) => ['coarse', 'medium', 'fine'].includes(v),
|
|
38
|
+
},
|
|
39
|
+
/** Rendered width in canvas px. Height follows from the grid's aspect ratio. */
|
|
40
|
+
width: { type: Number, default: 900 },
|
|
41
|
+
/** How LAND and RIVER cells are drawn: a filled circle or a square. Highlighted cells (see `highlight`) are always drawn as a circle regardless of this prop — the mark is a pin, not a grid cell, and stays a distinct shape from the base grid even when the base grid itself is drawn as squares. */
|
|
42
|
+
shape: { type: String, default: 'dot', validator: (v) => ['dot', 'square'].includes(v) },
|
|
43
|
+
/** Whether to draw the Thames beneath the land dots, in the palette's own river colour. */
|
|
44
|
+
river: { type: Boolean, default: true },
|
|
45
|
+
/**
|
|
46
|
+
* Dots to lift into the accent. Accepts either grid indices ([col, row]) or real coordinates
|
|
47
|
+
* ({ lng, lat }) — mixing the two in one array is fine.
|
|
48
|
+
*/
|
|
49
|
+
highlight: { type: Array, default: () => [] },
|
|
50
|
+
/** Fraction of the dot cell the mark fills. Below ~0.5 the grid reads as texture, above as a field. */
|
|
51
|
+
weight: { type: Number, default: 0.62 },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const grid = computed(() => GRIDS[props.density])
|
|
55
|
+
const height = computed(() => (props.width * grid.value.height) / grid.value.width)
|
|
56
|
+
|
|
57
|
+
/* Binning + validation live in dottedMapGeometry.js (unit-tested there — see that module's own doc
|
|
58
|
+
comment for the two silent failures this fix closes: a bad highlight input producing NaN
|
|
59
|
+
instead of an error, and a correctly-binned RIVER point never drawing because the old `onDots`
|
|
60
|
+
only ever looked in the LAND list). */
|
|
61
|
+
const onCells = computed(() => highlightedCells(props.highlight, grid.value))
|
|
62
|
+
const onKeys = computed(() => new Set(onCells.value.map(([c, r]) => `${c},${r}`)))
|
|
63
|
+
|
|
64
|
+
/* Land and highlights are split into separate lists rather than one list with a class, so the
|
|
65
|
+
highlighted dot can carry its own radius — a class can change colour but not geometry. */
|
|
66
|
+
const isOn = (col, row) => onKeys.value.has(`${col},${row}`)
|
|
67
|
+
const landDots = computed(() => grid.value.dots.filter(([c, r]) => !isOn(c, r)))
|
|
68
|
+
/* Drawn from the validated highlight cells directly, NOT `grid.dots.filter(isOn)` — a highlight
|
|
69
|
+
that bins to a river cell is still real and must still draw; it was never in `grid.dots` (the
|
|
70
|
+
LAND list) to begin with. */
|
|
71
|
+
const onDots = computed(() => onCells.value)
|
|
72
|
+
|
|
73
|
+
const r = computed(() => props.weight / 2)
|
|
74
|
+
/* A highlight fills its cell and a little of its neighbours' — 1.7× the land radius. Enough to
|
|
75
|
+
read as a marker rather than a slightly darker dot, without closing the grid up. */
|
|
76
|
+
const rOn = computed(() => (props.weight / 2) * 1.7)
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<svg
|
|
81
|
+
:viewBox="`0 0 ${grid.width} ${grid.height}`"
|
|
82
|
+
:width="width"
|
|
83
|
+
:height="height"
|
|
84
|
+
class="dotmap"
|
|
85
|
+
role="img"
|
|
86
|
+
aria-label="Map of London"
|
|
87
|
+
>
|
|
88
|
+
<!-- The Thames sits under the land dots: it is the orienting shape, not a data layer. -->
|
|
89
|
+
<g v-if="river" class="dotmap-river">
|
|
90
|
+
<template v-for="([col, row], i) in grid.river" :key="`r${i}`">
|
|
91
|
+
<circle v-if="shape === 'dot'" :cx="col + 0.5" :cy="row + 0.5" :r="r" />
|
|
92
|
+
<rect v-else :x="col + (1 - weight) / 2" :y="row + (1 - weight) / 2" :width="weight" :height="weight" />
|
|
93
|
+
</template>
|
|
94
|
+
</g>
|
|
95
|
+
|
|
96
|
+
<g class="dotmap-land">
|
|
97
|
+
<template v-for="([col, row], i) in landDots" :key="`d${i}`">
|
|
98
|
+
<circle v-if="shape === 'dot'" :cx="col + 0.5" :cy="row + 0.5" :r="r" />
|
|
99
|
+
<rect v-else :x="col + (1 - weight) / 2" :y="row + (1 - weight) / 2" :width="weight" :height="weight" />
|
|
100
|
+
</template>
|
|
101
|
+
</g>
|
|
102
|
+
|
|
103
|
+
<g class="dotmap-on">
|
|
104
|
+
<circle v-for="([col, row], i) in onDots" :key="`h${i}`" :cx="col + 0.5" :cy="row + 0.5" :r="rOn" />
|
|
105
|
+
</g>
|
|
106
|
+
</svg>
|
|
107
|
+
</template>
|
|
108
|
+
|
|
109
|
+
<style scoped>
|
|
110
|
+
.dotmap {
|
|
111
|
+
display: block;
|
|
112
|
+
max-width: 100%;
|
|
113
|
+
height: auto;
|
|
114
|
+
}
|
|
115
|
+
/* Nothing here picks a colour — the three tiers are roles, so the map re-points itself on a
|
|
116
|
+
dark slide along with everything else. */
|
|
117
|
+
.dotmap-land :where(circle, rect) {
|
|
118
|
+
fill: var(--deck-dot-land);
|
|
119
|
+
}
|
|
120
|
+
.dotmap-river :where(circle, rect) {
|
|
121
|
+
fill: var(--deck-dot-river);
|
|
122
|
+
}
|
|
123
|
+
.dotmap-on circle {
|
|
124
|
+
fill: var(--deck-accent);
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A third-party certification mark. One today: Certified B Corporation.
|
|
3
|
+
|
|
4
|
+
★★ NAMED FOR THE CATEGORY, NOT THE MARK, AND NOT ONLY FOR ROOM TO GROW. `BCorp` was the obvious
|
|
5
|
+
name and it is unusable here: `kebab()` turns it into `bcorp` (its regex needs a lowercase before
|
|
6
|
+
an uppercase, and `BC` has none), comark emits `<bcorp>`, and Vue resolves that to `Bcorp` — which
|
|
7
|
+
is not the registered `BCorp`. The tag would have rendered nothing at all, silently. Any component
|
|
8
|
+
whose name opens with two capitals has the same fault; `Endorsement` round-trips.
|
|
9
|
+
|
|
10
|
+
★★ THIS IS NOT OUR IDENTITY, AND THAT IS THE WHOLE CONTRACT. Every other mark this theme draws is
|
|
11
|
+
generated from the tokens primitives — the Forest mark is a path built in packages/tokens, its ink
|
|
12
|
+
is `currentColor`, and it takes whatever ground it lands on. This one is owned by B Lab and
|
|
13
|
+
governed by B Lab's rules: it ships as a fixed vendor SVG, used as supplied, never redrawn and
|
|
14
|
+
never recoloured. apps/docs states the same rule on the endorsements page it publishes.
|
|
15
|
+
|
|
16
|
+
★ SO THERE IS NO COLOUR PROP, AND THERE MUST NOT BE. The mark has exactly two sanctioned
|
|
17
|
+
colourways and the component SWAPS FILES between them — it does not tint one. A `color` or a
|
|
18
|
+
`mask` here would be the tinting the licence forbids, and it is the obvious thing to reach for in
|
|
19
|
+
a theme where everything else follows the ink, which is why the absence is written down.
|
|
20
|
+
|
|
21
|
+
★ Verra is deliberately NOT a value of `mark`. Its artwork needs written approval
|
|
22
|
+
(verra.org/trademarks) and the docs page renders a placeholder rather than the real thing; a
|
|
23
|
+
second value here would be a second place for that to go wrong. The enum is where it joins, once
|
|
24
|
+
it is approved.
|
|
25
|
+
-->
|
|
26
|
+
<script setup>
|
|
27
|
+
import { computed } from 'vue';
|
|
28
|
+
/*
|
|
29
|
+
Both colourways are imported, so the bundler carries two files and the component picks between
|
|
30
|
+
them at render. The alternative — one file plus a filter — is the recolouring the licence forbids.
|
|
31
|
+
*/
|
|
32
|
+
import positiveUrl from '../endorsements/b-corp-black.svg?url';
|
|
33
|
+
import reverseUrl from '../endorsements/b-corp-white.svg?url';
|
|
34
|
+
|
|
35
|
+
const props = defineProps({
|
|
36
|
+
/**
|
|
37
|
+
* Which certification. One value today — the enum exists so Verra can join it when its artwork is
|
|
38
|
+
* approved, rather than the component being renamed at that point.
|
|
39
|
+
*/
|
|
40
|
+
mark: { type: String, default: 'b-corp', validator: (m) => ['b-corp'].includes(m) },
|
|
41
|
+
/**
|
|
42
|
+
* Which sanctioned colourway to draw. `positive` is the black mark, for light grounds; `reverse`
|
|
43
|
+
* is the white one, for dark grounds and photographs. These are the two files B Lab supplies —
|
|
44
|
+
* the prop chooses between them and nothing tints either.
|
|
45
|
+
*/
|
|
46
|
+
tone: { type: String, default: 'positive', validator: (t) => ['positive', 'reverse'].includes(t) },
|
|
47
|
+
/**
|
|
48
|
+
* Drawn height in canvas px. The mark is taller than it is wide (77 × 128 in the vendor file), so
|
|
49
|
+
* height is the honest dimension to size it by and the width follows the aspect.
|
|
50
|
+
*/
|
|
51
|
+
size: { type: Number, default: 96 },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const src = computed(() => (props.tone === 'reverse' ? reverseUrl : positiveUrl));
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<img
|
|
59
|
+
class="deck-endorsement"
|
|
60
|
+
:src="src"
|
|
61
|
+
:style="{ height: `${size}px` }"
|
|
62
|
+
alt="Certified B Corporation"
|
|
63
|
+
>
|
|
64
|
+
<!--
|
|
65
|
+
`alt` names the certification rather than the picture — a reader who cannot see it needs to know
|
|
66
|
+
the deck carries the certification, which is what the mark is there to say.
|
|
67
|
+
-->
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
.deck-endorsement {
|
|
72
|
+
/* Width follows the vendor aspect; nothing else is set, because nothing else may be. */
|
|
73
|
+
width: auto;
|
|
74
|
+
display: block;
|
|
75
|
+
}
|
|
76
|
+
</style>
|