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
@@ -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
 
@@ -11,21 +11,31 @@
11
11
  }>()
12
12
 
13
13
  const { gsap, ScrollTrigger } = useGsap()
14
+ const reduced = useReducedMotion()
14
15
 
15
16
  const sectionRef = useTemplateRef<HTMLElement>('sectionRef')
16
17
  const contentRef = useTemplateRef<HTMLElement>('contentRef')
17
18
 
18
19
  const cleanups: Array<() => void> = []
19
20
 
21
+ // Reduced motion: no pin, no reveal — show every child at rest (inline style beats the
22
+ // first-paint hide rule below).
23
+ // ponytail: checked once at mount — a later reduced-motion toggle applies on next mount/reload
20
24
  onMounted(() => {
21
- if (!sectionRef.value || !contentRef.value) return
25
+ const section = sectionRef.value
26
+ const content = contentRef.value
27
+ if (!section || !content) return
28
+ const items = Array.from(content.querySelectorAll(':scope > *'))
29
+ if (reduced.value) return void gsap.set(items, { opacity: 1, y: 0, scale: 1 })
30
+ pinAndReveal(section, items)
31
+ })
22
32
 
23
- const endOffset = (duration * window.innerHeight) / 100
24
- const endValue = `+=${endOffset}`
33
+ function pinAndReveal(section: HTMLElement, items: Element[]) {
34
+ const endValue = `+=${(duration * window.innerHeight) / 100}`
25
35
 
26
36
  // Pin the section
27
37
  const pinSt = ScrollTrigger.create({
28
- trigger: sectionRef.value,
38
+ trigger: section,
29
39
  start: 'top top',
30
40
  end: endValue,
31
41
  pin: true,
@@ -34,10 +44,7 @@
34
44
  cleanups.push(() => pinSt.kill())
35
45
 
36
46
  // Animate each direct child
37
- const items = Array.from(contentRef.value.querySelectorAll(':scope > *'))
38
47
  const total = items.length
39
- if (total === 0) return
40
-
41
48
  items.forEach((item, i) => {
42
49
  gsap.set(item, { opacity: 0, y: 60, scale: 0.9 })
43
50
 
@@ -46,7 +53,7 @@
46
53
 
47
54
  const tween = gsap.to(item, {
48
55
  scrollTrigger: {
49
- trigger: sectionRef.value,
56
+ trigger: section,
50
57
  start: 'top top',
51
58
  end: endValue,
52
59
  scrub,
@@ -63,7 +70,7 @@
63
70
  tween.kill()
64
71
  })
65
72
  })
66
- })
73
+ }
67
74
 
68
75
  onUnmounted(() => {
69
76
  cleanups.forEach((fn) => fn())
@@ -0,0 +1,23 @@
1
+ import { computed, ref } from 'vue'
2
+ import { describe, expect, it, vi } from 'vite-plus/test'
3
+
4
+ const system = ref<'reduce' | 'no-preference'>('no-preference')
5
+ vi.mock('@vueuse/core', () => ({ usePreferredReducedMotion: () => system }))
6
+ const state = ref<'on' | 'off' | 'system'>('system')
7
+ vi.stubGlobal('useState', () => state)
8
+ vi.stubGlobal('computed', computed)
9
+
10
+ const { useReducedMotion } = await import('./useReducedMotion')
11
+
12
+ describe('useReducedMotion', () => {
13
+ it.each([
14
+ ['system', 'no-preference', false],
15
+ ['system', 'reduce', true],
16
+ ['on', 'no-preference', true],
17
+ ['off', 'reduce', false],
18
+ ] as const)('override %s + OS %s → %s', (override, os, expected) => {
19
+ state.value = override
20
+ system.value = os
21
+ expect(useReducedMotion().value).toBe(expected)
22
+ })
23
+ })
@@ -0,0 +1,14 @@
1
+ import { usePreferredReducedMotion } from '@vueuse/core'
2
+
3
+ /**
4
+ * True when motion should be reduced. Honours the ThemePicker override when the theming layer
5
+ * is loaded — it keeps it in `useState('theme-motion')`, read here by key so scroll doesn't
6
+ * depend on theming — and falls back to the OS `prefers-reduced-motion` setting.
7
+ */
8
+ export function useReducedMotion() {
9
+ const override = useState<'on' | 'off' | 'system'>('theme-motion', () => 'system')
10
+ const system = usePreferredReducedMotion()
11
+ return computed(
12
+ () => override.value === 'on' || (override.value === 'system' && system.value === 'reduce')
13
+ )
14
+ }
@@ -18,6 +18,12 @@ export default defineNuxtPlugin({
18
18
  const instance = shallowRef<LocomotiveScroll | null>(null)
19
19
  const { screen } = useLoading()
20
20
 
21
+ // Under the loading screen Lenis must be stopped, not just left un-started: an unstopped
22
+ // Lenis still takes wheel/touch input and replays it as a jump once it starts
23
+ function holdForLoadingScreen(scroll: LocomotiveScroll) {
24
+ if (screen.value === 'visible') scroll.lenisInstance?.stop()
25
+ }
26
+
21
27
  function init() {
22
28
  if (instance.value) return
23
29
  instance.value = new LocomotiveScroll({
@@ -37,6 +43,7 @@ export default defineNuxtPlugin({
37
43
  ScrollTrigger.update()
38
44
  },
39
45
  })
46
+ holdForLoadingScreen(instance.value)
40
47
 
41
48
  // ScrollTriggers created before Lenis exists (page:finish can fire first)
42
49
  // refresh against a null instance — remeasure once real layout settles.
@@ -51,6 +58,7 @@ export default defineNuxtPlugin({
51
58
 
52
59
  // Hold motion until core's boot loading screen reveals the page: GSAP stays paused while the
53
60
  // screen is up, so entrance animations play once it's gone; scrolling unlocks as it exits.
61
+ // Without a mounted screen the state stays 'hidden' and GSAP is never touched.
54
62
  watch(
55
63
  screen,
56
64
  (state) => {
@@ -64,7 +72,7 @@ export default defineNuxtPlugin({
64
72
  ScrollTrigger.refresh()
65
73
  })
66
74
  },
67
- { immediate: true }
75
+ { immediate: screen.value !== 'hidden' }
68
76
  )
69
77
 
70
78
  const router = useRouter()
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vite-plus/test'
2
+
3
+ import { parallaxMotionVars } from './parallax'
4
+
5
+ const base = { speed: 0.5, direction: 'vertical', rotate: 0, scale: 0 } as const
6
+
7
+ describe('parallaxMotionVars', () => {
8
+ it('moves up on y for vertical, right on x for horizontal', () => {
9
+ expect(parallaxMotionVars(base)).toEqual({ y: -100 })
10
+ expect(parallaxMotionVars({ ...base, direction: 'horizontal' })).toEqual({ x: 100 })
11
+ })
12
+
13
+ it('adds rotation and scale only when set', () => {
14
+ expect(parallaxMotionVars({ ...base, rotate: 15, scale: 0.2 })).toEqual({
15
+ y: -100,
16
+ rotation: 15,
17
+ scale: 1.2,
18
+ })
19
+ })
20
+
21
+ it('returns nothing to animate at zero speed', () => {
22
+ expect(parallaxMotionVars({ ...base, speed: 0 })).toEqual({})
23
+ })
24
+ })
@@ -0,0 +1,16 @@
1
+ type ParallaxMotion = {
2
+ speed: number
3
+ direction: 'vertical' | 'horizontal'
4
+ rotate: number
5
+ scale: number
6
+ }
7
+
8
+ /** MotionParallax tween vars — only the properties that actually move, so GSAP leaves the rest. */
9
+ export function parallaxMotionVars({ speed, direction, rotate, scale }: ParallaxMotion) {
10
+ const vars: { x?: number; y?: number; rotation?: number; scale?: number } = {}
11
+ const travel = direction === 'vertical' ? speed * -200 : speed * 200
12
+ if (travel !== 0) vars[direction === 'vertical' ? 'y' : 'x'] = travel
13
+ if (rotate !== 0) vars.rotation = rotate
14
+ if (scale !== 0) vars.scale = 1 + scale
15
+ return vars
16
+ }
@@ -1,7 +1,7 @@
1
1
  export default defineNuxtConfig({
2
2
  $meta: { name: 'scroll' },
3
3
 
4
- extends: ['../../core'],
4
+ extends: ['../../core', '../../design-system/visual'],
5
5
 
6
6
  alias: {
7
7
  '#layers/scroll': import.meta.dirname,
@@ -72,7 +72,7 @@
72
72
 
73
73
  <template>
74
74
  <Teleport to="body">
75
- <div v-if="visible" :style class="grid-debug fixed inset-0 z-9999" aria-hidden="true">
75
+ <div v-if="visible" :style class="grid-debug fixed inset-0 z-(--z-debug)" aria-hidden="true">
76
76
  <div v-for="i in cols" :key="i" :style="{ backgroundColor: color }" class="h-full" />
77
77
  </div>
78
78
  </Teleport>
@@ -10,7 +10,7 @@
10
10
  * <Transition> and Nuxt's page-root check (NUXT_E4004) require.
11
11
  *
12
12
  * Responsibilities:
13
- * - SEO via useHead()
13
+ * - title/description meta via useSeoMeta() (pages may override)
14
14
  * - provides 'pageTitle' to child components
15
15
  * - optional visible header (LayoutSection + LayoutPageHeader)
16
16
  *
@@ -45,15 +45,14 @@
45
45
 
46
46
  const { title, description = undefined, showHeader = false } = defineProps<Props>()
47
47
 
48
- useHead({
49
- title,
50
- meta: description
51
- ? [
52
- { name: 'description', content: description },
53
- { property: 'og:title', content: title },
54
- { property: 'og:description', content: description },
55
- ]
56
- : undefined,
48
+ // Deliberately a "connected" template: pages (and consumers such as KMCOMv5) set their title
49
+ // and description through these props. useSeoMeta rather than hand-written useHead tags, so a
50
+ // page that also calls useSeoMeta overrides these keys instead of adding duplicates.
51
+ useSeoMeta({
52
+ title: () => title,
53
+ ogTitle: () => title,
54
+ description: () => description,
55
+ ogDescription: () => description,
57
56
  })
58
57
 
59
58
  provide('pageTitle', title)
@@ -1,46 +1,20 @@
1
1
  <script lang="ts">
2
- import { cloneVNode, defineComponent, Fragment, h, ref, type VNode } from 'vue'
2
+ import { defineComponent, h } from 'vue'
3
3
 
4
+ import HoverDimGroup from '#layers/visual/app/components/HoverDimGroup.vue'
5
+
6
+ /**
7
+ * @deprecated Renamed to `HoverDimGroup` (visual layer) — it dims any children, not just
8
+ * links. Kept for one release so consumers can migrate; remove after that.
9
+ */
4
10
  export default defineComponent({
5
11
  name: 'LinksGroup',
6
12
  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
- }
13
+ setup(_, { attrs, slots }) {
14
+ if (import.meta.dev)
15
+ // eslint-disable-next-line no-console -- one-time deprecation notice, dev only
16
+ console.warn('[navigation] <LinksGroup> is deprecated — use <HoverDimGroup>.')
17
+ return () => h(HoverDimGroup, attrs, slots)
44
18
  },
45
19
  })
46
20
  </script>
@@ -17,6 +17,15 @@
17
17
  variant?: string
18
18
  color?: UiColors
19
19
  }>()
20
+
21
+ /**
22
+ * @deprecated Thin wrapper over `ULink` — use `<ULink :to="{ name, params, query }">`.
23
+ * Unused in this repo and KMCOMv5; remove in the next major.
24
+ */
25
+ if (import.meta.dev)
26
+ // eslint-disable-next-line no-console -- one-time deprecation notice, dev only
27
+ console.warn('[navigation] <LinksNamed> is deprecated — use <ULink :to="{ name }">.')
28
+
20
29
  const colorClass = useColor(() => color, 'text')
21
30
  // `name` is a plain string here, not one of the app's typed route names
22
31
  const to = computed(
@@ -6,7 +6,7 @@
6
6
  <UFooter>
7
7
  <template #bottom>
8
8
  <div class="pt-10">
9
- <p class="text-center text-sm text-neutral-300">Copyright © {{ year }}</p>
9
+ <p class="text-muted text-center text-sm">Copyright © {{ year }}</p>
10
10
  </div>
11
11
  </template>
12
12
  </UFooter>
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ // Positioning is the parent's call (e.g. `<MastNav class="fixed top-0 right-0" />`); the
3
+ // z-index stays here so the trigger always sits on the dropdown layer.
2
4
  const { open } = useMastNav()
3
5
  const z = useGridConfig().useZIndex('dropdown')
4
6
  </script>
@@ -6,7 +8,7 @@
6
8
  <template>
7
9
  <button
8
10
  aria-label="Open navigation menu"
9
- class="fixed top-0 right-0 flex cursor-pointer items-center justify-center border-0 bg-transparent p-4"
11
+ class="flex cursor-pointer items-center justify-center border-0 bg-transparent p-4"
10
12
  :style="{ zIndex: z }"
11
13
  @click="() => open()"
12
14
  >