@kolkrabbi/kol-shell 0.14.0 → 0.16.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 +7 -7
- package/src/AppShell.jsx +23 -29
- package/src/NavRail.jsx +225 -83
- package/src/PageHeader.jsx +36 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"./src/*": "./src/*"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"@kolkrabbi/kol-component": ">=0.
|
|
16
|
-
"@kolkrabbi/kol-framework": ">=0.34.0",
|
|
15
|
+
"@kolkrabbi/kol-component": ">=0.125.0",
|
|
17
16
|
"@kolkrabbi/kol-icons": ">=0.16.0",
|
|
18
|
-
"@kolkrabbi/kol-theme": ">=0.
|
|
17
|
+
"@kolkrabbi/kol-theme": ">=0.84.0",
|
|
18
|
+
"gsap": "^3.13.0",
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
|
20
20
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@kolkrabbi/kol-component": "^0.
|
|
24
|
-
"@kolkrabbi/kol-framework": "^0.
|
|
23
|
+
"@kolkrabbi/kol-component": "^0.125.0",
|
|
24
|
+
"@kolkrabbi/kol-framework": "^0.35.0",
|
|
25
25
|
"@kolkrabbi/kol-icons": "^0.24.0",
|
|
26
|
-
"@kolkrabbi/kol-theme": "^0.
|
|
26
|
+
"@kolkrabbi/kol-theme": "^0.88.0"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"src",
|
package/src/AppShell.jsx
CHANGED
|
@@ -9,21 +9,17 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
|
|
|
9
9
|
* children and wire `currentPath`/`onNavigate` from your router at the call
|
|
10
10
|
* site.
|
|
11
11
|
*
|
|
12
|
-
* THE RAIL IS
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* theme (kol-mirror's bump, 2026-08-28): the grid, the sidenav's box and
|
|
24
|
-
* collapse rules and its width tokens live there, not in kol-theme — without
|
|
25
|
-
* it the root is `display: block` and the rail is a horizontal bar, with a
|
|
26
|
-
* green build and a clean console.
|
|
12
|
+
* THE RAIL IS FLAT AGAIN (RailFlatGrabOpen, kol-mirror 2026-08-28 — user: "the
|
|
13
|
+
* div structure is super simple ok … it's 1 div parent and everything is just
|
|
14
|
+
* in that container"): 0.13.0 had made it a collapsed kol-framework `SideNav`
|
|
15
|
+
* inside the brand grid; 0.16.0 reverses that. The root is a plain wrapper, the
|
|
16
|
+
* rail is `position: fixed`, and the content is offset by
|
|
17
|
+
* `margin-left: var(--kol-shell-rail-width)` — the ONE variable the rail writes
|
|
18
|
+
* per pointermove during a grab-drag and tweens on release, so the page is
|
|
19
|
+
* pushed live through both. Hidden rail = `0px`.
|
|
20
|
+
*
|
|
21
|
+
* `kol-framework.css` is no longer required by the shell (it was, for one
|
|
22
|
+
* grid rule, 0.13–0.15).
|
|
27
23
|
*
|
|
28
24
|
* @param {Array} props.items nav items `{ icon, path, label }`
|
|
29
25
|
* @param {Array} props.bottomItems items pinned below the theme toggle
|
|
@@ -31,11 +27,11 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
|
|
|
31
27
|
* @param {string} props.currentPath the router's current pathname
|
|
32
28
|
* @param {Function} props.onNavigate `(path) => void`
|
|
33
29
|
* @param {ElementType} props.iconComponent icon renderer seam (see Button)
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
30
|
+
* NOTE (0.16.0): `settings` and `themeToggle` left with the SideNav-backed rail
|
|
31
|
+
* — the flat rail carries neither. Settings is a `bottomItems` rung whose
|
|
32
|
+
* open/close is the consumer's `onNavigate` (kol-mirror's model: navigating to
|
|
33
|
+
* `/settings` while already there returns to the last page), and the theme
|
|
34
|
+
* toggle lives on the settings page, not in the rail (user, 2026-08-28).
|
|
39
35
|
* @param {string} props.railToggleKey a key that toggles the rail (e.g. '\\') — ignored while typing in a field;
|
|
40
36
|
* the rail comes back on every `currentPath` change (ShellHomeSystem, 2026-08-27)
|
|
41
37
|
* @param {'shell'|'bare'|'overlay'} props.touch the touch-primary policy (default 'shell' = the rail regardless):
|
|
@@ -66,8 +62,6 @@ export default function AppShell({
|
|
|
66
62
|
currentPath,
|
|
67
63
|
onNavigate,
|
|
68
64
|
iconComponent,
|
|
69
|
-
settings,
|
|
70
|
-
themeToggle,
|
|
71
65
|
railToggleKey,
|
|
72
66
|
touch = 'shell',
|
|
73
67
|
appName,
|
|
@@ -120,11 +114,11 @@ export default function AppShell({
|
|
|
120
114
|
return (
|
|
121
115
|
<NavHiddenContext.Provider value={{ navHidden, setNavHidden }}>
|
|
122
116
|
{/* `kol-app-shell` = the app tier: neutral ::selection (kol-theme).
|
|
123
|
-
*
|
|
124
|
-
*
|
|
117
|
+
* A hidden rail zeroes the live width token, so the content's own
|
|
118
|
+
* margin closes with it — one variable, both sides. */}
|
|
125
119
|
<div
|
|
126
|
-
className="kol-app-shell
|
|
127
|
-
style={navHidden ? {
|
|
120
|
+
className="kol-app-shell min-h-dvh bg-surface-primary"
|
|
121
|
+
style={navHidden ? { '--kol-shell-rail-width': '0px' } : undefined}
|
|
128
122
|
>
|
|
129
123
|
{touch === 'overlay' && <TouchDeviceOverlay appName={appName} />}
|
|
130
124
|
{!navHidden && (
|
|
@@ -135,13 +129,13 @@ export default function AppShell({
|
|
|
135
129
|
currentPath={currentPath}
|
|
136
130
|
onNavigate={onNavigate}
|
|
137
131
|
iconComponent={iconComponent}
|
|
138
|
-
settings={settings}
|
|
139
|
-
themeToggle={themeToggle ?? !settings}
|
|
140
132
|
/>
|
|
141
133
|
)}
|
|
142
134
|
{/* THE BACK OF THE BACK — surface-primary, always, in every app; the
|
|
143
135
|
* page paints its wash over it (ShellPageWash). */}
|
|
144
|
-
|
|
136
|
+
{/* the margin IS the rail's live width, so the content is pushed
|
|
137
|
+
* through the drag and the snap (RailFlatGrabOpen) */}
|
|
138
|
+
<div className="bg-surface-primary min-w-0" style={{ marginLeft: `var(--kol-shell-rail-width)`, '--kol-shell-page-wash': pageWash }}>
|
|
145
139
|
{children}
|
|
146
140
|
</div>
|
|
147
141
|
</div>
|
package/src/NavRail.jsx
CHANGED
|
@@ -1,39 +1,204 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import gsap from 'gsap'
|
|
3
|
+
import { Button } from '@kolkrabbi/kol-component'
|
|
4
|
+
import { GRAB } from '@kolkrabbi/kol-component/utilities/motion'
|
|
2
5
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
6
|
import Logomark from './Logomark.jsx'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
|
-
* NavRail —
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* (48 vs 56 wide, 20 vs 16px glyphs, 40 vs 38 pitch, first glyph at y 76 vs
|
|
12
|
-
* 26). There is no second component now: this file maps the shell's flat
|
|
13
|
-
* `{ icon, path, label }` items onto the sidenav's tree and mounts it
|
|
14
|
-
* collapsed — the pill-marked grab edge opens it to the brand sidebar, the
|
|
15
|
-
* drag is the transition, and it stays a rail while it navigates
|
|
16
|
-
* (`expandOnSelect={false}`).
|
|
9
|
+
* NavRail — the flat rail. ONE fixed column, every child a direct child of it
|
|
10
|
+
* (RailFlatGrabOpen, kol-mirror 2026-08-28 — user, on the monitor rail he
|
|
11
|
+
* measured it against: "the div structure is super simple ok … it's 1 div
|
|
12
|
+
* parent and everything is just in that container", and on the SideNav-backed
|
|
13
|
+
* rail that had replaced it: it was "impossible" to get this shape out of it).
|
|
17
14
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* 0.13.0's footer placement: "why did you move the logo from top to bottom?
|
|
23
|
-
* … I've never seen that before so you are the first"). Its `title` is the
|
|
24
|
-
* label beside the mark in the open rail; collapsed, the mark alone.
|
|
15
|
+
* This REVERSES RailSideNavPixelParity (0.13.0), which made the rail a collapsed
|
|
16
|
+
* kol-framework `SideNav`. That ticket's pixel argument is settled the other way:
|
|
17
|
+
* the rail does not share pixels with the brand sidebar because it never becomes
|
|
18
|
+
* one. `RailLogomarkAtTop` stands — the mark is at the top in both states.
|
|
25
19
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
20
|
+
* THE GRAB OPENS IT (user: "when you grab it should not move the icons one
|
|
21
|
+
* pixel, only reveal the title, and a chevron to see sub categories" · "when it
|
|
22
|
+
* opens it should push the main content inside"). Closed, every row is the rail
|
|
23
|
+
* minus its gutter — the 32px rung alone shows and the label and chevron exist
|
|
24
|
+
* but are clipped by the row's own width. Open, the clip reveals them. Nothing
|
|
25
|
+
* in the icon column moves between the two states, and the content column
|
|
26
|
+
* follows because AppShell offsets by the same live variable.
|
|
27
|
+
*
|
|
28
|
+
* ONE WIDTH VARIABLE: `--kol-shell-rail-width` on `:root` — this rail is that
|
|
29
|
+
* wide and AppShell's content is offset by it, written per pointermove during a
|
|
30
|
+
* drag and tweened on release, so the page is pushed live through both. A drag
|
|
31
|
+
* writes the DOM, not React state: a pointermove per frame is not a render.
|
|
32
|
+
*
|
|
33
|
+
* The pill is kol-r2b2's, verbatim (user: "make it like it is in kol-r2b2, it
|
|
34
|
+
* has animation and gsap") — `.kol-rail-grab` in kol-theme's kol-animation.css,
|
|
35
|
+
* its pointer numbers in kol-component's `utilities/motion` (`GRAB`).
|
|
36
|
+
*
|
|
37
|
+
* @param {Array} items `{ icon, path, label, sub?: [{ path, label }] }`
|
|
38
|
+
* @param {Array} bottomItems pinned below the rule (Settings)
|
|
39
|
+
* @param {Object} logomark `{ svgUrl, title }` — the mark, and the app name beside it when open
|
|
40
|
+
* @param {string} currentPath active match: '/' exact, else prefix → aria-current="page"
|
|
41
|
+
* @param {Function} onNavigate `(path) => void` — every click, the mark included
|
|
36
42
|
*/
|
|
43
|
+
const RAIL_W = '--kol-shell-rail-width'
|
|
44
|
+
const CLOSED = 48
|
|
45
|
+
|
|
46
|
+
/* the OPEN width is the sidenav's ladder (kol-framework: 264, 320 from 1536),
|
|
47
|
+
* read at drag time so the rail lands on whichever rung the window is on */
|
|
48
|
+
const openWidth = () => {
|
|
49
|
+
const v = getComputedStyle(document.documentElement).getPropertyValue('--kol-sidenav-w').trim()
|
|
50
|
+
const px = parseFloat(v)
|
|
51
|
+
return Number.isFinite(px) ? (v.endsWith('rem') ? px * 16 : px) : 264
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* THE GRAB EDGE — proximity, not contact: the pill wakes within `GRAB.near` of
|
|
55
|
+
* the line and sleeps only past `GRAB.sleep` (hovering the line itself flapped
|
|
56
|
+
* the class every frame and restarted the fade). Along the line it rests on the
|
|
57
|
+
* nearest of GRAB.marks and is STICKY — it leaves a mark only within `GRAB.stick`
|
|
58
|
+
* of the pitch of another (user: "following the mouse a little too much"), so the
|
|
59
|
+
* middle of each gap is dead. One window listener, rAF-throttled. */
|
|
60
|
+
function useGrabEdge(ref) {
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
let raf = 0
|
|
63
|
+
const onMove = ({ clientX, clientY }) => {
|
|
64
|
+
if (raf) return
|
|
65
|
+
raf = requestAnimationFrame(() => {
|
|
66
|
+
raf = 0
|
|
67
|
+
const h = ref.current
|
|
68
|
+
if (!h) return
|
|
69
|
+
const r = h.getBoundingClientRect()
|
|
70
|
+
const dist = Math.abs(clientX - (r.left + r.width / 2))
|
|
71
|
+
const near = dist <= GRAB.near || (h.classList.contains('is-near') && dist <= GRAB.sleep)
|
|
72
|
+
h.classList.toggle('is-near', near)
|
|
73
|
+
if (!near) return
|
|
74
|
+
const frac = Math.min(Math.max((clientY - r.top) / r.height, 0), 1)
|
|
75
|
+
const mark = GRAB.marks.reduce((a, b) => (Math.abs(b - frac) < Math.abs(a - frac) ? b : a))
|
|
76
|
+
if (h.dataset.grabSeeded && Math.abs(frac - mark) > GRAB.stick) return
|
|
77
|
+
const along = mark * r.height
|
|
78
|
+
if (h.dataset.grabSeeded && along === Number(h.dataset.grabTarget)) return
|
|
79
|
+
h.dataset.grabTarget = String(along)
|
|
80
|
+
const vars = { '--kol-rail-grab-y': `${along}px` }
|
|
81
|
+
/* the CSS fallback is 50%, a percentage — nothing to tween from, so the
|
|
82
|
+
* first sighting sets and every move after tweens */
|
|
83
|
+
if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...GRAB.travel, overwrite: 'auto' })
|
|
84
|
+
else { gsap.set(h, vars); h.dataset.grabSeeded = '1' }
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
window.addEventListener('pointermove', onMove)
|
|
88
|
+
return () => { window.removeEventListener('pointermove', onMove); cancelAnimationFrame(raf) }
|
|
89
|
+
}, [ref])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* THE DRAG — hold the pill and the width follows the pointer between closed and
|
|
93
|
+
* open; release snaps to the nearer state, a click (no travel past GRAB.slop)
|
|
94
|
+
* toggles. `onSnap` reports the resting state once — the sub rows render only
|
|
95
|
+
* while open, because closed they would still take their height and push the
|
|
96
|
+
* rungs below them. */
|
|
97
|
+
function useRailDrag(railRef, grabRef, onSnap) {
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const strip = grabRef.current, rail = railRef.current, root = document.documentElement
|
|
100
|
+
if (!strip || !rail) return undefined
|
|
101
|
+
gsap.set(root, { [RAIL_W]: `${CLOSED}px` })
|
|
102
|
+
let drag = null
|
|
103
|
+
const snapTo = (w) => {
|
|
104
|
+
gsap.to(root, { [RAIL_W]: `${w}px`, ...GRAB.snap, overwrite: 'auto' })
|
|
105
|
+
onSnap(w !== CLOSED)
|
|
106
|
+
}
|
|
107
|
+
const onDown = (e) => {
|
|
108
|
+
strip.setPointerCapture(e.pointerId)
|
|
109
|
+
gsap.killTweensOf(root)
|
|
110
|
+
drag = { x: e.clientX, w: rail.offsetWidth, open: openWidth(), moved: false }
|
|
111
|
+
strip.classList.add('is-dragging')
|
|
112
|
+
}
|
|
113
|
+
const onMove = (e) => {
|
|
114
|
+
if (!drag) return
|
|
115
|
+
const dx = e.clientX - drag.x
|
|
116
|
+
if (Math.abs(dx) > GRAB.slop) drag.moved = true
|
|
117
|
+
root.style.setProperty(RAIL_W, `${Math.min(Math.max(drag.w + dx, CLOSED), drag.open)}px`)
|
|
118
|
+
}
|
|
119
|
+
const onUp = () => {
|
|
120
|
+
if (!drag) return
|
|
121
|
+
const w = rail.offsetWidth
|
|
122
|
+
const open = drag.open
|
|
123
|
+
snapTo(drag.moved ? (w > (CLOSED + open) / 2 ? open : CLOSED) : (drag.w > CLOSED ? CLOSED : open))
|
|
124
|
+
drag = null
|
|
125
|
+
strip.classList.remove('is-dragging')
|
|
126
|
+
}
|
|
127
|
+
strip.addEventListener('pointerdown', onDown)
|
|
128
|
+
strip.addEventListener('pointermove', onMove)
|
|
129
|
+
strip.addEventListener('pointerup', onUp)
|
|
130
|
+
strip.addEventListener('pointercancel', onUp)
|
|
131
|
+
return () => {
|
|
132
|
+
strip.removeEventListener('pointerdown', onDown)
|
|
133
|
+
strip.removeEventListener('pointermove', onMove)
|
|
134
|
+
strip.removeEventListener('pointerup', onUp)
|
|
135
|
+
strip.removeEventListener('pointercancel', onUp)
|
|
136
|
+
}
|
|
137
|
+
}, [railRef, grabRef, onSnap])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* one row: the rung exactly where the closed rail had it, then the label and
|
|
141
|
+
* (with `sub`) the chevron — the row clips to the rail's width */
|
|
142
|
+
function RailItem({ icon, path, label, sub, currentPath, onNavigate, iconComponent, railOpen }) {
|
|
143
|
+
const [open, setOpen] = useState(false)
|
|
144
|
+
const active = path === '/' ? currentPath === '/' : currentPath.startsWith(path)
|
|
145
|
+
return (
|
|
146
|
+
<>
|
|
147
|
+
<div className="flex items-center gap-3 w-full overflow-hidden shrink-0">
|
|
148
|
+
<Button
|
|
149
|
+
iconOnly={icon}
|
|
150
|
+
iconSize={20}
|
|
151
|
+
iconComponent={iconComponent}
|
|
152
|
+
variant="nav"
|
|
153
|
+
size="md"
|
|
154
|
+
className="shrink-0"
|
|
155
|
+
/* nav ACTIVE keys off aria-current="page" (kol-components-atoms.css) —
|
|
156
|
+
* a brighter glyph, never the pressed fill */
|
|
157
|
+
aria-current={active ? 'page' : undefined}
|
|
158
|
+
/* oq-96 on every rung: the nav variant's oq-64 rest is site-nav quiet
|
|
159
|
+
* and too dim on an icon rail (user: "make the color .96 on every icon
|
|
160
|
+
* in the rail") */
|
|
161
|
+
style={{ color: 'var(--kol-oq-96)' }}
|
|
162
|
+
onClick={() => onNavigate?.(path)}
|
|
163
|
+
title={label}
|
|
164
|
+
aria-label={label}
|
|
165
|
+
/>
|
|
166
|
+
<span
|
|
167
|
+
className="kol-helper-12 uppercase text-oq-96 flex-1 min-w-0 truncate cursor-pointer"
|
|
168
|
+
onClick={() => onNavigate?.(path)}
|
|
169
|
+
>
|
|
170
|
+
{label}
|
|
171
|
+
</span>
|
|
172
|
+
{sub?.length > 0 && (
|
|
173
|
+
<Button
|
|
174
|
+
iconOnly={open ? 'chevron-down' : 'chevron-right'}
|
|
175
|
+
iconSize={16}
|
|
176
|
+
iconComponent={iconComponent}
|
|
177
|
+
variant="nav"
|
|
178
|
+
size="sm"
|
|
179
|
+
className="shrink-0"
|
|
180
|
+
style={{ color: 'var(--kol-oq-96)' }}
|
|
181
|
+
aria-expanded={open}
|
|
182
|
+
onClick={() => setOpen((v) => !v)}
|
|
183
|
+
title={label}
|
|
184
|
+
aria-label={`${label} sub categories`}
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
{railOpen && open && sub.map((s) => (
|
|
189
|
+
<div key={s.path} className="flex items-center w-full overflow-hidden shrink-0 pl-11">
|
|
190
|
+
<span
|
|
191
|
+
className="kol-helper-12 uppercase text-oq-64 hover:text-oq-96 truncate cursor-pointer"
|
|
192
|
+
onClick={() => onNavigate?.(s.path)}
|
|
193
|
+
>
|
|
194
|
+
{s.label}
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
))}
|
|
198
|
+
</>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
37
202
|
export default function NavRail({
|
|
38
203
|
items = [],
|
|
39
204
|
bottomItems = [],
|
|
@@ -41,66 +206,43 @@ export default function NavRail({
|
|
|
41
206
|
currentPath = '',
|
|
42
207
|
onNavigate,
|
|
43
208
|
iconComponent,
|
|
44
|
-
settings,
|
|
45
|
-
themeToggle = !settings,
|
|
46
209
|
hidden = false,
|
|
47
210
|
}) {
|
|
211
|
+
const railRef = useRef(null)
|
|
212
|
+
const grabRef = useRef(null)
|
|
213
|
+
const [railOpen, setRailOpen] = useState(false)
|
|
214
|
+
useGrabEdge(grabRef)
|
|
215
|
+
useRailDrag(railRef, grabRef, setRailOpen)
|
|
48
216
|
if (hidden) return null
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
id: settings.path,
|
|
53
|
-
label: settings.label,
|
|
54
|
-
icon: settings.icon,
|
|
55
|
-
panel: ({ collapsed, close }) => (
|
|
56
|
-
<>
|
|
57
|
-
<div className="kol-sidenav-theme-slot flex">
|
|
58
|
-
<ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
|
|
59
|
-
</div>
|
|
60
|
-
<button
|
|
61
|
-
type="button"
|
|
62
|
-
className="kol-sidenav-hop kol-helper-12 text-strong hover:text-emphasis bg-transparent border-0 cursor-pointer text-left"
|
|
63
|
-
onClick={() => { close(); onNavigate?.(settings.path) }}
|
|
64
|
-
title={settings.label}
|
|
65
|
-
aria-label={settings.label}
|
|
66
|
-
>
|
|
67
|
-
<span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
|
|
68
|
-
<Glyph name="arrow-right" size={16} />
|
|
69
|
-
</span>
|
|
70
|
-
<span className="kol-sidenav-hop-label flex-1 min-w-0 truncate">{settings.label}</span>
|
|
71
|
-
</button>
|
|
72
|
-
</>
|
|
73
|
-
),
|
|
74
|
-
}
|
|
75
|
-
/* the shell's route match: '/' exact, anything else by prefix */
|
|
76
|
-
const isActive = (to) => (to === '/' ? currentPath === '/' : currentPath.startsWith(to))
|
|
217
|
+
const row = (item) => (
|
|
218
|
+
<RailItem key={item.path} {...item} currentPath={currentPath} onNavigate={onNavigate} iconComponent={iconComponent} railOpen={railOpen} />
|
|
219
|
+
)
|
|
77
220
|
return (
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
footer={false}
|
|
90
|
-
header={logomark ? (
|
|
91
|
-
<button
|
|
92
|
-
type="button"
|
|
93
|
-
className="flex items-center gap-3 w-full bg-transparent border-0 p-0 cursor-pointer text-emphasis no-underline text-left"
|
|
221
|
+
<div
|
|
222
|
+
ref={railRef}
|
|
223
|
+
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"
|
|
224
|
+
style={{ width: `var(${RAIL_W})` }}
|
|
225
|
+
>
|
|
226
|
+
<div ref={grabRef} className="kol-rail-grab" />
|
|
227
|
+
{logomark && (
|
|
228
|
+
/* the mark, and the app name beside it when open — uppercase like the
|
|
229
|
+
* rows (user 2026-08-28: "uppercase CONSISTENCY"). The `w-8` centring box
|
|
230
|
+
* puts the 20px mark on the same x as the 20px glyphs below it. */
|
|
231
|
+
<div
|
|
94
232
|
onClick={() => onNavigate?.('/')}
|
|
233
|
+
className="text-oq-96 cursor-pointer flex items-center gap-3 w-full overflow-hidden shrink-0 pt-1 mb-4"
|
|
95
234
|
title={logomark.title}
|
|
96
|
-
aria-label={logomark.title}
|
|
97
235
|
>
|
|
98
|
-
<span className="
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
236
|
+
<span className="w-8 flex justify-center shrink-0"><Logomark svgUrl={logomark.svgUrl} size={20} /></span>
|
|
237
|
+
<span className="kol-helper-12 uppercase flex-1 min-w-0 truncate">{logomark.title}</span>
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
{items.map(row)}
|
|
241
|
+
{/* the spacer IS the layout */}
|
|
242
|
+
<div className="flex-1" />
|
|
243
|
+
{/* the rule runs the full rail width, out past the px-2 */}
|
|
244
|
+
{bottomItems.length > 0 && <div className="self-stretch -mx-2 border-t border-fg-08" />}
|
|
245
|
+
{bottomItems.map(row)}
|
|
246
|
+
</div>
|
|
105
247
|
)
|
|
106
248
|
}
|
package/src/PageHeader.jsx
CHANGED
|
@@ -29,18 +29,34 @@
|
|
|
29
29
|
* kol-mono-heading-03 · kol-mono-display-03 · kol-mono-display-02
|
|
30
30
|
* (kol-theme ≥0.67.0). The subtitle stays kol-mono-14.
|
|
31
31
|
* @param {string} titleClass replaces the title role whole (the ContentText seam)
|
|
32
|
+
* @param {ReactNode} actions a control cluster on the SUBTITLE's first baseline — on the title's
|
|
33
|
+
* when there is no subtitle (PageHeaderTrailingSlot, kol-website 2026-08-28;
|
|
34
|
+
* kol-r2b2's header is the reference: wordmark left, controls right, on the
|
|
35
|
+
* line). Without it the consumer wrapped the header in a flex row and got the
|
|
36
|
+
* h1's baseline, or re-rendered the subtitle as a bare <p> off copied classes
|
|
37
|
+
* with an `!important` on the margin — a DS text role re-implemented outside.
|
|
38
|
+
* @param {string} subtitleMaxWidth the lede's measure (e.g. '800px' or '60ch'), a prop
|
|
39
|
+
* instead of a consumer selector reaching inside
|
|
40
|
+
* The bottom rhythm is `--kol-page-header-mb` (default 40px): inline, as before, but through a
|
|
41
|
+
* variable a consumer can re-point where an inline literal could only be `!important`-ed.
|
|
32
42
|
*/
|
|
33
43
|
const TITLE = {
|
|
34
44
|
sans: { sm: 'kol-sans-heading-03', md: 'kol-sans-display-03', lg: 'kol-sans-display-02' },
|
|
35
45
|
mono: { sm: 'kol-mono-heading-03', md: 'kol-mono-display-03', lg: 'kol-mono-display-02' },
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
export default function PageHeader({ eyebrow, title, subtitle, size = 'md', voice = 'sans', titleClass, className = '' }) {
|
|
48
|
+
export default function PageHeader({ eyebrow, title, subtitle, actions, subtitleMaxWidth, size = 'md', voice = 'sans', titleClass, className = '' }) {
|
|
39
49
|
const roles = TITLE[voice] ?? TITLE.sans
|
|
50
|
+
const h1 = <h1 className={`text-fg-96 ${titleClass ?? roles[size] ?? roles.md}`}>{title}</h1>
|
|
51
|
+
const lede = subtitle && <p className="text-oq-64 kol-mono-14 min-w-0" style={{ marginTop: actions ? undefined : 12, maxWidth: subtitleMaxWidth }}>{subtitle}</p>
|
|
52
|
+
/* the cluster shares a baseline row with the lede (or the title): flexbox
|
|
53
|
+
exposes a flex item's FIRST baseline, so putting them in one row is the
|
|
54
|
+
one way to land on the subtitle's line rather than the h1's */
|
|
55
|
+
const cluster = actions && <div className="flex items-center gap-4 shrink-0">{actions}</div>
|
|
40
56
|
return (
|
|
41
57
|
/* the block owns its own rhythm — margins inline, never in a shared type
|
|
42
58
|
class, which leaks estate-wide (ShellHeaderFilterRefinements, 2026-08-15) */
|
|
43
|
-
<header className={`flex flex-col ${className}`.trim()} style={{ marginBottom:
|
|
59
|
+
<header className={`flex flex-col ${className}`.trim()} style={{ marginBottom: 'var(--kol-page-header-mb, 40px)' }}>
|
|
44
60
|
{eyebrow && (
|
|
45
61
|
/* HELPER, not mono (user ruling 2026-08-15). An eyebrow is single-line
|
|
46
62
|
chrome — that is the whole definition of the `kol-helper-*` ramp, and
|
|
@@ -54,9 +70,24 @@ export default function PageHeader({ eyebrow, title, subtitle, size = 'md', voic
|
|
|
54
70
|
{eyebrow}
|
|
55
71
|
</p>
|
|
56
72
|
)}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
73
|
+
{actions && subtitle ? (
|
|
74
|
+
<>
|
|
75
|
+
{h1}
|
|
76
|
+
<div className="flex items-baseline justify-between gap-6" style={{ marginTop: 12 }}>
|
|
77
|
+
{lede}
|
|
78
|
+
{cluster}
|
|
79
|
+
</div>
|
|
80
|
+
</>
|
|
81
|
+
) : actions ? (
|
|
82
|
+
<div className="flex items-baseline justify-between gap-6">
|
|
83
|
+
{h1}
|
|
84
|
+
{cluster}
|
|
85
|
+
</div>
|
|
86
|
+
) : (
|
|
87
|
+
<>
|
|
88
|
+
{h1}
|
|
89
|
+
{lede}
|
|
90
|
+
</>
|
|
60
91
|
)}
|
|
61
92
|
</header>
|
|
62
93
|
)
|