@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 +1 -1
- package/src/index.js +7 -3
- package/src/molecules/InspectorSection.jsx +30 -0
- package/src/molecules/Section.jsx +4 -25
- package/src/molecules/SectionText.jsx +70 -0
- package/src/organisms/CtaGlobal.jsx +3 -65
- package/src/organisms/FeatureSplit.jsx +17 -126
- package/src/organisms/FeaturesCardSection.jsx +13 -84
- package/src/organisms/FullBleedHero.jsx +3 -109
- package/src/organisms/SectionCards.jsx +83 -0
- package/src/organisms/SectionCta.jsx +54 -0
- package/src/organisms/SectionFaq.jsx +56 -0
- package/src/organisms/SectionHero.jsx +135 -0
- package/src/organisms/SectionSplit.jsx +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "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/
|
|
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/
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
|
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
|
-
*
|
|
3
|
-
* (
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
1
|
+
import SectionCards from './SectionCards.jsx'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|