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
@@ -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
+ }
@@ -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,5 +1,7 @@
1
1
  import type { SiteConfig } from '#layers/core/app/types/site'
2
2
 
3
+ import type { MastNavLink, MastNavTrigger, MastNavUi, SiteLogoConfig } from './types/nav'
4
+
3
5
  export default defineAppConfig({
4
6
  site: {
5
7
  title: '',
@@ -7,29 +9,50 @@ export default defineAppConfig({
7
9
  description: '',
8
10
  } satisfies SiteConfig,
9
11
  mastNav: {
10
- links: [] as Array<{
11
- id: string
12
- label: string
13
- to:
14
- | string
15
- | { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> }
16
- }>,
12
+ links: [] as MastNavLink[],
17
13
  scrollBehaviour: 'router' as 'smooth-scroll' | 'router',
14
+ // UButton props for <MastNav>; attrs on the component win
15
+ trigger: {
16
+ icon: 'lucide:menu',
17
+ color: 'neutral',
18
+ variant: 'ghost',
19
+ size: 'xl',
20
+ } as MastNavTrigger,
21
+ ui: {
22
+ content: 'bg-default text-default',
23
+ close: 'absolute top-4 right-4',
24
+ nav: 'flex flex-col items-center gap-2',
25
+ // Weight keys off aria-current so active and inactive never carry two font-* classes
26
+ link: 'cursor-pointer border-0 bg-transparent px-4 py-3 text-3xl tracking-widest uppercase transition-all font-light aria-[current=page]:font-normal',
27
+ linkActive: '',
28
+ } satisfies MastNavUi,
18
29
  },
30
+ siteLogo: {
31
+ stacked: true,
32
+ ui: { root: 'font-display leading-none tracking-tight', word: '' },
33
+ sizes: {
34
+ sm: 'text-step-1',
35
+ md: 'text-step-4',
36
+ lg: 'text-step-6',
37
+ display: 'text-step-display',
38
+ },
39
+ } satisfies SiteLogoConfig,
19
40
  })
20
41
 
21
42
  declare module '@nuxt/schema' {
22
43
  interface AppConfigInput {
23
44
  // `site` is already typed by core's own AppConfigInput augmentation — no need to redeclare it.
24
45
  mastNav?: {
25
- links?: Array<{
26
- id: string
27
- label: string
28
- to:
29
- | string
30
- | { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> }
31
- }>
46
+ links?: MastNavLink[]
32
47
  scrollBehaviour?: 'smooth-scroll' | 'router'
48
+ trigger?: MastNavTrigger
49
+ ui?: Partial<MastNavUi>
50
+ }
51
+ siteLogo?: {
52
+ stacked?: boolean
53
+ ui?: Partial<SiteLogoConfig['ui']>
54
+ /** Merged per key, so a site can retune one size without restating the others. */
55
+ sizes?: Partial<SiteLogoConfig['sizes']>
33
56
  }
34
57
  }
35
58
  }
@@ -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,17 +1,20 @@
1
1
  <script setup lang="ts">
2
+ import type { ButtonProps } from '@nuxt/ui'
3
+
4
+ // A UButton styled by `mastNav.trigger` in app.config; attrs (class, label, icon…) win.
5
+ // Positioning is the parent's call, e.g. `<MastNav class="fixed top-0 right-0" />`.
6
+ defineOptions({ inheritAttrs: false })
7
+
2
8
  const { open } = useMastNav()
3
- const z = useGridConfig().useZIndex('dropdown')
9
+ // app.config types the trigger loosely (see MastNavTrigger); UButton validates the values
10
+ const trigger = ((useAppConfig().mastNav as { trigger?: unknown } | undefined)?.trigger ??
11
+ {}) as ButtonProps
4
12
  </script>
5
13
 
6
14
  <template>
7
- <button
8
- 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"
10
- :style="{ zIndex: z }"
11
- @click="() => open()"
12
- >
13
- <slot name="icon">
14
- <UIcon name="lucide:menu" class="size-7" />
15
- </slot>
16
- </button>
15
+ <UButton aria-label="Open navigation menu" v-bind="{ ...trigger, ...$attrs }" @click="open">
16
+ <template v-if="$slots.default" #default>
17
+ <slot />
18
+ </template>
19
+ </UButton>
17
20
  </template>
@@ -1,20 +1,61 @@
1
1
  <script setup lang="ts">
2
- import type { MastNavLink } from '../../types/nav'
2
+ import type { MastNavLink, MastNavUi } from '../../types/nav'
3
3
 
4
- const { open } = defineProps<{ open?: boolean }>()
4
+ // Render once, in the layout. Behaviour lives here (dialog, scroll lock via useMastNav,
5
+ // smooth-scroll vs router, active link); the look comes from `mastNav.ui`, `ui` and slots.
6
+ const {
7
+ open = undefined,
8
+ links = undefined,
9
+ ui = undefined,
10
+ } = defineProps<{
11
+ /** Manual control; omit to follow useMastNav().isOpen. */
12
+ open?: boolean
13
+ /** Replaces `mastNav.links`, e.g. filtered by the site's own page flags. */
14
+ links?: MastNavLink[]
15
+ /** Per-key override of `mastNav.ui` (strings replace, no class merging). */
16
+ ui?: Partial<MastNavUi>
17
+ }>()
5
18
  const emit = defineEmits<{
6
19
  'update:open': [value: boolean]
7
20
  close: []
8
21
  }>()
9
22
 
10
- const { links, scrollBehaviour } = useAppConfig().mastNav as {
23
+ const config = useAppConfig().mastNav as {
11
24
  links: MastNavLink[]
12
- scrollBehaviour: string
25
+ scrollBehaviour: 'smooth-scroll' | 'router'
26
+ ui: MastNavUi
13
27
  }
28
+ const classes = computed(() => ({ ...config.ui, ...ui }))
29
+ const visibleLinks = computed(() => (links ?? config.links).filter((link) => !link.hidden))
30
+
14
31
  const activeSection = useState<string>('activeSection', () => '')
15
32
  const route = useRoute()
16
- const { close: closeNav } = useMastNav()
17
- const { useZIndex } = useGridConfig()
33
+ const router = useRouter()
34
+ const { isOpen, close: closeNav } = useMastNav()
35
+ // On the smooth-scroll index every link routes to index, so only the section counts there
36
+ const smoothOnIndex = computed(
37
+ () => config.scrollBehaviour === 'smooth-scroll' && route.name === 'index'
38
+ )
39
+
40
+ function matchesRoute(link: MastNavLink) {
41
+ try {
42
+ return router.resolve(link.to).path === route.path
43
+ } catch {
44
+ return false // a named route that doesn't exist
45
+ }
46
+ }
47
+
48
+ const activeIds = computed(
49
+ () =>
50
+ new Set(
51
+ visibleLinks.value
52
+ .filter(
53
+ (link) =>
54
+ activeSection.value === link.id || (!smoothOnIndex.value && matchesRoute(link))
55
+ )
56
+ .map((link) => link.id)
57
+ )
58
+ )
18
59
 
19
60
  function dismiss() {
20
61
  emit('update:open', false)
@@ -22,8 +63,8 @@
22
63
  closeNav()
23
64
  }
24
65
 
25
- function handleNav(link: MastNavLink) {
26
- if (scrollBehaviour === 'smooth-scroll' && route.name === 'index') {
66
+ function navigate(link: MastNavLink) {
67
+ if (smoothOnIndex.value) {
27
68
  try {
28
69
  useSmoothScroll().scrollTo(`#${link.id}`)
29
70
  } catch {}
@@ -35,43 +76,52 @@
35
76
  </script>
36
77
 
37
78
  <template>
38
- <Transition name="nav-modal">
39
- <div
40
- v-if="open"
41
- class="bg-default text-default fixed inset-0 flex flex-col items-center justify-center"
42
- :style="{ zIndex: useZIndex('modal') }"
43
- >
44
- <button
45
- class="absolute top-[3vh] right-[5vw] cursor-pointer border-0 bg-transparent p-2"
46
- aria-label="Close navigation"
47
- @click="dismiss"
48
- >
49
- <UIcon name="lucide:x" class="size-7" />
50
- </button>
79
+ <!-- UModal supplies the dialog role, focus trap, Escape and portal; the title stays
80
+ visually hidden because the content slot replaces the header. -->
81
+ <UModal
82
+ :open="open ?? isOpen"
83
+ fullscreen
84
+ title="Site navigation"
85
+ :ui="{ overlay: 'z-(--z-modal)', content: `${classes.content} z-(--z-modal)` }"
86
+ @update:open="(value) => !value && dismiss()"
87
+ >
88
+ <template #content>
89
+ <div class="relative flex h-full flex-col items-center justify-center">
90
+ <slot name="close" :close="dismiss">
91
+ <UButton
92
+ icon="lucide:x"
93
+ color="neutral"
94
+ variant="ghost"
95
+ size="xl"
96
+ aria-label="Close navigation"
97
+ :class="classes.close"
98
+ @click="dismiss"
99
+ />
100
+ </slot>
51
101
 
52
- <LinksGroup tag="nav" class="flex flex-col items-center gap-2">
53
- <button
54
- v-for="link in links"
55
- :key="link.id"
56
- class="cursor-pointer border-0 bg-transparent px-4 py-3 text-3xl tracking-widest uppercase transition-all"
57
- :class="activeSection === link.id ? 'font-normal' : 'font-light'"
58
- @click="() => handleNav(link)"
59
- >
60
- {{ link.label }}
61
- </button>
62
- </LinksGroup>
63
- </div>
64
- </Transition>
65
- </template>
102
+ <slot name="header" />
66
103
 
67
- <style scoped>
68
- .nav-modal-enter-active,
69
- .nav-modal-leave-active {
70
- transition: opacity 300ms ease;
71
- }
104
+ <HoverDimGroup tag="nav" :class="classes.nav">
105
+ <template v-for="link in visibleLinks" :key="link.id">
106
+ <slot
107
+ name="link"
108
+ :link
109
+ :active="activeIds.has(link.id)"
110
+ :navigate="() => navigate(link)"
111
+ >
112
+ <button
113
+ :class="[classes.link, activeIds.has(link.id) && classes.linkActive]"
114
+ :aria-current="activeIds.has(link.id) ? 'page' : undefined"
115
+ @click="() => navigate(link)"
116
+ >
117
+ {{ link.label }}
118
+ </button>
119
+ </slot>
120
+ </template>
121
+ </HoverDimGroup>
72
122
 
73
- .nav-modal-enter-from,
74
- .nav-modal-leave-to {
75
- opacity: 0;
76
- }
77
- </style>
123
+ <slot name="footer" />
124
+ </div>
125
+ </template>
126
+ </UModal>
127
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ import type { SiteLogoConfig, SiteLogoSize } from '../../types/nav'
3
+
4
+ // The site wordmark from `site.title`, sized on the step scale. Look comes from `siteLogo` in
5
+ // app.config; `ui` overrides per instance. Replaces the deprecated SiteTitle/SiteSubtitle.
6
+ const {
7
+ as = 'span',
8
+ size = 'sm',
9
+ stacked = undefined,
10
+ ui = undefined,
11
+ } = defineProps<{
12
+ as?: string
13
+ size?: SiteLogoSize
14
+ /** One word per line. Defaults to `siteLogo.stacked`. */
15
+ stacked?: boolean
16
+ ui?: Partial<SiteLogoConfig['ui']>
17
+ }>()
18
+
19
+ const config = useAppConfig().siteLogo as SiteLogoConfig
20
+ const site = useSite()
21
+ const isStacked = computed(() => stacked ?? config.stacked)
22
+ </script>
23
+
24
+ <template>
25
+ <component :is="as" :class="[ui?.root ?? config.ui.root, config.sizes[size]]">
26
+ <slot :words="site.titleWords" :title="site.title">
27
+ <span
28
+ v-for="(word, i) in site.titleWords"
29
+ :key="i"
30
+ :class="[isStacked && 'block', ui?.word ?? config.ui.word]"
31
+ >{{ word }}{{ !isStacked && i < site.titleWords.length - 1 ? ' ' : '' }}</span
32
+ >
33
+ </slot>
34
+ </component>
35
+ </template>
@@ -1,4 +1,14 @@
1
1
  <script setup lang="ts">
2
+ /**
3
+ * @deprecated Render `useSite().subtitleWords` directly. Unused in this repo and KMCOMv5;
4
+ * remove in the next major.
5
+ */
6
+ if (import.meta.dev)
7
+ // eslint-disable-next-line no-console -- one-time deprecation notice, dev only
8
+ console.warn(
9
+ '[navigation] <SiteSubtitle> is deprecated — render useSite().subtitleWords directly.'
10
+ )
11
+
2
12
  const site = useSite()
3
13
  </script>
4
14
 
@@ -1,4 +1,12 @@
1
1
  <script setup lang="ts">
2
+ /**
3
+ * @deprecated Use `<SiteLogo :stacked="false">` (or `useSite().titleWords`). Remove in the
4
+ * next major.
5
+ */
6
+ if (import.meta.dev)
7
+ // eslint-disable-next-line no-console -- one-time deprecation notice, dev only
8
+ console.warn('[navigation] <SiteTitle> is deprecated — use <SiteLogo>.')
9
+
2
10
  const site = useSite()
3
11
  </script>
4
12