@kolkrabbi/kol-component 0.80.1 → 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.1",
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
@@ -109,6 +110,10 @@ function MediaLayer({ media }) {
109
110
  * glass panel per slide, autoplay, prev/next); and NO media renders the
110
111
  * composed text on the surface with no glass panel — the text-only hero.
111
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.
112
117
  * @param {'media'|'split'} variant
113
118
  * @param {ReactNode|{src, kind, poster, srcSet, alt}|Array} media background (media) / the half (split) / an ARRAY of slides → carousel
114
119
  * @param {number} overlayOpacity 0–100 surface-primary scrim over the media (default 0)
@@ -139,6 +144,7 @@ function MediaLayer({ media }) {
139
144
  */
140
145
  export default function SectionHero({
141
146
  variant = 'media',
147
+ theme,
142
148
  fullBleed = false,
143
149
  media,
144
150
  overlayOpacity = 0,
@@ -177,6 +183,9 @@ export default function SectionHero({
177
183
  align = 'center',
178
184
  className = '',
179
185
  }) {
186
+ const [themeRef, themeStamp] = useSectionTheme(theme)
187
+ const themed = theme ? 'bg-surface-primary' : ''
188
+
180
189
  if (variant === 'split') {
181
190
  const mediaFirst = align !== 'right'
182
191
  const bleed = fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : 'w-full'
@@ -186,7 +195,7 @@ export default function SectionHero({
186
195
  ? <ContentMedia ratio={null} radius={false} fit="cover" className="h-full">{media}</ContentMedia>
187
196
  : <MediaLayer media={media} />
188
197
  return (
189
- <section className={`kol-section-hero-split grid grid-cols-1 md:grid-cols-2 ${SPLIT_HEIGHTS[height] || height} ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}>
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()}>
190
199
  {/* the half sizes itself off the grid row (`h-full min-h-0`) so an
191
200
  * absolute media node — the descriptor's cover-fit img — fills it */}
192
201
  <div className={`relative h-full min-h-[50vh] overflow-hidden md:min-h-0 ${mediaFirst ? '' : 'md:order-2'}`.trim()}>{mediaNode}</div>
@@ -234,6 +243,24 @@ export default function SectionHero({
234
243
  if (Array.isArray(media)) {
235
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 } }))
236
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
+ ) : (
237
264
  <FeaturedCarousel
238
265
  items={items}
239
266
  fullWidth
@@ -247,7 +274,8 @@ export default function SectionHero({
247
274
  className={`kol-section-hero-carousel ${fullBleed ? 'w-screen ml-[calc(50%-50vw)]' : ''} ${className}`.replace(/\s+/g, ' ').trim()}
248
275
  >
249
276
  {children}
250
- </FeaturedCarousel>,
277
+ </FeaturedCarousel>
278
+ ),
251
279
  )
252
280
  }
253
281
 
@@ -284,7 +312,7 @@ export default function SectionHero({
284
312
  const justifyCls = justify === 'end' ? 'items-end pb-32 sm:pb-40 lg:pb-48 xl:pb-56' : 'items-center'
285
313
 
286
314
  return withFoot(
287
- <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()}>
288
316
  <MediaLayer media={media} />
289
317
  {overlayOpacity > 0 && (
290
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
  >