@kolkrabbi/kol-component 0.186.0 → 0.188.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.186.0",
3
+ "version": "0.188.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -104,6 +104,37 @@ export function harmonyColors(hue, harmony, { saturation = 100, lightness = 50 }
104
104
  return roleOffsets.map((off) => hslToHex(normHue(hue + off), saturation, lightness))
105
105
  }
106
106
 
107
+ /**
108
+ * Re-hue an EXISTING palette to a harmony, preserving each slot's own
109
+ * saturation and lightness.
110
+ *
111
+ * `harmonyColors` builds every role at ONE flat S/L, which is right when a
112
+ * caller has no palette yet and wrong the moment it does: a Light slot and a
113
+ * Dark slot both come back at 50% lightness, so the palette flattens on the
114
+ * first drag of the wheel (kol-fxr, `editor-set-is-behind-its-source`
115
+ * 2026-09-03 — its own wheel emits a hue and re-hues slot by slot, so the
116
+ * package's `colors` payload was half-ignorable).
117
+ *
118
+ * A slot that is `locked`, empty, or has no hex is passed through untouched —
119
+ * locking a colour is the one instruction a re-hue must not overrule.
120
+ *
121
+ * @param {number} hue base hue, 0–360
122
+ * @param {string|object} harmony harmony id or object
123
+ * @param {Array<{hex?: string, locked?: boolean}|string|null>} slots the current palette, in role order
124
+ * @returns {Array} the same shape back, re-hued — strings stay strings, objects keep every other key
125
+ */
126
+ export function reHueSlots(hue, harmony, slots = []) {
127
+ const { roleOffsets } = harmonyById(harmony)
128
+ return slots.map((slot, i) => {
129
+ const off = roleOffsets[i % roleOffsets.length]
130
+ const hex = typeof slot === 'string' ? slot : slot?.hex
131
+ if (!hex || (typeof slot === 'object' && slot?.locked)) return slot
132
+ const { s, l } = hexToHsl(hex)
133
+ const next = hslToHex(normHue(hue + off), s, l)
134
+ return typeof slot === 'string' ? next : { ...slot, hex: next }
135
+ })
136
+ }
137
+
107
138
  /**
108
139
  * Deterministic role colors derived from a base hex (S/L taken from the hex).
109
140
  * Convenience wrapper over `harmonyColors` for callers holding a color, not
@@ -1,3 +1,4 @@
1
+ import { useCallback, useEffect, useRef } from 'react'
1
2
  import useScrollSpy from '../hooks/useScrollSpy.js'
2
3
 
3
4
  /**
@@ -12,16 +13,101 @@ import useScrollSpy from '../hooks/useScrollSpy.js'
12
13
  * default, smooth-scroll, close a mobile drawer). Labels render verbatim —
13
14
  * casing is authored at the call site.
14
15
  *
16
+ * TWO RENDERS, ONE CONTRACT (`variant`, 2026-09-03 — `docstoc-rail-variant`,
17
+ * kol-client-olina, which built and ran it before filing):
18
+ *
19
+ * - **`list`** (default) — the docs-sidebar column. No existing call moves.
20
+ * - **`rail`** — a fisheye rail pinned to a page edge (Bederson, *Fisheye
21
+ * Menus*, UIST 2000). One hairline per heading with its label beside it,
22
+ * magnified under the pointer on a gaussian curve, with inert graduations
23
+ * between the headings. It reads as a ruler and behaves as a dial.
24
+ *
25
+ * WHY THE RAIL IS NOT A STYLESHEET OVER THE LIST — three behaviours a CSS file
26
+ * over the flat render cannot express:
27
+ *
28
+ * 1. **The curve.** Each row's magnitude is `exp(-((focus − centre)/falloff)²)`,
29
+ * written to the row as `--m`; every visual derives from it in CSS (scale,
30
+ * opacity, rule width), so JS sets ONE number per row and the cascade does
31
+ * the rest. Gaussian, not linear: linear leaves the pointer a visible cone
32
+ * edge, this has none.
33
+ * 2. **The lens SNAPS.** It never rests between two marks — it locks to the
34
+ * nearest graduation and holds across that mark's whole band, so travelling
35
+ * the rail is a run of countable clicks rather than a smear that settles
36
+ * nowhere. The filer's test, verbatim: *"think about a lock picking thief,
37
+ * he counts the ticks right"*. Snapping to the labelled rows alone was tried
38
+ * and rejected — seven coarse stops, and it throws the ruler away. **The
39
+ * graduations are the clicks; the labels are where the numbers happen to be
40
+ * printed.**
41
+ * 3. **The graduations.** `minors` inert ticks between each pair of headings,
42
+ * carrying the same `--m` — which is what makes the movement read as a lens
43
+ * rather than rows blinking, and gives the rail a scale that seven bare
44
+ * strokes have none of. They are `aria-hidden`, not links, no tab stop: a
45
+ * screen reader wants the headings, not the seventy-seven marks.
46
+ *
47
+ * TWO THINGS THAT BIT THE FILER, kept here so they do not bite twice.
48
+ * `transform` does nothing on a non-replaced INLINE element — the label is a
49
+ * `<span>` and its scale was silently ignored until `display:inline-block`
50
+ * (which is why `.kol-toc-label` sets it in the theme). And transitions are OFF
51
+ * while the pointer drives: the rail carries `data-live` on pointer-enter and
52
+ * the CSS drops every transition under it, because a 120ms ease on top of a
53
+ * continuous input lags the cursor and reads as sluggish. They come back on
54
+ * leave so the wave settles instead of snapping.
55
+ *
56
+ * Row centres are measured on ENTER and on resize, never per pointer-move, and
57
+ * `--m` is written inside one rAF straight to the DOM. Reading geometry in a
58
+ * move handler is what makes this pattern jank; so is re-rendering React seven
59
+ * times a frame to animate seven numbers.
60
+ *
61
+ * HEADING DISCOVERY STAYS THE CONSUMER'S. `toc` is the contract for both
62
+ * variants — reading `<section id>` and its `<h2>` out of the DOM is app
63
+ * knowledge and does not belong in the package.
64
+ *
15
65
  * @param {Array<{id: string, label: string}>} toc headings to render + observe
66
+ * @param {'list'|'rail'} variant render (default 'list' — the docs-sidebar column)
16
67
  * @param {Function} onNavigate (event) => void — optional click handler on every link
17
68
  * @param {string} rootMargin IntersectionObserver rootMargin passed to the
18
69
  * spy. Default keeps the ported source's tighter
19
70
  * top band (useScrollSpy's own default is
20
71
  * '-30% 0px -60% 0px')
72
+ * @param {Element} root IntersectionObserver root passed to the spy
73
+ * @param {number} minors RAIL: graduations between one heading and the next — the dial's resolution, so literally how many clicks a heading is worth (default 10)
74
+ * @param {number} falloff RAIL: the gaussian's sigma in px, measured against the TICK pitch not the label pitch — at 10 graduations of 3px a section spans ~52px, so 14 crosses four or five clicks. Widen it and the whole column swells together, which is a column getting bigger rather than a lens moving over it (default 14)
75
+ * @param {'left'|'right'} position RAIL: which edge it pins to (default 'right')
76
+ * @param {number} minSections RAIL: render nothing under this many headings — an index of one is noise (default 2)
77
+ * @param {string} ariaLabel RAIL: the nav's accessible name (default 'On this page')
78
+ * @param {string} className RAIL: extra classes on the nav — where a consumer puts its own breakpoint (olina hides it under `xl`, where the sidenav is the navigation)
21
79
  */
22
- export default function DocsToc({ toc, onNavigate, rootMargin = '-80px 0px -80% 0px', root = null }) {
80
+ export default function DocsToc({
81
+ toc,
82
+ variant = 'list',
83
+ onNavigate,
84
+ rootMargin = '-80px 0px -80% 0px',
85
+ root = null,
86
+ minors = 10,
87
+ falloff = 14,
88
+ position = 'right',
89
+ minSections = 2,
90
+ ariaLabel = 'On this page',
91
+ className = '',
92
+ }) {
23
93
  const activeId = useScrollSpy(toc.map((item) => item.id), { rootMargin, root })
24
94
 
95
+ if (variant === 'rail') {
96
+ return (
97
+ <TocRail
98
+ toc={toc}
99
+ activeId={activeId}
100
+ onNavigate={onNavigate}
101
+ minors={minors}
102
+ falloff={falloff}
103
+ position={position}
104
+ minSections={minSections}
105
+ ariaLabel={ariaLabel}
106
+ className={className}
107
+ />
108
+ )
109
+ }
110
+
25
111
  return (
26
112
  <nav>
27
113
  <ul className="shell-nav-items">
@@ -52,3 +138,114 @@ export default function DocsToc({ toc, onNavigate, rootMargin = '-80px 0px -80%
52
138
  </nav>
53
139
  )
54
140
  }
141
+
142
+ /* TocRail — the fisheye render. A sibling component rather than a branch inside
143
+ * the default export, because it owns refs, three pointer handlers and an
144
+ * effect that the list has no use for; hooks that only ever run for one variant
145
+ * do not belong in the other's render path.
146
+ *
147
+ * The magnitudes go to the DOM as `--m`, never to state — see the header. */
148
+ function TocRail({ toc, activeId, onNavigate, minors, falloff, position, minSections, ariaLabel, className }) {
149
+ const navRef = useRef(null)
150
+ const centresRef = useRef([])
151
+ const frameRef = useRef(0)
152
+
153
+ /* Row centres in viewport coords. Measured on enter and on resize — the rail
154
+ * is fixed and its rows do not move while the pointer is inside it. EVERY
155
+ * child is a detent: labels and graduations alike. */
156
+ const measure = useCallback(() => {
157
+ const nav = navRef.current
158
+ if (!nav) return
159
+ centresRef.current = [...nav.children].map((el) => {
160
+ const r = el.getBoundingClientRect()
161
+ return r.top + r.height / 2
162
+ })
163
+ }, [])
164
+
165
+ const paint = useCallback((y) => {
166
+ const nav = navRef.current
167
+ if (!nav) return
168
+
169
+ /* DETENT, on EVERY graduation — the safecracker's dial. The lens locks to
170
+ * the nearest mark and holds across that mark's whole band, so dragging
171
+ * down the rail is a run of discrete clicks rather than a smear. */
172
+ const marks = centresRef.current
173
+ const focus = y == null || !marks.length
174
+ ? null
175
+ : marks.reduce((best, c) => (Math.abs(c - y) < Math.abs(best - y) ? c : best), marks[0])
176
+
177
+ marks.forEach((c, i) => {
178
+ const m = focus == null ? 0 : Math.exp(-(((focus - c) / falloff) ** 2))
179
+ nav.children[i]?.style.setProperty('--m', m.toFixed(3))
180
+ })
181
+ }, [falloff])
182
+
183
+ const onMove = useCallback((e) => {
184
+ /* Reduced motion: the curve is the animation, so there is nothing to damp —
185
+ * the lens simply does not run, and the theme's reduced-motion block gives
186
+ * every row its resting size. */
187
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
188
+ const y = e.clientY
189
+ cancelAnimationFrame(frameRef.current)
190
+ frameRef.current = requestAnimationFrame(() => paint(y))
191
+ }, [paint])
192
+
193
+ const onLeave = useCallback(() => {
194
+ cancelAnimationFrame(frameRef.current)
195
+ frameRef.current = requestAnimationFrame(() => paint(null))
196
+ }, [paint])
197
+
198
+ useEffect(() => {
199
+ paint(null)
200
+ window.addEventListener('resize', measure)
201
+ return () => {
202
+ window.removeEventListener('resize', measure)
203
+ cancelAnimationFrame(frameRef.current)
204
+ }
205
+ }, [measure, paint, toc])
206
+
207
+ if (toc.length < minSections) return null
208
+
209
+ return (
210
+ <nav
211
+ ref={navRef}
212
+ aria-label={ariaLabel}
213
+ data-position={position}
214
+ onPointerEnter={(e) => { measure(); e.currentTarget.dataset.live = '' }}
215
+ onPointerMove={onMove}
216
+ onPointerLeave={(e) => { delete e.currentTarget.dataset.live; onLeave() }}
217
+ className={`kol-toc kol-toc--${position} ${className}`.trim()}
218
+ >
219
+ {toc.flatMap(({ id, label }, i) => {
220
+ const active = id === activeId
221
+ const row = (
222
+ <a
223
+ key={id}
224
+ href={`#${id}`}
225
+ onClick={onNavigate}
226
+ aria-current={active ? 'true' : undefined}
227
+ data-major=""
228
+ data-active={active ? '' : undefined}
229
+ className="kol-toc-row"
230
+ >
231
+ {/* ONE rail voice (validate:rails R1). The filer's fork ran
232
+ * `kol-helper-12`, which is a second row ramp inside a rail and
233
+ * exactly what that gate exists to stop — the rung is the rail's,
234
+ * and the fisheye scales FROM it rather than replacing it. If 14
235
+ * proves too heavy under the lens, that is a change to the rail
236
+ * law and belongs in the docs before it belongs here. */}
237
+ <span className="kol-toc-label kol-mono-14 text-meta">{label}</span>
238
+ <span aria-hidden="true" className="kol-toc-rule" />
239
+ </a>
240
+ )
241
+ /* No graduations after the last heading — a ruler ends on a mark. */
242
+ if (i === toc.length - 1) return [row]
243
+ return [row, ...Array.from({ length: minors }, (_, k) => (
244
+ <span key={`${id}-tick-${k}`} aria-hidden="true" className="kol-toc-row kol-toc-tick">
245
+ <span className="kol-toc-rule" />
246
+ </span>
247
+ ))]
248
+ })}
249
+ </nav>
250
+ )
251
+ }
@@ -3,7 +3,7 @@ import { toneClass, TONE_VARS } from '../utilities/tone.js'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
4
  import { MenuDropdownItem } from './MenuItem.jsx'
5
5
  import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
6
- import { indicatorSize } from '../hooks/glyphLadders.js'
6
+ import { glyphSize, indicatorSize } from '../hooks/glyphLadders.js'
7
7
 
8
8
  /**
9
9
  * Dropdown — trigger IS button chrome (2026-07-08 chrome law).
@@ -77,6 +77,18 @@ const Dropdown = ({
77
77
  * while a row is hovered would otherwise leave the consumer previewing
78
78
  * forever. It never fires while closed — a closed dropdown has no rows. */
79
79
  onOptionHover,
80
+ /* ICON-ONLY TRIGGER — an icon name, or a pre-rendered node dropped in where
81
+ * the glyph goes. The trigger becomes the pinned square (`kol-btn-icon`) at
82
+ * the current size, with no label, no ghost widths and no caret; the panel
83
+ * still sizes to its own rows rather than the trigger. This is what a tool
84
+ * rail needs, and its absence is why kol-fxr's ToolPalette hand-rolls a
85
+ * trigger out of `PopoverPanel` + `usePopover` at a bespoke 36/22 instead of
86
+ * importing anything (`editor-set-is-behind-its-source`, 2026-09-03). */
87
+ iconOnly,
88
+ /* A mark drawn INSIDE the trigger box, over the glyph — the tool-palette
89
+ * corner fold is the case. Kept a slot rather than a boolean so the square
90
+ * has one implementation and the drawing stays the caller's. */
91
+ triggerAdornment,
80
92
  defaultOpen = false,
81
93
  className = ''
82
94
  }) => {
@@ -163,6 +175,12 @@ const Dropdown = ({
163
175
  `kol-btn-${resolvedSize}`,
164
176
  SIZE_TYPE[resolvedSize],
165
177
  'kol-dd-trigger',
178
+ /* ICON-ONLY: the pinned square from Button's own class, so a tool-rail
179
+ * dropdown is the same box as the icon button beside it at every rung
180
+ * (editor-set-is-behind-its-source, kol-fxr 2026-09-03 — its ToolPalette
181
+ * hand-rolls a trigger out of PopoverPanel + usePopover precisely because
182
+ * this mode did not exist). No label, no ghost widths, no caret. */
183
+ iconOnly && 'kol-btn-icon kol-dd-trigger--icon',
166
184
  isOpen && 'kol-dd-trigger--open',
167
185
  /* the dark chip on a washed plane; the panel continues it (ControlToneInverse, 2026-08-27) */
168
186
  toneClass(tone),
@@ -179,20 +197,33 @@ const Dropdown = ({
179
197
  aria-expanded={isOpen}
180
198
  data-state={isOpen ? 'open' : 'closed'}
181
199
  >
182
- {/* every option's label rides along hidden so the trigger is as wide
183
- * as its widest valuethe panel matches the trigger's width, so
184
- * trigger and list stay one piece at every selection */}
185
- <span className="kol-dd-label">
186
- <span>{currentOption?.label}</span>
187
- {options.map((option) => (
188
- <span key={option.value} className="kol-dd-ghost" aria-hidden="true">
189
- {option.label}
200
+ {iconOnly ? (
201
+ /* the glyph comes from the SOLO ladder an icon alone in a pinned
202
+ * square never from a call-site number */
203
+ typeof iconOnly === 'string'
204
+ ? <Icon name={iconOnly} size={glyphSize(resolvedSize, true)} />
205
+ : iconOnly
206
+ ) : (
207
+ <>
208
+ {/* every option's label rides along hidden so the trigger is as wide
209
+ * as its widest value — the panel matches the trigger's width, so
210
+ * trigger and list stay one piece at every selection */}
211
+ <span className="kol-dd-label">
212
+ <span>{currentOption?.label}</span>
213
+ {options.map((option) => (
214
+ <span key={option.value} className="kol-dd-ghost" aria-hidden="true">
215
+ {option.label}
216
+ </span>
217
+ ))}
190
218
  </span>
191
- ))}
192
- </span>
193
- {/* chrome lives in .kol-dd-caret (trailing edge + open-state flip) —
194
- * keyed off the trigger's data-state, no inline styles */}
195
- <Icon name="chevron-down" size={indicatorSize(resolvedSize)} className="kol-dd-caret" />
219
+ {/* chrome lives in .kol-dd-caret (trailing edge + open-state flip)
220
+ * keyed off the trigger's data-state, no inline styles */}
221
+ <Icon name="chevron-down" size={indicatorSize(resolvedSize)} className="kol-dd-caret" />
222
+ </>
223
+ )}
224
+ {/* a consumer's own trigger mark — the tool-palette corner fold rides
225
+ here so the square keeps ONE implementation */}
226
+ {triggerAdornment}
196
227
  </button>
197
228
 
198
229
  <PopoverPanel
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef } from 'react'
2
- import { HARMONIES, harmonyById, harmonyColors, normHue } from '../hooks/colorMath.js'
2
+ import { HARMONIES, harmonyById, harmonyColors, normHue, reHueSlots } from '../hooks/colorMath.js'
3
3
 
4
4
  /* taxonomy-ok: a pure-canvas hue + harmony picker in the SpectrumControls
5
5
  * color-picker family. It nests no KOL component — its only import is the
@@ -20,6 +20,16 @@ import { HARMONIES, harmonyById, harmonyColors, normHue } from '../hooks/colorMa
20
20
  * arrow keys. `colors` is one hex per role offset of the active harmony
21
21
  * (see colorMath.harmonyColors), matching the satellite markers 1:1.
22
22
  *
23
+ * PASS `slots` IF YOU ALREADY HAVE A PALETTE (2026-09-03,
24
+ * `editor-set-is-behind-its-source`). Without it, `colors` is built at ONE
25
+ * flat `saturation`/`lightness`, so a Light role and a Dark role both come
26
+ * back at 50% — the palette flattens on the first drag, and kol-fxr's editor
27
+ * had to ignore half the payload because its own wheel re-hues slot by slot.
28
+ * With `slots`, every entry keeps its own S/L and only its hue moves, and a
29
+ * `locked` or empty slot is passed through untouched. `onHueChange(hue)` is
30
+ * the same seam with no payload at all, for a caller that owns the derivation
31
+ * outright — which is what fxr's wheel emits.
32
+ *
23
33
  * The ring hues, marker outlines and handle halo are literal color math
24
34
  * (hsl / #FFFFFF / rgba) on purpose — a spectrum is not themeable, and the
25
35
  * markers sit on fully-saturated ring hues, not on the surface (same
@@ -31,7 +41,9 @@ import { HARMONIES, harmonyById, harmonyColors, normHue } from '../hooks/colorMa
31
41
  * @param {number} saturation base saturation for emitted colors, 0–100 (default 100)
32
42
  * @param {number} lightness base lightness for emitted colors, 0–100 (default 50)
33
43
  * @param {Array} harmonies injectable scheme table (default HARMONIES)
44
+ * @param {Array} slots the CURRENT palette in role order (`{hex, locked}` objects or plain hex strings). Given, `colors` re-hues these — each slot keeps its own S/L, locked and empty entries pass through — instead of generating flat ones
34
45
  * @param {Function} onChange ({ hue, colors }) => void
46
+ * @param {Function} onHueChange (hue) => void — the payload-free seam, for a caller that derives its own colours
35
47
  */
36
48
 
37
49
  /* Marker outline — white for contrast against the fully-saturated ring hues
@@ -44,8 +56,10 @@ export default function PaletteHarmonyWheel({
44
56
  harmony = 'analogous',
45
57
  saturation = 100,
46
58
  lightness = 50,
59
+ slots,
47
60
  harmonies = HARMONIES,
48
61
  onChange,
62
+ onHueChange,
49
63
  }) {
50
64
  const canvasRef = useRef(null)
51
65
  const draggingRef = useRef(false)
@@ -58,10 +72,18 @@ export default function PaletteHarmonyWheel({
58
72
  )
59
73
 
60
74
  /* Emit next hue + its harmony colors. Held in a ref so the pointer/key
61
- * handlers stay stable while always seeing the latest props. */
75
+ * handlers stay stable while always seeing the latest props.
76
+ *
77
+ * With `slots`, the colours are the CALLER'S palette re-hued — each slot
78
+ * keeping its own saturation and lightness — rather than a fresh flat set.
79
+ * Both fire, so a caller can take the hue and ignore the colours. */
62
80
  emitRef.current = (nextHue) => {
63
81
  const h = normHue(nextHue)
64
- onChange?.({ hue: h, colors: harmonyColors(h, active, { saturation, lightness }) })
82
+ const colors = slots?.length
83
+ ? reHueSlots(h, active, slots)
84
+ : harmonyColors(h, active, { saturation, lightness })
85
+ onChange?.({ hue: h, colors })
86
+ onHueChange?.(h)
65
87
  }
66
88
 
67
89
  const outerR = size / 2 - 8