@kolkrabbi/kol-component 0.185.0 → 0.187.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.185.0",
3
+ "version": "0.187.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
@@ -0,0 +1,319 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ /* THE SAME GRAB AS THE SHELL RAIL (OneGrabGestureBothRails, kol-fxr 2026-08-30 —
3
+ * user: *"why dont we use the grab animation and other sidenav settings to be
4
+ * consistent?"*). fxr's /labs shows both rails at once and only the left one
5
+ * woke as the cursor neared it, because each package had built the gesture
6
+ * itself. `useGrabEdge` lives in kol-component, the one package both can reach —
7
+ * kol-shell dropped its framework peer in 0.16.0, so neither could import the
8
+ * other's.
9
+ *
10
+ * THIS HOOK FOLLOWED IT DOWN 2026-09-03 (`editor-set-is-behind-its-source`,
11
+ * kol-fxr). `EditorShell` is a kol-component export whose rails are fixed-px
12
+ * and unresizable, and the fix is this hook — but kol-component may not import
13
+ * kol-framework (ARCHITECTURE §3, no reverse deps), so the shared hook moved to
14
+ * the tier both sides reach, exactly as its own comment argues for the gesture
15
+ * it wraps. kol-framework re-exports it under the same name, so `SideNav` and
16
+ * every consumer import keep working unchanged. */
17
+ import useGrabEdge from './useGrabEdge.js'
18
+
19
+ /* Grab-edge resize + collapse for SideNav — THE single control since 0.17.0
20
+ * (user build order 2026-08-09, completing the SideNavGrabResize brief: the
21
+ * chip Button is gone in both states; the pill-marked edge does everything).
22
+ * Logic lifted from the hand-tested brand proto (kol-website _tmp
23
+ * useDragResizeProto.js), which forked this hook's 0.16.0 form.
24
+ *
25
+ * - CLICK TOGGLES expand↔collapse: pointerup with < 3px of travel is a
26
+ * click, never a resize — drags only start past the slop, so toggle
27
+ * presses can't jitter the rail.
28
+ * - DRAG resizes; live width goes to --kol-sidenav-w on :root so the grid,
29
+ * shell-header brand block and aside follow one number. Dragging under
30
+ * --kol-sidenav-snap stamps :root[data-sidenav="collapsed"].
31
+ * - SNAP-TO-DEFAULT: releasing within --kol-sidenav-snap-default of the
32
+ * stylesheet default clears the override — you land exactly on default,
33
+ * never 249px or 263px.
34
+ * - DOUBLE-CLICK RESET IS GONE — it cannot coexist with click-toggle (two
35
+ * clicks would toggle-toggle-reset). Home keeps the reset; the snap band
36
+ * covers pointer users.
37
+ * - Keyboard on the focused separator: arrows resize by --kol-sidenav-step
38
+ * (ArrowLeft past the snap collapses), Home resets, Enter/Space toggle.
39
+ * - Width + state survive reload ('kol-sidenav' keeps the consumers'
40
+ * existing 'collapsed'|'expanded' schema; width under its own key).
41
+ *
42
+ * Every value the gesture needs is a --<token>-* custom property in the
43
+ * consumer's CSS — no literals here except the click slop, which is a
44
+ * gesture constant, not chrome. Missing tokens leave the gesture inert.
45
+ *
46
+ * SIDE-AGNOSTIC since ThreeColumnEditorShell (kol-fxr, 2026-08-15): the name
47
+ * family and the drag direction are both arguments now, so a right-hand
48
+ * inspector rail reuses this gesture — pointer, keyboard, snap, collapse and
49
+ * persistence — instead of reimplementing it. The bullets above describe the
50
+ * DEFAULT ('kol-sidenav', side 'left'), which is byte-identical to 0.17.0. */
51
+
52
+ const CLICK_SLOP_PX = 3
53
+
54
+ const root = () => document.documentElement
55
+
56
+ /* Every name the gesture touches, derived from ONE token (ThreeColumnEditorShell,
57
+ * filed from kol-fxr 2026-08-15). The default token reproduces the hardcoded
58
+ * 0.17.0 names EXACTLY — 'kol-sidenav' → data-sidenav, --kol-sidenav-w,
59
+ * storage 'kol-sidenav'/'kol-sidenav-w' — so SideNav and every existing caller
60
+ * are untouched by this generalisation.
61
+ *
62
+ * The data-attribute drops the `kol-` prefix because that is what the shipped
63
+ * CSS already selects (`:root[data-sidenav="collapsed"]`), not a new scheme. */
64
+ export function buildNames(token) {
65
+ const base = token.replace(/^kol-/, '')
66
+ return {
67
+ stateKey: token,
68
+ widthKey: `${token}-w`,
69
+ collapsedAttr: `data-${base}`,
70
+ draggingAttr: `data-${base}-dragging`,
71
+ wVar: `--${token}-w`,
72
+ collapsedVar: `--${token}-w-collapsed`,
73
+ snapVar: `--${token}-snap`,
74
+ stepVar: `--${token}-step`,
75
+ snapDefaultVar: `--${token}-snap-default`,
76
+ }
77
+ }
78
+
79
+ /* Resolve a length token to px, or null when it is absent/unparsable. */
80
+ function readVarPx(name) {
81
+ const raw = getComputedStyle(root()).getPropertyValue(name).trim()
82
+ const n = parseFloat(raw)
83
+ if (!raw || Number.isNaN(n)) return null
84
+ return raw.endsWith('rem') ? n * parseFloat(getComputedStyle(root()).fontSize) : n
85
+ }
86
+
87
+ /* Imperative DOM writes — pointermove must never re-render the nav tree.
88
+ * React state syncs from the DOM at rest (release / key press / reset). */
89
+ const stampCollapsed = (n, on) => {
90
+ if (on) root().setAttribute(n.collapsedAttr, 'collapsed')
91
+ else root().removeAttribute(n.collapsedAttr)
92
+ }
93
+ const writeWidth = (n, px) => {
94
+ if (px == null) root().style.removeProperty(n.wVar)
95
+ else root().style.setProperty(n.wVar, `${px}px`)
96
+ }
97
+ const readBack = (n) => {
98
+ const inline = parseFloat(root().style.getPropertyValue(n.wVar))
99
+ return {
100
+ collapsed: root().getAttribute(n.collapsedAttr) === 'collapsed',
101
+ widthPx: Number.isNaN(inline) ? null : inline,
102
+ }
103
+ }
104
+
105
+ /* @param ref the panel being resized — its measured width seeds the drag
106
+ * @param options { token, side }
107
+ * token — the CSS/storage name family. Default 'kol-sidenav' (SideNav).
108
+ * A right-hand inspector passes its own, e.g. 'kol-rail', so the two
109
+ * rails never share one :root variable and drag together.
110
+ * side — which EDGE the grab handle sits on. 'left' (default) is a rail on
111
+ * the left of the viewport whose handle is on its right edge, so
112
+ * rightward drag = wider. 'right' inverts both the pointer sign and
113
+ * the arrow keys.
114
+ * persistWidth — remember a dragged WIDTH across sessions. **Default false**
115
+ * (sidenav-drag-width-outranks-breakpoints, kol-client-olina
116
+ * 2026-09-03; user: *"why would you want that in persistent memory?
117
+ * and even if you did, shouldnt it be an opt in via prop? off by
118
+ * default"*).
119
+ *
120
+ * It used to be unconditional, and the width was replayed on boot as
121
+ * an INLINE custom property on `:root` — which outranks every
122
+ * stylesheet rule, so one drag on a laptop killed both shipped rungs
123
+ * (`--kol-sidenav-w: 264px` and the 320px `min-width:1536px` rule)
124
+ * permanently, on every machine, with no UI saying so and no selector
125
+ * a consumer could beat. Measured at 1600×900: a stored 210 rendered
126
+ * 210 and the 1536 rung did nothing. A one-off gesture had become a
127
+ * permanent global.
128
+ *
129
+ * Off, a drag still works and lasts the session; the rail follows the
130
+ * stylesheet and its breakpoints on the next boot. On, the old
131
+ * behaviour returns for an app that genuinely wants the rail
132
+ * remembered — and it is still an inline stamp, so an app opting in is
133
+ * choosing to outrank its own breakpoints. That precedence is worth
134
+ * inverting, but it is a bigger change than this ticket asked for.
135
+ *
136
+ * STATE is untouched: collapsed/expanded still persists via
137
+ * `stateKey`, unconditionally, as it always did. The ticket says the
138
+ * two are separate questions and only asks about the width. */
139
+ export default function useDragResize(ref, options = {}) {
140
+ const { token = 'kol-sidenav', side = 'left', defaultCollapsed = false, persistWidth = false } = options
141
+ /* -1 on a right-hand rail: the same rightward pointer travel that widens a
142
+ * left rail must NARROW a right one, because its handle faces the canvas. */
143
+ const dir = side === 'right' ? -1 : 1
144
+ const names = useMemo(() => buildNames(token), [token])
145
+
146
+ /* the handle's own ref, so the shared gesture can find it. Returned in
147
+ * `grabProps`, so a consumer already spreading those gets the wake, the
148
+ * travel and the dwell with no change. */
149
+ const grabRef = useRef(null)
150
+ useGrabEdge(grabRef)
151
+ const drag = useRef(null) // { startX, startW, snapPx, maxPx, moved } during a drag
152
+ const defaultPx = useRef(null)
153
+ const collapsedPx = useRef(null)
154
+ const [collapsed, setCollapsed] = useState(false)
155
+ const [widthPx, setWidthPx] = useState(null) // null = stylesheet default
156
+
157
+ const syncAndPersist = () => {
158
+ const { collapsed: c, widthPx: w } = readBack(names)
159
+ setCollapsed(c)
160
+ setWidthPx(w)
161
+ try {
162
+ localStorage.setItem(names.stateKey, c ? 'collapsed' : 'expanded')
163
+ /* width only when asked for; off, the key is not written AND any key a
164
+ * previous version left behind is cleared, so a consumer that bumps stops
165
+ * replaying a width it never opted into */
166
+ if (!persistWidth || w == null) localStorage.removeItem(names.widthKey)
167
+ else localStorage.setItem(names.widthKey, String(Math.round(w)))
168
+ } catch { /* storage blocked */ }
169
+ }
170
+
171
+ const toggleCollapsed = () => {
172
+ const { collapsed: c } = readBack(names)
173
+ stampCollapsed(names, !c)
174
+ syncAndPersist()
175
+ }
176
+
177
+ /* Boot: capture the stylesheet defaults BEFORE any inline override lands,
178
+ * then restore the persisted width/state. */
179
+ useEffect(() => {
180
+ defaultPx.current = readVarPx(names.wVar)
181
+ collapsedPx.current = readVarPx(names.collapsedVar)
182
+ let w = null
183
+ let stored = null
184
+ try {
185
+ /* off → never read it, and drop a key an earlier version wrote */
186
+ if (persistWidth) w = parseFloat(localStorage.getItem(names.widthKey)) || null
187
+ else localStorage.removeItem(names.widthKey)
188
+ stored = localStorage.getItem(names.stateKey)
189
+ } catch { /* storage blocked */ }
190
+ /* nothing stored → the consumer's boot state (RailSideNavPixelParity,
191
+ * 2026-08-28): an app rail boots collapsed, the brand sidebar boots open;
192
+ * the first drag or click persists and the default never speaks again */
193
+ const c = stored ? stored === 'collapsed' : !!defaultCollapsed
194
+ if (w) { writeWidth(names, w); setWidthPx(w) }
195
+ if (c) { stampCollapsed(names, true); setCollapsed(true) }
196
+ }, [names, defaultCollapsed, persistWidth])
197
+
198
+ useEffect(() => {
199
+ const onMove = (e) => {
200
+ if (!drag.current) return
201
+ const d = drag.current
202
+ const dx = e.clientX - d.startX
203
+ /* Below the slop the pointer is still a CLICK — resizing from the
204
+ * first pixel would jitter the rail on every toggle press. */
205
+ if (!d.moved) {
206
+ if (Math.abs(dx) < CLICK_SLOP_PX) return
207
+ d.moved = true
208
+ }
209
+ const next = d.startW + dx * dir
210
+ if (next < d.snapPx) {
211
+ stampCollapsed(names, true)
212
+ } else {
213
+ stampCollapsed(names, false)
214
+ writeWidth(names, Math.min(next, d.maxPx))
215
+ }
216
+ }
217
+ const onUp = () => {
218
+ if (!drag.current) return
219
+ const { moved } = drag.current
220
+ drag.current = null
221
+ root().removeAttribute(names.draggingAttr)
222
+ document.body.style.cursor = ''
223
+ document.body.style.userSelect = ''
224
+ if (!moved) { toggleCollapsed(); return } // a click, not a drag
225
+ /* Snap-to-default: release near the stylesheet default clears the
226
+ * override entirely. */
227
+ const { collapsed: c, widthPx: w } = readBack(names)
228
+ const band = readVarPx(names.snapDefaultVar) ?? readVarPx(names.stepVar) ?? 16
229
+ if (!c && w != null && defaultPx.current != null && Math.abs(w - defaultPx.current) <= band) {
230
+ writeWidth(names, null)
231
+ }
232
+ syncAndPersist()
233
+ }
234
+ window.addEventListener('pointermove', onMove)
235
+ window.addEventListener('pointerup', onUp)
236
+ return () => {
237
+ window.removeEventListener('pointermove', onMove)
238
+ window.removeEventListener('pointerup', onUp)
239
+ }
240
+ }, [names, dir])
241
+
242
+ const onPointerDown = (e) => {
243
+ const snapPx = readVarPx(names.snapVar)
244
+ if (defaultPx.current == null || snapPx == null) return // tokens absent → inert
245
+ e.preventDefault()
246
+ drag.current = {
247
+ startX: e.clientX,
248
+ startW: ref.current?.getBoundingClientRect().width ?? defaultPx.current,
249
+ snapPx,
250
+ /* mirror's ceiling (default × 3), resolved from the token not hardcoded */
251
+ maxPx: defaultPx.current * 3,
252
+ moved: false,
253
+ }
254
+ /* the grid's grid-template-columns ease would trail the pointer —
255
+ * kol-framework.css suspends it while this attribute is stamped */
256
+ root().setAttribute(names.draggingAttr, '')
257
+ document.body.style.cursor = 'col-resize'
258
+ document.body.style.userSelect = 'none'
259
+ }
260
+
261
+ const resetToDefault = () => {
262
+ stampCollapsed(names, false)
263
+ writeWidth(names, null)
264
+ syncAndPersist()
265
+ }
266
+
267
+ const onKeyDown = (e) => {
268
+ const snapPx = readVarPx(names.snapVar)
269
+ const stepPx = readVarPx(names.stepVar)
270
+ if (defaultPx.current == null || snapPx == null || stepPx == null) return
271
+ const { collapsed: c, widthPx: w } = readBack(names)
272
+ const current = w ?? defaultPx.current
273
+ /* The arrow that GROWS is the one pointing away from the rail's own edge —
274
+ * ArrowRight on a left rail, ArrowLeft on a right one. Same inversion the
275
+ * pointer gets, so keyboard and drag never disagree. */
276
+ const growKey = dir === 1 ? 'ArrowRight' : 'ArrowLeft'
277
+ const shrinkKey = dir === 1 ? 'ArrowLeft' : 'ArrowRight'
278
+ if (e.key === growKey) {
279
+ e.preventDefault()
280
+ if (c) stampCollapsed(names, false)
281
+ else writeWidth(names, Math.min(current + stepPx, defaultPx.current * 3))
282
+ syncAndPersist()
283
+ } else if (e.key === shrinkKey) {
284
+ e.preventDefault()
285
+ if (c) return
286
+ const next = current - stepPx
287
+ if (next < snapPx) stampCollapsed(names, true)
288
+ else writeWidth(names, next)
289
+ syncAndPersist()
290
+ } else if (e.key === 'Home') {
291
+ e.preventDefault()
292
+ resetToDefault()
293
+ } else if (e.key === 'Enter' || e.key === ' ') {
294
+ e.preventDefault()
295
+ toggleCollapsed()
296
+ }
297
+ }
298
+
299
+ return {
300
+ collapsed,
301
+ toggleCollapsed,
302
+ grabProps: {
303
+ ref: grabRef,
304
+ /* the pill is drawn by `.kol-rail-grab` (kol-animation.css) — the hook
305
+ * only supplies `is-near` and `--kol-rail-grab-y`. A consumer's own
306
+ * className, spread after this, still wins. */
307
+ className: 'kol-rail-grab',
308
+ role: 'separator',
309
+ 'aria-orientation': 'vertical',
310
+ 'aria-label': 'Resize navigation',
311
+ 'aria-valuenow': Math.round(collapsed ? collapsedPx.current : (widthPx ?? defaultPx.current)) || undefined,
312
+ 'aria-valuemin': collapsedPx.current == null ? undefined : Math.round(collapsedPx.current),
313
+ 'aria-valuemax': defaultPx.current == null ? undefined : Math.round(defaultPx.current * 3),
314
+ tabIndex: 0,
315
+ onPointerDown,
316
+ onKeyDown,
317
+ },
318
+ }
319
+ }
package/src/index.js CHANGED
@@ -193,6 +193,11 @@ export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
193
193
  export { default as useInViewAttention } from './hooks/useInViewAttention.js'
194
194
  export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
195
195
  export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
196
+ /* The rail gesture's other half. It lived in kol-framework until 2026-09-03 and
197
+ * moved here for the same reason `useGrabEdge` did: kol-component's own
198
+ * `EditorShell` needs resizable rails and cannot import framework. framework
199
+ * re-exports it, so no consumer specifier changed. */
200
+ export { default as useDragResize } from './hooks/useDragResize.js'
196
201
  export { default as usePlaceholders } from './hooks/usePlaceholders.js'
197
202
  export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
198
203
 
@@ -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
@@ -1,4 +1,6 @@
1
+ import { useRef } from 'react'
1
2
  import Divider from '../atoms/Divider.jsx'
3
+ import useDragResize from '../hooks/useDragResize.js'
2
4
 
3
5
  /**
4
6
  * EditorShell — the two-rail editor layout frame.
@@ -7,33 +9,56 @@ import Divider from '../atoms/Divider.jsx'
7
9
  * ├────────┬──────────────────────────┬──────────┤
8
10
  * │ left │ [canvasHeader] │ right │
9
11
  * │ rail │ children (canvas) │ rail │
12
+ * │ │ [canvasFooter] │ │
10
13
  * └────────┴──────────────────────────┴──────────┘
11
14
  *
12
- * Fixed-width rails flank a fluid canvas column, all under an optional topbar.
13
- * Hairlines between regions are composed from the DS `Divider` (vertical
14
- * between rails/canvas, horizontal under the topbar and rail/canvas headers).
15
- * Headers render only when their slot is filled, so an unused header
16
- * contributes no border or gap (the source's `:empty` collapse, expressed as a
17
- * conditional render instead of a CSS rule).
15
+ * Rails flank a fluid canvas column, all under an optional topbar. Hairlines
16
+ * between regions are composed from the DS `Divider`. Headers and footers
17
+ * render only when their slot is filled, so an unused one contributes no
18
+ * border or gap (the source's `:empty` collapse, expressed as a conditional
19
+ * render instead of a CSS rule).
18
20
  *
19
- * Ported from the brand editor with the app couplings dropped (per lobby
21
+ * Ported from kol-fxr's editor with the app couplings dropped (per lobby
20
22
  * spec): the panel-registry + `panelsForSlot`/`SLOTS` indirection is replaced
21
- * by plain ReactNode slots (`left` / `right` / `children` + optional headers,
22
- * topbar, overlays); the `MenuTop` / `ShortcutsOverlay` imports become the
23
- * `topbar` / `overlays` slots; the editor stylesheet import is gone (layout is
24
- * Tailwind + composed Divider); the `#0E0E11` dark canvas is a `canvasBg` prop.
25
- * `data-editor-keep-selection` stays as an opt-in click-away hook, not baked
26
- * behavior.
23
+ * by plain ReactNode slots; the `MenuTop` / `ShortcutsOverlay` imports become
24
+ * the `topbar` / `overlays` slots; the editor stylesheet import is gone; the
25
+ * `#0E0E11` dark canvas is a `canvasBg` prop. `data-editor-keep-selection`
26
+ * stays as an opt-in click-away hook, not baked behavior.
27
+ *
28
+ * THREE GAPS CLOSED 2026-09-03 (`editor-set-is-behind-its-source`, kol-fxr,
29
+ * which adopted this and reverted):
30
+ *
31
+ * 1. **The rails are CSS-width now.** `railWidth` was a px number written
32
+ * straight onto the element, so a consumer stylesheet had nothing to target
33
+ * and the rail could not follow a breakpoint. Each rail's width now reads
34
+ * `var(--kol-editor-{side}-w, {railWidth}px)`, so the prop is the default
35
+ * and CSS — a media query, a consumer's own rule, a drag — wins over it.
36
+ * 2. **`resizable` gives both rails the estate's grab gesture** through
37
+ * `useDragResize`, the same hook `SideNav` and kol-shell's `NavRail` wear
38
+ * (that hook moved from kol-framework to kol-component in this same pass —
39
+ * a component-tier shell cannot import framework, ARCHITECTURE §3). Each
40
+ * rail owns its own token, so the two never drag together.
41
+ * 3. **The `.kol-editor-*` class hooks are emitted**, which is what a
42
+ * consumer's stylesheet targets (fxr's `kol-labs.css` styles this frame by
43
+ * name). They are HOOKS, not styling: the layout stays here in Tailwind, so
44
+ * a consumer without those rules renders identically.
45
+ *
46
+ * Footer slots (`leftFooter`, `rightFooter`, `canvasFooter`) also came back —
47
+ * the source fills all three and the port had none.
27
48
  *
28
49
  * @param {ReactNode} topbar top bar spanning the full width (optional)
29
50
  * @param {ReactNode} leftHeader left rail header (optional; renders a hairline when set)
30
51
  * @param {ReactNode} left left rail body (scrolls independently)
52
+ * @param {ReactNode} leftFooter left rail footer, pinned under the scrolling body (optional)
31
53
  * @param {ReactNode} canvasHeader sub-bar spanning only the canvas column, e.g. a tool palette (optional)
32
54
  * @param {ReactNode} children the canvas region (fills the fluid column)
55
+ * @param {ReactNode} canvasFooter bar under the canvas, e.g. a timeline or a status line (optional)
33
56
  * @param {ReactNode} rightHeader right rail header (optional)
34
57
  * @param {ReactNode} right right rail body (scrolls independently)
58
+ * @param {ReactNode} rightFooter right rail footer (optional)
35
59
  * @param {ReactNode} overlays floating overlays rendered above the frame (optional)
36
- * @param {number} railWidth rail column width in px (default 320)
60
+ * @param {number} railWidth DEFAULT rail width in px (default 320) — the fallback in `var(--kol-editor-{side}-w, …)`, so CSS and a drag both outrank it
61
+ * @param {boolean} resizable give both rails the drag-resize grab edge (default false)
37
62
  * @param {string} canvasBg canvas column background (default var(--kol-surface-primary))
38
63
  * @param {string|number} height shell height (default '100dvh'; pass a bounded value to embed)
39
64
  * @param {string} className extra classes merged onto the shell root
@@ -42,12 +67,16 @@ export default function EditorShell({
42
67
  topbar,
43
68
  leftHeader,
44
69
  left,
70
+ leftFooter,
45
71
  canvasHeader,
46
72
  children,
73
+ canvasFooter,
47
74
  rightHeader,
48
75
  right,
76
+ rightFooter,
49
77
  overlays,
50
78
  railWidth = 320,
79
+ resizable = false,
51
80
  canvasBg = 'var(--kol-surface-primary)',
52
81
  height = '100dvh',
53
82
  className = '',
@@ -55,7 +84,7 @@ export default function EditorShell({
55
84
  return (
56
85
  <div
57
86
  data-editor-keep-selection
58
- className={`flex flex-col overflow-hidden bg-surface-primary ${className}`.trim()}
87
+ className={`kol-editor-shell flex flex-col overflow-hidden bg-surface-primary ${className}`.trim()}
59
88
  style={{ height }}
60
89
  >
61
90
  {topbar && (
@@ -65,27 +94,37 @@ export default function EditorShell({
65
94
  </>
66
95
  )}
67
96
 
68
- <div className="flex flex-1 min-h-0">
69
- <Rail header={leftHeader} width={railWidth}>{left}</Rail>
97
+ <div className="kol-editor-grid flex flex-1 min-h-0">
98
+ <Rail side="left" header={leftHeader} footer={leftFooter} width={railWidth} resizable={resizable}>
99
+ {left}
100
+ </Rail>
70
101
  <Divider variant="vertical" />
71
102
 
72
- <div className="flex flex-col flex-1 min-w-0 min-h-0">
103
+ <div className="kol-editor-canvas-column flex flex-col flex-1 min-w-0 min-h-0">
73
104
  {canvasHeader && (
74
105
  <>
75
- <div className="shrink-0">{canvasHeader}</div>
106
+ <div className="kol-editor-canvas-header shrink-0">{canvasHeader}</div>
76
107
  <Divider />
77
108
  </>
78
109
  )}
79
110
  <main
80
- className="flex-1 min-h-0 select-none"
111
+ className="kol-editor-canvas flex-1 min-h-0 select-none"
81
112
  style={{ background: canvasBg }}
82
113
  >
83
114
  {children}
84
115
  </main>
116
+ {canvasFooter && (
117
+ <>
118
+ <Divider />
119
+ <div className="kol-editor-canvas-footer shrink-0">{canvasFooter}</div>
120
+ </>
121
+ )}
85
122
  </div>
86
123
 
87
124
  <Divider variant="vertical" />
88
- <Rail header={rightHeader} width={railWidth}>{right}</Rail>
125
+ <Rail side="right" header={rightHeader} footer={rightFooter} width={railWidth} resizable={resizable}>
126
+ {right}
127
+ </Rail>
89
128
  </div>
90
129
 
91
130
  {overlays}
@@ -93,19 +132,50 @@ export default function EditorShell({
93
132
  )
94
133
  }
95
134
 
96
- /* Rail — fixed-width aside with an optional header (+ hairline) over a
97
- * scrolling body. `min-h-0` lets the body's overflow scroll instead of
98
- * stretching the whole shell. */
99
- function Rail({ header, width, children }) {
135
+ /* Rail — an aside with an optional header (+ hairline) over a scrolling body,
136
+ * and an optional footer pinned under it. `min-h-0` lets the body's overflow
137
+ * scroll instead of stretching the whole shell.
138
+ *
139
+ * The width is a CSS custom property with the prop as its fallback, so the
140
+ * cascade can move it; `useDragResize` writes that same property while
141
+ * dragging. Each side carries its own token — `kol-editor-left` /
142
+ * `kol-editor-right` — because two rails sharing one `:root` variable drag
143
+ * together, which is the bug the hook's own docs record. */
144
+ function Rail({ side, header, footer, width, resizable, children }) {
145
+ /* The hook measures the rail it resizes (`ref.current.getBoundingClientRect`
146
+ * seeds the drag), so this is the element ref, not the handle's — the handle
147
+ * gets its own from `grabProps`. */
148
+ const railRef = useRef(null)
149
+ const { grabProps } = useDragResize(railRef, {
150
+ token: `kol-editor-${side}`,
151
+ /* which EDGE the handle sits on: a left rail's handle faces the canvas on
152
+ * its right, so rightward drag widens; a right rail inverts both. */
153
+ side,
154
+ })
155
+
100
156
  return (
101
- <aside className="flex flex-col min-h-0 shrink-0" style={{ width }}>
157
+ <aside
158
+ ref={railRef}
159
+ className={`kol-editor-${side} relative flex flex-col min-h-0 shrink-0`}
160
+ style={{ width: `var(--kol-editor-${side}-w, ${typeof width === 'number' ? `${width}px` : width})` }}
161
+ >
102
162
  {header && (
103
163
  <>
104
- <div className="shrink-0">{header}</div>
164
+ <div className="kol-editor-rail-header shrink-0">{header}</div>
165
+ <Divider />
166
+ </>
167
+ )}
168
+ <div className="kol-editor-rail-body flex-1 min-h-0 overflow-y-auto">{children}</div>
169
+ {footer && (
170
+ <>
105
171
  <Divider />
172
+ <div className="kol-editor-rail-footer shrink-0">{footer}</div>
106
173
  </>
107
174
  )}
108
- <div className="flex-1 min-h-0 overflow-y-auto">{children}</div>
175
+ {/* `.kol-rail-grab` is the drawing (kol-animation.css); the hook supplies
176
+ the proximity wake and the travel. Rendered only when asked, so a
177
+ static shell has no extra hit area over its rail edge. */}
178
+ {resizable && <div {...grabProps} className={`kol-rail-grab kol-rail-grab--${side}`} />}
109
179
  </aside>
110
180
  )
111
181
  }