@kolkrabbi/kol-framework 0.5.13 → 0.6.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.
- package/kol-framework.css +13 -46
- package/package.json +2 -2
- package/src/ThemeToggle.jsx +30 -18
- package/src/theme.js +48 -25
package/kol-framework.css
CHANGED
|
@@ -17,21 +17,11 @@ html,
|
|
|
17
17
|
body {
|
|
18
18
|
background: var(--kol-surface-primary);
|
|
19
19
|
color: var(--kol-surface-on-primary);
|
|
20
|
-
|
|
21
|
-
/* Editor-style app — selection is mostly meaningless on chrome. Default
|
|
22
|
-
* to non-selectable; opt back in for prose, tables, inputs, code below. */
|
|
23
|
-
-webkit-user-select: none;
|
|
24
|
-
user-select: none;
|
|
25
20
|
}
|
|
26
21
|
|
|
27
|
-
/*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.kol-table, .kol-table *,
|
|
31
|
-
code, pre {
|
|
32
|
-
-webkit-user-select: text;
|
|
33
|
-
user-select: text;
|
|
34
|
-
}
|
|
22
|
+
/* The global user-select:none gate + allowlist was REMOVED 2026-07-29 (user
|
|
23
|
+
* law, brand-app ruling): text is selectable everywhere — copyability beats
|
|
24
|
+
* chrome purity. */
|
|
35
25
|
|
|
36
26
|
/* Selection highlight uses brand-primary so it reads on-brand when it appears. */
|
|
37
27
|
::selection {
|
|
@@ -87,7 +77,10 @@ code, pre {
|
|
|
87
77
|
|
|
88
78
|
@media (min-width: 1280px) { :root { --kol-container-max: 1600px; } }
|
|
89
79
|
|
|
90
|
-
:root[data-sidenav="collapsed"]
|
|
80
|
+
/* :root[data-sidenav="collapsed"] removed 2026-07-29 — the manual collapse
|
|
81
|
+
* feature died with the editor's exit (brand ruling); the responsive rail
|
|
82
|
+
* below 1024px is the surviving narrow mode. --kol-sidenav-w-collapsed stays:
|
|
83
|
+
* the rail still uses it. */
|
|
91
84
|
|
|
92
85
|
/* ───── Brand doc layout (sidebar + content) ─────
|
|
93
86
|
The grid lives in CSS for its dynamic --kol-sidenav-w + transition. */
|
|
@@ -103,7 +96,6 @@ code, pre {
|
|
|
103
96
|
User's localStorage expand preference is overridden while viewport is narrow;
|
|
104
97
|
restored once they cross back over the threshold. */
|
|
105
98
|
.kol-brand-layout { grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr); }
|
|
106
|
-
.kol-sidenav-toggle { display: none; }
|
|
107
99
|
.kol-sidenav-hops { padding: 0 0 16px; margin-bottom: 16px; }
|
|
108
100
|
.kol-sidenav-hop { padding: 8px 0; justify-content: center; gap: 0; }
|
|
109
101
|
.kol-sidenav-hop-label { display: none; }
|
|
@@ -134,30 +126,9 @@ code, pre {
|
|
|
134
126
|
}
|
|
135
127
|
|
|
136
128
|
/* SideNav primitives (.kol-sidenav-hop, -hop-icon, -hop-label, -link,
|
|
137
|
-
* -group) promoted to kol-components-atoms.css 2026-04-30. The
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
/* ───── SideNav collapsed (rail mode) — overrides inlined hop padding/gap ───── */
|
|
142
|
-
|
|
143
|
-
.kol-sidenav.is-collapsed .kol-sidenav-hops {
|
|
144
|
-
padding: 0 0 16px;
|
|
145
|
-
margin-bottom: 16px;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
.kol-sidenav.is-collapsed .kol-sidenav-hop {
|
|
149
|
-
padding: 8px 0;
|
|
150
|
-
justify-content: center;
|
|
151
|
-
gap: 0;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
.kol-sidenav.is-collapsed .kol-sidenav-hop-label {
|
|
155
|
-
display: none;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
.kol-sidenav.is-collapsed .kol-sidenav-body {
|
|
159
|
-
display: none;
|
|
160
|
-
}
|
|
129
|
+
* -group) promoted to kol-components-atoms.css 2026-04-30. The manual
|
|
130
|
+
* collapse (.is-collapsed rail mode) was removed 2026-07-29 with the
|
|
131
|
+
* editor's exit — the ≤1024px responsive rail above is the survivor. */
|
|
161
132
|
|
|
162
133
|
/* ───── Page scaffold ───── */
|
|
163
134
|
|
|
@@ -807,13 +778,9 @@ code, pre {
|
|
|
807
778
|
}
|
|
808
779
|
|
|
809
780
|
/* SideNav collapsibleSections chrome — inert unless that mode renders.
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
.kol-sidenav.is-collapsed .kol-sidenav-section-toggle {
|
|
814
|
-
display: none;
|
|
815
|
-
}
|
|
816
|
-
|
|
781
|
+
The forced rail (≤1024px) hides count/toggle with the labels; the open
|
|
782
|
+
≤767px drawer restores them, mirroring -hop-label. (Manual .is-collapsed
|
|
783
|
+
removed 2026-07-29.) */
|
|
817
784
|
@media (max-width: 1024px) {
|
|
818
785
|
.kol-sidenav-hop-count,
|
|
819
786
|
.kol-sidenav-section-toggle {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@kolkrabbi/kol-component": "0.12.19",
|
|
31
|
-
"@kolkrabbi/kol-icons": "0.8.
|
|
31
|
+
"@kolkrabbi/kol-icons": "0.8.10"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": "^18.3.0 || ^19.0.0",
|
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
|
}
|