@kolkrabbi/kol-component 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/atoms/ActionButton.jsx +193 -0
- package/src/hooks/usePlaceholders.js +72 -0
- package/src/index.js +12 -1
- package/src/molecules/CodeBlock.jsx +2 -2
- package/src/molecules/ContentCard.jsx +250 -0
- package/src/molecules/ContentItem.jsx +14 -0
- package/src/molecules/ContentMedia.jsx +91 -0
- package/src/molecules/ContentRow.jsx +145 -0
- package/src/molecules/ContentText.jsx +223 -0
- package/src/molecules/CopyButton.jsx +41 -0
- package/src/molecules/EmptyState.jsx +14 -6
- package/src/molecules/MediaCard.jsx +2 -2
- package/src/molecules/SearchInput.jsx +1 -1
- package/src/molecules/Section.jsx +13 -2
- package/src/organisms/ContentCollection.jsx +56 -0
- package/src/organisms/ContentFilters.jsx +72 -30
- package/src/utilities/AssetPlaceholder.jsx +6 -1
- package/src/atoms/CopyButton.jsx +0 -42
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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="
|
|
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="
|
|
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 = '
|
|
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
|
+
}
|