@kolkrabbi/kol-framework 0.5.13 → 0.6.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/package.json +1 -1
- package/src/ThemeToggle.jsx +30 -18
- package/src/theme.js +48 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
package/src/ThemeToggle.jsx
CHANGED
|
@@ -2,7 +2,14 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
2
2
|
import { useTheme } from './theme.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Theme toggle — horizontal icon-swap button.
|
|
5
|
+
* Theme toggle — horizontal icon-swap button, tri-state (0.6.0).
|
|
6
|
+
*
|
|
7
|
+
* Clicking CYCLES light → dark → system → light. 'system' is the no-choice
|
|
8
|
+
* state: the stamp + saved key are cleared and the page follows
|
|
9
|
+
* prefers-color-scheme live (USER law: explicit choice > system/auto > light).
|
|
10
|
+
* The glyph shows the current mode — the split-circle `mode-toggle-01` for
|
|
11
|
+
* light/dark (rolling + spinning exactly as before), the v1 `desktop` glyph
|
|
12
|
+
* for system.
|
|
6
13
|
*
|
|
7
14
|
* Variants:
|
|
8
15
|
* icon — icon-only button on the DS button ladder: `size` 'sm'|'md'|'lg'
|
|
@@ -23,24 +30,26 @@ import { useTheme } from './theme.js'
|
|
|
23
30
|
*
|
|
24
31
|
* Theme state lives in useTheme (./theme.js) — the exported machinery
|
|
25
32
|
* (initial-state resolution, saved-choice re-stamp, system-follow, cross-
|
|
26
|
-
* instance sync). This component is only the
|
|
27
|
-
* The icon-swap slides a doubled `mode-toggle-01` (v1 set) horizontally
|
|
28
|
-
* while both glyphs spin 180° in the same clock — the split-circle rolls
|
|
29
|
-
* forward (>>) on dark→light and rolls back (<<) on light→dark (the
|
|
30
|
-
* original roll behavior, restored 2026-07-29).
|
|
33
|
+
* instance sync). This component is only the glyph-roll UI on top of it.
|
|
31
34
|
*/
|
|
35
|
+
const MODE_LABEL = { light: 'Light mode', dark: 'Dark mode', system: 'System' }
|
|
36
|
+
const NEXT_MODE = { light: 'dark', dark: 'system', system: 'light' }
|
|
37
|
+
|
|
32
38
|
export default function ThemeToggle({ variant = 'icon', size = 'md', className = '' }) {
|
|
33
|
-
const { isDark,
|
|
39
|
+
const { isDark, mode, cycle } = useTheme()
|
|
34
40
|
|
|
35
|
-
const next =
|
|
36
|
-
const
|
|
41
|
+
const next = NEXT_MODE[mode]
|
|
42
|
+
const nextLabel = next === 'system' ? 'system theme' : `${next} mode`
|
|
43
|
+
const handleToggle = cycle
|
|
37
44
|
|
|
38
|
-
/* The roll: the strip slides one glyph-width
|
|
39
|
-
* the same clock — dark→light rolls forward (>>),
|
|
45
|
+
/* The roll: the strip slides one glyph-width per mode while the split-circle
|
|
46
|
+
* glyphs spin 180° in the same clock — dark→light rolls forward (>>),
|
|
47
|
+
* light→dark rolls back (<<), system slides on to the desktop glyph. */
|
|
40
48
|
const glyphSpin = {
|
|
41
49
|
transform: isDark ? 'rotate(0deg)' : 'rotate(180deg)',
|
|
42
50
|
lineHeight: 0,
|
|
43
51
|
}
|
|
52
|
+
const slot = mode === 'dark' ? 0 : mode === 'light' ? 1 : 2
|
|
44
53
|
const iconSwap = (size) => (
|
|
45
54
|
<span
|
|
46
55
|
className="relative inline-block overflow-hidden"
|
|
@@ -49,7 +58,7 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
49
58
|
>
|
|
50
59
|
<span
|
|
51
60
|
className="flex transition-transform duration-500 ease-in-out"
|
|
52
|
-
style={{ width: size *
|
|
61
|
+
style={{ width: size * 3, transform: `translateX(-${slot * size}px)` }}
|
|
53
62
|
>
|
|
54
63
|
<span className="inline-flex transition-transform duration-500 ease-in-out" style={glyphSpin}>
|
|
55
64
|
<Icon name="mode-toggle-01" size={size} />
|
|
@@ -57,6 +66,9 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
57
66
|
<span className="inline-flex transition-transform duration-500 ease-in-out" style={glyphSpin}>
|
|
58
67
|
<Icon name="mode-toggle-01" size={size} />
|
|
59
68
|
</span>
|
|
69
|
+
<span className="inline-flex" style={{ lineHeight: 0 }}>
|
|
70
|
+
<Icon name="desktop" size={size} />
|
|
71
|
+
</span>
|
|
60
72
|
</span>
|
|
61
73
|
</span>
|
|
62
74
|
)
|
|
@@ -72,11 +84,11 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
72
84
|
<button
|
|
73
85
|
type="button"
|
|
74
86
|
onClick={handleToggle}
|
|
75
|
-
aria-label={`Switch to ${
|
|
87
|
+
aria-label={`Switch to ${nextLabel}`}
|
|
76
88
|
className={`kol-btn kol-btn-primary kol-btn-${size} ${mono} gap-2 ${className}`.trim()}
|
|
77
89
|
>
|
|
78
90
|
{iconSwap(glyph)}
|
|
79
|
-
<span>{
|
|
91
|
+
<span>{MODE_LABEL[mode]}</span>
|
|
80
92
|
</button>
|
|
81
93
|
)
|
|
82
94
|
}
|
|
@@ -90,7 +102,7 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
90
102
|
<button
|
|
91
103
|
type="button"
|
|
92
104
|
onClick={handleToggle}
|
|
93
|
-
aria-label={`Switch to ${
|
|
105
|
+
aria-label={`Switch to ${nextLabel}`}
|
|
94
106
|
className={`${chromeCls} ${className}`.trim()}
|
|
95
107
|
>
|
|
96
108
|
<span className="inline-flex items-center justify-center shrink-0" aria-hidden="true">
|
|
@@ -100,7 +112,7 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
100
112
|
* at narrow viewports without subjecting the button to the muted
|
|
101
113
|
* sidenav-hop text-color override. */}
|
|
102
114
|
<span className="kol-sidenav-hop-label flex-1 min-w-0 text-left">
|
|
103
|
-
{
|
|
115
|
+
{MODE_LABEL[mode]}
|
|
104
116
|
</span>
|
|
105
117
|
</button>
|
|
106
118
|
)
|
|
@@ -115,8 +127,8 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
115
127
|
<button
|
|
116
128
|
type="button"
|
|
117
129
|
onClick={handleToggle}
|
|
118
|
-
aria-label={`Switch to ${
|
|
119
|
-
title={`Switch to ${
|
|
130
|
+
aria-label={`Switch to ${nextLabel}`}
|
|
131
|
+
title={`Switch to ${nextLabel}`}
|
|
120
132
|
className={`kol-btn kol-btn-nav kol-btn-${size} kol-btn-icon ${className}`.trim()}
|
|
121
133
|
>
|
|
122
134
|
{iconSwap(glyph)}
|
package/src/theme.js
CHANGED
|
@@ -8,6 +8,11 @@ import { useEffect, useState } from 'react'
|
|
|
8
8
|
* follows prefers-color-scheme (the theme CSS ≥0.11.6 mirrors this via
|
|
9
9
|
* `:root:not([data-theme])`, so state and render agree); light is the
|
|
10
10
|
* last-resort fallback. A consumer stamps data-theme to veto the OS.
|
|
11
|
+
*
|
|
12
|
+
* Tri-state (0.6.0): `mode` is 'light' | 'dark' | 'system'. 'system' is the
|
|
13
|
+
* absence of an explicit choice — applyTheme('system') clears the stamp and
|
|
14
|
+
* the saved key, handing the page back to the OS. `theme` stays the RESOLVED
|
|
15
|
+
* value ('light'|'dark') so existing consumers keep working unchanged.
|
|
11
16
|
* `data-theme` + localStorage are written ONLY through applyTheme (a user
|
|
12
17
|
* action) — mounting a reader leaves the DOM untouched. */
|
|
13
18
|
|
|
@@ -23,14 +28,27 @@ function getExplicitTheme() {
|
|
|
23
28
|
return null
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
/* One resolver for both axes: mode (the choice) + theme (what renders). */
|
|
32
|
+
function resolveThemeState() {
|
|
33
|
+
const explicit = getExplicitTheme()
|
|
34
|
+
if (explicit) return { mode: explicit, theme: explicit }
|
|
35
|
+
const dark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches
|
|
36
|
+
return { mode: 'system', theme: dark ? 'dark' : 'light' }
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
export function getInitialTheme() {
|
|
27
40
|
if (typeof document === 'undefined') return 'light'
|
|
28
|
-
return
|
|
29
|
-
?? (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light')
|
|
41
|
+
return resolveThemeState().theme
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
/** Stamp + persist an explicit theme choice
|
|
44
|
+
/** Stamp + persist an explicit theme choice — or 'system' to clear both and
|
|
45
|
+
* hand the page back to prefers-color-scheme. */
|
|
33
46
|
export function applyTheme(theme) {
|
|
47
|
+
if (theme === 'system') {
|
|
48
|
+
delete document.documentElement.dataset.theme
|
|
49
|
+
try { localStorage.removeItem(THEME_STORAGE_KEY) } catch { /* storage blocked */ }
|
|
50
|
+
return
|
|
51
|
+
}
|
|
34
52
|
document.documentElement.dataset.theme = theme
|
|
35
53
|
try { localStorage.setItem(THEME_STORAGE_KEY, theme) } catch { /* storage blocked */ }
|
|
36
54
|
}
|
|
@@ -41,16 +59,26 @@ export function applyTheme(theme) {
|
|
|
41
59
|
* All instances observe the html `data-theme` attribute, so any writer
|
|
42
60
|
* (this hook's setTheme, ThemeToggle, an app boot script) updates every
|
|
43
61
|
* reader. When the page has no explicit choice, the theme follows the
|
|
44
|
-
* system (explicit > system > light).
|
|
62
|
+
* system (explicit > system > light).
|
|
63
|
+
* Returns { theme, isDark, mode, setTheme, toggle, cycle }:
|
|
64
|
+
* theme — resolved 'light' | 'dark' (what renders right now)
|
|
65
|
+
* mode — 'light' | 'dark' | 'system' (what is chosen)
|
|
66
|
+
* setTheme(t) — stamp 'light'/'dark', or 'system' to follow the OS
|
|
67
|
+
* toggle — binary light↔dark (always an explicit choice; kept for compat)
|
|
68
|
+
* cycle — light → dark → system → light (the tri-state control)
|
|
45
69
|
*/
|
|
46
70
|
export function useTheme() {
|
|
47
|
-
const [
|
|
71
|
+
const [state, setState] = useState(() =>
|
|
72
|
+
typeof document === 'undefined'
|
|
73
|
+
? { mode: 'light', theme: 'light' }
|
|
74
|
+
: resolveThemeState()
|
|
75
|
+
)
|
|
48
76
|
|
|
49
77
|
useEffect(() => {
|
|
50
78
|
/* Re-stamp a previously saved user choice so persistence works in apps
|
|
51
79
|
* with no theme boot script — but only when the app itself hasn't set
|
|
52
80
|
* data-theme (an app-set attribute always wins over storage). Idempotent
|
|
53
|
-
* across instances. */
|
|
81
|
+
* across instances; the observer below picks the stamp up. */
|
|
54
82
|
const attr = document.documentElement.dataset.theme
|
|
55
83
|
let saved = null
|
|
56
84
|
try { saved = localStorage.getItem(THEME_STORAGE_KEY) } catch { /* storage blocked */ }
|
|
@@ -58,38 +86,33 @@ export function useTheme() {
|
|
|
58
86
|
document.documentElement.dataset.theme = saved
|
|
59
87
|
}
|
|
60
88
|
|
|
61
|
-
/*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
89
|
+
/* One sync path for every writer: data-theme mutations (stamp OR removal)
|
|
90
|
+
* and live OS switches both re-resolve. resolveThemeState ignores the OS
|
|
91
|
+
* whenever an explicit choice exists, so the guards are inherent. */
|
|
92
|
+
const sync = () => setState(resolveThemeState())
|
|
93
|
+
const observer = new MutationObserver(sync)
|
|
66
94
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
|
67
|
-
|
|
68
|
-
/* No explicit choice anywhere — follow live OS switches, like the
|
|
69
|
-
* theme CSS's :root:not([data-theme]) block. */
|
|
70
95
|
const mq = window.matchMedia?.('(prefers-color-scheme: dark)')
|
|
71
|
-
|
|
72
|
-
try { if (localStorage.getItem(THEME_STORAGE_KEY)) return } catch { /* storage blocked */ }
|
|
73
|
-
if (document.documentElement.dataset.theme) return
|
|
74
|
-
setThemeState(e.matches ? 'dark' : 'light')
|
|
75
|
-
}
|
|
76
|
-
mq?.addEventListener?.('change', onChange)
|
|
96
|
+
mq?.addEventListener?.('change', sync)
|
|
77
97
|
|
|
98
|
+
sync()
|
|
78
99
|
return () => {
|
|
79
100
|
observer.disconnect()
|
|
80
|
-
mq?.removeEventListener?.('change',
|
|
101
|
+
mq?.removeEventListener?.('change', sync)
|
|
81
102
|
}
|
|
82
103
|
}, [])
|
|
83
104
|
|
|
84
105
|
const setTheme = (t) => {
|
|
85
|
-
setThemeState(t)
|
|
86
106
|
applyTheme(t)
|
|
107
|
+
setState(resolveThemeState())
|
|
87
108
|
}
|
|
88
109
|
|
|
89
110
|
return {
|
|
90
|
-
theme,
|
|
91
|
-
isDark: theme === 'dark',
|
|
111
|
+
theme: state.theme,
|
|
112
|
+
isDark: state.theme === 'dark',
|
|
113
|
+
mode: state.mode,
|
|
92
114
|
setTheme,
|
|
93
|
-
toggle: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
|
|
115
|
+
toggle: () => setTheme(state.theme === 'dark' ? 'light' : 'dark'),
|
|
116
|
+
cycle: () => setTheme(state.mode === 'light' ? 'dark' : state.mode === 'dark' ? 'system' : 'light'),
|
|
94
117
|
}
|
|
95
118
|
}
|