@kolkrabbi/kol-component 0.15.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.15.2",
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-[4px] bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
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}`}
@@ -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="space-y-0">
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
- className={`shell-nav-item block kol-mono-14 transition-colors focus-visible:ring-focus hover:text-emphasis ${
43
- activeId === item.id ? 'text-emphasis' : 'text-body'
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
- <button
34
- type="button"
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>
@@ -25,8 +25,15 @@ import { Icon } from '@kolkrabbi/kol-icons'
25
25
  * pairs that flip with the theme, so light/dark comes free from the tokens with
26
26
  * no per-theme props.
27
27
  *
28
- * `size` moves the square and the glyph together one prop, never two — on the
29
- * solo-glyph law (16/20/24 against the pinned squares 28/32/36).
28
+ * `size` moves the square and the glyph together on the solo-glyph ladder
29
+ * (16/20/24 against the pinned squares 28/32/36) — that pairing is the DEFAULT,
30
+ * and it is what every call site should take. `iconSize` unbinds the glyph for
31
+ * the cases the ladder cannot serve, exactly as it does on `Button` and `Input`:
32
+ * a `radius="full"` frame reads heavier than the square it was tuned against (a
33
+ * circle inscribes ~78.5% of its bounding box), so an edge-straddling round
34
+ * control wants a smaller glyph in the same pinned square. The square never
35
+ * moves with it — that is the 2026-07-28 law, and it only means something if the
36
+ * two are separable.
30
37
  *
31
38
  * Deliberately absent: `onClick`, `href`, `disabled`, `aria-pressed`, `title`.
32
39
  * Wanting any of those means wanting a `Button` with `iconOnly`, not this.
@@ -39,6 +46,9 @@ import { Icon } from '@kolkrabbi/kol-icons'
39
46
  * own chrome idiom (edge-straddling controls, avatars),
40
47
  * and it is the only sanctioned exception to the hard
41
48
  * 4px repo invariant.
49
+ * @param {number} iconSize glyph size in px — overrides the size-derived
50
+ * default. Null (the default) keeps the ladder. The
51
+ * square is unaffected; only the centred glyph moves.
42
52
  * @param {string} className escape hatch
43
53
  */
44
54
  const GLYPH = { sm: 16, md: 20, lg: 24 }
@@ -48,17 +58,19 @@ export default function IconFrame({
48
58
  variant = 'secondary',
49
59
  size = 'md',
50
60
  radius = 'sm',
61
+ iconSize = null,
51
62
  className = '',
52
63
  ...rest
53
64
  }) {
54
65
  if (!name) return null
55
66
  const radiusCls = radius === 'full' ? ' kol-icon-frame-radius-full' : ''
67
+ const resolvedIconSize = iconSize ?? GLYPH[size] ?? GLYPH.md
56
68
  return (
57
69
  <span
58
70
  className={`kol-icon-frame kol-icon-frame-${variant} kol-icon-frame-${size}${radiusCls} ${className}`.trim()}
59
71
  {...rest}
60
72
  >
61
- <Icon name={name} size={GLYPH[size] ?? GLYPH.md} />
73
+ <Icon name={name} size={resolvedIconSize} />
62
74
  </span>
63
75
  )
64
76
  }
@@ -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-[2px] px-6 py-8 ${maxWidth ? `${maxWidth} mx-auto` : ''} ${className}`.trim()}
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})`,
@@ -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-[3px] bg-fg-08 kol-helper-10 text-fg-48"
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
- * Canonical (merged web rich + brand compat). Web's rich API: variant
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
- * `size` defaults to `sm` per the chip law (2026-07-30, same pass as Pill).
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 = 'default',
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
- let baseClass
44
- if (variant === 'naked') {
45
- baseClass = color ? `tag-naked tag--${color}` : 'tag-naked'
46
- } else if (color) {
47
- baseClass = `tag tag--${color}`
48
- } else {
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
- const isSolid = solid || variant === 'solid'
53
- const activeClass = active ? (color ? 'tag--active' : 'is-active') : ''
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
- baseClass,
57
- `tag-${size}`,
58
- isSolid && variant !== 'naked' ? 'tag--solid' : '',
59
- activeClass,
60
- isInteractive ? 'cursor-pointer' : '',
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} />
@@ -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
- export default function useScrollSpy(ids, { rootMargin = '-30% 0px -60% 0px', edgeOffset = 100 } = {}) {
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 atTop = window.scrollY < edgeOffset
20
- const atBottom =
21
- window.scrollY + window.innerHeight >=
22
- document.documentElement.scrollHeight - edgeOffset * 0.8
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
- window.addEventListener('scroll', checkEdges, { passive: true })
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
- window.removeEventListener('scroll', checkEdges)
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-[2px] transition-opacity bg-fg-absolute-48 text-white pointer-events-none ${
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
  >
@@ -43,7 +43,7 @@ const SIZE_CLASSES = {
43
43
 
44
44
  const RADIUS_CLASSES = {
45
45
  none: 'rounded-none',
46
- tight: 'rounded-[2px]',
46
+ tight: 'rounded-[var(--kol-radius-xs)]',
47
47
  sm: 'rounded',
48
48
  full: 'rounded-full',
49
49
  }
@@ -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-[4px]` image) is intentional — the image nests inside the border.
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-[4px]"
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] bg-black/50 ${motionBackdrop}`}
137
+ className={`fixed inset-0 z-[100] kol-overlay-scrim ${motionBackdrop}`}
138
138
  onClick={onClose}
139
139
  aria-hidden="true"
140
140
  />