@kolkrabbi/kol-component 0.70.0 → 0.71.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.70.0",
3
+ "version": "0.71.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",
package/src/index.js CHANGED
@@ -48,7 +48,8 @@ export { default as ProsePreview } from './utilities/ProsePreview.jsx'
48
48
  export { default as QuantityInput } from './molecules/QuantityInput.jsx'
49
49
  export { default as RotaryDial } from './atoms/RotaryDial.jsx'
50
50
  export { default as SearchInput } from './molecules/SearchInput.jsx'
51
- export { default as Section } from './molecules/Section.jsx'
51
+ export { default as InspectorSection, default as Section } from './molecules/InspectorSection.jsx'
52
+ export { default as SectionText } from './molecules/SectionText.jsx'
52
53
  export { default as SectionLabel } from './atoms/SectionLabel.jsx'
53
54
  export { default as SegmentedToggle } from './atoms/SegmentedToggle.jsx'
54
55
  export { default as Stepper } from './molecules/Stepper.jsx'
@@ -119,13 +120,16 @@ export { default as Carousel } from './molecules/Carousel.jsx'
119
120
  * three in-package copies (and kol-website's CarouselNavigation) happened. */
120
121
  export { default as EmblaNav } from './molecules/EmblaNav.jsx'
121
122
  export { default as ContentFilters } from './organisms/ContentFilters.jsx'
122
- export { default as CtaGlobal } from './organisms/CtaGlobal.jsx'
123
123
  export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
124
124
  export { default as FeatureSplit } from './organisms/FeatureSplit.jsx'
125
125
  export { default as FeaturedCarousel } from './organisms/FeaturedCarousel.jsx'
126
126
  export { default as FeaturesCardSection } from './organisms/FeaturesCardSection.jsx'
127
127
  export { default as FoundryCTA } from './organisms/FoundryCTA.jsx'
128
- export { default as FullBleedHero } from './organisms/FullBleedHero.jsx'
128
+ export { default as SectionHero, default as FullBleedHero } from './organisms/SectionHero.jsx'
129
+ export { default as SectionSplit } from './organisms/SectionSplit.jsx'
130
+ export { default as SectionCards } from './organisms/SectionCards.jsx'
131
+ export { default as SectionCta, default as CtaGlobal } from './organisms/SectionCta.jsx'
132
+ export { default as SectionFaq } from './organisms/SectionFaq.jsx'
129
133
  export { default as LoaderOverlay } from './utilities/LoaderOverlay.jsx'
130
134
  export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPicker, MediaBrowser } from './organisms/MediaLibrary.jsx'
131
135
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
@@ -0,0 +1,30 @@
1
+ /**
2
+ * InspectorSection — labeled control group for inspector/editor panels.
3
+ * Renamed from `Section` 2026-08-26 (SectionSet): the website-section family
4
+ * took the `Section*` prefix and this one sat inside it as a stranger.
5
+ * `Section` stays exported as an alias. Same classes, same render.
6
+ *
7
+ * A small-caps label above a vertical content stack. Used across the editor
8
+ * inspector panels (palette / pattern / type modes): `<Section label="Aspect">…</Section>`.
9
+ *
10
+ * `divided` (InspectorSectionRhythm, 2026-08-15) adds the between-siblings
11
+ * hairline every rail consumer was retyping locally — the rule lives on the
12
+ * ADJACENT pair (`.kol-section--divided + .kol-section--divided`) in
13
+ * kol-components-molecules.css, so the first section in a stack never carries
14
+ * a stray top border. Set it on every section in the stack; a rail that mixes
15
+ * divided and plain sections divides only between the divided ones.
16
+ *
17
+ * ponytail: a `SectionStack` parent could own this instead of each child
18
+ * declaring it — that is the upgrade path if a consumer ever needs the stack
19
+ * to vary the rule per-gap. One prop is a smaller API than a new component.
20
+ */
21
+ export default function InspectorSection({ label, children, divided = false, className = '' }) {
22
+ return (
23
+ <div className={`flex flex-col gap-2${divided ? ' kol-section--divided' : ''} ${className}`}>
24
+ {label && (
25
+ <p className="kol-helper-10 tracking-widest text-meta">{label}</p>
26
+ )}
27
+ {children}
28
+ </div>
29
+ )
30
+ }
@@ -1,27 +1,6 @@
1
1
  /**
2
- * Sectionlabeled control group for inspector/editor panels.
3
- *
4
- * A small-caps label above a vertical content stack. Used across the editor
5
- * inspector panels (palette / pattern / type modes): `<Section label="Aspect">…</Section>`.
6
- *
7
- * `divided` (InspectorSectionRhythm, 2026-08-15) adds the between-siblings
8
- * hairline every rail consumer was retyping locally — the rule lives on the
9
- * ADJACENT pair (`.kol-section--divided + .kol-section--divided`) in
10
- * kol-components-molecules.css, so the first section in a stack never carries
11
- * a stray top border. Set it on every section in the stack; a rail that mixes
12
- * divided and plain sections divides only between the divided ones.
13
- *
14
- * ponytail: a `SectionStack` parent could own this instead of each child
15
- * declaring it — that is the upgrade path if a consumer ever needs the stack
16
- * to vary the rule per-gap. One prop is a smaller API than a new component.
2
+ * @deprecated 2026-08-26 `Section` is `InspectorSection` under its old name
3
+ * (SectionSet: the website-section family owns the `Section*` prefix now).
4
+ * Alias kept; removed at the next major.
17
5
  */
18
- export default function Section({ label, children, divided = false, className = '' }) {
19
- return (
20
- <div className={`flex flex-col gap-2${divided ? ' kol-section--divided' : ''} ${className}`}>
21
- {label && (
22
- <p className="kol-helper-10 tracking-widest text-meta">{label}</p>
23
- )}
24
- {children}
25
- </div>
26
- )
27
- }
6
+ export { default } from './InspectorSection.jsx'
@@ -0,0 +1,70 @@
1
+ /**
2
+ * SectionText — the ruled text block of the SECTION family (SectionSet,
3
+ * kol-website 2026-08-26): label · headline · body · actions, every slot
4
+ * opt-in — an omitted slot renders nothing. The section-tier twin of
5
+ * ContentText: the card family solved "every organism types its own kicker /
6
+ * heading / body by hand" on 2026-08-15, and the sections had the same
7
+ * disease — zero shared text primitive across hero, split, cards band, CTA.
8
+ *
9
+ * Type by ROLE, never a threaded class (DS ruling 2026-08-15): `headlineSize`
10
+ * picks one type class for the headline. `pull` is the split's display pull
11
+ * (`.kol-section-text-pull`, the old `.kol-feature-split-pull`).
12
+ *
13
+ * Every default class is a SEAM (the ContentText pattern): pass `labelClass`
14
+ * / `bodyClass` / `actionsClass` to replace the string whole — that is how
15
+ * SectionHero's eyebrow and SectionCta's rows wear their own voices on the
16
+ * same anatomy. No casing transforms — author strings in their final case.
17
+ *
18
+ * @param {ReactNode} label section header / kicker
19
+ * @param {ReactNode} headline the heading
20
+ * @param {'pull'|'display-01'|'display-02'|'display-03'|'display-04'|'heading-01'|'heading-02'|'heading-03'|'heading-04'|'heading-05'} [headlineSize='heading-02']
21
+ * @param {string} [headlineAs='h2'] element for the headline
22
+ * @param {ReactNode} body lede / paragraph(s)
23
+ * @param {ReactNode} actions row of Buttons / links / Tags
24
+ * @param {'start'|'center'} [align='start'] text-align + item alignment
25
+ * @param {string} [gap='gap-4'] inner rhythm (a Tailwind gap step)
26
+ * @param {ReactNode} children extra slots rendered between body and actions
27
+ * (the split's stats strip)
28
+ * @param {string} labelClass · bodyClass · actionsClass full class overrides
29
+ * @param {string} className extra classes on the block
30
+ */
31
+ export const HEADLINE_ROLE = {
32
+ pull: 'kol-section-text-pull',
33
+ 'display-01': 'kol-sans-display-01',
34
+ 'display-02': 'kol-sans-display-02',
35
+ 'display-03': 'kol-sans-display-03',
36
+ 'display-04': 'kol-sans-display-04',
37
+ 'heading-01': 'kol-sans-heading-01',
38
+ 'heading-02': 'kol-sans-heading-02',
39
+ 'heading-03': 'kol-sans-heading-03',
40
+ 'heading-04': 'kol-sans-heading-04',
41
+ 'heading-05': 'kol-sans-heading-05',
42
+ }
43
+
44
+ export default function SectionText({
45
+ label,
46
+ headline,
47
+ headlineSize = 'heading-02',
48
+ headlineAs: Headline = 'h2',
49
+ headlineClass,
50
+ body,
51
+ actions,
52
+ align = 'start',
53
+ gap = 'gap-4',
54
+ children,
55
+ labelClass = 'kol-section-text-label',
56
+ bodyClass = 'kol-section-text-body',
57
+ actionsClass = 'flex flex-wrap gap-4',
58
+ className = '',
59
+ }) {
60
+ const alignCls = align === 'center' ? 'items-center text-center' : 'items-start text-left'
61
+ return (
62
+ <div className={`kol-section-text flex flex-col ${gap} ${alignCls} ${className}`.replace(/\s+/g, ' ').trim()}>
63
+ {label && <span className={labelClass}>{label}</span>}
64
+ {headline && <Headline className={headlineClass ?? (HEADLINE_ROLE[headlineSize] ?? HEADLINE_ROLE['heading-02'])}>{headline}</Headline>}
65
+ {body && (typeof body === 'string' ? <p className={bodyClass}>{body}</p> : <div className={bodyClass}>{body}</div>)}
66
+ {children}
67
+ {actions && <div className={actionsClass}>{actions}</div>}
68
+ </div>
69
+ )
70
+ }
@@ -1,67 +1,5 @@
1
1
  /**
2
- * CtaGlobaleditorial two-column contact/CTA band: a large display wordmark
3
- * on the left (e.g. "/ CONNECT") and a right column of stacked
4
- * label-over-value rows — a prompt row ("WORKING ON A PROJECT?" / "SEND A
5
- * MESSAGE") and a contact row whose value is a `mailto:` link. The editorial
6
- * closer of the CTA-band family (FoundryCTA is the simple centered tier).
7
- * Columns sit side by side from `md` up and stack below — the source's
8
- * no-breakpoint two-column was a bug fixed on recreation.
9
- *
10
- * Presentational — all copy arrives as props (the source hard-coded every
11
- * string). The right column is internally a list of rows: the named
12
- * `promptLabel`/`heading` and `contactLabel`/`email` conveniences cover the
13
- * common case, `secondaryRows` slots extra rows between them. A row with an
14
- * `href` renders an `<a>`; without, a static heading. Empty rows emit no
15
- * nodes.
16
- *
17
- * Eyebrow, labels, and values render exactly as authored — the source's
18
- * `uppercase` classes were dropped per KOL casing rules; author strings in
19
- * their final case at the call site.
20
- *
21
- * @param {ReactNode} eyebrow left-column display wordmark (kol-sans-display-01)
22
- * @param {ReactNode} promptLabel prompt-row label (kol-helper-16)
23
- * @param {ReactNode} heading prompt-row value (kol-sans-heading-01)
24
- * @param {ReactNode} contactLabel contact-row label (kol-helper-16)
25
- * @param {string} email contact-row value + `mailto:` target; omit to drop the row
26
- * @param {{label: ReactNode, value: ReactNode, href?: string}[]} secondaryRows extra right-column rows between prompt and contact
27
- * @param {string} className extra classes on the section
2
+ * @deprecated 2026-08-26 `CtaGlobal` is `SectionCta` under its old name
3
+ * (SectionSet); identical props and render. Alias kept; removed at the next major.
28
4
  */
29
- export default function CtaGlobal({
30
- eyebrow,
31
- promptLabel,
32
- heading,
33
- contactLabel,
34
- email,
35
- secondaryRows = [],
36
- className = '',
37
- }) {
38
- const rows = [
39
- ...(promptLabel || heading ? [{ label: promptLabel, value: heading }] : []),
40
- ...secondaryRows,
41
- ...(email ? [{ label: contactLabel, value: email, href: `mailto:${email}` }] : []),
42
- ]
43
- return (
44
- <section className={`w-full bg-auto ${className}`.trim()}>
45
- <div className="w-full py-10 flex flex-col md:flex-row items-start gap-12 overflow-hidden">
46
- <div className="flex-1 self-stretch text-auto kol-sans-display-01">{eyebrow}</div>
47
- <div className="flex-1 self-stretch pb-6 md:pt-32 flex flex-col justify-end items-start gap-12">
48
- {rows.map((row, i) => (
49
- <div key={i} className="self-stretch flex flex-col items-start gap-2">
50
- {row.label && <div className="text-auto kol-helper-16">{row.label}</div>}
51
- {row.href ? (
52
- <a
53
- href={row.href}
54
- className="text-auto kol-sans-heading-01 hover:opacity-70 transition-opacity"
55
- >
56
- {row.value}
57
- </a>
58
- ) : (
59
- <div className="text-auto kol-sans-heading-01">{row.value}</div>
60
- )}
61
- </div>
62
- ))}
63
- </div>
64
- </div>
65
- </section>
66
- )
67
- }
5
+ export { default } from './SectionCta.jsx'
@@ -1,131 +1,22 @@
1
+ import SectionSplit from './SectionSplit.jsx'
2
+
1
3
  /**
2
- * FeatureSpliteditorial media-and-text split section: a text column
3
- * (kicker / display pull / lede, then a stats strip OR a CTA row) beside a
4
- * media column (image/video with an optional gradient veil + caption). Two
5
- * columns at/above 901px, single stacked column below. Emits
6
- * kol-feature-split* classes (CSS in @kol/theme).
7
- *
8
- * Presentational — every piece of content arrives as a slot. `meta` and
9
- * `ctas` are mutually exclusive by intent (pick one); `ctas` is
10
- * conventionally a row of KOL Buttons (composition dependency — nothing is
11
- * imported here). The media column renders only when `media` is passed, and
12
- * `caption` gates both the gradient veil and the caption element, so omitted
13
- * slots emit no empty nodes.
14
- *
15
- * Kicker / meta labels / caption render exactly as authored — the source's
16
- * `text-transform: uppercase` was dropped per KOL casing rules; author those
17
- * strings in their final case at the call site.
18
- *
19
- * THE SECTION ANATOMY, ONCE (SectionSplit ruling, 2026-08-15). The brief asked
20
- * for a new `SectionSplit` — media slot beside kicker/heading/body/actions,
21
- * with a flip — and named eleven hand-built kol-website sections as evidence.
22
- * That is this component: the anatomy already shipped here, and a second one
23
- * beside it would be the very duplication the brief exists to end. It grew the
24
- * three things it genuinely lacked instead — `flip`, `titleSize`, `mediaAspect`.
25
- *
26
- * ON THREADING PER-SITE TYPE CLASSES — the brief's named "core design
27
- * question". The answer is that you do not thread one: a consumer picks a ROLE
28
- * (`titleSize`), and the component emits exactly one type class for the
29
- * heading. Passing `kol-sans-heading-01` in alongside `.kol-feature-split-pull`
30
- * would put two equal-specificity rules on one element and let sheet load order
31
- * decide the winner — the failure ARCHITECTURE §5 records, and the 2026-07-30
32
- * law that a component's type lives in its own rule. `columnClassName` and
33
- * friends remain, for LAYOUT.
34
- *
35
- * @param {ReactNode} kicker mono eyebrow above the title (accent color)
36
- * @param {ReactNode} title display pull headline; `<em>` renders as the italic accent
37
- * @param {'pull'|'display-01'|'display-02'|'display-03'|'heading-01'|'heading-02'|'heading-03'|'heading-04'|'heading-05'} [titleSize='pull']
38
- * which type ROLE the heading wears — one class, never stacked
39
- * @param {ReactNode} body lede paragraph
40
- * @param {{num: ReactNode, label: string}[]} meta stats strip (mutually exclusive with `ctas`)
41
- * @param {ReactNode} ctas button row (mutually exclusive with `meta`)
42
- * @param {ReactNode} media image/video/interactive node for the visual
43
- * column; omit for the text-only section (the /CONNECT band shape)
44
- * @param {string} [mediaAspect='4/5'] aspect ratio of the media frame
45
- * @param {boolean} [mediaHover=false] zoom the media on frame hover — the same
46
- * 1.03 / 300ms / reduced-motion-safe treatment CardFeatureItem uses, so the
47
- * estate has one motion vocabulary instead of a per-section re-decision
48
- * @param {boolean} [flip=false] media first, text second — the order swaps at
49
- * both widths, so the stacked layout leads with the media too
50
- * @param {ReactNode} caption mono caption + gradient veil over the media
51
- * @param {string} bgImage URL for an inline cover background on the section
52
- * @param {boolean} fullBleed span the full viewport width (100vw breakout)
53
- * @param {string} className extra classes on the section
54
- * @param {string} innerClassName extra classes on the grid wrapper
55
- * @param {string} columnClassName extra classes on the text column
4
+ * @deprecated 2026-08-26 `FeatureSplit` is `SectionSplit` under its old name
5
+ * (SectionSet). Same render, old prop names mapped: `kicker` `label`,
6
+ * `title` `headline`, `titleSize` `headlineSize`, `ctas` `actions`,
7
+ * `mediaAspect` `ratio`, `flip` `align="left"`. Kept as an alias so no
8
+ * call site breaks; removed at the next major.
56
9
  */
57
- const TITLE_ROLE = {
58
- pull: 'kol-feature-split-pull',
59
- 'display-01': 'kol-sans-display-01',
60
- 'display-02': 'kol-sans-display-02',
61
- 'display-03': 'kol-sans-display-03',
62
- 'heading-01': 'kol-sans-heading-01',
63
- 'heading-02': 'kol-sans-heading-02',
64
- 'heading-03': 'kol-sans-heading-03',
65
- 'heading-04': 'kol-sans-heading-04',
66
- 'heading-05': 'kol-sans-heading-05',
67
- }
68
-
69
- export default function FeatureSplit({
70
- kicker,
71
- title,
72
- titleSize = 'pull',
73
- body,
74
- meta,
75
- ctas,
76
- media,
77
- mediaAspect = '4/5',
78
- mediaHover = false,
79
- flip = false,
80
- caption,
81
- bgImage,
82
- fullBleed = false,
83
- className = '',
84
- innerClassName = '',
85
- columnClassName = '',
86
- }) {
87
- const sectionStyle = bgImage
88
- ? { backgroundImage: `url(${bgImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
89
- : undefined
90
- const bleed = fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : ''
10
+ export default function FeatureSplit({ kicker, title, titleSize, ctas, mediaAspect, flip = false, align, ...rest }) {
91
11
  return (
92
- <section
93
- className={`kol-feature-split px-5 py-16 md:px-8 md:py-24 lg:px-14 lg:py-32 ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
94
- style={sectionStyle}
95
- >
96
- <div className={`max-w-[1200px] mx-auto grid grid-cols-1 min-[901px]:grid-cols-2 items-center gap-[clamp(48px,6vw,96px)] ${innerClassName}`.trim()}>
97
- {/* `order` rather than `flex-row-reverse`: the grid is one column below
98
- * 901px, and DOM order is what decides the stack there. Reversing a row
99
- * would flip the wide layout and leave the narrow one text-first. */}
100
- <div
101
- className={`flex flex-col gap-4 max-w-[640px] ${flip ? 'order-2' : ''} ${columnClassName}`.replace(/\s+/g, ' ').trim()}
102
- >
103
- {kicker && <span className="kol-feature-split-kicker">{kicker}</span>}
104
- {title && <h1 className={TITLE_ROLE[titleSize] ?? TITLE_ROLE.pull}>{title}</h1>}
105
- {body && <p className="kol-feature-split-body">{body}</p>}
106
- {meta && meta.length > 0 && (
107
- <div className="kol-feature-split-meta flex flex-wrap gap-y-7 gap-x-12 pt-4">
108
- {meta.map((m) => (
109
- <div key={m.label} className="flex flex-col gap-0.5">
110
- <span className="kol-feature-split-meta-num">{m.num}</span>
111
- <span className="kol-feature-split-meta-label">{m.label}</span>
112
- </div>
113
- ))}
114
- </div>
115
- )}
116
- {ctas && <div className="flex flex-wrap gap-4 pt-2">{ctas}</div>}
117
- </div>
118
- {media && (
119
- <div
120
- className={`kol-feature-split-visual relative rounded-[var(--kol-radius-sm)] overflow-hidden ${mediaHover ? 'is-hoverable' : ''} ${flip ? 'order-1' : ''}`.replace(/\s+/g, ' ').trim()}
121
- style={{ aspectRatio: mediaAspect }}
122
- >
123
- {media}
124
- {caption && <div className="kol-feature-split-visual-veil" aria-hidden="true" />}
125
- {caption && <span className="kol-feature-split-visual-caption">{caption}</span>}
126
- </div>
127
- )}
128
- </div>
129
- </section>
12
+ <SectionSplit
13
+ label={kicker}
14
+ headline={title}
15
+ headlineSize={titleSize}
16
+ actions={ctas}
17
+ ratio={mediaAspect}
18
+ align={align ?? (flip ? 'left' : 'right')}
19
+ {...rest}
20
+ />
130
21
  )
131
22
  }
@@ -1,90 +1,19 @@
1
- import CardFeatureItem from '../molecules/CardFeatureItem.jsx'
1
+ import SectionCards from './SectionCards.jsx'
2
2
 
3
3
  /**
4
- * FeaturesCardSection — the "N-up feature cards" band: an optional two-part
5
- * header (heading + mono lede) over a responsive row of CardFeatureItem
6
- * cards, capped by an optional centered CTA row. Cards stack column-wise
7
- * below `md` and become a fixed-height row at `md`+; swap
8
- * `cardsWrapperClassName` for a grid to change the layout.
9
- *
10
- * Fully prop-driven — no default copy, images, or routes live here; every
11
- * block is presence-gated (header on `headerLabel`/`headerDescription`,
12
- * cards on `features.length`, CTA row on `ctas`), so omitted slots emit no
13
- * empty nodes. `ctas` is conventionally a row of KOL Buttons (composition
14
- * dependency — nothing is imported here). Card links are plain anchors;
15
- * pass `onNavigate` to intercept same-tab navigations in an SPA.
16
- *
17
- * Header, lede, and card text render exactly as authored — no casing
18
- * transforms; author strings in their final case at the call site.
19
- *
20
- * @param {{title, icon, visual, description, href, backgroundColor, imageAspectRatio}[]} features
21
- * cards rendered as CardFeatureItem molecules
22
- * @param {ReactNode} headerLabel heading (kol-sans-heading-03)
23
- * @param {ReactNode} headerDescription mono lede under the heading
24
- * @param {ReactNode} ctas centered CTA row (a row of Buttons, authored by the caller)
25
- * @param {Function} onNavigate (event, feature) => void — same-tab card-link seam (SPA intercept)
26
- * @param {string} sectionClassName extra classes on the <section>
27
- * @param {string} wrapperClassName outer column container
28
- * @param {string} cardsWrapperClassName card row (default) or grid container
29
- * @param {string} ctasClassName CTA row padding
30
- * @param {string} headerClassName header container
31
- * @param {string} headerTextWidthClass lede measure
4
+ * @deprecated 2026-08-26 `FeaturesCardSection` is `SectionCards` under its
5
+ * old name (SectionSet). Old prop names mapped: `headerLabel` `headline`,
6
+ * `headerDescription` `body`, `ctas` `actions`, `ctasClassName`
7
+ * `actionsClassName`. Alias kept; removed at the next major.
32
8
  */
33
- export default function FeaturesCardSection({
34
- features = [],
35
- headerLabel,
36
- headerDescription,
37
- ctas,
38
- onNavigate,
39
- sectionClassName = '',
40
- wrapperClassName = 'w-full flex flex-col gap-8 md:gap-10 max-w-[1400px] mx-auto',
41
- cardsWrapperClassName = 'self-stretch inline-flex flex-col md:flex-row md:h-72 justify-start items-center gap-6',
42
- ctasClassName = 'pt-10 pb-24',
43
- headerClassName = 'w-full pt-[224px]',
44
- headerTextWidthClass = 'w-full md:w-[30%]',
45
- }) {
9
+ export default function FeaturesCardSection({ headerLabel, headerDescription, ctas, ctasClassName, ...rest }) {
46
10
  return (
47
- <section className={`w-full ${sectionClassName}`.trim()}>
48
- <div className={wrapperClassName}>
49
- {(headerLabel || headerDescription) && (
50
- <div className={headerClassName}>
51
- {headerLabel && (
52
- <div className="flex items-center h-8">
53
- <p className="kol-sans-heading-03">{headerLabel}</p>
54
- </div>
55
- )}
56
- {headerDescription && (
57
- <p className={`kol-mono-14 text-fg-64 mt-3 ${headerTextWidthClass}`.trim()}>
58
- {headerDescription}
59
- </p>
60
- )}
61
- </div>
62
- )}
63
-
64
- {features.length > 0 && (
65
- <div className={cardsWrapperClassName}>
66
- {features.map((feature, index) => (
67
- <CardFeatureItem
68
- key={index}
69
- title={feature.title}
70
- icon={feature.icon}
71
- visual={feature.visual}
72
- description={feature.description}
73
- href={feature.href}
74
- backgroundColor={feature.backgroundColor}
75
- imageAspectRatio={feature.imageAspectRatio}
76
- onNavigate={onNavigate ? (event) => onNavigate(event, feature) : undefined}
77
- />
78
- ))}
79
- </div>
80
- )}
81
-
82
- {ctas && (
83
- <div className={`w-full flex flex-wrap items-center justify-center gap-4 ${ctasClassName}`.trim()}>
84
- {ctas}
85
- </div>
86
- )}
87
- </div>
88
- </section>
11
+ <SectionCards
12
+ headline={headerLabel}
13
+ body={headerDescription}
14
+ actions={ctas}
15
+ actionsClassName={ctasClassName}
16
+ {...rest}
17
+ />
89
18
  )
90
19
  }
@@ -1,111 +1,5 @@
1
- import { isValidElement } from 'react'
2
- import HlsVideo from '../atoms/HlsVideo.jsx'
3
- import Image from '../atoms/Image.jsx'
4
-
5
- /** Height presets; anything else is passed through as a class string. */
6
- const HEIGHTS = {
7
- screen: 'h-screen',
8
- lg: 'h-[440px] md:h-[640px]',
9
- md: 'h-[320px] md:h-[440px]',
10
- }
11
-
12
1
  /**
13
- * Background layer: a media descriptor becomes a cover-fit Image / HlsVideo /
14
- * inert <video> (`.kol-full-bleed-hero-media` pins it absolute + object-cover
15
- * — CSS in @kol/theme); a ready ReactNode renders as-is and must position
16
- * itself (give it that same class).
2
+ * @deprecated 2026-08-26 `FullBleedHero` is `SectionHero` under its old name
3
+ * (SectionSet); identical props and render. Alias kept; removed at the next major.
17
4
  */
18
- function MediaLayer({ media }) {
19
- if (!media) return null
20
- if (isValidElement(media) || typeof media !== 'object') return media
21
-
22
- const { src, kind = 'image', poster, srcSet, alt = '' } = media
23
-
24
- if (kind === 'video') {
25
- // Non-HLS file (mp4/webm): a plain inert video element. HLS manifests —
26
- // and the poster-only case — go through the HlsVideo atom, which owns
27
- // hls.js attachment and the full non-interactive hardening set.
28
- if (src && !src.endsWith('.m3u8')) {
29
- return (
30
- <video
31
- src={src}
32
- poster={poster}
33
- className="kol-full-bleed-hero-media"
34
- style={{ pointerEvents: 'none' }}
35
- autoPlay
36
- loop
37
- muted
38
- playsInline
39
- controls={false}
40
- />
41
- )
42
- }
43
- return <HlsVideo src={src} poster={poster} className="kol-full-bleed-hero-media" />
44
- }
45
-
46
- return (
47
- <Image
48
- src={src}
49
- srcSet={srcSet}
50
- sizes={srcSet ? '100vw' : undefined}
51
- alt={alt}
52
- loading="eager"
53
- className="kol-full-bleed-hero-media"
54
- />
55
- )
56
- }
57
-
58
- /**
59
- * FullBleedHero — full-bleed media hero: cover-fit background media (image or
60
- * video) filling a fixed-height section, an optional surface scrim, and one
61
- * content slot laid over it. The third member of the hero family — BrandHero
62
- * and SubPageHero are band heroes; this is the media one. The canonical
63
- * recipe drops an OverlayGlassPanel into `panel`; that recipe with
64
- * `kind: 'video'` + `height="screen"` IS the monorepo's StudioHero, folded in
65
- * here as a capability rather than a separate component.
66
- *
67
- * Presentational — no copy, no CDN paths, no routes. All text is authored
68
- * inside the caller's panel/children. The source's `opacity-${n}` dynamic
69
- * Tailwind class (JIT-invisible) is replaced by the scrim's inline style; the
70
- * source's image self-dim (`imageOpacity={40}`) is expressed here as
71
- * `overlayOpacity={60}` — a surface-primary veil over full-opacity media.
72
- *
73
- * @param {ReactNode|{src, kind, poster, srcSet, alt}} media background — descriptor ({ kind: 'image'|'video' }) or a self-positioning node
74
- * @param {number} overlayOpacity 0–100 surface-primary scrim over the media (inline opacity; default 0 = none)
75
- * @param {string} height 'screen' | 'lg' | 'md' preset, or any Tailwind height class string
76
- * @param {ReactNode} panel content slot, usually a caller-authored OverlayGlassPanel; wins over children
77
- * @param {ReactNode} children fallback content slot when `panel` is omitted
78
- * @param {string} align 'center' | 'start' | 'end' — horizontal placement of the content (vertically centered)
79
- * @param {string} className extra classes on the section
80
- */
81
- export default function FullBleedHero({
82
- media,
83
- overlayOpacity = 0,
84
- height = 'lg',
85
- panel,
86
- children,
87
- align = 'center',
88
- className = '',
89
- }) {
90
- const heightCls = HEIGHTS[height] || height
91
- const alignCls =
92
- align === 'start' ? 'justify-start'
93
- : align === 'end' ? 'justify-end'
94
- : 'justify-center'
95
-
96
- return (
97
- <section className={`kol-full-bleed-hero relative isolate w-full overflow-hidden ${heightCls} ${className}`.trim()}>
98
- <MediaLayer media={media} />
99
- {overlayOpacity > 0 && (
100
- <div
101
- aria-hidden="true"
102
- className="absolute inset-0"
103
- style={{ background: 'var(--kol-surface-primary)', opacity: overlayOpacity / 100 }}
104
- />
105
- )}
106
- <div className={`relative z-10 flex h-full w-full items-center ${alignCls} p-6 md:p-10`}>
107
- {panel ?? children}
108
- </div>
109
- </section>
110
- )
111
- }
5
+ export { default } from './SectionHero.jsx'
@@ -0,0 +1,83 @@
1
+ import CardFeatureItem from '../molecules/CardFeatureItem.jsx'
2
+ import SectionText, { HEADLINE_ROLE } from '../molecules/SectionText.jsx'
3
+
4
+ /**
5
+ * SectionCards — the "N-up feature cards" band: a `SectionText` header over a
6
+ * responsive row of `CardFeatureItem` cards, capped by an optional centred
7
+ * action row. `FeaturesCardSection` is this component under its old name and
8
+ * prop names (`headerLabel` → `headline`, `headerDescription` → `body`,
9
+ * `ctas` → `actions`; alias kept).
10
+ *
11
+ * Every block is presence-gated (header on any text prop, cards on
12
+ * `features.length`, actions on `actions`), so omitted slots emit no empty
13
+ * nodes. Card links are plain anchors; pass `onNavigate` to intercept same-tab
14
+ * navigations in an SPA. Casing is authored at the call site.
15
+ *
16
+ * @param {{title, icon, visual, description, href, backgroundColor, imageAspectRatio}[]} features
17
+ * @param {ReactNode} label · headline · body the header (heading-03 + mono lede by default)
18
+ * @param {ReactNode} actions centred action row under the cards
19
+ * @param {Function} onNavigate (event, feature) => void
20
+ * @param {string} sectionClassName · wrapperClassName · cardsWrapperClassName · actionsClassName · headerClassName · headerTextWidthClass layout seams
21
+ */
22
+ export default function SectionCards({
23
+ features = [],
24
+ label,
25
+ headline,
26
+ headlineSize = 'heading-03',
27
+ body,
28
+ actions,
29
+ onNavigate,
30
+ sectionClassName = '',
31
+ wrapperClassName = 'w-full flex flex-col gap-8 md:gap-10 max-w-[1400px] mx-auto',
32
+ cardsWrapperClassName = 'self-stretch inline-flex flex-col md:flex-row md:h-72 justify-start items-center gap-6',
33
+ actionsClassName = 'pt-10 pb-24',
34
+ headerClassName = 'w-full pt-[224px]',
35
+ headerTextWidthClass = 'w-full md:w-[30%]',
36
+ }) {
37
+ return (
38
+ <section className={`w-full ${sectionClassName}`.trim()}>
39
+ <div className={wrapperClassName}>
40
+ {(label || headline || body) && (
41
+ <SectionText
42
+ label={label}
43
+ headline={headline}
44
+ headlineSize={headlineSize}
45
+ headlineAs="p"
46
+ /* the heading's 32px line box (`flex items-center h-8`) and the
47
+ * lede's 12px step, as the band has always drawn them */
48
+ headlineClass={`${HEADLINE_ROLE[headlineSize] ?? HEADLINE_ROLE['heading-03']} flex items-center h-8`}
49
+ body={body}
50
+ bodyClass={`kol-mono-14 text-fg-64 ${headerTextWidthClass}`}
51
+ labelClass="kol-helper-12 text-meta"
52
+ gap="gap-3"
53
+ className={headerClassName}
54
+ />
55
+ )}
56
+
57
+ {features.length > 0 && (
58
+ <div className={cardsWrapperClassName}>
59
+ {features.map((feature, index) => (
60
+ <CardFeatureItem
61
+ key={index}
62
+ title={feature.title}
63
+ icon={feature.icon}
64
+ visual={feature.visual}
65
+ description={feature.description}
66
+ href={feature.href}
67
+ backgroundColor={feature.backgroundColor}
68
+ imageAspectRatio={feature.imageAspectRatio}
69
+ onNavigate={onNavigate ? (event) => onNavigate(event, feature) : undefined}
70
+ />
71
+ ))}
72
+ </div>
73
+ )}
74
+
75
+ {actions && (
76
+ <div className={`w-full flex flex-wrap items-center justify-center gap-4 ${actionsClassName}`.trim()}>
77
+ {actions}
78
+ </div>
79
+ )}
80
+ </div>
81
+ </section>
82
+ )
83
+ }
@@ -0,0 +1,54 @@
1
+ import SectionText from '../molecules/SectionText.jsx'
2
+
3
+ /**
4
+ * SectionCta — the editorial two-column contact/CTA band: a large display
5
+ * wordmark on the left (e.g. "/ CONNECT") and a right column of stacked
6
+ * label-over-value rows, each a `SectionText` (label in the helper voice,
7
+ * value as a heading-01; a row with `href` renders its value as a link).
8
+ * `CtaGlobal` is this component under its old name (alias kept). Columns sit
9
+ * side by side from `md` up and stack below. Casing is authored at the call site.
10
+ *
11
+ * @param {ReactNode} eyebrow left-column display wordmark (kol-sans-display-01)
12
+ * @param {ReactNode} promptLabel prompt-row label
13
+ * @param {ReactNode} heading prompt-row value
14
+ * @param {ReactNode} contactLabel contact-row label
15
+ * @param {string} email contact-row value + `mailto:` target; omit to drop the row
16
+ * @param {{label: ReactNode, value: ReactNode, href?: string}[]} secondaryRows extra rows between prompt and contact
17
+ * @param {string} className extra classes on the section
18
+ */
19
+ export default function SectionCta({
20
+ eyebrow,
21
+ promptLabel,
22
+ heading,
23
+ contactLabel,
24
+ email,
25
+ secondaryRows = [],
26
+ className = '',
27
+ }) {
28
+ const rows = [
29
+ ...(promptLabel || heading ? [{ label: promptLabel, value: heading }] : []),
30
+ ...secondaryRows,
31
+ ...(email ? [{ label: contactLabel, value: email, href: `mailto:${email}` }] : []),
32
+ ]
33
+ return (
34
+ <section className={`w-full bg-auto ${className}`.trim()}>
35
+ <div className="w-full py-10 flex flex-col md:flex-row items-start gap-12 overflow-hidden">
36
+ <div className="flex-1 self-stretch text-auto kol-sans-display-01">{eyebrow}</div>
37
+ <div className="flex-1 self-stretch pb-6 md:pt-32 flex flex-col justify-end items-start gap-12">
38
+ {rows.map((row, i) => (
39
+ <SectionText
40
+ key={i}
41
+ label={row.label}
42
+ labelClass="text-auto kol-helper-16"
43
+ headline={row.href ? <a href={row.href} className="hover:opacity-70 transition-opacity">{row.value}</a> : row.value}
44
+ headlineAs="div"
45
+ headlineClass="text-auto kol-sans-heading-01"
46
+ gap="gap-2"
47
+ className="self-stretch"
48
+ />
49
+ ))}
50
+ </div>
51
+ </div>
52
+ </section>
53
+ )
54
+ }
@@ -0,0 +1,56 @@
1
+ import { useState } from 'react'
2
+ import { Accordion, AccordionPanel } from '../molecules/Accordion.jsx'
3
+ import SectionText from '../molecules/SectionText.jsx'
4
+
5
+ /**
6
+ * SectionFaq — a `SectionText` header over an `Accordion` of question /
7
+ * answer items. The one NEW surface in the section family (SectionSet,
8
+ * 2026-08-26) — the behaviour already shipped in the Accordion molecule; this
9
+ * is the section around it.
10
+ *
11
+ * @param {ReactNode} label · headline · body · actions the header (SectionText)
12
+ * @param {string} [headlineSize='heading-02']
13
+ * @param {{ q: ReactNode, a: ReactNode, meta?: ReactNode }[]} items
14
+ * @param {boolean} [singleOpen=false] opening one panel closes the others
15
+ * @param {number} [defaultOpen] index open on mount (singleOpen) — omit for all closed
16
+ * @param {string} className · innerClassName layout seams
17
+ */
18
+ export default function SectionFaq({
19
+ label,
20
+ headline,
21
+ headlineSize = 'heading-02',
22
+ body,
23
+ actions,
24
+ items = [],
25
+ singleOpen = false,
26
+ defaultOpen,
27
+ className = '',
28
+ innerClassName = 'max-w-[var(--kol-content-column)] flex flex-col gap-8',
29
+ }) {
30
+ const [open, setOpen] = useState(defaultOpen ?? null)
31
+ return (
32
+ <section className={`kol-section-faq w-full px-5 py-16 md:px-8 md:py-24 lg:px-14 ${className}`.trim()}>
33
+ <div className={innerClassName}>
34
+ {(label || headline || body || actions) && (
35
+ <SectionText label={label} headline={headline} headlineSize={headlineSize} body={body} actions={actions} />
36
+ )}
37
+ {items.length > 0 && (
38
+ <Accordion className="mt-0 mb-0">
39
+ {items.map((item, i) => (
40
+ <AccordionPanel
41
+ key={i}
42
+ title={item.q}
43
+ meta={item.meta}
44
+ {...(singleOpen
45
+ ? { open: open === i, onToggle: (next) => setOpen(next ? i : null) }
46
+ : { defaultOpen: defaultOpen === i })}
47
+ >
48
+ {item.a}
49
+ </AccordionPanel>
50
+ ))}
51
+ </Accordion>
52
+ )}
53
+ </div>
54
+ </section>
55
+ )
56
+ }
@@ -0,0 +1,135 @@
1
+ import { isValidElement } from 'react'
2
+ import HlsVideo from '../atoms/HlsVideo.jsx'
3
+ import Image from '../atoms/Image.jsx'
4
+ import OverlayGlassPanel from '../utilities/OverlayGlassPanel.jsx'
5
+ import SectionText from '../molecules/SectionText.jsx'
6
+
7
+ /** Height presets; anything else is passed through as a class string. */
8
+ const HEIGHTS = {
9
+ screen: 'h-screen',
10
+ lg: 'h-[440px] md:h-[640px]',
11
+ md: 'h-[320px] md:h-[440px]',
12
+ }
13
+
14
+ /**
15
+ * Background layer: a media descriptor becomes a cover-fit Image / HlsVideo /
16
+ * inert <video> (`.kol-full-bleed-hero-media` pins it absolute + object-cover
17
+ * — CSS in @kol/theme); a ready ReactNode renders as-is and must position
18
+ * itself (give it that same class).
19
+ */
20
+ function MediaLayer({ media }) {
21
+ if (!media) return null
22
+ if (isValidElement(media) || typeof media !== 'object') return media
23
+
24
+ const { src, kind = 'image', poster, srcSet, alt = '' } = media
25
+
26
+ if (kind === 'video') {
27
+ if (src && !src.endsWith('.m3u8')) {
28
+ return (
29
+ <video
30
+ src={src}
31
+ poster={poster}
32
+ className="kol-full-bleed-hero-media"
33
+ style={{ pointerEvents: 'none' }}
34
+ autoPlay
35
+ loop
36
+ muted
37
+ playsInline
38
+ controls={false}
39
+ />
40
+ )
41
+ }
42
+ return <HlsVideo src={src} poster={poster} className="kol-full-bleed-hero-media" />
43
+ }
44
+
45
+ return (
46
+ <Image
47
+ src={src}
48
+ srcSet={srcSet}
49
+ sizes={srcSet ? '100vw' : undefined}
50
+ alt={alt}
51
+ loading="eager"
52
+ className="kol-full-bleed-hero-media"
53
+ />
54
+ )
55
+ }
56
+
57
+ /**
58
+ * SectionHero — the full-bleed media hero: cover-fit background media (image
59
+ * or video) filling a fixed-height section, an optional surface scrim, and
60
+ * the text in a glass panel over it. `FullBleedHero` is this component under
61
+ * its old name (alias kept).
62
+ *
63
+ * TWO WAYS IN, ONE ANATOMY (SectionSet, 2026-08-26). Pass `label` / `headline`
64
+ * / `body` / `actions` and the hero renders them as `SectionText` inside its
65
+ * own `OverlayGlassPanel` — the composed route. Pass `panel` (or children) and
66
+ * the hero renders your node exactly as before — the escape hatch, and what
67
+ * every existing call site does; nothing there moves.
68
+ *
69
+ * @param {ReactNode|{src, kind, poster, srcSet, alt}} media background
70
+ * @param {number} overlayOpacity 0–100 surface-primary scrim over the media (default 0)
71
+ * @param {string} height 'screen' | 'lg' | 'md' preset, or a height class string
72
+ * @param {ReactNode} label · headline · body · actions the composed text (SectionText)
73
+ * @param {string} [headlineSize='display-04']
74
+ * @param {string} [panelMaxWidth='max-w-[440px]'] the glass panel's cap on the composed route
75
+ * @param {ReactNode} panel a caller-authored content node; wins over the text props and children
76
+ * @param {ReactNode} children fallback content slot
77
+ * @param {string} align 'center' | 'start' | 'end' — horizontal placement of the content
78
+ * @param {string} className extra classes on the section
79
+ */
80
+ export default function SectionHero({
81
+ media,
82
+ overlayOpacity = 0,
83
+ height = 'lg',
84
+ label,
85
+ headline,
86
+ headlineSize = 'display-04',
87
+ body,
88
+ actions,
89
+ panelMaxWidth = 'max-w-[440px]',
90
+ panel,
91
+ children,
92
+ align = 'center',
93
+ className = '',
94
+ }) {
95
+ const heightCls = HEIGHTS[height] || height
96
+ const alignCls =
97
+ align === 'start' ? 'justify-start'
98
+ : align === 'end' ? 'justify-end'
99
+ : 'justify-center'
100
+ const composed = label || headline || body || actions
101
+ const content = panel ?? (composed ? (
102
+ <OverlayGlassPanel maxWidth={panelMaxWidth} align={align === 'center' ? 'center' : 'start'}>
103
+ <SectionText
104
+ label={label}
105
+ headline={headline}
106
+ headlineSize={headlineSize}
107
+ body={body}
108
+ actions={actions}
109
+ align={align === 'center' ? 'center' : 'start'}
110
+ /* gap-6 — the rhythm every hand-built OverlayGlassPanel hero ran on,
111
+ * so the composed route lands on the same pixels */
112
+ gap="gap-6"
113
+ labelClass="kol-helper-12 text-meta"
114
+ bodyClass="kol-sans-body-02 text-body"
115
+ actionsClass={`flex flex-wrap gap-4${align === 'center' ? ' justify-center' : ''}`}
116
+ />
117
+ </OverlayGlassPanel>
118
+ ) : children)
119
+
120
+ return (
121
+ <section className={`kol-full-bleed-hero relative isolate w-full overflow-hidden ${heightCls} ${className}`.trim()}>
122
+ <MediaLayer media={media} />
123
+ {overlayOpacity > 0 && (
124
+ <div
125
+ aria-hidden="true"
126
+ className="absolute inset-0"
127
+ style={{ background: 'var(--kol-surface-primary)', opacity: overlayOpacity / 100 }}
128
+ />
129
+ )}
130
+ <div className={`relative z-10 flex h-full w-full items-center ${alignCls} p-6 md:p-10`}>
131
+ {content}
132
+ </div>
133
+ </section>
134
+ )
135
+ }
@@ -0,0 +1,107 @@
1
+ import SectionText from '../molecules/SectionText.jsx'
2
+
3
+ /**
4
+ * SectionSplit — the media-and-text split section, on `SectionText` (the
5
+ * text column) beside the media frame. Two columns at/above 901px, one
6
+ * stacked column below. `FeatureSplit` is this component under its old name
7
+ * and old prop names (kept as an alias — see FeatureSplit.jsx).
8
+ *
9
+ * `align` is the one prop the second brief (SectionSet, 2026-08-26) added:
10
+ * 'right' media on the right, text on the left — the default
11
+ * 'left' media first (the old `flip`)
12
+ * 'center' ONE column: text centred, media below
13
+ * One anatomy, one prop — not a third component.
14
+ *
15
+ * `meta` and `actions` are mutually exclusive by intent (pick one); `actions`
16
+ * is conventionally a row of KOL Buttons. The media column renders only when
17
+ * `media` is passed, and `caption` gates both the gradient veil and the
18
+ * caption element. Casing is authored at the call site.
19
+ *
20
+ * @param {ReactNode} label mono eyebrow above the headline (accent)
21
+ * @param {ReactNode} headline display pull; `<em>` renders as the italic accent
22
+ * @param {string} [headlineSize='pull'] which type ROLE the heading wears
23
+ * @param {string} [headlineAs='h1']
24
+ * @param {ReactNode} body lede paragraph
25
+ * @param {{num: ReactNode, label: string}[]} meta stats strip (mutually exclusive with `actions`)
26
+ * @param {ReactNode} actions button row
27
+ * @param {ReactNode} media image / video / interactive node for the visual column
28
+ * @param {string} [ratio='4/5'] aspect ratio of the media frame
29
+ * @param {boolean} [mediaHover=false] zoom the media on frame hover (the CardFeatureItem numbers)
30
+ * @param {'right'|'left'|'center'} [align='right'] media side, or centred single column
31
+ * @param {ReactNode} caption mono caption + gradient veil over the media
32
+ * @param {string} bgImage inline cover background on the section
33
+ * @param {boolean} fullBleed span the full viewport width
34
+ * @param {string} className · innerClassName · columnClassName layout seams
35
+ */
36
+ export default function SectionSplit({
37
+ label,
38
+ headline,
39
+ headlineSize = 'pull',
40
+ headlineAs = 'h1',
41
+ body,
42
+ meta,
43
+ actions,
44
+ media,
45
+ ratio = '4/5',
46
+ mediaHover = false,
47
+ align = 'right',
48
+ caption,
49
+ bgImage,
50
+ fullBleed = false,
51
+ className = '',
52
+ innerClassName = '',
53
+ columnClassName = '',
54
+ }) {
55
+ const sectionStyle = bgImage
56
+ ? { backgroundImage: `url(${bgImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
57
+ : undefined
58
+ const bleed = fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : ''
59
+ const centred = align === 'center'
60
+ const mediaFirst = align === 'left'
61
+ const grid = centred
62
+ ? 'grid grid-cols-1 justify-items-center gap-[clamp(48px,6vw,96px)]'
63
+ : 'grid grid-cols-1 min-[901px]:grid-cols-2 items-center gap-[clamp(48px,6vw,96px)]'
64
+ return (
65
+ <section
66
+ className={`kol-section-split px-5 py-16 md:px-8 md:py-24 lg:px-14 lg:py-32 ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
67
+ style={sectionStyle}
68
+ >
69
+ <div className={`max-w-[1200px] mx-auto ${grid} ${innerClassName}`.replace(/\s+/g, ' ').trim()}>
70
+ {/* `order` rather than `flex-row-reverse`: the grid is one column below
71
+ * 901px, and DOM order is what decides the stack there. */}
72
+ <SectionText
73
+ label={label}
74
+ headline={headline}
75
+ headlineSize={headlineSize}
76
+ headlineAs={headlineAs}
77
+ body={body}
78
+ actions={actions}
79
+ actionsClass={`flex flex-wrap gap-4 pt-2${centred ? ' justify-center' : ''}`}
80
+ align={centred ? 'center' : 'start'}
81
+ className={`max-w-[640px] ${mediaFirst ? 'order-2' : ''} ${columnClassName}`}
82
+ >
83
+ {meta && meta.length > 0 && (
84
+ <div className={`kol-section-split-meta flex flex-wrap gap-y-7 gap-x-12 pt-4${centred ? ' justify-center' : ''}`}>
85
+ {meta.map((m) => (
86
+ <div key={m.label} className="flex flex-col gap-0.5">
87
+ <span className="kol-section-split-meta-num">{m.num}</span>
88
+ <span className="kol-section-split-meta-label">{m.label}</span>
89
+ </div>
90
+ ))}
91
+ </div>
92
+ )}
93
+ </SectionText>
94
+ {media && (
95
+ <div
96
+ className={`kol-section-split-visual relative rounded-[var(--kol-radius-sm)] overflow-hidden ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'w-full max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
97
+ style={{ aspectRatio: ratio }}
98
+ >
99
+ {media}
100
+ {caption && <div className="kol-section-split-visual-veil" aria-hidden="true" />}
101
+ {caption && <span className="kol-section-split-visual-caption">{caption}</span>}
102
+ </div>
103
+ )}
104
+ </div>
105
+ </section>
106
+ )
107
+ }