@humanforest/slidev-theme 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +67 -0
- package/README.md +577 -0
- package/assetUrl.js +38 -0
- package/components/Alert.vue +209 -0
- package/components/AreaBox.vue +152 -0
- package/components/AreaChart.vue +145 -0
- package/components/Avatar.vue +267 -0
- package/components/AvatarGroup.vue +94 -0
- package/components/BadgeStrip.vue +203 -0
- package/components/Bars.vue +207 -0
- package/components/BoroughShape.vue +127 -0
- package/components/BrowserFrame.vue +484 -0
- package/components/CalendarGrid.vue +480 -0
- package/components/Card.vue +70 -0
- package/components/Carousel.vue +266 -0
- package/components/ChartFrame.vue +426 -0
- package/components/ChordChart.vue +264 -0
- package/components/ComposedChart.vue +187 -0
- package/components/Contents.vue +86 -0
- package/components/DataTable.vue +244 -0
- package/components/DeckMap.vue +409 -0
- package/components/DeviceFrame.vue +331 -0
- package/components/DonutChart.vue +103 -0
- package/components/DottedMap.vue +126 -0
- package/components/Endorsement.vue +76 -0
- package/components/Grid.vue +169 -0
- package/components/GroupedBarChart.vue +141 -0
- package/components/IconTile.vue +116 -0
- package/components/Kpi.vue +188 -0
- package/components/LineChart.vue +133 -0
- package/components/Logo.vue +203 -0
- package/components/LondonMap.vue +348 -0
- package/components/MarkPair.vue +109 -0
- package/components/Marquee.vue +263 -0
- package/components/NestedDonutChart.vue +295 -0
- package/components/Placeholder.vue +88 -0
- package/components/SankeyChart.vue +223 -0
- package/components/ScatterChart.vue +142 -0
- package/components/StackedBarChart.vue +143 -0
- package/components/StatCard.vue +134 -0
- package/components/StatusTrack.vue +334 -0
- package/components/Timeline.vue +249 -0
- package/components/TimelineChart.vue +329 -0
- package/components/TreemapChart.vue +267 -0
- package/components/backgrounds.js +135 -0
- package/components/boroughGeometry.ts +59 -0
- package/components/chartData.ts +277 -0
- package/components/chartTip.ts +201 -0
- package/components/codeTheme.ts +239 -0
- package/components/deckPalette.ts +157 -0
- package/components/dottedMapGeometry.js +60 -0
- package/components/iconSlugs.js +55 -0
- package/components/iconUrl.js +51 -0
- package/components/mermaidTheme.ts +972 -0
- package/components/motionDuration.ts +41 -0
- package/components/tableRules.ts +257 -0
- package/confidential-mark.vue +151 -0
- package/custom-nav-controls.vue +79 -0
- package/endorsements/b-corp-black.svg +30 -0
- package/endorsements/b-corp-white.svg +30 -0
- package/global-top.vue +1662 -0
- package/icons/forest/bike-asterisk.svg +21 -0
- package/icons/forest/bike-down.svg +19 -0
- package/icons/forest/bike-off.svg +20 -0
- package/icons/forest/bike-up.svg +19 -0
- package/icons/forest/bike-x.svg +19 -0
- package/icons/forest/bike.svg +18 -0
- package/icons/forest/mark-fill.svg +6 -0
- package/icons/forest/mark.svg +6 -0
- package/icons/forest/parking.svg +3 -0
- package/icons/forest/star-fill.svg +4 -0
- package/icons/forest/star.svg +4 -0
- package/icons/forest/traffic-light-caution.svg +8 -0
- package/icons/forest/traffic-light-go.svg +8 -0
- package/icons/forest/traffic-light-lit.svg +8 -0
- package/icons/forest/traffic-light-stop.svg +8 -0
- package/icons/forest/traffic-light.svg +8 -0
- package/icons/lucide/battery.svg +1 -0
- package/icons/lucide/bike.svg +1 -0
- package/icons/lucide/chart-line.svg +1 -0
- package/icons/lucide/check.svg +1 -0
- package/icons/lucide/circle-check.svg +1 -0
- package/icons/lucide/clock.svg +1 -0
- package/icons/lucide/code.svg +1 -0
- package/icons/lucide/component.svg +1 -0
- package/icons/lucide/frame.svg +1 -0
- package/icons/lucide/layout-grid.svg +1 -0
- package/icons/lucide/map-pin.svg +1 -0
- package/icons/lucide/map.svg +1 -0
- package/icons/lucide/palette.svg +1 -0
- package/icons/lucide/table.svg +1 -0
- package/icons/lucide/triangle-alert.svg +1 -0
- package/icons/lucide/type.svg +1 -0
- package/icons/lucide/wrench.svg +1 -0
- package/layouts/bleed.vue +317 -0
- package/layouts/cover.vue +1514 -0
- package/layouts/default.vue +187 -0
- package/layouts/divider.vue +286 -0
- package/layouts/end.vue +384 -0
- package/layouts/quote.vue +218 -0
- package/layouts/split.vue +1875 -0
- package/layouts/stack.vue +944 -0
- package/layouts/statement.vue +67 -0
- package/manifest/chrome.ts +36 -0
- package/manifest/components.ts +49 -0
- package/manifest/index.ts +57 -0
- package/manifest/layouts.ts +37 -0
- package/manifest/manifest.json +6514 -0
- package/manifest/presets.ts +635 -0
- package/notch.js +458 -0
- package/notchPath.js +188 -0
- package/package.json +77 -0
- package/section.js +110 -0
- package/setup/mermaid-renderer.ts +2120 -0
- package/setup/mermaid.ts +30 -0
- package/setup/shiki.ts +44 -0
- package/setup/shortcuts.ts +28 -0
- package/slide-bottom.vue +305 -0
- package/slide-top.vue +268 -0
- package/styles/base.css +2611 -0
- package/styles/index.ts +71 -0
- package/styles/tokens.css +144 -0
- package/uno.config.ts +231 -0
- package/useNotchCuts.js +200 -0
package/styles/index.ts
ADDED
|
@@ -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
|
+
})
|
package/useNotchCuts.js
ADDED
|
@@ -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
|
+
};
|