@kolkrabbi/kol-component 0.15.3 → 0.19.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 +1 -1
- package/src/atoms/AssetPlaceholder.jsx +1 -1
- package/src/atoms/DocsToc.jsx +11 -6
- package/src/atoms/FullscreenOverlay.jsx +18 -5
- package/src/atoms/InteractiveImage.jsx +152 -0
- package/src/atoms/OverlayGlassPanel.jsx +1 -1
- package/src/atoms/Pill.jsx +10 -1
- package/src/atoms/SearchInput.jsx +1 -1
- package/src/atoms/Tag.jsx +49 -25
- package/src/atoms/TiltCard.jsx +1 -21
- package/src/hooks/useCoarsePointer.js +27 -0
- package/src/hooks/useScrollSpy.js +18 -9
- package/src/index.js +3 -0
- package/src/molecules/ColorInputRow.jsx +1 -1
- package/src/molecules/ColorSwatch.jsx +1 -1
- package/src/molecules/FramedMediaBand.jsx +2 -2
- package/src/molecules/ShellDrawer.jsx +1 -1
- package/src/molecules/ShellSearchOverlay.jsx +59 -8
- package/src/molecules/SpectrumControls.jsx +5 -5
- package/src/organisms/ContentFilters.jsx +18 -10
- package/src/organisms/FeatureSplit.jsx +1 -1
- package/src/organisms/GalleryCarousel.jsx +1 -1
- package/src/organisms/MediaLibrary.jsx +516 -0
- package/src/organisms/MediaViewer.jsx +9 -1
- package/src/organisms/Table.jsx +67 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
|
@@ -16,7 +16,7 @@ export default function AssetPlaceholder({
|
|
|
16
16
|
const label = [category, name].filter(Boolean).join(' · ')
|
|
17
17
|
return (
|
|
18
18
|
<div
|
|
19
|
-
className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] rounded-[
|
|
19
|
+
className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] rounded-[var(--kol-radius-sm)] bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
|
|
20
20
|
style={{ aspectRatio }}
|
|
21
21
|
role="img"
|
|
22
22
|
aria-label={`${label || 'asset'} — ${note}`}
|
package/src/atoms/DocsToc.jsx
CHANGED
|
@@ -23,12 +23,12 @@ import useScrollSpy from '../hooks/useScrollSpy.js'
|
|
|
23
23
|
* top band (useScrollSpy's own default is
|
|
24
24
|
* '-30% 0px -60% 0px')
|
|
25
25
|
*/
|
|
26
|
-
export default function DocsToc({ toc, onNavigate, rootMargin = '-80px 0px -80% 0px' }) {
|
|
27
|
-
const activeId = useScrollSpy(toc.map((item) => item.id), { rootMargin })
|
|
26
|
+
export default function DocsToc({ toc, onNavigate, rootMargin = '-80px 0px -80% 0px', root = null }) {
|
|
27
|
+
const activeId = useScrollSpy(toc.map((item) => item.id), { rootMargin, root })
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
30
|
<nav>
|
|
31
|
-
<ul className="
|
|
31
|
+
<ul className="shell-nav-items">
|
|
32
32
|
{toc.map((item) => (
|
|
33
33
|
<li key={item.id}>
|
|
34
34
|
<a
|
|
@@ -39,9 +39,14 @@ export default function DocsToc({ toc, onNavigate, rootMargin = '-80px 0px -80%
|
|
|
39
39
|
* `kol-mono-12 … py-1` with no left padding, so on-this-page rows
|
|
40
40
|
* sat at a smaller size and a different left edge than the
|
|
41
41
|
* Related rows directly beneath them in the same rail. */
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/* THE one row string (user ruling 2026-08-01). This carried
|
|
43
|
+
* `block transition-colors focus-visible:ring-focus
|
|
44
|
+
* hover:text-emphasis text-body` as utilities, so the same rung
|
|
45
|
+
* rendered a different className here than in the left tree.
|
|
46
|
+
* Layout, colour, hover and focus live in `.shell-nav-item` now;
|
|
47
|
+
* active is the shared `is-active` marker, not `text-emphasis`
|
|
48
|
+
* typed at one call site. Matches RailRow exactly. */
|
|
49
|
+
className={`shell-nav-item kol-mono-14${activeId === item.id ? ' is-active' : ''}`}
|
|
45
50
|
>
|
|
46
51
|
{item.label}
|
|
47
52
|
</a>
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react'
|
|
2
|
+
import Button from './Button.jsx'
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* FullscreenOverlay — the scrim + centred sheet every overlay in the repo
|
|
6
|
+
* wears. Owns dismissal (Escape, backdrop, close button), scroll lock and
|
|
7
|
+
* stacking; the consumer supplies the panel.
|
|
8
|
+
*
|
|
9
|
+
* The close control is the DS `Button` (quiet, icon-only, the `x` glyph) —
|
|
10
|
+
* the same idiom as ShellLayout's. It was a hand-rolled <button> printing a
|
|
11
|
+
* literal `×` TEXT CHARACTER until 2026-08-01: no icon, no states, and a
|
|
12
|
+
* typographic multiplication sign standing in for a glyph the icon set has
|
|
13
|
+
* always shipped.
|
|
14
|
+
*/
|
|
3
15
|
export default function FullscreenOverlay({ open, onClose, closeButton = true, children }) {
|
|
4
16
|
const sheetRef = useRef(null)
|
|
5
17
|
|
|
@@ -30,14 +42,15 @@ export default function FullscreenOverlay({ open, onClose, closeButton = true, c
|
|
|
30
42
|
<div className="kol-overlay" role="dialog" aria-modal="true" onMouseDown={onBackdropClick}>
|
|
31
43
|
<div ref={sheetRef} className="kol-overlay-sheet">
|
|
32
44
|
{closeButton && (
|
|
33
|
-
<
|
|
34
|
-
|
|
45
|
+
<Button
|
|
46
|
+
variant="outline"
|
|
47
|
+
quiet
|
|
48
|
+
size="sm"
|
|
49
|
+
iconOnly="x"
|
|
35
50
|
className="kol-overlay-close"
|
|
36
51
|
onClick={onClose}
|
|
37
52
|
aria-label="Close"
|
|
38
|
-
|
|
39
|
-
×
|
|
40
|
-
</button>
|
|
53
|
+
/>
|
|
41
54
|
)}
|
|
42
55
|
{children}
|
|
43
56
|
</div>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { motion } from 'framer-motion'
|
|
3
|
+
import useTilt from '../hooks/useTilt.js'
|
|
4
|
+
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
5
|
+
import useCoarsePointer from '../hooks/useCoarsePointer.js'
|
|
6
|
+
|
|
7
|
+
/* The default mask: one closed blob authored in unit-square coordinates, so
|
|
8
|
+
* it scales to any element size. Swap it via the `shape` prop — any path in
|
|
9
|
+
* a 0..1 box works. */
|
|
10
|
+
const BLOB =
|
|
11
|
+
'M.96.217L.855.834a.09.09 0 01-.07.072L.166.994A.09.09 0 01.06.9L.04.166A.09.09 0 01.15.073L.89.13a.09.09 0 01.07.087z'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* InteractiveImage — an image seen through an organic blob mask that
|
|
15
|
+
* re-centres on the cursor, floating over a blurred, scaled copy of
|
|
16
|
+
* itself. The whole stage tilts in 3D toward the pointer.
|
|
17
|
+
*
|
|
18
|
+
* Composed, not forked: the tilt is the shared `useTilt` hook (springs,
|
|
19
|
+
* not tweens — the DS has one tilt and this is it), so the motion here
|
|
20
|
+
* matches TiltCard and BentoCard rather than introducing a second feel.
|
|
21
|
+
* The source's gsap tweens are gone with it; nothing else needed gsap.
|
|
22
|
+
*
|
|
23
|
+
* Motion is gated twice — on coarse-pointer devices and under
|
|
24
|
+
* prefers-reduced-motion the mask sits centred, the tilt never mounts,
|
|
25
|
+
* and no listeners are attached.
|
|
26
|
+
*
|
|
27
|
+
* The two SVG ids are per-instance (`useId`). The monorepo source hard-coded
|
|
28
|
+
* them at module scope, so a second instance on the same page silently
|
|
29
|
+
* clobbered the first one's clip path and pattern — the reason this was
|
|
30
|
+
* lobbied as a fresh effect rather than a migration.
|
|
31
|
+
*
|
|
32
|
+
* Purely image-driven: no tokens, no colors, nothing to theme. Size comes
|
|
33
|
+
* from the consumer via `className` — the stage fills its box.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} src image source, used by both the mask and the backdrop. Required
|
|
36
|
+
* @param {string} alt accessible name for the masked image
|
|
37
|
+
* @param {string} className classes on the root — supplies the size
|
|
38
|
+
* @param {string} shape SVG path `d` in unit-square coords; the mask outline
|
|
39
|
+
* @param {number} magnitude max tilt in degrees (±)
|
|
40
|
+
* @param {number} perspective CSS transform perspective, in px
|
|
41
|
+
* @param {number} blur backdrop blur radius, in px
|
|
42
|
+
* @param {number} backdropScale backdrop zoom, as a multiplier — hides the blur's soft edge
|
|
43
|
+
*/
|
|
44
|
+
export default function InteractiveImage({
|
|
45
|
+
src,
|
|
46
|
+
alt = '',
|
|
47
|
+
className = '',
|
|
48
|
+
shape = BLOB,
|
|
49
|
+
magnitude = 10,
|
|
50
|
+
perspective = 500,
|
|
51
|
+
blur = 10,
|
|
52
|
+
backdropScale = 1.1,
|
|
53
|
+
}) {
|
|
54
|
+
const uid = useId()
|
|
55
|
+
const clipId = `kol-ii-clip-${uid}`
|
|
56
|
+
const patternId = `kol-ii-pattern-${uid}`
|
|
57
|
+
|
|
58
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
59
|
+
const coarse = useCoarsePointer()
|
|
60
|
+
const still = reducedMotion || coarse
|
|
61
|
+
|
|
62
|
+
const tilt = useTilt({ magnitude, perspective })
|
|
63
|
+
const boxRef = useRef(null)
|
|
64
|
+
const [size, setSize] = useState({ width: 0, height: 0 })
|
|
65
|
+
/* Mask centre in element coordinates; null until the pointer arrives, which
|
|
66
|
+
* is also the resting state — `centre` below reads it as dead centre. */
|
|
67
|
+
const [point, setPoint] = useState(null)
|
|
68
|
+
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
const el = boxRef.current
|
|
71
|
+
if (!el) return undefined
|
|
72
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
73
|
+
const { width, height } = entry.contentRect
|
|
74
|
+
setSize({ width, height })
|
|
75
|
+
})
|
|
76
|
+
ro.observe(el)
|
|
77
|
+
return () => ro.disconnect()
|
|
78
|
+
}, [])
|
|
79
|
+
|
|
80
|
+
const centre = point ?? { x: size.width / 2, y: size.height / 2 }
|
|
81
|
+
|
|
82
|
+
/* The unit blob is scaled up to the element box, then offset so its centre
|
|
83
|
+
* lands under the pointer rather than at the origin. */
|
|
84
|
+
const maskTransform = `translate(${centre.x - size.width / 2} ${centre.y - size.height / 2}) scale(${size.width} ${size.height})`
|
|
85
|
+
|
|
86
|
+
const handleMove = (e) => {
|
|
87
|
+
if (still) return
|
|
88
|
+
tilt.onMouseMove(e)
|
|
89
|
+
const rect = boxRef.current?.getBoundingClientRect()
|
|
90
|
+
if (!rect) return
|
|
91
|
+
setPoint({ x: e.clientX - rect.left, y: e.clientY - rect.top })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handleLeave = () => {
|
|
95
|
+
if (still) return
|
|
96
|
+
tilt.onMouseLeave()
|
|
97
|
+
setPoint(null)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
ref={boxRef}
|
|
103
|
+
className={`relative ${className}`}
|
|
104
|
+
onMouseMove={still ? undefined : handleMove}
|
|
105
|
+
onMouseLeave={still ? undefined : handleLeave}
|
|
106
|
+
>
|
|
107
|
+
<div
|
|
108
|
+
className="absolute inset-0 bg-cover bg-center"
|
|
109
|
+
style={{
|
|
110
|
+
backgroundImage: `url(${src})`,
|
|
111
|
+
filter: `blur(${blur}px)`,
|
|
112
|
+
transform: `scale(${backdropScale})`,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
|
|
116
|
+
<motion.svg
|
|
117
|
+
ref={tilt.ref}
|
|
118
|
+
role="img"
|
|
119
|
+
aria-label={alt || undefined}
|
|
120
|
+
aria-hidden={alt ? undefined : true}
|
|
121
|
+
className="relative w-full h-full"
|
|
122
|
+
viewBox={`0 0 ${size.width} ${size.height}`}
|
|
123
|
+
style={still ? undefined : tilt.style}
|
|
124
|
+
>
|
|
125
|
+
<defs>
|
|
126
|
+
<clipPath id={clipId}>
|
|
127
|
+
<path d={shape} transform={maskTransform} />
|
|
128
|
+
</clipPath>
|
|
129
|
+
<pattern
|
|
130
|
+
id={patternId}
|
|
131
|
+
patternUnits="userSpaceOnUse"
|
|
132
|
+
width={size.width}
|
|
133
|
+
height={size.height}
|
|
134
|
+
>
|
|
135
|
+
<image
|
|
136
|
+
href={src}
|
|
137
|
+
width="100%"
|
|
138
|
+
height="100%"
|
|
139
|
+
preserveAspectRatio="xMidYMid slice"
|
|
140
|
+
/>
|
|
141
|
+
</pattern>
|
|
142
|
+
</defs>
|
|
143
|
+
<rect
|
|
144
|
+
width="100%"
|
|
145
|
+
height="100%"
|
|
146
|
+
fill={`url(#${patternId})`}
|
|
147
|
+
clipPath={`url(#${clipId})`}
|
|
148
|
+
/>
|
|
149
|
+
</motion.svg>
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -28,7 +28,7 @@ export default function OverlayGlassPanel({
|
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
30
|
<div
|
|
31
|
-
className={`flex flex-col ${alignCls} ${gap} rounded-[
|
|
31
|
+
className={`flex flex-col ${alignCls} ${gap} rounded-[var(--kol-radius-xs)] px-6 py-8 ${maxWidth ? `${maxWidth} mx-auto` : ''} ${className}`.trim()}
|
|
32
32
|
style={{
|
|
33
33
|
backgroundColor: `color-mix(in srgb, var(--kol-surface-primary) ${surfaceOpacity}%, transparent)`,
|
|
34
34
|
backdropFilter: `blur(${blur})`,
|
package/src/atoms/Pill.jsx
CHANGED
|
@@ -38,8 +38,17 @@ const Pill = ({ children, variant = 'primary', size = 'sm', className = '' }) =>
|
|
|
38
38
|
lg: 'pill-lg'
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/* Type from the helper ramp — same ruling as Tag (2026-08-01). The pill-*
|
|
42
|
+
* classes hardcoded size + weight and no line-height; kol-helper-* is the
|
|
43
|
+
* single-line ramp (`line-height: 1`) and matches 10/12/14 exactly. */
|
|
44
|
+
const typeClasses = {
|
|
45
|
+
sm: 'kol-helper-10',
|
|
46
|
+
md: 'kol-helper-12',
|
|
47
|
+
lg: 'kol-helper-14'
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
return (
|
|
42
|
-
<span className={`${variantClasses[variant]} ${sizeClasses[size]} ${className}`.trim()}>
|
|
51
|
+
<span className={`${variantClasses[variant]} ${sizeClasses[size]} ${typeClasses[size]} ${className}`.trim()}>
|
|
43
52
|
{children}
|
|
44
53
|
</span>
|
|
45
54
|
)
|
|
@@ -163,7 +163,7 @@ export default function SearchInput({
|
|
|
163
163
|
/* aria-hidden — affordance, not a label (same stance as Input's prefix/suffix) */
|
|
164
164
|
<kbd
|
|
165
165
|
aria-hidden="true"
|
|
166
|
-
className="inline-flex items-center justify-center shrink-0 h-4 min-w-4 px-1 rounded-[
|
|
166
|
+
className="inline-flex items-center justify-center shrink-0 h-4 min-w-4 px-1 rounded-[var(--kol-radius-xs)] bg-fg-08 kol-helper-10 text-fg-48"
|
|
167
167
|
>
|
|
168
168
|
{shortcutHint}
|
|
169
169
|
</kbd>
|
package/src/atoms/Tag.jsx
CHANGED
|
@@ -12,22 +12,42 @@ const ICON_SIZES = { sm: 10, md: 12, lg: 14 }
|
|
|
12
12
|
* <span> and is a Pill wearing the wrong name.
|
|
13
13
|
* Badge — system status or a count.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
* (default/naked/inverse/solid), size, color, solid, active, icon, onRemove,
|
|
17
|
-
* onClick. Plus:
|
|
18
|
-
* - `hash` (default true) — prepend `#` (web's tag style). Pass hash={false}
|
|
19
|
-
* for plain labels (brand usage).
|
|
20
|
-
* - `text` — content fallback when no children (brand SwatchControls passes text=).
|
|
15
|
+
* REBUILT ON PILL'S MODEL (user ruling 2026-08-01). What it was:
|
|
21
16
|
*
|
|
22
|
-
*
|
|
17
|
+
* - FOUR declared variants (default/naked/inverse/solid) and exactly ONE
|
|
18
|
+
* `:hover` rule between them, on `.tag-control`. Every other path rendered
|
|
19
|
+
* dead.
|
|
20
|
+
* - `color` was a SECOND axis that silently swapped the base class from
|
|
21
|
+
* `tag-control` to `tag tag--{color}` — so passing a colour cost you the
|
|
22
|
+
* interaction state, invisibly. That is what shipped a solid blue pill you
|
|
23
|
+
* could not hover.
|
|
24
|
+
* - `variant="solid"` and a `solid` boolean did the same job.
|
|
25
|
+
* - FOUR class schemes for one component: `tag-control`, `tag`, `tag-naked`,
|
|
26
|
+
* `tag-control-inverse` — not a base plus modifiers, four unrelated bases
|
|
27
|
+
* picked by which prop you happened to pass.
|
|
28
|
+
*
|
|
29
|
+
* Now it is Pill's vocabulary — `primary` (filled) · `secondary` (outlined) ·
|
|
30
|
+
* `inverse` — one size scale, ONE class scheme (`kol-tag--*`), and every
|
|
31
|
+
* variant carries hover + active. Colour is not a prop: a chip's look is its
|
|
32
|
+
* variant, exactly as it is on Pill and Button. Tag colour BY TAXONOMY returns
|
|
33
|
+
* later as its own decision, on top of the variants rather than instead of them.
|
|
34
|
+
*
|
|
35
|
+
* @param {ReactNode} children label content
|
|
36
|
+
* @param {string} text content fallback when no children (brand SwatchControls)
|
|
37
|
+
* @param {string} variant 'primary' | 'secondary' | 'inverse'
|
|
38
|
+
* @param {string} size 'sm' | 'md' | 'lg' — `sm` is the default and
|
|
39
|
+
* should stay the answer; `lg` needs a reason
|
|
40
|
+
* @param {boolean} active selected state (filter chips)
|
|
41
|
+
* @param {boolean} hash prepend `#` (default true); false for plain labels
|
|
42
|
+
* @param {string} icon leading icon name
|
|
43
|
+
* @param {Function} onRemove renders the dismiss affordance
|
|
44
|
+
* @param {Function} onClick makes it a <button>
|
|
23
45
|
*/
|
|
24
46
|
export default function Tag({
|
|
25
47
|
children,
|
|
26
48
|
text,
|
|
27
|
-
variant = '
|
|
49
|
+
variant = 'primary',
|
|
28
50
|
size = 'sm',
|
|
29
|
-
color,
|
|
30
|
-
solid = false,
|
|
31
51
|
active = false,
|
|
32
52
|
hash = true,
|
|
33
53
|
icon,
|
|
@@ -40,24 +60,28 @@ export default function Tag({
|
|
|
40
60
|
const iconSize = ICON_SIZES[size] || 12
|
|
41
61
|
const content = children ?? text
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
baseClass = variant === 'inverse' ? 'tag-control-inverse' : 'tag-control'
|
|
63
|
+
/* ONE scheme. `variant` is the only thing that picks a look — there is no
|
|
64
|
+
* second axis that can swap the base out from under it. */
|
|
65
|
+
const VARIANTS = {
|
|
66
|
+
primary: 'kol-tag--primary',
|
|
67
|
+
secondary: 'kol-tag--secondary',
|
|
68
|
+
inverse: 'kol-tag--inverse',
|
|
50
69
|
}
|
|
51
70
|
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
/* TYPE COMES FROM THE HELPER RAMP (user ruling 2026-08-01). The size classes
|
|
72
|
+
* hardcoded font-size + font-weight and set no line-height, so a 10px chip
|
|
73
|
+
* inherited the body's ~1.5 and stood 22px tall next to 14px rows. A chip is
|
|
74
|
+
* SINGLE-LINE chrome — that is exactly the kol-helper-* ramp, whose defining
|
|
75
|
+
* property is `line-height: 1`. The sizes match the scale 1:1 (10/12/14), so
|
|
76
|
+
* this is adopting the existing system, not restating it. */
|
|
77
|
+
const TYPE = { sm: 'kol-helper-10', md: 'kol-helper-12', lg: 'kol-helper-14' }
|
|
54
78
|
|
|
55
79
|
const classes = [
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
80
|
+
'kol-tag',
|
|
81
|
+
VARIANTS[variant] ?? VARIANTS.primary,
|
|
82
|
+
`kol-tag--${size}`,
|
|
83
|
+
TYPE[size] ?? TYPE.sm,
|
|
84
|
+
active ? 'is-active' : '',
|
|
61
85
|
className
|
|
62
86
|
].filter(Boolean).join(' ')
|
|
63
87
|
|
|
@@ -78,7 +102,7 @@ export default function Tag({
|
|
|
78
102
|
<span
|
|
79
103
|
role="button"
|
|
80
104
|
tabIndex={-1}
|
|
81
|
-
className="tag-dismiss"
|
|
105
|
+
className="kol-tag-dismiss"
|
|
82
106
|
onClick={handleRemove}
|
|
83
107
|
>
|
|
84
108
|
<Icon name="x" size={iconSize} />
|
package/src/atoms/TiltCard.jsx
CHANGED
|
@@ -1,27 +1,7 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react'
|
|
2
1
|
import { motion, useSpring, useTransform } from 'framer-motion'
|
|
3
2
|
import useTilt from '../hooks/useTilt.js'
|
|
4
3
|
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* True on coarse-pointer (touch) devices. Local to TiltCard; re-evaluates on
|
|
8
|
-
* device/orientation change via the media-query change event (the monorepo
|
|
9
|
-
* source froze this in a module-load const — fixed on recreate).
|
|
10
|
-
*/
|
|
11
|
-
function useCoarsePointer() {
|
|
12
|
-
const [coarse, setCoarse] = useState(
|
|
13
|
-
() => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
const mq = window.matchMedia('(pointer: coarse)')
|
|
18
|
-
const onChange = () => setCoarse(mq.matches)
|
|
19
|
-
mq.addEventListener('change', onChange)
|
|
20
|
-
return () => mq.removeEventListener('change', onChange)
|
|
21
|
-
}, [])
|
|
22
|
-
|
|
23
|
-
return coarse
|
|
24
|
-
}
|
|
4
|
+
import useCoarsePointer from '../hooks/useCoarsePointer.js'
|
|
25
5
|
|
|
26
6
|
/**
|
|
27
7
|
* TiltCard — self-contained image card with a spring-based 3D tilt that
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True on coarse-pointer (touch) devices — the DS-wide pointer gate, the
|
|
5
|
+
* sibling of `usePrefersReducedMotion`. Pointer-driven effects check this
|
|
6
|
+
* and render their static form when true: a tilt that follows a cursor is
|
|
7
|
+
* dead weight on a device that has none.
|
|
8
|
+
*
|
|
9
|
+
* Re-evaluates on device/orientation change via the media-query change
|
|
10
|
+
* event (the monorepo source froze this in a module-load const — fixed on
|
|
11
|
+
* recreate). Promoted out of TiltCard 2026-08-01 when InteractiveImage
|
|
12
|
+
* became the second consumer.
|
|
13
|
+
*/
|
|
14
|
+
export default function useCoarsePointer() {
|
|
15
|
+
const [coarse, setCoarse] = useState(
|
|
16
|
+
() => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const mq = window.matchMedia('(pointer: coarse)')
|
|
21
|
+
const onChange = () => setCoarse(mq.matches)
|
|
22
|
+
mq.addEventListener('change', onChange)
|
|
23
|
+
return () => mq.removeEventListener('change', onChange)
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
return coarse
|
|
27
|
+
}
|
|
@@ -5,7 +5,12 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
5
5
|
* IntersectionObserver with an edge-lock so first/last sections stay active
|
|
6
6
|
* when the user reaches the top/bottom of the page.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
/* `root` (2026-08-01) — the SCROLL CONTAINER, not always the viewport. The
|
|
9
|
+
* workshop shell scrolls `#main` internally (it is `fixed inset-0` with its own
|
|
10
|
+
* scroll regions), so an observer rooted at the viewport never fired and the
|
|
11
|
+
* right rail could not highlight anything. The edge-lock reads the same
|
|
12
|
+
* element for the same reason: `window.scrollY` is 0 forever in that shell. */
|
|
13
|
+
export default function useScrollSpy(ids, { rootMargin = '-30% 0px -60% 0px', edgeOffset = 100, root = null } = {}) {
|
|
9
14
|
const [activeId, setActiveId] = useState(null)
|
|
10
15
|
const edgeLockRef = useRef(null)
|
|
11
16
|
const key = ids.join(',')
|
|
@@ -15,11 +20,14 @@ export default function useScrollSpy(ids, { rootMargin = '-30% 0px -60% 0px', ed
|
|
|
15
20
|
const elements = ids.map((id) => document.getElementById(id)).filter(Boolean)
|
|
16
21
|
if (!elements.length) return
|
|
17
22
|
|
|
23
|
+
const scroller = typeof root === 'string' ? document.querySelector(root) : root
|
|
24
|
+
|
|
18
25
|
const checkEdges = () => {
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
const top = scroller ? scroller.scrollTop : window.scrollY
|
|
27
|
+
const viewH = scroller ? scroller.clientHeight : window.innerHeight
|
|
28
|
+
const fullH = scroller ? scroller.scrollHeight : document.documentElement.scrollHeight
|
|
29
|
+
const atTop = top < edgeOffset
|
|
30
|
+
const atBottom = top + viewH >= fullH - edgeOffset * 0.8
|
|
23
31
|
if (atTop) {
|
|
24
32
|
edgeLockRef.current = 'top'
|
|
25
33
|
setActiveId(null)
|
|
@@ -39,18 +47,19 @@ export default function useScrollSpy(ids, { rootMargin = '-30% 0px -60% 0px', ed
|
|
|
39
47
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
|
40
48
|
if (visible[0]) setActiveId(visible[0].target.id)
|
|
41
49
|
},
|
|
42
|
-
{ rootMargin, threshold: 0 },
|
|
50
|
+
{ root: scroller ?? null, rootMargin, threshold: 0 },
|
|
43
51
|
)
|
|
44
52
|
|
|
45
53
|
elements.forEach((el) => observer.observe(el))
|
|
46
|
-
|
|
54
|
+
const target = scroller ?? window
|
|
55
|
+
target.addEventListener('scroll', checkEdges, { passive: true })
|
|
47
56
|
checkEdges()
|
|
48
57
|
|
|
49
58
|
return () => {
|
|
50
59
|
observer.disconnect()
|
|
51
|
-
|
|
60
|
+
target.removeEventListener('scroll', checkEdges)
|
|
52
61
|
}
|
|
53
|
-
}, [key, rootMargin, edgeOffset])
|
|
62
|
+
}, [key, rootMargin, edgeOffset, root])
|
|
54
63
|
|
|
55
64
|
return activeId
|
|
56
65
|
}
|
package/src/index.js
CHANGED
|
@@ -62,6 +62,7 @@ export { default as ButtonGroup } from './molecules/ButtonGroup.jsx'
|
|
|
62
62
|
/* monorepo sets (P6–P10) — molecule members */
|
|
63
63
|
export { default as AlignmentGrid } from './molecules/AlignmentGrid.jsx'
|
|
64
64
|
export { default as ImageBlock } from './molecules/ImageBlock.jsx'
|
|
65
|
+
export { default as InteractiveImage } from './atoms/InteractiveImage.jsx'
|
|
65
66
|
export { default as SelectionOverlay } from './molecules/SelectionOverlay.jsx'
|
|
66
67
|
export { default as VideoBlock, getEmbedUrl } from './molecules/VideoBlock.jsx'
|
|
67
68
|
export { default as CardFeatureItem } from './molecules/CardFeatureItem.jsx'
|
|
@@ -108,6 +109,7 @@ export { default as FeaturesCardSection } from './organisms/FeaturesCardSection.
|
|
|
108
109
|
export { default as FoundryCTA } from './organisms/FoundryCTA.jsx'
|
|
109
110
|
export { default as FullBleedHero } from './organisms/FullBleedHero.jsx'
|
|
110
111
|
export { default as LoaderOverlay } from './organisms/LoaderOverlay.jsx'
|
|
112
|
+
export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPicker, MediaBrowser } from './organisms/MediaLibrary.jsx'
|
|
111
113
|
export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
|
|
112
114
|
export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
113
115
|
export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
|
|
@@ -126,6 +128,7 @@ export { default as usePrefersReducedMotion } from './hooks/usePrefersReducedMot
|
|
|
126
128
|
export { default as useReveal } from './hooks/useReveal.js'
|
|
127
129
|
export { default as useScrollSpy } from './hooks/useScrollSpy.js'
|
|
128
130
|
export { default as useTilt } from './hooks/useTilt.js'
|
|
131
|
+
export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
|
|
129
132
|
export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
|
|
130
133
|
export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
131
134
|
export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
|
|
@@ -116,7 +116,7 @@ export default function ColorInputRow({
|
|
|
116
116
|
{chip('stretch')}
|
|
117
117
|
<span
|
|
118
118
|
aria-hidden="true"
|
|
119
|
-
className={`absolute inset-0 inline-flex items-center justify-center rounded-[
|
|
119
|
+
className={`absolute inset-0 inline-flex items-center justify-center rounded-[var(--kol-radius-xs)] transition-opacity bg-fg-absolute-48 text-white pointer-events-none ${
|
|
120
120
|
locked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
121
121
|
}`}
|
|
122
122
|
>
|
|
@@ -5,7 +5,7 @@ import Image from './Image'
|
|
|
5
5
|
* frame (bordered surface-secondary panel, rounded) holding one object-cover
|
|
6
6
|
* image. Extracted from the foundry TypefacePage, where it sat inlined 5x
|
|
7
7
|
* verbatim between content sections. The double radius (`rounded` panel,
|
|
8
|
-
* `rounded-[
|
|
8
|
+
* `rounded-[var(--kol-radius-sm)]` image) is intentional — the image nests inside the border.
|
|
9
9
|
*
|
|
10
10
|
* Presentational — the caller passes finished `src`/`srcSet` strings; any
|
|
11
11
|
* CDN size-ladder generation stays at the call site. `media` swaps an
|
|
@@ -44,7 +44,7 @@ export default function FramedMediaBand({
|
|
|
44
44
|
srcSet={srcSet}
|
|
45
45
|
sizes={sizes}
|
|
46
46
|
alt={alt}
|
|
47
|
-
className="w-full h-full object-cover rounded-[
|
|
47
|
+
className="w-full h-full object-cover rounded-[var(--kol-radius-sm)]"
|
|
48
48
|
/>
|
|
49
49
|
)}
|
|
50
50
|
</div>
|
|
@@ -134,7 +134,7 @@ export default function ShellDrawer({
|
|
|
134
134
|
return createPortal(
|
|
135
135
|
<>
|
|
136
136
|
<div
|
|
137
|
-
className={`fixed inset-0 z-[100]
|
|
137
|
+
className={`fixed inset-0 z-[100] kol-overlay-scrim ${motionBackdrop}`}
|
|
138
138
|
onClick={onClose}
|
|
139
139
|
aria-hidden="true"
|
|
140
140
|
/>
|