@kolkrabbi/kol-shell 0.30.0 → 0.32.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.30.0",
3
+ "version": "0.32.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.142.0",
23
+ "@kolkrabbi/kol-component": "^0.150.1",
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-framework": "^0.36.0",
26
+ "@kolkrabbi/kol-theme": "^0.119.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/Logomark.jsx CHANGED
@@ -5,10 +5,39 @@ import { useState, useEffect } from 'react'
5
5
  * <img> renders a currentColor mark black in dark mode (invisible). Ported
6
6
  * verbatim from the kol-mirror cut (module-level cache; async resolve even on
7
7
  * cache hit — sync setState in an effect cascades renders).
8
+ *
9
+ * The fetched markup is SANITIZED before injection — `<style>`/`<script>`
10
+ * stripped, `on*` attributes dropped (LogomarkInlineStyleLeak, kol-chess
11
+ * 2026-09-01). An SVG *document* may legitimately carry its own `<style>` — a
12
+ * theme-aware favicon does — but once inlined, that `<style>` is
13
+ * DOCUMENT-GLOBAL: kol-chess pointed `svgUrl` at its favicon and every <svg>
14
+ * in the app took OS-keyed ink and ignored `data-theme`, presenting two
15
+ * packages away as "the theme toggle is broken". A mark that needs its own
16
+ * styling inlines it as attributes, not a stylesheet.
8
17
  */
9
18
 
10
19
  const svgCache = new Map()
11
20
 
21
+ /* strip what must never escape into the host document: stylesheets (global
22
+ * once inlined), scripts and event handlers (this is a fetch → innerHTML
23
+ * boundary). DOMParser is the correct tool; the regex fallback only runs if
24
+ * the markup does not even parse as SVG. */
25
+ function sanitizeSvg(svg) {
26
+ try {
27
+ const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
28
+ if (doc.querySelector('parsererror')) throw new Error('unparseable')
29
+ doc.querySelectorAll('style, script').forEach((el) => el.remove())
30
+ doc.querySelectorAll('*').forEach((el) => {
31
+ for (const a of [...el.attributes]) if (/^on/i.test(a.name)) el.removeAttribute(a.name)
32
+ })
33
+ return new XMLSerializer().serializeToString(doc)
34
+ } catch {
35
+ return svg
36
+ .replace(/<(style|script)[\s\S]*?<\/\1\s*>/gi, '')
37
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*')/gi, '')
38
+ }
39
+ }
40
+
12
41
  export default function Logomark({ svgUrl, size = 20, className = '', ...props }) {
13
42
  const [svgContent, setSvgContent] = useState(() => svgCache.get(svgUrl) || null)
14
43
 
@@ -16,7 +45,7 @@ export default function Logomark({ svgUrl, size = 20, className = '', ...props }
16
45
  if (!svgUrl) return
17
46
  let active = true
18
47
  Promise.resolve(
19
- svgCache.get(svgUrl) ?? fetch(svgUrl).then(res => res.text()).then(svg => { svgCache.set(svgUrl, svg); return svg })
48
+ svgCache.get(svgUrl) ?? fetch(svgUrl).then(res => res.text()).then(raw => { const svg = sanitizeSvg(raw); svgCache.set(svgUrl, svg); return svg })
20
49
  )
21
50
  .then(svg => { if (active) setSvgContent(svg) })
22
51
  .catch(() => {})
package/src/NavRail.jsx CHANGED
@@ -67,6 +67,9 @@ import Logomark from './Logomark.jsx'
67
67
  * @param {Object} logomark `{ svgUrl, title }` — the mark, and the app name beside it when open
68
68
  * @param {string} currentPath active match: '/' exact, else prefix → aria-current="page"
69
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.
70
73
  */
71
74
  const RAIL_W = '--kol-shell-rail-width'
72
75
  const CLOSED = 48
@@ -94,9 +97,15 @@ const openWidth = () => {
94
97
  * toggles. `onSnap` reports the resting state once — the sub rows render only
95
98
  * while open, because closed they would still take their height and push the
96
99
  * rungs below them. */
97
- function useRailDrag(railRef, grabRef, onSnap, snapRef) {
100
+ function useRailDrag(railRef, grabRef, onSnap, snapRef, enabled = true) {
98
101
  useEffect(() => {
99
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
100
109
  if (!strip || !rail) return undefined
101
110
  gsap.set(root, { [RAIL_W]: `${CLOSED}px` })
102
111
  let drag = null
@@ -259,24 +268,29 @@ export default function NavRail({
259
268
  onNavigate,
260
269
  iconComponent,
261
270
  hidden = false,
271
+ drawer = false,
262
272
  }) {
263
273
  const railRef = useRef(null)
264
274
  const grabRef = useRef(null)
265
275
  const [railOpen, setRailOpen] = useState(false)
266
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 */
267
279
  useGrabEdge(grabRef)
268
- useRailDrag(railRef, grabRef, setRailOpen, snapOpenRef)
280
+ useRailDrag(railRef, grabRef, setRailOpen, snapOpenRef, !drawer)
269
281
  if (hidden) return null
270
282
  const row = (item) => (
271
- <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?.()} />
272
284
  )
273
285
  return (
274
286
  <div
275
287
  ref={railRef}
276
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"
277
- 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})` }}
278
292
  >
279
- <div ref={grabRef} className="kol-rail-grab" />
293
+ {!drawer && <div ref={grabRef} className="kol-rail-grab" />}
280
294
  {logomark && (
281
295
  /* the mark, and the app name beside it when open — uppercase like the
282
296
  * rows (user 2026-08-28: "uppercase CONSISTENCY"). The `w-8` centring box