@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.
|
|
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
|
>
|