@kolkrabbi/kol-framework 0.51.0 → 0.53.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-framework",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Site shell and page layout",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,16 +14,16 @@
14
14
  "./kol-brand-color.css": "./kol-brand-color.css"
15
15
  },
16
16
  "peerDependencies": {
17
- "@kolkrabbi/kol-component": ">=0.177.0",
17
+ "@kolkrabbi/kol-component": ">=0.283.0",
18
18
  "@kolkrabbi/kol-icons": ">=0.18.0",
19
- "@kolkrabbi/kol-theme": ">=0.138.0",
19
+ "@kolkrabbi/kol-theme": ">=0.179.0",
20
20
  "react": "^18.3.0 || ^19.0.0",
21
21
  "react-dom": "^18.3.0 || ^19.0.0",
22
22
  "react-router-dom": "^6.0.0 || ^7.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@kolkrabbi/kol-component": "^0.267.0",
26
- "@kolkrabbi/kol-icons": "^0.39.0"
25
+ "@kolkrabbi/kol-component": "^0.284.0",
26
+ "@kolkrabbi/kol-icons": "^0.41.0"
27
27
  },
28
28
  "files": [
29
29
  "src",
package/src/SideNav.jsx CHANGED
@@ -1,479 +1,45 @@
1
- import { NavLink, useLocation } from 'react-router-dom'
2
- import { useEffect, useLayoutEffect, useRef, useState } from 'react'
3
- import { createPortal } from 'react-dom'
4
- import { Icon } from '@kolkrabbi/kol-icons'
5
- import { Tooltip } from '@kolkrabbi/kol-component'
1
+ import { useHref, useLocation, useNavigate } from 'react-router-dom'
2
+ import { NavRail } from '@kolkrabbi/kol-component'
6
3
  import ThemeToggle from './ThemeToggle'
7
- import { useDragResize } from '@kolkrabbi/kol-component'
8
4
 
9
5
  /**
10
- * SideNav — the grouped navigation rail.
6
+ * SideNav — `NavRail variant="site"` under its old name (2026-10-22, the one nav rail).
11
7
  *
12
- * REPLICATED from the elder reference (kol-website/apps/brand
13
- * src/components/framework/SideNav.jsx, 2026-08-09 user mandate: "there is a
14
- * better version in kol-website apps:brand") — the brand app's evolved fork
15
- * folded back into the package, generic seams kept.
8
+ * @deprecated — use `NavRail variant="site"` from `@kolkrabbi/kol-component`. This name is on the
9
+ * retirement ledger; it stays so every site keeps resolving.
16
10
  *
17
- * A category is a grouping LABEL — a disclosure <button> with a trailing
18
- * caret, no route. A page is a route. The ONE exception: a category with `to`
19
- * and no `pages` (Home) renders as a plain link row — it has nothing to
20
- * disclose.
11
+ * The drawing moved to kol-component class-for-class (`organisms/NavRailSite.jsx`; the 479-line
12
+ * original is in kol-ds-ui `_tmp/2026-10-22-rail-to-component/`). Two things stay here because
13
+ * kol-component cannot carry them: the ROUTER read and the THEME TOGGLE.
21
14
  *
22
- * The 2026-08-01 "TWO LEVELS ONLY" ruling was the BRAND APP's, about its
23
- * scroll-spied `#anchor` section layer — that layer stays dead (an `{ id }`
24
- * leaf is dropped). It was inscribed here as a law for every consumer, and
25
- * kol-studio lost two groups and six routes from its rail on adopting 0.23.0.
26
- * User, 2026-08-26, verbatim: "ok that is such a problem, sometimes Im
27
- * talking about a specific thing and it gets applied as a literal fit-all
28
- * rule, which isnt the case. its very hard to work in such uncertainty."
29
- * Re-scoped (sidenav-nested-groups, 0.24.0): nested ROUTE groups are
30
- * supported. A `{ label, children }` node inside a category renders as a
31
- * non-routing group header with its rows indented one step under it,
32
- * recursively. The tree shape is the opt-in — there is no prop — and a tree
33
- * with no group nodes renders byte-for-byte as before.
15
+ * Router: without `currentPath` this reads react-router (`useLocation`) and navigates with
16
+ * `useNavigate` — the NavLink rail it always was, `onNavigate(event, to)` still called first. With
17
+ * `currentPath` the router is never touched (kol-shell's agnostic contract).
34
18
  *
35
- * navTree: [{ id, label, icon, to?, pages?: [{ label, to } | { label, children }] }]
36
- * (legacy `children` on a category is read as `pages`; section anchors are not.)
37
- *
38
- * WORKSPACE RAILS (WorkspaceSidebarGeometry, kol-fxr 2026-08-27 — user: "I see
39
- * these 3 as very similar things … I would rather all use the same geometry
40
- * layout"; three forks, ~1,370 lines, wearing this component's classes):
41
- *
42
- * ACTION LEAVES — `{ label, onSelect, active? }` beside `{ label, to }`, at
43
- * any depth and as a category (`{ id, label, icon, onSelect, active? }`): a
44
- * rail that DISPATCHES (labs swaps an effect, monitor picks a module, mirror
45
- * selects a surface) instead of navigating. A <button>, lit by `active`;
46
- * never a route, so no history is walked (labs' `?preset=` replace-not-push
47
- * stays what it is).
48
- *
49
- * ROUTER-AGNOSTIC — pass `currentPath` and the component never touches
50
- * react-router: route leaves render `<a href>` and call `onNavigate(event,
51
- * to)` (kol-shell's contract); a router-free app can mount it. Without
52
- * `currentPath` it is the NavLink rail it always was.
53
- *
54
- * THE COLLAPSE RULE — a row that can survive collapse HAS AN ICON; one that
55
- * cannot is not a row, it is a LABEL. A top-level node with no `icon` (and no
56
- * `to` / `onSelect`) renders `.kol-sidenav-section` — the eyebrow voice, hidden
57
- * on collapse — over its `pages`; the icon rows beneath stay, so the rail
58
- * reads as one thing narrowing, the brand sidebar's model.
59
- *
60
- * Collapse: the pill-marked grab edge is THE single control (user build
61
- * order 2026-08-09, completing SideNavGrabResize — the chip Button is gone
62
- * in both states). Click toggles expand↔collapse, drag resizes with
63
- * snap-collapse and snap-to-default, keyboard on the separator: arrows /
64
- * Home / Enter / Space. One contract: `:root[data-sidenav="collapsed"]` +
65
- * the `kol-sidenav` storage schema, owned by useDragResize. Collapsed rows
66
- * get a DS Tooltip — the glyph is otherwise the only thing naming them.
67
- *
68
- * THE SHELL RAIL IS THIS COMPONENT (RailSideNavPixelParity, kol-fxr 2026-08-28
69
- * — user, on the brand sidebar's drag: "same component both states super
70
- * nice"): kol-shell's `AppShell` mounts a SideNav — collapsed by default, the
71
- * drag opens it — so an app rail and the brand sidebar are one geometry to the
72
- * pixel (fxr measured 48 vs 56 wide, 20 vs 16px glyphs, 40 vs 38 pitch, first
73
- * glyph at y 76 vs 26 between the old NavRail and this rail collapsed). The
74
- * seams the shell needed, all opt-in, a tree with none of them renders as before:
75
- * `header` a node ABOVE the tree, outside the scroll region, in the
76
- * hop's geometry (`.kol-sidenav-header`, kol-framework.css)
77
- * — the app's logomark (RailLogomarkAtTop, 2026-08-28 —
78
- * user: "why did you move the logo from top to bottom?").
79
- * Give it a `.kol-sidenav-hop-icon` span (20px mark on the
80
- * icon column) and, optionally, a `.kol-sidenav-hop-label`;
81
- * both follow the collapse. Same node in every consumer =
82
- * the first tree glyph at one y — the parity contract
83
- * PANEL LEAF `{ id, label, icon, panel }` — a top-level node (tree or
84
- * `bottomItems`) whose row is a DISCLOSURE: click opens,
85
- * click again closes, lit while open (RailSettingsDisclosure,
86
- * kol-fxr 2026-08-28 — user: "sidebar setting should open
87
- * close on click and click again" · "put the theme toggle in
88
- * the settings and out of the sidebar"). `panel` is a node or
89
- * `({ collapsed, close }) => node`, PORTALLED to <body>
90
- * directly above the row at the rail's own width (left/width
91
- * from the aside, bottom = viewport − the row's top,
92
- * `--kol-z-tooltip`), so it works collapsed (icon column)
93
- * and expanded (rows) alike. Escape closes; any navigation
94
- * or dispatch closes. fxr's RailSettings.jsx, promoted.
95
- * `bottomItems` top-level nodes pinned BELOW the theme slot, above the
96
- * footer (the shell pins theme → Settings; a Settings row in
97
- * the tree landed above the theme instead — "same rail, two
98
- * bottoms")
99
- * `footer` the footer box's content: undefined = the Kolkrabbi link
100
- * (K / wordmark, as ever); `false` = no footer; a node =
101
- * that node (the shell's logomark → '/')
102
- * `themeToggle` `false` drops the theme slot (default `true`)
103
- * `iconComponent` the hop glyph's renderer, kol-shell's seam — `(props:
104
- * { name, size }) => element`; default the DS `Icon`
105
- * `expandOnSelect` `false` keeps the rail collapsed when a row navigates or
106
- * dispatches — an app rail stays a rail. Default `true`, the
107
- * brand ruling ("pressing any icon would expand the sidebar")
108
- * `defaultCollapsed` boots collapsed when nothing is stored (useDragResize)
109
- * `background` (2026-08-09, prop): `true` (default) paints the rail surface;
110
- * `false` renders chromeless so the rail can float over a consumer's media
111
- * (the brand-hero model — the consumer owns the plane). `hairline` (2026-08-26,
112
- * default `false`) paints the right-edge `border-r border-fg-08` — split out
113
- * of `background` so the line is a choice of its own.
19
+ * Theme: `themeToggle` (default `true`) renders kol-framework's `ThemeToggle` in the rail's slot;
20
+ * `false` drops it.
114
21
  */
115
-
116
- const pageBase =
117
- 'kol-sidenav-link kol-helper-10 block relative py-[4px] pl-14 pr-6 no-underline transition-colors duration-150'
118
-
119
- /* The active-dot pseudo-element is positioned by `--kol-sidenav-dot-left`
120
- * (kol-components-atoms.css). The page row indents 3.5rem (pl-14); the dot
121
- * sits 0.875rem inside that, in the gutter the icon column leaves. */
122
- const pageStyle = { '--kol-sidenav-dot-left': '2.625rem' }
123
- /* Category and page must not share a stop: category `emphasis`, page `strong`
124
- * — two rungs of one ladder, so the tree reads as hierarchy (elder ruling). */
125
- const pageCls = `${pageBase} text-strong hover:text-emphasis`
126
- const pageActiveCls = `${pageBase} is-active`
127
-
128
- /* A category's rows: route leaves (`to`) and route groups (`children`).
129
- * `#anchor` leaves (`id` only) are dropped — that layer died with the brand
130
- * app's 2026-08-01 ruling and is not reopened here. */
131
- const rowsOf = (nodes) => nodes.filter((c) => c.to || c.children || c.onSelect)
132
- const pagesOf = (cat) => rowsOf(cat.pages ?? cat.children ?? [])
133
- /* Every route leaf under a set of rows, through any depth of groups. */
134
- const leavesOf = (rows) => rows.flatMap((r) => (r.children ? leavesOf(rowsOf(r.children)) : [r]))
135
- /* A nested row sits one --kol-spacing-3 step further in per depth. Inline,
136
- * not a rule: `pl-14` rides the leaf as a utility and outranks anything in
137
- * this layer. Depth 0 carries no indent so a flat tree renders unchanged. */
138
- const indentOf = (depth) => `calc(3.5rem + ${depth} * var(--kol-spacing-3))`
139
-
140
- /* Router-agnostic seam: with `currentPath` the rail never calls react-router
141
- * (a hook cannot be conditional, so the router read lives in its own wrapper
142
- * that mounts only when the prop is absent). */
143
22
  export default function SideNav(props) {
144
- if (props.currentPath != null) return <SideNavInner {...props} pathname={props.currentPath} router={false} />
23
+ if (props.currentPath != null) return <NavRail variant="site" {...props} themeToggle={themeSlot(props.themeToggle)} />
145
24
  return <RouterSideNav {...props} />
146
25
  }
147
- function RouterSideNav(props) {
148
- const { pathname } = useLocation()
149
- return <SideNavInner {...props} pathname={pathname} router />
150
- }
151
-
152
- function SideNavInner({
153
- pathname,
154
- router,
155
- drawerOpen = false,
156
- onCloseDrawer,
157
- navTree = [],
158
- onNavigate,
159
- isActive,
160
- background = true,
161
- /* the right-edge hairline is OPT-IN (sidenav-rail-hairline, kol-studio
162
- * 2026-08-26 — user: the footer's border-t is right, the border-r is wrong
163
- * as a default). It used to ride `background` — two decisions under one
164
- * name; a rail floating over media and a rail without a line are different
165
- * choices. */
166
- hairline = false,
167
- header,
168
- bottomItems = [],
169
- footer,
170
- themeToggle = true,
171
- iconComponent,
172
- expandOnSelect = true,
173
- defaultCollapsed = false,
174
- }) {
175
- const Glyph = iconComponent ?? Icon
176
- const isPageActive = (to) => (isActive ? isActive(to) : pathname === to)
177
- /* a leaf is lit by its route, or — an action leaf — by its own `active` */
178
- const isLeafActive = (leaf) => (leaf.onSelect ? !!leaf.active : isPageActive(leaf.to))
179
- const isActiveCat = (cat) => (cat.onSelect ? !!cat.active : leavesOf(pagesOf(cat)).some(isLeafActive))
180
-
181
- /* Every navigation closes the mobile drawer — the prop sat accepted-but-
182
- * unread until the kol-website session flagged it (2026-08-09) — and, from
183
- * the collapsed rail, expands the sidebar ("pressing any icon would expand
184
- * the sidebar", same ruling as the chip removal). */
185
- const handleNavigate = (e, to) => {
186
- onNavigate?.(e, to)
187
- onCloseDrawer?.()
188
- setOpenPanel(null)
189
- if (collapsed && expandOnSelect) toggleCollapsed()
190
- }
191
- /* an action leaf dispatches — same drawer / rail behaviour as a navigation */
192
- const handleSelect = (e, row) => {
193
- row.onSelect(e)
194
- onCloseDrawer?.()
195
- setOpenPanel(null)
196
- if (collapsed && expandOnSelect) toggleCollapsed()
197
- }
198
- /* the route leaf: NavLink under a router, `<a href>` on the agnostic seam */
199
- const RouteLeaf = ({ to, className, style, children }) => router ? (
200
- <NavLink to={to} end style={style} className={({ isActive: navActive }) => className(isActive ? isActive(to) : navActive)} onClick={(e) => handleNavigate(e, to)}>{children}</NavLink>
201
- ) : (
202
- <a href={to} style={style} className={className(isPageActive(to))} onClick={(e) => { e.preventDefault(); handleNavigate(e, to) }}>{children}</a>
203
- )
204
-
205
- /* Which categories are disclosed. INDEPENDENT per category (elder ruling
206
- * 2026-08-01) — arriving somewhere must not silently close wherever you
207
- * were. Loads closed, in-memory only (elder ruling 2026-08-06). */
208
- const [openCats, setOpenCats] = useState(() => new Set())
209
- /* the ONE open panel leaf (by id) and where its panel sits */
210
- const [openPanel, setOpenPanel] = useState(null)
211
- const [panelBox, setPanelBox] = useState(null)
212
- const toggleCat = (id) => setOpenCats((prev) => {
213
- const next = new Set(prev)
214
- next.has(id) ? next.delete(id) : next.add(id)
215
- return next
216
- })
217
- const discloseCat = (id) => setOpenCats((prev) => new Set(prev).add(id))
218
-
219
- /* One collapse contract, two gestures — the hook owns the stamp, the
220
- * storage schema and the state; the chip Button and the grab edge both
221
- * drive it. */
222
- const asideRef = useRef(null)
223
- const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef, { defaultCollapsed })
224
-
225
- /* the panel's anchor: its row — the panel's bottom edge meets the row's top
226
- * edge, its width is the rail's; re-placed on resize and on collapse */
227
- useLayoutEffect(() => {
228
- if (!openPanel) { setPanelBox(null); return undefined }
229
- const place = () => {
230
- const rail = asideRef.current
231
- const row = rail?.querySelector(`[data-panel-trigger="${CSS.escape(String(openPanel))}"]`)
232
- if (!rail || !row) return
233
- const rr = rail.getBoundingClientRect(), tr = row.getBoundingClientRect()
234
- setPanelBox({ left: rr.left, width: rr.width, bottom: window.innerHeight - tr.top })
235
- }
236
- place()
237
- window.addEventListener('resize', place)
238
- return () => window.removeEventListener('resize', place)
239
- }, [openPanel, collapsed])
240
- useEffect(() => {
241
- if (!openPanel) return undefined
242
- const onKey = (e) => { if (e.key === 'Escape') setOpenPanel(null) }
243
- window.addEventListener('keydown', onKey)
244
- return () => window.removeEventListener('keydown', onKey)
245
- }, [openPanel])
246
- const panelNode = openPanel != null ? [...navTree, ...bottomItems].find((c) => c.id === openPanel) : null
247
-
248
- /* Rows under a category: a leaf is a NavLink; a group is a non-routing
249
- * header over its own rows, one step further in (recursive). A group lights
250
- * up the way a category does — when one of its leaves is the current route. */
251
- const renderRows = (rows, depth) => rows.map((row, i) => {
252
- if (row.children) {
253
- const lit = leavesOf(rowsOf(row.children)).some(isLeafActive)
254
- return (
255
- <li key={row.id ?? `${row.label}-${i}`}>
256
- <div
257
- className={`kol-sidenav-group kol-helper-10${lit ? ' text-emphasis' : ' text-subtle'}`}
258
- style={depth ? { paddingLeft: indentOf(depth) } : undefined}
259
- >
260
- {row.label}
261
- </div>
262
- <ul className="kol-sidenav-list">{renderRows(rowsOf(row.children), depth + 1)}</ul>
263
- </li>
264
- )
265
- }
266
- /* the dot keeps its 0.875rem lead on the text at every depth */
267
- const leafStyle = depth ? { paddingLeft: indentOf(depth), '--kol-sidenav-dot-left': `calc(2.625rem + ${depth} * var(--kol-spacing-3))` } : pageStyle
268
- if (row.onSelect) {
269
- return (
270
- <li key={row.id ?? `${row.label}-${i}`}>
271
- <button
272
- type="button"
273
- style={leafStyle}
274
- className={`${row.active ? pageActiveCls : pageCls} w-full bg-transparent border-0 cursor-pointer text-left truncate`}
275
- aria-pressed={!!row.active}
276
- onClick={(e) => handleSelect(e, row)}
277
- >
278
- {row.label}
279
- </button>
280
- </li>
281
- )
282
- }
283
- return (
284
- <li key={row.to}>
285
- <RouteLeaf to={row.to} style={leafStyle} className={(active) => (active ? pageActiveCls : pageCls)}>
286
- {row.label}
287
- </RouteLeaf>
288
- </li>
289
- )
290
- })
291
-
292
- /* one renderer for the tree and the pinned rows (RailSideNavPixelParity) */
293
- const renderCat = (cat, ci) => {
294
- const pages = pagesOf(cat)
295
- const isOpen = openCats.has(cat.id)
296
- const isLink = !pages.length && cat.to
297
- const isPanel = !pages.length && !cat.to && !!cat.panel
298
- const isAction = !pages.length && !cat.to && !isPanel && cat.onSelect
299
- /* THE COLLAPSE RULE: no icon, no route, no action = a LABEL over
300
- * its rows, not a row — `.kol-sidenav-section` hides on collapse
301
- * and the icon rows beneath it stay */
302
- if (!cat.icon && !cat.to && !cat.onSelect && !cat.panel) {
303
- return (
304
- <li key={cat.id ?? `${cat.label}-${ci}`} className="relative">
305
- <div className="kol-sidenav-section kol-eyebrow text-strong">{cat.label}</div>
306
- {pages.length > 0 && <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>}
307
- </li>
308
- )
309
- }
310
- /* Box utilities deliberately absent — the hop's layout lives in
311
- * .kol-sidenav-hop (kol-framework.css): a utility here would
312
- * outrank the collapsed-state overrides (2026-08-09 defect). */
313
- const rowCls = 'kol-sidenav-hop kol-helper-12 text-emphasis no-underline'
314
- const glyph = (
315
- <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
316
- <Glyph name={cat.icon} size={16} />
317
- </span>
318
- )
319
- /* A category is a <button>, not a link — it has no route by
320
- definition, and an <a href> would promise a page that does
321
- not exist. Clicking it discloses its pages. */
322
- const head = isLink ? (
323
- <RouteLeaf to={cat.to} className={(active) => `${rowCls}${active ? ' is-active' : ''}`}>
324
- {glyph}
325
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
326
- </RouteLeaf>
327
- ) : isPanel ? (
328
- <button
329
- type="button"
330
- data-panel-trigger={cat.id}
331
- className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${openPanel === cat.id ? ' is-active' : ''}`}
332
- aria-expanded={openPanel === cat.id}
333
- onClick={() => setOpenPanel((o) => (o === cat.id ? null : cat.id))}
334
- >
335
- {glyph}
336
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
337
- </button>
338
- ) : isAction ? (
339
- <button
340
- type="button"
341
- className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${cat.active ? ' is-active' : ''}`}
342
- aria-pressed={!!cat.active}
343
- onClick={(e) => handleSelect(e, cat)}
344
- >
345
- {glyph}
346
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
347
- </button>
348
- ) : (
349
- <button
350
- type="button"
351
- className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${isActiveCat(cat) ? ' is-active' : ''}`}
352
- aria-expanded={isOpen}
353
- /* Collapsed rail: the click meant "open this" — expand the
354
- * rail AND disclose the category, or the tap is wasted on
355
- * an expand that lands on a closed tree. */
356
- onClick={() => {
357
- if (collapsed) {
358
- toggleCollapsed()
359
- discloseCat(cat.id)
360
- } else {
361
- toggleCat(cat.id)
362
- }
363
- }}
364
- >
365
- {glyph}
366
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
367
- {/* plain wrapper span — Icon stamps inline-flex on its own
368
- * element, which would outrank the rail's display:none */}
369
- <span
370
- className={`kol-sidenav-hop-caret transition-transform duration-150${isOpen ? '' : ' -rotate-90'}`}
371
- aria-hidden="true"
372
- >
373
- <Icon name="chevron-down" size={12} />
374
- </span>
375
- </button>
376
- )
377
- return (
378
- <li key={cat.id} className="relative">
379
- {/* Collapsed rail hides the label — the tooltip is the only
380
- thing naming the row. Portalled, so overflow can't clip it. */}
381
- {collapsed ? (
382
- <Tooltip label={cat.label} placement="right" triggerClassName="block">
383
- {head}
384
- </Tooltip>
385
- ) : head}
386
-
387
- {!isLink && !isAction && !isPanel && isOpen && (
388
- <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>
389
- )}
390
- </li>
391
- )
392
- }
393
26
 
27
+ function RouterSideNav({ onNavigate, ...props }) {
28
+ const { pathname } = useLocation()
29
+ const navigate = useNavigate()
30
+ const base = useHref('/').replace(/\/$/, '')
394
31
  return (
395
- <aside
396
- ref={asideRef}
397
- /* Box utilities deliberately absent (SideNavMobilePosition, 2026-08-25):
398
- * the aside's position/height/stack live in .kol-sidenav
399
- * (kol-framework.css). `sticky … h-dvh z-20` here outranked the 767px
400
- * drawer rule's `position: fixed`, so every consumer page opened one
401
- * viewport down on phones. Only color utilities ride the element. */
402
- className={`kol-sidenav${background ? ' bg-surface-primary' : ''}${hairline ? ' border-r border-fg-08' : ''}${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
403
- >
404
- {/* NO chip Button in either state (user build order 2026-08-09 — "MAKE
405
- * IT"; supersedes the elder A/B): the pill-marked grab edge below is
406
- * THE single collapse/resize control — click toggles, drag resizes,
407
- * snap-to-default on release, Home/Enter/Space on the keyboard. */}
408
-
409
- {header && <div className="kol-sidenav-header">{header}</div>}
410
-
411
- {/* ONLY the tree scrolls (fxr's re-measure, 2026-08-28): the theme slot
412
- * and the pinned rows used to sit inside this region, so on a 26-row
413
- * collapsed tree they scrolled off below the footer — "pinned" only
414
- * held while the tree was shorter than the rail. They are the block
415
- * after it now, above the footer, the tree scrolling underneath. */}
416
- <div className="kol-sidenav-scroll flex-1 overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
417
- <nav aria-label="Sections">
418
- <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
419
- {navTree.map(renderCat)}
420
- </ul>
421
- </nav>
422
- </div>
423
-
424
- {/* hop-bare carries its own gutter padding; collapsed falls back to
425
- `icon` — a full-width label can't sit in the collapsed rail. */}
426
- <div className="pb-4">
427
- {themeToggle && (
428
- <div className="kol-sidenav-theme-slot flex">
429
- <ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
430
- </div>
431
- )}
432
- {/* pinned rows — BELOW the theme, above the footer: the shell's bottom
433
- * pair (theme → Settings), specified once */}
434
- {bottomItems.length > 0 && (
435
- <nav aria-label="Pinned">
436
- <ul className="kol-sidenav-tree flex flex-col gap-[2px]">{bottomItems.map(renderCat)}</ul>
437
- </nav>
438
- )}
439
- </div>
440
-
441
- {footer !== false && footer !== null && (
442
- <div className="kol-sidenav-footer flex items-center h-14 border-t border-fg-08 min-w-0">
443
- {footer !== undefined ? footer : (
444
- <a
445
- href="https://kolkrabbi.io"
446
- target="_blank"
447
- rel="noopener"
448
- className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
449
- >
450
- {/* Collapsed rail shows the initial alone; the full wordmark
451
- overflows it. Both live in one <a> so the link survives either
452
- state (CSS swaps them on the data-sidenav stamp). */}
453
- <span className="kol-sidenav-footer-mark text-body group-hover:text-emphasis">K</span>
454
- <span className="kol-sidenav-footer-full">
455
- <span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
456
- <span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
457
- </span>
458
- </a>
459
- )}
460
- </div>
461
- )}
462
-
463
- {/* the open panel leaf's panel — a portal above its row, the rail's width */}
464
- {panelNode && panelBox && createPortal(
465
- <div
466
- className="kol-sidenav-panel fixed flex flex-col gap-[2px] py-2 bg-surface-primary border-t border-r border-fg-08"
467
- style={{ left: panelBox.left, width: panelBox.width, bottom: panelBox.bottom, zIndex: 'var(--kol-z-tooltip)' }}
468
- >
469
- {typeof panelNode.panel === 'function' ? panelNode.panel({ collapsed, close: () => setOpenPanel(null) }) : panelNode.panel}
470
- </div>,
471
- document.body,
472
- )}
473
-
474
- {/* Grab edge — width/cursor/focus chrome in kol-framework.css
475
- * (.kol-sidenav-grab); hidden below lg by the narrow-mode block. */}
476
- <div className="kol-sidenav-grab absolute top-0 right-0 bottom-0 z-[1]" {...grabProps} />
477
- </aside>
32
+ <NavRail
33
+ variant="site"
34
+ {...props}
35
+ currentPath={pathname}
36
+ hrefFor={(to) => `${base}${to}`}
37
+ onNavigate={(e, to) => { onNavigate?.(e, to); navigate(to) }}
38
+ themeToggle={themeSlot(props.themeToggle)}
39
+ />
478
40
  )
479
41
  }
42
+
43
+ const themeSlot = (on = true) => (on === false ? false : ({ collapsed }) => (
44
+ <ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
45
+ ))
package/src/loaderApp.js CHANGED
@@ -2,13 +2,18 @@
2
2
  * hideLoaderApp — the app loader's other half (`loaderApp()` in `@kolkrabbi/kol-framework/vite` writes it into
3
3
  * index.html). Call it once React has committed: two frames later the loader fades (240 ms, none under reduced
4
4
  * motion — the CSS drops the transition) and leaves the DOM. Safe to call when there is no loader.
5
+ *
6
+ * `minMs` (default 600, LoaderAppMinimumTime — kol-monitor, user ruling 2026-10-22) holds the loader at least
7
+ * that long from navigation start, which is when the static loader first paints: a fast load flashed the mark
8
+ * for a blink, a slow one waits nothing extra. Reduced motion drops the fade, never the minimum. `minMs: 0` is
9
+ * the old behaviour.
5
10
  */
6
- export function hideLoaderApp() {
11
+ export function hideLoaderApp({ minMs = 600 } = {}) {
7
12
  if (typeof document === 'undefined') return
8
13
  const el = document.getElementById('kol-loader-app')
9
14
  if (!el) return
10
- requestAnimationFrame(() => requestAnimationFrame(() => {
15
+ setTimeout(() => requestAnimationFrame(() => requestAnimationFrame(() => {
11
16
  el.classList.add('is-done')
12
17
  setTimeout(() => { el.remove(); document.getElementById('kol-loader-app-css')?.remove() }, 300)
13
- }))
18
+ })), Math.max(0, minMs - performance.now()))
14
19
  }
package/src/vite.js CHANGED
@@ -34,7 +34,7 @@ export function devFavicon({ label = 'K', ground = '#7C5CFF', ink = '#FFFFFF' }
34
34
  * owned in DS and consumed by the fxr monitor and mirror? with custom graphic input as prop?"*). The app's mark
35
35
  * centred on the theme's ground, pulsing while the bundle downloads. It has to be static HTML in `index.html` —
36
36
  * a React component would only appear after the wait it covers — so this plugin writes it there: fxr's loader,
37
- * once, with the graphic as an option. Call `hideLoaderApp()` (kol-framework) after the first commit.
37
+ * once, with the graphic as an option. Call `hideLoaderApp()` (kol-framework) after the first commit — it holds the loader 600 ms from navigation start (`minMs`).
38
38
  *
39
39
  * Ground and ink follow the stored `data-theme`, else the system (run `THEME_BOOT_SCRIPT` before it, as every
40
40
  * KOL app does). Reduced motion: no pulse, no fade.