@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,384 @@
1
+ <script setup>
2
+ /*
3
+ The closer. Four shapes, the same question the opener answers in reverse: what is the last thing
4
+ on the screen while the room is still looking at it.
5
+
6
+ ★★ THE VARIANTS ARE COMPOSITIONS, NOT COLOURS — the same split cover.vue documents at length. A
7
+ ground is `background` plus the register, and every shape here takes both, so the two props stay
8
+ orthogonal and the names stay about arrangement.
9
+
10
+ ⚠ AND NO PILL EITHER. `cover` takes a `badge` — a strapline under the title, where a title is
11
+ already the loud thing and a pill beside it is a second, quieter line. A closer has no such
12
+ hierarchy: on `signoff` the pill lands between the two words and reads as a third voice, and on
13
+ `wordmark` it turns the identity into a lockup with a caption. Jose, twice, on two different
14
+ variants: "remove this". So there is no `badge` prop here.
15
+
16
+ ⚠ AND THE CLOSER CARRIES NO CERTIFICATION. `cover` does, on its brand variant, because an opener
17
+ is where a deck states what it is; a sign-off that ends on a compliance mark ends on somebody
18
+ else's logo. Jose, on this layout specifically: "fuck bcorp in end layout". There is no
19
+ `endorsements` prop here and there should not be one.
20
+ */
21
+ import { computed } from 'vue';
22
+ import { BEAT_GROUND, END_GROUNDS, END_PHOTO_GROUND } from '../notch.js';
23
+ import { assetUrl } from '../assetUrl.js';
24
+
25
+ const props = defineProps({
26
+ /**
27
+ * WHAT SHAPE the closer is.
28
+ *
29
+ * · `plain` — the closing line and the lockup under it. Type alone on the ground.
30
+ * · `signoff` — two display words split around one of the brand's own renders, the second word
31
+ * in front of it. The loudest close the deck has.
32
+ * · `wordmark` — the wordmark alone at display scale, centred. The quietest.
33
+ * · `lockup` — the same, composed: mark over wordmark, at the brand's own proportions.
34
+ * · `photo` — a photograph edge to edge under a scrim, the closing line and lockup over it.
35
+ *
36
+ * ★ `wordmark` AND `lockup` ARE TWO SHAPES, NOT ONE WITH A SWITCH. The wordmark is a WORD held
37
+ * alone and reads as a signature; the vertical lockup is the mark ON the word and reads as a
38
+ * stamp. Which one closes a deck is the same kind of choice as which one opens it, and
39
+ * `Logo` already draws both from one set of proportions — this layout only says which.
40
+ */
41
+ variant: {
42
+ type: String,
43
+ default: 'plain',
44
+ validator: (v) => ['plain', 'signoff', 'wordmark', 'lockup', 'photo'].includes(v),
45
+ },
46
+ /**
47
+ * The ground. `auto` resolves to the brand pair, `paper` leaves the deck's own surface, and the
48
+ * four named pairs are the same table `default`, `split`'s panel, `stack`'s band and a Card read.
49
+ *
50
+ * ★ THE TWO BOOKENDS RESOLVE `auto` FROM ONE TABLE (notch.js's BEAT_GROUND), so a deck that
51
+ * states neither opens and closes on the same colour by construction rather than by an author
52
+ * remembering to match them.
53
+ *
54
+ * ★★ AND THE CLOSER TAKES MORE GROUNDS THAN THE OPENER. cover.vue narrows itself to two because a
55
+ * deck states its identity there; a closer is the last frame and is the slide a deck is allowed to
56
+ * be loud on. See END_GROUNDS in notch.js, which the chrome's own register reader clamps against.
57
+ */
58
+ background: {
59
+ type: String,
60
+ default: 'auto',
61
+ validator: (b) => END_GROUNDS.includes(b),
62
+ },
63
+ /** Force the register. Unstated, the ground decides — which is the whole point of `auto`. */
64
+ dark: { type: Boolean, default: null },
65
+ /**
66
+ * The two display words of a `signoff`, split around the render: `lead` sits above it and behind
67
+ * it, `trail` below it and in front.
68
+ *
69
+ * ★ TWO PROPS, NOT ONE STRING THIS LAYOUT SPLITS. "Thank you" splits at the space; "Ride with
70
+ * us" does not, and a layout guessing where a phrase breaks is a layout that breaks it in the
71
+ * wrong place on the one slide nobody re-reads. The author says which word goes where.
72
+ */
73
+ lead: { type: String, default: '' },
74
+ /** The second word — the one that overlaps the render from in front. */
75
+ trail: { type: String, default: '' },
76
+ /**
77
+ * The picture: one of the brand's renders on `signoff` (a cutout, drawn over the ground), a
78
+ * photograph on `photo` (bled to the frame, under a scrim). Slidev's own key, so
79
+ * `extractImagesUsage()` preloads it either way.
80
+ */
81
+ image: { type: String, default: '' },
82
+ /** Alt text for that picture. */
83
+ alt: { type: String, default: '' },
84
+ })
85
+
86
+ const VARIANT_CLASS = {
87
+ plain: 'is-plain',
88
+ signoff: 'is-signoff',
89
+ wordmark: 'is-wordmark',
90
+ lockup: 'is-lockup',
91
+ photo: 'is-photo',
92
+ }
93
+ /* Looked up rather than interpolated, so an invented variant renders the default composition
94
+ instead of minting an unstyled `is-<whatever>` — a validator is stripped in production. */
95
+ const variantClass = computed(() => VARIANT_CLASS[props.variant] ?? 'is-plain')
96
+
97
+ const stated = computed(() => (END_GROUNDS.includes(props.background) ? props.background : 'auto'))
98
+ const ground = computed(() => {
99
+ /* The photograph IS the ground on this one — the same fact cover.vue records for its poster. Light
100
+ ink on a dark picture, fixed, and notch.js agrees so the chrome reads the same slide. */
101
+ if (END_PHOTO_GROUND.includes(props.variant)) return { background: 'paper', dark: true }
102
+ const unstated = stated.value === 'auto' && props.dark === null
103
+ const g = unstated ? BEAT_GROUND : {}
104
+ return {
105
+ background: g.background ?? (stated.value === 'auto' ? 'paper' : stated.value),
106
+ dark: g.dark ?? (props.dark === true),
107
+ }
108
+ })
109
+
110
+ /*
111
+ ★ THE RENDER'S OWN SIZE, AS A SHARE OF THE FRAME RATHER THAN A PIXEL COUNT. A cutout has no box of
112
+ its own — it is a bike on nothing — so what decides its scale is how much of the slide it should
113
+ occupy against two words the same height as it. Stated here so the two numbers are read together.
114
+ */
115
+ const RENDER_WIDTH = '56%'
116
+ const RENDER_HEIGHT = '66%'
117
+
118
+ /* The wordmark variant's own scale. `size` on <Logo> is the MARK's height for every form, so 300
119
+ draws the wordmark 581px across — a wordmark that IS the composition rather than a signature in a
120
+ corner, and still short of the 697px the poster's own stack settled on for a narrower column. */
121
+ const END_WORDMARK_PX = 300
122
+ /* The composed form is sized from its MARK, and a mark over a wordmark is a taller object than the
123
+ wordmark alone — 260 draws the stack about the height 300 draws the word, which is what keeps the
124
+ two variants the same weight in the frame rather than the same number. */
125
+ const END_LOCKUP_PX = 260
126
+ </script>
127
+
128
+ <template>
129
+ <div
130
+ class="slidev-layout end h-full relative overflow-hidden"
131
+ :class="[
132
+ variantClass,
133
+ ground.background !== 'paper' ? `bg-${ground.background}` : '',
134
+ ground.dark ? 'deck-dark' : '',
135
+ ]"
136
+ >
137
+ <!--
138
+ ★★ ONE GRID CELL, THREE LAYERS, AND THE ORDER IS THE DESIGN. Jose, describing the reference:
139
+ "the 'thank' is back and above the bike, and the 'you' is top and below the bike". So the two
140
+ words are not a heading split over two lines with a picture between them — they are two planes
141
+ with the render BETWEEN them, and the composition only reads if the second word passes in
142
+ front of the wheels.
143
+
144
+ Every child takes the same `grid-area`, so they stack; `align-self` places each one in the
145
+ frame and `z-index` decides which passes in front. No absolute positioning: a corner-pinned
146
+ box would have to ask whether the chrome is there (deckNotch.static gates exactly that), and
147
+ none of these sits in a corner — they sit on top of each other.
148
+ -->
149
+ <div v-if="variant === 'signoff'" class="end-stage">
150
+ <p v-if="lead" class="end-word is-lead">{{ lead }}</p>
151
+ <div class="end-render">
152
+ <img v-if="image" :src="assetUrl(image)" :alt="alt">
153
+ <Placeholder v-else :label="alt || 'Closing render'" ratio="16:9" />
154
+ </div>
155
+ <p v-if="trail" class="end-word is-trail">{{ trail }}</p>
156
+ <!-- The slot still runs, under the stack: a closer that also wants a line of its own can
157
+ write one, and a slide that writes nothing draws nothing. -->
158
+ <div class="end-note"><slot /></div>
159
+ </div>
160
+
161
+ <!--
162
+ The identity as the last frame, in whichever of its two forms the deck closes on. No heading
163
+ over it and nothing under it: the identity IS the sign-off here, and a second mark beside it
164
+ would be the mark twice.
165
+
166
+ ★ THE VERTICAL LOCKUP RATHER THAN A MARK STACKED ON A WORDMARK BY HAND. `Logo` centres the mark
167
+ over the wordmark's midline and holds the canonical gap — its own note records that a
168
+ hand-copied gap made this form 1.7x too airy. One element, the brand's own proportions.
169
+ -->
170
+ <div v-else-if="variant === 'wordmark' || variant === 'lockup'" class="end-stage is-quiet">
171
+ <Logo
172
+ :variant="variant === 'lockup' ? 'lockup-vertical' : 'wordmark'"
173
+ :size="variant === 'lockup' ? END_LOCKUP_PX : END_WORDMARK_PX"
174
+ class="end-wordmark"
175
+ />
176
+ </div>
177
+
178
+ <template v-else>
179
+ <!-- The photograph, edge to edge, with the scrim drawn over it by `.is-photo::after`. -->
180
+ <div v-if="variant === 'photo'" class="end-media">
181
+ <img v-if="image" :src="assetUrl(image)" :alt="alt">
182
+ <Placeholder v-else :label="alt || 'Closing image'" ratio="16:9" />
183
+ </div>
184
+ <div class="end-content">
185
+ <slot />
186
+ <!-- The closer signs off with the full identity, not the bare tile. -->
187
+ <Logo variant="lockup" class="end-logo" :size="88" />
188
+ </div>
189
+ </template>
190
+ </div>
191
+ </template>
192
+
193
+ <style scoped>
194
+ /* The beat margin — see cover.vue's own note. Both edges, not just the left: the left-only version
195
+ this replaces rendered a 120px left margin against a 90px right one on every divider and closer,
196
+ with nothing in any file explaining the asymmetry. */
197
+ .end {
198
+ padding-left: var(--deck-inset-display);
199
+ padding-right: var(--deck-inset-display);
200
+ }
201
+ /*
202
+ ★★ THE NAMED GROUND IS PAINTED HERE, and without this rule the class is inert. `.bg-vivid` on the
203
+ root is only a marker: base.css keys ink, eyebrows and bullets off it but paints nothing, and
204
+ `.deck-dark { background: var(--deck-bg) }` then wins — so a closer that asked for the vivid pair
205
+ rendered on the deck's own dark ground instead. MEASURED on this slide before the rule existed:
206
+ forest-950 (0.052 0.162 0.142) where the cover beside it, which carries the same class AND its own
207
+ paint rule, drew forest-700 (0.182 0.417 0.307). cover.vue's note above its four says the same
208
+ thing; `end` takes two of them because a bookend takes two grounds.
209
+ */
210
+ .end.bg-warm { background: var(--deck-bg-warm); color: var(--deck-on-bg-warm); }
211
+ .end.bg-accent { background: var(--deck-bg-accent); color: var(--deck-on-bg-accent); }
212
+ .end.bg-vivid { background: var(--deck-bg-vivid); color: var(--deck-on-bg-vivid); }
213
+ .end.bg-neutral { background: var(--deck-bg-neutral); color: var(--deck-on-bg-neutral); }
214
+
215
+ /* Only the flowed variants centre their content in the frame; the staged ones fill it. */
216
+ .end-content {
217
+ height: 100%;
218
+ display: flex;
219
+ flex-direction: column;
220
+ justify-content: center;
221
+ align-items: flex-start;
222
+ position: relative;
223
+ z-index: 1;
224
+ }
225
+ /* The closer is a beat slide — Mohr uppercase, not the layout-default working voice. */
226
+ .end :deep(h1) {
227
+ font-family: var(--type-deck-display-family);
228
+ font-weight: var(--type-deck-display-weight);
229
+ text-transform: uppercase;
230
+ font-size: var(--type-deck-text-8xl); /* 6rem / 96px — ramp 8xl, pairs with the statement tier */
231
+ }
232
+ .end-logo {
233
+ margin-top: var(--deck-space-6);
234
+ }
235
+
236
+ /* ── signoff: two words, a render between them ───────────────────────────────────────────────── */
237
+ /*
238
+ The stage bleeds past the layout's own display margin, because a word used as a FIELD is not type
239
+ in a measure — it is a shape the render sits in front of, and holding it to a 120px margin on both
240
+ sides shrank it to the size of a heading. The margin still governs everything flowed.
241
+ */
242
+ .end-stage {
243
+ display: grid;
244
+ place-items: center;
245
+ height: 100%;
246
+ margin-inline: calc(-1 * var(--deck-inset-display));
247
+ }
248
+ .end-stage > * {
249
+ grid-area: 1 / 1;
250
+ }
251
+ /*
252
+ ★★ THE WORDS ARE A TONE OF THE GROUND, AND A SOLID ONE. Two wrong answers came first. `currentColor`
253
+ is white on the dark register, so the wash LIGHTENED — a word lighter than its ground reads as text
254
+ the deck forgot to finish rather than as a field behind an object. Black at 22% darkened correctly
255
+ on every ground and was still an alpha wash: it composites against whatever passes behind it, so
256
+ the word changed value where the render's shadow crossed it. Jose: "make it solid, not opacity".
257
+
258
+ So each ground names the tone one step down its own ramp, as a token. Three states, because the
259
+ ground has three: the vivid pair on either register, and the deck's own paper.
260
+ */
261
+ /* The deck's paper is warm-50, so the tone steps down its own family. 200 (#FFD6A6) was the first
262
+ step tried and read as a highlight rather than a tone against cream — two steps down is where the
263
+ word reads as a field. */
264
+ .end { --end-word-ink: var(--warm-300); }
265
+ /*
266
+ ★★ ONE TONE PER GROUND PER REGISTER, AND EVERY ONE IS A RAMP STEP OF THAT GROUND'S OWN FAMILY.
267
+ Not `--deck-bg-<name>-mark`, which is the obvious reach and is wrong here: that role is the colour
268
+ a SMALL shape needs to clear 3:1 on the ground, so on every dark half it is a LIGHT tint —
269
+ forest-200 on vivid — and a word lighter than its ground is the failure this whole rule exists to
270
+ avoid. A field wants the quiet neighbour, not the legible one.
271
+ */
272
+ .end.bg-vivid { --end-word-ink: var(--forest-700); }
273
+ .end.bg-vivid.deck-dark { --end-word-ink: var(--forest-950); }
274
+ /* Brown on peach, and the deeper brown on the dark half's warm-700. */
275
+ .end.bg-warm { --end-word-ink: var(--warm-700); }
276
+ .end.bg-warm.deck-dark { --end-word-ink: var(--warm-950); }
277
+ /* The mint pair: a green step down from forest-100, and on its forest-950 half the same lift the
278
+ deck's own dark ground takes, for the same reason — there is nothing below 950. */
279
+ .end.bg-accent { --end-word-ink: var(--forest-300); }
280
+ .end.bg-accent.deck-dark { --end-word-ink: var(--forest-900); }
281
+ /* Two steps down carries on every other pair and not on this one — the grey ramp has no chroma to
282
+ separate 300 from 100, so the word read as a printing artefact rather than a tone. 400. */
283
+ .end.bg-neutral { --end-word-ink: var(--neutral-400); }
284
+ .end.bg-neutral.deck-dark { --end-word-ink: var(--neutral-700); }
285
+ /* ⚠ ON THE DECK'S OWN DARK GROUND THE TONE GOES UP, NOT DOWN. That ground IS forest-950, the bottom
286
+ of the ramp, so there is nothing darker to step to — the tint lifts to 900 instead. A tone of the
287
+ ground is what this has to be; which direction it lies in is the ground's business, not the
288
+ word's. */
289
+ .end.deck-dark:not(.bg-vivid) { --end-word-ink: var(--forest-900); }
290
+ .end-word {
291
+ margin: 0;
292
+ font-family: var(--type-deck-headline-family);
293
+ font-weight: var(--type-deck-headline-weight);
294
+ /*
295
+ ⚠ OFF THE RAMP ON PURPOSE, and this is the one place in the theme that is true. The deck ramp
296
+ tops out at --type-deck-text-9xl (128px), which is the biggest a line of READING is allowed to
297
+ be in a room. These two words are not read as a line — they are the composition, and at 128 they
298
+ sat under the render like a caption. 320px is the height that makes the render's 66% and the
299
+ word's cap height the same order of size. Drawn at 320 first: the word came out 901px across
300
+ against a 1190px render, which read as a picture with type behind it rather than as one object.
301
+ */
302
+ font-size: 360px;
303
+ line-height: 0.82;
304
+ letter-spacing: -0.02em;
305
+ color: var(--end-word-ink);
306
+ text-transform: none;
307
+ white-space: nowrap;
308
+ }
309
+ /* Above and behind; below and in front. The two z-indexes ARE the brief. */
310
+ .end-word.is-lead {
311
+ align-self: start;
312
+ z-index: 0;
313
+ }
314
+ .end-word.is-trail {
315
+ align-self: end;
316
+ z-index: 2;
317
+ }
318
+ .end-render {
319
+ display: flex;
320
+ align-items: center;
321
+ justify-content: center;
322
+ z-index: 1;
323
+ width: 100%;
324
+ }
325
+ .end-render :deep(img) {
326
+ width: v-bind(RENDER_WIDTH);
327
+ max-height: v-bind(RENDER_HEIGHT);
328
+ object-fit: contain;
329
+ display: block;
330
+ }
331
+ .end-render :deep(.deck-placeholder) {
332
+ width: v-bind(RENDER_WIDTH);
333
+ }
334
+ /* A closing line under a stage is a footnote to it: small, quiet, and out of the composition's way. */
335
+ .end-note {
336
+ align-self: end;
337
+ justify-self: start;
338
+ z-index: 3;
339
+ margin-inline-start: var(--deck-inset-display);
340
+ }
341
+ .end-note :deep(h1) {
342
+ font-size: var(--type-deck-text-5xl);
343
+ }
344
+
345
+ /* ── wordmark: the identity alone ────────────────────────────────────────────────────────────── */
346
+ .end-stage.is-quiet {
347
+ grid-auto-flow: row;
348
+ place-content: center;
349
+ gap: var(--deck-space-5);
350
+ }
351
+ .end-stage.is-quiet > * {
352
+ grid-area: auto;
353
+ }
354
+
355
+ /* ── photo: the photograph, and the words over it ────────────────────────────────────────────── */
356
+ .end-media {
357
+ position: absolute;
358
+ inset: 0;
359
+ overflow: hidden;
360
+ z-index: 0;
361
+ }
362
+ .end-media :deep(img) {
363
+ width: 100%;
364
+ height: 100%;
365
+ object-fit: cover;
366
+ display: block;
367
+ }
368
+ .end-media :deep(.deck-placeholder) {
369
+ width: 100%;
370
+ height: 100%;
371
+ }
372
+ /*
373
+ The scrim, from the side the words are on. Black rather than the ground's own ink for the reason
374
+ bleed.vue's full scrim is: a green wash over a photograph tints the photograph, and what this has
375
+ to do is make white type legible over whatever the picture happens to be doing.
376
+ */
377
+ .end.is-photo::after {
378
+ content: '';
379
+ position: absolute;
380
+ inset: 0;
381
+ background: linear-gradient(to right, rgb(0 0 0 / 74%), rgb(0 0 0 / 52%) 42%, transparent 78%);
382
+ z-index: 0;
383
+ }
384
+ </style>
@@ -0,0 +1,218 @@
1
+ <script setup>
2
+ import { assetUrl } from '../assetUrl.js';
3
+ /*
4
+ A pull quote. The one slide where the sans face gets to be large — the display face is the
5
+ brand SHOUTING, and a quote is somebody else speaking, so it stays in GT Haptik.
6
+
7
+ The quotation mark is drawn as a decorative glyph in the accent, set in the display face and
8
+ hung outside the text block, which is the system's editorial treatment for a lifted quote.
9
+
10
+ ★ THE COMPANION IMAGE IS A HALF, NOT A SPLIT. `image` puts a portrait beside the words — the one
11
+ thing a quote wants a picture of is the person saying it — and `flip` says which wall the WORDS take, the picture taking the other — the same reading `split` and `bleed` give it.
12
+ Deliberately ONE arrangement and a fixed half, rather than split.vue's ratio ladder: a quote is
13
+ already capped at 17em of measure, so a wider content column buys nothing and a narrower one
14
+ breaks the line-length the layout exists to protect. An author who wants a ratio wants `split`.
15
+
16
+ The figure carries split's own treatment — filled, corner-clipped, cover-fitted — but NO notch,
17
+ and that is measured rather than assumed. A quote keeps the display inset at the sides and the
18
+ text inset top and bottom, which puts the figure at 1020..1800 x 180..900 against a mark sitting
19
+ at 1812..1884 x 36..108: clear on BOTH axes, by 12px horizontally and 72 vertically. The cut was
20
+ wired here first and could never fire, so it came back out. A figure that reached the chrome would
21
+ need it — see split.vue — but this one cannot.
22
+ */
23
+
24
+ defineProps({
25
+ /** Who said it. */
26
+ attribution: { type: String, default: '' },
27
+ /** Their role, company, or the source. */
28
+ source: { type: String, default: '' },
29
+ /** A companion image — a portrait of whoever is speaking. Omit for a quote that owns the whole slide. */
30
+ image: { type: String, default: '' },
31
+ /** Assistive text for that image. */
32
+ alt: { type: String, default: '' },
33
+ /**
34
+ * Which physical side the CONTENT sits on; the image takes the other. Ignored without an image.
35
+ *
36
+ * ★★ THIS USED TO MEAN THE OPPOSITE, and it was the only layout where it did. `split` and `bleed`
37
+ * both read `flip` as the CONTENT's side; quote read it as the IMAGE's, so `flip: false` put words
38
+ * on the left in two layouts and the photograph on the left in the third. One prop name, two
39
+ * meanings, and nothing in the type system to say which one a slide was getting.
40
+ *
41
+ * The default moved with the meaning — 'right' (image right) became 'left' (content left), which
42
+ * is the SAME rendering — so a quote that does not state a side is unaffected. A quote that does
43
+ * state one now gets the mirror of what it used to, which is the point: it is the layout that was
44
+ * lying, not the slides.
45
+ */
46
+ /**
47
+ * Mirror the layout: by default the WORDS take the left wall and the picture the right; flipped, they swap.
48
+ * Ignored without an image.
49
+ *
50
+ * ★★ A BOOLEAN, AND SHARED WITH `stack` — which is the only shape that gives the two layouts one
51
+ * key with one DEFAULT. They invert deliberately (split leads with the title, stack subordinates
52
+ * it), so any value that names a POSITION comes out opposite between them: content-first on one
53
+ * is content-last on the other. `false` means "this layout's own arrangement" on both, and that
54
+ * is the only thing they genuinely share — there is no word for "figure right" and "band top" at
55
+ * once. It replaced `flip`, which had no third value in prospect either.
56
+ *
57
+ * ⚠ IT IS RELATIVE, WHICH IS THE TRADE. `flip: true` said where the content went; `flip` says
58
+ * "the other way from the default", so reading a slide needs the default in hand — and reversing
59
+ * a layout's default would silently change every flipped slide. The defaults are doctrine and do
60
+ * not move, which is what makes that acceptable.
61
+ */
62
+ flip: { type: Boolean, default: false },
63
+ });
64
+
65
+ </script>
66
+
67
+ <template>
68
+ <div
69
+ class="slidev-layout quote h-full"
70
+ :class="[image ? 'has-figure' : 'is-plain', flip ? 'is-right' : 'is-left']"
71
+ >
72
+ <div class="quote-content">
73
+ <div class="quote-mark" aria-hidden="true">“</div>
74
+ <div class="quote-body">
75
+ <slot />
76
+ </div>
77
+ <div v-if="attribution || source" class="quote-attribution">
78
+ <span v-if="attribution" class="quote-who">{{ attribution }}</span>
79
+ <span v-if="source" class="quote-source">{{ source }}</span>
80
+ </div>
81
+ </div>
82
+ <div v-if="image" class="quote-figure">
83
+ <img :src="assetUrl(image)" :alt="alt">
84
+ </div>
85
+ </div>
86
+ </template>
87
+
88
+ <style scoped>
89
+ /* The beat margin — see cover.vue's own note. Both edges, not just the left: the left-only version
90
+ this replaces rendered a 120px left margin against a 90px right one on every divider and closer,
91
+ with nothing in any file explaining the asymmetry. */
92
+ .quote {
93
+ padding-left: var(--deck-inset-display);
94
+ padding-right: var(--deck-inset-display);
95
+ display: flex;
96
+ align-items: center;
97
+ }
98
+ /*
99
+ ★ TWO THIRDS FOR THE WORDS, ONE THIRD LEFT EMPTY. Without an image the quote is the only thing on
100
+ the slide, and letting it run the full column made a wall of 7xl type with nothing to sit against.
101
+ The blank third is not slack — it is what makes a lone quote read as placed rather than as
102
+ overflowing, and it is the same restraint the beat layouts get from their display inset.
103
+
104
+ A fraction of the CONTENT box, so it holds whatever the display inset is: 2/3 of 1680 = 1120,
105
+ with 560 left over. The paragraph's own measure cap comes off here — two rules capping one column
106
+ is how a block ends up narrower than either intended, and the column is now the measure.
107
+ */
108
+ .quote.is-plain .quote-content { flex: 0 0 66.6667%; max-width: 66.6667%; }
109
+ .quote.is-plain .quote-body { width: auto; }
110
+ /*
111
+ ★ `pretty`, NOT `balance`, once the column is the measure. `balance` evens the lines out, which is
112
+ right for a heading and wrong here: given a 1120px column it returned four short lines about half
113
+ that wide, so the slide read as a third of quote and two thirds of blank — the opposite of what
114
+ the 2/3 rule was for. `pretty` fills the measure and only fixes the last-line orphan.
115
+
116
+ Kept as `balance` beside a figure, where the column is half a slide and short lines are the point.
117
+ */
118
+ .quote.is-plain .quote-body :deep(p) { max-width: none; text-wrap: pretty; }
119
+ .quote-content {
120
+ display: flex;
121
+ flex-direction: column;
122
+ justify-content: center;
123
+ }
124
+ /*
125
+ Two halves, and the ORDER is what `flip` moves — not a `direction` or a float. The figure is last
126
+ in the DOM so a screen reader hears the quote before the portrait of whoever said it, and `order`
127
+ puts it on the left visually without disturbing that.
128
+ */
129
+ .quote.has-figure {
130
+ gap: var(--deck-inset-display);
131
+ }
132
+ .quote.has-figure .quote-content { flex: 1 1 0; min-width: 0; }
133
+ .quote.has-figure .quote-figure { flex: 1 1 0; min-width: 0; }
134
+ /* Content on the right puts the figure first. `order` rather than `direction` or a float, so
135
+ the DOM keeps the quote before the portrait of whoever said it for a screen reader. */
136
+ .quote.has-figure.is-right .quote-figure { order: -1; }
137
+
138
+ /* The figure keeps split's own treatment, so a portrait here and a portrait there are the same
139
+ picture: filled, corner-clipped, and cover-fitted rather than stretched. */
140
+ .quote-figure {
141
+ align-self: stretch;
142
+ margin-top: var(--deck-inset-text);
143
+ margin-bottom: var(--deck-inset-text);
144
+ border-radius: var(--deck-radius-card);
145
+ overflow: hidden;
146
+ display: flex;
147
+ }
148
+ .quote-figure img {
149
+ width: 100%;
150
+ height: 100%;
151
+ object-fit: cover;
152
+ }
153
+
154
+ .quote-mark {
155
+ font-family: var(--font-display);
156
+ font-size: var(--type-deck-text-9xl);
157
+ line-height: 0.6;
158
+ color: var(--deck-accent);
159
+ margin-bottom: var(--deck-space-2);
160
+ }
161
+ /*
162
+ ★ THE BOX HUGS THE MEASURE. The cap lives on the paragraph — it has to, because `em` on this div
163
+ would resolve against its inherited body size rather than the quote's own 7xl — which left the
164
+ DIV spanning the full column while the words stopped at 17em. Harmless to look at, wrong to
165
+ inspect: every box in this deck is something a ruler or an audit can be pointed at, and one
166
+ claiming width it never uses reports a measure the slide does not have.
167
+
168
+ `fit-content` is min(max-content, available), which IS the paragraph's cap once the cap is set,
169
+ so the two cannot disagree.
170
+ */
171
+ .quote-body {
172
+ width: fit-content;
173
+ max-width: 100%;
174
+ }
175
+ .quote-body :deep(p) {
176
+ font-family: var(--font-sans);
177
+ font-size: var(--type-deck-text-7xl);
178
+ line-height: 1.2;
179
+ font-weight: 400;
180
+ color: var(--deck-fg);
181
+ max-width: 17em; /* ≈34 characters of GT Haptik */
182
+ text-wrap: balance;
183
+ }
184
+ /* ★ Beside a figure the column IS the measure, so the 17em cap would apply a second time and hold
185
+ the quote to about half its own half. The cap above is a beat-layout cap — display type being
186
+ shaped into a two- or three-line block — which is why it survives base.css uncapping running
187
+ prose, and why it still has to be switched off where a column has already chosen the width. */
188
+ /*
189
+ ★★ ONE STEP DOWN, NOT TWO, AND THE STEP IS THE COLUMN'S. A figure takes a third of the slide, so
190
+ the words get 780px where a plain quote gets 1120 — 70% of the measure. The size dropped from 7xl
191
+ to 5xl, which is 48px against 72: 67% of the type for 70% of the width, compounding a reduction
192
+ the column had already made. Measured side by side, the same voice read two sizes apart depending
193
+ on whether somebody was pictured saying it, which is not a distinction this layout means to draw.
194
+
195
+ 6xl (60px) is the step the column actually asks for. The quote is still the loudest thing on the
196
+ slide and still sets in three lines; what goes away is the impression that a pictured quote is a
197
+ quieter one.
198
+ */
199
+ .quote.has-figure .quote-body :deep(p) {
200
+ max-width: none;
201
+ font-size: var(--type-deck-text-6xl);
202
+ }
203
+ .quote-attribution {
204
+ display: flex;
205
+ align-items: baseline;
206
+ gap: var(--deck-space-2);
207
+ margin-top: var(--deck-space-5);
208
+ }
209
+ .quote-who {
210
+ font-size: var(--type-deck-label-size);
211
+ font-weight: var(--type-deck-label-weight);
212
+ color: var(--deck-fg);
213
+ }
214
+ .quote-source {
215
+ font-size: var(--type-deck-caption-size);
216
+ color: var(--deck-fg-muted);
217
+ }
218
+ </style>