@kolkrabbi/kol-component 0.46.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 +108 -31
- package/src/molecules/ContentCard.jsx +136 -26
- package/src/molecules/ContentMedia.jsx +60 -4
- package/src/molecules/ContentRow.jsx +95 -21
- package/src/molecules/ContentText.jsx +128 -43
- package/src/molecules/CopyButton.jsx +1 -0
- package/src/molecules/SearchInput.jsx +1 -1
- package/src/organisms/ContentFilters.jsx +2 -2
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",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useRef, useState, useEffect } from 'react'
|
|
2
|
-
import
|
|
1
|
+
import { useRef, useState, useEffect, useLayoutEffect } from 'react'
|
|
2
|
+
import gsap from 'gsap'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
4
|
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
5
5
|
|
|
@@ -16,14 +16,26 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
16
16
|
*
|
|
17
17
|
* The swap is ANIMATED, which the original was not — it hard-cut between two
|
|
18
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
23
|
*
|
|
24
|
-
* Chrome is
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
27
39
|
* card's corner, or nothing to leave it in flow.
|
|
28
40
|
*
|
|
29
41
|
* @param {string} icon glyph at rest
|
|
@@ -43,12 +55,35 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
43
55
|
* exists to stop.
|
|
44
56
|
* @param {number} iconSize px override for the glyph only — the square
|
|
45
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
|
|
46
69
|
*/
|
|
47
|
-
const HOUSE_EASE = [0.
|
|
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
|
+
}
|
|
48
83
|
|
|
49
84
|
export default function ActionButton({
|
|
50
85
|
icon,
|
|
51
|
-
confirmIcon
|
|
86
|
+
confirmIcon,
|
|
52
87
|
label,
|
|
53
88
|
confirmLabel,
|
|
54
89
|
onAction,
|
|
@@ -56,46 +91,88 @@ export default function ActionButton({
|
|
|
56
91
|
hold = 2000,
|
|
57
92
|
size = 'md',
|
|
58
93
|
iconSize,
|
|
94
|
+
chrome = 'copy',
|
|
95
|
+
toggle = false,
|
|
59
96
|
className = '',
|
|
60
97
|
...rest
|
|
61
98
|
}) {
|
|
62
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)
|
|
63
104
|
const timer = useRef(null)
|
|
105
|
+
const pressTimer = useRef(null)
|
|
64
106
|
|
|
65
107
|
/* the timer outlives the click — clear it if the control unmounts mid-hold,
|
|
66
108
|
* or React warns and the callback fires into a dead component */
|
|
67
|
-
useEffect(() => () => clearTimeout(timer.current), [])
|
|
109
|
+
useEffect(() => () => { clearTimeout(timer.current); clearTimeout(pressTimer.current) }, [])
|
|
68
110
|
|
|
69
111
|
const handle = async (event) => {
|
|
112
|
+
clearTimeout(pressTimer.current)
|
|
113
|
+
setPressed(true)
|
|
114
|
+
pressTimer.current = setTimeout(() => setPressed(false), PRESS_HOLD)
|
|
70
115
|
if (onAction) await onAction(event)
|
|
71
116
|
clearTimeout(timer.current)
|
|
117
|
+
if (toggle) {
|
|
118
|
+
setDone((on) => !on)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
72
121
|
setDone(true)
|
|
73
122
|
timer.current = setTimeout(() => setDone(false), hold)
|
|
74
123
|
}
|
|
75
124
|
|
|
76
125
|
const aria = (done && confirmLabel) || label
|
|
77
|
-
const
|
|
126
|
+
const base = CHROME[chrome] ?? CHROME.copy
|
|
127
|
+
const cls = `${base}${toggle && done ? ` ${base}--on` : ''}${pressed ? ` ${base}--pressed` : ''} ${className}`.trim()
|
|
78
128
|
const glyphPx = iconSize ?? glyphSize(size, true)
|
|
79
129
|
|
|
80
|
-
/* A
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
+
|
|
84
166
|
const glyph = (
|
|
85
|
-
<span className="inline-grid place-items-center" style={{ width: glyphPx, height: glyphPx }}>
|
|
86
|
-
<
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
transition={{ duration: 0.22, ease: HOUSE_EASE }}
|
|
95
|
-
>
|
|
96
|
-
<Icon name={done ? confirmIcon : icon} size={glyphPx} />
|
|
97
|
-
</motion.span>
|
|
98
|
-
</AnimatePresence>
|
|
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
|
+
)}
|
|
99
176
|
</span>
|
|
100
177
|
)
|
|
101
178
|
|
|
@@ -37,8 +37,39 @@ const BOX = {
|
|
|
37
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
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
39
|
article: { layout: 'stack', border: null, bg: null, pad: '0', mediaGap: 'var(--kol-spacing-4)' },
|
|
40
|
-
work
|
|
41
|
-
|
|
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 },
|
|
42
73
|
}
|
|
43
74
|
|
|
44
75
|
export default function ContentCard({
|
|
@@ -46,10 +77,15 @@ export default function ContentCard({
|
|
|
46
77
|
pad,
|
|
47
78
|
media,
|
|
48
79
|
ratio,
|
|
80
|
+
fit,
|
|
81
|
+
frame,
|
|
82
|
+
ring,
|
|
49
83
|
control,
|
|
50
84
|
actions,
|
|
51
85
|
selected = false,
|
|
52
86
|
onClick,
|
|
87
|
+
href,
|
|
88
|
+
onNavigate,
|
|
53
89
|
className = '',
|
|
54
90
|
...text
|
|
55
91
|
}) {
|
|
@@ -57,27 +93,44 @@ export default function ContentCard({
|
|
|
57
93
|
const r = ratio ?? RATIOS[variant]
|
|
58
94
|
const padding = pad ? `var(--kol-pad-card-${pad})` : box.pad
|
|
59
95
|
/* image-only cards (print) pass no text slots — the empty plate must not render */
|
|
60
|
-
const hasText = ['title', 'body', 'kicker', 'detail', 'date', 'size', 'meta'].some((k) => text[k] != null)
|
|
61
|
-
/* `actions`
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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. */
|
|
65
103
|
const hasPlate = hasText || actions != null
|
|
66
104
|
const framed = box.border != null || box.bg != null
|
|
67
105
|
|
|
68
106
|
const textNode = hasPlate ? (
|
|
69
107
|
<div
|
|
108
|
+
className="kol-card-plate relative"
|
|
70
109
|
style={{
|
|
110
|
+
'--kol-plate-pad': padding,
|
|
111
|
+
'--kol-plate-pad-md': box.padMd,
|
|
71
112
|
padding,
|
|
72
113
|
marginTop: box.layout === 'stack' ? box.mediaGap : undefined,
|
|
73
114
|
borderTop: box.plateTop ? '1px solid var(--kol-fg-04)' : undefined,
|
|
74
|
-
background: box.plateBg,
|
|
75
|
-
|
|
115
|
+
background: box.layout === 'drawer' ? 'var(--kol-surface-inverse)' : box.plateBg,
|
|
116
|
+
color: box.layout === 'drawer' ? 'var(--kol-fg-inverse)' : undefined,
|
|
117
|
+
position: 'relative',
|
|
76
118
|
zIndex: box.layout === 'canvas' ? 1 : undefined,
|
|
77
119
|
}}
|
|
78
120
|
>
|
|
79
121
|
{hasText && <ContentText variant={variant} form="card" {...text} />}
|
|
80
|
-
{
|
|
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
|
+
)}
|
|
81
134
|
</div>
|
|
82
135
|
) : null
|
|
83
136
|
|
|
@@ -87,6 +140,13 @@ export default function ContentCard({
|
|
|
87
140
|
* them, so their media keeps its radius. The ROW is untouched: it does not
|
|
88
141
|
* clip, so ContentRow's thumb rounds as before. */
|
|
89
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
|
+
}
|
|
90
150
|
|
|
91
151
|
/* `control` — the in-frame control slot (user ruling 2026-08-15). One node,
|
|
92
152
|
* placed in the media frame's corner by `.kol-frame-control` (kol-theme).
|
|
@@ -96,11 +156,12 @@ export default function ContentCard({
|
|
|
96
156
|
* the reason its media library could not migrate onto ContentCard. */
|
|
97
157
|
const controlNode = control ? <div className="kol-frame-control">{control}</div> : null
|
|
98
158
|
|
|
159
|
+
|
|
99
160
|
const body =
|
|
100
161
|
box.layout === 'stack' ? (
|
|
101
162
|
<>
|
|
102
163
|
<div className="relative">
|
|
103
|
-
<ContentMedia ratio={r}
|
|
164
|
+
<ContentMedia ratio={r} {...mediaProps}>{media}</ContentMedia>
|
|
104
165
|
{controlNode}
|
|
105
166
|
</div>
|
|
106
167
|
{textNode}
|
|
@@ -108,33 +169,82 @@ export default function ContentCard({
|
|
|
108
169
|
) : box.layout === 'fill-card' ? (
|
|
109
170
|
<>
|
|
110
171
|
<div className="flex-1 min-w-0 relative overflow-hidden">
|
|
111
|
-
<ContentMedia ratio={null}
|
|
172
|
+
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
112
173
|
{controlNode}
|
|
113
174
|
</div>
|
|
114
175
|
{textNode}
|
|
115
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
|
+
</>
|
|
116
187
|
) : (
|
|
117
188
|
/* canvas — media fills the frame, plate floats on top */
|
|
118
189
|
<>
|
|
119
190
|
<div className="absolute" style={{ inset: 0 }}>
|
|
120
|
-
<ContentMedia ratio={null}
|
|
191
|
+
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
121
192
|
{controlNode}
|
|
122
193
|
</div>
|
|
123
194
|
{textNode}
|
|
124
195
|
</>
|
|
125
196
|
)
|
|
126
197
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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>
|
|
140
250
|
}
|
|
@@ -16,20 +16,76 @@ import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
|
16
16
|
* one edge — the visible double-round. ContentCard turns it off for its framed
|
|
17
17
|
* variants; the ROW keeps it, because a row does not clip.
|
|
18
18
|
*
|
|
19
|
+
* `fit` (2026-08-15) — `cover` crops to fill, which is right for a photograph
|
|
20
|
+
* and wrong for a diagram or a screenshot, where the crop eats the content.
|
|
21
|
+
* `natural` and `compact` are GridCard's `previewFit` under the family's name;
|
|
22
|
+
* the shipped values are kept so a catalog grid can move over without a
|
|
23
|
+
* re-tune. `cover` stays the default — every card in the family today is a
|
|
24
|
+
* photograph.
|
|
25
|
+
*
|
|
26
|
+
* THREE separate edge treatments, because the shipped components use three:
|
|
27
|
+
*
|
|
28
|
+
* frame a TINTED box + border UNDER the media — article's card media is
|
|
29
|
+
* `bg-fg-04 border-fg-08`, and the tint shows wherever a 16/9 thumb
|
|
30
|
+
* does not fill its box
|
|
31
|
+
* border border ONLY, no tint — WorkListItem's thumb is `border-fg-08` over
|
|
32
|
+
* a full-bleed image, where a tint would never be seen anyway and
|
|
33
|
+
* painting one is just a wrong value nobody notices
|
|
34
|
+
* ring an inset hairline OVER the artwork — how a print card keeps a light
|
|
35
|
+
* image from bleeding into a light page
|
|
36
|
+
*
|
|
37
|
+
* `bg` tints without any border — ListingCard's row thumb is `bg-fg-12` bare.
|
|
38
|
+
* They compose; a frame behind a full-bleed cover image is invisible, a ring
|
|
39
|
+
* over one is the only thing you see.
|
|
40
|
+
*
|
|
41
|
+
* NOT here, deliberately: `loading="lazy"` and the fade-on-load. The media is
|
|
42
|
+
* consumer-INJECTED — the real `<img>` is theirs — so lazy is one attribute on
|
|
43
|
+
* their own element, and taking it over would mean cloneElement'ing a node the
|
|
44
|
+
* family does not own to attach an onLoad it cannot guarantee fires (a cached
|
|
45
|
+
* image never does). Reaching into someone else's element to animate it is the
|
|
46
|
+
* kind of magic that breaks silently a year later.
|
|
47
|
+
*
|
|
19
48
|
* @param {string} ratio CSS aspect-ratio, e.g. '1 / 1', '16 / 9', '1 / 1.41421'
|
|
20
49
|
* @param {boolean} radius round the media's own corners (default true)
|
|
21
|
-
* @param {
|
|
50
|
+
* @param {string} fit cover | natural | compact — how the child sits in the box
|
|
51
|
+
* @param {boolean} frame tinted box + border UNDER the media
|
|
52
|
+
* @param {boolean} border border only, no tint
|
|
53
|
+
* @param {string} bg tint only, no border — a raw token value
|
|
54
|
+
* @param {string} borderHover border colour on hover (article's fg-16 step)
|
|
55
|
+
* @param {boolean} ring hairline border OVER the media, inset
|
|
56
|
+
* @param {ReactNode} children the real media
|
|
22
57
|
*/
|
|
23
|
-
|
|
58
|
+
const FIT = {
|
|
59
|
+
cover: '[&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:h-full [&>video]:w-full [&>video]:object-cover',
|
|
60
|
+
natural: '[&>img]:h-full [&>img]:w-full [&>img]:object-contain [&>video]:h-full [&>video]:w-full [&>video]:object-contain',
|
|
61
|
+
compact: 'grid place-items-center [&>img]:max-h-[70%] [&>img]:max-w-[70%] [&>img]:object-contain',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function ContentMedia({
|
|
65
|
+
ratio = '1 / 1',
|
|
66
|
+
radius = true,
|
|
67
|
+
fit = 'cover',
|
|
68
|
+
frame = false,
|
|
69
|
+
border = false,
|
|
70
|
+
bg,
|
|
71
|
+
borderHover,
|
|
72
|
+
ring = false,
|
|
73
|
+
children,
|
|
74
|
+
className = '',
|
|
75
|
+
}) {
|
|
24
76
|
if (children == null) {
|
|
25
77
|
return <AssetPlaceholder radius={radius} aspectRatio={ratio ?? undefined} className={ratio == null ? `h-full ${className}` : className} />
|
|
26
78
|
}
|
|
79
|
+
const round = radius ? 'rounded-[var(--kol-radius-sm)]' : ''
|
|
27
80
|
return (
|
|
28
81
|
<div
|
|
29
|
-
className={`relative w-full overflow-hidden ${
|
|
30
|
-
style={ratio != null ? { aspectRatio: ratio } :
|
|
82
|
+
className={`relative w-full overflow-hidden ${round} ${FIT[fit] ?? FIT.cover} ${frame ? 'bg-fg-04 border border-fg-08' : ''} ${border ? 'border border-fg-08' : ''} ${borderHover ? 'transition-colors hover:border-fg-16' : ''} ${ratio == null ? 'h-full' : ''} ${className}`.trim()}
|
|
83
|
+
style={{ ...(ratio != null ? { aspectRatio: ratio } : null), background: bg }}
|
|
31
84
|
>
|
|
32
85
|
{children}
|
|
86
|
+
{/* OVER the artwork, and inert — a hairline that must not eat the click
|
|
87
|
+
* the card above it is listening for. */}
|
|
88
|
+
{ring && <div className={`pointer-events-none absolute inset-0 border border-fg-08 ${round}`} />}
|
|
33
89
|
</div>
|
|
34
90
|
)
|
|
35
91
|
}
|
|
@@ -24,12 +24,22 @@ import ContentText from './ContentText.jsx'
|
|
|
24
24
|
* --kol-spacing-* tokens (2=8 · 3=12 · 4=16 · 6=24), never a literal */
|
|
25
25
|
const S2 = 'var(--kol-spacing-2)', S3 = 'var(--kol-spacing-3)', S4 = 'var(--kol-spacing-4)', S6 = 'var(--kol-spacing-6)'
|
|
26
26
|
const BOX = {
|
|
27
|
-
default: { thumb: 48, ratio: '1 / 1', pad: `${S2} 0`, gap: S3, align: 'items-center', divider: true },
|
|
28
|
-
catalog
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
default: { thumb: 48, ratio: '1 / 1', pad: `${S2} 0`, gap: S3, align: 'items-center', divider: true, hover: 'var(--kol-oq-04)' },
|
|
28
|
+
/* catalog/print rows render AT 36px — the shipped GridCard list row is a
|
|
29
|
+
* fixed 36 and the Y padding was what pushed it past that. X padding stays;
|
|
30
|
+
* `minH` is now the whole height budget and the row centres inside it. */
|
|
31
|
+
catalog: { thumb: 0, ratio: '1 / 1', pad: `0 ${S3}`, gap: S3, frame: 'var(--kol-fg-04)', bg: 'var(--kol-surface-tertiary)', minH: 36, align: 'items-center', hover: 'var(--kol-oq-04)' },
|
|
32
|
+
print: { thumb: 0, ratio: '1 / 1', pad: `0 ${S3}`, gap: S3, frame: 'var(--kol-fg-04)', bg: 'var(--kol-surface-tertiary)', minH: 36, align: 'items-center', hover: 'var(--kol-oq-04)' },
|
|
33
|
+
/* ListingCard's row thumb is `bg-fg-12` bare — a tint, no border. */
|
|
34
|
+
article: { thumb: 120, ratio: '1 / 1', pad: '0', gap: S6, align: 'items-start', thumbBg: 'var(--kol-fg-12)' },
|
|
35
|
+
/* work and typeface step UP at md — the shipped rows both do, and a work row
|
|
36
|
+
* at a fixed 96 cannot hold the display-03 line it was ruled to carry. */
|
|
37
|
+
work: { thumb: 64, thumbMd: 112, ratio: '1 / 1', pad: S4, padMd: S6, gap: S4, gapMd: S6, frame: 'transparent', frameHover: 'var(--kol-fg-16)', bg: 'var(--kol-surface-secondary)', minH: 96, minHMd: 160, align: 'items-stretch', thumbRadius: 'var(--kol-radius-xs)', thumbBorder: true },
|
|
38
|
+
/* typeface's row is a COLUMN, not a line: a header (name/styles left,
|
|
39
|
+
* classification/year right) with a full-width specimen band under it. The
|
|
40
|
+
* shipped item is `flex-col gap-6`, and forcing it into the horizontal
|
|
41
|
+
* thumb-beside-text shape is what turned its alphabet into a 160px thumb. */
|
|
42
|
+
typeface: { thumb: 0, ratio: '1 / 1', pad: S6, gap: S6, column: true, frame: 'var(--kol-fg-08)', bg: 'transparent', minH: 160, align: 'items-start', hover: 'color-mix(in srgb, var(--kol-surface-on-primary) 1%, transparent)', frameHover: 'color-mix(in srgb, var(--kol-surface-on-primary) 24%, transparent)' },
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export default function ContentRow({
|
|
@@ -39,33 +49,97 @@ export default function ContentRow({
|
|
|
39
49
|
ratio,
|
|
40
50
|
paddingY,
|
|
41
51
|
actions,
|
|
52
|
+
footer,
|
|
42
53
|
selected = false,
|
|
43
54
|
onClick,
|
|
55
|
+
href,
|
|
56
|
+
onNavigate,
|
|
44
57
|
className = '',
|
|
45
58
|
...text
|
|
46
59
|
}) {
|
|
47
60
|
const box = BOX[variant] ?? BOX.default
|
|
48
61
|
const thumbPx = thumb ?? box.thumb
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
|
|
63
|
+
/* The md: STEP is a custom property, not a Tailwind variant. Tailwind cannot
|
|
64
|
+
* generate `md:min-h-40` from package source (the SegmentedToggle rule), and
|
|
65
|
+
* these values are per-variant data rather than markup, so the row publishes
|
|
66
|
+
* `--kol-row-*` / `--kol-row-*-md` and one media query in kol-theme swaps
|
|
67
|
+
* them. A variant with no md value publishes nothing and never steps. */
|
|
68
|
+
const vars = {
|
|
69
|
+
'--kol-row-pad': paddingY != null ? `${paddingY}px 0` : box.pad,
|
|
70
|
+
'--kol-row-pad-md': box.padMd,
|
|
71
|
+
'--kol-row-gap': box.gap,
|
|
72
|
+
'--kol-row-gap-md': box.gapMd,
|
|
73
|
+
'--kol-row-min-h': box.minH != null ? `${box.minH}px` : undefined,
|
|
74
|
+
'--kol-row-min-h-md': box.minHMd != null ? `${box.minHMd}px` : undefined,
|
|
75
|
+
'--kol-row-thumb': `${thumbPx}px`,
|
|
76
|
+
'--kol-row-thumb-md': box.thumbMd != null ? `${box.thumbMd}px` : undefined,
|
|
77
|
+
'--kol-content-hover-bg': box.hover,
|
|
78
|
+
'--kol-content-hover-border': box.frameHover,
|
|
79
|
+
/* rest values are PROPERTIES, not inline declarations — an inline
|
|
80
|
+
* `background`/`borderColor` outranks every class, so the hover rules in
|
|
81
|
+
* kol-theme could never win and no row hover fired at all. */
|
|
82
|
+
'--kol-row-bg': selected ? 'var(--kol-fg-04)' : box.bg,
|
|
83
|
+
'--kol-row-border': box.frame || undefined,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const nav = (event) => {
|
|
87
|
+
if (onNavigate) onNavigate(event, href)
|
|
88
|
+
if (onClick) onClick(event)
|
|
89
|
+
}
|
|
90
|
+
const interactive = href || onClick
|
|
91
|
+
|
|
92
|
+
/* COLUMN rows stack their band under the text instead of laying a thumb
|
|
93
|
+
* beside it — `footer` is that band, and it is a node because what goes in it
|
|
94
|
+
* (a rendered alphabet, a waveform, a sparkline) is never the family's. */
|
|
95
|
+
const inner = box.column ? (
|
|
96
|
+
<>
|
|
97
|
+
<ContentText variant={variant} form="row" className="w-full" {...text} />
|
|
98
|
+
{footer}
|
|
99
|
+
</>
|
|
100
|
+
) : (
|
|
101
|
+
<>
|
|
61
102
|
{thumbPx > 0 && (
|
|
62
|
-
<div className="shrink-0"
|
|
63
|
-
<ContentMedia
|
|
103
|
+
<div className="kol-row-thumb shrink-0">
|
|
104
|
+
<ContentMedia
|
|
105
|
+
ratio={ratio ?? box.ratio}
|
|
106
|
+
border={box.thumbBorder ?? false}
|
|
107
|
+
bg={box.thumbBg}
|
|
108
|
+
className={box.thumbRadius ? 'rounded-[var(--kol-radius-xs)]' : ''}
|
|
109
|
+
>
|
|
110
|
+
{media}
|
|
111
|
+
</ContentMedia>
|
|
64
112
|
</div>
|
|
65
113
|
)}
|
|
66
114
|
<ContentText variant={variant} form="row" className="flex-1" {...text} />
|
|
67
115
|
{/* trailing edge, never in the text flow — MediaRow's placement */}
|
|
68
116
|
{actions && <div className="shrink-0">{actions}</div>}
|
|
69
|
-
|
|
117
|
+
</>
|
|
70
118
|
)
|
|
119
|
+
|
|
120
|
+
const common = {
|
|
121
|
+
className: `kol-row flex ${box.column ? 'flex-col' : ''} ${box.align} ${box.divider ? 'border-b border-fg-08' : ''} ${box.frame ? 'rounded-[var(--kol-radius-sm)] border' : ''} ${interactive ? 'cursor-pointer select-none' : ''} ${interactive && box.hover ? 'kol-content-hover' : ''} ${interactive && box.frameHover ? 'kol-content-hover-frame' : ''} ${className}`.trim(),
|
|
122
|
+
style: vars,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* same root-follows-the-affordance rule as ContentCard */
|
|
126
|
+
if (href) return <a href={href} onClick={nav} {...common}>{inner}</a>
|
|
127
|
+
if (onClick) {
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
onClick={onClick}
|
|
131
|
+
role="button"
|
|
132
|
+
tabIndex={0}
|
|
133
|
+
onKeyDown={(e) => {
|
|
134
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
135
|
+
e.preventDefault()
|
|
136
|
+
onClick(e)
|
|
137
|
+
}}
|
|
138
|
+
{...common}
|
|
139
|
+
>
|
|
140
|
+
{inner}
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
return <div {...common}>{inner}</div>
|
|
71
145
|
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* Renders the per-variant / per-form type ramp ruled 2026-08-15
|
|
5
5
|
* (docs/documentation/03-components/06-content-card-system.md §3). The laws:
|
|
6
6
|
* title is the ONLY slot that steps between card and row (size only, one
|
|
7
|
-
* family); body and meta are identical in both forms;
|
|
8
|
-
*
|
|
7
|
+
* family); body and meta are identical in both forms; ink is three roles
|
|
8
|
+
* (emphasis · body · meta). `kol-helper-*` is out of this family — it is not
|
|
9
|
+
* mono with line-height 1, it also carries weight 500 and 0.06em tracking, so
|
|
10
|
+
* a helper field beside a mono one reads as a different voice.
|
|
9
11
|
*
|
|
10
12
|
* Every type class is a SEAM: pass `<slot>Class` to replace the ruled
|
|
11
13
|
* class+ink string whole (consumers on their own faces swap here). Passing
|
|
@@ -28,8 +30,8 @@
|
|
|
28
30
|
/* [variant][form][slot] → 'type-class ink-role', verbatim from the ruled table */
|
|
29
31
|
const RAMP = {
|
|
30
32
|
default: {
|
|
31
|
-
card: { title: 'kol-
|
|
32
|
-
row: { title: 'kol-
|
|
33
|
+
card: { title: 'kol-sans-heading-04 text-emphasis truncate', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta' },
|
|
34
|
+
row: { title: 'kol-sans-heading-05 text-emphasis truncate', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta' },
|
|
33
35
|
},
|
|
34
36
|
catalog: {
|
|
35
37
|
card: { title: 'kol-mono-14 text-emphasis', detail: 'kol-mono-10 text-meta' },
|
|
@@ -40,96 +42,179 @@ const RAMP = {
|
|
|
40
42
|
row: { title: 'kol-mono-10 text-body', detail: 'kol-mono-10 text-meta' },
|
|
41
43
|
},
|
|
42
44
|
article: {
|
|
43
|
-
card: { kicker: 'kol-
|
|
44
|
-
row: { kicker: 'kol-
|
|
45
|
+
card: { kicker: 'kol-mono-12 text-body', title: 'kol-sans-heading-03 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-body', tags: 'flex flex-wrap gap-2' },
|
|
46
|
+
row: { kicker: 'kol-mono-12 text-body', title: 'kol-sans-heading-04 text-emphasis', body: 'kol-mono-12 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-body', tags: 'flex flex-wrap gap-2' },
|
|
45
47
|
},
|
|
46
48
|
work: {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
/* INVERSE ink — the card's plate is the drawer, `surface-inverse`. Leaving
|
|
50
|
+
* these on `text-emphasis` painted light type on a light plate and the
|
|
51
|
+
* caption disappeared. */
|
|
52
|
+
card: { title: 'kol-sans-display-03 text-fg-inverse', meta: 'kol-mono-12 text-fg-inverse-48', body: 'kol-mono-14 text-fg-inverse', date: 'kol-mono-12 text-fg-inverse-48', tags: 'flex flex-wrap gap-2' },
|
|
53
|
+
/* verbatim from WorkListItem: title `kol-mono-14` truncated · type
|
|
54
|
+
* `kol-mono-12 md:kol-mono-14` at FULL ink, no opacity step · year
|
|
55
|
+
* `kol-mono-12 text-fg-64` · description `kol-sans-heading-03 text-auto`.
|
|
56
|
+
* Title and type are the pair — same rung, same full ink; only the year
|
|
57
|
+
* steps down. */
|
|
58
|
+
row: { title: 'kol-mono-12 text-emphasis uppercase truncate', body: 'kol-sans-heading-03 leading-tight text-emphasis truncate', meta: 'kol-mono-12 text-emphasis', date: 'kol-mono-12 text-fg-64', tags: 'flex flex-wrap items-center gap-1.5' },
|
|
49
59
|
},
|
|
50
60
|
typeface: {
|
|
51
|
-
card: { title: 'kol-mono-
|
|
52
|
-
row: { title: 'kol-mono-14 text-emphasis', body: 'kol-mono-14 text-
|
|
61
|
+
card: { title: 'kol-mono-16 text-emphasis', body: 'kol-mono-14 text-fg-64', date: 'kol-mono-12 text-fg-64' },
|
|
62
|
+
row: { title: 'kol-mono-14 uppercase text-emphasis', body: 'kol-mono-12 text-fg-64', detail: 'kol-mono-14 text-emphasis', date: 'kol-mono-12 text-fg-64' },
|
|
53
63
|
},
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/* render order per variant/form. Strings are slots; arrays are ONE line:
|
|
57
67
|
* ['group', …] = 16px baseline group · ['between', …] = header.between ·
|
|
58
|
-
* ['line', …] = one flex line, first slot flex-1 truncating, rest fixed
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
68
|
+
* ['line', …] = one flex line, first slot flex-1 truncating, rest fixed ·
|
|
69
|
+
* ['stack', …] = a vertical block on the tight 4px gap, nestable inside any of
|
|
70
|
+
* the above. In a `between`, the LEADING part flexes and the trailing one hugs.
|
|
71
|
+
* Directions are the RULED structures (06-content-card-system.md §2 boxes),
|
|
72
|
+
* read off the shipped components and the live pages they render on. */
|
|
63
73
|
const ORDER = {
|
|
64
|
-
default: { card: ['title', ['group', 'date', 'size']], row: [
|
|
74
|
+
default: { card: ['title', ['group', 'date', 'size']], row: ['title', ['group', 'date', 'size']] },
|
|
65
75
|
catalog: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
|
|
66
76
|
print: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
/* title + body are ONE block in BOTH forms — a `stack`, so they sit on the
|
|
78
|
+
* tight 4px internal gap while tags, kicker and the meta group keep the
|
|
79
|
+
* form's own outer gap. A flat column gave every line the same gap, which
|
|
80
|
+
* read as unrelated lines rather than a heading with its standfirst. */
|
|
81
|
+
article: { card: ['tags', 'kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']], row: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']] },
|
|
82
|
+
/* work CARD = the drawer's two lines: title, then one meta line.
|
|
83
|
+
*
|
|
84
|
+
* work ROW = WorkListItem, read off the live /work listing: a LEFT column of
|
|
85
|
+
* title (small) → tags → description (the big line), and a RIGHT column of
|
|
86
|
+
* type over year. The big line is the DESCRIPTION, not the title — the
|
|
87
|
+
* earlier "the fields are crossed, uncross them" reading was wrong, and the
|
|
88
|
+
* shipped page shows the small-title / big-description order is the design. */
|
|
89
|
+
/* WorkListItem's inner column is `justify-between` with the header row on
|
|
90
|
+
* top and the description BELOW IT, spanning the full width — not tucked
|
|
91
|
+
* inside the left column, which is what squeezed the big line. */
|
|
92
|
+
work: { card: ['title', 'meta'], row: [['between', ['stack', 'title', 'tags'], ['stack', 'meta', 'date']], 'body'] },
|
|
93
|
+
/* typeface row = ONE header line, both sides stacked: name over styles on the
|
|
94
|
+
* left, classification over year on the right. Verbatim from the shipped
|
|
95
|
+
* item, which had been flattened into title-left / date-right and lost the
|
|
96
|
+
* styles line into a body slot below. */
|
|
97
|
+
typeface: { card: ['title', 'body'], row: [['between', ['stack', 'title', 'body'], ['stack', 'detail', 'date']]] },
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* inner line gap per variant/form, read from the shipped boxes and spelled in
|
|
101
|
+
* --kol-spacing-* tokens — ALL of them. article's row carried a raw `10px` off
|
|
102
|
+
* the scale, inherited from the shipped card; once its title and body became a
|
|
103
|
+
* `stack` this gap stopped separating lines and started separating blocks, so
|
|
104
|
+
* it sits on the 12px rung the rest of the family uses for that. */
|
|
105
|
+
/* Variants whose ROW text column STRETCHES: the block fills the row's height
|
|
106
|
+
* and pushes its last child to the floor, so the big line bottom-aligns with
|
|
107
|
+
* the thumb beside it instead of floating under the header. WorkListItem's
|
|
108
|
+
* inner column is `flex flex-col justify-between … flex-1`.
|
|
109
|
+
*
|
|
110
|
+
* `self-stretch`, NOT `h-full` — the row carries `min-height`, never `height`,
|
|
111
|
+
* so `height: 100%` resolves against an indefinite parent, computes to auto,
|
|
112
|
+
* and shrink-wraps the column. Which is exactly what it did. */
|
|
113
|
+
const FILL = { work: true }
|
|
114
|
+
|
|
115
|
+
/* gap INSIDE a ['stack', …] block. Defaults to the tight 4px pair article
|
|
116
|
+
* wants; work's header stack is the shipped gap-1 md:gap-2. */
|
|
117
|
+
const STACK = {
|
|
118
|
+
work: 'var(--kol-spacing-2)',
|
|
119
|
+
typeface: 'var(--kol-spacing-2)',
|
|
70
120
|
}
|
|
71
121
|
|
|
72
|
-
/* inner line gap per variant/form, read from the shipped boxes — spelled in
|
|
73
|
-
* --kol-spacing-* tokens where the value sits on the scale (10px is the
|
|
74
|
-
* shipped article-row literal; the scale has no rung there) */
|
|
75
122
|
const GAPS = {
|
|
76
|
-
default: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-
|
|
77
|
-
catalog: { card: 'var(--kol-spacing-
|
|
123
|
+
default: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-2)' },
|
|
124
|
+
catalog: { card: 'var(--kol-spacing-1)', row: 'var(--kol-spacing-2)' },
|
|
78
125
|
print: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
|
|
79
|
-
article: { card: 'var(--kol-spacing-3)', row: '
|
|
80
|
-
work: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-
|
|
126
|
+
article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)' },
|
|
127
|
+
work: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-4)' },
|
|
81
128
|
typeface: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-6)' },
|
|
82
129
|
}
|
|
83
130
|
|
|
84
131
|
export default function ContentText({
|
|
85
132
|
variant = 'default',
|
|
86
133
|
form = 'card',
|
|
87
|
-
title, body, kicker, detail, date, size, meta,
|
|
88
|
-
gap,
|
|
89
|
-
titleClass, bodyClass, kickerClass, detailClass, dateClass, sizeClass, metaClass,
|
|
134
|
+
title, body, kicker, detail, date, size, meta, tags,
|
|
135
|
+
gap, clamp,
|
|
136
|
+
titleClass, bodyClass, kickerClass, detailClass, dateClass, sizeClass, metaClass, tagsClass,
|
|
90
137
|
className = '',
|
|
91
138
|
}) {
|
|
92
139
|
const ramp = RAMP[variant]?.[form] ?? RAMP.default[form] ?? RAMP.default.card
|
|
93
140
|
const order = ORDER[variant]?.[form] ?? ORDER.default.card
|
|
94
|
-
const values = { title, body, kicker, detail, date, size, meta }
|
|
95
|
-
const overrides = { title: titleClass, body: bodyClass, kicker: kickerClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass }
|
|
141
|
+
const values = { title, body, kicker, detail, date, size, meta, tags }
|
|
142
|
+
const overrides = { title: titleClass, body: bodyClass, kicker: kickerClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass, tags: tagsClass }
|
|
143
|
+
|
|
144
|
+
/* the clamp rides the BODY only — it is the one slot that carries prose long
|
|
145
|
+
* enough to need cutting, and clamping a title is what `truncate` in the ramp
|
|
146
|
+
* already does on one line. Number in, `line-clamp-N` out; unset = no clamp,
|
|
147
|
+
* so a card that wants the whole excerpt simply does not pass it. */
|
|
148
|
+
const extra = (slot) => (slot === 'body' && clamp ? ` line-clamp-${clamp}` : '')
|
|
96
149
|
|
|
97
150
|
const line = (slot) =>
|
|
98
151
|
values[slot] == null ? null : (
|
|
99
|
-
<div key={slot} className={overrides[slot] ?? ramp[slot] ?? ''}>{values[slot]}</div>
|
|
152
|
+
<div key={slot} className={`${overrides[slot] ?? ramp[slot] ?? ''}${extra(slot)}`.trim()}>{values[slot]}</div>
|
|
100
153
|
)
|
|
101
154
|
|
|
102
|
-
|
|
155
|
+
/* RECURSIVE (2026-08-15) — an entry inside a line/between/group may itself be
|
|
156
|
+
* an entry, so a trailing COLUMN can hold two stacked fields. typeface's row
|
|
157
|
+
* needs exactly that: classification over year at the right edge, which a
|
|
158
|
+
* flat slot list cannot express and which was previously collapsed into one
|
|
159
|
+
* `date` slot that lost a value. */
|
|
160
|
+
const render = (entry, i) => {
|
|
103
161
|
if (typeof entry === 'string') return line(entry)
|
|
104
162
|
const [kind, ...slots] = entry
|
|
105
|
-
const parts = slots.map(
|
|
163
|
+
const parts = slots.map((s, j) => render(s, j)).filter(Boolean)
|
|
106
164
|
if (!parts.length) return null
|
|
165
|
+
if (kind === 'stack') {
|
|
166
|
+
return (
|
|
167
|
+
<div key={`stack-${i}`} className="flex w-full min-w-0 flex-col" style={{ gap: STACK[variant] ?? 'var(--kol-spacing-1)' }}>
|
|
168
|
+
{parts}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
107
172
|
if (kind === 'line') {
|
|
108
173
|
/* ruled (default row): title flex-1 truncate · date w-24 · size w-20,
|
|
109
|
-
* trailing fields
|
|
110
|
-
|
|
174
|
+
* trailing fields in fixed columns so rows align in a list.
|
|
175
|
+
*
|
|
176
|
+
* Trailing fields HUG their content and sit on the group's 24px gap, so
|
|
177
|
+
* the row and the card space their meta identically. They used to be
|
|
178
|
+
* fixed 96/80px right-aligned columns: the date never filled 96px, so a
|
|
179
|
+
* row showed leftover column PLUS the gap, and anything that grew on
|
|
180
|
+
* hover (the size/download affordance) expanded leftwards in a row and
|
|
181
|
+
* rightwards in a card. */
|
|
111
182
|
return (
|
|
112
|
-
<div key={`line-${i}`} className="flex items-baseline min-w-0" style={{ gap: '
|
|
183
|
+
<div key={`line-${i}`} className="flex items-baseline min-w-0" style={{ gap: 'var(--kol-spacing-6)' }}>
|
|
113
184
|
{parts.map((p, j) => (j === 0
|
|
114
185
|
? <div key={j} className="flex-1 min-w-0 truncate">{p}</div>
|
|
115
|
-
: <div key={j} className="shrink-0
|
|
186
|
+
: <div key={j} className="shrink-0">{p}</div>))}
|
|
116
187
|
</div>
|
|
117
188
|
)
|
|
118
189
|
}
|
|
119
190
|
return (
|
|
120
191
|
<div
|
|
121
192
|
key={`${kind}-${i}`}
|
|
122
|
-
|
|
123
|
-
|
|
193
|
+
/* a `between` whose trailing part is a STACK aligns on the top, not the
|
|
194
|
+
* baseline — a two-line column has no single baseline to share with the
|
|
195
|
+
* title beside it, and baseline-aligning it hangs the second line below
|
|
196
|
+
* the row's floor. */
|
|
197
|
+
className={`flex min-w-0 ${parts.length > 1 && Array.isArray(slots[slots.length - 1]) ? 'items-center' : 'items-baseline'} ${kind === 'between' ? 'justify-between' : ''}`}
|
|
198
|
+
style={{ gap: 'var(--kol-spacing-6)' }}
|
|
124
199
|
>
|
|
125
|
-
{
|
|
200
|
+
{/* the LEADING part of a `between` takes the room; the trailing column
|
|
201
|
+
* hugs its content. Without this the left stack sized to its content
|
|
202
|
+
* and the big line truncated at a quarter of the row's width while
|
|
203
|
+
* empty space sat between the two columns. */}
|
|
204
|
+
{kind === 'between' && parts.length > 1
|
|
205
|
+
? parts.map((p, j) => (
|
|
206
|
+
<div key={j} className={j === 0 ? 'min-w-0 flex-1' : 'shrink-0'}>{p}</div>
|
|
207
|
+
))
|
|
208
|
+
: parts}
|
|
126
209
|
</div>
|
|
127
210
|
)
|
|
128
|
-
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const nodes = order.map(render)
|
|
129
214
|
|
|
130
215
|
return (
|
|
131
216
|
<div
|
|
132
|
-
className={`flex min-w-0 flex-col ${className}`.trim()}
|
|
217
|
+
className={`flex min-w-0 flex-col ${FILL[variant] && form === 'row' ? 'self-stretch justify-between' : ''} ${className}`.trim()}
|
|
133
218
|
style={{ gap: typeof gap === 'number' ? `${gap}px` : gap ?? GAPS[variant]?.[form] ?? 'var(--kol-spacing-2)' }}
|
|
134
219
|
>
|
|
135
220
|
{nodes}
|
|
@@ -44,7 +44,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
44
44
|
|
|
45
45
|
const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14' }
|
|
46
46
|
const ICON_SIZE = { sm: 14, md: 14 }
|
|
47
|
-
const CUBIC_EASE = '
|
|
47
|
+
const CUBIC_EASE = 'var(--kol-ease-house)'
|
|
48
48
|
|
|
49
49
|
export default function SearchInput({
|
|
50
50
|
value = '',
|
|
@@ -237,7 +237,7 @@ const ContentFilters = ({
|
|
|
237
237
|
width: searchOpen ? 200 : 32,
|
|
238
238
|
background: searchOpen ? 'var(--kol-surface-secondary)' : 'transparent',
|
|
239
239
|
border: 'none',
|
|
240
|
-
transition: 'width 600ms
|
|
240
|
+
transition: 'width 600ms var(--kol-ease-house), background 400ms var(--kol-ease-house)',
|
|
241
241
|
overflow: 'hidden',
|
|
242
242
|
}}
|
|
243
243
|
onClick={() => {
|
|
@@ -252,7 +252,7 @@ const ContentFilters = ({
|
|
|
252
252
|
className="flex items-center justify-center flex-shrink-0"
|
|
253
253
|
style={{
|
|
254
254
|
opacity: searchOpen ? 0 : 1,
|
|
255
|
-
transition: 'opacity 300ms
|
|
255
|
+
transition: 'opacity 300ms var(--kol-ease-house)',
|
|
256
256
|
position: searchOpen ? 'absolute' : 'relative',
|
|
257
257
|
}}
|
|
258
258
|
>
|