@kolkrabbi/kol-component 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
2
+
3
+ /**
4
+ * ContentMedia — the media slot of the content-card system.
5
+ *
6
+ * Ratio is the only knob (`fit` was cut 2026-08-15 — a card image covers,
7
+ * full stop). No children → AssetPlaceholder at the same ratio, so a card
8
+ * with no media is visibly flagged, never collapsed.
9
+ *
10
+ * `ratio` is a FREE prop (06-content-card-system.md §4 — the A4 question is
11
+ * open; nothing hardcodes a ratio). The ruled per-variant defaults live in
12
+ * ContentCard/ContentRow, not here.
13
+ *
14
+ * `radius` (2026-08-15 user ruling): OFF when the host frame already clips its
15
+ * own corners. A card that clips and a media slot that rounds are two radii on
16
+ * one edge — the visible double-round. ContentCard turns it off for its framed
17
+ * variants; the ROW keeps it, because a row does not clip.
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
+ *
48
+ * @param {string} ratio CSS aspect-ratio, e.g. '1 / 1', '16 / 9', '1 / 1.41421'
49
+ * @param {boolean} radius round the media's own corners (default true)
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
57
+ */
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
+ }) {
76
+ if (children == null) {
77
+ return <AssetPlaceholder radius={radius} aspectRatio={ratio ?? undefined} className={ratio == null ? `h-full ${className}` : className} />
78
+ }
79
+ const round = radius ? 'rounded-[var(--kol-radius-sm)]' : ''
80
+ return (
81
+ <div
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 }}
84
+ >
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}`} />}
89
+ </div>
90
+ )
91
+ }
@@ -0,0 +1,145 @@
1
+ import ContentMedia from './ContentMedia.jsx'
2
+ import ContentText from './ContentText.jsx'
3
+
4
+ /**
5
+ * ContentRow — the row form of the content-card system: leading thumb (where
6
+ * the variant has one) beside the ruled text. Box values — thumb size, gap,
7
+ * padding, frame — default per variant to the RULED structures from the live
8
+ * review (06-content-card-system.md §2 boxes): default is a bare table-like
9
+ * line with a 48px thumb; catalog/print are framed between-headers with no
10
+ * thumb; article rides a 120px 16:9 thumb; work a framed 64px row; typeface a
11
+ * framed no-thumb block.
12
+ *
13
+ * @param {string} variant default | catalog | print | article | work | typeface
14
+ * @param {ReactNode} media thumb content (omit → placeholder)
15
+ * @param {number} thumb thumb edge px — overrides the ruled default; 0 hides
16
+ * @param {string} ratio thumb aspect-ratio — overrides the ruled default
17
+ * @param {number} paddingY vertical padding px — overrides the ruled default
18
+ * @param {boolean} selected
19
+ * @param {Function} onClick
20
+ * text slots + *Class seams forwarded to ContentText.
21
+ */
22
+
23
+ /* ruled row boxes per variant (the §2 review) — paddings/gaps spell the
24
+ * --kol-spacing-* tokens (2=8 · 3=12 · 4=16 · 6=24), never a literal */
25
+ const S2 = 'var(--kol-spacing-2)', S3 = 'var(--kol-spacing-3)', S4 = 'var(--kol-spacing-4)', S6 = 'var(--kol-spacing-6)'
26
+ const BOX = {
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)' },
43
+ }
44
+
45
+ export default function ContentRow({
46
+ variant = 'default',
47
+ media,
48
+ thumb,
49
+ ratio,
50
+ paddingY,
51
+ actions,
52
+ footer,
53
+ selected = false,
54
+ onClick,
55
+ href,
56
+ onNavigate,
57
+ className = '',
58
+ ...text
59
+ }) {
60
+ const box = BOX[variant] ?? BOX.default
61
+ const thumbPx = thumb ?? box.thumb
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
+ <>
102
+ {thumbPx > 0 && (
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>
112
+ </div>
113
+ )}
114
+ <ContentText variant={variant} form="row" className="flex-1" {...text} />
115
+ {/* trailing edge, never in the text flow — MediaRow's placement */}
116
+ {actions && <div className="shrink-0">{actions}</div>}
117
+ </>
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>
145
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * ContentText — the ruled text block of the content-card system.
3
+ *
4
+ * Renders the per-variant / per-form type ramp ruled 2026-08-15
5
+ * (docs/documentation/03-components/06-content-card-system.md §3). The laws:
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; 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.
11
+ *
12
+ * Every type class is a SEAM: pass `<slot>Class` to replace the ruled
13
+ * class+ink string whole (consumers on their own faces swap here). Passing
14
+ * nothing renders the ruled values.
15
+ *
16
+ * @param {string} variant default | catalog | print | article | work | typeface
17
+ * @param {string} form card | row
18
+ * @param {ReactNode} title
19
+ * @param {ReactNode} body article · work · typeface
20
+ * @param {ReactNode} kicker article only
21
+ * @param {ReactNode} detail catalog · print
22
+ * @param {ReactNode} date default · article · typeface
23
+ * @param {ReactNode} size default · article (file size / read length)
24
+ * @param {ReactNode} meta work only
25
+ * @param {number} gap inner line gap in px (defaults per variant/form)
26
+ * @param {string} titleClass … kickerClass, bodyClass, detailClass,
27
+ * dateClass, sizeClass, metaClass — full class overrides
28
+ */
29
+
30
+ /* [variant][form][slot] → 'type-class ink-role', verbatim from the ruled table */
31
+ const RAMP = {
32
+ default: {
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' },
35
+ },
36
+ catalog: {
37
+ card: { title: 'kol-mono-14 text-emphasis', detail: 'kol-mono-10 text-meta' },
38
+ row: { title: 'kol-mono-12 text-emphasis', detail: 'kol-mono-10 text-meta' },
39
+ },
40
+ print: {
41
+ card: { title: 'kol-mono-14 text-body', detail: 'kol-mono-10 text-meta' },
42
+ row: { title: 'kol-mono-10 text-body', detail: 'kol-mono-10 text-meta' },
43
+ },
44
+ article: {
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' },
47
+ },
48
+ work: {
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' },
59
+ },
60
+ typeface: {
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' },
63
+ },
64
+ }
65
+
66
+ /* render order per variant/form. Strings are slots; arrays are ONE line:
67
+ * ['group', …] = 16px baseline group · ['between', …] = header.between ·
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. */
73
+ const ORDER = {
74
+ default: { card: ['title', ['group', 'date', 'size']], row: ['title', ['group', 'date', 'size']] },
75
+ catalog: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
76
+ print: { card: ['title', 'detail'], row: [['between', 'title', 'detail']] },
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)',
120
+ }
121
+
122
+ const GAPS = {
123
+ default: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-2)' },
124
+ catalog: { card: 'var(--kol-spacing-1)', row: 'var(--kol-spacing-2)' },
125
+ print: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-2)' },
126
+ article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)' },
127
+ work: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-4)' },
128
+ typeface: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-6)' },
129
+ }
130
+
131
+ export default function ContentText({
132
+ variant = 'default',
133
+ form = 'card',
134
+ title, body, kicker, detail, date, size, meta, tags,
135
+ gap, clamp,
136
+ titleClass, bodyClass, kickerClass, detailClass, dateClass, sizeClass, metaClass, tagsClass,
137
+ className = '',
138
+ }) {
139
+ const ramp = RAMP[variant]?.[form] ?? RAMP.default[form] ?? RAMP.default.card
140
+ const order = ORDER[variant]?.[form] ?? ORDER.default.card
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}` : '')
149
+
150
+ const line = (slot) =>
151
+ values[slot] == null ? null : (
152
+ <div key={slot} className={`${overrides[slot] ?? ramp[slot] ?? ''}${extra(slot)}`.trim()}>{values[slot]}</div>
153
+ )
154
+
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) => {
161
+ if (typeof entry === 'string') return line(entry)
162
+ const [kind, ...slots] = entry
163
+ const parts = slots.map((s, j) => render(s, j)).filter(Boolean)
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
+ }
172
+ if (kind === 'line') {
173
+ /* ruled (default row): title flex-1 truncate · date w-24 · size w-20,
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. */
182
+ return (
183
+ <div key={`line-${i}`} className="flex items-baseline min-w-0" style={{ gap: 'var(--kol-spacing-6)' }}>
184
+ {parts.map((p, j) => (j === 0
185
+ ? <div key={j} className="flex-1 min-w-0 truncate">{p}</div>
186
+ : <div key={j} className="shrink-0">{p}</div>))}
187
+ </div>
188
+ )
189
+ }
190
+ return (
191
+ <div
192
+ key={`${kind}-${i}`}
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)' }}
199
+ >
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}
209
+ </div>
210
+ )
211
+ }
212
+
213
+ const nodes = order.map(render)
214
+
215
+ return (
216
+ <div
217
+ className={`flex min-w-0 flex-col ${FILL[variant] && form === 'row' ? 'self-stretch justify-between' : ''} ${className}`.trim()}
218
+ style={{ gap: typeof gap === 'number' ? `${gap}px` : gap ?? GAPS[variant]?.[form] ?? 'var(--kol-spacing-2)' }}
219
+ >
220
+ {nodes}
221
+ </div>
222
+ )
223
+ }
@@ -0,0 +1,41 @@
1
+ import ActionButton from '../atoms/ActionButton.jsx'
2
+
3
+ /**
4
+ * CopyButton — THE copy-to-clipboard control (2026-08-09 user ruling): the
5
+ * 32×32 icon button — `copy` glyph flipping to `check` for 2s on copied,
6
+ * no text label. This is the button CodeBlock carried privately since the
7
+ * 2026-07-28 elder replication, promoted to the one shared atom; the old
8
+ * Copy/Copied label chip (one-off SVGs outside the icon set) is retired.
9
+ * Chrome comes from .kol-copy-btn (kol-theme); parents add their own
10
+ * positioning class (e.g. .kol-frame-control).
11
+ *
12
+ * The flip itself moved to ActionButton (2026-08-15) — it was the only
13
+ * confirm-feedback in the system and it was welded to the clipboard, so no
14
+ * other in-frame control could acknowledge a click. This is now clipboard
15
+ * behaviour plus that component; the public API is unchanged, and the swap
16
+ * gained an animation it never had.
17
+ *
18
+ * Props:
19
+ * text — string (or () => string) written to the clipboard
20
+ * className — extra classes (positioning etc.)
21
+ */
22
+ export default function CopyButton({ text, className = '', ...props }) {
23
+ return (
24
+ <ActionButton
25
+ icon="copy"
26
+ confirmIcon="check"
27
+ label="Copy to clipboard"
28
+ confirmLabel="Copied"
29
+ size="sm"
30
+ className={className}
31
+ onAction={async () => {
32
+ try {
33
+ await navigator.clipboard.writeText(typeof text === 'function' ? text() : String(text ?? ''))
34
+ } catch {
35
+ /* clipboard blocked — silent */
36
+ }
37
+ }}
38
+ {...props}
39
+ />
40
+ )
41
+ }
@@ -5,14 +5,22 @@
5
5
  * the placeholder name). All lines render as authored — no auto casing
6
6
  * (the source's `uppercase` on the eyebrow was dropped per KOL rules).
7
7
  *
8
- * @param {string} eyebrow kicker line above the title
9
- * @param {string} title headline
10
- * @param {string} body optional supporting line
11
- * @param {string} footer optional note above a top hairline
8
+ * `gated` (GatedEmptyState, kol-fxr 2026-08-15) opts this instance into the
9
+ * app-wide placeholder switch — see usePlaceholders(). It is OPT-IN, not the
10
+ * default, on purpose: the ruling that placeholder prose defaults to hidden is
11
+ * the filing app's, and flipping it here would silently blank every surface
12
+ * already shipping an EmptyState. A consumer that wants the gate asks for it.
13
+ *
14
+ * @param {string} eyebrow kicker line above the title
15
+ * @param {string} title headline
16
+ * @param {string} body optional supporting line
17
+ * @param {string} footer optional note above a top hairline
18
+ * @param {boolean} gated hide unless placeholders are switched on (default false)
19
+ * @param {string} className extra classes on the wrapper
12
20
  */
13
- export default function EmptyState({ eyebrow, title, body, footer }) {
21
+ export default function EmptyState({ eyebrow, title, body, footer, gated = false, className = '' }) {
14
22
  return (
15
- <div>
23
+ <div className={`${gated ? 'kol-placeholder' : ''}${className ? ` ${className}` : ''}` || undefined}>
16
24
  {/* helper (line-height 1) is single-line chrome ONLY — title and footer
17
25
  * can wrap, so they ride the line-height-bearing kol-mono-* scale
18
26
  * (the type-conform fault line; user, 2026-08-09). Eyebrow stays
@@ -69,7 +69,7 @@ export default function MediaCard({
69
69
  {thumb}
70
70
  {selectMode ? (
71
71
  <span
72
- className="absolute top-3 left-3 rounded p-1"
72
+ className="kol-frame-control kol-frame-control--top-left rounded p-1"
73
73
  style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
74
74
  >
75
75
  <SelectIndicator on={selected} />
@@ -79,7 +79,7 @@ export default function MediaCard({
79
79
  href={downloadHref}
80
80
  aria-label="Download"
81
81
  title="Download"
82
- className="absolute top-3 right-3 inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-absolute-24 transition-colors"
82
+ className="kol-frame-control inline-flex items-center justify-center w-8 h-8 rounded text-emphasis hover:bg-fg-absolute-24 transition-colors"
83
83
  style={{ background: 'var(--kol-fg-absolute-12, rgba(0,0,0,0.4))', backdropFilter: 'blur(4px)' }}
84
84
  onClick={(e) => e.stopPropagation()}
85
85
  >
@@ -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 = '',
@@ -3,10 +3,21 @@
3
3
  *
4
4
  * A small-caps label above a vertical content stack. Used across the editor
5
5
  * inspector panels (palette / pattern / type modes): `<Section label="Aspect">…</Section>`.
6
+ *
7
+ * `divided` (InspectorSectionRhythm, 2026-08-15) adds the between-siblings
8
+ * hairline every rail consumer was retyping locally — the rule lives on the
9
+ * ADJACENT pair (`.kol-section--divided + .kol-section--divided`) in
10
+ * kol-components-molecules.css, so the first section in a stack never carries
11
+ * a stray top border. Set it on every section in the stack; a rail that mixes
12
+ * divided and plain sections divides only between the divided ones.
13
+ *
14
+ * ponytail: a `SectionStack` parent could own this instead of each child
15
+ * declaring it — that is the upgrade path if a consumer ever needs the stack
16
+ * to vary the rule per-gap. One prop is a smaller API than a new component.
6
17
  */
7
- export default function Section({ label, children, className = '' }) {
18
+ export default function Section({ label, children, divided = false, className = '' }) {
8
19
  return (
9
- <div className={`flex flex-col gap-2 ${className}`}>
20
+ <div className={`flex flex-col gap-2${divided ? ' kol-section--divided' : ''} ${className}`}>
10
21
  {label && (
11
22
  <p className="kol-helper-10 tracking-widest text-meta">{label}</p>
12
23
  )}
@@ -0,0 +1,56 @@
1
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
2
+
3
+ /**
4
+ * ContentCollection — the container half of the content-card system: the
5
+ * grid/list switch plus the motion that belongs to it. Animation lives in
6
+ * the wrapper — the only place it can (06-content-card-system.md §6);
7
+ * cards never animate themselves.
8
+ *
9
+ * Children are ContentItem / ContentCard / ContentRow (or anything). The
10
+ * collection owns the enter stagger, keyed to `form` so the switch re-runs
11
+ * it. Uses the house curve (--kol-ease-house).
12
+ *
13
+ * ponytail: form switch re-mounts with a stagger, no FLIP — add FLIP
14
+ * (measure → invert → play) when a consumer needs card↔row to tween.
15
+ *
16
+ * @param {string} form 'grid' | 'list'
17
+ * @param {string} min grid track minimum (CSS length), grid form only
18
+ * @param {number} gap px between items
19
+ * @param {boolean} stagger enter animation on/off (reduced motion wins)
20
+ */
21
+ export default function ContentCollection({
22
+ form = 'grid',
23
+ min = '12rem',
24
+ gap = 16,
25
+ stagger = true,
26
+ children,
27
+ className = '',
28
+ }) {
29
+ const reduced = usePrefersReducedMotion()
30
+ const animate = stagger && !reduced
31
+ const items = Array.isArray(children) ? children.flat() : [children]
32
+
33
+ return (
34
+ <ul
35
+ key={form}
36
+ className={`m-0 list-none p-0 ${className}`.trim()}
37
+ style={
38
+ form === 'grid'
39
+ ? { display: 'grid', gridTemplateColumns: `repeat(auto-fill, minmax(${min}, 1fr))`, gap: `${gap}px` }
40
+ : { display: 'flex', flexDirection: 'column', gap: `${gap}px` }
41
+ }
42
+ >
43
+ {items.map((child, i) =>
44
+ child == null ? null : (
45
+ <li
46
+ key={child.key ?? i}
47
+ className={animate ? 'kol-collection-item' : undefined}
48
+ style={animate ? { animationDelay: `${i * 40}ms` } : undefined}
49
+ >
50
+ {child}
51
+ </li>
52
+ ),
53
+ )}
54
+ </ul>
55
+ )
56
+ }