@kolkrabbi/kol-framework 0.22.0 → 0.24.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
@@ -150,8 +150,37 @@ body {
150
150
  gap: 0.125rem;
151
151
  margin-bottom: 0.5rem;
152
152
  }
153
+ /* A nested route GROUP's header (sidenav-nested-groups, kol-studio
154
+ * 2026-08-26): a non-routing label sitting where a leaf sits, its rows one
155
+ * step further in. Type and colour ride the element as utilities
156
+ * (kol-helper-10; text-subtle, text-emphasis when a leaf beneath it is the
157
+ * route); the box lives here. No text-transform — the retired fork's
158
+ * `uppercase` does not return (type law: only the display voice carries one). */
159
+ .kol-sidenav-group {
160
+ display: block;
161
+ padding: 0.25rem 1.5rem 0.25rem 3.5rem;
162
+ }
153
163
  .kol-sidenav-footer { padding-inline: 1.5rem 1rem; }
154
164
 
165
+ /* The aside's OWN box (SideNavMobilePosition, kol-website 2026-08-25) — the
166
+ * hop's law above, one element up. `sticky top-0 self-start h-dvh flex
167
+ * flex-col z-20` rode the className, and a utility outranks every rule in
168
+ * this layer — so the 767px drawer block below (`position: fixed`) never won:
169
+ * the aside stayed IN FLOW, the one-column grid handed it an 852px row, and
170
+ * every consumer page opened one viewport down on phones (probe: aside
171
+ * `sticky`, content top=852 at 393; the consumer's stopgap was one media rule
172
+ * re-stating `fixed`). Declared here, the drawer rule beats it by source
173
+ * order — which is how it was written to work. */
174
+ .kol-sidenav {
175
+ position: sticky;
176
+ top: 0;
177
+ align-self: start;
178
+ height: 100dvh;
179
+ display: flex;
180
+ flex-direction: column;
181
+ z-index: var(--kol-z-sticky);
182
+ }
183
+
155
184
  /* :root[data-sidenav="collapsed"] — the manual collapse, rebuilt DELIBERATELY
156
185
  * 2026-08-09 (lobby: SideNavGrabResize) after the 2026-07-29 removal of the
157
186
  * renderer-less stamp. SideNav's grab edge (useDragResize.js) owns the state
@@ -894,7 +923,15 @@ body {
894
923
  }
895
924
  }
896
925
 
897
- /* Tab strip — horizontally scrollable, scrollbar hidden. */
926
+ /* Tab strip — horizontally scrollable, scrollbar hidden.
927
+ * Snaps to a tab's start on a flick (WorkshopShellMobile, kol-website
928
+ * 2026-08-25): the strip measured 613px of tabs in a 345px viewport and did
929
+ * scroll, but a swipe could settle on half a label. `proximity`, not
930
+ * `mandatory` — the last tabs can never reach a start position, and mandatory
931
+ * would refuse to show them. ShellHeader.jsx scrolls the active tab into view
932
+ * on load, to the same start edge, so the two never fight. NO edge fade: the
933
+ * 2026-07-28 ruling bans scroll-edge paint (the table wrapper's cover
934
+ * gradients died for it 2026-08-09). */
898
935
  .kol-shell-header-tabs {
899
936
  display: flex;
900
937
  flex-wrap: nowrap;
@@ -903,6 +940,7 @@ body {
903
940
  padding-top: 8px;
904
941
  scrollbar-width: none;
905
942
  -ms-overflow-style: none;
943
+ scroll-snap-type: x proximity;
906
944
  }
907
945
 
908
946
  .kol-shell-header-tabs::-webkit-scrollbar {
@@ -931,6 +969,7 @@ body {
931
969
  border-bottom: 2px solid transparent;
932
970
  transition: color 0.2s ease, border-color 0.2s ease;
933
971
  text-decoration: none;
972
+ scroll-snap-align: start;
934
973
  }
935
974
 
936
975
  .kol-shell-header-tab:hover {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.22.0",
3
+ "version": "0.24.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",
@@ -28,8 +28,8 @@
28
28
  "app-shell"
29
29
  ],
30
30
  "dependencies": {
31
- "@kolkrabbi/kol-component": "^0.46.0",
32
- "@kolkrabbi/kol-icons": "^0.17.0"
31
+ "@kolkrabbi/kol-component": "^0.68.1",
32
+ "@kolkrabbi/kol-icons": "^0.18.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^18.3.0 || ^19.0.0",
@@ -1,3 +1,4 @@
1
+ import { useLayoutEffect, useRef } from 'react'
1
2
  import { Icon } from '@kolkrabbi/kol-icons'
2
3
  import { IconFrame, SearchInput, Tooltip, glyphSize } from '@kolkrabbi/kol-component'
3
4
  import ThemeToggle from './ThemeToggle.jsx'
@@ -89,6 +90,26 @@ export default function ShellHeader({
89
90
  }) {
90
91
  const hasTabRow = nav.length > 0 || Boolean(search) || Boolean(onNavToggle) || Boolean(onTocToggle)
91
92
 
93
+ /* The current tab must be ON SCREEN (WorkshopShellMobile, kol-website
94
+ * 2026-08-25). The strip scrolls (.kol-shell-header-tabs) — but on a phone
95
+ * the active tab measured x=347–445 in a 345px strip: "Dashboard" was the
96
+ * page you were on and the one tab you could not see. Scrolled to the
97
+ * strip's start edge (the snap position), and ONLY when it is out of view,
98
+ * so a wide viewport never moves. Keyed on the active href, not `nav` —
99
+ * consumers rebuild that array every render. Layout effect: before paint,
100
+ * so a route change never flashes the wrong tab. */
101
+ const tabsRef = useRef(null)
102
+ const activeHref = nav.find((item) => (isActive ? isActive(item.href) : false))?.href
103
+ useLayoutEffect(() => {
104
+ const strip = tabsRef.current
105
+ const tab = strip?.querySelector('[aria-current="page"]')
106
+ if (!strip || !tab) return
107
+ const s = strip.getBoundingClientRect()
108
+ const t = tab.getBoundingClientRect()
109
+ if (t.left >= s.left && t.right <= s.right) return
110
+ strip.scrollLeft += t.left - s.left
111
+ }, [activeHref])
112
+
92
113
  return (
93
114
  <header className={`kol-shell-header sticky top-0 z-50 shrink-0 bg-surface-primary ${className}`.trim()}>
94
115
  {/* Row 1: brand block + controls */}
@@ -139,7 +160,7 @@ export default function ShellHeader({
139
160
  {hasTabRow && (
140
161
  <div className="border-b border-fg-08">
141
162
  <div className="w-full" style={{ paddingInline: 'var(--kol-pad-chrome-x)' }}>
142
- <div className="kol-shell-header-tabs">
163
+ <div className="kol-shell-header-tabs" ref={tabsRef}>
143
164
  <nav className="flex flex-1 gap-6" aria-label="Sections">
144
165
  {nav.map((item) => {
145
166
  const active = isActive ? isActive(item.href) : false
package/src/SideNav.jsx CHANGED
@@ -13,14 +13,26 @@ import useDragResize from './useDragResize'
13
13
  * better version in kol-website apps:brand") — the brand app's evolved fork
14
14
  * folded back into the package, generic seams kept.
15
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.
16
+ * A category is a grouping LABEL — a disclosure <button> with a trailing
17
+ * caret, no route. A page is a route. The ONE exception: a category with `to`
18
+ * and no `pages` (Home) renders as a plain link row — it has nothing to
19
+ * disclose.
21
20
  *
22
- * navTree: [{ id, label, icon, to?, pages?: [{ label, to }] }]
23
- * (legacy `children` route leaves are read as `pages`; section anchors are not.)
21
+ * The 2026-08-01 "TWO LEVELS ONLY" ruling was the BRAND APP's, about its
22
+ * scroll-spied `#anchor` section layer — that layer stays dead (an `{ id }`
23
+ * leaf is dropped). It was inscribed here as a law for every consumer, and
24
+ * kol-studio lost two groups and six routes from its rail on adopting 0.23.0.
25
+ * User, 2026-08-26, verbatim: "ok that is such a problem, sometimes Im
26
+ * talking about a specific thing and it gets applied as a literal fit-all
27
+ * rule, which isnt the case. its very hard to work in such uncertainty."
28
+ * Re-scoped (sidenav-nested-groups, 0.24.0): nested ROUTE groups are
29
+ * supported. A `{ label, children }` node inside a category renders as a
30
+ * non-routing group header with its rows indented one step under it,
31
+ * recursively. The tree shape is the opt-in — there is no prop — and a tree
32
+ * with no group nodes renders byte-for-byte as before.
33
+ *
34
+ * navTree: [{ id, label, icon, to?, pages?: [{ label, to } | { label, children }] }]
35
+ * (legacy `children` on a category is read as `pages`; section anchors are not.)
24
36
  *
25
37
  * Collapse: the pill-marked grab edge is THE single control (user build
26
38
  * order 2026-08-09, completing SideNavGrabResize — the chip Button is gone
@@ -47,9 +59,17 @@ const pageStyle = { '--kol-sidenav-dot-left': '2.625rem' }
47
59
  const pageCls = `${pageBase} text-strong hover:text-emphasis`
48
60
  const pageActiveCls = `${pageBase} is-active`
49
61
 
50
- /* Legacy DS trees carried `children` (groups + `#anchor` leaves). Route
51
- * leaves survive as pages; anchor sections died with the two-level ruling. */
52
- const pagesOf = (cat) => cat.pages ?? cat.children?.filter((c) => c.to) ?? []
62
+ /* A category's rows: route leaves (`to`) and route groups (`children`).
63
+ * `#anchor` leaves (`id` only) are dropped — that layer died with the brand
64
+ * app's 2026-08-01 ruling and is not reopened here. */
65
+ const rowsOf = (nodes) => nodes.filter((c) => c.to || c.children)
66
+ const pagesOf = (cat) => rowsOf(cat.pages ?? cat.children ?? [])
67
+ /* Every route leaf under a set of rows, through any depth of groups. */
68
+ const leavesOf = (rows) => rows.flatMap((r) => (r.children ? leavesOf(rowsOf(r.children)) : [r]))
69
+ /* A nested row sits one --kol-spacing-3 step further in per depth. Inline,
70
+ * not a rule: `pl-14` rides the leaf as a utility and outranks anything in
71
+ * this layer. Depth 0 carries no indent so a flat tree renders unchanged. */
72
+ const indentOf = (depth) => `calc(3.5rem + ${depth} * var(--kol-spacing-3))`
53
73
 
54
74
  export default function SideNav({
55
75
  drawerOpen = false,
@@ -61,7 +81,7 @@ export default function SideNav({
61
81
  }) {
62
82
  const { pathname } = useLocation()
63
83
  const isPageActive = (to) => (isActive ? isActive(to) : pathname === to)
64
- const isActiveCat = (cat) => pagesOf(cat).some((p) => isPageActive(p.to))
84
+ const isActiveCat = (cat) => leavesOf(pagesOf(cat)).some((p) => isPageActive(p.to))
65
85
 
66
86
  /* Every navigation closes the mobile drawer — the prop sat accepted-but-
67
87
  * unread until the kol-website session flagged it (2026-08-09) — and, from
@@ -90,10 +110,49 @@ export default function SideNav({
90
110
  const asideRef = useRef(null)
91
111
  const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef)
92
112
 
113
+ /* Rows under a category: a leaf is a NavLink; a group is a non-routing
114
+ * header over its own rows, one step further in (recursive). A group lights
115
+ * up the way a category does — when one of its leaves is the current route. */
116
+ const renderRows = (rows, depth) => rows.map((row, i) => {
117
+ if (row.children) {
118
+ const lit = leavesOf(rowsOf(row.children)).some((l) => isPageActive(l.to))
119
+ return (
120
+ <li key={row.id ?? `${row.label}-${i}`}>
121
+ <div
122
+ className={`kol-sidenav-group kol-helper-10${lit ? ' text-emphasis' : ' text-subtle'}`}
123
+ style={depth ? { paddingLeft: indentOf(depth) } : undefined}
124
+ >
125
+ {row.label}
126
+ </div>
127
+ <ul className="kol-sidenav-list">{renderRows(rowsOf(row.children), depth + 1)}</ul>
128
+ </li>
129
+ )
130
+ }
131
+ return (
132
+ <li key={row.to}>
133
+ <NavLink
134
+ to={row.to}
135
+ end
136
+ /* the dot keeps its 0.875rem lead on the text at every depth */
137
+ style={depth ? { paddingLeft: indentOf(depth), '--kol-sidenav-dot-left': `calc(2.625rem + ${depth} * var(--kol-spacing-3))` } : pageStyle}
138
+ className={({ isActive: navActive }) => ((isActive ? isActive(row.to) : navActive) ? pageActiveCls : pageCls)}
139
+ onClick={handleNavigate}
140
+ >
141
+ {row.label}
142
+ </NavLink>
143
+ </li>
144
+ )
145
+ })
146
+
93
147
  return (
94
148
  <aside
95
149
  ref={asideRef}
96
- 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' : ''}`}
150
+ /* Box utilities deliberately absent (SideNavMobilePosition, 2026-08-25):
151
+ * the aside's position/height/stack live in .kol-sidenav
152
+ * (kol-framework.css). `sticky … h-dvh z-20` here outranked the 767px
153
+ * drawer rule's `position: fixed`, so every consumer page opened one
154
+ * viewport down on phones. Only colour utilities ride the element. */
155
+ className={`kol-sidenav${background ? ' bg-surface-primary border-r border-fg-08' : ''}${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
97
156
  >
98
157
  {/* NO chip Button in either state (user build order 2026-08-09 — "MAKE
99
158
  * IT"; supersedes the elder A/B): the pill-marked grab edge below is
@@ -169,21 +228,7 @@ export default function SideNav({
169
228
  ) : head}
170
229
 
171
230
  {!isLink && isOpen && (
172
- <ul className="kol-sidenav-list">
173
- {pages.map((page) => (
174
- <li key={page.to}>
175
- <NavLink
176
- to={page.to}
177
- end
178
- style={pageStyle}
179
- className={({ isActive: navActive }) => ((isActive ? isActive(page.to) : navActive) ? pageActiveCls : pageCls)}
180
- onClick={handleNavigate}
181
- >
182
- {page.label}
183
- </NavLink>
184
- </li>
185
- ))}
186
- </ul>
231
+ <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>
187
232
  )}
188
233
  </li>
189
234
  )