@kolkrabbi/kol-component 0.93.0 → 0.94.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 +17 -4
- package/src/molecules/ContentMedia.jsx +21 -1
- package/src/molecules/ContentRow.jsx +11 -20
- package/src/molecules/ContentText.jsx +14 -11
- package/src/molecules/SectionText.jsx +11 -6
- package/src/organisms/ContentFilters.jsx +6 -1
- 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.94.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 —
|
|
@@ -191,6 +193,7 @@ export default function ContentCard({
|
|
|
191
193
|
ring: ring ?? MEDIA[variant]?.ring ?? false,
|
|
192
194
|
borderHover: MEDIA[variant]?.borderHover ?? false,
|
|
193
195
|
zoom: zoom ?? (isHero ? 'hero' : MEDIA[variant]?.zoom ?? false),
|
|
196
|
+
fade: MEDIA[variant]?.fade ?? false,
|
|
194
197
|
}
|
|
195
198
|
|
|
196
199
|
/* `control` — the in-frame control slot (user ruling 2026-08-15). One node,
|
|
@@ -221,8 +224,18 @@ export default function ContentCard({
|
|
|
221
224
|
</>
|
|
222
225
|
) : box.layout === 'fill-card' ? (
|
|
223
226
|
<>
|
|
224
|
-
<div className="flex-1 min-w-0 min-h-0 relative overflow-hidden" style={expanded ? { flex: '0 0 50%' } :
|
|
225
|
-
|
|
227
|
+
<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) }}>
|
|
228
|
+
{/* the FLIP (print): PrintGridCard's turn, verbatim — preserve-3d,
|
|
229
|
+
* 0.4s ease-out, rotateY(180deg) while `selected`; the consumer's
|
|
230
|
+
* `onClick` reads the rect off `event.currentTarget` for its
|
|
231
|
+
* FLIP-transition (the seam was always reachable) */}
|
|
232
|
+
{box.flip ? (
|
|
233
|
+
<div className="h-full w-full" style={{ transformStyle: 'preserve-3d', backfaceVisibility: 'hidden', transition: 'transform 0.4s ease-out', transform: selected ? 'rotateY(180deg)' : 'rotateY(0deg)' }}>
|
|
234
|
+
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
235
|
+
</div>
|
|
236
|
+
) : (
|
|
237
|
+
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
238
|
+
)}
|
|
226
239
|
{controlNode}
|
|
227
240
|
{controlStartNode}
|
|
228
241
|
</div>
|
|
@@ -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
|
|
@@ -118,9 +118,9 @@ const ORDER = {
|
|
|
118
118
|
* tight 4px internal gap while tags, kicker and the meta group keep the
|
|
119
119
|
* form's own outer gap. A flat column gave every line the same gap, which
|
|
120
120
|
* read as unrelated lines rather than a heading with its standfirst. */
|
|
121
|
-
article: { card: ['tags', '
|
|
121
|
+
article: { card: ['tags', 'eyebrow', ['stack', 'title', 'body'], ['group', 'date', 'size']], row: ['eyebrow', ['stack', 'title', 'body'], ['group', 'date', 'size']] },
|
|
122
122
|
/* 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: ['
|
|
123
|
+
articleHero: ['eyebrow', ['stack', 'title', 'body'], ['group', 'date', 'size']],
|
|
124
124
|
/* work CARD = the drawer's two lines: title, then one meta line.
|
|
125
125
|
*
|
|
126
126
|
* work ROW = WorkListItem, read off the live /work listing: a LEFT column of
|
|
@@ -173,15 +173,18 @@ const GAPS = {
|
|
|
173
173
|
export default function ContentText({
|
|
174
174
|
variant = 'default',
|
|
175
175
|
form = 'card',
|
|
176
|
-
title, body, kicker, detail, date, size, meta, tags,
|
|
176
|
+
title, body, eyebrow, kicker, detail, date, size, meta, tags,
|
|
177
177
|
gap, clamp,
|
|
178
|
-
titleClass, bodyClass, kickerClass, detailClass, dateClass, sizeClass, metaClass, tagsClass,
|
|
178
|
+
titleClass, bodyClass, eyebrowClass, kickerClass, detailClass, dateClass, sizeClass, metaClass, tagsClass,
|
|
179
179
|
className = '',
|
|
180
180
|
}) {
|
|
181
|
+
/* `kicker` / `kickerClass` = aliases of `eyebrow` / `eyebrowClass` (2026-08-27) */
|
|
182
|
+
eyebrow = eyebrow ?? kicker
|
|
183
|
+
eyebrowClass = eyebrowClass ?? kickerClass
|
|
181
184
|
const ramp = RAMP[variant]?.[form] ?? RAMP.default[form] ?? RAMP.default.card
|
|
182
185
|
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,
|
|
186
|
+
const values = { title, body, eyebrow, detail, date, size, meta, tags }
|
|
187
|
+
const overrides = { title: titleClass, body: bodyClass, eyebrow: eyebrowClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass, tags: tagsClass }
|
|
185
188
|
|
|
186
189
|
/* the clamp rides the BODY only — it is the one slot that carries prose long
|
|
187
190
|
* 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}
|
|
@@ -297,7 +297,12 @@ const ContentFilters = ({
|
|
|
297
297
|
* states"); the span here was the same defect that promoted it. */}
|
|
298
298
|
<h2 className="flex items-center gap-2">
|
|
299
299
|
{titleIcon && <IconFrame name={titleIcon} variant="secondary" size="md" />}
|
|
300
|
-
|
|
300
|
+
{/* `pr-4` by rule (ContentFiltersTitleGap, kol-website 2026-08-27): the
|
|
301
|
+
* divider sat 24px from the title's text edge but 32 from the
|
|
302
|
+
* icon glyphs (the frames carry 8px of air a side) and read pushed
|
|
303
|
+
* toward the title; 16px on the title side balances it. The seam
|
|
304
|
+
* `titleClassName` stays what it was. */}
|
|
305
|
+
<span className={`${titleClassName} pr-4`} style={titleUppercase ? { textTransform: 'uppercase', letterSpacing: 1 } : undefined}>{title}</span>
|
|
301
306
|
</h2>
|
|
302
307
|
<Divider variant="vertical" />
|
|
303
308
|
<div className="flex items-center gap-1">
|
|
@@ -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}
|