kmcom-nuxt-layers 1.1.8 → 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 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 = 'hidden'
100
- body.style.overflowX = 'hidden'
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 utility)
9
- * └── BaseSection (basesection utility, uses subgrid)
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
- @utility mastmain {
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
- @utility basesection {
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
- @utility leading-rhythm-4 {
103
+ .leading-rhythm-4 {
104
104
  --rhythm: 0.25rem;
105
105
  line-height: calc(var(--rhythm) * 4); /* 1rem */
106
106
  }
107
107
 
108
- @utility leading-rhythm-5 {
108
+ .leading-rhythm-5 {
109
109
  --rhythm: 0.25rem;
110
110
  line-height: calc(var(--rhythm) * 5); /* 1.25rem */
111
111
  }
112
112
 
113
- @utility leading-rhythm-6 {
113
+ .leading-rhythm-6 {
114
114
  --rhythm: 0.25rem;
115
115
  line-height: calc(var(--rhythm) * 6); /* 1.5rem */
116
116
  }
117
117
 
118
- @utility leading-rhythm-7 {
118
+ .leading-rhythm-7 {
119
119
  --rhythm: 0.25rem;
120
120
  line-height: calc(var(--rhythm) * 7); /* 1.75rem */
121
121
  }
122
122
 
123
- @utility leading-rhythm-8 {
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
- @utility space-rhythm-1 {
129
+ .space-rhythm-1 {
130
130
  --rhythm: 0.25rem;
131
131
  margin-block: var(--rhythm); /* 0.25rem */
132
132
  }
133
133
 
134
- @utility space-rhythm-2 {
134
+ .space-rhythm-2 {
135
135
  --rhythm: 0.25rem;
136
136
  margin-block: calc(var(--rhythm) * 2); /* 0.5rem */
137
137
  }
138
138
 
139
- @utility space-rhythm-4 {
139
+ .space-rhythm-4 {
140
140
  --rhythm: 0.25rem;
141
141
  margin-block: calc(var(--rhythm) * 4); /* 1rem */
142
142
  }
143
143
 
144
- @utility space-rhythm-6 {
144
+ .space-rhythm-6 {
145
145
  --rhythm: 0.25rem;
146
146
  margin-block: calc(var(--rhythm) * 6); /* 1.5rem */
147
147
  }
148
148
 
149
- @utility space-rhythm-8 {
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
- @utility prose-rhythm {
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
+ }
@@ -1 +1,2 @@
1
1
  @import '#layers/layout/app/assets/css/layout/grids.css';
2
+ @import '#layers/layout/app/assets/css/layout/modes/fluid.css';
@@ -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
- // Use CSS variables to match the responsive grid
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: 'repeat(var(--grid-cols, 6), 1fr)',
62
+ gridTemplateColumns: `repeat(${cols.value}, 1fr)`,
49
63
  gap,
50
- paddingInline: 'var(--grid-padding, clamp(1rem, 2.5vw, 2rem))',
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 18" :key="i" :style="{ backgroundColor: color }" class="h-full" />
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
- // Create Locomotive Scroll instance
23
- const locomotiveScroll = new LocomotiveScroll({
24
- // Lenis options passthrough (LS5 is built on Lenis)
25
- lenisOptions: {
26
- lerp: 0.1,
27
- smoothWheel: true,
28
- wheelMultiplier: 1,
29
- },
30
- // Scroll callback for reactive state updates and GSAP sync
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
-
38
- // Sync ScrollTrigger with Locomotive Scroll
39
- ScrollTrigger.update()
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kmcom-nuxt-layers",
3
3
  "private": false,
4
- "version": "1.1.8",
4
+ "version": "1.2.0",
5
5
  "description": "Composable Nuxt 4 layers for building scalable Vue applications",
6
6
  "files": [
7
7
  "layers/*/nuxt.config.ts",
@@ -35,7 +35,7 @@
35
35
  "better-sqlite3": "^12.0.0",
36
36
  "gsap": "^3.12.0",
37
37
  "locomotive-scroll": "^5.0.0",
38
- "nuxt": "^4.0.0",
38
+ "nuxt": "^4.3.1",
39
39
  "nuxt-studio": "^1.0.0",
40
40
  "pinia": "^3.0.0",
41
41
  "tailwindcss": "^4.2.1",