@kolkrabbi/kol-component 0.45.0 → 0.47.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.45.0",
3
+ "version": "0.47.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",
@@ -0,0 +1,193 @@
1
+ import { useRef, useState, useEffect, useLayoutEffect } from 'react'
2
+ import gsap from 'gsap'
3
+ import { Icon } from '@kolkrabbi/kol-icons'
4
+ import { glyphSize } from '../hooks/glyphLadders.js'
5
+
6
+ /**
7
+ * ActionButton — an icon control that CONFIRMS what it did (2026-08-15 user
8
+ * ruling: *"where is the 'code copied' or whatever message"*).
9
+ *
10
+ * The confirm-flip existed exactly once, welded to the clipboard inside
11
+ * CopyButton: fire, swap `copy`→`check` for 2s, flip the accessible label.
12
+ * Nothing else could reuse it, so every other in-frame control — download,
13
+ * select, anything a card puts over its media — had no way to acknowledge a
14
+ * click at all. This is that behaviour with the clipboard taken out of it;
15
+ * CopyButton is now a six-line wrapper around it and keeps its own API.
16
+ *
17
+ * The swap is ANIMATED, which the original was not — it hard-cut between two
18
+ * glyphs. framer-motion is already a declared peer of this package (TiltCard),
19
+ * so nothing new is installed. The curve is the house curve — the numeric twin
20
+ * of `--kol-ease-house`, since gsap takes an array and cannot read a CSS var.
21
+ * If the token moves, `HOUSE_EASE` below moves with it by hand; that is the one
22
+ * place in the DS where the curve is duplicated rather than referenced.
23
+ *
24
+ * Chrome is IconFrame's — `kol-icon-frame kol-icon-frame-{variant} -{size}`,
25
+ * emitted the way Dropdown emits `kol-btn` classes. 05-control-chrome.md:109:
26
+ * *"Any icon-only control in chrome is IconFrame variant="nav" size="…" —
27
+ * nothing hand-writes the square (user ruling 2026-08-01)."* This component
28
+ * hand-wrote one for a day; it does not any more.
29
+ *
30
+ * A DIMMED REST IS A VARIANT SWAP, NOT A STATE (same doc). `nav` rests at
31
+ * oq-64, `ghost` at oq-48, both static — so there is no hover ladder here and
32
+ * none should be added.
33
+ *
34
+ * `plate` is the doc's ONE named exception: an affordance over a PHOTO needs an
35
+ * opaque plate plus a backdrop blur to stay legible over arbitrary pixels, and
36
+ * no Button variant covers it. The plate is the discriminator, not a list.
37
+ *
38
+ * Positioning stays the parent's job: pass `.kol-frame-control` to sit it in a
39
+ * card's corner, or nothing to leave it in flow.
40
+ *
41
+ * @param {string} icon glyph at rest
42
+ * @param {string} confirmIcon glyph while confirming (default 'check')
43
+ * @param {string} label accessible label at rest
44
+ * @param {string} confirmLabel accessible label while confirming
45
+ * @param {Function} onAction (event) => void | Promise — awaited; the
46
+ * confirm only fires once it resolves
47
+ * @param {string} href renders an <a> instead of a <button>
48
+ * @param {number} hold ms to hold the confirm state (default 2000)
49
+ * @param {string} size sm | md | lg — the pinned square (28/32/36)
50
+ * and its SOLO glyph (16/20/24) move together,
51
+ * resolved from hooks/glyphLadders.js. This was
52
+ * a raw px number and defaulted to 16 in a 32px
53
+ * box: the sm glyph in the md square, which is
54
+ * the exact hand-transcription the ladders file
55
+ * exists to stop.
56
+ * @param {number} iconSize px override for the glyph only — the square
57
+ * never moves with it (2026-07-28 law)
58
+ * @param {boolean} toggle STICKY instead of timed — the on-state holds
59
+ * until clicked again, and `confirmIcon` is the
60
+ * on-glyph (star → star-solid), not a receipt.
61
+ * A confirm says "that happened"; a toggle says
62
+ * "this IS", and the two must not share a timer.
63
+ * @param {string} chrome WHICH control this is. Three separate styles,
64
+ * no shared base — they had one, and every edit
65
+ * to a shared rule moved all of them:
66
+ * 'copy' .kol-copy-btn CodeBlock's, untouched
67
+ * 'media' .kol-media-control boxed, over a photo
68
+ * 'inline' .kol-inline-control bare, inside text
69
+ */
70
+ const HOUSE_EASE = [0.4, 0, 0.2, 1]
71
+
72
+ /* long enough that the press is unmistakably a state, short enough that it is
73
+ * not a mode. The release then plays the full bounce out. */
74
+ const PRESS_HOLD = 1600
75
+
76
+ const SWAP_MS = 500
77
+
78
+ const CHROME = {
79
+ copy: 'kol-copy-btn',
80
+ media: 'kol-media-control',
81
+ inline: 'kol-inline-control',
82
+ }
83
+
84
+ export default function ActionButton({
85
+ icon,
86
+ confirmIcon,
87
+ label,
88
+ confirmLabel,
89
+ onAction,
90
+ href,
91
+ hold = 2000,
92
+ size = 'md',
93
+ iconSize,
94
+ chrome = 'copy',
95
+ toggle = false,
96
+ className = '',
97
+ ...rest
98
+ }) {
99
+ const [done, setDone] = useState(false)
100
+ /* CSS :active lasts exactly as long as the mouse button is down — about 50ms
101
+ * on a real click — so no duration or curve can make the press read. The
102
+ * pressed state is HELD here instead, then released to animate out. */
103
+ const [pressed, setPressed] = useState(false)
104
+ const timer = useRef(null)
105
+ const pressTimer = useRef(null)
106
+
107
+ /* the timer outlives the click — clear it if the control unmounts mid-hold,
108
+ * or React warns and the callback fires into a dead component */
109
+ useEffect(() => () => { clearTimeout(timer.current); clearTimeout(pressTimer.current) }, [])
110
+
111
+ const handle = async (event) => {
112
+ clearTimeout(pressTimer.current)
113
+ setPressed(true)
114
+ pressTimer.current = setTimeout(() => setPressed(false), PRESS_HOLD)
115
+ if (onAction) await onAction(event)
116
+ clearTimeout(timer.current)
117
+ if (toggle) {
118
+ setDone((on) => !on)
119
+ return
120
+ }
121
+ setDone(true)
122
+ timer.current = setTimeout(() => setDone(false), hold)
123
+ }
124
+
125
+ const aria = (done && confirmLabel) || label
126
+ const base = CHROME[chrome] ?? CHROME.copy
127
+ const cls = `${base}${toggle && done ? ` ${base}--on` : ''}${pressed ? ` ${base}--pressed` : ''} ${className}`.trim()
128
+ const glyphPx = iconSize ?? glyphSize(size, true)
129
+
130
+ /* THE SWAP IS A MORPH where the glyphs allow it (user ruling 2026-08-15).
131
+ * Both glyphs are mounted in one cell; when each side is a single <path>,
132
+ * MorphSVG tweens the path data so one shape BECOMES the other. Multi-path
133
+ * glyphs (download is 3, trash is 5) cannot morph 1:1, so they cross over on
134
+ * the same clock instead — the timing stays identical either way. */
135
+ const restRef = useRef(null)
136
+ const onRef = useRef(null)
137
+ const first = useRef(true)
138
+
139
+ /* A CROSSFADE between two real glyphs, not a morph. MorphSVG rewrote the
140
+ * path `d` in the DOM to tween between shapes — which mutates the icon the
141
+ * set shipped. If a state needs a different shape, that shape is an icon in
142
+ * the set (`star` / `star-solid`), not something computed at runtime. */
143
+ useLayoutEffect(() => {
144
+ const rest = restRef.current
145
+ const on = onRef.current
146
+ /* no confirmIcon = ONE glyph, and the state is carried by the class alone
147
+ * (a fill, a colour). Nothing to crossfade. */
148
+ if (!rest || !on) return undefined
149
+
150
+ if (first.current) {
151
+ first.current = false
152
+ gsap.set(rest, { autoAlpha: 1 })
153
+ gsap.set(on, { autoAlpha: 0 })
154
+ return undefined
155
+ }
156
+
157
+ const from = done ? rest : on
158
+ const to = done ? on : rest
159
+ const ctx = gsap.context(() => {
160
+ gsap.to(from, { autoAlpha: 0, duration: SWAP_MS / 1000, ease: 'power1.inOut' })
161
+ gsap.to(to, { autoAlpha: 1, duration: SWAP_MS / 1000, ease: 'power1.inOut' })
162
+ })
163
+ return () => ctx.revert()
164
+ }, [done])
165
+
166
+ const glyph = (
167
+ <span className="kol-action-glyph relative inline-grid place-items-center" style={{ width: glyphPx, height: glyphPx }}>
168
+ <span ref={restRef} className="inline-flex" style={{ gridArea: '1 / 1' }}>
169
+ <Icon name={icon} size={glyphPx} />
170
+ </span>
171
+ {confirmIcon && (
172
+ <span ref={onRef} className="inline-flex" style={{ gridArea: '1 / 1', opacity: 0 }}>
173
+ <Icon name={confirmIcon} size={glyphPx} />
174
+ </span>
175
+ )}
176
+ </span>
177
+ )
178
+
179
+ /* The element follows the affordance — IconFrame's contract, same reasoning:
180
+ * a link must be a real <a> for middle-click, focus order and screen readers. */
181
+ if (href) {
182
+ return (
183
+ <a className={cls} href={href} onClick={handle} aria-label={aria} title={aria} {...rest}>
184
+ {glyph}
185
+ </a>
186
+ )
187
+ }
188
+ return (
189
+ <button type="button" className={cls} onClick={handle} aria-label={aria} title={aria} {...rest}>
190
+ {glyph}
191
+ </button>
192
+ )
193
+ }
@@ -0,0 +1,72 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * usePlaceholders — the ONE gate for placeholder / empty-state prose
5
+ * (GatedEmptyState, filed from kol-fxr 2026-08-15).
6
+ *
7
+ * The ticket's ruling, and the reason this is one concept rather than three:
8
+ * the filing repo had invented "helper text" vs "empty state" vs "hint" as
9
+ * separate ideas, and that is precisely what let the prose creep back — each
10
+ * kind had its own home and none had an off switch. There is one kind here,
11
+ * it is called a placeholder, and it is OFF until asked for.
12
+ *
13
+ * WHAT THE DS OWNS: the preference, its persistence, and the `.kol-placeholder`
14
+ * suppression rule in kol-utilities.css.
15
+ * WHAT THE CONSUMER OWNS: the keybind. A design system that grabs a global
16
+ * key collides with every app that already used it — kol-fxr's own `H` is
17
+ * already `toggle-visibility` in its editor keymap, which is exactly the
18
+ * collision the DS must not ship. Call `toggle` from whatever key you like.
19
+ *
20
+ * The hiding is CSS, not a render branch, so it covers a consumer's OWN prose
21
+ * the moment they put `.kol-placeholder` on it — not just <EmptyState gated>.
22
+ * One rule, no re-render, nothing to thread through a tree.
23
+ */
24
+
25
+ const STORAGE_KEY = 'kol-placeholders'
26
+ const ATTR = 'data-kol-placeholders'
27
+
28
+ const root = () => document.documentElement
29
+
30
+ /* Module-level subscribers: a settings checkbox and a keybind are usually two
31
+ * different components calling this hook, and per-component useState would let
32
+ * them disagree about a preference there is only one of. The CSS never
33
+ * desyncs (it reads the attribute), but the reported `shown` would. */
34
+ const listeners = new Set()
35
+
36
+ function read() {
37
+ try {
38
+ return localStorage.getItem(STORAGE_KEY) === 'on'
39
+ } catch {
40
+ return false // storage blocked → stay off, which is the default anyway
41
+ }
42
+ }
43
+
44
+ function write(on) {
45
+ if (on) root().setAttribute(ATTR, '')
46
+ else root().removeAttribute(ATTR)
47
+ try {
48
+ localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
49
+ } catch { /* storage blocked — the attribute still holds for this session */ }
50
+ listeners.forEach((fn) => fn(on))
51
+ }
52
+
53
+ export default function usePlaceholders() {
54
+ const [shown, setShown] = useState(false)
55
+
56
+ /* Boot from storage and stamp the attribute. Default OFF is the ruling, so
57
+ * an absent key and a blocked localStorage both land on the same answer. */
58
+ useEffect(() => {
59
+ const on = read()
60
+ if (on) root().setAttribute(ATTR, '')
61
+ setShown(on)
62
+ listeners.add(setShown)
63
+ return () => listeners.delete(setShown)
64
+ }, [])
65
+
66
+ /* Read-then-flip off storage rather than state: a keybind handler bound once
67
+ * would otherwise close over the first render's value and toggle from stale. */
68
+ const toggle = useCallback(() => write(!read()), [])
69
+ const set = useCallback((on) => write(!!on), [])
70
+
71
+ return { shown, toggle, setShown: set }
72
+ }
package/src/index.js CHANGED
@@ -22,7 +22,8 @@ export { default as AssetPlaceholder } from './utilities/AssetPlaceholder.jsx'
22
22
  export { default as Avatar } from './atoms/Avatar.jsx'
23
23
  export { default as Badge } from './atoms/Badge.jsx'
24
24
  export { default as Button } from './atoms/Button.jsx'
25
- export { default as CopyButton } from './atoms/CopyButton.jsx'
25
+ export { default as ActionButton } from './atoms/ActionButton.jsx'
26
+ export { default as CopyButton } from './molecules/CopyButton.jsx'
26
27
  export { default as CurveOverlay } from './atoms/CurveOverlay.jsx'
27
28
  export { default as Divider } from './atoms/Divider.jsx'
28
29
  export { default as DocsToc } from './molecules/DocsToc.jsx'
@@ -79,6 +80,15 @@ export { default as FramedMediaBand } from './organisms/FramedMediaBand.jsx'
79
80
  export { default as Image } from './atoms/Image.jsx'
80
81
  export { default as MediaCard } from './molecules/MediaCard.jsx'
81
82
  export { default as MediaRow } from './molecules/MediaRow.jsx'
83
+
84
+ /* content-card system (2026-08-15) — the ruled card/row family:
85
+ * docs/documentation/03-components/06-content-card-system.md */
86
+ export { default as ContentText } from './molecules/ContentText.jsx'
87
+ export { default as ContentMedia } from './molecules/ContentMedia.jsx'
88
+ export { default as ContentCard } from './molecules/ContentCard.jsx'
89
+ export { default as ContentRow } from './molecules/ContentRow.jsx'
90
+ export { default as ContentItem } from './molecules/ContentItem.jsx'
91
+ export { default as ContentCollection } from './organisms/ContentCollection.jsx'
82
92
  export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
83
93
  export { MenuPopover } from './molecules/MenuPopover.jsx'
84
94
  export { ModalProvider, useModal } from './molecules/Modal.jsx'
@@ -148,6 +158,7 @@ export { default as useTilt } from './hooks/useTilt.js'
148
158
  export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
149
159
  export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
150
160
  export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
161
+ export { default as usePlaceholders } from './hooks/usePlaceholders.js'
151
162
  export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
152
163
 
153
164
  // color math (support module — HSL/hex conversion + harmony generation)
@@ -1,6 +1,6 @@
1
1
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
2
2
  import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
3
- import CopyButton from '../atoms/CopyButton.jsx'
3
+ import CopyButton from './CopyButton.jsx'
4
4
 
5
5
  /**
6
6
  * CodeBlock — REPLICATED from the elder reference
@@ -105,7 +105,7 @@ export default function CodeBlock({ children, code: codeProp, language: language
105
105
  >
106
106
  {code}
107
107
  </SyntaxHighlighter>
108
- <CopyButton text={code} className="kol-codeblock-copy" />
108
+ <CopyButton text={code} className="kol-frame-control" />
109
109
  </div>
110
110
  </div>
111
111
  )
@@ -0,0 +1,250 @@
1
+ import ContentMedia from './ContentMedia.jsx'
2
+ import ContentText from './ContentText.jsx'
3
+
4
+ /**
5
+ * ContentCard — the card form of the content-card system. Variants differ in
6
+ * text composition, media ratio, and the RULED box structure, all defaulted
7
+ * from the live review (06-content-card-system.md §2/§3):
8
+ *
9
+ * stack media (own ratio) on top, text plate below — default · article · work
10
+ * fill-card the CARD is the ratio frame; media fills the remainder above
11
+ * the plate — catalog · print (the A4 cards)
12
+ * canvas the card is the ratio frame; media fills it absolutely and the
13
+ * text plate floats on top — typeface (details over specimen)
14
+ *
15
+ * Padding spells the TOKENS (--kol-pad-card-{sm,md,lg} = 12/16/24), never a
16
+ * literal. Passing `pad` overrides the plate padding with one token step
17
+ * (named `pad`, not `size` — `size` is the ruled TEXT slot). Image-only cards
18
+ * (print) pass no text slots and render no plate. Exotic chrome (drawers,
19
+ * keylines, specimens) is consumer content through `media`.
20
+ */
21
+
22
+ /* ruled defaults — defaults, not hardcodes; `ratio` stays overridable while
23
+ * the A4 question is open (06-content-card-system.md §4). */
24
+ const RATIOS = {
25
+ default: '1 / 1',
26
+ catalog: '1 / 1.41421',
27
+ print: '1 / 1.41421',
28
+ article: '16 / 9',
29
+ work: '3 / 4',
30
+ typeface: '1 / 1.41421',
31
+ }
32
+
33
+ /* ruled box values per variant, verbatim from the §3 reference cards —
34
+ * paddings in tokens: pad-card-sm 12 · pad-card-md 16 · pad-card-lg 24 */
35
+ const BOX = {
36
+ default: { layout: 'stack', border: 'var(--kol-fg-12)', bg: 'var(--kol-fg-02)', pad: 'var(--kol-pad-card-sm)' },
37
+ catalog: { layout: 'fill-card', border: 'var(--kol-fg-04)', bg: 'var(--kol-fg-04)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, plateBg: 'var(--kol-surface-primary)' },
38
+ print: { layout: 'fill-card', border: null, bg: 'var(--kol-surface-secondary)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true },
39
+ article: { layout: 'stack', border: null, bg: null, pad: '0', mediaGap: 'var(--kol-spacing-4)' },
40
+ /* work is a DRAWER: image-only at rest, and on hover a light plate rises
41
+ * over the bottom of the artwork carrying the title + meta. This is the
42
+ * shipped WorkCard and it was wrong to reject it as "hiding the title" — a
43
+ * work shelf is a wall of images by design, and the caption is the reveal. */
44
+ work: { layout: 'drawer', border: 'var(--kol-fg-04)', bg: null, pad: 'var(--kol-pad-card-md)', padMd: 'var(--kol-pad-card-lg)' },
45
+ /* typeface's card is a FIXED 500px tall specimen board, not a ratio — the
46
+ * shipped item is `h-[500px]`, and a ratio re-crops the glyph at every
47
+ * column width, which is the one thing a specimen must not do. */
48
+ typeface: { layout: 'canvas', border: 'var(--kol-fg-08)', bg: 'var(--kol-surface-primary)', pad: 'var(--kol-pad-card-lg)', height: 500 },
49
+ }
50
+
51
+ /* HOVER is a bg STEP on the opaque tier, per 05-control-chrome.md's state model
52
+ * — "interactive fills mix ink into the surface via the oq-* tier, never a
53
+ * translucent fg-* wash", because a translucent fill over an image reads as the
54
+ * control vanishing. article is the exception the shipped card already set: it
55
+ * has no surface of its own to step, so it dims its title instead. */
56
+ const HOVER = {
57
+ default: 'var(--kol-oq-04)',
58
+ catalog: 'var(--kol-surface-tertiary)',
59
+ print: 'var(--kol-oq-04)',
60
+ article: null,
61
+ work: null,
62
+ typeface: 'var(--kol-surface-inverse)',
63
+ }
64
+
65
+ /* per-variant media treatment. `ring` sits OVER the artwork, `frame` UNDER it —
66
+ * see ContentMedia. print rings because an A4 print on a light page has no edge
67
+ * of its own; article frames because its media is a 16/9 thumbnail that rarely
68
+ * fills its box. */
69
+ const MEDIA = {
70
+ print: { ring: true },
71
+ /* ListingCard's card media steps its border on hover — fg-08 → fg-16 */
72
+ article: { frame: true, borderHover: true },
73
+ }
74
+
75
+ export default function ContentCard({
76
+ variant = 'default',
77
+ pad,
78
+ media,
79
+ ratio,
80
+ fit,
81
+ frame,
82
+ ring,
83
+ control,
84
+ actions,
85
+ selected = false,
86
+ onClick,
87
+ href,
88
+ onNavigate,
89
+ className = '',
90
+ ...text
91
+ }) {
92
+ const box = BOX[variant] ?? BOX.default
93
+ const r = ratio ?? RATIOS[variant]
94
+ const padding = pad ? `var(--kol-pad-card-${pad})` : box.pad
95
+ /* image-only cards (print) pass no text slots — the empty plate must not render */
96
+ const hasText = ['title', 'body', 'kicker', 'detail', 'date', 'size', 'meta', 'tags'].some((k) => text[k] != null)
97
+ /* `actions` sit IN THE TEXT PLATE, bottom-right (user ruling 2026-08-15:
98
+ * *"space in text bottom right"*). Not stacked under the copy in their own
99
+ * row — that grew the card — and not on the media, which was my call to make
100
+ * and wasn't. The plate is one flex row: text takes the width, actions hold
101
+ * the trailing edge, both bottom-aligned so the buttons sit on the last line
102
+ * of copy rather than floating beside the title. */
103
+ const hasPlate = hasText || actions != null
104
+ const framed = box.border != null || box.bg != null
105
+
106
+ const textNode = hasPlate ? (
107
+ <div
108
+ className="kol-card-plate relative"
109
+ style={{
110
+ '--kol-plate-pad': padding,
111
+ '--kol-plate-pad-md': box.padMd,
112
+ padding,
113
+ marginTop: box.layout === 'stack' ? box.mediaGap : undefined,
114
+ borderTop: box.plateTop ? '1px solid var(--kol-fg-04)' : undefined,
115
+ background: box.layout === 'drawer' ? 'var(--kol-surface-inverse)' : box.plateBg,
116
+ color: box.layout === 'drawer' ? 'var(--kol-fg-inverse)' : undefined,
117
+ position: 'relative',
118
+ zIndex: box.layout === 'canvas' ? 1 : undefined,
119
+ }}
120
+ >
121
+ {hasText && <ContentText variant={variant} form="card" {...text} />}
122
+ {/* ABSOLUTE, not a flex sibling: the plate's height moves with the title
123
+ * and the meta, so a laid-out stack would stretch or drift with it. The
124
+ * inset reads the SAME pad token the plate uses, so the icons sit the
125
+ * same distance from the top and right edges as the copy does. */}
126
+ {actions && (
127
+ <div
128
+ className="absolute flex"
129
+ style={{ top: padding, bottom: padding, right: padding }}
130
+ >
131
+ {actions}
132
+ </div>
133
+ )}
134
+ </div>
135
+ ) : null
136
+
137
+ /* The CARD clips its own corners when it is framed, so the media must not
138
+ * round again — two radii on one edge is the visible double-round (user
139
+ * ruling 2026-08-15). Unframed variants (`article`) have nothing clipping
140
+ * them, so their media keeps its radius. The ROW is untouched: it does not
141
+ * clip, so ContentRow's thumb rounds as before. */
142
+ const mediaRadius = !framed
143
+ const mediaProps = {
144
+ radius: mediaRadius,
145
+ fit: fit ?? MEDIA[variant]?.fit,
146
+ frame: frame ?? MEDIA[variant]?.frame ?? false,
147
+ ring: ring ?? MEDIA[variant]?.ring ?? false,
148
+ borderHover: MEDIA[variant]?.borderHover ?? false,
149
+ }
150
+
151
+ /* `control` — the in-frame control slot (user ruling 2026-08-15). One node,
152
+ * placed in the media frame's corner by `.kol-frame-control` (kol-theme).
153
+ * The CARD owns WHERE, the consumer owns WHAT: CopyButton, IconFrame with an
154
+ * href, a select indicator — the card knows none of them by name. This is
155
+ * what MediaCard hardcodes as a download link plus a select checkbox, and
156
+ * the reason its media library could not migrate onto ContentCard. */
157
+ const controlNode = control ? <div className="kol-frame-control">{control}</div> : null
158
+
159
+
160
+ const body =
161
+ box.layout === 'stack' ? (
162
+ <>
163
+ <div className="relative">
164
+ <ContentMedia ratio={r} {...mediaProps}>{media}</ContentMedia>
165
+ {controlNode}
166
+ </div>
167
+ {textNode}
168
+ </>
169
+ ) : box.layout === 'fill-card' ? (
170
+ <>
171
+ <div className="flex-1 min-w-0 relative overflow-hidden">
172
+ <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
173
+ {controlNode}
174
+ </div>
175
+ {textNode}
176
+ </>
177
+ ) : box.layout === 'drawer' ? (
178
+ <>
179
+ <div className="relative h-full">
180
+ <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
181
+ {controlNode}
182
+ </div>
183
+ {/* the plate is INVERSE and hidden until hover — `kol-card-drawer` owns
184
+ * the reveal so the transition sits with the rest of the chrome */}
185
+ {textNode && <div className="kol-card-drawer">{textNode}</div>}
186
+ </>
187
+ ) : (
188
+ /* canvas — media fills the frame, plate floats on top */
189
+ <>
190
+ <div className="absolute" style={{ inset: 0 }}>
191
+ <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
192
+ {controlNode}
193
+ </div>
194
+ {textNode}
195
+ </>
196
+ )
197
+
198
+ /* THE ROOT FOLLOWS THE AFFORDANCE. A card that navigates must be a real <a>:
199
+ * middle-click, cmd-click, focus order, "copy link address" and every screen
200
+ * reader's link list all come from the tag, and none of them can be added
201
+ * back with a click handler. `onNavigate` is the SPA seam — call it, and if
202
+ * it does not preventDefault the browser follows the href, so the card works
203
+ * with or without a router.
204
+ *
205
+ * A card with only onClick stays an <article> but becomes operable: a div you
206
+ * can click and cannot focus is the single most common a11y regression in a
207
+ * card family, and it is what every shipped variant here had. */
208
+ const nav = (event) => {
209
+ if (onNavigate) onNavigate(event, href)
210
+ if (onClick) onClick(event)
211
+ }
212
+ const interactive = href || onClick
213
+ const hoverBg = HOVER[variant]
214
+
215
+ const common = {
216
+ className: `kol-card group flex flex-col ${box.layout === 'drawer' ? 'relative overflow-hidden rounded-[var(--kol-radius-sm)]' : ''} ${framed ? 'overflow-hidden rounded-[var(--kol-radius-sm)]' : ''} ${box.border ? 'border' : ''} ${box.layout === 'canvas' ? 'relative' : ''} ${interactive ? 'cursor-pointer select-none' : ''} ${hoverBg && interactive ? 'kol-content-hover' : ''} ${className}`.trim(),
217
+ style: {
218
+ /* same reason as ContentRow: rest colours are PROPERTIES, because an
219
+ * inline background/borderColor outranks the hover class and the step
220
+ * would never render. */
221
+ '--kol-card-bg': box.bg ?? undefined,
222
+ '--kol-card-border': box.border ? (selected ? 'var(--kol-fg-64)' : box.border) : undefined,
223
+ '--kol-content-hover-bg': hoverBg ?? undefined,
224
+ aspectRatio: box.height ? undefined : (box.layout !== 'stack' ? r : undefined),
225
+ height: box.height,
226
+ },
227
+ }
228
+
229
+ if (href) {
230
+ return <a href={href} onClick={nav} {...common}>{body}</a>
231
+ }
232
+ if (onClick) {
233
+ return (
234
+ <article
235
+ onClick={onClick}
236
+ role="button"
237
+ tabIndex={0}
238
+ onKeyDown={(e) => {
239
+ if (e.key !== 'Enter' && e.key !== ' ') return
240
+ e.preventDefault()
241
+ onClick(e)
242
+ }}
243
+ {...common}
244
+ >
245
+ {body}
246
+ </article>
247
+ )
248
+ }
249
+ return <article {...common}>{body}</article>
250
+ }
@@ -0,0 +1,14 @@
1
+ import ContentCard from './ContentCard.jsx'
2
+ import ContentRow from './ContentRow.jsx'
3
+
4
+ /**
5
+ * ContentItem — the form switch the estate hand-wrote nine times
6
+ * (`layout === 'list' ? row : card`). One prop picks the form; everything
7
+ * else passes through to ContentCard / ContentRow unchanged, so a listing
8
+ * under a LIST/GRID toggle is one component with one prop flipped.
9
+ *
10
+ * @param {string} form 'card' | 'row'
11
+ */
12
+ export default function ContentItem({ form = 'card', ...rest }) {
13
+ return form === 'row' ? <ContentRow {...rest} /> : <ContentCard {...rest} />
14
+ }