@kolkrabbi/kol-framework 0.13.0 → 0.14.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/kol-framework.css CHANGED
@@ -35,6 +35,14 @@ body {
35
35
  grid track that had to line up with it read 256, so the shell header's
36
36
  brand block sat 4 off the sidebar column it exists to align to. */
37
37
  --kol-sidenav-w: 16rem;
38
+ /* Grab-edge gesture (lobby: SideNavGrabResize, 2026-08-09). Minted HERE —
39
+ no --kol-* token carried these before (checked: only -w, -w-collapsed,
40
+ -dot-left shipped). kol-mirror carried them as bare literals, the
41
+ consumer-app slack the brief bans copying; both widths reference the
42
+ spacing ladder (the mirror strip's width lands on the ladder's first rung). */
43
+ --kol-sidenav-grab-w: var(--kol-spacing-1); /* the strip's hit width */
44
+ --kol-sidenav-snap: 12rem; /* drag under this snaps to -w-collapsed */
45
+ --kol-sidenav-step: var(--kol-spacing-4); /* keyboard resize increment */
38
46
  --kol-sidenav-w-collapsed: 56px;
39
47
  /* The workshop shell's RIGHT rail. EQUAL TO THE LEFT (user ruling
40
48
  2026-08-01) — the two rails frame the same content and a lopsided frame is
@@ -104,10 +112,36 @@ body {
104
112
  * said 1800 — same element, two answers (lobby/WidthSystemContradictions). */
105
113
  @media (min-width: 1920px) { :root { --kol-container-max: var(--kol-content-shell, 1800px); } }
106
114
 
107
- /* :root[data-sidenav="collapsed"] removed 2026-07-29 — the manual collapse
108
- * feature died with the editor's exit (brand ruling); the responsive rail
109
- * below 1024px is the surviving narrow mode. --kol-sidenav-w-collapsed stays:
110
- * the rail still uses it. */
115
+ /* :root[data-sidenav="collapsed"] — the manual collapse, rebuilt DELIBERATELY
116
+ * 2026-08-09 (lobby: SideNavGrabResize) after the 2026-07-29 removal of the
117
+ * renderer-less stamp. SideNav's grab edge (useDragResize.js) owns the state
118
+ * now; these rules mirror the narrow mode below, so "collapsed" is that same
119
+ * rail — one look, two triggers. */
120
+ :root[data-sidenav="collapsed"] .kol-brand-layout { grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr); }
121
+ :root[data-sidenav="collapsed"] .kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
122
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
123
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop-label { display: none; }
124
+ :root[data-sidenav="collapsed"] .kol-sidenav-body { display: none; }
125
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop-count,
126
+ :root[data-sidenav="collapsed"] .kol-sidenav-section-toggle { display: none; }
127
+
128
+ /* The grab edge itself — invisible by spec (col-resize cursor is the pointer
129
+ * affordance, the focus ring the keyboard one — recipe from
130
+ * .kol-btn:focus-visible, offset pulled inside because the strip hugs the
131
+ * layout edge). Positioning utilities ride inline in SideNav.jsx; the narrow
132
+ * mode hides the strip in its own block further down. */
133
+ .kol-sidenav-grab {
134
+ width: var(--kol-sidenav-grab-w);
135
+ cursor: col-resize;
136
+ }
137
+ .kol-sidenav-grab:focus-visible {
138
+ outline: 2px solid var(--kol-focus-ring);
139
+ outline-offset: -2px;
140
+ }
141
+ /* Live drag writes --kol-sidenav-w per pointermove — the grid's ease below
142
+ * would render the rail a frame behind the pointer, so the drag suspends it
143
+ * (useDragResize stamps the attribute for the drag's duration). */
144
+ :root[data-sidenav-dragging] .kol-brand-layout { transition: none; }
111
145
 
112
146
  /* ───── Brand doc layout (sidebar + content) ─────
113
147
  The grid lives in CSS for its dynamic --kol-sidenav-w + transition. */
@@ -123,10 +157,12 @@ body {
123
157
  User's localStorage expand preference is overridden while viewport is narrow;
124
158
  restored once they cross back over the threshold. */
125
159
  .kol-brand-layout { grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr); }
126
- .kol-sidenav-hops { padding: 0 0 16px; margin-bottom: 16px; }
127
- .kol-sidenav-hop { padding: 8px 0; justify-content: center; gap: 0; }
160
+ .kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
161
+ .kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
128
162
  .kol-sidenav-hop-label { display: none; }
129
163
  .kol-sidenav-body { display: none; }
164
+ /* forced narrow / drawer modes own the rail below lg — no resize here */
165
+ .kol-sidenav-grab { display: none; }
130
166
  }
131
167
 
132
168
  @media (max-width: 767px) {
@@ -700,6 +736,10 @@ body {
700
736
  .kol-brand-layout[data-toc="true"] {
701
737
  grid-template-columns: var(--kol-sidenav-w) minmax(0, 1fr) var(--kol-toc-w);
702
738
  }
739
+ /* collapsed + TOC: the third column survives; only the rail track narrows */
740
+ :root[data-sidenav="collapsed"] .kol-brand-layout[data-toc="true"] {
741
+ grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr) var(--kol-toc-w);
742
+ }
703
743
  }
704
744
 
705
745
  /* SideNav collapsibleSections chrome — inert unless that mode renders.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.13.0",
3
+ "version": "0.14.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/SideNav.jsx CHANGED
@@ -1,7 +1,8 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import { Link, NavLink, useLocation } from 'react-router-dom'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
4
  import ThemeToggle from './ThemeToggle'
5
+ import useDragResize from './useDragResize'
5
6
  import { useScrollSpy } from '@kolkrabbi/kol-component'
6
7
 
7
8
  /* Walk the active page's children and return all leaf section ids (for scroll-spy). */
@@ -144,15 +145,14 @@ export default function SideNav({
144
145
  const onPageRoot = activePage && pathname === activePage.to
145
146
  const activeSectionId = useScrollSpy(onPageRoot ? sectionIds : [])
146
147
 
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. */
148
+ /* Collapse, rebuilt DELIBERATELY 2026-08-09 (lobby: SideNavGrabResize) —
149
+ * the 2026-07-29/30 removals killed a stamp with no renderer; the grab edge
150
+ * below reinstates BOTH halves. useDragResize owns the state, stamps
151
+ * :root[data-sidenav="collapsed"], and kol-framework.css renders it again.
152
+ * `collapsed` / `onToggle` remain ACCEPTED-but-inert — the gesture is
153
+ * self-contained, so call sites still don't break. */
154
+ const asideRef = useRef(null)
155
+ const { grabProps } = useDragResize(asideRef)
156
156
 
157
157
  /* Per-page section expand/collapse (opt-in via collapsibleSections).
158
158
  * The section containing the active route auto-expands on every
@@ -171,16 +171,14 @@ export default function SideNav({
171
171
 
172
172
  return (
173
173
  <aside
174
+ ref={asideRef}
174
175
  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' : ''}`}
175
176
  >
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. */}
177
+ {/* The collapse chevron Button was REMOVED 2026-07-30 (lobby ruling,
178
+ * finding #3) as a control with no renderer. The grab edge at the end
179
+ * of this aside is its deliberate 2026-08-09 replacement — pointer-drag
180
+ * resizes, drag under --kol-sidenav-snap collapses, double-click and
181
+ * Home reset, arrows resize from the keyboard. See useDragResize.js. */}
184
182
 
185
183
  <div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
186
184
  <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
@@ -267,6 +265,10 @@ export default function SideNav({
267
265
  <span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
268
266
  </a>
269
267
  </div>
268
+
269
+ {/* Grab edge — width/cursor/focus chrome in kol-framework.css
270
+ * (.kol-sidenav-grab); hidden below lg by the narrow-mode block. */}
271
+ <div className="kol-sidenav-grab absolute top-0 right-0 bottom-0 z-[1]" {...grabProps} />
270
272
  </aside>
271
273
  )
272
274
  }
@@ -0,0 +1,176 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /* Grab-edge resize + snap-collapse for SideNav (lobby: SideNavGrabResize).
4
+ * Mechanics are kol-mirror's MirrorPlayground.jsx#L21-L76 — pointerdown on the
5
+ * strip, window-level move/up, body cursor + userSelect held for the drag,
6
+ * double-click resets. What the DS adds on top of that prior art:
7
+ * - live width goes to --kol-sidenav-w on :root, so the layout grid, the
8
+ * shell-header brand block and the aside all follow one number
9
+ * - dragging under --kol-sidenav-snap stamps :root[data-sidenav="collapsed"]
10
+ * (rendered again in kol-framework.css since 2026-08-09); back out re-expands
11
+ * - width + state survive reload. 'kol-sidenav' keeps the consumers' existing
12
+ * 'collapsed'|'expanded' schema; width lives under its own key so the
13
+ * brand app's reader of that value never breaks
14
+ * Keyboard path (a11y is not optional per the brief): the strip is a focusable
15
+ * separator — arrows resize by --kol-sidenav-step, ArrowLeft past the snap
16
+ * collapses, ArrowRight re-expands, Home resets to the stylesheet default.
17
+ * Every value the gesture needs is a --kol-sidenav-* token in kol-framework.css
18
+ * — no literals here, so a missing token (framework CSS not loaded) leaves the
19
+ * gesture inert rather than improvising numbers. */
20
+
21
+ const STATE_KEY = 'kol-sidenav'
22
+ const WIDTH_KEY = 'kol-sidenav-w'
23
+
24
+ const root = () => document.documentElement
25
+
26
+ /* Resolve a length token to px, or null when it is absent/unparsable. */
27
+ function readVarPx(name) {
28
+ const raw = getComputedStyle(root()).getPropertyValue(name).trim()
29
+ const n = parseFloat(raw)
30
+ if (!raw || Number.isNaN(n)) return null
31
+ return raw.endsWith('rem') ? n * parseFloat(getComputedStyle(root()).fontSize) : n
32
+ }
33
+
34
+ /* Imperative DOM writes — pointermove must never re-render the nav tree.
35
+ * React state syncs from the DOM at rest (drag end / key press / reset). */
36
+ const stampCollapsed = (on) => {
37
+ if (on) root().setAttribute('data-sidenav', 'collapsed')
38
+ else root().removeAttribute('data-sidenav')
39
+ }
40
+ const writeWidth = (px) => {
41
+ if (px == null) root().style.removeProperty('--kol-sidenav-w')
42
+ else root().style.setProperty('--kol-sidenav-w', `${px}px`)
43
+ }
44
+ const readBack = () => {
45
+ const inline = parseFloat(root().style.getPropertyValue('--kol-sidenav-w'))
46
+ return {
47
+ collapsed: root().getAttribute('data-sidenav') === 'collapsed',
48
+ widthPx: Number.isNaN(inline) ? null : inline,
49
+ }
50
+ }
51
+
52
+ export default function useDragResize(asideRef) {
53
+ const drag = useRef(null) // { startX, startW, snapPx, maxPx } during a drag
54
+ const defaultPx = useRef(null)
55
+ const collapsedPx = useRef(null)
56
+ const [collapsed, setCollapsed] = useState(false)
57
+ const [widthPx, setWidthPx] = useState(null) // null = stylesheet default
58
+
59
+ const syncAndPersist = () => {
60
+ const { collapsed: c, widthPx: w } = readBack()
61
+ setCollapsed(c)
62
+ setWidthPx(w)
63
+ try {
64
+ localStorage.setItem(STATE_KEY, c ? 'collapsed' : 'expanded')
65
+ if (w == null) localStorage.removeItem(WIDTH_KEY)
66
+ else localStorage.setItem(WIDTH_KEY, String(Math.round(w)))
67
+ } catch { /* storage blocked */ }
68
+ }
69
+
70
+ /* Boot: capture the stylesheet defaults BEFORE any inline override lands,
71
+ * then restore the persisted width/state. */
72
+ useEffect(() => {
73
+ defaultPx.current = readVarPx('--kol-sidenav-w')
74
+ collapsedPx.current = readVarPx('--kol-sidenav-w-collapsed')
75
+ let w = null
76
+ let c = false
77
+ try {
78
+ w = parseFloat(localStorage.getItem(WIDTH_KEY)) || null
79
+ c = localStorage.getItem(STATE_KEY) === 'collapsed'
80
+ } catch { /* storage blocked */ }
81
+ if (w) { writeWidth(w); setWidthPx(w) }
82
+ if (c) { stampCollapsed(true); setCollapsed(true) }
83
+ }, [])
84
+
85
+ useEffect(() => {
86
+ const onMove = (e) => {
87
+ if (!drag.current) return
88
+ const { startX, startW, snapPx, maxPx } = drag.current
89
+ const next = startW + (e.clientX - startX)
90
+ if (next < snapPx) {
91
+ stampCollapsed(true)
92
+ } else {
93
+ stampCollapsed(false)
94
+ writeWidth(Math.min(next, maxPx))
95
+ }
96
+ }
97
+ const onUp = () => {
98
+ if (!drag.current) return
99
+ drag.current = null
100
+ root().removeAttribute('data-sidenav-dragging')
101
+ document.body.style.cursor = ''
102
+ document.body.style.userSelect = ''
103
+ syncAndPersist()
104
+ }
105
+ window.addEventListener('pointermove', onMove)
106
+ window.addEventListener('pointerup', onUp)
107
+ return () => {
108
+ window.removeEventListener('pointermove', onMove)
109
+ window.removeEventListener('pointerup', onUp)
110
+ }
111
+ }, [])
112
+
113
+ const onPointerDown = (e) => {
114
+ const snapPx = readVarPx('--kol-sidenav-snap')
115
+ if (defaultPx.current == null || snapPx == null) return // tokens absent → inert
116
+ e.preventDefault()
117
+ drag.current = {
118
+ startX: e.clientX,
119
+ startW: asideRef.current?.getBoundingClientRect().width ?? defaultPx.current,
120
+ snapPx,
121
+ /* mirror's ceiling (default × 3), resolved from the token not hardcoded */
122
+ maxPx: defaultPx.current * 3,
123
+ }
124
+ /* the grid's 180ms grid-template-columns ease would trail the pointer —
125
+ * kol-framework.css suspends it while this attribute is stamped */
126
+ root().setAttribute('data-sidenav-dragging', '')
127
+ document.body.style.cursor = 'col-resize'
128
+ document.body.style.userSelect = 'none'
129
+ }
130
+
131
+ const onDoubleClick = () => {
132
+ stampCollapsed(false)
133
+ writeWidth(null)
134
+ syncAndPersist()
135
+ }
136
+
137
+ const onKeyDown = (e) => {
138
+ const snapPx = readVarPx('--kol-sidenav-snap')
139
+ const stepPx = readVarPx('--kol-sidenav-step')
140
+ if (defaultPx.current == null || snapPx == null || stepPx == null) return
141
+ const { collapsed: c, widthPx: w } = readBack()
142
+ const current = w ?? defaultPx.current
143
+ if (e.key === 'ArrowRight') {
144
+ e.preventDefault()
145
+ if (c) stampCollapsed(false)
146
+ else writeWidth(Math.min(current + stepPx, defaultPx.current * 3))
147
+ syncAndPersist()
148
+ } else if (e.key === 'ArrowLeft') {
149
+ e.preventDefault()
150
+ if (c) return
151
+ const next = current - stepPx
152
+ if (next < snapPx) stampCollapsed(true)
153
+ else writeWidth(next)
154
+ syncAndPersist()
155
+ } else if (e.key === 'Home') {
156
+ e.preventDefault()
157
+ onDoubleClick()
158
+ }
159
+ }
160
+
161
+ return {
162
+ collapsed,
163
+ grabProps: {
164
+ role: 'separator',
165
+ 'aria-orientation': 'vertical',
166
+ 'aria-label': 'Resize navigation',
167
+ 'aria-valuenow': Math.round(collapsed ? collapsedPx.current : (widthPx ?? defaultPx.current)) || undefined,
168
+ 'aria-valuemin': collapsedPx.current == null ? undefined : Math.round(collapsedPx.current),
169
+ 'aria-valuemax': defaultPx.current == null ? undefined : Math.round(defaultPx.current * 3),
170
+ tabIndex: 0,
171
+ onPointerDown,
172
+ onDoubleClick,
173
+ onKeyDown,
174
+ },
175
+ }
176
+ }