@kolkrabbi/kol-component 0.2.0 → 0.4.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 +11 -3
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/atoms/Button.jsx +17 -6
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/QuantityInput.jsx +76 -114
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +66 -2
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/MenuPopover.jsx +4 -27
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- package/src/organisms/foundry/index.js +21 -0
- package/src/atoms/QuantityStepper.jsx +0 -149
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { isValidElement, useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import useEmblaCarousel from 'embla-carousel-react'
|
|
3
|
+
import Image from '../molecules/Image.jsx'
|
|
4
|
+
import HlsVideo from '../atoms/HlsVideo.jsx'
|
|
5
|
+
import OverlayGlassPanel from '../atoms/OverlayGlassPanel.jsx'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-slide media layer: a `{ src, kind }` descriptor becomes a cover-fit
|
|
9
|
+
* Image / HlsVideo / inert <video> pinned over the frame
|
|
10
|
+
* (`.kol-featured-carousel-media` — absolute inset-0 object-cover, CSS in
|
|
11
|
+
* @kol/theme, out-cascades Image's baked `h-auto`). A ready ReactNode renders
|
|
12
|
+
* as-is and must position itself (give it that same class). `onEnded` /
|
|
13
|
+
* `onTimeUpdate` attach to video elements only — they drive the active slide's
|
|
14
|
+
* auto-advance and the progress bar; images ignore them.
|
|
15
|
+
*/
|
|
16
|
+
function SlideMedia({ media, onEnded, onTimeUpdate }) {
|
|
17
|
+
if (!media) return null
|
|
18
|
+
if (isValidElement(media)) return media
|
|
19
|
+
|
|
20
|
+
const { src, kind = 'image', poster, srcSet, alt = '' } = media
|
|
21
|
+
|
|
22
|
+
if (kind === 'video') {
|
|
23
|
+
// Non-HLS file (mp4/webm): a plain inert video. HLS manifests and the
|
|
24
|
+
// poster-only case go through HlsVideo, which owns hls.js attachment and
|
|
25
|
+
// the non-interactive hardening set. `loop` is dropped once `onEnded` is
|
|
26
|
+
// wired so the active slide plays once, then advances.
|
|
27
|
+
if (src && !src.endsWith('.m3u8')) {
|
|
28
|
+
return (
|
|
29
|
+
<video
|
|
30
|
+
src={src}
|
|
31
|
+
poster={poster}
|
|
32
|
+
className="kol-featured-carousel-media"
|
|
33
|
+
style={{ pointerEvents: 'none' }}
|
|
34
|
+
autoPlay
|
|
35
|
+
loop={!onEnded}
|
|
36
|
+
muted
|
|
37
|
+
playsInline
|
|
38
|
+
controls={false}
|
|
39
|
+
onEnded={onEnded}
|
|
40
|
+
onTimeUpdate={onTimeUpdate}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
<HlsVideo
|
|
46
|
+
src={src}
|
|
47
|
+
poster={poster}
|
|
48
|
+
className="kol-featured-carousel-media"
|
|
49
|
+
onEnded={onEnded}
|
|
50
|
+
onTimeUpdate={onTimeUpdate}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Image
|
|
57
|
+
src={src}
|
|
58
|
+
srcSet={srcSet}
|
|
59
|
+
sizes={srcSet ? '100vw' : undefined}
|
|
60
|
+
alt={alt}
|
|
61
|
+
loading="eager"
|
|
62
|
+
className="kol-featured-carousel-media"
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* FeaturedCarousel — a full-width carousel of featured media: each wide slide
|
|
69
|
+
* is a fixed-height frame with an image or HLS-video background and a centered
|
|
70
|
+
* OverlayGlassPanel (title / description / CTA), plus prev/next and optional
|
|
71
|
+
* autoplay with a progress bar.
|
|
72
|
+
*
|
|
73
|
+
* Rebuilt on the DS Carousel *core*: it renders the same embla + `kol-embla`
|
|
74
|
+
* chrome (`.is-slides` wide-slide mode, `kol-embla-controls` prev/next) rather
|
|
75
|
+
* than the `Carousel` component, because the autoplay ring needs the embla api
|
|
76
|
+
* that Carousel encapsulates and Carousel.jsx must not be modified. The
|
|
77
|
+
* autoplay/progress behavior lives here, wrapped around that core — a timer
|
|
78
|
+
* for image slides, the video's `ended` event for video slides, and a progress
|
|
79
|
+
* bar fed by the same clock (rAF for images, `timeupdate` for video).
|
|
80
|
+
*
|
|
81
|
+
* Presentational — no copy, no CDN paths, no routes. The monorepo source's
|
|
82
|
+
* foundry coupling (name-based title size ramp, per-typeface `fontFamily`, the
|
|
83
|
+
* `'Explore Typeface'` default) is dropped: titles default to a plain KOL type
|
|
84
|
+
* class and are fully overridable via `renderTitle` / `titleClassName`. The
|
|
85
|
+
* CTA is a plain anchor styled with the DS button classes plus an `onNavigate`
|
|
86
|
+
* seam (router-agnostic — call `preventDefault` inside it for SPA nav).
|
|
87
|
+
*
|
|
88
|
+
* @param {Array} items slides: `{ media: { src, kind: 'image'|'video', poster, srcSet, alt }, title, description, href, ctaLabel, titleClassName }`
|
|
89
|
+
* @param {string} sectionLabel header label (default 'Featured')
|
|
90
|
+
* @param {string} ctaLabel default CTA copy; per-item `ctaLabel` overrides (default 'Learn more')
|
|
91
|
+
* @param {string} height slide-frame height class (default 'h-[440px] md:h-[640px]')
|
|
92
|
+
* @param {Function} renderTitle custom title renderer `(item) => ReactNode`; wins over the default
|
|
93
|
+
* @param {string} titleClassName default title class; per-item `titleClassName` overrides
|
|
94
|
+
* @param {boolean} showHeader show the label + `n / total` counter bar (default true)
|
|
95
|
+
* @param {boolean} autoPlay auto-advance: timer for image slides, `ended` for video slides (default false)
|
|
96
|
+
* @param {number} autoPlayInterval image-slide timer in ms (default 5000)
|
|
97
|
+
* @param {Function} onNavigate `(href, event) => void` CTA click seam for SPA routing
|
|
98
|
+
* @param {Object} options embla options passthrough (default `{ align: 'center', loop: true }`)
|
|
99
|
+
* @param {string} className extra classes on the section
|
|
100
|
+
*/
|
|
101
|
+
export default function FeaturedCarousel({
|
|
102
|
+
items = [],
|
|
103
|
+
sectionLabel = 'Featured',
|
|
104
|
+
ctaLabel = 'Learn more',
|
|
105
|
+
height = 'h-[440px] md:h-[640px]',
|
|
106
|
+
renderTitle,
|
|
107
|
+
titleClassName = '',
|
|
108
|
+
showHeader = true,
|
|
109
|
+
autoPlay = false,
|
|
110
|
+
autoPlayInterval = 5000,
|
|
111
|
+
onNavigate,
|
|
112
|
+
options = { align: 'center', loop: true },
|
|
113
|
+
className = '',
|
|
114
|
+
}) {
|
|
115
|
+
const [emblaRef, emblaApi] = useEmblaCarousel(options)
|
|
116
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
117
|
+
const [canPrev, setCanPrev] = useState(false)
|
|
118
|
+
const [canNext, setCanNext] = useState(false)
|
|
119
|
+
const [paused, setPaused] = useState(false)
|
|
120
|
+
const [progress, setProgress] = useState(0)
|
|
121
|
+
|
|
122
|
+
const onSelect = useCallback((api) => {
|
|
123
|
+
setSelectedIndex(api.selectedScrollSnap())
|
|
124
|
+
setCanPrev(api.canScrollPrev())
|
|
125
|
+
setCanNext(api.canScrollNext())
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!emblaApi) return
|
|
130
|
+
onSelect(emblaApi)
|
|
131
|
+
emblaApi.on('select', onSelect)
|
|
132
|
+
emblaApi.on('reInit', onSelect)
|
|
133
|
+
}, [emblaApi, onSelect])
|
|
134
|
+
|
|
135
|
+
const advance = useCallback(() => emblaApi?.scrollNext(), [emblaApi])
|
|
136
|
+
|
|
137
|
+
const currentHasVideo = items[selectedIndex]?.media?.kind === 'video'
|
|
138
|
+
|
|
139
|
+
// Reset the progress bar whenever the active slide changes.
|
|
140
|
+
useEffect(() => setProgress(0), [selectedIndex])
|
|
141
|
+
|
|
142
|
+
// Image slides: one rAF loop is the source of truth for both the progress
|
|
143
|
+
// bar and the auto-advance (video slides drive their own via 'ended' /
|
|
144
|
+
// 'timeupdate', so this bails when the active slide is a video or paused).
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!autoPlay || items.length <= 1 || currentHasVideo || paused) return
|
|
147
|
+
let raf
|
|
148
|
+
const start = performance.now()
|
|
149
|
+
const tick = (now) => {
|
|
150
|
+
const p = Math.min((now - start) / autoPlayInterval, 1)
|
|
151
|
+
setProgress(p)
|
|
152
|
+
if (p >= 1) return advance()
|
|
153
|
+
raf = requestAnimationFrame(tick)
|
|
154
|
+
}
|
|
155
|
+
raf = requestAnimationFrame(tick)
|
|
156
|
+
return () => cancelAnimationFrame(raf)
|
|
157
|
+
}, [autoPlay, autoPlayInterval, items.length, selectedIndex, currentHasVideo, paused, advance])
|
|
158
|
+
|
|
159
|
+
const handleVideoTime = (e) => {
|
|
160
|
+
const { currentTime, duration } = e.currentTarget
|
|
161
|
+
if (duration) setProgress(currentTime / duration)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const renderTitleNode = (item) => {
|
|
165
|
+
if (renderTitle) return renderTitle(item)
|
|
166
|
+
if (!item.title) return null
|
|
167
|
+
return (
|
|
168
|
+
<span className={`kol-sans-display-01 text-emphasis block leading-none ${item.titleClassName || titleClassName}`.trim()}>
|
|
169
|
+
{item.title}
|
|
170
|
+
</span>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (items.length === 0) return null
|
|
175
|
+
|
|
176
|
+
const showProgress = autoPlay && items.length > 1
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<section
|
|
180
|
+
className={`kol-featured-carousel w-full ${className}`.trim()}
|
|
181
|
+
onMouseEnter={autoPlay ? () => setPaused(true) : undefined}
|
|
182
|
+
onMouseLeave={autoPlay ? () => setPaused(false) : undefined}
|
|
183
|
+
>
|
|
184
|
+
{showHeader && (
|
|
185
|
+
<div className="mb-6 flex items-center gap-4">
|
|
186
|
+
<span className="kol-label-mono-xs text-auto">{sectionLabel}</span>
|
|
187
|
+
<span className="kol-mono-12 text-fg-64">{selectedIndex + 1} / {items.length}</span>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
<div className="kol-embla is-slides">
|
|
192
|
+
<div className="relative">
|
|
193
|
+
{showProgress && (
|
|
194
|
+
<div className="absolute left-0 right-0 top-0 z-20 h-[2px] bg-fg-08">
|
|
195
|
+
<div
|
|
196
|
+
className="h-full origin-left bg-fg-64"
|
|
197
|
+
style={{ transform: `scaleX(${progress})` }}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<div className="kol-embla-viewport" ref={emblaRef}>
|
|
203
|
+
<div className="kol-embla-container">
|
|
204
|
+
{items.map((item, i) => {
|
|
205
|
+
const active = i === selectedIndex
|
|
206
|
+
return (
|
|
207
|
+
<div key={i} className="kol-embla-slide">
|
|
208
|
+
<div className={`relative overflow-hidden rounded border border-fg-08 bg-surface-secondary ${height}`}>
|
|
209
|
+
<SlideMedia
|
|
210
|
+
media={item.media}
|
|
211
|
+
onEnded={autoPlay && items.length > 1 && active ? advance : undefined}
|
|
212
|
+
onTimeUpdate={autoPlay && active ? handleVideoTime : undefined}
|
|
213
|
+
/>
|
|
214
|
+
<div className="relative z-10 flex h-full w-full items-center justify-center p-6">
|
|
215
|
+
<OverlayGlassPanel maxWidth="max-w-[600px]">
|
|
216
|
+
{renderTitleNode(item)}
|
|
217
|
+
{item.description && (
|
|
218
|
+
<p className="kol-mono-12 text-auto max-w-[600px]">{item.description}</p>
|
|
219
|
+
)}
|
|
220
|
+
{item.href && (
|
|
221
|
+
<a
|
|
222
|
+
href={item.href}
|
|
223
|
+
onClick={onNavigate ? (e) => onNavigate(item.href, e) : undefined}
|
|
224
|
+
className="kol-btn kol-btn-primary kol-btn-sm kol-mono-12"
|
|
225
|
+
>
|
|
226
|
+
{item.ctaLabel || ctaLabel}
|
|
227
|
+
</a>
|
|
228
|
+
)}
|
|
229
|
+
</OverlayGlassPanel>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
})}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<div className="kol-embla-controls">
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
|
|
243
|
+
aria-label="Previous"
|
|
244
|
+
onClick={() => emblaApi?.scrollPrev()}
|
|
245
|
+
disabled={!canPrev}
|
|
246
|
+
>‹</button>
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
|
|
250
|
+
aria-label="Next"
|
|
251
|
+
onClick={() => emblaApi?.scrollNext()}
|
|
252
|
+
disabled={!canNext}
|
|
253
|
+
>›</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</section>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import CardFeatureItem from '../molecules/CardFeatureItem.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FeaturesCardSection — the "N-up feature cards" band: an optional two-part
|
|
5
|
+
* header (heading + mono lede) over a responsive row of CardFeatureItem
|
|
6
|
+
* cards, capped by an optional centered CTA row. Cards stack column-wise
|
|
7
|
+
* below `md` and become a fixed-height row at `md`+; swap
|
|
8
|
+
* `cardsWrapperClassName` for a grid to change the layout.
|
|
9
|
+
*
|
|
10
|
+
* Fully prop-driven — no default copy, images, or routes live here; every
|
|
11
|
+
* block is presence-gated (header on `headerLabel`/`headerDescription`,
|
|
12
|
+
* cards on `features.length`, CTA row on `ctas`), so omitted slots emit no
|
|
13
|
+
* empty nodes. `ctas` is conventionally a row of KOL Buttons (composition
|
|
14
|
+
* dependency — nothing is imported here). Card links are plain anchors;
|
|
15
|
+
* pass `onNavigate` to intercept same-tab navigations in an SPA.
|
|
16
|
+
*
|
|
17
|
+
* Header, lede, and card text render exactly as authored — no casing
|
|
18
|
+
* transforms; author strings in their final case at the call site.
|
|
19
|
+
*
|
|
20
|
+
* @param {{title, icon, visual, description, href, backgroundColor, imageAspectRatio}[]} features
|
|
21
|
+
* cards rendered as CardFeatureItem molecules
|
|
22
|
+
* @param {ReactNode} headerLabel heading (kol-sans-heading-03)
|
|
23
|
+
* @param {ReactNode} headerDescription mono lede under the heading
|
|
24
|
+
* @param {ReactNode} ctas centered CTA row (a row of Buttons, authored by the caller)
|
|
25
|
+
* @param {Function} onNavigate (event, feature) => void — same-tab card-link seam (SPA intercept)
|
|
26
|
+
* @param {string} sectionClassName extra classes on the <section>
|
|
27
|
+
* @param {string} wrapperClassName outer column container
|
|
28
|
+
* @param {string} cardsWrapperClassName card row (default) or grid container
|
|
29
|
+
* @param {string} ctasClassName CTA row padding
|
|
30
|
+
* @param {string} headerClassName header container
|
|
31
|
+
* @param {string} headerTextWidthClass lede measure
|
|
32
|
+
*/
|
|
33
|
+
export default function FeaturesCardSection({
|
|
34
|
+
features = [],
|
|
35
|
+
headerLabel,
|
|
36
|
+
headerDescription,
|
|
37
|
+
ctas,
|
|
38
|
+
onNavigate,
|
|
39
|
+
sectionClassName = '',
|
|
40
|
+
wrapperClassName = 'w-full flex flex-col gap-8 md:gap-10 max-w-[1400px] mx-auto',
|
|
41
|
+
cardsWrapperClassName = 'self-stretch inline-flex flex-col md:flex-row md:h-72 justify-start items-center gap-6',
|
|
42
|
+
ctasClassName = 'pt-10 pb-24',
|
|
43
|
+
headerClassName = 'w-full pt-[224px]',
|
|
44
|
+
headerTextWidthClass = 'w-full md:w-[30%]',
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<section className={`w-full ${sectionClassName}`.trim()}>
|
|
48
|
+
<div className={wrapperClassName}>
|
|
49
|
+
{(headerLabel || headerDescription) && (
|
|
50
|
+
<div className={headerClassName}>
|
|
51
|
+
{headerLabel && (
|
|
52
|
+
<div className="flex items-center h-8">
|
|
53
|
+
<p className="kol-sans-heading-03">{headerLabel}</p>
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
{headerDescription && (
|
|
57
|
+
<p className={`kol-mono-14 text-fg-64 mt-3 ${headerTextWidthClass}`.trim()}>
|
|
58
|
+
{headerDescription}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
{features.length > 0 && (
|
|
65
|
+
<div className={cardsWrapperClassName}>
|
|
66
|
+
{features.map((feature, index) => (
|
|
67
|
+
<CardFeatureItem
|
|
68
|
+
key={index}
|
|
69
|
+
title={feature.title}
|
|
70
|
+
icon={feature.icon}
|
|
71
|
+
visual={feature.visual}
|
|
72
|
+
description={feature.description}
|
|
73
|
+
href={feature.href}
|
|
74
|
+
backgroundColor={feature.backgroundColor}
|
|
75
|
+
imageAspectRatio={feature.imageAspectRatio}
|
|
76
|
+
onNavigate={onNavigate ? (event) => onNavigate(event, feature) : undefined}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{ctas && (
|
|
83
|
+
<div className={`w-full flex flex-wrap items-center justify-center gap-4 ${ctasClassName}`.trim()}>
|
|
84
|
+
{ctas}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { isValidElement } from 'react'
|
|
2
|
+
import HlsVideo from '../atoms/HlsVideo.jsx'
|
|
3
|
+
import Image from '../molecules/Image.jsx'
|
|
4
|
+
|
|
5
|
+
/** Height presets; anything else is passed through as a class string. */
|
|
6
|
+
const HEIGHTS = {
|
|
7
|
+
screen: 'h-screen',
|
|
8
|
+
lg: 'h-[440px] md:h-[640px]',
|
|
9
|
+
md: 'h-[320px] md:h-[440px]',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Background layer: a media descriptor becomes a cover-fit Image / HlsVideo /
|
|
14
|
+
* inert <video> (`.kol-full-bleed-hero-media` pins it absolute + object-cover
|
|
15
|
+
* — CSS in @kol/theme); a ready ReactNode renders as-is and must position
|
|
16
|
+
* itself (give it that same class).
|
|
17
|
+
*/
|
|
18
|
+
function MediaLayer({ media }) {
|
|
19
|
+
if (!media) return null
|
|
20
|
+
if (isValidElement(media) || typeof media !== 'object') return media
|
|
21
|
+
|
|
22
|
+
const { src, kind = 'image', poster, srcSet, alt = '' } = media
|
|
23
|
+
|
|
24
|
+
if (kind === 'video') {
|
|
25
|
+
// Non-HLS file (mp4/webm): a plain inert video element. HLS manifests —
|
|
26
|
+
// and the poster-only case — go through the HlsVideo atom, which owns
|
|
27
|
+
// hls.js attachment and the full non-interactive hardening set.
|
|
28
|
+
if (src && !src.endsWith('.m3u8')) {
|
|
29
|
+
return (
|
|
30
|
+
<video
|
|
31
|
+
src={src}
|
|
32
|
+
poster={poster}
|
|
33
|
+
className="kol-full-bleed-hero-media"
|
|
34
|
+
style={{ pointerEvents: 'none' }}
|
|
35
|
+
autoPlay
|
|
36
|
+
loop
|
|
37
|
+
muted
|
|
38
|
+
playsInline
|
|
39
|
+
controls={false}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
return <HlsVideo src={src} poster={poster} className="kol-full-bleed-hero-media" />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Image
|
|
48
|
+
src={src}
|
|
49
|
+
srcSet={srcSet}
|
|
50
|
+
sizes={srcSet ? '100vw' : undefined}
|
|
51
|
+
alt={alt}
|
|
52
|
+
loading="eager"
|
|
53
|
+
className="kol-full-bleed-hero-media"
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* FullBleedHero — full-bleed media hero: cover-fit background media (image or
|
|
60
|
+
* video) filling a fixed-height section, an optional surface scrim, and one
|
|
61
|
+
* content slot laid over it. The third member of the hero family — BrandHero
|
|
62
|
+
* and SubPageHero are band heroes; this is the media one. The canonical
|
|
63
|
+
* recipe drops an OverlayGlassPanel into `panel`; that recipe with
|
|
64
|
+
* `kind: 'video'` + `height="screen"` IS the monorepo's StudioHero, folded in
|
|
65
|
+
* here as a capability rather than a separate component.
|
|
66
|
+
*
|
|
67
|
+
* Presentational — no copy, no CDN paths, no routes. All text is authored
|
|
68
|
+
* inside the caller's panel/children. The source's `opacity-${n}` dynamic
|
|
69
|
+
* Tailwind class (JIT-invisible) is replaced by the scrim's inline style; the
|
|
70
|
+
* source's image self-dim (`imageOpacity={40}`) is expressed here as
|
|
71
|
+
* `overlayOpacity={60}` — a surface-primary veil over full-opacity media.
|
|
72
|
+
*
|
|
73
|
+
* @param {ReactNode|{src, kind, poster, srcSet, alt}} media background — descriptor ({ kind: 'image'|'video' }) or a self-positioning node
|
|
74
|
+
* @param {number} overlayOpacity 0–100 surface-primary scrim over the media (inline opacity; default 0 = none)
|
|
75
|
+
* @param {string} height 'screen' | 'lg' | 'md' preset, or any Tailwind height class string
|
|
76
|
+
* @param {ReactNode} panel content slot, usually a caller-authored OverlayGlassPanel; wins over children
|
|
77
|
+
* @param {ReactNode} children fallback content slot when `panel` is omitted
|
|
78
|
+
* @param {string} align 'center' | 'start' | 'end' — horizontal placement of the content (vertically centered)
|
|
79
|
+
* @param {string} className extra classes on the section
|
|
80
|
+
*/
|
|
81
|
+
export default function FullBleedHero({
|
|
82
|
+
media,
|
|
83
|
+
overlayOpacity = 0,
|
|
84
|
+
height = 'lg',
|
|
85
|
+
panel,
|
|
86
|
+
children,
|
|
87
|
+
align = 'center',
|
|
88
|
+
className = '',
|
|
89
|
+
}) {
|
|
90
|
+
const heightCls = HEIGHTS[height] || height
|
|
91
|
+
const alignCls =
|
|
92
|
+
align === 'start' ? 'justify-start'
|
|
93
|
+
: align === 'end' ? 'justify-end'
|
|
94
|
+
: 'justify-center'
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<section className={`kol-full-bleed-hero relative isolate w-full overflow-hidden ${heightCls} ${className}`.trim()}>
|
|
98
|
+
<MediaLayer media={media} />
|
|
99
|
+
{overlayOpacity > 0 && (
|
|
100
|
+
<div
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
className="absolute inset-0"
|
|
103
|
+
style={{ background: 'var(--kol-surface-primary)', opacity: overlayOpacity / 100 }}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
<div className={`relative z-10 flex h-full w-full items-center ${alignCls} p-6 md:p-10`}>
|
|
107
|
+
{panel ?? children}
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import Carousel from './Carousel.jsx'
|
|
3
|
+
import MediaViewer from './MediaViewer.jsx'
|
|
4
|
+
|
|
5
|
+
/* Drag-vs-click guard: DS Carousel owns Embla's drag physics but does NOT
|
|
6
|
+
* forward a click-allowed signal, so a free-scroll drag that ends on a tile
|
|
7
|
+
* would otherwise fire its onClick and open the viewer. This 5px pointer-move
|
|
8
|
+
* threshold suppresses that click — self-contained, no Embla api needed. */
|
|
9
|
+
const DRAG_THRESHOLD_SQ = 25
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GalleryCarousel — the project-detail media gallery: a horizontal, drag-scroll
|
|
13
|
+
* shelf of image/video tiles where clicking a tile opens THE shared fullscreen
|
|
14
|
+
* MediaViewer starting at that tile.
|
|
15
|
+
*
|
|
16
|
+
* Composed from DS pieces, not forked: the shelf is DS **Carousel** in its
|
|
17
|
+
* default `dragFree` mode; the fullscreen stage is DS **MediaViewer** (the one
|
|
18
|
+
* paged viewer every lightbox collapses onto) opened at the clicked index. The
|
|
19
|
+
* source's bespoke Embla wiring + `WheelGesturesPlugin` + hand-rolled
|
|
20
|
+
* `internalEngine()` drag guard are dropped; a lightweight pointer-distance
|
|
21
|
+
* guard keeps a drag from opening the viewer.
|
|
22
|
+
*
|
|
23
|
+
* Media is normalized (no Sanity): each item `{ url, kind: 'image'|'video',
|
|
24
|
+
* aspect?, alt? }`. `kind` replaces the `_type` sniff; `aspect` replaces the
|
|
25
|
+
* authored aspect-ratio string ladder (fallback `defaultAspect`). Videos
|
|
26
|
+
* autoplay muted/looped inline in the track.
|
|
27
|
+
*
|
|
28
|
+
* @param {Array} media [{ url, kind: 'image'|'video', aspect?, alt? }]
|
|
29
|
+
* @param {string} title alt-text fallback (`${title} ${i+1}`)
|
|
30
|
+
* @param {number} defaultAspect tile aspect when an item omits `aspect` (default 0.8)
|
|
31
|
+
* @param {string} className extra classes on the section
|
|
32
|
+
*/
|
|
33
|
+
export default function GalleryCarousel({ media = [], title = '', defaultAspect = 0.8, className = '' }) {
|
|
34
|
+
const [viewerIndex, setViewerIndex] = useState(null)
|
|
35
|
+
const pointerStart = useRef({ x: 0, y: 0 })
|
|
36
|
+
const dragged = useRef(false)
|
|
37
|
+
|
|
38
|
+
if (!media.length) return null
|
|
39
|
+
|
|
40
|
+
const onPointerDown = (e) => {
|
|
41
|
+
dragged.current = false
|
|
42
|
+
pointerStart.current = { x: e.clientX, y: e.clientY }
|
|
43
|
+
}
|
|
44
|
+
const onPointerMove = (e) => {
|
|
45
|
+
if (dragged.current) return
|
|
46
|
+
const dx = e.clientX - pointerStart.current.x
|
|
47
|
+
const dy = e.clientY - pointerStart.current.y
|
|
48
|
+
if (dx * dx + dy * dy > DRAG_THRESHOLD_SQ) dragged.current = true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section className={className}>
|
|
53
|
+
<Carousel>
|
|
54
|
+
{media.map((item, i) => {
|
|
55
|
+
const aspect = item.aspect ?? defaultAspect
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
key={item.url || i}
|
|
59
|
+
className="w-full overflow-hidden rounded-[2px] cursor-pointer"
|
|
60
|
+
style={{ aspectRatio: aspect }}
|
|
61
|
+
onPointerDown={onPointerDown}
|
|
62
|
+
onPointerMove={onPointerMove}
|
|
63
|
+
onClick={() => { if (!dragged.current) setViewerIndex(i) }}
|
|
64
|
+
>
|
|
65
|
+
{item.kind === 'video' ? (
|
|
66
|
+
<video src={item.url} autoPlay muted playsInline loop className="w-full h-full object-cover" />
|
|
67
|
+
) : (
|
|
68
|
+
<img src={item.url} alt={item.alt || `${title} ${i + 1}`} className="w-full h-full object-cover" />
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
})}
|
|
73
|
+
</Carousel>
|
|
74
|
+
|
|
75
|
+
<MediaViewer
|
|
76
|
+
open={viewerIndex !== null}
|
|
77
|
+
media={media}
|
|
78
|
+
initialIndex={viewerIndex ?? 0}
|
|
79
|
+
onClose={() => setViewerIndex(null)}
|
|
80
|
+
/>
|
|
81
|
+
</section>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
|
|
2
|
+
import ColorLoader from './ColorLoader.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* LoaderOverlay — mounts a loading curtain over everything, dead center. A thin
|
|
6
|
+
* composition: it wraps DS FullscreenOverlay for the fixed, top-`z`,
|
|
7
|
+
* scroll-locking backdrop (replacing the app's hand-rolled `fixed inset-0 …
|
|
8
|
+
* z-[100]` div and its `CursorProvider`), then renders `children` — or a
|
|
9
|
+
* default ColorLoader — as the overlay content. Being mounted IS being visible;
|
|
10
|
+
* the parent removes this element to dismiss.
|
|
11
|
+
*
|
|
12
|
+
* The default ColorLoader is a full-viewport `dismissOnClick` curtain whose
|
|
13
|
+
* completion is wired to `onEnter`. A `fixed inset-0` box escapes
|
|
14
|
+
* FullscreenOverlay's centered, `--kol-container-max`-width sheet so the loader
|
|
15
|
+
* fills the screen. Pass `children` to drop that and mount anything else.
|
|
16
|
+
*
|
|
17
|
+
* @param {ReactNode} children overlay content; defaults to a full-screen ColorLoader
|
|
18
|
+
* @param {() => void} onEnter fired when the default ColorLoader completes/dismisses
|
|
19
|
+
*/
|
|
20
|
+
export default function LoaderOverlay({ children, onEnter }) {
|
|
21
|
+
return (
|
|
22
|
+
<FullscreenOverlay open>
|
|
23
|
+
{children || (
|
|
24
|
+
<div className="fixed inset-0">
|
|
25
|
+
<ColorLoader onComplete={onEnter} dismissOnClick />
|
|
26
|
+
</div>
|
|
27
|
+
)}
|
|
28
|
+
</FullscreenOverlay>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
3
|
+
import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MediaViewer — THE fullscreen paged media viewer. One viewer for every
|
|
7
|
+
* lightbox/gallery opener (the monorepo's ImageLightbox, FullscreenGallery
|
|
8
|
+
* and WorkDetail's inline gallery all collapse onto this).
|
|
9
|
+
*
|
|
10
|
+
* The shell (scrim, scroll-lock, Escape, backdrop-close, close button) is
|
|
11
|
+
* FullscreenOverlay — not reimplemented. This adds paging: prev/next chips,
|
|
12
|
+
* arrow keys, touch swipe, wrap-around.
|
|
13
|
+
*
|
|
14
|
+
* ponytail: paging is ~20 lines of state + swipe, so it's inline rather than
|
|
15
|
+
* composing Carousel — embla's drag physics buy nothing for a one-item stage;
|
|
16
|
+
* revisit if momentum/preview-of-next is ever wanted.
|
|
17
|
+
*
|
|
18
|
+
* @param {boolean} open viewer visible
|
|
19
|
+
* @param {Array} media [{ url, alt?, kind: 'image' | 'video' }]
|
|
20
|
+
* @param {number} initialIndex item shown on open
|
|
21
|
+
* @param {Function} onClose close request
|
|
22
|
+
* @param {Function} onIndexChange fires with the new index on page
|
|
23
|
+
*/
|
|
24
|
+
export default function MediaViewer({ open, media = [], initialIndex = 0, onClose, onIndexChange }) {
|
|
25
|
+
const [index, setIndex] = useState(initialIndex)
|
|
26
|
+
const touchStart = useRef(null)
|
|
27
|
+
|
|
28
|
+
// re-anchor to initialIndex each time the viewer opens
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (open) setIndex(initialIndex)
|
|
31
|
+
}, [open, initialIndex])
|
|
32
|
+
|
|
33
|
+
const page = (delta) => {
|
|
34
|
+
if (media.length < 2) return
|
|
35
|
+
setIndex((i) => {
|
|
36
|
+
const next = (i + delta + media.length) % media.length
|
|
37
|
+
onIndexChange?.(next)
|
|
38
|
+
return next
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) return
|
|
44
|
+
const onKey = (e) => {
|
|
45
|
+
if (e.key === 'ArrowLeft') page(-1)
|
|
46
|
+
if (e.key === 'ArrowRight') page(1)
|
|
47
|
+
}
|
|
48
|
+
document.addEventListener('keydown', onKey)
|
|
49
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
50
|
+
}, [open, media.length]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
51
|
+
|
|
52
|
+
const item = media[index]
|
|
53
|
+
if (!open || !item) return null
|
|
54
|
+
|
|
55
|
+
const onTouchStart = (e) => { touchStart.current = e.touches[0].clientX }
|
|
56
|
+
const onTouchEnd = (e) => {
|
|
57
|
+
if (touchStart.current == null) return
|
|
58
|
+
const delta = e.changedTouches[0].clientX - touchStart.current
|
|
59
|
+
if (delta > 50) page(-1)
|
|
60
|
+
if (delta < -50) page(1)
|
|
61
|
+
touchStart.current = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const chip = 'absolute top-1/2 -translate-y-1/2 z-10 hidden md:flex items-center justify-center w-10 h-10 rounded-full bg-fg-04 hover:bg-fg-08 transition-colors cursor-pointer border-0'
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<FullscreenOverlay open={open} onClose={onClose}>
|
|
68
|
+
<div className="relative flex h-full w-full items-center justify-center" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
|
|
69
|
+
{media.length > 1 && (
|
|
70
|
+
<button type="button" className={`${chip} left-4`} aria-label="Previous" onClick={() => page(-1)}>
|
|
71
|
+
<Icon name="chevron-left" size={20} />
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
{item.kind === 'video' ? (
|
|
75
|
+
<video
|
|
76
|
+
key={item.url}
|
|
77
|
+
src={item.url}
|
|
78
|
+
autoPlay
|
|
79
|
+
muted
|
|
80
|
+
loop
|
|
81
|
+
playsInline
|
|
82
|
+
className="max-h-[85vh] max-w-full object-contain"
|
|
83
|
+
/>
|
|
84
|
+
) : (
|
|
85
|
+
<img src={item.url} alt={item.alt || ''} className="max-h-[85vh] max-w-full object-contain" />
|
|
86
|
+
)}
|
|
87
|
+
{media.length > 1 && (
|
|
88
|
+
<button type="button" className={`${chip} right-4`} aria-label="Next" onClick={() => page(1)}>
|
|
89
|
+
<Icon name="chevron-right" size={20} />
|
|
90
|
+
</button>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</FullscreenOverlay>
|
|
94
|
+
)
|
|
95
|
+
}
|