@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,71 @@
1
+ // Theme style entry — Slidev auto-loads styles/index.ts.
2
+ // Order matters: fonts + token ramps first (they only declare CSS custom properties
3
+ // and @font-face), then base.css maps those tokens onto the deck's semantic roles.
4
+
5
+ // Brand fonts (@font-face: MohrAlt, GT Haptik, JetBrains Mono), fetched from the CDN.
6
+ //
7
+ // ⚠⚠ THE CDN BUILD, AND THAT IS A LICENCE DECISION RATHER THAN A DELIVERY ONE. The sibling
8
+ // `forest-fonts.css` points at `./files/*.woff2`, which Vite emits into whatever imports it — a
9
+ // built deck then SHIPS the binaries, and every copy of that deck is another place the faces are
10
+ // distributed from. Mohr and GT Haptik are licensed to Forest, not to whoever a deck is sent to.
11
+ // The CDN build names `https://assets.forest.bike/fonts/*` instead, so a deck references the faces
12
+ // and never carries them. This is also the only build the fonts package publishes (`files` +
13
+ // `exports` in its package.json list the CDN css alone), which is why this is a bare specifier now
14
+ // rather than a reach across the workspace.
15
+ //
16
+ // The trade, stated: a deck no longer renders in its own typeface offline, and it depends on that
17
+ // host answering. Verified — the host sends `access-control-allow-origin: *` with `vary: Origin`,
18
+ // which a cross-origin font fetch requires and fails SILENTLY without.
19
+ import '@humanforest/fonts/forest-fonts.cdn.css'
20
+
21
+ // Engine colour ramps (--forest-*, --neutral-*, …) — single source of truth.
22
+ import '@humanforest/tokens/scales/ramps.css'
23
+ // Typography role bundles (--type-*) + .type-* utility classes.
24
+ import '@humanforest/tokens/type/type.css'
25
+ // The deck type context (--type-deck-*): canonical roles remapped to the top of the ramp.
26
+ import '@humanforest/tokens/type/type.deck.css'
27
+ // The motion ladder (--motion-*) and the system-wide reduced-motion floor. The deck used to restate
28
+ // these by hand, because they lived in @humanforest/ui's forest.css and pulling that drags the whole
29
+ // Nuxt UI preset in. They are engine tokens now, and motion.css is deliberately framework-agnostic —
30
+ // no @theme, no @utility — so UnoCSS eats it exactly as Tailwind does. The Tailwind-only companion
31
+ // (motion.theme.css) is the one file a deck must NOT import: it re-points Tailwind's own
32
+ // --default-transition-* and means nothing here.
33
+ import '@humanforest/tokens/motion/motion.css'
34
+ // Data-viz palette (--dataviz-*) so charts inherit the same colours.
35
+ import '@humanforest/tokens/dataviz/dataviz.css'
36
+ // The deck SURFACE: colour roles, the radius contract, the spacing scale, the derived surface ladder
37
+ // and the dark mapping. Generated by scripts/deck.ts, so the deck's brand decisions are versioned and
38
+ // reviewable alongside every other token — they used to live in this package, which is gitignored.
39
+ // Framework-agnostic by construction: not one Tailwind at-rule, so UnoCSS reads it unchanged.
40
+ import '@humanforest/tokens/deck/deck.css'
41
+
42
+ // What is left of this package's own CSS, in two halves. tokens.css holds only the SLIDEV bridges —
43
+ // the font-family names the engine's type.css expects, and Slidev's --slidev-* vars re-pointed at
44
+ // Forest roles. base.css styles slide elements using nothing but engine tokens. Keeping them apart is
45
+ // what makes the guardrail checkable: a literal colour or corner in base.css is visible as a rule
46
+ // that skipped the token layer.
47
+ import './tokens.css'
48
+ import './base.css'
49
+
50
+ // ── THE OBLIQUE IS NOT FETCHED FOR FREE, and that is a measured difference from the other faces ──
51
+ //
52
+ // Every slide's own h1 and body text make the browser request MohrAlt and GT Haptik Regular/Bold as
53
+ // part of ordinary layout, so those are on the wire immediately. The oblique is requested only when
54
+ // the first italic glyph is actually painted — so on a deck where one slide uses <em>, it lands
55
+ // after everything else has settled and that line reflows in front of the audience.
56
+ //
57
+ // MEASURED, by scripts/deckAudit.ts, on the first deck in this repo ever to use italic:
58
+ // `em → dx 0 dy -2 dh 4` — the element moved 2px up and grew 4px after the swap. That is precisely
59
+ // the category deckAudit's own setup() comment describes for JetBrains Mono ("only slides with a
60
+ // <code>/<pre> actually render it, so on every OTHER slide nothing on the page ever asks the browser
61
+ // to fetch it"), and it takes the same fix: ask for the face explicitly instead of waiting for a
62
+ // glyph to ask on your behalf.
63
+ //
64
+ // Fired, never awaited — this must not block first paint. Guarded for `slidev build`'s SSR pass,
65
+ // where there is no document. Mohr-BlackIt is deliberately NOT here: it is a separate family the
66
+ // deck never sets, so requesting it would cost a round trip for a face no slide can render.
67
+ if (typeof document !== 'undefined' && document.fonts) {
68
+ for (const face of ['italic 400 1em "GT Haptik"', 'italic 700 1em "GT Haptik"']) {
69
+ document.fonts.load(face).catch(() => {})
70
+ }
71
+ }
@@ -0,0 +1,144 @@
1
+ /* ───────────────────────────────────────────────────────────────────────────
2
+ Forest deck theme — the SLIDEV-SPECIFIC half of the token layer.
3
+
4
+ The deck's own identity — colour roles, the radius contract, the spacing scale, the derived
5
+ surface ladder, the dark mapping, the chart series — is NOT here any more. It lives in the
6
+ engine, generated by scripts/deck.ts into @humanforest/tokens/deck/deck.css, and styles/index.ts
7
+ imports it. That move is the point: mobile and marketing declare their voice in tracked, tested
8
+ files under packages/ui/src/contexts/, while this package is gitignored and out-of-workspace, so
9
+ every brand decision made here was unversioned and unreviewable.
10
+
11
+ What is left is the two things that are genuinely about SLIDEV rather than about Forest:
12
+
13
+ 1. The font-family bridge. --font-display/sans/mono normally come from @humanforest/ui's
14
+ forest.css, which the deck cannot pull (it carries the whole Nuxt UI preset). The engine's
15
+ type.css references these names, so something has to declare them.
16
+ 2. Slidev's own --slidev-* vars, re-pointed at Forest roles. Left alone they leak a teal primary,
17
+ a stock grey code block and a black letterbox through an otherwise on-brand deck.
18
+
19
+ Both are bridges to a specific tool. Neither is a brand decision, which is why neither followed
20
+ the roles into the engine.
21
+ ─────────────────────────────────────────────────────────────────────────── */
22
+
23
+ :root {
24
+ /* ── Font bridge — resolves type.css's var(--font-display) etc. ─────────────────────────── */
25
+ --font-display: 'MohrAlt', 'Mohr-BlackIt', system-ui, sans-serif;
26
+ --font-display-italic: 'Mohr-BlackIt', 'MohrAlt', system-ui, sans-serif;
27
+ --font-sans: 'GT Haptik', system-ui, sans-serif;
28
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
29
+
30
+ /* ── Slidev's own vars, re-pointed ───────────────────────────────────────────────────────
31
+ Slide-to-slide travel: Slidev ships 0.5s with `ease`, which is off the motion ladder in both
32
+ halves. `slow` is the bucket for large travel and keeps the deck on the snappy end.
33
+
34
+ The deck's own transition is `view-transition`, which does NOT read this — the API runs its
35
+ animations on pseudo-elements and base.css puts them on the same ladder. This still governs
36
+ any slide that opts back into a Vue transition with its own `transition:` frontmatter. */
37
+ --slidev-transition-duration: var(--motion-slow);
38
+
39
+ --slidev-theme-primary: var(--deck-accent);
40
+
41
+ /* The letterbox behind the 16∶9 canvas. Slidev ships black, which frames a light slide like a
42
+ cinema screen; a deep neutral recedes instead and lets the paper ground read as paper. */
43
+ --slidev-slide-container-background: var(--neutral-900);
44
+
45
+ /* ★ 24px — three steps of the base radius, between the nested step (16) and the slide card (48).
46
+ The item step (8px) is for code CHIPS, tags and swatches; on a block the size of half a slide
47
+ it reads as a square with the corners filed off, and the nested step is still tighter than the
48
+ block wants.
49
+
50
+ ⚠ AND IT IS THIS TOKEN THAT RENDERS, not the `border-radius` base.css sets on `pre`. Slidev's own
51
+ `.slidev-code` carries `border-radius: var(--slidev-code-radius) !important`, so the rule in
52
+ base.css loses and the value here is the one that reaches the corner. */
53
+ --slidev-code-radius: calc(var(--deck-radius) * 3);
54
+ --slidev-code-font-family: var(--font-mono);
55
+ /* ★ ONE STEP UP THE LADDER FROM THE OLD 24. A block set at 36px wants more air than a paragraph
56
+ does, and this token is read TWICE — Slidev pads the block with it and base.css sizes each line
57
+ to overhang by it, so the lit band still reaches both padding edges at the new value. */
58
+ --slidev-code-padding: var(--deck-space-4);
59
+ --slidev-code-font-size: var(--type-deck-body-sm-size);
60
+ --slidev-code-line-height: var(--type-deck-body-sm-line-height);
61
+ /* A code-group tab is furniture naming a file, so it takes the OVERLINE rung the running chrome's
62
+ own pills use. Slidev ships 12px — a desk number on a 1920px canvas, where it lands at roughly
63
+ the width of a full stop from the back of a room. */
64
+ --slidev-code-tab-font-size: var(--type-deck-overline-size);
65
+ }
66
+
67
+ /* ★★ MEASURED, task-8-brief.md CONTROLLER ADDENDUM 2's rendered-contrast sweep — --slidev-code-
68
+ background/-foreground are the one pair here that reference DARK-AWARE roles (--deck-surface,
69
+ --deck-fg), so they need the SAME "declared on every mode boundary, not just :root" fix
70
+ deck.css's own derived roles (--deck-surface itself included) already carry, and — before this
71
+ fix — did not have.
72
+
73
+ Root cause, confirmed live: @slidev/client/styles/code.css ships its OWN inline-code rule,
74
+ `.slidev-layout :not(pre) > code { background: var(--slidev-code-background); }`, which OUTRANKS
75
+ base.css's `.slidev-layout code { background: var(--deck-accent-soft); }` outright — one more
76
+ class-shaped pseudo-selector than base.css's rule, (0,2,1) against (0,1,1) — regardless of
77
+ either rule's position in the cascade. So Slidev's own rule is what a `<code>` element actually
78
+ paints with, not base.css's. That makes --slidev-code-background load-bearing for every inline
79
+ code span in the deck, not a background hook nobody actually reaches — and with it declared only
80
+ at :root, a `class: deck-dark` slide's `<code>` background/foreground stayed frozen at :root's
81
+ OWN (light) --deck-surface/--deck-fg forever, because a custom property resolves ITS OWN var()
82
+ reference once, where IT is declared, not again at each point of use. Measured on
83
+ apps/deck-template's own "Charts inherit the palette" demo slide: a `<code>` pill rendered pale
84
+ cream-on-near-white-text — text #3bd996 (dark register's --deck-accent) on a background frozen at
85
+ #f1ede6 (LIGHT --deck-surface) — 1.56:1, on a DARK slide, invisible to a geometry-only audit and
86
+ to a unit-level gate that only ever checks token PAIRS in isolation, never what Slidev's own CSS
87
+ actually resolves them against. */
88
+ :root,
89
+ .dark,
90
+ .deck-dark {
91
+ /*
92
+ ★★ THE BLOCK'S GROUND IS CATPPUCCIN'S OWN BASE, NOT --deck-surface.
93
+
94
+ It was the derived surface — deck-bg mixed 5% toward the ink — which is right for a surface OF
95
+ the deck and wrong for this one: the palette on top of it is Catppuccin's, and Catppuccin's
96
+ colours are designed against Catppuccin's ground. On the dark register the derived surface is
97
+ forest-950 lifted, so the block was the PAGE's green showing through, under a violet-lavender
98
+ palette. Jose: "why are you using that shade of green for the bg?"
99
+
100
+ The hexes are latte `base` and mocha `base`, and they are restated here only because a Shiki
101
+ theme registration cannot be read from CSS. codeTheme.static.test.ts pins both against
102
+ CATPPUCCIN[register].base so the two copies cannot drift.
103
+
104
+ Measured: every mocha ink gains on its own base (9.50 -> 11.34, 6.17 -> 7.37, 6.76 -> 8.07,
105
+ 9.24 -> 11.03, 7.76 -> 9.27), and latte needs the same three lifts either way.
106
+
107
+ ⚠ INLINE code is deliberately NOT this — see base.css. A `code` chip in a sentence is deck
108
+ furniture, and a Catppuccin-blue chip mid-paragraph reads as a bug.
109
+ */
110
+ --slidev-code-background: var(--deck-surface);
111
+ /* The block's EDGE and its lit-line MARKER, from the same palette as its ground — see the note
112
+ above AYU in components/codeTheme.ts. There is no marker token: the lit-line bar is the deck's
113
+ own --deck-accent, and the band is the ground mixed toward its own ink, so neither is a colour
114
+ this file has to hold. */
115
+ /* ★ THE EDGE IS DERIVED FROM THE GROUND, in ONE declaration for both registers: mixing toward
116
+ --deck-fg means it darkens on light and lightens on dark on its own, exactly as --deck-border
117
+ does against the page. Nothing to keep in sync when the well moves. */
118
+ --deck-code-border: color-mix(in oklab, var(--slidev-code-background), var(--deck-fg) 9%);
119
+ --slidev-code-foreground: var(--deck-fg);
120
+
121
+ /* The code-group tab strip, same three-selector treatment and for the identical reason: Slidev
122
+ declares these at `:root` and `.dark` only (its own styles/vars.css), so on a `class: deck-dark`
123
+ slide the tabs kept the LIGHT #67676c/#3c3c43 pair over a dark strip. Two of the three are
124
+ text colours, so this is the tokens.css trap above repeated on a second surface. */
125
+ --slidev-code-tab-divider: var(--deck-border);
126
+ --slidev-code-tab-text-color: var(--deck-fg-muted);
127
+ --slidev-code-tab-active-text-color: var(--deck-fg);
128
+ }
129
+
130
+ /* The dark half of the pair above. Both registers of `.deck-dark` and the whole-deck `.dark`, for
131
+ the same reason every other role is bound to both. Mocha's base, unmodified. */
132
+ .dark,
133
+ .deck-dark {
134
+ --slidev-code-background: var(--neutral-900);
135
+ }
136
+
137
+ /* A dark slide paints its own ground. The ROLES for `.deck-dark` are declared in the engine's
138
+ deck.css; this is only the paint, because it is styling rather than a token. `.slidev-layout`
139
+ already reads --deck-bg, but the class can also land on a wrapper inside a slide (a dark card
140
+ band, a chart well), so it is said here rather than left to the layout rule. */
141
+ .deck-dark {
142
+ background: var(--deck-bg);
143
+ color: var(--deck-fg);
144
+ }
package/uno.config.ts ADDED
@@ -0,0 +1,231 @@
1
+ /*
2
+ Slidev loads a uno.config.ts from every root it knows about — the deck's and the theme's —
3
+ and merges them, so this is where the theme adds what the brand owns to the utility layer.
4
+
5
+ One job right now: register the FOREST ICON SET. The design system owns its own icons (bikes,
6
+ traffic lights, parking, the mark) alongside the third-party sets, and without this a slide
7
+ that needs a bike has to reach for someone else's. The files are read straight from
8
+ packages/ui/icons/forest — the same source the product's addCollection() call uses, so a deck
9
+ icon and a product icon are the same artwork, not a redraw.
10
+
11
+ They are monotone (currentColor on fill and stroke), which puts presetIcons in mask mode, so
12
+ the icon takes its colour from the text around it:
13
+
14
+ <div class="i-forest-bike text-6xl" />
15
+ */
16
+ import { readdirSync, readFileSync } from 'node:fs'
17
+ import { dirname, resolve } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { defineConfig, presetIcons } from 'unocss'
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url))
22
+ const iconDir = resolve(here, '../ui/icons/forest')
23
+
24
+ /* Read eagerly rather than through a loader: the set is 17 files, so the cost is nothing, and a
25
+ plain object means no dependency on @iconify/utils' filesystem loader — which is only present
26
+ here transitively, via UnoCSS. */
27
+ const forest = Object.fromEntries(
28
+ readdirSync(iconDir)
29
+ .filter((f) => f.endsWith('.svg'))
30
+ .map((f) => [f.replace(/\.svg$/, ''), readFileSync(resolve(iconDir, f), 'utf-8')]),
31
+ )
32
+
33
+ /*
34
+ ⚠ The rename is load-bearing, not cosmetic.
35
+
36
+ Slidev's own config already installs presetIcons (for its `slidev` collection), and UnoCSS
37
+ de-duplicates presets BY NAME — `resolveConfig` runs
38
+ `uniqueBy(presets, (a, b) => a.name === b.name)`. A second preset called
39
+ "@unocss/preset-icons" is therefore dropped in silence, taking `collections: { forest }` with
40
+ it: no error, no warning, just no `i-forest-*` rules ever generated. Naming this one
41
+ differently is what keeps both alive.
42
+
43
+ `autoInstall: false` so an unknown `i-<something>-*` on a slide never tries to fetch an
44
+ Iconify package from the network mid-presentation; unknown names simply fall through to
45
+ Slidev's preset.
46
+ */
47
+ const forestIcons = presetIcons({
48
+ collections: { forest },
49
+ autoInstall: false,
50
+ /*
51
+ `width`/`height` are NOT optional here. presetIcons forwards `extraProperties` into Iconify's
52
+ SVG customiser (`customizations.additionalProps`), and the forest SVGs carry their own
53
+ `width="24" height="24"`, so the generated rule came out with a mask and no box — every icon
54
+ rendered 0×0 with the CSS present and correct. Sizing them in `em` makes an icon scale with
55
+ the text tier it sits in, which is what `text-6xl` on the element is meant to do.
56
+
57
+ display/vertical-align: icons sit beside 36–48px body copy and are read at distance, so they
58
+ align to the middle of the text box rather than UnoCSS's default baseline nudge.
59
+ */
60
+ extraProperties: {
61
+ width: '1em',
62
+ height: '1em',
63
+ display: 'inline-block',
64
+ 'vertical-align': 'middle',
65
+ },
66
+ })
67
+ forestIcons.name = 'forest-icons'
68
+
69
+ /*
70
+ Second job: register LUCIDE, alongside forest rather than in place of it — Jose's own direction
71
+ ("icons should come from lucide or from our own icon library") plus the concrete gap it closes:
72
+ a `checklist` preset was reworded away from a check icon on the false premise the design system
73
+ has no tick. It does — in Lucide, not Forest. `<IconTile>` (components/IconTile.vue) draws its
74
+ own icons via a CSS mask built from a handful of pre-extracted local .svg files, never through
75
+ this preset's `i-lucide-*` classes — that file's own doc comment explains why (the exact same
76
+ "a class assembled from a runtime prop is invisible to UnoCSS's static scanner" trap this file's
77
+ own comment above already documents for forest). So this registration exists for its OWN sake,
78
+ independent of IconTile: any deck author (or a future component) can drop a raw
79
+ `i-lucide-<name>` class straight onto an element, no wrapper required, and it works precisely
80
+ because THAT class is a literal string UnoCSS's scanner can actually see.
81
+
82
+ `@iconify-json/lucide/icons.json` is 543 KB — collections here are ALWAYS resolved in Node, at
83
+ config-eval / build time, never shipped to a deck's own bundle (see this file's own comment on
84
+ `forest` above: presetIcons only ever emits CSS for classes actually found in scanned source).
85
+ Loaded as a function, not the eager `readFileSync` forest gets, specifically so nothing forces
86
+ it to resolve at all unless some `i-lucide-*` class is actually found — the standard UnoCSS
87
+ recipe for a collection too big to read eagerly. `packages/slidev-theme/test/uno.static.test.ts`
88
+ proves this registration actually produces a rule (and that an UNREFERENCED name — the
89
+ `zodiac-virgo` canary — never does), rather than trusting the de-dup trap stayed closed.
90
+ */
91
+ const lucideIcons = presetIcons({
92
+ collections: {
93
+ lucide: () => import('@iconify-json/lucide/icons.json').then((m) => m.default),
94
+ },
95
+ autoInstall: false,
96
+ extraProperties: {
97
+ width: '1em',
98
+ height: '1em',
99
+ display: 'inline-block',
100
+ 'vertical-align': 'middle',
101
+ },
102
+ })
103
+ lucideIcons.name = 'lucide-icons'
104
+
105
+
106
+ /*
107
+ THE DECK'S OWN SPACING, AS UTILITIES.
108
+
109
+ Slidev ships presetWind3, and the deck runs it — the theme patched @slidev/client onto presetWind4
110
+ for a while and the patch is gone, because measuring both showed the deck renders identically
111
+ either way and a patched dependency cannot travel to anyone who installs this theme from npm.
112
+ So a deck gets Tailwind v3's vocabulary on the default 4px
113
+ scale. Most of this system's ladder LANDS on that scale by arithmetic — `p-10` is 40px, which is
114
+ --deck-space-5 — and that coincidence is exactly the problem: it is not the token. Retune
115
+ --deck-grid and every `p-10` in every deck silently means something else while every
116
+ `var(--deck-space-5)` follows. One of the ladder's own numbers does not even land: 90px is 22.5
117
+ steps, and presetWind3 does not generate `p-22.5` at all.
118
+
119
+ So the scale is registered by NAME, pointing at the generated custom properties rather than at
120
+ numbers. `p-deck-inset-text` compiles to `padding: var(--deck-inset-text)` — one source of truth, and a
121
+ utility that cannot drift from the token it is named after.
122
+
123
+ Values, not literals, for the same reason scripts/deck.ts emits them: the engine owns the numbers.
124
+ */
125
+ const deckSpacing = Object.fromEntries([
126
+ /*
127
+ ★★ EVERY NAME CARRIES THE `deck-` PREFIX, and the rungs are the reason it started.
128
+
129
+ The preset's numeric scale reads the digit in `p-4` as Tailwind's own step — 1rem, 16px. This
130
+ system reads the digit in --deck-space-4 as a RUNG INDEX, so it is 32px. Same digit, half the
131
+ value. While the utility was `p-space-4` the two sat beside each other looking like a long and a
132
+ short spelling of one idea, and reaching for the wrong one halved the spacing with nothing to
133
+ notice.
134
+
135
+ The insets never had that problem — checked, not assumed: `inset-text` and `top-inset-text`
136
+ generate nothing on their own, and the preset's positional `inset-4` is untouched by registering
137
+ an `inset-text` SPACING key. They are prefixed anyway, because a vocabulary where some names say
138
+ whose they are and others do not is a vocabulary you have to remember rather than read. The cost
139
+ is length — `p-deck-inset-text` — and the gain is that every Forest utility is recognisable as
140
+ one on sight, and no future wind4 key can collide with any of them.
141
+
142
+ The prefix is on the UTILITY name only. The tokens keep their own names, and every value here
143
+ still points at one, so nothing is restated.
144
+ */
145
+ ...[1, 2, 3, 4, 5, 6, 7].map((n) => [`deck-${n}`, `var(--deck-space-${n})`]),
146
+ ['deck-grid', 'var(--deck-grid)'],
147
+ ['deck-inset-chrome', 'var(--deck-inset-chrome)'],
148
+ ['deck-inset-media', 'var(--deck-inset-media)'],
149
+ ['deck-inset-text', 'var(--deck-inset-text)'],
150
+ ['deck-inset-display', 'var(--deck-inset-display)'],
151
+ ]);
152
+
153
+ export default defineConfig({
154
+ presets: [forestIcons, lucideIcons],
155
+ /*
156
+ THE CODE-GROUP TAB ICONS — the file-type marks on a ```::code-group``` tab strip.
157
+
158
+ @slidev/client's internals/TitleIcon.vue matches a tab title against a table of
159
+ `i-vscode-icons:file-type-*` names, so `[Panel.vue]` gets the mark an editor would show it with.
160
+ These are the one place a full-colour third-party icon is right rather than off-brand: a file
161
+ mark is shared developer furniture, read the way a file extension is. They are not part of the
162
+ deck's own icon vocabulary — forest and lucide are what a slide reaches for.
163
+
164
+ ★ SAFELISTED, BECAUSE NOTHING SCANS THE FILE THAT ASKS FOR THEM. TitleIcon.vue lives inside
165
+ node_modules/@slidev/client, outside UnoCSS's scan, and picks its class at RUNTIME — so without
166
+ these lines the tab resolves a class against CSS that was never generated and renders an empty
167
+ box beside the label. That is the whole fix: the COLLECTION needs no registration of its own,
168
+ because presetIcons resolves `@iconify-json/vscode-icons` out of node_modules once it is a
169
+ dependency (verified from both the theme's directory and a deck's, with and without an explicit
170
+ `collections:` entry — it resolves either way, so the entry was deleted rather than kept as
171
+ decoration).
172
+
173
+ Listed by hand rather than imported from Slidev's table: a safelist is a promise about the built
174
+ stylesheet and should read as one. These are the extensions a Forest deck writes a tab for; a
175
+ new one is one line, and uno.static.test.ts proves each still resolves, so a name that moves
176
+ upstream fails here rather than on a slide.
177
+ */
178
+ safelist: [
179
+ 'i-vscode-icons:file-type-vue',
180
+ 'i-vscode-icons:file-type-typescript',
181
+ 'i-vscode-icons:file-type-js',
182
+ 'i-vscode-icons:file-type-json',
183
+ 'i-vscode-icons:file-type-markdown',
184
+ 'i-vscode-icons:file-type-python',
185
+ 'i-vscode-icons:file-type-html',
186
+ 'i-vscode-icons:file-type-css',
187
+ 'i-vscode-icons:file-type-light-yaml',
188
+ 'i-vscode-icons:file-type-node',
189
+ 'i-vscode-icons:file-type-bun',
190
+ /* ★★ THESE THREE ARE REQUESTED BY A TITLE MARKER, NOT BY SLIDEV'S OWN LOOKUP. Slidev's
191
+ TitleIcon.vue matches an extension against a closed `builtinIcons` map and renders
192
+ `<div v-if="matchIcon(title)">`; go, sql and shell are not on that map, so it emits no element
193
+ at all and a safelist entry alone would never be asked for. The generator appends a `~…~` icon
194
+ marker to those tabs (see TAB_ICONS in scripts/deckSpecimen.ts), which is the hook matchIcon
195
+ consults first — that is what makes these three reachable. */
196
+ 'i-vscode-icons:file-type-go',
197
+ 'i-vscode-icons:file-type-sql',
198
+ 'i-vscode-icons:file-type-shell',
199
+ ],
200
+ /*
201
+ `spacing` feeds p/m/gap/space/inset utilities alike, so one table covers `p-deck-inset-text`,
202
+ `gap-deck-5`, `mt-deck-3` and the rest without a rule per property.
203
+ */
204
+ theme: {
205
+ spacing: deckSpacing,
206
+ /*
207
+ ★★ WIND4 EMITS `--font-sans` AND `--font-mono` ITSELF, and that is a collision with this
208
+ design system rather than a coincidence — Tailwind v4 exposes its theme AS custom properties,
209
+ and those two names are already ours (styles/tokens.css declares them on :root, pointing at
210
+ GT Haptik and JetBrains Mono). wind4's preflight declares them again at the same specificity
211
+ and wins on order, so every type role that resolves through `var(--font-sans)` fell back to
212
+ `ui-sans-serif, system-ui, …`.
213
+
214
+ MEASURED after the preset swap: GT Haptik was LOADED — `document.fonts` said so — and no text
215
+ on the deck was set in it, because `--type-deck-body-family` had quietly become the browser's
216
+ own stack.
217
+
218
+ Slidev's own wiring does not save it: setups/unocss.ts assigns `config.theme.fontFamily.sans`,
219
+ which is wind3's shape. wind4 reads `theme.font`, so that assignment silently does nothing.
220
+
221
+ Declaring them here means the value wind4 emits IS the deck's, so there is one declaration
222
+ rather than two fighting. Literals rather than `var(--font-sans)`, which would be circular —
223
+ wind4 would emit `--font-sans: var(--font-sans)`. Pinned to tokens.css by
224
+ packages/slidev-theme/test/uno.static.test.ts.
225
+ */
226
+ font: {
227
+ sans: "'GT Haptik', system-ui, sans-serif",
228
+ mono: "'JetBrains Mono', ui-monospace, monospace",
229
+ },
230
+ },
231
+ })
@@ -0,0 +1,200 @@
1
+ // Measure whatever running chrome is sitting on a panel, and return the clip-path that seats it.
2
+ //
3
+ // WHY MEASURED, WHEN THE FIRST NOTCH WAS DECLARED. The original cut was sized from a token because
4
+ // its one occupant — the Forest mark — is 72px and always has been. Everything added since has a
5
+ // size that CSS cannot know:
6
+ //
7
+ // · the section pill's width is a section NAME;
8
+ // · the page marker is a stadium that grows with the digit count;
9
+ // · the confidentiality pill's width is an author's own string.
10
+ //
11
+ // A token cannot state any of those, and hard-coding a generous cut for the worst case would draw a
12
+ // hole far larger than the thing it holds. So the panel asks the DOM what is actually on it.
13
+ //
14
+ // THE COST, stated plainly: a measured cut cannot be correct on the FIRST frame, and the earlier
15
+ // declared-not-measured note in notch.js was right that this matters. It is mitigated rather than
16
+ // solved — styles/base.css still carries the single-notch clip-path for the mark, so the first
17
+ // painted frame is the old, correct-for-the-common-case shape, and this refines it on mount. A
18
+ // slide changing shape one frame in is a smaller defect than a cut that never fits its occupant.
19
+ //
20
+ // WHAT IT DOES NOT DO: reflow. Clipping removes paint, not space — a panel's own content still has
21
+ // to keep out of its corners, which is what the layouts' own padding is for.
22
+ import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
23
+ import { notchPath, occupantFor } from './notchPath.js';
24
+
25
+ /**
26
+ * Every chrome element that can land on a panel. Queried by class from the slide root rather than
27
+ * passed in, because chrome is a SIBLING of the layout (SlideWrapper mounts both), so there is no
28
+ * component boundary to hand them across and no shared ancestor to provide through.
29
+ *
30
+ * The page marker and the confidentiality pill are addressed by their own wrappers rather than by
31
+ * `.badge-strip-item`, so a future strip of tags somewhere else on the slide cannot be mistaken for
32
+ * running chrome and start cutting holes in panels.
33
+ */
34
+ const CHROME_SELECTORS = [
35
+ '.deck-chrome-mark',
36
+ '.deck-chrome-pills .badge-strip-item',
37
+ '.deck-page-dot',
38
+ /*
39
+ ⚠ `.deck-confidential-pill`, NOT `.badge-strip-item`, AND IT WAS THE WRONG ONE FOR A WHILE. The
40
+ marking used to be `BadgeStrip variant="outline"`; ConfidentialMark.vue records the split that
41
+ ended that, and this selector was not moved with it. It kept matching nothing — measured on the
42
+ specimen's own marking slide, 0 hits for the old selector and 1 for this one — so the notch was
43
+ blind to the one piece of chrome a cover, a split panel or a stack band can still be carrying.
44
+
45
+ ★ classRefs.static.test.ts could not catch it. That gate asks whether every hardcoded class is
46
+ one the theme EMITS, and `.badge-strip-item` is emitted — by BadgeStrip, elsewhere. A compound
47
+ selector whose two halves are both real and never co-occur is invisible to it.
48
+ */
49
+ '.deck-chrome-confidential .deck-confidential-pill',
50
+ ];
51
+
52
+ /**
53
+ * An occupant's OWN corner radius, so the cut around it can be concentric.
54
+ *
55
+ * Read from the element rather than passed in: the mark is a squircle whose corner is 21.87% of its
56
+ * size, a pill is a stadium, and a future occupant will be something else again. `--deck-radius-full`
57
+ * resolves to 9999px, which is not a radius so much as "as round as this box gets" — for a stadium
58
+ * that is half its height, and using the literal would ask for a cut radius of ~10,000px.
59
+ */
60
+ const ownRadius = (el) => {
61
+ const r = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
62
+ const half = Math.min(el.offsetWidth, el.offsetHeight) / 2;
63
+ return Math.min(r, half);
64
+ };
65
+
66
+ /**
67
+ * The ring and the card radius are READ FROM THE PANEL'S OWN COMPUTED STYLE rather than passed in.
68
+ * They are already tokens (--deck-notch-gap, --deck-radius-card) that the panel resolves anyway, and
69
+ * a caller restating them in JS is how two sources of one number start to disagree.
70
+ *
71
+ * @param {import('vue').Ref<HTMLElement|null>} panelRef the element to cut
72
+ * @param {object} opts
73
+ * @param {() => boolean} opts.enabled whether this slide wants a cut at all (chrome, notch, media)
74
+ * @param {string[]} [opts.occupants] EXTRA selectors to seat, beyond the running chrome. A cover
75
+ * carries no chrome at all — that is the point of it — and still has something in a corner: its
76
+ * own lockup. Passing the selector in keeps ONE implementation of the cut rather than a second
77
+ * one in the layout, which is what a first attempt at this did, reproducing both of the bugs
78
+ * documented inside `measure()` (screen-vs-CSS pixels, and parseFloat of a `calc()` custom
79
+ * property) within twenty lines of the notes that record them.
80
+ * @returns {{ clip: import('vue').Ref<string>, remeasure: () => void }} a `path(...)` value, or ''
81
+ * before the first measurement — which is when styles/base.css's own single-notch rule applies.
82
+ */
83
+ export const useNotchCuts = (panelRef, opts) => {
84
+ const clip = ref('');
85
+ let ro = null;
86
+ let raf = 0;
87
+
88
+ const measure = () => {
89
+ const panel = panelRef.value;
90
+ if (!panel) return;
91
+ if (!opts.enabled()) { clip.value = ''; return; }
92
+
93
+ const slide = panel.closest('.slidev-page') ?? panel.ownerDocument;
94
+ const rect = panel.getBoundingClientRect();
95
+ if (!(rect.width > 0) || !(rect.height > 0)) return;
96
+
97
+ /*
98
+ ★★ EVERYTHING IS CONVERTED TO CSS PIXELS FIRST, and this is not a nicety.
99
+
100
+ A slide is a transform-scaled box, so getBoundingClientRect() returns SCREEN pixels while
101
+ offsetWidth, the tokens and the path all speak CSS pixels. Mixing them is silent: the first
102
+ version added a 24px CSS ring to a screen-pixel measurement and drew a cut 76.8 wide around an
103
+ occupant whose edge was at 70.4 — a 6.4px gap where the deck declares 24. It looked plausible,
104
+ because everything was still roughly notch-shaped. Reported by eye as a lost margin.
105
+ */
106
+ const scale = panel.offsetWidth / rect.width;
107
+ const toCss = (r) => ({
108
+ left: (r.left - rect.left) * scale,
109
+ top: (r.top - rect.top) * scale,
110
+ right: (r.right - rect.left) * scale,
111
+ bottom: (r.bottom - rect.top) * scale,
112
+ width: r.width * scale,
113
+ height: r.height * scale,
114
+ });
115
+ // The panel in its own CSS-pixel space, which is the frame the path is written in.
116
+ const panelBox = { left: 0, top: 0, right: panel.offsetWidth, bottom: panel.offsetHeight,
117
+ width: panel.offsetWidth, height: panel.offsetHeight };
118
+
119
+ /*
120
+ ★ RESOLVE, don't parse. getComputedStyle().getPropertyValue() returns a custom property's RAW
121
+ DECLARED TEXT, not its computed value — `--deck-notch-gap` reads back as the literal string
122
+ "calc(40px * 0.6)", and parseFloat() of that is NaN. The first version of this fell straight
123
+ through to its own hard-coded fallback and looked correct only because the fallback happened to
124
+ equal the token. Measured: ring came back null, and the cut was drawn from a number this file
125
+ invented rather than the one the deck declares.
126
+
127
+ A throwaway element is the only way to make the browser evaluate the expression: give it the
128
+ var as a width and read the width back.
129
+ */
130
+ const probe = document.createElement('div');
131
+ probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none';
132
+ panel.appendChild(probe);
133
+ /*
134
+ ⚠ `>= 0`, NOT `> 0`. Zero is a REAL value for both of these and the strict test threw it away:
135
+ a flush picture set sets `--deck-radius-card: 0px` so its inner corners meet square, the probe
136
+ resolved 0, the guard read that as "unset" and the cut was drawn with the hard-coded 48 —
137
+ putting a rounded bite back in the middle of a set whose whole point is that it has no seam.
138
+ An UNSET var is what the fallback is for, and an unset var gives `auto`, hence NaN.
139
+ */
140
+ const px = (name, fallback) => {
141
+ probe.style.width = `var(${name})`;
142
+ const v = parseFloat(getComputedStyle(probe).width);
143
+ return Number.isFinite(v) && v >= 0 ? v : fallback;
144
+ };
145
+ const ring = px('--deck-notch-gap', 24);
146
+ const notches = {};
147
+ for (const sel of [...CHROME_SELECTORS, ...(opts.occupants ?? [])]) {
148
+ for (const el of slide.querySelectorAll(sel)) {
149
+ const cut = occupantFor(toCss(el.getBoundingClientRect()), panelBox, ring);
150
+ if (!cut) continue;
151
+ const r = ownRadius(el) + ring;
152
+ // Two occupants in one corner (the mark and a companion are one box, but a pill row need not
153
+ // be) take the UNION: the deeper cut wins on each axis, and the larger radius with it, so
154
+ // neither is left sitting on the panel.
155
+ const prev = notches[cut.corner];
156
+ notches[cut.corner] = prev
157
+ ? { w: Math.max(prev.w, cut.w), h: Math.max(prev.h, cut.h), r: Math.max(prev.r, r) }
158
+ : { w: cut.w, h: cut.h, r };
159
+ }
160
+ }
161
+
162
+ const cardRadius = px('--deck-radius-card', 48);
163
+ probe.remove();
164
+
165
+ const path = notchPath({
166
+ w: panel.offsetWidth,
167
+ h: panel.offsetHeight,
168
+ radius: cardRadius,
169
+ notches,
170
+ });
171
+ clip.value = `path('${path}')`;
172
+ };
173
+
174
+ // Coalesce to one measurement per frame. Chrome, panel and fonts all settle independently, and a
175
+ // ResizeObserver firing per element would rebuild the path several times for one change.
176
+ const schedule = () => {
177
+ if (raf) return;
178
+ raf = requestAnimationFrame(() => { raf = 0; measure(); });
179
+ };
180
+
181
+ onMounted(() => {
182
+ schedule();
183
+ // Fonts land after first paint and change every pill's width — without this the cut is sized to
184
+ // the fallback face's metrics and stays there.
185
+ if (typeof document !== 'undefined' && document.fonts?.ready) document.fonts.ready.then(schedule);
186
+ if (typeof ResizeObserver === 'undefined') return;
187
+ ro = new ResizeObserver(schedule);
188
+ const panel = panelRef.value;
189
+ if (panel) {
190
+ ro.observe(panel);
191
+ const slide = panel.closest('.slidev-page');
192
+ if (slide) for (const sel of CHROME_SELECTORS) for (const el of slide.querySelectorAll(sel)) ro.observe(el);
193
+ }
194
+ });
195
+
196
+ onBeforeUnmount(() => { ro?.disconnect(); if (raf) cancelAnimationFrame(raf); });
197
+ watch(() => opts.enabled(), schedule);
198
+
199
+ return { clip, remeasure: schedule };
200
+ };