kmcom-nuxt-layers 6.1.0 → 6.2.1

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/USAGE.md CHANGED
@@ -188,14 +188,14 @@ export default defineAppConfig({
188
188
 
189
189
  ### Available Config Namespaces
190
190
 
191
- | Namespace | Layer | Key Options |
192
- | ----------------------- | ------- | -------------------------------------------------------------------------- |
193
- | `coreLayer.loading` | core | `enabled`, `minDuration`, `maxDuration`, `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` |
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` |
199
199
 
200
200
  ## Optional Modules
201
201
 
@@ -33,10 +33,12 @@ export default defineAppConfig({
33
33
  loading: {
34
34
  // Master toggle - set to false to disable completely
35
35
  enabled: true,
36
- // Minimum time to display loading screen (prevents flash)
37
- minDuration: 3000, // Temporarily increased for testing
38
- // Maximum time for simulated progress
36
+ // Minimum time (ms, from navigation start) the count runs before reaching 100 (prevents flash)
37
+ minDuration: 1500,
38
+ // Longest wait (ms, after mount) for fonts and images before finishing anyway
39
39
  maxDuration: 3000,
40
+ // Small caption above the counter - empty string hides it
41
+ label: 'Loading',
40
42
  // Background color
41
43
  background: '#0a0a0a',
42
44
  // Progress text color
@@ -74,6 +76,7 @@ declare module '@nuxt/schema' {
74
76
  enabled?: boolean
75
77
  minDuration?: number
76
78
  maxDuration?: number
79
+ label?: string
77
80
  background?: string
78
81
  textColor?: string
79
82
  zIndex?: number
@@ -1,105 +1,88 @@
1
1
  <script setup lang="ts">
2
- interface LoadingConfig {
3
- enabled?: boolean
4
- minDuration?: number
5
- maxDuration?: number
6
- background?: string
7
- textColor?: string
8
- zIndex?: number
9
- }
2
+ import type { LoadingScreenState } from '../types/loading'
3
+ import { resolveLoadingConfig } from '../utils/loading'
10
4
 
11
- const { isLoading, progress } = useLoading()
12
- const config = useAppConfig()
5
+ // Beat on "100" so the finished count registers before the exit starts
6
+ const HOLD_MS = 350
7
+
8
+ const { enabled, label, background, textColor, zIndex } = resolveLoadingConfig(
9
+ useAppConfig().coreLayer
10
+ )
13
11
 
14
- // Type-safe access to loading config
15
- const loadingConfig = computed(() => {
16
- const coreLayer = config.coreLayer as { loading?: LoadingConfig }
17
- return coreLayer?.loading || {}
18
- })
12
+ const { isLoading, progress } = useLoading()
13
+ // Written only here; everything else reads it through useLoading().screen
14
+ const screen = useState<LoadingScreenState>('core:loading:screen', () => 'hidden')
19
15
 
20
- const minDuration = computed(() => loadingConfig.value?.minDuration || 500)
21
- const background = computed(() => loadingConfig.value?.background || '#0a0a0a')
22
- const textColor = computed(() => loadingConfig.value?.textColor || '#ffffff')
23
- const zIndex = computed(() => loadingConfig.value?.zIndex || 10000)
16
+ const visible = ref(enabled)
17
+ if (enabled) screen.value = 'visible'
24
18
 
25
- // Rounded progress for display
26
- const displayProgress = computed(() => Math.round(progress.value))
19
+ const displayProgress = computed(() => Math.floor(progress.value))
27
20
 
28
- // Control visibility
29
- const visible = ref(true)
30
- const hasFinished = ref(false)
21
+ if (enabled) {
22
+ useHead({
23
+ // Lock page scroll from the SSR'd first paint until the exit begins
24
+ htmlAttrs: {
25
+ style: computed(() => (screen.value === 'visible' ? 'overflow: hidden' : '')),
26
+ },
27
+ // Nothing dismisses the screen without JS - don't let it trap the page
28
+ noscript: [
29
+ { innerHTML: '<style>#loading-screen{display:none}html{overflow:auto!important}</style>' },
30
+ ],
31
+ })
32
+ }
31
33
 
32
- // Watch for loading completion
33
34
  watch(
34
- [isLoading, progress],
35
- ([loading, prog]) => {
36
- if (!loading && prog >= 100 && !hasFinished.value) {
37
- hasFinished.value = true
38
-
39
- setTimeout(() => {
40
- visible.value = false
41
- }, minDuration.value)
42
- }
35
+ isLoading,
36
+ (loading) => {
37
+ if (loading || !visible.value) return
38
+ setTimeout(() => {
39
+ visible.value = false
40
+ screen.value = 'leaving'
41
+ }, HOLD_MS)
43
42
  },
44
43
  { immediate: true }
45
44
  )
46
45
  </script>
47
46
 
48
47
  <template>
49
- <Transition name="fade">
48
+ <Transition name="loading-screen" @after-leave="screen = 'hidden'">
50
49
  <div
51
50
  v-if="visible"
52
51
  id="loading-screen"
53
- :style="{
54
- backgroundColor: background,
55
- zIndex: zIndex,
56
- }"
52
+ class="loading-screen"
53
+ :style="{ backgroundColor: background, color: textColor, zIndex }"
57
54
  >
58
- <div class="loading-container">
59
- <div class="loading-progress" :style="{ color: textColor }">
60
- {{ displayProgress }}
61
- </div>
55
+ <div
56
+ class="loading-content"
57
+ role="progressbar"
58
+ :aria-label="label || 'Loading'"
59
+ aria-valuemin="0"
60
+ aria-valuemax="100"
61
+ :aria-valuenow="displayProgress"
62
+ >
63
+ <span v-if="label" class="loading-label" aria-hidden="true">{{ label }}</span>
64
+ <span class="loading-progress" aria-hidden="true"
65
+ >{{ displayProgress }}<span class="loading-unit">%</span></span
66
+ >
62
67
  </div>
68
+ <div class="loading-bar" :style="{ transform: `scaleX(${progress / 100})` }" />
63
69
  </div>
64
70
  </Transition>
65
71
  </template>
66
72
 
67
73
  <style scoped>
68
- /* stylelint-disable selector-max-id */
69
- .fade-enter-active,
70
- .fade-leave-active {
71
- transition: opacity 2s cubic-bezier(0.4, 0, 0.2, 1);
72
- }
73
-
74
- .fade-enter-from,
75
- .fade-leave-to {
76
- opacity: 0;
74
+ .loading-screen {
75
+ display: grid;
76
+ position: fixed;
77
+ inset: 0;
78
+ container-type: size;
77
79
  }
78
80
 
79
- #loading-screen {
81
+ .loading-content {
80
82
  display: flex;
81
- position: fixed;
82
- top: 0;
83
- left: 0;
84
83
  flex-direction: column;
85
- justify-content: center;
86
84
  align-items: center;
87
- width: 100%;
88
- height: 100vh;
89
- }
90
-
91
- .loading-container {
92
- display: grid;
93
- container-type: size;
94
- width: 100%;
95
- height: 100vh;
96
- }
97
-
98
- .loading-progress {
99
85
  place-self: center;
100
- font-weight: 900;
101
- font-stretch: ultra-expanded;
102
- font-size: clamp(3rem, 35cqw + 1rem, 18rem);
103
86
  font-family:
104
87
  system-ui,
105
88
  -apple-system,
@@ -109,15 +92,98 @@
109
92
  'Helvetica Neue',
110
93
  Arial,
111
94
  sans-serif;
112
- text-align: center;
113
- /* stylelint-disable-next-line function-disallowed-list */
114
- text-shadow: 0 0 20px rgb(0 0 0 / 0.3);
95
+ /* `backwards`, not `both` - a held end state would block the exit transition below */
96
+ animation: loading-enter 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.1s backwards;
97
+ }
98
+
99
+ .loading-label {
100
+ margin-bottom: 1em;
101
+ font-weight: 500;
102
+ font-size: clamp(0.75rem, 1cqw + 0.5rem, 1rem);
103
+ letter-spacing: 0.3em;
104
+ text-transform: uppercase;
105
+ opacity: 0.6;
106
+ }
107
+
108
+ .loading-progress {
109
+ font-weight: 900;
110
+ font-stretch: ultra-expanded;
111
+ font-size: clamp(3rem, 35cqw + 1rem, 18rem);
112
+ line-height: 0.8;
113
+ /* Equal-width digits - the counter doesn't jitter as it ticks */
114
+ font-variant-numeric: tabular-nums;
115
+ }
116
+
117
+ .loading-unit {
118
+ margin-left: 0.08em;
119
+ font-weight: 500;
120
+ font-size: 0.2em;
121
+ vertical-align: top;
122
+ opacity: 0.6;
123
+ }
124
+
125
+ .loading-bar {
126
+ position: absolute;
127
+ inset: auto 0 0;
128
+ height: 2px;
129
+ transform-origin: left;
130
+ background-color: currentcolor;
131
+ opacity: 0.5;
115
132
  }
116
133
 
117
134
  @media (width >= 768px) {
118
- .loading-progress {
135
+ .loading-content {
136
+ align-items: flex-start;
119
137
  place-self: end start;
120
138
  padding: 2rem;
121
139
  }
122
140
  }
141
+
142
+ @keyframes loading-enter {
143
+ from {
144
+ transform: translateY(0.75rem);
145
+ opacity: 0;
146
+ }
147
+ }
148
+
149
+ /* Exit: the count lifts away first, then the screen fades to reveal the page */
150
+ .loading-screen-leave-active {
151
+ transition: opacity 0.8s cubic-bezier(0.65, 0, 0.35, 1) 0.2s;
152
+ }
153
+
154
+ .loading-screen-leave-to {
155
+ opacity: 0;
156
+ }
157
+
158
+ .loading-screen-leave-active .loading-content,
159
+ .loading-screen-leave-active .loading-bar {
160
+ transition:
161
+ opacity 0.4s ease-in,
162
+ transform 0.5s cubic-bezier(0.7, 0, 0.84, 0);
163
+ }
164
+
165
+ .loading-screen-leave-to .loading-content {
166
+ transform: translateY(-1.5rem);
167
+ opacity: 0;
168
+ }
169
+
170
+ .loading-screen-leave-to .loading-bar {
171
+ opacity: 0;
172
+ }
173
+
174
+ @media (prefers-reduced-motion: reduce) {
175
+ .loading-content {
176
+ animation-name: loading-fade-in;
177
+ }
178
+
179
+ .loading-screen-leave-to .loading-content {
180
+ transform: none;
181
+ }
182
+ }
183
+
184
+ @keyframes loading-fade-in {
185
+ from {
186
+ opacity: 0;
187
+ }
188
+ }
123
189
  </style>
@@ -1,65 +1,85 @@
1
1
  // composables/useLoading.ts
2
2
 
3
- // Timer IDs are non-reactive and intentionally module-scope (singleton)
4
- const _timers = {
5
- progressInterval: null as ReturnType<typeof setInterval> | null,
6
- progressTimeout: null as ReturnType<typeof setTimeout> | null,
3
+ import type { LoadingScreenState } from '../types/loading'
4
+ import { approachProgress, resolveLoadingConfig, trickleProgress } from '../utils/loading'
5
+
6
+ // Frame-loop state is non-reactive and intentionally module-scope (singleton)
7
+ const _loop = {
8
+ frame: 0,
9
+ lastTime: 0,
10
+ startedAt: 0,
11
+ target: 0,
12
+ done: false,
7
13
  }
8
14
 
9
15
  /**
10
16
  * Loading state composable for app initialization and async operations
11
17
  *
12
18
  * Features:
13
- * - Simulated progress (0-90% auto, then waits)
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
14
22
  * - Shared state across all component instances
15
23
  * - Can be disabled via app.config
16
24
  * - Works without UI component (headless)
17
25
  *
18
- * @param id - Optional scoped loading ID (for future multi-loading support)
19
26
  * @returns Loading state and control methods
20
27
  */
21
28
  export function useLoading() {
22
- const appConfig = useAppConfig()
23
- const loadingConfig = (appConfig.coreLayer as { loading?: { enabled?: boolean } } | undefined)
24
- ?.loading
25
- const isLoading = useState('core:loading', () => loadingConfig?.enabled !== false)
29
+ const { enabled, minDuration } = resolveLoadingConfig(useAppConfig().coreLayer)
30
+ const isLoading = useState('core:loading', () => enabled)
26
31
  const progress = useState('core:loading:progress', () => 0)
32
+ const screen = useState<LoadingScreenState>('core:loading:screen', () => 'hidden')
27
33
 
28
- function startLoading(): void {
29
- isLoading.value = true
30
- progress.value = 0
34
+ // Trickle pace follows minDuration: a typical load sits around 60% when it may finish
35
+ const trickleTau = Math.max(minDuration, 500) * 0.8
31
36
 
32
- if (_timers.progressInterval) clearInterval(_timers.progressInterval)
33
- if (_timers.progressTimeout) clearTimeout(_timers.progressTimeout)
37
+ function tick(now: number): void {
38
+ // Cap at one 30fps frame so a main-thread hitch (hydration, a backgrounded tab) is followed
39
+ // by a quick count, not a leap
40
+ const dt = Math.min((now - _loop.lastTime) / 1000, 1 / 30)
41
+ _loop.lastTime = now
34
42
 
35
- _timers.progressInterval = setInterval(() => {
36
- if (progress.value < 90) {
37
- const increment = Math.random() * 5 + 3
38
- progress.value = Math.min(progress.value + increment, 90)
39
- return
40
- }
41
- if (_timers.progressInterval) {
42
- clearInterval(_timers.progressInterval)
43
- _timers.progressInterval = null
44
- }
45
- }, 150)
46
- }
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
47
46
 
48
- function stopLoading(): void {
49
- if (_timers.progressInterval) {
50
- clearInterval(_timers.progressInterval)
51
- _timers.progressInterval = null
47
+ progress.value = approachProgress(progress.value, _loop.target, dt)
48
+
49
+ if (progress.value >= 100) {
50
+ _loop.frame = 0
51
+ isLoading.value = false
52
+ return
52
53
  }
54
+ _loop.frame = requestAnimationFrame(tick)
55
+ }
53
56
 
54
- progress.value = 100
57
+ function run(): void {
58
+ if (!import.meta.client || _loop.frame) return
59
+ _loop.lastTime = performance.now()
60
+ _loop.frame = requestAnimationFrame(tick)
61
+ }
55
62
 
56
- _timers.progressTimeout = setTimeout(() => {
57
- isLoading.value = false
58
- }, 100)
63
+ function startLoading(since?: number): void {
64
+ isLoading.value = true
65
+ progress.value = 0
66
+
67
+ if (!import.meta.client) return
68
+ cancelAnimationFrame(_loop.frame)
69
+ _loop.frame = 0
70
+ _loop.startedAt = since ?? performance.now()
71
+ _loop.target = 0
72
+ _loop.done = false
73
+ run()
74
+ }
75
+
76
+ function stopLoading(): void {
77
+ _loop.done = true
78
+ if (isLoading.value) run()
59
79
  }
60
80
 
61
81
  function updateProgress(value: number): void {
62
- progress.value = Math.min(Math.max(value, 0), 100)
82
+ _loop.target = Math.max(_loop.target, Math.min(Math.max(value, 0), 99))
63
83
  }
64
84
 
65
85
  async function withLoading(fn: () => Promise<void>): Promise<void> {
@@ -74,6 +94,7 @@ export function useLoading() {
74
94
  return {
75
95
  isLoading: readonly(isLoading),
76
96
  progress: readonly(progress),
97
+ screen: readonly(screen),
77
98
  startLoading,
78
99
  stopLoading,
79
100
  updateProgress,
@@ -1,24 +1,17 @@
1
1
  // plugins/loading.client.ts
2
2
 
3
- type LoadingConfig = {
4
- enabled?: boolean
5
- minDuration?: number
6
- maxDuration?: number
7
- background?: string
8
- textColor?: string
9
- zIndex?: number
10
- }
11
-
12
- type CoreLayerConfig = {
13
- loading?: LoadingConfig
14
- }
3
+ import { resolveLoadingConfig } from '../utils/loading'
15
4
 
16
5
  /**
17
6
  * Loading screen initialization plugin
18
7
  *
19
- * Runs on app mount (client-side only) to:
20
- * 1. Start simulated progress on page load
21
- * 2. Stop loading when app is fully mounted and hydrated
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
11
+ * 2. Waits for fonts and the initial page's images, capped at `maxDuration`
12
+ * 3. Marks loading done - the count then finishes to 100 before the screen exits
13
+ *
14
+ * Starting after mount keeps the counter at the SSR'd 0 through hydration (no mismatch).
22
15
  *
23
16
  * Configuration via app.config.ts:
24
17
  * - Set `coreLayer.loading.enabled: false` to disable
@@ -26,26 +19,27 @@ type CoreLayerConfig = {
26
19
  export default defineNuxtPlugin({
27
20
  name: 'core:loading',
28
21
  setup(nuxtApp) {
29
- const config = useAppConfig()
30
- const coreLayer = config.coreLayer as CoreLayerConfig | undefined
22
+ const { enabled, maxDuration } = resolveLoadingConfig(useAppConfig().coreLayer)
31
23
 
32
24
  // Check if loading is enabled
33
- if (coreLayer?.loading?.enabled === false) {
25
+ if (!enabled) {
34
26
  return // Disabled, skip initialization
35
27
  }
36
28
 
37
29
  const { startLoading, stopLoading } = useLoading()
38
30
 
39
- // Start loading immediately on plugin load
40
- startLoading()
41
-
42
- // Stop loading when app is fully mounted
31
+ // Not awaited: app:mounted handlers run serially, so waiting here would hold up other layers
43
32
  nuxtApp.hook('app:mounted', () => {
44
- // Add a small delay to ensure LoadingScreen component has mounted
45
- // and is visible before we stop loading
46
- setTimeout(() => {
47
- stopLoading()
48
- }, 500) // 500ms delay to ensure component is visible
33
+ startLoading(0)
34
+ void Promise.race([
35
+ Promise.all([pageLoaded(), document.fonts?.ready]),
36
+ new Promise((resolve) => setTimeout(resolve, maxDuration)),
37
+ ]).then(stopLoading)
49
38
  })
50
39
  },
51
40
  })
41
+
42
+ function pageLoaded(): Promise<unknown> {
43
+ if (document.readyState === 'complete') return Promise.resolve()
44
+ return new Promise((resolve) => window.addEventListener('load', resolve, { once: true }))
45
+ }
@@ -6,10 +6,12 @@
6
6
  export type LoadingConfig = {
7
7
  /** Master toggle - set to false to disable completely */
8
8
  enabled: boolean
9
- /** Minimum time to display loading screen (prevents flash) */
9
+ /** Minimum time (ms, from navigation start) the count runs before it may reach 100 */
10
10
  minDuration: number
11
- /** Maximum time for simulated progress */
11
+ /** Longest wait (ms, after the app mounts) for fonts and images before finishing anyway */
12
12
  maxDuration: number
13
+ /** Small caption above the counter - empty string hides it */
14
+ label: string
13
15
  /** Background color */
14
16
  background: string
15
17
  /** Progress text color */
@@ -18,6 +20,12 @@ export type LoadingConfig = {
18
20
  zIndex: number
19
21
  }
20
22
 
23
+ /**
24
+ * Boot loading screen lifecycle, shared so other layers can hold their own motion until the
25
+ * page is revealed: covering the page → playing its exit → gone (or never mounted)
26
+ */
27
+ export type LoadingScreenState = 'visible' | 'leaving' | 'hidden'
28
+
21
29
  /**
22
30
  * Loading state interface
23
31
  */
@@ -32,15 +40,17 @@ export type LoadingState = {
32
40
  * Loading composable return interface
33
41
  */
34
42
  export type UseLoadingReturn = {
35
- /** Current loading state (readonly) */
43
+ /** True until the displayed progress has counted up to 100 (readonly) */
36
44
  isLoading: Readonly<Ref<boolean>>
37
- /** Progress percentage 0-100 (readonly) */
45
+ /** Displayed progress 0-100, eased every frame - never jumps or moves backwards (readonly) */
38
46
  progress: Readonly<Ref<number>>
39
- /** Start loading with simulated progress */
40
- startLoading: () => void
41
- /** Stop loading and jump to 100% */
47
+ /** Boot loading screen lifecycle (readonly) */
48
+ screen: Readonly<Ref<LoadingScreenState>>
49
+ /** Start simulated progress, timed from `since` (a `performance.now()` value; default now) */
50
+ startLoading: (since?: number) => void
51
+ /** Mark work done - the count finishes to 100 once `minDuration` has passed */
42
52
  stopLoading: () => void
43
- /** Manually update progress (0-100) */
53
+ /** Raise the progress floor (0-99) - only `stopLoading` completes */
44
54
  updateProgress: (value: number) => void
45
55
  /** Execute async function with loading state */
46
56
  withLoading: (fn: () => Promise<void>) => Promise<void>
@@ -6,5 +6,6 @@
6
6
 
7
7
  export * from './browserInfo'
8
8
  export * from './featureClasses'
9
+ export * from './loading'
9
10
  export * from './scrollGuardClamp'
10
11
  export * from './styleToTailwindClasses'
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vite-plus/test'
2
+
3
+ import { approachProgress, resolveLoadingConfig, trickleProgress } from './loading'
4
+
5
+ const FRAME = 1 / 60
6
+
7
+ describe('resolveLoadingConfig', () => {
8
+ it('fills unset keys with defaults but keeps falsy values that were set', () => {
9
+ expect(resolveLoadingConfig(undefined)).toMatchObject({ enabled: true, minDuration: 1500 })
10
+ expect(resolveLoadingConfig({ loading: { minDuration: 0, label: '' } })).toMatchObject({
11
+ enabled: true,
12
+ minDuration: 0,
13
+ label: '',
14
+ })
15
+ })
16
+ })
17
+
18
+ describe('trickleProgress', () => {
19
+ it('starts at 0, rises with time, and stays below the cap', () => {
20
+ expect(trickleProgress(0, 1200)).toBe(0)
21
+ expect(trickleProgress(-500, 1200)).toBe(0)
22
+ expect(trickleProgress(1200, 1200)).toBeGreaterThan(trickleProgress(600, 1200))
23
+ expect(trickleProgress(60_000, 1200)).toBeLessThanOrEqual(90)
24
+ })
25
+ })
26
+
27
+ describe('approachProgress', () => {
28
+ it('counts 0 → 100 in small per-frame steps and lands exactly on the target', () => {
29
+ let current = 0
30
+ let frames = 0
31
+ while (current < 100) {
32
+ const next = approachProgress(current, 100, FRAME)
33
+ expect(next).toBeGreaterThan(current)
34
+ expect(Math.floor(next) - Math.floor(current)).toBeLessThanOrEqual(3)
35
+ current = next
36
+ frames++
37
+ }
38
+ expect(current).toBe(100)
39
+ // A visible count of at least ~0.7s, not a jump
40
+ expect(frames).toBeGreaterThan(40)
41
+ })
42
+
43
+ it('never moves backwards or overshoots', () => {
44
+ expect(approachProgress(60, 40, FRAME)).toBe(60)
45
+ expect(approachProgress(0, 90, -0.008)).toBe(0)
46
+ expect(approachProgress(99.9, 100, 1)).toBe(100)
47
+ })
48
+ })
@@ -0,0 +1,53 @@
1
+ import type { LoadingConfig } from '../types/loading'
2
+
3
+ /** Fallbacks for keys app.config leaves unset - core's app.config ships the same values. */
4
+ const LOADING_DEFAULTS: LoadingConfig = {
5
+ enabled: true,
6
+ minDuration: 1500,
7
+ maxDuration: 3000,
8
+ label: 'Loading',
9
+ background: '#0a0a0a',
10
+ textColor: '#ffffff',
11
+ zIndex: 10000,
12
+ }
13
+
14
+ /** Reads `coreLayer.loading` from app config, filling unset keys with the defaults. */
15
+ export function resolveLoadingConfig(coreLayer: unknown): LoadingConfig {
16
+ const loading = (coreLayer as { loading?: Partial<LoadingConfig> } | undefined)?.loading
17
+ return { ...LOADING_DEFAULTS, ...loading }
18
+ }
19
+
20
+ /*
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.
24
+ */
25
+
26
+ /** Simulated progress stays below this until loading actually finishes. */
27
+ const TRICKLE_CAP = 90
28
+ /** Time constant (s) of the displayed value's exponential ease toward its target. */
29
+ const EASE = 0.15
30
+ /** Speed band (%/s) for the displayed value: no crawling at the tail, no leaps on big jumps. */
31
+ const MIN_SPEED = 24
32
+ const MAX_SPEED = 140
33
+
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
+ /**
43
+ * Advances `current` toward `target` over `dt` seconds. Eases out as the gap closes, within the
44
+ * speed band, and never moves backwards or past `target`. A rAF timestamp can precede the
45
+ * `performance.now()` the loop started from, so `dt <= 0` is a no-op rather than a step back.
46
+ */
47
+ export function approachProgress(current: number, target: number, dt: number): number {
48
+ const gap = target - current
49
+ if (gap <= 0 || dt <= 0) return current
50
+ const eased = gap * (1 - Math.exp(-dt / EASE))
51
+ const step = Math.min(Math.max(eased, MIN_SPEED * dt), MAX_SPEED * dt)
52
+ return Math.min(current + step, target)
53
+ }
@@ -1,3 +1,4 @@
1
+ import { gsap } from 'gsap'
1
2
  import { ScrollTrigger } from 'gsap/ScrollTrigger'
2
3
  import LocomotiveScroll from 'locomotive-scroll'
3
4
 
@@ -15,10 +16,19 @@ export default defineNuxtPlugin({
15
16
  })
16
17
 
17
18
  const instance = shallowRef<LocomotiveScroll | null>(null)
19
+ const { screen } = useLoading()
20
+
21
+ // Under the loading screen Lenis must be stopped, not just left un-started: an unstopped
22
+ // Lenis still takes wheel/touch input and replays it as a jump once it starts
23
+ function holdForLoadingScreen(scroll: LocomotiveScroll) {
24
+ if (screen.value === 'visible') scroll.lenisInstance?.stop()
25
+ }
18
26
 
19
27
  function init() {
20
28
  if (instance.value) return
21
29
  instance.value = new LocomotiveScroll({
30
+ // Under core's boot loading screen, wait for its exit to start (watcher below)
31
+ autoStart: screen.value !== 'visible',
22
32
  lenisOptions: {
23
33
  lerp: 0.1,
24
34
  smoothWheel: true,
@@ -33,6 +43,7 @@ export default defineNuxtPlugin({
33
43
  ScrollTrigger.update()
34
44
  },
35
45
  })
46
+ holdForLoadingScreen(instance.value)
36
47
 
37
48
  // ScrollTriggers created before Lenis exists (page:finish can fire first)
38
49
  // refresh against a null instance — remeasure once real layout settles.
@@ -45,6 +56,25 @@ export default defineNuxtPlugin({
45
56
  instance.value = null
46
57
  }
47
58
 
59
+ // Hold motion until core's boot loading screen reveals the page: GSAP stays paused while the
60
+ // screen is up, so entrance animations play once it's gone; scrolling unlocks as it exits.
61
+ // Without a mounted screen the state stays 'hidden' and GSAP is never touched.
62
+ watch(
63
+ screen,
64
+ (state) => {
65
+ gsap.globalTimeline.paused(state !== 'hidden')
66
+
67
+ if (state !== 'leaving') return
68
+ instance.value?.start()
69
+ // Remeasure once the screen's scroll lock is off the page
70
+ requestAnimationFrame(() => {
71
+ instance.value?.resize()
72
+ ScrollTrigger.refresh()
73
+ })
74
+ },
75
+ { immediate: screen.value !== 'hidden' }
76
+ )
77
+
48
78
  const router = useRouter()
49
79
  const appConfig = useAppConfig()
50
80
  const scrollConfig = appConfig.scroll as { smoothScroll?: boolean | string[] } | undefined
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kmcom-nuxt-layers",
3
3
  "private": false,
4
- "version": "6.1.0",
4
+ "version": "6.2.1",
5
5
  "description": "Composable Nuxt 4 layers for building scalable Vue applications",
6
6
  "exports": {
7
7
  "./layers/core": "./layers/core/nuxt.config.ts",