kmcom-nuxt-layers 6.0.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/docs/USAGE.md +8 -8
  2. package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +58 -0
  3. package/layers/core/app/app.config.ts +6 -3
  4. package/layers/core/app/assets/css/tokens/spacing-fluid.css +24 -19
  5. package/layers/core/app/components/Element.test.ts +2 -2
  6. package/layers/core/app/components/LoadingScreen.vue +142 -76
  7. package/layers/core/app/composables/useLayoutAttrs.test.ts +35 -5
  8. package/layers/core/app/composables/useLayoutAttrs.ts +1 -1
  9. package/layers/core/app/composables/useLoading.ts +57 -36
  10. package/layers/core/app/plugins/loading.client.ts +21 -27
  11. package/layers/core/app/types/loading.ts +18 -8
  12. package/layers/core/app/utils/index.ts +1 -0
  13. package/layers/core/app/utils/loading.test.ts +48 -0
  14. package/layers/core/app/utils/loading.ts +53 -0
  15. package/layers/core/app/utils/resolveElementStyle.test.ts +2 -2
  16. package/layers/design-system/theming/app/types/designSystem.ts +21 -17
  17. package/layers/design-system/typography/app/app.config.ts +26 -0
  18. package/layers/design-system/typography/app/assets/css/typography.css +216 -187
  19. package/layers/design-system/typography/app/components/Typography/CodeBlock.vue +4 -4
  20. package/layers/design-system/typography/app/components/Typography/Headline.test.ts +36 -0
  21. package/layers/design-system/typography/app/components/Typography/Headline.vue +20 -21
  22. package/layers/design-system/typography/app/components/Typography/QuoteBlock.vue +4 -4
  23. package/layers/design-system/typography/app/components/Typography/RepeatText.vue +7 -6
  24. package/layers/design-system/typography/app/components/Typography/index.test.ts +31 -0
  25. package/layers/design-system/typography/app/components/Typography/index.vue +10 -8
  26. package/layers/design-system/typography/app/composables/typography.test.ts +53 -0
  27. package/layers/design-system/typography/app/composables/typography.ts +28 -43
  28. package/layers/design-system/typography/app/types/typography.ts +5 -32
  29. package/layers/motion/scroll/app/plugins/locomotive-scroll.client.ts +22 -0
  30. package/layers/structure/grid/app/assets/css/grid.css +11 -7
  31. package/package.json +1 -1
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
 
@@ -0,0 +1,58 @@
1
+ # 0003 — One modular step scale replaces the fluid type and spacing clamps
2
+
3
+ Status: accepted (2026-10-20)
4
+
5
+ ## Context
6
+
7
+ Type and spacing were two unrelated sets of hand-tuned `clamp()` ranges: 13 `text-fluid-*`
8
+ utilities (plus 13 `-cq` copies) snapped by `round(down, …, 0.25rem)`, and 8 `--fluid-space-*`
9
+ tokens (plus 7 `-cq` copies). No ratio linked one size to the next, so every new size was
10
+ another clamp to tune, and the `-cq` sets had no consumers. The reference system (matuzo.at,
11
+ analysed in `matuzo-typography-system-1.md`) gets better proportions from two ratios.
12
+
13
+ ## Decision
14
+
15
+ Every size is a step on a scale:
16
+
17
+ - **Type:** `font-size = round(--type-base × 1.26^--step, 0.0625rem)`. Base is `1rem` below
18
+ 48rem and `1.2rem` from 48rem; body is step 1 (20 → 24px). `h1` uses a fluid display step,
19
+ 5 → 7 across 360 → 1280px, cast from a length with `tan(atan2(…, 1rem))`. Utilities are
20
+ `text-step-sm`, `text-step-0` … `text-step-12`, `text-step-display`.
21
+ - **Space:** `--space-* = --space-base × 1.414^n`, with `--space-base` running 11.3 → 16px
22
+ across 320 → 1280px — one step of growth, not the reference's two (8 → 16px), so phones keep
23
+ the old token sizes. The `Spacing` union is unchanged; only the values behind it moved.
24
+ - **Grid:** the `grid` layer's `--fs` shares the type scale's endpoint tokens (`--type-min`,
25
+ `--type-max`) but stays continuous, so its rhythm unit does not inherit the 48rem jump.
26
+
27
+ It is a parallel namespace: Tailwind's `--text-*`, Nuxt UI's `--ui-*` and the `html` font-size
28
+ are untouched. Tokens are plain `:root` properties, not `@theme`, so `var(--step)` resolves on
29
+ the element using it.
30
+
31
+ ## Consequences
32
+
33
+ - The top end is coarser: `TypographyHeadline` levels step down from the fluid display step
34
+ (97/77/61/48/38/30px at 1280px, 51 → 16px at 320px) instead of the old 144/120/96/76/36/24px,
35
+ which also overflowed phone widths. Plain h1–h6 use the reading scale (display, 3, 2, 1, 0, 0).
36
+ - Step line heights fall smoothly with size (sm 1.5 … body 1.7, h2/h3 1.2 … display 0.95) and
37
+ now reach components: `useTypography` only emits `leading-*` when asked.
38
+ - Body text grows from 16px to 20/24px, and body line-height from 1.5 to 1.7. Nuxt UI
39
+ components with explicit `text-*` sizes are unaffected; anything that inherits body size
40
+ (card bodies, plain prose) grows. Heading defaults skip `[data-slot]` headings, so Nuxt UI's
41
+ own titles (UModal, USlideover) inherit instead of jumping to a heading step.
42
+ - Every step jumps ×1.2 at 48rem because the base switches there (h1 ≈ 62 → 75px at 768px).
43
+ - Spacing matches the old tokens on phones and stays close on desktop for xs–xl; 2xl/3xl top out
44
+ smaller than before (64/90px vs 80/112px).
45
+ - `--space-*` steps are substituted at `:root`, so per-subtree spacing retuning is not
46
+ supported. Type retuning is: `--type-base` and `--type-scale` are read per element.
47
+ - The display step is normalised by `1rem` and its `vw` term is scaled by root / 16px, so its
48
+ exponent depends only on viewport width and every size grows with a larger root font size.
49
+ (Unscaled, the exponent fell as the root grew: h1 was only 1.02× larger at a 24px root.)
50
+ - The old `--heading-text-scale-damp` multiplier is gone. As written it enlarged steps ≥ 7
51
+ under OS text-scale > 100% instead of damping them; a true damp would break "visibly larger
52
+ at a larger root".
53
+ - `--fs` stays unregistered: a registered `<length>` holding `rem` on `:root`, read by the
54
+ root's own line-height, is a dependency cycle in Gecko that freezes the grid unit.
55
+ - Nuxt UI prose is retuned onto the scale (typography `app.config.ts`): prose headings take
56
+ steps (h1 4, h2 3, h3 2, h4 1) and `p`/`li` keep the body's 1.7 leading. Otherwise the
57
+ 24px copy sat at or above Nuxt UI's fixed `text-2xl`/`text-xl` prose headings. This needs
58
+ `text-step-*` registered as a font-size group in tailwind-merge (`ui.tv.twMergeConfig`).
@@ -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,25 +1,30 @@
1
1
  /**
2
- * Fluid spacing scale — clamp() + cqi container-relative variant, same
3
- * pattern as layers/typography's fluid type scale (see typography.css).
2
+ * Fluid spacing scale — one ratio, same idea as typography.css's type scale:
3
+ * space = --space-base × --space-scale ^ n
4
+ * 1.414 ≈ 2^(1/2), so two steps double a gap. --space-base runs 11.3px at ≤320px to 16px
5
+ * at ≥1280px — one step of growth (√2), not the reference's two (8 → 16px), so phones keep
6
+ * the old fluid tokens' sizes (xs 8 · md 16 · xl 32 · 3xl 64px at 320px). The rem term in the
7
+ * middle keeps it zoom-safe (WCAG 1.4.4).
4
8
  * Backs the `Spacing` token (layers/core/app/types/tokens.ts) consumed by
5
- * useLayoutAttrs(). --fluid-space-none is 0, not clamp() — no scaling needed.
9
+ * useLayoutAttrs(). --space-none is 0, not a step.
10
+ * Plain :root properties, not @theme: --space-* is not a Tailwind namespace, and
11
+ * xs/md/xl declared there would risk colliding with --container-* size names.
12
+ *
13
+ * Trap: each step substitutes --space-base here at :root, so overriding --space-base
14
+ * on a subtree does NOT change --space-md inside it. Per-subtree spacing retuning is
15
+ * not a supported extension point (type retuning is — see typography.css).
6
16
  */
7
17
  :root {
8
- --fluid-space-none: 0px;
9
- --fluid-space-xs: clamp(0.5rem, 0.46rem + 0.2vi, 0.625rem);
10
- --fluid-space-sm: clamp(0.75rem, 0.7rem + 0.25vi, 1rem);
11
- --fluid-space-md: clamp(1rem, 0.9rem + 0.5vi, 1.5rem);
12
- --fluid-space-lg: clamp(1.5rem, 1.3rem + 1vi, 2.5rem);
13
- --fluid-space-xl: clamp(2rem, 1.7rem + 1.5vi, 3.5rem);
14
- --fluid-space-2xl: clamp(3rem, 2.4rem + 3vi, 5rem);
15
- --fluid-space-3xl: clamp(4rem, 3rem + 5vi, 7rem);
18
+ --space-scale: 1.414;
19
+ --space-base: clamp(0.7071rem, 0.6095rem + 0.4882vw, 1rem);
16
20
 
17
- /* Container-relative counterparts (cqi) — opt-in inside a `container-type` ancestor. */
18
- --fluid-space-xs-cq: clamp(0.5rem, 0.46rem + 2cqi, 0.625rem);
19
- --fluid-space-sm-cq: clamp(0.75rem, 0.65rem + 2.5cqi, 1rem);
20
- --fluid-space-md-cq: clamp(1rem, 0.8rem + 5cqi, 1.5rem);
21
- --fluid-space-lg-cq: clamp(1.5rem, 1.1rem + 10cqi, 2.5rem);
22
- --fluid-space-xl-cq: clamp(2rem, 1.4rem + 15cqi, 3.5rem);
23
- --fluid-space-2xl-cq: clamp(3rem, 2rem + 25cqi, 5rem);
24
- --fluid-space-3xl-cq: clamp(4rem, 2.5rem + 37.5cqi, 7rem);
21
+ /* n = −1 … 5 */
22
+ --space-none: 0px;
23
+ --space-xs: calc(var(--space-base) * pow(var(--space-scale), -1));
24
+ --space-sm: calc(var(--space-base) * pow(var(--space-scale), 0));
25
+ --space-md: calc(var(--space-base) * pow(var(--space-scale), 1));
26
+ --space-lg: calc(var(--space-base) * pow(var(--space-scale), 2));
27
+ --space-xl: calc(var(--space-base) * pow(var(--space-scale), 3));
28
+ --space-2xl: calc(var(--space-base) * pow(var(--space-scale), 4));
29
+ --space-3xl: calc(var(--space-base) * pow(var(--space-scale), 5));
25
30
  }
@@ -69,8 +69,8 @@ describe('Element', () => {
69
69
  props: { m: 'md', p: 'lg' },
70
70
  })
71
71
  const style = wrapper.attributes('style') ?? ''
72
- expect(style).toContain('margin: var(--fluid-space-md)')
73
- expect(style).toContain('padding: var(--fluid-space-lg)')
72
+ expect(style).toContain('margin: var(--space-md)')
73
+ expect(style).toContain('padding: var(--space-lg)')
74
74
  })
75
75
 
76
76
  it('passes componentProps to the resolved component, past a colliding prop name', () => {
@@ -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,21 +1,51 @@
1
+ import { readFileSync } from 'node:fs'
2
+
1
3
  import { describe, expect, it } from 'vite-plus/test'
2
4
 
5
+ import type { Spacing } from '../types/tokens'
3
6
  import { useLayoutAttrs } from './useLayoutAttrs'
4
7
 
8
+ // spacingVar() builds the custom-property name by string interpolation, so a token
9
+ // with no matching declaration resolves to an undefined var() — silent in CSS.
10
+ describe('useLayoutAttrs spacing tokens', () => {
11
+ const css = readFileSync(
12
+ new URL('../assets/css/tokens/spacing-fluid.css', import.meta.url),
13
+ 'utf8'
14
+ )
15
+ // Record, not an array, so typecheck fails if the Spacing union gains a member.
16
+ const tokens: Record<Exclude<Spacing, 'none'>, true> = {
17
+ xs: true,
18
+ sm: true,
19
+ md: true,
20
+ lg: true,
21
+ xl: true,
22
+ '2xl': true,
23
+ '3xl': true,
24
+ }
25
+
26
+ it('resolves every token to a property spacing-fluid.css declares', () => {
27
+ for (const token of Object.keys(tokens) as Spacing[]) {
28
+ const { style } = useLayoutAttrs(() => ({ gap: token }))
29
+ const name = /^var\((--[\w-]+)\)$/.exec(String(style.value.gap))?.[1]
30
+ expect(css).toMatch(new RegExp(`^\\s*${name}:`, 'm'))
31
+ }
32
+ })
33
+ })
34
+
5
35
  describe('useLayoutAttrs margin support', () => {
6
36
  it('sets margin from a static token', () => {
7
37
  const { style } = useLayoutAttrs(() => ({ m: 'md' }))
8
- expect(style.value.margin).toBe('var(--fluid-space-md)')
38
+ expect(style.value.margin).toBe('var(--space-md)')
9
39
  })
10
40
 
11
41
  it('sets marginInline from px-equivalent mx', () => {
12
42
  const { style } = useLayoutAttrs(() => ({ mx: 'lg' }))
13
- expect(style.value.marginInline).toBe('var(--fluid-space-lg)')
43
+ expect(style.value.marginInline).toBe('var(--space-lg)')
14
44
  })
15
45
 
16
46
  it('sets marginBlock from my', () => {
17
47
  const { style } = useLayoutAttrs(() => ({ my: 'sm' }))
18
- expect(style.value.marginBlock).toBe('var(--fluid-space-sm)')
48
+ expect(style.value.marginBlock).toBe('var(--space-sm)')
19
49
  })
20
50
 
21
51
  it('resolves none to 0px for margin', () => {
@@ -38,8 +68,8 @@ describe('useLayoutAttrs margin support', () => {
38
68
  p: 'lg',
39
69
  }))
40
70
  expect(style.value).toEqual({
41
- gap: 'var(--fluid-space-md)',
42
- padding: 'var(--fluid-space-lg)',
71
+ gap: 'var(--space-md)',
72
+ padding: 'var(--space-lg)',
43
73
  alignItems: 'center',
44
74
  justifyContent: 'space-between',
45
75
  })
@@ -47,7 +47,7 @@ function resolveDefault<T>(value: ResponsiveValue<T> | T | undefined): T | undef
47
47
  }
48
48
 
49
49
  function spacingVar(token: Spacing): string {
50
- return token === 'none' ? '0px' : `var(--fluid-space-${token})`
50
+ return token === 'none' ? '0px' : `var(--space-${token})`
51
51
  }
52
52
 
53
53
  function applySpacing(
@@ -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,