@kolkrabbi/kol-framework 0.8.1 → 0.9.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/ShellHeader.jsx +5 -3
- package/src/ThemeToggle.jsx +114 -91
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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/ShellHeader.jsx
CHANGED
|
@@ -78,9 +78,11 @@ export default function ShellHeader({
|
|
|
78
78
|
{showThemeToggle && (
|
|
79
79
|
<Tooltip label="Toggle theme">
|
|
80
80
|
{/* one rung up at lg+ — the bar has room, and the toggle is
|
|
81
|
-
* the most-used control in the row
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
* the most-used control in the row. New spec API (0.9.0):
|
|
82
|
+
* quiet square = button geometry + fill none + no label
|
|
83
|
+
* (was the old `icon` variant default). */}
|
|
84
|
+
<span className="hidden lg:inline-flex"><ThemeToggle fill="none" label={false} size="lg" /></span>
|
|
85
|
+
<span className="lg:hidden"><ThemeToggle fill="none" label={false} /></span>
|
|
84
86
|
</Tooltip>
|
|
85
87
|
)}
|
|
86
88
|
{onMenuClick && (
|
package/src/ThemeToggle.jsx
CHANGED
|
@@ -2,121 +2,126 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
2
2
|
import { useTheme } from './theme.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Theme toggle —
|
|
5
|
+
* Theme toggle — tri-state glyph-roll button (0.9.0, the approved 2-variant spec).
|
|
6
6
|
*
|
|
7
7
|
* Clicking CYCLES light → dark → system → light. 'system' is the no-choice
|
|
8
8
|
* state: the stamp + saved key are cleared and the page follows
|
|
9
|
-
* prefers-color-scheme live (USER law: explicit choice > system
|
|
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.
|
|
9
|
+
* prefers-color-scheme live (USER law: explicit choice > system > light).
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* button — a REAL ladder button (2026-07-29, user-ordered): glyph + label
|
|
20
|
-
* inside plain `.kol-btn .kol-btn-primary .kol-btn-{size}` with
|
|
21
|
-
* the rung's mono type and pairing-law glyph (16/20/24) — no
|
|
22
|
-
* width/justify overrides, so it measures exactly like any
|
|
23
|
-
* other kol-btn on the rung.
|
|
24
|
-
* hop — full-width labeled Button-primary-styled sidenav row
|
|
25
|
-
* (bg-fg-04, on-primary text). For sidenav rows where it pairs
|
|
26
|
-
* with other Button-primary "hop" entries.
|
|
27
|
-
* hop-bare — same shape + padding as hop, but fully transparent (no rest
|
|
28
|
-
* bg, no hover bg). For sidenav rows where the row should read
|
|
29
|
-
* as plain text + icon, not a button.
|
|
11
|
+
* ONE glyph, ever (user ruling 2026-07-30): every position in the roll strip
|
|
12
|
+
* is `mode-toggle-01`, the split circle — the system state wears it too, told
|
|
13
|
+
* apart by the label / tooltip, never by a third glyph. The strip rolls like
|
|
14
|
+
* a coin: rotation ACCUMULATES −180° per slot on the same 500ms clock as the
|
|
15
|
+
* travel, so one lap shows both directions.
|
|
30
16
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
17
|
+
* THE SPEC (approved 2026-07-30 — variants are container GEOMETRY only;
|
|
18
|
+
* everything else is a prop):
|
|
19
|
+
*
|
|
20
|
+
* variant "button" — padded rung + corner radius (THE button container)
|
|
21
|
+
* fill 'subtle' | 'none' the one bg element — subtle = grey fill
|
|
22
|
+
* (brand sidebar) · none = the invisible
|
|
23
|
+
* container (nav-bar chrome)
|
|
24
|
+
* iconRight bool glyph right of the text (left default)
|
|
25
|
+
* label bool text on/off — off pins the square box
|
|
26
|
+
* per rung (28/32/36; icon-only is a
|
|
27
|
+
* geometry condition, not a variant)
|
|
28
|
+
* fullWidth bool stretches to a sidenav row
|
|
29
|
+
* size 'sm' | 'md' | 'lg' moves pad + text + glyph together
|
|
30
|
+
*
|
|
31
|
+
* variant "flush" — no pad, no radius, no fill, ever
|
|
32
|
+
* same prop list minus `fill`; no square pinning (there is no box);
|
|
33
|
+
* size scales text + glyph only.
|
|
34
|
+
*
|
|
35
|
+
* Glyph law: text-adjacent 14/16/18 (stays inside the rung line box);
|
|
36
|
+
* solo 16/20/24 (the pinned-square pairing).
|
|
37
|
+
*
|
|
38
|
+
* DEPRECATED aliases (0.6.x variants) — render their old chrome verbatim so
|
|
39
|
+
* no consumer shifts a pixel; migrate at your own pace:
|
|
40
|
+
* icon → button + fill none + label off
|
|
41
|
+
* hop → button + fill subtle + fullWidth (old: chrome pinned md)
|
|
42
|
+
* hop-bare → flush + fullWidth (old: 6/24 padding kept)
|
|
43
|
+
*
|
|
44
|
+
* Theme state lives in useTheme (./theme.js); this component is only the
|
|
45
|
+
* glyph-roll UI on top of it.
|
|
34
46
|
*/
|
|
35
47
|
const MODE_LABEL = { light: 'Light mode', dark: 'Dark mode', system: 'System' }
|
|
36
48
|
const NEXT_MODE = { light: 'dark', dark: 'system', system: 'light' }
|
|
49
|
+
const SLOT = { dark: 0, light: 1, system: 2 }
|
|
37
50
|
|
|
38
|
-
export default function ThemeToggle({
|
|
51
|
+
export default function ThemeToggle({
|
|
52
|
+
variant = 'button',
|
|
53
|
+
fill = 'subtle',
|
|
54
|
+
size = 'md',
|
|
55
|
+
label = true,
|
|
56
|
+
iconRight = false,
|
|
57
|
+
fullWidth = false,
|
|
58
|
+
className = '',
|
|
59
|
+
}) {
|
|
39
60
|
const { mode, cycle } = useTheme()
|
|
40
61
|
|
|
41
62
|
const next = NEXT_MODE[mode]
|
|
42
63
|
const nextLabel = next === 'system' ? 'system theme' : `${next} mode`
|
|
43
|
-
const handleToggle = cycle
|
|
44
64
|
|
|
45
|
-
/* The ROLL (
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
* on. The desktop glyph doesn't spin — it's not a wheel; it slides in as
|
|
53
|
-
* the no-choice state. */
|
|
54
|
-
const slot = mode === 'dark' ? 0 : mode === 'light' ? 1 : 2
|
|
55
|
-
const glyphRoll = {
|
|
56
|
-
transform: `rotate(${slot * -180}deg)`,
|
|
57
|
-
lineHeight: 0,
|
|
58
|
-
}
|
|
59
|
-
const iconSwap = (size) => (
|
|
65
|
+
/* The ROLL — wheel mechanics (2026-07-30, one-glyph ruling): three copies
|
|
66
|
+
* of the split circle; rotation accumulates with the slot (−180° per
|
|
67
|
+
* glyph-width) on the same 500ms/ease clock as the strip's translateX.
|
|
68
|
+
* light→dark rolls one way, dark→system rolls back two half-turns,
|
|
69
|
+
* system→light forward again — both directions in one lap. */
|
|
70
|
+
const slot = SLOT[mode]
|
|
71
|
+
const iconSwap = (px) => (
|
|
60
72
|
<span
|
|
61
73
|
className="relative inline-block overflow-hidden"
|
|
62
|
-
style={{ width:
|
|
74
|
+
style={{ width: px, height: px }}
|
|
63
75
|
aria-hidden="true"
|
|
64
76
|
>
|
|
65
77
|
<span
|
|
66
78
|
className="flex transition-transform duration-500 ease-in-out"
|
|
67
|
-
style={{ width:
|
|
79
|
+
style={{ width: px * 3, transform: `translateX(-${slot * px}px)` }}
|
|
68
80
|
>
|
|
69
|
-
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
{[0, 1, 2].map((i) => (
|
|
82
|
+
<span
|
|
83
|
+
key={i}
|
|
84
|
+
className="inline-flex transition-transform duration-500 ease-in-out"
|
|
85
|
+
style={{ transform: `rotate(${slot * -180}deg)`, lineHeight: 0 }}
|
|
86
|
+
>
|
|
87
|
+
<Icon name="mode-toggle-01" size={px} />
|
|
88
|
+
</span>
|
|
89
|
+
))}
|
|
78
90
|
</span>
|
|
79
91
|
</span>
|
|
80
92
|
)
|
|
81
93
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
const mono = size === 'sm' ? 'kol-mono-12' : size === 'lg' ? 'kol-mono-16' : 'kol-mono-14'
|
|
95
|
+
/* Glyph law: text-adjacent stays inside the rung line box; solo takes the
|
|
96
|
+
* pinned-square pairing sizes. */
|
|
97
|
+
const glyphWithText = size === 'sm' ? 14 : size === 'lg' ? 18 : 16
|
|
98
|
+
const glyphSolo = size === 'sm' ? 16 : size === 'lg' ? 24 : 20
|
|
99
|
+
|
|
100
|
+
const shared = {
|
|
101
|
+
type: 'button',
|
|
102
|
+
onClick: cycle,
|
|
103
|
+
'aria-label': `Switch to ${nextLabel}`,
|
|
104
|
+
title: `Switch to ${nextLabel}`,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ── DEPRECATED aliases — old chrome verbatim (0.6.x) ── */
|
|
108
|
+
if (variant === 'icon') {
|
|
89
109
|
return (
|
|
90
|
-
<button
|
|
91
|
-
|
|
92
|
-
onClick={handleToggle}
|
|
93
|
-
aria-label={`Switch to ${nextLabel}`}
|
|
94
|
-
className={`kol-btn kol-btn-primary kol-btn-${size} ${mono} gap-2 ${className}`.trim()}
|
|
95
|
-
>
|
|
96
|
-
{iconSwap(glyph)}
|
|
97
|
-
<span>{MODE_LABEL[mode]}</span>
|
|
110
|
+
<button {...shared} className={`kol-btn kol-btn-nav kol-btn-${size} kol-btn-icon ${className}`.trim()}>
|
|
111
|
+
{iconSwap(glyphSolo)}
|
|
98
112
|
</button>
|
|
99
113
|
)
|
|
100
114
|
}
|
|
101
|
-
|
|
102
115
|
if (variant === 'hop' || variant === 'hop-bare') {
|
|
103
116
|
const bare = variant === 'hop-bare'
|
|
104
117
|
const chromeCls = bare
|
|
105
118
|
? 'w-full inline-flex items-center justify-start gap-2 py-1.5 px-6 kol-mono-14 bg-transparent text-emphasis transition-colors'
|
|
106
119
|
: 'kol-btn kol-btn-primary kol-btn-md kol-mono-14 w-full justify-start gap-2'
|
|
107
120
|
return (
|
|
108
|
-
<button
|
|
109
|
-
type="button"
|
|
110
|
-
onClick={handleToggle}
|
|
111
|
-
aria-label={`Switch to ${nextLabel}`}
|
|
112
|
-
className={`${chromeCls} ${className}`.trim()}
|
|
113
|
-
>
|
|
121
|
+
<button {...shared} className={`${chromeCls} ${className}`.trim()}>
|
|
114
122
|
<span className="inline-flex items-center justify-center shrink-0" aria-hidden="true">
|
|
115
|
-
{iconSwap(
|
|
123
|
+
{iconSwap(glyphSolo)}
|
|
116
124
|
</span>
|
|
117
|
-
{/* kol-sidenav-hop-label keeps the responsive label-hide rule firing
|
|
118
|
-
* at narrow viewports without subjecting the button to the muted
|
|
119
|
-
* sidenav-hop text-color override. */}
|
|
120
125
|
<span className="kol-sidenav-hop-label flex-1 min-w-0 text-left">
|
|
121
126
|
{MODE_LABEL[mode]}
|
|
122
127
|
</span>
|
|
@@ -124,20 +129,38 @@ export default function ThemeToggle({ variant = 'icon', size = 'md', className =
|
|
|
124
129
|
)
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
/* ── THE SPEC ── */
|
|
133
|
+
const width = fullWidth ? 'w-full justify-start' : ''
|
|
134
|
+
|
|
135
|
+
if (variant === 'flush') {
|
|
136
|
+
const glyph = label ? glyphWithText : glyphSolo
|
|
137
|
+
const cls = `inline-flex items-center gap-2 ${mono} bg-transparent text-emphasis transition-colors ${width} ${className}`
|
|
138
|
+
return (
|
|
139
|
+
<button {...shared} className={cls.replace(/\s+/g, ' ').trim()}>
|
|
140
|
+
{!iconRight && iconSwap(glyph)}
|
|
141
|
+
{label && <span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[mode]}</span>}
|
|
142
|
+
{iconRight && iconSwap(glyph)}
|
|
143
|
+
</button>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* variant === 'button' (default) — the padded rung. fill is the one bg
|
|
148
|
+
* element: subtle = the grey fill (kol-btn-primary), none = the invisible
|
|
149
|
+
* container (kol-btn-nav — quiet ink + hover wash, geometry identical). */
|
|
150
|
+
const fillCls = fill === 'none' ? 'kol-btn-nav' : 'kol-btn-primary'
|
|
151
|
+
if (!label) {
|
|
152
|
+
// icon-only pins the square box per rung — geometry condition, not a variant
|
|
153
|
+
return (
|
|
154
|
+
<button {...shared} className={`kol-btn ${fillCls} kol-btn-${size} kol-btn-icon ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
155
|
+
{iconSwap(glyphSolo)}
|
|
156
|
+
</button>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
132
159
|
return (
|
|
133
|
-
<button
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
title={`Switch to ${nextLabel}`}
|
|
138
|
-
className={`kol-btn kol-btn-nav kol-btn-${size} kol-btn-icon ${className}`.trim()}
|
|
139
|
-
>
|
|
140
|
-
{iconSwap(glyph)}
|
|
160
|
+
<button {...shared} className={`kol-btn ${fillCls} kol-btn-${size} ${mono} gap-2 ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
161
|
+
{!iconRight && iconSwap(glyphWithText)}
|
|
162
|
+
<span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[mode]}</span>
|
|
163
|
+
{iconRight && iconSwap(glyphWithText)}
|
|
141
164
|
</button>
|
|
142
165
|
)
|
|
143
166
|
}
|