@kolkrabbi/kol-framework 0.9.1 → 0.10.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/package.json +2 -2
- package/src/SideNav.jsx +22 -32
- package/src/ThemeToggle.jsx +84 -42
- package/src/theme.js +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"app-shell"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@kolkrabbi/kol-component": "0.
|
|
30
|
+
"@kolkrabbi/kol-component": "0.15.0",
|
|
31
31
|
"@kolkrabbi/kol-icons": "0.8.11"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
package/src/SideNav.jsx
CHANGED
|
@@ -131,8 +131,10 @@ export default function SideNav({
|
|
|
131
131
|
navTree = [],
|
|
132
132
|
getActivePage,
|
|
133
133
|
onNavigate,
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
/* INERT since 0.9.2 — accepted so no call site breaks, but nothing reads
|
|
135
|
+
* them; the collapse feature they drove has no CSS. See below. */
|
|
136
|
+
collapsed: _collapsed,
|
|
137
|
+
onToggle: _onToggle,
|
|
136
138
|
collapsibleSections = false,
|
|
137
139
|
isActive,
|
|
138
140
|
}) {
|
|
@@ -142,26 +144,15 @@ export default function SideNav({
|
|
|
142
144
|
const onPageRoot = activePage && pathname === activePage.to
|
|
143
145
|
const activeSectionId = useScrollSpy(onPageRoot ? sectionIds : [])
|
|
144
146
|
|
|
145
|
-
/*
|
|
146
|
-
*
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const root = document.documentElement
|
|
155
|
-
if (collapsed) root.setAttribute('data-sidenav', 'collapsed')
|
|
156
|
-
else root.removeAttribute('data-sidenav')
|
|
157
|
-
}, [collapsed])
|
|
158
|
-
|
|
159
|
-
/* /editor → collapsed. Anywhere else → expanded. Manual chevron toggle
|
|
160
|
-
* works for the session but doesn't persist across navigation.
|
|
161
|
-
* Skipped in controlled mode — the parent owns collapse. */
|
|
162
|
-
useEffect(() => {
|
|
163
|
-
if (!isControlled) setInternalCollapsed(isEditor)
|
|
164
|
-
}, [isEditor, isControlled])
|
|
147
|
+
/* The collapse state machine went with the button (finding #3). It stamped
|
|
148
|
+
* `data-sidenav="collapsed"` on <html> and an `is-collapsed` class on the
|
|
149
|
+
* aside, neither of which any stylesheet has read since 2026-07-29 — state
|
|
150
|
+
* with no renderer is just a lie the component tells itself.
|
|
151
|
+
*
|
|
152
|
+
* `collapsed` / `onToggle` are still ACCEPTED so no consumer's call site
|
|
153
|
+
* breaks; they are inert, and the prop docs say so. A consumer that wants a
|
|
154
|
+
* collapsing rail owns it locally (kol-website's apps/brand does exactly
|
|
155
|
+
* that) until the feature is deliberately rebuilt here. */
|
|
165
156
|
|
|
166
157
|
/* Per-page section expand/collapse (opt-in via collapsibleSections).
|
|
167
158
|
* The section containing the active route auto-expands on every
|
|
@@ -180,17 +171,16 @@ export default function SideNav({
|
|
|
180
171
|
|
|
181
172
|
return (
|
|
182
173
|
<aside
|
|
183
|
-
className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col border-r border-fg-08 z-20 bg-surface-primary${
|
|
174
|
+
className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col border-r border-fg-08 z-20 bg-surface-primary${drawerOpen ? ' is-drawer-open' : ''}`}
|
|
184
175
|
>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
</button>
|
|
176
|
+
{/* The collapse chevron was REMOVED 2026-07-30 (lobby ruling, finding
|
|
177
|
+
* #3). Its CSS — `:root[data-sidenav="collapsed"]` — was deleted
|
|
178
|
+
* 2026-07-29 when the manual-collapse feature was ruled dead, but the
|
|
179
|
+
* button, the `data-sidenav` stamp and the `is-collapsed` class all
|
|
180
|
+
* kept shipping, so every consumer of the package got a control that
|
|
181
|
+
* did nothing at all. Nothing styles any of it (see the tombstone at
|
|
182
|
+
* kol-framework.css:85). The surviving narrow mode is the responsive
|
|
183
|
+
* ≤1024px rail, which needs no control. */}
|
|
194
184
|
|
|
195
185
|
<div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
|
|
196
186
|
<ul className="kol-sidenav-tree flex flex-col gap-[2px]">
|
package/src/ThemeToggle.jsx
CHANGED
|
@@ -2,25 +2,29 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
2
2
|
import { useTheme } from './theme.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Theme toggle —
|
|
5
|
+
* Theme toggle — two-position glyph-roll button (the approved 2-variant spec).
|
|
6
6
|
*
|
|
7
|
-
* Clicking
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Clicking cycles **light ↔ dark**. `system` is not a position — see the
|
|
8
|
+
* ruling above MODE_LABEL. Alt- or shift-click clears the choice and hands the
|
|
9
|
+
* page back to the OS (USER law: explicit choice > system > light).
|
|
10
10
|
*
|
|
11
|
-
* ONE glyph, ever (user ruling 2026-07-30):
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* travel, so one lap shows both directions.
|
|
11
|
+
* ONE glyph, ever (user ruling 2026-07-30): both positions in the roll strip
|
|
12
|
+
* are `mode-toggle-01`, the split circle, told apart by the label / tooltip,
|
|
13
|
+
* never by a second glyph. The strip rolls like a coin: rotation ACCUMULATES
|
|
14
|
+
* −180° per slot on the same 500ms clock as the travel.
|
|
16
15
|
*
|
|
17
16
|
* THE SPEC (approved 2026-07-30 — variants are container GEOMETRY only;
|
|
18
17
|
* everything else is a prop):
|
|
19
18
|
*
|
|
20
19
|
* variant "button" — padded rung + corner radius (THE button container)
|
|
21
|
-
* fill '
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* fill 'none' (default) | 'subtle' the one bg element — none = the
|
|
21
|
+
* invisible container (nav rows, sidebars,
|
|
22
|
+
* chrome) · subtle = the grey filled rung,
|
|
23
|
+
* for a toggle that IS a button on a page.
|
|
24
|
+
* The old default was 'subtle' and its
|
|
25
|
+
* docstring claimed the filled rung suited
|
|
26
|
+
* the brand sidebar; the ruling withdrew
|
|
27
|
+
* that — a sidebar row is not a button.
|
|
24
28
|
* iconRight bool glyph right of the text (left default)
|
|
25
29
|
* label bool text on/off — off pins the square box
|
|
26
30
|
* per rung (28/32/36; icon-only is a
|
|
@@ -41,33 +45,65 @@ import { useTheme } from './theme.js'
|
|
|
41
45
|
* hop → button + fill subtle + fullWidth (old: chrome pinned md)
|
|
42
46
|
* hop-bare → flush + fullWidth (old: 6/24 padding kept)
|
|
43
47
|
*
|
|
48
|
+
* NO INTERACTIVE STATES, ANY VARIANT (user ruling 2026-07-30): *"REMOVE ALL
|
|
49
|
+
* STATES, no fucking hover… no variant at all should have an interactive state
|
|
50
|
+
* — it's just a click 1-2, and it's themed."* It emitted `.kol-btn` +
|
|
51
|
+
* `.kol-btn-nav`/`.kol-btn-primary`, which dragged the button state machine
|
|
52
|
+
* along (base transition + focus ring, per-variant :hover wash, [aria-current]
|
|
53
|
+
* shift). Those three were the only state sources; it now emits
|
|
54
|
+
* `.kol-theme-toggle{,-none,-subtle,-flush}` instead, which declare the same
|
|
55
|
+
* resting appearance and have no state rules to inherit. The SIZE classes
|
|
56
|
+
* (`kol-btn-{sm,md,lg}`, `kol-btn-icon`) are pure padding/geometry and stay.
|
|
57
|
+
* The glyph roll stays too — that animates a state CHANGE, it is not a state.
|
|
58
|
+
*
|
|
44
59
|
* Theme state lives in useTheme (./theme.js); this component is only the
|
|
45
60
|
* glyph-roll UI on top of it.
|
|
46
61
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
/* TWO positions (user ruling 2026-07-30, lobby/ThemeToggleSystemState.md):
|
|
63
|
+
* "I see three states… dark mode system light mode, why the fuck is system
|
|
64
|
+
* there, that is not a state."
|
|
65
|
+
*
|
|
66
|
+
* `system` is the ABSENCE of a choice — theme.js says so in its own words — so
|
|
67
|
+
* it can never be a labelled rung a click walks into. Promoting it made the
|
|
68
|
+
* control lie twice: it claimed a mode called "System", and it stopped being
|
|
69
|
+
* able to tell you what the next click would do, because from system the
|
|
70
|
+
* result depends on the OS.
|
|
71
|
+
*
|
|
72
|
+
* What ships now: the toggle cycles light ↔ dark and LABELS WHAT RENDERS. When
|
|
73
|
+
* the page is unset, the label reads the resolved theme (which is what the eye
|
|
74
|
+
* sees) and one click commits it. The reset lives behind a modifier — alt- or
|
|
75
|
+
* shift-click — which is the "separate affordance" the ruling allows, with no
|
|
76
|
+
* new chrome and no third stop. */
|
|
77
|
+
const MODE_LABEL = { light: 'Light mode', dark: 'Dark mode' }
|
|
78
|
+
const SLOT = { dark: 0, light: 1 }
|
|
50
79
|
|
|
51
80
|
export default function ThemeToggle({
|
|
52
81
|
variant = 'button',
|
|
53
|
-
|
|
82
|
+
/* DEFAULT FLIPPED subtle → none (user ruling 2026-07-30, finding #2): a bare
|
|
83
|
+
* <ThemeToggle /> was taking the grey filled rung, which is why the brand
|
|
84
|
+
* sidebar rendered a filled button nobody asked for. `none` is the quiet
|
|
85
|
+
* container — identical geometry, hover wash, no box. Consumers that want
|
|
86
|
+
* the fill now say so. */
|
|
87
|
+
fill = 'none',
|
|
54
88
|
size = 'md',
|
|
55
89
|
label = true,
|
|
56
90
|
iconRight = false,
|
|
57
91
|
fullWidth = false,
|
|
58
92
|
className = '',
|
|
59
93
|
}) {
|
|
60
|
-
const { mode, cycle } = useTheme()
|
|
94
|
+
const { theme, mode, cycle, clear } = useTheme()
|
|
61
95
|
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
/* Label and slot follow the RESOLVED theme, not the stored choice: when the
|
|
97
|
+
* page is unset, what the user is looking at is the OS's answer, and the
|
|
98
|
+
* button must describe that or it can't say what the next click does. */
|
|
99
|
+
const shown = theme === 'dark' ? 'dark' : 'light'
|
|
100
|
+
const next = shown === 'dark' ? 'light' : 'dark'
|
|
64
101
|
|
|
65
|
-
/* The ROLL — wheel mechanics (2026-07-30, one-glyph ruling):
|
|
66
|
-
*
|
|
67
|
-
* glyph-width) on the same 500ms/ease clock as the strip's translateX
|
|
68
|
-
* light→dark rolls one way
|
|
69
|
-
|
|
70
|
-
const slot = SLOT[mode]
|
|
102
|
+
/* The ROLL — wheel mechanics (2026-07-30, one-glyph ruling): two copies of
|
|
103
|
+
* the split circle; rotation accumulates with the slot (−180° per
|
|
104
|
+
* glyph-width) on the same 500ms/ease clock as the strip's translateX, so
|
|
105
|
+
* light→dark rolls one way and dark→light rolls back. */
|
|
106
|
+
const slot = SLOT[shown]
|
|
71
107
|
const iconSwap = (px) => (
|
|
72
108
|
<span
|
|
73
109
|
className="relative inline-block overflow-hidden"
|
|
@@ -76,9 +112,9 @@ export default function ThemeToggle({
|
|
|
76
112
|
>
|
|
77
113
|
<span
|
|
78
114
|
className="flex transition-transform duration-500 ease-in-out"
|
|
79
|
-
style={{ width: px *
|
|
115
|
+
style={{ width: px * 2, transform: `translateX(-${slot * px}px)` }}
|
|
80
116
|
>
|
|
81
|
-
{[0, 1
|
|
117
|
+
{[0, 1].map((i) => (
|
|
82
118
|
<span
|
|
83
119
|
key={i}
|
|
84
120
|
className="inline-flex transition-transform duration-500 ease-in-out"
|
|
@@ -97,17 +133,23 @@ export default function ThemeToggle({
|
|
|
97
133
|
const glyphWithText = size === 'sm' ? 14 : size === 'lg' ? 18 : 16
|
|
98
134
|
const glyphSolo = size === 'sm' ? 16 : size === 'lg' ? 24 : 20
|
|
99
135
|
|
|
136
|
+
/* alt/shift-click = the reset. A modifier, not a rung — the ruling's
|
|
137
|
+
* "separate affordance", costing no chrome and no third stop. Announced in
|
|
138
|
+
* the title so it's discoverable without being in the way. */
|
|
139
|
+
const onClick = (e) => (e.altKey || e.shiftKey ? clear() : cycle())
|
|
140
|
+
const resetHint = mode === 'system' ? ' · following your system' : ' · alt-click to follow your system'
|
|
141
|
+
|
|
100
142
|
const shared = {
|
|
101
143
|
type: 'button',
|
|
102
|
-
onClick
|
|
103
|
-
'aria-label': `Switch to ${
|
|
104
|
-
title: `Switch to ${
|
|
144
|
+
onClick,
|
|
145
|
+
'aria-label': `Switch to ${next} mode`,
|
|
146
|
+
title: `Switch to ${next} mode${resetHint}`,
|
|
105
147
|
}
|
|
106
148
|
|
|
107
149
|
/* ── DEPRECATED aliases — old chrome verbatim (0.6.x) ── */
|
|
108
150
|
if (variant === 'icon') {
|
|
109
151
|
return (
|
|
110
|
-
<button {...shared} className={`kol-
|
|
152
|
+
<button {...shared} className={`kol-theme-toggle kol-theme-toggle-none kol-btn-${size} kol-btn-icon ${className}`.trim()}>
|
|
111
153
|
{iconSwap(glyphSolo)}
|
|
112
154
|
</button>
|
|
113
155
|
)
|
|
@@ -115,15 +157,15 @@ export default function ThemeToggle({
|
|
|
115
157
|
if (variant === 'hop' || variant === 'hop-bare') {
|
|
116
158
|
const bare = variant === 'hop-bare'
|
|
117
159
|
const chromeCls = bare
|
|
118
|
-
? 'w-full inline-flex items-center justify-start gap-2 py-1.5 px-6 kol-mono-14 bg-transparent text-emphasis
|
|
119
|
-
: 'kol-
|
|
160
|
+
? 'w-full inline-flex items-center justify-start gap-2 py-1.5 px-6 kol-mono-14 bg-transparent text-emphasis'
|
|
161
|
+
: 'kol-theme-toggle kol-theme-toggle-subtle kol-btn-md kol-mono-14 w-full justify-start gap-2'
|
|
120
162
|
return (
|
|
121
163
|
<button {...shared} className={`${chromeCls} ${className}`.trim()}>
|
|
122
164
|
<span className="inline-flex items-center justify-center shrink-0" aria-hidden="true">
|
|
123
165
|
{iconSwap(glyphSolo)}
|
|
124
166
|
</span>
|
|
125
167
|
<span className="kol-sidenav-hop-label flex-1 min-w-0 text-left">
|
|
126
|
-
{MODE_LABEL[
|
|
168
|
+
{MODE_LABEL[shown]}
|
|
127
169
|
</span>
|
|
128
170
|
</button>
|
|
129
171
|
)
|
|
@@ -134,32 +176,32 @@ export default function ThemeToggle({
|
|
|
134
176
|
|
|
135
177
|
if (variant === 'flush') {
|
|
136
178
|
const glyph = label ? glyphWithText : glyphSolo
|
|
137
|
-
const cls = `
|
|
179
|
+
const cls = `kol-theme-toggle kol-theme-toggle-flush gap-2 ${mono} ${width} ${className}`
|
|
138
180
|
return (
|
|
139
181
|
<button {...shared} className={cls.replace(/\s+/g, ' ').trim()}>
|
|
140
182
|
{!iconRight && iconSwap(glyph)}
|
|
141
|
-
{label && <span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[
|
|
183
|
+
{label && <span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[shown]}</span>}
|
|
142
184
|
{iconRight && iconSwap(glyph)}
|
|
143
185
|
</button>
|
|
144
186
|
)
|
|
145
187
|
}
|
|
146
188
|
|
|
147
189
|
/* variant === 'button' (default) — the padded rung. fill is the one bg
|
|
148
|
-
* element: subtle = the grey fill
|
|
149
|
-
*
|
|
150
|
-
const fillCls = fill === 'none' ? 'kol-
|
|
190
|
+
* element: subtle = the grey fill, none = the invisible container. Geometry
|
|
191
|
+
* identical; no hover wash on either — see the no-states note below. */
|
|
192
|
+
const fillCls = fill === 'none' ? 'kol-theme-toggle-none' : 'kol-theme-toggle-subtle'
|
|
151
193
|
if (!label) {
|
|
152
194
|
// icon-only pins the square box per rung — geometry condition, not a variant
|
|
153
195
|
return (
|
|
154
|
-
<button {...shared} className={`kol-
|
|
196
|
+
<button {...shared} className={`kol-theme-toggle ${fillCls} kol-btn-${size} kol-btn-icon ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
155
197
|
{iconSwap(glyphSolo)}
|
|
156
198
|
</button>
|
|
157
199
|
)
|
|
158
200
|
}
|
|
159
201
|
return (
|
|
160
|
-
<button {...shared} className={`kol-
|
|
202
|
+
<button {...shared} className={`kol-theme-toggle ${fillCls} kol-btn-${size} ${mono} gap-2 ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
161
203
|
{!iconRight && iconSwap(glyphWithText)}
|
|
162
|
-
<span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[
|
|
204
|
+
<span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[shown]}</span>
|
|
163
205
|
{iconRight && iconSwap(glyphWithText)}
|
|
164
206
|
</button>
|
|
165
207
|
)
|
package/src/theme.js
CHANGED
|
@@ -65,7 +65,8 @@ export function applyTheme(theme) {
|
|
|
65
65
|
* mode — 'light' | 'dark' | 'system' (what is chosen)
|
|
66
66
|
* setTheme(t) — stamp 'light'/'dark', or 'system' to follow the OS
|
|
67
67
|
* toggle — binary light↔dark (always an explicit choice; kept for compat)
|
|
68
|
-
* cycle — light
|
|
68
|
+
* cycle — light ↔ dark (two positions; see the ruling at the call site)
|
|
69
|
+
* clear — hand the page back to the OS (what 'system' actually is)
|
|
69
70
|
*/
|
|
70
71
|
export function useTheme() {
|
|
71
72
|
const [state, setState] = useState(() =>
|
|
@@ -113,6 +114,14 @@ export function useTheme() {
|
|
|
113
114
|
mode: state.mode,
|
|
114
115
|
setTheme,
|
|
115
116
|
toggle: () => setTheme(state.theme === 'dark' ? 'light' : 'dark'),
|
|
116
|
-
|
|
117
|
+
/* light ↔ dark, TWO positions (user ruling 2026-07-30: "system … that is
|
|
118
|
+
* not a state"). `system` is the absence of a choice, so a click can land
|
|
119
|
+
* ON it only by resetting — never by cycling INTO it. From system, cycling
|
|
120
|
+
* commits the value the OS is currently resolving to, which is the one
|
|
121
|
+
* thing a user can predict from what they're looking at. */
|
|
122
|
+
cycle: () => setTheme(state.theme === 'dark' ? 'light' : 'dark'),
|
|
123
|
+
/* the reset — hands the page back to the OS. Kept as its own verb, not a
|
|
124
|
+
* rung, so no single click can walk into it. */
|
|
125
|
+
clear: () => setTheme('system'),
|
|
117
126
|
}
|
|
118
127
|
}
|