@kolkrabbi/kol-component 0.148.0 → 0.150.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/hooks/useInViewAttention.js +54 -0
- package/src/index.js +2 -1
- package/src/molecules/ContentRow.jsx +14 -2
- package/src/molecules/ContentText.jsx +30 -2
- package/src/molecules/SearchInput.jsx +3 -1
- package/src/molecules/SectionCardItem.jsx +18 -1
- package/src/molecules/ShellDrawer.jsx +7 -2
- package/src/molecules/TiltBento.jsx +5 -32
- package/src/organisms/ContentCollection.jsx +13 -3
- package/src/organisms/ContentFilters.jsx +9 -5
- package/src/organisms/SectionCards.jsx +11 -1
- package/src/organisms/SectionCta.jsx +10 -2
- package/src/organisms/SectionFaq.jsx +9 -1
- package/src/organisms/SectionHero.jsx +5 -4
- package/src/organisms/SectionNewsletter.jsx +9 -2
- package/src/organisms/SectionSplit.jsx +14 -3
- package/src/organisms/ShellSearchOverlay.jsx +8 -2
- package/src/organisms/sectionBleed.js +27 -0
- /package/src/{utilities → hooks}/useGrabEdge.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.150.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",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useInViewAttention — which card has the reader's attention when there is no
|
|
5
|
+
* hover to give it. Returns `[ref, active]`; put the ref on the card's root.
|
|
6
|
+
*
|
|
7
|
+
* ── WHY THIS IS A SHARED HOOK (CardSetInViewAttention, kol-website 2026-08-31)
|
|
8
|
+
* Every card in the set expresses attention as `:hover`, and a touch device
|
|
9
|
+
* cannot hold hover — the cards are anchors, so a tap navigates rather than
|
|
10
|
+
* dwelling. On a phone the whole hover vocabulary is dead: zooms never fire,
|
|
11
|
+
* borders never step, media never scales.
|
|
12
|
+
*
|
|
13
|
+
* `TiltBento` solved that for ONE component in 0.145.0 with its own observer.
|
|
14
|
+
* The second component to need it could not have it, so kol-website ended up
|
|
15
|
+
* carrying `useMobileActiveCard.js` — the same observer, the same root margin —
|
|
16
|
+
* to put `.is-viewing` on `.kol-card-feature`. A DS behaviour living in a
|
|
17
|
+
* consumer, and the third consumer would have written it again. Two components
|
|
18
|
+
* solving one thing two ways in one evening is the cost, not the behaviour.
|
|
19
|
+
*
|
|
20
|
+
* ── THE MECHANISM
|
|
21
|
+
* An IntersectionObserver whose root is squeezed to the viewport's middle band
|
|
22
|
+
* intersects only the element crossing the centre line. At most one full-width
|
|
23
|
+
* card in a column is ever active — the same "one at a time" hover gives a
|
|
24
|
+
* mouse — with NO cross-card coordination and no shared store: each card
|
|
25
|
+
* answers for itself. A "most-visible card wins" rule would need the cards to
|
|
26
|
+
* know about each other, which none of them has a way to arrange.
|
|
27
|
+
*
|
|
28
|
+
* `-45%` top and bottom is TiltBento's measured value, carried unchanged rather
|
|
29
|
+
* than re-derived. A wall of small tiles wants none of this — that is what the
|
|
30
|
+
* `enabled` flag is for, and every consuming component exposes it as an escape.
|
|
31
|
+
*
|
|
32
|
+
* @param {boolean} enabled usually `coarse && mode !== 'static'`
|
|
33
|
+
* @param {string} band root margin; the default is the ruled centre band
|
|
34
|
+
* @returns {[React.RefObject, boolean]}
|
|
35
|
+
*/
|
|
36
|
+
export default function useInViewAttention(enabled, band = '-45% 0px -45% 0px') {
|
|
37
|
+
const ref = useRef(null)
|
|
38
|
+
const [active, setActive] = useState(false)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const el = ref.current
|
|
42
|
+
if (!enabled || !el || typeof IntersectionObserver === 'undefined') return undefined
|
|
43
|
+
const io = new IntersectionObserver(([entry]) => setActive(entry.isIntersecting), {
|
|
44
|
+
rootMargin: band,
|
|
45
|
+
threshold: 0,
|
|
46
|
+
})
|
|
47
|
+
io.observe(el)
|
|
48
|
+
return () => io.disconnect()
|
|
49
|
+
}, [enabled, band])
|
|
50
|
+
|
|
51
|
+
/* never leaks a stale `true` when the flag goes off (a pointer change, or a
|
|
52
|
+
* consumer switching the escape on) */
|
|
53
|
+
return [ref, enabled && active]
|
|
54
|
+
}
|
package/src/index.js
CHANGED
|
@@ -154,7 +154,7 @@ export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegmen
|
|
|
154
154
|
export { nearestRatio, RATIOS } from './utilities/ratios.js'
|
|
155
155
|
/* the grab gesture — shared so kol-shell's NavRail and kol-framework's
|
|
156
156
|
* useDragResize wear ONE implementation (OneGrabGestureBothRails, 2026-08-30) */
|
|
157
|
-
export { default as useGrabEdge } from './
|
|
157
|
+
export { default as useGrabEdge } from './hooks/useGrabEdge.js'
|
|
158
158
|
export { default as markdownToHtml, inlineToHtml } from './utilities/markdownToHtml.js'
|
|
159
159
|
export { default as RecordManager } from './organisms/RecordManager.jsx'
|
|
160
160
|
export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
|
|
@@ -182,6 +182,7 @@ export { default as useScrollSpy } from './hooks/useScrollSpy.js'
|
|
|
182
182
|
export { default as useTilt } from './hooks/useTilt.js'
|
|
183
183
|
export { default as usePlayback } from './hooks/usePlayback.js'
|
|
184
184
|
export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
|
|
185
|
+
export { default as useInViewAttention } from './hooks/useInViewAttention.js'
|
|
185
186
|
export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
|
|
186
187
|
export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
187
188
|
export { default as usePlaceholders } from './hooks/usePlaceholders.js'
|
|
@@ -62,7 +62,16 @@ const BOX = {
|
|
|
62
62
|
* the content height (136 in a 168 row) with no hairline, the frame steps
|
|
63
63
|
* transparent → fg-08 on hover ("0 → 16 is a big jump"). No md step: the rows
|
|
64
64
|
* were approved at their base values. */
|
|
65
|
-
|
|
65
|
+
/* `heightSm` — a FIXED rung below the md container, the floor above it
|
|
66
|
+
* (ContentRowShowcaseImageDrivenHeight, kol-website 2026-09-01, user ruling:
|
|
67
|
+
* the row's height is a function of the image, content fits inside it). On a
|
|
68
|
+
* 390 phone the narrow text column wrapped five tags to four rows and grew
|
|
69
|
+
* the row 62px past its floor, stranding the 136px thumb in dead space —
|
|
70
|
+
* 60px of ragged heights down one list. 168 is the floor's own number
|
|
71
|
+
* (136 thumb + 2×16 pad), so this is a change of KIND, not of value; the
|
|
72
|
+
* text has 136 to live in and the tags line goes single-row below md
|
|
73
|
+
* (ContentText's showcase ramp) so the cut lands on the chips, not mid-row. */
|
|
74
|
+
showcase: { thumb: 'fill', ratio: '1 / 1', pad: S4, gap: S4, frame: 'transparent', frameHover: 'var(--kol-fg-08)', bg: 'var(--kol-surface-secondary)', minH: 168, heightSm: 168, align: 'items-stretch', thumbRadius: 'var(--kol-radius-xs)', thumbBorder: false },
|
|
66
75
|
/* ROSTER — a PICKABLE row (ContentRowRosterVariant, kol-chess 2026-08-31):
|
|
67
76
|
* filled tile, no border, no divider, fixed height, square thumb, two
|
|
68
77
|
* truncated lines that FILL the row rather than setting it.
|
|
@@ -171,6 +180,9 @@ export default function ContentRow({
|
|
|
171
180
|
* so a consumer that wants a taller pick row gets one; what it cannot get is
|
|
172
181
|
* a row whose height drifts with its own copy. */
|
|
173
182
|
'--kol-row-h': box.height != null ? `${minHeight ?? box.height}px` : undefined,
|
|
183
|
+
/* the stacked case's fixed rung — `heightSm` variants only; `minHeight`
|
|
184
|
+
* still overrides, same contract as `height` above */
|
|
185
|
+
'--kol-row-h-sm': box.heightSm != null ? `${minHeight ?? box.heightSm}px` : undefined,
|
|
174
186
|
/* `fill` = the rung minus the vertical padding — a definite number, not a
|
|
175
187
|
* stretch (a min-height row has no definite cross size, so a stretched
|
|
176
188
|
* aspect-ratio square resolved to the image's intrinsic width).
|
|
@@ -261,7 +273,7 @@ export default function ContentRow({
|
|
|
261
273
|
)
|
|
262
274
|
|
|
263
275
|
const common = {
|
|
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(),
|
|
276
|
+
className: `kol-row group flex ${box.column ? 'flex-col' : ''} ${box.align} ${box.height != null ? 'kol-row--fixed' : ''} ${box.heightSm != null ? 'kol-row--fixed-sm' : ''} ${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(),
|
|
265
277
|
style: vars,
|
|
266
278
|
}
|
|
267
279
|
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* dateClass, sizeClass, metaClass — full class overrides
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import Tag from '../atoms/Tag'
|
|
32
|
+
|
|
31
33
|
/* THE ROW INK LADDER — three rungs, and only three (2026-08-15).
|
|
32
34
|
*
|
|
33
35
|
* A work row was rendering its small title, its big description AND its type
|
|
@@ -89,7 +91,11 @@ const RAMP = {
|
|
|
89
91
|
/* RULED ON /work beside the local WorkListItem (WorkListingRowsAndFilters,
|
|
90
92
|
* 2026-08-27): the typeface row's voices — title + type kol-mono-14 full
|
|
91
93
|
* ink, the year kol-mono-12 fg-64. Body unchanged (the site passes its face). */
|
|
92
|
-
|
|
94
|
+
/* tags go SINGLE-ROW below md (ContentRowShowcaseImageDrivenHeight,
|
|
95
|
+
* 2026-09-01): the row wears a fixed rung there, and a wrapping tag block
|
|
96
|
+
* was what grew it past the floor — the cut lands on the chips' right
|
|
97
|
+
* edge, not mid-row. */
|
|
98
|
+
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 max-md:flex-nowrap max-md:overflow-hidden' },
|
|
93
99
|
},
|
|
94
100
|
/* ROSTER — a pickable row's two lines, both TRUNCATED (ContentRowRosterVariant,
|
|
95
101
|
* kol-chess 2026-08-31). Written out rather than derived from `showcase`: the
|
|
@@ -222,9 +228,31 @@ export default function ContentText({
|
|
|
222
228
|
* it overrode the class (user 2026-08-27: "I had put A HOVER OPACITY drop,
|
|
223
229
|
* WHERE IS IT"). The hook is re-attached after the override. */
|
|
224
230
|
const hook = (slot) => (overrides[slot] && /\bkol-content-title-dim\b/.test(ramp[slot] ?? '') ? ' kol-content-title-dim' : '')
|
|
231
|
+
|
|
232
|
+
/* An ARRAY-valued slot never renders as concatenated text
|
|
233
|
+
* (ContentTextTagsSlotRendersRawArray, 2026-09-01). React writes an array of
|
|
234
|
+
* strings as adjacent text nodes, and CSS folds contiguous text into ONE
|
|
235
|
+
* anonymous flex item — so the tags ramp's `gap-2` spaced nothing and the
|
|
236
|
+
* run-on string inherited the section's font. Strings in `tags` become the
|
|
237
|
+
* chip the estate's correct tag rows already wear (the tertiary Tag);
|
|
238
|
+
* elements a consumer pre-built pass through untouched. Any other slot's
|
|
239
|
+
* array (kol-website passes `meta={[date, readingTime]}`) gets one span per
|
|
240
|
+
* item plus the same flex seam, so the values separate instead of running on. */
|
|
241
|
+
const content = (slot) => {
|
|
242
|
+
const v = values[slot]
|
|
243
|
+
if (!Array.isArray(v)) return v
|
|
244
|
+
if (slot === 'tags')
|
|
245
|
+
return v.map((t, i) =>
|
|
246
|
+
typeof t === 'string' || typeof t === 'number' ? (
|
|
247
|
+
<Tag key={i} variant="tertiary" size="sm">{t}</Tag>
|
|
248
|
+
) : t
|
|
249
|
+
)
|
|
250
|
+
return v.map((item, i) => <span key={i}>{item}</span>)
|
|
251
|
+
}
|
|
252
|
+
const spread = (slot) => (Array.isArray(values[slot]) && slot !== 'tags' ? ' flex flex-wrap gap-2' : '')
|
|
225
253
|
const line = (slot) =>
|
|
226
254
|
values[slot] == null ? null : (
|
|
227
|
-
<div key={slot} className={`${overrides[slot] ?? ramp[slot] ?? ''}${extra(slot)}${hook(slot)}`.trim()}>{
|
|
255
|
+
<div key={slot} className={`${overrides[slot] ?? ramp[slot] ?? ''}${extra(slot)}${hook(slot)}${spread(slot)}`.trim()}>{content(slot)}</div>
|
|
228
256
|
)
|
|
229
257
|
|
|
230
258
|
/* RECURSIVE (2026-08-15) — an entry inside a line/between/group may itself be
|
|
@@ -198,7 +198,9 @@ export default function SearchInput({
|
|
|
198
198
|
}
|
|
199
199
|
const shellCls = [
|
|
200
200
|
bare
|
|
201
|
-
|
|
201
|
+
/* kol-control--bare: zero-chrome marker so the theme's coarse-pointer
|
|
202
|
+
* 16px floor covers this body plan too (OverlaySearchFieldZoomsIOS) */
|
|
203
|
+
? 'kol-control--bare flex w-full gap-2.5 px-4 py-3'
|
|
202
204
|
: `kol-control kol-control--${variant} kol-control-${size} gap-2${toneClass(tone) ? ' kol-tone-sunken' : ''}`,
|
|
203
205
|
'items-center cursor-text',
|
|
204
206
|
SIZE_TYPE[size],
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
+
import useCoarsePointer from '../hooks/useCoarsePointer.js'
|
|
3
|
+
import useInViewAttention from '../hooks/useInViewAttention.js'
|
|
2
4
|
|
|
3
5
|
/* taxonomy-ok: nests only kol-icons's Icon (a package import the
|
|
4
6
|
* relative-import check can't see). */
|
|
@@ -32,6 +34,13 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
32
34
|
* @param {string} href link target; `http*`/`mailto` → new tab, else plain same-tab anchor
|
|
33
35
|
* @param {Function} onNavigate (event) => void — click seam on the same-tab anchor (SPA intercept)
|
|
34
36
|
* @param {'auto'|'9/6'|'10/6'|'16/9'|'1/1'} imageAspectRatio aspect class on the visual middle
|
|
37
|
+
* @param {'in-view'|'static'} [coarseReveal='in-view'] what counts as attention on a device with no
|
|
38
|
+
* hover (CardSetInViewAttention, kol-website 2026-08-31). A touch device cannot hold hover and the
|
|
39
|
+
* card is an anchor, so a tap navigates — the whole hover vocabulary was dead on a phone and the
|
|
40
|
+
* zoom never fired at all. `in-view` stamps `data-attention` on the card crossing the viewport's
|
|
41
|
+
* centre band, which the theme's hover rules ALSO key on, so hover and in-view resolve to ONE
|
|
42
|
+
* treatment rather than two parallel sets that drift. `static` opts out — a wall of small tiles
|
|
43
|
+
* does not want a tile lighting up as it passes the centre. Fine pointers never change.
|
|
35
44
|
* @param {number} zoom hover zoom scale for THIS card's visual (default 1.03, the shipped value).
|
|
36
45
|
* Per-feature because the right amount belongs to the artwork, not the component: 3% is correct on
|
|
37
46
|
* a dense photographic visual and invisible on sparse line-art, and one set can hold both
|
|
@@ -48,10 +57,14 @@ export default function SectionCardItem({
|
|
|
48
57
|
onNavigate,
|
|
49
58
|
imageAspectRatio = 'auto',
|
|
50
59
|
zoom,
|
|
60
|
+
coarseReveal = 'in-view',
|
|
51
61
|
imagePosition = 'center',
|
|
52
62
|
className = '',
|
|
53
63
|
style,
|
|
54
64
|
}) {
|
|
65
|
+
const coarse = useCoarsePointer()
|
|
66
|
+
const [viewRef, attention] = useInViewAttention(coarse && coarseReveal === 'in-view')
|
|
67
|
+
|
|
55
68
|
const isSvgUrl = typeof visual === 'string' && visual.endsWith('.svg')
|
|
56
69
|
/* TEXT-ONLY (FoundrySpecimenSections, 2026-08-27): no `visual` = a title +
|
|
57
70
|
* description tile — the frame and hover of kol-website's .feature-card, no
|
|
@@ -150,6 +163,8 @@ export default function SectionCardItem({
|
|
|
150
163
|
if (isExternal) {
|
|
151
164
|
return (
|
|
152
165
|
<a
|
|
166
|
+
ref={viewRef}
|
|
167
|
+
data-attention={attention || undefined}
|
|
153
168
|
href={href}
|
|
154
169
|
className={`${baseClasses} hover:border-fg-32 transition-colors duration-300`}
|
|
155
170
|
style={rootStyle}
|
|
@@ -163,6 +178,8 @@ export default function SectionCardItem({
|
|
|
163
178
|
|
|
164
179
|
return (
|
|
165
180
|
<a
|
|
181
|
+
ref={viewRef}
|
|
182
|
+
data-attention={attention || undefined}
|
|
166
183
|
href={href}
|
|
167
184
|
onClick={onNavigate}
|
|
168
185
|
className={`${baseClasses} hover:border-fg-24 transition-colors duration-300`}
|
|
@@ -173,5 +190,5 @@ export default function SectionCardItem({
|
|
|
173
190
|
)
|
|
174
191
|
}
|
|
175
192
|
|
|
176
|
-
return <div className={baseClasses} style={rootStyle}>{content}</div>
|
|
193
|
+
return <div ref={viewRef} data-attention={attention || undefined} className={baseClasses} style={rootStyle}>{content}</div>
|
|
177
194
|
}
|
|
@@ -141,10 +141,15 @@ export default function ShellDrawer({
|
|
|
141
141
|
return createPortal(
|
|
142
142
|
<>
|
|
143
143
|
{backdrop && (
|
|
144
|
-
|
|
144
|
+
/* A BUTTON, not a div (OverlayScrimTapDismiss, 2026-09-01): iOS Safari
|
|
145
|
+
* does not bubble tap-clicks from non-interactive elements, so a div
|
|
146
|
+
* scrim's onClick never fires on a phone — the same line that broke
|
|
147
|
+
* the search overlay's dismiss. */
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
aria-label="Close"
|
|
145
151
|
className={`fixed inset-0 z-[100] kol-overlay-scrim ${motionBackdrop}`}
|
|
146
152
|
onClick={onClose}
|
|
147
|
-
aria-hidden="true"
|
|
148
153
|
/>
|
|
149
154
|
)}
|
|
150
155
|
<div
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import useInViewAttention from '../hooks/useInViewAttention.js'
|
|
2
3
|
import { motion } from 'framer-motion'
|
|
3
4
|
import HlsVideo from '../atoms/HlsVideo.jsx'
|
|
4
5
|
import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
@@ -31,36 +32,8 @@ function useCoarsePointer() {
|
|
|
31
32
|
return coarse
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
/*
|
|
35
|
-
*
|
|
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
|
-
}
|
|
35
|
+
/* the shared card-set behaviour (CardSetInViewAttention, 2026-08-31) — this
|
|
36
|
+
* component's observer was the precedent and is now the hook itself. */
|
|
64
37
|
|
|
65
38
|
/**
|
|
66
39
|
* Media — internal, NOT exported. Sniffs `src` by extension and renders the
|
|
@@ -164,7 +137,7 @@ export default function TiltBento({
|
|
|
164
137
|
const reduced = usePrefersReducedMotion()
|
|
165
138
|
const coarse = useCoarsePointer()
|
|
166
139
|
const tilt = useTilt()
|
|
167
|
-
const [viewRef, centred] =
|
|
140
|
+
const [viewRef, centred] = useInViewAttention(coarse && coarseReveal === 'in-view')
|
|
168
141
|
/* On a coarse pointer the card is "open" when it holds the centre; under
|
|
169
142
|
* `static` it is always open, which is what shipped before. */
|
|
170
143
|
const coarseOpen = coarseReveal === 'static' || centred
|
|
@@ -40,7 +40,9 @@ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
|
40
40
|
* a class built at runtime is never emitted.
|
|
41
41
|
*
|
|
42
42
|
* @param {string} form 'grid' | 'list'
|
|
43
|
-
* @param {string} min grid track minimum, card form (default 320px) — the FLUID wall's floor
|
|
43
|
+
* @param {string} min grid track minimum, card form (default 320px) — the FLUID wall's floor.
|
|
44
|
+
* Emitted as `min(<value>, 100%)`, so it can never demand a column wider
|
|
45
|
+
* than its container (ContentGridMinColumnWidth, 2026-08-31)
|
|
44
46
|
* @param {string} minCol the floor a `cols` track may not go under. DEFAULTS TO `min` (320px), so
|
|
45
47
|
* the count path and the fluid path share one ruled minimum and raising
|
|
46
48
|
* `min` raises both (ContentCollectionMinColumnWidth, kol-chess
|
|
@@ -144,8 +146,16 @@ export default function ContentCollection({
|
|
|
144
146
|
/* minmax(0, 1fr), never a bare 1fr (= minmax(auto, 1fr)): a truncated
|
|
145
147
|
* nowrap line handed its min-content width to the track and a /work row
|
|
146
148
|
* measured 3586px in a 1232px wall (CollectionItemMinWidth, 2026-08-27) */
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
/* `min(<fixed>, 100%)`, never a bare fixed track (ContentGridMinColumnWidth,
|
|
150
|
+
* kol-website 2026-08-31). `minmax(352px, 1fr)` DEMANDS 352 whatever the
|
|
151
|
+
* container is, so in a 302px column the track wins and the nearest
|
|
152
|
+
* overflow-x ancestor starts scrolling sideways — `main` scrolled 342 to
|
|
153
|
+
* 372 on /workshop while the page itself never overflowed, which is why it
|
|
154
|
+
* was reported as a broken gutter. Identical above the breakpoint,
|
|
155
|
+
* collapses to the container below it. A content grid may never demand a
|
|
156
|
+
* column wider than what it is in. */
|
|
157
|
+
? (listMin ? `repeat(auto-fill, minmax(min(${listMin}, 100%), 1fr))` : 'minmax(0, 1fr)')
|
|
158
|
+
: `repeat(auto-fill, minmax(min(${min}, 100%), 1fr))`,
|
|
149
159
|
gap: g,
|
|
150
160
|
}}
|
|
151
161
|
>
|
|
@@ -319,7 +319,7 @@ const ContentFilters = ({
|
|
|
319
319
|
* divider equally from both sides, so it read as a free-floating line
|
|
320
320
|
* rather than the header's own baseline. */}
|
|
321
321
|
<div className="flex items-center justify-between" style={{ marginBottom: 'var(--kol-spacing-3)' }}>
|
|
322
|
-
<div className="flex items-center gap-6">
|
|
322
|
+
<div className="flex items-center gap-3 md:gap-6">
|
|
323
323
|
{/* IconFrame, NOT a kol-btn span: this is decoration and clicks
|
|
324
324
|
* nothing, so it must not wear a button's chrome. The atom exists
|
|
325
325
|
* for exactly this (lobby ruling 2026-07-30 — "icons only, NO
|
|
@@ -331,8 +331,12 @@ const ContentFilters = ({
|
|
|
331
331
|
* divider sat 24px from the title's text edge but 32 from the
|
|
332
332
|
* icon glyphs (the frames carry 8px of air a side) and read pushed
|
|
333
333
|
* toward the title; 16px on the title side balances it. The seam
|
|
334
|
-
* `titleClassName` stays what it was.
|
|
335
|
-
|
|
334
|
+
* `titleClassName` stays what it was.
|
|
335
|
+
* MOBILE RUNG (ContentFiltersMobileGaps, 2026-09-01): the seam and
|
|
336
|
+
* the pad both halve below md — gap-3 + pr-2 keeps the same
|
|
337
|
+
* frame-air balance (12+8 glyph side · 12+8 title side) without
|
|
338
|
+
* spending 40px of a 390px viewport on one divider. */}
|
|
339
|
+
<span className={`${titleClassName} pr-2 md:pr-4`} style={titleUppercase ? { textTransform: 'uppercase', letterSpacing: 1 } : undefined}>{title}</span>
|
|
336
340
|
</h2>
|
|
337
341
|
<Divider variant="vertical" />
|
|
338
342
|
<div className="flex items-center gap-1">
|
|
@@ -457,8 +461,8 @@ const ContentFilters = ({
|
|
|
457
461
|
* the whole row on `isExpanded` hid the strip until you opened filters,
|
|
458
462
|
* which is not a state anyone would guess at. */}
|
|
459
463
|
{(isExpanded || (layoutPlacement === 'below' && layoutStrip) || leadingActions || belowActions) && (
|
|
460
|
-
<div className="kol-filters-row flex items-start justify-between gap-16">
|
|
461
|
-
<div className="flex min-w-0 flex-1 items-start gap-16">
|
|
464
|
+
<div className="kol-filters-row flex items-start justify-between gap-8 md:gap-16">
|
|
465
|
+
<div className="flex min-w-0 flex-1 items-start gap-8 md:gap-16">
|
|
462
466
|
{leadingActions}
|
|
463
467
|
{isExpanded && filterGroups.map((group, i) => renderFilterGroup(group, i))}
|
|
464
468
|
{isExpanded && activeFilters.size > 0 && (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import SectionCardItem from '../molecules/SectionCardItem.jsx'
|
|
2
|
+
import { FULL_BLEED } from './sectionBleed.js'
|
|
2
3
|
import { surfaceClass } from './sectionSurface.js'
|
|
3
4
|
import SectionText, { HEADLINE_ROLE } from '../molecules/SectionText.jsx'
|
|
4
5
|
import useSectionTheme from '../hooks/useSectionTheme.js'
|
|
@@ -32,11 +33,19 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
32
33
|
* @param {string} itemClassName extra classes on every card (reveal seam)
|
|
33
34
|
* @param {object|Function} itemStyle inline style per card, or `(index) => style`
|
|
34
35
|
* @param {object} slotClass · slotStyle per-slot class / style on the header text
|
|
36
|
+
* @param {boolean} [fullBleed=false] the FILL breaks the page gutter while the content keeps it —
|
|
37
|
+
* the family's shared breakout (`sectionBleed.js`, SectionFamilyFullBleed, kol-website 2026-08-31).
|
|
38
|
+
* Any member of this family can be a filled surface, and a filled surface inside `.kol-page` has its
|
|
39
|
+
* colour clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
|
|
40
|
+
* over-bleed in a parent with no gutter of its own. The section's horizontal padding re-insets the
|
|
41
|
+
* CONTENT, so only the fill moves. Default false — nothing renders differently until it is passed.
|
|
35
42
|
* @param {string} sectionClassName · wrapperClassName · cardsWrapperClassName · actionsClassName · headerClassName · headerTextWidthClass layout seams
|
|
36
43
|
* @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
|
|
37
44
|
* (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
|
|
38
45
|
*/
|
|
39
46
|
export default function SectionCards({
|
|
47
|
+
fullBleed = false,
|
|
48
|
+
coarseReveal = 'in-view',
|
|
40
49
|
theme,
|
|
41
50
|
background,
|
|
42
51
|
height = '60',
|
|
@@ -67,7 +76,7 @@ export default function SectionCards({
|
|
|
67
76
|
const eb = eyebrow ?? label
|
|
68
77
|
const [themeRef, themeStamp] = useSectionTheme(theme)
|
|
69
78
|
return (
|
|
70
|
-
<section ref={themeRef} data-theme={themeStamp} className={
|
|
79
|
+
<section ref={themeRef} data-theme={themeStamp} className={`${fullBleed ? FULL_BLEED : 'w-full'} flex flex-col justify-center ${minHeightClass(height)} ${surfaceClass(background, theme ? 'primary' : 'none')} ${sectionClassName}`.replace(/\s+/g, ' ').trim()}>
|
|
71
80
|
<div className={wrapperClassName}>
|
|
72
81
|
{(eb || headline || body) && (
|
|
73
82
|
<SectionText
|
|
@@ -101,6 +110,7 @@ export default function SectionCards({
|
|
|
101
110
|
backgroundColor={feature.backgroundColor}
|
|
102
111
|
imageAspectRatio={feature.imageAspectRatio}
|
|
103
112
|
zoom={feature.zoom}
|
|
113
|
+
coarseReveal={coarseReveal}
|
|
104
114
|
onNavigate={onNavigate ? (event) => onNavigate(event, feature) : undefined}
|
|
105
115
|
className={itemClassName}
|
|
106
116
|
style={typeof itemStyle === 'function' ? itemStyle(index) : itemStyle}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Button from '../atoms/Button.jsx'
|
|
2
|
+
import { FULL_BLEED } from './sectionBleed.js'
|
|
2
3
|
import { surfaceClass } from './sectionSurface.js'
|
|
3
4
|
import SectionText from '../molecules/SectionText.jsx'
|
|
4
5
|
import { minHeightClass } from './sectionHeights.js'
|
|
@@ -26,11 +27,18 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
26
27
|
* @param {ReactNode} contactLabel contact-row label
|
|
27
28
|
* @param {string} email contact-row value + `mailto:` target; omit to drop the row
|
|
28
29
|
* @param {{label: ReactNode, value: ReactNode, href?: string}[]} secondaryRows extra rows between prompt and contact
|
|
30
|
+
* @param {boolean} [fullBleed=false] the FILL breaks the page gutter while the content keeps it —
|
|
31
|
+
* the family's shared breakout (`sectionBleed.js`, SectionFamilyFullBleed, kol-website 2026-08-31).
|
|
32
|
+
* Any member of this family can be a filled surface, and a filled surface inside `.kol-page` has its
|
|
33
|
+
* colour clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
|
|
34
|
+
* over-bleed in a parent with no gutter of its own. The section's horizontal padding re-insets the
|
|
35
|
+
* CONTENT, so only the fill moves. Default false — nothing renders differently until it is passed.
|
|
29
36
|
* @param {string} className extra classes on the section
|
|
30
37
|
* @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
|
|
31
38
|
* (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
|
|
32
39
|
*/
|
|
33
40
|
export default function SectionCta({
|
|
41
|
+
fullBleed = false,
|
|
34
42
|
variant = 'editorial',
|
|
35
43
|
background,
|
|
36
44
|
height = '60',
|
|
@@ -60,7 +68,7 @@ export default function SectionCta({
|
|
|
60
68
|
}
|
|
61
69
|
if (variant === 'centered') {
|
|
62
70
|
return (
|
|
63
|
-
<section className={
|
|
71
|
+
<section className={`${fullBleed ? FULL_BLEED : 'w-full'} flex flex-col justify-center py-24 ${surfaceClass(background, 'auto')} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
64
72
|
<div className="w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto">
|
|
65
73
|
<div className="w-32 h-px bg-fg-24 mx-auto mb-8" />
|
|
66
74
|
<SectionText
|
|
@@ -87,7 +95,7 @@ export default function SectionCta({
|
|
|
87
95
|
...(email ? [{ label: contactLabel, value: email, href: `mailto:${email}` }] : []),
|
|
88
96
|
]
|
|
89
97
|
return (
|
|
90
|
-
<section className={
|
|
98
|
+
<section className={`${fullBleed ? FULL_BLEED : 'w-full'} ${surfaceClass(background, 'auto')} flex flex-col justify-center ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
91
99
|
{/* the family's ONE cap — the shell's --kol-container-max ladder (user
|
|
92
100
|
* ruling 2026-08-26; the 1600 SectionCtaEditorial asked for was a third
|
|
93
101
|
* number beside split's 1200 and cards' 1400) — the surface stays full
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
+
import { FULL_BLEED } from './sectionBleed.js'
|
|
2
3
|
import { surfaceClass } from './sectionSurface.js'
|
|
3
4
|
import { Accordion, AccordionPanel } from '../molecules/Accordion.jsx'
|
|
4
5
|
import SectionText from '../molecules/SectionText.jsx'
|
|
@@ -17,11 +18,18 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
17
18
|
* @param {{ q: ReactNode, a: ReactNode, meta?: ReactNode }[]} items
|
|
18
19
|
* @param {boolean} [singleOpen=false] opening one panel closes the others
|
|
19
20
|
* @param {number} [defaultOpen] index open on mount (singleOpen) — omit for all closed
|
|
21
|
+
* @param {boolean} [fullBleed=false] the FILL breaks the page gutter while the content keeps it —
|
|
22
|
+
* the family's shared breakout (`sectionBleed.js`, SectionFamilyFullBleed, kol-website 2026-08-31).
|
|
23
|
+
* Any member of this family can be a filled surface, and a filled surface inside `.kol-page` has its
|
|
24
|
+
* colour clipped by the gutter on mobile. Viewport-relative, so unlike `.kol-full-bleed` it does not
|
|
25
|
+
* over-bleed in a parent with no gutter of its own. The section's horizontal padding re-insets the
|
|
26
|
+
* CONTENT, so only the fill moves. Default false — nothing renders differently until it is passed.
|
|
20
27
|
* @param {string} className · innerClassName layout seams
|
|
21
28
|
* @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
|
|
22
29
|
* (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
|
|
23
30
|
*/
|
|
24
31
|
export default function SectionFaq({
|
|
32
|
+
fullBleed = false,
|
|
25
33
|
eyebrow,
|
|
26
34
|
label,
|
|
27
35
|
headline,
|
|
@@ -44,7 +52,7 @@ export default function SectionFaq({
|
|
|
44
52
|
const eb = eyebrow ?? label
|
|
45
53
|
const [open, setOpen] = useState(defaultOpen ?? null)
|
|
46
54
|
return (
|
|
47
|
-
<section className={`kol-section-faq w-full flex flex-col justify-center px-5 py-16 md:px-8 md:py-24 lg:px-14 ${minHeightClass(height)} ${surfaceClass(background, 'none')} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
55
|
+
<section className={`kol-section-faq ${fullBleed ? FULL_BLEED : 'w-full'} flex flex-col justify-center px-5 py-16 md:px-8 md:py-24 lg:px-14 ${minHeightClass(height)} ${surfaceClass(background, 'none')} ${className}`.replace(/\s+/g, ' ').trim()}>
|
|
48
56
|
<div className="w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto">
|
|
49
57
|
<div className={innerClassName}>
|
|
50
58
|
{(eb || headline || body || actions) && (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isValidElement } from 'react'
|
|
2
|
+
import { FULL_BLEED, bleedClass } from './sectionBleed.js'
|
|
2
3
|
import { surfaceClass } from './sectionSurface.js'
|
|
3
4
|
import HlsVideo from '../atoms/HlsVideo.jsx'
|
|
4
5
|
import Image from '../atoms/Image.jsx'
|
|
@@ -197,7 +198,7 @@ export default function SectionHero({
|
|
|
197
198
|
|
|
198
199
|
if (variant === 'split') {
|
|
199
200
|
const mediaFirst = align !== 'right'
|
|
200
|
-
const bleed = fullBleed
|
|
201
|
+
const bleed = bleedClass(fullBleed)
|
|
201
202
|
const mediaNode = media == null
|
|
202
203
|
? <AssetPlaceholder radius={false} className="h-full w-full" />
|
|
203
204
|
: isValidElement(media) || typeof media !== 'object'
|
|
@@ -264,7 +265,7 @@ export default function SectionHero({
|
|
|
264
265
|
autoPlayInterval={autoPlayInterval}
|
|
265
266
|
navPosition={navPosition}
|
|
266
267
|
{...Object.fromEntries(Object.entries({ renderTitle, ctaLabel, onNavigate, showTitle, showDescription, showCta, titleClassName, descriptionClassName, options }).filter(([, v]) => v !== undefined))}
|
|
267
|
-
className={`kol-section-hero-carousel ${fullBleed ?
|
|
268
|
+
className={`kol-section-hero-carousel ${fullBleed ? FULL_BLEED : ''} ${className}`.replace(/\s+/g, ' ').trim()}
|
|
268
269
|
>
|
|
269
270
|
{children}
|
|
270
271
|
</FeaturedCarousel>
|
|
@@ -280,7 +281,7 @@ export default function SectionHero({
|
|
|
280
281
|
autoPlayInterval={autoPlayInterval}
|
|
281
282
|
navPosition={navPosition}
|
|
282
283
|
{...Object.fromEntries(Object.entries({ renderTitle, ctaLabel, onNavigate, showTitle, showDescription, showCta, titleClassName, descriptionClassName, options }).filter(([, v]) => v !== undefined))}
|
|
283
|
-
className={`kol-section-hero-carousel ${fullBleed ?
|
|
284
|
+
className={`kol-section-hero-carousel ${fullBleed ? FULL_BLEED : ''} ${className}`.replace(/\s+/g, ' ').trim()}
|
|
284
285
|
>
|
|
285
286
|
{children}
|
|
286
287
|
</FeaturedCarousel>
|
|
@@ -340,7 +341,7 @@ export default function SectionHero({
|
|
|
340
341
|
const footVar = foot ? { '--kol-section-foot-overlap': `${overlap}px` } : undefined
|
|
341
342
|
|
|
342
343
|
return withFoot(
|
|
343
|
-
<section ref={themeRef} data-theme={themeStamp} className={`kol-full-bleed-hero relative isolate w-full overflow-hidden ${themed} ${heightCls} ${fullBleed ?
|
|
344
|
+
<section ref={themeRef} data-theme={themeStamp} className={`kol-full-bleed-hero relative isolate w-full overflow-hidden ${themed} ${heightCls} ${fullBleed ? FULL_BLEED : ''} ${className}`.replace(/\s+/g, ' ').trim()} style={footVar}>
|
|
344
345
|
<MediaLayer media={media} />
|
|
345
346
|
{overlayOpacity > 0 && (
|
|
346
347
|
<div
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useId, useState } from 'react'
|
|
2
|
+
import { bleedClass } from './sectionBleed.js'
|
|
2
3
|
import { surfaceClass } from './sectionSurface.js'
|
|
3
4
|
import Input from '../atoms/Input.jsx'
|
|
4
5
|
import Button from '../atoms/Button.jsx'
|
|
@@ -125,8 +126,14 @@ export default function SectionNewsletter({
|
|
|
125
126
|
* at 390 the field and submit ran edge to edge against the band's own
|
|
126
127
|
* boundary and read as breaking out of it. `px-5` is a floor the band owns.
|
|
127
128
|
* Desktop does not move — the measure caps below the padded width, so the
|
|
128
|
-
* inner block still centres at 80px from the band edge.
|
|
129
|
-
|
|
129
|
+
* inner block still centres at 80px from the band edge.
|
|
130
|
+
*
|
|
131
|
+
* `py-16 md:py-24` (SectionNewsletterMobileFoot, 2026-09-01): the flat
|
|
132
|
+
* `py-24` left 96px of empty band under the Subscribe button at 390 — a
|
|
133
|
+
* fifth of the card. 16/24 is the FAMILY's own mobile rung (SectionFaq,
|
|
134
|
+
* SectionSplit carry it already); this band was the outlier holding the
|
|
135
|
+
* desktop constant. Desktop unmoved. */
|
|
136
|
+
className={`kol-section-newsletter ${bleedClass(fullBleed)} flex flex-col justify-center px-5 sm:px-8 py-16 md:py-24 ${surfaceClass(background, theme ? 'primary' : 'none')} ${theme ? 'text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
|
|
130
137
|
>
|
|
131
138
|
{/* the family's ONE cap — the shell's --kol-container-max ladder — and
|
|
132
139
|
* inside it the lede's MEASURE on a wrapper (SectionNewsletterForm,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import SectionText from '../molecules/SectionText.jsx'
|
|
2
|
+
import { FULL_BLEED } from './sectionBleed.js'
|
|
2
3
|
import { surfaceClass } from './sectionSurface.js'
|
|
3
4
|
import useSectionTheme from '../hooks/useSectionTheme.js'
|
|
4
5
|
import { minHeightClass } from './sectionHeights.js'
|
|
@@ -87,7 +88,7 @@ export default function SectionSplit({
|
|
|
87
88
|
const sectionStyle = bgImage
|
|
88
89
|
? { backgroundImage: `url(${bgImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
|
89
90
|
: undefined
|
|
90
|
-
const bleed = fullBleed ?
|
|
91
|
+
const bleed = fullBleed ? FULL_BLEED : ''
|
|
91
92
|
const centred = align === 'center'
|
|
92
93
|
const mediaFirst = align === 'left'
|
|
93
94
|
const grid = centred
|
|
@@ -146,8 +147,18 @@ export default function SectionSplit({
|
|
|
146
147
|
* phone with browser chrome sits in the 660–720 band, which is why
|
|
147
148
|
* this was reported from a device four times and never reproduced
|
|
148
149
|
* against a nominal 844-tall test. `max-w-full` alone only caps. */
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
/* THE RUNG STOPS AT THE STACK (SectionSplitVisualHeightRemainder,
|
|
151
|
+
* kol-website 2026-09-01). The bounded-frame height (rung − 2×py,
|
|
152
|
+
* SectionSplitMediaBounded) is a two-column ruling: it holds where
|
|
153
|
+
* the text sits BESIDE the media. Stacked below 901px the text sits
|
|
154
|
+
* ABOVE it, the same calc handed the frame whatever the rung's
|
|
155
|
+
* arithmetic left (rung 40 at a 700-tall phone = 117px), and
|
|
156
|
+
* `overflow-hidden` clipped a 350px card to a letterbox strip —
|
|
157
|
+
* silently, which is why it was reported as a ProfileCard crop.
|
|
158
|
+
* Below 901 the frame is `w-full` + ratio: width decides, height
|
|
159
|
+
* follows, media never clips. ≥901 nothing moves. */
|
|
160
|
+
className={`kol-section-split-visual relative w-full max-w-full justify-self-center rounded-[var(--kol-radius-sm)] min-[901px]:h-[calc(var(--kol-section-h,60vh)_-_2*var(--kol-section-py,4rem))] ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
|
|
161
|
+
style={{ aspectRatio: ratio }}
|
|
151
162
|
>
|
|
152
163
|
{media}
|
|
153
164
|
{caption && <div className="kol-section-split-visual-veil" aria-hidden="true" />}
|
|
@@ -132,10 +132,16 @@ export default function ShellSearchOverlay({
|
|
|
132
132
|
|
|
133
133
|
return (
|
|
134
134
|
<div className="fixed inset-0 flex items-start justify-center pt-[20vh]" style={{ zIndex: 'var(--kol-z-modal, 100)' }}>
|
|
135
|
-
|
|
135
|
+
{/* A BUTTON, not a div (OverlayScrimTapDismiss, 2026-09-01): iOS Safari
|
|
136
|
+
* does not bubble tap-clicks from non-interactive elements, so the div's
|
|
137
|
+
* onClick never fired on a phone and the only way out was the close
|
|
138
|
+
* control. A button always fires — and it ends the other half of the
|
|
139
|
+
* defect, an interactive target carrying `aria-hidden`. */}
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
aria-label="Close search"
|
|
136
143
|
className="absolute inset-0 kol-overlay-scrim"
|
|
137
144
|
onClick={onClose}
|
|
138
|
-
aria-hidden="true"
|
|
139
145
|
/>
|
|
140
146
|
<div
|
|
141
147
|
ref={panelRef}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* ONE BREAKOUT, ONE LITERAL (SectionFamilyFullBleed, kol-website 2026-08-31).
|
|
2
|
+
*
|
|
3
|
+
* `SectionHero` had `fullBleed`; `SectionNewsletter` needed it and copied the
|
|
4
|
+
* literal character for character. That copy was the right call at the time and
|
|
5
|
+
* is exactly the argument for this file: the second organism to need a thing had
|
|
6
|
+
* to duplicate the first, and the third would have too.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT IS A FILLED-SECTION PROBLEM, not a newsletter one. Any member of the
|
|
9
|
+
* family can be a filled surface, and a filled surface inside `.kol-page` has its
|
|
10
|
+
* colour clipped by the page gutter on mobile — strips of page down both sides of
|
|
11
|
+
* the fill. Reported once per organism until the prop is shared.
|
|
12
|
+
*
|
|
13
|
+
* NOT `.kol-full-bleed`: that escape is CONTAINER-relative, so on an organism
|
|
14
|
+
* whose own parent has no gutter it over-bleeds — kol-website hit exactly that on
|
|
15
|
+
* an Instagram section the same evening. This is viewport-relative and does not
|
|
16
|
+
* care what it is nested in.
|
|
17
|
+
*
|
|
18
|
+
* The section's own horizontal padding re-insets the CONTENT, so only the fill
|
|
19
|
+
* moves. Default false everywhere: the blast radius is wide and shallow — no
|
|
20
|
+
* consumer's rendering changes until it passes the prop.
|
|
21
|
+
*
|
|
22
|
+
* Literal strings, never built at runtime — Tailwind's scanner cannot see a class
|
|
23
|
+
* assembled from parts. */
|
|
24
|
+
export const FULL_BLEED = 'w-screen ml-[calc(50%-50vw)]'
|
|
25
|
+
export const NO_BLEED = 'w-full'
|
|
26
|
+
|
|
27
|
+
export const bleedClass = (fullBleed) => (fullBleed ? FULL_BLEED : NO_BLEED)
|
|
File without changes
|