@kolkrabbi/kol-framework 0.16.0 → 0.17.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
@@ -40,9 +40,10 @@ body {
40
40
  -dot-left shipped). kol-mirror carried them as bare literals, the
41
41
  consumer-app slack the brief bans copying; both widths reference the
42
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 */
43
+ --kol-sidenav-grab-w: var(--kol-spacing-2); /* the strip's hit width (doubled 2026-08-09 — the pill made it a click target) */
44
44
  --kol-sidenav-snap: 12rem; /* drag under this snaps to -w-collapsed */
45
45
  --kol-sidenav-step: var(--kol-spacing-4); /* keyboard resize increment */
46
+ --kol-sidenav-snap-default: var(--kol-sidenav-step); /* release within this of the default clears the override */
46
47
  --kol-sidenav-w-collapsed: 56px;
47
48
  /* The workshop shell's RIGHT rail. EQUAL TO THE LEFT (user ruling
48
49
  2026-08-01) — the two rails frame the same content and a lopsided frame is
@@ -175,6 +176,25 @@ body {
175
176
  width: var(--kol-sidenav-grab-w);
176
177
  cursor: col-resize;
177
178
  }
179
+ /* THE PILL (user build order 2026-08-09): the edge's rest-visible indicator —
180
+ * a vertical rounded bar centred mid-height, dim at rest, full ink on
181
+ * hover/focus. Same law as the record grip: affordances visible at rest. */
182
+ .kol-sidenav-grab::before {
183
+ content: '';
184
+ position: absolute;
185
+ top: 50%;
186
+ left: 50%;
187
+ translate: -50% -50%;
188
+ width: 0.1875rem;
189
+ height: 2rem;
190
+ border-radius: var(--kol-radius-full);
191
+ background: var(--kol-fg-24);
192
+ transition: background-color 150ms ease;
193
+ }
194
+ .kol-sidenav-grab:hover::before,
195
+ .kol-sidenav-grab:focus-visible::before {
196
+ background: var(--kol-fg-64);
197
+ }
178
198
  .kol-sidenav-grab:focus-visible {
179
199
  outline: 2px solid var(--kol-focus-ring);
180
200
  outline-offset: -2px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.16.0",
3
+ "version": "0.17.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,8 +27,8 @@
27
27
  "app-shell"
28
28
  ],
29
29
  "dependencies": {
30
- "@kolkrabbi/kol-component": "0.28.0",
31
- "@kolkrabbi/kol-icons": "0.10.0"
30
+ "@kolkrabbi/kol-component": "^0.32.2",
31
+ "@kolkrabbi/kol-icons": "^0.12.1"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^18.3.0 || ^19.0.0",
package/src/SideNav.jsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { NavLink, useLocation } from 'react-router-dom'
2
2
  import { useRef, useState } from 'react'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
- import { Button, Tooltip } from '@kolkrabbi/kol-component'
4
+ import { Tooltip } from '@kolkrabbi/kol-component'
5
5
  import ThemeToggle from './ThemeToggle'
6
6
  import useDragResize from './useDragResize'
7
7
 
@@ -22,12 +22,13 @@ import useDragResize from './useDragResize'
22
22
  * navTree: [{ id, label, icon, to?, pages?: [{ label, to }] }]
23
23
  * (legacy `children` route leaves are read as `pages`; section anchors are not.)
24
24
  *
25
- * Collapse: BOTH affordances share one contract —
26
- * `:root[data-sidenav="collapsed"]` + the `kol-sidenav` storage schema, owned
27
- * by useDragResize. The round chip Button (elder A/B, closed 2026-08-01) is
28
- * the one-click toggle; the grab edge (lobby: SideNavGrabResize, 2026-08-09)
29
- * resizes, snap-collapses, and carries the keyboard path. Collapsed rows get
30
- * a DS Tooltip — the glyph is otherwise the only thing naming them.
25
+ * Collapse: the pill-marked grab edge is THE single control (user build
26
+ * order 2026-08-09, completing SideNavGrabResize — the chip Button is gone
27
+ * in both states). Click toggles expand↔collapse, drag resizes with
28
+ * snap-collapse and snap-to-default, keyboard on the separator: arrows /
29
+ * Home / Enter / Space. One contract: `:root[data-sidenav="collapsed"]` +
30
+ * the `kol-sidenav` storage schema, owned by useDragResize. Collapsed rows
31
+ * get a DS Tooltip — the glyph is otherwise the only thing naming them.
31
32
  *
32
33
  * `background` (2026-08-09, prop): `true` (default) paints the rail surface +
33
34
  * right hairline; `false` renders chromeless so the rail can float over a
@@ -94,25 +95,10 @@ export default function SideNav({
94
95
  ref={asideRef}
95
96
  className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col z-20${background ? ' bg-surface-primary border-r border-fg-08' : ''}${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
96
97
  >
97
- {/* The elder A/B (closed 2026-08-01): the DS Button IS the collapse
98
- * control — EXPANDED ONLY (user ruling 2026-08-09, verbatim: "REMOVE
99
- * chevron from collapsed view … from collapsed view pressing any icon
100
- * would expand the sidebar!"). The collapsed rail's expand affordance
101
- * is the rows themselves. Offset is half the pinned sm square,
102
- * centring it on the rail's edge. */}
103
- {!collapsed && (
104
- <Button
105
- variant="primary"
106
- size="sm"
107
- radius="full"
108
- iconOnly="chevron-left"
109
- aria-label="Collapse sidebar"
110
- aria-expanded
111
- title="Collapse"
112
- onClick={toggleCollapsed}
113
- className="kol-sidenav-toggle absolute top-5 right-[-0.875rem] z-[2]"
114
- />
115
- )}
98
+ {/* NO chip Button in either state (user build order 2026-08-09 — "MAKE
99
+ * IT"; supersedes the elder A/B): the pill-marked grab edge below is
100
+ * THE single collapse/resize control — click toggles, drag resizes,
101
+ * snap-to-default on release, Home/Enter/Space on the keyboard. */}
116
102
 
117
103
  <div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
118
104
  <nav aria-label="Sections">
@@ -1,25 +1,35 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
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. */
3
+ /* Grab-edge resize + collapse for SideNav — THE single control since 0.17.0
4
+ * (user build order 2026-08-09, completing the SideNavGrabResize brief: the
5
+ * chip Button is gone in both states; the pill-marked edge does everything).
6
+ * Logic lifted from the hand-tested brand proto (kol-website _tmp
7
+ * useDragResizeProto.js), which forked this hook's 0.16.0 form.
8
+ *
9
+ * - CLICK TOGGLES expand↔collapse: pointerup with < 3px of travel is a
10
+ * click, never a resize — drags only start past the slop, so toggle
11
+ * presses can't jitter the rail.
12
+ * - DRAG resizes; live width goes to --kol-sidenav-w on :root so the grid,
13
+ * shell-header brand block and aside follow one number. Dragging under
14
+ * --kol-sidenav-snap stamps :root[data-sidenav="collapsed"].
15
+ * - SNAP-TO-DEFAULT: releasing within --kol-sidenav-snap-default of the
16
+ * stylesheet default clears the override — you land exactly on default,
17
+ * never 249px or 263px.
18
+ * - DOUBLE-CLICK RESET IS GONE — it cannot coexist with click-toggle (two
19
+ * clicks would toggle-toggle-reset). Home keeps the reset; the snap band
20
+ * covers pointer users.
21
+ * - Keyboard on the focused separator: arrows resize by --kol-sidenav-step
22
+ * (ArrowLeft past the snap collapses), Home resets, Enter/Space toggle.
23
+ * - Width + state survive reload ('kol-sidenav' keeps the consumers'
24
+ * existing 'collapsed'|'expanded' schema; width under its own key).
25
+ *
26
+ * Every value the gesture needs is a --kol-sidenav-* token in
27
+ * kol-framework.css — no literals here except the click slop, which is a
28
+ * gesture constant, not chrome. Missing tokens leave the gesture inert. */
20
29
 
21
30
  const STATE_KEY = 'kol-sidenav'
22
31
  const WIDTH_KEY = 'kol-sidenav-w'
32
+ const CLICK_SLOP_PX = 3
23
33
 
24
34
  const root = () => document.documentElement
25
35
 
@@ -32,7 +42,7 @@ function readVarPx(name) {
32
42
  }
33
43
 
34
44
  /* 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). */
45
+ * React state syncs from the DOM at rest (release / key press / reset). */
36
46
  const stampCollapsed = (on) => {
37
47
  if (on) root().setAttribute('data-sidenav', 'collapsed')
38
48
  else root().removeAttribute('data-sidenav')
@@ -50,7 +60,7 @@ const readBack = () => {
50
60
  }
51
61
 
52
62
  export default function useDragResize(asideRef) {
53
- const drag = useRef(null) // { startX, startW, snapPx, maxPx } during a drag
63
+ const drag = useRef(null) // { startX, startW, snapPx, maxPx, moved } during a drag
54
64
  const defaultPx = useRef(null)
55
65
  const collapsedPx = useRef(null)
56
66
  const [collapsed, setCollapsed] = useState(false)
@@ -67,6 +77,12 @@ export default function useDragResize(asideRef) {
67
77
  } catch { /* storage blocked */ }
68
78
  }
69
79
 
80
+ const toggleCollapsed = () => {
81
+ const { collapsed: c } = readBack()
82
+ stampCollapsed(!c)
83
+ syncAndPersist()
84
+ }
85
+
70
86
  /* Boot: capture the stylesheet defaults BEFORE any inline override lands,
71
87
  * then restore the persisted width/state. */
72
88
  useEffect(() => {
@@ -85,21 +101,37 @@ export default function useDragResize(asideRef) {
85
101
  useEffect(() => {
86
102
  const onMove = (e) => {
87
103
  if (!drag.current) return
88
- const { startX, startW, snapPx, maxPx } = drag.current
89
- const next = startW + (e.clientX - startX)
90
- if (next < snapPx) {
104
+ const d = drag.current
105
+ const dx = e.clientX - d.startX
106
+ /* Below the slop the pointer is still a CLICK — resizing from the
107
+ * first pixel would jitter the rail on every toggle press. */
108
+ if (!d.moved) {
109
+ if (Math.abs(dx) < CLICK_SLOP_PX) return
110
+ d.moved = true
111
+ }
112
+ const next = d.startW + dx
113
+ if (next < d.snapPx) {
91
114
  stampCollapsed(true)
92
115
  } else {
93
116
  stampCollapsed(false)
94
- writeWidth(Math.min(next, maxPx))
117
+ writeWidth(Math.min(next, d.maxPx))
95
118
  }
96
119
  }
97
120
  const onUp = () => {
98
121
  if (!drag.current) return
122
+ const { moved } = drag.current
99
123
  drag.current = null
100
124
  root().removeAttribute('data-sidenav-dragging')
101
125
  document.body.style.cursor = ''
102
126
  document.body.style.userSelect = ''
127
+ if (!moved) { toggleCollapsed(); return } // a click, not a drag
128
+ /* Snap-to-default: release near the stylesheet default clears the
129
+ * override entirely. */
130
+ const { collapsed: c, widthPx: w } = readBack()
131
+ const band = readVarPx('--kol-sidenav-snap-default') ?? readVarPx('--kol-sidenav-step') ?? 16
132
+ if (!c && w != null && defaultPx.current != null && Math.abs(w - defaultPx.current) <= band) {
133
+ writeWidth(null)
134
+ }
103
135
  syncAndPersist()
104
136
  }
105
137
  window.addEventListener('pointermove', onMove)
@@ -120,29 +152,21 @@ export default function useDragResize(asideRef) {
120
152
  snapPx,
121
153
  /* mirror's ceiling (default × 3), resolved from the token not hardcoded */
122
154
  maxPx: defaultPx.current * 3,
155
+ moved: false,
123
156
  }
124
- /* the grid's 180ms grid-template-columns ease would trail the pointer —
157
+ /* the grid's grid-template-columns ease would trail the pointer —
125
158
  * kol-framework.css suspends it while this attribute is stamped */
126
159
  root().setAttribute('data-sidenav-dragging', '')
127
160
  document.body.style.cursor = 'col-resize'
128
161
  document.body.style.userSelect = 'none'
129
162
  }
130
163
 
131
- const onDoubleClick = () => {
164
+ const resetToDefault = () => {
132
165
  stampCollapsed(false)
133
166
  writeWidth(null)
134
167
  syncAndPersist()
135
168
  }
136
169
 
137
- /* One-click toggle for the chip Button (elder A/B, folded back 2026-08-09)
138
- * — same stamp, same storage schema, so the chip and the grab edge can
139
- * never disagree about the state. */
140
- const toggleCollapsed = () => {
141
- const { collapsed: c } = readBack()
142
- stampCollapsed(!c)
143
- syncAndPersist()
144
- }
145
-
146
170
  const onKeyDown = (e) => {
147
171
  const snapPx = readVarPx('--kol-sidenav-snap')
148
172
  const stepPx = readVarPx('--kol-sidenav-step')
@@ -163,7 +187,10 @@ export default function useDragResize(asideRef) {
163
187
  syncAndPersist()
164
188
  } else if (e.key === 'Home') {
165
189
  e.preventDefault()
166
- onDoubleClick()
190
+ resetToDefault()
191
+ } else if (e.key === 'Enter' || e.key === ' ') {
192
+ e.preventDefault()
193
+ toggleCollapsed()
167
194
  }
168
195
  }
169
196
 
@@ -179,7 +206,6 @@ export default function useDragResize(asideRef) {
179
206
  'aria-valuemax': defaultPx.current == null ? undefined : Math.round(defaultPx.current * 3),
180
207
  tabIndex: 0,
181
208
  onPointerDown,
182
- onDoubleClick,
183
209
  onKeyDown,
184
210
  },
185
211
  }