@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.9.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.1",
30
+ "@kolkrabbi/kol-component": "0.14.3",
31
31
  "@kolkrabbi/kol-icons": "0.8.11"
32
32
  },
33
33
  "peerDependencies": {
@@ -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
- <header className={fullbleed ? 'max-w-[960px]' : 'max-w-[720px]'}>
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
- 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
@@ -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
- 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 }
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
- fill = 'subtle',
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
- const next = NEXT_MODE[mode]
63
- const nextLabel = next === 'system' ? 'system theme' : `${next} mode`
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): 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]
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 * 3, transform: `translateX(-${slot * px}px)` }}
104
+ style={{ width: px * 2, transform: `translateX(-${slot * px}px)` }}
80
105
  >
81
- {[0, 1, 2].map((i) => (
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: cycle,
103
- 'aria-label': `Switch to ${nextLabel}`,
104
- title: `Switch to ${nextLabel}`,
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[mode]}
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[mode]}</span>}
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[mode]}</span>
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 → 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
  }