kmcom-nuxt-layers 6.4.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/docs/MIGRATION-v6.5.0.md +87 -0
  2. package/docs/MIGRATION.md +2 -1
  3. package/docs/USAGE.md +35 -10
  4. package/layers/content/app/components/content/Figure.vue +1 -1
  5. package/layers/content/app/composables/useContentSection.ts +1 -1
  6. package/layers/core/app/app.config.ts +7 -3
  7. package/layers/core/app/components/LoadingScreen.vue +35 -21
  8. package/layers/core/app/composables/useCache.ts +2 -2
  9. package/layers/core/app/composables/useErrorLog.ts +5 -5
  10. package/layers/core/app/composables/useLoading.ts +19 -10
  11. package/layers/core/app/plugins/loading.client.ts +11 -3
  12. package/layers/core/app/types/loading.ts +10 -3
  13. package/layers/core/app/utils/loading.ts +6 -15
  14. package/layers/core/nuxt.config.ts +14 -14
  15. package/layers/core/server/utils/app-config.ts +17 -0
  16. package/layers/data/auth/app/middleware/auth.ts +1 -1
  17. package/layers/data/forms/app/components/Form/Contact.vue +1 -1
  18. package/layers/data/forms/server/api/contact.post.ts +16 -8
  19. package/layers/data/forms/server/api/forms/status.get.ts +3 -0
  20. package/layers/data/forms/{app → shared}/utils/contact-schema.ts +2 -1
  21. package/layers/data/metadata/server/api/metadata/search.get.ts +27 -7
  22. package/layers/data/metadata/server/api/metadata/status.get.ts +4 -1
  23. package/layers/data/metadata/server/api/metadata/sync.post.ts +13 -3
  24. package/layers/data/metadata/server/utils/metadata/resolve-record.ts +3 -3
  25. package/layers/delivery/feeds/nuxt.config.ts +6 -1
  26. package/layers/delivery/feeds/package.json +2 -2
  27. package/layers/delivery/feeds/server/routes/feed/demo.get.ts +2 -2
  28. package/layers/delivery/feeds/server/utils/content-adapter.ts +1 -1
  29. package/layers/delivery/feeds/server/utils/feed-service.ts +2 -2
  30. package/layers/design-system/theming/app/plugins/theme.client.ts +7 -0
  31. package/layers/design-system/theming/nuxt.config.ts +0 -2
  32. package/layers/motion/scroll/app/plugins/locomotive-scroll.client.ts +3 -2
  33. package/layers/render/canvas/nuxt.config.ts +0 -4
  34. package/layers/render/shader/nuxt.config.ts +0 -4
  35. package/layers/structure/routing/app/middleware/01.maintenance.global.ts +4 -1
  36. package/layers/structure/routing/app/middleware/02.governance.global.ts +1 -1
  37. package/layers/structure/routing/nuxt.config.ts +19 -12
  38. package/layers/structure/routing/server/api/feature-flags.get.ts +4 -2
  39. package/package.json +35 -5
  40. package/docs/FALLOW-COMPLEXITY-DUPLICATION-AUDIT.md +0 -64
  41. package/docs/IMPROVE-AUDIT-README.md +0 -29
  42. package/docs/IMPROVE-AUDIT-RESULTS.md +0 -51
  43. package/docs/IMPROVE-DEEP-AUDIT-RESULTS.md +0 -81
  44. package/docs/fallow-refactor/apps-debug.md +0 -27
  45. package/docs/fallow-refactor/apps-playground.md +0 -46
  46. package/docs/fallow-refactor/apps-visual-identity.md +0 -41
  47. package/docs/fallow-refactor/layers-animations.md +0 -34
  48. package/docs/fallow-refactor/layers-canvas.md +0 -32
  49. package/docs/fallow-refactor/layers-content.md +0 -33
  50. package/docs/fallow-refactor/layers-core.md +0 -39
  51. package/docs/fallow-refactor/layers-feeds.md +0 -39
  52. package/docs/fallow-refactor/layers-forms.md +0 -30
  53. package/docs/fallow-refactor/layers-layout.md +0 -42
  54. package/docs/fallow-refactor/layers-mailer.md +0 -32
  55. package/docs/fallow-refactor/layers-motion.md +0 -27
  56. package/docs/fallow-refactor/layers-navigation.md +0 -31
  57. package/docs/fallow-refactor/layers-page-transitions.md +0 -30
  58. package/docs/fallow-refactor/layers-routing.md +0 -33
  59. package/docs/fallow-refactor/layers-scripts.md +0 -35
  60. package/docs/fallow-refactor/layers-scroll.md +0 -38
  61. package/docs/fallow-refactor/layers-seo.md +0 -32
  62. package/docs/fallow-refactor/layers-shader.md +0 -53
  63. package/docs/fallow-refactor/layers-theme.md +0 -33
  64. package/docs/fallow-refactor/layers-transitions.md +0 -27
  65. package/docs/fallow-refactor/layers-typography.md +0 -29
  66. package/docs/fallow-refactor/layers-visual.md +0 -34
  67. package/docs/superpowers/plans/2026-09-03-layout-layer-fluid-rebuild.md +0 -1850
  68. package/docs/superpowers/plans/2026-09-03-vue-element-system.md +0 -1019
  69. package/docs/superpowers/plans/2026-09-04-grid-layer.md +0 -977
  70. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-0-1-design.md +0 -533
  71. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-2-design.md +0 -154
  72. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-3-design.md +0 -109
  73. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-4-design.md +0 -124
  74. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-5-design.md +0 -121
  75. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-6-design.md +0 -77
  76. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-7-design.md +0 -73
  77. package/docs/superpowers/specs/2026-08-31-swiss-grid-layer-design.md +0 -190
  78. package/docs/superpowers/specs/2026-09-03-layout-layer-fluid-rebuild-design.md +0 -312
  79. package/docs/superpowers/specs/2026-09-03-vue-element-system-design.md +0 -342
  80. package/docs/superpowers/specs/2026-09-04-grid-layer-design.md +0 -236
  81. package/layers/content/app/utils/galleryImageMeta.test.ts +0 -30
  82. package/layers/core/app/components/Element.test.ts +0 -90
  83. package/layers/core/app/components/ElementTw.test.ts +0 -64
  84. package/layers/core/app/composables/useLayoutAttrs.test.ts +0 -77
  85. package/layers/core/app/utils/browserInfo.test.ts +0 -81
  86. package/layers/core/app/utils/featureClasses.test.ts +0 -101
  87. package/layers/core/app/utils/loading.test.ts +0 -48
  88. package/layers/core/app/utils/resolveElementStyle.test.ts +0 -99
  89. package/layers/core/app/utils/scrollGuardClamp.test.ts +0 -36
  90. package/layers/core/app/utils/styleToTailwindClasses.test.ts +0 -31
  91. package/layers/data/forms/app/utils/contact-schema.test.ts +0 -75
  92. package/layers/data/forms/app/utils/fieldProps.test.ts +0 -141
  93. package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +0 -201
  94. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +0 -76
  95. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +0 -144
  96. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +0 -25
  97. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +0 -144
  98. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +0 -29
  99. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +0 -162
  100. package/layers/data/metadata/server/utils/metadata/cache.test.ts +0 -39
  101. package/layers/data/metadata/server/utils/metadata/provider-fetch.test.ts +0 -27
  102. package/layers/data/metadata/server/utils/metadata/provider-registry.test.ts +0 -54
  103. package/layers/data/metadata/server/utils/metadata/resolve-record.test.ts +0 -87
  104. package/layers/data/metadata/server/utils/metadata/search.test.ts +0 -204
  105. package/layers/delivery/feeds/server/utils/content-adapter.test.ts +0 -67
  106. package/layers/delivery/feeds/server/utils/feed-author.test.ts +0 -71
  107. package/layers/delivery/feeds/server/utils/feed-config.test.ts +0 -184
  108. package/layers/delivery/feeds/server/utils/formats.test.ts +0 -71
  109. package/layers/delivery/seo/app/utils/seoConfig.test.ts +0 -58
  110. package/layers/design-system/theming/server/utils/accent-css.test.ts +0 -71
  111. package/layers/design-system/theming/server/utils/fouc-config.test.ts +0 -78
  112. package/layers/design-system/typography/app/components/Typography/Headline.test.ts +0 -36
  113. package/layers/design-system/typography/app/components/Typography/index.test.ts +0 -31
  114. package/layers/design-system/typography/app/composables/color.test.ts +0 -30
  115. package/layers/design-system/typography/app/composables/typography.test.ts +0 -53
  116. package/layers/design-system/visual/app/components/HoverDimGroup.test.ts +0 -38
  117. package/layers/design-system/visual/app/composables/tint.test.ts +0 -22
  118. package/layers/design-system/visual/app/utils/colorTokens.test.ts +0 -51
  119. package/layers/design-system/visual/app/utils/gradientStyle.test.ts +0 -75
  120. package/layers/design-system/visual/app/utils/responsiveSizes.test.ts +0 -71
  121. package/layers/motion/animations/app/components/Motion/Staggered.test.ts +0 -40
  122. package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +0 -23
  123. package/layers/motion/scroll/app/utils/parallax.test.ts +0 -24
  124. package/layers/structure/grid/app/composables/useGridTracks.test.ts +0 -94
  125. package/layers/structure/grid/app/types/tracks.test.ts +0 -32
  126. package/layers/structure/grid/app/utils/placement.test.ts +0 -19
  127. package/layers/structure/layout/app/utils/gridPlacementStyle.test.ts +0 -269
  128. package/layers/structure/navigation/app/utils/site.test.ts +0 -39
  129. package/layers/structure/routing/app/utils/resolveRoute.test.ts +0 -47
@@ -0,0 +1,87 @@
1
+ # Migration Guide — v6.4.x → v6.5.0
2
+
3
+ **Audience:** an AI coding agent (or developer) updating a downstream Nuxt app that consumes these
4
+ layers.
5
+ **What changed:** layers stop making app-wide choices for you, the published package declares the
6
+ dependencies its layers import, and two diagnostic API routes are now dev-only.
7
+
8
+ Work top to bottom. Each change has a **Detect** step and an **Apply** step; skip anything Detect
9
+ doesn't find.
10
+
11
+ ## 0. Preconditions
12
+
13
+ - Update the package to `^6.5.0`, reinstall, run `npx nuxt prepare`.
14
+
15
+ ---
16
+
17
+ ## Required changes
18
+
19
+ ### 1. `core` no longer sets `future.compatibilityVersion: 5` or `devtools`
20
+
21
+ Layer config merges into the app, so `core` was opting every consumer into Nuxt 5 behaviour and
22
+ forcing devtools on. Both are now the app's call.
23
+
24
+ **Detect:** your app's `nuxt.config.ts` has no `future.compatibilityVersion`.
25
+
26
+ **Apply:** to keep the behaviour you had on v6.4, add:
27
+
28
+ ```ts
29
+ export default defineNuxtConfig({
30
+ future: { compatibilityVersion: 5 },
31
+ devtools: { enabled: true },
32
+ })
33
+ ```
34
+
35
+ ### 2. Install the peers for the layers you use
36
+
37
+ The root package now lists these as optional peer dependencies. They were always imported at
38
+ runtime; previously the app only worked if something else happened to install them.
39
+
40
+ **Detect:** you extend `mailer`/`forms`, `database`, `auth` or `feeds`.
41
+
42
+ **Apply:**
43
+
44
+ | Layer | Install |
45
+ | ------------------ | ------------------------------------------ |
46
+ | `mailer` / `forms` | `resend hookable` (plus `zod` for `forms`) |
47
+ | `database` | `drizzle-orm @neondatabase/serverless` |
48
+ | `auth` | `nuxt-auth-utils` |
49
+ | `feeds` | `feed` |
50
+
51
+ ---
52
+
53
+ ## Behaviour changes (no action unless you rely on them)
54
+
55
+ - **`GET /api/forms/status`** returns only `{ configured }` outside dev. `emailFrom`/`emailTo` are
56
+ dev-only because `emailTo` is the private inbox every contact submission goes to.
57
+ - **`GET /api/metadata/status`** returns 404 outside dev. Each call ran a live search on every
58
+ provider, spending real API quota.
59
+ - **`GET /api/metadata/search`** rejects an unknown `mediaType` or a non-positive `limit` with a
60
+ 400, and caps `limit` at 50.
61
+ - **`feeds`** now always extends `core` and `content`. The `FEEDS_STANDALONE` env var is gone.
62
+ - **`routing`**: `layerDefaultDeny` now works in npm consumers. Layer pages were never tagged
63
+ before, so a layer page without a `feature` meta will now be denied if you enabled
64
+ `layerDefaultDeny`.
65
+ - **`auth`** middleware redirects to `/auth/github` with `external: true`, so client-side
66
+ navigation reaches the OAuth route instead of the 404 page.
67
+ - **`forms`**: `contactSchema` moved from `app/utils/` to `shared/utils/`. Import it from
68
+ `#layers/forms/shared/utils/contact-schema` if you imported it by path.
69
+ - **`@nuxt/eslint`** loads only when installed, so apps without it no longer fail outside
70
+ production.
71
+ - **`feeds`** and **`routing`** server routes now read your `app.config.ts` when Nitro
72
+ auto-imports are on. They imported `useAppConfig` from `nitropack/runtime`, which never sees
73
+ it, so feeds were titled "My Site", linked to the request origin (localhost when prerendered)
74
+ and ignored `feedsLayer`, and `/api/feature-flags` returned `{}`. They now use `site`,
75
+ `feedsLayer` and `routingLayer.features`. With `experimental.nitroAutoImports: false` (the
76
+ Nuxt 5 default) Nuxt can't evaluate app.config.ts in Nitro, so they keep the old defaults;
77
+ set `nitroAutoImports: true` to get your values. Layer server code reads app config through
78
+ core's `readServerAppConfig()`.
79
+ - **`core` loading screen** stops being a timer. `minDuration` defaults to `0` (was `1500`) and
80
+ `maxDuration` to `1500` (was `3000`). A new `showAfter` (default `400` ms) keeps the screen
81
+ hidden on fast loads. The percentage moves at real milestones (hydrated, fonts ready, page
82
+ loaded) instead of a simulated trickle; `trickleProgress` is gone. Under
83
+ `prefers-reduced-motion` the screen is hidden and loading ends on mount. `useLoading()` adds
84
+ `finishNow()` and `skipped`. Set `minDuration: 1500` to keep the old pacing.
85
+ - **`theming`**: the Reduce Motion switch also ends the boot loading screen.
86
+ - **`forms`**: `contactSchema` accepts any non-blank name (was 3+ characters), so short names
87
+ like Li or Ng get through.
package/docs/MIGRATION.md CHANGED
@@ -1,4 +1,5 @@
1
- > **Upgrading?** v6.3 → v6.4: [`MIGRATION-v6.4.0.md`](./MIGRATION-v6.4.0.md) · v6.2 → v6.3:
1
+ > **Upgrading?** v6.4 → v6.5: [`MIGRATION-v6.5.0.md`](./MIGRATION-v6.5.0.md) · v6.3 → v6.4:
2
+ > [`MIGRATION-v6.4.0.md`](./MIGRATION-v6.4.0.md) · v6.2 → v6.3:
2
3
  > [`MIGRATION-v6.3.0.md`](./MIGRATION-v6.3.0.md). The guides below cover older versions.
3
4
 
4
5
  # Agent Migration Guide — Update an app to nuxt-layers v2.2.x
package/docs/USAGE.md CHANGED
@@ -93,12 +93,21 @@ npm install gsap locomotive-scroll
93
93
  # canvas / shader
94
94
  npm install three @tresjs/nuxt @tresjs/core @tresjs/cientos @tresjs/post-processing
95
95
 
96
- # forms
97
- npm install zod
96
+ # forms / mailer
97
+ npm install zod resend hookable
98
+
99
+ # database
100
+ npm install drizzle-orm @neondatabase/serverless
101
+
102
+ # auth
103
+ npm install nuxt-auth-utils
98
104
 
99
105
  # content
100
106
  npm install @nuxt/content better-sqlite3
101
107
 
108
+ # feeds
109
+ npm install feed
110
+
102
111
  # scripts (baseline-status badge)
103
112
  npm install @nuxt/scripts baseline-status
104
113
  ```
@@ -188,14 +197,30 @@ export default defineAppConfig({
188
197
 
189
198
  ### Available Config Namespaces
190
199
 
191
- | Namespace | Layer | Key Options |
192
- | ----------------------- | ------- | --------------------------------------------------------------------------- |
193
- | `coreLayer.loading` | core | `enabled`, `minDuration`, `maxDuration`, `label`, `background`, `textColor` |
194
- | `coreLayer.scrollGuard` | core | `enabled`, `strict`, `excludeSelectors`, `debug` |
195
- | `coreLayer.errors` | core | `logToConsole`, `logToExternal`, `externalUrl` |
196
- | `coreLayer.notFound` | core | `icon`, `title`, `description`, `showHomeButton`, `actions`, `suggestions` |
197
- | `motion` | motion | `gsapScrollTrigger`, `lenis.*` (duration, smoothWheel, smoothTouch, etc.) |
198
- | `themeLayer` | theming | `accents`, `defaultAccent` |
200
+ | Namespace | Layer | Key Options |
201
+ | ----------------------- | ------- | ---------------------------------------------------------------------------------------- |
202
+ | `coreLayer.loading` | core | `enabled`, `minDuration`, `maxDuration`, `showAfter`, `label`, `background`, `textColor` |
203
+ | `coreLayer.scrollGuard` | core | `enabled`, `strict`, `excludeSelectors`, `debug` |
204
+ | `coreLayer.errors` | core | `logToConsole`, `logToExternal`, `externalUrl` |
205
+ | `coreLayer.notFound` | core | `icon`, `title`, `description`, `showHomeButton`, `actions`, `suggestions` |
206
+ | `motion` | motion | `gsapScrollTrigger`, `lenis.*` (duration, smoothWheel, smoothTouch, etc.) |
207
+ | `themeLayer` | theming | `accents`, `defaultAccent` |
208
+
209
+ ### Overriding Layer Components
210
+
211
+ Layer components are auto-imported by file name, and the app always wins: a component in your
212
+ app with the same name replaces the layer's copy everywhere, including where other layers render
213
+ it. These unprefixed names are the ones most likely to collide by accident:
214
+
215
+ | Component | Layer | Notes |
216
+ | -------------------------------------- | ------ | -------------------------------------------------------------- |
217
+ | `LoadingScreen` | core | Meant to be overridable (custom boot screen) |
218
+ | `ElementTw` | core | Rendered by typography and visual — shadowing changes them too |
219
+ | `Element`, `AppContainer` | core | Primitives; pick another name for app components |
220
+ | `ErrorBoundary` | core | Pick another name for app boundaries |
221
+ | `HStack`, `VStack`, `ZStack`, `Spacer` | layout | Primitives; pick another name for app components |
222
+
223
+ Override one on purpose by creating the same-named file in your `app/components/`.
199
224
 
200
225
  ## Optional Modules
201
226
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  <template>
18
18
  <figure class="my-6">
19
- <img :src :alt :width :height class="w-full rounded-lg" />
19
+ <img :src :alt :width :height loading="lazy" decoding="async" class="w-full rounded-lg" />
20
20
  <figcaption v-if="caption" class="text-muted mt-2 text-center text-sm italic">
21
21
  {{ caption }}
22
22
  </figcaption>
@@ -4,6 +4,6 @@ import type { ContentLayerConfig } from '../types/content'
4
4
  export function useContentSection(section: keyof NonNullable<ContentLayerConfig['sections']>) {
5
5
  const contentLayer = useAppConfig().contentLayer as ContentLayerConfig | undefined
6
6
  if (contentLayer?.sections?.[section] === false) {
7
- throw createError({ statusCode: 404, statusMessage: 'Not Found' })
7
+ throw createError({ status: 404, statusText: 'Not Found' })
8
8
  }
9
9
  }
@@ -33,10 +33,13 @@ export default defineAppConfig({
33
33
  loading: {
34
34
  // Opt in: app.vue renders LoadingScreen for every app, so it stays off unless enabled
35
35
  enabled: false,
36
- // Minimum time (ms, from navigation start) the count runs before reaching 100 (prevents flash)
37
- minDuration: 1500,
36
+ // Minimum time (ms, from navigation start) before the count may reach 100 - 0 finishes
37
+ // as soon as the work does; raise it only for a deliberate brand moment
38
+ minDuration: 0,
38
39
  // Longest wait (ms, after mount) for fonts and images before finishing anyway
39
- maxDuration: 3000,
40
+ maxDuration: 1500,
41
+ // Delay (ms, from first paint) before the screen fades in - faster loads never see it
42
+ showAfter: 400,
40
43
  // Small caption above the counter - empty string hides it
41
44
  label: 'Loading',
42
45
  // Background color
@@ -76,6 +79,7 @@ declare module '@nuxt/schema' {
76
79
  enabled?: boolean
77
80
  minDuration?: number
78
81
  maxDuration?: number
82
+ showAfter?: number
79
83
  label?: string
80
84
  background?: string
81
85
  textColor?: string
@@ -5,16 +5,18 @@
5
5
  // Beat on "100" so the finished count registers before the exit starts
6
6
  const HOLD_MS = 350
7
7
 
8
- const { enabled, label, background, textColor, zIndex } = resolveLoadingConfig(
8
+ const { enabled, label, background, textColor, zIndex, showAfter } = resolveLoadingConfig(
9
9
  useAppConfig().coreLayer
10
10
  )
11
11
 
12
- const { isLoading, progress } = useLoading()
12
+ const { isLoading, progress, skipped } = useLoading()
13
13
  // Written only here; everything else reads it through useLoading().screen
14
14
  const screen = useState<LoadingScreenState>('core:loading:screen', () => 'hidden')
15
15
 
16
16
  const visible = ref(enabled)
17
17
  if (enabled) screen.value = 'visible'
18
+ // Leave without the beat or the exit animation: skipped, or done before it ever faded in
19
+ const instant = ref(false)
18
20
 
19
21
  const displayProgress = computed(() => Math.floor(progress.value))
20
22
 
@@ -35,22 +37,31 @@
35
37
  isLoading,
36
38
  (loading) => {
37
39
  if (loading || !visible.value) return
38
- setTimeout(() => {
39
- visible.value = false
40
- screen.value = 'leaving'
41
- }, HOLD_MS)
40
+ instant.value = skipped.value || performance.now() < showAfter
41
+ setTimeout(
42
+ () => {
43
+ visible.value = false
44
+ screen.value = 'leaving'
45
+ },
46
+ instant.value ? 0 : HOLD_MS
47
+ )
42
48
  },
43
49
  { immediate: true }
44
50
  )
45
51
  </script>
46
52
 
47
53
  <template>
48
- <Transition name="loading-screen" @after-leave="screen = 'hidden'">
54
+ <Transition name="loading-screen" :css="!instant" @after-leave="screen = 'hidden'">
49
55
  <div
50
56
  v-if="visible"
51
57
  id="loading-screen"
52
58
  class="loading-screen"
53
- :style="{ backgroundColor: background, color: textColor, zIndex }"
59
+ :style="{
60
+ backgroundColor: background,
61
+ color: textColor,
62
+ zIndex,
63
+ '--loading-show-after': `${showAfter}ms`,
64
+ }"
54
65
  >
55
66
  <div
56
67
  class="loading-content"
@@ -76,6 +87,10 @@
76
87
  position: fixed;
77
88
  inset: 0;
78
89
  container-type: size;
90
+
91
+ /* Fast loads never see it: hidden and click-through until showAfter has passed.
92
+ `backwards`, not `both` - a held end state would block the exit transition below */
93
+ animation: loading-show 0.2s ease var(--loading-show-after, 0ms) backwards;
79
94
  }
80
95
 
81
96
  .loading-content {
@@ -94,7 +109,8 @@
94
109
  sans-serif;
95
110
 
96
111
  /* `backwards`, not `both` - a held end state would block the exit transition below */
97
- animation: loading-enter 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.1s backwards;
112
+ animation: loading-enter 0.9s cubic-bezier(0.16, 1, 0.3, 1)
113
+ calc(var(--loading-show-after, 0ms) + 0.1s) backwards;
98
114
  }
99
115
 
100
116
  .loading-label {
@@ -141,6 +157,13 @@
141
157
  }
142
158
  }
143
159
 
160
+ @keyframes loading-show {
161
+ from {
162
+ visibility: hidden;
163
+ opacity: 0;
164
+ }
165
+ }
166
+
144
167
  @keyframes loading-enter {
145
168
  from {
146
169
  transform: translateY(0.75rem);
@@ -173,19 +196,10 @@
173
196
  opacity: 0;
174
197
  }
175
198
 
199
+ /* No screen under reduced motion - the plugin ends loading on mount (finishNow) */
176
200
  @media (prefers-reduced-motion: reduce) {
177
- .loading-content {
178
- animation-name: loading-fade-in;
179
- }
180
-
181
- .loading-screen-leave-to .loading-content {
182
- transform: none;
183
- }
184
- }
185
-
186
- @keyframes loading-fade-in {
187
- from {
188
- opacity: 0;
201
+ .loading-screen {
202
+ display: none;
189
203
  }
190
204
  }
191
205
  </style>
@@ -78,11 +78,11 @@ export function useCache() {
78
78
  try {
79
79
  const cacheNames = await caches.keys()
80
80
  await Promise.all(cacheNames.map((name) => caches.delete(name)))
81
- if (process.env.NODE_ENV === 'development') {
81
+ if (import.meta.dev) {
82
82
  console.log('[useCache] All caches cleared')
83
83
  }
84
84
  } catch (error) {
85
- if (process.env.NODE_ENV === 'development') {
85
+ if (import.meta.dev) {
86
86
  console.error('[useCache] Failed to clear cache:', error)
87
87
  }
88
88
  }
@@ -73,7 +73,7 @@ export function useErrorLog() {
73
73
  }
74
74
 
75
75
  // Console logging (development only by default)
76
- if (config.value.logToConsole && import.meta.dev && process.env.NODE_ENV === 'development') {
76
+ if (config.value.logToConsole && import.meta.dev) {
77
77
  console.group('🔴 Error Logged')
78
78
 
79
79
  console.error('Error:', errorObj)
@@ -96,7 +96,7 @@ export function useErrorLog() {
96
96
  const { externalUrl, externalToken } = config.value
97
97
 
98
98
  if (!externalUrl) {
99
- if (process.env.NODE_ENV === 'development') {
99
+ if (import.meta.dev) {
100
100
  console.warn('Error logging enabled but no externalUrl configured')
101
101
  }
102
102
  return
@@ -120,7 +120,7 @@ export function useErrorLog() {
120
120
  })
121
121
  } catch (loggingError) {
122
122
  // Don't let logging errors crash the app
123
- if (import.meta.dev && process.env.NODE_ENV === 'development') {
123
+ if (import.meta.dev) {
124
124
  console.error('Failed to send error to external service:', loggingError)
125
125
  }
126
126
  }
@@ -130,7 +130,7 @@ export function useErrorLog() {
130
130
  * Log a warning (non-critical error)
131
131
  */
132
132
  const logWarning = (message: string, context?: ErrorContext) => {
133
- if (config.value.logToConsole && import.meta.dev && process.env.NODE_ENV === 'development') {
133
+ if (config.value.logToConsole && import.meta.dev) {
134
134
  console.warn('⚠️ Warning:', message, context)
135
135
  }
136
136
  }
@@ -139,7 +139,7 @@ export function useErrorLog() {
139
139
  * Log info (for debugging)
140
140
  */
141
141
  const logInfo = (message: string, data?: unknown) => {
142
- if (config.value.logToConsole && import.meta.dev && process.env.NODE_ENV === 'development') {
142
+ if (config.value.logToConsole && import.meta.dev) {
143
143
  console.log('ℹ️ Info:', message, data)
144
144
  }
145
145
  }
@@ -1,7 +1,7 @@
1
1
  // composables/useLoading.ts
2
2
 
3
3
  import type { LoadingScreenState } from '../types/loading'
4
- import { approachProgress, resolveLoadingConfig, trickleProgress } from '../utils/loading'
4
+ import { approachProgress, resolveLoadingConfig } from '../utils/loading'
5
5
 
6
6
  // Frame-loop state is non-reactive and intentionally module-scope (singleton)
7
7
  const _loop = {
@@ -16,9 +16,10 @@ const _loop = {
16
16
  * Loading state composable for app initialization and async operations
17
17
  *
18
18
  * Features:
19
- * - Smooth progress: a per-frame loop eases the displayed value toward a simulated target that
20
- * slows as it nears 90%, then counts up to 100 once work is done
21
- * - Holds below 100 until `minDuration` has passed, so fast loads don't flash
19
+ * - Real progress: callers raise the target at real milestones (`updateProgress`) and a per-frame
20
+ * loop eases the displayed value toward it, then counts up to 100 once work is done
21
+ * - Optional `minDuration` hold before 100 (default 0: finish as soon as the work does)
22
+ * - `finishNow()` ends the boot screen at once, e.g. for reduced motion
22
23
  * - Shared state across all component instances
23
24
  * - Can be disabled via app.config
24
25
  * - Works without UI component (headless)
@@ -30,9 +31,8 @@ export function useLoading() {
30
31
  const isLoading = useState('core:loading', () => enabled)
31
32
  const progress = useState('core:loading:progress', () => 0)
32
33
  const screen = useState<LoadingScreenState>('core:loading:screen', () => 'hidden')
33
-
34
- // Trickle pace follows minDuration: a typical load sits around 60% when it may finish
35
- const trickleTau = Math.max(minDuration, 500) * 0.8
34
+ // Set by finishNow: the boot screen leaves at once, without its beat or exit animation
35
+ const skipped = useState('core:loading:skipped', () => false)
36
36
 
37
37
  function tick(now: number): void {
38
38
  // Cap at one 30fps frame so a main-thread hitch (hydration, a backgrounded tab) is followed
@@ -40,9 +40,7 @@ export function useLoading() {
40
40
  const dt = Math.min((now - _loop.lastTime) / 1000, 1 / 30)
41
41
  _loop.lastTime = now
42
42
 
43
- const elapsed = now - _loop.startedAt
44
- _loop.target = Math.max(_loop.target, trickleProgress(elapsed, trickleTau))
45
- if (_loop.done && elapsed >= minDuration) _loop.target = 100
43
+ if (_loop.done && now - _loop.startedAt >= minDuration) _loop.target = 100
46
44
 
47
45
  progress.value = approachProgress(progress.value, _loop.target, dt)
48
46
 
@@ -82,6 +80,15 @@ export function useLoading() {
82
80
  _loop.target = Math.max(_loop.target, Math.min(Math.max(value, 0), 99))
83
81
  }
84
82
 
83
+ function finishNow(): void {
84
+ skipped.value = true
85
+ if (import.meta.client) cancelAnimationFrame(_loop.frame)
86
+ _loop.frame = 0
87
+ _loop.done = true
88
+ progress.value = 100
89
+ isLoading.value = false
90
+ }
91
+
85
92
  async function withLoading(fn: () => Promise<void>): Promise<void> {
86
93
  startLoading()
87
94
  try {
@@ -95,9 +102,11 @@ export function useLoading() {
95
102
  isLoading: readonly(isLoading),
96
103
  progress: readonly(progress),
97
104
  screen: readonly(screen),
105
+ skipped: readonly(skipped),
98
106
  startLoading,
99
107
  stopLoading,
100
108
  updateProgress,
109
+ finishNow,
101
110
  withLoading,
102
111
  }
103
112
  }
@@ -6,12 +6,13 @@ import { resolveLoadingConfig } from '../utils/loading'
6
6
  * Loading screen initialization plugin
7
7
  *
8
8
  * Client-side only. Once the app has mounted (hydration done) it:
9
- * 1. Starts the progress count, timed from navigation start - the SSR'd screen has been up
10
- * since first paint, so the count catches up smoothly rather than starting late
9
+ * 1. Starts the count, timed from navigation start, and moves it at real milestones:
10
+ * hydrated (50), fonts ready (75)
11
11
  * 2. Waits for fonts and the initial page's images, capped at `maxDuration`
12
12
  * 3. Marks loading done - the count then finishes to 100 before the screen exits
13
13
  *
14
14
  * Starting after mount keeps the counter at the SSR'd 0 through hydration (no mismatch).
15
+ * With `prefers-reduced-motion: reduce` it ends at once instead: CSS hides the screen.
15
16
  *
16
17
  * Configuration via app.config.ts:
17
18
  * - Set `coreLayer.loading.enabled: false` to disable
@@ -26,11 +27,18 @@ export default defineNuxtPlugin({
26
27
  return // Disabled, skip initialization
27
28
  }
28
29
 
29
- const { startLoading, stopLoading } = useLoading()
30
+ const { isLoading, startLoading, stopLoading, updateProgress, finishNow } = useLoading()
30
31
 
31
32
  // Not awaited: app:mounted handlers run serially, so waiting here would hold up other layers
32
33
  nuxtApp.hook('app:mounted', () => {
34
+ // CSS hides the screen under reduced motion, so don't hold the page (GSAP, scrolling) for it.
35
+ // After hydration, not in setup: state changes before then would fail to hydrate
36
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return finishNow()
37
+ // Already ended by another layer, e.g. the theme's Reduce Motion switch
38
+ if (!isLoading.value) return
33
39
  startLoading(0)
40
+ updateProgress(50)
41
+ void document.fonts?.ready.then(() => updateProgress(75))
34
42
  void Promise.race([
35
43
  Promise.all([pageLoaded(), document.fonts?.ready]),
36
44
  new Promise((resolve) => setTimeout(resolve, maxDuration)),
@@ -6,10 +6,13 @@
6
6
  export type LoadingConfig = {
7
7
  /** Master toggle - set to false to disable completely */
8
8
  enabled: boolean
9
- /** Minimum time (ms, from navigation start) the count runs before it may reach 100 */
9
+ /** Minimum time (ms, from navigation start) before the count may reach 100 - 0 finishes
10
+ * as soon as the work does */
10
11
  minDuration: number
11
12
  /** Longest wait (ms, after the app mounts) for fonts and images before finishing anyway */
12
13
  maxDuration: number
14
+ /** Delay (ms, from first paint) before the screen fades in - faster loads never see it */
15
+ showAfter: number
13
16
  /** Small caption above the counter - empty string hides it */
14
17
  label: string
15
18
  /** Background color */
@@ -46,12 +49,16 @@ export type UseLoadingReturn = {
46
49
  progress: Readonly<Ref<number>>
47
50
  /** Boot loading screen lifecycle (readonly) */
48
51
  screen: Readonly<Ref<LoadingScreenState>>
49
- /** Start simulated progress, timed from `since` (a `performance.now()` value; default now) */
52
+ /** True once `finishNow` has skipped the boot screen (readonly) */
53
+ skipped: Readonly<Ref<boolean>>
54
+ /** Start counting from 0, timed from `since` (a `performance.now()` value; default now) */
50
55
  startLoading: (since?: number) => void
51
56
  /** Mark work done - the count finishes to 100 once `minDuration` has passed */
52
57
  stopLoading: () => void
53
- /** Raise the progress floor (0-99) - only `stopLoading` completes */
58
+ /** Raise the target to a real milestone (0-99) - only `stopLoading` completes */
54
59
  updateProgress: (value: number) => void
60
+ /** End the boot screen now, without its beat or exit animation. Call after hydration */
61
+ finishNow: () => void
55
62
  /** Execute async function with loading state */
56
63
  withLoading: (fn: () => Promise<void>) => Promise<void>
57
64
  }
@@ -3,8 +3,9 @@ import type { LoadingConfig } from '../types/loading'
3
3
  /** Fallbacks for keys app.config leaves unset - core's app.config ships the same values. */
4
4
  const LOADING_DEFAULTS: LoadingConfig = {
5
5
  enabled: false,
6
- minDuration: 1500,
7
- maxDuration: 3000,
6
+ minDuration: 0,
7
+ maxDuration: 1500,
8
+ showAfter: 400,
8
9
  label: 'Loading',
9
10
  background: '#0a0a0a',
10
11
  textColor: '#ffffff',
@@ -18,27 +19,17 @@ export function resolveLoadingConfig(coreLayer: unknown): LoadingConfig {
18
19
  }
19
20
 
20
21
  /*
21
- * Pure easing math behind `useLoading`'s progress counter. `useLoading` runs these once per
22
- * animation frame: `trickleProgress` moves the target, `approachProgress` moves the displayed
23
- * value toward it, so the counter climbs smoothly instead of jumping between random values.
22
+ * Pure easing math behind `useLoading`'s progress counter. The target only moves at real
23
+ * milestones; `approachProgress` runs once per animation frame to move the displayed value
24
+ * toward it, so the counter climbs smoothly instead of jumping.
24
25
  */
25
26
 
26
- /** Simulated progress stays below this until loading actually finishes. */
27
- const TRICKLE_CAP = 90
28
27
  /** Time constant (s) of the displayed value's exponential ease toward its target. */
29
28
  const EASE = 0.15
30
29
  /** Speed band (%/s) for the displayed value: no crawling at the tail, no leaps on big jumps. */
31
30
  const MIN_SPEED = 24
32
31
  const MAX_SPEED = 140
33
32
 
34
- /**
35
- * Simulated progress `elapsed` ms after loading started: quick at first, then slowing as it
36
- * approaches the cap. `tau` (ms) sets the pace — ~63% of the cap is reached after `tau`.
37
- */
38
- export function trickleProgress(elapsed: number, tau: number): number {
39
- return TRICKLE_CAP * (1 - Math.exp(-Math.max(elapsed, 0) / tau))
40
- }
41
-
42
33
  /**
43
34
  * Advances `current` toward `target` over `dt` seconds. Eases out as the gap closes, within the
44
35
  * speed band, and never moves backwards or past `target`. A rAF timestamp can precede the
@@ -1,3 +1,15 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ // @nuxt/eslint is an optional peer: load it outside production only when the app has it
4
+ // installed, otherwise a consumer without it fails module resolution in dev/test builds.
5
+ const lintModules: string[] = []
6
+ if (process.env.NODE_ENV !== 'production') {
7
+ try {
8
+ createRequire(import.meta.url).resolve('@nuxt/eslint')
9
+ lintModules.push('@nuxt/eslint')
10
+ } catch {}
11
+ }
12
+
1
13
  // https://nuxt.com/docs/api/configuration/nuxt-config
2
14
  export default defineNuxtConfig({
3
15
  $meta: {
@@ -15,7 +27,7 @@ export default defineNuxtConfig({
15
27
 
16
28
  // Base modules (always loaded)
17
29
  modules: [
18
- ...(process.env.NODE_ENV !== 'production' ? ['@nuxt/eslint'] : []),
30
+ ...lintModules,
19
31
  '@nuxt/ui',
20
32
  // '@nuxt/image'
21
33
  '@vueuse/nuxt',
@@ -27,24 +39,12 @@ export default defineNuxtConfig({
27
39
 
28
40
  css: ['#layers/core/app/assets/css/core.css'],
29
41
 
30
- devtools: {
31
- enabled: true,
32
-
33
- vscode: {
34
- enabled: true,
35
- },
36
- },
37
-
38
- // Force light mode for consistent appearance
42
+ // Follow the OS colour scheme; light when it can't be detected
39
43
  colorMode: {
40
44
  preference: 'system',
41
45
  fallback: 'light',
42
46
  },
43
47
 
44
- future: {
45
- compatibilityVersion: 5,
46
- },
47
-
48
48
  compatibilityDate: '2026-01-24',
49
49
 
50
50
  typescript: {
@@ -0,0 +1,17 @@
1
+ // eslint-disable-next-line no-restricted-imports -- the fallback below is the one place it belongs
2
+ import { useAppConfig as useNitroAppConfig } from 'nitropack/runtime'
3
+
4
+ /**
5
+ * The app's merged app.config, read on the server.
6
+ *
7
+ * Nuxt hands Nitro its merged `useAppConfig` as an auto-import, so this leaves `useAppConfig`
8
+ * unimported on purpose. With `experimental.nitroAutoImports: false` (Nuxt 5's default) nothing
9
+ * is injected and Nuxt can't evaluate app.config.ts in Nitro at all - importing `nuxt/server`
10
+ * there crashes the server - so fall back to Nitro's own app config, which is empty.
11
+ */
12
+ export function readServerAppConfig(): Record<string, unknown> {
13
+ // `typeof` doesn't throw for an identifier that was never injected
14
+ return typeof useAppConfig === 'function'
15
+ ? (useAppConfig() as Record<string, unknown>)
16
+ : (useNitroAppConfig() as Record<string, unknown>)
17
+ }
@@ -1,4 +1,4 @@
1
1
  export default defineNuxtRouteMiddleware(() => {
2
2
  const { loggedIn } = useUserSession()
3
- if (!loggedIn.value) return navigateTo('/auth/github')
3
+ if (!loggedIn.value) return navigateTo('/auth/github', { external: true })
4
4
  })
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { FormSubmitEvent } from '@nuxt/ui'
3
3
 
4
- import { contactSchema, type ContactData } from '../../utils/contact-schema'
4
+ import { contactSchema, type ContactData } from '../../../shared/utils/contact-schema'
5
5
 
6
6
  const emit = defineEmits<{
7
7
  submit: [data: ContactData]