@kolkrabbi/kol-framework 0.14.0 → 0.15.1

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
@@ -124,6 +124,17 @@ body {
124
124
  :root[data-sidenav="collapsed"] .kol-sidenav-body { display: none; }
125
125
  :root[data-sidenav="collapsed"] .kol-sidenav-hop-count,
126
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; }
127
138
 
128
139
  /* The grab edge itself — invisible by spec (col-resize cursor is the pointer
129
140
  * affordance, the focus ring the keyboard one — recipe from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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,256 +1,182 @@
1
- import { useEffect, useRef, 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
6
  import useDragResize from './useDragResize'
6
- import { useScrollSpy } from '@kolkrabbi/kol-component'
7
7
 
8
- /* Walk the active page's children and return all leaf section ids (for scroll-spy). */
9
- function collectSectionIds(node) {
10
- if (!node?.children) return []
11
- const ids = []
12
- const walk = (children) => {
13
- for (const c of children) {
14
- if (c.id && !c.to) ids.push(c.id)
15
- if (c.children) walk(c.children)
16
- }
17
- }
18
- walk(node.children)
19
- return ids
20
- }
21
-
22
- const linkBase = 'kol-sidenav-link kol-helper-10 block relative py-[4px] no-underline transition-colors duration-150'
23
- const linkCls = `${linkBase} text-body hover:text-emphasis`
24
- const linkActiveCls = `${linkBase} is-active`
25
-
26
- /* Walk the children tree; return true if any leaf matches activeSectionId. */
27
- function hasActiveDescendant(children, activeSectionId) {
28
- if (!activeSectionId) return false
29
- for (const c of children ?? []) {
30
- if (c.id === activeSectionId) return true
31
- if (c.children && hasActiveDescendant(c.children, activeSectionId)) return true
32
- }
33
- return false
34
- }
35
-
36
- const leafStyle = (indent) => ({
37
- paddingLeft: indent,
38
- '--kol-sidenav-dot-left': `${indent - 14}px`,
39
- })
40
-
41
- function SectionLeaf({ leaf, basePath, isActive, indent, onNavigate }) {
42
- return (
43
- <li>
44
- <Link
45
- to={`${basePath}#${leaf.id}`}
46
- className={isActive ? linkActiveCls : linkCls}
47
- style={leafStyle(indent)}
48
- onClick={onNavigate}
49
- >
50
- {leaf.label}
51
- </Link>
52
- </li>
53
- )
54
- }
55
-
56
- function RouteLeaf({ leaf, indent, onNavigate, isActiveFn }) {
57
- return (
58
- <li>
59
- <NavLink
60
- to={leaf.to}
61
- end
62
- className={({ isActive }) => ((isActiveFn ? isActiveFn(leaf.to) : isActive) ? linkActiveCls : linkCls)}
63
- style={leafStyle(indent)}
64
- onClick={onNavigate}
65
- >
66
- {leaf.label}
67
- </NavLink>
68
- </li>
69
- )
70
- }
71
-
72
- function GroupNode({ group, basePath, activeSectionId, indent, onNavigate, isActiveFn }) {
73
- const isAncestor = hasActiveDescendant(group.children, activeSectionId)
74
- return (
75
- <li>
76
- <div
77
- className={`kol-sidenav-group kol-helper-10 ${isAncestor ? 'text-emphasis' : 'text-subtle'}`}
78
- style={{ paddingLeft: indent }}
79
- >
80
- {group.label}
81
- </div>
82
- <ul className="kol-sidenav-list">
83
- {group.children.map((child, i) => (
84
- <ChildNode
85
- key={child.id ?? child.to ?? `g-${i}`}
86
- child={child}
87
- basePath={basePath}
88
- activeSectionId={activeSectionId}
89
- indent={indent + 12}
90
- onNavigate={onNavigate}
91
- isActiveFn={isActiveFn}
92
- />
93
- ))}
94
- </ul>
95
- </li>
96
- )
97
- }
98
-
99
- function ChildNode({ child, basePath, activeSectionId, indent, onNavigate, isActiveFn }) {
100
- if (child.children) {
101
- return (
102
- <GroupNode
103
- group={child}
104
- basePath={basePath}
105
- activeSectionId={activeSectionId}
106
- indent={indent}
107
- onNavigate={onNavigate}
108
- isActiveFn={isActiveFn}
109
- />
110
- )
111
- }
112
- if (child.to) {
113
- return <RouteLeaf leaf={child} indent={indent} onNavigate={onNavigate} isActiveFn={isActiveFn} />
114
- }
115
- if (child.id) {
116
- return (
117
- <SectionLeaf
118
- leaf={child}
119
- basePath={basePath}
120
- isActive={activeSectionId === child.id}
121
- indent={indent}
122
- onNavigate={onNavigate}
123
- />
124
- )
125
- }
126
- return null
127
- }
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) ?? []
128
52
 
129
53
  export default function SideNav({
130
54
  drawerOpen = false,
131
55
  onCloseDrawer,
132
56
  navTree = [],
133
- getActivePage,
134
57
  onNavigate,
135
- /* INERT since 0.9.2 — accepted so no call site breaks, but nothing reads
136
- * them; the collapse feature they drove has no CSS. See below. */
137
- collapsed: _collapsed,
138
- onToggle: _onToggle,
139
- collapsibleSections = false,
140
58
  isActive,
59
+ background = true,
141
60
  }) {
142
61
  const { pathname } = useLocation()
143
- const activePage = getActivePage?.(pathname)
144
- const sectionIds = activePage ? collectSectionIds(activePage) : []
145
- const onPageRoot = activePage && pathname === activePage.to
146
- const activeSectionId = useScrollSpy(onPageRoot ? sectionIds : [])
147
-
148
- /* Collapse, rebuilt DELIBERATELY 2026-08-09 (lobby: SideNavGrabResize) —
149
- * the 2026-07-29/30 removals killed a stamp with no renderer; the grab edge
150
- * below reinstates BOTH halves. useDragResize owns the state, stamps
151
- * :root[data-sidenav="collapsed"], and kol-framework.css renders it again.
152
- * `collapsed` / `onToggle` remain ACCEPTED-but-inert — the gesture is
153
- * self-contained, so call sites still don't break. */
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. */
154
78
  const asideRef = useRef(null)
155
- const { grabProps } = useDragResize(asideRef)
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] }))
79
+ const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef)
171
80
 
172
81
  return (
173
82
  <aside
174
83
  ref={asideRef}
175
- 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' : ''}`}
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' : ''}`}
176
85
  >
177
- {/* The collapse chevron Button was REMOVED 2026-07-30 (lobby ruling,
178
- * finding #3) as a control with no renderer. The grab edge at the end
179
- * of this aside is its deliberate 2026-08-09 replacement — pointer-drag
180
- * resizes, drag under --kol-sidenav-snap collapses, double-click and
181
- * Home reset, arrows resize from the keyboard. See useDragResize.js. */}
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
+ />
182
100
 
183
101
  <div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
184
- <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
185
- {navTree.map((page) => {
186
- const isActivePage = activePage?.id === page.id
187
- const isSectionCollapsible = collapsibleSections && page.children?.length > 0
188
- const sectionExpanded = isSectionCollapsible && !!expandedSections[page.id]
189
- const hopLink = (
190
- <NavLink
191
- to={page.to}
192
- end={page.to === '/'}
193
- className={({ isActive: navActive }) =>
194
- `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' : ''}`
195
- }
196
- onClick={onNavigate}
197
- >
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 = (
198
110
  <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
199
- <Icon name={page.icon} size={16} />
111
+ <Icon name={cat.icon} size={16} />
200
112
  </span>
201
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{page.label}</span>
202
- {isSectionCollapsible && (
203
- <span className="kol-sidenav-hop-count kol-helper-10 text-meta shrink-0">
204
- ({page.children.length})
205
- </span>
206
- )}
207
- </NavLink>
208
- )
209
- return (
210
- <li key={page.id}>
211
- {isSectionCollapsible ? (
212
- <div className="relative">
213
- {hopLink}
214
- <button
215
- type="button"
216
- 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"
217
- aria-label={sectionExpanded ? `Collapse ${page.label}` : `Expand ${page.label}`}
218
- aria-expanded={sectionExpanded}
219
- onClick={() => toggleSection(page.id)}
220
- >
221
- <Icon
222
- name="chevron-right"
223
- size={12}
224
- className={`transition-transform duration-150${sectionExpanded ? ' rotate-90' : ''}`}
225
- />
226
- </button>
227
- </div>
228
- ) : (
229
- hopLink
230
- )}
231
-
232
- {page.children && (collapsibleSections ? sectionExpanded : isActivePage) && (
233
- <ul className="kol-sidenav-list mb-2 flex flex-col gap-2">
234
- {page.children.map((child, i) => (
235
- <ChildNode
236
- key={child.id ?? child.to ?? `g-${i}`}
237
- child={child}
238
- basePath={page.to}
239
- activeSectionId={activeSectionId}
240
- indent={56}
241
- onNavigate={onNavigate}
242
- isActiveFn={isActive}
243
- />
244
- ))}
245
- </ul>
246
- )}
247
- </li>
248
- )
249
- })}
250
- </ul>
251
-
252
- <div className="flex flex-col">
253
- <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" />
254
180
  </div>
255
181
  </div>
256
182
 
@@ -261,8 +187,14 @@ export default function SideNav({
261
187
  rel="noopener"
262
188
  className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
263
189
  >
264
- <span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
265
- <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>
266
198
  </a>
267
199
  </div>
268
200
 
package/src/index.js CHANGED
@@ -2,14 +2,15 @@
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
 
10
10
  export { default as AppShell } from './AppShell.jsx'
11
11
  export { ShellTocContext, ShellTocCollapsedContext } from './AppShell.jsx'
12
12
  export { default as SideNav } from './SideNav.jsx'
13
+ export { default as useDragResize } from './useDragResize.js'
13
14
  export { default as ShellHeader, HEADER_ICON } from './ShellHeader.jsx'
14
15
  export { default as ThemeToggle } from './ThemeToggle.jsx'
15
16
  export { useTheme, applyTheme, getInitialTheme, THEME_STORAGE_KEY } from './theme.js'
@@ -134,6 +134,15 @@ export default function useDragResize(asideRef) {
134
134
  syncAndPersist()
135
135
  }
136
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
+
137
146
  const onKeyDown = (e) => {
138
147
  const snapPx = readVarPx('--kol-sidenav-snap')
139
148
  const stepPx = readVarPx('--kol-sidenav-step')
@@ -160,6 +169,7 @@ export default function useDragResize(asideRef) {
160
169
 
161
170
  return {
162
171
  collapsed,
172
+ toggleCollapsed,
163
173
  grabProps: {
164
174
  role: 'separator',
165
175
  'aria-orientation': 'vertical',