@kolkrabbi/kol-framework 0.13.0 → 0.15.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
@@ -35,6 +35,14 @@ body {
35
35
  grid track that had to line up with it read 256, so the shell header's
36
36
  brand block sat 4 off the sidebar column it exists to align to. */
37
37
  --kol-sidenav-w: 16rem;
38
+ /* Grab-edge gesture (lobby: SideNavGrabResize, 2026-08-09). Minted HERE —
39
+ no --kol-* token carried these before (checked: only -w, -w-collapsed,
40
+ -dot-left shipped). kol-mirror carried them as bare literals, the
41
+ consumer-app slack the brief bans copying; both widths reference the
42
+ spacing ladder (the mirror strip's width lands on the ladder's first rung). */
43
+ --kol-sidenav-grab-w: var(--kol-spacing-1); /* the strip's hit width */
44
+ --kol-sidenav-snap: 12rem; /* drag under this snaps to -w-collapsed */
45
+ --kol-sidenav-step: var(--kol-spacing-4); /* keyboard resize increment */
38
46
  --kol-sidenav-w-collapsed: 56px;
39
47
  /* The workshop shell's RIGHT rail. EQUAL TO THE LEFT (user ruling
40
48
  2026-08-01) — the two rails frame the same content and a lopsided frame is
@@ -104,10 +112,47 @@ body {
104
112
  * said 1800 — same element, two answers (lobby/WidthSystemContradictions). */
105
113
  @media (min-width: 1920px) { :root { --kol-container-max: var(--kol-content-shell, 1800px); } }
106
114
 
107
- /* :root[data-sidenav="collapsed"] removed 2026-07-29 — the manual collapse
108
- * feature died with the editor's exit (brand ruling); the responsive rail
109
- * below 1024px is the surviving narrow mode. --kol-sidenav-w-collapsed stays:
110
- * the rail still uses it. */
115
+ /* :root[data-sidenav="collapsed"] — the manual collapse, rebuilt DELIBERATELY
116
+ * 2026-08-09 (lobby: SideNavGrabResize) after the 2026-07-29 removal of the
117
+ * renderer-less stamp. SideNav's grab edge (useDragResize.js) owns the state
118
+ * now; these rules mirror the narrow mode below, so "collapsed" is that same
119
+ * rail — one look, two triggers. */
120
+ :root[data-sidenav="collapsed"] .kol-brand-layout { grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr); }
121
+ :root[data-sidenav="collapsed"] .kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
122
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
123
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop-label { display: none; }
124
+ :root[data-sidenav="collapsed"] .kol-sidenav-body { display: none; }
125
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop-count,
126
+ :root[data-sidenav="collapsed"] .kol-sidenav-section-toggle { display: none; }
127
+ /* Folded back from the elder's brand-local sidenav-collapse.css (2026-08-09
128
+ * port): the disclosure caret and the disclosed list are noise in a rail with
129
+ * no labels; the theme slot and footer centre; the footer wordmark swaps for
130
+ * its initial — both spans live in one <a>, so the link survives either state. */
131
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop-caret,
132
+ :root[data-sidenav="collapsed"] .kol-sidenav-list { display: none; }
133
+ :root[data-sidenav="collapsed"] .kol-sidenav-theme-slot { padding-inline: 0; justify-content: center; }
134
+ :root[data-sidenav="collapsed"] .kol-sidenav-footer { padding-inline: 0; justify-content: center; }
135
+ .kol-sidenav-footer-mark { display: none; }
136
+ :root[data-sidenav="collapsed"] .kol-sidenav-footer-mark { display: inline; }
137
+ :root[data-sidenav="collapsed"] .kol-sidenav-footer-full { display: none; }
138
+
139
+ /* The grab edge itself — invisible by spec (col-resize cursor is the pointer
140
+ * affordance, the focus ring the keyboard one — recipe from
141
+ * .kol-btn:focus-visible, offset pulled inside because the strip hugs the
142
+ * layout edge). Positioning utilities ride inline in SideNav.jsx; the narrow
143
+ * mode hides the strip in its own block further down. */
144
+ .kol-sidenav-grab {
145
+ width: var(--kol-sidenav-grab-w);
146
+ cursor: col-resize;
147
+ }
148
+ .kol-sidenav-grab:focus-visible {
149
+ outline: 2px solid var(--kol-focus-ring);
150
+ outline-offset: -2px;
151
+ }
152
+ /* Live drag writes --kol-sidenav-w per pointermove — the grid's ease below
153
+ * would render the rail a frame behind the pointer, so the drag suspends it
154
+ * (useDragResize stamps the attribute for the drag's duration). */
155
+ :root[data-sidenav-dragging] .kol-brand-layout { transition: none; }
111
156
 
112
157
  /* ───── Brand doc layout (sidebar + content) ─────
113
158
  The grid lives in CSS for its dynamic --kol-sidenav-w + transition. */
@@ -123,10 +168,12 @@ body {
123
168
  User's localStorage expand preference is overridden while viewport is narrow;
124
169
  restored once they cross back over the threshold. */
125
170
  .kol-brand-layout { grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr); }
126
- .kol-sidenav-hops { padding: 0 0 16px; margin-bottom: 16px; }
127
- .kol-sidenav-hop { padding: 8px 0; justify-content: center; gap: 0; }
171
+ .kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
172
+ .kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
128
173
  .kol-sidenav-hop-label { display: none; }
129
174
  .kol-sidenav-body { display: none; }
175
+ /* forced narrow / drawer modes own the rail below lg — no resize here */
176
+ .kol-sidenav-grab { display: none; }
130
177
  }
131
178
 
132
179
  @media (max-width: 767px) {
@@ -700,6 +747,10 @@ body {
700
747
  .kol-brand-layout[data-toc="true"] {
701
748
  grid-template-columns: var(--kol-sidenav-w) minmax(0, 1fr) var(--kol-toc-w);
702
749
  }
750
+ /* collapsed + TOC: the third column survives; only the rail track narrows */
751
+ :root[data-sidenav="collapsed"] .kol-brand-layout[data-toc="true"] {
752
+ grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr) var(--kol-toc-w);
753
+ }
703
754
  }
704
755
 
705
756
  /* SideNav collapsibleSections chrome — inert unless that mode renders.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",
@@ -27,7 +27,7 @@
27
27
  "app-shell"
28
28
  ],
29
29
  "dependencies": {
30
- "@kolkrabbi/kol-component": "0.24.0",
30
+ "@kolkrabbi/kol-component": "0.27.0",
31
31
  "@kolkrabbi/kol-icons": "0.10.0"
32
32
  },
33
33
  "peerDependencies": {
package/src/AppShell.jsx CHANGED
@@ -15,7 +15,10 @@ export const ShellTocContext = createContext(null)
15
15
  * useLayoutEffect(() => { setTocCollapsed(true); return () => setTocCollapsed(false) }, [setTocCollapsed]) */
16
16
  export const ShellTocCollapsedContext = createContext(null)
17
17
 
18
- export default function AppShell({ navTree = [], getActivePage, header, footer, defaultTocContent }) {
18
+ /* `getActivePage` — ACCEPTED-but-inert since the 2026-08-09 SideNav port (the
19
+ * two-level elder model derives the active category from the route); kept so
20
+ * no call site breaks. */
21
+ export default function AppShell({ navTree = [], getActivePage: _getActivePage, header, footer, defaultTocContent }) {
19
22
  const [drawerOpen, setDrawerOpen] = useState(false)
20
23
  const [tocContent, setTocContent] = useState(null)
21
24
  const [tocCollapsed, setTocCollapsed] = useState(false)
@@ -58,7 +61,7 @@ export default function AppShell({ navTree = [], getActivePage, header, footer,
58
61
  aria-hidden="true"
59
62
  />
60
63
 
61
- <SideNav navTree={navTree} getActivePage={getActivePage} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} />
64
+ <SideNav navTree={navTree} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} />
62
65
  <div className="min-w-0">
63
66
  {header}
64
67
  <Outlet />
package/src/SideNav.jsx CHANGED
@@ -1,258 +1,182 @@
1
- import { useEffect, useState } from 'react'
2
- import { Link, NavLink, useLocation } from 'react-router-dom'
1
+ import { NavLink, useLocation } from 'react-router-dom'
2
+ import { useRef, useState } from 'react'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
+ import { Button, Tooltip } from '@kolkrabbi/kol-component'
4
5
  import ThemeToggle from './ThemeToggle'
5
- import { useScrollSpy } from '@kolkrabbi/kol-component'
6
-
7
- /* Walk the active page's children and return all leaf section ids (for scroll-spy). */
8
- function collectSectionIds(node) {
9
- if (!node?.children) return []
10
- const ids = []
11
- const walk = (children) => {
12
- for (const c of children) {
13
- if (c.id && !c.to) ids.push(c.id)
14
- if (c.children) walk(c.children)
15
- }
16
- }
17
- walk(node.children)
18
- return ids
19
- }
20
-
21
- const linkBase = 'kol-sidenav-link kol-helper-10 block relative py-[4px] no-underline transition-colors duration-150'
22
- const linkCls = `${linkBase} text-body hover:text-emphasis`
23
- const linkActiveCls = `${linkBase} is-active`
24
-
25
- /* Walk the children tree; return true if any leaf matches activeSectionId. */
26
- function hasActiveDescendant(children, activeSectionId) {
27
- if (!activeSectionId) return false
28
- for (const c of children ?? []) {
29
- if (c.id === activeSectionId) return true
30
- if (c.children && hasActiveDescendant(c.children, activeSectionId)) return true
31
- }
32
- return false
33
- }
34
-
35
- const leafStyle = (indent) => ({
36
- paddingLeft: indent,
37
- '--kol-sidenav-dot-left': `${indent - 14}px`,
38
- })
39
-
40
- function SectionLeaf({ leaf, basePath, isActive, indent, onNavigate }) {
41
- return (
42
- <li>
43
- <Link
44
- to={`${basePath}#${leaf.id}`}
45
- className={isActive ? linkActiveCls : linkCls}
46
- style={leafStyle(indent)}
47
- onClick={onNavigate}
48
- >
49
- {leaf.label}
50
- </Link>
51
- </li>
52
- )
53
- }
54
-
55
- function RouteLeaf({ leaf, indent, onNavigate, isActiveFn }) {
56
- return (
57
- <li>
58
- <NavLink
59
- to={leaf.to}
60
- end
61
- className={({ isActive }) => ((isActiveFn ? isActiveFn(leaf.to) : isActive) ? linkActiveCls : linkCls)}
62
- style={leafStyle(indent)}
63
- onClick={onNavigate}
64
- >
65
- {leaf.label}
66
- </NavLink>
67
- </li>
68
- )
69
- }
70
-
71
- function GroupNode({ group, basePath, activeSectionId, indent, onNavigate, isActiveFn }) {
72
- const isAncestor = hasActiveDescendant(group.children, activeSectionId)
73
- return (
74
- <li>
75
- <div
76
- className={`kol-sidenav-group kol-helper-10 ${isAncestor ? 'text-emphasis' : 'text-subtle'}`}
77
- style={{ paddingLeft: indent }}
78
- >
79
- {group.label}
80
- </div>
81
- <ul className="kol-sidenav-list">
82
- {group.children.map((child, i) => (
83
- <ChildNode
84
- key={child.id ?? child.to ?? `g-${i}`}
85
- child={child}
86
- basePath={basePath}
87
- activeSectionId={activeSectionId}
88
- indent={indent + 12}
89
- onNavigate={onNavigate}
90
- isActiveFn={isActiveFn}
91
- />
92
- ))}
93
- </ul>
94
- </li>
95
- )
96
- }
97
-
98
- function ChildNode({ child, basePath, activeSectionId, indent, onNavigate, isActiveFn }) {
99
- if (child.children) {
100
- return (
101
- <GroupNode
102
- group={child}
103
- basePath={basePath}
104
- activeSectionId={activeSectionId}
105
- indent={indent}
106
- onNavigate={onNavigate}
107
- isActiveFn={isActiveFn}
108
- />
109
- )
110
- }
111
- if (child.to) {
112
- return <RouteLeaf leaf={child} indent={indent} onNavigate={onNavigate} isActiveFn={isActiveFn} />
113
- }
114
- if (child.id) {
115
- return (
116
- <SectionLeaf
117
- leaf={child}
118
- basePath={basePath}
119
- isActive={activeSectionId === child.id}
120
- indent={indent}
121
- onNavigate={onNavigate}
122
- />
123
- )
124
- }
125
- return null
126
- }
6
+ import useDragResize from './useDragResize'
7
+
8
+ /**
9
+ * SideNav — the grouped navigation rail.
10
+ *
11
+ * REPLICATED from the elder reference (kol-website/apps/brand
12
+ * src/components/framework/SideNav.jsx, 2026-08-09 user mandate: "there is a
13
+ * better version in kol-website apps:brand") — the brand app's evolved fork
14
+ * folded back into the package, generic seams kept.
15
+ *
16
+ * TWO LEVELS ONLY (elder ruling 2026-08-01). A category is a grouping LABEL —
17
+ * a disclosure <button> with a trailing caret, no route. A page is a route.
18
+ * The `#anchor` scroll-spied section layer is gone, and with it the
19
+ * group/section tree walkers. The ONE exception: a category with `to` and no
20
+ * `pages` (Home) renders as a plain link row — it has nothing to disclose.
21
+ *
22
+ * navTree: [{ id, label, icon, to?, pages?: [{ label, to }] }]
23
+ * (legacy `children` route leaves are read as `pages`; section anchors are not.)
24
+ *
25
+ * Collapse: BOTH affordances share one contract —
26
+ * `:root[data-sidenav="collapsed"]` + the `kol-sidenav` storage schema, owned
27
+ * by useDragResize. The round chip Button (elder A/B, closed 2026-08-01) is
28
+ * the one-click toggle; the grab edge (lobby: SideNavGrabResize, 2026-08-09)
29
+ * resizes, snap-collapses, and carries the keyboard path. Collapsed rows get
30
+ * a DS Tooltip — the glyph is otherwise the only thing naming them.
31
+ *
32
+ * `background` (2026-08-09, prop): `true` (default) paints the rail surface +
33
+ * right hairline; `false` renders chromeless so the rail can float over a
34
+ * consumer's media (the brand-hero model — the consumer owns the plane).
35
+ */
36
+
37
+ const pageBase =
38
+ 'kol-sidenav-link kol-helper-10 block relative py-[4px] pl-14 pr-6 no-underline transition-colors duration-150'
39
+
40
+ /* The active-dot pseudo-element is positioned by `--kol-sidenav-dot-left`
41
+ * (kol-components-atoms.css). The page row indents 3.5rem (pl-14); the dot
42
+ * sits 0.875rem inside that, in the gutter the icon column leaves. */
43
+ const pageStyle = { '--kol-sidenav-dot-left': '2.625rem' }
44
+ /* Category and page must not share a stop: category `emphasis`, page `strong`
45
+ * — two rungs of one ladder, so the tree reads as hierarchy (elder ruling). */
46
+ const pageCls = `${pageBase} text-strong hover:text-emphasis`
47
+ const pageActiveCls = `${pageBase} is-active`
48
+
49
+ /* Legacy DS trees carried `children` (groups + `#anchor` leaves). Route
50
+ * leaves survive as pages; anchor sections died with the two-level ruling. */
51
+ const pagesOf = (cat) => cat.pages ?? cat.children?.filter((c) => c.to) ?? []
127
52
 
128
53
  export default function SideNav({
129
54
  drawerOpen = false,
130
55
  onCloseDrawer,
131
56
  navTree = [],
132
- getActivePage,
133
57
  onNavigate,
134
- /* INERT since 0.9.2 — accepted so no call site breaks, but nothing reads
135
- * them; the collapse feature they drove has no CSS. See below. */
136
- collapsed: _collapsed,
137
- onToggle: _onToggle,
138
- collapsibleSections = false,
139
58
  isActive,
59
+ background = true,
140
60
  }) {
141
61
  const { pathname } = useLocation()
142
- const activePage = getActivePage?.(pathname)
143
- const sectionIds = activePage ? collectSectionIds(activePage) : []
144
- const onPageRoot = activePage && pathname === activePage.to
145
- const activeSectionId = useScrollSpy(onPageRoot ? sectionIds : [])
146
-
147
- /* The collapse state machine went with the button (finding #3). It stamped
148
- * `data-sidenav="collapsed"` on <html> and an `is-collapsed` class on the
149
- * aside, neither of which any stylesheet has read since 2026-07-29 — state
150
- * with no renderer is just a lie the component tells itself.
151
- *
152
- * `collapsed` / `onToggle` are still ACCEPTED so no consumer's call site
153
- * breaks; they are inert, and the prop docs say so. A consumer that wants a
154
- * collapsing rail owns it locally (kol-website's apps/brand does exactly
155
- * that) until the feature is deliberately rebuilt here. */
156
-
157
- /* Per-page section expand/collapse (opt-in via collapsibleSections).
158
- * The section containing the active route auto-expands on every
159
- * navigation; manual toggles persist for the session. */
160
- const [expandedSections, setExpandedSections] = useState(() =>
161
- collapsibleSections && activePage?.id ? { [activePage.id]: true } : {}
162
- )
163
-
164
- useEffect(() => {
165
- if (!collapsibleSections || !activePage?.id) return
166
- setExpandedSections((prev) => (prev[activePage.id] ? prev : { ...prev, [activePage.id]: true }))
167
- }, [collapsibleSections, activePage?.id, pathname])
168
-
169
- const toggleSection = (pageId) =>
170
- setExpandedSections((prev) => ({ ...prev, [pageId]: !prev[pageId] }))
62
+ const isPageActive = (to) => (isActive ? isActive(to) : pathname === to)
63
+ const isActiveCat = (cat) => pagesOf(cat).some((p) => isPageActive(p.to))
64
+
65
+ /* Which categories are disclosed. INDEPENDENT per category (elder ruling
66
+ * 2026-08-01) — arriving somewhere must not silently close wherever you
67
+ * were. Loads closed, in-memory only (elder ruling 2026-08-06). */
68
+ const [openCats, setOpenCats] = useState(() => new Set())
69
+ const toggleCat = (id) => setOpenCats((prev) => {
70
+ const next = new Set(prev)
71
+ next.has(id) ? next.delete(id) : next.add(id)
72
+ return next
73
+ })
74
+
75
+ /* One collapse contract, two gestures — the hook owns the stamp, the
76
+ * storage schema and the state; the chip Button and the grab edge both
77
+ * drive it. */
78
+ const asideRef = useRef(null)
79
+ const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef)
171
80
 
172
81
  return (
173
82
  <aside
174
- className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col border-r border-fg-08 z-20 bg-surface-primary${drawerOpen ? ' is-drawer-open' : ''}`}
83
+ ref={asideRef}
84
+ className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col z-20${background ? ' bg-surface-primary border-r border-fg-08' : ''}${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
175
85
  >
176
- {/* The collapse chevron was REMOVED 2026-07-30 (lobby ruling, finding
177
- * #3). Its CSS — `:root[data-sidenav="collapsed"]` — was deleted
178
- * 2026-07-29 when the manual-collapse feature was ruled dead, but the
179
- * button, the `data-sidenav` stamp and the `is-collapsed` class all
180
- * kept shipping, so every consumer of the package got a control that
181
- * did nothing at all. Nothing styles any of it (see the tombstone at
182
- * kol-framework.css:85). The surviving narrow mode is the responsive
183
- * ≤1024px rail, which needs no control. */}
86
+ {/* The elder A/B (closed 2026-08-01): the DS Button IS the collapse
87
+ * control. Offset is half the pinned sm square, centring it on the
88
+ * rail's edge — the DS hardcodes that square and exposes no token. */}
89
+ <Button
90
+ variant="primary"
91
+ size="sm"
92
+ radius="full"
93
+ iconOnly={collapsed ? 'chevron-right' : 'chevron-left'}
94
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
95
+ aria-expanded={!collapsed}
96
+ title={collapsed ? 'Expand' : 'Collapse'}
97
+ onClick={toggleCollapsed}
98
+ className="kol-sidenav-toggle absolute top-5 right-[-0.875rem] z-[2]"
99
+ />
184
100
 
185
101
  <div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
186
- <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
187
- {navTree.map((page) => {
188
- const isActivePage = activePage?.id === page.id
189
- const isSectionCollapsible = collapsibleSections && page.children?.length > 0
190
- const sectionExpanded = isSectionCollapsible && !!expandedSections[page.id]
191
- const hopLink = (
192
- <NavLink
193
- to={page.to}
194
- end={page.to === '/'}
195
- className={({ isActive: navActive }) =>
196
- `kol-sidenav-hop kol-helper-12 relative flex items-center gap-3 py-2 pr-10 pl-6 no-underline${(isActive ? isActive(page.to) : navActive) ? ' is-active' : ''}`
197
- }
198
- onClick={onNavigate}
199
- >
102
+ <nav aria-label="Sections">
103
+ <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
104
+ {navTree.map((cat) => {
105
+ const pages = pagesOf(cat)
106
+ const isOpen = openCats.has(cat.id)
107
+ const isLink = !pages.length && cat.to
108
+ const rowCls = 'kol-sidenav-hop kol-helper-12 text-emphasis w-full relative flex items-center gap-3 py-2 pr-10 pl-6 no-underline'
109
+ const glyph = (
200
110
  <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
201
- <Icon name={page.icon} size={16} />
111
+ <Icon name={cat.icon} size={16} />
202
112
  </span>
203
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{page.label}</span>
204
- {isSectionCollapsible && (
205
- <span className="kol-sidenav-hop-count kol-helper-10 text-meta shrink-0">
206
- ({page.children.length})
207
- </span>
208
- )}
209
- </NavLink>
210
- )
211
- return (
212
- <li key={page.id}>
213
- {isSectionCollapsible ? (
214
- <div className="relative">
215
- {hopLink}
216
- <button
217
- type="button"
218
- className="kol-sidenav-section-toggle absolute right-3 top-1/2 -translate-y-1/2 z-[1] inline-flex items-center justify-center w-5 h-5 p-0 bg-transparent border-0 cursor-pointer text-meta hover:text-emphasis transition-colors duration-150"
219
- aria-label={sectionExpanded ? `Collapse ${page.label}` : `Expand ${page.label}`}
220
- aria-expanded={sectionExpanded}
221
- onClick={() => toggleSection(page.id)}
222
- >
223
- <Icon
224
- name="chevron-right"
225
- size={12}
226
- className={`transition-transform duration-150${sectionExpanded ? ' rotate-90' : ''}`}
227
- />
228
- </button>
229
- </div>
230
- ) : (
231
- hopLink
232
- )}
233
-
234
- {page.children && (collapsibleSections ? sectionExpanded : isActivePage) && (
235
- <ul className="kol-sidenav-list mb-2 flex flex-col gap-2">
236
- {page.children.map((child, i) => (
237
- <ChildNode
238
- key={child.id ?? child.to ?? `g-${i}`}
239
- child={child}
240
- basePath={page.to}
241
- activeSectionId={activeSectionId}
242
- indent={56}
243
- onNavigate={onNavigate}
244
- isActiveFn={isActive}
245
- />
246
- ))}
247
- </ul>
248
- )}
249
- </li>
250
- )
251
- })}
252
- </ul>
253
-
254
- <div className="flex flex-col">
255
- <ThemeToggle variant="hop-bare" />
113
+ )
114
+ /* A category is a <button>, not a link — it has no route by
115
+ definition, and an <a href> would promise a page that does
116
+ not exist. Clicking it discloses its pages. */
117
+ const head = isLink ? (
118
+ <NavLink
119
+ to={cat.to}
120
+ end
121
+ className={({ isActive: navActive }) => `${rowCls}${(isActive ? isActive(cat.to) : navActive) ? ' is-active' : ''}`}
122
+ onClick={onNavigate}
123
+ >
124
+ {glyph}
125
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
126
+ </NavLink>
127
+ ) : (
128
+ <button
129
+ type="button"
130
+ className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${isActiveCat(cat) ? ' is-active' : ''}`}
131
+ aria-expanded={isOpen}
132
+ onClick={() => toggleCat(cat.id)}
133
+ >
134
+ {glyph}
135
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
136
+ <Icon
137
+ name="chevron-down"
138
+ size={12}
139
+ className={`kol-sidenav-hop-caret absolute top-3 right-3 transition-transform duration-150${isOpen ? '' : ' -rotate-90'}`}
140
+ />
141
+ </button>
142
+ )
143
+ return (
144
+ <li key={cat.id} className="relative">
145
+ {/* Collapsed rail hides the label — the tooltip is the only
146
+ thing naming the row. Portalled, so overflow can't clip it. */}
147
+ {collapsed ? (
148
+ <Tooltip label={cat.label} placement="right" triggerClassName="block">
149
+ {head}
150
+ </Tooltip>
151
+ ) : head}
152
+
153
+ {!isLink && isOpen && (
154
+ <ul className="kol-sidenav-list mb-2 flex flex-col gap-[2px]">
155
+ {pages.map((page) => (
156
+ <li key={page.to}>
157
+ <NavLink
158
+ to={page.to}
159
+ end
160
+ style={pageStyle}
161
+ className={({ isActive: navActive }) => ((isActive ? isActive(page.to) : navActive) ? pageActiveCls : pageCls)}
162
+ onClick={onNavigate}
163
+ >
164
+ {page.label}
165
+ </NavLink>
166
+ </li>
167
+ ))}
168
+ </ul>
169
+ )}
170
+ </li>
171
+ )
172
+ })}
173
+ </ul>
174
+ </nav>
175
+
176
+ {/* hop-bare carries its own gutter padding; collapsed falls back to
177
+ `icon` — a full-width label can't sit in the collapsed rail. */}
178
+ <div className="kol-sidenav-theme-slot flex">
179
+ <ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
256
180
  </div>
257
181
  </div>
258
182
 
@@ -263,10 +187,20 @@ export default function SideNav({
263
187
  rel="noopener"
264
188
  className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
265
189
  >
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>
190
+ {/* Collapsed rail shows the initial alone; the full wordmark
191
+ overflows it. Both live in one <a> so the link survives either
192
+ state (CSS swaps them on the data-sidenav stamp). */}
193
+ <span className="kol-sidenav-footer-mark text-body group-hover:text-emphasis">K</span>
194
+ <span className="kol-sidenav-footer-full">
195
+ <span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
196
+ <span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
197
+ </span>
268
198
  </a>
269
199
  </div>
200
+
201
+ {/* Grab edge — width/cursor/focus chrome in kol-framework.css
202
+ * (.kol-sidenav-grab); hidden below lg by the narrow-mode block. */}
203
+ <div className="kol-sidenav-grab absolute top-0 right-0 bottom-0 z-[1]" {...grabProps} />
270
204
  </aside>
271
205
  )
272
206
  }
package/src/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * @kol/framework — shared KOL app shell.
3
3
  *
4
4
  * Site chrome (sidenav, layout, theme toggle, footer, heroes) consumed across
5
- * apps. SideNav takes its nav data as props (navTree + getActivePage) so the
6
- * tree stays app-local. CSS lives in src/styles: kol-framework.css
5
+ * apps. SideNav takes its nav data as props (navTree) so the tree stays
6
+ * app-local. CSS lives in src/styles: kol-framework.css
7
7
  * and kol-brand-color.css.
8
8
  */
9
9
 
@@ -0,0 +1,186 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /* Grab-edge resize + snap-collapse for SideNav (lobby: SideNavGrabResize).
4
+ * Mechanics are kol-mirror's MirrorPlayground.jsx#L21-L76 — pointerdown on the
5
+ * strip, window-level move/up, body cursor + userSelect held for the drag,
6
+ * double-click resets. What the DS adds on top of that prior art:
7
+ * - live width goes to --kol-sidenav-w on :root, so the layout grid, the
8
+ * shell-header brand block and the aside all follow one number
9
+ * - dragging under --kol-sidenav-snap stamps :root[data-sidenav="collapsed"]
10
+ * (rendered again in kol-framework.css since 2026-08-09); back out re-expands
11
+ * - width + state survive reload. 'kol-sidenav' keeps the consumers' existing
12
+ * 'collapsed'|'expanded' schema; width lives under its own key so the
13
+ * brand app's reader of that value never breaks
14
+ * Keyboard path (a11y is not optional per the brief): the strip is a focusable
15
+ * separator — arrows resize by --kol-sidenav-step, ArrowLeft past the snap
16
+ * collapses, ArrowRight re-expands, Home resets to the stylesheet default.
17
+ * Every value the gesture needs is a --kol-sidenav-* token in kol-framework.css
18
+ * — no literals here, so a missing token (framework CSS not loaded) leaves the
19
+ * gesture inert rather than improvising numbers. */
20
+
21
+ const STATE_KEY = 'kol-sidenav'
22
+ const WIDTH_KEY = 'kol-sidenav-w'
23
+
24
+ const root = () => document.documentElement
25
+
26
+ /* Resolve a length token to px, or null when it is absent/unparsable. */
27
+ function readVarPx(name) {
28
+ const raw = getComputedStyle(root()).getPropertyValue(name).trim()
29
+ const n = parseFloat(raw)
30
+ if (!raw || Number.isNaN(n)) return null
31
+ return raw.endsWith('rem') ? n * parseFloat(getComputedStyle(root()).fontSize) : n
32
+ }
33
+
34
+ /* Imperative DOM writes — pointermove must never re-render the nav tree.
35
+ * React state syncs from the DOM at rest (drag end / key press / reset). */
36
+ const stampCollapsed = (on) => {
37
+ if (on) root().setAttribute('data-sidenav', 'collapsed')
38
+ else root().removeAttribute('data-sidenav')
39
+ }
40
+ const writeWidth = (px) => {
41
+ if (px == null) root().style.removeProperty('--kol-sidenav-w')
42
+ else root().style.setProperty('--kol-sidenav-w', `${px}px`)
43
+ }
44
+ const readBack = () => {
45
+ const inline = parseFloat(root().style.getPropertyValue('--kol-sidenav-w'))
46
+ return {
47
+ collapsed: root().getAttribute('data-sidenav') === 'collapsed',
48
+ widthPx: Number.isNaN(inline) ? null : inline,
49
+ }
50
+ }
51
+
52
+ export default function useDragResize(asideRef) {
53
+ const drag = useRef(null) // { startX, startW, snapPx, maxPx } during a drag
54
+ const defaultPx = useRef(null)
55
+ const collapsedPx = useRef(null)
56
+ const [collapsed, setCollapsed] = useState(false)
57
+ const [widthPx, setWidthPx] = useState(null) // null = stylesheet default
58
+
59
+ const syncAndPersist = () => {
60
+ const { collapsed: c, widthPx: w } = readBack()
61
+ setCollapsed(c)
62
+ setWidthPx(w)
63
+ try {
64
+ localStorage.setItem(STATE_KEY, c ? 'collapsed' : 'expanded')
65
+ if (w == null) localStorage.removeItem(WIDTH_KEY)
66
+ else localStorage.setItem(WIDTH_KEY, String(Math.round(w)))
67
+ } catch { /* storage blocked */ }
68
+ }
69
+
70
+ /* Boot: capture the stylesheet defaults BEFORE any inline override lands,
71
+ * then restore the persisted width/state. */
72
+ useEffect(() => {
73
+ defaultPx.current = readVarPx('--kol-sidenav-w')
74
+ collapsedPx.current = readVarPx('--kol-sidenav-w-collapsed')
75
+ let w = null
76
+ let c = false
77
+ try {
78
+ w = parseFloat(localStorage.getItem(WIDTH_KEY)) || null
79
+ c = localStorage.getItem(STATE_KEY) === 'collapsed'
80
+ } catch { /* storage blocked */ }
81
+ if (w) { writeWidth(w); setWidthPx(w) }
82
+ if (c) { stampCollapsed(true); setCollapsed(true) }
83
+ }, [])
84
+
85
+ useEffect(() => {
86
+ const onMove = (e) => {
87
+ if (!drag.current) return
88
+ const { startX, startW, snapPx, maxPx } = drag.current
89
+ const next = startW + (e.clientX - startX)
90
+ if (next < snapPx) {
91
+ stampCollapsed(true)
92
+ } else {
93
+ stampCollapsed(false)
94
+ writeWidth(Math.min(next, maxPx))
95
+ }
96
+ }
97
+ const onUp = () => {
98
+ if (!drag.current) return
99
+ drag.current = null
100
+ root().removeAttribute('data-sidenav-dragging')
101
+ document.body.style.cursor = ''
102
+ document.body.style.userSelect = ''
103
+ syncAndPersist()
104
+ }
105
+ window.addEventListener('pointermove', onMove)
106
+ window.addEventListener('pointerup', onUp)
107
+ return () => {
108
+ window.removeEventListener('pointermove', onMove)
109
+ window.removeEventListener('pointerup', onUp)
110
+ }
111
+ }, [])
112
+
113
+ const onPointerDown = (e) => {
114
+ const snapPx = readVarPx('--kol-sidenav-snap')
115
+ if (defaultPx.current == null || snapPx == null) return // tokens absent → inert
116
+ e.preventDefault()
117
+ drag.current = {
118
+ startX: e.clientX,
119
+ startW: asideRef.current?.getBoundingClientRect().width ?? defaultPx.current,
120
+ snapPx,
121
+ /* mirror's ceiling (default × 3), resolved from the token not hardcoded */
122
+ maxPx: defaultPx.current * 3,
123
+ }
124
+ /* the grid's 180ms grid-template-columns ease would trail the pointer —
125
+ * kol-framework.css suspends it while this attribute is stamped */
126
+ root().setAttribute('data-sidenav-dragging', '')
127
+ document.body.style.cursor = 'col-resize'
128
+ document.body.style.userSelect = 'none'
129
+ }
130
+
131
+ const onDoubleClick = () => {
132
+ stampCollapsed(false)
133
+ writeWidth(null)
134
+ syncAndPersist()
135
+ }
136
+
137
+ /* One-click toggle for the chip Button (elder A/B, folded back 2026-08-09)
138
+ * — same stamp, same storage schema, so the chip and the grab edge can
139
+ * never disagree about the state. */
140
+ const toggleCollapsed = () => {
141
+ const { collapsed: c } = readBack()
142
+ stampCollapsed(!c)
143
+ syncAndPersist()
144
+ }
145
+
146
+ const onKeyDown = (e) => {
147
+ const snapPx = readVarPx('--kol-sidenav-snap')
148
+ const stepPx = readVarPx('--kol-sidenav-step')
149
+ if (defaultPx.current == null || snapPx == null || stepPx == null) return
150
+ const { collapsed: c, widthPx: w } = readBack()
151
+ const current = w ?? defaultPx.current
152
+ if (e.key === 'ArrowRight') {
153
+ e.preventDefault()
154
+ if (c) stampCollapsed(false)
155
+ else writeWidth(Math.min(current + stepPx, defaultPx.current * 3))
156
+ syncAndPersist()
157
+ } else if (e.key === 'ArrowLeft') {
158
+ e.preventDefault()
159
+ if (c) return
160
+ const next = current - stepPx
161
+ if (next < snapPx) stampCollapsed(true)
162
+ else writeWidth(next)
163
+ syncAndPersist()
164
+ } else if (e.key === 'Home') {
165
+ e.preventDefault()
166
+ onDoubleClick()
167
+ }
168
+ }
169
+
170
+ return {
171
+ collapsed,
172
+ toggleCollapsed,
173
+ grabProps: {
174
+ role: 'separator',
175
+ 'aria-orientation': 'vertical',
176
+ 'aria-label': 'Resize navigation',
177
+ 'aria-valuenow': Math.round(collapsed ? collapsedPx.current : (widthPx ?? defaultPx.current)) || undefined,
178
+ 'aria-valuemin': collapsedPx.current == null ? undefined : Math.round(collapsedPx.current),
179
+ 'aria-valuemax': defaultPx.current == null ? undefined : Math.round(defaultPx.current * 3),
180
+ tabIndex: 0,
181
+ onPointerDown,
182
+ onDoubleClick,
183
+ onKeyDown,
184
+ },
185
+ }
186
+ }