@kolkrabbi/kol-component 0.80.0 → 0.81.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.80.0",
3
+ "version": "0.81.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,61 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * useSectionTheme — the `theme` prop of the section organisms (SectionThemeInverse,
5
+ * kol-website 2026-08-27).
6
+ *
7
+ * undefined inherit — nothing stamped
8
+ * 'light' | 'dark' pinned — the section carries `data-theme`, and the theme's
9
+ * subtree scopes (kol-theme ≥0.52.0: surfaces, fg ramp, roles, oq,
10
+ * borders all re-resolve on a stamped element) do the rest
11
+ * 'inverse' the PAIRED theme of the nearest live one — a light page gets a
12
+ * dark section, a dark page a light one — and it follows the
13
+ * toggle: an observer on <html> and the system-scheme query re-read
14
+ * the live theme, so the section flips with the page.
15
+ *
16
+ * Resolved in JS rather than CSS on purpose: the theme scopes already exist,
17
+ * so stamping the RIGHT one is 20 lines; expressing "the other one" in CSS
18
+ * means a twin of every surface token in every theme block and no nesting.
19
+ * Here nesting works — an inverse inside an inverse reads the stamped
20
+ * ancestor and flips back.
21
+ *
22
+ * Every token resolves on the stamped element, so Buttons, Pills, the glass
23
+ * panel — anything reading `--kol-*` — flip for free. No inverse classes.
24
+ *
25
+ * @returns [ref, resolvedTheme] put `ref` on the section root and stamp
26
+ * `data-theme={resolvedTheme}` (undefined stamps nothing)
27
+ */
28
+ const htmlTheme = () => {
29
+ if (typeof document === 'undefined') return 'light'
30
+ const root = document.documentElement
31
+ const stamped = root.dataset.theme || (root.classList.contains('dark') ? 'dark' : root.classList.contains('light') ? 'light' : null)
32
+ if (stamped) return stamped
33
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
34
+ }
35
+
36
+ const nearestTheme = (el) => {
37
+ const scope = el?.parentElement?.closest('[data-theme], .dark, .light')
38
+ if (scope && scope !== document.documentElement) {
39
+ return scope.dataset.theme || (scope.classList.contains('dark') ? 'dark' : 'light')
40
+ }
41
+ return htmlTheme()
42
+ }
43
+
44
+ export default function useSectionTheme(theme) {
45
+ const ref = useRef(null)
46
+ const [live, setLive] = useState(htmlTheme)
47
+
48
+ useEffect(() => {
49
+ if (theme !== 'inverse') return undefined
50
+ const read = () => setLive(nearestTheme(ref.current))
51
+ read()
52
+ const mo = new MutationObserver(read)
53
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] })
54
+ const mq = window.matchMedia?.('(prefers-color-scheme: dark)')
55
+ mq?.addEventListener('change', read)
56
+ return () => { mo.disconnect(); mq?.removeEventListener('change', read) }
57
+ }, [theme])
58
+
59
+ const resolved = theme === 'inverse' ? (live === 'dark' ? 'light' : 'dark') : theme
60
+ return [ref, resolved]
61
+ }
@@ -1,5 +1,6 @@
1
1
  import SectionCardItem from '../molecules/SectionCardItem.jsx'
2
2
  import SectionText, { HEADLINE_ROLE } from '../molecules/SectionText.jsx'
3
+ import useSectionTheme from '../hooks/useSectionTheme.js'
3
4
 
4
5
  /**
5
6
  * SectionCards — the "N-up feature cards" band: a `SectionText` header over a
@@ -13,6 +14,10 @@ import SectionText, { HEADLINE_ROLE } from '../molecules/SectionText.jsx'
13
14
  * nodes. Card links are plain anchors; pass `onNavigate` to intercept same-tab
14
15
  * navigations in an SPA. The label is uppercase by role; the rest as authored.
15
16
  *
17
+ * @param {'inverse'|'light'|'dark'} theme the section's theme scope (SectionThemeInverse, 2026-08-27):
18
+ * `inverse` = the paired theme of the nearest live one, following the toggle;
19
+ * `light` / `dark` pinned; omit to inherit. Stamps `data-theme` on the root and
20
+ * paints its surface — every token inside resolves to the other theme's.
16
21
  * @param {{title, icon, visual, description, href, backgroundColor, imageAspectRatio}[]} features
17
22
  * @param {ReactNode} label · headline · body the header (heading-03 + mono lede by default)
18
23
  * @param {ReactNode} actions centred action row under the cards
@@ -23,6 +28,7 @@ import SectionText, { HEADLINE_ROLE } from '../molecules/SectionText.jsx'
23
28
  * @param {string} sectionClassName · wrapperClassName · cardsWrapperClassName · actionsClassName · headerClassName · headerTextWidthClass layout seams
24
29
  */
25
30
  export default function SectionCards({
31
+ theme,
26
32
  features = [],
27
33
  label,
28
34
  headline,
@@ -45,8 +51,9 @@ export default function SectionCards({
45
51
  headerClassName = 'w-full pt-[224px]',
46
52
  headerTextWidthClass = 'w-full md:w-[30%]',
47
53
  }) {
54
+ const [themeRef, themeStamp] = useSectionTheme(theme)
48
55
  return (
49
- <section className={`w-full ${sectionClassName}`.trim()}>
56
+ <section ref={themeRef} data-theme={themeStamp} className={`w-full ${theme ? 'bg-surface-primary' : ''} ${sectionClassName}`.replace(/\s+/g, ' ').trim()}>
50
57
  <div className={wrapperClassName}>
51
58
  {(label || headline || body) && (
52
59
  <SectionText
@@ -6,6 +6,7 @@ import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
6
6
  import ContentMedia from '../molecules/ContentMedia.jsx'
7
7
  import SectionText from '../molecules/SectionText.jsx'
8
8
  import FeaturedCarousel from './FeaturedCarousel.jsx'
9
+ import useSectionTheme from '../hooks/useSectionTheme.js'
9
10
 
10
11
  /* Height presets (SectionHeroRound2, user ruling 2026-08-26): three tiers in
11
12
  * viewport units, each with a phone value the DS picks. `full` sits UNDER a
@@ -22,6 +23,20 @@ const HEIGHTS = {
22
23
  md: 'h-[320px] md:h-[440px]',
23
24
  }
24
25
 
26
+ /* The SPLIT variant's presets as LITERALS (SectionHeroSplitHeight, kol-website
27
+ * 2026-08-26). 0.76.0 rewrote HEIGHTS to min-h-* at runtime — a string no
28
+ * source file carries, so Tailwind's scanner never emitted it and every
29
+ * split preset was dead: the Studio hero rendered its text column's 175px.
30
+ * A class Tailwind cannot see is not a class. */
31
+ const SPLIT_HEIGHTS = {
32
+ full: 'min-h-dvh',
33
+ 80: 'min-h-[70svh] md:min-h-[80vh]',
34
+ 60: 'min-h-[50svh] md:min-h-[60vh]',
35
+ screen: 'min-h-dvh',
36
+ lg: 'min-h-dvh',
37
+ md: 'min-h-[50svh] md:min-h-[60vh]',
38
+ }
39
+
25
40
  /**
26
41
  * Background layer: a media descriptor becomes a cover-fit Image / HlsVideo /
27
42
  * inert <video> (`.kol-full-bleed-hero-media` pins it absolute + object-cover
@@ -95,6 +110,10 @@ function MediaLayer({ media }) {
95
110
  * glass panel per slide, autoplay, prev/next); and NO media renders the
96
111
  * composed text on the surface with no glass panel — the text-only hero.
97
112
  *
113
+ * @param {'inverse'|'light'|'dark'} theme the section's theme scope (SectionThemeInverse, 2026-08-27):
114
+ * `inverse` = the paired theme of the nearest live one, following the toggle;
115
+ * `light` / `dark` pinned; omit to inherit. Stamps `data-theme` on the root and
116
+ * paints its surface — every token inside resolves to the other theme's.
98
117
  * @param {'media'|'split'} variant
99
118
  * @param {ReactNode|{src, kind, poster, srcSet, alt}|Array} media background (media) / the half (split) / an ARRAY of slides → carousel
100
119
  * @param {number} overlayOpacity 0–100 surface-primary scrim over the media (default 0)
@@ -125,6 +144,7 @@ function MediaLayer({ media }) {
125
144
  */
126
145
  export default function SectionHero({
127
146
  variant = 'media',
147
+ theme,
128
148
  fullBleed = false,
129
149
  media,
130
150
  overlayOpacity = 0,
@@ -163,6 +183,9 @@ export default function SectionHero({
163
183
  align = 'center',
164
184
  className = '',
165
185
  }) {
186
+ const [themeRef, themeStamp] = useSectionTheme(theme)
187
+ const themed = theme ? 'bg-surface-primary' : ''
188
+
166
189
  if (variant === 'split') {
167
190
  const mediaFirst = align !== 'right'
168
191
  const bleed = fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : 'w-full'
@@ -172,8 +195,10 @@ export default function SectionHero({
172
195
  ? <ContentMedia ratio={null} radius={false} fit="cover" className="h-full">{media}</ContentMedia>
173
196
  : <MediaLayer media={media} />
174
197
  return (
175
- <section className={`kol-section-hero-split grid grid-cols-1 md:grid-cols-2 ${(HEIGHTS[height === 'lg' ? 'full' : height] || height).replace(/\bh-/g, 'min-h-')} ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}>
176
- <div className={`relative min-h-[50vh] overflow-hidden md:min-h-0 ${mediaFirst ? '' : 'md:order-2'}`.trim()}>{mediaNode}</div>
198
+ <section ref={themeRef} data-theme={themeStamp} className={`kol-section-hero-split grid grid-cols-1 md:grid-cols-2 ${themed} ${SPLIT_HEIGHTS[height] || height} ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}>
199
+ {/* the half sizes itself off the grid row (`h-full min-h-0`) so an
200
+ * absolute media node — the descriptor's cover-fit img — fills it */}
201
+ <div className={`relative h-full min-h-[50vh] overflow-hidden md:min-h-0 ${mediaFirst ? '' : 'md:order-2'}`.trim()}>{mediaNode}</div>
177
202
  <div className={`flex items-center justify-center p-10 ${mediaFirst ? '' : 'md:order-1'}`.trim()}>
178
203
  {/* BARE — the molecule's own voice; `headlineCase="upper"` is the
179
204
  * split hero's one trait, a role on SectionText (SectionHeroNoOverrides) */}
@@ -218,6 +243,24 @@ export default function SectionHero({
218
243
  if (Array.isArray(media)) {
219
244
  const items = media.map((s) => (s.media ? s : { ...s, media: { src: s.src, kind: s.kind, poster: s.poster, srcSet: s.srcSet, alt: s.alt } }))
220
245
  return withFoot(
246
+ theme ? (
247
+ <div ref={themeRef} data-theme={themeStamp} className={themed}>
248
+ <FeaturedCarousel
249
+ items={items}
250
+ fullWidth
251
+ rounded={false}
252
+ showHeader={false}
253
+ height={heightCls}
254
+ autoPlay={autoPlay}
255
+ autoPlayInterval={autoPlayInterval}
256
+ navPosition={navPosition}
257
+ {...Object.fromEntries(Object.entries({ renderTitle, ctaLabel, onNavigate, showTitle, showDescription, showCta, titleClassName, descriptionClassName, options }).filter(([, v]) => v !== undefined))}
258
+ className={`kol-section-hero-carousel ${fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : ''} ${className}`.replace(/\s+/g, ' ').trim()}
259
+ >
260
+ {children}
261
+ </FeaturedCarousel>
262
+ </div>
263
+ ) : (
221
264
  <FeaturedCarousel
222
265
  items={items}
223
266
  fullWidth
@@ -231,7 +274,8 @@ export default function SectionHero({
231
274
  className={`kol-section-hero-carousel ${fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : ''} ${className}`.replace(/\s+/g, ' ').trim()}
232
275
  >
233
276
  {children}
234
- </FeaturedCarousel>,
277
+ </FeaturedCarousel>
278
+ ),
235
279
  )
236
280
  }
237
281
 
@@ -268,7 +312,7 @@ export default function SectionHero({
268
312
  const justifyCls = justify === 'end' ? 'items-end pb-32 sm:pb-40 lg:pb-48 xl:pb-56' : 'items-center'
269
313
 
270
314
  return withFoot(
271
- <section className={`kol-full-bleed-hero relative isolate w-full overflow-hidden ${heightCls} ${fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : ''} ${className}`.replace(/\s+/g, ' ').trim()}>
315
+ <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()}>
272
316
  <MediaLayer media={media} />
273
317
  {overlayOpacity > 0 && (
274
318
  <div
@@ -1,4 +1,5 @@
1
1
  import SectionText from '../molecules/SectionText.jsx'
2
+ import useSectionTheme from '../hooks/useSectionTheme.js'
2
3
 
3
4
  /**
4
5
  * SectionSplit — the media-and-text split section, on `SectionText` (the
@@ -17,6 +18,10 @@ import SectionText from '../molecules/SectionText.jsx'
17
18
  * `media` is passed, and `caption` gates both the gradient veil and the
18
19
  * caption element. The label is uppercase by role; the rest renders as authored.
19
20
  *
21
+ * @param {'inverse'|'light'|'dark'} theme the section's theme scope (SectionThemeInverse, 2026-08-27):
22
+ * `inverse` = the paired theme of the nearest live one, following the toggle;
23
+ * `light` / `dark` pinned; omit to inherit. Stamps `data-theme` on the root and
24
+ * paints its surface — every token inside resolves to the other theme's.
20
25
  * @param {ReactNode} label mono eyebrow above the headline (accent)
21
26
  * @param {ReactNode} headline display pull; `<em>` renders as the italic accent
22
27
  * @param {string} [headlineSize='pull'] which type ROLE the heading wears
@@ -40,6 +45,7 @@ import SectionText from '../molecules/SectionText.jsx'
40
45
  * @param {string} className · innerClassName · columnClassName layout seams
41
46
  */
42
47
  export default function SectionSplit({
48
+ theme,
43
49
  label,
44
50
  headline,
45
51
  headlineSize = 'pull',
@@ -61,6 +67,7 @@ export default function SectionSplit({
61
67
  innerClassName = '',
62
68
  columnClassName = '',
63
69
  }) {
70
+ const [themeRef, themeStamp] = useSectionTheme(theme)
64
71
  const sectionStyle = bgImage
65
72
  ? { backgroundImage: `url(${bgImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
66
73
  : undefined
@@ -72,6 +79,8 @@ export default function SectionSplit({
72
79
  : 'grid grid-cols-1 min-[901px]:grid-cols-2 items-center gap-[clamp(48px,6vw,96px)]'
73
80
  return (
74
81
  <section
82
+ ref={themeRef}
83
+ data-theme={themeStamp}
75
84
  className={`kol-section-split px-5 py-16 md:px-8 md:py-24 lg:px-14 lg:py-32 ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
76
85
  style={sectionStyle}
77
86
  >