@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,267 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A person: their face in a ring, their name on a chip riding the ring's bottom edge, and what they
|
|
3
|
+
do under that.
|
|
4
|
+
|
|
5
|
+
★★ ONE COMPONENT FOR ONE PERSON, AND A `::grid` FOR SEVERAL. A team slide is six of these in a
|
|
6
|
+
three-column grid, which is the arrangement Grid already draws and gates — a second component that
|
|
7
|
+
took an ARRAY of people would own a layout the deck has, and would then need its own answers for
|
|
8
|
+
columns, gutter and alignment. The same call MarkPair's note makes about pairs: the layout is not
|
|
9
|
+
the component's business.
|
|
10
|
+
|
|
11
|
+
⚠ THE RING IS THE PAGE'S OWN INK AND THE CHIP IS THE COLOUR, AND THAT DIVISION IS THE DESIGN. A
|
|
12
|
+
photograph on a slide is a rectangle among rectangles; a circle with a heavy edge reads as a
|
|
13
|
+
PERSON at the back of a room, which is why every roster, credit and byline in the world draws one
|
|
14
|
+
— so the RING's whole job is to be an edge, and an edge is not a place to spend the brand. Drawn
|
|
15
|
+
in the green it competed with the name chip sitting on it and the object came out uniformly green,
|
|
16
|
+
which is the same as having no accent at all.
|
|
17
|
+
|
|
18
|
+
`currentColor`, which is `--deck-fg` on an ordinary slide: it turns over with the register the way
|
|
19
|
+
the Forest mark's own ring does, and inside a dark card it takes THAT card's ink rather than the
|
|
20
|
+
page's, which a literal `var(--deck-fg)` would get wrong. The quieter neutrals were tried and are
|
|
21
|
+
not edges at this width — `--deck-border-strong` is a 26% mix with the ground and came out as a
|
|
22
|
+
haze at 7-16px.
|
|
23
|
+
|
|
24
|
+
★ SO THE CHIP CARRIES THE COLOUR ALONE: `--deck-accent` with `--deck-on-accent` as its ink, the
|
|
25
|
+
pair the tokens guarantee is readable. One green on the object, in the one place that is a label
|
|
26
|
+
rather than a frame.
|
|
27
|
+
|
|
28
|
+
★★ EVERY MEASURE IS A RATIO OF THE DIAMETER OR A RUNG BESIDE IT. The ring is the Forest mark's own
|
|
29
|
+
ring ratio; the monogram is a fraction of the circle; the two caption sizes step down the deck's
|
|
30
|
+
type ramp with `size`. So the whole object scales as one thing, and there is no diameter at which
|
|
31
|
+
the name is suddenly too big for the face it names — which is exactly what a fixed caption size
|
|
32
|
+
did at `sm`, where a 48px name overhung a 160px circle on both sides.
|
|
33
|
+
|
|
34
|
+
★ AND IT FALLS BACK TO INITIALS, not to a grey silhouette. A new hire whose photograph has not
|
|
35
|
+
arrived is the commonest case this component meets, and an empty circle in a row of faces reads as
|
|
36
|
+
a loading failure. Initials in the ring read as a person the deck has not photographed yet.
|
|
37
|
+
-->
|
|
38
|
+
<script setup>
|
|
39
|
+
import { computed } from 'vue';
|
|
40
|
+
import { MARK_SIZE, MARK_RING } from '@humanforest/tokens/logo';
|
|
41
|
+
import { BACKGROUND_NAMES, groundStyle } from './backgrounds';
|
|
42
|
+
import { assetUrl } from '../assetUrl.js';
|
|
43
|
+
|
|
44
|
+
const props = defineProps({
|
|
45
|
+
/** Their photograph. Square is what the circle wants; anything else is covered and centred. */
|
|
46
|
+
image: { type: String, default: '' },
|
|
47
|
+
/** The person. Also the alt text, and the source of the initials when there is no photograph. */
|
|
48
|
+
name: { type: String, default: '' },
|
|
49
|
+
/** What they do — a job title, a team, a one-line credit. Optional: a face and a name is a valid slide. */
|
|
50
|
+
role: { type: String, default: '' },
|
|
51
|
+
/**
|
|
52
|
+
* How big the person is drawn — the diameter AND the two caption sizes, which step together.
|
|
53
|
+
*
|
|
54
|
+
* · `sm` 160px — a row of six or more, where the grid is the picture
|
|
55
|
+
* · `md` 240px — the default, three or four across
|
|
56
|
+
* · `lg` 360px — one person given the slide: a new hire, a speaker, a quote's author
|
|
57
|
+
*/
|
|
58
|
+
size: { type: String, default: 'md', validator: (s) => ['sm', 'md', 'lg'].includes(s) },
|
|
59
|
+
/**
|
|
60
|
+
* Which named ground the INITIALS sit on, when there is no photograph — the generator's four
|
|
61
|
+
* families, read as register-following tokens so the disc turns over with the ring around it.
|
|
62
|
+
*
|
|
63
|
+
* It is a real choice rather than one fixed fill because a row of new starters is the case this
|
|
64
|
+
* state exists for, and four identical discs beside each other read as one repeated placeholder
|
|
65
|
+
* where four grounds read as four people. Ignored entirely when `image` is set: a photograph
|
|
66
|
+
* paints its own circle.
|
|
67
|
+
*
|
|
68
|
+
* `vivid` by default — forest-300 on light, forest-700 on dark — because it is the one family
|
|
69
|
+
* that is a step off the ground in BOTH registers. `accent` is forest-950 on dark, which is also
|
|
70
|
+
* that register's own slide ground, so a disc drawn on it disappears and leaves the initials
|
|
71
|
+
* floating inside an empty ring.
|
|
72
|
+
*/
|
|
73
|
+
background: {
|
|
74
|
+
type: String,
|
|
75
|
+
default: 'vivid',
|
|
76
|
+
// Imported, not inlined — a module-level import survives defineProps() being hoisted out of
|
|
77
|
+
// setup() at build time, and it is the generator's own list. See backgrounds.js.
|
|
78
|
+
validator: (b) => BACKGROUND_NAMES.includes(b),
|
|
79
|
+
},
|
|
80
|
+
/**
|
|
81
|
+
* Alt text, when the person's name is not what a screen reader should hear — a specimen drawing
|
|
82
|
+
* eight strangers, say. Defaults to the name, which is what a real roster wants.
|
|
83
|
+
*/
|
|
84
|
+
alt: { type: String, default: '' },
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
★★ THREE RUNGS OF THE DECK'S OWN RAMP, NOT THREE MULTIPLIERS. `--type-deck-text-*` is the
|
|
89
|
+
generated ramp every other size in this theme comes off; picking `calc(diameter * 0.15)` instead
|
|
90
|
+
would scale smoothly and land between rungs at every step, which is the one thing a deck must not
|
|
91
|
+
do — packages/tokens/type/type.deck.css exists so a deck cannot express an off-system size.
|
|
92
|
+
|
|
93
|
+
The role sits one rung below the name at each diameter, so the two keep the same relationship as
|
|
94
|
+
the circle grows rather than converging on it. The space BETWEEN them steps too, off `--deck-space-*`
|
|
95
|
+
— held at one value it read as generous under a 30px name and cramped under a 48px one, which is
|
|
96
|
+
the same defect as a fixed caption size and has the same cure.
|
|
97
|
+
*/
|
|
98
|
+
const SIZES = {
|
|
99
|
+
sm: { px: 160, name: '3xl', role: '2xl', gap: 1 },
|
|
100
|
+
md: { px: 240, name: '4xl', role: '3xl', gap: 2 },
|
|
101
|
+
lg: { px: 360, name: '5xl', role: '4xl', gap: 3 },
|
|
102
|
+
}
|
|
103
|
+
/* Looked up rather than interpolated: a validator is stripped in production, so an invented size
|
|
104
|
+
would otherwise resolve `undefined` px and collapse the circle. */
|
|
105
|
+
const rung = computed(() => SIZES[props.size] ?? SIZES.md)
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
★★ THE RING'S WIDTH IS THE MARK'S OWN RATIO, not a number picked here. logo.ts publishes the ring
|
|
109
|
+
as 19 units of a 434.82 tile — 4.37% — and MarkPair already scales a partner's tile by it, so an
|
|
110
|
+
avatar drawn beside a lockup carries the same weight of edge at the same size.
|
|
111
|
+
*/
|
|
112
|
+
const style = computed(() => ({
|
|
113
|
+
'--avatar-size': `${rung.value.px}px`,
|
|
114
|
+
'--avatar-ring': `${Math.round(rung.value.px * (MARK_RING / MARK_SIZE))}px`,
|
|
115
|
+
'--avatar-name-size': `var(--type-deck-text-${rung.value.name})`,
|
|
116
|
+
'--avatar-role-size': `var(--type-deck-text-${rung.value.role})`,
|
|
117
|
+
'--avatar-caption-gap': `var(--deck-space-${rung.value.gap})`,
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
★ FIRST LETTERS OF THE FIRST TWO WORDS, upper-cased, and never more than two. "Olivia Rhye" is OR;
|
|
122
|
+
a one-word name is one letter. Three initials in a circle at 160px is a texture rather than a
|
|
123
|
+
reading, and a name with five words (a title, a middle name, a suffix) would produce one.
|
|
124
|
+
*/
|
|
125
|
+
const initials = computed(() =>
|
|
126
|
+
props.name
|
|
127
|
+
.split(/\s+/)
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.slice(0, 2)
|
|
130
|
+
.map((word) => word[0].toUpperCase())
|
|
131
|
+
.join(''));
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<template>
|
|
135
|
+
<figure class="avatar" :style="style">
|
|
136
|
+
<!-- `decoding="async"` for the reason every many-picture slot in this theme carries it: a slide
|
|
137
|
+
holding six portraits should not block its own first paint on decoding all six. -->
|
|
138
|
+
<img
|
|
139
|
+
v-if="image"
|
|
140
|
+
class="avatar-face"
|
|
141
|
+
:src="assetUrl(image)"
|
|
142
|
+
:alt="alt || name || 'A person'"
|
|
143
|
+
decoding="async"
|
|
144
|
+
>
|
|
145
|
+
<!-- aria-hidden: the initials are a stand-in for the photograph, and the name is already read
|
|
146
|
+
out by the caption below — announcing "OR" before it would be the same person twice. With
|
|
147
|
+
neither a photograph nor a name the circle is drawn empty, which is a ringed disc rather
|
|
148
|
+
than a broken image, and is the only honest thing left to show. -->
|
|
149
|
+
<span v-else class="avatar-face is-initials" :style="groundStyle(background)" aria-hidden="true">{{ initials }}</span>
|
|
150
|
+
<figcaption v-if="name || role" class="avatar-caption">
|
|
151
|
+
<span v-if="name" class="avatar-name">{{ name }}</span>
|
|
152
|
+
<span v-if="role" class="avatar-role">{{ role }}</span>
|
|
153
|
+
</figcaption>
|
|
154
|
+
</figure>
|
|
155
|
+
</template>
|
|
156
|
+
|
|
157
|
+
<style scoped>
|
|
158
|
+
.avatar {
|
|
159
|
+
margin: 0;
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-direction: column;
|
|
162
|
+
align-items: center;
|
|
163
|
+
text-align: center;
|
|
164
|
+
}
|
|
165
|
+
/*
|
|
166
|
+
⚠ `flex: none`, BECAUSE A GRID CELL WILL SQUASH IT. The circle is sized in px and a flex or grid
|
|
167
|
+
parent is free to shrink a child below its own size — measured on a six-up row before this line,
|
|
168
|
+
where the faces came out as ellipses 12px narrower than they were tall.
|
|
169
|
+
*/
|
|
170
|
+
.avatar-face {
|
|
171
|
+
flex: none;
|
|
172
|
+
width: var(--avatar-size);
|
|
173
|
+
height: var(--avatar-size);
|
|
174
|
+
border-radius: var(--deck-radius-full);
|
|
175
|
+
object-fit: cover;
|
|
176
|
+
object-position: center;
|
|
177
|
+
display: block;
|
|
178
|
+
/*
|
|
179
|
+
★ AN OUTLINE PULLED INWARD, NOT A BORDER. A border adds to the box and rounds on its own
|
|
180
|
+
fractional seam, which left a hairline of the slide ground between the ring and the photograph
|
|
181
|
+
at some diameters — the same defect, and the same cure, as MarkPair's partner tile.
|
|
182
|
+
*/
|
|
183
|
+
outline: var(--avatar-ring) solid currentColor;
|
|
184
|
+
outline-offset: calc(-1 * var(--avatar-ring));
|
|
185
|
+
/* A ground behind the picture, so a portrait with a transparent or pale background still has
|
|
186
|
+
something inside the ring rather than the slide showing through it. */
|
|
187
|
+
background: var(--deck-surface);
|
|
188
|
+
}
|
|
189
|
+
/*
|
|
190
|
+
The stand-in. Mohr, uppercase — the deck's display voice, which is what a monogram is: a mark made
|
|
191
|
+
of letters rather than a line of reading.
|
|
192
|
+
*/
|
|
193
|
+
.avatar-face.is-initials {
|
|
194
|
+
display: flex;
|
|
195
|
+
align-items: center;
|
|
196
|
+
justify-content: center;
|
|
197
|
+
font-family: var(--type-deck-display-family);
|
|
198
|
+
font-weight: var(--type-deck-display-weight);
|
|
199
|
+
/* 0.34 of the circle: measured against the photographs beside it, the pair reads as one row when
|
|
200
|
+
the monogram's cap height is about a third of the face. */
|
|
201
|
+
font-size: calc(var(--avatar-size) * 0.34);
|
|
202
|
+
line-height: 1;
|
|
203
|
+
/*
|
|
204
|
+
⚠ THE FILL AND ITS INK ARE ONE DECISION AND ARRIVE TOGETHER, from `groundStyle(background)` in
|
|
205
|
+
the template. Never `--deck-fg` over a named fill: of the combinations this palette could make,
|
|
206
|
+
most fail body-text contrast, which is why a background is picked BY NAME and the pair is the
|
|
207
|
+
unit. The same reason BadgeStrip and IconTile route through that module rather than
|
|
208
|
+
interpolating a token name of their own.
|
|
209
|
+
*/
|
|
210
|
+
}
|
|
211
|
+
.avatar-caption {
|
|
212
|
+
display: flex;
|
|
213
|
+
flex-direction: column;
|
|
214
|
+
align-items: center;
|
|
215
|
+
gap: var(--avatar-caption-gap);
|
|
216
|
+
/* No width cap here — the chip below sets its own, and a cap on the column would clip the one
|
|
217
|
+
child allowed to be wider than the face. */
|
|
218
|
+
}
|
|
219
|
+
/*
|
|
220
|
+
★★ THE NAME IS A CHIP RIDING THE CIRCLE'S BOTTOM EDGE, in the same green as the ring. Set as a
|
|
221
|
+
plain line under the face it read as a caption BESIDE a photograph — two objects, and in a grid of
|
|
222
|
+
four the eye had to pair each name with the circle above it. Overlapping the edge makes the pair
|
|
223
|
+
one object, and puts the name on the line a reader's eye is already on when it leaves the face.
|
|
224
|
+
|
|
225
|
+
★ THE CHIP SITS INSIDE THE CIRCLE, NOT ON ITS EDGE. The padding and line-height are stated rather
|
|
226
|
+
than inherited because together they make the chip exactly 1.7em tall at every diameter, so the
|
|
227
|
+
pull can be a fraction of it: -1.7em puts the chip's whole height above the circle's bottom, with
|
|
228
|
+
its baseline on the edge. Half — the obvious number — sat the pill ON the ring and the two still
|
|
229
|
+
read as a face with a label under it; the deeper it sits inside, the more the pair reads as one
|
|
230
|
+
object. The em scales with `size`, so the same fraction overlaps at all three diameters — a ratio
|
|
231
|
+
of the DIAMETER would only be right at one of them.
|
|
232
|
+
*/
|
|
233
|
+
.avatar-name {
|
|
234
|
+
font-size: var(--avatar-name-size);
|
|
235
|
+
/* One line, always: the chip is pulled up by half its own height, and a wrapped name would put
|
|
236
|
+
the second line where the pull assumes there is none. A name longer than the circle makes the
|
|
237
|
+
pill wider than the face, which reads as a label on it rather than as a broken caption. */
|
|
238
|
+
white-space: nowrap;
|
|
239
|
+
line-height: 1.2;
|
|
240
|
+
padding: 0.25em 0.9em;
|
|
241
|
+
font-weight: var(--type-deck-headline-weight);
|
|
242
|
+
/* The label rung's own tracking. A short bold line in a filled pill closes up without it, and
|
|
243
|
+
every other small bold label in this theme carries the same 0.01em. */
|
|
244
|
+
letter-spacing: var(--type-deck-label-tracking);
|
|
245
|
+
color: var(--deck-on-accent);
|
|
246
|
+
background: var(--deck-accent);
|
|
247
|
+
border-radius: var(--deck-radius-full);
|
|
248
|
+
margin-top: -1.7em;
|
|
249
|
+
/* Above the photograph in the stacking order without a z-index: `position` is what puts it in
|
|
250
|
+
one, and both are needed — a static sibling paints under an image that has its own background. */
|
|
251
|
+
position: relative;
|
|
252
|
+
}
|
|
253
|
+
/* The quiet tier, because a title is what the name is qualified BY — the same relationship the
|
|
254
|
+
eyebrow has to a heading, and the same ink. */
|
|
255
|
+
.avatar-role {
|
|
256
|
+
font-size: var(--avatar-role-size);
|
|
257
|
+
line-height: var(--type-deck-caption-line-height);
|
|
258
|
+
color: var(--deck-fg-muted);
|
|
259
|
+
/* Never wider than the face and a bit: a long job title that ran past the circle would drag the
|
|
260
|
+
columns of a team grid apart. It wraps, and an unbreakable word breaks rather than overflowing. */
|
|
261
|
+
max-width: calc(var(--avatar-size) * 1.35);
|
|
262
|
+
overflow-wrap: anywhere;
|
|
263
|
+
/* A two-line title breaks into two lines of similar length rather than a long one and an orphan,
|
|
264
|
+
which is what a centred caption under a circle shows up immediately. */
|
|
265
|
+
text-wrap: balance;
|
|
266
|
+
}
|
|
267
|
+
</style>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A row of faces overlapping into one object — how many people, rather than which ones.
|
|
3
|
+
|
|
4
|
+
★★ IT DRAWS NO NAMES, AND THAT IS THE WHOLE DISTINCTION FROM A `::grid` OF `::avatar`. A team grid
|
|
5
|
+
introduces people: every face is captioned, and the slide is a list of individuals. This is the
|
|
6
|
+
other slide — "twenty-four of us", "everyone who touched it", the people beside a number — where
|
|
7
|
+
the count is the message and a caption under each face would be an invitation to read a roster
|
|
8
|
+
nobody is going to read from the back of a room. Overlapping them says so before a word is read:
|
|
9
|
+
a row of separate circles is a list, and a row that touches is a group.
|
|
10
|
+
|
|
11
|
+
★ SO IT COMPOSES `::avatar` RATHER THAN REDRAWING ONE. The circle, the ring, its ratio to the
|
|
12
|
+
diameter and the three diameters themselves are Avatar's, passed straight through; an Avatar with
|
|
13
|
+
no `name` and no `role` renders the face alone, which is exactly the piece this needs. What lives
|
|
14
|
+
here is only the arrangement — the negative gap and the stacking order — the same division
|
|
15
|
+
MarkPair's note draws between a component and the layout it sits in.
|
|
16
|
+
-->
|
|
17
|
+
<script setup>
|
|
18
|
+
import { computed } from 'vue';
|
|
19
|
+
|
|
20
|
+
const props = defineProps({
|
|
21
|
+
/**
|
|
22
|
+
* The people, in the order they should be read: `{ image, name }` each. `name` never appears on
|
|
23
|
+
* the slide — it is the alt text, so a screen reader gets a roster the room does not need.
|
|
24
|
+
*/
|
|
25
|
+
people: { type: Array, default: () => [] },
|
|
26
|
+
/**
|
|
27
|
+
* The diameter, passed to each `::avatar`. Defaults to `sm` rather than Avatar's own `md`: this
|
|
28
|
+
* component exists to fit many faces in a row, and `sm` is the rung that does it.
|
|
29
|
+
*/
|
|
30
|
+
size: { type: String, default: 'sm', validator: (s) => ['sm', 'md', 'lg'].includes(s) },
|
|
31
|
+
/**
|
|
32
|
+
* How far each face sits over the one before it, as a fraction of the diameter.
|
|
33
|
+
*
|
|
34
|
+
* · `snug` 0.2 — a long row: twelve faces in the width of nine
|
|
35
|
+
* · `base` 0.3 — the default, a clear group that still reads face by face
|
|
36
|
+
* · `tight` 0.45 — a crowd, where the row is a texture and the count is the only reading
|
|
37
|
+
*/
|
|
38
|
+
overlap: { type: String, default: 'base', validator: (o) => ['snug', 'base', 'tight'].includes(o) },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const OVERLAPS = { snug: 0.2, base: 0.3, tight: 0.45 }
|
|
42
|
+
/* Looked up, not interpolated — a validator is stripped in production and an invented value would
|
|
43
|
+
otherwise resolve `undefined` and lay the faces out with no overlap at all. */
|
|
44
|
+
const step = computed(() => OVERLAPS[props.overlap] ?? OVERLAPS.base)
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
★★ THE FIRST FACE IS ON TOP, AND EVERY ROW OF OVERLAPPING DISCS IN THE WORLD IS DRAWN THIS WAY —
|
|
48
|
+
a hand of cards, a stack of coins, a facepile. Reading order is left to right, so the leftmost
|
|
49
|
+
disc being the nearest is what makes the row read as a sequence laid down rather than as a queue
|
|
50
|
+
seen from behind. Descending z-index, which needs one number per item and so cannot be a class.
|
|
51
|
+
*/
|
|
52
|
+
const stacked = computed(() =>
|
|
53
|
+
props.people.map((person, i) => ({ ...person, z: props.people.length - i })));
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div class="avatar-group" :style="{ '--group-step': step }">
|
|
58
|
+
<Avatar
|
|
59
|
+
v-for="(person, i) of stacked"
|
|
60
|
+
:key="`${person.image}-${i}`"
|
|
61
|
+
class="avatar-group-item"
|
|
62
|
+
:style="{ zIndex: person.z }"
|
|
63
|
+
:image="person.image"
|
|
64
|
+
:alt="person.name"
|
|
65
|
+
:size="size"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
.avatar-group {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
/* A row that runs past its container wraps rather than overflowing the slide; the negative margin
|
|
75
|
+
below is horizontal only, so a second line sits clear of the first. */
|
|
76
|
+
flex-wrap: wrap;
|
|
77
|
+
}
|
|
78
|
+
/*
|
|
79
|
+
⚠ `> *` REACHES THE CHILD COMPONENT'S ROOT, AND ONLY ITS ROOT. Vue's scoped CSS stamps this
|
|
80
|
+
component's scope id onto a child component's outermost element as well as its own, which is what
|
|
81
|
+
lets the arrangement live here while the circle stays Avatar's. Nothing deeper is reachable, and
|
|
82
|
+
nothing deeper should be — a parent reaching into `.avatar-face` would be this component owning
|
|
83
|
+
half of another one.
|
|
84
|
+
*/
|
|
85
|
+
.avatar-group > * + * {
|
|
86
|
+
/* The overlap. `--avatar-size` is set by Avatar itself on the same element, so the step is a
|
|
87
|
+
fraction of the diameter that element actually drew rather than one restated here. */
|
|
88
|
+
margin-left: calc(var(--avatar-size) * var(--group-step) * -1);
|
|
89
|
+
}
|
|
90
|
+
.avatar-group > * {
|
|
91
|
+
/* z-index does nothing without it; the inline order would otherwise put the LAST face on top. */
|
|
92
|
+
position: relative;
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/*
|
|
3
|
+
An array-driven row of dark tiles — the deck's tag list. `items` is the array-driven path Grid's
|
|
4
|
+
own `items` prop also documents: Slidev binds YAML arrays straight to props, so a strip of
|
|
5
|
+
badges is data, not four hand-written <span> tags that drift out of sync with the list it names.
|
|
6
|
+
Unlike Grid's `items`, this one is a PLAIN STRING per tile, not an object — a badge has nothing
|
|
7
|
+
to render beyond its own label, so there's no per-item slot to hand a shape to. Passing objects
|
|
8
|
+
(`:items="[{label:'Cargo'}]"`) renders "[object Object]" at the tile's correct size — the
|
|
9
|
+
template below falls back to a `label` field if it finds one, so that mistake stays readable
|
|
10
|
+
rather than silently wrong.
|
|
11
|
+
|
|
12
|
+
Colour is the same PAIR mechanism as Chip: a badge picks a chip by `background`, never a fill on its
|
|
13
|
+
own, so it can't land on one of the unreadable fill/ink combinations the palette actually has.
|
|
14
|
+
*/
|
|
15
|
+
import { backgroundStyle, outlineStyle, chromePillStyle, BACKGROUND_VALUES } from './backgrounds';
|
|
16
|
+
|
|
17
|
+
defineProps({
|
|
18
|
+
/** The badge labels, in row order — plain strings, not objects. */
|
|
19
|
+
items: { type: Array, required: true },
|
|
20
|
+
/**
|
|
21
|
+
* Which named background every tile paints — the family, and for `solid` its half.
|
|
22
|
+
*
|
|
23
|
+
* `solid` takes a PINNED value, `<name>-<half>`: warm, accent, vivid or neutral, pinned to the
|
|
24
|
+
* light or dark column of the generator's table. A solid badge is an OBJECT sitting on the deck
|
|
25
|
+
* and paints its own ground, so it reads the same on either register.
|
|
26
|
+
*
|
|
27
|
+
* `outline` takes the BARE name, because it borrows the slide's ground and so follows the
|
|
28
|
+
* register — `--deck-ink-<name>` has no half for an author to choose between. Passing a pinned
|
|
29
|
+
* value there discards the half rather than erroring; passing a bare name to `solid` takes that
|
|
30
|
+
* family's dark half.
|
|
31
|
+
*
|
|
32
|
+
* `accent-dark` (near-black green) is the default — a dark strip, as the reference deck draws it.
|
|
33
|
+
*/
|
|
34
|
+
background: {
|
|
35
|
+
type: String,
|
|
36
|
+
default: 'accent-dark',
|
|
37
|
+
// Imported, not inlined — BACKGROUND_VALUES is a module-level import, so it survives defineProps() being
|
|
38
|
+
// hoisted out of setup() at build time. See backgrounds.js.
|
|
39
|
+
validator: (t) => BACKGROUND_VALUES.includes(t),
|
|
40
|
+
},
|
|
41
|
+
/**
|
|
42
|
+
* Paint from the running chrome's own register-aware role instead of a chip tone.
|
|
43
|
+
*
|
|
44
|
+
* ★★ THE CHROME IS NOT A CHIP. Chips are register-INVARIANT by design — a saturated object on the
|
|
45
|
+
* deck, reading the same on either ground — and the running pills are the opposite case: they land
|
|
46
|
+
* on whatever ground the SLIDE gives them. Pinned to forest-700 they failed the 3:1 non-text floor
|
|
47
|
+
* on every dark panel ground (bark 1.09:1, forest-900 1.77:1, neutral-800 2.33:1). This opts a
|
|
48
|
+
* strip into the accent pair, which moves with the register; `background` is then ignored.
|
|
49
|
+
*/
|
|
50
|
+
chrome: { type: Boolean, default: false },
|
|
51
|
+
/**
|
|
52
|
+
* How the tile is painted. `solid` fills with the tone's pair; `outline` draws the same tone as a
|
|
53
|
+
* stroke and a label on the slide's own ground, filling nothing.
|
|
54
|
+
*
|
|
55
|
+
* Both take the SAME background, the same uppercase overline, the same size and the same pill — the only
|
|
56
|
+
* difference is whether the colour is the ground or the ink. That is the point of the pair: a strip
|
|
57
|
+
* can be quietened without becoming a different kind of object.
|
|
58
|
+
*
|
|
59
|
+
* ★★ THEY READ THE TONE FROM OPPOSITE ENDS, and that asymmetry is not a defect. `solid` paints its
|
|
60
|
+
* own ground, so it takes a PINNED half (`accent-dark`) and reads the same on either register.
|
|
61
|
+
* `outline` borrows the SLIDE's ground, so it takes the register-following `--deck-ink-<name>` and
|
|
62
|
+
* the half in the tone name is discarded. Stroking a pinned half instead would put `accent-dark` —
|
|
63
|
+
* forest-950, which IS the dark register's ground — at 1.00:1 on every dark slide.
|
|
64
|
+
*
|
|
65
|
+
* ★ AN EARLIER `outline` WAS NOT A VARIANT OF THIS AT ALL. It painted --deck-fg-subtle rather than
|
|
66
|
+
* a background, dropped the uppercase and the tracking, ran at 0.6 of the overline size, and carried its
|
|
67
|
+
* own exemption from the contrast floor. It had one caller, the standing confidentiality marking,
|
|
68
|
+
* which is now ConfidentialMark.vue.
|
|
69
|
+
*/
|
|
70
|
+
variant: { type: String, default: 'solid', validator: (v) => ['solid', 'outline'].includes(v) },
|
|
71
|
+
/**
|
|
72
|
+
* An icon class for every item in the strip — a UnoCSS icon, e.g. 'i-lucide-eye-off'. Applies to
|
|
73
|
+
* EVERY item, because this component renders a row of peers and singling one out would need a
|
|
74
|
+
* per-item shape `items` does not have; in practice the only caller that wants one passes a
|
|
75
|
+
* single item. Empty by default, so a strip stays text-only unless asked.
|
|
76
|
+
*/
|
|
77
|
+
icon: { type: String, default: '' },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** A stray object item (the Grid `items` shape, passed here by mistake) still reads as text. */
|
|
81
|
+
const label = (item) => (typeof item === 'string' ? item : String(item?.label ?? item));
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<template>
|
|
85
|
+
<div class="badge-strip">
|
|
86
|
+
<span
|
|
87
|
+
v-for="(item, index) in items"
|
|
88
|
+
:key="index"
|
|
89
|
+
class="badge-strip-item"
|
|
90
|
+
:class="{ 'is-outline': variant === 'outline' && !chrome }"
|
|
91
|
+
:style="chrome ? chromePillStyle() : variant === 'outline' ? outlineStyle(background) : backgroundStyle(background)"
|
|
92
|
+
><span v-if="icon" class="badge-strip-icon" :class="icon" aria-hidden="true" />{{ label(item) }}</span>
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<style scoped>
|
|
97
|
+
.badge-strip {
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-wrap: wrap;
|
|
100
|
+
gap: var(--deck-space-2);
|
|
101
|
+
}
|
|
102
|
+
/*
|
|
103
|
+
★★ `display: block`, NOT `inline-flex` — this is what makes the ellipsis below actually render.
|
|
104
|
+
|
|
105
|
+
`text-overflow` applies to a BLOCK CONTAINER's inline content. This element is a flex item of
|
|
106
|
+
`.badge-strip`, so `inline-flex` was blockified to `flex` — a flex container has no inline
|
|
107
|
+
formatting context of its own, `text-overflow` had nothing to act on, and the declaration was
|
|
108
|
+
inert. Shipped, 17 pills across 10 slides were cut MID-GLYPH with no "…" — the exact failure the
|
|
109
|
+
cap below exists to prevent, reproduced one box further in. Proved by mutation on the live page:
|
|
110
|
+
the same element with the same text renders "DECK-D…" as a block and "DECK-DAR|K" (the K sliced
|
|
111
|
+
vertically) as a flex container.
|
|
112
|
+
|
|
113
|
+
It also fixes the SECOND, different mechanism — a pill flex-SHRUNK below its own content width
|
|
114
|
+
(deck-showcase 78, where the 20em cap is not involved at all). `overflow: hidden` zeroes a flex
|
|
115
|
+
item's automatic minimum size, so a crowded row squeezes a pill regardless of the cap; as a block
|
|
116
|
+
container the squeezed pill now ellipsises instead of clipping.
|
|
117
|
+
|
|
118
|
+
Vertical centring does not need flex here: the box is one line of text with symmetric padding, so
|
|
119
|
+
the line box IS the centred content. `align-items` was doing nothing a line box was not already
|
|
120
|
+
doing.
|
|
121
|
+
*/
|
|
122
|
+
/*
|
|
123
|
+
★★ AND `flex: none`, WHICH IS WHAT MAKES `flex-wrap: wrap` ABOVE MEAN ANYTHING.
|
|
124
|
+
|
|
125
|
+
A flex item's automatic minimum size is normally its min-content width — for `white-space: nowrap`
|
|
126
|
+
text, the whole label — which is what would push a row that does not fit onto a second line. But
|
|
127
|
+
`overflow: hidden` (three lines down, and load-bearing for the ellipsis) sets that automatic
|
|
128
|
+
minimum to ZERO, so the flex algorithm preferred to SQUEEZE every badge onto one line rather than
|
|
129
|
+
wrap. Measured on deck-showcase 169 — a slide titled "Every feature, in one row" — all eight
|
|
130
|
+
badges were shrunk below their own content and cut, between 14px and 134px each, with not one
|
|
131
|
+
feature legible in full. The 20em cap was not even involved.
|
|
132
|
+
|
|
133
|
+
`flex: none` restores the intent: a badge is as wide as its label (up to the cap), and a row that
|
|
134
|
+
does not fit wraps, which is exactly what `flex-wrap: wrap` on the container already said. The cap
|
|
135
|
+
and the ellipsis stay for the other case — ONE label longer than the cap, which no amount of
|
|
136
|
+
wrapping can fix.
|
|
137
|
+
*/
|
|
138
|
+
.badge-strip-item {
|
|
139
|
+
display: block;
|
|
140
|
+
flex: none;
|
|
141
|
+
padding: var(--deck-space-1) var(--deck-space-3);
|
|
142
|
+
border-radius: var(--deck-radius-full);
|
|
143
|
+
font-family: var(--type-deck-overline-family);
|
|
144
|
+
font-weight: var(--type-deck-overline-weight);
|
|
145
|
+
font-size: var(--type-deck-overline-size);
|
|
146
|
+
line-height: var(--type-deck-overline-line-height);
|
|
147
|
+
letter-spacing: var(--type-deck-overline-tracking);
|
|
148
|
+
text-transform: uppercase;
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
/* FIX ROUND 1, IMPORTANT 4 — every label this component renders is caller-supplied text, not a
|
|
151
|
+
closed vocabulary this file controls: slide-top.vue's section pill is a divider's extracted
|
|
152
|
+
TITLE or an author's `section:` override, slide-bottom.vue's confidentiality pill is a bare
|
|
153
|
+
`confidential:` string, and Task 8 generates 200 slides' worth of both. `white-space: nowrap`
|
|
154
|
+
without a cap just keeps growing — measured live, a 22-character label already runs 488px, so
|
|
155
|
+
a realistic ~90-character string (the review's own example) clears the 1920px canvas outright,
|
|
156
|
+
and does so INVISIBLY: `.slidev-slide-content` is `overflow: hidden`, so the overrun is
|
|
157
|
+
silently clipped with no ellipsis, not a visible spill anyone would notice. `max-width` here
|
|
158
|
+
is in `em`, the same unit `.deck-chip` already caps its own prose at (22em) — a length, not a
|
|
159
|
+
token, matching this file's own "prose is measured in em, not a fixed px" precedent (see
|
|
160
|
+
README's "Measure is set in em, never ch") — and `overflow`/`text-overflow` turn a cap into a
|
|
161
|
+
visible "…" instead of a silent cut, which is the actual point: a truncated pill still reads
|
|
162
|
+
as "there was more here", where a clipped one reads as complete and wrong. */
|
|
163
|
+
max-width: 20em;
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
text-overflow: ellipsis;
|
|
166
|
+
}
|
|
167
|
+
/*
|
|
168
|
+
The stroke is `currentColor`, so it is the label's own colour by construction and the two cannot be
|
|
169
|
+
set apart.
|
|
170
|
+
|
|
171
|
+
⚠ A PLAIN 1px, NOT DIVIDED BY `--slidev-slide-scale`. It was written that way first, reasoning that
|
|
172
|
+
a 1px border on a canvas drawn at 0.58 is a sub-pixel line — but that argument applies to EVERY
|
|
173
|
+
border in the theme, and none of the others do it (BrowserFrame, Carousel, the code frames and the
|
|
174
|
+
card rules are all plain `1px`). The variable exists for the opposite case: base.css uses it only
|
|
175
|
+
under `body > [class*='-tooltip']`, elements Unovis renders OUTSIDE the scaled canvas, which have
|
|
176
|
+
to borrow the factor because they never had it. A badge is inside the canvas and already carries
|
|
177
|
+
it, so dividing again yields a stroke pinned to one DEVICE pixel — visibly finer than the card it
|
|
178
|
+
sits next to at any zoom.
|
|
179
|
+
|
|
180
|
+
The padding is unchanged from `solid`. A border adds 2px to the box, which is a real difference in a
|
|
181
|
+
row that mixes the two, and taking it out of the padding to compensate would make the outline's TEXT
|
|
182
|
+
sit 1px in from where the solid's does — a worse mismatch than the one it fixes, and visible where
|
|
183
|
+
the two stack.
|
|
184
|
+
*/
|
|
185
|
+
.badge-strip-item.is-outline {
|
|
186
|
+
background: none;
|
|
187
|
+
border: 1px solid currentColor;
|
|
188
|
+
}
|
|
189
|
+
/*
|
|
190
|
+
The icon rides INSIDE the pill's own line box, sized in em so it tracks whatever type role the
|
|
191
|
+
variant sets rather than needing its own number. aria-hidden: it repeats what the label already
|
|
192
|
+
says, and a screen reader announcing "eye-off Private & Confidential" is worse than the label
|
|
193
|
+
alone. Gap in em for the same reason as the size.
|
|
194
|
+
*/
|
|
195
|
+
.badge-strip-icon {
|
|
196
|
+
display: inline-block;
|
|
197
|
+
width: 1em;
|
|
198
|
+
height: 1em;
|
|
199
|
+
margin-right: 0.4em;
|
|
200
|
+
vertical-align: -0.12em; /* optical centring against the cap height, not the baseline */
|
|
201
|
+
flex: none;
|
|
202
|
+
}
|
|
203
|
+
</style>
|