kmcom-nuxt-layers 1.1.9 → 1.2.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/LAYOUT.md +517 -0
- package/layers/core/app/composables/useScrollGuard.ts +2 -2
- package/layers/layout/app/assets/css/layout/grids.css +18 -18
- package/layers/layout/app/assets/css/layout/modes/fluid.css +100 -0
- package/layers/layout/app/assets/css/main.css +1 -0
- package/layers/layout/app/components/Layout/Container.vue +49 -0
- package/layers/layout/app/components/Layout/Grid/Debug.vue +18 -19
- package/layers/layout/app/components/Layout/Main.vue +36 -0
- package/layers/layout/app/components/Layout/Page/index.vue +69 -0
- package/layers/layout/app/composables/useGridConfig.ts +28 -1
- package/layers/layout/app/types/layouts.ts +35 -0
- package/layers/layout/app.config.ts +65 -0
- package/layers/motion/app/plugins/locomotive-scroll.client.ts +41 -21
- package/package.json +1 -1
package/docs/LAYOUT.md
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# Layout Layer
|
|
2
|
+
|
|
3
|
+
Swiss Grid System for Nuxt 4 applications. Provides a responsive 6/12/18-column CSS subgrid, page structure components, a mode system, and fluid layout utilities.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [How it works](#how-it-works)
|
|
10
|
+
- [Playground setup](#playground-setup)
|
|
11
|
+
- [Mode system](#mode-system)
|
|
12
|
+
- [Components](#components)
|
|
13
|
+
- [Composables](#composables)
|
|
14
|
+
- [CSS utilities](#css-utilities)
|
|
15
|
+
- [Z-index system](#z-index-system)
|
|
16
|
+
- [Config reference](#config-reference)
|
|
17
|
+
- [Known pitfalls](#known-pitfalls)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
The grid is a single CSS `display: grid` on `<main>` (the `.mastmain` class). Everything else — sections, items — participates via CSS `subgrid`, meaning they inherit the parent's column and row lines rather than defining their own. No JS is involved in the grid itself.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
<main class="mastmain"> ← defines the grid (6/12/18 cols, row height)
|
|
27
|
+
<section class="basesection"> ← subgrid: inherits cols + rows, spans 12 rows (= 100vh)
|
|
28
|
+
<div style="grid-column: ..."> ← positioned within the subgrid
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`LayoutMain` renders the `<main class="mastmain">`. `LayoutSection` renders `<section class="basesection">`. `LayoutGridItem` handles grid positioning via inline style or presets.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Playground setup
|
|
36
|
+
|
|
37
|
+
The layout layer requires a dedicated Nuxt layout file and page-level layout declaration. Without these, content appears edge-to-edge and the grid CSS never applies.
|
|
38
|
+
|
|
39
|
+
**1. Create `layouts/grid.vue`** in your app:
|
|
40
|
+
|
|
41
|
+
```vue
|
|
42
|
+
<template>
|
|
43
|
+
<LayoutMain>
|
|
44
|
+
<slot />
|
|
45
|
+
<LayoutGridDebug />
|
|
46
|
+
</LayoutMain>
|
|
47
|
+
</template>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**2. Declare the layout in each page that uses the grid:**
|
|
51
|
+
|
|
52
|
+
```vue
|
|
53
|
+
<script setup lang="ts">
|
|
54
|
+
definePageMeta({ layout: 'grid' })
|
|
55
|
+
</script>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> `LayoutPage` is a fragment component — it intentionally provides no wrapper element. The `mastmain` grid lives on `LayoutMain` in the layout file, not in `LayoutPage` itself. Do not add a second `LayoutMain` inside `LayoutPage`.
|
|
59
|
+
|
|
60
|
+
**Grid debug overlay** (`Cmd+G` to toggle) is rendered by `LayoutGridDebug` inside the layout file, not per-page.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Mode system
|
|
65
|
+
|
|
66
|
+
The grid has three modes controlled via `app.config.ts`:
|
|
67
|
+
|
|
68
|
+
| Mode | Behaviour |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `'swiss'` | Default. Full Swiss grid with `mastmain`/`basesection` CSS subgrid. |
|
|
71
|
+
| `'fluid'` | Container-query-based auto-fit grid. `basesection` still gets `container-type`. |
|
|
72
|
+
| `'disabled'` | `LayoutMain` renders a plain `<main>` without grid CSS. |
|
|
73
|
+
|
|
74
|
+
Set in your `app.config.ts`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
export default defineAppConfig({
|
|
78
|
+
layoutLayer: {
|
|
79
|
+
ui: {
|
|
80
|
+
grid: {
|
|
81
|
+
mode: 'swiss', // 'swiss' | 'fluid' | 'disabled'
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The old `enabled: boolean` flag is still supported (mapped to `'disabled'` / `'swiss'`) but `mode` takes precedence.
|
|
89
|
+
|
|
90
|
+
Read in code:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const { mode, isEnabled } = useGridConfig()
|
|
94
|
+
// mode.value === 'swiss' | 'fluid' | 'disabled'
|
|
95
|
+
// isEnabled.value === mode !== 'disabled'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Components
|
|
101
|
+
|
|
102
|
+
### `LayoutMain`
|
|
103
|
+
|
|
104
|
+
Renders `<main class="mastmain">`. When `mode === 'disabled'` it renders a plain `<main>` with no grid classes.
|
|
105
|
+
|
|
106
|
+
```vue
|
|
107
|
+
<LayoutMain> <!-- default: <main> -->
|
|
108
|
+
<LayoutMain tag="div"> <!-- custom tag -->
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Props:**
|
|
112
|
+
|
|
113
|
+
| Prop | Type | Default |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `tag` | `string` | `'main'` |
|
|
116
|
+
|
|
117
|
+
Place this in your layout file (`layouts/grid.vue`), not in individual pages.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### `LayoutContainer`
|
|
122
|
+
|
|
123
|
+
Constrains content width and centres it. Use inside sections or anywhere you need a contained block.
|
|
124
|
+
|
|
125
|
+
```vue
|
|
126
|
+
<LayoutContainer> <!-- default: wide (90rem) -->
|
|
127
|
+
<LayoutContainer size="content"> <!-- 65ch prose width -->
|
|
128
|
+
<LayoutContainer size="wide"> <!-- 90rem -->
|
|
129
|
+
<LayoutContainer size="fluid"> <!-- 100% of parent -->
|
|
130
|
+
<LayoutContainer size="full"> <!-- 100vw, escapes grid padding -->
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Props:**
|
|
134
|
+
|
|
135
|
+
| Prop | Type | Default |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `size` | `'content' \| 'wide' \| 'fluid' \| 'full'` | `'wide'` |
|
|
138
|
+
| `tag` | `string` | `'div'` |
|
|
139
|
+
|
|
140
|
+
The `full` size uses negative `margin-inline` to escape the grid's `--grid-padding`, reaching the true viewport edge.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### `LayoutPage`
|
|
145
|
+
|
|
146
|
+
Fragment component (no wrapper element). Handles `useHead()` SEO and optionally renders a `LayoutPageHeader` section.
|
|
147
|
+
|
|
148
|
+
```vue
|
|
149
|
+
<template>
|
|
150
|
+
<LayoutPage title="About" description="About us">
|
|
151
|
+
<LayoutSection>
|
|
152
|
+
<LayoutGridItem preset="centered">
|
|
153
|
+
<!-- content -->
|
|
154
|
+
</LayoutGridItem>
|
|
155
|
+
</LayoutSection>
|
|
156
|
+
</LayoutPage>
|
|
157
|
+
</template>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Props:**
|
|
161
|
+
|
|
162
|
+
| Prop | Type | Default |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `title` | `string` | — (required) |
|
|
165
|
+
| `description` | `string` | `''` |
|
|
166
|
+
| `showHeader` | `boolean` | `false` |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### `LayoutSection`
|
|
171
|
+
|
|
172
|
+
Full-viewport subgrid section. Spans 12 rows (= 100vh). Inherits the parent `.mastmain` grid columns via `subgrid`.
|
|
173
|
+
|
|
174
|
+
```vue
|
|
175
|
+
<LayoutSection>
|
|
176
|
+
<LayoutSection full-height> <!-- always 100vh, even on mobile -->
|
|
177
|
+
<LayoutSection tag="article">
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Props:**
|
|
181
|
+
|
|
182
|
+
| Prop | Type | Default |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `fullHeight` | `boolean` | `false` |
|
|
185
|
+
| `fullWidth` | `boolean` | `false` |
|
|
186
|
+
| `tag` | `string` | `'section'` |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### `LayoutGridItem`
|
|
191
|
+
|
|
192
|
+
Positioned child within a subgrid section. Use `preset` for common layouts or set column/row values directly.
|
|
193
|
+
|
|
194
|
+
```vue
|
|
195
|
+
<LayoutGridItem preset="centered" />
|
|
196
|
+
<LayoutGridItem col-start="3" col-span="12" row-start="2" row-span="8" />
|
|
197
|
+
<!-- Responsive values -->
|
|
198
|
+
<LayoutGridItem :col-span="{ default: 6, md: 10, lg: 12 }" />
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**Props:**
|
|
202
|
+
|
|
203
|
+
| Prop | Type | Notes |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| `preset` | `string` | Named preset from config |
|
|
206
|
+
| `colStart` | `number \| ResponsiveValue` | Grid column start |
|
|
207
|
+
| `colSpan` | `number \| ResponsiveValue` | Column span |
|
|
208
|
+
| `rowStart` | `number \| ResponsiveValue` | Grid row start |
|
|
209
|
+
| `rowSpan` | `number \| ResponsiveValue` | Row span |
|
|
210
|
+
| `layer` | `keyof GridLayers` | Z-index layer: `back`, `mid`, `front`, `top` |
|
|
211
|
+
| `align` | `string` | `align-self` value |
|
|
212
|
+
| `justify` | `string` | `justify-self` value |
|
|
213
|
+
| `bleed` | `boolean` | Negative margin to reach viewport edge |
|
|
214
|
+
| `as` | `string` | Tag override |
|
|
215
|
+
|
|
216
|
+
**Built-in presets:**
|
|
217
|
+
|
|
218
|
+
| Preset | Columns | Rows |
|
|
219
|
+
|---|---|---|
|
|
220
|
+
| `hero` | full width | full height |
|
|
221
|
+
| `centered` | centre 10 cols | rows 2–10 |
|
|
222
|
+
| `fullWidth` | full width | auto |
|
|
223
|
+
| `sidebar` | cols 1–4 | full height |
|
|
224
|
+
| `content` | cols 5–14 | rows 2–10 |
|
|
225
|
+
| `splitLeft` | left half | full |
|
|
226
|
+
| `splitRight` | right half | full |
|
|
227
|
+
| `quarterLeft` | first quarter | full |
|
|
228
|
+
| `threeQuarterRight` | right 3/4 | full |
|
|
229
|
+
| `halfTop` | full width | top half |
|
|
230
|
+
| `halfBottom` | full width | bottom half |
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
### `LayoutSectionHero`
|
|
235
|
+
|
|
236
|
+
Section with `background`, `default`, and `footer` slot layers.
|
|
237
|
+
|
|
238
|
+
```vue
|
|
239
|
+
<LayoutSectionHero>
|
|
240
|
+
<template #background><img ... /></template>
|
|
241
|
+
<h1>Title</h1>
|
|
242
|
+
<template #footer><p>Subtitle</p></template>
|
|
243
|
+
</LayoutSectionHero>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
### `LayoutSectionSplit`
|
|
249
|
+
|
|
250
|
+
Two-column 50/50 layout.
|
|
251
|
+
|
|
252
|
+
```vue
|
|
253
|
+
<LayoutSectionSplit>
|
|
254
|
+
<template #left>Left content</template>
|
|
255
|
+
<template #right>Right content</template>
|
|
256
|
+
</LayoutSectionSplit>
|
|
257
|
+
|
|
258
|
+
<LayoutSectionSplit reverse /> <!-- right renders first visually -->
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
### `LayoutSectionGallery`
|
|
264
|
+
|
|
265
|
+
Auto-placing collection grid.
|
|
266
|
+
|
|
267
|
+
```vue
|
|
268
|
+
<LayoutSectionGallery :items="items" :columns="3" :item-row-span="4">
|
|
269
|
+
<template #item="{ item, index }">
|
|
270
|
+
<img :src="item.src" />
|
|
271
|
+
</template>
|
|
272
|
+
</LayoutSectionGallery>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Props:** `items` (required), `columns` (2/3/4/6, default 3), `itemRowSpan` (default 4)
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
### `LayoutGridDebug`
|
|
280
|
+
|
|
281
|
+
Column overlay toggled with `Cmd+G`. Place once in your grid layout file, not in pages.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Composables
|
|
286
|
+
|
|
287
|
+
### `useGridConfig()`
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const { config, getPreset, isEnabled, mode, layers, useZIndex } = useGridConfig()
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
| Return | Type | Description |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `config` | `Ref<GridConfig>` | Raw config from `app.config` |
|
|
296
|
+
| `isEnabled` | `ComputedRef<boolean>` | `true` when `mode !== 'disabled'` |
|
|
297
|
+
| `mode` | `ComputedRef<GridMode>` | `'swiss' \| 'fluid' \| 'disabled'` |
|
|
298
|
+
| `layers` | `ComputedRef<GridLayers>` | All z-index values |
|
|
299
|
+
| `getPreset(name)` | `GridPresetsItem \| undefined` | Look up a preset by name |
|
|
300
|
+
| `useZIndex(layer)` | `number` | Get a z-index value by layer name |
|
|
301
|
+
|
|
302
|
+
**`useZIndex` example:**
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
const { useZIndex } = useGridConfig()
|
|
306
|
+
const zModal = useZIndex('modal') // 400
|
|
307
|
+
const zHeader = useZIndex('header') // 100
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## CSS utilities
|
|
313
|
+
|
|
314
|
+
All grid CSS is in `layers/layout/app/assets/css/layout/`.
|
|
315
|
+
|
|
316
|
+
> **Important:** All classes are plain CSS (not `@utility` directives). Do not convert them to `@utility` — they are loaded via Nuxt's `css: []` array which bypasses the Tailwind CSS 4 pipeline, so `@utility` would produce no output.
|
|
317
|
+
|
|
318
|
+
### `grids.css` — Core grid classes
|
|
319
|
+
|
|
320
|
+
**`.mastmain`** — Root grid container on `<main>`.
|
|
321
|
+
|
|
322
|
+
```css
|
|
323
|
+
/* Responsive column counts */
|
|
324
|
+
/* Mobile: 6 columns (< 768px) */
|
|
325
|
+
/* Tablet: 12 columns (≥ 768px) */
|
|
326
|
+
/* Desktop: 18 columns (≥ 1280px) */
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
CSS custom properties exposed:
|
|
330
|
+
|
|
331
|
+
| Variable | Value | Purpose |
|
|
332
|
+
|---|---|---|
|
|
333
|
+
| `--grid-cols` | 6 / 12 / 18 | Active column count |
|
|
334
|
+
| `--grid-gap` | `clamp(0.75rem, 1.5vw, 1.5rem)` | Gap between columns and rows |
|
|
335
|
+
| `--grid-padding` | `clamp(1rem, 2.5vw, 2rem)` | Outer left/right gutters |
|
|
336
|
+
| `--section-height` | `100vh` | Height of one section |
|
|
337
|
+
| `--grid-row-height` | computed | Height of one grid row |
|
|
338
|
+
|
|
339
|
+
**`.basesection`** — Subgrid section (12 rows = 100vh).
|
|
340
|
+
|
|
341
|
+
**Vertical rhythm utilities** (`.leading-rhythm-*`, `.space-rhythm-*`, `.prose-rhythm`)
|
|
342
|
+
|
|
343
|
+
### `modes/fluid.css` — Fluid grid classes
|
|
344
|
+
|
|
345
|
+
Container-query-based auto-fit grids for components that should respond to their container width rather than the viewport.
|
|
346
|
+
|
|
347
|
+
```vue
|
|
348
|
+
<div class="fluid-grid"> <!-- auto-fit, min col 16rem -->
|
|
349
|
+
<div class="fluid-grid-2"> <!-- 2-column at container ≥ 30rem -->
|
|
350
|
+
<div class="fluid-grid-3"> <!-- 3-column at container ≥ 44rem -->
|
|
351
|
+
<div class="fluid-grid-4"> <!-- 4-column at container ≥ 52rem -->
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Tune minimum column width per-instance:
|
|
355
|
+
|
|
356
|
+
```vue
|
|
357
|
+
<div class="fluid-grid" style="--fluid-col-min: 20rem">
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**Container size classes** (used by `LayoutContainer`):
|
|
361
|
+
|
|
362
|
+
| Class | Effect |
|
|
363
|
+
|---|---|
|
|
364
|
+
| `.layout-container-content` | `max-width: 65ch`, centred |
|
|
365
|
+
| `.layout-container-wide` | `max-width: 90rem`, centred |
|
|
366
|
+
| `.layout-container-fluid` | `max-width: 100%` |
|
|
367
|
+
| `.layout-container-full` | `width: 100vw`, escapes grid padding |
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## Z-index system
|
|
372
|
+
|
|
373
|
+
The grid manages z-index through named layers. Use `useZIndex()` or the `layer` prop on `LayoutGridItem` — never raw numbers.
|
|
374
|
+
|
|
375
|
+
| Layer | Value | Use |
|
|
376
|
+
|---|---|---|
|
|
377
|
+
| `back` | 0 | Background elements |
|
|
378
|
+
| `mid` | 10 | Content |
|
|
379
|
+
| `front` | 20 | Floating content |
|
|
380
|
+
| `top` | 30 | Pinned / sticky |
|
|
381
|
+
| `header` | 100 | Site header / nav |
|
|
382
|
+
| `dropdown` | 200 | Dropdown menus, popups |
|
|
383
|
+
| `overlay` | 300 | Drawers, sidebars |
|
|
384
|
+
| `modal` | 400 | Modal dialogs |
|
|
385
|
+
| `toast` | 500 | Toasts, notifications |
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
// In a component
|
|
389
|
+
const { useZIndex } = useGridConfig()
|
|
390
|
+
</script>
|
|
391
|
+
<template>
|
|
392
|
+
<div :style="{ zIndex: useZIndex('modal') }">
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
```vue
|
|
396
|
+
<!-- On a grid item -->
|
|
397
|
+
<LayoutGridItem layer="front">
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Override values in `app.config.ts`:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
layoutLayer: {
|
|
404
|
+
ui: {
|
|
405
|
+
grid: {
|
|
406
|
+
layers: {
|
|
407
|
+
toast: 9999, // override individual values
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Config reference
|
|
417
|
+
|
|
418
|
+
Full type from `layers/layout/app/types/layouts.ts`:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
interface GridConfig {
|
|
422
|
+
mode?: 'swiss' | 'fluid' | 'disabled'
|
|
423
|
+
/** @deprecated use mode: 'disabled' */
|
|
424
|
+
enabled?: boolean
|
|
425
|
+
layers?: Partial<GridLayers>
|
|
426
|
+
presets?: Record<string, GridPresetsItem>
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
interface GridPresetsItem {
|
|
430
|
+
colStart?: number | ResponsiveValue
|
|
431
|
+
colSpan?: number | ResponsiveValue
|
|
432
|
+
rowStart?: number | ResponsiveValue
|
|
433
|
+
rowSpan?: number | ResponsiveValue
|
|
434
|
+
container?: 'content' | 'wide' | 'fluid' | 'full'
|
|
435
|
+
gap?: string
|
|
436
|
+
density?: 'compact' | 'normal' | 'spacious'
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
App config namespace: `layoutLayer.ui.grid`.
|
|
441
|
+
|
|
442
|
+
**Custom preset example:**
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
export default defineAppConfig({
|
|
446
|
+
layoutLayer: {
|
|
447
|
+
ui: {
|
|
448
|
+
grid: {
|
|
449
|
+
presets: {
|
|
450
|
+
spotlight: {
|
|
451
|
+
colStart: 4,
|
|
452
|
+
colSpan: 12,
|
|
453
|
+
rowStart: 3,
|
|
454
|
+
rowSpan: 6,
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
})
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## Known pitfalls
|
|
466
|
+
|
|
467
|
+
### `@utility` doesn't work in layer CSS files
|
|
468
|
+
|
|
469
|
+
`@utility` is a Tailwind CSS 4 directive that only works inside files processed by TW4's own pipeline (files containing `@import "tailwindcss"`). CSS loaded via Nuxt's `css: []` array bypasses this pipeline — `@utility` blocks are silently ignored and the class is never generated.
|
|
470
|
+
|
|
471
|
+
**Rule: always use plain CSS class selectors in layer CSS files.**
|
|
472
|
+
|
|
473
|
+
```css
|
|
474
|
+
/* Wrong — class will not exist in output */
|
|
475
|
+
@utility mastmain {
|
|
476
|
+
display: grid;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* Correct */
|
|
480
|
+
.mastmain {
|
|
481
|
+
display: grid;
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### `overflow-x: hidden` creates unintended scroll containers
|
|
486
|
+
|
|
487
|
+
The CSS overflow interaction rule: setting `overflow-x: hidden` on any element forces `overflow-y` from `visible` to `auto`, turning that element into a scroll container. When the element is `html` or `body`, `window.scrollY` stays 0 even when content overflows the viewport.
|
|
488
|
+
|
|
489
|
+
**Use `overflow-x: clip` instead.** `clip` cuts off overflow without creating a scroll container and is not subject to the forced `overflow-y` conversion. This is why `.mastmain`, `html`, and `body` all use `overflow-x: clip` in this layer.
|
|
490
|
+
|
|
491
|
+
```css
|
|
492
|
+
/* Wrong */
|
|
493
|
+
.mastmain { overflow-x: hidden; } /* becomes a scroll container */
|
|
494
|
+
|
|
495
|
+
/* Correct */
|
|
496
|
+
.mastmain { overflow-x: clip; } /* clips without scroll container */
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Locomotive Scroll intercepts wheel events globally
|
|
500
|
+
|
|
501
|
+
Locomotive Scroll v5 (Lenis-based) with `smoothWheel: true` calls `preventDefault()` on every `wheel` event. If initialised globally in a Nuxt plugin, it intercepts scrolling on every page — even pages where the window has no scroll height, silently swallowing events.
|
|
502
|
+
|
|
503
|
+
**Scope Locomotive Scroll to the specific route** using `addRouteMiddleware`:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
// plugins/locomotive-scroll.client.ts
|
|
507
|
+
addRouteMiddleware((to, from) => {
|
|
508
|
+
if (to.path === '/the-route') nextTick(init)
|
|
509
|
+
else if (from?.path === '/the-route') destroy()
|
|
510
|
+
})
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### `LayoutPage` is a fragment — it provides no grid wrapper
|
|
514
|
+
|
|
515
|
+
`LayoutPage` renders no element of its own. The `mastmain` grid lives on `LayoutMain` inside a layout file. If you use `<LayoutPage>` on a page that uses the `default` layout (which doesn't include `LayoutMain`), the grid CSS never applies.
|
|
516
|
+
|
|
517
|
+
Always pair `LayoutPage` usage with `definePageMeta({ layout: 'grid' })` and ensure `layouts/grid.vue` exists with `<LayoutMain>`.
|
|
@@ -96,8 +96,8 @@ function guard() {
|
|
|
96
96
|
const html = document.documentElement
|
|
97
97
|
const body = document.body
|
|
98
98
|
|
|
99
|
-
html.style.overflowX = '
|
|
100
|
-
body.style.overflowX = '
|
|
99
|
+
html.style.overflowX = 'clip'
|
|
100
|
+
body.style.overflowX = 'clip'
|
|
101
101
|
body.style.maxWidth = '100vw'
|
|
102
102
|
|
|
103
103
|
if (!opts.strict) return
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* Uses CSS subgrid for precise alignment across layout hierarchy.
|
|
6
6
|
*
|
|
7
7
|
* Hierarchy:
|
|
8
|
-
* MastMain (mastmain
|
|
9
|
-
* └── BaseSection (basesection
|
|
8
|
+
* MastMain (.mastmain)
|
|
9
|
+
* └── BaseSection (.basesection, uses subgrid)
|
|
10
10
|
* └── BaseGridItem (positioned via inline grid-column/grid-row)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* mastmain - Root grid container with responsive density
|
|
14
|
+
* .mastmain - Root grid container with responsive density
|
|
15
15
|
*
|
|
16
16
|
* Creates responsive grid:
|
|
17
17
|
* - Mobile: 6 columns (default)
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Each row height is calculated to fit 12 rows per viewport.
|
|
22
22
|
*/
|
|
23
|
-
|
|
23
|
+
.mastmain {
|
|
24
24
|
--grid-cols: 6;
|
|
25
25
|
--grid-rows-per-section: 12;
|
|
26
26
|
--grid-gap: clamp(0.75rem, 1.5vw, 1.5rem);
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
grid-auto-rows: var(--grid-row-height);
|
|
37
37
|
gap: var(--grid-gap);
|
|
38
38
|
padding-inline: var(--grid-padding); /* Outer gutters on left/right */
|
|
39
|
-
overflow-x: hidden
|
|
39
|
+
overflow-x: clip; /* clip without creating a scroll container (overflow-x:hidden would force overflow-y:auto) */
|
|
40
40
|
width: 100%;
|
|
41
41
|
max-width: 100%;
|
|
42
42
|
box-sizing: border-box;
|
|
@@ -58,12 +58,12 @@
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* basesection - Full-viewport section using subgrid
|
|
61
|
+
* .basesection - Full-viewport section using subgrid
|
|
62
62
|
*
|
|
63
63
|
* Inherits parent's grid lines via subgrid.
|
|
64
64
|
* Spans 12 rows (1 viewport height).
|
|
65
65
|
*/
|
|
66
|
-
|
|
66
|
+
.basesection {
|
|
67
67
|
container-type: inline-size;
|
|
68
68
|
container-name: section;
|
|
69
69
|
grid-column: 1 / -1;
|
|
@@ -100,59 +100,59 @@
|
|
|
100
100
|
*/
|
|
101
101
|
|
|
102
102
|
/* Line height utilities */
|
|
103
|
-
|
|
103
|
+
.leading-rhythm-4 {
|
|
104
104
|
--rhythm: 0.25rem;
|
|
105
105
|
line-height: calc(var(--rhythm) * 4); /* 1rem */
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
.leading-rhythm-5 {
|
|
109
109
|
--rhythm: 0.25rem;
|
|
110
110
|
line-height: calc(var(--rhythm) * 5); /* 1.25rem */
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
.leading-rhythm-6 {
|
|
114
114
|
--rhythm: 0.25rem;
|
|
115
115
|
line-height: calc(var(--rhythm) * 6); /* 1.5rem */
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
.leading-rhythm-7 {
|
|
119
119
|
--rhythm: 0.25rem;
|
|
120
120
|
line-height: calc(var(--rhythm) * 7); /* 1.75rem */
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
.leading-rhythm-8 {
|
|
124
124
|
--rhythm: 0.25rem;
|
|
125
125
|
line-height: calc(var(--rhythm) * 8); /* 2rem */
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/* Vertical spacing utilities */
|
|
129
|
-
|
|
129
|
+
.space-rhythm-1 {
|
|
130
130
|
--rhythm: 0.25rem;
|
|
131
131
|
margin-block: var(--rhythm); /* 0.25rem */
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
.space-rhythm-2 {
|
|
135
135
|
--rhythm: 0.25rem;
|
|
136
136
|
margin-block: calc(var(--rhythm) * 2); /* 0.5rem */
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
.space-rhythm-4 {
|
|
140
140
|
--rhythm: 0.25rem;
|
|
141
141
|
margin-block: calc(var(--rhythm) * 4); /* 1rem */
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
.space-rhythm-6 {
|
|
145
145
|
--rhythm: 0.25rem;
|
|
146
146
|
margin-block: calc(var(--rhythm) * 6); /* 1.5rem */
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
.space-rhythm-8 {
|
|
150
150
|
--rhythm: 0.25rem;
|
|
151
151
|
margin-block: calc(var(--rhythm) * 8); /* 2rem */
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/* Prose container - auto-applies rhythm to children */
|
|
155
|
-
|
|
155
|
+
.prose-rhythm {
|
|
156
156
|
--rhythm: 0.25rem;
|
|
157
157
|
|
|
158
158
|
& > * + * {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fluid Mode — Container-Query Based Grid Utilities
|
|
3
|
+
*
|
|
4
|
+
* These utilities complement .mastmain/.basesection and are safe to use
|
|
5
|
+
* alongside them — they only take effect when their class is applied.
|
|
6
|
+
*
|
|
7
|
+
* Design goals:
|
|
8
|
+
* - Auto-fit columns that respond to their *container* width, not the viewport
|
|
9
|
+
* - No fixed breakpoints — layout adapts continuously
|
|
10
|
+
* - Consistent gap matching the base grid gap custom property
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* .fluid-grid — auto-fit grid that fills its container.
|
|
15
|
+
*
|
|
16
|
+
* Columns shrink no smaller than --fluid-col-min (default 16rem / 256px).
|
|
17
|
+
* Use `--fluid-col-min` to tune the minimum column width per-instance.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* <div class="fluid-grid" style="--fluid-col-min: 20rem">…</div>
|
|
21
|
+
*/
|
|
22
|
+
.fluid-grid {
|
|
23
|
+
--fluid-col-min: 16rem;
|
|
24
|
+
display: grid;
|
|
25
|
+
grid-template-columns: repeat(auto-fit, minmax(var(--fluid-col-min), 1fr));
|
|
26
|
+
gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* .fluid-grid-2 through .fluid-grid-4 — named column-count variants.
|
|
31
|
+
*
|
|
32
|
+
* These use container queries so the number of columns degrades gracefully
|
|
33
|
+
* when the container is too narrow.
|
|
34
|
+
*/
|
|
35
|
+
.fluid-grid-2 {
|
|
36
|
+
--fluid-col-min: 14rem;
|
|
37
|
+
display: grid;
|
|
38
|
+
grid-template-columns: repeat(auto-fit, minmax(var(--fluid-col-min), 1fr));
|
|
39
|
+
gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
|
|
40
|
+
|
|
41
|
+
@container (width >= 30rem) {
|
|
42
|
+
grid-template-columns: repeat(2, 1fr);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.fluid-grid-3 {
|
|
47
|
+
--fluid-col-min: 14rem;
|
|
48
|
+
display: grid;
|
|
49
|
+
grid-template-columns: repeat(auto-fit, minmax(var(--fluid-col-min), 1fr));
|
|
50
|
+
gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
|
|
51
|
+
|
|
52
|
+
@container (width >= 44rem) {
|
|
53
|
+
grid-template-columns: repeat(3, 1fr);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.fluid-grid-4 {
|
|
58
|
+
--fluid-col-min: 12rem;
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-columns: repeat(auto-fit, minmax(var(--fluid-col-min), 1fr));
|
|
61
|
+
gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
|
|
62
|
+
|
|
63
|
+
@container (width >= 52rem) {
|
|
64
|
+
grid-template-columns: repeat(4, 1fr);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* LayoutContainer size utilities
|
|
70
|
+
*
|
|
71
|
+
* Used by the <LayoutContainer> component. Each class constrains the element
|
|
72
|
+
* width and centres it within its parent.
|
|
73
|
+
*/
|
|
74
|
+
.layout-container-content {
|
|
75
|
+
max-width: 65ch;
|
|
76
|
+
width: 100%;
|
|
77
|
+
margin-inline: auto;
|
|
78
|
+
box-sizing: border-box;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.layout-container-wide {
|
|
82
|
+
max-width: 90rem;
|
|
83
|
+
width: 100%;
|
|
84
|
+
margin-inline: auto;
|
|
85
|
+
box-sizing: border-box;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.layout-container-fluid {
|
|
89
|
+
max-width: 100%;
|
|
90
|
+
width: 100%;
|
|
91
|
+
box-sizing: border-box;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.layout-container-full {
|
|
95
|
+
/* Escape parent padding to reach the viewport edge */
|
|
96
|
+
width: 100vw;
|
|
97
|
+
max-width: 100vw;
|
|
98
|
+
margin-inline: calc(var(--grid-padding, clamp(1rem, 2.5vw, 2rem)) * -1);
|
|
99
|
+
box-sizing: border-box;
|
|
100
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LayoutContainer — constrained content width wrapper.
|
|
4
|
+
*
|
|
5
|
+
* Provides four standard container widths that sit within the grid:
|
|
6
|
+
*
|
|
7
|
+
* | size | max-width | use case |
|
|
8
|
+
* |---------|-----------|----------------------------------|
|
|
9
|
+
* | content | 65ch | Long-form prose, articles |
|
|
10
|
+
* | wide | 90rem | Cards, media-rich sections |
|
|
11
|
+
* | fluid | 100% | Full bleed within grid padding |
|
|
12
|
+
* | full | 100vw | True full-bleed (escapes padding)|
|
|
13
|
+
*
|
|
14
|
+
* The container is centred with `margin-inline: auto` for `content` and `wide`.
|
|
15
|
+
*
|
|
16
|
+
* @prop {GridContainerSize} size — Container width variant (default: 'wide')
|
|
17
|
+
* @prop {string} tag — HTML element to render (default: 'div')
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* <LayoutContainer size="content">
|
|
21
|
+
* <p>Readable prose constrained to ~65 characters wide.</p>
|
|
22
|
+
* </LayoutContainer>
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { GridContainerSize } from '#layers/layout/app/types/layouts'
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
size?: GridContainerSize
|
|
29
|
+
tag?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { size = 'wide', tag = 'div' } = defineProps<Props>()
|
|
33
|
+
|
|
34
|
+
const sizeClass: Record<GridContainerSize, string> = {
|
|
35
|
+
content: 'layout-container-content',
|
|
36
|
+
wide: 'layout-container-wide',
|
|
37
|
+
fluid: 'layout-container-fluid',
|
|
38
|
+
full: 'layout-container-full',
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<component
|
|
44
|
+
:is="tag"
|
|
45
|
+
:class="sizeClass[size]"
|
|
46
|
+
>
|
|
47
|
+
<slot />
|
|
48
|
+
</component>
|
|
49
|
+
</template>
|
|
@@ -34,22 +34,35 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Track column count in JS so v-for renders the correct number of divs.
|
|
38
|
+
// Mirrors the same breakpoints as mastmain (48rem = 768px, 80rem = 1280px).
|
|
39
|
+
const cols = ref(6)
|
|
40
|
+
|
|
41
|
+
const updateCols = () => {
|
|
42
|
+
if (window.matchMedia('(min-width: 80rem)').matches) cols.value = 18
|
|
43
|
+
else if (window.matchMedia('(min-width: 48rem)').matches) cols.value = 12
|
|
44
|
+
else cols.value = 6
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
onMounted(() => {
|
|
48
|
+
updateCols()
|
|
49
|
+
window.addEventListener('resize', updateCols)
|
|
38
50
|
window.addEventListener('keydown', handleKeydown)
|
|
39
51
|
})
|
|
40
52
|
|
|
41
53
|
onUnmounted(() => {
|
|
54
|
+
window.removeEventListener('resize', updateCols)
|
|
42
55
|
window.removeEventListener('keydown', handleKeydown)
|
|
43
56
|
})
|
|
44
57
|
|
|
45
|
-
//
|
|
58
|
+
// Do NOT set --grid-cols here as an inline style — that would override the
|
|
59
|
+
// CSS media queries in <style scoped> below. Let CSS own the variable.
|
|
46
60
|
const style = computed(() => ({
|
|
47
61
|
display: 'grid',
|
|
48
|
-
gridTemplateColumns:
|
|
62
|
+
gridTemplateColumns: `repeat(${cols.value}, 1fr)`,
|
|
49
63
|
gap,
|
|
50
|
-
paddingInline: '
|
|
64
|
+
paddingInline: 'clamp(1rem, 2.5vw, 2rem)',
|
|
51
65
|
pointerEvents: 'none' as const,
|
|
52
|
-
'--grid-cols': '6',
|
|
53
66
|
}))
|
|
54
67
|
|
|
55
68
|
defineExpose({ toggle })
|
|
@@ -58,22 +71,8 @@ defineExpose({ toggle })
|
|
|
58
71
|
<template>
|
|
59
72
|
<Teleport to="body">
|
|
60
73
|
<div v-if="visible" :style class="grid-debug z-9999 fixed inset-0" aria-hidden="true">
|
|
61
|
-
<div v-for="i in
|
|
74
|
+
<div v-for="i in cols" :key="i" :style="{ backgroundColor: color }" class="h-full" />
|
|
62
75
|
</div>
|
|
63
76
|
</Teleport>
|
|
64
77
|
</template>
|
|
65
78
|
|
|
66
|
-
<style scoped>
|
|
67
|
-
/* Match responsive breakpoints from mastmain */
|
|
68
|
-
@media (width >= 48rem) {
|
|
69
|
-
.grid-debug {
|
|
70
|
-
--grid-cols: 12;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
@media (width >= 80rem) {
|
|
75
|
-
.grid-debug {
|
|
76
|
-
--grid-cols: 18;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LayoutMain — grid root container applying the `mastmain` utility.
|
|
4
|
+
*
|
|
5
|
+
* Use this component when you need an explicit wrapper element that owns the
|
|
6
|
+
* Swiss Grid stacking context. The default layout can use it in place of a
|
|
7
|
+
* raw `<main class="mastmain">`.
|
|
8
|
+
*
|
|
9
|
+
* When `mode` is `'disabled'`, falls back to a plain semantic `<main>` so the
|
|
10
|
+
* page renders correctly without grid dependencies.
|
|
11
|
+
*
|
|
12
|
+
* @prop {string} tag — HTML element to render (default: 'main')
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <LayoutMain>
|
|
16
|
+
* <LayoutSection>…</LayoutSection>
|
|
17
|
+
* </LayoutMain>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
tag?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { tag = 'main' } = defineProps<Props>()
|
|
25
|
+
|
|
26
|
+
const { mode } = useGridConfig()
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<component
|
|
31
|
+
:is="tag"
|
|
32
|
+
:class="mode !== 'disabled' ? 'mastmain' : undefined"
|
|
33
|
+
>
|
|
34
|
+
<slot />
|
|
35
|
+
</component>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LayoutPage — canonical page wrapper for the Swiss Grid System.
|
|
4
|
+
*
|
|
5
|
+
* This is a fragment component (no wrapper element). The grid root is already
|
|
6
|
+
* provided by MastMain at the layout level via <div class="mastmain">.
|
|
7
|
+
* Adding another mastmain wrapper here would nest grids and misalign everything.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - SEO via useHead()
|
|
11
|
+
* - provides 'pageTitle' to child components
|
|
12
|
+
* - optional visible header (LayoutSection + LayoutPageHeader)
|
|
13
|
+
*
|
|
14
|
+
* LayoutGridDebug is owned by the default layout — do NOT add it here.
|
|
15
|
+
*
|
|
16
|
+
* @prop {string} title — Page title: sets <title> and optional visible heading
|
|
17
|
+
* @prop {string} description — Optional meta description for SEO
|
|
18
|
+
* @prop {boolean} showHeader — Render a LayoutPageHeader block (default: false)
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* <LayoutPage title="Home">
|
|
22
|
+
* <LayoutSectionHero>
|
|
23
|
+
* <h1>Welcome</h1>
|
|
24
|
+
* </LayoutSectionHero>
|
|
25
|
+
* </LayoutPage>
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <LayoutPage title="About" description="Learn more." :show-header="true">
|
|
29
|
+
* <LayoutSection>
|
|
30
|
+
* <LayoutGridItem preset="centered">
|
|
31
|
+
* <p>Content here.</p>
|
|
32
|
+
* </LayoutGridItem>
|
|
33
|
+
* </LayoutSection>
|
|
34
|
+
* </LayoutPage>
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
title: string
|
|
39
|
+
description?: string
|
|
40
|
+
showHeader?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { title, description, showHeader = false } = defineProps<Props>()
|
|
44
|
+
|
|
45
|
+
useHead({
|
|
46
|
+
title,
|
|
47
|
+
meta: description
|
|
48
|
+
? [
|
|
49
|
+
{ name: 'description', content: description },
|
|
50
|
+
{ property: 'og:title', content: title },
|
|
51
|
+
{ property: 'og:description', content: description },
|
|
52
|
+
]
|
|
53
|
+
: undefined,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
provide('pageTitle', title)
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<!-- Optional visible page header — rendered as a grid section -->
|
|
61
|
+
<LayoutSection v-if="showHeader">
|
|
62
|
+
<LayoutGridItem preset="centered">
|
|
63
|
+
<LayoutPageHeader :title :description />
|
|
64
|
+
</LayoutGridItem>
|
|
65
|
+
</LayoutSection>
|
|
66
|
+
|
|
67
|
+
<!-- Page content — direct children of mastmain (via MastMain in the layout) -->
|
|
68
|
+
<slot />
|
|
69
|
+
</template>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GridConfig, GridPresetsItem } from '#layers/layout/app/types/layouts'
|
|
1
|
+
import type { GridConfig, GridLayers, GridMode, GridPresetsItem } from '#layers/layout/app/types/layouts'
|
|
2
2
|
|
|
3
3
|
interface LayoutLayerConfig {
|
|
4
4
|
layoutLayer?: {
|
|
@@ -13,15 +13,42 @@ export function useGridConfig() {
|
|
|
13
13
|
const appConfig = useAppConfig() as LayoutLayerConfig
|
|
14
14
|
const gridConfig = computed(() => appConfig.layoutLayer?.ui?.grid)
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Resolved layout mode. Derives from `mode` field first, then falls back to
|
|
18
|
+
* the legacy `enabled` boolean for backwards compatibility.
|
|
19
|
+
*/
|
|
20
|
+
const mode = computed<GridMode>(() => {
|
|
21
|
+
const cfg = gridConfig.value
|
|
22
|
+
if (!cfg) return 'swiss'
|
|
23
|
+
if (cfg.mode) return cfg.mode
|
|
24
|
+
return cfg.enabled === false ? 'disabled' : 'swiss'
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** True when the Swiss Grid system is active. */
|
|
28
|
+
const isEnabled = computed(() => mode.value !== 'disabled')
|
|
29
|
+
|
|
16
30
|
const getPreset = (name: string): GridPresetsItem | undefined => {
|
|
17
31
|
const presets = gridConfig.value?.presets
|
|
18
32
|
if (!presets) return undefined
|
|
19
33
|
return presets[name as keyof typeof presets]
|
|
20
34
|
}
|
|
21
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Returns the z-index value for a named stacking layer.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* const zModal = useZIndex('modal') // → 400
|
|
41
|
+
*/
|
|
42
|
+
const useZIndex = (layer: keyof GridLayers): number => {
|
|
43
|
+
return gridConfig.value?.layers?.[layer] ?? 0
|
|
44
|
+
}
|
|
45
|
+
|
|
22
46
|
return {
|
|
23
47
|
config: gridConfig,
|
|
24
48
|
getPreset,
|
|
25
49
|
layers: computed(() => gridConfig.value?.layers),
|
|
50
|
+
isEnabled,
|
|
51
|
+
mode,
|
|
52
|
+
useZIndex,
|
|
26
53
|
}
|
|
27
54
|
}
|
|
@@ -7,17 +7,41 @@ export interface ResponsiveValue<T> {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export interface GridLayers {
|
|
10
|
+
/** Component/content at rest — z-index 0 */
|
|
10
11
|
back: number
|
|
12
|
+
/** Standard interactive content — z-index 10 */
|
|
11
13
|
mid: number
|
|
14
|
+
/** Elevated content, sticky elements — z-index 20 */
|
|
12
15
|
front: number
|
|
16
|
+
/** Page-level overlays — z-index 30 */
|
|
13
17
|
top: number
|
|
18
|
+
/** Site header / navigation bar — z-index 100 */
|
|
19
|
+
header: number
|
|
20
|
+
/** Dropdown menus, popovers — z-index 200 */
|
|
21
|
+
dropdown: number
|
|
22
|
+
/** Overlay backdrops — z-index 300 */
|
|
23
|
+
overlay: number
|
|
24
|
+
/** Modal dialogs — z-index 400 */
|
|
25
|
+
modal: number
|
|
26
|
+
/** Toast notifications — z-index 500 */
|
|
27
|
+
toast: number
|
|
14
28
|
}
|
|
15
29
|
|
|
30
|
+
export type GridContainerSize = 'content' | 'wide' | 'fluid' | 'full'
|
|
31
|
+
export type GridDensity = 'compact' | 'normal' | 'relaxed'
|
|
32
|
+
export type GridMode = 'swiss' | 'fluid' | 'disabled'
|
|
33
|
+
|
|
16
34
|
export interface GridPresetsItem {
|
|
17
35
|
colStart: number | ResponsiveValue<number>
|
|
18
36
|
colSpan: number | ResponsiveValue<number>
|
|
19
37
|
rowStart?: number | ResponsiveValue<number>
|
|
20
38
|
rowSpan?: number
|
|
39
|
+
/** Container size applied to the item's content */
|
|
40
|
+
container?: GridContainerSize
|
|
41
|
+
/** Gap override for this preset */
|
|
42
|
+
gap?: string
|
|
43
|
+
/** Vertical rhythm density for this preset */
|
|
44
|
+
density?: GridDensity
|
|
21
45
|
}
|
|
22
46
|
|
|
23
47
|
export interface GridPresets {
|
|
@@ -29,6 +53,17 @@ export interface GridPresets {
|
|
|
29
53
|
}
|
|
30
54
|
|
|
31
55
|
export interface GridConfig {
|
|
56
|
+
/**
|
|
57
|
+
* Layout mode.
|
|
58
|
+
* - `'swiss'` — Swiss Grid System (default)
|
|
59
|
+
* - `'fluid'` — Container-query based auto-fit grid
|
|
60
|
+
* - `'disabled'` — Falls back to standard Nuxt UI layout
|
|
61
|
+
*
|
|
62
|
+
* Setting `enabled: false` is equivalent to `mode: 'disabled'` (backwards compat).
|
|
63
|
+
*/
|
|
64
|
+
mode?: GridMode
|
|
65
|
+
/** @deprecated Use `mode: 'disabled'` instead. Kept for backwards compatibility. */
|
|
66
|
+
enabled?: boolean
|
|
32
67
|
columns: ResponsiveValue<number>
|
|
33
68
|
rowsPerSection: number
|
|
34
69
|
rhythm: string
|
|
@@ -1,8 +1,66 @@
|
|
|
1
1
|
export default defineAppConfig({
|
|
2
|
+
/**
|
|
3
|
+
* Nuxt UI component theming — aligned to the Swiss Grid System.
|
|
4
|
+
*
|
|
5
|
+
* UHeader: removes the default max-width container so it spans the full
|
|
6
|
+
* viewport width, using clamp-based gutters that match the grid padding.
|
|
7
|
+
*
|
|
8
|
+
* UPage / UPage* components: participate as subgrid members so their
|
|
9
|
+
* columns align to the inherited mastmain grid lines.
|
|
10
|
+
*
|
|
11
|
+
* These overrides are additive and safe when the swiss grid is disabled —
|
|
12
|
+
* col-span-full / grid-cols-subgrid have no effect outside a grid context.
|
|
13
|
+
* The consuming app can override any of these via its own app.config.ts.
|
|
14
|
+
*/
|
|
15
|
+
ui: {
|
|
16
|
+
header: {
|
|
17
|
+
container: 'max-w-full px-[clamp(1rem,2.5vw,2rem)]',
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
// UPage: transparent subgrid participant; left/center/right slots map
|
|
21
|
+
// to named column ranges on the 18-column grid (sidebar 4, content 10,
|
|
22
|
+
// right sidebar 4; all col-start values are explicit to avoid overlap)
|
|
23
|
+
page: {
|
|
24
|
+
root: 'col-span-full grid grid-cols-subgrid',
|
|
25
|
+
left: 'col-span-4',
|
|
26
|
+
center: 'col-start-5 col-span-10',
|
|
27
|
+
right: 'col-start-15 col-span-4',
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// UPageBody: no opinionated padding — the grid and sections own spacing
|
|
31
|
+
pageBody: {
|
|
32
|
+
base: '',
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// UPageGrid: inherits subgrid column lines; gap matches grid gap clamp
|
|
36
|
+
pageGrid: {
|
|
37
|
+
base: 'col-span-full grid grid-cols-subgrid gap-[clamp(0.75rem,1.5vw,1.5rem)]',
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// UPageColumns: inherits subgrid column lines
|
|
41
|
+
pageColumns: {
|
|
42
|
+
base: 'col-span-full grid grid-cols-subgrid',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
2
46
|
layoutLayer: {
|
|
3
47
|
ui: {
|
|
4
48
|
// Swiss Grid System Configuration
|
|
5
49
|
grid: {
|
|
50
|
+
/**
|
|
51
|
+
* Layout mode.
|
|
52
|
+
* - 'swiss' — Swiss Grid System (default)
|
|
53
|
+
* - 'fluid' — Container-query based auto-fit grid
|
|
54
|
+
* - 'disabled' — Falls back to standard UMain > UPage layout
|
|
55
|
+
*/
|
|
56
|
+
mode: 'swiss',
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @deprecated Use mode: 'disabled' instead. Kept for backwards compatibility.
|
|
60
|
+
* When false, acts as mode: 'disabled'.
|
|
61
|
+
*/
|
|
62
|
+
enabled: true,
|
|
63
|
+
|
|
6
64
|
// Core settings
|
|
7
65
|
columns: { default: 6, md: 12, lg: 18 },
|
|
8
66
|
rowsPerSection: 12,
|
|
@@ -10,10 +68,17 @@ export default defineAppConfig({
|
|
|
10
68
|
|
|
11
69
|
// Z-index layers
|
|
12
70
|
layers: {
|
|
71
|
+
// Base layers (swiss grid stacking)
|
|
13
72
|
back: 0,
|
|
14
73
|
mid: 10,
|
|
15
74
|
front: 20,
|
|
16
75
|
top: 30,
|
|
76
|
+
// UI stacking layers
|
|
77
|
+
header: 100,
|
|
78
|
+
dropdown: 200,
|
|
79
|
+
overlay: 300,
|
|
80
|
+
modal: 400,
|
|
81
|
+
toast: 500,
|
|
17
82
|
},
|
|
18
83
|
|
|
19
84
|
// Preset layouts for common patterns
|
|
@@ -10,7 +10,6 @@ export interface ScrollState {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export default defineNuxtPlugin(() => {
|
|
13
|
-
// Reactive scroll state that will be updated via scrollCallback
|
|
14
13
|
const scrollState = reactive<ScrollState>({
|
|
15
14
|
scroll: 0,
|
|
16
15
|
limit: 0,
|
|
@@ -19,30 +18,51 @@ export default defineNuxtPlugin(() => {
|
|
|
19
18
|
progress: 0,
|
|
20
19
|
})
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
21
|
+
let instance: LocomotiveScroll | null = null
|
|
22
|
+
|
|
23
|
+
function init() {
|
|
24
|
+
if (instance) return
|
|
25
|
+
instance = new LocomotiveScroll({
|
|
26
|
+
lenisOptions: {
|
|
27
|
+
lerp: 0.1,
|
|
28
|
+
smoothWheel: true,
|
|
29
|
+
wheelMultiplier: 1,
|
|
30
|
+
},
|
|
31
|
+
scrollCallback: ({ scroll, limit, velocity, direction, progress }) => {
|
|
32
|
+
scrollState.scroll = scroll
|
|
33
|
+
scrollState.limit = limit
|
|
34
|
+
scrollState.velocity = velocity
|
|
35
|
+
scrollState.direction = direction
|
|
36
|
+
scrollState.progress = progress
|
|
37
|
+
ScrollTrigger.update()
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function destroy() {
|
|
43
|
+
instance?.destroy()
|
|
44
|
+
instance = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const router = useRouter()
|
|
48
|
+
|
|
49
|
+
// Only active on the locomotive-scroll route
|
|
50
|
+
addRouteMiddleware((to, from) => {
|
|
51
|
+
if (to.path === '/locomotive-scroll') {
|
|
52
|
+
nextTick(init)
|
|
53
|
+
} else if (from?.path === '/locomotive-scroll') {
|
|
54
|
+
destroy()
|
|
55
|
+
}
|
|
41
56
|
})
|
|
42
57
|
|
|
58
|
+
// Activate immediately if already on that route (e.g. hard refresh)
|
|
59
|
+
if (router.currentRoute.value.path === '/locomotive-scroll') {
|
|
60
|
+
init()
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
return {
|
|
44
64
|
provide: {
|
|
45
|
-
locomotiveScroll,
|
|
65
|
+
locomotiveScroll: readonly(instance),
|
|
46
66
|
scrollState,
|
|
47
67
|
},
|
|
48
68
|
}
|