@kolkrabbi/kol-component 0.165.0 → 0.167.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.165.0",
3
+ "version": "0.167.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",
@@ -47,7 +47,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
47
47
  * confirm only fires once it resolves
48
48
  * @param {string} href renders an <a> instead of a <button>
49
49
  * @param {number} hold ms to hold the confirm state (default 2000)
50
- * @param {string} size sm | md | lg — the pinned square (28/32/36)
50
+ * @param {string} size xs | sm | md | lg — the pinned square (22/26/32/40)
51
51
  * and its SOLO glyph (16/20/24) move together,
52
52
  * resolved from hooks/glyphLadders.js. This was
53
53
  * a raw px number and defaulted to 16 in a 32px
@@ -28,7 +28,7 @@ import { toneClass } from '../utilities/tone.js'
28
28
  * no per-theme props.
29
29
  *
30
30
  * `size` moves the square and the glyph together on the solo-glyph ladder
31
- * (16/20/24 against the pinned squares 28/32/36) — that pairing is the DEFAULT,
31
+ * (16/20/24 against the pinned squares 22/26/32/40) — that pairing is the DEFAULT,
32
32
  * and it is what every call site should take. `iconSize` unbinds the glyph for
33
33
  * the cases the ladder cannot serve, exactly as it does on `Button` and `Input`:
34
34
  * a `radius="full"` frame reads heavier than the square it was tuned against (a
@@ -37,7 +37,7 @@
37
37
  * 'tonal' (filled tiles, but the clicked cell marks itself by
38
38
  * TONE — surface-tertiary fill, no ring; 2026-08-12).
39
39
  * Every variant renders at the SAME pinned button-ladder
40
- * height (26/32/40) — icon or text, the box never moves.
40
+ * height (22/26/32/40) — icon or text, the box never moves.
41
41
  * size — mirrors Button exactly: 'sm' (26px, mono-12, 4/12 pad) |
42
42
  * 'md' (default, 32px, mono-14, 6/16 pad) | 'lg' (40px,
43
43
  * mono-16, 8/20 pad). Same cell padding + mono type as the
@@ -7,12 +7,12 @@
7
7
  *
8
8
  * The ladders split on ONE question: is there a label beside the glyph?
9
9
  *
10
- * SOLO an icon alone in a pinned square (28/32/36) — takes the room
10
+ * SOLO an icon alone in a pinned square (22/26/32/40) — takes the room
11
11
  * ADJACENT an icon inside a rung's line box, beside a label
12
12
  *
13
13
  * Stated in the DS before this file existed — ThemeToggle.jsx:40 ("solo
14
14
  * 16/20/24, the pinned-square pairing") and IconFrame.jsx ("the solo-glyph
15
- * ladder against the pinned squares 28/32/36"). This file is where they stop
15
+ * ladder against the pinned squares"). This file is where they stop
16
16
  * being transcribed.
17
17
  *
18
18
  * Why it exists: the same two ladders were re-typed in four places
@@ -23,7 +23,7 @@
23
23
  * carrying a label) and nothing connected the two.
24
24
  */
25
25
 
26
- /** Icon alone in a pinned square. Pairs with squares 28 · 32 · 36. */
26
+ /** Icon alone in a pinned square. Pairs with squares 22 · 26 · 32 · 40. */
27
27
  export const SOLO = { xs: 12, sm: 16, md: 20, lg: 24 }
28
28
 
29
29
  /** Icon beside a label, inside the rung's line box. */
@@ -25,11 +25,49 @@ import gsap from 'gsap'
25
25
  * than that leaves the pill where it is, which is what stops it twitching.
26
26
  *
27
27
  * The element needs the `.kol-rail-grab` class for the drawing (kol-animation.css);
28
- * this hook only supplies `is-near` and `--kol-rail-grab-y`.
28
+ * this hook only supplies `is-near` and the travel variable.
29
+ *
30
+ * BOTH AXES (BrowsePageRulingsAndSeams, kol-r2b2 2026-09-02). A rail has one
31
+ * vertical edge; `ColumnBrowser` has a vertical handle per column AND a
32
+ * horizontal one along its foot, and the user's ruling is that they wear the
33
+ * same gesture. `axis` says which way the pill TRAVELS, and the near test is
34
+ * always the other one — a vertical line is approached across x, a horizontal
35
+ * line across y:
36
+ *
37
+ * axis: 'y' (default) vertical edge · near on x · travels on y · --kol-rail-grab-y
38
+ * axis: 'x' horizontal edge · near on y · travels on x · --kol-rail-grab-x
39
+ *
40
+ * The `-y` name is kept as it shipped rather than renamed to something
41
+ * axis-neutral: it is a published token the rail's CSS reads, and the pair
42
+ * `-x`/`-y` names two real axes rather than one concept twice.
43
+ *
44
+ * TUNING IS PER-EDGE (BrowsePageRulingsAndSeams, 2026-09-02 — user: *"its a bit
45
+ * different, its not like the sidenav"*). The rail's dwell-and-throw is its own
46
+ * ruling, not a house constant every edge inherits: `ColumnBrowser`'s handles
47
+ * were ruled on their own feel in kol-r2b2 — a longer chase and a much smaller
48
+ * retarget threshold, so the pill tracks the pointer closely instead of landing
49
+ * and holding. `GRAB` stays the default, so the rail does not move.
29
50
  *
30
51
  * @param {React.RefObject<HTMLElement>} ref the grab handle
52
+ * EVERY tuning key the constants carry is an option, so a whole set spreads in
53
+ * (`useGrabEdge(ref, { axis, ...GRAB_COLUMN })`) with nothing silently dropped
54
+ * — a key the hook did not read would be a seam wired to nothing.
55
+ *
56
+ * @param {{axis?: 'x'|'y', near?: number, sleep?: number, travel?: object, stick?: number, range?: number}} [options]
57
+ * `axis` — which way the pill travels (default 'y').
58
+ * `near` / `sleep` — wake and sleep distance from the line, the hysteresis pair.
59
+ * `travel` — the gsap chase (default `GRAB.travel`).
60
+ * `stick` — px the pointer must move before the pill re-targets (default `GRAB.stick`).
61
+ * `range` — the travel band as a fraction of the edge (default `GRAB.range`).
31
62
  */
32
- export default function useGrabEdge(ref) {
63
+ export default function useGrabEdge(ref, {
64
+ axis = 'y',
65
+ near: nearAt = GRAB.near,
66
+ sleep = GRAB.sleep,
67
+ travel = GRAB.travel,
68
+ stick = GRAB.stick,
69
+ range = GRAB.range,
70
+ } = {}) {
33
71
  useEffect(() => {
34
72
  let raf = 0
35
73
  const onMove = ({ clientX, clientY }) => {
@@ -39,23 +77,29 @@ export default function useGrabEdge(ref) {
39
77
  const h = ref.current
40
78
  if (!h) return
41
79
  const r = h.getBoundingClientRect()
42
- const dist = Math.abs(clientX - (r.left + r.width / 2))
43
- const near = dist <= GRAB.near || (h.classList.contains('is-near') && dist <= GRAB.sleep)
80
+ const vertical = axis === 'y'
81
+ /* the near test is ACROSS the line, the travel ALONG it */
82
+ const across = vertical ? clientX - (r.left + r.width / 2) : clientY - (r.top + r.height / 2)
83
+ const dist = Math.abs(across)
84
+ const near = dist <= nearAt || (h.classList.contains('is-near') && dist <= sleep)
44
85
  h.classList.toggle('is-near', near)
45
86
  if (!near) return
46
- /* the travel band: bipolar from the middle, GRAB.range of the height */
47
- const edge = (r.height * (1 - GRAB.range)) / 2
48
- const along = Math.min(Math.max(clientY - r.top, edge), r.height - edge)
49
- if (h.dataset.grabSeeded && Math.abs(along - Number(h.dataset.grabTarget)) < GRAB.stick) return
87
+ /* the travel band: bipolar from the middle, `range` of the span */
88
+ const span = vertical ? r.height : r.width
89
+ const start = vertical ? r.top : r.left
90
+ const point = vertical ? clientY : clientX
91
+ const edge = (span * (1 - range)) / 2
92
+ const along = Math.min(Math.max(point - start, edge), span - edge)
93
+ if (h.dataset.grabSeeded && Math.abs(along - Number(h.dataset.grabTarget)) < stick) return
50
94
  h.dataset.grabTarget = String(along)
51
- const vars = { '--kol-rail-grab-y': `${along}px` }
95
+ const vars = { [vertical ? '--kol-rail-grab-y' : '--kol-rail-grab-x']: `${along}px` }
52
96
  /* the CSS fallback is 50%, a percentage — nothing to tween from, so the
53
97
  * first sighting sets and every move after tweens */
54
- if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...GRAB.travel, overwrite: 'auto' })
98
+ if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...travel, overwrite: 'auto' })
55
99
  else { gsap.set(h, vars); h.dataset.grabSeeded = '1' }
56
100
  })
57
101
  }
58
102
  window.addEventListener('pointermove', onMove)
59
103
  return () => { window.removeEventListener('pointermove', onMove); cancelAnimationFrame(raf) }
60
- }, [ref])
104
+ }, [ref, axis, nearAt, sleep, travel, stick, range])
61
105
  }
@@ -103,7 +103,7 @@ export default function SearchInput({
103
103
  if (expanding && isOpen) inputRef.current?.focus()
104
104
  }, [expanding, isOpen])
105
105
 
106
- /* the pinned squares, 05-control-chrome.md — sm 28 · md 32 · lg 36 */
106
+ /* the pinned squares, 01-foundations/09-sizes.md — 22 · 26 · 32 · 40 */
107
107
  const square = { sm: 28, md: 32, lg: 36 }[size] ?? 32
108
108
  /* THE OPEN FIELD'S HEIGHT IS ITS OWN KNOB, defaulting to the square.
109
109
  *
@@ -154,7 +154,7 @@ export default function SearchInput({
154
154
  collapsed pill rendered as a filled circle next to a bare glyph and
155
155
  the pair looked like two different kinds of control. */
156
156
  /* THE SQUARE FOLLOWS THE LADDER, and it follows `size` like every other
157
- control: sm 28 · md 32 · lg 36 (hooks/glyphLadders.js). This was a
157
+ control: 22 · 26 · 32 · 40 (01-foundations/09-sizes.md). This was a
158
158
  hardcoded 36 — the LG square — so an expanding search sat beside a
159
159
  `kol-btn-md` filter button at two different sizes. */
160
160
  className={`kol-expand flex items-center rounded-full ${isOpen ? (toneClass(tone) ? 'kol-tone-sunken' : 'bg-fg-04') : ''} ${className}`.trim()}
@@ -198,18 +198,17 @@ export default function ShellDrawer({
198
198
  </button>
199
199
  )}
200
200
  {header != null && <div className="min-w-0 flex-1">{header}</div>}
201
- {/* The box has an owner (2026-08-01). This hand-wrote the icon-button
202
- * square and its hover wash; `Button variant="nav"` IS that string.
203
- * `iconSize` holds the glyph where it was the ladder's md rung is
204
- * heavier than a drawer close wants, and Button documents iconSize
205
- * for exactly the cases the ladder cannot serve. The SQUARE is what
206
- * needed an owner, and it now has one. */}
201
+ {/* A NORMAL ICON BUTTON (user 2026-09-02: *"it should just be like a
202
+ * normal button with a close icon, its not new?"*). It carried
203
+ * `iconSize={14}` kept in 2026-08-01 to preserve the glyph size the
204
+ * hand-rolled button before it happened to have so the box sat at
205
+ * the md rung's 32px around a glyph six under it, and it read as an
206
+ * oversized empty square. No override: the rung sets both. */}
207
207
  {closeSide !== 'start' && (
208
208
  <Button
209
209
  variant="nav"
210
210
  size="md"
211
211
  iconOnly="x"
212
- iconSize={14}
213
212
  onClick={onClose}
214
213
  aria-label="Close"
215
214
  className="ml-auto shrink-0"
@@ -3,6 +3,8 @@ import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import KindPreview from '../molecules/KindPreview.jsx'
4
4
  import { formatLength } from '../molecules/AudioPreview.jsx'
5
5
  import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
6
+ import useGrabEdge from '../hooks/useGrabEdge.js'
7
+ import { GRAB_COLUMN } from '../utilities/motion.js'
6
8
 
7
9
  /**
8
10
  * ColumnBrowser — Finder-style Miller columns over a flat key space (kol-r2b2's
@@ -24,17 +26,21 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
24
26
  * `window` (the overlay holds focus) and space closes — the opening keystroke
25
27
  * bubbles from here and is ignored.
26
28
  *
27
- * Rows wear the DS Table's cell metrics (12px 16px, mono 12, an oq-08 hairline
28
- * between rows, none after the last). Selected and cursor rows draw the HOVER
29
- * fill (`bg-fg-04`) user ruling: "make hover state the selected state".
29
+ * Rows wear the DS Table's cell metrics (12px 16px, mono 12) with NO divider —
30
+ * the row is a rounded pill on a constant 4px inset, so selecting never shifts
31
+ * the layout (BrowsePageRulingsAndSeams, 2026-09-02). ONE fill on screen and it
32
+ * means selected: hover and the bare keyboard cursor paint nothing, the trail is
33
+ * `fg-02` and the deepest column holding a selection is `fg-04`.
30
34
  * Height defaults to 528px = 12 rows × 44px (user ruling); columns scroll. Both
31
35
  * the height and every column's width are DRAGGABLE, Finder-style
32
36
  * (ColumnBrowserResize, kol-r2b2 2026-08-27 — user: "column height drag yes and
33
37
  * individual column width drag" · "that should be a set in ds"): a strip along
34
38
  * the browser's bottom edge (`row-resize`) and one on each column's right edge
35
39
  * (`col-resize`, the border already there is the visual — the strip is the hit
36
- * area, invisible at rest, `fg-08` on hover / while dragging). Pointer events
37
- * with `setPointerCapture`, no library; native CSS `resize:` was rejected.
40
+ * area). Each strip carries the estate's GRAB PILL: hidden at rest, woken by
41
+ * pointer PROXIMITY (`useGrabEdge`, the rail's hook) and travelling along the
42
+ * line to meet the cursor. Pointer events with `setPointerCapture`, no library;
43
+ * native CSS `resize:` was rejected.
38
44
  *
39
45
  * The app-owned bits are SEAMS with working defaults: `urlOf(o)` (the preview
40
46
  * image — no URL, no image), `kindOf(o)` (image / video / audio / file from
@@ -193,12 +199,33 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width
193
199
  }
194
200
 
195
201
  /* one edge handle: captures the pointer, reports the delta along its axis;
196
- * `is-dragging` keeps the wash on while the pointer is captured */
202
+ * `is-dragging` keeps the pill lit while the pointer is captured.
203
+ *
204
+ * THE PILL FOLLOWS THE POINTER (BrowsePageRulingsAndSeams, kol-r2b2 2026-09-02,
205
+ * user ruling). It used to sit dead centre and wake on :hover — the theme's own
206
+ * comment said pointer-following "was built and rejected", while the SAME
207
+ * gesture had shipped on the rail's grab edge the same day and has run in
208
+ * kol-r2b2 since. `useGrabEdge` is that implementation; this is the second
209
+ * consumer of it, which is why the hook grew an `axis` rather than a fork.
210
+ *
211
+ * The handle's axis is named for what it RESIZES — `x` drags the column's width
212
+ * — and the pill on it travels the other way, down the vertical edge. So the
213
+ * `x` handle takes `axis: 'y'` and the `y` handle `axis: 'x'`: the hook's axis
214
+ * is the pill's travel, not the drag. Getting this pair backwards is the one
215
+ * easy mistake here.
216
+ *
217
+ * `GRAB_COLUMN`, not the rail's `GRAB` (user 2026-09-02: *"its not like the
218
+ * sidenav. the handles are different from the sidenav"*) — a 2.8s chase and a
219
+ * 30px retarget, so the pill tracks the pointer instead of landing and holding
220
+ * on the rail's 90px dwell. */
197
221
  function ResizeHandle({ axis, onDrag, onEnd }) {
198
222
  const [dragging, setDragging] = useState(false)
199
223
  const start = useRef(null)
224
+ const ref = useRef(null)
225
+ useGrabEdge(ref, { axis: axis === 'x' ? 'y' : 'x', ...GRAB_COLUMN })
200
226
  return (
201
227
  <div
228
+ ref={ref}
202
229
  className={`kol-column-browser-resize-${axis} ${dragging ? 'is-dragging' : ''}`.trim()}
203
230
  role="separator"
204
231
  aria-orientation={axis === 'x' ? 'vertical' : 'horizontal'}
@@ -86,3 +86,29 @@ export const GRAB = {
86
86
  snap: { duration: 0.5, ease: EASE.outGsap },
87
87
  slop: 3,
88
88
  }
89
+
90
+ /* THE COLUMN BROWSER'S HANDLES ARE NOT THE RAIL'S (BrowsePageRulingsAndSeams,
91
+ * kol-r2b2 2026-09-02 — user: *"its a bit different, its not like the sidenav.
92
+ * the handles are different from the sidenav"*). Same gesture, its own feel,
93
+ * ruled in kol-r2b2 and running there since 2026-08-28:
94
+ *
95
+ * travel 2.8s, not 1.1 — a long, unhurried chase
96
+ * stick 30px, not 90 — a much smaller retarget threshold, so the pill
97
+ * TRACKS the pointer down the edge instead of landing and holding.
98
+ * The dwell that fixed the rail's four-mark grid is not what these
99
+ * want; the deadband exists only so a slow drag does not nudge the
100
+ * target every frame — and it is measured against the last TARGET,
101
+ * not the animated value, because mid-flight the live value is still
102
+ * travelling.
103
+ * range 1 — no bipolar band. The rail chokes its travel so the pill never
104
+ * rides up beside the logomark; there is nothing at these edges to
105
+ * avoid, and the CSS `clamp()` at 2.25rem already stops it flush.
106
+ *
107
+ * `near` / `sleep` are shared — proximity is proximity. The FADE curve differs
108
+ * too and lives with the rule (kol-theme's kol-components-molecules.css). */
109
+ export const GRAB_COLUMN = {
110
+ ...GRAB,
111
+ stick: 30,
112
+ range: 1,
113
+ travel: { duration: 2.8, ease: EASE.outGsap },
114
+ }