kmcom-nuxt-layers 6.3.0 → 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.
@@ -0,0 +1,215 @@
1
+ # Migration Guide — v6.3.x → v6.4.0
2
+
3
+ **Audience:** an AI coding agent (or developer) updating a downstream Nuxt app that consumes these
4
+ layers.
5
+ **What changed:** the duplicate overlay provider is gone, the nav is plain shared state with a
6
+ configurable trigger and modal, a new `SiteLogo` wordmark, and a per-element display type range.
7
+ Background: [`NAV-LOGO-OVERLAY-PLAN.md`](../NAV-LOGO-OVERLAY-PLAN.md).
8
+
9
+ Work top to bottom. Each change has a **Detect** step and an **Apply** step; skip anything Detect
10
+ doesn't find. An app component with the same name as a layer component overrides it, so a change
11
+ to a layer component you've overridden doesn't reach you until you delete the override.
12
+
13
+ ## 0. Preconditions
14
+
15
+ - Update the package to `^6.4.0`, reinstall, run `npx nuxt prepare`.
16
+
17
+ ---
18
+
19
+ ## Required changes
20
+
21
+ ### 1. Remove your own `<UOverlayProvider />`
22
+
23
+ `UApp` already renders one. A second one made every `useOverlay()`/`createModal` overlay render
24
+ twice (doubled modals, layout shift). `core`'s `app.vue` no longer adds one.
25
+
26
+ **Detect:** grep your `app.vue` and layouts for `<UOverlayProvider`.
27
+
28
+ **Apply:** delete it. Keep `<UApp>`.
29
+
30
+ ### 2. Render `<MastNavModal />` once in your layout
31
+
32
+ `<MastNav>` is only a trigger now. The modal is rendered once by the layout, and every trigger on
33
+ the page opens that one modal.
34
+
35
+ **Detect:** a layout or page renders `<MastNav` but not `<MastNavModal`.
36
+
37
+ **Apply:**
38
+
39
+ ```vue
40
+ <!-- app/layouts/default.vue -->
41
+ <template>
42
+ <MastNav class="fixed top-0 right-0 z-(--z-header)" />
43
+ <MastNavModal />
44
+ <slot />
45
+ </template>
46
+ ```
47
+
48
+ ### 3. `useMastNav()` returns shared state
49
+
50
+ | Before (v6.3) | After (v6.4) |
51
+ | --------------------------------- | --------------------------------------------------- |
52
+ | `{ open(props?), close, patch }` | `{ isOpen, open, close, toggle }` |
53
+ | An overlay created per app | `useState('mast-nav:open')`; `isOpen` is read-only |
54
+ | `open()` called `lockScrolling()` | UModal locks body scroll; `open()` only stops Lenis |
55
+
56
+ **Detect:** grep for `useMastNav().patch`, `patch(` on the nav, or `open({` with props.
57
+
58
+ **Apply:** drop the props (the modal reads `mastNav` config and its own props instead), and read
59
+ `isOpen` where you used to track open state yourself.
60
+
61
+ ### 4. `MastNav` is a `UButton`
62
+
63
+ The trigger was a bare `<button>` with a hamburger icon, an `#icon` slot and an inline
64
+ `z-index` (dropdown layer). It's now a `UButton` whose props come from `mastNav.trigger`, and
65
+ attrs on the component win.
66
+
67
+ **Detect:** `<MastNav>` with an `#icon` slot, or a fixed trigger that relied on the old inline
68
+ z-index.
69
+
70
+ **Apply:**
71
+
72
+ ```ts
73
+ // app/app.config.ts
74
+ mastNav: {
75
+ trigger: { label: 'Menu', icon: undefined, variant: 'ghost', size: 'xl' },
76
+ }
77
+ ```
78
+
79
+ - Replace `#icon` with `trigger.icon` (or `icon="…"` on the component). The default slot now fills
80
+ the button label.
81
+ - Add `z-(--z-header)` (or your own) to a fixed trigger that has to sit over page content.
82
+ - The default look moved from `p-4` + 28px icon to `UButton` neutral/ghost/xl. Restate `class` if
83
+ you need the old size.
84
+
85
+ ### 5. Loading screen is opt-in, and `core` renders it
86
+
87
+ `core`'s `app.vue` now renders `<LoadingScreen />`, so you can delete an `app.vue` that existed only
88
+ to add it. So that every other app doesn't suddenly get a loading screen,
89
+ `coreLayer.loading.enabled` now defaults to **`false`**.
90
+
91
+ **Detect:** your app shows the loading screen (it renders `<LoadingScreen />` or relied on the
92
+ default being on).
93
+
94
+ **Apply:**
95
+
96
+ ```ts
97
+ // app/app.config.ts
98
+ coreLayer: { loading: { enabled: true } },
99
+ ```
100
+
101
+ Then delete your `app.vue` if `<LoadingScreen />` was its only difference from `core`'s.
102
+
103
+ ### 6. `mastNav.links` is typed as `MastNavLink[]`
104
+
105
+ Route `params`/`query` values are `string | number` (the app.config type used to allow
106
+ `unknown`).
107
+
108
+ **Detect:** typecheck errors in `app.config.ts` under `mastNav.links`.
109
+
110
+ **Apply:** stringify the offending values.
111
+
112
+ ---
113
+
114
+ ## Behaviour changes — check, usually no code
115
+
116
+ ### 7. The current page's link is active
117
+
118
+ A link used to be active only when `useState('activeSection')` matched its `id`. It's now also
119
+ active when its `to` resolves to the current route, except on the smooth-scroll index, where every
120
+ link points at `index`. Active links get `aria-current="page"` plus `mastNav.ui.linkActive`. The
121
+ default weight (light → normal) keys off `aria-current`.
122
+
123
+ ### 8. No more scrollbar jump when the nav opens
124
+
125
+ With classic (non-overlay) scrollbars the page used to widen by the scrollbar on open, because the
126
+ nav hid `html` overflow before the dialog measured the scrollbar. UModal now owns the lock and pads
127
+ `body`, and Lenis stops after it. `useSmoothScroll().lockScrolling()` is unchanged for other
128
+ callers.
129
+
130
+ ### 9. `HoverDimGroup` renders nested fragments
131
+
132
+ Children inside `<template v-for>` around a `<slot>` (fragments two deep) used to be dropped
133
+ silently. They render and dim now. Check any `HoverDimGroup` that rendered fewer children than
134
+ you passed.
135
+
136
+ ---
137
+
138
+ ## New — optional
139
+
140
+ ### `MastNavModal` styling and slots
141
+
142
+ The layer keeps the behaviour (dialog, focus trap, Escape, scroll lock, smooth-scroll vs router,
143
+ active link). The look comes from config and slots:
144
+
145
+ ```ts
146
+ // app/app.config.ts: each key you set replaces that default string (no class merging)
147
+ mastNav: {
148
+ ui: {
149
+ content: 'bg-default text-default',
150
+ close: 'absolute right-[5vw] top-[3vh]',
151
+ nav: 'flex w-full flex-col items-start gap-2 px-12',
152
+ link: 'cursor-pointer py-3 text-step-4 tracking-wider transition-all',
153
+ linkActive: 'underline',
154
+ },
155
+ }
156
+ ```
157
+
158
+ | API | Purpose |
159
+ | ------------------------------------ | --------------------------------------------------------------- |
160
+ | `ui` prop | Per-instance override, merged per key over `mastNav.ui` |
161
+ | `links` prop | Replace `mastNav.links`, e.g. filtered by your page flags |
162
+ | `open` prop / `update:open` | Manual control; omit to follow `useMastNav().isOpen` |
163
+ | `#link="{ link, active, navigate }"` | Replace a link's markup; `navigate()` scrolls/routes and closes |
164
+ | `#close="{ close }"` | Replace the close button |
165
+ | `#header`, `#footer` | Content above/below the links |
166
+ | `hidden: true` on a link | Skip it without a custom `links` prop |
167
+
168
+ ### `SiteLogo`
169
+
170
+ `site.title` as a wordmark on the step scale. Replaces the deprecated `SiteTitle`.
171
+
172
+ ```vue
173
+ <SiteLogo />
174
+ <!-- stacked, text-step-1 -->
175
+ <SiteLogo size="display" :stacked="false" />
176
+ <!-- one line -->
177
+ <SiteLogo v-slot="{ words }">…</SiteLogo>
178
+ <!-- your own markup -->
179
+ ```
180
+
181
+ ```ts
182
+ // app/app.config.ts: sizes merge per key
183
+ siteLogo: {
184
+ stacked: true,
185
+ ui: { root: 'font-display leading-none tracking-tight', word: '' },
186
+ sizes: { display: 'text-step-display [--display-from:4] [--display-to:9]' },
187
+ }
188
+ ```
189
+
190
+ ### Per-element display range
191
+
192
+ `text-step-display` and plain `h1` now read `--display-from` (step at ≤360px, default 5) and
193
+ `--display-to` (step at ≥1280px, default 7) on the element itself:
194
+
195
+ ```html
196
+ <span class="text-step-display [--display-from:4] [--display-to:9]">…</span>
197
+ <!-- ≈42px at 390px, 154px at 1280px -->
198
+ ```
199
+
200
+ Defaults are unchanged in Chromium, Firefox and WebKit. If you override `--step-display` on an
201
+ element with your own unlayered CSS, that still works; the two numbers are the simpler way.
202
+
203
+ ## Deprecated
204
+
205
+ `SiteTitle` → `<SiteLogo :stacked="false">`. Removal in the next major, alongside `SiteSubtitle`,
206
+ `LinksNamed` and `LinksGroup`.
207
+
208
+ ## Verify
209
+
210
+ - [ ] `npx nuxt prepare && npx nuxi typecheck` is clean.
211
+ - [ ] Opening any modal puts exactly one `[role="dialog"]` in the DOM (§1).
212
+ - [ ] The nav trigger opens one modal. Escape and the close button close it, focus returns to the
213
+ trigger, and the page stays where it was (§2–4, §8).
214
+ - [ ] The current page's link is highlighted (§7).
215
+ - [ ] The loading screen shows only where you enabled it (§5).
package/docs/MIGRATION.md CHANGED
@@ -1,4 +1,5 @@
1
- > **Upgrading to v6.3.0?** See [`MIGRATION-v6.3.0.md`](./MIGRATION-v6.3.0.md). The guides below cover older versions.
1
+ > **Upgrading?** v6.3 → v6.4: [`MIGRATION-v6.4.0.md`](./MIGRATION-v6.4.0.md) · v6.2 → v6.3:
2
+ > [`MIGRATION-v6.3.0.md`](./MIGRATION-v6.3.0.md). The guides below cover older versions.
2
3
 
3
4
  # Agent Migration Guide — Update an app to nuxt-layers v2.2.x
4
5
 
@@ -56,3 +56,19 @@ the element using it.
56
56
  steps (h1 4, h2 3, h3 2, h4 1) and `p`/`li` keep the body's 1.7 leading. Otherwise the
57
57
  24px copy sat at or above Nuxt UI's fixed `text-2xl`/`text-xl` prose headings. This needs
58
58
  `text-step-*` registered as a font-size group in tailwind-merge (`ui.tv.twMergeConfig`).
59
+
60
+ ## Amendment (v6.4.0): per-element display range
61
+
62
+ The display step was computed once at `:root`, so retuning it for one element (a hero wordmark)
63
+ meant restating the whole `tan(atan2(clamp(…)))` expression. Its two ends are now plain numbers,
64
+ `--display-from: 5` and `--display-to: 7`, and `text-step-display` and the `h1` base rule
65
+ recompute `--step-display` from them on the element. `class="[--display-from:4]
66
+ [--display-to:9]"` retunes one element without touching the formula.
67
+
68
+ - At the defaults the middle term reduces to the old `4.2176rem + 3.4784vw·k`; computed sizes
69
+ are identical (Chromium, Firefox and WebKit, 360–1600px).
70
+ - The accessibility rules above are unchanged: the `tan(atan2(1rem, 16px))` factor keeps the
71
+ exponent independent of root font size, and the clamp keeps its rem terms.
72
+ - `--step-display` still exists at `:root` (TypographyHeadline levels and theming read it). An
73
+ unlayered `--step-display` override on an element still beats the utility's, so existing
74
+ overrides keep working.
@@ -112,5 +112,6 @@ left out.
112
112
  | `layers/structure/navigation/app/components/Mast/Header.vue` | organism |
113
113
  | `layers/structure/navigation/app/components/Mast/Nav.vue` | organism |
114
114
  | `layers/structure/navigation/app/components/Mast/NavModal.vue` | organism |
115
+ | `layers/structure/navigation/app/components/Site/Logo.vue` | atom |
115
116
  | `layers/structure/navigation/app/components/Site/Subtitle.vue` | atom |
116
117
  | `layers/structure/navigation/app/components/Site/Title.vue` | atom |
@@ -86,9 +86,27 @@ Catalogue of layout shift (CLS) issues found and fixed in this monorepo, with ro
86
86
 
87
87
  ---
88
88
 
89
+ ## 11. Duplicate `UOverlayProvider` — `core/app.vue`
90
+
91
+ **Cause:** `core`'s `app.vue` rendered `<UOverlayProvider />` inside `<UApp>`, which already renders one. Both read the same `useOverlay()` state, so every `useOverlay().create()` overlay (including the old `createModal`-based nav) rendered twice: a doubled modal and a layout shift on open.
92
+
93
+ **Fix:** Delete the extra provider (v6.4.0). `UApp` owns overlays and toasts.
94
+
95
+ ---
96
+
97
+ ## 12. Nav modal hid the scrollbar before Reka measured it — `useMastNav`
98
+
99
+ **Cause:** `useMastNav().open()` called `lockScrolling()` (`html { overflow: hidden }`) before the modal opened. Reka's body scroll lock then measured a 0px scrollbar and added no padding, so with classic (non-overlay) scrollbars the page widened by the scrollbar width on open.
100
+
101
+ **Fix:** Let UModal/Reka own the overflow lock (it pads `body` for the scrollbar) and stop Lenis on the next tick, after Reka has measured (v6.4.0). A stopped Lenis sets `overflow: clip` on `html`, so the order matters.
102
+
103
+ ---
104
+
89
105
  ## Key Rules Going Forward
90
106
 
91
107
  - Never use `@layer` / `@utility` in CSS files loaded via `css: []` — use plain selectors instead.
92
108
  - Never use runtime `resolveComponent` to conditionally render components in Nuxt 4 — components are compile-time, not runtime-registered.
93
109
  - Use `useState` (not `useLocalStorage`) for any value rendered during SSR that might differ between server and client.
94
110
  - Keep accessibility-only elements (`aria-live` regions, announcers) outside CSS grid containers to prevent them participating in grid flow.
111
+ - Never add a `<UOverlayProvider />` next to `<UApp>` — `UApp` renders one.
112
+ - When a dialog locks scroll, don't hide `html` overflow first: the lock has to measure the scrollbar to compensate for it.
@@ -31,8 +31,8 @@ export default defineAppConfig({
31
31
 
32
32
  // Loading screen configuration (initial app load only)
33
33
  loading: {
34
- // Master toggle - set to false to disable completely
35
- enabled: true,
34
+ // Opt in: app.vue renders LoadingScreen for every app, so it stays off unless enabled
35
+ enabled: false,
36
36
  // Minimum time (ms, from navigation start) the count runs before reaching 100 (prevents flash)
37
37
  minDuration: 1500,
38
38
  // Longest wait (ms, after mount) for fonts and images before finishing anyway
@@ -1,9 +1,10 @@
1
1
  <template>
2
+ <!-- UApp already renders UOverlayProvider; a second one rendered every overlay twice. -->
2
3
  <UApp>
4
+ <LoadingScreen />
3
5
  <NuxtLoadingIndicator />
4
6
  <NuxtLayout>
5
7
  <NuxtPage />
6
8
  </NuxtLayout>
7
- <UOverlayProvider />
8
9
  </UApp>
9
10
  </template>
@@ -6,9 +6,9 @@ const FRAME = 1 / 60
6
6
 
7
7
  describe('resolveLoadingConfig', () => {
8
8
  it('fills unset keys with defaults but keeps falsy values that were set', () => {
9
- expect(resolveLoadingConfig(undefined)).toMatchObject({ enabled: true, minDuration: 1500 })
9
+ expect(resolveLoadingConfig(undefined)).toMatchObject({ enabled: false, minDuration: 1500 })
10
10
  expect(resolveLoadingConfig({ loading: { minDuration: 0, label: '' } })).toMatchObject({
11
- enabled: true,
11
+ enabled: false,
12
12
  minDuration: 0,
13
13
  label: '',
14
14
  })
@@ -2,7 +2,7 @@ import type { LoadingConfig } from '../types/loading'
2
2
 
3
3
  /** Fallbacks for keys app.config leaves unset - core's app.config ships the same values. */
4
4
  const LOADING_DEFAULTS: LoadingConfig = {
5
- enabled: true,
5
+ enabled: false,
6
6
  minDuration: 1500,
7
7
  maxDuration: 3000,
8
8
  label: 'Loading',
@@ -31,12 +31,25 @@
31
31
  --type-scale: 1.26;
32
32
  --type-snap: 0.0625rem; /* rounding interval: rem so it scales with the root; never px, never 0 */
33
33
 
34
- /* Fluid display step, 5 → 7 across 360 → 1280px. tan(atan2(length, 1rem)) casts the
35
- length to the unitless exponent pow() needs; normalised by 1rem (not 1px) so the
36
- exponent stays 5 → 7 whatever the user's root font size; the vw term is scaled by root / 16px so a
37
- larger root doesn't lower the exponent at a given width (h1 stays visibly larger). */
34
+ /* Fluid display step: --display-from at ≤360px → --display-to at ≥1280px (57.5 = 920px / 16).
35
+ tan(atan2(length, 1rem)) casts the length to the unitless exponent pow() needs; normalised
36
+ by 1rem (not 1px) so the exponent doesn't move with the user's root font size; the vw term
37
+ is scaled by root / 16px so a larger root doesn't lower it at a given width (h1 stays
38
+ visibly larger). Defaults reduce to clamp(5rem, 4.2176rem + 3.4784vw·k, 7rem).
39
+ --step-display resolves var() here at :root, so overriding --display-from/to on a subtree
40
+ only reaches elements that recompute it: text-step-display and h1 do. */
41
+ --display-from: 5;
42
+ --display-to: 7;
38
43
  --step-display: tan(
39
- atan2(clamp(5rem, 4.2176rem + 3.4784vw * tan(atan2(1rem, 16px)), 7rem), 1rem)
44
+ atan2(
45
+ clamp(
46
+ var(--display-from) * 1rem,
47
+ var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
48
+ tan(atan2(1rem, 16px)) / 57.5,
49
+ var(--display-to) * 1rem
50
+ ),
51
+ 1rem
52
+ )
40
53
  );
41
54
  }
42
55
 
@@ -252,6 +265,18 @@
252
265
  }
253
266
 
254
267
  @utility text-step-display {
268
+ /* Recomputed here so [--display-from:4] [--display-to:9] on the element takes effect */
269
+ --step-display: tan(
270
+ atan2(
271
+ clamp(
272
+ var(--display-from) * 1rem,
273
+ var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
274
+ tan(atan2(1rem, 16px)) / 57.5,
275
+ var(--display-to) * 1rem
276
+ ),
277
+ 1rem
278
+ )
279
+ );
255
280
  --step: var(--step-display);
256
281
 
257
282
  font-size: calc(var(--type-base) * pow(var(--type-scale), var(--step)));
@@ -283,6 +308,17 @@
283
308
 
284
309
  /* Same expressions as the text-step-* utilities. h1 uses the display step. */
285
310
  h1:where(:not([data-slot])) {
311
+ --step-display: tan(
312
+ atan2(
313
+ clamp(
314
+ var(--display-from) * 1rem,
315
+ var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
316
+ tan(atan2(1rem, 16px)) / 57.5,
317
+ var(--display-to) * 1rem
318
+ ),
319
+ 1rem
320
+ )
321
+ );
286
322
  --step: var(--step-display);
287
323
 
288
324
  font-size: calc(var(--type-base) * pow(var(--type-scale), var(--step)));
@@ -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
+ })
@@ -12,10 +12,18 @@
12
12
  setup(props, { slots, attrs }) {
13
13
  const hoveredIndex = ref<number | null>(null)
14
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
+
15
23
  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')
24
+ const children = flatten(slots.default?.() ?? []).filter(
25
+ (node) => typeof node.type === 'string' || typeof node.type === 'object'
26
+ )
19
27
 
20
28
  const styledChildren = children.map((vnode, index) =>
21
29
  cloneVNode(vnode, {
@@ -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,19 +1,20 @@
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
+ 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
+
4
8
  const { open } = useMastNav()
5
- 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
6
12
  </script>
7
13
 
8
14
  <template>
9
- <button
10
- aria-label="Open navigation menu"
11
- class="flex cursor-pointer items-center justify-center border-0 bg-transparent p-4"
12
- :style="{ zIndex: z }"
13
- @click="() => open()"
14
- >
15
- <slot name="icon">
16
- <UIcon name="lucide:menu" class="size-7" />
17
- </slot>
18
- </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>
19
20
  </template>
@@ -1,19 +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()
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
+ )
17
59
 
18
60
  function dismiss() {
19
61
  emit('update:open', false)
@@ -21,8 +63,8 @@
21
63
  closeNav()
22
64
  }
23
65
 
24
- function handleNav(link: MastNavLink) {
25
- if (scrollBehaviour === 'smooth-scroll' && route.name === 'index') {
66
+ function navigate(link: MastNavLink) {
67
+ if (smoothOnIndex.value) {
26
68
  try {
27
69
  useSmoothScroll().scrollTo(`#${link.id}`)
28
70
  } catch {}
@@ -37,35 +79,48 @@
37
79
  <!-- UModal supplies the dialog role, focus trap, Escape and portal; the title stays
38
80
  visually hidden because the content slot replaces the header. -->
39
81
  <UModal
40
- :open
82
+ :open="open ?? isOpen"
41
83
  fullscreen
42
84
  title="Site navigation"
43
- :ui="{ overlay: 'z-(--z-modal)', content: 'bg-default text-default z-(--z-modal)' }"
85
+ :ui="{ overlay: 'z-(--z-modal)', content: `${classes.content} z-(--z-modal)` }"
44
86
  @update:open="(value) => !value && dismiss()"
45
87
  >
46
88
  <template #content>
47
89
  <div class="relative flex h-full flex-col items-center justify-center">
48
- <UButton
49
- icon="lucide:x"
50
- color="neutral"
51
- variant="ghost"
52
- size="xl"
53
- aria-label="Close navigation"
54
- class="absolute top-4 right-4"
55
- @click="dismiss"
56
- />
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>
57
101
 
58
- <HoverDimGroup tag="nav" class="flex flex-col items-center gap-2">
59
- <button
60
- v-for="link in links"
61
- :key="link.id"
62
- class="cursor-pointer border-0 bg-transparent px-4 py-3 text-3xl tracking-widest uppercase transition-all"
63
- :class="activeSection === link.id ? 'font-normal' : 'font-light'"
64
- @click="() => handleNav(link)"
65
- >
66
- {{ link.label }}
67
- </button>
102
+ <slot name="header" />
103
+
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>
68
121
  </HoverDimGroup>
122
+
123
+ <slot name="footer" />
69
124
  </div>
70
125
  </template>
71
126
  </UModal>
@@ -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,11 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * @deprecated Render `useSite().titleWords` directly. Unused in this repo and KMCOMv5;
4
- * remove in the next major.
3
+ * @deprecated Use `<SiteLogo :stacked="false">` (or `useSite().titleWords`). Remove in the
4
+ * next major.
5
5
  */
6
6
  if (import.meta.dev)
7
7
  // eslint-disable-next-line no-console -- one-time deprecation notice, dev only
8
- console.warn('[navigation] <SiteTitle> is deprecated — render useSite().titleWords directly.')
8
+ console.warn('[navigation] <SiteTitle> is deprecated — use <SiteLogo>.')
9
9
 
10
10
  const site = useSite()
11
11
  </script>
@@ -1,15 +1,40 @@
1
- import MastNavModal from '../components/Mast/NavModal.vue'
2
- import { createModal } from '../utils/createModal'
1
+ /**
2
+ * Shared open state for the site nav. Plain `useState`, not an overlay: layouts and triggers read
3
+ * `isOpen`, and `<MastNavModal />` (rendered once by the layout) binds to it. Any number of
4
+ * `<MastNav>` triggers share the one modal.
5
+ *
6
+ * Scroll lock: UModal (Reka) locks body scroll and pads for the scrollbar, so this only stops
7
+ * Lenis — and only after Reka has measured. Hiding overflow first (lockScrolling) left Reka
8
+ * measuring a 0px scrollbar, and the page widened by the scrollbar on open.
9
+ */
10
+ export function useMastNav() {
11
+ const isOpen = useState('mast-nav:open', () => false)
3
12
 
4
- export const useMastNav = createModal(MastNavModal, {
5
- onOpen: () => {
13
+ function smoothScroll() {
6
14
  try {
7
- useSmoothScroll().lockScrolling()
8
- } catch {}
9
- },
10
- onClose: () => {
11
- try {
12
- useSmoothScroll().unlockScrolling()
13
- } catch {}
14
- },
15
- })
15
+ return useSmoothScroll().locomotiveScroll
16
+ } catch {
17
+ return undefined
18
+ }
19
+ }
20
+
21
+ function open() {
22
+ const scroll = smoothScroll()
23
+ isOpen.value = true
24
+ if (import.meta.client) nextTick(() => scroll?.value?.stop())
25
+ }
26
+
27
+ function close() {
28
+ const scroll = smoothScroll()
29
+ isOpen.value = false
30
+ if (!import.meta.client) return
31
+ scroll?.value?.start()
32
+ // Lenis/Locomotive measure the page while stopped; resize after restart so they resync
33
+ scroll?.value?.lenisInstance?.resize()
34
+ requestAnimationFrame(() => scroll?.value?.resize())
35
+ }
36
+
37
+ const toggle = () => (isOpen.value ? close() : open())
38
+
39
+ return { isOpen: readonly(isOpen), open, close, toggle }
40
+ }
@@ -8,4 +8,38 @@ export type MastNavLink = {
8
8
  params?: Record<string, string | number>
9
9
  query?: Record<string, string | number>
10
10
  }
11
+ /** Skip the link without passing a custom `links` prop. */
12
+ hidden?: boolean
13
+ }
14
+
15
+ /**
16
+ * UButton props for the MastNav trigger. Plain strings: Nuxt UI's ButtonProps is derived from the
17
+ * resolved app config, so using it inside app.config.ts would be a type cycle.
18
+ */
19
+ export type MastNavTrigger = {
20
+ label?: string
21
+ icon?: string
22
+ trailingIcon?: string
23
+ color?: string
24
+ variant?: string
25
+ size?: string
26
+ square?: boolean
27
+ class?: string
28
+ }
29
+
30
+ /** Class strings for MastNavModal's parts. Merged per key over app.config; strings replace. */
31
+ export type MastNavUi = {
32
+ content: string
33
+ close: string
34
+ nav: string
35
+ link: string
36
+ linkActive: string
37
+ }
38
+
39
+ export type SiteLogoSize = 'sm' | 'md' | 'lg' | 'display'
40
+
41
+ export type SiteLogoConfig = {
42
+ stacked: boolean
43
+ ui: { root: string; word: string }
44
+ sizes: Record<SiteLogoSize, string>
11
45
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kmcom-nuxt-layers",
3
3
  "private": false,
4
- "version": "6.3.0",
4
+ "version": "6.4.0",
5
5
  "description": "Composable Nuxt 4 layers for building scalable Vue applications",
6
6
  "exports": {
7
7
  "./layers/core": "./layers/core/nuxt.config.ts",