@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 +4 -4
- package/src/AppShell.jsx +73 -4
- package/src/Logomark.jsx +30 -1
- package/src/NavRail.jsx +19 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-shell",
|
|
3
|
-
"version": "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.
|
|
23
|
+
"@kolkrabbi/kol-component": "^0.150.1",
|
|
24
24
|
"@kolkrabbi/kol-icons": "^0.25.0",
|
|
25
|
-
"@kolkrabbi/kol-framework": "^0.
|
|
26
|
-
"@kolkrabbi/kol-theme": "^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
|
-
/*
|
|
157
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|