@leavepulse/ui 0.14.9 → 0.15.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.
@@ -0,0 +1,285 @@
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
+ checkedCount,
15
+ checkStateOf,
16
+ fileIcon,
17
+ formatModified,
18
+ formatSize,
19
+ sortNodes,
20
+ subtreeSize,
21
+ type FileNode,
22
+ } from "./fileTree"
23
+ import LpCheckbox from "./LpCheckbox.vue"
24
+ import LpContextMenu from "./LpContextMenu.vue"
25
+ import LpIcon from "./LpIcon.vue"
26
+
27
+ const props = defineProps<{
28
+ node: FileNode
29
+ /** Nesting depth, 0 at the root — drives the indent guide and aria-level. */
30
+ depth: number
31
+ expanded: Set<string>
32
+ loading: Set<string>
33
+ selectedId?: string
34
+ focusedId?: string
35
+ sort: boolean
36
+ /** Rendered size of the row's icon, in px. */
37
+ iconSize: number
38
+ /** Render a tri-state checkbox per row (multi-select mode). */
39
+ checkable: boolean
40
+ checked: Set<string>
41
+ showSize: boolean
42
+ showModified: boolean
43
+ }>()
44
+
45
+ const emit = defineEmits<{
46
+ (e: "select", node: FileNode): void
47
+ (e: "toggle", node: FileNode): void
48
+ (e: "focus", node: FileNode): void
49
+ (e: "check", node: FileNode, value: boolean): void
50
+ }>()
51
+
52
+ // Declared explicitly: the component renders itself, so an inferred slot type
53
+ // would reference its own initializer (TS7022).
54
+ defineSlots<{
55
+ row(props: { node: FileNode; depth: number; expanded: boolean; selected: boolean }): unknown
56
+ }>()
57
+
58
+ const isDir = computed(() => props.node.kind === "dir")
59
+ const isOpen = computed(() => isDir.value && props.expanded.has(props.node.id))
60
+ const isLoading = computed(() => props.loading.has(props.node.id))
61
+ const isSelected = computed(() => props.selectedId === props.node.id)
62
+ const isFocused = computed(() => props.focusedId === props.node.id)
63
+
64
+ // A directory with no `children` array hasn't been loaded yet — we can't know if
65
+ // it's empty, so it stays expandable and the caller fills it on `expand`.
66
+ const hasChildren = computed(() => isDir.value && (props.node.children?.length ?? 0) > 0)
67
+ const canExpand = computed(() => isDir.value && (props.node.children === undefined || hasChildren.value))
68
+
69
+ const children = computed(() => {
70
+ const list = props.node.children ?? []
71
+ return props.sort ? sortNodes(list) : list
72
+ })
73
+
74
+ const checkState = computed(() =>
75
+ props.checkable ? checkStateOf(props.node, props.checked) : "unchecked",
76
+ )
77
+
78
+ // A directory shows the rolled-up size of what's under it, so a backup picker
79
+ // can answer "how much is this folder" without expanding it.
80
+ const sizeLabel = computed(() => {
81
+ if (!props.showSize) return ""
82
+ const bytes = subtreeSize(props.node)
83
+ return bytes === undefined ? "" : formatSize(bytes)
84
+ })
85
+
86
+ /**
87
+ * "3 / 12" on a directory whose subtree is partly ticked. Only shown while it
88
+ * actually differs from the whole — a fully ticked or untouched folder reads
89
+ * fine from the checkbox alone, and a count on every row would be noise.
90
+ */
91
+ const countLabel = computed(() => {
92
+ if (!props.checkable || !isDir.value) return ""
93
+ const { checked, total } = checkedCount(props.node, props.checked)
94
+ return total && checked && checked < total ? `${checked} / ${total}` : ""
95
+ })
96
+
97
+ const modifiedLabel = computed(() =>
98
+ props.showModified && props.node.modified !== undefined
99
+ ? formatModified(props.node.modified)
100
+ : "",
101
+ )
102
+
103
+ /**
104
+ * Per-row stagger for the reveal animation. Capped so a large directory doesn't
105
+ * spend a second unfolding — past the cap every remaining row comes in together.
106
+ */
107
+ function rowDelay(index: number): string {
108
+ return `${Math.min(index * 18, 180)}ms`
109
+ }
110
+
111
+ /** Respect the OS setting — skip the height animation entirely. */
112
+ function reducedMotion(): boolean {
113
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
114
+ }
115
+
116
+ // Expand/collapse the branch by animating from 0 to its measured height. The
117
+ // Web Animations API is used directly (rather than a CSS class) because the
118
+ // target height isn't known until the rows are in the DOM.
119
+ function onBranchEnter(el: Element, done: () => void) {
120
+ if (reducedMotion()) return done()
121
+ const height = (el as HTMLElement).scrollHeight
122
+ el.animate(
123
+ [
124
+ { height: "0px", opacity: 0 },
125
+ { height: `${height}px`, opacity: 1 },
126
+ ],
127
+ { duration: 220, easing: "cubic-bezier(0.2, 0, 0, 1)" },
128
+ ).onfinish = () => done()
129
+ }
130
+
131
+ function onBranchLeave(el: Element, done: () => void) {
132
+ if (reducedMotion()) return done()
133
+ const height = (el as HTMLElement).scrollHeight
134
+ el.animate(
135
+ [
136
+ { height: `${height}px`, opacity: 1 },
137
+ { height: "0px", opacity: 0 },
138
+ ],
139
+ { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)" },
140
+ ).onfinish = () => done()
141
+ }
142
+
143
+ function onActivate() {
144
+ if (props.node.disabled) return
145
+ emit("focus", props.node)
146
+ // A directory row toggles; a file row selects. Directories still emit select
147
+ // so callers can drive a details panel off the highlighted entry.
148
+ emit("select", props.node)
149
+ if (isDir.value) emit("toggle", props.node)
150
+ }
151
+ </script>
152
+
153
+ <template>
154
+ <li
155
+ role="treeitem"
156
+ class="min-w-0"
157
+ :aria-level="depth + 1"
158
+ :aria-selected="isSelected"
159
+ :aria-expanded="canExpand ? isOpen : undefined"
160
+ :aria-disabled="node.disabled || undefined"
161
+ >
162
+ <!-- Passthrough when the node has no menu, so the row keeps the native one. -->
163
+ <LpContextMenu :items="node.menu ?? []">
164
+ <div
165
+ :data-node-id="node.id"
166
+ :tabindex="isFocused ? 0 : -1"
167
+ 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"
168
+ :class="[
169
+ node.disabled
170
+ ? 'cursor-not-allowed text-muted/50'
171
+ : isSelected
172
+ ? 'bg-brand-soft text-ink'
173
+ : 'text-muted hover:bg-surface-soft hover:text-ink',
174
+ ]"
175
+ :style="{ paddingLeft: `${depth * 14 + 6}px` }"
176
+ @click.stop="onActivate"
177
+ >
178
+ <!-- Chevron sits in a fixed-width slot so names line up whether or not
179
+ the row can expand. Leaves get the same gutter, left empty. -->
180
+ <span class="flex size-4 shrink-0 items-center justify-center">
181
+ <LpIcon
182
+ v-if="isLoading"
183
+ name="lucide:loader-circle"
184
+ :size="13"
185
+ class="animate-spin text-muted"
186
+ />
187
+ <LpIcon
188
+ v-else-if="canExpand"
189
+ name="lucide:chevron-right"
190
+ :size="14"
191
+ class="text-muted transition-transform duration-[var(--duration-fast)]"
192
+ :class="isOpen ? 'rotate-90' : ''"
193
+ />
194
+ </span>
195
+
196
+ <!-- Checkbox is its own hit target: ticking a folder must not also
197
+ expand it (and vice versa), so the click stops here. -->
198
+ <LpCheckbox
199
+ v-if="checkable"
200
+ :model-value="checkState === 'checked'"
201
+ :indeterminate="checkState === 'indeterminate'"
202
+ :disabled="node.disabled"
203
+ class="shrink-0"
204
+ :aria-label="node.name"
205
+ @click.stop
206
+ @update:model-value="emit('check', node, $event)"
207
+ />
208
+
209
+ <LpIcon
210
+ :name="fileIcon(node, isOpen)"
211
+ :size="iconSize"
212
+ class="shrink-0"
213
+ :class="node.disabled ? '' : isDir ? 'text-brand' : 'text-muted'"
214
+ />
215
+
216
+ <span class="min-w-0 flex-1 truncate">
217
+ <slot name="row" :node="node" :depth="depth" :expanded="isOpen" :selected="isSelected">
218
+ {{ node.name }}
219
+ </slot>
220
+ </span>
221
+
222
+ <!-- Partial-selection counter: says outright how much of the folder is
223
+ ticked, which a half-state checkbox alone doesn't convey. -->
224
+ <span
225
+ v-if="countLabel"
226
+ class="shrink-0 rounded-pill bg-brand-soft px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-brand"
227
+ >
228
+ {{ countLabel }}
229
+ </span>
230
+
231
+ <!-- Metadata columns: tabular figures so sizes line up down the tree. -->
232
+ <span v-if="node.meta" class="shrink-0 text-xs text-muted">{{ node.meta }}</span>
233
+ <span
234
+ v-if="modifiedLabel"
235
+ class="shrink-0 text-xs tabular-nums text-muted/80"
236
+ >
237
+ {{ modifiedLabel }}
238
+ </span>
239
+ <span
240
+ v-if="sizeLabel"
241
+ class="w-16 shrink-0 text-right text-xs tabular-nums text-muted"
242
+ >
243
+ {{ sizeLabel }}
244
+ </span>
245
+ </div>
246
+ </LpContextMenu>
247
+
248
+ <!-- Height is only known at runtime, so the expand/collapse is measured in
249
+ JS hooks rather than a keyframe. Rows inside stagger themselves in. -->
250
+ <Transition
251
+ :css="false"
252
+ @enter="onBranchEnter"
253
+ @leave="onBranchLeave"
254
+ >
255
+ <ul v-if="isOpen && hasChildren" role="group" class="flex min-w-0 flex-col overflow-hidden">
256
+ <LpFileTreeNode
257
+ v-for="(child, i) in children"
258
+ :key="child.id"
259
+ :style="{ '--row-delay': rowDelay(i) }"
260
+ class="animate-[tree-row-in_var(--duration-medium)_var(--ease-emphasized)_both] [animation-delay:var(--row-delay)] motion-reduce:animate-none"
261
+ :node="child"
262
+ :depth="depth + 1"
263
+ :expanded="expanded"
264
+ :loading="loading"
265
+ :selected-id="selectedId"
266
+ :focused-id="focusedId"
267
+ :sort="sort"
268
+ :icon-size="iconSize"
269
+ :checkable="checkable"
270
+ :checked="checked"
271
+ :show-size="showSize"
272
+ :show-modified="showModified"
273
+ @select="emit('select', $event)"
274
+ @toggle="emit('toggle', $event)"
275
+ @focus="emit('focus', $event)"
276
+ @check="(n, v) => emit('check', n, v)"
277
+ >
278
+ <template #row="slotProps">
279
+ <slot name="row" v-bind="slotProps" />
280
+ </template>
281
+ </LpFileTreeNode>
282
+ </ul>
283
+ </Transition>
284
+ </li>
285
+ </template>
@@ -0,0 +1,239 @@
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
+ /**
200
+ * How much of a directory's subtree is ticked, counting FILES only (folders are
201
+ * containers, not payload). Lets a row say "3 / 12" so a half-filled checkbox
202
+ * isn't the only clue that a folder is partially selected.
203
+ */
204
+ export function checkedCount(
205
+ node: FileNode,
206
+ checked: ReadonlySet<string>,
207
+ ): { checked: number; total: number } {
208
+ let count = 0
209
+ let total = 0
210
+ function walk(list: FileNode[]) {
211
+ for (const child of list) {
212
+ if (child.kind === "file") {
213
+ total++
214
+ if (checked.has(child.id)) count++
215
+ } else if (child.children?.length) {
216
+ walk(child.children)
217
+ }
218
+ }
219
+ }
220
+ walk(node.children ?? [])
221
+ return { checked: count, total }
222
+ }
223
+
224
+ /** Ids of every ancestor directory of `id`, so callers can reveal a deep node. */
225
+ export function ancestorIds(nodes: FileNode[], id: string): string[] {
226
+ const path: string[] = []
227
+ function walk(list: FileNode[], trail: string[]): boolean {
228
+ for (const node of list) {
229
+ if (node.id === id) {
230
+ path.push(...trail)
231
+ return true
232
+ }
233
+ if (node.children && walk(node.children, [...trail, node.id])) return true
234
+ }
235
+ return false
236
+ }
237
+ walk(nodes, [])
238
+ return path
239
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,20 @@ 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
+ checkedCount,
34
+ checkStateOf,
35
+ fileIcon,
36
+ formatModified,
37
+ formatSize,
38
+ sortNodes,
39
+ subtreeIds,
40
+ subtreeSize,
41
+ } from "./components/fileTree"
28
42
  export { default as LpFormField } from "./components/LpFormField.vue"
29
43
  export { default as LpIcon } from "./components/LpIcon.vue"
30
44
  export { default as LpInfraNode } from "./components/LpInfraNode.vue"
@@ -279,6 +279,21 @@
279
279
  transform: translateY(0);
280
280
  }
281
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
+ }
282
297
 
283
298
  /*
284
299
  * Skin painter. A raised panel opts in with .lp-skin-panel and gets the active
@@ -319,10 +334,13 @@
319
334
  * the glow is the wider of the two, so reserving for it covers both.
320
335
  *
321
336
  * Put this on the element that LAYS OUT the cards (the <ul>, the grid), not on
322
- * the clipping parent: it pads the content inward by exactly the distance the
323
- * glow travels, so the glow lands on padding instead of on the clip boundary.
324
- * Negative margins pull that padding back out, so the cards stay where they
325
- * were and nothing shifts only the overflow gets somewhere to go.
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.
326
344
  *
327
345
  * Bottom gets more room than the top because the glow is offset downward, and
328
346
  * the top gets the lift added back, since a hovered card rises into it.
@@ -330,8 +348,6 @@
330
348
  .lp-glow-room {
331
349
  padding: calc(var(--lp-glow-spread) + var(--lp-card-lift)) var(--lp-glow-spread)
332
350
  var(--lp-glow-spread-bottom);
333
- margin: calc(-1 * (var(--lp-glow-spread) + var(--lp-card-lift))) calc(-1 * var(--lp-glow-spread))
334
- calc(-1 * var(--lp-glow-spread-bottom));
335
351
  }
336
352
 
337
353
  /* ── circular theme reveal (View Transitions) ────