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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/app/.claude/skills/ai-annotator/SKILL.md +31 -0
- package/app/app.config.ts +20 -0
- package/app/app.vue +7 -0
- package/app/assets/css/main.css +609 -0
- package/app/components/ContentView.vue +838 -0
- package/app/components/LocalStorageChecklist.vue +372 -0
- package/app/components/StackedColumn.vue +81 -0
- package/app/components/StackedColumns.vue +216 -0
- package/app/composables/useStack.ts +331 -0
- package/app/layouts/default.vue +22 -0
- package/app/pages/[...slug].vue +3 -0
- package/app/types/app-config.d.ts +19 -0
- package/content/index.md +25 -0
- package/content/license.md +62 -0
- package/nuxt.config.ts +76 -0
- package/package.json +55 -0
- package/tailwind.config.js +64 -0
- package/tsconfig.json +18 -0
|
@@ -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>
|