kmcom-nuxt-layers 6.3.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 (142) hide show
  1. package/docs/MIGRATION-v6.4.0.md +215 -0
  2. package/docs/MIGRATION-v6.5.0.md +87 -0
  3. package/docs/MIGRATION.md +3 -1
  4. package/docs/USAGE.md +35 -10
  5. package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +16 -0
  6. package/docs/component-levels.md +1 -0
  7. package/docs/layout-shift-causes.md +18 -0
  8. package/layers/content/app/components/content/Figure.vue +1 -1
  9. package/layers/content/app/composables/useContentSection.ts +1 -1
  10. package/layers/core/app/app.config.ts +9 -5
  11. package/layers/core/app/app.vue +2 -1
  12. package/layers/core/app/components/LoadingScreen.vue +35 -21
  13. package/layers/core/app/composables/useCache.ts +2 -2
  14. package/layers/core/app/composables/useErrorLog.ts +5 -5
  15. package/layers/core/app/composables/useLoading.ts +19 -10
  16. package/layers/core/app/plugins/loading.client.ts +11 -3
  17. package/layers/core/app/types/loading.ts +10 -3
  18. package/layers/core/app/utils/loading.ts +7 -16
  19. package/layers/core/nuxt.config.ts +14 -14
  20. package/layers/core/server/utils/app-config.ts +17 -0
  21. package/layers/data/auth/app/middleware/auth.ts +1 -1
  22. package/layers/data/forms/app/components/Form/Contact.vue +1 -1
  23. package/layers/data/forms/server/api/contact.post.ts +16 -8
  24. package/layers/data/forms/server/api/forms/status.get.ts +3 -0
  25. package/layers/data/forms/{app → shared}/utils/contact-schema.ts +2 -1
  26. package/layers/data/metadata/server/api/metadata/search.get.ts +27 -7
  27. package/layers/data/metadata/server/api/metadata/status.get.ts +4 -1
  28. package/layers/data/metadata/server/api/metadata/sync.post.ts +13 -3
  29. package/layers/data/metadata/server/utils/metadata/resolve-record.ts +3 -3
  30. package/layers/delivery/feeds/nuxt.config.ts +6 -1
  31. package/layers/delivery/feeds/package.json +2 -2
  32. package/layers/delivery/feeds/server/routes/feed/demo.get.ts +2 -2
  33. package/layers/delivery/feeds/server/utils/content-adapter.ts +1 -1
  34. package/layers/delivery/feeds/server/utils/feed-service.ts +2 -2
  35. package/layers/design-system/theming/app/plugins/theme.client.ts +7 -0
  36. package/layers/design-system/theming/nuxt.config.ts +0 -2
  37. package/layers/design-system/typography/app/assets/css/typography.css +41 -5
  38. package/layers/design-system/visual/app/components/HoverDimGroup.vue +11 -3
  39. package/layers/motion/scroll/app/plugins/locomotive-scroll.client.ts +3 -2
  40. package/layers/render/canvas/nuxt.config.ts +0 -4
  41. package/layers/render/shader/nuxt.config.ts +0 -4
  42. package/layers/structure/navigation/app/app.config.ts +37 -14
  43. package/layers/structure/navigation/app/components/Mast/Nav.vue +14 -13
  44. package/layers/structure/navigation/app/components/Mast/NavModal.vue +83 -28
  45. package/layers/structure/navigation/app/components/Site/Logo.vue +35 -0
  46. package/layers/structure/navigation/app/components/Site/Title.vue +3 -3
  47. package/layers/structure/navigation/app/composables/mastNav.ts +38 -13
  48. package/layers/structure/navigation/app/types/nav.ts +34 -0
  49. package/layers/structure/routing/app/middleware/01.maintenance.global.ts +4 -1
  50. package/layers/structure/routing/app/middleware/02.governance.global.ts +1 -1
  51. package/layers/structure/routing/nuxt.config.ts +19 -12
  52. package/layers/structure/routing/server/api/feature-flags.get.ts +4 -2
  53. package/package.json +35 -5
  54. package/docs/FALLOW-COMPLEXITY-DUPLICATION-AUDIT.md +0 -64
  55. package/docs/IMPROVE-AUDIT-README.md +0 -29
  56. package/docs/IMPROVE-AUDIT-RESULTS.md +0 -51
  57. package/docs/IMPROVE-DEEP-AUDIT-RESULTS.md +0 -81
  58. package/docs/fallow-refactor/apps-debug.md +0 -27
  59. package/docs/fallow-refactor/apps-playground.md +0 -46
  60. package/docs/fallow-refactor/apps-visual-identity.md +0 -41
  61. package/docs/fallow-refactor/layers-animations.md +0 -34
  62. package/docs/fallow-refactor/layers-canvas.md +0 -32
  63. package/docs/fallow-refactor/layers-content.md +0 -33
  64. package/docs/fallow-refactor/layers-core.md +0 -39
  65. package/docs/fallow-refactor/layers-feeds.md +0 -39
  66. package/docs/fallow-refactor/layers-forms.md +0 -30
  67. package/docs/fallow-refactor/layers-layout.md +0 -42
  68. package/docs/fallow-refactor/layers-mailer.md +0 -32
  69. package/docs/fallow-refactor/layers-motion.md +0 -27
  70. package/docs/fallow-refactor/layers-navigation.md +0 -31
  71. package/docs/fallow-refactor/layers-page-transitions.md +0 -30
  72. package/docs/fallow-refactor/layers-routing.md +0 -33
  73. package/docs/fallow-refactor/layers-scripts.md +0 -35
  74. package/docs/fallow-refactor/layers-scroll.md +0 -38
  75. package/docs/fallow-refactor/layers-seo.md +0 -32
  76. package/docs/fallow-refactor/layers-shader.md +0 -53
  77. package/docs/fallow-refactor/layers-theme.md +0 -33
  78. package/docs/fallow-refactor/layers-transitions.md +0 -27
  79. package/docs/fallow-refactor/layers-typography.md +0 -29
  80. package/docs/fallow-refactor/layers-visual.md +0 -34
  81. package/docs/superpowers/plans/2026-09-03-layout-layer-fluid-rebuild.md +0 -1850
  82. package/docs/superpowers/plans/2026-09-03-vue-element-system.md +0 -1019
  83. package/docs/superpowers/plans/2026-09-04-grid-layer.md +0 -977
  84. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-0-1-design.md +0 -533
  85. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-2-design.md +0 -154
  86. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-3-design.md +0 -109
  87. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-4-design.md +0 -124
  88. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-5-design.md +0 -121
  89. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-6-design.md +0 -77
  90. package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-7-design.md +0 -73
  91. package/docs/superpowers/specs/2026-08-31-swiss-grid-layer-design.md +0 -190
  92. package/docs/superpowers/specs/2026-09-03-layout-layer-fluid-rebuild-design.md +0 -312
  93. package/docs/superpowers/specs/2026-09-03-vue-element-system-design.md +0 -342
  94. package/docs/superpowers/specs/2026-09-04-grid-layer-design.md +0 -236
  95. package/layers/content/app/utils/galleryImageMeta.test.ts +0 -30
  96. package/layers/core/app/components/Element.test.ts +0 -90
  97. package/layers/core/app/components/ElementTw.test.ts +0 -64
  98. package/layers/core/app/composables/useLayoutAttrs.test.ts +0 -77
  99. package/layers/core/app/utils/browserInfo.test.ts +0 -81
  100. package/layers/core/app/utils/featureClasses.test.ts +0 -101
  101. package/layers/core/app/utils/loading.test.ts +0 -48
  102. package/layers/core/app/utils/resolveElementStyle.test.ts +0 -99
  103. package/layers/core/app/utils/scrollGuardClamp.test.ts +0 -36
  104. package/layers/core/app/utils/styleToTailwindClasses.test.ts +0 -31
  105. package/layers/data/forms/app/utils/contact-schema.test.ts +0 -75
  106. package/layers/data/forms/app/utils/fieldProps.test.ts +0 -141
  107. package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +0 -201
  108. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +0 -76
  109. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +0 -144
  110. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +0 -25
  111. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +0 -144
  112. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +0 -29
  113. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +0 -162
  114. package/layers/data/metadata/server/utils/metadata/cache.test.ts +0 -39
  115. package/layers/data/metadata/server/utils/metadata/provider-fetch.test.ts +0 -27
  116. package/layers/data/metadata/server/utils/metadata/provider-registry.test.ts +0 -54
  117. package/layers/data/metadata/server/utils/metadata/resolve-record.test.ts +0 -87
  118. package/layers/data/metadata/server/utils/metadata/search.test.ts +0 -204
  119. package/layers/delivery/feeds/server/utils/content-adapter.test.ts +0 -67
  120. package/layers/delivery/feeds/server/utils/feed-author.test.ts +0 -71
  121. package/layers/delivery/feeds/server/utils/feed-config.test.ts +0 -184
  122. package/layers/delivery/feeds/server/utils/formats.test.ts +0 -71
  123. package/layers/delivery/seo/app/utils/seoConfig.test.ts +0 -58
  124. package/layers/design-system/theming/server/utils/accent-css.test.ts +0 -71
  125. package/layers/design-system/theming/server/utils/fouc-config.test.ts +0 -78
  126. package/layers/design-system/typography/app/components/Typography/Headline.test.ts +0 -36
  127. package/layers/design-system/typography/app/components/Typography/index.test.ts +0 -31
  128. package/layers/design-system/typography/app/composables/color.test.ts +0 -30
  129. package/layers/design-system/typography/app/composables/typography.test.ts +0 -53
  130. package/layers/design-system/visual/app/composables/tint.test.ts +0 -22
  131. package/layers/design-system/visual/app/utils/colorTokens.test.ts +0 -51
  132. package/layers/design-system/visual/app/utils/gradientStyle.test.ts +0 -75
  133. package/layers/design-system/visual/app/utils/responsiveSizes.test.ts +0 -71
  134. package/layers/motion/animations/app/components/Motion/Staggered.test.ts +0 -40
  135. package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +0 -23
  136. package/layers/motion/scroll/app/utils/parallax.test.ts +0 -24
  137. package/layers/structure/grid/app/composables/useGridTracks.test.ts +0 -94
  138. package/layers/structure/grid/app/types/tracks.test.ts +0 -32
  139. package/layers/structure/grid/app/utils/placement.test.ts +0 -19
  140. package/layers/structure/layout/app/utils/gridPlacementStyle.test.ts +0 -269
  141. package/layers/structure/navigation/app/utils/site.test.ts +0 -39
  142. package/layers/structure/routing/app/utils/resolveRoute.test.ts +0 -47
@@ -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
  }
@@ -2,9 +2,10 @@ import type { LoadingConfig } from '../types/loading'
2
2
 
3
3
  /** Fallbacks for keys app.config leaves unset - core's app.config ships the same values. */
4
4
  const LOADING_DEFAULTS: LoadingConfig = {
5
- enabled: true,
6
- minDuration: 1500,
7
- maxDuration: 3000,
5
+ enabled: false,
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]
@@ -1,16 +1,20 @@
1
- import { createError, defineEventHandler, readBody } from 'h3'
1
+ import { createError, defineEventHandler, getRequestHeader, getRequestIP, readBody } from 'h3'
2
2
  import { sendContactEmail } from '#layers/mailer/server/utils/email'
3
3
  import { mailerLayerHooks } from '#layers/mailer/server/utils/hooks'
4
4
 
5
- import { contactSchema } from '../../app/utils/contact-schema'
5
+ import { contactSchema } from '../../shared/utils/contact-schema'
6
6
 
7
7
  type RateLimitRecord = { count: number; resetAt: number }
8
+ // Per-instance memory: serverless instances each keep their own count. Move to useStorage()
9
+ // or the host's rate limiting if the limit must hold across instances.
8
10
  const rateLimitStore = new Map<string, RateLimitRecord>()
9
11
  const RATE_LIMIT_MAX = 5
10
12
  const RATE_LIMIT_WINDOW_MS = 60_000
11
13
 
12
14
  function checkRateLimit(ip: string): boolean {
13
15
  const now = Date.now()
16
+ // Drop expired windows so the map doesn't grow with every IP ever seen
17
+ for (const [key, entry] of rateLimitStore) if (now > entry.resetAt) rateLimitStore.delete(key)
14
18
  const record = rateLimitStore.get(ip)
15
19
  if (!record || now > record.resetAt) {
16
20
  rateLimitStore.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
@@ -22,11 +26,15 @@ function checkRateLimit(ip: string): boolean {
22
26
  }
23
27
 
24
28
  export default defineEventHandler(async (event) => {
25
- const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
29
+ // Netlify's client IP header can't be set by the client; X-Forwarded-For's first entry can
30
+ const ip =
31
+ getRequestHeader(event, 'x-nf-client-connection-ip') ??
32
+ getRequestIP(event, { xForwardedFor: true }) ??
33
+ 'unknown'
26
34
  if (!checkRateLimit(ip)) {
27
35
  throw createError({
28
- statusCode: 429,
29
- statusMessage: 'Too many requests. Please try again later.',
36
+ status: 429,
37
+ statusText: 'Too many requests. Please try again later.',
30
38
  })
31
39
  }
32
40
 
@@ -34,7 +42,7 @@ export default defineEventHandler(async (event) => {
34
42
  const result = contactSchema.safeParse(body)
35
43
 
36
44
  if (!result.success) {
37
- throw createError({ statusCode: 400, data: result.error.flatten().fieldErrors })
45
+ throw createError({ status: 400, data: result.error.flatten().fieldErrors })
38
46
  }
39
47
 
40
48
  const data = result.data
@@ -49,7 +57,7 @@ export default defineEventHandler(async (event) => {
49
57
 
50
58
  await mailerLayerHooks.callHook('contact:failed', { ...data, error: emailResult.error })
51
59
  throw createError({
52
- statusCode: 500,
53
- statusMessage: 'Failed to send message. Please try again later.',
60
+ status: 500,
61
+ statusText: 'Failed to send message. Please try again later.',
54
62
  })
55
63
  })
@@ -4,6 +4,9 @@ import { useMailerConfig } from '#layers/mailer/server/utils/config'
4
4
  export default defineEventHandler(() => {
5
5
  const { resendApiKey, emailFrom, emailTo } = useMailerConfig()
6
6
 
7
+ // Addresses are dev-only: emailTo is the private inbox every submission lands in
8
+ if (!import.meta.dev) return { configured: Boolean(resendApiKey) }
9
+
7
10
  return {
8
11
  configured: Boolean(resendApiKey),
9
12
  emailFrom,
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  export const contactSchema = z.object({
4
- name: z.string().min(3, 'Name must be at least 3 characters'),
4
+ // Real names can be one or two letters (Li, Ng), so only require one
5
+ name: z.string().trim().min(1, 'Please enter your name'),
5
6
  email: z.string().email('Please enter a valid email'),
6
7
  message: z.string().min(8, 'Message must be at least 8 characters'),
7
8
  })
@@ -1,19 +1,39 @@
1
1
  import { searchMetadata } from '../../utils/metadata/search'
2
2
  import { createError, defineEventHandler, getQuery } from 'h3'
3
- import type { MetadataMediaType, MetadataProviderId } from '#layers/metadata/shared/types'
3
+ import type { MetadataMediaType } from '#layers/metadata/shared/types'
4
+
5
+ const MEDIA_TYPES = new Set<MetadataMediaType>([
6
+ 'book',
7
+ 'comic',
8
+ 'comic-series',
9
+ 'manga',
10
+ 'graphic-novel',
11
+ 'collected-edition',
12
+ 'movie',
13
+ 'tv-show',
14
+ ])
15
+ const MAX_LIMIT = 50
4
16
 
5
17
  // fallow-ignore-next-line complexity
6
18
  export default defineEventHandler(async (event) => {
7
19
  const query = getQuery(event)
8
20
 
9
21
  const q = String(query.q ?? '').trim()
10
- if (!q) throw createError({ statusCode: 400, message: 'q is required' })
22
+ if (!q) throw createError({ status: 400, message: 'q is required' })
23
+
24
+ const mediaType = query.mediaType ? (String(query.mediaType) as MetadataMediaType) : undefined
25
+ if (mediaType && !MEDIA_TYPES.has(mediaType)) {
26
+ throw createError({ status: 400, message: `Unknown mediaType '${mediaType}'` })
27
+ }
28
+
29
+ // Unknown provider ids are dropped by searchMetadata's registry filter
30
+ const providers = query.providers ? String(query.providers).split(',').filter(Boolean) : undefined
11
31
 
12
- const mediaType = query.mediaType as MetadataMediaType | undefined
13
- const providers = query.providers
14
- ? (String(query.providers).split(',').filter(Boolean) as MetadataProviderId[])
15
- : undefined
16
- const limit = query.limit ? Number(query.limit) : 10
32
+ const rawLimit = query.limit ? Number(query.limit) : 10
33
+ if (!Number.isInteger(rawLimit) || rawLimit < 1) {
34
+ throw createError({ status: 400, message: 'limit must be a positive integer' })
35
+ }
36
+ const limit = Math.min(rawLimit, MAX_LIMIT)
17
37
 
18
38
  return searchMetadata({
19
39
  query: q,
@@ -1,7 +1,10 @@
1
1
  import { getProviders } from '../../utils/metadata/provider-registry'
2
- import { defineEventHandler } from 'h3'
2
+ import { createError, defineEventHandler } from 'h3'
3
3
 
4
4
  export default defineEventHandler(async () => {
5
+ // Dev-only: each call runs a live search on every provider, spending real API quota
6
+ if (!import.meta.dev) throw createError({ status: 404 })
7
+
5
8
  const providers = getProviders()
6
9
 
7
10
  return Promise.all(
@@ -1,8 +1,18 @@
1
1
  import { resolveRecord } from '../../utils/metadata/resolve-record'
2
2
  import { defineEventHandler, readBody } from 'h3'
3
- import type { MetadataSyncInput } from '#layers/metadata/shared/types'
3
+
4
+ const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
4
5
 
5
6
  export default defineEventHandler(async (event) => {
6
- const body = await readBody<MetadataSyncInput>(event)
7
- return resolveRecord({ ...body })
7
+ // Pick known fields only; resolveRecord rejects missing provider/providerId with a 400
8
+ const body = (await readBody<Record<string, unknown>>(event)) ?? {}
9
+ const provider = str(body.provider)
10
+ const providerId = str(body.providerId)
11
+ const resourceType = str(body.resourceType)
12
+ return resolveRecord({
13
+ ...(provider && { provider }),
14
+ ...(providerId && { providerId }),
15
+ ...(resourceType && { resourceType }),
16
+ force: body.force === true,
17
+ })
8
18
  })
@@ -20,12 +20,12 @@ export async function resolveRecord(
20
20
  ): Promise<{ record: MetadataRecord; synced: boolean }> {
21
21
  const { provider: providerName, providerId, resourceType, force } = input
22
22
  if (!providerName || !providerId) {
23
- throw createError({ statusCode: 400, message: 'provider and providerId are required' })
23
+ throw createError({ status: 400, message: 'provider and providerId are required' })
24
24
  }
25
25
 
26
26
  const provider = getProvider(providerName)
27
27
  if (!provider) {
28
- throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
28
+ throw createError({ status: 404, message: `Provider '${providerName}' not registered` })
29
29
  }
30
30
 
31
31
  const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
@@ -40,7 +40,7 @@ export async function resolveRecord(
40
40
  providerId,
41
41
  ...(resourceType && { resourceType }),
42
42
  })
43
- if (!record) throw createError({ statusCode: 404, message: 'Not found' })
43
+ if (!record) throw createError({ status: 404, message: 'Not found' })
44
44
 
45
45
  const now = new Date().toISOString()
46
46
  await setCacheRecord(cacheKey, {
@@ -4,7 +4,7 @@ export default defineNuxtConfig({
4
4
  name: 'feeds',
5
5
  },
6
6
 
7
- extends: process.env.FEEDS_STANDALONE ? ['../../core', '../../content'] : [],
7
+ extends: ['../../core', '../../content'],
8
8
 
9
9
  alias: {
10
10
  '#layers/feeds': import.meta.dirname,
@@ -29,4 +29,9 @@ export default defineNuxtConfig({
29
29
  },
30
30
 
31
31
  compatibilityDate: '2025-07-15',
32
+
33
+ typescript: {
34
+ typeCheck: false,
35
+ strict: true,
36
+ },
32
37
  })
@@ -8,8 +8,8 @@
8
8
  "typecheck": "vue-tsc --noEmit -p ../../../tsconfig.typecheck.json",
9
9
  "dev": "nuxi dev .playground",
10
10
  "dev:prepare": "nuxt prepare .playground",
11
- "build": "FEEDS_STANDALONE=true nuxi build",
12
- "generate": "FEEDS_STANDALONE=true nuxi generate",
11
+ "build": "nuxi build",
12
+ "generate": "nuxi generate",
13
13
  "preview": "nuxt preview .playground",
14
14
  "lint": "eslint ."
15
15
  },
@@ -1,6 +1,6 @@
1
1
  import { buildFeed } from '../../utils/feed-service'
2
2
  import { defineEventHandler, getRequestURL, setHeader } from 'h3'
3
- import { useAppConfig } from 'nitropack/runtime'
3
+ import { readServerAppConfig } from '#layers/core/server/utils/app-config'
4
4
 
5
5
  const RSS_ICON = `<svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
6
6
  <path d="M2.5 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3Z"/>
@@ -26,7 +26,7 @@ function formatDate(date: Date): string {
26
26
  }
27
27
 
28
28
  export default defineEventHandler(async (event) => {
29
- const appConfig = useAppConfig()
29
+ const appConfig = readServerAppConfig()
30
30
  const feedConfig =
31
31
  (appConfig as { feedsLayer?: { feed?: { defaultCollection?: string } } }).feedsLayer?.feed ?? {}
32
32
  const defaultCollection = feedConfig.defaultCollection ?? 'blog'
@@ -39,7 +39,7 @@ export async function getContentFeedItems(
39
39
  limit: number = 30
40
40
  ): Promise<FeedItem[]> {
41
41
  if (!COLLECTION_NAME_RE.test(collection)) {
42
- throw createError({ statusCode: 400, statusMessage: 'Invalid collection name' })
42
+ throw createError({ status: 400, statusText: 'Invalid collection name' })
43
43
  }
44
44
 
45
45
  // queryCollection is keyed by literal collection names, but feed routes accept
@@ -1,6 +1,6 @@
1
1
  import { getContentFeedItems } from './content-adapter'
2
2
  import { getRequestURL, type H3Event } from 'h3'
3
- import { useAppConfig } from 'nitropack/runtime'
3
+ import { readServerAppConfig } from '#layers/core/server/utils/app-config'
4
4
 
5
5
  import {
6
6
  createFeedConfig,
@@ -16,7 +16,7 @@ export async function buildFeed(
16
16
  collection?: string,
17
17
  options?: { unlimited?: boolean }
18
18
  ): Promise<{ items: FeedItem[]; config: FeedConfig }> {
19
- const appConfig = useAppConfig() as unknown as AppFeedConfig
19
+ const appConfig = readServerAppConfig() as unknown as AppFeedConfig
20
20
  const feedConfig = appConfig.feedsLayer?.feed
21
21
  const requestUrl = getRequestURL(event)
22
22
  const { site, siteUrl } = resolveFeedSiteConfig(appConfig, requestUrl)
@@ -50,6 +50,13 @@ export default defineNuxtPlugin({
50
50
  { immediate: true }
51
51
  )
52
52
 
53
+ // The Reduce Motion switch (restored on mount) also ends core's boot loading screen. Core
54
+ // handles the OS setting itself, so this only reacts to changes, never before hydration
55
+ const loading = useLoading()
56
+ watch(effectiveReducedMotion, (reduced) => {
57
+ if (reduced && loading.isLoading.value) loading.finishNow()
58
+ })
59
+
53
60
  watch(
54
61
  effectiveReducedTransparency,
55
62
  (reduced) => {
@@ -28,8 +28,6 @@ export default defineNuxtConfig({
28
28
  // Shadow/duration tokens live in core (core/app/assets/css/tokens/elevation-motion.css).
29
29
  css: ['#layers/theming/app/assets/css/theme.css'],
30
30
 
31
- plugins: ['#layers/theming/app/plugins/theme.client.ts'],
32
-
33
31
  colorMode: {
34
32
  dataValue: 'theme-mode',
35
33
  storageKey: 'theme-mode',
@@ -31,12 +31,25 @@
31
31
  --type-scale: 1.26;
32
32
  --type-snap: 0.0625rem; /* rounding interval: rem so it scales with the root; never px, never 0 */
33
33
 
34
- /* Fluid display step, 5 → 7 across 360 → 1280px. tan(atan2(length, 1rem)) casts the
35
- length to the unitless exponent pow() needs; normalised by 1rem (not 1px) so the
36
- exponent stays 5 → 7 whatever the user's root font size; the vw term is scaled by root / 16px so a
37
- larger root doesn't lower the exponent at a given width (h1 stays visibly larger). */
34
+ /* Fluid display step: --display-from at ≤360px → --display-to at ≥1280px (57.5 = 920px / 16).
35
+ tan(atan2(length, 1rem)) casts the length to the unitless exponent pow() needs; normalised
36
+ by 1rem (not 1px) so the exponent doesn't move with the user's root font size; the vw term
37
+ is scaled by root / 16px so a larger root doesn't lower it at a given width (h1 stays
38
+ visibly larger). Defaults reduce to clamp(5rem, 4.2176rem + 3.4784vw·k, 7rem).
39
+ --step-display resolves var() here at :root, so overriding --display-from/to on a subtree
40
+ only reaches elements that recompute it: text-step-display and h1 do. */
41
+ --display-from: 5;
42
+ --display-to: 7;
38
43
  --step-display: tan(
39
- atan2(clamp(5rem, 4.2176rem + 3.4784vw * tan(atan2(1rem, 16px)), 7rem), 1rem)
44
+ atan2(
45
+ clamp(
46
+ var(--display-from) * 1rem,
47
+ var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
48
+ tan(atan2(1rem, 16px)) / 57.5,
49
+ var(--display-to) * 1rem
50
+ ),
51
+ 1rem
52
+ )
40
53
  );
41
54
  }
42
55
 
@@ -252,6 +265,18 @@
252
265
  }
253
266
 
254
267
  @utility text-step-display {
268
+ /* Recomputed here so [--display-from:4] [--display-to:9] on the element takes effect */
269
+ --step-display: tan(
270
+ atan2(
271
+ clamp(
272
+ var(--display-from) * 1rem,
273
+ var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
274
+ tan(atan2(1rem, 16px)) / 57.5,
275
+ var(--display-to) * 1rem
276
+ ),
277
+ 1rem
278
+ )
279
+ );
255
280
  --step: var(--step-display);
256
281
 
257
282
  font-size: calc(var(--type-base) * pow(var(--type-scale), var(--step)));
@@ -283,6 +308,17 @@
283
308
 
284
309
  /* Same expressions as the text-step-* utilities. h1 uses the display step. */
285
310
  h1:where(:not([data-slot])) {
311
+ --step-display: tan(
312
+ atan2(
313
+ clamp(
314
+ var(--display-from) * 1rem,
315
+ var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
316
+ tan(atan2(1rem, 16px)) / 57.5,
317
+ var(--display-to) * 1rem
318
+ ),
319
+ 1rem
320
+ )
321
+ );
286
322
  --step: var(--step-display);
287
323
 
288
324
  font-size: calc(var(--type-base) * pow(var(--type-scale), var(--step)));