@kolkrabbi/kol-component 0.144.0 → 0.146.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.144.0",
3
+ "version": "0.146.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",
@@ -10,8 +10,10 @@ import ContentText from './ContentText.jsx'
10
10
  * thumb; article rides a 120px 16:9 thumb; work a framed 64px row; typeface a
11
11
  * framed no-thumb block.
12
12
  *
13
- * @param {string} variant file | catalog | print | article | work | typeface
14
- * (`default` is an alias of `file`)
13
+ * @param {string} variant file | catalog | print | article | work | typeface | roster
14
+ * (`default` is an alias of `file`). `roster` is the pickable
15
+ * row: filled tile, no border or divider, a FIXED 56px height the
16
+ * content fills, a 40px square thumb and two truncated lines.
15
17
  * @param {ReactNode} media thumb content (omit → placeholder)
16
18
  * @param {number|'fill'} thumb thumb edge px — overrides the ruled default; 0 hides; `'fill'` = a square
17
19
  * the height of the row's content, whatever the rung (WorkListingRowsAndFilters)
@@ -61,6 +63,23 @@ const BOX = {
61
63
  * transparent → fg-08 on hover ("0 → 16 is a big jump"). No md step: the rows
62
64
  * were approved at their base values. */
63
65
  showcase: { thumb: 'fill', ratio: '1 / 1', pad: S4, gap: S4, frame: 'transparent', frameHover: 'var(--kol-fg-08)', bg: 'var(--kol-surface-secondary)', minH: 168, align: 'items-stretch', thumbRadius: 'var(--kol-radius-xs)', thumbBorder: false },
66
+ /* ROSTER — a PICKABLE row (ContentRowRosterVariant, kol-chess 2026-08-31):
67
+ * filled tile, no border, no divider, fixed height, square thumb, two
68
+ * truncated lines that FILL the row rather than setting it.
69
+ *
70
+ * `file` is the nearest part and a different object: a bare ruled line with a
71
+ * 48px thumb, a divider, and no hover wash by the 2026-08-29 ruling — right
72
+ * for a file listing, wrong for a grid of things you choose between. These
73
+ * are targets, so they take a hover.
74
+ *
75
+ * THE FIXED HEIGHT IS THE POINT. Every other row in this family follows its
76
+ * content, and on a pick grid that reads as broken: the filer measured 34 →
77
+ * 40 → 50 → 58 across four passes, and ONE long meta line was enough to push
78
+ * a tile out of line with its neighbours. `height`, not `minH` — the content
79
+ * fills the row and cannot move it. */
80
+ roster: { thumb: 40, ratio: '1 / 1', pad: S2, gap: S2, height: 56, align: 'items-stretch',
81
+ bg: 'var(--kol-surface-secondary)', hover: 'var(--kol-fg-04)',
82
+ thumbBg: 'var(--kol-fg-04)', thumbBorder: false },
64
83
  /* typeface's row is a COLUMN, not a line: a header (name/styles left,
65
84
  * classification/year right) with a full-width specimen band under it. The
66
85
  * shipped item is `flex-col gap-6`, and forcing it into the horizontal
@@ -148,6 +167,10 @@ export default function ContentRow({
148
167
  * taller row carried `--kol-row-min-h: 224px !important`. */
149
168
  '--kol-row-min-h': minHeight != null ? `${minHeight}px` : box.minH != null ? `${box.minH}px` : undefined,
150
169
  '--kol-row-min-h-md': box.minHMd != null ? `${box.minHMd}px` : undefined,
170
+ /* A FIXED rung, not a floor — `roster` only. `minHeight` still overrides it,
171
+ * so a consumer that wants a taller pick row gets one; what it cannot get is
172
+ * a row whose height drifts with its own copy. */
173
+ '--kol-row-h': box.height != null ? `${minHeight ?? box.height}px` : undefined,
151
174
  /* `fill` = the rung minus the vertical padding — a definite number, not a
152
175
  * stretch (a min-height row has no definite cross size, so a stretched
153
176
  * aspect-ratio square resolved to the image's intrinsic width).
@@ -211,7 +234,11 @@ export default function ContentRow({
211
234
  </ContentMedia>
212
235
  </div>
213
236
  )}
214
- <ContentText variant={variant} form="row" className="flex-1" {...text} />
237
+ {/* THE 2px IS DELIBERATE and the one off-grid value in `roster` (the filer
238
+ * ruled it on screen): the two lines stack to 34 inside a 40 content box,
239
+ * and pushing them fully apart puts the ascenders hard against the thumb's
240
+ * top and bottom edges. It is the ROW's ruling, so the row passes it. */}
241
+ <ContentText variant={variant} form="row" className={`flex-1 ${box.height != null ? 'py-[2px]' : ''}`.trim()} {...text} />
215
242
  {/* `specs` rides the trailing edge on EVERY variant — year · material ·
216
243
  * edition is a content difference, not a geometry one, and minting a
217
244
  * seventh page-named box for it is the exact mistake §1 of the ticket is
@@ -234,7 +261,7 @@ export default function ContentRow({
234
261
  )
235
262
 
236
263
  const common = {
237
- className: `kol-row group flex ${box.column ? 'flex-col' : ''} ${box.align} ${box.divider ? 'kol-row--divided' : ''} ${box.frame ? 'rounded-[var(--kol-radius-sm)] border' : ''} ${interactive ? 'cursor-pointer select-none' : ''} ${interactive && box.hover ? 'kol-content-hover' : ''} ${interactive && box.frameHover ? 'kol-content-hover-frame' : ''} ${className}`.trim(),
264
+ className: `kol-row group flex ${box.column ? 'flex-col' : ''} ${box.align} ${box.height != null ? 'kol-row--fixed' : ''} ${box.divider ? 'kol-row--divided' : ''} ${box.frame ? 'rounded-[var(--kol-radius-sm)] border' : ''} ${interactive ? 'cursor-pointer select-none' : ''} ${interactive && box.hover ? 'kol-content-hover' : ''} ${interactive && box.frameHover ? 'kol-content-hover-frame' : ''} ${className}`.trim(),
238
265
  style: vars,
239
266
  }
240
267
 
@@ -91,6 +91,15 @@ const RAMP = {
91
91
  * ink, the year kol-mono-12 fg-64. Body unchanged (the site passes its face). */
92
92
  row: { title: 'kol-mono-14 uppercase text-emphasis truncate', body: 'kol-sans-heading-03 leading-tight text-emphasis truncate', meta: 'kol-mono-14 text-emphasis', date: 'kol-mono-12 text-fg-64', tags: 'flex flex-wrap items-center gap-1.5' },
93
93
  },
94
+ /* ROSTER — a pickable row's two lines, both TRUNCATED (ContentRowRosterVariant,
95
+ * kol-chess 2026-08-31). Written out rather than derived from `showcase`: the
96
+ * name-keyed maps below (FILL, GAPS) cannot be reached by a spread, and a
97
+ * derive that misses one of them is the exact trap `showcaseCanvas` fell into
98
+ * twice. Two literals beat a derive. Row form only — kol-chess wants no card,
99
+ * and a card nobody asked for is a variant to keep in step for nothing. */
100
+ roster: {
101
+ row: { title: 'kol-mono-14 text-fg-96 truncate', meta: 'kol-mono-12 text-fg-48 truncate' },
102
+ },
94
103
  showcaseCanvas: {
95
104
  /* RULED ON SCREEN (TypefaceCardAndRow, kol-website 2026-08-27): name and
96
105
  * classification are FULL ink, the year steps to 64; the card's title is the
@@ -143,6 +152,8 @@ const ORDER = {
143
152
  * item, which had been flattened into title-left / date-right and lost the
144
153
  * styles line into a body slot below. */
145
154
  showcaseCanvas: { card: ['title', 'body'], row: [['between', ['stack', 'title', 'body'], ['stack', 'detail', 'date']]] },
155
+ /* two lines, pushed apart by the column's own justify-between (FILL) */
156
+ roster: { row: ['title', 'meta'] },
146
157
  }
147
158
 
148
159
  /* inner line gap per variant/form, read from the shipped boxes and spelled in
@@ -158,7 +169,7 @@ const ORDER = {
158
169
  * `self-stretch`, NOT `h-full` — the row carries `min-height`, never `height`,
159
170
  * so `height: 100%` resolves against an indefinite parent, computes to auto,
160
171
  * and shrink-wraps the column. Which is exactly what it did. */
161
- const FILL = { showcase: true }
172
+ const FILL = { showcase: true, roster: true }
162
173
 
163
174
  /* gap INSIDE a ['stack', …] block. Defaults to the tight 4px pair article
164
175
  * wants; work's header stack is the shipped gap-1 md:gap-2. */
@@ -173,6 +184,9 @@ const GAPS = {
173
184
  article: { card: 'var(--kol-spacing-3)', row: 'var(--kol-spacing-3)', hero: 'var(--kol-spacing-3)' },
174
185
  showcase: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-4)' },
175
186
  showcaseCanvas: { card: 'var(--kol-spacing-2)', row: 'var(--kol-spacing-6)' },
187
+ /* roster's lines are pushed apart by justify-between, so this is only a
188
+ * floor for the case where the row is given more height than 56. */
189
+ roster: { row: 'var(--kol-spacing-1)' },
176
190
  }
177
191
 
178
192
  /* `default` → `file` (user 2026-08-29) — kept working as an alias here too,
@@ -61,7 +61,18 @@ export default function SectionCardItem({
61
61
  '16/9': 'aspect-video',
62
62
  '1/1': 'aspect-square',
63
63
  }
64
- const aspectClass = aspectClasses[imageAspectRatio] || ''
64
+ /* A MEDIA BOX MAY NOT RESOLVE TO ZERO HEIGHT (CardFeatureVisualCollapses,
65
+ * kol-website 2026-08-31). With no ratio the box was `flex-1` and nothing
66
+ * else — `flex: 1 1 0%`, basis ZERO, so its own content contributed nothing
67
+ * and its height was donated entirely by the parent. Where no ancestor
68
+ * supplies a definite height it resolves to 0 and the card silently drops to
69
+ * title + subtitle: no broken image, no failed request, just a short card and
70
+ * a reader who never learns a visual was meant to be there. Reported from a
71
+ * real iPhone; not reproducible on this machine in any of the three engines,
72
+ * which is exactly what a donated-height collapse looks like.
73
+ * 3/2 is the geometry those cards already render at (316 wide → 211 tall at
74
+ * 390), so nothing moves where it currently works. */
75
+ const aspectClass = aspectClasses[imageAspectRatio] || 'aspect-[3/2]'
65
76
 
66
77
  const content = textOnly ? (
67
78
  <>
@@ -81,7 +92,7 @@ export default function SectionCardItem({
81
92
  {/* kol-card-feature-visual: zooms 1.03 on card hover (chrome in
82
93
  * kol-theme — CardFeatureHoverZoom 2026-08-12); all three visual
83
94
  * forms ride the same wrapper, reduced-motion opts out. */}
84
- <div className={`kol-card-feature-visual w-full flex-1 flex items-center justify-center overflow-hidden ${aspectClass}`.trim()}>
95
+ <div className={`kol-card-feature-visual w-full flex-auto flex items-center justify-center overflow-hidden ${aspectClass}`.trim()}>
85
96
  {visual ? (
86
97
  typeof visual === 'string' ? (
87
98
  isSvgUrl ? (
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import { motion } from 'framer-motion'
3
3
  import HlsVideo from '../atoms/HlsVideo.jsx'
4
4
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
@@ -31,6 +31,37 @@ function useCoarsePointer() {
31
31
  return coarse
32
32
  }
33
33
 
34
+ /* WHICH CARD HAS ATTENTION WHEN THERE IS NO HOVER (TiltBentoCoarseRevealInView,
35
+ * kol-website 2026-08-31). Coarse devices used to render every card fully open —
36
+ * title, subtitle, description and CTA stacked over the artwork at once — which
37
+ * on a column of full-width cards means the text permanently competes with the
38
+ * image it sits on, on the device where the image is largest relative to it.
39
+ *
40
+ * An IntersectionObserver whose root is squeezed to the viewport's middle band
41
+ * (-45% top and bottom) intersects ONLY the element crossing the centre line, so
42
+ * at most one full-width card in a column is ever active. That is the same "one
43
+ * open at a time" a mouse gets from hover, with no cross-card coordination and
44
+ * no shared store — each card answers for itself.
45
+ *
46
+ * The component's vocabulary does not change: the title is always visible, the
47
+ * scrim and the rest reveal on attention. Only the definition of attention
48
+ * differs per input type. */
49
+ function useInViewCentre(enabled) {
50
+ const ref = useRef(null)
51
+ const [inView, setInView] = useState(false)
52
+ useEffect(() => {
53
+ const el = ref.current
54
+ if (!enabled || !el || typeof IntersectionObserver === 'undefined') return
55
+ const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
56
+ rootMargin: '-45% 0px -45% 0px',
57
+ threshold: 0,
58
+ })
59
+ io.observe(el)
60
+ return () => io.disconnect()
61
+ }, [enabled])
62
+ return [ref, enabled && inView]
63
+ }
64
+
34
65
  /**
35
66
  * Media — internal, NOT exported. Sniffs `src` by extension and renders the
36
67
  * right full-bleed media element: `.m3u8` → HlsVideo, `.mov|.mp4|.webm` →
@@ -86,6 +117,10 @@ function Media({ src, poster, className }) {
86
117
  * @param {string} src media source; type auto-detected by extension
87
118
  * @param {string} poster poster frame for HLS/video
88
119
  * @param {ReactNode} title always-visible heading
120
+ * @param {'in-view'|'static'} [coarseReveal='in-view'] what counts as attention on a device
121
+ * with no hover. `in-view` reveals only the card crossing the viewport's centre line and leaves
122
+ * the others at title-only; `static` is the pre-2026-08-31 behaviour — every card fully open —
123
+ * which a wall of small bento tiles may still want. The fine-pointer hover path never changes.
89
124
  * @param {ReactNode} subtitle hover-revealed line
90
125
  * @param {ReactNode} description hover-revealed paragraph
91
126
  * @param {string} href CTA target; `http*`/`mailto` → new-tab anchor, else same-tab (onNavigate seam)
@@ -118,6 +153,7 @@ export default function TiltBento({
118
153
  overlayOpacity = 60,
119
154
  alignRight = false,
120
155
  enableTilt = true,
156
+ coarseReveal = 'in-view',
121
157
  titleClassName = 'kol-sans-heading-01 text-ab-white',
122
158
  contentClassName = 'max-w-[384px]',
123
159
  imageClassName = 'object-cover object-center',
@@ -128,6 +164,10 @@ export default function TiltBento({
128
164
  const reduced = usePrefersReducedMotion()
129
165
  const coarse = useCoarsePointer()
130
166
  const tilt = useTilt()
167
+ const [viewRef, centred] = useInViewCentre(coarse && coarseReveal === 'in-view')
168
+ /* On a coarse pointer the card is "open" when it holds the centre; under
169
+ * `static` it is always open, which is what shipped before. */
170
+ const coarseOpen = coarseReveal === 'static' || centred
131
171
 
132
172
  const tiltOff = !enableTilt || reduced || coarse
133
173
  const Component = tiltOff ? 'div' : motion.div
@@ -140,11 +180,15 @@ export default function TiltBento({
140
180
  ? {}
141
181
  : { ref: tilt.ref, onMouseMove: tilt.onMouseMove, onMouseLeave: tilt.onMouseLeave }
142
182
 
143
- // Reveal choreography. Coarse (no-hover) devices show everything statically;
144
- // fine pointers reveal on group-hover. The opacity transition is motion, so
145
- // reduced-motion drops it (content still reveals, just without the fade).
183
+ // Reveal choreography. Fine pointers reveal on group-hover; coarse pointers
184
+ // reveal the centred card (or every card under `coarseReveal="static"`). The
185
+ // opacity transition is motion, so reduced-motion drops it (content still
186
+ // reveals, just without the fade).
146
187
  const fade = reduced ? '' : 'transition-opacity duration-300'
147
- const revealClass = `${coarse ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} ${fade}`.trim()
188
+ const openClass = coarse
189
+ ? (coarseOpen ? 'opacity-100' : 'opacity-0')
190
+ : 'opacity-0 group-hover:opacity-100'
191
+ const revealClass = `${openClass} ${fade}`.trim()
148
192
 
149
193
  const mediaClass =
150
194
  `absolute left-0 top-0 size-full rounded overflow-hidden ${imageClassName} ${coarse ? 'pointer-events-none' : ''}`.trim()
@@ -154,7 +198,7 @@ export default function TiltBento({
154
198
  return (
155
199
  <Component
156
200
  className={`relative group ${alignRight ? 'ms-auto' : 'size-full'} ${className}`.trim()}
157
- {...tiltHandlers}
201
+ {...(coarse ? { ref: viewRef } : tiltHandlers)}
158
202
  {...rest}
159
203
  style={{ ...rootStyle, ...rest.style }}
160
204
  >
@@ -164,7 +208,7 @@ export default function TiltBento({
164
208
  <div className={`relative z-10 ${contentClassName} w-full h-full self-stretch`}>
165
209
  {overlayOpacity > 0 && (
166
210
  <div
167
- className={`absolute -inset-1 rounded ${coarse ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'} ${fade} pointer-events-none`.trim()}
211
+ className={`absolute -inset-1 rounded ${coarse ? (coarseOpen ? 'opacity-60' : 'opacity-0') : 'opacity-0 group-hover:opacity-100'} ${fade} pointer-events-none`.trim()}
168
212
  style={{ backgroundColor: `rgba(0, 0, 0, ${overlayOpacity / 100})` }}
169
213
  />
170
214
  )}
@@ -29,8 +29,11 @@ import { minHeightClass } from './sectionHeights.js'
29
29
  * error ids are useId-generated (or `inputId`) so multiple sections mount
30
30
  * without collisions.
31
31
  *
32
- * @param {'full'|'80'|'60'|'40'|string} [height='60'] min-height on the family's ladder — full = 100dvh,
33
- * 80 = 70svh / 80vh, 60 = 50svh / 60vh (default), 40 = 35svh / 40vh; content stays vertically centred inside it
32
+ * @param {'full'|'80'|'60'|'40'|string} [height='40'] min-height on the family's ladder — full = 100dvh,
33
+ * 80 = 70svh / 80vh, 60 = 50svh / 60vh, 40 = 35svh / 40vh (default); content stays vertically centred inside it.
34
+ * DEFAULT DROPPED 60 → 40 (SectionNewsletterMobileMeasure, kol-website 2026-08-31): at rung 60 the band
35
+ * reserved 422px around 308px of content on an 844-tall phone — ~114px of empty grey to scroll past. The
36
+ * LADDER is untouched; every other section still wants its rung. Pass `height="60"` to keep the old air.
34
37
  * @param {'inverse'|'light'|'dark'} theme the paired theme of whatever is live, or a pinned one — stamped on the section
35
38
  * @param {ReactNode} eyebrow eyebrow above the headline (uppercase by role); `label` is its alias
36
39
  * @param {ReactNode} headline heading (display-01 by default; `headlineSize` picks another role)
@@ -44,12 +47,17 @@ import { minHeightClass } from './sectionHeights.js'
44
47
  * @param {string} id anchor id on the section (e.g. "signup")
45
48
  * @param {string} inputId id override for the email input (default useId-generated)
46
49
  * @param {object} slotClass · slotStyle per-slot class / style on the SectionText (reveal seam)
50
+ * @param {'sm'|'md'|'lg'} [controlSize='md'] size rung for BOTH the email Input and the submit
51
+ * Button (SectionNewsletterControlSize, kol-website 2026-08-31). The pair was hardcoded md with no
52
+ * seam, so a page that sets `size="lg"` on every other call-site button could not match it here and
53
+ * the newsletter read visibly smaller directly beneath them. Default is today's md — nothing moves.
47
54
  * @param {string} className extra classes on the section
48
55
  * @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
49
56
  * (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
50
57
  */
51
58
  export default function SectionNewsletter({
52
- height = '60',
59
+ height = '40',
60
+ controlSize = 'md',
53
61
  theme,
54
62
  background,
55
63
  eyebrow,
@@ -103,7 +111,14 @@ export default function SectionNewsletter({
103
111
  id={id}
104
112
  ref={themeRef}
105
113
  data-theme={themeStamp}
106
- className={`kol-section-newsletter w-full flex flex-col justify-center py-24 ${surfaceClass(background, theme ? 'primary' : 'none')} ${theme ? 'text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
114
+ /* THE FORM NEEDS AN INSET FLOOR (SectionNewsletterMobileMeasure, 2026-08-31).
115
+ * Desktop's 80px is not padding — it is the leftover of the inner measure
116
+ * (1184 band − 1024 max-w, halved), so it SCALES TO ZERO rather than down:
117
+ * at 390 the field and submit ran edge to edge against the band's own
118
+ * boundary and read as breaking out of it. `px-5` is a floor the band owns.
119
+ * Desktop does not move — the measure caps below the padded width, so the
120
+ * inner block still centres at 80px from the band edge. */
121
+ className={`kol-section-newsletter w-full flex flex-col justify-center px-5 sm:px-8 py-24 ${surfaceClass(background, theme ? 'primary' : 'none')} ${theme ? 'text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
107
122
  >
108
123
  {/* the family's ONE cap — the shell's --kol-container-max ladder — and
109
124
  * inside it the lede's MEASURE on a wrapper (SectionNewsletterForm,
@@ -134,7 +149,7 @@ export default function SectionNewsletter({
134
149
  placeholder={placeholder}
135
150
  value={email}
136
151
  onChange={(e) => setEmail(e.target.value)}
137
- size="md"
152
+ size={controlSize}
138
153
  aria-required="true"
139
154
  aria-describedby={status === 'error' ? errorId : undefined}
140
155
  className="w-full sm:max-w-[400px] md:max-w-[520px]"
@@ -142,6 +157,7 @@ export default function SectionNewsletter({
142
157
  <Button
143
158
  type="submit"
144
159
  variant="primary"
160
+ size={controlSize}
145
161
  disabled={status === 'submitting'}
146
162
  className="w-full sm:w-auto"
147
163
  >
@@ -135,7 +135,18 @@ export default function SectionSplit({
135
135
  * caps at the column — the media takes its size from the section,
136
136
  * never gives it */
137
137
  <div
138
- className={`kol-section-split-visual relative w-auto max-w-full justify-self-center rounded-[var(--kol-radius-sm)] ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
138
+ /* THE MEDIA FILLS ITS COLUMN (SectionSplitVisualWidth, kol-website
139
+ * 2026-08-31). It was `w-auto`, so the width was derived from the
140
+ * image's aspect against whatever height the box was given — tall
141
+ * enough and it clamped to max-w-full and filled; short and it
142
+ * resolved NARROWER than the column and `justify-self-center` then
143
+ * centred it, so the media sat visibly inset while the copy beneath
144
+ * stayed at the page gutter. It is VIEWPORT HEIGHT that decides:
145
+ * 390×844 passes, 390×700 renders at left 56 · width 278. A real
146
+ * phone with browser chrome sits in the 660–720 band, which is why
147
+ * this was reported from a device four times and never reproduced
148
+ * against a nominal 844-tall test. `max-w-full` alone only caps. */
149
+ className={`kol-section-split-visual relative w-full max-w-full justify-self-center rounded-[var(--kol-radius-sm)] ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
139
150
  style={{ aspectRatio: ratio, height: 'calc(var(--kol-section-h, 60vh) - 2 * var(--kol-section-py, 4rem))' }}
140
151
  >
141
152
  {media}