@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.5.13",
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",
@@ -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 icon-swap UI on top of it.
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, toggle } = useTheme()
39
+ const { isDark, mode, cycle } = useTheme()
34
40
 
35
- const next = isDark ? 'light' : 'dark'
36
- const handleToggle = toggle
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 while each glyph spins 180° in
39
- * the same clock — dark→light rolls forward (>>), light→dark rolls back (<<). */
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 * 2, transform: isDark ? 'translateX(0)' : `translateX(-${size}px)` }}
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 ${next} mode`}
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>{isDark ? 'Dark mode' : 'Light mode'}</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 ${next} mode`}
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
- {isDark ? 'Dark mode' : 'Light mode'}
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 ${next} mode`}
119
- title={`Switch to ${next} mode`}
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 getExplicitTheme()
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). Returns { theme, isDark, setTheme, toggle }.
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 [theme, setThemeState] = useState(getInitialTheme)
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
- /* Sync with any data-theme writer. */
62
- const observer = new MutationObserver(() => {
63
- const current = document.documentElement.dataset.theme
64
- if (current === 'light' || current === 'dark') setThemeState(current)
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
- const onChange = (e) => {
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', onChange)
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
  }