@humanforest/slidev-theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +577 -0
  3. package/assetUrl.js +38 -0
  4. package/components/Alert.vue +209 -0
  5. package/components/AreaBox.vue +152 -0
  6. package/components/AreaChart.vue +145 -0
  7. package/components/Avatar.vue +267 -0
  8. package/components/AvatarGroup.vue +94 -0
  9. package/components/BadgeStrip.vue +203 -0
  10. package/components/Bars.vue +207 -0
  11. package/components/BoroughShape.vue +127 -0
  12. package/components/BrowserFrame.vue +484 -0
  13. package/components/CalendarGrid.vue +480 -0
  14. package/components/Card.vue +70 -0
  15. package/components/Carousel.vue +266 -0
  16. package/components/ChartFrame.vue +426 -0
  17. package/components/ChordChart.vue +264 -0
  18. package/components/ComposedChart.vue +187 -0
  19. package/components/Contents.vue +86 -0
  20. package/components/DataTable.vue +244 -0
  21. package/components/DeckMap.vue +409 -0
  22. package/components/DeviceFrame.vue +331 -0
  23. package/components/DonutChart.vue +103 -0
  24. package/components/DottedMap.vue +126 -0
  25. package/components/Endorsement.vue +76 -0
  26. package/components/Grid.vue +169 -0
  27. package/components/GroupedBarChart.vue +141 -0
  28. package/components/IconTile.vue +116 -0
  29. package/components/Kpi.vue +188 -0
  30. package/components/LineChart.vue +133 -0
  31. package/components/Logo.vue +203 -0
  32. package/components/LondonMap.vue +348 -0
  33. package/components/MarkPair.vue +109 -0
  34. package/components/Marquee.vue +263 -0
  35. package/components/NestedDonutChart.vue +295 -0
  36. package/components/Placeholder.vue +88 -0
  37. package/components/SankeyChart.vue +223 -0
  38. package/components/ScatterChart.vue +142 -0
  39. package/components/StackedBarChart.vue +143 -0
  40. package/components/StatCard.vue +134 -0
  41. package/components/StatusTrack.vue +334 -0
  42. package/components/Timeline.vue +249 -0
  43. package/components/TimelineChart.vue +329 -0
  44. package/components/TreemapChart.vue +267 -0
  45. package/components/backgrounds.js +135 -0
  46. package/components/boroughGeometry.ts +59 -0
  47. package/components/chartData.ts +277 -0
  48. package/components/chartTip.ts +201 -0
  49. package/components/codeTheme.ts +239 -0
  50. package/components/deckPalette.ts +157 -0
  51. package/components/dottedMapGeometry.js +60 -0
  52. package/components/iconSlugs.js +55 -0
  53. package/components/iconUrl.js +51 -0
  54. package/components/mermaidTheme.ts +972 -0
  55. package/components/motionDuration.ts +41 -0
  56. package/components/tableRules.ts +257 -0
  57. package/confidential-mark.vue +151 -0
  58. package/custom-nav-controls.vue +79 -0
  59. package/endorsements/b-corp-black.svg +30 -0
  60. package/endorsements/b-corp-white.svg +30 -0
  61. package/global-top.vue +1662 -0
  62. package/icons/forest/bike-asterisk.svg +21 -0
  63. package/icons/forest/bike-down.svg +19 -0
  64. package/icons/forest/bike-off.svg +20 -0
  65. package/icons/forest/bike-up.svg +19 -0
  66. package/icons/forest/bike-x.svg +19 -0
  67. package/icons/forest/bike.svg +18 -0
  68. package/icons/forest/mark-fill.svg +6 -0
  69. package/icons/forest/mark.svg +6 -0
  70. package/icons/forest/parking.svg +3 -0
  71. package/icons/forest/star-fill.svg +4 -0
  72. package/icons/forest/star.svg +4 -0
  73. package/icons/forest/traffic-light-caution.svg +8 -0
  74. package/icons/forest/traffic-light-go.svg +8 -0
  75. package/icons/forest/traffic-light-lit.svg +8 -0
  76. package/icons/forest/traffic-light-stop.svg +8 -0
  77. package/icons/forest/traffic-light.svg +8 -0
  78. package/icons/lucide/battery.svg +1 -0
  79. package/icons/lucide/bike.svg +1 -0
  80. package/icons/lucide/chart-line.svg +1 -0
  81. package/icons/lucide/check.svg +1 -0
  82. package/icons/lucide/circle-check.svg +1 -0
  83. package/icons/lucide/clock.svg +1 -0
  84. package/icons/lucide/code.svg +1 -0
  85. package/icons/lucide/component.svg +1 -0
  86. package/icons/lucide/frame.svg +1 -0
  87. package/icons/lucide/layout-grid.svg +1 -0
  88. package/icons/lucide/map-pin.svg +1 -0
  89. package/icons/lucide/map.svg +1 -0
  90. package/icons/lucide/palette.svg +1 -0
  91. package/icons/lucide/table.svg +1 -0
  92. package/icons/lucide/triangle-alert.svg +1 -0
  93. package/icons/lucide/type.svg +1 -0
  94. package/icons/lucide/wrench.svg +1 -0
  95. package/layouts/bleed.vue +317 -0
  96. package/layouts/cover.vue +1514 -0
  97. package/layouts/default.vue +187 -0
  98. package/layouts/divider.vue +286 -0
  99. package/layouts/end.vue +384 -0
  100. package/layouts/quote.vue +218 -0
  101. package/layouts/split.vue +1875 -0
  102. package/layouts/stack.vue +944 -0
  103. package/layouts/statement.vue +67 -0
  104. package/manifest/chrome.ts +36 -0
  105. package/manifest/components.ts +49 -0
  106. package/manifest/index.ts +57 -0
  107. package/manifest/layouts.ts +37 -0
  108. package/manifest/manifest.json +6514 -0
  109. package/manifest/presets.ts +635 -0
  110. package/notch.js +458 -0
  111. package/notchPath.js +188 -0
  112. package/package.json +77 -0
  113. package/section.js +110 -0
  114. package/setup/mermaid-renderer.ts +2120 -0
  115. package/setup/mermaid.ts +30 -0
  116. package/setup/shiki.ts +44 -0
  117. package/setup/shortcuts.ts +28 -0
  118. package/slide-bottom.vue +305 -0
  119. package/slide-top.vue +268 -0
  120. package/styles/base.css +2611 -0
  121. package/styles/index.ts +71 -0
  122. package/styles/tokens.css +144 -0
  123. package/uno.config.ts +231 -0
  124. package/useNotchCuts.js +200 -0
@@ -0,0 +1,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>