kmcom-nuxt-layers 6.2.0 → 6.3.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.
Files changed (65) hide show
  1. package/docs/MIGRATION-v6.3.0.md +234 -0
  2. package/docs/MIGRATION.md +2 -0
  3. package/docs/component-levels.md +116 -0
  4. package/layers/content/app/components/Blog/ItemPage.vue +2 -7
  5. package/layers/content/app/components/Blog/List.vue +9 -2
  6. package/layers/content/app/components/Blog/Page.vue +9 -19
  7. package/layers/content/app/components/Gallery/Detail.vue +1 -0
  8. package/layers/content/app/components/Gallery/Grid.vue +9 -2
  9. package/layers/content/app/components/Gallery/ImageDetail.vue +0 -6
  10. package/layers/content/app/components/Gallery/ImagePage.vue +10 -6
  11. package/layers/content/app/components/Gallery/Page.vue +9 -19
  12. package/layers/content/app/components/Gallery/SlugPage.vue +2 -7
  13. package/layers/content/app/components/NuxtContent/Detail.vue +20 -11
  14. package/layers/content/app/components/NuxtContent/IndexTemplate.vue +21 -0
  15. package/layers/content/app/components/NuxtContent/List.vue +6 -1
  16. package/layers/content/app/components/Portfolio/ColorPalette.vue +2 -2
  17. package/layers/content/app/components/Portfolio/ItemPage.vue +2 -6
  18. package/layers/content/app/components/Portfolio/List.vue +19 -8
  19. package/layers/content/app/components/Portfolio/Page.vue +9 -19
  20. package/layers/content/app/composables/useContentItemSeo.ts +19 -0
  21. package/layers/content/app/composables/useContentSection.ts +9 -0
  22. package/layers/content/app/utils/galleryImageMeta.test.ts +30 -0
  23. package/layers/content/app/utils/galleryImageMeta.ts +11 -0
  24. package/layers/content/nuxt.config.ts +1 -1
  25. package/layers/core/app/assets/css/core.css +5 -11
  26. package/layers/core/app/assets/css/tokens/z-index.css +16 -0
  27. package/layers/core/app/components/DiagnosticsPage.vue +41 -41
  28. package/layers/core/app/components/LoadingScreen.vue +2 -0
  29. package/layers/delivery/feeds/app/components/Feeds/Index.vue +36 -75
  30. package/layers/delivery/feeds/app/components/Feeds/RouteCard.vue +6 -8
  31. package/layers/design-system/theming/nuxt.config.ts +1 -3
  32. package/layers/design-system/visual/app/components/Base/Modal.vue +34 -86
  33. package/layers/design-system/visual/app/components/HoverDimGroup.vue +46 -0
  34. package/layers/design-system/visual/app/components/Progress/Bar.vue +9 -29
  35. package/layers/motion/animations/app/components/Motion/Cursor.vue +1 -1
  36. package/layers/motion/animations/app/components/Motion/Marquee.vue +5 -0
  37. package/layers/motion/animations/app/components/Motion/MarqueeText.vue +2 -1
  38. package/layers/motion/animations/app/components/Motion/Staggered.test.ts +40 -0
  39. package/layers/motion/animations/app/components/Motion/Staggered.vue +37 -42
  40. package/layers/motion/animations/app/components/Motion/TextReveal.vue +8 -1
  41. package/layers/motion/animations/app/components/Motion/VelocityEffect.vue +6 -0
  42. package/layers/motion/animations/app/composables/useCountUp.ts +6 -0
  43. package/layers/motion/animations/app/composables/useCursorFollower.ts +4 -1
  44. package/layers/motion/animations/app/utils/pointerMotion.ts +7 -2
  45. package/layers/motion/scroll/app/components/Motion/HorizontalScroll.vue +9 -0
  46. package/layers/motion/scroll/app/components/Motion/Parallax.vue +13 -12
  47. package/layers/motion/scroll/app/components/Motion/PinnedSection.vue +16 -9
  48. package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +23 -0
  49. package/layers/motion/scroll/app/composables/useReducedMotion.ts +14 -0
  50. package/layers/motion/scroll/app/plugins/locomotive-scroll.client.ts +9 -1
  51. package/layers/motion/scroll/app/utils/parallax.test.ts +24 -0
  52. package/layers/motion/scroll/app/utils/parallax.ts +16 -0
  53. package/layers/motion/scroll/nuxt.config.ts +1 -1
  54. package/layers/structure/layout/app/components/Layout/Grid/Debug.vue +1 -1
  55. package/layers/structure/layout/app/components/Layout/Page/index.vue +9 -10
  56. package/layers/structure/navigation/app/components/Links/Group.vue +12 -38
  57. package/layers/structure/navigation/app/components/Links/Named.vue +9 -0
  58. package/layers/structure/navigation/app/components/Mast/Footer.vue +1 -1
  59. package/layers/structure/navigation/app/components/Mast/Nav.vue +3 -1
  60. package/layers/structure/navigation/app/components/Mast/NavModal.vue +34 -39
  61. package/layers/structure/navigation/app/components/Site/Subtitle.vue +10 -0
  62. package/layers/structure/navigation/app/components/Site/Title.vue +8 -0
  63. package/layers/structure/navigation/nuxt.config.ts +7 -1
  64. package/package.json +1 -1
  65. /package/layers/{design-system/theming/app/assets/css/tokens.css → core/app/assets/css/tokens/elevation-motion.css} +0 -0
@@ -20,18 +20,13 @@
20
20
 
21
21
  <template>
22
22
  <div
23
- class="feeds-shell relative overflow-hidden rounded-4xl border border-amber-200/70 bg-[linear-gradient(180deg,rgba(255,251,244,0.96),rgba(243,236,223,0.95))] shadow-[0_30px_100px_rgba(15,23,42,0.12)] dark:border-slate-800/80 dark:bg-[linear-gradient(180deg,rgba(2,6,23,0.96),rgba(3,7,18,0.98))]"
24
- style="container-type: inline-size"
23
+ class="feeds-shell border-default bg-muted @container relative overflow-hidden rounded-4xl border shadow-xl"
25
24
  >
26
25
  <div class="pointer-events-none absolute inset-0">
26
+ <div class="bg-primary/20 absolute top-0 -right-20 h-72 w-72 rounded-full blur-3xl" />
27
+ <div class="bg-secondary/10 absolute bottom-0 -left-20 h-80 w-80 rounded-full blur-3xl" />
27
28
  <div
28
- class="absolute top-0 -right-20 h-72 w-72 rounded-full bg-orange-400/20 blur-3xl dark:bg-orange-500/15"
29
- />
30
- <div
31
- class="absolute bottom-0 -left-20 h-80 w-80 rounded-full bg-sky-400/12 blur-3xl dark:bg-sky-500/10"
32
- />
33
- <div
34
- class="absolute inset-x-8 top-0 h-px bg-linear-to-r from-transparent via-orange-400/35 to-transparent"
29
+ class="via-primary/35 absolute inset-x-8 top-0 h-px bg-linear-to-r from-transparent to-transparent"
35
30
  />
36
31
  </div>
37
32
 
@@ -39,19 +34,17 @@
39
34
  <div class="feeds-split grid gap-6">
40
35
  <div class="space-y-6">
41
36
  <div
42
- class="inline-flex items-center gap-2 rounded-full border border-amber-200/80 bg-white/80 px-3 py-1 text-[0.68rem] font-semibold tracking-[0.35em] text-amber-700 uppercase shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-amber-200"
37
+ class="border-default bg-default/80 text-primary inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-[0.35em] uppercase shadow-sm backdrop-blur"
43
38
  >
44
39
  <UIcon name="i-lucide-rss" class="text-sm" />
45
40
  Feeds layer
46
41
  </div>
47
42
 
48
43
  <div class="space-y-3">
49
- <h1
50
- class="feeds-h1 max-w-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-50"
51
- >
44
+ <h1 class="feeds-h1 text-highlighted max-w-3xl font-semibold tracking-tight">
52
45
  Feed catalog for {{ catalog.site.title }}
53
46
  </h1>
54
- <p class="feeds-body max-w-2xl leading-7 text-slate-700 dark:text-slate-300">
47
+ <p class="feeds-body text-toned max-w-2xl leading-7">
55
48
  Configured in <code class="font-mono text-[0.9em]">app.config.ts</code>, validated
56
49
  against <code class="font-mono text-[0.9em]">content.config.ts</code>, and rendered as
57
50
  a human-readable index for readers and site owners.
@@ -60,82 +53,56 @@
60
53
 
61
54
  <div class="feeds-cards-grid gap-3">
62
55
  <div
63
- class="rounded-2xl border border-slate-200/80 bg-white/75 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
56
+ class="border-default bg-default/75 rounded-2xl border p-4 shadow-sm backdrop-blur"
64
57
  >
65
- <p
66
- class="text-[0.68rem] font-semibold tracking-[0.3em] text-slate-500 uppercase dark:text-slate-400"
67
- >
68
- Default
69
- </p>
70
- <p class="mt-2 text-lg font-semibold text-slate-950 dark:text-slate-100">
58
+ <p class="text-muted text-xs font-semibold tracking-[0.3em] uppercase">Default</p>
59
+ <p class="text-highlighted mt-2 text-lg font-semibold">
71
60
  {{ catalog.feed.defaultCollection }}
72
61
  </p>
73
- <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
74
- Shorthand routes point here.
75
- </p>
62
+ <p class="text-muted mt-1 text-xs">Shorthand routes point here.</p>
76
63
  </div>
77
64
 
78
65
  <div
79
- class="rounded-2xl border border-slate-200/80 bg-white/75 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
66
+ class="border-default bg-default/75 rounded-2xl border p-4 shadow-sm backdrop-blur"
80
67
  >
81
- <p
82
- class="text-[0.68rem] font-semibold tracking-[0.3em] text-slate-500 uppercase dark:text-slate-400"
83
- >
84
- Exposed
85
- </p>
86
- <p class="mt-2 text-lg font-semibold text-slate-950 dark:text-slate-100">
68
+ <p class="text-muted text-xs font-semibold tracking-[0.3em] uppercase">Exposed</p>
69
+ <p class="text-highlighted mt-2 text-lg font-semibold">
87
70
  {{ catalog.collectionGroups.length }} collections
88
71
  </p>
89
- <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
90
- Published in the feed catalog.
91
- </p>
72
+ <p class="text-muted mt-1 text-xs">Published in the feed catalog.</p>
92
73
  </div>
93
74
 
94
75
  <div
95
- class="rounded-2xl border border-slate-200/80 bg-white/75 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
76
+ class="border-default bg-default/75 rounded-2xl border p-4 shadow-sm backdrop-blur"
96
77
  >
97
- <p
98
- class="text-[0.68rem] font-semibold tracking-[0.3em] text-slate-500 uppercase dark:text-slate-400"
99
- >
100
- Limit
101
- </p>
102
- <p class="mt-2 text-lg font-semibold text-slate-950 dark:text-slate-100">
78
+ <p class="text-muted text-xs font-semibold tracking-[0.3em] uppercase">Limit</p>
79
+ <p class="text-highlighted mt-2 text-lg font-semibold">
103
80
  {{ catalog.feed.limit }} items
104
81
  </p>
105
- <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
106
- Applied to each feed response.
107
- </p>
82
+ <p class="text-muted mt-1 text-xs">Applied to each feed response.</p>
108
83
  </div>
109
84
  </div>
110
85
 
111
86
  <div
112
87
  v-if="catalog.site.url || catalog.site.description"
113
- class="flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200/80 bg-white/70 px-4 py-3 text-sm shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
88
+ class="border-default bg-default/70 flex flex-wrap items-center gap-3 rounded-2xl border px-4 py-3 text-sm shadow-sm backdrop-blur"
114
89
  >
115
- <span
116
- class="text-xs font-semibold tracking-[0.3em] text-slate-500 uppercase dark:text-slate-400"
117
- >
118
- Site
119
- </span>
120
- <span v-if="catalog.site.description" class="text-slate-700 dark:text-slate-300">
90
+ <span class="text-muted text-xs font-semibold tracking-[0.3em] uppercase"> Site </span>
91
+ <span v-if="catalog.site.description" class="text-toned">
121
92
  {{ catalog.site.description }}
122
93
  </span>
123
94
  <code
124
95
  v-if="catalog.site.url"
125
- class="rounded-full bg-slate-100 px-2 py-1 font-mono text-[0.72rem] text-slate-600 dark:bg-slate-800 dark:text-slate-300"
96
+ class="bg-elevated text-muted rounded-full px-2 py-1 font-mono text-xs"
126
97
  >
127
98
  {{ catalog.site.url }}
128
99
  </code>
129
100
  </div>
130
101
  </div>
131
102
 
132
- <aside
133
- class="rounded-[1.5rem] border border-slate-200/80 bg-white/75 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/45"
134
- >
103
+ <aside class="border-default bg-default/75 rounded-3xl border p-4 shadow-sm backdrop-blur">
135
104
  <div class="flex items-center justify-between gap-3">
136
- <p
137
- class="text-[0.68rem] font-semibold tracking-[0.35em] text-slate-500 uppercase dark:text-slate-400"
138
- >
105
+ <p class="text-muted text-xs font-semibold tracking-[0.35em] uppercase">
139
106
  Global routes
140
107
  </p>
141
108
  <UBadge color="neutral" variant="subtle" size="xs">
@@ -161,18 +128,14 @@
161
128
  <div class="space-y-4">
162
129
  <div class="flex flex-wrap items-end justify-between gap-4">
163
130
  <div>
164
- <p
165
- class="text-[0.68rem] font-semibold tracking-[0.35em] text-slate-500 uppercase dark:text-slate-400"
166
- >
131
+ <p class="text-muted text-xs font-semibold tracking-[0.35em] uppercase">
167
132
  Collection routes
168
133
  </p>
169
- <h2
170
- class="mt-2 text-2xl font-semibold tracking-tight text-slate-950 dark:text-slate-100"
171
- >
134
+ <h2 class="text-highlighted mt-2 text-2xl font-semibold tracking-tight">
172
135
  Each exposed collection gets RSS, Atom, and JSON.
173
136
  </h2>
174
137
  </div>
175
- <p class="max-w-lg text-sm text-slate-600 dark:text-slate-400">
138
+ <p class="text-muted max-w-lg text-sm">
176
139
  Add or remove collections in <code class="font-mono text-[0.9em]">app.config.ts</code>
177
140
  and the catalog updates automatically.
178
141
  </p>
@@ -183,21 +146,19 @@
183
146
  v-for="group in catalog.collectionGroups"
184
147
  :key="group.collection"
185
148
  :ui="{ body: 'p-0' }"
186
- class="overflow-hidden border-slate-200/80 bg-white/80 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-950/45"
149
+ class="border-default bg-default/80 overflow-hidden shadow-sm backdrop-blur"
187
150
  >
188
151
  <div
189
- class="flex flex-wrap items-start justify-between gap-4 border-b border-slate-200/80 px-5 py-4 dark:border-slate-800"
152
+ class="border-default flex flex-wrap items-start justify-between gap-4 border-b px-5 py-4"
190
153
  >
191
154
  <div>
192
- <p
193
- class="text-[0.68rem] font-semibold tracking-[0.35em] text-slate-500 uppercase dark:text-slate-400"
194
- >
155
+ <p class="text-muted text-xs font-semibold tracking-[0.35em] uppercase">
195
156
  Collection
196
157
  </p>
197
- <h3 class="mt-2 text-xl font-semibold text-slate-950 dark:text-slate-100">
158
+ <h3 class="text-highlighted mt-2 text-xl font-semibold">
198
159
  {{ group.label }}
199
160
  </h3>
200
- <p class="mt-1 font-mono text-sm text-slate-500 dark:text-slate-400">
161
+ <p class="text-muted mt-1 font-mono text-sm">
201
162
  {{ group.collection }}
202
163
  </p>
203
164
  </div>
@@ -215,13 +176,13 @@
215
176
  <UCard
216
177
  v-else
217
178
  :ui="{ body: 'p-0' }"
218
- class="border-dashed border-slate-300 bg-white/70 shadow-none dark:border-slate-700 dark:bg-slate-950/40"
179
+ class="border-accented bg-default/70 border-dashed shadow-none"
219
180
  >
220
181
  <div class="space-y-2 p-5">
221
- <p class="text-sm font-medium text-slate-950 dark:text-slate-100">
182
+ <p class="text-highlighted text-sm font-medium">
222
183
  No collection feeds are currently exposed.
223
184
  </p>
224
- <p class="text-sm text-slate-600 dark:text-slate-400">
185
+ <p class="text-muted text-sm">
225
186
  Add page collections to
226
187
  <code class="font-mono text-[0.9em]">feedsLayer.feed.collections</code>
227
188
  in <code class="font-mono text-[0.9em]">app.config.ts</code>.
@@ -16,8 +16,8 @@
16
16
  }
17
17
  > = {
18
18
  index: {
19
- strip: 'bg-slate-400',
20
- dot: 'bg-slate-500',
19
+ strip: 'bg-accented',
20
+ dot: 'bg-inverted/40',
21
21
  },
22
22
  rss: {
23
23
  strip: 'bg-orange-400',
@@ -50,23 +50,21 @@
50
50
  <UCard
51
51
  :to="route.path"
52
52
  :ui="{ body: 'p-0' }"
53
- class="group @container relative overflow-hidden border-slate-200/80 bg-white/90 shadow-none transition duration-200 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md dark:border-slate-800 dark:bg-slate-950/55 dark:hover:border-slate-700"
53
+ class="group border-default bg-default/90 hover:border-accented @container relative overflow-hidden shadow-none transition duration-200 hover:-translate-y-0.5 hover:shadow-md"
54
54
  >
55
55
  <div class="absolute inset-y-0 left-0 w-1" :class="routeTone.strip" />
56
56
  <div :class="compact ? 'flex items-center gap-3 p-3' : 'flex items-center gap-3 p-4'">
57
57
  <span class="h-2.5 w-2.5 rounded-full" :class="routeTone.dot" />
58
58
  <div class="min-w-0 flex-1">
59
59
  <div class="flex items-start justify-between gap-3">
60
- <p class="font-medium text-slate-950 dark:text-slate-100">
60
+ <p class="text-highlighted font-medium">
61
61
  {{ route.label }}
62
62
  </p>
63
- <code
64
- class="shrink-0 rounded-full bg-slate-100 px-2 py-1 font-mono text-[0.72rem] text-slate-600 dark:bg-slate-800 dark:text-slate-300"
65
- >
63
+ <code class="bg-elevated text-muted shrink-0 rounded-full px-2 py-1 font-mono text-xs">
66
64
  {{ route.path }}
67
65
  </code>
68
66
  </div>
69
- <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
67
+ <p class="text-muted mt-1 text-xs">
70
68
  {{ getRouteDescription() }}
71
69
  </p>
72
70
  </div>
@@ -25,9 +25,7 @@ export default defineNuxtConfig({
25
25
  '#layers/theming/types': `${import.meta.dirname}/app/types`,
26
26
  },
27
27
 
28
- // tokens.css is NOT listed here — it defines a Tailwind `@theme static` block, so per the
29
- // CSS single-build-root rule (see .claude/rules/nuxt-layers.md) it must fold into core.css's
30
- // @import chain via relative path instead, or its @theme block is silently inert.
28
+ // Shadow/duration tokens live in core (core/app/assets/css/tokens/elevation-motion.css).
31
29
  css: ['#layers/theming/app/assets/css/theme.css'],
32
30
 
33
31
  plugins: ['#layers/theming/app/plugins/theme.client.ts'],
@@ -1,11 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * BaseModal — overlay-compatible modal shell.
4
- *
5
- * Use this as a starting point for new modals:
6
- * 1. Copy or extend this component
7
- * 2. Add your props and content in the slots
8
- * 3. Register with createModal() in a composable
3
+ * BaseModal — overlay-compatible modal preset on UModal, which supplies the dialog role,
4
+ * focus trap, Escape, portal and transitions. This adds a size scale and a footer slot that
5
+ * receives `dismiss`.
9
6
  *
10
7
  * Example:
11
8
  * // components/ConfirmModal.vue
@@ -31,13 +28,13 @@
31
28
  close: []
32
29
  }>()
33
30
 
34
- const sizeClass: Record<string, string> = {
35
- sm: 'max-w-sm',
36
- md: 'max-w-md',
37
- lg: 'max-w-lg',
38
- xl: 'max-w-xl',
39
- full: 'max-w-full',
40
- }
31
+ const sizeClass = {
32
+ sm: 'sm:max-w-sm',
33
+ md: 'sm:max-w-md',
34
+ lg: 'sm:max-w-lg',
35
+ xl: 'sm:max-w-xl',
36
+ full: '',
37
+ } as const
41
38
 
42
39
  function dismiss() {
43
40
  emit('update:open', false)
@@ -46,77 +43,28 @@
46
43
  </script>
47
44
 
48
45
  <template>
49
- <Transition name="base-modal">
50
- <div
51
- v-if="open"
52
- class="fixed inset-0 z-50 flex items-center justify-center p-4"
53
- @click.self="dismiss"
54
- >
55
- <!-- Backdrop -->
56
- <div class="bg-muted absolute inset-0 opacity-60" @click="dismiss" />
57
-
58
- <!-- Panel -->
59
- <div
60
- class="bg-default ring-default relative w-full rounded-2xl shadow-xl ring-1"
61
- :class="sizeClass[size]"
62
- >
63
- <!-- Header -->
64
- <div
65
- v-if="title || $slots.header"
66
- class="border-default flex items-start justify-between border-b px-6 py-4"
67
- >
68
- <slot name="header">
69
- <div>
70
- <p class="text-default text-base font-semibold">
71
- {{ title }}
72
- </p>
73
- <p v-if="description" class="text-muted mt-0.5 text-sm">
74
- {{ description }}
75
- </p>
76
- </div>
77
- </slot>
78
- <button
79
- class="text-muted hover:text-default ml-4 cursor-pointer border-0 bg-transparent p-1 transition-colors"
80
- aria-label="Close"
81
- @click="dismiss"
82
- >
83
- <UIcon name="lucide:x" class="size-5" />
84
- </button>
85
- </div>
86
-
87
- <!-- Body -->
88
- <div class="px-6 py-5">
89
- <slot />
90
- </div>
91
-
92
- <!-- Footer -->
93
- <div v-if="$slots.footer" class="border-default flex justify-end gap-3 border-t px-6 py-4">
94
- <slot name="footer" :dismiss />
95
- </div>
96
- </div>
97
- </div>
98
- </Transition>
46
+ <UModal
47
+ :open
48
+ v-bind="{
49
+ ...(title !== undefined && { title }),
50
+ ...(description !== undefined && { description }),
51
+ }"
52
+ :fullscreen="size === 'full'"
53
+ :ui="{
54
+ overlay: 'z-(--z-modal)',
55
+ content: `z-(--z-modal) ${sizeClass[size]}`,
56
+ footer: 'justify-end gap-3',
57
+ }"
58
+ @update:open="(value) => !value && dismiss()"
59
+ >
60
+ <template v-if="$slots.header" #header>
61
+ <slot name="header" />
62
+ </template>
63
+ <template v-if="$slots.default" #body>
64
+ <slot />
65
+ </template>
66
+ <template v-if="$slots.footer" #footer>
67
+ <slot name="footer" :dismiss />
68
+ </template>
69
+ </UModal>
99
70
  </template>
100
-
101
- <style scoped>
102
- .base-modal-enter-active,
103
- .base-modal-leave-active {
104
- transition: opacity 200ms ease;
105
- }
106
-
107
- .base-modal-enter-from,
108
- .base-modal-leave-to {
109
- opacity: 0;
110
- }
111
- .base-modal-enter-active .bg-\(--ui-bg\),
112
- .base-modal-leave-active .bg-\(--ui-bg\) {
113
- transition:
114
- opacity 200ms ease,
115
- transform 200ms ease;
116
- }
117
- .base-modal-enter-from .bg-\(--ui-bg\),
118
- .base-modal-leave-to .bg-\(--ui-bg\) {
119
- transform: scale(0.96) translateY(4px);
120
- opacity: 0;
121
- }
122
- </style>
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import { cloneVNode, defineComponent, Fragment, h, ref, type VNode } from 'vue'
3
+
4
+ export default defineComponent({
5
+ name: 'HoverDimGroup',
6
+ inheritAttrs: false,
7
+ props: {
8
+ tag: { type: String, default: 'div' },
9
+ dimOpacity: { type: Number, default: 0.3 },
10
+ duration: { type: Number, default: 200 },
11
+ },
12
+ setup(props, { slots, attrs }) {
13
+ const hoveredIndex = ref<number | null>(null)
14
+
15
+ return () => {
16
+ const children = (slots.default?.() ?? [])
17
+ .flatMap((node) => (node.type === Fragment ? (node.children as VNode[]) : [node]))
18
+ .filter((node) => typeof node.type === 'string' || typeof node.type === 'object')
19
+
20
+ const styledChildren = children.map((vnode, index) =>
21
+ cloneVNode(vnode, {
22
+ onMouseenter: () => {
23
+ hoveredIndex.value = index
24
+ },
25
+ style: {
26
+ opacity:
27
+ hoveredIndex.value !== null && hoveredIndex.value !== index ? props.dimOpacity : 1,
28
+ transition: `opacity ${props.duration}ms ease-out`,
29
+ },
30
+ })
31
+ )
32
+
33
+ return h(
34
+ props.tag,
35
+ {
36
+ ...attrs,
37
+ onMouseleave: () => {
38
+ hoveredIndex.value = null
39
+ },
40
+ },
41
+ styledChildren
42
+ )
43
+ }
44
+ },
45
+ })
46
+ </script>
@@ -1,35 +1,15 @@
1
1
  <script setup lang="ts">
2
- const {
3
- progress = null,
4
- color = undefined,
5
- size = undefined,
6
- orientation = undefined,
7
- status = undefined,
8
- animation = undefined,
9
- inverted = undefined,
10
- } = defineProps<{
11
- progress?: number | null
12
- color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
13
- size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
14
- orientation?: 'horizontal' | 'vertical'
15
- status?: boolean
16
- animation?: 'carousel' | 'carousel-inverse' | 'swing' | 'elastic'
17
- inverted?: boolean
18
- }>()
2
+ import type { ProgressProps } from '@nuxt/ui'
19
3
 
20
- const modelValue = computed(() => (progress != null ? progress * 100 : undefined))
21
-
22
- const progressProps = computed(() => ({
23
- ...(modelValue.value !== undefined && { modelValue: modelValue.value }),
24
- ...(color !== undefined && { color: color as 'primary' | 'neutral' }),
25
- ...(size !== undefined && { size }),
26
- ...(orientation !== undefined && { orientation }),
27
- ...(status !== undefined && { status }),
28
- ...(animation !== undefined && { animation }),
29
- ...(inverted !== undefined && { inverted }),
30
- }))
4
+ // The one decision over UProgress: progress is 0–1 (null = indeterminate). Every other
5
+ // UProgress prop stays typed here but falls through as an attr (@vue-ignore).
6
+ /* eslint-disable vue/no-unused-properties -- @vue-ignore props fall through as attrs */
7
+ const { progress = null } = defineProps<
8
+ { progress?: number | null } & /* @vue-ignore */ Omit<ProgressProps, 'modelValue'>
9
+ >()
10
+ /* eslint-enable vue/no-unused-properties */
31
11
  </script>
32
12
 
33
13
  <template>
34
- <UProgress v-bind="progressProps" />
14
+ <UProgress :model-value="progress == null ? null : progress * 100" />
35
15
  </template>
@@ -52,7 +52,7 @@
52
52
  <template>
53
53
  <Teleport to="body">
54
54
  <div
55
- class="motion-cursor pointer-events-none fixed inset-0 z-9999 transition-opacity duration-300"
55
+ class="motion-cursor pointer-events-none fixed inset-0 z-(--z-cursor) transition-opacity duration-300"
56
56
  :style="{ opacity: visible ? 1 : 0 }"
57
57
  aria-hidden="true"
58
58
  >
@@ -50,6 +50,7 @@
50
50
  }>()
51
51
 
52
52
  const { gsap } = useGsap()
53
+ const reduced = useReducedMotion()
53
54
  const { velocity: scrollVelocity, direction: scrollDirection } = useSmoothScroll()
54
55
 
55
56
  const containerRef = useTemplateRef<HTMLElement>('containerRef')
@@ -78,9 +79,13 @@
78
79
  return direction === 'left' ? parseFloat(x) % mod : Math.abs(parseFloat(x) % mod)
79
80
  }),
80
81
  },
82
+ paused: reduced.value,
81
83
  })
82
84
  })
83
85
 
86
+ // Reduced motion holds the marquee still (and resumes it if the preference is turned off)
87
+ watch(reduced, (r) => tweenRef.value?.paused(r))
88
+
84
89
  // Watch velocity changes for velocity-based animation
85
90
  watch(
86
91
  [scrollVelocity, scrollDirection],
@@ -33,6 +33,7 @@
33
33
  }>()
34
34
 
35
35
  const { gsap, ScrollTrigger } = useGsap()
36
+ const reduced = useReducedMotion()
36
37
  const { velocityFactor } = useMarqueeVelocity({ damping, stiffness, velocityMapping })
37
38
 
38
39
  const containerRef = useTemplateRef<HTMLElement[]>('containerRef')
@@ -71,7 +72,7 @@
71
72
  }
72
73
 
73
74
  function animate() {
74
- if (isPaused.value) return
75
+ if (isPaused.value || reduced.value) return
75
76
 
76
77
  const now = performance.now()
77
78
  const delta = animLastTime ? now - animLastTime : 16
@@ -0,0 +1,40 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { ref } from 'vue'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
4
+
5
+ import Staggered from './Staggered.vue'
6
+
7
+ // Auto-imported from the scroll layer in Nuxt; stubbed here (no Nuxt context in the 'vue' project)
8
+ const reduced = ref(false)
9
+ vi.stubGlobal('useReducedMotion', () => reduced)
10
+
11
+ describe('MotionStaggered', () => {
12
+ beforeEach(() => {
13
+ reduced.value = false
14
+ vi.useFakeTimers()
15
+ })
16
+ afterEach(() => vi.useRealTimers())
17
+
18
+ it('renders each child once and reveals every one after the stagger', () => {
19
+ const wrapper = mount(Staggered, {
20
+ props: { staggerDelay: 50 },
21
+ slots: { default: '<p>a</p><p>b</p><p>c</p>' },
22
+ })
23
+ const items = wrapper.findAll('p')
24
+ expect(items).toHaveLength(3)
25
+ expect(items.every((p) => !p.classes('animated'))).toBe(true)
26
+
27
+ vi.advanceTimersByTime(100)
28
+ expect(items.map((p) => p.classes('animated'))).toEqual([true, true, true])
29
+ expect(items[0]?.classes()).toContain('animate-fade-in')
30
+ })
31
+
32
+ it('reveals every child at once, without the animation class, under reduced motion', () => {
33
+ reduced.value = true
34
+ const wrapper = mount(Staggered, { slots: { default: '<p>a</p><p>b</p>' } })
35
+ const items = wrapper.findAll('p')
36
+ expect(items.map((p) => p.classes('animated'))).toEqual([true, true])
37
+ expect(items[0]?.classes()).not.toContain('animate-fade-in')
38
+ expect(wrapper.attributes('data-reduced')).toBe('')
39
+ })
40
+ })