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.
- package/docs/MIGRATION-v6.4.0.md +215 -0
- package/docs/MIGRATION-v6.5.0.md +87 -0
- package/docs/MIGRATION.md +3 -1
- package/docs/USAGE.md +35 -10
- package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +16 -0
- package/docs/component-levels.md +1 -0
- package/docs/layout-shift-causes.md +18 -0
- package/layers/content/app/components/content/Figure.vue +1 -1
- package/layers/content/app/composables/useContentSection.ts +1 -1
- package/layers/core/app/app.config.ts +9 -5
- package/layers/core/app/app.vue +2 -1
- package/layers/core/app/components/LoadingScreen.vue +35 -21
- package/layers/core/app/composables/useCache.ts +2 -2
- package/layers/core/app/composables/useErrorLog.ts +5 -5
- package/layers/core/app/composables/useLoading.ts +19 -10
- package/layers/core/app/plugins/loading.client.ts +11 -3
- package/layers/core/app/types/loading.ts +10 -3
- package/layers/core/app/utils/loading.ts +7 -16
- package/layers/core/nuxt.config.ts +14 -14
- package/layers/core/server/utils/app-config.ts +17 -0
- package/layers/data/auth/app/middleware/auth.ts +1 -1
- package/layers/data/forms/app/components/Form/Contact.vue +1 -1
- package/layers/data/forms/server/api/contact.post.ts +16 -8
- package/layers/data/forms/server/api/forms/status.get.ts +3 -0
- package/layers/data/forms/{app → shared}/utils/contact-schema.ts +2 -1
- package/layers/data/metadata/server/api/metadata/search.get.ts +27 -7
- package/layers/data/metadata/server/api/metadata/status.get.ts +4 -1
- package/layers/data/metadata/server/api/metadata/sync.post.ts +13 -3
- package/layers/data/metadata/server/utils/metadata/resolve-record.ts +3 -3
- package/layers/delivery/feeds/nuxt.config.ts +6 -1
- package/layers/delivery/feeds/package.json +2 -2
- package/layers/delivery/feeds/server/routes/feed/demo.get.ts +2 -2
- package/layers/delivery/feeds/server/utils/content-adapter.ts +1 -1
- package/layers/delivery/feeds/server/utils/feed-service.ts +2 -2
- package/layers/design-system/theming/app/plugins/theme.client.ts +7 -0
- package/layers/design-system/theming/nuxt.config.ts +0 -2
- package/layers/design-system/typography/app/assets/css/typography.css +41 -5
- package/layers/design-system/visual/app/components/HoverDimGroup.vue +11 -3
- package/layers/motion/scroll/app/plugins/locomotive-scroll.client.ts +3 -2
- package/layers/render/canvas/nuxt.config.ts +0 -4
- package/layers/render/shader/nuxt.config.ts +0 -4
- package/layers/structure/navigation/app/app.config.ts +37 -14
- package/layers/structure/navigation/app/components/Mast/Nav.vue +14 -13
- package/layers/structure/navigation/app/components/Mast/NavModal.vue +83 -28
- package/layers/structure/navigation/app/components/Site/Logo.vue +35 -0
- package/layers/structure/navigation/app/components/Site/Title.vue +3 -3
- package/layers/structure/navigation/app/composables/mastNav.ts +38 -13
- package/layers/structure/navigation/app/types/nav.ts +34 -0
- package/layers/structure/routing/app/middleware/01.maintenance.global.ts +4 -1
- package/layers/structure/routing/app/middleware/02.governance.global.ts +1 -1
- package/layers/structure/routing/nuxt.config.ts +19 -12
- package/layers/structure/routing/server/api/feature-flags.get.ts +4 -2
- package/package.json +35 -5
- package/docs/FALLOW-COMPLEXITY-DUPLICATION-AUDIT.md +0 -64
- package/docs/IMPROVE-AUDIT-README.md +0 -29
- package/docs/IMPROVE-AUDIT-RESULTS.md +0 -51
- package/docs/IMPROVE-DEEP-AUDIT-RESULTS.md +0 -81
- package/docs/fallow-refactor/apps-debug.md +0 -27
- package/docs/fallow-refactor/apps-playground.md +0 -46
- package/docs/fallow-refactor/apps-visual-identity.md +0 -41
- package/docs/fallow-refactor/layers-animations.md +0 -34
- package/docs/fallow-refactor/layers-canvas.md +0 -32
- package/docs/fallow-refactor/layers-content.md +0 -33
- package/docs/fallow-refactor/layers-core.md +0 -39
- package/docs/fallow-refactor/layers-feeds.md +0 -39
- package/docs/fallow-refactor/layers-forms.md +0 -30
- package/docs/fallow-refactor/layers-layout.md +0 -42
- package/docs/fallow-refactor/layers-mailer.md +0 -32
- package/docs/fallow-refactor/layers-motion.md +0 -27
- package/docs/fallow-refactor/layers-navigation.md +0 -31
- package/docs/fallow-refactor/layers-page-transitions.md +0 -30
- package/docs/fallow-refactor/layers-routing.md +0 -33
- package/docs/fallow-refactor/layers-scripts.md +0 -35
- package/docs/fallow-refactor/layers-scroll.md +0 -38
- package/docs/fallow-refactor/layers-seo.md +0 -32
- package/docs/fallow-refactor/layers-shader.md +0 -53
- package/docs/fallow-refactor/layers-theme.md +0 -33
- package/docs/fallow-refactor/layers-transitions.md +0 -27
- package/docs/fallow-refactor/layers-typography.md +0 -29
- package/docs/fallow-refactor/layers-visual.md +0 -34
- package/docs/superpowers/plans/2026-09-03-layout-layer-fluid-rebuild.md +0 -1850
- package/docs/superpowers/plans/2026-09-03-vue-element-system.md +0 -1019
- package/docs/superpowers/plans/2026-09-04-grid-layer.md +0 -977
- package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-0-1-design.md +0 -533
- package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-2-design.md +0 -154
- package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-3-design.md +0 -109
- package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-4-design.md +0 -124
- package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-5-design.md +0 -121
- package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-6-design.md +0 -77
- package/docs/superpowers/specs/2026-08-30-layer-taxonomy-tier-7-design.md +0 -73
- package/docs/superpowers/specs/2026-08-31-swiss-grid-layer-design.md +0 -190
- package/docs/superpowers/specs/2026-09-03-layout-layer-fluid-rebuild-design.md +0 -312
- package/docs/superpowers/specs/2026-09-03-vue-element-system-design.md +0 -342
- package/docs/superpowers/specs/2026-09-04-grid-layer-design.md +0 -236
- package/layers/content/app/utils/galleryImageMeta.test.ts +0 -30
- package/layers/core/app/components/Element.test.ts +0 -90
- package/layers/core/app/components/ElementTw.test.ts +0 -64
- package/layers/core/app/composables/useLayoutAttrs.test.ts +0 -77
- package/layers/core/app/utils/browserInfo.test.ts +0 -81
- package/layers/core/app/utils/featureClasses.test.ts +0 -101
- package/layers/core/app/utils/loading.test.ts +0 -48
- package/layers/core/app/utils/resolveElementStyle.test.ts +0 -99
- package/layers/core/app/utils/scrollGuardClamp.test.ts +0 -36
- package/layers/core/app/utils/styleToTailwindClasses.test.ts +0 -31
- package/layers/data/forms/app/utils/contact-schema.test.ts +0 -75
- package/layers/data/forms/app/utils/fieldProps.test.ts +0 -141
- package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +0 -201
- package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +0 -76
- package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +0 -144
- package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +0 -25
- package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +0 -144
- package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +0 -29
- package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +0 -162
- package/layers/data/metadata/server/utils/metadata/cache.test.ts +0 -39
- package/layers/data/metadata/server/utils/metadata/provider-fetch.test.ts +0 -27
- package/layers/data/metadata/server/utils/metadata/provider-registry.test.ts +0 -54
- package/layers/data/metadata/server/utils/metadata/resolve-record.test.ts +0 -87
- package/layers/data/metadata/server/utils/metadata/search.test.ts +0 -204
- package/layers/delivery/feeds/server/utils/content-adapter.test.ts +0 -67
- package/layers/delivery/feeds/server/utils/feed-author.test.ts +0 -71
- package/layers/delivery/feeds/server/utils/feed-config.test.ts +0 -184
- package/layers/delivery/feeds/server/utils/formats.test.ts +0 -71
- package/layers/delivery/seo/app/utils/seoConfig.test.ts +0 -58
- package/layers/design-system/theming/server/utils/accent-css.test.ts +0 -71
- package/layers/design-system/theming/server/utils/fouc-config.test.ts +0 -78
- package/layers/design-system/typography/app/components/Typography/Headline.test.ts +0 -36
- package/layers/design-system/typography/app/components/Typography/index.test.ts +0 -31
- package/layers/design-system/typography/app/composables/color.test.ts +0 -30
- package/layers/design-system/typography/app/composables/typography.test.ts +0 -53
- package/layers/design-system/visual/app/composables/tint.test.ts +0 -22
- package/layers/design-system/visual/app/utils/colorTokens.test.ts +0 -51
- package/layers/design-system/visual/app/utils/gradientStyle.test.ts +0 -75
- package/layers/design-system/visual/app/utils/responsiveSizes.test.ts +0 -71
- package/layers/motion/animations/app/components/Motion/Staggered.test.ts +0 -40
- package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +0 -23
- package/layers/motion/scroll/app/utils/parallax.test.ts +0 -24
- package/layers/structure/grid/app/composables/useGridTracks.test.ts +0 -94
- package/layers/structure/grid/app/types/tracks.test.ts +0 -32
- package/layers/structure/grid/app/utils/placement.test.ts +0 -19
- package/layers/structure/layout/app/utils/gridPlacementStyle.test.ts +0 -269
- package/layers/structure/navigation/app/utils/site.test.ts +0 -39
- 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
|
|
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
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
10
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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:
|
|
6
|
-
minDuration:
|
|
7
|
-
maxDuration:
|
|
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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
...
|
|
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
|
-
|
|
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,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 '
|
|
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 '../../
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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({
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const limit =
|
|
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
|
-
|
|
3
|
+
|
|
4
|
+
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
|
4
5
|
|
|
5
6
|
export default defineEventHandler(async (event) => {
|
|
6
|
-
|
|
7
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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:
|
|
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": "
|
|
12
|
-
"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 {
|
|
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 =
|
|
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({
|
|
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 {
|
|
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 =
|
|
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
|
|
35
|
-
length to the unitless exponent pow() needs; normalised
|
|
36
|
-
|
|
37
|
-
larger root doesn't lower
|
|
34
|
+
/* Fluid display step: --display-from at ≤360px → --display-to at ≥1280px (57.5 = 920px / 16).
|
|
35
|
+
tan(atan2(length, 1rem)) casts the length to the unitless exponent pow() needs; normalised
|
|
36
|
+
by 1rem (not 1px) so the exponent doesn't move with the user's root font size; the vw term
|
|
37
|
+
is scaled by root / 16px so a larger root doesn't lower it at a given width (h1 stays
|
|
38
|
+
visibly larger). Defaults reduce to clamp(5rem, 4.2176rem + 3.4784vw·k, 7rem).
|
|
39
|
+
--step-display resolves var() here at :root, so overriding --display-from/to on a subtree
|
|
40
|
+
only reaches elements that recompute it: text-step-display and h1 do. */
|
|
41
|
+
--display-from: 5;
|
|
42
|
+
--display-to: 7;
|
|
38
43
|
--step-display: tan(
|
|
39
|
-
atan2(
|
|
44
|
+
atan2(
|
|
45
|
+
clamp(
|
|
46
|
+
var(--display-from) * 1rem,
|
|
47
|
+
var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
|
|
48
|
+
tan(atan2(1rem, 16px)) / 57.5,
|
|
49
|
+
var(--display-to) * 1rem
|
|
50
|
+
),
|
|
51
|
+
1rem
|
|
52
|
+
)
|
|
40
53
|
);
|
|
41
54
|
}
|
|
42
55
|
|
|
@@ -252,6 +265,18 @@
|
|
|
252
265
|
}
|
|
253
266
|
|
|
254
267
|
@utility text-step-display {
|
|
268
|
+
/* Recomputed here so [--display-from:4] [--display-to:9] on the element takes effect */
|
|
269
|
+
--step-display: tan(
|
|
270
|
+
atan2(
|
|
271
|
+
clamp(
|
|
272
|
+
var(--display-from) * 1rem,
|
|
273
|
+
var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
|
|
274
|
+
tan(atan2(1rem, 16px)) / 57.5,
|
|
275
|
+
var(--display-to) * 1rem
|
|
276
|
+
),
|
|
277
|
+
1rem
|
|
278
|
+
)
|
|
279
|
+
);
|
|
255
280
|
--step: var(--step-display);
|
|
256
281
|
|
|
257
282
|
font-size: calc(var(--type-base) * pow(var(--type-scale), var(--step)));
|
|
@@ -283,6 +308,17 @@
|
|
|
283
308
|
|
|
284
309
|
/* Same expressions as the text-step-* utilities. h1 uses the display step. */
|
|
285
310
|
h1:where(:not([data-slot])) {
|
|
311
|
+
--step-display: tan(
|
|
312
|
+
atan2(
|
|
313
|
+
clamp(
|
|
314
|
+
var(--display-from) * 1rem,
|
|
315
|
+
var(--display-from) * 1rem + (var(--display-to) - var(--display-from)) * (100vw - 360px) *
|
|
316
|
+
tan(atan2(1rem, 16px)) / 57.5,
|
|
317
|
+
var(--display-to) * 1rem
|
|
318
|
+
),
|
|
319
|
+
1rem
|
|
320
|
+
)
|
|
321
|
+
);
|
|
286
322
|
--step: var(--step-display);
|
|
287
323
|
|
|
288
324
|
font-size: calc(var(--type-base) * pow(var(--type-scale), var(--step)));
|