@kolkrabbi/kol-framework 0.5.12 → 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 +35 -19
- 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,22 +66,29 @@ 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
|
)
|
|
63
75
|
|
|
64
76
|
if (variant === 'button') {
|
|
65
77
|
const mono = size === 'sm' ? 'kol-mono-12' : size === 'lg' ? 'kol-mono-16' : 'kol-mono-14'
|
|
66
|
-
|
|
78
|
+
/* Text-adjacent glyph law: the glyph must never exceed the rung's mono
|
|
79
|
+
* line box (16/18/22), or it inflates the button past the ladder height —
|
|
80
|
+
* playwright-measured against a same-rung kol-btn: 14/16/18 keeps them
|
|
81
|
+
* pixel-equal. (The 16/20/24 pairing law is for pinned icon-only squares.) */
|
|
82
|
+
const glyph = size === 'sm' ? 14 : size === 'lg' ? 18 : 16
|
|
67
83
|
return (
|
|
68
84
|
<button
|
|
69
85
|
type="button"
|
|
70
86
|
onClick={handleToggle}
|
|
71
|
-
aria-label={`Switch to ${
|
|
87
|
+
aria-label={`Switch to ${nextLabel}`}
|
|
72
88
|
className={`kol-btn kol-btn-primary kol-btn-${size} ${mono} gap-2 ${className}`.trim()}
|
|
73
89
|
>
|
|
74
90
|
{iconSwap(glyph)}
|
|
75
|
-
<span>{
|
|
91
|
+
<span>{MODE_LABEL[mode]}</span>
|
|
76
92
|
</button>
|
|
77
93
|
)
|
|
78
94
|
}
|
|
@@ -86,7 +102,7 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
86
102
|
<button
|
|
87
103
|
type="button"
|
|
88
104
|
onClick={handleToggle}
|
|
89
|
-
aria-label={`Switch to ${
|
|
105
|
+
aria-label={`Switch to ${nextLabel}`}
|
|
90
106
|
className={`${chromeCls} ${className}`.trim()}
|
|
91
107
|
>
|
|
92
108
|
<span className="inline-flex items-center justify-center shrink-0" aria-hidden="true">
|
|
@@ -96,7 +112,7 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
96
112
|
* at narrow viewports without subjecting the button to the muted
|
|
97
113
|
* sidenav-hop text-color override. */}
|
|
98
114
|
<span className="kol-sidenav-hop-label flex-1 min-w-0 text-left">
|
|
99
|
-
{
|
|
115
|
+
{MODE_LABEL[mode]}
|
|
100
116
|
</span>
|
|
101
117
|
</button>
|
|
102
118
|
)
|
|
@@ -111,8 +127,8 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
111
127
|
<button
|
|
112
128
|
type="button"
|
|
113
129
|
onClick={handleToggle}
|
|
114
|
-
aria-label={`Switch to ${
|
|
115
|
-
title={`Switch to ${
|
|
130
|
+
aria-label={`Switch to ${nextLabel}`}
|
|
131
|
+
title={`Switch to ${nextLabel}`}
|
|
116
132
|
className={`kol-btn kol-btn-nav kol-btn-${size} kol-btn-icon ${className}`.trim()}
|
|
117
133
|
>
|
|
118
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
|
}
|