@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.93.1",
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
- print: { layout: 'fill-card', border: null, bg: 'var(--kol-surface-secondary)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true },
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
- print: { ring: true, zoom: true },
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%' } : undefined}>
225
- <ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
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)', thumbZoom: true },
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
- /* thumbSquare (2026-08-26, user on the comparison page): the shipped
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 FILLS THE ROW'S HEIGHT (user ruling 2026-08-15).
113
- *
114
- * WIDTH stays pinned to `--kol-row-thumb` that is what stops it
115
- * running away. Only the height stretches, and `ratio={null}` puts
116
- * ContentMedia on `h-full` so the image object-covers into whatever
117
- * height the row is. An earlier cut freed the width instead and let
118
- * `aspect-ratio` fall back to the image's intrinsic size, which is how
119
- * one card ate the page. */
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={box.thumbSquare ? '1 / 1' : null}
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} kicker article only
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: { kicker: '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: { kicker: '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' },
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: { kicker: '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' },
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-16 text-body', body: 'kol-mono-12 text-meta', date: 'kol-mono-12 text-meta' },
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-body', body: 'kol-mono-12 text-meta', detail: 'kol-mono-14 text-body', date: 'kol-mono-12 text-meta' },
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', 'kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']], row: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']] },
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: ['kicker', ['stack', 'title', 'body'], ['group', 'date', 'size']],
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, kicker, detail, date, size, meta, tags }
184
- const overrides = { title: titleClass, body: bodyClass, kicker: kickerClass, detail: detailClass, date: dateClass, size: sizeClass, meta: metaClass, tags: tagsClass }
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 section header / kicker
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 (label default: the hero's `kol-helper-12 text-meta`)
34
- * @param {object} slotClass extra classes per slot — `{ label, headline, body, actions }`
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
- labelClass = 'kol-helper-12 text-meta',
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
- const cls = (base, slot) => `${base} ${slotClass[slot] ?? ''}`.trim()
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
- {label && <span className={cls(`kol-section-text-eyebrow ${labelClass}`, 'label')} style={slotStyle.label}>{label}</span>}
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
- {(label || headline || body) && (
65
+ {(eb || headline || body) && (
63
66
  <SectionText
64
- label={label}
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
- label={row.label}
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
- {(label || headline || body || actions) && (
44
- <SectionText label={label} headline={headline} headlineSize={headlineSize} body={body} actions={actions} slotClass={slotClass} slotStyle={slotStyle} />
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
- label={label}
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 = label || headline || body || actions
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
- label={label}
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} label eyebrow above the headline (uppercase by role)
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
- label={label}
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} label mono eyebrow above the headline (accent)
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
- label={label}
106
+ eyebrow={eb}
104
107
  headline={headline}
105
108
  headlineSize={headlineSize}
106
109
  headlineAs={headlineAs}