@kolkrabbi/kol-framework 0.29.0 → 0.31.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/kol-framework.css CHANGED
@@ -160,6 +160,16 @@ body {
160
160
  display: block;
161
161
  padding: 0.25rem 1.5rem 0.25rem 3.5rem;
162
162
  }
163
+ /* THE COLLAPSE RULE (WorkspaceSidebarGeometry, kol-fxr 2026-08-27 — user: "I
164
+ * would rather all use the same geometry layout"): a row that can survive
165
+ * collapse has an icon; one that cannot is not a row, it is a LABEL. A
166
+ * top-level node with no icon renders this — the eyebrow over its rows — and
167
+ * it is hidden with the labels when the rail narrows, so the rail reads as one
168
+ * thing narrowing (the brand sidebar's model) instead of rows vanishing. */
169
+ .kol-sidenav-section {
170
+ display: block;
171
+ padding: 0.5rem 1.5rem 0.25rem 1.5rem;
172
+ }
163
173
  .kol-sidenav-footer { padding-inline: 1.5rem 1rem; }
164
174
 
165
175
  /* The aside's OWN box (SideNavMobilePosition, kol-website 2026-08-25) — the
@@ -198,6 +208,7 @@ body {
198
208
  * no labels; the theme slot and footer centre; the footer wordmark swaps for
199
209
  * its initial — both spans live in one <a>, so the link survives either state. */
200
210
  :root[data-sidenav="collapsed"] .kol-sidenav-hop-caret,
211
+ :root[data-sidenav="collapsed"] .kol-sidenav-section,
201
212
  :root[data-sidenav="collapsed"] .kol-sidenav-list { display: none; }
202
213
  :root[data-sidenav="collapsed"] .kol-sidenav-theme-slot { padding-inline: 0; justify-content: center; }
203
214
  :root[data-sidenav="collapsed"] .kol-sidenav-footer { padding-inline: 0; justify-content: center; }
@@ -259,6 +270,7 @@ body {
259
270
  .kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
260
271
  .kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
261
272
  .kol-sidenav-hop-label { display: none; }
273
+ .kol-sidenav-section { display: none; }
262
274
  .kol-sidenav-body { display: none; }
263
275
  /* forced narrow / drawer modes own the rail below lg — no resize here */
264
276
  .kol-sidenav-grab { display: none; }
@@ -280,6 +292,7 @@ body {
280
292
  /* Restore expanded labels + body inside the open drawer (override the rail rules above). */
281
293
  .kol-sidenav.is-drawer-open .kol-sidenav-hop { padding: 8px 0; justify-content: flex-start; gap: 12px; }
282
294
  .kol-sidenav.is-drawer-open .kol-sidenav-hop-label { display: inline; }
295
+ .kol-sidenav.is-drawer-open .kol-sidenav-section { display: block; }
283
296
  .kol-sidenav.is-drawer-open .kol-sidenav-body { display: block; }
284
297
  .kol-brand-layout[data-drawer-open="true"] .kol-sidenav-backdrop {
285
298
  opacity: 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "KOL app shell — sidenav, layout, theme toggle, footer, heroes + brand color layer. SideNav takes nav data as props so the tree stays app-local.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,8 +20,8 @@
20
20
  "react-router-dom": "^6.0.0 || ^7.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kolkrabbi/kol-component": "^0.114.0",
24
- "@kolkrabbi/kol-icons": "^0.23.0"
23
+ "@kolkrabbi/kol-component": "^0.118.3",
24
+ "@kolkrabbi/kol-icons": "^0.24.0"
25
25
  },
26
26
  "files": [
27
27
  "src",
package/src/SideNav.jsx CHANGED
@@ -34,6 +34,28 @@ import useDragResize from './useDragResize'
34
34
  * navTree: [{ id, label, icon, to?, pages?: [{ label, to } | { label, children }] }]
35
35
  * (legacy `children` on a category is read as `pages`; section anchors are not.)
36
36
  *
37
+ * WORKSPACE RAILS (WorkspaceSidebarGeometry, kol-fxr 2026-08-27 — user: "I see
38
+ * these 3 as very similar things … I would rather all use the same geometry
39
+ * layout"; three forks, ~1,370 lines, wearing this component's classes):
40
+ *
41
+ * ACTION LEAVES — `{ label, onSelect, active? }` beside `{ label, to }`, at
42
+ * any depth and as a category (`{ id, label, icon, onSelect, active? }`): a
43
+ * rail that DISPATCHES (labs swaps an effect, monitor picks a module, mirror
44
+ * selects a surface) instead of navigating. A <button>, lit by `active`;
45
+ * never a route, so no history is walked (labs' `?preset=` replace-not-push
46
+ * stays what it is).
47
+ *
48
+ * ROUTER-AGNOSTIC — pass `currentPath` and the component never touches
49
+ * react-router: route leaves render `<a href>` and call `onNavigate(event,
50
+ * to)` (kol-shell's contract); a router-free app can mount it. Without
51
+ * `currentPath` it is the NavLink rail it always was.
52
+ *
53
+ * THE COLLAPSE RULE — a row that can survive collapse HAS AN ICON; one that
54
+ * cannot is not a row, it is a LABEL. A top-level node with no `icon` (and no
55
+ * `to` / `onSelect`) renders `.kol-sidenav-section` — the eyebrow voice, hidden
56
+ * on collapse — over its `pages`; the icon rows beneath stay, so the rail
57
+ * reads as one thing narrowing, the brand sidebar's model.
58
+ *
37
59
  * Collapse: the pill-marked grab edge is THE single control (user build
38
60
  * order 2026-08-09, completing SideNavGrabResize — the chip Button is gone
39
61
  * in both states). Click toggles expand↔collapse, drag resizes with
@@ -42,6 +64,27 @@ import useDragResize from './useDragResize'
42
64
  * the `kol-sidenav` storage schema, owned by useDragResize. Collapsed rows
43
65
  * get a DS Tooltip — the glyph is otherwise the only thing naming them.
44
66
  *
67
+ * THE SHELL RAIL IS THIS COMPONENT (RailSideNavPixelParity, kol-fxr 2026-08-28
68
+ * — user, on the brand sidebar's drag: "same component both states super
69
+ * nice"): kol-shell's `AppShell` mounts a SideNav — collapsed by default, the
70
+ * drag opens it — so an app rail and the brand sidebar are one geometry to the
71
+ * pixel (fxr measured 48 vs 56 wide, 20 vs 16px glyphs, 40 vs 38 pitch, first
72
+ * glyph at y 76 vs 26 between the old NavRail and this rail collapsed). The
73
+ * seams the shell needed, all opt-in, a tree with none of them renders as before:
74
+ * `bottomItems` top-level nodes pinned BELOW the theme slot, above the
75
+ * footer (the shell pins theme → Settings; a Settings row in
76
+ * the tree landed above the theme instead — "same rail, two
77
+ * bottoms")
78
+ * `footer` the footer box's content: undefined = the Kolkrabbi link
79
+ * (K / wordmark, as ever); `false` = no footer; a node =
80
+ * that node (the shell's logomark → '/')
81
+ * `themeToggle` `false` drops the theme slot (default `true`)
82
+ * `iconComponent` the hop glyph's renderer, kol-shell's seam — `(props:
83
+ * { name, size }) => element`; default the DS `Icon`
84
+ * `expandOnSelect` `false` keeps the rail collapsed when a row navigates or
85
+ * dispatches — an app rail stays a rail. Default `true`, the
86
+ * brand ruling ("pressing any icon would expand the sidebar")
87
+ * `defaultCollapsed` boots collapsed when nothing is stored (useDragResize)
45
88
  * `background` (2026-08-09, prop): `true` (default) paints the rail surface;
46
89
  * `false` renders chromeless so the rail can float over a consumer's media
47
90
  * (the brand-hero model — the consumer owns the plane). `hairline` (2026-08-26,
@@ -64,7 +107,7 @@ const pageActiveCls = `${pageBase} is-active`
64
107
  /* A category's rows: route leaves (`to`) and route groups (`children`).
65
108
  * `#anchor` leaves (`id` only) are dropped — that layer died with the brand
66
109
  * app's 2026-08-01 ruling and is not reopened here. */
67
- const rowsOf = (nodes) => nodes.filter((c) => c.to || c.children)
110
+ const rowsOf = (nodes) => nodes.filter((c) => c.to || c.children || c.onSelect)
68
111
  const pagesOf = (cat) => rowsOf(cat.pages ?? cat.children ?? [])
69
112
  /* Every route leaf under a set of rows, through any depth of groups. */
70
113
  const leavesOf = (rows) => rows.flatMap((r) => (r.children ? leavesOf(rowsOf(r.children)) : [r]))
@@ -73,7 +116,21 @@ const leavesOf = (rows) => rows.flatMap((r) => (r.children ? leavesOf(rowsOf(r.c
73
116
  * this layer. Depth 0 carries no indent so a flat tree renders unchanged. */
74
117
  const indentOf = (depth) => `calc(3.5rem + ${depth} * var(--kol-spacing-3))`
75
118
 
76
- export default function SideNav({
119
+ /* Router-agnostic seam: with `currentPath` the rail never calls react-router
120
+ * (a hook cannot be conditional, so the router read lives in its own wrapper
121
+ * that mounts only when the prop is absent). */
122
+ export default function SideNav(props) {
123
+ if (props.currentPath != null) return <SideNavInner {...props} pathname={props.currentPath} router={false} />
124
+ return <RouterSideNav {...props} />
125
+ }
126
+ function RouterSideNav(props) {
127
+ const { pathname } = useLocation()
128
+ return <SideNavInner {...props} pathname={pathname} router />
129
+ }
130
+
131
+ function SideNavInner({
132
+ pathname,
133
+ router,
77
134
  drawerOpen = false,
78
135
  onCloseDrawer,
79
136
  navTree = [],
@@ -86,20 +143,40 @@ export default function SideNav({
86
143
  * name; a rail floating over media and a rail without a line are different
87
144
  * choices. */
88
145
  hairline = false,
146
+ bottomItems = [],
147
+ footer,
148
+ themeToggle = true,
149
+ iconComponent,
150
+ expandOnSelect = true,
151
+ defaultCollapsed = false,
89
152
  }) {
90
- const { pathname } = useLocation()
153
+ const Glyph = iconComponent ?? Icon
91
154
  const isPageActive = (to) => (isActive ? isActive(to) : pathname === to)
92
- const isActiveCat = (cat) => leavesOf(pagesOf(cat)).some((p) => isPageActive(p.to))
155
+ /* a leaf is lit by its route, or — an action leaf — by its own `active` */
156
+ const isLeafActive = (leaf) => (leaf.onSelect ? !!leaf.active : isPageActive(leaf.to))
157
+ const isActiveCat = (cat) => (cat.onSelect ? !!cat.active : leavesOf(pagesOf(cat)).some(isLeafActive))
93
158
 
94
159
  /* Every navigation closes the mobile drawer — the prop sat accepted-but-
95
160
  * unread until the kol-website session flagged it (2026-08-09) — and, from
96
161
  * the collapsed rail, expands the sidebar ("pressing any icon would expand
97
162
  * the sidebar", same ruling as the chip removal). */
98
- const handleNavigate = (e) => {
99
- onNavigate?.(e)
163
+ const handleNavigate = (e, to) => {
164
+ onNavigate?.(e, to)
165
+ onCloseDrawer?.()
166
+ if (collapsed && expandOnSelect) toggleCollapsed()
167
+ }
168
+ /* an action leaf dispatches — same drawer / rail behaviour as a navigation */
169
+ const handleSelect = (e, row) => {
170
+ row.onSelect(e)
100
171
  onCloseDrawer?.()
101
- if (collapsed) toggleCollapsed()
172
+ if (collapsed && expandOnSelect) toggleCollapsed()
102
173
  }
174
+ /* the route leaf: NavLink under a router, `<a href>` on the agnostic seam */
175
+ const RouteLeaf = ({ to, className, style, children }) => router ? (
176
+ <NavLink to={to} end style={style} className={({ isActive: navActive }) => className(isActive ? isActive(to) : navActive)} onClick={(e) => handleNavigate(e, to)}>{children}</NavLink>
177
+ ) : (
178
+ <a href={to} style={style} className={className(isPageActive(to))} onClick={(e) => { e.preventDefault(); handleNavigate(e, to) }}>{children}</a>
179
+ )
103
180
 
104
181
  /* Which categories are disclosed. INDEPENDENT per category (elder ruling
105
182
  * 2026-08-01) — arriving somewhere must not silently close wherever you
@@ -116,14 +193,14 @@ export default function SideNav({
116
193
  * storage schema and the state; the chip Button and the grab edge both
117
194
  * drive it. */
118
195
  const asideRef = useRef(null)
119
- const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef)
196
+ const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef, { defaultCollapsed })
120
197
 
121
198
  /* Rows under a category: a leaf is a NavLink; a group is a non-routing
122
199
  * header over its own rows, one step further in (recursive). A group lights
123
200
  * up the way a category does — when one of its leaves is the current route. */
124
201
  const renderRows = (rows, depth) => rows.map((row, i) => {
125
202
  if (row.children) {
126
- const lit = leavesOf(rowsOf(row.children)).some((l) => isPageActive(l.to))
203
+ const lit = leavesOf(rowsOf(row.children)).some(isLeafActive)
127
204
  return (
128
205
  <li key={row.id ?? `${row.label}-${i}`}>
129
206
  <div
@@ -136,66 +213,76 @@ export default function SideNav({
136
213
  </li>
137
214
  )
138
215
  }
216
+ /* the dot keeps its 0.875rem lead on the text at every depth */
217
+ const leafStyle = depth ? { paddingLeft: indentOf(depth), '--kol-sidenav-dot-left': `calc(2.625rem + ${depth} * var(--kol-spacing-3))` } : pageStyle
218
+ if (row.onSelect) {
219
+ return (
220
+ <li key={row.id ?? `${row.label}-${i}`}>
221
+ <button
222
+ type="button"
223
+ style={leafStyle}
224
+ className={`${row.active ? pageActiveCls : pageCls} w-full bg-transparent border-0 cursor-pointer text-left truncate`}
225
+ aria-pressed={!!row.active}
226
+ onClick={(e) => handleSelect(e, row)}
227
+ >
228
+ {row.label}
229
+ </button>
230
+ </li>
231
+ )
232
+ }
139
233
  return (
140
234
  <li key={row.to}>
141
- <NavLink
142
- to={row.to}
143
- end
144
- /* the dot keeps its 0.875rem lead on the text at every depth */
145
- style={depth ? { paddingLeft: indentOf(depth), '--kol-sidenav-dot-left': `calc(2.625rem + ${depth} * var(--kol-spacing-3))` } : pageStyle}
146
- className={({ isActive: navActive }) => ((isActive ? isActive(row.to) : navActive) ? pageActiveCls : pageCls)}
147
- onClick={handleNavigate}
148
- >
235
+ <RouteLeaf to={row.to} style={leafStyle} className={(active) => (active ? pageActiveCls : pageCls)}>
149
236
  {row.label}
150
- </NavLink>
237
+ </RouteLeaf>
151
238
  </li>
152
239
  )
153
240
  })
154
241
 
155
- return (
156
- <aside
157
- ref={asideRef}
158
- /* Box utilities deliberately absent (SideNavMobilePosition, 2026-08-25):
159
- * the aside's position/height/stack live in .kol-sidenav
160
- * (kol-framework.css). `sticky … h-dvh z-20` here outranked the 767px
161
- * drawer rule's `position: fixed`, so every consumer page opened one
162
- * viewport down on phones. Only colour utilities ride the element. */
163
- className={`kol-sidenav${background ? ' bg-surface-primary' : ''}${hairline ? ' border-r border-fg-08' : ''}${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
164
- >
165
- {/* NO chip Button in either state (user build order 2026-08-09 — "MAKE
166
- * IT"; supersedes the elder A/B): the pill-marked grab edge below is
167
- * THE single collapse/resize control — click toggles, drag resizes,
168
- * snap-to-default on release, Home/Enter/Space on the keyboard. */}
169
-
170
- <div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
171
- <nav aria-label="Sections">
172
- <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
173
- {navTree.map((cat) => {
242
+ /* one renderer for the tree and the pinned rows (RailSideNavPixelParity) */
243
+ const renderCat = (cat, ci) => {
174
244
  const pages = pagesOf(cat)
175
245
  const isOpen = openCats.has(cat.id)
176
246
  const isLink = !pages.length && cat.to
247
+ const isAction = !pages.length && !cat.to && cat.onSelect
248
+ /* THE COLLAPSE RULE: no icon, no route, no action = a LABEL over
249
+ * its rows, not a row — `.kol-sidenav-section` hides on collapse
250
+ * and the icon rows beneath it stay */
251
+ if (!cat.icon && !cat.to && !cat.onSelect) {
252
+ return (
253
+ <li key={cat.id ?? `${cat.label}-${ci}`} className="relative">
254
+ <div className="kol-sidenav-section kol-eyebrow text-strong">{cat.label}</div>
255
+ {pages.length > 0 && <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>}
256
+ </li>
257
+ )
258
+ }
177
259
  /* Box utilities deliberately absent — the hop's layout lives in
178
260
  * .kol-sidenav-hop (kol-framework.css): a utility here would
179
261
  * outrank the collapsed-state overrides (2026-08-09 defect). */
180
262
  const rowCls = 'kol-sidenav-hop kol-helper-12 text-emphasis no-underline'
181
263
  const glyph = (
182
264
  <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
183
- <Icon name={cat.icon} size={16} />
265
+ <Glyph name={cat.icon} size={16} />
184
266
  </span>
185
267
  )
186
268
  /* A category is a <button>, not a link — it has no route by
187
269
  definition, and an <a href> would promise a page that does
188
270
  not exist. Clicking it discloses its pages. */
189
271
  const head = isLink ? (
190
- <NavLink
191
- to={cat.to}
192
- end
193
- className={({ isActive: navActive }) => `${rowCls}${(isActive ? isActive(cat.to) : navActive) ? ' is-active' : ''}`}
194
- onClick={handleNavigate}
272
+ <RouteLeaf to={cat.to} className={(active) => `${rowCls}${active ? ' is-active' : ''}`}>
273
+ {glyph}
274
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
275
+ </RouteLeaf>
276
+ ) : isAction ? (
277
+ <button
278
+ type="button"
279
+ className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${cat.active ? ' is-active' : ''}`}
280
+ aria-pressed={!!cat.active}
281
+ onClick={(e) => handleSelect(e, cat)}
195
282
  >
196
283
  {glyph}
197
284
  <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
198
- </NavLink>
285
+ </button>
199
286
  ) : (
200
287
  <button
201
288
  type="button"
@@ -235,39 +322,74 @@ export default function SideNav({
235
322
  </Tooltip>
236
323
  ) : head}
237
324
 
238
- {!isLink && isOpen && (
325
+ {!isLink && !isAction && isOpen && (
239
326
  <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>
240
327
  )}
241
328
  </li>
242
329
  )
243
- })}
330
+ }
331
+
332
+ return (
333
+ <aside
334
+ ref={asideRef}
335
+ /* Box utilities deliberately absent (SideNavMobilePosition, 2026-08-25):
336
+ * the aside's position/height/stack live in .kol-sidenav
337
+ * (kol-framework.css). `sticky … h-dvh z-20` here outranked the 767px
338
+ * drawer rule's `position: fixed`, so every consumer page opened one
339
+ * viewport down on phones. Only colour utilities ride the element. */
340
+ className={`kol-sidenav${background ? ' bg-surface-primary' : ''}${hairline ? ' border-r border-fg-08' : ''}${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
341
+ >
342
+ {/* NO chip Button in either state (user build order 2026-08-09 — "MAKE
343
+ * IT"; supersedes the elder A/B): the pill-marked grab edge below is
344
+ * THE single collapse/resize control — click toggles, drag resizes,
345
+ * snap-to-default on release, Home/Enter/Space on the keyboard. */}
346
+
347
+ <div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
348
+ <nav aria-label="Sections">
349
+ <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
350
+ {navTree.map(renderCat)}
244
351
  </ul>
245
352
  </nav>
246
353
 
247
354
  {/* hop-bare carries its own gutter padding; collapsed falls back to
248
355
  `icon` — a full-width label can't sit in the collapsed rail. */}
249
- <div className="kol-sidenav-theme-slot flex">
250
- <ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
356
+ <div>
357
+ {themeToggle && (
358
+ <div className="kol-sidenav-theme-slot flex">
359
+ <ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
360
+ </div>
361
+ )}
362
+ {/* pinned rows — BELOW the theme, above the footer: the shell's bottom
363
+ * pair (theme → Settings), specified once */}
364
+ {bottomItems.length > 0 && (
365
+ <nav aria-label="Pinned">
366
+ <ul className="kol-sidenav-tree flex flex-col gap-[2px]">{bottomItems.map(renderCat)}</ul>
367
+ </nav>
368
+ )}
251
369
  </div>
252
370
  </div>
253
371
 
254
- <div className="kol-sidenav-footer flex items-center h-14 border-t border-fg-08 min-w-0">
255
- <a
256
- href="https://kolkrabbi.io"
257
- target="_blank"
258
- rel="noopener"
259
- className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
260
- >
261
- {/* Collapsed rail shows the initial alone; the full wordmark
262
- overflows it. Both live in one <a> so the link survives either
263
- state (CSS swaps them on the data-sidenav stamp). */}
264
- <span className="kol-sidenav-footer-mark text-body group-hover:text-emphasis">K</span>
265
- <span className="kol-sidenav-footer-full">
266
- <span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
267
- <span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
268
- </span>
269
- </a>
270
- </div>
372
+ {footer !== false && footer !== null && (
373
+ <div className="kol-sidenav-footer flex items-center h-14 border-t border-fg-08 min-w-0">
374
+ {footer !== undefined ? footer : (
375
+ <a
376
+ href="https://kolkrabbi.io"
377
+ target="_blank"
378
+ rel="noopener"
379
+ className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
380
+ >
381
+ {/* Collapsed rail shows the initial alone; the full wordmark
382
+ overflows it. Both live in one <a> so the link survives either
383
+ state (CSS swaps them on the data-sidenav stamp). */}
384
+ <span className="kol-sidenav-footer-mark text-body group-hover:text-emphasis">K</span>
385
+ <span className="kol-sidenav-footer-full">
386
+ <span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
387
+ <span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
388
+ </span>
389
+ </a>
390
+ )}
391
+ </div>
392
+ )}
271
393
 
272
394
  {/* Grab edge — width/cursor/focus chrome in kol-framework.css
273
395
  * (.kol-sidenav-grab); hidden below lg by the narrow-mode block. */}
@@ -96,7 +96,7 @@ const readBack = (n) => {
96
96
  * rightward drag = wider. 'right' inverts both the pointer sign and
97
97
  * the arrow keys. */
98
98
  export default function useDragResize(ref, options = {}) {
99
- const { token = 'kol-sidenav', side = 'left' } = options
99
+ const { token = 'kol-sidenav', side = 'left', defaultCollapsed = false } = options
100
100
  /* -1 on a right-hand rail: the same rightward pointer travel that widens a
101
101
  * left rail must NARROW a right one, because its handle faces the canvas. */
102
102
  const dir = side === 'right' ? -1 : 1
@@ -131,14 +131,18 @@ export default function useDragResize(ref, options = {}) {
131
131
  defaultPx.current = readVarPx(names.wVar)
132
132
  collapsedPx.current = readVarPx(names.collapsedVar)
133
133
  let w = null
134
- let c = false
134
+ let stored = null
135
135
  try {
136
136
  w = parseFloat(localStorage.getItem(names.widthKey)) || null
137
- c = localStorage.getItem(names.stateKey) === 'collapsed'
137
+ stored = localStorage.getItem(names.stateKey)
138
138
  } catch { /* storage blocked */ }
139
+ /* nothing stored → the consumer's boot state (RailSideNavPixelParity,
140
+ * 2026-08-28): an app rail boots collapsed, the brand sidebar boots open;
141
+ * the first drag or click persists and the default never speaks again */
142
+ const c = stored ? stored === 'collapsed' : !!defaultCollapsed
139
143
  if (w) { writeWidth(names, w); setWidthPx(w) }
140
144
  if (c) { stampCollapsed(names, true); setCollapsed(true) }
141
- }, [names])
145
+ }, [names, defaultCollapsed])
142
146
 
143
147
  useEffect(() => {
144
148
  const onMove = (e) => {