@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/notch.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// The notch, and the chrome mirror it implies — the shared decisions, split out of the three
|
|
2
|
+
// components that have to agree on them so they are one rule rather than three copies.
|
|
3
|
+
//
|
|
4
|
+
// WHAT THE NOTCH IS. A split slide's figure side is a PANEL — a photo frame or a tint — and its
|
|
5
|
+
// outer top corner is cut INWARD so the running Forest mark can sit in the cut-out. The panel's
|
|
6
|
+
// edge then reads convex → concave → convex around the mark, which is what makes the mark look
|
|
7
|
+
// seated INTO the panel rather than parked on top of it. split.vue owns the cut (a mask, see its
|
|
8
|
+
// own `.has-notch` comment); slide-top.vue owns the mark. Neither may decide on its own whether
|
|
9
|
+
// there is a notch, because a cut with nothing in it is a hole and a mark with nothing cut for it
|
|
10
|
+
// is the collision this replaced.
|
|
11
|
+
//
|
|
12
|
+
// WHY A PLAIN MODULE. packages/slidev-theme's suite runs under plain Node with no DOM
|
|
13
|
+
// (vitest.config.ts: `environment: 'node'`) and never mounts a component, so a computed buried in
|
|
14
|
+
// slide-top.vue's <script setup> is unreachable from a test. Same move, same reason, as section.js
|
|
15
|
+
// and backgrounds.js: the decision is a pure function taking plain data.
|
|
16
|
+
//
|
|
17
|
+
// ★ THE MIRROR, AND THE ONE JUDGEMENT CALL IN IT. Jose: "this should work on either order... if
|
|
18
|
+
// text is left or right, the notch position should adapt (and move the page to the other side,
|
|
19
|
+
// along with the confidenciality chip)". The rule in one sentence: CHROME SITS ON THE TEXT SIDE,
|
|
20
|
+
// THE MARK SITS ON THE PANEL SIDE. Everything else falls out of split's existing `flip` prop.
|
|
21
|
+
//
|
|
22
|
+
// `flip` is frontmatter, so slide-top.vue and slide-bottom.vue can read it exactly the way they
|
|
23
|
+
// already read `chrome` and `confidential` — no new plumbing, no prop drilling, no coupling to the
|
|
24
|
+
// layout component.
|
|
25
|
+
//
|
|
26
|
+
// The call: the chrome mirrors on `layout === 'split'` AND `flip === true`, never on `flip`
|
|
27
|
+
// alone. `flip` is a plain frontmatter key, so any slide can carry one — a `default` slide with a
|
|
28
|
+
// stray `flip: true` (copy-pasted frontmatter, an AI's guess) has no panel and no second column,
|
|
29
|
+
// and swinging its page number across the canvas would be a silent, unexplainable move. Gating on
|
|
30
|
+
// the layout that actually splits means the mirror only fires where there is something to mirror
|
|
31
|
+
// AROUND. bleed is the same case for the same reason: it fills the canvas, has no panel side, and
|
|
32
|
+
// must keep the default arrangement and grow no notch.
|
|
33
|
+
|
|
34
|
+
/** The one layout whose `flip` means "which half is the text" — the only one the chrome mirrors for. */
|
|
35
|
+
export const SPLIT_LAYOUT = 'split';
|
|
36
|
+
|
|
37
|
+
/** The one layout whose figure spans the WHOLE canvas width, so both ends of the chrome sit over it. */
|
|
38
|
+
export const STACK_LAYOUT = 'stack';
|
|
39
|
+
|
|
40
|
+
/** `frame` values that make split's figure side a real PANEL (rather than a second content column). */
|
|
41
|
+
/*
|
|
42
|
+
* The frame kinds that are a REAL FIGURE — one that fills its cell top to bottom, so the running
|
|
43
|
+
* mark would be drawn over it and the corner has to be cut.
|
|
44
|
+
*
|
|
45
|
+
* ★ `figure` joined `photo` and `panel` when it was added: it is the kind for a COMPONENT figure (a
|
|
46
|
+
* live map, a chart) and it fills the cell exactly as the other two do. Measured before adding it —
|
|
47
|
+
* a full-height component on the figure side runs its top corner straight under the Forest mark,
|
|
48
|
+
* which is the collision this list exists to describe. `none` stays out because that side is a
|
|
49
|
+
* second column of TEXT, which the chrome sits above rather than on.
|
|
50
|
+
*/
|
|
51
|
+
export const PANEL_FRAMES = ['photo', 'panel', 'figure', 'code'];
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
★★ THE CHROME NO LONGER MIRRORS, AND THE NOTCH IS WHAT PAID FOR IT.
|
|
55
|
+
|
|
56
|
+
`isMirrored(layout, side)` and `chromeSides(mirrored)` lived here. A `flip: true` split puts the
|
|
57
|
+
figure on the LEFT, so the notch was cut from the figure's top-left and the mark travelled there
|
|
58
|
+
to sit in it, with the pills pushed to the far end. The mark and the cut were one mechanism: the
|
|
59
|
+
bite exists so the mark has somewhere to sit.
|
|
60
|
+
|
|
61
|
+
Jose, looking at a rendered mirrored slide: "i dont think we want to mirror the chrome". So the
|
|
62
|
+
mark is fixed top-right and the row never reverses.
|
|
63
|
+
|
|
64
|
+
★★ AND THE NOTCH SURVIVES IT, WHICH THE FIRST ATTEMPT AT THIS GOT WRONG. That attempt reasoned
|
|
65
|
+
that the cut exists to seat the MARK, so a figure the mark can no longer reach must not be bitten,
|
|
66
|
+
and gated hasNotch on `flip`. That is false, and ./useNotchCuts.js says so in its own first line:
|
|
67
|
+
it measures whatever running chrome is sitting on the panel — the mark, each section pill item,
|
|
68
|
+
the page marker, the confidentiality pill — assigns each to the corner it lands in, and cuts all
|
|
69
|
+
of them. Jose: "ffs there could still be a section pill... dont kill the notch".
|
|
70
|
+
|
|
71
|
+
On a `flip: true` split the figure is on the LEFT, which is exactly where the pills now are. So
|
|
72
|
+
the cut is still needed there; what changed is only which occupant it seats. Nothing in hasNotch
|
|
73
|
+
had to know about sides at all.
|
|
74
|
+
|
|
75
|
+
Not left as a function returning false. A predicate that always answers the same way reads as a
|
|
76
|
+
live decision to the next person and is a branch nobody can ever exercise; the arrangement is
|
|
77
|
+
fixed in slide-top.vue's and slide-bottom.vue's own CSS instead. scripts/deckAudit.ts still
|
|
78
|
+
measures the chrome's corners per slide, so a mirror reappearing is caught by the rendering rather
|
|
79
|
+
than trusted to this comment.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Is the running chrome actually drawn on this slide? The same predicate slide-top.vue has always
|
|
84
|
+
* used for its own `v-if`, lifted here because split.vue now needs the SAME answer: a notch is only
|
|
85
|
+
* ever correct when there is a mark to put in it, and `chrome: false` (or slide 1, which carries no
|
|
86
|
+
* page number worth reading) means there is not.
|
|
87
|
+
*
|
|
88
|
+
* ★★ A DIVIDER CARRIES NO CHROME UNLESS IT ASKS FOR SOME. It is the one slide that takes over the
|
|
89
|
+
* whole field, so the running furniture is the thing it is taking the field FROM — and the pill it
|
|
90
|
+
* would draw names the section this very slide is announcing, which is the heading already filling
|
|
91
|
+
* the middle of it. Every divider in both decks passed `chrome: false` by hand; that is a default,
|
|
92
|
+
* written nine times.
|
|
93
|
+
*
|
|
94
|
+
* ⚠ AND IT WAS NOT ONLY REDUNDANT, IT COLLIDED. The section pill paints the deck's own green, and
|
|
95
|
+
* the section rank now paints that same green full-bleed — measured on the rank specimen, the pill
|
|
96
|
+
* vanished into the ground it sat on while still passing every contrast check, because white text
|
|
97
|
+
* on green scores 6.27 whether or not the chip behind it is visible.
|
|
98
|
+
*
|
|
99
|
+
* `??`, so an explicit `chrome: true` still brings it back — the default moves, the lever does not.
|
|
100
|
+
*
|
|
101
|
+
* ★ SPLIT IN TWO because the two chrome rows do not ask the same question: the bottom row has never
|
|
102
|
+
* carried the `currentPage > 1` clause, since slide 1 has no page number worth reading at the TOP
|
|
103
|
+
* and that says nothing about the row underneath. `chromeWanted` is the half they share, and it is
|
|
104
|
+
* the only place in this theme where a layout name decides VISIBILITY rather than position.
|
|
105
|
+
*
|
|
106
|
+
* @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
|
|
107
|
+
* @param {number} currentPage Slidev's own one-based page number.
|
|
108
|
+
*/
|
|
109
|
+
/*
|
|
110
|
+
★★ A COVER JOINS THE DIVIDER, AND FOR A SHARPER REASON. A divider suppresses the chrome because
|
|
111
|
+
the chrome would repeat what the slide is already shouting. A cover suppresses it because the
|
|
112
|
+
chrome is a RUNNING mark — a page number, a section pill, the identity — and an opener is the
|
|
113
|
+
slide none of those has started running on yet. It also carried the Forest mark twice: once as the
|
|
114
|
+
running identity top-right, once as the lockup cover.vue signs with.
|
|
115
|
+
|
|
116
|
+
⚠ THE CONFIDENTIALITY MARKING IS NOT PART OF THIS. slide-bottom.vue draws it on its own condition
|
|
117
|
+
rather than through `chromeShows`, precisely so a title slide can still carry its marking — which
|
|
118
|
+
is the one piece of chrome a real deck wants on a cover, and the reason this is a default here
|
|
119
|
+
rather than `chrome: false` written on every cover by hand.
|
|
120
|
+
*/
|
|
121
|
+
/* ★ `end` JOINED THE LIST RATHER THAN CARRYING `chrome: false` ON EVERY SLIDE. Jose: "end layouts
|
|
122
|
+
should not have chrome never". A closer is the last frame of a deck — a page number and a running
|
|
123
|
+
section pill on it are furniture for a deck that is still going. Written as a default here, an
|
|
124
|
+
author who genuinely wants the chrome back can still say `chrome: true`; written as frontmatter on
|
|
125
|
+
every closing slide, it is a line nobody remembers. */
|
|
126
|
+
const CHROMELESS_LAYOUTS = ['divider', 'cover', 'end'];
|
|
127
|
+
|
|
128
|
+
export const chromeWanted = (frontmatter) => frontmatter?.chrome ?? !CHROMELESS_LAYOUTS.includes(frontmatter?.layout);
|
|
129
|
+
|
|
130
|
+
export const chromeShows = (frontmatter, currentPage) => chromeWanted(frontmatter) && currentPage > 1;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* ★ THE OPT-OUT (task-18-brief.md). `notch: false` on a slide's own frontmatter.
|
|
134
|
+
*
|
|
135
|
+
* The notch is theme-wide by construction — every `frame: photo`/`frame: panel` figure in every
|
|
136
|
+
* deck grows one — which is what made it visible without editing a single slide, and is also why it
|
|
137
|
+
* needs an escape hatch: it changed the look of ~50 committed slides and there was no way to say
|
|
138
|
+
* "not this one". A photograph whose subject sits in that corner, a panel that must keep its full
|
|
139
|
+
* height, a slide being compared against an older export: all real, none of them a bug in the cut.
|
|
140
|
+
*
|
|
141
|
+
* ⚠ WHAT OPTING OUT ACTUALLY RESTORES, stated plainly rather than discovered later. Every part of
|
|
142
|
+
* the notch keys off the same predicate — the mask, the panel's rise to the chrome inset, and the
|
|
143
|
+
* band of content reserved from a text panel — so `notch: false` drops all three together and the
|
|
144
|
+
* figure returns EXACTLY to its pre-notch geometry: full corner, --deck-inset-text top margin, no
|
|
145
|
+
* reserved band. That is the clean removal the brief asks for (no gap left where the cut was), and
|
|
146
|
+
* it is also, unavoidably, the 18px mark-over-corner overlap the notch was built to fix — the mark
|
|
147
|
+
* is inset 36px and the un-notched figure starts at 90px. The escape hatch's whole job is to give
|
|
148
|
+
* that old look back on a chosen slide; a slide that wants neither can also carry `chrome: false`.
|
|
149
|
+
*
|
|
150
|
+
* Separate from `chrome: false` on purpose: that hides the mark AND the pills AND the
|
|
151
|
+
* confidentiality watermark. A slide that wants its running chrome but not the cut has no way to
|
|
152
|
+
* say so through `chrome:`.
|
|
153
|
+
*
|
|
154
|
+
* @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
|
|
155
|
+
* @returns {boolean} false only when the slide explicitly wrote `notch: false`.
|
|
156
|
+
*/
|
|
157
|
+
export const notchShows = (frontmatter) => frontmatter?.notch !== false;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Does this figure cut a notch?
|
|
161
|
+
*
|
|
162
|
+
* All three are required, and each one alone is a defect: without a panel there is no material to
|
|
163
|
+
* cut (a `frame: content` figure side is the author's own second CONTENT column, which owns its own
|
|
164
|
+
* corner), without the chrome the cut has no occupant, and with `notch: false` the author has said
|
|
165
|
+
* not to.
|
|
166
|
+
*
|
|
167
|
+
* Called by split.vue for its figure side and by stack.vue for its band — both are the same
|
|
168
|
+
* question, which is why `frame` is the parameter rather than a layout name.
|
|
169
|
+
*
|
|
170
|
+
* @param {string | undefined} frame the layout's `frame` prop.
|
|
171
|
+
* @param {boolean} chromeShowing `chromeShows()`'s answer for this slide.
|
|
172
|
+
* @param {boolean} [notchShowing] `notchShows()`'s answer. Defaults to true so a caller that has no
|
|
173
|
+
* frontmatter to consult still gets the theme-wide behaviour rather than silently no cut.
|
|
174
|
+
*/
|
|
175
|
+
export const hasNotch = (frame, chromeShowing, notchShowing = true) =>
|
|
176
|
+
chromeShowing && notchShowing && PANEL_FRAMES.includes(frame);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* ★ CO-BRANDING — "maybe two logo marks for forest+companion" (Jose), task-18-brief.md.
|
|
180
|
+
*
|
|
181
|
+
* task-15 proved the cut SIZES to any occupant (measured 72px → 160px of occupant, corner radius
|
|
182
|
+
* unmoved) but nothing ever drew a second mark. This is the missing half: one pure function, so the
|
|
183
|
+
* component that DRAWS the lockup and the layout that CUTS room for it compute the same width from
|
|
184
|
+
* the same frontmatter and cannot part company. That is the same reason `hasNotch` lives here
|
|
185
|
+
* rather than inside either component.
|
|
186
|
+
*
|
|
187
|
+
* ★★ THE PARTNER IS A SQUARE, THE SAME SIZE AND SHAPE AS THE MARK. Jose: "the other logo should be
|
|
188
|
+
* anything (an avatar or whatever), but the second one should be a rounded square like the mark".
|
|
189
|
+
* The companion's artwork can be any ratio — an avatar, a wordmark, a photograph — and it is COVERED
|
|
190
|
+
* into a mark-sized square clipped to the mark's own corner, rather than being drawn at its own
|
|
191
|
+
* proportions beside it.
|
|
192
|
+
*
|
|
193
|
+
* No author input reaches the size, and that is what makes the cut right on the first frame: an
|
|
194
|
+
* image's intrinsic ratio is unknown until it loads, so the occupant is `markPx + pairGapPx +
|
|
195
|
+
* markPx` — 160px — known before anything loads, identical on every slide, and inside even the
|
|
196
|
+
* narrowest ratio's ceiling of 220.5px at 3:1. The clamp below cannot fire for a co-brand; it stays
|
|
197
|
+
* because `notchBudgetPx()` still guards the general `--deck-notch-occupant-w` override.
|
|
198
|
+
*
|
|
199
|
+
* The lockup's own composition follows packages/ui/src/logo/FSubLogo.vue's rule — one shared
|
|
200
|
+
* height, the lead first, a measured gap between — rather than inventing a second co-brand
|
|
201
|
+
* grammar. It does NOT import it: FSubLogo composes from the glyph atlas through Nuxt UI/Tailwind,
|
|
202
|
+
* neither of which exists on a slide.
|
|
203
|
+
*
|
|
204
|
+
* @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
|
|
205
|
+
* @param {{ markPx: number, pairGapPx: number }} chrome deck.json's `chrome` block — the mark's own
|
|
206
|
+
* size and the lockup gap, read rather than restated.
|
|
207
|
+
* @returns {{ src: string, companionPx: number, occupantPx: number } | null} null when the slide
|
|
208
|
+
* declares no companion, in which case NEITHER reader may declare --deck-notch-occupant-w: leaving
|
|
209
|
+
* it undeclared is what keeps the documented `class:`-based override reaching both of them.
|
|
210
|
+
*/
|
|
211
|
+
export const coBrand = (frontmatter, chrome, budgetPx = Infinity) => {
|
|
212
|
+
const src = frontmatter?.companion;
|
|
213
|
+
if (typeof src !== 'string' || !src.trim()) return null;
|
|
214
|
+
// Always the mark's own size — see the note above. No author input reaches this, which is what
|
|
215
|
+
// makes the occupant deterministic and the first frame correct without measuring anything.
|
|
216
|
+
const declaredPx = chrome.markPx;
|
|
217
|
+
// The lockup's fixed part: the Forest mark plus the pair gap. Whatever the budget is, this much of
|
|
218
|
+
// it is already spent before a companion's mark is drawn at all.
|
|
219
|
+
const lead = chrome.markPx + chrome.pairGapPx;
|
|
220
|
+
const room = Number.isFinite(budgetPx) ? Math.max(0, budgetPx - lead) : Infinity;
|
|
221
|
+
const companionPx = Math.min(declaredPx, room);
|
|
222
|
+
return {
|
|
223
|
+
src: src.trim(),
|
|
224
|
+
companionPx,
|
|
225
|
+
occupantPx: lead + companionPx,
|
|
226
|
+
/** What the slide asked for, kept so a clamp is inspectable rather than invisible. */
|
|
227
|
+
declaredCompanionPx: declaredPx,
|
|
228
|
+
/** True when the panel could not hold the declared width — see notchBudgetPx(). */
|
|
229
|
+
clamped: companionPx < declaredPx,
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* ★★ THE BUDGET, AND THE THING THAT SPENDS AGAINST IT.
|
|
235
|
+
*
|
|
236
|
+
* `deck.json → notch.fit.maxOccupantPx` is 208.5px and `scripts/deck.ts` defines `NOTCH_PAIR_PX` as
|
|
237
|
+
* 160, the widest occupant. Both are ceilings this function applies rather than assertions about
|
|
238
|
+
* defaults: anything a slide can widen the cut with is measured against the panel it is cut into.
|
|
239
|
+
*
|
|
240
|
+
* Rendered against the real theme CSS on a 3:1 panel (489px wide):
|
|
241
|
+
*
|
|
242
|
+
* occupant 72 (default) → cut 108 correct seat
|
|
243
|
+
* occupant 288 (shipped) → cut 324 66% of the panel width: an L-shaped step, not a bite;
|
|
244
|
+
* the convex → concave → convex read is gone
|
|
245
|
+
* occupant 600 → cut 636 the mask subtracts the ENTIRE top 108px of the panel and
|
|
246
|
+
* the mark floats above it with nothing cut for it
|
|
247
|
+
*
|
|
248
|
+
* So the ceiling is now per-RATIO, read from the generated table rather than restated, and
|
|
249
|
+
* `coBrand()` clamps against it. A clamped companion still DRAWS — its `<img>` is `object-fit:
|
|
250
|
+
* contain`, so it renders smaller inside a narrower box rather than distorted or dropped — because
|
|
251
|
+
* a co-brand silently disappearing is worse than one that is visibly too small for its slide.
|
|
252
|
+
*
|
|
253
|
+
* Every caller must pass the SAME budget or the cut and its occupant part company again, which is
|
|
254
|
+
* why `coBrandFitted()` below exists and is what the three components actually call.
|
|
255
|
+
*
|
|
256
|
+
* @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
|
|
257
|
+
* @param {number} currentPage Slidev's own one-based page number.
|
|
258
|
+
* @param {{ defaultRatio: string, byRatio: Record<string, { figurePx: number, maxOccupantPx: number }> }} fit
|
|
259
|
+
* deck.json's `notch.fit` block.
|
|
260
|
+
* @returns {number} the widest occupant this slide's panel can seat — `Infinity` when there is no
|
|
261
|
+
* cut at all, because then nothing is being spent and clamping would only shrink a lockup for no
|
|
262
|
+
* geometric reason.
|
|
263
|
+
*/
|
|
264
|
+
export const notchBudgetPx = (frontmatter, currentPage, fit) => {
|
|
265
|
+
const frame = frontmatter?.frame;
|
|
266
|
+
const chromeShowing = chromeShows(frontmatter, currentPage);
|
|
267
|
+
// Every reason there is no cut is a reason not to clamp: no panel, opted out, no chrome, slide 1.
|
|
268
|
+
// Shrinking a lockup that is merely being DRAWN would be an arbitrary haircut.
|
|
269
|
+
const cutting = hasNotch(frame, chromeShowing, notchShows(frontmatter));
|
|
270
|
+
if (!cutting) return Infinity;
|
|
271
|
+
const byRatio = fit?.byRatio ?? {};
|
|
272
|
+
const entry =
|
|
273
|
+
frontmatter?.layout === STACK_LAYOUT
|
|
274
|
+
? byRatio.stack
|
|
275
|
+
: byRatio[frontmatter?.ratio] ?? byRatio[fit?.defaultRatio];
|
|
276
|
+
// An unknown ratio resolves to the layout's own default (split.vue clamps `ratio` through
|
|
277
|
+
// RATIO_CLASS for exactly the same reason); a table with no entry at all means the generator and
|
|
278
|
+
// this module have parted company, and an unbounded occupant is the safer failure than a
|
|
279
|
+
// zero-width one — it is visible.
|
|
280
|
+
return Number.isFinite(entry?.maxOccupantPx) ? entry.maxOccupantPx : Infinity;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The co-brand lockup, already fitted to the panel it will be cut into — the ONE call slide-top.vue,
|
|
285
|
+
* split.vue and stack.vue all make, so the mark that is DRAWN and the cut made FOR it are the same
|
|
286
|
+
* arithmetic on the same inputs rather than three call sites that must remember to pass the same
|
|
287
|
+
* third argument.
|
|
288
|
+
*
|
|
289
|
+
* @param {Record<string, any> | undefined} frontmatter the slide's own frontmatter.
|
|
290
|
+
* @param {number} currentPage Slidev's own one-based page number.
|
|
291
|
+
* @param {{ chrome: { markPx: number, pairGapPx: number }, notch: { fit: any } }} deck deck.json.
|
|
292
|
+
*/
|
|
293
|
+
export const coBrandFitted = (frontmatter, currentPage, deck) =>
|
|
294
|
+
coBrand(frontmatter, deck.chrome, notchBudgetPx(frontmatter, currentPage, deck.notch.fit));
|
|
295
|
+
|
|
296
|
+
/*
|
|
297
|
+
★★ RETIRED — task-26's `clearsChrome()` is gone, and this note is what it left behind.
|
|
298
|
+
|
|
299
|
+
It made a full-width PHOTO band stand clear of the running chrome rather than notch into it. The
|
|
300
|
+
reasoning was sound at the time: the chrome then MIRRORED, routing the pills to the text side and
|
|
301
|
+
the mark to the panel side, which is complete for `split` but not for `stack`, whose band spans
|
|
302
|
+
the whole canvas so BOTH ends land on it. (The mirror is gone now — see the note above hasNotch —
|
|
303
|
+
but that is not what retired this rule.) The mark got its notch; the page marker and the section pill got
|
|
304
|
+
nothing and sat directly on a photograph whose corner can be any colour.
|
|
305
|
+
|
|
306
|
+
★ WHAT RETIRED IT: useNotchCuts.js measures EVERY running-chrome element on the panel — the mark,
|
|
307
|
+
each pill, the page marker, the confidentiality pill — assigns each to the corner it sits in, and
|
|
308
|
+
cuts all of them. The premise "the pills get nothing" stopped being true when that landed, and
|
|
309
|
+
nothing re-checked the rule built on it.
|
|
310
|
+
|
|
311
|
+
MEASURED, on apps/deck's `frame: photo` band with the clearance removed: the band's clip
|
|
312
|
+
path opens at `M 234.508 0`, i.e. the top-LEFT is cut for the section pill exactly as the top-right
|
|
313
|
+
is cut for the mark, and the deck audit's occlusion pass reports 0 text elements painted under an
|
|
314
|
+
opaque media surface across all 126 slides. Both ends are seated, which is what the rule wanted
|
|
315
|
+
and could not get.
|
|
316
|
+
*/
|
|
317
|
+
|
|
318
|
+
/*
|
|
319
|
+
★★ WHICH END OF THE RUNNING CHROME SITS ON A DARK PANEL.
|
|
320
|
+
|
|
321
|
+
`panelDark` re-points a panel's ground to the dark register (split.vue, stack.vue). The running
|
|
322
|
+
chrome is a SIBLING of `.slidev-layout`, so it cannot inherit that — and when the panel BLEEDS
|
|
323
|
+
there is no ring of ground left for the chrome to sit on, so it lands directly on it.
|
|
324
|
+
|
|
325
|
+
Measured on the specimen's alert slide before this existed: the page marker painted
|
|
326
|
+
the chrome pill (--deck-accent, forest-700 on light) on a forest-900 panel, 1.09:1. That is the exact
|
|
327
|
+
collision deckNotch.static.test.ts's ground table already predicts for a pinned pill on a dark
|
|
328
|
+
ground; the table's own dark column is only survivable because the pill turns over with the
|
|
329
|
+
register, and here the register turned over under half the slide while the pill did not.
|
|
330
|
+
|
|
331
|
+
So each end is answered separately, from the same frontmatter both layouts read:
|
|
332
|
+
|
|
333
|
+
split the panel is the FIGURE side, which is the opposite of `flip`. The chrome does not
|
|
334
|
+
mirror — mark and page marker are always at the right end, section and confidentiality
|
|
335
|
+
pills always at the left — so one end lands on the panel and the other on the content
|
|
336
|
+
column, whichever way `flip` points.
|
|
337
|
+
stack the band spans the full width, so BOTH ends of the row it occupies land on it and neither
|
|
338
|
+
end of the other does: the strip facing it is cream by definition. `flip` moves which row
|
|
339
|
+
that is — unflipped the band is row 1, flipped it is row 2.
|
|
340
|
+
|
|
341
|
+
Panels at `edge: inset` are deliberately absent. They keep the media inset, and the chrome then
|
|
342
|
+
sits on the slide's own ground with the notch cutting a seat for it — which is what the notch is
|
|
343
|
+
for.
|
|
344
|
+
*/
|
|
345
|
+
/*
|
|
346
|
+
── THE ONE GROUND THE FRONTMATTER DOES NOT STATE ──────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
★★ A COVER CAN CHOOSE ITS OWN, AND THE CHROME CANNOT SEE IT. `cover.vue` resolves
|
|
349
|
+
`background: auto` — the default — from its VARIANT: the mosaic opens on the vivid pair's dark
|
|
350
|
+
half because that is the register the brand draws a field of photographs on. That resolution
|
|
351
|
+
happens inside the component and lands as classes on `.slidev-layout`, which the chrome is a
|
|
352
|
+
SIBLING of, not a descendant.
|
|
353
|
+
|
|
354
|
+
So a slide whose frontmatter states neither `dark` nor a background still renders dark, and both
|
|
355
|
+
chrome rows read the frontmatter to decide their register. MEASURED on the specimen's mosaic
|
|
356
|
+
cover with a `confidential:` added: the marking painted --deck-fg-subtle (neutral-500) on
|
|
357
|
+
forest-700 at 1.36:1 — the row had no idea it was on a dark ground.
|
|
358
|
+
|
|
359
|
+
The table lives HERE, and cover.vue imports it, because this is the module the "chrome names no
|
|
360
|
+
layout" gate exempts (see darkChromeParts below, which names split and stack for the same
|
|
361
|
+
reason). One table, two readers, no drift.
|
|
362
|
+
|
|
363
|
+
⚠ "UNSTATED" IS SPELLED DIFFERENTLY ON EACH SIDE and that is not sloppiness: cover.vue reads
|
|
364
|
+
PROPS, where an absent boolean arrives as `null` from its declared default, and this reads
|
|
365
|
+
FRONTMATTER, where it is simply missing. Same question, two vocabularies.
|
|
366
|
+
*/
|
|
367
|
+
/* Two, not six — see the `background` prop in cover.vue for why an opener does not take the named
|
|
368
|
+
pairs. Kept in step with that validator; the clamp below and the layout's own must agree.
|
|
369
|
+
|
|
370
|
+
★ BEAT_, NOT COVER_, since `end` took the same contract. The two bookends of a deck answer the
|
|
371
|
+
same question — which ground does this open (or close) on when nobody said — and answering it
|
|
372
|
+
twice is how they would come to differ. Named for the pair of layouts rather than for one. */
|
|
373
|
+
export const BEAT_GROUNDS = ['auto', 'paper'];
|
|
374
|
+
/*
|
|
375
|
+
★★ ONE GROUND FOR EVERY VARIANT, NOT A TABLE KEYED BY ONE. It began as `{ mosaic: {...} }` — the
|
|
376
|
+
brand cover choosing its own green while the other six fell back to paper — and that made the
|
|
377
|
+
default ground a property of the SHAPE, so two openers in the same deck opened on different
|
|
378
|
+
colours depending on which composition an author reached for. A cover is where a deck states its
|
|
379
|
+
identity; the identity does not change with the arrangement of the picture.
|
|
380
|
+
*/
|
|
381
|
+
export const BEAT_GROUND = { background: 'vivid', dark: true };
|
|
382
|
+
/*
|
|
383
|
+
The cover variants whose ground props describe a PANEL rather than the slide — cover.vue reads the
|
|
384
|
+
same list. Their page keeps its paper however the panel is painted, so the chrome on one of them
|
|
385
|
+
is on the light register no matter what `dark` says.
|
|
386
|
+
*/
|
|
387
|
+
export const COVER_PANEL_SHAPED = ['inset'];
|
|
388
|
+
/*
|
|
389
|
+
★★ THE VARIANT WHOSE GROUND IS A PHOTOGRAPH, so `background` and `dark` have nothing to paint: a
|
|
390
|
+
poster covers the canvas edge to edge and lays a black scrim over it. Every one of them is light
|
|
391
|
+
ink on a dark picture whatever the frontmatter says, and drawing the same composition once per
|
|
392
|
+
named ground gave four slides that were pixel-identical apart from a caption — which is how this
|
|
393
|
+
was caught. The register is FIXED here rather than chosen, and both readers have to agree on it
|
|
394
|
+
or the confidentiality marking comes back dark on a dark photograph.
|
|
395
|
+
*/
|
|
396
|
+
export const COVER_PHOTO_GROUND = ['poster'];
|
|
397
|
+
/* The same fact about the closer: `photo` fills the canvas with a photograph under a scrim, so its
|
|
398
|
+
register is fixed light-on-dark whatever the frontmatter says. One list per layout rather than one
|
|
399
|
+
shared list, because these name VARIANTS and the two layouts' enums are their own. */
|
|
400
|
+
export const END_PHOTO_GROUND = ['photo'];
|
|
401
|
+
/*
|
|
402
|
+
★★ THE CLOSER TAKES THE NAMED PAIRS AND THE OPENER DOES NOT, and that asymmetry is deliberate
|
|
403
|
+
rather than drift. cover.vue's own note argues an opener down to two grounds: a deck states its
|
|
404
|
+
identity there, and the identity does not change with the arrangement. A closer is the last frame
|
|
405
|
+
and is regularly the one slide a deck is allowed to be loud on — Jose asked for a peach ground with
|
|
406
|
+
brown words the day the layout gained its variants — so it reads the same four-name table
|
|
407
|
+
`default`, `split`'s panel, `stack`'s band and a Card already share.
|
|
408
|
+
|
|
409
|
+
`auto` stays first because it is the default, and it means the same thing here as there: the brand
|
|
410
|
+
pair, resolved from BEAT_GROUND.
|
|
411
|
+
*/
|
|
412
|
+
export const END_GROUNDS = ['auto', 'paper', 'warm', 'accent', 'vivid', 'neutral'];
|
|
413
|
+
/* The layouts that resolve an unstated ground from BEAT_GROUND. Everything else renders on the
|
|
414
|
+
deck's paper unless it says otherwise, which is what `dark` and a `deck-dark` class are for. */
|
|
415
|
+
const BEAT_LAYOUTS = ['cover', 'end'];
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Does this slide RENDER on the dark register? `dark: true` and a hand-written `deck-dark` class are
|
|
419
|
+
* the two ways a slide says so itself; the third is a cover letting its variant choose.
|
|
420
|
+
*/
|
|
421
|
+
export function darkRegister(frontmatter) {
|
|
422
|
+
const fm = frontmatter ?? {};
|
|
423
|
+
if (fm.layout === 'cover' && COVER_PANEL_SHAPED.includes(fm.variant)) return false;
|
|
424
|
+
if (fm.layout === 'cover' && COVER_PHOTO_GROUND.includes(fm.variant)) return true;
|
|
425
|
+
if (fm.layout === 'end' && END_PHOTO_GROUND.includes(fm.variant)) return true;
|
|
426
|
+
if (fm.dark === true) return true;
|
|
427
|
+
if (String(fm.class ?? '').split(/\s+/).includes('deck-dark')) return true;
|
|
428
|
+
if (!BEAT_LAYOUTS.includes(fm.layout) || fm.dark !== undefined) return false;
|
|
429
|
+
// Clamped the same way the layout clamps it, so a misspelt ground resolves here exactly as it
|
|
430
|
+
// does there — `auto` — rather than the two disagreeing about which slide is dark.
|
|
431
|
+
//
|
|
432
|
+
// ⚠ AND EACH LAYOUT'S OWN LIST DOES THE CLAMPING. `end` takes the four named pairs as well; read
|
|
433
|
+
// against the opener's two, a closer on `warm` fell through to `auto` here and the marking on it
|
|
434
|
+
// was drawn for a dark register on a peach ground.
|
|
435
|
+
const grounds = fm.layout === 'end' ? END_GROUNDS : BEAT_GROUNDS;
|
|
436
|
+
const stated = grounds.includes(fm.background) ? fm.background : 'auto';
|
|
437
|
+
return stated === 'auto' && BEAT_GROUND.dark === true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const NO_DARK_CHROME = { top: 'none', bottom: 'none' };
|
|
441
|
+
export function darkChromeParts(frontmatter) {
|
|
442
|
+
const fm = frontmatter ?? {};
|
|
443
|
+
if (fm.panelDark !== true || fm.edge !== 'bleed') return NO_DARK_CHROME;
|
|
444
|
+
if (fm.layout === 'split') {
|
|
445
|
+
if ((fm.frame ?? 'figure') !== 'panel') return NO_DARK_CHROME;
|
|
446
|
+
const figureSide = fm.flip === true ? 'left' : 'right';
|
|
447
|
+
return { top: figureSide, bottom: figureSide };
|
|
448
|
+
}
|
|
449
|
+
if (fm.layout === 'stack') {
|
|
450
|
+
if ((fm.frame ?? 'panel') !== 'panel') return NO_DARK_CHROME;
|
|
451
|
+
/* A band is full width, so BOTH ends of whichever row it occupies land on it and neither end of
|
|
452
|
+
the other does — the region facing it is cream by definition. `flip` moves which row that is. */
|
|
453
|
+
return fm.flip === true ? { top: 'none', bottom: 'both' } : { top: 'both', bottom: 'none' };
|
|
454
|
+
}
|
|
455
|
+
return NO_DARK_CHROME;
|
|
456
|
+
}
|
|
457
|
+
/** True when the given end of a chrome row sits on a dark panel. `part` is 'left' or 'right'. */
|
|
458
|
+
export const darkChromeAt = (parts, part) => parts === 'both' || parts === part;
|
package/notchPath.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The panel outline, as one geometric path — a rounded rectangle with a notch bitten out of any of
|
|
2
|
+
// its four corners.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS A FUNCTION AND NOT CSS. The first notch was a `clip-path: shape()` written by hand,
|
|
5
|
+
// once per corner, with the occupant's size baked in as a token. That worked while exactly one
|
|
6
|
+
// thing (the Forest mark, 72px, known ahead of time) ever sat on a panel. It does not survive what
|
|
7
|
+
// the chrome actually became:
|
|
8
|
+
//
|
|
9
|
+
// · the section pill sits on a stack band's other top corner, and its width is a section NAME;
|
|
10
|
+
// · the page marker sits on a split panel's bottom corner, and its width grows with the digit
|
|
11
|
+
// count — "9", "19", "199" are three different boxes;
|
|
12
|
+
// · a co-branded slide widens the mark's own occupant to a pair.
|
|
13
|
+
//
|
|
14
|
+
// Written as CSS that would be four corners x two mirror states x per-corner sizes, and every one
|
|
15
|
+
// of those variants would be a separate hand-derived path that could drift from the others. Written
|
|
16
|
+
// as one function it is a single derivation, unit-testable in node against an independent model,
|
|
17
|
+
// and the callers only have to say which corners are occupied and by what.
|
|
18
|
+
//
|
|
19
|
+
// THE PROFILE, corner-local, is unchanged from the hand-written version it replaces — a convex
|
|
20
|
+
// return of radius r meeting the edge at (w + r, 0), a straight run down x = w, a concave quarter
|
|
21
|
+
// circle centred on (w - r, h - r), and the mirrored return meeting the side edge at (0, h + r).
|
|
22
|
+
// See styles/base.css for why the cut is a clip and not a mask (a mask is alpha, and `subtract`
|
|
23
|
+
// leaves a hairline of panel painted into the ground wherever a boundary lands on a fractional
|
|
24
|
+
// device pixel).
|
|
25
|
+
|
|
26
|
+
/** The four corners, named as they are addressed everywhere else in the theme: physical, not logical. */
|
|
27
|
+
export const CORNERS = ['tl', 'tr', 'br', 'bl'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a notch's TWO radii, which the first version wrongly treated as one.
|
|
31
|
+
*
|
|
32
|
+
* A notch has a concave inner corner and two convex returns, and they answer to different masters:
|
|
33
|
+
*
|
|
34
|
+
* · the CONCAVE corner must be CONCENTRIC with the occupant it wraps — the occupant's own corner
|
|
35
|
+
* plus the ring — or the gap around the occupant is not constant, which is visible as soon as
|
|
36
|
+
* the occupant is round;
|
|
37
|
+
* · the CONVEX returns are only the transition back to the panel's edge. Nothing is seated in
|
|
38
|
+
* them, so their radius is free.
|
|
39
|
+
*
|
|
40
|
+
* Sharing one radius forces a false choice. Measured on a 49px-tall page marker with a 24px ring:
|
|
41
|
+
* concentric wants 48.5, but the cut is only 73 tall, so `min(r, h/2)` clamped it to 36.5 and the
|
|
42
|
+
* cut stopped being concentric — its concave corner curved about (58.2, 971.5) where the pill's
|
|
43
|
+
* curved about (46.2, 983.5). Reported by eye as "not perfect", and it was.
|
|
44
|
+
*
|
|
45
|
+
* Clamped independently instead: the concave radius only has to fit inside the cut, and the return
|
|
46
|
+
* radius only has to leave the concave corner room to exist. The mark is unaffected (96px cut, 39.75
|
|
47
|
+
* concentric — the return still resolves to 39.75, exactly what it drew before).
|
|
48
|
+
*
|
|
49
|
+
* @returns {{ r: number, rr: number }} concave radius, convex return radius
|
|
50
|
+
*/
|
|
51
|
+
export const notchRadii = (r, w, h) => {
|
|
52
|
+
const concave = Math.max(0, Math.min(r, w, h));
|
|
53
|
+
// What is left on each axis once the concave corner has taken its share.
|
|
54
|
+
const ret = Math.max(0, Math.min(concave, h - concave, w - concave));
|
|
55
|
+
return { r: concave, rr: ret };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {{ w: number, h: number, r: number }} Notch
|
|
60
|
+
* w/h — the CUT, i.e. the occupant plus its ring, not the occupant itself. r — the cut's own
|
|
61
|
+
* corner radius, ideally concentric with the occupant's (the occupant's corner plus the ring).
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the panel outline.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} spec
|
|
68
|
+
* @param {number} spec.w panel width in px
|
|
69
|
+
* @param {number} spec.h panel height in px
|
|
70
|
+
* @param {number} spec.radius the card corner radius, used on every corner that has no notch
|
|
71
|
+
* @param {Partial<Record<'tl'|'tr'|'br'|'bl', Notch>>} [spec.notches]
|
|
72
|
+
* @returns {string} an SVG path, ready for `clip-path: path('...')`
|
|
73
|
+
*/
|
|
74
|
+
export const notchPath = ({ w, h, radius, notches = {} }) => {
|
|
75
|
+
const R = Math.max(0, Math.min(radius, w / 2, h / 2));
|
|
76
|
+
const n = (corner) => {
|
|
77
|
+
const it = notches[corner];
|
|
78
|
+
if (!it || !(it.w > 0) || !(it.h > 0)) return null;
|
|
79
|
+
// A cut cannot be larger than the panel it is bitten out of.
|
|
80
|
+
const cw = Math.min(it.w, w);
|
|
81
|
+
const ch = Math.min(it.h, h);
|
|
82
|
+
return { w: cw, h: ch, ...notchRadii(it.r, cw, ch) };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const seg = [];
|
|
86
|
+
const A = (rx, x, y, sweep) => seg.push(`A ${rx} ${rx} 0 0 ${sweep} ${x} ${y}`);
|
|
87
|
+
const L = (x, y) => seg.push(`L ${x} ${y}`);
|
|
88
|
+
|
|
89
|
+
// Walked CLOCKWISE from the top-left corner. Each corner either turns through the card radius or
|
|
90
|
+
// bites inward through its notch; the notch profiles below are the same shape rotated, which is
|
|
91
|
+
// why they read as four near-copies rather than four derivations.
|
|
92
|
+
const tl = n('tl'), tr = n('tr'), br = n('br'), bl = n('bl');
|
|
93
|
+
|
|
94
|
+
// ── start: just past the top-left corner, on the top edge ──────────────────────────────────
|
|
95
|
+
seg.push(`M ${tl ? tl.w + tl.rr : R} 0`);
|
|
96
|
+
|
|
97
|
+
// ── top edge → top-right corner ────────────────────────────────────────────────────────────
|
|
98
|
+
if (tr) {
|
|
99
|
+
L(w - tr.w - tr.rr, 0);
|
|
100
|
+
A(tr.rr, w - tr.w, tr.rr, 1); // convex return, into the cut
|
|
101
|
+
L(w - tr.w, tr.h - tr.r);
|
|
102
|
+
A(tr.r, w - tr.w + tr.r, tr.h, 0); // concave inner corner — concentric with the occupant
|
|
103
|
+
L(w - tr.rr, tr.h);
|
|
104
|
+
A(tr.rr, w, tr.h + tr.rr, 1); // convex return, back to the side edge
|
|
105
|
+
} else {
|
|
106
|
+
L(w - R, 0);
|
|
107
|
+
A(R, w, R, 1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── right edge → bottom-right corner ───────────────────────────────────────────────────────
|
|
111
|
+
if (br) {
|
|
112
|
+
L(w, h - br.h - br.rr);
|
|
113
|
+
A(br.rr, w - br.rr, h - br.h, 1);
|
|
114
|
+
L(w - br.w + br.r, h - br.h);
|
|
115
|
+
A(br.r, w - br.w, h - br.h + br.r, 0);
|
|
116
|
+
L(w - br.w, h - br.rr);
|
|
117
|
+
A(br.rr, w - br.w - br.rr, h, 1);
|
|
118
|
+
} else {
|
|
119
|
+
L(w, h - R);
|
|
120
|
+
A(R, w - R, h, 1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── bottom edge → bottom-left corner ───────────────────────────────────────────────────────
|
|
124
|
+
if (bl) {
|
|
125
|
+
L(bl.w + bl.rr, h);
|
|
126
|
+
A(bl.rr, bl.w, h - bl.rr, 1);
|
|
127
|
+
L(bl.w, h - bl.h + bl.r);
|
|
128
|
+
A(bl.r, bl.w - bl.r, h - bl.h, 0);
|
|
129
|
+
L(bl.rr, h - bl.h);
|
|
130
|
+
A(bl.rr, 0, h - bl.h - bl.rr, 1);
|
|
131
|
+
} else {
|
|
132
|
+
L(R, h);
|
|
133
|
+
A(R, 0, h - R, 1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── left edge → back to the start ──────────────────────────────────────────────────────────
|
|
137
|
+
if (tl) {
|
|
138
|
+
L(0, tl.h + tl.rr);
|
|
139
|
+
A(tl.rr, tl.rr, tl.h, 1);
|
|
140
|
+
L(tl.w - tl.r, tl.h);
|
|
141
|
+
A(tl.r, tl.w, tl.h - tl.r, 0);
|
|
142
|
+
L(tl.w, tl.rr);
|
|
143
|
+
A(tl.rr, tl.w + tl.rr, 0, 1);
|
|
144
|
+
} else {
|
|
145
|
+
L(0, R);
|
|
146
|
+
A(R, R, 0, 1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
seg.push('Z');
|
|
150
|
+
return seg.join(' ');
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Is a chrome box actually sitting on this panel, and if so how far into which corner?
|
|
155
|
+
*
|
|
156
|
+
* Returns the cut a corner needs to seat that box — the occupant's own extent from the corner, plus
|
|
157
|
+
* one ring — or null when the box does not overlap the panel at all, which is the common case: on a
|
|
158
|
+
* split, half the running chrome sits on the text side and must not cut anything.
|
|
159
|
+
*
|
|
160
|
+
* The overlap test is deliberately generous by `slack`: a box that stops a pixel short of the panel
|
|
161
|
+
* still needs the cut, because the ring is what the reader sees, not the overlap.
|
|
162
|
+
*
|
|
163
|
+
* @param {DOMRect|null} box the chrome element's rect
|
|
164
|
+
* @param {DOMRect} panel the panel's rect
|
|
165
|
+
* @param {number} ring the breathing gap between occupant and panel
|
|
166
|
+
* @param {number} slack how close counts as touching
|
|
167
|
+
* @returns {{ corner: 'tl'|'tr'|'br'|'bl', w: number, h: number } | null}
|
|
168
|
+
*/
|
|
169
|
+
export const occupantFor = (box, panel, ring, slack = 2) => {
|
|
170
|
+
if (!box || !(box.width > 0) || !(box.height > 0)) return null;
|
|
171
|
+
const overlaps =
|
|
172
|
+
box.right > panel.left - slack && box.left < panel.right + slack &&
|
|
173
|
+
box.bottom > panel.top - slack && box.top < panel.bottom + slack;
|
|
174
|
+
if (!overlaps) return null;
|
|
175
|
+
|
|
176
|
+
// Which corner is it in? Whichever edges it is nearer to — a chrome box always sits in a corner,
|
|
177
|
+
// never mid-edge, so the nearer edge on each axis names the corner without ambiguity.
|
|
178
|
+
const nearLeft = Math.abs(box.left - panel.left) <= Math.abs(panel.right - box.right);
|
|
179
|
+
const nearTop = Math.abs(box.top - panel.top) <= Math.abs(panel.bottom - box.bottom);
|
|
180
|
+
const corner = `${nearTop ? 't' : 'b'}${nearLeft ? 'l' : 'r'}`;
|
|
181
|
+
|
|
182
|
+
// The cut is measured from the panel's own corner to the far side of the box, plus the ring. Using
|
|
183
|
+
// the box's FAR edge rather than its width is what makes a box that overhangs the panel's edge
|
|
184
|
+
// still produce a cut deep enough to clear it.
|
|
185
|
+
const w = (nearLeft ? box.right - panel.left : panel.right - box.left) + ring;
|
|
186
|
+
const h = (nearTop ? box.bottom - panel.top : panel.bottom - box.top) + ring;
|
|
187
|
+
return { corner, w: Math.max(0, w), h: Math.max(0, h) };
|
|
188
|
+
};
|