@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.
|
|
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 ${
|
|
176
|
-
|
|
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
|
>
|