@kolkrabbi/kol-component 0.112.0 → 0.113.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.112.0",
3
+ "version": "0.113.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",
@@ -13,19 +13,36 @@ import { useMotionValue, useSpring, useTransform } from 'framer-motion'
13
13
  *
14
14
  * Defaults are the design: tilt ±4°, spring 350/35, perspective 700,
15
15
  * rest position center (0.5/0.5).
16
+ *
17
+ * `grounded` (ShelfCardTiltWrapsCard, kol-website 2026-08-27 — lifted out of
18
+ * TiltCardInner so the shelf and the card share ONE feel, no new component):
19
+ * the "planted at the bottom" tilt — targets snapped to 3 zones, rescaled to
20
+ * ±2.5°, chased by a lazy spring (250/25/0.6), rotateX clamped to min(0, …) so
21
+ * it only ever tilts back, pivoting about `center bottom`.
16
22
  */
17
23
  export default function useTilt({
18
24
  magnitude = 4,
19
25
  perspective = 700,
20
26
  stiffness = 350,
21
27
  damping = 35,
28
+ grounded = false,
22
29
  } = {}) {
23
30
  const ref = useRef(null)
24
31
  const mouseX = useMotionValue(0.5)
25
32
  const mouseY = useMotionValue(0.5)
26
33
 
27
- const rotateX = useSpring(useTransform(mouseY, [0, 1], [magnitude, -magnitude]), { stiffness, damping })
28
- const rotateY = useSpring(useTransform(mouseX, [0, 1], [-magnitude, magnitude]), { stiffness, damping })
34
+ const springX = useSpring(useTransform(mouseY, [0, 1], [magnitude, -magnitude]), { stiffness, damping })
35
+ const springY = useSpring(useTransform(mouseX, [0, 1], [-magnitude, magnitude]), { stiffness, damping })
36
+
37
+ /* the grounded springs always exist (hooks are unconditional) and are only
38
+ * SELECTED when asked for — a free tilt pays two idle springs, nothing more */
39
+ const zones = 3
40
+ const snap = (v) => Math.round(v * zones) / zones
41
+ const lazy = { stiffness: 250, damping: 25, mass: 0.6 }
42
+ const lazyX = useSpring(useTransform(springX, (v) => Math.min(0, snap(-v / magnitude) * 2.5)), lazy)
43
+ const lazyY = useSpring(useTransform(springY, (v) => snap(v / magnitude) * 2.5), lazy)
44
+ const rotateX = grounded ? lazyX : springX
45
+ const rotateY = grounded ? lazyY : springY
29
46
 
30
47
  const onMouseMove = (e) => {
31
48
  const rect = ref.current?.getBoundingClientRect()
@@ -41,7 +58,13 @@ export default function useTilt({
41
58
 
42
59
  return {
43
60
  ref,
44
- style: { rotateX, rotateY, transformStyle: 'preserve-3d', transformPerspective: perspective },
61
+ style: {
62
+ rotateX,
63
+ rotateY,
64
+ transformStyle: 'preserve-3d',
65
+ transformPerspective: perspective,
66
+ ...(grounded ? { transformOrigin: 'center bottom' } : null),
67
+ },
45
68
  onMouseMove,
46
69
  onMouseLeave,
47
70
  motionValues: { mouseX, mouseY, rotateX, rotateY },
@@ -90,12 +90,16 @@ function Media({ src, poster, className }) {
90
90
  * @param {ReactNode} description hover-revealed paragraph
91
91
  * @param {string} href CTA target; `http*`/`mailto` → new-tab anchor, else same-tab (onNavigate seam)
92
92
  * @param {Function} onNavigate (event) => void — same-tab CTA click seam (SPA intercept)
93
- * @param {ReactNode} buttonLabel CTA label (no default author it)
93
+ * @param {ReactNode} buttonLabel CTA label — NO DEFAULT, BY DESIGN: an `href` with no label is a
94
+ * call-site bug, not a gap (the retired kol-website fork defaulted
95
+ * 'View Project'; TiltBentoVoiceAndDefaults 2026-08-27)
94
96
  * @param {ReactNode} bodyContent extra content injected into the stack
95
97
  * @param {number} overlayOpacity scrim darkness % over the media, 0 disables (default 60)
96
98
  * @param {boolean} alignRight right-align the card (ms-auto) vs fill (size-full)
97
99
  * @param {boolean} enableTilt master tilt switch (default true)
98
- * @param {string} titleClassName title classes
100
+ * @param {string} titleClassName title classes — the family default is `kol-sans-heading-01
101
+ * text-absolute-white` (confirmed 2026-08-27; the fork's was
102
+ * heading-02 uppercase — a consumer wanting that passes it)
99
103
  * @param {string} contentClassName inner content-box classes
100
104
  * @param {string} imageClassName media fit/position classes
101
105
  * @param {string} contentStackClassName stack layout classes
@@ -166,7 +170,10 @@ export default function TiltBento({
166
170
  )}
167
171
  <div className={contentStackClassName}>
168
172
  {title && <h3 className={titleClassName}>{title}</h3>}
169
- {subtitle && <p className={`kol-mono-text text-absolute-white ${revealClass}`}>{subtitle}</p>}
173
+ {/* kol-mono-14 — one rung above the mono-12 description, as the retired
174
+ * fork drew it (TiltBentoVoiceAndDefaults, kol-website 2026-08-27: this
175
+ * rode `kol-mono-text`, a class retired from the theme — zero rules emitted) */}
176
+ {subtitle && <p className={`kol-mono-14 text-absolute-white ${revealClass}`}>{subtitle}</p>}
170
177
  {description && <p className={`kol-mono-12 text-absolute-white pb-6 ${revealClass}`}>{description}</p>}
171
178
  {bodyContent}
172
179
  {href && (
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { Fragment, useEffect, useRef, useState } from 'react'
2
2
  import { Icon } from '@kolkrabbi/kol-icons'
3
3
  import KindPreview from '../molecules/KindPreview.jsx'
4
4
  import { formatLength } from '../molecules/AudioPreview.jsx'
@@ -27,7 +27,14 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
27
27
  * Rows wear the DS Table's cell metrics (12px 16px, mono 12, an oq-08 hairline
28
28
  * between rows, none after the last). Selected and cursor rows draw the HOVER
29
29
  * fill (`bg-fg-04`) — user ruling: "make hover state the selected state".
30
- * Height is fixed at 528px = 12 rows × 44px (user ruling); columns scroll.
30
+ * Height defaults to 528px = 12 rows × 44px (user ruling); columns scroll. Both
31
+ * the height and every column's width are DRAGGABLE, Finder-style
32
+ * (ColumnBrowserResize, kol-r2b2 2026-08-27 — user: "column height drag yes and
33
+ * individual column width drag" · "that should be a set in ds"): a strip along
34
+ * the browser's bottom edge (`row-resize`) and one on each column's right edge
35
+ * (`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.
31
38
  *
32
39
  * The app-owned bits are SEAMS with working defaults: `urlOf(o)` (the preview
33
40
  * image — no URL, no image), `kindOf(o)` (image / video / audio / file from
@@ -45,6 +52,11 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
45
52
  * @param {Function} formatSize (bytes) => string
46
53
  * @param {Function} partition (objects, level) => { folders: string[], files: object[] }
47
54
  * @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
55
+ * @param {number} height controlled height in px (omit for uncontrolled)
56
+ * @param {number} defaultHeight uncontrolled start height (528); min 240
57
+ * @param {Function} onHeightChange (px) => void — on every drag step; the consumer persists it
58
+ * @param {number} columnWidth every column's start width (260); the preview column starts at 320; min 160
59
+ * @param {Function} onColumnResize (index, px) => void — the column's index, or `'preview'`
48
60
  * @param {string} className extra classes on the browser
49
61
  */
50
62
 
@@ -96,7 +108,7 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
96
108
  )
97
109
  }
98
110
 
99
- function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
111
+ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width }) {
100
112
  // Pixel size and length come from the loaded media itself — the bucket stores
101
113
  // none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
102
114
  // `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
@@ -115,7 +127,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
115
127
  ['Date', o.uploaded ? new Date(o.uploaded).toISOString().slice(0, 10) : '—'],
116
128
  ]
117
129
  return (
118
- <div className="kol-column-browser-preview w-[320px] shrink-0 overflow-y-auto p-4 flex flex-col gap-4">
130
+ <div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
119
131
  {/* the media frame: an image is the organism's own <img> (it reads the
120
132
  * dimensions); anything else is `renderPreview(o)` or the DS KindPreview
121
133
  * (video · audio · code · text — SettingsPanelChromeAndColumnPreview,
@@ -158,6 +170,28 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
158
170
  )
159
171
  }
160
172
 
173
+ /* one edge handle: captures the pointer, reports the delta along its axis;
174
+ * `is-dragging` keeps the wash on while the pointer is captured */
175
+ function ResizeHandle({ axis, onDrag, onEnd }) {
176
+ const [dragging, setDragging] = useState(false)
177
+ const start = useRef(null)
178
+ return (
179
+ <div
180
+ className={`kol-column-browser-resize-${axis} ${dragging ? 'is-dragging' : ''}`.trim()}
181
+ role="separator"
182
+ aria-orientation={axis === 'x' ? 'vertical' : 'horizontal'}
183
+ onPointerDown={(e) => { e.preventDefault(); e.currentTarget.setPointerCapture(e.pointerId); start.current = axis === 'x' ? e.clientX : e.clientY; setDragging(true) }}
184
+ onPointerMove={(e) => { if (start.current == null) return; onDrag((axis === 'x' ? e.clientX : e.clientY) - start.current) }}
185
+ onPointerUp={(e) => { e.currentTarget.releasePointerCapture(e.pointerId); start.current = null; setDragging(false); onEnd?.() }}
186
+ onPointerCancel={() => { start.current = null; setDragging(false); onEnd?.() }}
187
+ />
188
+ )
189
+ }
190
+
191
+ const MIN_H = 240
192
+ const MIN_W = 160
193
+ const PREVIEW_W = 320
194
+
161
195
  export default function ColumnBrowser({
162
196
  objects = [],
163
197
  prefix = '',
@@ -171,8 +205,33 @@ export default function ColumnBrowser({
171
205
  formatSize = defaultFormatSize,
172
206
  partition = defaultPartition,
173
207
  renderPreview,
208
+ height,
209
+ defaultHeight = 528,
210
+ onHeightChange,
211
+ columnWidth = 260,
212
+ onColumnResize,
174
213
  className = '',
175
214
  }) {
215
+ /* height: controlled-or-uncontrolled like Slider; widths: organism-internal,
216
+ * by column index (the preview keyed apart), seeded from `columnWidth` */
217
+ const [ownH, setOwnH] = useState(defaultHeight)
218
+ const h = height ?? ownH
219
+ const [widths, setWidths] = useState({})
220
+ const widthOf = (i) => widths[i] ?? (i === 'preview' ? PREVIEW_W : columnWidth)
221
+ const dragBase = useRef(null)
222
+ const resizeCol = (i) => (dx) => {
223
+ if (dragBase.current == null) dragBase.current = widthOf(i)
224
+ const w = Math.max(MIN_W, Math.round(dragBase.current + dx))
225
+ setWidths((prev) => (prev[i] === w ? prev : { ...prev, [i]: w }))
226
+ onColumnResize?.(i, w)
227
+ }
228
+ const resizeH = (dy) => {
229
+ if (dragBase.current == null) dragBase.current = h
230
+ const next = Math.max(MIN_H, Math.round(dragBase.current + dy))
231
+ if (height == null) setOwnH(next)
232
+ onHeightChange?.(next)
233
+ }
234
+ const endDrag = () => { dragBase.current = null }
176
235
  const [picked, setPicked] = useState(null)
177
236
  /* `onPick` (ColumnBrowserOnPick, kol-r2b2 2026-08-27): the picked file, for
178
237
  * a Finder-style breadcrumb — fired whenever it changes; a folder pick or an
@@ -304,19 +363,22 @@ export default function ColumnBrowser({
304
363
  ref={rootRef}
305
364
  tabIndex={0}
306
365
  onKeyDown={onKeyDown}
307
- // Fixed height — 12 rows of 44px (user ruling 2026-08-27), so the browser never jumps as columns change; each column scrolls.
308
- className={`kol-column-browser flex overflow-x-auto h-[528px] border rounded outline-none ${className}`.trim()}
309
- style={{ borderColor: 'var(--kol-oq-08)' }}
366
+ // 12 rows of 44px by default (user ruling 2026-08-27), so the browser never jumps as columns change; each column scrolls.
367
+ // The scroll box is the INNER flex row: the bottom handle is absolute on this root, so it spans the visible width, not the scrolled one.
368
+ className={`kol-column-browser relative border rounded outline-none ${className}`.trim()}
369
+ style={{ borderColor: 'var(--kol-oq-08)', height: h }}
310
370
  >
371
+ <div className="flex h-full overflow-x-auto">
311
372
  {levels.map((level, k) => {
312
373
  const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
313
374
  const next = levels[k + 1]
314
375
  const activeFolder = next ? next.slice(level.length) : null
376
+ const last = k === levels.length - 1 && !shown
315
377
  return (
378
+ <Fragment key={level || '/'}>
316
379
  <ul
317
- key={level || '/'}
318
- className="kol-column-browser-column w-[260px] shrink-0 overflow-y-auto border-r last:border-r-0"
319
- style={{ borderColor: 'var(--kol-oq-08)' }}
380
+ className={`kol-column-browser-column shrink-0 overflow-y-auto ${last ? '' : 'border-r'}`.trim()}
381
+ style={{ borderColor: 'var(--kol-oq-08)', width: widthOf(k) }}
320
382
  >
321
383
  {folders.map((f, i) => (
322
384
  <Row
@@ -344,9 +406,18 @@ export default function ColumnBrowser({
344
406
  <li className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
345
407
  )}
346
408
  </ul>
409
+ <ResizeHandle axis="x" onDrag={resizeCol(k)} onEnd={endDrag} />
410
+ </Fragment>
347
411
  )
348
412
  })}
349
- {shown && <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} />}
413
+ {shown && (
414
+ <>
415
+ <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} width={widthOf('preview')} />
416
+ <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
417
+ </>
418
+ )}
419
+ </div>
420
+ <ResizeHandle axis="y" onDrag={resizeH} onEnd={endDrag} />
350
421
  </div>
351
422
  )
352
423
  }
@@ -1,4 +1,4 @@
1
- import { motion, useSpring, useTransform } from 'framer-motion'
1
+ import { motion } from 'framer-motion'
2
2
  import useTilt from '../hooks/useTilt.js'
3
3
  import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
4
4
  import useCoarsePointer from '../hooks/useCoarsePointer.js'
@@ -65,36 +65,16 @@ export default function TiltCard({
65
65
  }
66
66
 
67
67
  function TiltCardInner({ src, alt, className, variant, magnitude, perspective, children }) {
68
- const tilt = useTilt({ magnitude, perspective })
69
- const grounded = variant === 'grounded'
70
-
71
- /* Zone-based tilt: normalize the spring output back to -1..1, snap it to
72
- * 3 zones, rescale to ±2.5°, and let a slower lazy spring catch up so the
73
- * card lags and settles into quantized angles. rotateX is clamped to
74
- * min(0, …) — grounded cards only ever tilt back. */
75
- const zones = 3
76
- const snap = (v) => Math.round(v * zones) / zones
77
- const lazySpring = { stiffness: 250, damping: 25, mass: 0.6 }
78
-
79
- const targetX = useTransform(tilt.motionValues.rotateX, (v) =>
80
- grounded ? Math.min(0, snap(-v / magnitude) * 2.5) : v,
81
- )
82
- const targetY = useTransform(tilt.motionValues.rotateY, (v) =>
83
- grounded ? snap(v / magnitude) * 2.5 : v,
84
- )
85
-
86
- const lazyRotateX = useSpring(targetX, lazySpring)
87
- const lazyRotateY = useSpring(targetY, lazySpring)
88
-
89
- const style = grounded
90
- ? { ...tilt.style, rotateX: lazyRotateX, rotateY: lazyRotateY, transformOrigin: 'center bottom' }
91
- : tilt.style
68
+ /* the grounded feel — zones, ±2.5°, the lazy spring, the bottom pivot — lives
69
+ * in useTilt now (ShelfCardTiltWrapsCard, 2026-08-27), so the shelf's card
70
+ * wrapper and this frame are one motion, not two copies of eight lines */
71
+ const tilt = useTilt({ magnitude, perspective, grounded: variant === 'grounded' })
92
72
 
93
73
  return (
94
74
  <motion.div
95
75
  ref={tilt.ref}
96
76
  className={`relative ${className}`}
97
- style={style}
77
+ style={tilt.style}
98
78
  onMouseMove={tilt.onMouseMove}
99
79
  onMouseLeave={tilt.onMouseLeave}
100
80
  >