@kolkrabbi/kol-framework 0.53.0 → 0.55.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,7 +160,20 @@ body {
160
160
  gap: 0.75rem;
161
161
  width: 100%;
162
162
  position: relative;
163
- padding: 0.5rem 2.5rem 0.5rem 1.5rem;
163
+ /* the gutter is THE CHROME INSET (`--kol-pad-chrome-x`, 05-layout-systems: 20 on a phone, 24 from 768) — the rail
164
+ * carried a literal 1.5rem of its own until 2026-10-24, right on the desk and wrong in the phone drawer */
165
+ padding: 0.5rem 2.5rem 0.5rem var(--kol-pad-chrome-x);
166
+ /* THE CLIP MODEL (user 2026-10-24: "some shifting and not very smooth animation between collapsed and
167
+ * expanded"): the collapsed row is the same row with its padding and gap eased on the grid's own 180ms
168
+ * clock, the label fading under the rail's edge — not a second layout that snaps in while the column is
169
+ * still moving. The glyph slides from the gutter to the narrow column's centre; nothing jumps. */
170
+ white-space: nowrap;
171
+ overflow: hidden;
172
+ transition: padding 180ms ease, gap 180ms ease;
173
+ }
174
+ .kol-sidenav-hop-label {
175
+ overflow: hidden;
176
+ transition: opacity 120ms ease;
164
177
  }
165
178
  /* THE HEADER SLOT (RailLogomarkAtTop, kol-fxr 2026-08-28 — user, on the
166
179
  * raven in the footer: "why did you move the logo from top to bottom?"): the
@@ -175,14 +188,25 @@ body {
175
188
  align-items: center;
176
189
  gap: 0.75rem;
177
190
  width: 100%;
178
- padding: 1rem 2.5rem 0 1.5rem;
191
+ padding: 1rem 2.5rem 0 var(--kol-pad-chrome-x);
192
+ white-space: nowrap;
193
+ overflow: hidden;
194
+ transition: padding 180ms ease, gap 180ms ease;
179
195
  }
180
196
  /* the caret rides a PLAIN span (Icon stamps inline-flex on its own element,
181
197
  * which would outrank the collapsed display:none) */
198
+ /* the caret's box lives HERE, not as a utility on the element (2026-10-24: the foldernote's caret is a <button>, and
199
+ * an `inline-flex` utility on it outranked the collapsed rule's display:none, so the chevron showed on the narrow
200
+ * rail) — a 12px line box for the span and the button alike */
182
201
  .kol-sidenav-hop-caret {
183
202
  position: absolute;
184
203
  top: 0.75rem;
185
204
  right: 0.75rem;
205
+ display: inline-flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ line-height: 1;
209
+ transition: transform 150ms ease, opacity 120ms ease, visibility 0s linear 120ms;
186
210
  }
187
211
  .kol-sidenav-list {
188
212
  display: flex;
@@ -194,11 +218,13 @@ body {
194
218
  * 2026-08-26): a non-routing label sitting where a leaf sits, its rows one
195
219
  * step further in. Type and color ride the element as utilities
196
220
  * (kol-helper-10; text-subtle, text-emphasis when a leaf beneath it is the
197
- * route); the box lives here. No text-transform — the retired fork's
198
- * `uppercase` does not return (type law: only the display voice carries one). */
221
+ * route); the box lives here. The `uppercase` rides the element as a utility
222
+ * too (user ruling 2026-10-24 on the another-creation reference, reversing the
223
+ * 2026-08-26 line that kept it out). */
199
224
  .kol-sidenav-group {
200
225
  display: block;
201
- padding: 0.25rem 1.5rem 0.25rem 3.5rem;
226
+ /* one step in from the gutter: the inset + the icon column (20) + the gap (12) */
227
+ padding: 0.25rem var(--kol-pad-chrome-x) 0.25rem calc(var(--kol-pad-chrome-x) + 2rem);
202
228
  }
203
229
  /* THE COLLAPSE RULE (WorkspaceSidebarGeometry, kol-fxr 2026-08-27 — user: "I
204
230
  * would rather all use the same geometry layout"): a row that can survive
@@ -208,9 +234,9 @@ body {
208
234
  * thing narrowing (the brand sidebar's model) instead of rows vanishing. */
209
235
  .kol-sidenav-section {
210
236
  display: block;
211
- padding: 0.5rem 1.5rem 0.25rem 1.5rem;
237
+ padding: 0.5rem var(--kol-pad-chrome-x) 0.25rem var(--kol-pad-chrome-x);
212
238
  }
213
- .kol-sidenav-footer { padding-inline: 1.5rem 1rem; }
239
+ .kol-sidenav-footer { padding-inline: var(--kol-pad-chrome-x) 1rem; }
214
240
 
215
241
  /* The aside's OWN box (SideNavMobilePosition, kol-website 2026-08-25) — the
216
242
  * hop's law above, one element up. `sticky top-0 self-start h-dvh flex
@@ -238,9 +264,11 @@ body {
238
264
  * rail — one look, two triggers. */
239
265
  :root[data-sidenav="collapsed"] .kol-brand-layout { grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr); }
240
266
  :root[data-sidenav="collapsed"] .kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
241
- :root[data-sidenav="collapsed"] .kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
242
- :root[data-sidenav="collapsed"] .kol-sidenav-header { padding: 1rem 0 0; justify-content: center; gap: 0; }
243
- :root[data-sidenav="collapsed"] .kol-sidenav-hop-label { display: none; }
267
+ /* the narrow row is the wide row with its gutter eased to the column's centre — (56 − 20) / 2 — and its label
268
+ * faded, so the toggle animates as one motion (the clip model, above) */
269
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop { padding: var(--kol-spacing-2) 0 var(--kol-spacing-2) calc((var(--kol-sidenav-w-collapsed) - 1.25rem) / 2); gap: 0; }
270
+ :root[data-sidenav="collapsed"] .kol-sidenav-header { padding: 1rem 0 0 calc((var(--kol-sidenav-w-collapsed) - 1.25rem) / 2); gap: 0; }
271
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop-label { opacity: 0; }
244
272
  :root[data-sidenav="collapsed"] .kol-sidenav-body { display: none; }
245
273
  :root[data-sidenav="collapsed"] .kol-sidenav-hop-count,
246
274
  :root[data-sidenav="collapsed"] .kol-sidenav-section-toggle { display: none; }
@@ -248,9 +276,12 @@ body {
248
276
  * port): the disclosure caret and the disclosed list are noise in a rail with
249
277
  * no labels; the theme slot and footer centre; the footer wordmark swaps for
250
278
  * its initial — both spans live in one <a>, so the link survives either state. */
251
- :root[data-sidenav="collapsed"] .kol-sidenav-hop-caret,
279
+ :root[data-sidenav="collapsed"] .kol-sidenav-hop-caret { opacity: 0; visibility: hidden; pointer-events: none; }
252
280
  :root[data-sidenav="collapsed"] .kol-sidenav-section,
253
281
  :root[data-sidenav="collapsed"] .kol-sidenav-list { display: none; }
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .kol-brand-layout, .kol-sidenav-hop, .kol-sidenav-header, .kol-sidenav-hop-label, .kol-sidenav-hop-caret { transition: none; }
284
+ }
254
285
  :root[data-sidenav="collapsed"] .kol-sidenav-theme-slot { padding-inline: 0; justify-content: center; }
255
286
  :root[data-sidenav="collapsed"] .kol-sidenav-footer { padding-inline: 0; justify-content: center; }
256
287
  .kol-sidenav-footer-mark { display: none; }
@@ -322,28 +353,29 @@ body {
322
353
  }
323
354
 
324
355
  @media (max-width: 767px) {
325
- /* Mobile drawer mode: hide the sidebar from the layout grid entirely.
326
- SideNav repositions as a fixed slide-in panel triggered by the hamburger
327
- in BrandLayout. Backdrop is the sibling .kol-sidenav-backdrop. */
356
+ /* THE PHONE MODE IS THE APP RAIL'S DRAWER (user 2026-10-24): the rail leaves the grid and slides in from
357
+ the left on the shell's curve; SiteLayout stamps `data-rail-drawer` on the root and renders the shell's
358
+ trigger (`.kol-shell-drawer-trigger`, top-right, hamburger/×) and scrim (`.kol-shell-drawer-scrim`) —
359
+ both kol-theme's, shared with AppShell. The round top-left hamburger and the opacity backdrop are in
360
+ `_tmp/2026-10-24-site-drawer/`. */
328
361
  .kol-brand-layout { grid-template-columns: minmax(0, 1fr); }
329
362
  .kol-sidenav {
330
363
  position: fixed;
331
364
  inset: 0 auto 0 0;
332
365
  width: min(80vw, 280px);
333
366
  transform: translateX(-100%);
334
- transition: transform 220ms ease;
367
+ transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1);
335
368
  }
336
369
  .kol-sidenav.is-drawer-open { transform: translateX(0); }
370
+ @media (prefers-reduced-motion: reduce) { .kol-sidenav { transition: none; } }
337
371
  /* Restore expanded labels + body inside the open drawer (override the rail rules above). */
338
- .kol-sidenav.is-drawer-open .kol-sidenav-hop { padding: 8px 0; justify-content: flex-start; gap: 12px; }
339
- .kol-sidenav.is-drawer-open .kol-sidenav-header { padding: 1rem 2.5rem 0 1.5rem; justify-content: flex-start; gap: 12px; }
372
+ /* the open drawer's row is the WIDE row — the gutter at 1.5rem as on the desk (user 2026-10-24: "mobile is missing
373
+ padding left?"; this line restored `padding: 8px 0`, the narrow row's zero gutter, so the rows hugged the edge) */
374
+ .kol-sidenav.is-drawer-open .kol-sidenav-hop { padding: 0.5rem 2.5rem 0.5rem var(--kol-pad-chrome-x); justify-content: flex-start; gap: 0.75rem; }
375
+ .kol-sidenav.is-drawer-open .kol-sidenav-header { padding: 1rem 2.5rem 0 var(--kol-pad-chrome-x); justify-content: flex-start; gap: 0.75rem; }
340
376
  .kol-sidenav.is-drawer-open .kol-sidenav-hop-label { display: inline; }
341
377
  .kol-sidenav.is-drawer-open .kol-sidenav-section { display: block; }
342
378
  .kol-sidenav.is-drawer-open .kol-sidenav-body { display: block; }
343
- .kol-brand-layout[data-drawer-open="true"] .kol-sidenav-backdrop {
344
- opacity: 1;
345
- pointer-events: auto;
346
- }
347
379
  }
348
380
 
349
381
  /* SideNav primitives (.kol-sidenav-hop, -hop-icon, -hop-label, -link,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.53.0",
3
+ "version": "0.55.0",
4
4
  "description": "Site shell and page layout",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,16 +14,16 @@
14
14
  "./kol-brand-color.css": "./kol-brand-color.css"
15
15
  },
16
16
  "peerDependencies": {
17
- "@kolkrabbi/kol-component": ">=0.283.0",
17
+ "@kolkrabbi/kol-component": ">=0.298.0",
18
18
  "@kolkrabbi/kol-icons": ">=0.18.0",
19
- "@kolkrabbi/kol-theme": ">=0.179.0",
19
+ "@kolkrabbi/kol-theme": ">=0.186.0",
20
20
  "react": "^18.3.0 || ^19.0.0",
21
21
  "react-dom": "^18.3.0 || ^19.0.0",
22
22
  "react-router-dom": "^6.0.0 || ^7.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@kolkrabbi/kol-component": "^0.284.0",
26
- "@kolkrabbi/kol-icons": "^0.41.0"
25
+ "@kolkrabbi/kol-component": "^0.301.0",
26
+ "@kolkrabbi/kol-icons": "^0.45.0"
27
27
  },
28
28
  "files": [
29
29
  "src",
@@ -0,0 +1,45 @@
1
+ import { useNavigate } from 'react-router-dom'
2
+ import { ContentCard, ContentCollection } from '@kolkrabbi/kol-component'
3
+ import PageHero from './PageHero.jsx'
4
+
5
+ /**
6
+ * CategoryPage — A category's overview, from the nav tree. the FOLDERNOTE: the page a rail category opens
7
+ * on — its label and lede over its pages as cards — read from the same tree the rail reads, so nothing is
8
+ * listed by hand and a page added to the rail is on the overview the same moment (site-hub W4, user
9
+ * 2026-10-24: kol-branding's three categories each hand-wrote this page).
10
+ *
11
+ * The tree is `SideNav`'s: `{ id, label, icon, lede?, pages: [{ to, label, card? }] }`. A page with a `card`
12
+ * is on the overview — `{ eyebrow, body, media, meta }`, the article card's fields (`media` a node or an
13
+ * image src); a page without one (the overview's own row, a plain link) is not. The card opens its page.
14
+ *
15
+ * @param {Array} navTree the sidenav's tree
16
+ * @param {string} id the category's id
17
+ * @param {string} [min] the collection's column minimum (ContentCollection `min`, default 280px)
18
+ * @param {string} [ratio] the card media's ratio (default 16 / 9)
19
+ */
20
+ export default function CategoryPage({ navTree = [], id, min = '280px', ratio = '16 / 9' }) {
21
+ const navigate = useNavigate()
22
+ const cat = navTree.find((c) => c.id === id)
23
+ if (!cat) return null
24
+ const pages = (cat.pages ?? []).filter((p) => p.card)
25
+ return (
26
+ <div className="kol-page flex flex-col gap-10">
27
+ <PageHero label={cat.eyebrow} title={cat.label} lede={cat.lede} />
28
+ <ContentCollection min={min}>
29
+ {pages.map((p) => (
30
+ <ContentCard
31
+ key={p.to}
32
+ variant="article"
33
+ ratio={ratio}
34
+ media={typeof p.card.media === 'string' ? <img src={p.card.media} alt="" className="size-full object-cover" /> : p.card.media}
35
+ eyebrow={p.card.eyebrow}
36
+ title={p.label}
37
+ body={p.card.body}
38
+ date={p.card.meta}
39
+ onClick={() => navigate(p.to)}
40
+ />
41
+ ))}
42
+ </ContentCollection>
43
+ </div>
44
+ )
45
+ }
@@ -1,7 +1,7 @@
1
1
  import { Divider, SectionText } from '@kolkrabbi/kol-component'
2
2
 
3
3
  /* The chapter section of the brand-book page kit — `PageHero` · `PageSection`
4
- * · `PageLayout`. Its head COMPOSES `SectionText` (page-family-is-not-a-set,
4
+ * · `SiteLayout`. Its head COMPOSES `SectionText` (page-family-is-not-a-set,
5
5
  * 2026-09-03) instead of hand-stacking label / title / lede: the voices are the
6
6
  * same `.kol-prose-*` trio as before, passed as the base's seams, and each class
7
7
  * carries its own bottom margin, so the block's gap is 0 and nothing moved. */
package/src/SideNav.jsx CHANGED
@@ -3,7 +3,7 @@ import { NavRail } from '@kolkrabbi/kol-component'
3
3
  import ThemeToggle from './ThemeToggle'
4
4
 
5
5
  /**
6
- * SideNav — `NavRail variant="site"` under its old name (2026-10-22, the one nav rail).
6
+ * SideNav — The grouped navigation rail. `NavRail variant="site"` under its old name (2026-10-22, the one nav rail).
7
7
  *
8
8
  * @deprecated — use `NavRail variant="site"` from `@kolkrabbi/kol-component`. This name is on the
9
9
  * retirement ledger; it stays so every site keeps resolving.
@@ -40,6 +40,9 @@ function RouterSideNav({ onNavigate, ...props }) {
40
40
  )
41
41
  }
42
42
 
43
+ /* the theme is a TREE ROW (user 2026-10-24, site-hub): `ThemeToggle variant="row"` wears the rail's hop
44
+ * box and follows the collapse as every row does, so there is no `icon` fallback; collapsed it carries the
45
+ * toggle's own tooltip, as the rows carry theirs */
43
46
  const themeSlot = (on = true) => (on === false ? false : ({ collapsed }) => (
44
- <ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
47
+ <ThemeToggle variant="row" tooltip={collapsed} />
45
48
  ))
@@ -1,8 +1,7 @@
1
1
  import { createContext, useEffect, useState } from 'react'
2
2
  import { Outlet, useLocation } from 'react-router-dom'
3
3
  import SideNav from './SideNav.jsx'
4
- import { Icon } from '@kolkrabbi/kol-icons'
5
- import { ModalProvider } from '@kolkrabbi/kol-component'
4
+ import { Button, ModalProvider } from '@kolkrabbi/kol-component'
6
5
 
7
6
  /* Pages can register right-rail TOC content via this context.
8
7
  * Usage: const setTocContent = useContext(ShellTocContext)
@@ -24,11 +23,23 @@ export const ShellTocCollapsedContext = createContext(null)
24
23
  * (sidenav-rail-hairline, 2026-08-26): `background`, `hairline`, `isActive`,
25
24
  * `onNavigate` … the next rail option needs no AppShell release. */
26
25
  /**
27
- * PageLayout — The brand-book page layout. the brand-book page layout: the `.kol-brand-layout` grid with
28
- * the sidenav in its first track, the page plane in the second, the mobile
29
- * drawer + hamburger, the modal provider and the optional TOC rail.
26
+ * SiteLayout — The site frame. the frame every site on the site rail stands in: the `.kol-brand-layout`
27
+ * grid with `NavRail variant="site"` (through `SideNav`) in its first track, the page plane in the
28
+ * second, the phone drawer, the modal provider and the optional TOC rail. A site is this plus a nav
29
+ * tree and routes — `apps/site-hub` is the reference.
30
30
  *
31
- * WAS `AppShell` (page-family-is-not-a-set, kol-client-olina 2026-09-03). The
31
+ * WAS `PageLayout` (user 2026-10-24: "why not SiteLayout instead? to at least name it with variant
32
+ * 'site'") — the frame is named for the rail variant it frames, as `AppShell` is for the app rail.
33
+ * `PageLayout` stays an alias on the retirements ledger, as `AppShell` already was.
34
+ *
35
+ * THE PHONE MODE IS THE APP RAIL'S DRAWER (user 2026-10-24: "I think we should use that hamburger
36
+ * siderail"): below 768 the rail goes off-canvas and slides in from the left on the shell's 220ms
37
+ * curve, a 32px nav Button pinned top-right swaps hamburger for × (`.kol-shell-drawer-trigger`,
38
+ * kol-theme), a scrim BUTTON closes on tap (`.kol-shell-drawer-scrim`; iOS Safari does not bubble
39
+ * taps from a div), Escape closes, and a tap on a destination closes. The round top-left hamburger
40
+ * and the opacity backdrop this frame carried are in `_tmp/2026-10-24-site-drawer/`.
41
+ *
42
+ * BEFORE THAT it was `AppShell` (page-family-is-not-a-set, kol-client-olina 2026-09-03). The
32
43
  * ticket asked for a `PageLayout` to ship beside the fifteen `.kol-brand-layout`
33
44
  * rules kol-framework already owned — every consumer `BrandLayout.jsx` in the
34
45
  * estate is a copy of this file's markup. The component existed; the name did
@@ -58,8 +69,20 @@ export const ShellTocCollapsedContext = createContext(null)
58
69
  * @param {string} pageWash the plane's wash, a CSS color (e.g. `'var(--kol-fg-02)'`)
59
70
  * @param {boolean} bare plane + outlet only (default false)
60
71
  */
61
- export default function PageLayout({ navTree = [], header, footer, defaultTocContent, sideNav, pageWash, bare = false }) {
72
+ const NARROW = '(max-width: 767px)'
73
+
74
+ export default function SiteLayout({ navTree = [], header, footer, defaultTocContent, sideNav, pageWash, bare = false }) {
62
75
  const [drawerOpen, setDrawerOpen] = useState(false)
76
+ /* DRAWER MODE below 768 — a width query, as AppShell's `drawerBelow`; JS owns the breakpoint and
77
+ * stamps `data-rail-drawer` on the root, the CSS only paints */
78
+ const [narrow, setNarrow] = useState(() => typeof window !== 'undefined' && window.matchMedia(NARROW).matches)
79
+ useEffect(() => {
80
+ const mq = window.matchMedia(NARROW)
81
+ const on = () => setNarrow(mq.matches)
82
+ mq.addEventListener('change', on)
83
+ return () => mq.removeEventListener('change', on)
84
+ }, [])
85
+ useEffect(() => { if (!narrow) setDrawerOpen(false) }, [narrow])
63
86
  const [tocContent, setTocContent] = useState(null)
64
87
  const [tocCollapsed, setTocCollapsed] = useState(false)
65
88
  const { pathname } = useLocation()
@@ -114,25 +137,30 @@ export default function PageLayout({ navTree = [], header, footer, defaultTocCon
114
137
  * and the PLANE wears `pageWash` over it (BrandLayout's line,
115
138
  * 2026-08-24 → 08-27). */
116
139
  className="kol-brand-layout bg-surface-primary min-h-dvh"
117
- data-drawer-open={drawerOpen ? 'true' : undefined}
140
+ data-rail-drawer={narrow ? (drawerOpen ? 'open' : 'closed') : undefined}
118
141
  data-toc={showToc ? 'true' : undefined}
119
142
  >
120
- {/* tip-ok: phone-only (md:hidden) — a hover tip never shows on touch; the aria-label names it */}
121
- <button
122
- type="button"
123
- className="kol-sidenav-hamburger md:hidden fixed top-3 left-3 z-30 w-10 h-10 inline-flex items-center justify-center rounded-full bg-surface-primary border border-fg-08 text-emphasis"
124
- aria-label={drawerOpen ? 'Close menu' : 'Open menu'}
125
- aria-expanded={drawerOpen}
126
- onClick={() => setDrawerOpen((v) => !v)}
127
- >
128
- <Icon name={drawerOpen ? 'x' : 'hamburger'} size={18} />
129
- </button>
130
-
131
- <div
132
- className="kol-sidenav-backdrop fixed inset-0 z-20 kol-overlay-scrim opacity-0 pointer-events-none transition-opacity duration-200 md:hidden"
133
- onClick={() => setDrawerOpen(false)}
134
- aria-hidden="true"
135
- />
143
+ {/* THE TRIGGER SHIPS HERE, as the app rail's does in AppShell — hamburger closed, × open; 32px clears the
144
+ * 24px touch floor; pinned top-right so it never meets the left-hand panel */}
145
+ {narrow && (
146
+ <Button
147
+ variant="nav"
148
+ iconOnly={drawerOpen ? 'x' : 'hamburger'}
149
+ aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
150
+ aria-expanded={drawerOpen}
151
+ className="kol-shell-drawer-trigger"
152
+ onClick={() => setDrawerOpen((o) => !o)}
153
+ />
154
+ )}
155
+ {narrow && drawerOpen && (
156
+ /* A BUTTON, not a div: iOS Safari does not bubble tap-clicks from non-interactive elements */
157
+ <button
158
+ type="button"
159
+ aria-label="Close navigation"
160
+ className="kol-shell-drawer-scrim"
161
+ onClick={() => setDrawerOpen(false)}
162
+ />
163
+ )}
136
164
 
137
165
  <SideNav navTree={navTree} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} {...sideNav} />
138
166
  <div className="min-w-0" style={plane}>
@@ -37,6 +37,14 @@ import { useTheme } from './theme.js'
37
37
  * same prop list minus `fill`; no square pinning (there is no box);
38
38
  * size scales text + glyph only.
39
39
  *
40
+ * variant "row" — the sidenav's TREE ROW (user 2026-10-24, site-hub: "this theme toggle is not
41
+ * the same kind of row item as the rest … just the rolling icon and pair it with a text in the
42
+ * same as in the rows"): `.kol-sidenav-hop` with the roll at 16 in the 20px icon column and
43
+ * the target's words in `.kol-sidenav-hop-label`, the rows' voice (kol-helper-12). The box is
44
+ * the rail's rule, so it collapses as a row does — no `icon` fallback. Takes no size, fill,
45
+ * label or width: a row is the rail's geometry, not the toggle's. kol-framework's SideNav
46
+ * passes it to the rail's theme slot.
47
+ *
40
48
  * Glyph law: text-adjacent 14/16/18 (stays inside the rung line box);
41
49
  * solo 16/20/24 (the pinned-square pairing).
42
50
  *
@@ -162,6 +170,25 @@ export default function ThemeToggle({
162
170
  const tip = 'Theme'
163
171
  const wrap = (node) => (tooltip ? <Tooltip label={tip}>{node}</Tooltip> : node)
164
172
 
173
+ /* ── THE ROW — the rail's tree row verbatim (NavRailSite's `rowCls` + glyph + label; the box lives in
174
+ * .kol-sidenav-hop, kol-framework.css, never a utility here — the 2026-08-09 defect) ── */
175
+ if (variant === 'row') {
176
+ /* the row's tooltip is the RAIL's: to the right, on a block trigger, present in both states and merely
177
+ * disabled when the labels show (user 2026-10-24: collapsed, the toggle sat half off the rail — the default
178
+ * inline-flex trigger sized itself to the hidden label and centred wide; and a trigger that came and went
179
+ * remounted the row on every toggle) */
180
+ return (
181
+ <Tooltip label={tip} placement="right" triggerClassName="block w-full" disabled={!tooltip}>
182
+ <button {...shared} className={`kol-sidenav-hop kol-helper-12 text-emphasis bg-transparent border-0 cursor-pointer text-left ${extra}`.trim()}>
183
+ <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
184
+ {iconSwap(16)}
185
+ </span>
186
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{MODE_LABEL[next]}</span>
187
+ </button>
188
+ </Tooltip>
189
+ )
190
+ }
191
+
165
192
  /* ── DEPRECATED aliases — old chrome verbatim (0.6.x) ── */
166
193
  if (variant === 'icon') {
167
194
  return wrap(
package/src/index.js CHANGED
@@ -7,8 +7,10 @@
7
7
  * and kol-brand-color.css.
8
8
  */
9
9
 
10
- export { default as PageLayout, default as AppShell } from './PageLayout.jsx'
11
- export { ShellTocContext, ShellTocCollapsedContext } from './PageLayout.jsx'
10
+ /* the site frame — `SiteLayout` (2026-10-24, named for the rail variant it frames); `PageLayout` and `AppShell` are
11
+ * its earlier names, aliases on the retirements ledger */
12
+ export { default as SiteLayout, default as PageLayout, default as AppShell } from './SiteLayout.jsx'
13
+ export { ShellTocContext, ShellTocCollapsedContext } from './SiteLayout.jsx'
12
14
  export { default as SideNav } from './SideNav.jsx'
13
15
  /* MOVED to kol-component 2026-09-03 (editor-set-is-behind-its-source): its
14
16
  * EditorShell needs the same rails and cannot import this package. Re-exported
@@ -24,3 +26,4 @@ export { default as PageSection } from './PageSection.jsx'
24
26
  export { default as PortalFooter } from './PortalFooter.jsx'
25
27
  export { default as ScrollToTop } from './ScrollToTop.jsx'
26
28
  export { default as PageHero, default as BrandHero, default as SubPageHero } from './PageHero.jsx'
29
+ export { default as CategoryPage } from './CategoryPage.jsx'
package/src/theme.js CHANGED
@@ -63,6 +63,12 @@ export function applyTheme(theme) {
63
63
  if (theme === 'system') {
64
64
  delete document.documentElement.dataset.theme
65
65
  try { localStorage.removeItem(THEME_STORAGE_KEY) } catch { /* storage blocked */ }
66
+ /* THE APP'S DEFAULT STANDS IN FOR THE OS (AppHubDefaultTheme, kol-mirror 2026-10-24 — user: *"make this repo dark
67
+ * mode default"*): a dark-first app stamps `data-theme-default="dark"` on the root (the Hub does it from
68
+ * `defaultTheme`), and handing the page back to the system lands on that, not on prefers-color-scheme — stamped,
69
+ * never persisted, so the saved choice stays "none" and a reload stamps the same default again. */
70
+ const fallback = document.documentElement.dataset.themeDefault
71
+ if (fallback === 'light' || fallback === 'dark') document.documentElement.dataset.theme = fallback
66
72
  return
67
73
  }
68
74
  document.documentElement.dataset.theme = theme