@kolkrabbi/kol-component 0.148.0 → 0.149.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.148.0",
3
+ "version": "0.149.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
@@ -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'
@@ -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
  }
@@ -1,4 +1,5 @@
1
- import { useEffect, useRef, useState } from 'react'
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
- /* 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
- }
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] = useInViewCentre(coarse && coarseReveal === 'in-view')
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
- ? (listMin ? `repeat(auto-fill, minmax(${listMin}, 1fr))` : 'minmax(0, 1fr)')
148
- : `repeat(auto-fill, minmax(${min}, 1fr))`,
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
  >
@@ -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={`w-full flex flex-col justify-center ${minHeightClass(height)} ${surfaceClass(background, theme ? 'primary' : 'none')} ${sectionClassName}`.replace(/\s+/g, ' ').trim()}>
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={`w-full flex flex-col justify-center py-24 ${surfaceClass(background, 'auto')} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
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={`w-full ${surfaceClass(background, 'auto')} flex flex-col justify-center ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
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 ? 'w-screen ml-[calc(50%-50vw)]' : 'w-full'
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 ? 'w-screen ml-[calc(50%-50vw)]' : ''} ${className}`.replace(/\s+/g, ' ').trim()}
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 ? 'w-screen ml-[calc(50%-50vw)]' : ''} ${className}`.replace(/\s+/g, ' ').trim()}
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 ? 'w-screen ml-[calc(50%-50vw)]' : ''} ${className}`.replace(/\s+/g, ' ').trim()} style={footVar}>
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'
@@ -126,7 +127,7 @@ export default function SectionNewsletter({
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
129
  * inner block still centres at 80px from the band edge. */
129
- className={`kol-section-newsletter ${fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : '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()}
130
+ className={`kol-section-newsletter ${bleedClass(fullBleed)} 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()}
130
131
  >
131
132
  {/* the family's ONE cap — the shell's --kol-container-max ladder — and
132
133
  * 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 ? 'w-screen ml-[calc(50%-50vw)]' : ''
91
+ const bleed = fullBleed ? FULL_BLEED : ''
91
92
  const centred = align === 'center'
92
93
  const mediaFirst = align === 'left'
93
94
  const grid = centred
@@ -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)