@uniweb/kit 0.9.30 → 0.9.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,7 +39,7 @@
39
39
  "fuse.js": "^7.0.0",
40
40
  "shiki": "^3.0.0",
41
41
  "tailwind-merge": "^3.6.0",
42
- "@uniweb/core": "0.7.23",
42
+ "@uniweb/core": "0.7.25",
43
43
  "@uniweb/scene": "0.1.2"
44
44
  },
45
45
  "peerDependencies": {
@@ -22,40 +22,31 @@
22
22
  */
23
23
 
24
24
  import { useState, useEffect, useCallback } from 'react'
25
- import { getUniweb, Theme } from '@uniweb/core'
25
+ import { getUniweb, Theme, hasDarkScheme } from '@uniweb/core'
26
26
 
27
27
  // Storage key for appearance preference
28
28
  const APPEARANCE_STORAGE_KEY = 'uniweb-appearance'
29
29
 
30
- // CSS class for dark scheme
30
+ // CSS classes for the two schemes
31
31
  const DARK_SCHEME_CLASS = 'scheme-dark'
32
+ const LIGHT_SCHEME_CLASS = 'scheme-light'
32
33
 
33
34
  /**
34
- * Get the initial scheme from storage or system preference
35
+ * Read the scheme currently applied to the document.
36
+ *
37
+ * The runtime resolves and applies the boot scheme before React renders (see
38
+ * @uniweb/runtime's appearance.js), so this hook reads that result rather than
39
+ * re-deriving it from config + localStorage. One resolver, one boot-time writer.
40
+ *
41
+ * Deriving it independently here is what used to break: the runtime and this
42
+ * hook each applied their own answer, and the runtime's — which ignored the
43
+ * stored preference — won, because React runs child effects before parent ones.
35
44
  *
36
- * @param {Object} appearance - Theme appearance configuration
37
45
  * @returns {string} 'light' or 'dark'
38
46
  */
39
- function getInitialScheme(appearance) {
40
- // Check localStorage first
41
- if (typeof localStorage !== 'undefined') {
42
- const stored = localStorage.getItem(APPEARANCE_STORAGE_KEY)
43
- if (stored === 'light' || stored === 'dark') {
44
- return stored
45
- }
46
- }
47
-
48
- // Check system preference if respectSystemPreference is enabled
49
- if (appearance?.respectSystemPreference && typeof window !== 'undefined') {
50
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
51
- if (prefersDark && appearance.schemes?.includes('dark')) {
52
- return 'dark'
53
- }
54
- }
55
-
56
- // Fall back to default
57
- const defaultScheme = appearance?.default || 'light'
58
- return defaultScheme === 'system' ? 'light' : defaultScheme
47
+ function readAppliedScheme() {
48
+ if (typeof document === 'undefined') return 'light'
49
+ return document.documentElement.classList.contains(DARK_SCHEME_CLASS) ? 'dark' : 'light'
59
50
  }
60
51
 
61
52
  /**
@@ -150,17 +141,25 @@ export function useAppearance() {
150
141
  const theme = useThemeData()
151
142
  const appearance = theme?.getAppearance() || { default: 'light', allowToggle: false }
152
143
 
153
- const [scheme, setSchemeState] = useState(() => getInitialScheme(appearance))
144
+ const [scheme, setSchemeState] = useState(readAppliedScheme)
154
145
 
155
- // Apply scheme to document
146
+ // Apply scheme to document.
147
+ //
148
+ // Sets an explicit class both ways rather than relying on the absence of one:
149
+ // `default: 'system'` themes generate a `@media (prefers-color-scheme: dark)`
150
+ // block scoped to `:root:not(.scheme-light)`, so forcing light on a dark OS
151
+ // needs `scheme-light` present — removing `scheme-dark` alone would leave the
152
+ // media query still applying dark tokens.
156
153
  const applyScheme = useCallback((newScheme) => {
157
- if (typeof document !== 'undefined') {
158
- const root = document.documentElement
159
- if (newScheme === 'dark') {
160
- root.classList.add(DARK_SCHEME_CLASS)
161
- } else {
162
- root.classList.remove(DARK_SCHEME_CLASS)
163
- }
154
+ if (typeof document === 'undefined') return
155
+
156
+ const root = document.documentElement
157
+ if (newScheme === 'dark') {
158
+ root.classList.add(DARK_SCHEME_CLASS)
159
+ root.classList.remove(LIGHT_SCHEME_CLASS)
160
+ } else {
161
+ root.classList.add(LIGHT_SCHEME_CLASS)
162
+ root.classList.remove(DARK_SCHEME_CLASS)
164
163
  }
165
164
  }, [])
166
165
 
@@ -186,10 +185,9 @@ export function useAppearance() {
186
185
  setScheme(newScheme)
187
186
  }, [scheme, setScheme])
188
187
 
189
- // Apply initial scheme on mount
190
- useEffect(() => {
191
- applyScheme(scheme)
192
- }, []) // eslint-disable-line react-hooks/exhaustive-deps
188
+ // No mount effect applying the initial scheme: the runtime already applied it
189
+ // before React rendered, and `scheme` was read back from that. Re-applying
190
+ // here is what made this hook a second writer.
193
191
 
194
192
  // Listen for system preference changes
195
193
  useEffect(() => {
@@ -200,19 +198,37 @@ export function useAppearance() {
200
198
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
201
199
 
202
200
  const handleChange = (e) => {
203
- // Only auto-switch if user hasn't manually set preference
204
- const stored = localStorage.getItem(APPEARANCE_STORAGE_KEY)
205
- if (!stored) {
206
- const newScheme = e.matches ? 'dark' : 'light'
207
- if (appearance.schemes?.includes(newScheme)) {
208
- setScheme(newScheme)
209
- }
201
+ // Only auto-switch if the visitor hasn't manually set a preference
202
+ let stored = null
203
+ try {
204
+ stored = localStorage.getItem(APPEARANCE_STORAGE_KEY)
205
+ } catch {
206
+ // Safari private mode throws on access — treat as "no preference"
210
207
  }
208
+ if (stored) return
209
+
210
+ const newScheme = e.matches ? 'dark' : 'light'
211
+ // Light is always reachable; only going dark needs the site to support it.
212
+ // Uses the same @uniweb/core predicate as the runtime's boot resolver, so
213
+ // this mid-session path and first-visit agree on when dark is allowed.
214
+ if (newScheme === 'dark' && !hasDarkScheme(appearance)) return
215
+
216
+ // Track the OS without persisting. Going through setScheme() would write
217
+ // to localStorage, which reads back as a manual preference and stops this
218
+ // listener from ever following the OS again.
219
+ setSchemeState(newScheme)
220
+ applyScheme(newScheme)
211
221
  }
212
222
 
213
223
  mediaQuery.addEventListener('change', handleChange)
214
224
  return () => mediaQuery.removeEventListener('change', handleChange)
215
- }, [appearance.respectSystemPreference, appearance.schemes, setScheme])
225
+ }, [
226
+ appearance.respectSystemPreference,
227
+ appearance.allowToggle,
228
+ appearance.default,
229
+ appearance.schemes,
230
+ applyScheme,
231
+ ])
216
232
 
217
233
  return {
218
234
  scheme,