@kolkrabbi/kol-component 0.93.1 → 0.95.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/molecules/ContentCard.jsx +28 -7
- package/src/molecules/ContentMedia.jsx +21 -1
- package/src/molecules/ContentRow.jsx +11 -20
- package/src/molecules/ContentText.jsx +19 -13
- package/src/molecules/SectionText.jsx +11 -6
- package/src/organisms/SectionCards.jsx +6 -3
- package/src/organisms/SectionCta.jsx +1 -1
- package/src/organisms/SectionFaq.jsx +6 -3
- package/src/organisms/SectionHero.jsx +7 -4
- package/src/organisms/SectionNewsletter.jsx +5 -2
- package/src/organisms/SectionSplit.jsx +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.95.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",
|
|
@@ -42,7 +42,8 @@ const RATIOS = {
|
|
|
42
42
|
const BOX = {
|
|
43
43
|
default: { layout: 'stack', border: 'var(--kol-fg-12)', bg: 'var(--kol-fg-02)', pad: 'var(--kol-pad-card-sm)' },
|
|
44
44
|
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)' },
|
|
45
|
-
|
|
45
|
+
/* flip: PrintGridCard's 3D turn on `isFlipped` → `selected` (ContentRowsAndPrintCard, 2026-08-27) */
|
|
46
|
+
print: { layout: 'fill-card', border: null, bg: 'var(--kol-surface-secondary)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, flip: true },
|
|
46
47
|
article: { layout: 'stack', border: null, bg: null, pad: '0', mediaGap: 'var(--kol-spacing-4)' },
|
|
47
48
|
/* work is a DRAWER: image-only at rest, and on hover a light plate rises
|
|
48
49
|
* over the bottom of the artwork carrying the title + meta. This is the
|
|
@@ -81,7 +82,8 @@ const MEDIA = {
|
|
|
81
82
|
/* zoom is for IMAGE-LED cards — where the artwork is the content and the
|
|
82
83
|
* card is a frame around it. A catalog tile whose preview is a diagram, or
|
|
83
84
|
* a default file card whose thumb is a 48px chip, gets nothing from it. */
|
|
84
|
-
|
|
85
|
+
/* fade: PrintGridCard's image fade-in + loading="lazy", carried (2026-08-27) */
|
|
86
|
+
print: { ring: true, zoom: true, fade: true },
|
|
85
87
|
work: { zoom: true },
|
|
86
88
|
/* frame OFF by default (ListingCardThumbBorder, user 2026-08-27: "I hate
|
|
87
89
|
* border — remove border"): the article thumb's hairline is opt-in —
|
|
@@ -102,6 +104,7 @@ export default function ContentCard({
|
|
|
102
104
|
zoom,
|
|
103
105
|
control,
|
|
104
106
|
controlStart,
|
|
107
|
+
reveal,
|
|
105
108
|
actions,
|
|
106
109
|
expanded = false,
|
|
107
110
|
expandedContent,
|
|
@@ -191,6 +194,7 @@ export default function ContentCard({
|
|
|
191
194
|
ring: ring ?? MEDIA[variant]?.ring ?? false,
|
|
192
195
|
borderHover: MEDIA[variant]?.borderHover ?? false,
|
|
193
196
|
zoom: zoom ?? (isHero ? 'hero' : MEDIA[variant]?.zoom ?? false),
|
|
197
|
+
fade: MEDIA[variant]?.fade ?? false,
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
/* `control` — the in-frame control slot (user ruling 2026-08-15). One node,
|
|
@@ -221,8 +225,18 @@ export default function ContentCard({
|
|
|
221
225
|
</>
|
|
222
226
|
) : box.layout === 'fill-card' ? (
|
|
223
227
|
<>
|
|
224
|
-
<div className="flex-1 min-w-0 min-h-0 relative overflow-hidden" style={expanded ? { flex: '0 0 50%' } :
|
|
225
|
-
|
|
228
|
+
<div className="flex-1 min-w-0 min-h-0 relative overflow-hidden" style={{ ...(expanded ? { flex: '0 0 50%' } : null), ...(box.flip ? { perspective: '1000px' } : null) }}>
|
|
229
|
+
{/* the FLIP (print): PrintGridCard's turn, verbatim — preserve-3d,
|
|
230
|
+
* 0.4s ease-out, rotateY(180deg) while `selected`; the consumer's
|
|
231
|
+
* `onClick` reads the rect off `event.currentTarget` for its
|
|
232
|
+
* FLIP-transition (the seam was always reachable) */}
|
|
233
|
+
{box.flip ? (
|
|
234
|
+
<div className="h-full w-full" style={{ transformStyle: 'preserve-3d', backfaceVisibility: 'hidden', transition: 'transform 0.4s ease-out', transform: selected ? 'rotateY(180deg)' : 'rotateY(0deg)' }}>
|
|
235
|
+
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
236
|
+
</div>
|
|
237
|
+
) : (
|
|
238
|
+
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
239
|
+
)}
|
|
226
240
|
{controlNode}
|
|
227
241
|
{controlStartNode}
|
|
228
242
|
</div>
|
|
@@ -247,14 +261,21 @@ export default function ContentCard({
|
|
|
247
261
|
{textNode && <div className="kol-card-drawer">{textNode}</div>}
|
|
248
262
|
</>
|
|
249
263
|
) : (
|
|
250
|
-
/* canvas — media fills the frame, plate floats on top
|
|
264
|
+
/* canvas — media fills the frame, plate floats on top. `reveal`
|
|
265
|
+
* (TypefaceCardAndRow, 2026-08-27): on hover the plate and the media
|
|
266
|
+
* fade out and the reveal node fades in — the card owns the
|
|
267
|
+
* choreography (kol-theme `.kol-card.has-reveal`), the consumer owns the
|
|
268
|
+
* node (what it says and which face it wears are never the family's). */
|
|
251
269
|
<>
|
|
252
|
-
<div className="absolute" style={{ inset: 0 }}>
|
|
270
|
+
<div className="kol-card-canvas-media absolute" style={{ inset: 0 }}>
|
|
253
271
|
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
254
272
|
{controlNode}
|
|
255
273
|
{controlStartNode}
|
|
256
274
|
</div>
|
|
257
275
|
{textNode}
|
|
276
|
+
{reveal != null && (
|
|
277
|
+
<div className="kol-card-reveal absolute inset-0 flex items-center justify-center p-8 pointer-events-none" style={{ zIndex: 2 }}>{reveal}</div>
|
|
278
|
+
)}
|
|
258
279
|
</>
|
|
259
280
|
)
|
|
260
281
|
|
|
@@ -277,7 +298,7 @@ export default function ContentCard({
|
|
|
277
298
|
|
|
278
299
|
const common = {
|
|
279
300
|
'data-tags': isHero && Array.isArray(text.tags) && text.tags.length ? text.tags.join(' ') : undefined,
|
|
280
|
-
className: `kol-card group flex ${expanded ? 'flex-row-reverse' : '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(),
|
|
301
|
+
className: `kol-card group flex ${box.layout === 'canvas' && reveal != null ? 'has-reveal' : ''} ${expanded ? 'flex-row-reverse' : '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(),
|
|
281
302
|
style: {
|
|
282
303
|
/* same reason as ContentRow: rest colours are PROPERTIES, because an
|
|
283
304
|
* inline background/borderColor outranks the hover class and the step
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, useEffect, useRef, useState } from 'react'
|
|
1
2
|
import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -64,6 +65,8 @@ import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
|
64
65
|
* thumb sized off the row's own height keeps the
|
|
65
66
|
* row's rhythm, where a fixed width leaves it
|
|
66
67
|
* floating in a tall row.
|
|
68
|
+
* @param {boolean} fade an <img> child fades in on load (500ms, house curve) and
|
|
69
|
+
* takes loading="lazy" — PrintGridCard's move (2026-08-27)
|
|
67
70
|
* @param {ReactNode} children the real media
|
|
68
71
|
*/
|
|
69
72
|
const FIT = {
|
|
@@ -72,6 +75,22 @@ const FIT = {
|
|
|
72
75
|
compact: 'grid place-items-center [&>img]:max-h-[70%] [&>img]:max-w-[70%] [&>img]:object-contain',
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
/* the fade: the child <img> is cloned with loading="lazy" and `kol-media-fade`,
|
|
79
|
+
* flips to `is-loaded` on load — or at once when the browser already has it
|
|
80
|
+
* (`complete`), since a cached image never fires onLoad after mount */
|
|
81
|
+
function FadeImg({ img }) {
|
|
82
|
+
const ref = useRef(null)
|
|
83
|
+
const [loaded, setLoaded] = useState(false)
|
|
84
|
+
useEffect(() => { if (ref.current?.complete) setLoaded(true) }, [])
|
|
85
|
+
return cloneElement(img, {
|
|
86
|
+
ref,
|
|
87
|
+
loading: img.props.loading ?? 'lazy',
|
|
88
|
+
className: `${img.props.className ?? ''} kol-media-fade ${loaded ? 'is-loaded' : ''}`.replace(/\s+/g, ' ').trim(),
|
|
89
|
+
onLoad: (e) => { setLoaded(true); img.props.onLoad?.(e) },
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
const withFade = (children) => (isValidElement(children) && children.type === 'img' ? <FadeImg img={children} /> : children)
|
|
93
|
+
|
|
75
94
|
export default function ContentMedia({
|
|
76
95
|
ratio = '1 / 1',
|
|
77
96
|
radius = true,
|
|
@@ -83,6 +102,7 @@ export default function ContentMedia({
|
|
|
83
102
|
ring = false,
|
|
84
103
|
zoom = false,
|
|
85
104
|
fillHeight = false,
|
|
105
|
+
fade = false,
|
|
86
106
|
children,
|
|
87
107
|
className = '',
|
|
88
108
|
}) {
|
|
@@ -95,7 +115,7 @@ export default function ContentMedia({
|
|
|
95
115
|
className={`relative ${fillHeight ? 'h-full w-auto' : 'w-full'} overflow-hidden ${round} ${FIT[fit] ?? FIT.cover} ${frame ? 'bg-fg-04 border border-fg-08' : ''} ${border ? 'border border-fg-08' : ''} ${zoom ? `kol-media-zoom${zoom === 'hero' ? ' is-hero' : ''}` : ''} ${borderHover ? 'transition-colors hover:border-fg-16' : ''} ${ratio == null ? 'h-full' : ''} ${className}`.trim()}
|
|
96
116
|
style={{ ...(ratio != null ? { aspectRatio: ratio } : null), background: bg }}
|
|
97
117
|
>
|
|
98
|
-
{children}
|
|
118
|
+
{fade ? withFade(children) : children}
|
|
99
119
|
{/* OVER the artwork, and inert — a hairline that must not eat the click
|
|
100
120
|
* the card above it is listening for. */}
|
|
101
121
|
{ring && <div className={`pointer-events-none absolute inset-0 border border-fg-08 ${round}`} />}
|
|
@@ -35,15 +35,10 @@ const BOX = {
|
|
|
35
35
|
* point at and get nothing back. It has no surface of its own, so it takes
|
|
36
36
|
* the lightest step there is. Its thumb zooms: on an article row the image
|
|
37
37
|
* IS the subject. */
|
|
38
|
-
article: { thumb: 120, ratio: '1 / 1', pad: '0', gap: S6, align: 'items-start', thumbBg: 'var(--kol-fg-12)', hover: 'var(--kol-oq-02)'
|
|
38
|
+
article: { thumb: 120, ratio: '1 / 1', pad: '0', gap: S6, align: 'items-start', thumbBg: 'var(--kol-fg-12)', hover: 'var(--kol-oq-02)' },
|
|
39
39
|
/* work and typeface step UP at md — the shipped rows both do, and a work row
|
|
40
40
|
* at a fixed 96 cannot hold the display-03 line it was ruled to carry. */
|
|
41
|
-
|
|
42
|
-
* WorkListItem thumb is a fixed SQUARE (w-16 h-16 md:w-28 md:h-28) sitting at
|
|
43
|
-
* the top of a taller row — it does not stretch to the row's height, so the
|
|
44
|
-
* fill-the-height ruling does not apply here and a 64-wide thumb in a 96-tall
|
|
45
|
-
* row came out portrait. */
|
|
46
|
-
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, thumbZoom: true, thumbSquare: true },
|
|
41
|
+
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 },
|
|
47
42
|
/* typeface's row is a COLUMN, not a line: a header (name/styles left,
|
|
48
43
|
* classification/year right) with a full-width specimen band under it. The
|
|
49
44
|
* shipped item is `flex-col gap-6`, and forcing it into the horizontal
|
|
@@ -109,22 +104,18 @@ export default function ContentRow({
|
|
|
109
104
|
) : (
|
|
110
105
|
<>
|
|
111
106
|
{thumbPx > 0 && (
|
|
112
|
-
/* THE THUMB
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
|
|
120
|
-
<div className={`kol-row-thumb shrink-0 ${box.thumbSquare ? 'self-start' : 'self-stretch'}`}>
|
|
107
|
+
/* THE THUMB IS A FIXED SQUARE BOX (ContentRowsAndPrintCard, user
|
|
108
|
+
* 2026-08-27: "the image should not control height, image should fit
|
|
109
|
+
* the row image placeholder") — `--kol-row-thumb` wide, square, at the
|
|
110
|
+
* top of the row (`.kol-row > .kol-row-thumb` in kol-theme ≥0.63.0), the
|
|
111
|
+
* media object-covers into it; row height = max(thumb, text). This
|
|
112
|
+
* retires the 2026-08-15 fill-the-height ruling and the `thumbSquare`
|
|
113
|
+
* exception it needed. Rows never zoom their thumb — cards keep theirs. */
|
|
114
|
+
<div className="kol-row-thumb shrink-0 self-start">
|
|
121
115
|
<ContentMedia
|
|
122
|
-
ratio={
|
|
116
|
+
ratio={null}
|
|
123
117
|
border={box.thumbBorder ?? false}
|
|
124
118
|
bg={box.thumbBg}
|
|
125
|
-
/* zoom where the thumb IS the subject (article · work), never on a
|
|
126
|
-
* 48px file chip or a between-header with no media at all */
|
|
127
|
-
zoom={box.thumbZoom ?? false}
|
|
128
119
|
className={box.thumbRadius ? 'rounded-[var(--kol-radius-xs)]' : ''}
|
|
129
120
|
>
|
|
130
121
|
{media}
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
* @param {string} form card | row
|
|
18
18
|
* @param {ReactNode} title
|
|
19
19
|
* @param {ReactNode} body article · work · typeface
|
|
20
|
-
* @param {ReactNode}
|
|
20
|
+
* @param {ReactNode} eyebrow article only — THE EYEBROW HAS ONE NAME (2026-08-27); `kicker` is its alias
|
|
21
21
|
* @param {ReactNode} detail catalog · print
|
|
22
22
|
* @param {ReactNode} date default · article · typeface
|
|
23
23
|
* @param {'card'|'row'|'hero'} form the block's form; `hero` is article's featured text (ContentCard `hero`)
|
|
24
24
|
* @param {ReactNode} size default · article (file size / read length)
|
|
25
25
|
* @param {ReactNode} meta work only
|
|
26
26
|
* @param {number} gap inner line gap in px (defaults per variant/form)
|
|
27
|
-
* @param {string} titleClass … kickerClass, bodyClass, detailClass,
|
|
27
|
+
* @param {string} titleClass … eyebrowClass (alias `kickerClass`), bodyClass, detailClass,
|
|
28
28
|
* dateClass, sizeClass, metaClass — full class overrides
|
|
29
29
|
*/
|
|
30
30
|
|
|
@@ -63,15 +63,15 @@ const RAMP = {
|
|
|
63
63
|
},
|
|
64
64
|
article: {
|
|
65
65
|
/* kol-content-title-dim: the title dims to 70% on card hover (StackCardHover, 2026-08-27 — ListingCard's move, kol-theme) */
|
|
66
|
-
card: {
|
|
67
|
-
row: {
|
|
66
|
+
card: { eyebrow: 'kol-mono-12 text-meta', title: 'kol-sans-heading-03 text-emphasis kol-content-title-dim', body: 'kol-mono-14 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
|
|
67
|
+
row: { eyebrow: 'kol-mono-12 text-meta', title: 'kol-sans-heading-04 text-emphasis kol-content-title-dim', body: 'kol-mono-12 text-body', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
|
|
68
68
|
/* HERO — the featured card riding a page's fold (ContentCard `hero`,
|
|
69
69
|
/* THE OLD FEATURED CARD, VERBATIM — ListingCard size="hero" as Stack
|
|
70
70
|
* rendered it (user 2026-08-27: "everything was correct in the old featured
|
|
71
71
|
* card … the only thing you had to do was carry it through"): kicker
|
|
72
72
|
* `kol-card-kicker tracking-wide text-fg-64`, title display-section-sm →
|
|
73
73
|
* display-03 uppercase, clamp 2, dim on hover; body mono-14 fg-48 clamp 2. */
|
|
74
|
-
hero: {
|
|
74
|
+
hero: { eyebrow: 'kol-card-kicker tracking-wide text-fg-64', title: 'kol-sans-display-03 uppercase line-clamp-2 kol-content-title-dim', body: 'kol-mono-14 text-fg-48 line-clamp-2', date: 'kol-mono-12 text-meta', size: 'kol-mono-12 text-meta', tags: 'flex flex-wrap gap-2' },
|
|
75
75
|
},
|
|
76
76
|
work: {
|
|
77
77
|
/* INVERSE ink — the card's plate is the drawer, `surface-inverse`. Leaving
|
|
@@ -88,13 +88,16 @@ const RAMP = {
|
|
|
88
88
|
row: { title: 'kol-mono-12 text-body uppercase truncate', body: 'kol-sans-heading-03 leading-tight text-emphasis truncate', meta: 'kol-mono-12 text-body', date: 'kol-mono-12 text-meta', tags: 'flex flex-wrap items-center gap-1.5' },
|
|
89
89
|
},
|
|
90
90
|
typeface: {
|
|
91
|
+
/* RULED ON SCREEN (TypefaceCardAndRow, kol-website 2026-08-27): name and
|
|
92
|
+
* classification are FULL ink, the year steps to 64; the card's title is the
|
|
93
|
+
* row's title string — one title voice for the typeface family. Ink only. */
|
|
91
94
|
/* same as the row: the SPECIMEN carries the emphasis on a typeface card,
|
|
92
95
|
* so the name steps down. The glyph is what you came to look at. */
|
|
93
|
-
card: { title: 'kol-mono-
|
|
96
|
+
card: { title: 'kol-mono-14 uppercase text-emphasis', body: 'kol-mono-12 text-meta', date: 'kol-mono-12 text-meta' },
|
|
94
97
|
/* the name steps DOWN to `body`: on a typeface row the SPECIMEN is the
|
|
95
98
|
* thing you came for and it carries the one emphasis. Two full inks in one
|
|
96
99
|
* block is the ladder broken, and the measurement caught it. */
|
|
97
|
-
row: { title: 'kol-mono-14 uppercase text-
|
|
100
|
+
row: { title: 'kol-mono-14 uppercase text-emphasis', body: 'kol-mono-12 text-meta', detail: 'kol-mono-14 text-emphasis', date: 'kol-mono-12 text-fg-64' },
|
|
98
101
|
},
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -118,9 +121,9 @@ const ORDER = {
|
|
|
118
121
|
* tight 4px internal gap while tags, kicker and the meta group keep the
|
|
119
122
|
* form's own outer gap. A flat column gave every line the same gap, which
|
|
120
123
|
* read as unrelated lines rather than a heading with its standfirst. */
|
|
121
|
-
article: { card: ['tags', '
|
|
124
|
+
article: { card: ['tags', 'eyebrow', ['stack', 'title', 'body'], ['group', 'date', 'size']], row: ['eyebrow', ['stack', 'title', 'body'], ['group', 'date', 'size']] },
|
|
122
125
|
/* the hero carries its tags as data (ListingCard hero: `data-tags` only) and its meta in the header row above the media — neither is a text line here */
|
|
123
|
-
articleHero: ['
|
|
126
|
+
articleHero: ['eyebrow', ['stack', 'title', 'body'], ['group', 'date', 'size']],
|
|
124
127
|
/* work CARD = the drawer's two lines: title, then one meta line.
|
|
125
128
|
*
|
|
126
129
|
* work ROW = WorkListItem, read off the live /work listing: a LEFT column of
|
|
@@ -173,15 +176,18 @@ const GAPS = {
|
|
|
173
176
|
export default function ContentText({
|
|
174
177
|
variant = 'default',
|
|
175
178
|
form = 'card',
|
|
176
|
-
title, body, kicker, detail, date, size, meta, tags,
|
|
179
|
+
title, body, eyebrow, kicker, detail, date, size, meta, tags,
|
|
177
180
|
gap, clamp,
|
|
178
|
-
titleClass, bodyClass, kickerClass, detailClass, dateClass, sizeClass, metaClass, tagsClass,
|
|
181
|
+
titleClass, bodyClass, eyebrowClass, kickerClass, detailClass, dateClass, sizeClass, metaClass, tagsClass,
|
|
179
182
|
className = '',
|
|
180
183
|
}) {
|
|
184
|
+
/* `kicker` / `kickerClass` = aliases of `eyebrow` / `eyebrowClass` (2026-08-27) */
|
|
185
|
+
eyebrow = eyebrow ?? kicker
|
|
186
|
+
eyebrowClass = eyebrowClass ?? kickerClass
|
|
181
187
|
const ramp = RAMP[variant]?.[form] ?? RAMP.default[form] ?? RAMP.default.card
|
|
182
188
|
const order = (form === 'hero' && ORDER[`${variant}Hero`]) || ORDER[variant]?.[form] || ORDER.default.card
|
|
183
|
-
const values = { title, body,
|
|
184
|
-
const overrides = { title: titleClass, body: bodyClass,
|
|
189
|
+
const values = { title, body, eyebrow, detail, date, size, meta, tags }
|
|
190
|
+
const overrides = { title: titleClass, body: bodyClass, eyebrow: eyebrowClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass, tags: tagsClass }
|
|
185
191
|
|
|
186
192
|
/* the clamp rides the BODY only — it is the one slot that carries prose long
|
|
187
193
|
* enough to need cutting, and clamping a title is what `truncate` in the ramp
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* an eyebrow is caps by design language, so the span carries
|
|
18
18
|
* `.kol-section-text-eyebrow`; headline, body and actions render as authored.
|
|
19
19
|
*
|
|
20
|
-
* @param {ReactNode} label
|
|
20
|
+
* @param {ReactNode} eyebrow the eyebrow — THE EYEBROW HAS ONE NAME (2026-08-27); `label` is its alias
|
|
21
21
|
* @param {ReactNode} headline the heading
|
|
22
22
|
* @param {'pull'|'display-01'|'display-02'|'display-03'|'display-04'|'heading-01'|'heading-02'|'heading-03'|'heading-04'|'heading-05'} [headlineSize='heading-02']
|
|
23
23
|
* @param {string} [headlineAs='h2'] element for the headline
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
* @param {string} [gap='gap-4'] inner rhythm (a Tailwind gap step)
|
|
31
31
|
* @param {ReactNode} children extra slots rendered between body and actions
|
|
32
32
|
* (the split's stats strip)
|
|
33
|
-
* @param {string} labelClass · bodyClass · actionsClass full class overrides (
|
|
34
|
-
* @param {object} slotClass extra classes per slot — `{
|
|
33
|
+
* @param {string} eyebrowClass (alias labelClass) · bodyClass · actionsClass full class overrides (eyebrow default: the hero's `kol-helper-12 text-meta`)
|
|
34
|
+
* @param {object} slotClass extra classes per slot — `{ eyebrow, headline, body, actions }` (`label` still read)
|
|
35
35
|
* (SectionRevealSeams, 2026-08-26: a consumer's reveal system
|
|
36
36
|
* stamps `reveal` on each part; nothing animates in the DS)
|
|
37
37
|
* @param {object} slotStyle inline style per slot — `{ headline: { '--reveal-delay': '0.1s' } }`
|
|
@@ -51,6 +51,7 @@ export const HEADLINE_ROLE = {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export default function SectionText({
|
|
54
|
+
eyebrow,
|
|
54
55
|
label,
|
|
55
56
|
headline,
|
|
56
57
|
headlineSize = 'heading-02',
|
|
@@ -66,20 +67,24 @@ export default function SectionText({
|
|
|
66
67
|
* "closer to the hero eyebrow") — the hero's helper-12 / meta, small and
|
|
67
68
|
* quiet, not the split's mono-18 accent kicker. Uppercase stays: that is
|
|
68
69
|
* the eyebrow ROLE (ruled the same day), on `.kol-section-text-eyebrow`. */
|
|
69
|
-
|
|
70
|
+
eyebrowClass,
|
|
71
|
+
labelClass,
|
|
70
72
|
bodyClass = 'kol-section-text-body',
|
|
71
73
|
actionsClass = 'flex flex-wrap gap-4',
|
|
72
74
|
slotClass = {},
|
|
73
75
|
slotStyle = {},
|
|
74
76
|
className = '',
|
|
75
77
|
}) {
|
|
76
|
-
|
|
78
|
+
/* `label` / `labelClass` / slot key `label` = aliases of `eyebrow` (2026-08-27) */
|
|
79
|
+
const eb = eyebrow ?? label
|
|
80
|
+
const ebClass = eyebrowClass ?? labelClass ?? 'kol-helper-12 text-meta'
|
|
81
|
+
const cls = (base, slot) => `${base} ${slotClass[slot] ?? (slot === 'eyebrow' ? slotClass.label : undefined) ?? ''}`.trim()
|
|
77
82
|
const alignCls = align === 'center' ? 'items-center text-center' : 'items-start text-left'
|
|
78
83
|
return (
|
|
79
84
|
<div className={`kol-section-text flex flex-col ${gap} ${alignCls} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
80
85
|
{/* `kol-section-text-eyebrow` = uppercase by ROLE (kol-theme ≥0.55.0);
|
|
81
86
|
* `labelClass` is the voice riding beside it */}
|
|
82
|
-
{
|
|
87
|
+
{eb && <span className={cls(`kol-section-text-eyebrow ${ebClass}`, 'eyebrow')} style={slotStyle.eyebrow ?? slotStyle.label}>{eb}</span>}
|
|
83
88
|
{headline && <Headline className={cls(`${headlineClass ?? (HEADLINE_ROLE[headlineSize] ?? HEADLINE_ROLE['heading-02'])}${headlineCase === 'upper' ? ' kol-section-text-caps' : ''}`, 'headline')} style={slotStyle.headline}>{headline}</Headline>}
|
|
84
89
|
{body && (typeof body === 'string' ? <p className={cls(bodyClass, 'body')} style={slotStyle.body}>{body}</p> : <div className={cls(bodyClass, 'body')} style={slotStyle.body}>{body}</div>)}
|
|
85
90
|
{children}
|
|
@@ -22,7 +22,7 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
22
22
|
* @param {'full'|'80'|'60'|string} [height='60'] min-height on the family's ladder — full = 100dvh,
|
|
23
23
|
* 80 = 70svh / 80vh, 60 = 50svh / 60vh (default), 40 = 35svh / 40vh; content stays vertically centred inside it
|
|
24
24
|
* @param {{title, icon, visual, description, href, backgroundColor, imageAspectRatio}[]} features
|
|
25
|
-
* @param {ReactNode} label · headline · body the header (heading-03 + mono lede by default)
|
|
25
|
+
* @param {ReactNode} eyebrow (alias label) · headline · body the header (heading-03 + mono lede by default)
|
|
26
26
|
* @param {ReactNode} actions centred action row under the cards
|
|
27
27
|
* @param {Function} onNavigate (event, feature) => void
|
|
28
28
|
* @param {string} itemClassName extra classes on every card (reveal seam)
|
|
@@ -34,6 +34,7 @@ export default function SectionCards({
|
|
|
34
34
|
theme,
|
|
35
35
|
height = '60',
|
|
36
36
|
features = [],
|
|
37
|
+
eyebrow,
|
|
37
38
|
label,
|
|
38
39
|
headline,
|
|
39
40
|
headlineSize = 'heading-03',
|
|
@@ -55,13 +56,15 @@ export default function SectionCards({
|
|
|
55
56
|
headerClassName = 'w-full pt-[224px]',
|
|
56
57
|
headerTextWidthClass = 'w-full md:w-[30%]',
|
|
57
58
|
}) {
|
|
59
|
+
/* `label` = alias of `eyebrow` (2026-08-27) */
|
|
60
|
+
const eb = eyebrow ?? label
|
|
58
61
|
const [themeRef, themeStamp] = useSectionTheme(theme)
|
|
59
62
|
return (
|
|
60
63
|
<section ref={themeRef} data-theme={themeStamp} className={`w-full flex flex-col justify-center ${minHeightClass(height)} ${theme ? 'bg-surface-primary' : ''} ${sectionClassName}`.replace(/\s+/g, ' ').trim()}>
|
|
61
64
|
<div className={wrapperClassName}>
|
|
62
|
-
{(
|
|
65
|
+
{(eb || headline || body) && (
|
|
63
66
|
<SectionText
|
|
64
|
-
|
|
67
|
+
eyebrow={eb}
|
|
65
68
|
headline={headline}
|
|
66
69
|
headlineSize={headlineSize}
|
|
67
70
|
headlineAs="p"
|
|
@@ -83,7 +83,7 @@ export default function SectionCta({
|
|
|
83
83
|
{rows.map((row, i) => (
|
|
84
84
|
<SectionText
|
|
85
85
|
key={i}
|
|
86
|
-
|
|
86
|
+
eyebrow={row.label}
|
|
87
87
|
/* the set's eyebrow voice (helper-12 · meta · uppercase by role),
|
|
88
88
|
* not the shipped helper-16 — one eyebrow across the set */
|
|
89
89
|
headline={row.href ? <a href={row.href} className="hover:opacity-70 transition-opacity">{row.value}</a> : row.value}
|
|
@@ -9,7 +9,7 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
9
9
|
* 2026-08-26) — the behaviour already shipped in the Accordion molecule; this
|
|
10
10
|
* is the section around it.
|
|
11
11
|
*
|
|
12
|
-
* @param {ReactNode} label · headline · body · actions the header (SectionText)
|
|
12
|
+
* @param {ReactNode} eyebrow (alias label) · headline · body · actions the header (SectionText)
|
|
13
13
|
* @param {string} [headlineSize='heading-02']
|
|
14
14
|
* @param {'full'|'80'|'60'|string} [height='60'] min-height on the family's ladder — full = 100dvh,
|
|
15
15
|
* 80 = 70svh / 80vh, 60 = 50svh / 60vh (default), 40 = 35svh / 40vh; content stays vertically centred inside it
|
|
@@ -19,6 +19,7 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
19
19
|
* @param {string} className · innerClassName layout seams
|
|
20
20
|
*/
|
|
21
21
|
export default function SectionFaq({
|
|
22
|
+
eyebrow,
|
|
22
23
|
label,
|
|
23
24
|
headline,
|
|
24
25
|
headlineSize = 'heading-02',
|
|
@@ -35,13 +36,15 @@ export default function SectionFaq({
|
|
|
35
36
|
* column inside it — a measure is a content decision, not the frame */
|
|
36
37
|
innerClassName = 'max-w-[var(--kol-content-column)] flex flex-col gap-8',
|
|
37
38
|
}) {
|
|
39
|
+
/* `label` = alias of `eyebrow` (2026-08-27) */
|
|
40
|
+
const eb = eyebrow ?? label
|
|
38
41
|
const [open, setOpen] = useState(defaultOpen ?? null)
|
|
39
42
|
return (
|
|
40
43
|
<section className={`kol-section-faq w-full flex flex-col justify-center px-5 py-16 md:px-8 md:py-24 lg:px-14 ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
41
44
|
<div className="w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto">
|
|
42
45
|
<div className={innerClassName}>
|
|
43
|
-
{(
|
|
44
|
-
<SectionText
|
|
46
|
+
{(eb || headline || body || actions) && (
|
|
47
|
+
<SectionText eyebrow={eb} headline={headline} headlineSize={headlineSize} body={body} actions={actions} slotClass={slotClass} slotStyle={slotStyle} />
|
|
45
48
|
)}
|
|
46
49
|
{items.length > 0 && (
|
|
47
50
|
<Accordion className="mt-0 mb-0">
|
|
@@ -129,7 +129,7 @@ function MediaLayer({ media }) {
|
|
|
129
129
|
* seams, forwarded whole (SectionHeroCarouselSeams, 2026-08-26: the foundry index sets each
|
|
130
130
|
* slide's title in the typeface's own font and routes the CTA through the SPA)
|
|
131
131
|
* @param {string} height 'full' | '80' | '60' | '40' (viewport tiers) · 'lg' | 'md' | 'screen' (aliases) · or a height class string
|
|
132
|
-
* @param {ReactNode} label · headline · body · actions the composed text (SectionText)
|
|
132
|
+
* @param {ReactNode} eyebrow (alias label) · headline · body · actions the composed text (SectionText)
|
|
133
133
|
* @param {object} slotClass · slotStyle per-slot class / style, forwarded to SectionText (a consumer's reveal seam)
|
|
134
134
|
* @param {string} headlineSize role; defaults per variant — media 'display-04', split 'heading-02'
|
|
135
135
|
* @param {string} gap SectionText's inner rhythm, forwarded as given (no hero default)
|
|
@@ -167,6 +167,7 @@ export default function SectionHero({
|
|
|
167
167
|
descriptionClassName,
|
|
168
168
|
options,
|
|
169
169
|
height = 'lg',
|
|
170
|
+
eyebrow,
|
|
170
171
|
label,
|
|
171
172
|
headline,
|
|
172
173
|
/* per-variant DEFAULT only — the hero has no say in the text's voice
|
|
@@ -185,6 +186,8 @@ export default function SectionHero({
|
|
|
185
186
|
align = 'center',
|
|
186
187
|
className = '',
|
|
187
188
|
}) {
|
|
189
|
+
/* `label` = alias of `eyebrow` (2026-08-27) */
|
|
190
|
+
const eb = eyebrow ?? label
|
|
188
191
|
const [themeRef, themeStamp] = useSectionTheme(theme)
|
|
189
192
|
const themed = theme ? 'bg-surface-primary' : ''
|
|
190
193
|
|
|
@@ -205,7 +208,7 @@ export default function SectionHero({
|
|
|
205
208
|
{/* BARE — the molecule's own voice; `headlineCase="upper"` is the
|
|
206
209
|
* split hero's one trait, a role on SectionText (SectionHeroNoOverrides) */}
|
|
207
210
|
<SectionText
|
|
208
|
-
|
|
211
|
+
eyebrow={eb}
|
|
209
212
|
headline={headline}
|
|
210
213
|
headlineSize={headlineSize ?? 'heading-02'}
|
|
211
214
|
headlineCase="upper"
|
|
@@ -227,7 +230,7 @@ export default function SectionHero({
|
|
|
227
230
|
align === 'start' ? 'justify-start'
|
|
228
231
|
: align === 'end' ? 'justify-end'
|
|
229
232
|
: 'justify-center'
|
|
230
|
-
const composed =
|
|
233
|
+
const composed = eb || headline || body || actions
|
|
231
234
|
|
|
232
235
|
/* the foot straddles the fold: the organism owns the negative margin */
|
|
233
236
|
const withFoot = (hero) =>
|
|
@@ -289,7 +292,7 @@ export default function SectionHero({
|
|
|
289
292
|
* with it is not the bar; one voice across the set is. `gap` is the
|
|
290
293
|
* consumer's if they want it. */
|
|
291
294
|
<SectionText
|
|
292
|
-
|
|
295
|
+
eyebrow={eb}
|
|
293
296
|
headline={headline}
|
|
294
297
|
headlineSize={headlineSize ?? 'display-04'}
|
|
295
298
|
body={body}
|
|
@@ -31,7 +31,7 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
31
31
|
* @param {'full'|'80'|'60'|'40'|string} [height='60'] min-height on the family's ladder — full = 100dvh,
|
|
32
32
|
* 80 = 70svh / 80vh, 60 = 50svh / 60vh (default), 40 = 35svh / 40vh; content stays vertically centred inside it
|
|
33
33
|
* @param {'inverse'|'light'|'dark'} theme the paired theme of whatever is live, or a pinned one — stamped on the section
|
|
34
|
-
* @param {ReactNode}
|
|
34
|
+
* @param {ReactNode} eyebrow eyebrow above the headline (uppercase by role); `label` is its alias
|
|
35
35
|
* @param {ReactNode} headline heading (display-01 by default; `headlineSize` picks another role)
|
|
36
36
|
* @param {string} headlineSize SectionText role (default 'display-01')
|
|
37
37
|
* @param {ReactNode} body lede under the heading
|
|
@@ -48,6 +48,7 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
48
48
|
export default function SectionNewsletter({
|
|
49
49
|
height = '60',
|
|
50
50
|
theme,
|
|
51
|
+
eyebrow,
|
|
51
52
|
label,
|
|
52
53
|
headline,
|
|
53
54
|
headlineSize = 'display-01',
|
|
@@ -63,6 +64,8 @@ export default function SectionNewsletter({
|
|
|
63
64
|
slotStyle,
|
|
64
65
|
className = '',
|
|
65
66
|
}) {
|
|
67
|
+
/* `label` = alias of `eyebrow` (2026-08-27) */
|
|
68
|
+
const eb = eyebrow ?? label
|
|
66
69
|
const [email, setEmail] = useState('')
|
|
67
70
|
const [status, setStatus] = useState('idle') // 'idle' | 'submitting' | 'success' | 'error'
|
|
68
71
|
const [themeRef, themeStamp] = useSectionTheme(theme)
|
|
@@ -106,7 +109,7 @@ export default function SectionNewsletter({
|
|
|
106
109
|
<div className="mx-auto max-w-[64rem]">
|
|
107
110
|
<SectionText
|
|
108
111
|
align="center"
|
|
109
|
-
|
|
112
|
+
eyebrow={eb}
|
|
110
113
|
headline={headline}
|
|
111
114
|
headlineSize={headlineSize}
|
|
112
115
|
body={body}
|
|
@@ -23,7 +23,7 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
23
23
|
* `inverse` = the paired theme of the nearest live one, following the toggle;
|
|
24
24
|
* `light` / `dark` pinned; omit to inherit. Stamps `data-theme` on the root and
|
|
25
25
|
* paints its surface — every token inside resolves to the other theme's.
|
|
26
|
-
* @param {ReactNode}
|
|
26
|
+
* @param {ReactNode} eyebrow mono eyebrow above the headline (accent); `label` is its alias
|
|
27
27
|
* @param {ReactNode} headline display pull; `<em>` renders as the italic accent
|
|
28
28
|
* @param {string} [headlineSize='pull'] which type ROLE the heading wears
|
|
29
29
|
* @param {string} [headlineAs='h1']
|
|
@@ -54,6 +54,7 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
54
54
|
|
|
55
55
|
export default function SectionSplit({
|
|
56
56
|
theme,
|
|
57
|
+
eyebrow,
|
|
57
58
|
label,
|
|
58
59
|
headline,
|
|
59
60
|
headlineSize = 'pull',
|
|
@@ -76,6 +77,8 @@ export default function SectionSplit({
|
|
|
76
77
|
innerClassName = '',
|
|
77
78
|
columnClassName = '',
|
|
78
79
|
}) {
|
|
80
|
+
/* `label` = alias of `eyebrow` (2026-08-27) */
|
|
81
|
+
const eb = eyebrow ?? label
|
|
79
82
|
const [themeRef, themeStamp] = useSectionTheme(theme)
|
|
80
83
|
const sectionStyle = bgImage
|
|
81
84
|
? { backgroundImage: `url(${bgImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
|
@@ -100,7 +103,7 @@ export default function SectionSplit({
|
|
|
100
103
|
{/* `order` rather than `flex-row-reverse`: the grid is one column below
|
|
101
104
|
* 901px, and DOM order is what decides the stack there. */}
|
|
102
105
|
<SectionText
|
|
103
|
-
|
|
106
|
+
eyebrow={eb}
|
|
104
107
|
headline={headline}
|
|
105
108
|
headlineSize={headlineSize}
|
|
106
109
|
headlineAs={headlineAs}
|