@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.
Files changed (77) hide show
  1. package/package.json +11 -3
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/atoms/Button.jsx +17 -6
  5. package/src/atoms/CurveOverlay.jsx +180 -0
  6. package/src/atoms/DocsToc.jsx +48 -0
  7. package/src/atoms/EmptyState.jsx +22 -0
  8. package/src/atoms/Figure.jsx +27 -0
  9. package/src/atoms/HlsVideo.jsx +56 -0
  10. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  11. package/src/atoms/PriceDisplay.jsx +34 -0
  12. package/src/atoms/ProsePreview.jsx +53 -0
  13. package/src/atoms/QuantityInput.jsx +76 -114
  14. package/src/atoms/RotaryDial.jsx +150 -0
  15. package/src/atoms/SearchInput.jsx +108 -0
  16. package/src/atoms/TextPressure.jsx +331 -0
  17. package/src/atoms/TiltCard.jsx +127 -0
  18. package/src/atoms/TypeSample.jsx +50 -0
  19. package/src/atoms/TypeSpecCard.jsx +42 -0
  20. package/src/hooks/cssVar.js +53 -0
  21. package/src/hooks/useAxisAnimation.js +91 -0
  22. package/src/hooks/usePrefersReducedMotion.js +21 -0
  23. package/src/hooks/useTilt.js +49 -0
  24. package/src/index.js +66 -2
  25. package/src/molecules/AlignmentGrid.jsx +53 -0
  26. package/src/molecules/ArticleCard.jsx +178 -0
  27. package/src/molecules/CardFeatureItem.jsx +130 -0
  28. package/src/molecules/ColorInputRow.jsx +179 -0
  29. package/src/molecules/ColorRamp.jsx +114 -0
  30. package/src/molecules/FramedMediaBand.jsx +56 -0
  31. package/src/molecules/ImageBlock.jsx +34 -0
  32. package/src/molecules/MenuPopover.jsx +4 -27
  33. package/src/molecules/SelectionOverlay.jsx +108 -0
  34. package/src/molecules/ShapeDropdown.jsx +92 -0
  35. package/src/molecules/ShellDrawer.jsx +169 -0
  36. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  37. package/src/molecules/SpecList.jsx +30 -0
  38. package/src/molecules/SpectrumControls.jsx +504 -0
  39. package/src/molecules/SwatchControls.jsx +217 -0
  40. package/src/molecules/TabsRow.jsx +87 -0
  41. package/src/molecules/VideoBlock.jsx +86 -0
  42. package/src/molecules/WorkListItem.jsx +83 -0
  43. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  44. package/src/organisms/ArticleHeader.jsx +95 -0
  45. package/src/organisms/AsciiCursor.jsx +526 -0
  46. package/src/organisms/BentoCard.jsx +187 -0
  47. package/src/organisms/Canvas.jsx +299 -0
  48. package/src/organisms/ColorLoader.jsx +155 -0
  49. package/src/organisms/CtaGlobal.jsx +67 -0
  50. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  51. package/src/organisms/EditorShell.jsx +111 -0
  52. package/src/organisms/ErrorBoundary.jsx +70 -0
  53. package/src/organisms/FeatureSplit.jsx +82 -0
  54. package/src/organisms/FeaturedCarousel.jsx +258 -0
  55. package/src/organisms/FeaturesCardSection.jsx +90 -0
  56. package/src/organisms/FullBleedHero.jsx +111 -0
  57. package/src/organisms/GalleryCarousel.jsx +83 -0
  58. package/src/organisms/LoaderOverlay.jsx +30 -0
  59. package/src/organisms/MediaViewer.jsx +95 -0
  60. package/src/organisms/NewsletterBand.jsx +122 -0
  61. package/src/organisms/ParallaxShelf.jsx +141 -0
  62. package/src/organisms/PortableTextRenderer.jsx +115 -0
  63. package/src/organisms/ProductDetailLayout.jsx +189 -0
  64. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  65. package/src/organisms/SpectrumGrid.jsx +90 -0
  66. package/src/organisms/StackHero.jsx +83 -0
  67. package/src/organisms/WorkCard.jsx +120 -0
  68. package/src/organisms/WorkViewToggle.jsx +170 -0
  69. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  70. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  71. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  72. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  73. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  74. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  75. package/src/organisms/foundry/glyphData.js +30 -0
  76. package/src/organisms/foundry/index.js +21 -0
  77. 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
+ }