@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-shell",
3
- "version": "0.14.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.108.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.80.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.119.0",
24
- "@kolkrabbi/kol-framework": "^0.34.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.81.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 A SIDENAV (RailSideNavPixelParity, kol-fxr 2026-08-28 — user:
13
- * "same component both states super nice"): since 0.13.0 the root is the
14
- * brand grid, `.kol-brand-layout` (kol-framework.css `--kol-sidenav-w` ×
15
- * `minmax(0, 1fr)`, the collapsed column on the `data-sidenav` stamp, the
16
- * ≤1024 forced rail and the ≤767 drawer), and `NavRail` mounts kol-framework's
17
- * `SideNav` collapsed in it. The grab edge opens it to the brand sidebar and
18
- * the drag is the transition; the content column follows the grid, so the old
19
- * fixed rail + `margin-left` offset is gone. `--kol-shell-rail-width`
20
- * (kol-theme ≥0.80.0) is LIVE — the sidenav's width in its current state —
21
- * for consumers that offset a fixed element by it; hidden rail = `0px`.
22
- * REQUIRES `@kolkrabbi/kol-framework/kol-framework.css` imported after the
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
- * @param {Object} props.settings `{ icon, path, label }` Settings as a DISCLOSURE in the pinned bottom
35
- * row (RailSettingsDisclosure, 2026-08-28): click opens a panel above the
36
- * row with the theme toggle and the settings page as a row; click again,
37
- * Escape or any navigation closes it. Turns the rail's theme slot off.
38
- * @param {boolean} props.themeToggle render the ThemeToggle above bottomItems (default true; false when `settings` is given)
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
- * `kol-brand-layout` = the sidenav grid; a hidden rail collapses the
124
- * grid to the content column and zeroes the live width token. */}
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 kol-brand-layout min-h-dvh bg-surface-primary"
127
- style={navHidden ? { gridTemplateColumns: 'minmax(0, 1fr)', '--kol-shell-rail-width': '0px' } : undefined}
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
- <div className="bg-surface-primary min-w-0" style={{ '--kol-shell-page-wash': pageWash }}>
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 { SideNav, ThemeToggle } from '@kolkrabbi/kol-framework'
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 — AppShell's rail, which IS kol-framework's `SideNav`
7
- * (RailSideNavPixelParity, kol-fxr 2026-08-28 — user, on the brand sidebar's
8
- * drag: "same component both states super nice"). Until 0.13.0 this was its
9
- * own fixed 48px icon rail, and the first app to run it beside a collapsed
10
- * SideNav on adjacent routes measured every icon moving on the route change
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
- * The bottom, specified once: theme slot `bottomItems` (pinned rows) →
19
- * no footer (an app rail never carried the site's wordmark link). The
20
- * `logomark` is the sidenav's `header` at the TOP, both states, where every
21
- * rail in the estate has carried it (RailLogomarkAtTop, 2026-08-28 user, on
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
- * `settings` SETTINGS IS A DISCLOSURE, NOT A ROUTE (RailSettingsDisclosure,
27
- * kol-fxr 2026-08-28 user: "put the settings where the logo is, and logo
28
- * back on top" · "sidebar setting should open close on click and click again"
29
- * · "put the theme toggle in the settings and out of the sidebar" · "we make
30
- * it work here then ship it"): `{ icon, path, label }` renders the gear as the
31
- * pinned bottom row a SideNav panel leaf, same pixel as the route row it
32
- * replaces whose panel holds the theme toggle (`icon` collapsed /
33
- * `hop-bare` expanded, the slot's two variants) and the Settings page as a
34
- * row. The rail's standing theme slot is off when `settings` is given; the
35
- * toggle exists in the disclosure and on the settings page only.
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 node = (item) => ({ id: item.path, label: item.label, icon: item.icon, to: item.path })
50
- const Glyph = iconComponent ?? Icon
51
- const settingsLeaf = settings && {
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
- <SideNav
79
- navTree={items.map(node)}
80
- bottomItems={[...bottomItems.map(node), ...(settingsLeaf ? [settingsLeaf] : [])]}
81
- currentPath={currentPath}
82
- isActive={isActive}
83
- onNavigate={(e, to) => onNavigate?.(to)}
84
- iconComponent={iconComponent}
85
- themeToggle={themeToggle}
86
- expandOnSelect={false}
87
- defaultCollapsed
88
- hairline
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="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
99
- <Logomark svgUrl={logomark.svgUrl} size={20} />
100
- </span>
101
- {logomark.title && <span className="kol-sidenav-hop-label kol-helper-12 flex-1 min-w-0 truncate">{logomark.title}</span>}
102
- </button>
103
- ) : undefined}
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
  }
@@ -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: 40 }}>
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
- <h1 className={`text-fg-96 ${titleClass ?? roles[size] ?? roles.md}`}>{title}</h1>
58
- {subtitle && (
59
- <p className="text-oq-64 kol-mono-14" style={{ marginTop: 12 }}>{subtitle}</p>
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
  )