@leavepulse/ui 0.14.8 → 0.14.10

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.
@@ -0,0 +1,264 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * One row of LpFileTree plus its recursively rendered children. Split out so the
4
+ * tree can recurse without LpFileTree importing itself, and so the row's
5
+ * indent/expand/selection logic lives in one place. Not exported on its own; an
6
+ * LpFileTree detail.
7
+ *
8
+ * Roving tabindex: exactly one row in the whole tree is focusable at a time
9
+ * (the one matching `focusedId`), which is what a11y expects of role="tree" —
10
+ * Tab enters the tree once, arrows move within it. The parent owns that state.
11
+ */
12
+ import { computed } from "vue"
13
+ import {
14
+ checkStateOf,
15
+ fileIcon,
16
+ formatModified,
17
+ formatSize,
18
+ sortNodes,
19
+ subtreeSize,
20
+ type FileNode,
21
+ } from "./fileTree"
22
+ import LpCheckbox from "./LpCheckbox.vue"
23
+ import LpContextMenu from "./LpContextMenu.vue"
24
+ import LpIcon from "./LpIcon.vue"
25
+
26
+ const props = defineProps<{
27
+ node: FileNode
28
+ /** Nesting depth, 0 at the root — drives the indent guide and aria-level. */
29
+ depth: number
30
+ expanded: Set<string>
31
+ loading: Set<string>
32
+ selectedId?: string
33
+ focusedId?: string
34
+ sort: boolean
35
+ /** Rendered size of the row's icon, in px. */
36
+ iconSize: number
37
+ /** Render a tri-state checkbox per row (multi-select mode). */
38
+ checkable: boolean
39
+ checked: Set<string>
40
+ showSize: boolean
41
+ showModified: boolean
42
+ }>()
43
+
44
+ const emit = defineEmits<{
45
+ (e: "select", node: FileNode): void
46
+ (e: "toggle", node: FileNode): void
47
+ (e: "focus", node: FileNode): void
48
+ (e: "check", node: FileNode, value: boolean): void
49
+ }>()
50
+
51
+ // Declared explicitly: the component renders itself, so an inferred slot type
52
+ // would reference its own initializer (TS7022).
53
+ defineSlots<{
54
+ row(props: { node: FileNode; depth: number; expanded: boolean; selected: boolean }): unknown
55
+ }>()
56
+
57
+ const isDir = computed(() => props.node.kind === "dir")
58
+ const isOpen = computed(() => isDir.value && props.expanded.has(props.node.id))
59
+ const isLoading = computed(() => props.loading.has(props.node.id))
60
+ const isSelected = computed(() => props.selectedId === props.node.id)
61
+ const isFocused = computed(() => props.focusedId === props.node.id)
62
+
63
+ // A directory with no `children` array hasn't been loaded yet — we can't know if
64
+ // it's empty, so it stays expandable and the caller fills it on `expand`.
65
+ const hasChildren = computed(() => isDir.value && (props.node.children?.length ?? 0) > 0)
66
+ const canExpand = computed(() => isDir.value && (props.node.children === undefined || hasChildren.value))
67
+
68
+ const children = computed(() => {
69
+ const list = props.node.children ?? []
70
+ return props.sort ? sortNodes(list) : list
71
+ })
72
+
73
+ const checkState = computed(() =>
74
+ props.checkable ? checkStateOf(props.node, props.checked) : "unchecked",
75
+ )
76
+
77
+ // A directory shows the rolled-up size of what's under it, so a backup picker
78
+ // can answer "how much is this folder" without expanding it.
79
+ const sizeLabel = computed(() => {
80
+ if (!props.showSize) return ""
81
+ const bytes = subtreeSize(props.node)
82
+ return bytes === undefined ? "" : formatSize(bytes)
83
+ })
84
+
85
+ const modifiedLabel = computed(() =>
86
+ props.showModified && props.node.modified !== undefined
87
+ ? formatModified(props.node.modified)
88
+ : "",
89
+ )
90
+
91
+ /**
92
+ * Per-row stagger for the reveal animation. Capped so a large directory doesn't
93
+ * spend a second unfolding — past the cap every remaining row comes in together.
94
+ */
95
+ function rowDelay(index: number): string {
96
+ return `${Math.min(index * 18, 180)}ms`
97
+ }
98
+
99
+ /** Respect the OS setting — skip the height animation entirely. */
100
+ function reducedMotion(): boolean {
101
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
102
+ }
103
+
104
+ // Expand/collapse the branch by animating from 0 to its measured height. The
105
+ // Web Animations API is used directly (rather than a CSS class) because the
106
+ // target height isn't known until the rows are in the DOM.
107
+ function onBranchEnter(el: Element, done: () => void) {
108
+ if (reducedMotion()) return done()
109
+ const height = (el as HTMLElement).scrollHeight
110
+ el.animate(
111
+ [
112
+ { height: "0px", opacity: 0 },
113
+ { height: `${height}px`, opacity: 1 },
114
+ ],
115
+ { duration: 220, easing: "cubic-bezier(0.2, 0, 0, 1)" },
116
+ ).onfinish = () => done()
117
+ }
118
+
119
+ function onBranchLeave(el: Element, done: () => void) {
120
+ if (reducedMotion()) return done()
121
+ const height = (el as HTMLElement).scrollHeight
122
+ el.animate(
123
+ [
124
+ { height: `${height}px`, opacity: 1 },
125
+ { height: "0px", opacity: 0 },
126
+ ],
127
+ { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)" },
128
+ ).onfinish = () => done()
129
+ }
130
+
131
+ function onActivate() {
132
+ if (props.node.disabled) return
133
+ emit("focus", props.node)
134
+ // A directory row toggles; a file row selects. Directories still emit select
135
+ // so callers can drive a details panel off the highlighted entry.
136
+ emit("select", props.node)
137
+ if (isDir.value) emit("toggle", props.node)
138
+ }
139
+ </script>
140
+
141
+ <template>
142
+ <li
143
+ role="treeitem"
144
+ class="min-w-0"
145
+ :aria-level="depth + 1"
146
+ :aria-selected="isSelected"
147
+ :aria-expanded="canExpand ? isOpen : undefined"
148
+ :aria-disabled="node.disabled || undefined"
149
+ >
150
+ <!-- Passthrough when the node has no menu, so the row keeps the native one. -->
151
+ <LpContextMenu :items="node.menu ?? []">
152
+ <div
153
+ :data-node-id="node.id"
154
+ :tabindex="isFocused ? 0 : -1"
155
+ class="group/row relative flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
156
+ :class="[
157
+ node.disabled
158
+ ? 'cursor-not-allowed text-muted/50'
159
+ : isSelected
160
+ ? 'bg-brand-soft text-ink'
161
+ : 'text-muted hover:bg-surface-soft hover:text-ink',
162
+ ]"
163
+ :style="{ paddingLeft: `${depth * 14 + 6}px` }"
164
+ @click.stop="onActivate"
165
+ >
166
+ <!-- Chevron sits in a fixed-width slot so names line up whether or not
167
+ the row can expand. Leaves get the same gutter, left empty. -->
168
+ <span class="flex size-4 shrink-0 items-center justify-center">
169
+ <LpIcon
170
+ v-if="isLoading"
171
+ name="lucide:loader-circle"
172
+ :size="13"
173
+ class="animate-spin text-muted"
174
+ />
175
+ <LpIcon
176
+ v-else-if="canExpand"
177
+ name="lucide:chevron-right"
178
+ :size="14"
179
+ class="text-muted transition-transform duration-[var(--duration-fast)]"
180
+ :class="isOpen ? 'rotate-90' : ''"
181
+ />
182
+ </span>
183
+
184
+ <!-- Checkbox is its own hit target: ticking a folder must not also
185
+ expand it (and vice versa), so the click stops here. -->
186
+ <LpCheckbox
187
+ v-if="checkable"
188
+ :model-value="checkState === 'checked'"
189
+ :indeterminate="checkState === 'indeterminate'"
190
+ :disabled="node.disabled"
191
+ class="shrink-0"
192
+ :aria-label="node.name"
193
+ @click.stop
194
+ @update:model-value="emit('check', node, $event)"
195
+ />
196
+
197
+ <LpIcon
198
+ :name="fileIcon(node, isOpen)"
199
+ :size="iconSize"
200
+ class="shrink-0"
201
+ :class="node.disabled ? '' : isDir ? 'text-brand' : 'text-muted'"
202
+ />
203
+
204
+ <span class="min-w-0 flex-1 truncate">
205
+ <slot name="row" :node="node" :depth="depth" :expanded="isOpen" :selected="isSelected">
206
+ {{ node.name }}
207
+ </slot>
208
+ </span>
209
+
210
+ <!-- Metadata columns: tabular figures so sizes line up down the tree. -->
211
+ <span v-if="node.meta" class="shrink-0 text-xs text-muted">{{ node.meta }}</span>
212
+ <span
213
+ v-if="modifiedLabel"
214
+ class="shrink-0 text-xs tabular-nums text-muted/80"
215
+ >
216
+ {{ modifiedLabel }}
217
+ </span>
218
+ <span
219
+ v-if="sizeLabel"
220
+ class="w-16 shrink-0 text-right text-xs tabular-nums text-muted"
221
+ >
222
+ {{ sizeLabel }}
223
+ </span>
224
+ </div>
225
+ </LpContextMenu>
226
+
227
+ <!-- Height is only known at runtime, so the expand/collapse is measured in
228
+ JS hooks rather than a keyframe. Rows inside stagger themselves in. -->
229
+ <Transition
230
+ :css="false"
231
+ @enter="onBranchEnter"
232
+ @leave="onBranchLeave"
233
+ >
234
+ <ul v-if="isOpen && hasChildren" role="group" class="flex min-w-0 flex-col overflow-hidden">
235
+ <LpFileTreeNode
236
+ v-for="(child, i) in children"
237
+ :key="child.id"
238
+ :style="{ '--row-delay': rowDelay(i) }"
239
+ class="animate-[tree-row-in_var(--duration-medium)_var(--ease-emphasized)_both] [animation-delay:var(--row-delay)] motion-reduce:animate-none"
240
+ :node="child"
241
+ :depth="depth + 1"
242
+ :expanded="expanded"
243
+ :loading="loading"
244
+ :selected-id="selectedId"
245
+ :focused-id="focusedId"
246
+ :sort="sort"
247
+ :icon-size="iconSize"
248
+ :checkable="checkable"
249
+ :checked="checked"
250
+ :show-size="showSize"
251
+ :show-modified="showModified"
252
+ @select="emit('select', $event)"
253
+ @toggle="emit('toggle', $event)"
254
+ @focus="emit('focus', $event)"
255
+ @check="(n, v) => emit('check', n, v)"
256
+ >
257
+ <template #row="slotProps">
258
+ <slot name="row" v-bind="slotProps" />
259
+ </template>
260
+ </LpFileTreeNode>
261
+ </ul>
262
+ </Transition>
263
+ </li>
264
+ </template>
@@ -0,0 +1,214 @@
1
+ /*
2
+ * Shared file-tree types and helpers. Lives in its own module so LpFileTree and
3
+ * its recursive LpFileTreeNode body can both import them without a
4
+ * component-to-component cycle. Re-exported from LpFileTree.vue.
5
+ */
6
+
7
+ import type { ContextMenuItemDef } from "./LpContextMenu.vue"
8
+
9
+ export interface FileNode {
10
+ /** Stable identity — use the full path when you have one. */
11
+ id: string
12
+ name: string
13
+ /** Directories can hold `children`; files are leaves. */
14
+ kind: "file" | "dir"
15
+ /** Size in bytes. Shown right-aligned when `showSize` is on. */
16
+ size?: number
17
+ /** Last-modified time — Date, epoch ms, or a pre-formatted string. */
18
+ modified?: Date | number | string
19
+ /** Extra right-aligned text (badge-ish), e.g. "read-only". */
20
+ meta?: string
21
+ /**
22
+ * Omit on a directory to mark its children as not-yet-loaded: the row shows a
23
+ * chevron and emits `expand` on first open so the caller can fetch them.
24
+ * An empty array means "loaded, and genuinely empty".
25
+ */
26
+ children?: FileNode[]
27
+ /** Override the type-derived icon. */
28
+ icon?: string
29
+ /** Dimmed row (e.g. a git-ignored or unreadable entry). Still expandable. */
30
+ disabled?: boolean
31
+ /** Right-click menu for this entry. */
32
+ menu?: ContextMenuItemDef[]
33
+ }
34
+
35
+ /** Directory icons are state-driven; files fall back to an extension map. */
36
+ const EXT_ICONS: Record<string, string> = {
37
+ json: "lucide:file-json",
38
+ jsonc: "lucide:file-json",
39
+ yaml: "lucide:file-cog",
40
+ yml: "lucide:file-cog",
41
+ toml: "lucide:file-cog",
42
+ ini: "lucide:file-cog",
43
+ conf: "lucide:file-cog",
44
+ env: "lucide:file-key",
45
+ ts: "lucide:file-code",
46
+ tsx: "lucide:file-code",
47
+ js: "lucide:file-code",
48
+ mjs: "lucide:file-code",
49
+ cjs: "lucide:file-code",
50
+ vue: "lucide:file-code",
51
+ py: "lucide:file-code",
52
+ rs: "lucide:file-code",
53
+ go: "lucide:file-code",
54
+ sh: "lucide:file-terminal",
55
+ zsh: "lucide:file-terminal",
56
+ bash: "lucide:file-terminal",
57
+ md: "lucide:file-text",
58
+ mdx: "lucide:file-text",
59
+ txt: "lucide:file-text",
60
+ log: "lucide:scroll-text",
61
+ csv: "lucide:file-spreadsheet",
62
+ sql: "lucide:database",
63
+ png: "lucide:file-image",
64
+ jpg: "lucide:file-image",
65
+ jpeg: "lucide:file-image",
66
+ gif: "lucide:file-image",
67
+ svg: "lucide:file-image",
68
+ webp: "lucide:file-image",
69
+ ico: "lucide:file-image",
70
+ zip: "lucide:file-archive",
71
+ gz: "lucide:file-archive",
72
+ tar: "lucide:file-archive",
73
+ tgz: "lucide:file-archive",
74
+ xz: "lucide:file-archive",
75
+ pdf: "lucide:file-type",
76
+ lock: "lucide:file-lock",
77
+ pem: "lucide:file-key",
78
+ key: "lucide:file-key",
79
+ crt: "lucide:file-key",
80
+ }
81
+
82
+ /** Dotfiles that read as config regardless of extension. */
83
+ const NAME_ICONS: Record<string, string> = {
84
+ ".env": "lucide:file-key",
85
+ ".gitignore": "lucide:git-branch",
86
+ ".dockerignore": "lucide:container",
87
+ dockerfile: "lucide:container",
88
+ "docker-compose.yml": "lucide:container",
89
+ "docker-compose.yaml": "lucide:container",
90
+ makefile: "lucide:file-cog",
91
+ "package.json": "lucide:package",
92
+ }
93
+
94
+ /** Icon for a node: explicit override → dir state → name → extension → generic. */
95
+ export function fileIcon(node: FileNode, expanded = false): string {
96
+ if (node.icon) return node.icon
97
+ if (node.kind === "dir") return expanded ? "lucide:folder-open" : "lucide:folder"
98
+
99
+ const lower = node.name.toLowerCase()
100
+ if (NAME_ICONS[lower]) return NAME_ICONS[lower]
101
+ // Leading dot doesn't start an extension (".env" is a name, not an ext).
102
+ const dot = lower.lastIndexOf(".")
103
+ const ext = dot > 0 ? lower.slice(dot + 1) : ""
104
+ return EXT_ICONS[ext] ?? "lucide:file"
105
+ }
106
+
107
+ /** Directories first, then case-insensitive by name — the file-manager order. */
108
+ export function sortNodes(nodes: FileNode[]): FileNode[] {
109
+ return [...nodes].sort((a, b) => {
110
+ if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
111
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
112
+ })
113
+ }
114
+
115
+ /** Human-readable byte size — binary units, matching what file managers show. */
116
+ export function formatSize(bytes: number): string {
117
+ if (!Number.isFinite(bytes) || bytes < 0) return "—"
118
+ if (bytes < 1024) return `${bytes} B`
119
+ const units = ["KB", "MB", "GB", "TB", "PB"]
120
+ let value = bytes / 1024
121
+ let i = 0
122
+ while (value >= 1024 && i < units.length - 1) {
123
+ value /= 1024
124
+ i++
125
+ }
126
+ // One decimal below 10 (2.4 MB), none above (240 MB) — keeps the column tidy.
127
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`
128
+ }
129
+
130
+ /** Short local date-time for the modified column; passes strings through. */
131
+ export function formatModified(value: Date | number | string): string {
132
+ if (typeof value === "string") return value
133
+ const date = value instanceof Date ? value : new Date(value)
134
+ if (Number.isNaN(date.getTime())) return "—"
135
+ return date.toLocaleString(undefined, {
136
+ day: "2-digit",
137
+ month: "short",
138
+ hour: "2-digit",
139
+ minute: "2-digit",
140
+ })
141
+ }
142
+
143
+ /**
144
+ * Total size of a subtree: a directory's own `size` when the caller computed one
145
+ * (the common case for a server-side listing), otherwise the sum of its loaded
146
+ * descendants. Returns undefined when nothing along the way carries a size.
147
+ */
148
+ export function subtreeSize(node: FileNode): number | undefined {
149
+ if (node.kind === "file") return node.size
150
+ if (node.size !== undefined) return node.size
151
+ if (!node.children?.length) return undefined
152
+ let total = 0
153
+ let seen = false
154
+ for (const child of node.children) {
155
+ const size = subtreeSize(child)
156
+ if (size !== undefined) {
157
+ total += size
158
+ seen = true
159
+ }
160
+ }
161
+ return seen ? total : undefined
162
+ }
163
+
164
+ /** Every id in a subtree including the root — the unit a checkbox toggles. */
165
+ export function subtreeIds(node: FileNode): string[] {
166
+ const out = [node.id]
167
+ for (const child of node.children ?? []) out.push(...subtreeIds(child))
168
+ return out
169
+ }
170
+
171
+ export type CheckState = "checked" | "unchecked" | "indeterminate"
172
+
173
+ /**
174
+ * A node's checkbox state derived from the checked-id set.
175
+ *
176
+ * A directory reflects its loaded children: all checked → checked, some →
177
+ * indeterminate. A directory whose children aren't loaded yet (or which has
178
+ * none) falls back to its own membership, so an unexpanded folder can still be
179
+ * ticked as a whole — which is exactly what a backup picker needs.
180
+ */
181
+ export function checkStateOf(node: FileNode, checked: ReadonlySet<string>): CheckState {
182
+ if (node.kind === "file" || !node.children?.length) {
183
+ return checked.has(node.id) ? "checked" : "unchecked"
184
+ }
185
+ let all = true
186
+ let any = false
187
+ for (const child of node.children) {
188
+ const state = checkStateOf(child, checked)
189
+ if (state === "checked") any = true
190
+ else if (state === "indeterminate") {
191
+ any = true
192
+ all = false
193
+ } else all = false
194
+ }
195
+ if (all) return "checked"
196
+ return any ? "indeterminate" : "unchecked"
197
+ }
198
+
199
+ /** Ids of every ancestor directory of `id`, so callers can reveal a deep node. */
200
+ export function ancestorIds(nodes: FileNode[], id: string): string[] {
201
+ const path: string[] = []
202
+ function walk(list: FileNode[], trail: string[]): boolean {
203
+ for (const node of list) {
204
+ if (node.id === id) {
205
+ path.push(...trail)
206
+ return true
207
+ }
208
+ if (node.children && walk(node.children, [...trail, node.id])) return true
209
+ }
210
+ return false
211
+ }
212
+ walk(nodes, [])
213
+ return path
214
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,19 @@ export { default as LpDrawer } from "./components/LpDrawer.vue"
25
25
  export { default as LpDropdownMenu } from "./components/LpDropdownMenu.vue"
26
26
  export type { MenuItem } from "./components/LpDropdownMenu.vue"
27
27
  export { default as LpEmptyState } from "./components/LpEmptyState.vue"
28
+ export { default as LpFileTree } from "./components/LpFileTree.vue"
29
+ export type { FileTreeSummary } from "./components/LpFileTree.vue"
30
+ export type { CheckState, FileNode } from "./components/fileTree"
31
+ export {
32
+ ancestorIds,
33
+ checkStateOf,
34
+ fileIcon,
35
+ formatModified,
36
+ formatSize,
37
+ sortNodes,
38
+ subtreeIds,
39
+ subtreeSize,
40
+ } from "./components/fileTree"
28
41
  export { default as LpFormField } from "./components/LpFormField.vue"
29
42
  export { default as LpIcon } from "./components/LpIcon.vue"
30
43
  export { default as LpInfraNode } from "./components/LpInfraNode.vue"
@@ -60,6 +60,16 @@
60
60
  /* ── depth ───────────────────────────────────── */
61
61
  --shadow-panel: 0 16px 48px rgba(0, 0, 0, 0.32);
62
62
 
63
+ /* How far an interactive card's hover glow spreads past its own box, and how
64
+ far the card lifts. A scroll area or any `overflow: hidden` parent clips
65
+ whatever crosses its edge, so a list of cards has to reserve this much room
66
+ around itself or the glow gets sliced off — see the `.lp-glow-room` helper.
67
+ Derived from LpCard's glow (0 8px 24px -6px): blur 24 minus spread 6 = 18
68
+ sideways and up, plus the 8px downward offset below. */
69
+ --lp-glow-spread: 18px;
70
+ --lp-glow-spread-bottom: 26px;
71
+ --lp-card-lift: 2px;
72
+
63
73
  /* ── surface / skin ──────────────────────────────
64
74
  The skin axis (flat default). applyTheme() overrides these at runtime per
65
75
  TokenSet.surface; components paint panels via .lp-skin-panel which reads
@@ -131,16 +141,22 @@
131
141
  * of appearing. Exit drops back the same way but shorter and on an accelerating
132
142
  * curve, since a dismissal shouldn't be savoured.
133
143
  */
144
+ /*
145
+ * Centred-dialog enter/exit. Deliberately translate + fade with NO scale: the
146
+ * panel is a flex column with a max-height and its own scroll regions, and
147
+ * scaling it re-runs that layout every frame — the content visibly squashed and
148
+ * the animation stuttered. A short rise reads as arriving just as well.
149
+ */
134
150
  @keyframes pop-in {
135
151
  from {
136
152
  opacity: 0;
137
- transform: translate(-50%, calc(-50% + 8px)) scale(0.97);
153
+ transform: translate(-50%, calc(-50% + 10px));
138
154
  }
139
155
  }
140
156
  @keyframes pop-out {
141
157
  to {
142
158
  opacity: 0;
143
- transform: translate(-50%, calc(-50% + 4px)) scale(0.985);
159
+ transform: translate(-50%, calc(-50% + 5px));
144
160
  }
145
161
  }
146
162
  /*
@@ -263,6 +279,21 @@
263
279
  transform: translateY(0);
264
280
  }
265
281
  }
282
+ /*
283
+ * Tree/list row reveal: a row fades in while sliding from its parent's indent,
284
+ * so an expanding branch reads as unfolding outward rather than popping. Used by
285
+ * LpFileTree, which staggers it per row via --row-delay.
286
+ */
287
+ @keyframes tree-row-in {
288
+ from {
289
+ opacity: 0;
290
+ transform: translateX(-6px);
291
+ }
292
+ to {
293
+ opacity: 1;
294
+ transform: translateX(0);
295
+ }
296
+ }
266
297
 
267
298
  /*
268
299
  * Skin painter. A raised panel opts in with .lp-skin-panel and gets the active
@@ -294,6 +325,31 @@
294
325
  z-index: 1;
295
326
  }
296
327
 
328
+ /*
329
+ * Room for a hover glow. An interactive card's glow spreads past the card's own
330
+ * box, so inside anything that clips — a scroll area, a dialog body, a panel
331
+ * with `overflow: hidden` — the glow gets sliced off flat against the edge, and
332
+ * a card sitting at the very top or bottom of a list loses it entirely. The
333
+ * same clip takes the focus ring, which is likewise drawn outside the border;
334
+ * the glow is the wider of the two, so reserving for it covers both.
335
+ *
336
+ * Put this on the element that LAYS OUT the cards (the <ul>, the grid), not on
337
+ * the clipping parent: it insets the cards by exactly the distance the glow
338
+ * travels, so the glow lands on that padding instead of on the clip boundary.
339
+ *
340
+ * Deliberately plain padding, with NO negative margin to cancel it: the whole
341
+ * point is to keep the glow inside the clip, and pulling the box back out with
342
+ * margins would push it straight back under the blade. The cards therefore sit
343
+ * a little in from the edge — that inset IS the fix, not a side effect.
344
+ *
345
+ * Bottom gets more room than the top because the glow is offset downward, and
346
+ * the top gets the lift added back, since a hovered card rises into it.
347
+ */
348
+ .lp-glow-room {
349
+ padding: calc(var(--lp-glow-spread) + var(--lp-card-lift)) var(--lp-glow-spread)
350
+ var(--lp-glow-spread-bottom);
351
+ }
352
+
297
353
  /* ── circular theme reveal (View Transitions) ────
298
354
  applyThemeWithTransition() snapshots the page, swaps the theme tokens, then
299
355
  shrinks a clip-path circle in the OLD snapshot from the toggle's position,