@kolkrabbi/kol-component 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/package.json +11 -3
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/atoms/Button.jsx +17 -6
  5. package/src/atoms/CurveOverlay.jsx +180 -0
  6. package/src/atoms/DocsToc.jsx +48 -0
  7. package/src/atoms/EmptyState.jsx +22 -0
  8. package/src/atoms/Figure.jsx +27 -0
  9. package/src/atoms/HlsVideo.jsx +56 -0
  10. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  11. package/src/atoms/PriceDisplay.jsx +34 -0
  12. package/src/atoms/ProsePreview.jsx +53 -0
  13. package/src/atoms/RotaryDial.jsx +150 -0
  14. package/src/atoms/SearchInput.jsx +108 -0
  15. package/src/atoms/TextPressure.jsx +331 -0
  16. package/src/atoms/TiltCard.jsx +127 -0
  17. package/src/atoms/TypeSample.jsx +50 -0
  18. package/src/atoms/TypeSpecCard.jsx +42 -0
  19. package/src/hooks/cssVar.js +53 -0
  20. package/src/hooks/useAxisAnimation.js +91 -0
  21. package/src/hooks/usePrefersReducedMotion.js +21 -0
  22. package/src/hooks/useTilt.js +49 -0
  23. package/src/index.js +66 -1
  24. package/src/molecules/AlignmentGrid.jsx +53 -0
  25. package/src/molecules/ArticleCard.jsx +178 -0
  26. package/src/molecules/CardFeatureItem.jsx +130 -0
  27. package/src/molecules/ColorInputRow.jsx +179 -0
  28. package/src/molecules/ColorRamp.jsx +114 -0
  29. package/src/molecules/FramedMediaBand.jsx +56 -0
  30. package/src/molecules/ImageBlock.jsx +34 -0
  31. package/src/molecules/SelectionOverlay.jsx +108 -0
  32. package/src/molecules/ShapeDropdown.jsx +92 -0
  33. package/src/molecules/ShellDrawer.jsx +169 -0
  34. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  35. package/src/molecules/SpecList.jsx +30 -0
  36. package/src/molecules/SpectrumControls.jsx +504 -0
  37. package/src/molecules/SwatchControls.jsx +217 -0
  38. package/src/molecules/TabsRow.jsx +87 -0
  39. package/src/molecules/VideoBlock.jsx +86 -0
  40. package/src/molecules/WorkListItem.jsx +83 -0
  41. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  42. package/src/organisms/ArticleHeader.jsx +95 -0
  43. package/src/organisms/AsciiCursor.jsx +526 -0
  44. package/src/organisms/BentoCard.jsx +187 -0
  45. package/src/organisms/Canvas.jsx +299 -0
  46. package/src/organisms/ColorLoader.jsx +155 -0
  47. package/src/organisms/CtaGlobal.jsx +67 -0
  48. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  49. package/src/organisms/EditorShell.jsx +111 -0
  50. package/src/organisms/ErrorBoundary.jsx +70 -0
  51. package/src/organisms/FeatureSplit.jsx +82 -0
  52. package/src/organisms/FeaturedCarousel.jsx +258 -0
  53. package/src/organisms/FeaturesCardSection.jsx +90 -0
  54. package/src/organisms/FullBleedHero.jsx +111 -0
  55. package/src/organisms/GalleryCarousel.jsx +83 -0
  56. package/src/organisms/LoaderOverlay.jsx +30 -0
  57. package/src/organisms/MediaViewer.jsx +95 -0
  58. package/src/organisms/NewsletterBand.jsx +122 -0
  59. package/src/organisms/ParallaxShelf.jsx +141 -0
  60. package/src/organisms/PortableTextRenderer.jsx +115 -0
  61. package/src/organisms/ProductDetailLayout.jsx +189 -0
  62. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  63. package/src/organisms/SpectrumGrid.jsx +90 -0
  64. package/src/organisms/StackHero.jsx +83 -0
  65. package/src/organisms/WorkCard.jsx +120 -0
  66. package/src/organisms/WorkViewToggle.jsx +170 -0
  67. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  68. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  69. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  70. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  71. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  72. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  73. package/src/organisms/foundry/glyphData.js +30 -0
  74. package/src/organisms/foundry/index.js +21 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * CtaGlobal — editorial 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
28
+ */
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
+ }
@@ -0,0 +1,138 @@
1
+ import { useEffect, useMemo, useRef } from 'react'
2
+ import { gsap } from 'gsap'
3
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
4
+
5
+ /**
6
+ * DiagonalMarqueeRiver — an auto-scrolling multi-column marquee "river":
7
+ * `items` are dealt round-robin into `colCount` vertical columns, each column
8
+ * runs an infinite constant-velocity GSAP y-tween at its own speed, and the
9
+ * whole block is rotated + scaled so the columns stream diagonally across a
10
+ * tall stage. An IntersectionObserver pauses every tween while the stage is
11
+ * off-screen. Data-agnostic — the card is a `renderItem` slot; the engine only
12
+ * measures scrollHeight and tweens y.
13
+ *
14
+ * Each column's list is rendered twice so the tween can travel exactly one set
15
+ * then repeat with an invisible wrap. On `prefers-reduced-motion` no GSAP runs
16
+ * and the columns render static. The tween set is rebuilt on container resize
17
+ * (a ResizeObserver remeasures scrollHeight — the source keyed only on the item
18
+ * list and drifted when the viewport changed).
19
+ *
20
+ * @param {any[]} items pool dealt round-robin into columns
21
+ * @param {(item:any,i:number)=>ReactNode} renderItem card slot per item
22
+ * @param {number} colCount number of columns (default 4)
23
+ * @param {number[]} columnSpeeds px/s per column (default [28,38,22,34], fallback 30)
24
+ * @param {number} gap vertical gap between cards and gap-between-columns, px (default 24)
25
+ * @param {string} height stage height (default '300vh')
26
+ * @param {number} rotate tilt of the block, deg (default -15)
27
+ * @param {number} scale oversize of the block (default 1.3)
28
+ * @param {boolean} shuffle one-time shuffle of `items` before dealing (default false)
29
+ */
30
+ export default function DiagonalMarqueeRiver({
31
+ items = [],
32
+ renderItem,
33
+ colCount = 4,
34
+ columnSpeeds = [28, 38, 22, 34],
35
+ gap = 24,
36
+ height = '300vh',
37
+ rotate = -15,
38
+ scale = 1.3,
39
+ shuffle = false,
40
+ }) {
41
+ const containerRef = useRef(null)
42
+ const columnRefs = useRef([])
43
+ const tweensRef = useRef([])
44
+ const reducedMotion = usePrefersReducedMotion()
45
+
46
+ // Deal items round-robin into columns (optionally shuffled once).
47
+ const columns = useMemo(() => {
48
+ const pool = shuffle ? [...items].sort(() => Math.random() - 0.5) : items
49
+ const cols = Array.from({ length: colCount }, () => [])
50
+ pool.forEach((item, i) => cols[i % colCount].push(item))
51
+ return cols
52
+ }, [items, colCount, shuffle])
53
+
54
+ useEffect(() => {
55
+ if (reducedMotion) return undefined
56
+
57
+ const build = () => {
58
+ const colEls = columnRefs.current.filter(Boolean)
59
+ tweensRef.current.forEach((tw) => tw.kill())
60
+ tweensRef.current = []
61
+ if (!colEls.length) return
62
+
63
+ colEls.forEach((colEl, i) => {
64
+ colEl.style.willChange = 'transform'
65
+ const singleSetHeight = colEl.scrollHeight / 2 // one copy of the doubled list
66
+ const speed = columnSpeeds[i] || 30
67
+ const initialOffset = -(singleSetHeight * ((i * 0.15) % 1)) // staggered per-column start
68
+ gsap.set(colEl, { y: initialOffset })
69
+ const tween = gsap.to(colEl, {
70
+ y: initialOffset - singleSetHeight, // travel exactly one set → seamless
71
+ duration: singleSetHeight / speed, // constant px/s regardless of column length
72
+ ease: 'none',
73
+ repeat: -1,
74
+ })
75
+ tweensRef.current.push(tween)
76
+ })
77
+ }
78
+
79
+ build()
80
+
81
+ // Pause tweens while the stage is off-screen.
82
+ const io = new IntersectionObserver(
83
+ ([entry]) => {
84
+ tweensRef.current.forEach((tw) => (entry.isIntersecting ? tw.resume() : tw.pause()))
85
+ },
86
+ { threshold: 0 },
87
+ )
88
+ if (containerRef.current) io.observe(containerRef.current)
89
+
90
+ // [source-bug] resize-remeasure: source keyed only on the item list, so a
91
+ // viewport change left the tweens measuring a stale scrollHeight. Rebuild
92
+ // on container resize (debounced).
93
+ let raf = 0
94
+ const ro = new ResizeObserver(() => {
95
+ cancelAnimationFrame(raf)
96
+ raf = requestAnimationFrame(build)
97
+ })
98
+ if (containerRef.current) ro.observe(containerRef.current)
99
+
100
+ return () => {
101
+ io.disconnect()
102
+ ro.disconnect()
103
+ cancelAnimationFrame(raf)
104
+ tweensRef.current.forEach((tw) => tw.kill())
105
+ tweensRef.current = []
106
+ }
107
+ }, [columns, columnSpeeds, reducedMotion])
108
+
109
+ return (
110
+ <div ref={containerRef} className="w-full overflow-hidden" style={{ height }}>
111
+ <div
112
+ className="flex gap-6"
113
+ style={{
114
+ transform: `rotate(${rotate}deg) scale(${scale})`,
115
+ transformOrigin: 'center center',
116
+ height: '140%',
117
+ marginTop: '-20%',
118
+ }}
119
+ >
120
+ {columns.map((col, colIndex) => (
121
+ <div key={colIndex} className="flex-1 overflow-hidden">
122
+ <div
123
+ ref={(el) => {
124
+ columnRefs.current[colIndex] = el
125
+ }}
126
+ className="flex flex-col"
127
+ style={{ gap: `${gap}px` }}
128
+ >
129
+ {[...col, ...col].map((item, i) => (
130
+ <div key={i}>{renderItem?.(item, i)}</div>
131
+ ))}
132
+ </div>
133
+ </div>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ )
138
+ }
@@ -0,0 +1,111 @@
1
+ import Divider from '../atoms/Divider.jsx'
2
+
3
+ /**
4
+ * EditorShell — the two-rail editor layout frame.
5
+ *
6
+ * ┌──────────────── topbar ──────────────────────┐
7
+ * ├────────┬──────────────────────────┬──────────┤
8
+ * │ left │ [canvasHeader] │ right │
9
+ * │ rail │ children (canvas) │ rail │
10
+ * └────────┴──────────────────────────┴──────────┘
11
+ *
12
+ * Fixed-width rails flank a fluid canvas column, all under an optional topbar.
13
+ * Hairlines between regions are composed from the DS `Divider` (vertical
14
+ * between rails/canvas, horizontal under the topbar and rail/canvas headers).
15
+ * Headers render only when their slot is filled, so an unused header
16
+ * contributes no border or gap (the source's `:empty` collapse, expressed as a
17
+ * conditional render instead of a CSS rule).
18
+ *
19
+ * Ported from the brand editor with the app couplings dropped (per lobby
20
+ * spec): the panel-registry + `panelsForSlot`/`SLOTS` indirection is replaced
21
+ * by plain ReactNode slots (`left` / `right` / `children` + optional headers,
22
+ * topbar, overlays); the `MenuTop` / `ShortcutsOverlay` imports become the
23
+ * `topbar` / `overlays` slots; the editor stylesheet import is gone (layout is
24
+ * Tailwind + composed Divider); the `#0E0E11` dark canvas is a `canvasBg` prop.
25
+ * `data-editor-keep-selection` stays as an opt-in click-away hook, not baked
26
+ * behavior.
27
+ *
28
+ * @param {ReactNode} topbar top bar spanning the full width (optional)
29
+ * @param {ReactNode} leftHeader left rail header (optional; renders a hairline when set)
30
+ * @param {ReactNode} left left rail body (scrolls independently)
31
+ * @param {ReactNode} canvasHeader sub-bar spanning only the canvas column, e.g. a tool palette (optional)
32
+ * @param {ReactNode} children the canvas region (fills the fluid column)
33
+ * @param {ReactNode} rightHeader right rail header (optional)
34
+ * @param {ReactNode} right right rail body (scrolls independently)
35
+ * @param {ReactNode} overlays floating overlays rendered above the frame (optional)
36
+ * @param {number} railWidth rail column width in px (default 320)
37
+ * @param {string} canvasBg canvas column background (default var(--kol-surface-primary))
38
+ * @param {string|number} height shell height (default '100dvh'; pass a bounded value to embed)
39
+ * @param {string} className extra classes merged onto the shell root
40
+ */
41
+ export default function EditorShell({
42
+ topbar,
43
+ leftHeader,
44
+ left,
45
+ canvasHeader,
46
+ children,
47
+ rightHeader,
48
+ right,
49
+ overlays,
50
+ railWidth = 320,
51
+ canvasBg = 'var(--kol-surface-primary)',
52
+ height = '100dvh',
53
+ className = '',
54
+ }) {
55
+ return (
56
+ <div
57
+ data-editor-keep-selection
58
+ className={`flex flex-col overflow-hidden bg-surface-primary ${className}`.trim()}
59
+ style={{ height }}
60
+ >
61
+ {topbar && (
62
+ <>
63
+ <div className="shrink-0">{topbar}</div>
64
+ <Divider />
65
+ </>
66
+ )}
67
+
68
+ <div className="flex flex-1 min-h-0">
69
+ <Rail header={leftHeader} width={railWidth}>{left}</Rail>
70
+ <Divider variant="vertical" />
71
+
72
+ <div className="flex flex-col flex-1 min-w-0 min-h-0">
73
+ {canvasHeader && (
74
+ <>
75
+ <div className="shrink-0">{canvasHeader}</div>
76
+ <Divider />
77
+ </>
78
+ )}
79
+ <main
80
+ className="flex-1 min-h-0 select-none"
81
+ style={{ background: canvasBg }}
82
+ >
83
+ {children}
84
+ </main>
85
+ </div>
86
+
87
+ <Divider variant="vertical" />
88
+ <Rail header={rightHeader} width={railWidth}>{right}</Rail>
89
+ </div>
90
+
91
+ {overlays}
92
+ </div>
93
+ )
94
+ }
95
+
96
+ /* Rail — fixed-width aside with an optional header (+ hairline) over a
97
+ * scrolling body. `min-h-0` lets the body's overflow scroll instead of
98
+ * stretching the whole shell. */
99
+ function Rail({ header, width, children }) {
100
+ return (
101
+ <aside className="flex flex-col min-h-0 shrink-0" style={{ width }}>
102
+ {header && (
103
+ <>
104
+ <div className="shrink-0">{header}</div>
105
+ <Divider />
106
+ </>
107
+ )}
108
+ <div className="flex-1 min-h-0 overflow-y-auto">{children}</div>
109
+ </aside>
110
+ )
111
+ }
@@ -0,0 +1,70 @@
1
+ import { Component } from 'react'
2
+ import Button from '../atoms/Button.jsx'
3
+
4
+ /**
5
+ * ErrorBoundary — class error boundary (boundaries can't be hooks). Catches
6
+ * render errors in its subtree and swaps in a centered full-screen fallback
7
+ * ("Something went wrong") with two recovery actions: retry in place and go
8
+ * home. A dev-only error/stack panel renders on Vite dev builds
9
+ * (`import.meta.env.DEV`) — prod users never see a stack. Wrap app regions
10
+ * so a single crash can't blank the whole app.
11
+ *
12
+ * @param {ReactNode} children the guarded subtree
13
+ * @param {string} homeHref "Go home" destination (default '/')
14
+ * @param {Function} fallback ({ error, errorInfo, reset }) => ReactNode — replaces the built-in fallback screen
15
+ * @param {Function} onReset called after "Try again" clears the error state
16
+ */
17
+ export default class ErrorBoundary extends Component {
18
+ state = { hasError: false, error: null, errorInfo: null }
19
+
20
+ static getDerivedStateFromError() {
21
+ return { hasError: true }
22
+ }
23
+
24
+ componentDidCatch(error, errorInfo) {
25
+ console.error('ErrorBoundary caught an error:', error, errorInfo)
26
+ this.setState({ error, errorInfo })
27
+ }
28
+
29
+ handleReset = () => {
30
+ this.setState({ hasError: false, error: null, errorInfo: null })
31
+ this.props.onReset?.()
32
+ }
33
+
34
+ render() {
35
+ if (!this.state.hasError) return this.props.children
36
+
37
+ const { error, errorInfo } = this.state
38
+ if (this.props.fallback) {
39
+ return this.props.fallback({ error, errorInfo, reset: this.handleReset })
40
+ }
41
+
42
+ return (
43
+ <div className="min-h-screen w-full flex items-center justify-center px-8 bg-surface-primary">
44
+ <div className="max-w-[600px] space-y-8 text-center">
45
+ <div className="space-y-4">
46
+ <h1 className="kol-sans-display-02">Something went wrong</h1>
47
+ <p className="kol-mono-16 opacity-70">
48
+ We encountered an unexpected error. This has been logged and we'll look into it.
49
+ </p>
50
+ </div>
51
+
52
+ {import.meta.env.DEV && error && (
53
+ <div className="text-left p-6 rounded overflow-auto max-h-[300px] bg-surface-secondary">
54
+ <p className="kol-mono-12 font-bold mb-2">Error Details (dev only):</p>
55
+ <pre className="kol-mono-12 whitespace-pre-wrap break-all">
56
+ {error.toString()}
57
+ {errorInfo?.componentStack}
58
+ </pre>
59
+ </div>
60
+ )}
61
+
62
+ <div className="flex gap-4 justify-center flex-wrap">
63
+ <Button variant="primary" onClick={this.handleReset}>Try again</Button>
64
+ <Button variant="secondary" href={this.props.homeHref ?? '/'}>Go home</Button>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ )
69
+ }
70
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * FeatureSplit — editorial 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
+ * @param {ReactNode} kicker mono eyebrow above the title (accent color)
20
+ * @param {ReactNode} title display pull headline; `<em>` renders as the italic accent
21
+ * @param {ReactNode} body lede paragraph
22
+ * @param {{num: ReactNode, label: string}[]} meta stats strip (mutually exclusive with `ctas`)
23
+ * @param {ReactNode} ctas button row (mutually exclusive with `meta`)
24
+ * @param {ReactNode} media image/video for the visual column; omit to hide it
25
+ * @param {ReactNode} caption mono caption + gradient veil over the media
26
+ * @param {string} bgImage URL for an inline cover background on the section
27
+ * @param {boolean} fullBleed span the full viewport width (100vw breakout)
28
+ * @param {string} className extra classes on the section
29
+ * @param {string} innerClassName extra classes on the grid wrapper
30
+ * @param {string} columnClassName extra classes on the text column
31
+ */
32
+ export default function FeatureSplit({
33
+ kicker,
34
+ title,
35
+ body,
36
+ meta,
37
+ ctas,
38
+ media,
39
+ caption,
40
+ bgImage,
41
+ fullBleed = false,
42
+ className = '',
43
+ innerClassName = '',
44
+ columnClassName = '',
45
+ }) {
46
+ const sectionStyle = bgImage
47
+ ? { backgroundImage: `url(${bgImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
48
+ : undefined
49
+ const bleed = fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : ''
50
+ return (
51
+ <section
52
+ 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()}
53
+ style={sectionStyle}
54
+ >
55
+ <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()}>
56
+ <div className={`flex flex-col gap-4 max-w-[640px] ${columnClassName}`.trim()}>
57
+ {kicker && <span className="kol-feature-split-kicker">{kicker}</span>}
58
+ {title && <h1 className="kol-feature-split-pull">{title}</h1>}
59
+ {body && <p className="kol-feature-split-body">{body}</p>}
60
+ {meta && meta.length > 0 && (
61
+ <div className="kol-feature-split-meta flex flex-wrap gap-y-7 gap-x-12 pt-4">
62
+ {meta.map((m) => (
63
+ <div key={m.label} className="flex flex-col gap-0.5">
64
+ <span className="kol-feature-split-meta-num">{m.num}</span>
65
+ <span className="kol-feature-split-meta-label">{m.label}</span>
66
+ </div>
67
+ ))}
68
+ </div>
69
+ )}
70
+ {ctas && <div className="flex flex-wrap gap-4 pt-2">{ctas}</div>}
71
+ </div>
72
+ {media && (
73
+ <div className="kol-feature-split-visual relative aspect-[4/5] rounded-[4px] overflow-hidden">
74
+ {media}
75
+ {caption && <div className="kol-feature-split-visual-veil" aria-hidden="true" />}
76
+ {caption && <span className="kol-feature-split-visual-caption">{caption}</span>}
77
+ </div>
78
+ )}
79
+ </div>
80
+ </section>
81
+ )
82
+ }