@kolkrabbi/kol-component 0.111.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.
|
|
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",
|
package/src/hooks/useTilt.js
CHANGED
|
@@ -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
|
|
28
|
-
const
|
|
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: {
|
|
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 },
|
package/src/index.js
CHANGED
|
@@ -137,7 +137,7 @@ export { default as LoaderOverlay } from './utilities/LoaderOverlay.jsx'
|
|
|
137
137
|
export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPicker, MediaBrowser } from './organisms/MediaLibrary.jsx'
|
|
138
138
|
export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
|
|
139
139
|
export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
140
|
-
export { default as SettingsPanel,
|
|
140
|
+
export { default as SettingsPanel, LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
|
|
141
141
|
export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
|
|
142
142
|
export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
|
|
143
143
|
export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
|
|
@@ -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
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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 &&
|
|
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
|
}
|
|
@@ -130,11 +130,15 @@ export function SettingsSwitch({ on = false, onChange, disabled = false, disable
|
|
|
130
130
|
)
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
|
|
133
|
+
/** LabeledControlSection — a section of LabeledControls: the EYEBROW
|
|
134
|
+
* (`kol-eyebrow text-fg-80`) standing apart from the rows (gap-3), the rows in
|
|
135
|
+
* their own stack — `rowGap` 1 for switch rows (24 tall already), 2 for
|
|
136
|
+
* dropdown rows. `divided` = a hairline above (between sections).
|
|
137
|
+
* Was `SettingsSection` until 2026-08-27 (LabeledControlSection, kol-r2b2 —
|
|
138
|
+
* user ruling: it named the place it was first used, not what it is; kol-fxr
|
|
139
|
+
* took it for a params rail the same day). RENAMED, NO ALIAS — the render is
|
|
140
|
+
* the 0.104.0 locked composition, untouched. */
|
|
141
|
+
export function LabeledControlSection({ label, divided = false, rowGap = 2, children, className = '' }) {
|
|
138
142
|
return (
|
|
139
143
|
<div className={`flex flex-col gap-3 ${divided ? 'kol-section--divided' : ''} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
140
144
|
{label && <p className="kol-eyebrow text-fg-80">{label}</p>}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { 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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
>
|