@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.46.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 { AnimatePresence, motion } from 'framer-motion'
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
- * `cubic-bezier(0.16, 1, 0.3, 1)`, not `--kol-transition-base`'s generic
21
- * material ease: the rest of the card family moves on the house curve and a
22
- * control inside a card that eases differently reads as a foreign part.
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 `.kol-copy-btn` (kol-theme) — the DS's action-button look, which
25
- * predates this component and is misnamed for it. Positioning stays the
26
- * parent's job, exactly as before: pass `.kol-frame-control` to sit it in a
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.16, 1, 0.3, 1]
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 = 'check',
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 cls = `kol-copy-btn kol-copy-btn-${size} ${className}`.trim()
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 HANDOFF, not a swap (user ruling 2026-08-15). `mode="wait"` held the box
81
- * empty while the outgoing glyph finished, which reads as a blink at 32px.
82
- * Both glyphs occupy the SAME grid cell and cross over each other: the old
83
- * one lifts and fades as the new one rises into place. */
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
- <AnimatePresence initial={false}>
87
- <motion.span
88
- key={done ? 'confirm' : 'rest'}
89
- className="inline-flex"
90
- style={{ gridArea: '1 / 1' }}
91
- initial={{ opacity: 0, scale: 0.6, y: 4 }}
92
- animate={{ opacity: 1, scale: 1, y: 0 }}
93
- exit={{ opacity: 0, scale: 0.6, y: -4 }}
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: { layout: 'stack', border: 'var(--kol-fg-04)', bg: null, pad: 'var(--kol-pad-card-md)' },
41
- typeface: { layout: 'canvas', border: 'var(--kol-fg-08)', bg: 'var(--kol-surface-primary)', pad: 'var(--kol-pad-card-lg)' },
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` consumer content BELOW the text, in flow (2026-08-15 ruling).
62
- * The other half of what MediaCard hardcodes: `control` sits OVER the media,
63
- * this sits under the copy. Two positions, two slots, and a plate renders for
64
- * either an actions-only card is legal. */
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
- position: box.layout === 'canvas' ? 'relative' : 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',
76
118
  zIndex: box.layout === 'canvas' ? 1 : undefined,
77
119
  }}
78
120
  >
79
121
  {hasText && <ContentText variant={variant} form="card" {...text} />}
80
- {actions && <div style={{ marginTop: hasText ? 'var(--kol-spacing-2)' : undefined }}>{actions}</div>}
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} radius={mediaRadius}>{media}</ContentMedia>
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} radius={mediaRadius}>{media}</ContentMedia>
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} radius={mediaRadius}>{media}</ContentMedia>
191
+ <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
121
192
  {controlNode}
122
193
  </div>
123
194
  {textNode}
124
195
  </>
125
196
  )
126
197
 
127
- return (
128
- <article
129
- onClick={onClick}
130
- className={`flex flex-col ${framed ? 'overflow-hidden rounded-[var(--kol-radius-sm)]' : ''} ${box.border ? 'border' : ''} ${box.layout === 'canvas' ? 'relative' : ''} ${onClick ? 'cursor-pointer select-none' : ''} ${className}`.trim()}
131
- style={{
132
- borderColor: box.border ? (selected ? 'var(--kol-fg-64)' : box.border) : undefined,
133
- background: box.bg ?? undefined,
134
- aspectRatio: box.layout !== 'stack' ? r : undefined,
135
- }}
136
- >
137
- {body}
138
- </article>
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 {ReactNode} children the real media; rendered object-cover
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
- export default function ContentMedia({ ratio = '1 / 1', radius = true, children, className = '' }) {
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 ${radius ? 'rounded-[var(--kol-radius-sm)]' : ''} [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:h-full [&>video]:w-full [&>video]:object-cover ${ratio == null ? 'h-full' : ''} ${className}`.trim()}
30
- style={ratio != null ? { aspectRatio: ratio } : undefined}
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: { thumb: 0, ratio: '1 / 1', pad: `${S2} ${S3}`, gap: S3, frame: 'var(--kol-fg-04)', bg: 'var(--kol-surface-secondary)', minH: 36, align: 'items-center' },
29
- print: { thumb: 0, ratio: '1 / 1', pad: `${S2} ${S3}`, gap: S3, frame: 'var(--kol-fg-04)', bg: 'var(--kol-surface-secondary)', minH: 36, align: 'items-center' },
30
- article: { thumb: 120, ratio: '16 / 9', pad: '0', gap: S6, align: 'items-start' },
31
- work: { thumb: 64, ratio: '1 / 1', pad: S4, gap: S4, frame: 'var(--kol-fg-08)', bg: 'var(--kol-surface-secondary)', minH: 96, align: 'items-stretch' },
32
- typeface: { thumb: 0, ratio: '1 / 1', pad: S6, gap: S6, frame: 'var(--kol-fg-08)', bg: 'var(--kol-surface-primary)', align: 'items-start' },
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
- return (
50
- <div
51
- onClick={onClick}
52
- className={`flex ${box.align} ${box.divider ? 'border-b border-fg-08' : ''} ${box.frame ? 'rounded-[var(--kol-radius-sm)] border' : ''} ${onClick ? 'cursor-pointer select-none' : ''} ${className}`.trim()}
53
- style={{
54
- gap: box.gap,
55
- padding: paddingY != null ? `${paddingY}px 0` : box.pad,
56
- minHeight: box.minH,
57
- borderColor: box.frame || undefined,
58
- background: selected ? 'var(--kol-fg-04)' : box.bg,
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" style={{ width: thumbPx }}>
63
- <ContentMedia ratio={ratio ?? box.ratio}>{media}</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
- </div>
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; helper-* only where a
8
- * single line is guaranteed; ink is three roles (emphasis · body · meta).
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-helper-12 text-emphasis truncate', date: 'kol-helper-12 text-meta', size: 'kol-helper-12 text-body' },
32
- row: { title: 'kol-helper-12 text-emphasis truncate', date: 'kol-helper-12 text-meta', size: 'kol-helper-12 text-body' },
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-helper-12 text-body', title: 'kol-sans-heading-03 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-helper-12 text-meta', size: 'kol-helper-12 text-body' },
44
- row: { kicker: 'kol-helper-12 text-body', title: 'kol-sans-heading-05 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-helper-12 text-meta', size: 'kol-helper-12 text-body' },
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
- card: { title: 'kol-sans-display-02 text-emphasis', body: 'kol-mono-14 text-body', meta: 'kol-mono-12 text-body' },
48
- row: { title: 'kol-sans-display-03 text-emphasis', body: 'kol-mono-14 text-body', meta: 'kol-mono-12 text-body' },
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-20 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-body' },
52
- row: { title: 'kol-mono-14 text-emphasis', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-body' },
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
- * Directions are the RULED structures (06-content-card-system.md §2 boxes):
60
- * default row is a single table-like line, catalog/print rows are between-
61
- * headers. work row: line 2 stays the big line and carries the TITLE (fields
62
- * were crossed in the shipped WorkListItem ruled, do not re-derive). */
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: [['line', 'title', 'date', 'size']] },
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
- article: { card: ['kicker', 'title', 'body', ['group', 'date', 'size']], row: ['kicker', 'title', 'body', ['group', 'date', 'size']] },
68
- work: { card: ['title', 'body', 'meta'], row: ['body', 'title', 'meta'] },
69
- typeface: { card: ['title', 'body', 'date'], row: [['between', 'title', 'date'], 'body'] },
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-3)' },
77
- catalog: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
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: '10px' },
80
- work: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-3)' },
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
- const nodes = order.map((entry, i) => {
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(line).filter(Boolean)
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 right-aligned fixed columns so rows align in a list */
110
- const W = [null, 96, 80]
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: '12px' }}>
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 text-right" style={{ width: W[j] ?? undefined }}>{p}</div>))}
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
- className={`flex items-baseline ${kind === 'between' ? 'justify-between' : ''}`}
123
- style={{ gap: 'var(--kol-spacing-4)' }}
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
- {parts}
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}
@@ -26,6 +26,7 @@ export default function CopyButton({ text, className = '', ...props }) {
26
26
  confirmIcon="check"
27
27
  label="Copy to clipboard"
28
28
  confirmLabel="Copied"
29
+ size="sm"
29
30
  className={className}
30
31
  onAction={async () => {
31
32
  try {
@@ -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 = 'cubic-bezier(0.16, 1, 0.3, 1)'
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 cubic-bezier(0.16, 1, 0.3, 1), background 400ms cubic-bezier(0.16, 1, 0.3, 1)',
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 cubic-bezier(0.16, 1, 0.3, 1)',
255
+ transition: 'opacity 300ms var(--kol-ease-house)',
256
256
  position: searchOpen ? 'absolute' : 'relative',
257
257
  }}
258
258
  >