@kolkrabbi/kol-component 0.144.0 → 0.145.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.144.0",
3
+ "version": "0.145.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",
@@ -61,7 +61,18 @@ export default function SectionCardItem({
61
61
  '16/9': 'aspect-video',
62
62
  '1/1': 'aspect-square',
63
63
  }
64
- const aspectClass = aspectClasses[imageAspectRatio] || ''
64
+ /* A MEDIA BOX MAY NOT RESOLVE TO ZERO HEIGHT (CardFeatureVisualCollapses,
65
+ * kol-website 2026-08-31). With no ratio the box was `flex-1` and nothing
66
+ * else — `flex: 1 1 0%`, basis ZERO, so its own content contributed nothing
67
+ * and its height was donated entirely by the parent. Where no ancestor
68
+ * supplies a definite height it resolves to 0 and the card silently drops to
69
+ * title + subtitle: no broken image, no failed request, just a short card and
70
+ * a reader who never learns a visual was meant to be there. Reported from a
71
+ * real iPhone; not reproducible on this machine in any of the three engines,
72
+ * which is exactly what a donated-height collapse looks like.
73
+ * 3/2 is the geometry those cards already render at (316 wide → 211 tall at
74
+ * 390), so nothing moves where it currently works. */
75
+ const aspectClass = aspectClasses[imageAspectRatio] || 'aspect-[3/2]'
65
76
 
66
77
  const content = textOnly ? (
67
78
  <>
@@ -81,7 +92,7 @@ export default function SectionCardItem({
81
92
  {/* kol-card-feature-visual: zooms 1.03 on card hover (chrome in
82
93
  * kol-theme — CardFeatureHoverZoom 2026-08-12); all three visual
83
94
  * forms ride the same wrapper, reduced-motion opts out. */}
84
- <div className={`kol-card-feature-visual w-full flex-1 flex items-center justify-center overflow-hidden ${aspectClass}`.trim()}>
95
+ <div className={`kol-card-feature-visual w-full flex-auto flex items-center justify-center overflow-hidden ${aspectClass}`.trim()}>
85
96
  {visual ? (
86
97
  typeof visual === 'string' ? (
87
98
  isSvgUrl ? (
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import { motion } from 'framer-motion'
3
3
  import HlsVideo from '../atoms/HlsVideo.jsx'
4
4
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
@@ -31,6 +31,37 @@ function useCoarsePointer() {
31
31
  return coarse
32
32
  }
33
33
 
34
+ /* WHICH CARD HAS ATTENTION WHEN THERE IS NO HOVER (TiltBentoCoarseRevealInView,
35
+ * kol-website 2026-08-31). Coarse devices used to render every card fully open —
36
+ * title, subtitle, description and CTA stacked over the artwork at once — which
37
+ * on a column of full-width cards means the text permanently competes with the
38
+ * image it sits on, on the device where the image is largest relative to it.
39
+ *
40
+ * An IntersectionObserver whose root is squeezed to the viewport's middle band
41
+ * (-45% top and bottom) intersects ONLY the element crossing the centre line, so
42
+ * at most one full-width card in a column is ever active. That is the same "one
43
+ * open at a time" a mouse gets from hover, with no cross-card coordination and
44
+ * no shared store — each card answers for itself.
45
+ *
46
+ * The component's vocabulary does not change: the title is always visible, the
47
+ * scrim and the rest reveal on attention. Only the definition of attention
48
+ * differs per input type. */
49
+ function useInViewCentre(enabled) {
50
+ const ref = useRef(null)
51
+ const [inView, setInView] = useState(false)
52
+ useEffect(() => {
53
+ const el = ref.current
54
+ if (!enabled || !el || typeof IntersectionObserver === 'undefined') return
55
+ const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
56
+ rootMargin: '-45% 0px -45% 0px',
57
+ threshold: 0,
58
+ })
59
+ io.observe(el)
60
+ return () => io.disconnect()
61
+ }, [enabled])
62
+ return [ref, enabled && inView]
63
+ }
64
+
34
65
  /**
35
66
  * Media — internal, NOT exported. Sniffs `src` by extension and renders the
36
67
  * right full-bleed media element: `.m3u8` → HlsVideo, `.mov|.mp4|.webm` →
@@ -86,6 +117,10 @@ function Media({ src, poster, className }) {
86
117
  * @param {string} src media source; type auto-detected by extension
87
118
  * @param {string} poster poster frame for HLS/video
88
119
  * @param {ReactNode} title always-visible heading
120
+ * @param {'in-view'|'static'} [coarseReveal='in-view'] what counts as attention on a device
121
+ * with no hover. `in-view` reveals only the card crossing the viewport's centre line and leaves
122
+ * the others at title-only; `static` is the pre-2026-08-31 behaviour — every card fully open —
123
+ * which a wall of small bento tiles may still want. The fine-pointer hover path never changes.
89
124
  * @param {ReactNode} subtitle hover-revealed line
90
125
  * @param {ReactNode} description hover-revealed paragraph
91
126
  * @param {string} href CTA target; `http*`/`mailto` → new-tab anchor, else same-tab (onNavigate seam)
@@ -118,6 +153,7 @@ export default function TiltBento({
118
153
  overlayOpacity = 60,
119
154
  alignRight = false,
120
155
  enableTilt = true,
156
+ coarseReveal = 'in-view',
121
157
  titleClassName = 'kol-sans-heading-01 text-ab-white',
122
158
  contentClassName = 'max-w-[384px]',
123
159
  imageClassName = 'object-cover object-center',
@@ -128,6 +164,10 @@ export default function TiltBento({
128
164
  const reduced = usePrefersReducedMotion()
129
165
  const coarse = useCoarsePointer()
130
166
  const tilt = useTilt()
167
+ const [viewRef, centred] = useInViewCentre(coarse && coarseReveal === 'in-view')
168
+ /* On a coarse pointer the card is "open" when it holds the centre; under
169
+ * `static` it is always open, which is what shipped before. */
170
+ const coarseOpen = coarseReveal === 'static' || centred
131
171
 
132
172
  const tiltOff = !enableTilt || reduced || coarse
133
173
  const Component = tiltOff ? 'div' : motion.div
@@ -140,11 +180,15 @@ export default function TiltBento({
140
180
  ? {}
141
181
  : { ref: tilt.ref, onMouseMove: tilt.onMouseMove, onMouseLeave: tilt.onMouseLeave }
142
182
 
143
- // Reveal choreography. Coarse (no-hover) devices show everything statically;
144
- // fine pointers reveal on group-hover. The opacity transition is motion, so
145
- // reduced-motion drops it (content still reveals, just without the fade).
183
+ // Reveal choreography. Fine pointers reveal on group-hover; coarse pointers
184
+ // reveal the centred card (or every card under `coarseReveal="static"`). The
185
+ // opacity transition is motion, so reduced-motion drops it (content still
186
+ // reveals, just without the fade).
146
187
  const fade = reduced ? '' : 'transition-opacity duration-300'
147
- const revealClass = `${coarse ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} ${fade}`.trim()
188
+ const openClass = coarse
189
+ ? (coarseOpen ? 'opacity-100' : 'opacity-0')
190
+ : 'opacity-0 group-hover:opacity-100'
191
+ const revealClass = `${openClass} ${fade}`.trim()
148
192
 
149
193
  const mediaClass =
150
194
  `absolute left-0 top-0 size-full rounded overflow-hidden ${imageClassName} ${coarse ? 'pointer-events-none' : ''}`.trim()
@@ -154,7 +198,7 @@ export default function TiltBento({
154
198
  return (
155
199
  <Component
156
200
  className={`relative group ${alignRight ? 'ms-auto' : 'size-full'} ${className}`.trim()}
157
- {...tiltHandlers}
201
+ {...(coarse ? { ref: viewRef } : tiltHandlers)}
158
202
  {...rest}
159
203
  style={{ ...rootStyle, ...rest.style }}
160
204
  >
@@ -164,7 +208,7 @@ export default function TiltBento({
164
208
  <div className={`relative z-10 ${contentClassName} w-full h-full self-stretch`}>
165
209
  {overlayOpacity > 0 && (
166
210
  <div
167
- className={`absolute -inset-1 rounded ${coarse ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'} ${fade} pointer-events-none`.trim()}
211
+ className={`absolute -inset-1 rounded ${coarse ? (coarseOpen ? 'opacity-60' : 'opacity-0') : 'opacity-0 group-hover:opacity-100'} ${fade} pointer-events-none`.trim()}
168
212
  style={{ backgroundColor: `rgba(0, 0, 0, ${overlayOpacity / 100})` }}
169
213
  />
170
214
  )}
@@ -29,8 +29,11 @@ import { minHeightClass } from './sectionHeights.js'
29
29
  * error ids are useId-generated (or `inputId`) so multiple sections mount
30
30
  * without collisions.
31
31
  *
32
- * @param {'full'|'80'|'60'|'40'|string} [height='60'] min-height on the family's ladder — full = 100dvh,
33
- * 80 = 70svh / 80vh, 60 = 50svh / 60vh (default), 40 = 35svh / 40vh; content stays vertically centred inside it
32
+ * @param {'full'|'80'|'60'|'40'|string} [height='40'] min-height on the family's ladder — full = 100dvh,
33
+ * 80 = 70svh / 80vh, 60 = 50svh / 60vh, 40 = 35svh / 40vh (default); content stays vertically centred inside it.
34
+ * DEFAULT DROPPED 60 → 40 (SectionNewsletterMobileMeasure, kol-website 2026-08-31): at rung 60 the band
35
+ * reserved 422px around 308px of content on an 844-tall phone — ~114px of empty grey to scroll past. The
36
+ * LADDER is untouched; every other section still wants its rung. Pass `height="60"` to keep the old air.
34
37
  * @param {'inverse'|'light'|'dark'} theme the paired theme of whatever is live, or a pinned one — stamped on the section
35
38
  * @param {ReactNode} eyebrow eyebrow above the headline (uppercase by role); `label` is its alias
36
39
  * @param {ReactNode} headline heading (display-01 by default; `headlineSize` picks another role)
@@ -44,12 +47,17 @@ import { minHeightClass } from './sectionHeights.js'
44
47
  * @param {string} id anchor id on the section (e.g. "signup")
45
48
  * @param {string} inputId id override for the email input (default useId-generated)
46
49
  * @param {object} slotClass · slotStyle per-slot class / style on the SectionText (reveal seam)
50
+ * @param {'sm'|'md'|'lg'} [controlSize='md'] size rung for BOTH the email Input and the submit
51
+ * Button (SectionNewsletterControlSize, kol-website 2026-08-31). The pair was hardcoded md with no
52
+ * seam, so a page that sets `size="lg"` on every other call-site button could not match it here and
53
+ * the newsletter read visibly smaller directly beneath them. Default is today's md — nothing moves.
47
54
  * @param {string} className extra classes on the section
48
55
  * @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
49
56
  * (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
50
57
  */
51
58
  export default function SectionNewsletter({
52
- height = '60',
59
+ height = '40',
60
+ controlSize = 'md',
53
61
  theme,
54
62
  background,
55
63
  eyebrow,
@@ -103,7 +111,14 @@ export default function SectionNewsletter({
103
111
  id={id}
104
112
  ref={themeRef}
105
113
  data-theme={themeStamp}
106
- className={`kol-section-newsletter w-full flex flex-col justify-center py-24 ${surfaceClass(background, theme ? 'primary' : 'none')} ${theme ? 'text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
114
+ /* THE FORM NEEDS AN INSET FLOOR (SectionNewsletterMobileMeasure, 2026-08-31).
115
+ * Desktop's 80px is not padding — it is the leftover of the inner measure
116
+ * (1184 band − 1024 max-w, halved), so it SCALES TO ZERO rather than down:
117
+ * at 390 the field and submit ran edge to edge against the band's own
118
+ * boundary and read as breaking out of it. `px-5` is a floor the band owns.
119
+ * Desktop does not move — the measure caps below the padded width, so the
120
+ * inner block still centres at 80px from the band edge. */
121
+ className={`kol-section-newsletter w-full flex flex-col justify-center px-5 sm:px-8 py-24 ${surfaceClass(background, theme ? 'primary' : 'none')} ${theme ? 'text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
107
122
  >
108
123
  {/* the family's ONE cap — the shell's --kol-container-max ladder — and
109
124
  * inside it the lede's MEASURE on a wrapper (SectionNewsletterForm,
@@ -134,7 +149,7 @@ export default function SectionNewsletter({
134
149
  placeholder={placeholder}
135
150
  value={email}
136
151
  onChange={(e) => setEmail(e.target.value)}
137
- size="md"
152
+ size={controlSize}
138
153
  aria-required="true"
139
154
  aria-describedby={status === 'error' ? errorId : undefined}
140
155
  className="w-full sm:max-w-[400px] md:max-w-[520px]"
@@ -142,6 +157,7 @@ export default function SectionNewsletter({
142
157
  <Button
143
158
  type="submit"
144
159
  variant="primary"
160
+ size={controlSize}
145
161
  disabled={status === 'submitting'}
146
162
  className="w-full sm:w-auto"
147
163
  >
@@ -135,7 +135,18 @@ export default function SectionSplit({
135
135
  * caps at the column — the media takes its size from the section,
136
136
  * never gives it */
137
137
  <div
138
- className={`kol-section-split-visual relative w-auto max-w-full justify-self-center rounded-[var(--kol-radius-sm)] ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
138
+ /* THE MEDIA FILLS ITS COLUMN (SectionSplitVisualWidth, kol-website
139
+ * 2026-08-31). It was `w-auto`, so the width was derived from the
140
+ * image's aspect against whatever height the box was given — tall
141
+ * enough and it clamped to max-w-full and filled; short and it
142
+ * resolved NARROWER than the column and `justify-self-center` then
143
+ * centred it, so the media sat visibly inset while the copy beneath
144
+ * stayed at the page gutter. It is VIEWPORT HEIGHT that decides:
145
+ * 390×844 passes, 390×700 renders at left 56 · width 278. A real
146
+ * phone with browser chrome sits in the 660–720 band, which is why
147
+ * this was reported from a device four times and never reproduced
148
+ * against a nominal 844-tall test. `max-w-full` alone only caps. */
149
+ className={`kol-section-split-visual relative w-full max-w-full justify-self-center rounded-[var(--kol-radius-sm)] ${mediaClip ? 'overflow-hidden' : ''} ${mediaHover ? 'is-hoverable' : ''} ${mediaFirst ? 'order-1' : ''} ${centred ? 'max-w-[640px]' : ''}`.replace(/\s+/g, ' ').trim()}
139
150
  style={{ aspectRatio: ratio, height: 'calc(var(--kol-section-h, 60vh) - 2 * var(--kol-section-py, 4rem))' }}
140
151
  >
141
152
  {media}