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.
- package/docs/MIGRATION-v6.4.0.md +215 -0
- package/docs/MIGRATION.md +2 -1
- package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +16 -0
- package/docs/component-levels.md +1 -0
- package/docs/layout-shift-causes.md +18 -0
- package/layers/core/app/app.config.ts +2 -2
- package/layers/core/app/app.vue +2 -1
- package/layers/core/app/utils/loading.test.ts +2 -2
- package/layers/core/app/utils/loading.ts +1 -1
- package/layers/design-system/typography/app/assets/css/typography.css +41 -5
- package/layers/design-system/visual/app/components/HoverDimGroup.test.ts +38 -0
- package/layers/design-system/visual/app/components/HoverDimGroup.vue +11 -3
- package/layers/structure/navigation/app/app.config.ts +37 -14
- package/layers/structure/navigation/app/components/Mast/Nav.vue +14 -13
- package/layers/structure/navigation/app/components/Mast/NavModal.vue +83 -28
- package/layers/structure/navigation/app/components/Site/Logo.vue +35 -0
- package/layers/structure/navigation/app/components/Site/Title.vue +3 -3
- package/layers/structure/navigation/app/composables/mastNav.ts +38 -13
- package/layers/structure/navigation/app/types/nav.ts +34 -0
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
package/docs/component-levels.md
CHANGED
|
@@ -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
|
-
//
|
|
35
|
-
enabled:
|
|
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
|
package/layers/core/app/app.vue
CHANGED
|
@@ -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:
|
|
9
|
+
expect(resolveLoadingConfig(undefined)).toMatchObject({ enabled: false, minDuration: 1500 })
|
|
10
10
|
expect(resolveLoadingConfig({ loading: { minDuration: 0, label: '' } })).toMatchObject({
|
|
11
|
-
enabled:
|
|
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:
|
|
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
|
|
35
|
-
length to the unitless exponent pow() needs; normalised
|
|
36
|
-
|
|
37
|
-
larger root doesn't lower
|
|
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(
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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?:
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
23
|
+
const config = useAppConfig().mastNav as {
|
|
11
24
|
links: MastNavLink[]
|
|
12
|
-
scrollBehaviour:
|
|
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
|
|
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
|
|
25
|
-
if (
|
|
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:
|
|
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
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
4
|
-
*
|
|
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 —
|
|
8
|
+
console.warn('[navigation] <SiteTitle> is deprecated — use <SiteLogo>.')
|
|
9
9
|
|
|
10
10
|
const site = useSite()
|
|
11
11
|
</script>
|
|
@@ -1,15 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
onOpen: () => {
|
|
13
|
+
function smoothScroll() {
|
|
6
14
|
try {
|
|
7
|
-
useSmoothScroll().
|
|
8
|
-
} catch {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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