@kolkrabbi/kol-component 0.316.0 → 0.317.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.316.0",
3
+ "version": "0.317.1",
4
4
  "description": "The core component library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "react-dom": "^18.3.0 || ^19.0.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@kolkrabbi/kol-icons": "^0.52.0"
41
+ "@kolkrabbi/kol-icons": "^0.53.1"
42
42
  },
43
43
  "files": [
44
44
  "src",
@@ -12,7 +12,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
12
12
  *
13
13
  * @param {Object} props
14
14
  * @param {ReactNode} props.children - Button content
15
- * @param {'accent'|'danger'|'nav'|'tab'} props.variant - The intent; the GROUND is `tone`. `tab` is the tab chip (2026-10-02): quiet text at rest, a soft filled chip while `pressed`. `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
15
+ * @param {'accent'|'danger'|'nav'|'tab'|'tab-filled'} props.variant - The intent; the GROUND is `tone`. `tab` is the tab chip (2026-10-02): quiet text at rest, a soft filled chip while `pressed`. `tab-filled` (2026-10-29) shows its chip at rest (oq-04), brighter on hover (oq-08) and brightest selected (oq-16). `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
16
16
  * @param {'xs'|'sm'|'md'|'lg'} props.size - Button size (default: 'md'); xs is the panel rung (ControlsXsRung, 2026-09-01) — kol-mono-8 in a 22px shell (20 icon-only), opt-in by prop
17
17
  * @param {string} props.iconLeft - Icon name to display on the left
18
18
  * @param {string} props.iconRight - Icon name to display on the right
@@ -93,7 +93,7 @@ const Button = ({
93
93
  warned.add(variant)
94
94
  console.warn(`Button: variant="${variant}" is deprecated — use tone="${VARIANT_TONE[variant]}"`)
95
95
  }
96
- const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'tab', 'danger', 'grey']
96
+ const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'tab', 'tab-filled', 'danger', 'grey']
97
97
  const known = !resolvedVariant || KNOWN.includes(resolvedVariant)
98
98
  if (!known && import.meta.env.DEV) console.warn(`Button: unknown variant "${resolvedVariant}" — rendering the inherited tone (primary)`)
99
99
  const variantClass = resolvedVariant && known ? `kol-btn-${resolvedVariant}` : ''
@@ -35,8 +35,11 @@
35
35
  * (`aria-disabled`, not the attribute — the tooltip still gives the reason),
36
36
  * refuses the press, and ←/→ step over it. It may still be the current `value`
37
37
  * (the ground changed under it): selected + disabled, no throw.
38
- * variant — 'default' (shipped chrome: shared outer stroke + dividers,
39
- * filled active cell) | 'filled' (the state law's tiles: every
38
+ * variant — 'default' — BORDERLESS (2026-10-29, the user: *"WE DONT USE BORDER, almost NEVER … DONT USE FUCKING
39
+ * BORDER unless I APPROVE it"*): the `sunken` tone, no outer stroke, the selected cell on the sunken
40
+ * ground. A choice is a Dropdown or text tabs first; a toggle that stays is this one |
41
+ * 'outlined' (the old default: shared outer stroke + dividers, filled active cell — only where he has
42
+ * approved a border) | 'filled' (the state law's tiles: every
40
43
  * cell a surface-secondary tile with 1px transparent gaps, NO
41
44
  * outline shell; the inset ring marks ONLY the selected cell) |
42
45
  * 'tonal' (filled tiles, but the clicked cell marks itself by
@@ -80,6 +83,8 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
80
83
  e.currentTarget.children[next]?.focus()
81
84
  }
82
85
 
86
+ /* the default draws borderless — the sunken tone — unless a tone is asked for; `outlined` keeps the stroke */
87
+ const resolvedTone = variant === 'default' && tone === 'default' ? 'sunken' : tone
83
88
  return (
84
89
  <div
85
90
  role={stateless ? 'group' : 'radiogroup'}
@@ -90,7 +95,7 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
90
95
  (variant === 'filled' || variant === 'tonal') && 'kol-seg--filled',
91
96
  variant === 'tonal' && 'kol-seg--tonal',
92
97
  size !== 'md' && `kol-seg--${size}`,
93
- toneClass(tone),
98
+ toneClass(resolvedTone),
94
99
  className,
95
100
  ].filter(Boolean).join(' ')}
96
101
  >
@@ -86,8 +86,11 @@ export default function PaletteHarmonyWheel({
86
86
  onHueChange?.(h)
87
87
  }
88
88
 
89
- const outerR = size / 2 - 8
90
- const innerR = outerR - 22
89
+ /* the ring, its margin and the dots were pixels tuned at the 248 default; they scale with `size` so a small wheel (the lab's
90
+ * harmony tiles, 2026-10-29) draws the same wheel instead of a negative radius. 248 is unchanged */
91
+ const k = size / 248
92
+ const outerR = size / 2 - 8 * k
93
+ const innerR = outerR - 22 * k
91
94
  const midR = (outerR + innerR) / 2
92
95
 
93
96
  useEffect(() => {
@@ -134,7 +137,7 @@ export default function PaletteHarmonyWheel({
134
137
  const x = cx + Math.cos(a) * midR
135
138
  const y = cy + Math.sin(a) * midR
136
139
  ctx.beginPath()
137
- ctx.arc(x, y, 5, 0, Math.PI * 2)
140
+ ctx.arc(x, y, 5 * k, 0, Math.PI * 2)
138
141
  ctx.fillStyle = `hsl(${h}, 100%, 50%)`
139
142
  ctx.fill()
140
143
  ctx.strokeStyle = MARKER_STROKE
@@ -147,14 +150,14 @@ export default function PaletteHarmonyWheel({
147
150
  const bx = cx + Math.cos(ba) * midR
148
151
  const by = cy + Math.sin(ba) * midR
149
152
  ctx.beginPath()
150
- ctx.arc(bx, by, 9, 0, Math.PI * 2)
153
+ ctx.arc(bx, by, 9 * k, 0, Math.PI * 2)
151
154
  ctx.fillStyle = `hsl(${baseHue}, 100%, 50%)`
152
155
  ctx.fill()
153
156
  ctx.strokeStyle = MARKER_STROKE
154
157
  ctx.lineWidth = 2
155
158
  ctx.stroke()
156
159
  ctx.beginPath()
157
- ctx.arc(bx, by, 10.5, 0, Math.PI * 2)
160
+ ctx.arc(bx, by, 10.5 * k, 0, Math.PI * 2)
158
161
  ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)'
159
162
  ctx.lineWidth = 1
160
163
  ctx.stroke()
@@ -92,7 +92,9 @@ export default function Stepper({
92
92
 
93
93
  const shellCls = [
94
94
  'kol-control',
95
- 'kol-control--filled',
95
+ /* NO MODIFIER, as Input (2026-09-03): the shell takes a `kol-tone-*` wrapper's tone, else the filled default. It
96
+ * forced `--filled`, so beside an Input or a Dropdown in a toned rail it drew a second tone (BrandLabToolWindow,
97
+ * 2026-10-29 — the user: *"2 different dropdown tones"*) */
96
98
  `kol-control-${size}`,
97
99
  SIZE_TYPE[size],
98
100
  'relative',
@@ -31,7 +31,9 @@ import { RAIL_ROW } from '../hooks/railRow.js'
31
31
  * active tab scrolled into view when it changes — arrow keys included; `wrap` breaks the tabs onto a second line.
32
32
  * Every look but `stacked`, which has its phone bar
33
33
  * @param {'underline'|'chip'|'text'|'stacked'} [look='underline']
34
- * underline — this row, as it always was
34
+ * @param {boolean} [filled=false] - `chip` only: every chip shows at rest (Button `tab-filled`), so the row reads as choices
35
+ * underline — this row, as it always was; a tab's `icon` sits before its label. `size="md"` is the sans body
36
+ * face with a 20px glyph in a 48px row (the user's ref, Adobe's tab strip, 2026-10-29)
35
37
  * chip — the active tab a filled chip, the rest quiet (was `TabChips`); `size` is the control rung
36
38
  * text — flat helper-type tabs, the active one bright (was kol-shell's `TabStrip`); `size` is the
37
39
  * helper size (default 14), `tracked` adds 1px tracking
@@ -69,13 +71,13 @@ export const tabbarPillWidth = (count) => count * TABBAR_PILL_TAB + 14
69
71
  /* the collapse's motion — one duration for the width and the glyph swap (300, user 2026-10-23: 180 read as abrupt) */
70
72
  const COLLAPSE_MS = 300
71
73
 
72
- export default function TabsRow({ tabs: raw = [], value, onChange, onClose, onMinimise, look = 'underline', placement = 'inline', shape = 'bar', collapsed = false, onExpand, edge, border, shadow, dock, active, foldable, folded, onFoldChange, foldSide, size, tracked = false, overflow = 'scroll', ariaLabel, className, style }) {
74
+ export default function TabsRow({ tabs: raw = [], value, onChange, onClose, onMinimise, look = 'underline', filled = false, placement = 'inline', shape = 'bar', collapsed = false, onExpand, edge, border, shadow, dock, active, foldable, folded, onFoldChange, foldSide, size, tracked = false, overflow = 'scroll', ariaLabel, className, style }) {
73
75
  const tabs = raw.map((t) => ({ ...t, id: t.id ?? t.value }))
74
76
  const wrap = overflow === 'wrap'
75
- if (look === 'chip') return <Overflow mode={overflow} value={value}><ChipTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 'sm'} ariaLabel={ariaLabel} className={`${wrap ? 'flex-wrap ' : ''}${className ?? ''}`} /></Overflow>
77
+ if (look === 'chip') return <Overflow mode={overflow} value={value}><ChipTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 'sm'} filled={filled} ariaLabel={ariaLabel} className={`${wrap ? 'flex-wrap ' : ''}${className ?? ''}`} /></Overflow>
76
78
  if (look === 'text') return <Overflow mode={overflow} value={value}><TextTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 14} tracked={tracked} className={`${wrap ? 'flex-wrap gap-y-2 ' : ''}${className ?? 'gap-6'}`} style={style} /></Overflow>
77
79
  if (look === 'stacked') return <StackedTabs tabs={tabs} value={value} onChange={onChange} placement={placement} shape={shape} collapsed={collapsed} onExpand={onExpand} edge={edge} border={border} shadow={shadow} dock={dock} active={active} foldable={foldable} folded={folded} onFoldChange={onFoldChange} foldSide={foldSide} ariaLabel={ariaLabel} className={className ?? ''} />
78
- return <Overflow mode={overflow} value={value}><UnderlineTabs tabs={tabs} value={value} onChange={onChange} onClose={onClose} onMinimise={onMinimise} wrap={wrap} /></Overflow>
80
+ return <Overflow mode={overflow} value={value}><UnderlineTabs tabs={tabs} value={value} onChange={onChange} onClose={onClose} onMinimise={onMinimise} wrap={wrap} size={size} /></Overflow>
79
81
  }
80
82
 
81
83
  /* `overflow="scroll"`: the row in a sideways scroller that fades its clipped edges and keeps the active tab (`data-active`)
@@ -98,14 +100,14 @@ function Overflow({ mode, value, children }) {
98
100
  }
99
101
 
100
102
  /* chip — TabChips verbatim */
101
- function ChipTabs({ tabs, value, onChange, size, ariaLabel, className }) {
103
+ function ChipTabs({ tabs, value, onChange, size, filled, ariaLabel, className }) {
102
104
  return (
103
- <div className={`flex items-center gap-1 ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
105
+ <div className={`flex items-center ${filled ? 'gap-2' : 'gap-1'} ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
104
106
  {tabs.map((t) => {
105
107
  const active = t.id === value
106
108
  return (
107
109
  /* a tab is selected, not pressed — the role's own attribute replaces Button's aria-pressed */
108
- <Button key={t.id} variant="tab" size={size} pressed={active} role="tab" aria-selected={active} aria-pressed={undefined} data-active={active || undefined} onClick={() => onChange?.(t.id)}>
110
+ <Button key={t.id} variant={filled ? 'tab-filled' : 'tab'} size={size} pressed={active} role="tab" aria-selected={active} aria-pressed={undefined} data-active={active || undefined} onClick={() => onChange?.(t.id)}>
109
111
  {t.label}
110
112
  </Button>
111
113
  )
@@ -263,7 +265,9 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
263
265
  )
264
266
  }
265
267
 
266
- function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap }) {
268
+ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, size }) {
269
+ /* `size="md"` (2026-10-29, the user's ref — Adobe's tab strip): the sans body face, a 20px glyph, a 48px row, 24 between tabs */
270
+ const md = size === 'md'
267
271
  const listRef = useRef(null)
268
272
  const activeIdx = tabs.findIndex((t) => t.id === value)
269
273
 
@@ -277,14 +281,14 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap }) {
277
281
  }
278
282
 
279
283
  return (
280
- <div className={`flex items-stretch gap-4 ${wrap ? 'min-h-10' : 'h-10'}`}>
284
+ <div className={`flex items-stretch gap-4 ${wrap ? (md ? 'min-h-12' : 'min-h-10') : md ? 'h-12' : 'h-10'}`}>
281
285
  {onClose && (
282
286
  /* the one close, at the rung whose glyph is the 12 this hand-rolled
283
287
  * (2026-09-03) — xs SOLO is 12, so the drawing does not move */
284
288
  <CloseButton onClick={onClose} size="xs" className="self-center" />
285
289
  )}
286
290
 
287
- <div ref={listRef} role="tablist" onKeyDown={onKeyDown} className={`flex items-stretch gap-4${wrap ? ' flex-wrap gap-y-1' : ''}`}>
291
+ <div ref={listRef} role="tablist" onKeyDown={onKeyDown} className={`flex items-stretch ${md ? 'gap-6' : 'gap-4'}${wrap ? ' flex-wrap gap-y-1' : ''}`}>
288
292
  {tabs.map((t, i) => {
289
293
  const isActive = t.id === value
290
294
  return (
@@ -297,12 +301,15 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap }) {
297
301
  tabIndex={isActive || (activeIdx === -1 && i === 0) ? 0 : -1}
298
302
  onClick={() => onChange?.(t.id)}
299
303
  className={[
300
- 'kol-mono-12 flex items-center cursor-pointer border-b-2',
304
+ `group ${md ? 'kol-sans-body-02 gap-2.5' : 'kol-mono-12 gap-2'} flex items-center cursor-pointer border-b-2`,
301
305
  isActive
302
306
  ? 'text-emphasis border-fg'
303
307
  : 'text-meta hover:text-emphasis border-transparent',
304
308
  ].join(' ')}
305
309
  >
310
+ {/* a tab may carry its glyph before the label (2026-10-29, Adobe's Base color · Image · Color wheel) */}
311
+ {/* the glyph in OPAQUE ink (icons take oq, never fg) — full when active, meta otherwise, full under the pointer */}
312
+ {t.icon && <Icon name={t.icon} size={md ? 20 : 16} className={isActive ? 'text-oq-96' : 'text-oq-48 group-hover:text-oq-96'} />}
306
313
  {t.label}
307
314
  </button>
308
315
  )
@@ -1,6 +1,9 @@
1
- import { useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import ColorSwatch from '../molecules/ColorSwatch.jsx'
3
3
  import Input from '../atoms/Input.jsx'
4
+ import Slider from '../molecules/Slider.jsx'
5
+ import Dropdown from '../molecules/Dropdown.jsx'
6
+ import { Icon } from '@kolkrabbi/kol-icons'
4
7
  import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
5
8
  import { HueStrip, SBSquare, AlphaStrip } from './SpectrumControls.jsx'
6
9
  import { hexToHsva, hsvaToHex } from '../hooks/colorMath.js'
@@ -9,6 +12,74 @@ import { resolveCssColor } from '../hooks/cssVar.js'
9
12
  /* taxonomy-ok: organism — composes the SpectrumControls family (an organism) with a swatch and the hex Input */
10
13
 
11
14
  const SWATCH = { xs: 16, sm: 24 }
15
+
16
+ /* ── THE MODES (review 3, 2026-10-29, Adobe's — the user: *"which mode? HEX HSL HSB GREYSCALE? like everything is
17
+ * missing?"*): HEX · RGB · HSB · Lab, each channel a gradient strip with its thumb and its number ── */
18
+ export const PICKER_MODES = [{ value: 'hex', label: 'HEX' }, { value: 'rgb', label: 'RGB' }, { value: 'hsb', label: 'HSB' }, { value: 'lab', label: 'Lab' }]
19
+ const rgbOf = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
20
+ const hexOf = (rgb) => '#' + rgb.map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0')).join('').toUpperCase()
21
+ /* CIE Lab (D65) ↔ sRGB */
22
+ const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }
23
+ const gam = (c) => 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
24
+ const W = [0.95047, 1, 1.08883]
25
+ const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116)
26
+ const fi = (t) => (t ** 3 > 216 / 24389 ? t ** 3 : (116 * t - 16) / (24389 / 27))
27
+ function hexToLab(hex) {
28
+ const [r, g, b] = rgbOf(hex).map(lin)
29
+ const xyz = [0.4124 * r + 0.3576 * g + 0.1805 * b, 0.2126 * r + 0.7152 * g + 0.0722 * b, 0.0193 * r + 0.1192 * g + 0.9505 * b].map((v, i) => f(v / W[i]))
30
+ return [116 * xyz[1] - 16, 500 * (xyz[0] - xyz[1]), 200 * (xyz[1] - xyz[2])]
31
+ }
32
+ function labToHex([L, a, bb]) {
33
+ const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - bb / 200
34
+ const [x, y, z] = [fi(fx) * W[0], fi(fy) * W[1], fi(fz) * W[2]]
35
+ return hexOf([3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z].map(gam))
36
+ }
37
+ /* a mode's three channels: name, range, read from the hsva, write back as a hex */
38
+ const CHANNELS = {
39
+ rgb: [0, 1, 2].map((i) => ({ key: 'RGB'[i], min: 0, max: 255, read: (hex) => rgbOf(hex)[i], write: (hex, v) => { const c = rgbOf(hex); c[i] = v; return hexOf(c) } })),
40
+ hsb: [
41
+ { key: 'H', min: 0, max: 360, read: (_, h) => h.hue, write: null, hsva: (h, v) => ({ ...h, hue: v }) },
42
+ { key: 'S', min: 0, max: 100, read: (_, h) => h.sat, write: null, hsva: (h, v) => ({ ...h, sat: v }) },
43
+ { key: 'B', min: 0, max: 100, read: (_, h) => h.val, write: null, hsva: (h, v) => ({ ...h, val: v }) },
44
+ ],
45
+ lab: [
46
+ { key: 'L', min: 0, max: 100, read: (hex) => hexToLab(hex)[0], write: (hex, v) => { const l = hexToLab(hex); l[0] = v; return labToHex(l) } },
47
+ { key: 'a', min: -128, max: 127, read: (hex) => hexToLab(hex)[1], write: (hex, v) => { const l = hexToLab(hex); l[1] = v; return labToHex(l) } },
48
+ { key: 'b', min: -128, max: 127, read: (hex) => hexToLab(hex)[2], write: (hex, v) => { const l = hexToLab(hex); l[2] = v; return labToHex(l) } },
49
+ ],
50
+ }
51
+
52
+ /* one channel: its letter, a strip painted with the channel's own range at the current colour, a thumb, its number */
53
+ /* brightness — Adobe's ☀ ramp under the hue strip in HEX and RGB: HSV value, black to the colour at full brightness */
54
+ const BRIGHTNESS = { key: 'B', label: 'Brightness', icon: 'brightness', min: 0, max: 100, read: (_, h) => h.val, write: null, hsva: (h, v) => ({ ...h, val: v }) }
55
+
56
+ function ChannelStrip({ ch, hex, hsva, onHsva }) {
57
+ const ref = useRef(null)
58
+ const value = ch.read(hex, hsva)
59
+ const at = (v) => (ch.hsva ? hsvaToHex({ ...ch.hsva(hsva, v), alpha: 1 }) : ch.write(hex, v))
60
+ const stops = Array.from({ length: 9 }, (_, i) => at(ch.min + ((ch.max - ch.min) * i) / 8).slice(0, 7))
61
+ const set = (v) => {
62
+ const n = Math.min(ch.max, Math.max(ch.min, v))
63
+ onHsva(ch.hsva ? ch.hsva(hsva, n) : { ...hexToHsva(ch.write(hex, n)), alpha: hsva.alpha })
64
+ }
65
+ const fromPointer = (e) => { const r = ref.current.getBoundingClientRect(); set(ch.min + ((e.clientX - r.left) / r.width) * (ch.max - ch.min)) }
66
+ const pct = ((value - ch.min) / (ch.max - ch.min)) * 100
67
+ return (
68
+ <div className="flex items-center gap-2">
69
+ <span className="flex w-4 shrink-0 items-center kol-mono-12 text-meta">{ch.icon ? <Icon name={ch.icon} size={16} className="text-oq-64" /> : ch.key}</span>
70
+ <div ref={ref} role="slider" aria-label={ch.label ?? ch.key} aria-valuemin={ch.min} aria-valuemax={ch.max} aria-valuenow={Math.round(value)} tabIndex={0}
71
+ className="relative h-4 flex-1 cursor-pointer rounded-full touch-none" style={{ background: `linear-gradient(to right, ${stops.join(', ')})` }}
72
+ onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); fromPointer(e) }}
73
+ onPointerMove={(e) => { if (e.currentTarget.hasPointerCapture(e.pointerId)) fromPointer(e) }}
74
+ onKeyDown={(e) => { const d = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key]; if (d) { e.preventDefault(); set(value + d * (e.shiftKey ? 10 : 1)) } }}>
75
+ <span className="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ left: `${pct}%`, boxShadow: '0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,0.35)', background: hex.slice(0, 7) }} />
76
+ </div>
77
+ <div className="w-14 shrink-0">
78
+ <Input size="sm" type="number" value={Math.round(value)} aria-label={`${ch.label ?? ch.key} value`} onChange={(e) => { const v = Number(e.target.value); if (!Number.isNaN(v)) set(v) }} />
79
+ </div>
80
+ </div>
81
+ )
82
+ }
12
83
  const PANEL_W = 180
13
84
 
14
85
  /* the browser's computed `rgb()` / `rgba()` → '#RRGGBB(AA)' — for `currentColor` and tokens */
@@ -35,8 +106,20 @@ function cssToHex(css) {
35
106
  * @param {string} [defaultValue] ⌥-click on the swatch resets to it
36
107
  * @param {'xs'|'sm'} [size='xs'] swatch 16 · 24
37
108
  * @param {string} [label='Color'] the swatch's accessible name
109
+ * @param {ReactNode} [trigger] what opens the panel instead of the swatch — any node, drawn inside the button (the lab's
110
+ * hex, Adobe's "Edit color": BrandLabToolWindow 2026-10-29). The panel is the same
111
+ * @param {string} [placement='bottom-end'] where the panel opens
112
+ * @param {boolean} [alpha=true] the opacity strip
113
+ * @param {boolean} [brightness=false] a brightness row under the hue (HSV value, 0–100, with its number) — Adobe's
114
+ * @param {boolean} [inline=false] the panel IN the page, no swatch and no popover — the same square, strips and hex,
115
+ * filling its column (the lab's Base color tab, 2026-10-29)
116
+ * @param {ReactNode} [hexEnd] inline only: a control at the end of the hex row (the lab's base lock)
117
+ * @param {boolean} [modes=false] a head row — `title` and a mode Dropdown, HEX · RGB · HSB · Lab — and, under the square and
118
+ * the hue, that mode's fields: the hex, or three channel strips with their numbers (Adobe's)
119
+ * @param {string} [title] the head row's name ("Base", "Edit color")
38
120
  */
39
- export default function ColorPickerPopover({ color, onChange, defaultValue, size = 'xs', label = 'Color', className = '' }) {
121
+ export default function ColorPickerPopover({ color, onChange, defaultValue, size = 'xs', label = 'Color', trigger, placement = 'bottom-end', alpha = true, brightness = false, inline = false, hexEnd, modes = false, title, className = '' }) {
122
+ const [mode, setMode] = useState('hex')
40
123
  const isNone = !color || color === 'transparent'
41
124
  const [open, setOpen] = useState(false)
42
125
  const [hsva, setHsva] = useState(null)
@@ -44,7 +127,7 @@ export default function ColorPickerPopover({ color, onChange, defaultValue, size
44
127
 
45
128
  const popover = usePopover({
46
129
  open,
47
- placement: 'bottom-end',
130
+ placement,
48
131
  offset: 4,
49
132
  onOpenChange: (next, event) => {
50
133
  if (next && event?.altKey) {
@@ -80,6 +163,70 @@ export default function ColorPickerPopover({ color, onChange, defaultValue, size
80
163
 
81
164
  const opaque = hsva && hsvaToHex({ ...hsva, alpha: 1 })
82
165
 
166
+ /* INLINE: the working copy follows `color` whenever it changes from outside (a roll, an undo) */
167
+ useEffect(() => {
168
+ if (!inline) return
169
+ const literal = isNone ? '#000000' : hexToHsva(color) ? color : '#000000'
170
+ if (hsva && hsvaToHex(hsva).slice(0, 7).toUpperCase() === literal.slice(0, 7).toUpperCase()) return
171
+ const h = hexToHsva(literal) ?? { hue: 0, sat: 0, val: 0, alpha: 1 }
172
+ setHsva(h)
173
+ setDraft(hsvaToHex(h).slice(1))
174
+ // eslint-disable-next-line react-hooks/exhaustive-deps
175
+ }, [inline, color])
176
+
177
+ const hexField = (
178
+ /* no variant: the field takes the nearest `kol-tone-*` wrapper (a toned rail), filled where there is none (review 3 —
179
+ * *"input is invisible, just set tone to sunken … so you dont have to set it individually"*) */
180
+ <Input size="sm" prefix="#" maxLength={8} value={draft} onChange={onHex} aria-label={`${label} hex`} />
181
+ )
182
+ const strips = hsva && (
183
+ <>
184
+ <div className="rounded-[var(--kol-radius-xs)] overflow-hidden" style={{ width: inline || modes ? '100%' : PANEL_W, height: inline || modes ? 160 : 140 }}>
185
+ <SBSquare hue={hsva.hue} sat={hsva.sat} val={hsva.val} onChange={(sat, val) => commit({ ...hsva, sat, val })} />
186
+ </div>
187
+ <HueStrip hue={hsva.hue} onChange={(hue) => commit({ ...hsva, hue })} />
188
+ {modes && mode !== 'hex' && CHANNELS[mode].map((ch) => <ChannelStrip key={ch.key} ch={ch} hex={hsvaToHex({ ...hsva, alpha: 1 })} hsva={hsva} onHsva={commit} />)}
189
+ {!modes && brightness && (
190
+ <Slider min={0} max={100} value={Math.round(hsva.val)} onChange={(v) => commit({ ...hsva, val: v })} formatValue={(v) => `${v}`} displayWidth={3} aria-label="Brightness" />
191
+ )}
192
+ {alpha && <AlphaStrip alpha={hsva.alpha} color={opaque} onChange={(a) => commit({ ...hsva, alpha: a })} />}
193
+ </>
194
+ )
195
+ /* the head row: the picker's name and its mode */
196
+ const head = modes && (
197
+ <div className="flex items-center justify-between gap-2">
198
+ <span className="truncate kol-helper-12 text-emphasis">{title ?? label}</span>
199
+ <Dropdown size="sm" value={mode} onChange={setMode} options={PICKER_MODES} />
200
+ </div>
201
+ )
202
+
203
+ if (inline) {
204
+ /* ADOBE'S BASE COLOR PANEL (review 7, refs 68–72): the label row — the name, the mode as plain text at the right;
205
+ * in HEX, ONE field: the swatch, the hex, the lock (Input's slotLeft · slotRight); the square, THEN the hue strip;
206
+ * the mode's channels under them (RGB · HSB · Lab), and brightness — the ☀ ramp — in HEX and RGB */
207
+ const hexField = (!modes || mode === 'hex') && (
208
+ <Input size="md" prefix="#" maxLength={8} value={draft} onChange={onHex} aria-label={`${label} hex`} width="100%"
209
+ slotLeft={<ColorSwatch hex={isNone ? null : color} size={SWATCH.xs} radius="full" hoverable={false} />}
210
+ slotRight={hexEnd} />
211
+ )
212
+ return hsva && (
213
+ <div className={`flex w-full flex-col gap-3 ${className}`.trim()}>
214
+ <div className="flex items-center justify-between gap-3">
215
+ <span className="kol-sans-body-02 text-emphasis">{title ?? label}</span>
216
+ {modes && <Dropdown variant="nav" size="sm" value={mode} onChange={setMode} options={PICKER_MODES} ariaLabel={`${label} mode`} />}
217
+ </div>
218
+ {hexField}
219
+ <div className="w-full overflow-hidden rounded-[var(--kol-radius-xs)]" style={{ height: 200 }}>
220
+ <SBSquare hue={hsva.hue} sat={hsva.sat} val={hsva.val} onChange={(sat, val) => commit({ ...hsva, sat, val })} />
221
+ </div>
222
+ <HueStrip hue={hsva.hue} onChange={(hue) => commit({ ...hsva, hue })} />
223
+ {modes && mode !== 'hex' && CHANNELS[mode].map((ch) => <ChannelStrip key={ch.key} ch={ch} hex={hsvaToHex({ ...hsva, alpha: 1 })} hsva={hsva} onHsva={commit} />)}
224
+ {(!modes || mode === 'hex' || mode === 'rgb') && <ChannelStrip ch={BRIGHTNESS} hex={hsvaToHex({ ...hsva, alpha: 1 })} hsva={hsva} onHsva={commit} />}
225
+ {alpha && <AlphaStrip alpha={hsva.alpha} color={opaque} onChange={(a) => commit({ ...hsva, alpha: a })} />}
226
+ </div>
227
+ )
228
+ }
229
+
83
230
  return (
84
231
  <>
85
232
  <button
@@ -90,36 +237,27 @@ export default function ColorPickerPopover({ color, onChange, defaultValue, size
90
237
  aria-expanded={open}
91
238
  className={`inline-flex shrink-0 rounded focus-visible:outline-1 focus-visible:outline-offset-2 ${className}`}
92
239
  >
93
- <ColorSwatch
94
- hex={isNone ? null : color}
95
- size={SWATCH[size] ?? SWATCH.xs}
96
- showTransparent={isNone}
97
- transparentTone="error"
98
- hoverable={false}
99
- className="border border-[var(--kol-border-default)]"
100
- />
240
+ {trigger ?? (
241
+ <ColorSwatch
242
+ hex={isNone ? null : color}
243
+ size={SWATCH[size] ?? SWATCH.xs}
244
+ showTransparent={isNone}
245
+ transparentTone="error"
246
+ hoverable={false}
247
+ className="border border-[var(--kol-border-default)]"
248
+ />
249
+ )}
101
250
  </button>
102
251
  {open && hsva && (
103
252
  <PopoverPanel
104
253
  popover={popover}
105
254
  panel={false}
106
255
  className="bg-surface-secondary border border-fg-08 rounded p-2 flex flex-col gap-2 shadow-lg"
107
- style={{ width: PANEL_W + 16 }}
256
+ style={{ width: modes ? 296 : PANEL_W + 16 }}
108
257
  >
109
- <div className="rounded-[var(--kol-radius-xs)] overflow-hidden" style={{ width: PANEL_W, height: 140 }}>
110
- <SBSquare hue={hsva.hue} sat={hsva.sat} val={hsva.val} onChange={(sat, val) => commit({ ...hsva, sat, val })} />
111
- </div>
112
- <HueStrip hue={hsva.hue} onChange={(hue) => commit({ ...hsva, hue })} />
113
- <AlphaStrip alpha={hsva.alpha} color={opaque} onChange={(alpha) => commit({ ...hsva, alpha })} />
114
- <Input
115
- variant="filled"
116
- size="sm"
117
- prefix="#"
118
- maxLength={8}
119
- value={draft}
120
- onChange={onHex}
121
- aria-label={`${label} hex`}
122
- />
258
+ {head}
259
+ {strips}
260
+ {(!modes || mode === 'hex') && hexField}
123
261
  </PopoverPanel>
124
262
  )}
125
263
  </>
@@ -136,6 +136,19 @@ import useScrollFade from '../hooks/useScrollFade.js'
136
136
  * `defaultOpen` category ids open on first render (default none — the rail loads closed, elder ruling 2026-08-06)
137
137
  * `openOnRoute` `true` opens the category holding the current route whenever the route changes (default `false`
138
138
  * — nothing auto-expands on arrival); it only adds, never closes what you opened
139
+ * EVERY ROW IS OVERRIDABLE (NavRailRowProps, kol-website 2026-10-29 — user: "props are good. we use variants and presets
140
+ * to define how things ship, but props make things flexible"). All opt-in; with none passed every rail renders as before:
141
+ * `case` the label's case on every level — `as-written` · `upper` · `lower` · `title` · `sentence`;
142
+ * `caseCategory` / `caseGroup` / `casePage` per level (defaults today's: upper, upper, as-written)
143
+ * and a node's own `case` wins. CSS only (text-transform, `::first-letter` for sentence), so the
144
+ * label stays the authored string for screen readers and search
145
+ * `icon` on any node at any depth (page, group label, action row, nested child), through `iconComponent`
146
+ * `iconSize` `{ category, group, page }` (defaults 16, 12, 12); a node's own `iconSize` wins
147
+ * `alignIcons` `true` (default): a level where any row has an icon reserves the icon column for all its rows
148
+ * `collapsedIcons` `'category'` (default) — the collapsed rail shows category glyphs only; `'all'` also shows the
149
+ * icon-bearing pages of an open category under it
150
+ * per node `trailing` (a count, badge or node, right-aligned), `className`, `title` (the tooltip when the
151
+ * label truncates), `external: true` (the external-link glyph, opens a new tab)
139
152
  * `background` (2026-08-09, prop): `true` (default) paints the rail surface;
140
153
  * `false` renders chromeless so the rail can float over a consumer's media
141
154
  * (the brand-hero model — the consumer owns the plane). `hairline` (2026-08-26,
@@ -177,13 +190,25 @@ const indentOf = (depth) => `calc(var(--kol-pad-chrome-x) + 2rem + ${depth} * va
177
190
  * router lives in the consumer (kol-framework's `SideNav` wraps this with react-router). A
178
191
  * modified click (cmd, ctrl, shift, middle) is left to the browser, as a NavLink left it.
179
192
  * Hoisted out of the component so its identity is stable across renders (see `leafProps`). */
180
- function RouteLeaf({ to, href, active, onNavigate, className, style, children, onPress }) {
193
+ function RouteLeaf({ to, href, active, onNavigate, className, style, title, children, onPress }) {
181
194
  return (
182
- <a href={href} style={style} className={className(active)} aria-current={active ? 'page' : undefined}
195
+ <a href={href} style={style} title={title} className={className(active)} aria-current={active ? 'page' : undefined}
183
196
  onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; e.preventDefault(); onPress?.(); onNavigate(e, to) }}>{children}</a>
184
197
  )
185
198
  }
186
199
 
200
+ /* THE ROW PROPS (NavRailRowProps): a case resolves node → level → rail → the level's own look; the level's own look emits
201
+ * NO class, so a rail without the props carries the classes it always did. Tailwind's case utilities — they outrank the
202
+ * hop's `text-transform: uppercase` (kol-theme) from the utilities layer. */
203
+ const CASE_CLS = { 'as-written': 'normal-case', upper: 'uppercase', lower: 'lowercase', title: 'capitalize', sentence: 'lowercase first-letter:uppercase' }
204
+ const LEVEL_CASE = { category: 'upper', group: 'upper', page: 'as-written' }
205
+ const ICON_SIZE = { category: 16, group: 12, page: 12 }
206
+ const withCls = (...parts) => parts.filter(Boolean).join(' ')
207
+ /* `external`: a plain new-tab link, never the router's */
208
+ const ExternalLeaf = ({ to, className, style, title, children }) => (
209
+ <a href={to} target="_blank" rel="noopener noreferrer" style={style} title={title} className={className}>{children}</a>
210
+ )
211
+
187
212
  export default function NavRailSite({
188
213
  currentPath: pathname = '',
189
214
  /* `(to) => href` — the router's basename on the link a modified click opens (kol-framework's
@@ -212,7 +237,27 @@ export default function NavRailSite({
212
237
  fade = true,
213
238
  defaultOpen = [],
214
239
  openOnRoute = false,
240
+ case: railCase,
241
+ caseCategory,
242
+ caseGroup,
243
+ casePage,
244
+ iconSize: iconSizes,
245
+ alignIcons = true,
246
+ collapsedIcons = 'category',
215
247
  }) {
248
+ /* the row props' resolvers (NavRailRowProps) — '' at a level's own look, so the classes stay as they were */
249
+ const caseCls = (node, level) => {
250
+ const c = node.case ?? { category: caseCategory, group: caseGroup, page: casePage }[level] ?? railCase ?? LEVEL_CASE[level]
251
+ return c === LEVEL_CASE[level] ? '' : (CASE_CLS[c] ?? '')
252
+ }
253
+ const sizeOf = (node, level) => node.iconSize ?? iconSizes?.[level] ?? ICON_SIZE[level]
254
+ /* what rides a row's right end: its `trailing`, then the external glyph */
255
+ const tailOf = (node) => (node.trailing != null || node.external) && (
256
+ <>
257
+ {node.trailing != null && <span className="shrink-0 text-subtle">{node.trailing}</span>}
258
+ {node.external && <span className="shrink-0 inline-flex text-subtle" aria-hidden="true"><Icon name="external-link" size={10} /></span>}
259
+ </>
260
+ )
216
261
  /* the tree fades where it clips — the head once scrolled, the foot while more lies below (kol-theme `.kol-scroll-fade`;
217
262
  * TimeSetAndSiteRailFade, 2026-10-29) */
218
263
  const scrollRef = useRef(null)
@@ -299,19 +344,50 @@ export default function NavRailSite({
299
344
  /* Rows under a category: a leaf is a route link; a group is a non-routing
300
345
  * header over its own rows, one step further in (recursive). A group lights
301
346
  * up the way a category does — when one of its leaves is the current route. */
302
- const renderRows = (rows, depth) => rows.map((row, i) => {
347
+ const renderRows = (rows, depth) => {
348
+ /* THE ICON COLUMN (NavRailRowProps): with `alignIcons`, a level where any row has an icon reserves the column for all
349
+ * its rows, as wide as its widest glyph; a level with no icon draws exactly as before */
350
+ const levelOf = (r) => (r.children ? 'group' : 'page')
351
+ const iconW = rows.reduce((w, r) => (r.icon ? Math.max(w, sizeOf(r, levelOf(r))) : w), 0)
352
+ const aligned = alignIcons && iconW > 0
353
+ /* a row with an icon, a reserved column or a tail lays out as glyph · label · tail; its case rides the label span
354
+ * (a flex item, so `::first-letter` applies). A plain row keeps its text bare and the case rides the row. */
355
+ const body = (row, cc) => {
356
+ const tail = tailOf(row)
357
+ if (!row.icon && !aligned && !tail) return { rowCase: cc, content: row.label }
358
+ const size = sizeOf(row, levelOf(row))
359
+ return {
360
+ rowCase: '',
361
+ content: (
362
+ <span className="flex items-center gap-2 min-w-0">
363
+ {(row.icon || aligned) && (
364
+ /* zero-height slot: the 12px glyph centres on the 10px line without growing the row */
365
+ <span className="kol-sidenav-hop-icon inline-flex items-center justify-center shrink-0" style={{ width: aligned ? iconW : size, height: 0 }} aria-hidden="true">
366
+ {row.icon && <Glyph name={row.icon} size={size} />}
367
+ </span>
368
+ )}
369
+ <span className={withCls('flex-1 min-w-0 truncate', cc)}>{row.label}</span>
370
+ {tail}
371
+ </span>
372
+ ),
373
+ }
374
+ }
375
+ return rows.map((row, i) => {
303
376
  if (row.children) {
377
+ const { rowCase, content } = body(row, caseCls(row, 'group'))
304
378
  return (
305
379
  <li key={row.id ?? `${row.label}-${i}`}>
306
380
  <div
307
381
  /* UPPERCASE (user 2026-10-24, the another-creation reference: "look at uppercase sections") — the fork's
308
382
  * `uppercase` utility returns, as the reference carries it; the group is a section label in the rows' voice.
309
383
  * ONE INK (user 2026-10-24: "just keep them same opacity"): the label never lights — the row's dot says
310
- * which row is active, and a label that brightened said it twice. */
311
- className="kol-sidenav-group kol-helper-10 uppercase text-subtle"
384
+ * which row is active, and a label that brightened said it twice. `caseGroup` / a node's `case` swap the
385
+ * utility (NavRailRowProps). */
386
+ className={withCls('kol-sidenav-group kol-helper-10', rowCase || 'uppercase', 'text-subtle', row.className)}
312
387
  style={depth ? { paddingLeft: indentOf(depth) } : undefined}
388
+ title={row.title}
313
389
  >
314
- {row.label}
390
+ {content}
315
391
  </div>
316
392
  <ul className="kol-sidenav-list">{renderRows(rowsOf(row.children), depth + 1)}</ul>
317
393
  </li>
@@ -321,29 +397,65 @@ export default function NavRailSite({
321
397
  /* a row under a group sits 2px further in than the step, dot and text together, so the dot's edge lines up under
322
398
  * the group label's first letter (user 2026-10-24: "just indent the dot+text by 2px to right") */
323
399
  const leafStyle = depth ? { paddingLeft: `calc(${indentOf(depth)} + 2px)`, '--kol-sidenav-dot-left': `calc(var(--kol-pad-chrome-x) + 1.125rem + 2px + ${depth} * var(--kol-spacing-3))` } : pageStyle
400
+ const { rowCase, content } = body(row, caseCls(row, 'page'))
324
401
  if (row.onSelect) {
325
402
  return (
326
403
  <li key={row.id ?? `${row.label}-${i}`}>
327
404
  <button
328
405
  type="button"
329
406
  style={leafStyle}
330
- className={`${row.active ? pageActiveCls : pageCls} w-full bg-transparent border-0 cursor-pointer text-left truncate`}
407
+ className={withCls(`${row.active ? pageActiveCls : pageCls} w-full bg-transparent border-0 cursor-pointer text-left truncate`, rowCase, row.className)}
331
408
  aria-pressed={!!row.active}
409
+ title={row.title}
332
410
  onClick={(e) => handleSelect(e, row)}
333
411
  >
334
- {row.label}
412
+ {content}
335
413
  </button>
336
414
  </li>
337
415
  )
338
416
  }
417
+ if (row.external) {
418
+ return (
419
+ <li key={row.to}>
420
+ <ExternalLeaf to={row.to} style={leafStyle} title={row.title} className={withCls(pageCls, rowCase, row.className)}>{content}</ExternalLeaf>
421
+ </li>
422
+ )
423
+ }
339
424
  return (
340
425
  <li key={row.to}>
341
- <RouteLeaf {...leafProps(row.to)} style={leafStyle} className={(active) => (active ? pageActiveCls : pageCls)}>
342
- {row.label}
426
+ <RouteLeaf {...leafProps(row.to)} style={leafStyle} title={row.title} className={(active) => withCls(active ? pageActiveCls : pageCls, rowCase, row.className)}>
427
+ {content}
343
428
  </RouteLeaf>
344
429
  </li>
345
430
  )
346
- })
431
+ })
432
+ }
433
+
434
+ /* a page as a collapsed glyph row (`collapsedIcons="all"`) — the hop's own classes, so the collapse geometry centres it */
435
+ const renderIconLeaf = (leaf, i) => {
436
+ const cls = 'kol-sidenav-hop kol-helper-12 text-emphasis no-underline'
437
+ const inner = (
438
+ <>
439
+ <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
440
+ <Glyph name={leaf.icon} size={sizeOf(leaf, 'page')} />
441
+ </span>
442
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{leaf.label}</span>
443
+ </>
444
+ )
445
+ return (
446
+ <li key={leaf.to ?? leaf.id ?? `${leaf.label}-${i}`}>
447
+ <Tooltip label={leaf.label} placement="right" triggerClassName="block">
448
+ {leaf.onSelect ? (
449
+ <button type="button" className={`${cls} bg-transparent border-0 cursor-pointer text-left${leaf.active ? ' is-active' : ''}`} aria-pressed={!!leaf.active} onClick={(e) => handleSelect(e, leaf)}>{inner}</button>
450
+ ) : leaf.external ? (
451
+ <ExternalLeaf to={leaf.to} className={cls}>{inner}</ExternalLeaf>
452
+ ) : (
453
+ <RouteLeaf {...leafProps(leaf.to)} className={(active) => `${cls}${active ? ' is-active' : ''}`}>{inner}</RouteLeaf>
454
+ )}
455
+ </Tooltip>
456
+ </li>
457
+ )
458
+ }
347
459
 
348
460
  /* one renderer for the tree and the pinned rows (RailSideNavPixelParity) */
349
461
  const renderCat = (cat, ci) => {
@@ -368,12 +480,21 @@ export default function NavRailSite({
368
480
  /* Box utilities deliberately absent — the hop's layout lives in
369
481
  * .kol-sidenav-hop (kol-framework.css): a utility here would
370
482
  * outrank the collapsed-state overrides (2026-08-09 defect). */
371
- const rowCls = 'kol-sidenav-hop kol-helper-12 text-emphasis no-underline'
483
+ const rowCls = withCls('kol-sidenav-hop kol-helper-12 text-emphasis no-underline', cat.className)
372
484
  const glyph = (
373
485
  <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
374
- <Glyph name={cat.icon} size={16} />
486
+ <Glyph name={cat.icon} size={sizeOf(cat, 'category')} />
375
487
  </span>
376
488
  )
489
+ /* the label slot: the case on it (the hop's uppercase is inherited, so a class here wins); with a tail it
490
+ * lays out label · tail, the case moving onto the label's own span (NavRailRowProps) */
491
+ const cc = caseCls(cat, 'category')
492
+ const tail = tailOf(cat)
493
+ const label = (inner) => (tail ? (
494
+ <span className="kol-sidenav-hop-label flex-1 min-w-0 flex items-center gap-2">
495
+ <span className={withCls('flex-1 min-w-0 truncate', cc)}>{inner}</span>{tail}
496
+ </span>
497
+ ) : <span className={withCls('kol-sidenav-hop-label flex-1 min-w-0', cc)}>{inner}</span>)
377
498
  /* A category is a <button>, not a link — it has no route by
378
499
  definition, and an <a href> would promise a page that does
379
500
  not exist. Clicking it discloses its pages. */
@@ -396,15 +517,20 @@ export default function NavRailSite({
396
517
  >
397
518
  <span className={`kol-sidenav-hop-chevron${isOpen ? '' : ' -rotate-90'}`}><Icon name="chevron-down" size={12} /></span>
398
519
  </button>
399
- <RouteLeaf {...leafProps(cat.to)} onPress={discloseOnTitle ? () => discloseCat(cat.id) : undefined} className={(active) => `${rowCls} kol-sidenav-hop--split${(active || isActiveCat(cat)) ? ' is-active' : ''}`}>
520
+ <RouteLeaf {...leafProps(cat.to)} title={cat.title} onPress={discloseOnTitle ? () => discloseCat(cat.id) : undefined} className={(active) => `${rowCls} kol-sidenav-hop--split${(active || isActiveCat(cat)) ? ' is-active' : ''}`}>
400
521
  {glyph}
401
- <span className="kol-sidenav-hop-label flex-1 min-w-0"><span className="kol-sidenav-hop-title">{cat.label}</span></span>
522
+ {label(<span className="kol-sidenav-hop-title">{cat.label}</span>)}
402
523
  </RouteLeaf>
403
524
  </>
525
+ ) : isLink && cat.external ? (
526
+ <ExternalLeaf to={cat.to} title={cat.title} className={rowCls}>
527
+ {glyph}
528
+ {label(cat.label)}
529
+ </ExternalLeaf>
404
530
  ) : isLink ? (
405
- <RouteLeaf {...leafProps(cat.to)} className={(active) => `${rowCls}${active ? ' is-active' : ''}`}>
531
+ <RouteLeaf {...leafProps(cat.to)} title={cat.title} className={(active) => `${rowCls}${active ? ' is-active' : ''}`}>
406
532
  {glyph}
407
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
533
+ {label(cat.label)}
408
534
  </RouteLeaf>
409
535
  ) : isPanel ? (
410
536
  <button
@@ -412,26 +538,29 @@ export default function NavRailSite({
412
538
  data-panel-trigger={cat.id}
413
539
  className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${openPanel === cat.id ? ' is-active' : ''}`}
414
540
  aria-expanded={openPanel === cat.id}
541
+ title={cat.title}
415
542
  onClick={() => setOpenPanel((o) => (o === cat.id ? null : cat.id))}
416
543
  >
417
544
  {glyph}
418
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
545
+ {label(cat.label)}
419
546
  </button>
420
547
  ) : isAction ? (
421
548
  <button
422
549
  type="button"
423
550
  className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${cat.active ? ' is-active' : ''}`}
424
551
  aria-pressed={!!cat.active}
552
+ title={cat.title}
425
553
  onClick={(e) => handleSelect(e, cat)}
426
554
  >
427
555
  {glyph}
428
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
556
+ {label(cat.label)}
429
557
  </button>
430
558
  ) : (
431
559
  <button
432
560
  type="button"
433
561
  className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${isActiveCat(cat) ? ' is-active' : ''}`}
434
562
  aria-expanded={isOpen}
563
+ title={cat.title}
435
564
  /* Collapsed rail: the click meant "open this" — expand the
436
565
  * rail AND disclose the category, or the tap is wasted on
437
566
  * an expand that lands on a closed tree. */
@@ -445,7 +574,7 @@ export default function NavRailSite({
445
574
  }}
446
575
  >
447
576
  {glyph}
448
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
577
+ {label(cat.label)}
449
578
  {/* plain wrapper span — Icon stamps inline-flex on its own
450
579
  * element, which would outrank the rail's display:none */}
451
580
  <span
@@ -470,6 +599,12 @@ export default function NavRailSite({
470
599
  {!isLink && !isAction && !isPanel && isOpen && (
471
600
  <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>
472
601
  )}
602
+ {/* `collapsedIcons="all"` (NavRailRowProps): collapsed, an open category's icon-bearing pages show
603
+ * under it as glyph rows in the hop's geometry, each named by its tooltip; expanded they are the
604
+ * list's rows above. Only mounted collapsed, so the expanded tree is untouched. */}
605
+ {collapsed && collapsedIcons === 'all' && isOpen && pages.length > 0 && (
606
+ <ul className="kol-sidenav-tree flex flex-col gap-[2px]">{leavesOf(pages).filter((l) => l.icon).map(renderIconLeaf)}</ul>
607
+ )}
473
608
  </li>
474
609
  )
475
610
  }