@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 +1 -1
- package/src/atoms/ActionButton.jsx +193 -0
- package/src/hooks/usePlaceholders.js +72 -0
- package/src/index.js +12 -1
- package/src/molecules/CodeBlock.jsx +2 -2
- package/src/molecules/ContentCard.jsx +250 -0
- package/src/molecules/ContentItem.jsx +14 -0
- package/src/molecules/ContentMedia.jsx +91 -0
- package/src/molecules/ContentRow.jsx +145 -0
- package/src/molecules/ContentText.jsx +223 -0
- package/src/molecules/CopyButton.jsx +41 -0
- package/src/molecules/EmptyState.jsx +14 -6
- package/src/molecules/MediaCard.jsx +2 -2
- package/src/molecules/SearchInput.jsx +1 -1
- package/src/molecules/Section.jsx +13 -2
- package/src/organisms/ContentCollection.jsx +56 -0
- package/src/organisms/ContentFilters.jsx +72 -30
- package/src/utilities/AssetPlaceholder.jsx +6 -1
- package/src/atoms/CopyButton.jsx +0 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "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
|
|
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 '
|
|
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-
|
|
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
|
+
}
|