@kolkrabbi/kol-shell 0.29.0 → 0.31.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-shell",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "private": false,
5
5
  "description": "KOL application shell — fixed 48px NavRail + AppShell layout root, PageShell/PageHeader scaffolds, ContentFilters catalog organism, GridCard, SettingsScaffold, WalkthroughPanel, ShortcutsOverlay. App chrome (kol-framework owns site chrome). Nav items, content, shortcuts and settings are consumer-injected. Sits above @kolkrabbi/kol-{theme,component,framework}.",
6
6
  "license": "MIT",
@@ -20,10 +20,10 @@
20
20
  "react-dom": "^18.3.0 || ^19.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kolkrabbi/kol-component": "^0.138.0",
23
+ "@kolkrabbi/kol-component": "^0.145.0",
24
24
  "@kolkrabbi/kol-icons": "^0.25.0",
25
- "@kolkrabbi/kol-framework": "^0.35.0",
26
- "@kolkrabbi/kol-theme": "^0.111.0"
25
+ "@kolkrabbi/kol-theme": "^0.112.0",
26
+ "@kolkrabbi/kol-framework": "^0.36.0"
27
27
  },
28
28
  "files": [
29
29
  "src",
package/src/AppShell.jsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { Button } from '@kolkrabbi/kol-component'
2
3
  import NavRail from './NavRail.jsx'
3
4
  import { NavHiddenContext } from './navHidden.js'
4
5
  import { SettingsToggleContext } from './settingsToggle.js'
@@ -41,7 +42,17 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
41
42
  * toggle lives on the settings page, not in the rail (user, 2026-08-28).
42
43
  * @param {string} props.railToggleKey a key that toggles the rail (e.g. '\\') — ignored while typing in a field;
43
44
  * the rail comes back on every `currentPath` change (ShellHomeSystem, 2026-08-27)
44
- * @param {'shell'|'bare'|'overlay'} props.touch the touch-primary policy (default 'shell' = the rail regardless):
45
+ * @param {'shell'|'bare'|'overlay'|'drawer'} props.touch the touch-primary policy (default 'shell' = the rail regardless):
46
+ * 'drawer' takes the rail OFF-CANVAS below `drawerBelow`, hands its width
47
+ * back to the content, and renders a trigger that brings it in over a scrim.
48
+ * Tapping a destination closes it. (ShellRailNoDrawerOnMobile, kol-chess
49
+ * 2026-08-31: at 390 the 48px rail is 12.3% of the viewport, and
50
+ * `railToggleKey` is a KEY — a phone has no keyboard, so on the device where
51
+ * the rail costs most it could not be dismissed at all. `bare` was the only
52
+ * other way to reclaim the width and it throws navigation away entirely.)
53
+ * @param {number} [props.drawerBelow=768] viewport width under which `touch="drawer"` folds. A width, not a
54
+ * pointer test: an iPad is coarse and has room, a narrow desktop window is
55
+ * fine-pointered and does not.
45
56
  * `bare` renders the children with NO shell on a coarse-pointer device unless
46
57
  * localStorage `kol-desktop` is '1' (fxr's gate); `overlay` keeps the shell and
47
58
  * mounts TouchDeviceOverlay once (monitor's)
@@ -95,11 +106,34 @@ export default function AppShell({
95
106
  navKeys = false,
96
107
  settingsPath,
97
108
  settingsKey,
109
+ drawerBelow = 768,
98
110
  children,
99
111
  }) {
100
112
  const [navHidden, setNavHidden] = useState(false)
101
113
  const coarse = useTouchPrimary()
102
114
 
115
+ /* DRAWER MODE. A width query, not a pointer one — see `drawerBelow`. */
116
+ const [narrow, setNarrow] = useState(
117
+ () => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${drawerBelow - 1}px)`).matches,
118
+ )
119
+ useEffect(() => {
120
+ if (typeof window === 'undefined') return undefined
121
+ const mq = window.matchMedia(`(max-width: ${drawerBelow - 1}px)`)
122
+ const on = (e) => setNarrow(e.matches)
123
+ setNarrow(mq.matches)
124
+ mq.addEventListener('change', on)
125
+ return () => mq.removeEventListener('change', on)
126
+ }, [drawerBelow])
127
+ const drawer = touch === 'drawer' && narrow
128
+ const [drawerOpen, setDrawerOpen] = useState(false)
129
+ useEffect(() => { if (!drawer) setDrawerOpen(false) }, [drawer])
130
+ useEffect(() => {
131
+ if (!drawer || !drawerOpen) return undefined
132
+ const onKey = (e) => { if (e.key === 'Escape') setDrawerOpen(false) }
133
+ window.addEventListener('keydown', onKey)
134
+ return () => window.removeEventListener('keydown', onKey)
135
+ }, [drawer, drawerOpen])
136
+
103
137
  /* TOGGLING A DESTINATION (user 2026-08-30: "comma opens and closes the
104
138
  * settings page, and clicking the icon in sidebar opens and clicking again
105
139
  * closes"). The shell already owned two keyboard behaviours; this is a third
@@ -153,8 +187,16 @@ export default function AppShell({
153
187
  [settingsPath, toggleSettings, onNavigate],
154
188
  )
155
189
 
156
- /* the rail comes back on every route change */
157
- useEffect(() => { setNavHidden(false) }, [currentPath])
190
+ /* THE ROUTE CHANGE MEANS THE OPPOSITE IN EACH MODE. For `railToggleKey` the
191
+ * rail comes back on every navigation (ShellHomeSystem, 2026-08-27); for a
192
+ * drawer, tapping a destination must CLOSE it. The old unconditional
193
+ * `setNavHidden(false)` also made `navHidden` unusable as a consumer seam —
194
+ * child effects run before parent effects, so a consumer hiding the rail on a
195
+ * path change was overwritten in the same commit. */
196
+ useEffect(() => {
197
+ if (drawer) setDrawerOpen(false)
198
+ else setNavHidden(false)
199
+ }, [currentPath, drawer])
158
200
 
159
201
  /* THE WASH ALSO GOES ON THE ROOT (2026-08-30). It is set on the content
160
202
  * wrapper below, which every page inherits — but a PORTALLED surface does
@@ -228,9 +270,35 @@ export default function AppShell({
228
270
  * margin closes with it — one variable, both sides. */}
229
271
  <div
230
272
  className="kol-app-shell min-h-dvh bg-surface-primary"
231
- style={navHidden ? { '--kol-shell-rail-width': '0px' } : undefined}
273
+ /* A DRAWER ZEROES THE TOKEN TOO. Off-canvas means the content owns the
274
+ * whole viewport, so the same one variable that closes the content's
275
+ * margin for `navHidden` closes it here — the rail then takes its own
276
+ * `--kol-shell-drawer-width` rather than this token. */
277
+ data-rail-drawer={drawer ? (drawerOpen ? 'open' : 'closed') : undefined}
278
+ style={navHidden || drawer ? { '--kol-shell-rail-width': '0px' } : undefined}
232
279
  >
233
280
  {touch === 'overlay' && <TouchDeviceOverlay appName={appName} />}
281
+ {/* THE TRIGGER SHIPS HERE, not in every consumer's page header — the rail
282
+ * is the shell's, so the only way to reach it is too. Hamburger closed,
283
+ * × open; 32px square clears the 24px touch floor. */}
284
+ {drawer && (
285
+ <Button
286
+ variant="nav"
287
+ iconOnly={drawerOpen ? 'x' : 'hamburger'}
288
+ iconComponent={iconComponent}
289
+ aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
290
+ aria-expanded={drawerOpen}
291
+ className="kol-shell-drawer-trigger"
292
+ onClick={() => setDrawerOpen((o) => !o)}
293
+ />
294
+ )}
295
+ {drawer && drawerOpen && (
296
+ <div
297
+ className="kol-shell-drawer-scrim"
298
+ onClick={() => setDrawerOpen(false)}
299
+ aria-hidden="true"
300
+ />
301
+ )}
234
302
  {!navHidden && (
235
303
  <Rail
236
304
  items={items}
@@ -240,6 +308,7 @@ export default function AppShell({
240
308
  /* the rail routes through `navigate`, so its settings row toggles like the key */
241
309
  onNavigate={navigate}
242
310
  iconComponent={iconComponent}
311
+ drawer={drawer}
243
312
  />
244
313
  )}
245
314
  {/* THE BACK OF THE BACK — surface-primary, always, in every app; the
package/src/NavRail.jsx CHANGED
@@ -2,6 +2,11 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import gsap from 'gsap'
3
3
  import { Button } from '@kolkrabbi/kol-component'
4
4
  import { GRAB } from '@kolkrabbi/kol-component/utilities/motion'
5
+ /* the grab pill's proximity wake, travel and dwell — ONE implementation, shared
6
+ * with kol-framework's useDragResize (OneGrabGestureBothRails, kol-fxr
7
+ * 2026-08-30: two rails on one screen felt different because each package had
8
+ * built the gesture itself). It lived here until then. */
9
+ import { useGrabEdge } from '@kolkrabbi/kol-component'
5
10
  import { Icon } from '@kolkrabbi/kol-icons'
6
11
  import Logomark from './Logomark.jsx'
7
12
 
@@ -62,6 +67,9 @@ import Logomark from './Logomark.jsx'
62
67
  * @param {Object} logomark `{ svgUrl, title }` — the mark, and the app name beside it when open
63
68
  * @param {string} currentPath active match: '/' exact, else prefix → aria-current="page"
64
69
  * @param {Function} onNavigate `(path) => void` — every click, the mark included
70
+ * @param {boolean} drawer off-canvas mode: no grab, no drag, no token writes, rows always
71
+ * labelled, and the rail sizes itself from `--kol-shell-drawer-width` (default 240px).
72
+ * AppShell sets this from `touch="drawer"` — see ShellRailNoDrawerOnMobile, 2026-08-31.
65
73
  */
66
74
  const RAIL_W = '--kol-shell-rail-width'
67
75
  const CLOSED = 48
@@ -83,45 +91,21 @@ const openWidth = () => {
83
91
  * lands on you, holds while you move inside the radius, then travels to where you
84
92
  * are now — inside `GRAB.range`, the middle band of the edge, so it never rides
85
93
  * up beside the logomark. One window listener, rAF-throttled. */
86
- function useGrabEdge(ref) {
87
- useEffect(() => {
88
- let raf = 0
89
- const onMove = ({ clientX, clientY }) => {
90
- if (raf) return
91
- raf = requestAnimationFrame(() => {
92
- raf = 0
93
- const h = ref.current
94
- if (!h) return
95
- const r = h.getBoundingClientRect()
96
- const dist = Math.abs(clientX - (r.left + r.width / 2))
97
- const near = dist <= GRAB.near || (h.classList.contains('is-near') && dist <= GRAB.sleep)
98
- h.classList.toggle('is-near', near)
99
- if (!near) return
100
- /* the travel band: bipolar from the middle, GRAB.range of the height */
101
- const edge = (r.height * (1 - GRAB.range)) / 2
102
- const along = Math.min(Math.max(clientY - r.top, edge), r.height - edge)
103
- if (h.dataset.grabSeeded && Math.abs(along - Number(h.dataset.grabTarget)) < GRAB.stick) return
104
- h.dataset.grabTarget = String(along)
105
- const vars = { '--kol-rail-grab-y': `${along}px` }
106
- /* the CSS fallback is 50%, a percentage — nothing to tween from, so the
107
- * first sighting sets and every move after tweens */
108
- if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...GRAB.travel, overwrite: 'auto' })
109
- else { gsap.set(h, vars); h.dataset.grabSeeded = '1' }
110
- })
111
- }
112
- window.addEventListener('pointermove', onMove)
113
- return () => { window.removeEventListener('pointermove', onMove); cancelAnimationFrame(raf) }
114
- }, [ref])
115
- }
116
94
 
117
95
  /* THE DRAG — hold the pill and the width follows the pointer between closed and
118
96
  * open; release snaps to the nearer state, a click (no travel past GRAB.slop)
119
97
  * toggles. `onSnap` reports the resting state once — the sub rows render only
120
98
  * while open, because closed they would still take their height and push the
121
99
  * rungs below them. */
122
- function useRailDrag(railRef, grabRef, onSnap, snapRef) {
100
+ function useRailDrag(railRef, grabRef, onSnap, snapRef, enabled = true) {
123
101
  useEffect(() => {
124
102
  const strip = grabRef.current, rail = railRef.current, root = document.documentElement
103
+ /* A DRAWER IS NOT A DRAGGABLE RAIL (ShellRailNoDrawerOnMobile, kol-chess
104
+ * 2026-08-31). In drawer mode the rail is off-canvas at a fixed width and
105
+ * the content owns the whole viewport, so the drag must not run at all —
106
+ * it writes `--kol-shell-rail-width` on `:root` per pointermove, which is
107
+ * exactly the inline token a consumer could not reach without `!important`. */
108
+ if (!enabled) return undefined
125
109
  if (!strip || !rail) return undefined
126
110
  gsap.set(root, { [RAIL_W]: `${CLOSED}px` })
127
111
  let drag = null
@@ -284,24 +268,29 @@ export default function NavRail({
284
268
  onNavigate,
285
269
  iconComponent,
286
270
  hidden = false,
271
+ drawer = false,
287
272
  }) {
288
273
  const railRef = useRef(null)
289
274
  const grabRef = useRef(null)
290
275
  const [railOpen, setRailOpen] = useState(false)
291
276
  const snapOpenRef = useRef(null)
277
+ /* the grab strip is not rendered in drawer mode, so the hook's ref stays null
278
+ * and it no-ops on its own — no second argument needed */
292
279
  useGrabEdge(grabRef)
293
- useRailDrag(railRef, grabRef, setRailOpen, snapOpenRef)
280
+ useRailDrag(railRef, grabRef, setRailOpen, snapOpenRef, !drawer)
294
281
  if (hidden) return null
295
282
  const row = (item) => (
296
- <RailItem key={item.path} {...item} currentPath={currentPath} onNavigate={onNavigate} iconComponent={iconComponent} railOpen={railOpen} onOpenRail={() => snapOpenRef.current?.()} />
283
+ <RailItem key={item.path} {...item} currentPath={currentPath} onNavigate={onNavigate} iconComponent={iconComponent} railOpen={drawer || railOpen} onOpenRail={() => snapOpenRef.current?.()} />
297
284
  )
298
285
  return (
299
286
  <div
300
287
  ref={railRef}
301
288
  className="kol-shell-rail bg-surface-primary border-r border-fg-08 fixed inset-y-0 left-0 flex flex-col items-start pt-4 pb-4 px-2 gap-2"
302
- style={{ width: `var(${RAIL_W})` }}
289
+ /* A drawer takes its own width, NOT the live rail token — that token is
290
+ * zeroed in drawer mode so the content gets the whole viewport back. */
291
+ style={{ width: drawer ? 'var(--kol-shell-drawer-width, 240px)' : `var(${RAIL_W})` }}
303
292
  >
304
- <div ref={grabRef} className="kol-rail-grab" />
293
+ {!drawer && <div ref={grabRef} className="kol-rail-grab" />}
305
294
  {logomark && (
306
295
  /* the mark, and the app name beside it when open — uppercase like the
307
296
  * rows (user 2026-08-28: "uppercase CONSISTENCY"). The `w-8` centring box