kmcom-nuxt-layers 6.2.1 → 6.4.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 (77) hide show
  1. package/docs/MIGRATION-v6.3.0.md +234 -0
  2. package/docs/MIGRATION-v6.4.0.md +215 -0
  3. package/docs/MIGRATION.md +3 -0
  4. package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +16 -0
  5. package/docs/component-levels.md +117 -0
  6. package/docs/layout-shift-causes.md +18 -0
  7. package/layers/content/app/components/Blog/ItemPage.vue +2 -7
  8. package/layers/content/app/components/Blog/List.vue +9 -2
  9. package/layers/content/app/components/Blog/Page.vue +9 -19
  10. package/layers/content/app/components/Gallery/Detail.vue +1 -0
  11. package/layers/content/app/components/Gallery/Grid.vue +9 -2
  12. package/layers/content/app/components/Gallery/ImageDetail.vue +0 -6
  13. package/layers/content/app/components/Gallery/ImagePage.vue +10 -6
  14. package/layers/content/app/components/Gallery/Page.vue +9 -19
  15. package/layers/content/app/components/Gallery/SlugPage.vue +2 -7
  16. package/layers/content/app/components/NuxtContent/Detail.vue +20 -11
  17. package/layers/content/app/components/NuxtContent/IndexTemplate.vue +21 -0
  18. package/layers/content/app/components/NuxtContent/List.vue +6 -1
  19. package/layers/content/app/components/Portfolio/ColorPalette.vue +2 -2
  20. package/layers/content/app/components/Portfolio/ItemPage.vue +2 -6
  21. package/layers/content/app/components/Portfolio/List.vue +19 -8
  22. package/layers/content/app/components/Portfolio/Page.vue +9 -19
  23. package/layers/content/app/composables/useContentItemSeo.ts +19 -0
  24. package/layers/content/app/composables/useContentSection.ts +9 -0
  25. package/layers/content/app/utils/galleryImageMeta.test.ts +30 -0
  26. package/layers/content/app/utils/galleryImageMeta.ts +11 -0
  27. package/layers/content/nuxt.config.ts +1 -1
  28. package/layers/core/app/app.config.ts +2 -2
  29. package/layers/core/app/app.vue +2 -1
  30. package/layers/core/app/assets/css/core.css +5 -11
  31. package/layers/core/app/assets/css/tokens/z-index.css +16 -0
  32. package/layers/core/app/components/DiagnosticsPage.vue +41 -41
  33. package/layers/core/app/components/LoadingScreen.vue +2 -0
  34. package/layers/core/app/utils/loading.test.ts +2 -2
  35. package/layers/core/app/utils/loading.ts +1 -1
  36. package/layers/delivery/feeds/app/components/Feeds/Index.vue +36 -75
  37. package/layers/delivery/feeds/app/components/Feeds/RouteCard.vue +6 -8
  38. package/layers/design-system/theming/nuxt.config.ts +1 -3
  39. package/layers/design-system/typography/app/assets/css/typography.css +41 -5
  40. package/layers/design-system/visual/app/components/Base/Modal.vue +34 -86
  41. package/layers/design-system/visual/app/components/HoverDimGroup.test.ts +38 -0
  42. package/layers/design-system/visual/app/components/HoverDimGroup.vue +54 -0
  43. package/layers/design-system/visual/app/components/Progress/Bar.vue +9 -29
  44. package/layers/motion/animations/app/components/Motion/Cursor.vue +1 -1
  45. package/layers/motion/animations/app/components/Motion/Marquee.vue +5 -0
  46. package/layers/motion/animations/app/components/Motion/MarqueeText.vue +2 -1
  47. package/layers/motion/animations/app/components/Motion/Staggered.test.ts +40 -0
  48. package/layers/motion/animations/app/components/Motion/Staggered.vue +37 -42
  49. package/layers/motion/animations/app/components/Motion/TextReveal.vue +8 -1
  50. package/layers/motion/animations/app/components/Motion/VelocityEffect.vue +6 -0
  51. package/layers/motion/animations/app/composables/useCountUp.ts +6 -0
  52. package/layers/motion/animations/app/composables/useCursorFollower.ts +4 -1
  53. package/layers/motion/animations/app/utils/pointerMotion.ts +7 -2
  54. package/layers/motion/scroll/app/components/Motion/HorizontalScroll.vue +9 -0
  55. package/layers/motion/scroll/app/components/Motion/Parallax.vue +13 -12
  56. package/layers/motion/scroll/app/components/Motion/PinnedSection.vue +16 -9
  57. package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +23 -0
  58. package/layers/motion/scroll/app/composables/useReducedMotion.ts +14 -0
  59. package/layers/motion/scroll/app/utils/parallax.test.ts +24 -0
  60. package/layers/motion/scroll/app/utils/parallax.ts +16 -0
  61. package/layers/motion/scroll/nuxt.config.ts +1 -1
  62. package/layers/structure/layout/app/components/Layout/Grid/Debug.vue +1 -1
  63. package/layers/structure/layout/app/components/Layout/Page/index.vue +9 -10
  64. package/layers/structure/navigation/app/app.config.ts +37 -14
  65. package/layers/structure/navigation/app/components/Links/Group.vue +12 -38
  66. package/layers/structure/navigation/app/components/Links/Named.vue +9 -0
  67. package/layers/structure/navigation/app/components/Mast/Footer.vue +1 -1
  68. package/layers/structure/navigation/app/components/Mast/Nav.vue +14 -11
  69. package/layers/structure/navigation/app/components/Mast/NavModal.vue +95 -45
  70. package/layers/structure/navigation/app/components/Site/Logo.vue +35 -0
  71. package/layers/structure/navigation/app/components/Site/Subtitle.vue +10 -0
  72. package/layers/structure/navigation/app/components/Site/Title.vue +8 -0
  73. package/layers/structure/navigation/app/composables/mastNav.ts +38 -13
  74. package/layers/structure/navigation/app/types/nav.ts +34 -0
  75. package/layers/structure/navigation/nuxt.config.ts +7 -1
  76. package/package.json +1 -1
  77. /package/layers/{design-system/theming/app/assets/css/tokens.css → core/app/assets/css/tokens/elevation-motion.css} +0 -0
@@ -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,38 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { defineComponent } from 'vue'
3
+ import { describe, expect, it } from 'vite-plus/test'
4
+
5
+ import HoverDimGroup from './HoverDimGroup.vue'
6
+
7
+ // Same template shape as MastNavModal: <template v-for> around a <slot> with fallback content,
8
+ // which compiles to fragments nested two deep
9
+ const Parent = defineComponent({
10
+ components: { HoverDimGroup },
11
+ data: () => ({ ids: ['a', 'b', 'c'] }),
12
+ template: `
13
+ <HoverDimGroup tag="nav">
14
+ <template v-for="id in ids" :key="id">
15
+ <slot name="link" :id="id"><button>{{ id }}</button></slot>
16
+ </template>
17
+ </HoverDimGroup>`,
18
+ })
19
+
20
+ describe('HoverDimGroup', () => {
21
+ it('renders and dims children nested in v-for and slot fragments', async () => {
22
+ const wrapper = mount(Parent)
23
+ const items = wrapper.findAll('nav > button')
24
+ expect(items.map((b) => b.text())).toEqual(['a', 'b', 'c'])
25
+
26
+ await items[0]?.trigger('mouseenter')
27
+ expect(items.map((b) => b.attributes('style'))).toEqual([
28
+ expect.stringContaining('opacity: 1'),
29
+ expect.stringContaining('opacity: 0.3'),
30
+ expect.stringContaining('opacity: 0.3'),
31
+ ])
32
+ })
33
+
34
+ it('uses slot content when the parent passes it', () => {
35
+ const wrapper = mount(Parent, { slots: { link: '<a href="#">x</a>' } })
36
+ expect(wrapper.findAll('nav > a')).toHaveLength(3)
37
+ })
38
+ })
@@ -0,0 +1,54 @@
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
+ // v-for over slots nests fragments (v-for → slot → element), so flatten all the way down
16
+ const flatten = (nodes: VNode[]): VNode[] =>
17
+ nodes.flatMap((node) =>
18
+ node.type === Fragment && Array.isArray(node.children)
19
+ ? flatten(node.children as VNode[])
20
+ : [node]
21
+ )
22
+
23
+ return () => {
24
+ const children = flatten(slots.default?.() ?? []).filter(
25
+ (node) => typeof node.type === 'string' || typeof node.type === 'object'
26
+ )
27
+
28
+ const styledChildren = children.map((vnode, index) =>
29
+ cloneVNode(vnode, {
30
+ onMouseenter: () => {
31
+ hoveredIndex.value = index
32
+ },
33
+ style: {
34
+ opacity:
35
+ hoveredIndex.value !== null && hoveredIndex.value !== index ? props.dimOpacity : 1,
36
+ transition: `opacity ${props.duration}ms ease-out`,
37
+ },
38
+ })
39
+ )
40
+
41
+ return h(
42
+ props.tag,
43
+ {
44
+ ...attrs,
45
+ onMouseleave: () => {
46
+ hoveredIndex.value = null
47
+ },
48
+ },
49
+ styledChildren
50
+ )
51
+ }
52
+ },
53
+ })
54
+ </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
+ })
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
- // Props
2
+ // Explicit import so Staggered mounts under vitest's 'vue' project (no Nuxt auto-imports)
3
+ // — same exemption as ElementTw.vue (eslint.config.mjs).
4
+ import { onBeforeUnmount, onMounted, useTemplateRef } from 'vue'
5
+
3
6
  const {
4
7
  staggerDelay = 100,
5
8
  initialDelay = 0,
@@ -13,61 +16,53 @@
13
16
  animation?: string
14
17
  }>()
15
18
 
16
- // State
17
- const items = ref<HTMLElement[]>([])
18
- const animatedItems = ref<boolean[]>([])
19
-
20
- // Slots
21
- const slots = useSlots()
19
+ // Stagger the rendered DOM children, not slot vnodes: a `ref`/`class` on `<slot>` becomes a
20
+ // slot prop and never reaches an element, which left every item hidden for good.
21
+ const reduced = useReducedMotion()
22
+ const root = useTemplateRef<HTMLElement>('root')
23
+ const timers: ReturnType<typeof setTimeout>[] = []
22
24
 
23
- // Methods
24
- const setItemRef = (el: HTMLElement | null, index: number) => {
25
- if (el) {
26
- items.value[index] = el
27
- animatedItems.value[index] = false
28
- }
29
- }
30
-
31
- // Lifecycle
32
25
  onMounted(() => {
33
- // Animate items with staggered delay
34
- setTimeout(() => {
35
- items.value.forEach((item, index) => {
36
- if (item) {
37
- setTimeout(
38
- () => {
39
- animatedItems.value[index] = true
40
- item.classList.add(`animate-${animation}`, 'animated')
41
- },
42
- initialDelay + index * staggerDelay
43
- )
44
- }
45
- })
46
- }, 100)
26
+ const items = [...(root.value?.children ?? [])]
27
+ if (reduced.value) {
28
+ root.value?.setAttribute('data-reduced', '')
29
+ return items.forEach((item) => item.classList.add('animated'))
30
+ }
31
+ items.forEach((item, index) => {
32
+ timers.push(
33
+ setTimeout(
34
+ () => item.classList.add(`animate-${animation}`, 'animated'),
35
+ initialDelay + index * staggerDelay
36
+ )
37
+ )
38
+ })
47
39
  })
40
+
41
+ onBeforeUnmount(() => timers.forEach(clearTimeout))
48
42
  </script>
49
43
 
50
44
  <template>
51
- <div class="motion-staggered">
52
- <slot
53
- v-for="(_, index) in slots.default?.()"
54
- :key="index"
55
- :ref="(el: HTMLElement | null) => setItemRef(el, index)"
56
- :class="{ animated: animatedItems[index] }"
57
- />
45
+ <div ref="root" class="motion-staggered">
46
+ <slot />
58
47
  </div>
59
48
  </template>
60
49
 
61
50
  <style scoped>
62
51
  /* stylelint-disable selector-pseudo-class-no-unknown */
63
- .motion-staggered :deep(.animated) {
52
+ .motion-staggered > :deep(*) {
53
+ transform: translateY(20px);
54
+ opacity: 0;
55
+ transition:
56
+ opacity 0.3s ease-out,
57
+ transform 0.3s ease-out;
58
+ }
59
+
60
+ .motion-staggered > :deep(.animated) {
64
61
  transform: translateY(0);
65
62
  opacity: 1;
66
63
  }
67
64
 
68
- .motion-staggered :deep(*) {
69
- transform: translateY(20px);
70
- opacity: 0;
71
- transition: all 0.3s ease-out;
65
+ .motion-staggered[data-reduced] > :deep(*) {
66
+ transition: none;
72
67
  }
73
68
  </style>
@@ -27,6 +27,12 @@
27
27
  start?: string
28
28
  }>()
29
29
 
30
+ // Swapped to a plain span after mount: the server can't know the preference, so deciding in
31
+ // render would mismatch on hydration.
32
+ const reduced = useReducedMotion()
33
+ const isStatic = ref(false)
34
+ onMounted(() => (isStatic.value = reduced.value))
35
+
30
36
  const animBinding = computed(() => ({
31
37
  y: 40,
32
38
  opacity: 0,
@@ -39,8 +45,9 @@
39
45
  </script>
40
46
 
41
47
  <template>
48
+ <span v-if="isStatic" class="motion-text-reveal">{{ text }}</span>
42
49
  <span
43
- v-if="type === 'chars'"
50
+ v-else-if="type === 'chars'"
44
51
  v-gsap.whenVisible.once.splitText.chars.from="animBinding"
45
52
  class="motion-text-reveal"
46
53
  >{{ text }}</span
@@ -25,11 +25,17 @@
25
25
 
26
26
  const { gsap } = useGsap()
27
27
  const { velocity } = useSmoothScroll()
28
+ const reduced = useReducedMotion()
28
29
 
29
30
  const smoothVelocity = ref(0)
30
31
  const smoothAbsVelocity = ref(0)
31
32
 
32
33
  function tick() {
34
+ if (reduced.value) {
35
+ smoothVelocity.value = 0
36
+ smoothAbsVelocity.value = 0
37
+ return
38
+ }
33
39
  const d = damping / 1000
34
40
  const s = stiffness / 1000
35
41
 
@@ -15,6 +15,7 @@ export function useCountUp(
15
15
  opts: UseCountUpOptions
16
16
  ) {
17
17
  const { gsap, ScrollTrigger } = useGsap()
18
+ const reduced = useReducedMotion()
18
19
 
19
20
  const displayValue = ref(String(opts.from ?? 0))
20
21
  const isComplete = ref(false)
@@ -26,6 +27,11 @@ export function useCountUp(
26
27
  let tween: gsap.core.Tween | null = null
27
28
 
28
29
  function startCount() {
30
+ if (reduced.value) {
31
+ displayValue.value = format(opts.to)
32
+ isComplete.value = true
33
+ return
34
+ }
29
35
  tween = gsap.to(counter, {
30
36
  value: opts.to,
31
37
  duration: opts.duration ?? 2,
@@ -4,11 +4,14 @@ export function useCursorFollower(opts: { smoothing?: MaybeRef<number> } = {}) {
4
4
  const { gsap } = useGsap()
5
5
  const { x: mouseX, y: mouseY } = useMouse({ type: 'client' })
6
6
 
7
+ const reduced = useReducedMotion()
8
+
7
9
  const x = ref(0)
8
10
  const y = ref(0)
9
11
 
10
12
  function tick() {
11
- const factor = unref(opts.smoothing) ?? 0.15
13
+ // Reduced motion: track the pointer exactly instead of trailing it
14
+ const factor = reduced.value ? 1 : (unref(opts.smoothing) ?? 0.15)
12
15
  x.value += (mouseX.value - x.value) * factor
13
16
  y.value += (mouseY.value - y.value) * factor
14
17
  }
@@ -14,13 +14,18 @@ export function resolvePointerSpring(
14
14
 
15
15
  export function usePointerMotionFrame(update: () => void, cleanup?: () => void) {
16
16
  const { gsap } = useGsap()
17
+ const reduced = useReducedMotion()
18
+
19
+ // Checked per frame so a reduced-motion toggle applies live; reduced holds the element at rest.
20
+ // ponytail: cleanup re-runs every frame while reduced — cheap gsap.set, gate it if it ever shows up in a profile
21
+ const frame = () => (reduced.value ? cleanup?.() : update())
17
22
 
18
23
  onMounted(() => {
19
- gsap.ticker.add(update)
24
+ gsap.ticker.add(frame)
20
25
  })
21
26
 
22
27
  onUnmounted(() => {
23
- gsap.ticker.remove(update)
28
+ gsap.ticker.remove(frame)
24
29
  cleanup?.()
25
30
  })
26
31
 
@@ -11,6 +11,7 @@
11
11
  }>()
12
12
 
13
13
  const { gsap } = useGsap()
14
+ const reduced = useReducedMotion()
14
15
 
15
16
  const sectionRef = useTemplateRef<HTMLElement>('sectionRef')
16
17
  const trackRef = useTemplateRef<HTMLElement>('trackRef')
@@ -21,6 +22,14 @@
21
22
  if (!sectionRef.value || !trackRef.value) return
22
23
 
23
24
  const track = trackRef.value
25
+
26
+ // Reduced motion: no pinned scrub — panels scroll natively sideways instead.
27
+ // ponytail: checked once at mount — a later reduced-motion toggle applies on next mount/reload; re-run set-up in a watch if live toggling matters
28
+ if (reduced.value) {
29
+ sectionRef.value.style.overflowX = 'auto'
30
+ return
31
+ }
32
+
24
33
  const trackWidth = track.scrollWidth - window.innerWidth
25
34
 
26
35
  const tween = gsap.to(track, {
@@ -34,32 +34,33 @@
34
34
  }>()
35
35
 
36
36
  const { gsap } = useGsap()
37
+ const reduced = useReducedMotion()
37
38
 
38
39
  const containerRef = useTemplateRef<HTMLElement | null>('containerRef')
39
40
  const contentRef = useTemplateRef<HTMLElement | null>('contentRef')
40
41
 
42
+ let tween: gsap.core.Tween | null = null
43
+
41
44
  onMounted(() => {
42
45
  if (!containerRef.value || !contentRef.value) return
46
+ // ponytail: checked once at mount — a later reduced-motion toggle applies on next mount/reload; re-run set-up in a watch if live toggling matters
47
+ if (reduced.value) return
43
48
 
44
- const yAmount = direction === 'vertical' ? speed * -200 : 0
45
- const xAmount = direction === 'horizontal' ? speed * 200 : 0
46
-
47
- const animationProps: gsap.TweenVars = {
49
+ tween = gsap.to(contentRef.value, {
48
50
  scrollTrigger: {
49
51
  trigger: containerRef.value,
50
52
  start: 'top bottom',
51
53
  end: 'bottom top',
52
- scrub: scrub,
54
+ scrub,
53
55
  },
54
56
  ease: 'none',
55
- }
56
-
57
- if (yAmount !== 0) animationProps.y = yAmount
58
- if (xAmount !== 0) animationProps.x = xAmount
59
- if (rotate !== 0) animationProps.rotation = rotate
60
- if (scale !== 0) animationProps.scale = 1 + scale
57
+ ...parallaxMotionVars({ speed, direction, rotate, scale }),
58
+ })
59
+ })
61
60
 
62
- gsap.to(contentRef.value, animationProps)
61
+ onUnmounted(() => {
62
+ tween?.scrollTrigger?.kill()
63
+ tween?.kill()
63
64
  })
64
65
  </script>
65
66