andy-note-nuxt 0.2.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,372 @@
1
+ <script setup lang="ts">
2
+ // Generic checklist with localStorage persistence for use in MDC content pages.
3
+ // Reusable across challenges, build progression, gear upgrade lists, atlas
4
+ // allocation tracking — anywhere a stateful checklist makes sense.
5
+ //
6
+ // `storageKey` is used verbatim — caller is responsible for namespacing
7
+ // (e.g. `poe-challenges-phase-1`, `build-twshamap-checklist`). Two checklists
8
+ // sharing a key share state.
9
+ //
10
+ // All styles in scoped <style> with plain CSS (no Tailwind utilities) to
11
+ // stay independent of (a) Tailwind purge state when this file is new and
12
+ // (b) prose-style overrides from ContentRenderer's `.content` wrapper.
13
+
14
+ interface ChecklistItem {
15
+ id: string
16
+ label: string
17
+ hint?: string
18
+ group?: string
19
+ }
20
+
21
+ const props = defineProps<{
22
+ storageKey: string
23
+ title?: string
24
+ description?: string
25
+ items: ChecklistItem[]
26
+ }>()
27
+
28
+ const mounted = ref(false)
29
+ const checked = ref<Set<string>>(new Set())
30
+
31
+ function readStorage(): Set<string> {
32
+ try {
33
+ const raw = localStorage.getItem(props.storageKey)
34
+ if (!raw) return new Set()
35
+ const parsed = JSON.parse(raw) as unknown
36
+ if (
37
+ typeof parsed === 'object'
38
+ && parsed !== null
39
+ && 'checked' in parsed
40
+ && Array.isArray((parsed as { checked: unknown }).checked)
41
+ ) {
42
+ return new Set((parsed as { checked: string[] }).checked)
43
+ }
44
+ }
45
+ catch {
46
+ // localStorage unavailable (private mode etc.) — fall back to in-memory
47
+ }
48
+ return new Set()
49
+ }
50
+
51
+ function writeStorage(state: Set<string>): void {
52
+ try {
53
+ localStorage.setItem(props.storageKey, JSON.stringify({ checked: Array.from(state) }))
54
+ }
55
+ catch {
56
+ // swallow — in-memory state still works
57
+ }
58
+ }
59
+
60
+ onMounted(() => {
61
+ checked.value = readStorage()
62
+ mounted.value = true
63
+ })
64
+
65
+ const checkedCount = computed(() => checked.value.size)
66
+ const totalCount = computed(() => props.items.length)
67
+ const isComplete = computed(() => totalCount.value > 0 && checkedCount.value === totalCount.value)
68
+
69
+ const hasGroups = computed(() => props.items.some(item => item.group))
70
+
71
+ const groups = computed<Array<{ name: string | null; items: ChecklistItem[] }>>(() => {
72
+ if (!hasGroups.value) return [{ name: null, items: props.items }]
73
+ const map = new Map<string, ChecklistItem[]>()
74
+ for (const item of props.items) {
75
+ const key = item.group ?? ''
76
+ if (!map.has(key)) map.set(key, [])
77
+ map.get(key)!.push(item)
78
+ }
79
+ return Array.from(map.entries()).map(([name, items]) => ({ name: name || null, items }))
80
+ })
81
+
82
+ function toggle(itemId: string): void {
83
+ const next = new Set(checked.value)
84
+ if (next.has(itemId)) next.delete(itemId)
85
+ else next.add(itemId)
86
+ checked.value = next
87
+ writeStorage(next)
88
+ }
89
+
90
+ function reset(): void {
91
+ const n = checkedCount.value
92
+ if (n === 0) return
93
+ // eslint-disable-next-line no-restricted-globals
94
+ if (!confirm(`Reset ${n} mục đã đánh dấu?`)) return
95
+ checked.value = new Set()
96
+ writeStorage(new Set())
97
+ }
98
+ </script>
99
+
100
+ <template>
101
+ <!-- All structural elements use <div>/<span> rather than prose tags
102
+ (ul/li/h3/p) so the surrounding ContentRenderer's `.content`
103
+ prose CSS (coral square ::before on li, h3 size, p margins, etc.)
104
+ does not target component internals. -->
105
+ <div class="lsc-card" role="group" :aria-label="title || 'Checklist'">
106
+ <div class="lsc-header">
107
+ <div class="lsc-header-row">
108
+ <div v-if="title" class="lsc-title">
109
+ {{ title }}
110
+ </div>
111
+ <div
112
+ v-if="mounted"
113
+ class="lsc-progress"
114
+ :class="{ 'lsc-progress--done': isComplete }"
115
+ >
116
+ {{ checkedCount }} / {{ totalCount }}
117
+ </div>
118
+ <button
119
+ v-if="mounted && checkedCount > 0"
120
+ type="button"
121
+ class="lsc-reset"
122
+ aria-label="Reset checklist"
123
+ @click="reset"
124
+ >
125
+
126
+ </button>
127
+ </div>
128
+ <div v-if="description" class="lsc-description">
129
+ {{ description }}
130
+ </div>
131
+ </div>
132
+
133
+ <div v-if="items.length === 0" class="lsc-empty">
134
+ No items.
135
+ </div>
136
+
137
+ <div v-else class="lsc-list">
138
+ <template v-for="group in groups" :key="group.name ?? '__flat__'">
139
+ <div v-if="group.name" class="lsc-group-label">
140
+ <span class="lsc-group-marker">▾</span>{{ group.name }}
141
+ </div>
142
+
143
+ <div v-for="item in group.items" :key="item.id" class="lsc-item">
144
+ <label class="lsc-row">
145
+ <input
146
+ type="checkbox"
147
+ class="lsc-checkbox"
148
+ :checked="mounted ? checked.has(item.id) : false"
149
+ @change="toggle(item.id)"
150
+ >
151
+ <span
152
+ class="lsc-label"
153
+ :class="{ 'lsc-label--checked': mounted && checked.has(item.id) }"
154
+ >
155
+ {{ item.label }}
156
+ </span>
157
+ </label>
158
+ <div v-if="item.hint" class="lsc-hint">
159
+ {{ item.hint }}
160
+ </div>
161
+ </div>
162
+ </template>
163
+ </div>
164
+ </div>
165
+ </template>
166
+
167
+ <style scoped>
168
+ /* Plain CSS — independent of Tailwind purge state and ContentRenderer's
169
+ `.content` prose overrides. Colors hardcoded from theme tokens
170
+ in tailwind.config.js so palette stays consistent. */
171
+
172
+ .lsc-card {
173
+ border: 3px solid #474541;
174
+ background: #2e2f2c;
175
+ box-shadow: 4px 4px 0 0 #474541;
176
+ margin: 1.5rem 0;
177
+ font-family: 'Space Grotesk', -apple-system, sans-serif;
178
+ color: #d5cfc5;
179
+ }
180
+
181
+ .lsc-header {
182
+ padding: 1rem 1rem 0.75rem;
183
+ border-bottom: 1px solid #474541;
184
+ }
185
+
186
+ .lsc-header-row {
187
+ display: flex;
188
+ align-items: flex-start;
189
+ gap: 0.5rem;
190
+ flex-wrap: wrap;
191
+ }
192
+
193
+ .lsc-title {
194
+ font-family: 'Space Grotesk', sans-serif;
195
+ font-size: 0.95rem;
196
+ font-weight: 700;
197
+ text-transform: uppercase;
198
+ letter-spacing: -0.01em;
199
+ color: #d5cfc5;
200
+ line-height: 1.25;
201
+ margin: 0;
202
+ flex: 1 1 60%;
203
+ min-width: 0;
204
+ }
205
+
206
+ .lsc-progress {
207
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
208
+ font-size: 0.625rem;
209
+ font-weight: 700;
210
+ font-variant-numeric: tabular-nums;
211
+ color: #a8a298;
212
+ border: 1px solid #474541;
213
+ padding: 0.25rem 0.5rem;
214
+ flex-shrink: 0;
215
+ white-space: nowrap;
216
+ }
217
+ .lsc-progress--done {
218
+ color: #ff7b6b;
219
+ border-color: #ff7b6b;
220
+ }
221
+
222
+ .lsc-reset {
223
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
224
+ font-size: 0.625rem;
225
+ text-transform: uppercase;
226
+ color: #8a857c;
227
+ background: transparent;
228
+ border: 1px solid #474541;
229
+ padding: 0.25rem 0.5rem;
230
+ cursor: pointer;
231
+ flex-shrink: 0;
232
+ transition: color 0.15s, border-color 0.15s;
233
+ }
234
+ .lsc-reset:hover {
235
+ color: #ff7b6b;
236
+ border-color: #ff7b6b;
237
+ }
238
+
239
+ .lsc-description {
240
+ margin: 0.5rem 0 0;
241
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
242
+ font-size: 0.875rem;
243
+ color: #a8a298;
244
+ line-height: 1.5;
245
+ }
246
+
247
+ .lsc-empty {
248
+ padding: 1.25rem 1rem;
249
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
250
+ font-size: 0.8125rem;
251
+ font-style: italic;
252
+ color: #8a857c;
253
+ }
254
+
255
+ .lsc-list {
256
+ list-style: none;
257
+ margin: 0;
258
+ padding: 0.5rem 1rem;
259
+ }
260
+
261
+ .lsc-group-label {
262
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
263
+ font-size: 0.8125rem;
264
+ font-weight: 700;
265
+ text-transform: uppercase;
266
+ letter-spacing: 0.1em;
267
+ color: #c0b8a8;
268
+ margin: 1rem 0 0.375rem;
269
+ display: flex;
270
+ align-items: center;
271
+ gap: 0.375rem;
272
+ }
273
+ .lsc-group-label:first-child {
274
+ margin-top: 0;
275
+ }
276
+ .lsc-group-marker {
277
+ color: #ff7b6b;
278
+ }
279
+
280
+ .lsc-item {
281
+ list-style: none;
282
+ margin: 0;
283
+ padding: 0.5rem 0;
284
+ }
285
+ .lsc-item + .lsc-item {
286
+ border-top: 1px dashed #3b3c39;
287
+ }
288
+
289
+ .lsc-row {
290
+ display: flex;
291
+ align-items: flex-start;
292
+ gap: 0.625rem;
293
+ cursor: pointer;
294
+ user-select: none;
295
+ width: 100%;
296
+ }
297
+
298
+ .lsc-checkbox {
299
+ appearance: none;
300
+ -webkit-appearance: none;
301
+ width: 1rem;
302
+ height: 1rem;
303
+ flex-shrink: 0;
304
+ margin: 0.1875rem 0 0;
305
+ border: 1.5px solid #5a5854;
306
+ background: #2a2a28;
307
+ border-radius: 0;
308
+ cursor: pointer;
309
+ position: relative;
310
+ transition: background 0.1s, border-color 0.1s;
311
+ }
312
+ .lsc-checkbox:hover {
313
+ border-color: #ff7b6b;
314
+ }
315
+ .lsc-checkbox:checked {
316
+ background: #ff7b6b;
317
+ border-color: #ff7b6b;
318
+ }
319
+ .lsc-checkbox:checked::after {
320
+ content: '';
321
+ position: absolute;
322
+ inset: 0;
323
+ background: #2a2a28;
324
+ clip-path: polygon(15% 50%, 40% 75%, 85% 25%, 75% 15%, 40% 55%, 25% 40%);
325
+ }
326
+ .lsc-checkbox:focus-visible {
327
+ outline: 2px solid #ff7b6b;
328
+ outline-offset: 2px;
329
+ }
330
+
331
+ .lsc-label {
332
+ font-family: 'Space Grotesk', sans-serif;
333
+ font-size: 0.9375rem;
334
+ font-weight: 500;
335
+ color: #d5cfc5;
336
+ line-height: 1.45;
337
+ flex: 1 1 auto;
338
+ min-width: 0;
339
+ word-wrap: break-word;
340
+ overflow-wrap: break-word;
341
+ hyphens: auto;
342
+ }
343
+ .lsc-label--checked {
344
+ text-decoration: line-through;
345
+ opacity: 0.55;
346
+ }
347
+
348
+ .lsc-hint {
349
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
350
+ font-size: 0.8125rem;
351
+ color: #a8a298;
352
+ line-height: 1.5;
353
+ margin: 0.375rem 0 0 1.625rem;
354
+ word-wrap: break-word;
355
+ overflow-wrap: break-word;
356
+ }
357
+
358
+ /* Narrow column safety — keep readability when host column < 360px. */
359
+ @media (max-width: 360px) {
360
+ .lsc-card {
361
+ box-shadow: 2px 2px 0 0 #474541;
362
+ }
363
+ .lsc-header,
364
+ .lsc-list {
365
+ padding-left: 0.625rem;
366
+ padding-right: 0.625rem;
367
+ }
368
+ .lsc-hint {
369
+ margin-left: 0;
370
+ }
371
+ }
372
+ </style>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ path: string
4
+ index: number
5
+ }>()
6
+
7
+ const { fullStack, handleStackClick, scrollToColumn } = useStack()
8
+
9
+
10
+ /**
11
+ * Click on a column performs two things in order:
12
+ * 1. handleStackClick — push a new column if an internal anchor was clicked
13
+ * (preventDefault is set inside useStack when this path is taken).
14
+ * 2. Fallthrough — if the click was NOT consumed by anchor handling
15
+ * (empty space, peek-strip click, external link, etc.), bring this column
16
+ * into focus by scrolling its right edge to the viewport's right edge.
17
+ * Idempotent for already-active columns (scrollIntoView is a no-op).
18
+ */
19
+ function onClick(event: MouseEvent) {
20
+ handleStackClick(event, props.index)
21
+ if (!event.defaultPrevented) {
22
+ scrollToColumn(props.index)
23
+ }
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <div
29
+ class="stacked-column flex flex-col h-full overflow-hidden bg-terminal-bg border-r-[3px] border-terminal-border"
30
+ :data-column-index="index"
31
+ :style="{ '--col-idx': index }"
32
+ >
33
+ <div class="flex-1 min-h-0" @click.capture="onClick">
34
+ <!--
35
+ :key="path" forces ContentView to remount when this slot's path
36
+ prop changes. Without it, a slot whose StackedColumns key is the
37
+ index (kept stable across stack mutations to preserve transition
38
+ identity) keeps the same ContentView instance — but ContentView's
39
+ useAsyncData baked the initial path into its cache key at setup
40
+ time, so the rendered content stays frozen on the original path.
41
+ Remounting on path change re-runs setup with the new path.
42
+ -->
43
+ <ContentView :key="path" :path="path" :no-throw="true" />
44
+ </div>
45
+ </div>
46
+ </template>
47
+
48
+ <style scoped>
49
+ /* Sticky-stack layout — earlier columns peek 48px at the viewport's left
50
+ edge via position:sticky; z-index ascends with --col-idx so later columns
51
+ paint over earlier ones (otherwise col 0 would cover all subsequent
52
+ peeks). The last column's sticky never activates because max-scroll
53
+ stays below its threshold (N*col-width − viewport vs. (N−1)*(col-width
54
+ − peek)) for typical viewport sizes, so it stays at natural flex
55
+ position — exactly where the active card belongs.
56
+
57
+ No opacity/filter dim on stacked cols. Tried it — the translucency
58
+ bled lower-z columns through their stacked neighbors, producing visual
59
+ noise the user called "chồng chéo". Borders + z-index already give
60
+ sufficient stacking cues without compromising readability. */
61
+ .stacked-column {
62
+ flex: 0 0 var(--column-width);
63
+ min-width: var(--column-min-width);
64
+ position: sticky;
65
+ left: calc(var(--col-idx, 0) * var(--stack-peek, 48px));
66
+ z-index: var(--col-idx, 0);
67
+ /* Left outline via box-shadow: takes no layout space, so when two columns
68
+ are adjacent the left shadow of the later column (higher z-index) paints
69
+ directly over the earlier column's right border — unified table-cell look.
70
+ When a column stands alone, it renders as a clean left border. */
71
+ box-shadow: -3px 0 0 #474541;
72
+ }
73
+
74
+ @media (max-width: 767px) {
75
+ .stacked-column {
76
+ flex: 0 0 100vw;
77
+ min-width: 100vw;
78
+ position: static;
79
+ }
80
+ }
81
+ </style>
@@ -0,0 +1,216 @@
1
+ <script setup lang="ts">
2
+ const containerRef = useTemplateRef<HTMLDivElement>('container')
3
+ const { fullStack, activeIndex, stack, isMobile, scrollToColumn } = useStack()
4
+ const route = useRoute()
5
+ const router = useRouter()
6
+
7
+ let observer: IntersectionObserver | null = null
8
+ const ratios = new Map<number, number>()
9
+ let activeUpdateTimer: ReturnType<typeof setTimeout> | null = null
10
+
11
+ function recomputeActive() {
12
+ let best = -1
13
+ let bestRatio = 0
14
+ for (const [idx, ratio] of ratios) {
15
+ if (ratio > bestRatio) {
16
+ bestRatio = ratio
17
+ best = idx
18
+ }
19
+ }
20
+ if (best >= 0 && best !== activeIndex.value) {
21
+ activeIndex.value = best
22
+ }
23
+ }
24
+
25
+ /**
26
+ * IntersectionObserver fires many times per second during smooth scroll,
27
+ * which without debouncing caused activeIndex (and the .border-primary
28
+ * highlight on the active column) to flicker between adjacent columns
29
+ * mid-scroll. Wait until intersection ratios stabilize for 200ms before
30
+ * resolving the new active column. Programmatic scrolls from
31
+ * scrollToColumn already set activeIndex synchronously so the highlight
32
+ * is correct immediately on click — this debounce only governs the
33
+ * post-settle reconciliation.
34
+ */
35
+ function scheduleRecomputeActive() {
36
+ if (activeUpdateTimer) clearTimeout(activeUpdateTimer)
37
+ activeUpdateTimer = setTimeout(() => {
38
+ recomputeActive()
39
+ activeUpdateTimer = null
40
+ }, 200)
41
+ }
42
+
43
+ function observeAllColumns() {
44
+ if (!observer || !containerRef.value) return
45
+ observer.disconnect()
46
+ ratios.clear()
47
+ const cols = containerRef.value.querySelectorAll<HTMLElement>('[data-column-index]')
48
+ for (const el of cols) {
49
+ observer.observe(el)
50
+ }
51
+ }
52
+
53
+ function maybeRedirectMobile() {
54
+ if (!isMobile.value) return
55
+ if (stack.value.length === 0) return
56
+ const last = stack.value[stack.value.length - 1]
57
+ if (!last) return
58
+ router.replace({ path: last })
59
+ }
60
+
61
+ onMounted(() => {
62
+ if (!containerRef.value) return
63
+ observer = new IntersectionObserver(
64
+ (entries) => {
65
+ for (const entry of entries) {
66
+ const idx = Number((entry.target as HTMLElement).dataset.columnIndex)
67
+ ratios.set(idx, entry.intersectionRatio)
68
+ }
69
+ scheduleRecomputeActive()
70
+ },
71
+ {
72
+ root: containerRef.value,
73
+ threshold: [0, 0.25, 0.5, 0.75, 1],
74
+ },
75
+ )
76
+ observeAllColumns()
77
+ maybeRedirectMobile()
78
+
79
+ if (!isMobile.value && fullStack.value.length > 1) {
80
+ scrollToColumn(fullStack.value.length - 1)
81
+ }
82
+ })
83
+
84
+ watch(isMobile, (now, prev) => {
85
+ if (now && !prev) maybeRedirectMobile()
86
+ })
87
+
88
+ onBeforeUnmount(() => {
89
+ if (activeUpdateTimer) clearTimeout(activeUpdateTimer)
90
+ observer?.disconnect()
91
+ observer = null
92
+ })
93
+
94
+ watch(
95
+ fullStack,
96
+ () => {
97
+ nextTick(() => observeAllColumns())
98
+ },
99
+ { flush: 'post' },
100
+ )
101
+ </script>
102
+
103
+ <template>
104
+ <div
105
+ ref="container"
106
+ class="flex overflow-x-auto overflow-y-hidden h-full w-full"
107
+ data-stacked-columns
108
+ >
109
+ <!--
110
+ v-for is owned by this component (not the page) so TransitionGroup
111
+ can track keys directly without going through a slot. Key is the
112
+ column INDEX (not the path) — this is the load-bearing decision for
113
+ a clean transition story:
114
+
115
+ Stack [route, A, B, C, D] + click link in col 2 → [route, A, B, X]
116
+
117
+ With :key="path" → C unmounts at slot 3, X mounts at slot 3, D
118
+ unmounts at slot 4. Three concurrent
119
+ transitions, including a crossfade collision
120
+ at slot 3. Reads as flickery and confused.
121
+
122
+ With :key="index" → slot 3's path prop changes C→X (no transition,
123
+ instant content swap). Only slot 4 actually
124
+ disappears, fading once. Symmetric and
125
+ logical — matches the user's mental model
126
+ ("trailing column closes, this column shows
127
+ new content"). Same applies for pure pushes
128
+ (only the new last index fades in) and pure
129
+ trims (only the trailing indices fade out).
130
+ -->
131
+ <TransitionGroup name="col-fade">
132
+ <StackedColumn
133
+ v-for="(p, i) in fullStack"
134
+ :key="i"
135
+ :path="p"
136
+ :index="i"
137
+ />
138
+ </TransitionGroup>
139
+ </div>
140
+ </template>
141
+
142
+ <style scoped>
143
+ /*
144
+ Stack-mutation transition story.
145
+
146
+ Click a link in a middle column (e.g. [A,B,C,D,E] + click in B → [A,B,X])
147
+ is implemented in useStack.pushColumn as a SINGLE router.replace combining
148
+ trim + push. That mutation lands as one atomic stack swap on TransitionGroup,
149
+ which — with key=index (see v-for above) — sees:
150
+
151
+ Slot 0 (A): unchanged → no transition.
152
+ Slot 1 (B): unchanged → no transition.
153
+ Slot 2 (was C): path prop changes → C → X. ContentView's :key="path"
154
+ inside the slot remounts so the new content renders;
155
+ the slot itself runs NO column-level transition.
156
+ Slot 3 (was D): no replacement → leave-fade.
157
+ Slot 4 (was E): no replacement → leave-fade.
158
+
159
+ Three pathologies the design defends against:
160
+
161
+ 1. Z-INDEX OCCLUSION. Leaving cols natural z-index:var(--col-idx) would
162
+ paint OVER the new active X during their fade. Defended by .col-fade-
163
+ leave-active forcing z-index:0 + pointer-events:none below.
164
+
165
+ 2. SCROLLWIDTH SHRINK MID-SCROLL. Smooth scroll target depends on
166
+ finalFullLength, but during the leave-fade the leaving slots still
167
+ occupy width. useStack passes finalFullLength to scrollToColumn so
168
+ it clamps to the POST-fade max up front; once Vue unmounts the
169
+ leaving slots and scrollWidth shrinks, scrollLeft is already at the
170
+ new max and there is no visible clamp jump.
171
+
172
+ 3. STAGGER TIMING. CSS leave-active gets a delay = (max-col-idx − col-idx)
173
+ × LEAVE_STAGGER_MS, so the rightmost leaving column starts fading at
174
+ t=0 and inner ones follow inward — tail collapses toward the clicked
175
+ column rather than dissolving as a block. useStack writes
176
+ --max-col-idx onto the container before router.replace so the calc
177
+ resolves correctly the first time leave-active is applied.
178
+
179
+ Constants must stay in sync with LEAVE_DURATION_MS and LEAVE_STAGGER_MS
180
+ in useStack.ts — search for "LEAVE_DURATION_MS" if you change them.
181
+ */
182
+ .col-fade-enter-active {
183
+ transition: opacity 220ms ease;
184
+ }
185
+ .col-fade-enter-from {
186
+ opacity: 0;
187
+ }
188
+
189
+ .col-fade-leave-active {
190
+ transition: opacity 200ms ease;
191
+ /* Stagger: rightmost leaving col (high col-idx) goes first, leftmost
192
+ leaving col (low col-idx, closest to surviving stack) goes last.
193
+ --max-col-idx is set by useStack.pushColumn on the container before
194
+ trim. When unset (e.g. browser back/forward navigation that bypasses
195
+ pushColumn), the calc resolves to invalid and transition-delay falls
196
+ to 0 — every column fades at once, the correct behavior for an
197
+ un-orchestrated removal. */
198
+ transition-delay: calc((var(--max-col-idx) - var(--col-idx)) * 60ms);
199
+ /* Take leaving cols out of the visible stack so they can't occlude
200
+ surviving columns or steal clicks during the fade. */
201
+ z-index: 0 !important;
202
+ pointer-events: none;
203
+ }
204
+ .col-fade-leave-to {
205
+ opacity: 0;
206
+ }
207
+
208
+ /*
209
+ scroll-snap was removed. With sticky-stacking, mandatory snap-end
210
+ caused multiple columns to share scrollLeft=0 as their snap target
211
+ (clamped for any K where (K+1)*column-width ≤ viewport), so scrolling
212
+ toward col 0 yanked the user back to a middle column. Smooth scroll
213
+ via scrollIntoView({behavior:'smooth'}) handles programmatic alignment
214
+ on click without CSS snap.
215
+ */
216
+ </style>