@kolkrabbi/kol-framework 0.3.5 → 0.4.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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/ThemeToggle.jsx +55 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "description": "KOL app shell — sidenav, layout, theme toggle, footer, heroes + brand color layer. SideNav takes nav data as props so the tree stays app-local.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,8 +27,8 @@
27
27
  "app-shell"
28
28
  ],
29
29
  "dependencies": {
30
- "@kolkrabbi/kol-component": "0.9.0",
31
- "@kolkrabbi/kol-icons": "0.5.0"
30
+ "@kolkrabbi/kol-component": "0.10.0",
31
+ "@kolkrabbi/kol-icons": "0.6.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^18.3.0 || ^19.0.0",
@@ -3,9 +3,24 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
 
4
4
  const STORAGE_KEY = 'kol-theme'
5
5
 
6
+ /* An explicit choice is an app-set data-theme attribute or a saved user
7
+ * toggle. Absent both, the DS is light-first and follows the system
8
+ * (prefers-color-scheme) — matching the theme CSS, which only darkens via
9
+ * explicit [data-theme="dark"] or the media query. */
10
+ function getExplicitTheme() {
11
+ const attr = document.documentElement.dataset.theme
12
+ if (attr === 'light' || attr === 'dark') return attr
13
+ try {
14
+ const saved = localStorage.getItem(STORAGE_KEY)
15
+ if (saved === 'light' || saved === 'dark') return saved
16
+ } catch { /* storage blocked */ }
17
+ return null
18
+ }
19
+
6
20
  function getInitialTheme() {
7
- if (typeof document === 'undefined') return 'dark'
8
- return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'
21
+ if (typeof document === 'undefined') return 'light'
22
+ return getExplicitTheme()
23
+ ?? (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light')
9
24
  }
10
25
 
11
26
  /**
@@ -22,22 +37,50 @@ function getInitialTheme() {
22
37
  * bg, no hover bg). For sidenav rows where the row should read
23
38
  * as plain text + icon, not a button.
24
39
  *
25
- * Self-contained: owns theme state, writes `data-theme` to <html>, persists
26
- * to localStorage under `kol-theme`. The icon-swap animation slides a pair
27
- * of half-split theme-toggle icons horizontally — the `theme-toggle` SVG is
28
- * designed as a split circle, so the slide produces a visible light/dark flip.
40
+ * Self-contained but never imposing: initial state is the app-set
41
+ * `data-theme` attribute, else the user's saved toggle (`kol-theme`), else
42
+ * the system preference. `data-theme` + localStorage are written ONLY on a
43
+ * user toggle — mounting the component leaves the DOM untouched, so an
44
+ * undecided page keeps following prefers-color-scheme via the theme CSS.
45
+ * The icon-swap animation slides a doubled `mode-toggle-01` (v1 set)
46
+ * horizontally — the split-circle glyph makes the slide itself read as
47
+ * the flip (same-icon slide, per design).
29
48
  */
30
49
  export default function ThemeToggle({ variant = 'icon', className = '' }) {
31
50
  const [theme, setTheme] = useState(getInitialTheme)
32
51
 
33
52
  useEffect(() => {
34
- document.documentElement.dataset.theme = theme
35
- try { localStorage.setItem(STORAGE_KEY, theme) } catch { /* storage blocked */ }
36
- }, [theme])
53
+ /* Re-stamp a previously saved user choice so persistence works in apps
54
+ * with no theme boot script — but only when the app itself hasn't set
55
+ * data-theme (an app-set attribute always wins over storage). */
56
+ const attr = document.documentElement.dataset.theme
57
+ let saved = null
58
+ try { saved = localStorage.getItem(STORAGE_KEY) } catch { /* storage blocked */ }
59
+ if (attr !== 'light' && attr !== 'dark') {
60
+ if (saved === 'light' || saved === 'dark') {
61
+ document.documentElement.dataset.theme = saved
62
+ return
63
+ }
64
+ /* No explicit choice anywhere — CSS follows the system on its own;
65
+ * this listener only keeps the toggle's icon in sync with it. */
66
+ const mq = window.matchMedia?.('(prefers-color-scheme: dark)')
67
+ if (!mq?.addEventListener) return
68
+ const onChange = (e) => {
69
+ try { if (localStorage.getItem(STORAGE_KEY)) return } catch { /* storage blocked */ }
70
+ setTheme(e.matches ? 'dark' : 'light')
71
+ }
72
+ mq.addEventListener('change', onChange)
73
+ return () => mq.removeEventListener('change', onChange)
74
+ }
75
+ }, [])
37
76
 
38
77
  const isDark = theme === 'dark'
39
78
  const next = isDark ? 'light' : 'dark'
40
- const handleToggle = () => setTheme(next)
79
+ const handleToggle = () => {
80
+ setTheme(next)
81
+ document.documentElement.dataset.theme = next
82
+ try { localStorage.setItem(STORAGE_KEY, next) } catch { /* storage blocked */ }
83
+ }
41
84
 
42
85
  const iconSwap = (size) => (
43
86
  <span
@@ -49,8 +92,8 @@ export default function ThemeToggle({ variant = 'icon', className = '' }) {
49
92
  className="flex transition-transform duration-500 ease-in-out"
50
93
  style={{ width: size * 2, transform: isDark ? 'translateX(0)' : `translateX(-${size}px)` }}
51
94
  >
52
- <Icon name="theme-toggle" size={size} />
53
- <Icon name="theme-toggle" size={size} />
95
+ <Icon name="mode-toggle-01" size={size} />
96
+ <Icon name="mode-toggle-01" size={size} />
54
97
  </span>
55
98
  </span>
56
99
  )