@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.9.1",
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.14.2",
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
- collapsed: collapsedProp,
135
- onToggle,
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
- /* Root collapse: controlled when `collapsed` is defined (pair with
146
- * onToggle), otherwise internal state. */
147
- const isEditor = pathname.startsWith('/editor/')
148
- const [internalCollapsed, setInternalCollapsed] = useState(isEditor)
149
- const isControlled = collapsedProp !== undefined
150
- const collapsed = isControlled ? collapsedProp : internalCollapsed
151
- const handleToggle = isControlled ? onToggle : () => setInternalCollapsed((v) => !v)
152
-
153
- useEffect(() => {
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${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
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
- <button
186
- type="button"
187
- className="kol-sidenav-toggle absolute top-5 right-[-12px] z-[2] w-6 h-6 inline-flex items-center justify-center bg-[var(--kol-surface-primary)] border border-[var(--kol-border-default)] rounded-full p-0 cursor-pointer kol-helper-14 transition-colors duration-150 text-meta hover:text-emphasis hover:border-fg-24"
188
- aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
189
- title={collapsed ? 'Expand' : 'Collapse'}
190
- onClick={handleToggle}
191
- >
192
- <Icon name={collapsed ? 'chevron-right' : 'chevron-left'} size={12} />
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]">
@@ -2,25 +2,29 @@ import { Icon } from '@kolkrabbi/kol-icons'
2
2
  import { useTheme } from './theme.js'
3
3
 
4
4
  /**
5
- * Theme toggle — tri-state glyph-roll button (0.9.0, the approved 2-variant spec).
5
+ * Theme toggle — two-position glyph-roll button (the approved 2-variant spec).
6
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 > light).
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): 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.
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 'subtle' | 'none' the one bg element — subtle = grey fill
22
- * (brand sidebar) · none = the invisible
23
- * container (nav-bar chrome)
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
- const MODE_LABEL = { light: 'Light mode', dark: 'Dark mode', system: 'System' }
48
- const NEXT_MODE = { light: 'dark', dark: 'system', system: 'light' }
49
- const SLOT = { dark: 0, light: 1, system: 2 }
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
- fill = 'subtle',
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
- const next = NEXT_MODE[mode]
63
- const nextLabel = next === 'system' ? 'system theme' : `${next} mode`
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): 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]
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 * 3, transform: `translateX(-${slot * px}px)` }}
115
+ style={{ width: px * 2, transform: `translateX(-${slot * px}px)` }}
80
116
  >
81
- {[0, 1, 2].map((i) => (
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: cycle,
103
- 'aria-label': `Switch to ${nextLabel}`,
104
- title: `Switch to ${nextLabel}`,
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-btn kol-btn-nav kol-btn-${size} kol-btn-icon ${className}`.trim()}>
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 transition-colors'
119
- : 'kol-btn kol-btn-primary kol-btn-md kol-mono-14 w-full justify-start gap-2'
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[mode]}
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 = `inline-flex items-center gap-2 ${mono} bg-transparent text-emphasis transition-colors ${width} ${className}`
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[mode]}</span>}
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 (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'
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-btn ${fillCls} kol-btn-${size} kol-btn-icon ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
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-btn ${fillCls} kol-btn-${size} ${mono} gap-2 ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
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[mode]}</span>
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 → dark → system → light (the tri-state control)
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
- cycle: () => setTheme(state.mode === 'light' ? 'dark' : state.mode === 'dark' ? 'system' : 'light'),
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
  }