@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 +1 -1
- package/src/molecules/ContentRow.jsx +31 -4
- package/src/molecules/ContentText.jsx +15 -1
- package/src/molecules/SectionCardItem.jsx +13 -2
- package/src/molecules/TiltBento.jsx +51 -7
- package/src/organisms/SectionNewsletter.jsx +21 -5
- package/src/organisms/SectionSplit.jsx +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
-
|
|
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-
|
|
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.
|
|
144
|
-
//
|
|
145
|
-
// reduced-motion drops it (content still
|
|
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
|
|
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='
|
|
33
|
-
* 80 = 70svh / 80vh, 60 = 50svh / 60vh
|
|
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 = '
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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}
|