@kolkrabbi/kol-framework 0.9.0 → 0.10.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 +2 -2
- package/src/PageSection.jsx +4 -1
- package/src/SideNav.jsx +22 -32
- package/src/ThemeToggle.jsx +64 -33
- 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.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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"app-shell"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@kolkrabbi/kol-component": "0.14.
|
|
30
|
+
"@kolkrabbi/kol-component": "0.14.3",
|
|
31
31
|
"@kolkrabbi/kol-icons": "0.8.11"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
package/src/PageSection.jsx
CHANGED
|
@@ -12,7 +12,10 @@ export default function PageSection({ id, label, title, body, children, classNam
|
|
|
12
12
|
<section id={id} className={cls}>
|
|
13
13
|
{divider && <Divider className="kol-page-section-divider" />}
|
|
14
14
|
{hasHead && (
|
|
15
|
-
|
|
15
|
+
/* head widths read the scale instead of transcribing it. `960px` was
|
|
16
|
+
* exactly the panel token; `720px` was an improvised near-miss and
|
|
17
|
+
* becomes the column token — 768px, so section heads widen by 48px. */
|
|
18
|
+
<header className={fullbleed ? 'max-w-[var(--kol-content-panel)]' : 'max-w-[var(--kol-content-column)]'}>
|
|
16
19
|
{label && <p className="kol-prose-label">{label}</p>}
|
|
17
20
|
{title && <h2 className="kol-prose-title">{title}</h2>}
|
|
18
21
|
{body && <p className="kol-prose-lede">{body}</p>}
|
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
|
|
@@ -44,30 +48,51 @@ import { useTheme } from './theme.js'
|
|
|
44
48
|
* Theme state lives in useTheme (./theme.js); this component is only the
|
|
45
49
|
* glyph-roll UI on top of it.
|
|
46
50
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
/* TWO positions (user ruling 2026-07-30, lobby/ThemeToggleSystemState.md):
|
|
52
|
+
* "I see three states… dark mode system light mode, why the fuck is system
|
|
53
|
+
* there, that is not a state."
|
|
54
|
+
*
|
|
55
|
+
* `system` is the ABSENCE of a choice — theme.js says so in its own words — so
|
|
56
|
+
* it can never be a labelled rung a click walks into. Promoting it made the
|
|
57
|
+
* control lie twice: it claimed a mode called "System", and it stopped being
|
|
58
|
+
* able to tell you what the next click would do, because from system the
|
|
59
|
+
* result depends on the OS.
|
|
60
|
+
*
|
|
61
|
+
* What ships now: the toggle cycles light ↔ dark and LABELS WHAT RENDERS. When
|
|
62
|
+
* the page is unset, the label reads the resolved theme (which is what the eye
|
|
63
|
+
* sees) and one click commits it. The reset lives behind a modifier — alt- or
|
|
64
|
+
* shift-click — which is the "separate affordance" the ruling allows, with no
|
|
65
|
+
* new chrome and no third stop. */
|
|
66
|
+
const MODE_LABEL = { light: 'Light mode', dark: 'Dark mode' }
|
|
67
|
+
const SLOT = { dark: 0, light: 1 }
|
|
50
68
|
|
|
51
69
|
export default function ThemeToggle({
|
|
52
70
|
variant = 'button',
|
|
53
|
-
|
|
71
|
+
/* DEFAULT FLIPPED subtle → none (user ruling 2026-07-30, finding #2): a bare
|
|
72
|
+
* <ThemeToggle /> was taking the grey filled rung, which is why the brand
|
|
73
|
+
* sidebar rendered a filled button nobody asked for. `none` is the quiet
|
|
74
|
+
* container — identical geometry, hover wash, no box. Consumers that want
|
|
75
|
+
* the fill now say so. */
|
|
76
|
+
fill = 'none',
|
|
54
77
|
size = 'md',
|
|
55
78
|
label = true,
|
|
56
79
|
iconRight = false,
|
|
57
80
|
fullWidth = false,
|
|
58
81
|
className = '',
|
|
59
82
|
}) {
|
|
60
|
-
const { mode, cycle } = useTheme()
|
|
83
|
+
const { theme, mode, cycle, clear } = useTheme()
|
|
61
84
|
|
|
62
|
-
|
|
63
|
-
|
|
85
|
+
/* Label and slot follow the RESOLVED theme, not the stored choice: when the
|
|
86
|
+
* page is unset, what the user is looking at is the OS's answer, and the
|
|
87
|
+
* button must describe that or it can't say what the next click does. */
|
|
88
|
+
const shown = theme === 'dark' ? 'dark' : 'light'
|
|
89
|
+
const next = shown === 'dark' ? 'light' : 'dark'
|
|
64
90
|
|
|
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]
|
|
91
|
+
/* The ROLL — wheel mechanics (2026-07-30, one-glyph ruling): two copies of
|
|
92
|
+
* the split circle; rotation accumulates with the slot (−180° per
|
|
93
|
+
* glyph-width) on the same 500ms/ease clock as the strip's translateX, so
|
|
94
|
+
* light→dark rolls one way and dark→light rolls back. */
|
|
95
|
+
const slot = SLOT[shown]
|
|
71
96
|
const iconSwap = (px) => (
|
|
72
97
|
<span
|
|
73
98
|
className="relative inline-block overflow-hidden"
|
|
@@ -76,9 +101,9 @@ export default function ThemeToggle({
|
|
|
76
101
|
>
|
|
77
102
|
<span
|
|
78
103
|
className="flex transition-transform duration-500 ease-in-out"
|
|
79
|
-
style={{ width: px *
|
|
104
|
+
style={{ width: px * 2, transform: `translateX(-${slot * px}px)` }}
|
|
80
105
|
>
|
|
81
|
-
{[0, 1
|
|
106
|
+
{[0, 1].map((i) => (
|
|
82
107
|
<span
|
|
83
108
|
key={i}
|
|
84
109
|
className="inline-flex transition-transform duration-500 ease-in-out"
|
|
@@ -97,11 +122,17 @@ export default function ThemeToggle({
|
|
|
97
122
|
const glyphWithText = size === 'sm' ? 14 : size === 'lg' ? 18 : 16
|
|
98
123
|
const glyphSolo = size === 'sm' ? 16 : size === 'lg' ? 24 : 20
|
|
99
124
|
|
|
125
|
+
/* alt/shift-click = the reset. A modifier, not a rung — the ruling's
|
|
126
|
+
* "separate affordance", costing no chrome and no third stop. Announced in
|
|
127
|
+
* the title so it's discoverable without being in the way. */
|
|
128
|
+
const onClick = (e) => (e.altKey || e.shiftKey ? clear() : cycle())
|
|
129
|
+
const resetHint = mode === 'system' ? ' · following your system' : ' · alt-click to follow your system'
|
|
130
|
+
|
|
100
131
|
const shared = {
|
|
101
132
|
type: 'button',
|
|
102
|
-
onClick
|
|
103
|
-
'aria-label': `Switch to ${
|
|
104
|
-
title: `Switch to ${
|
|
133
|
+
onClick,
|
|
134
|
+
'aria-label': `Switch to ${next} mode`,
|
|
135
|
+
title: `Switch to ${next} mode${resetHint}`,
|
|
105
136
|
}
|
|
106
137
|
|
|
107
138
|
/* ── DEPRECATED aliases — old chrome verbatim (0.6.x) ── */
|
|
@@ -123,7 +154,7 @@ export default function ThemeToggle({
|
|
|
123
154
|
{iconSwap(glyphSolo)}
|
|
124
155
|
</span>
|
|
125
156
|
<span className="kol-sidenav-hop-label flex-1 min-w-0 text-left">
|
|
126
|
-
{MODE_LABEL[
|
|
157
|
+
{MODE_LABEL[shown]}
|
|
127
158
|
</span>
|
|
128
159
|
</button>
|
|
129
160
|
)
|
|
@@ -138,7 +169,7 @@ export default function ThemeToggle({
|
|
|
138
169
|
return (
|
|
139
170
|
<button {...shared} className={cls.replace(/\s+/g, ' ').trim()}>
|
|
140
171
|
{!iconRight && iconSwap(glyph)}
|
|
141
|
-
{label && <span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[
|
|
172
|
+
{label && <span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[shown]}</span>}
|
|
142
173
|
{iconRight && iconSwap(glyph)}
|
|
143
174
|
</button>
|
|
144
175
|
)
|
|
@@ -159,7 +190,7 @@ export default function ThemeToggle({
|
|
|
159
190
|
return (
|
|
160
191
|
<button {...shared} className={`kol-btn ${fillCls} kol-btn-${size} ${mono} gap-2 ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
161
192
|
{!iconRight && iconSwap(glyphWithText)}
|
|
162
|
-
<span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[
|
|
193
|
+
<span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[shown]}</span>
|
|
163
194
|
{iconRight && iconSwap(glyphWithText)}
|
|
164
195
|
</button>
|
|
165
196
|
)
|
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
|
}
|