@kolkrabbi/kol-component 0.6.0 → 0.8.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 (38) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -3
  3. package/src/atoms/RotaryDial.jsx +35 -21
  4. package/src/index.js +6 -18
  5. package/src/molecules/ButtonGroup.jsx +45 -0
  6. package/src/molecules/ColorInputRow.jsx +146 -126
  7. package/src/molecules/Slider.jsx +29 -14
  8. package/src/molecules/SplitToolButton.jsx +133 -0
  9. package/src/organisms/ContentFilters.jsx +2 -2
  10. package/src/organisms/GalleryCarousel.jsx +2 -1
  11. package/src/organisms/LoaderOverlay.jsx +11 -14
  12. package/src/organisms/MediaTileGallery.jsx +57 -0
  13. package/src/organisms/MediaViewer.jsx +89 -64
  14. package/src/atoms/PriceDisplay.jsx +0 -34
  15. package/src/atoms/TextPressure.jsx +0 -331
  16. package/src/atoms/TypeSample.jsx +0 -50
  17. package/src/atoms/TypeSpecCard.jsx +0 -42
  18. package/src/molecules/ArticleCard.jsx +0 -178
  19. package/src/molecules/WorkListItem.jsx +0 -83
  20. package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
  21. package/src/organisms/ArticleHeader.jsx +0 -95
  22. package/src/organisms/ColorLoader.jsx +0 -155
  23. package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
  24. package/src/organisms/ParallaxShelf.jsx +0 -141
  25. package/src/organisms/PortableTextRenderer.jsx +0 -115
  26. package/src/organisms/ProductDetailLayout.jsx +0 -189
  27. package/src/organisms/ScrollDriftGallery.jsx +0 -214
  28. package/src/organisms/StackHero.jsx +0 -83
  29. package/src/organisms/WorkCard.jsx +0 -120
  30. package/src/organisms/WorkViewToggle.jsx +0 -170
  31. package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
  32. package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
  33. package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
  34. package/src/organisms/foundry/TypefaceHero.jsx +0 -107
  35. package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
  36. package/src/organisms/foundry/VariableFontSection.jsx +0 -158
  37. package/src/organisms/foundry/glyphData.js +0 -30
  38. package/src/organisms/foundry/index.js +0 -21
@@ -1,115 +0,0 @@
1
- import { Fragment } from 'react'
2
- import CodeBlock from '../molecules/CodeBlock.jsx'
3
- import ImageBlock from '../molecules/ImageBlock.jsx'
4
- import VideoBlock from '../molecules/VideoBlock.jsx'
5
- import Table from './Table.jsx'
6
- import Divider from '../atoms/Divider.jsx'
7
-
8
- /**
9
- * slugify — heading text → anchor id (lowercase, non-word stripped, spaces to
10
- * dashes, repeats collapsed). Feeds the h2/h3 `id`s that power in-page anchors
11
- * / a table of contents. Kept from the source; it is the one bit of real logic.
12
- */
13
- export function slugify(text = '') {
14
- return String(text)
15
- .toLowerCase()
16
- .replace(/[^\w\s-]/g, '')
17
- .replace(/\s+/g, '-')
18
- .replace(/-+/g, '-')
19
- .trim()
20
- }
21
-
22
- /* ── Inline marks ──────────────────────────────────────────────────────────
23
- * A block's `text` is either a plain string or an array of inline nodes:
24
- * 'plain text'
25
- * { mark: 'link', href, text } → <a> (http* → new tab + noopener)
26
- * { mark: 'segmentTitle', text } → <span class="kol-segment-title">
27
- */
28
- function renderInline(text) {
29
- if (text == null) return null
30
- const nodes = Array.isArray(text) ? text : [text]
31
- return nodes.map((node, i) => {
32
- if (typeof node === 'string') return <Fragment key={i}>{node}</Fragment>
33
- if (node.mark === 'link') {
34
- const external = (node.href || '').startsWith('http')
35
- return (
36
- <a
37
- key={i}
38
- href={node.href}
39
- target={external ? '_blank' : undefined}
40
- rel={external ? 'noopener noreferrer' : undefined}
41
- >
42
- {node.text}
43
- </a>
44
- )
45
- }
46
- if (node.mark === 'segmentTitle') {
47
- return <span key={i} className="kol-segment-title">{node.text}</span>
48
- }
49
- return <Fragment key={i}>{node.text}</Fragment>
50
- })
51
- }
52
-
53
- const plainText = (text) =>
54
- Array.isArray(text) ? text.map((n) => (typeof n === 'string' ? n : n.text)).join('') : String(text ?? '')
55
-
56
- /* ── Block registry ────────────────────────────────────────────────────────
57
- * Maps a plain { type, ... } block to a DS component / prose tag. This is the
58
- * de-Sanitized counterpart to the app's Portable-Text components map: generic
59
- * block-type names (`heading`/`quote`/`divider`/`table`/`video`), no CMS
60
- * schema keys, no @portabletext/react host.
61
- */
62
- const BLOCKS = {
63
- heading: ({ level = 2, text, id }) => {
64
- const Tag = `h${level}`
65
- const anchorId = id ?? (level === 2 || level === 3 ? slugify(plainText(text)) : undefined)
66
- return <Tag id={anchorId}>{renderInline(text)}</Tag>
67
- },
68
- paragraph: ({ text }) => <p>{renderInline(text)}</p>,
69
- caption: ({ text }) => <p className="caption">{renderInline(text)}</p>,
70
- quote: ({ text, cite }) => (
71
- <blockquote>
72
- <p>{renderInline(text)}</p>
73
- {cite && <cite>{cite}</cite>}
74
- </blockquote>
75
- ),
76
- code: ({ language, code }) => <CodeBlock language={language}>{code}</CodeBlock>,
77
- divider: () => <Divider className="my-10" />,
78
- image: (block) => <ImageBlock {...block} />,
79
- video: (block) => <VideoBlock {...block} />,
80
- table: ({ columns, rows, caption }) => <Table columns={columns} rows={rows} caption={caption} variant="simple" />,
81
- list: ({ style = 'bullet', items = [] }) => {
82
- const Tag = style === 'number' ? 'ol' : 'ul'
83
- return <Tag>{items.map((item, i) => <li key={i}>{renderInline(item)}</li>)}</Tag>
84
- },
85
- }
86
-
87
- /**
88
- * PortableTextRenderer — turns a plain block array into `.kol-prose`-styled
89
- * long-form markup via one block-type registry. The design system's CMS
90
- * renderer (the counterpart to ProseStylesViewer, which only *showcases* the
91
- * prose styles) — both plug into the same `.kol-prose` stylesheet.
92
- *
93
- * The block shape is a simple `{ type, ... }` — NOT Sanity Portable Text and
94
- * with no `@portabletext/react` host. Custom blocks (`image`/`video`/`table`/
95
- * `code`/`divider`) resolve to DS components; text blocks render bare tags the
96
- * `.kol-prose` sheet styles. Inline `link`/`segmentTitle` marks are supported
97
- * via the array `text` form (see `renderInline`). Unknown types are skipped.
98
- *
99
- * All content — headings, paragraphs, captions — is authored as-is; the map
100
- * applies no `text-transform`.
101
- *
102
- * @param {Array<{type:string}>} blocks the document, in order
103
- * @param {boolean} prose wrap output in a `.kol-prose` container (default true)
104
- * @param {string} className extra classes on the wrapper
105
- */
106
- export default function PortableTextRenderer({ blocks = [], prose = true, className = '' }) {
107
- const rendered = blocks.map((block, i) => {
108
- const render = BLOCKS[block?.type]
109
- if (!render) return null
110
- return <Fragment key={block.key ?? i}>{render(block)}</Fragment>
111
- })
112
-
113
- if (!prose) return <>{rendered}</>
114
- return <div className={`kol-prose ${className}`.trim()}>{rendered}</div>
115
- }
@@ -1,189 +0,0 @@
1
- import { useState } from 'react'
2
- import PriceDisplay from '../atoms/PriceDisplay.jsx'
3
- import Pill from '../atoms/Pill.jsx'
4
- import Divider from '../atoms/Divider.jsx'
5
- import QuantityInput from '../atoms/QuantityInput.jsx'
6
- import SpecList from '../molecules/SpecList.jsx'
7
- import TabsRow from '../molecules/TabsRow.jsx'
8
- import Dropdown from '../molecules/Dropdown.jsx'
9
-
10
- /**
11
- * ProductDetailLayout — the pure two-column PDP skeleton: a full-height media
12
- * gallery beside a scrollable details column (eyebrow + title + tags, a
13
- * SpecList of key facts, an underline TabsRow with its panel, then a purchase
14
- * block of price / size / quantity / CTA / fine print / back link).
15
- *
16
- * Presentational and commerce-agnostic — the print-store source's PayPal URL,
17
- * `print.*` reads, Sanity image normalization and route wiring are all dropped.
18
- * Data enters through props; the DS parts (PriceDisplay, SpecList, TabsRow,
19
- * QuantityInput, Dropdown) are composed here, everything else is a slot. Size,
20
- * quantity and the active tab are owned internally (uncontrolled), seeded from
21
- * props; pass the change callbacks to observe them.
22
- *
23
- * @param {ReactNode[]} mediaItems gallery frames (rendered nodes, e.g. inline SVG); drives the internal big-frame + thumbnail strip
24
- * @param {ReactNode} media single media slot used when `mediaItems` is empty
25
- * @param {ReactNode} eyebrow category caption above the title (authored verbatim)
26
- * @param {ReactNode} title product name — rendered kol-heading-md (author case at the call site)
27
- * @param {string[]} tags optional Pill row
28
- * @param {Array<{label,value}>} specs key-facts rows → SpecList
29
- * @param {Array<{id,label,content}>} tabs tab strip (→ TabsRow) + panel content per tab
30
- * @param {{amount:number,currency?:string,locale?:string,secondary?:ReactNode}} price → PriceDisplay
31
- * @param {string[]} sizeOptions size <select> options → Dropdown (default ['A3'])
32
- * @param {string} defaultSize initially-selected size (default sizeOptions[0])
33
- * @param {Function} onSizeChange (value) => void — fires on size select
34
- * @param {number} defaultQuantity initial qty (default 1)
35
- * @param {number} minQuantity qty floor (default 1)
36
- * @param {number} maxQuantity qty ceiling (default 10)
37
- * @param {Function} onQuantityChange (value) => void — fires on qty step
38
- * @param {ReactNode} actions CTA slot — pass the "Add to cart" Button
39
- * @param {ReactNode} shippingNote fine-print line under the CTA
40
- * @param {ReactNode} backLink back-to-catalog link slot
41
- */
42
- export default function ProductDetailLayout({
43
- mediaItems = [],
44
- media,
45
- eyebrow,
46
- title,
47
- tags = [],
48
- specs = [],
49
- tabs = [],
50
- price,
51
- sizeOptions = ['A3'],
52
- defaultSize,
53
- onSizeChange,
54
- defaultQuantity = 1,
55
- minQuantity = 1,
56
- maxQuantity = 10,
57
- onQuantityChange,
58
- actions,
59
- shippingNote,
60
- backLink,
61
- }) {
62
- const [activeMedia, setActiveMedia] = useState(0)
63
- const [size, setSize] = useState(defaultSize ?? sizeOptions[0])
64
- const [quantity, setQuantity] = useState(defaultQuantity)
65
- const [activeTab, setActiveTab] = useState(tabs[0]?.id)
66
-
67
- const activeTabItem = tabs.find((t) => t.id === activeTab) ?? tabs[0]
68
-
69
- const handleSize = (value) => {
70
- setSize(value)
71
- onSizeChange?.(value)
72
- }
73
- const handleQuantity = (value) => {
74
- setQuantity(value)
75
- onQuantityChange?.(value)
76
- }
77
-
78
- return (
79
- <main className="min-h-screen w-full overflow-x-hidden bg-surface-primary text-auto">
80
- <section className="grid h-dvh w-full gap-0 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
81
- {/* Media column */}
82
- <div className="relative flex h-dvh bg-surface-secondary">
83
- <div className="relative flex-1 flex items-center justify-center px-8 py-24 lg:px-16 lg:py-32 overflow-hidden">
84
- {mediaItems.length > 0 ? (
85
- <div className="flex max-h-full max-w-full items-center justify-center">
86
- {mediaItems[activeMedia] ?? mediaItems[0]}
87
- </div>
88
- ) : (
89
- media
90
- )}
91
-
92
- {mediaItems.length > 1 && (
93
- <div className="absolute inset-x-0 bottom-0 flex gap-3 overflow-x-auto bg-surface-primary/80 px-6 py-4">
94
- {mediaItems.map((item, index) => {
95
- const isActive = index === activeMedia
96
- return (
97
- <button
98
- key={index}
99
- type="button"
100
- aria-label={`View image ${index + 1}`}
101
- aria-pressed={isActive}
102
- onClick={() => setActiveMedia(index)}
103
- className={`h-20 aspect-[4/5] overflow-hidden rounded border-2 transition-all ${
104
- isActive
105
- ? 'border-auto opacity-100'
106
- : 'border-transparent opacity-40 hover:opacity-100'
107
- }`}
108
- >
109
- <span className="flex size-full items-center justify-center overflow-hidden">
110
- {item}
111
- </span>
112
- </button>
113
- )
114
- })}
115
- </div>
116
- )}
117
- </div>
118
- </div>
119
-
120
- {/* Details column */}
121
- <div className="h-dvh flex flex-col text-left px-6 pt-20 pb-8 sm:px-10 lg:px-16 lg:pt-24 lg:pb-12 bg-surface-primary overflow-y-auto">
122
- <div className="mx-auto flex flex-1 w-full max-w-[640px] flex-col gap-6">
123
- {/* Header */}
124
- <header className="space-y-4">
125
- {eyebrow != null && <p className="kol-helper-uc-xs text-accent-primary">{eyebrow}</p>}
126
- {title != null && <h1 className="kol-heading-md">{title}</h1>}
127
- {tags.length > 0 && (
128
- <div className="flex flex-wrap gap-2">
129
- {tags.map((tag) => (
130
- <Pill key={tag} variant="subtle" size="sm">
131
- {tag}
132
- </Pill>
133
- ))}
134
- </div>
135
- )}
136
- </header>
137
-
138
- {/* Specs */}
139
- {specs.length > 0 && <SpecList items={specs} framed />}
140
-
141
- {/* Tabs */}
142
- {tabs.length > 0 && (
143
- <div className="space-y-4">
144
- <div className="border-b border-auto">
145
- <TabsRow tabs={tabs.map(({ id, label }) => ({ id, label }))} value={activeTab} onChange={setActiveTab} />
146
- </div>
147
- <div role="tabpanel" className="kol-mono-text text-fg-64 leading-relaxed space-y-4">
148
- {activeTabItem?.content}
149
- </div>
150
- </div>
151
- )}
152
-
153
- {/* Purchase block */}
154
- <div className="mt-auto space-y-6 border-t border-auto pt-6">
155
- {price != null && <PriceDisplay {...price} />}
156
-
157
- <div className="grid gap-4 sm:grid-cols-2">
158
- <div className="space-y-2">
159
- <span className="kol-helper-uc-xs text-fg-48">Size</span>
160
- <Dropdown
161
- options={sizeOptions.map((s) => ({ label: s, value: s }))}
162
- value={size}
163
- onChange={handleSize}
164
- className="w-full"
165
- />
166
- </div>
167
- <div className="space-y-2">
168
- <span className="kol-helper-uc-xs text-fg-48">Quantity</span>
169
- <QuantityInput
170
- value={quantity}
171
- onChange={handleQuantity}
172
- min={minQuantity}
173
- max={maxQuantity}
174
- className="w-full"
175
- />
176
- </div>
177
- </div>
178
-
179
- {actions}
180
-
181
- {shippingNote != null && <p className="kol-mono-xs text-fg-48">{shippingNote}</p>}
182
- {backLink}
183
- </div>
184
- </div>
185
- </div>
186
- </section>
187
- </main>
188
- )
189
- }
@@ -1,214 +0,0 @@
1
- import { useEffect, useMemo, useRef } from 'react'
2
- import { gsap } from 'gsap'
3
- import { ScrollTrigger } from 'gsap/ScrollTrigger'
4
- import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
5
-
6
- gsap.registerPlugin(ScrollTrigger)
7
-
8
- const DEFAULT_BG_KEYFRAMES = [
9
- { backgroundColor: '#000000', duration: 0 },
10
- { backgroundColor: '#0a0a08', duration: 0.15 },
11
- { backgroundColor: '#1a1812', duration: 0.3 },
12
- { backgroundColor: '#f5f0e8', duration: 0.5 }, // warm cream
13
- { backgroundColor: '#e8df00', duration: 0.7 }, // acid yellow
14
- { backgroundColor: '#c8ff00', duration: 0.85 }, // electric chartreuse
15
- { backgroundColor: '#0a0a0a', duration: 1 }, // back to black
16
- ]
17
-
18
- // Deterministic per-index layout — depends only on index + count, so it
19
- // survives without any per-item data.
20
- function layoutFor(index, cardSpacing) {
21
- const seed = (index * 7 + 3) % 24
22
- const row = index % 3
23
- const baseY = [8, 35, 62][row] // top / middle / bottom (vh %)
24
- const y = baseY + ((seed % 7) - 3) * 3 // ± jitter
25
- const scale = [0.7, 1, 0.85, 1.15, 0.75, 0.95, 1.1, 0.8][index % 8]
26
- const rotation = ((seed % 5) - 2) * 1.5 // -3..+3 deg
27
- const parallaxSpeed = [0.6, 0.8, 1, 1.2, 0.7, 0.9, 1.1, 0.85][index % 8]
28
- const x = index * cardSpacing + (seed % 3) * 60 // horizontal slot + jitter
29
- const width = scale > 1 ? 400 : scale > 0.85 ? 340 : 280
30
- return { x, y, scale, rotation, parallaxSpeed, width, index }
31
- }
32
-
33
- /**
34
- * ScrollDriftGallery — a pinned hero where vertical scroll scrubs a horizontal
35
- * track ("The Drift"): the section pins and page-scroll drives (1) the track
36
- * sideways, (2) each floating card at its own parallax speed, (3) a keyframed
37
- * background-color journey, (4) a slow-parallax vertical title, and (5) a
38
- * bottom progress bar. Card geometry (y-row, scale, rotation, x-slot, width) is
39
- * derived deterministically from index + count; content is a `renderCard` slot.
40
- *
41
- * Requires GSAP ScrollTrigger (registered here). All tweens live in a
42
- * gsap.context reverted on cleanup, with invalidateOnRefresh + function-based
43
- * end/x so pin distance and travel recompute on resize. On
44
- * `prefers-reduced-motion` the rig is skipped entirely and the cards render in
45
- * a plain horizontally-scrollable row.
46
- *
47
- * @param {any[]} items cards; drives layout count + totalWidth
48
- * @param {(item:any,meta:{scale,rotation,width,index})=>ReactNode} renderCard card slot
49
- * @param {Function} onCardClick (item, index) => void — click on a card
50
- * @param {number} totalWidth scroll/scrub distance (default items.length*cardSpacing + 800)
51
- * @param {ReactNode} title giant vertical wordmark (content — author case at call site)
52
- * @param {ReactNode} intro opening breathing-space slot
53
- * @param {ReactNode} outro end-CTA slot
54
- * @param {Array<{backgroundColor,duration}>} bgKeyframes background-color journey (fractional durations 0→1)
55
- * @param {number} cardSpacing horizontal slot pitch, px (default 520)
56
- */
57
- export default function ScrollDriftGallery({
58
- items = [],
59
- renderCard,
60
- onCardClick,
61
- totalWidth,
62
- title,
63
- intro,
64
- outro,
65
- bgKeyframes = DEFAULT_BG_KEYFRAMES,
66
- cardSpacing = 520,
67
- }) {
68
- const bgRef = useRef(null)
69
- const containerRef = useRef(null)
70
- const horizontalRef = useRef(null)
71
- const titleRef = useRef(null)
72
- const reducedMotion = usePrefersReducedMotion()
73
-
74
- const layout = useMemo(
75
- () => items.map((_, i) => layoutFor(i, cardSpacing)),
76
- [items, cardSpacing],
77
- )
78
- const resolvedTotalWidth = totalWidth ?? items.length * cardSpacing + 800
79
-
80
- useEffect(() => {
81
- if (reducedMotion) return undefined
82
-
83
- const ctx = gsap.context(() => {
84
- const trigger = {
85
- trigger: containerRef.current,
86
- start: 'top top',
87
- end: () => `+=${resolvedTotalWidth}`,
88
- }
89
-
90
- // (1) Pin + horizontal scrub.
91
- gsap.to(horizontalRef.current, {
92
- x: () => -(resolvedTotalWidth - window.innerWidth),
93
- ease: 'none',
94
- scrollTrigger: { ...trigger, scrub: 1, pin: true, anticipatePin: 1, invalidateOnRefresh: true },
95
- })
96
-
97
- // (2) Background-color journey.
98
- gsap.to(bgRef.current, { scrollTrigger: { ...trigger, scrub: true }, keyframes: bgKeyframes })
99
-
100
- // (3) Title slow-parallax (0.3× the track).
101
- gsap.to(titleRef.current, {
102
- x: () => -(resolvedTotalWidth - window.innerWidth) * 0.3,
103
- ease: 'none',
104
- scrollTrigger: { ...trigger, scrub: 1 },
105
- })
106
-
107
- // (4) Per-card parallax.
108
- const cards = horizontalRef.current?.querySelectorAll('[data-drift-card]')
109
- cards?.forEach((card, i) => {
110
- const meta = layout[i]
111
- if (!meta) return
112
- gsap.to(card, {
113
- x: (meta.parallaxSpeed - 1) * 300,
114
- ease: 'none',
115
- scrollTrigger: { ...trigger, scrub: 1 },
116
- })
117
- })
118
-
119
- // (5) Progress bar.
120
- const bar = containerRef.current?.querySelector('[data-drift-progress]')
121
- if (bar) gsap.to(bar, { width: '100%', ease: 'none', scrollTrigger: { ...trigger, scrub: true } })
122
- }, containerRef)
123
-
124
- return () => ctx.revert()
125
- }, [layout, resolvedTotalWidth, bgKeyframes, reducedMotion])
126
-
127
- // Reduced-motion fallback: plain horizontal scroll, no pin, no gsap.
128
- if (reducedMotion) {
129
- return (
130
- <div style={{ backgroundColor: bgKeyframes[bgKeyframes.length - 1]?.backgroundColor || '#0a0a0a' }}>
131
- <section className="relative overflow-hidden py-16">
132
- {title != null && (
133
- <div className="px-6 pb-8" style={{ writingMode: 'horizontal-tb' }}>
134
- <span className="text-[12vw] font-bold tracking-[-0.04em] leading-none opacity-[0.06]">{title}</span>
135
- </div>
136
- )}
137
- <div className="flex items-center gap-8 overflow-x-auto px-6 pb-6">
138
- {intro != null && <div className="flex-shrink-0">{intro}</div>}
139
- {items.map((item, i) => {
140
- const meta = layout[i]
141
- return (
142
- <div
143
- key={i}
144
- className="flex-shrink-0 cursor-pointer"
145
- style={{ width: `${meta.width}px` }}
146
- onClick={() => onCardClick?.(item, i)}
147
- >
148
- {renderCard?.(item, meta)}
149
- </div>
150
- )
151
- })}
152
- {outro != null && <div className="flex-shrink-0">{outro}</div>}
153
- </div>
154
- </section>
155
- </div>
156
- )
157
- }
158
-
159
- return (
160
- <div ref={bgRef} style={{ backgroundColor: bgKeyframes[0]?.backgroundColor || '#000000' }} className="transition-colors duration-0">
161
- <section ref={containerRef} className="relative h-screen overflow-hidden">
162
- {/* Vertical title — pinned, slow parallax */}
163
- {title != null && (
164
- <div
165
- ref={titleRef}
166
- className="absolute left-8 top-0 h-full flex items-center z-10 pointer-events-none select-none"
167
- style={{ writingMode: 'vertical-rl', textOrientation: 'mixed' }}
168
- >
169
- <span className="text-[20vw] font-bold tracking-[-0.04em] leading-none opacity-[0.06] mix-blend-difference">{title}</span>
170
- </div>
171
- )}
172
-
173
- {/* Horizontal track */}
174
- <div ref={horizontalRef} className="absolute top-0 left-0 h-full flex items-start" style={{ width: `${resolvedTotalWidth}px` }}>
175
- {/* Opening breathing space */}
176
- {intro != null && (
177
- <div className="flex-shrink-0 w-[50vw] h-full flex items-center justify-center">{intro}</div>
178
- )}
179
-
180
- {/* Floating cards */}
181
- {layout.map((meta, i) => (
182
- <div
183
- key={i}
184
- data-drift-card
185
- className="absolute cursor-pointer group"
186
- style={{
187
- left: `${meta.x + 600}px`, // offset past the opening space
188
- top: `${meta.y}%`,
189
- width: `${meta.width}px`,
190
- transform: `rotate(${meta.rotation}deg) scale(${meta.scale})`,
191
- transformOrigin: 'center center',
192
- }}
193
- onClick={() => onCardClick?.(items[i], i)}
194
- >
195
- {renderCard?.(items[i], meta)}
196
- </div>
197
- ))}
198
-
199
- {/* End CTA */}
200
- {outro != null && (
201
- <div className="absolute top-1/2 -translate-y-1/2 text-center" style={{ left: `${resolvedTotalWidth - 500}px` }}>
202
- {outro}
203
- </div>
204
- )}
205
- </div>
206
-
207
- {/* Progress bar */}
208
- <div className="absolute bottom-0 left-0 right-0 h-px bg-fg-08">
209
- <div data-drift-progress className="h-full bg-accent-primary" style={{ width: '0%' }} />
210
- </div>
211
- </section>
212
- </div>
213
- )
214
- }
@@ -1,83 +0,0 @@
1
- import FullBleedHero from './FullBleedHero.jsx'
2
- import Image from '../molecules/Image.jsx'
3
-
4
- /** Per-variant spacing presets — the only delta between StackHero and the
5
- * folded-in StackHeroTall (taller viewport + deeper bottom padding). */
6
- const VARIANTS = {
7
- default: { height: 'min-h-[80vh]', padBottom: 'pb-12' },
8
- tall: { height: 'min-h-[90vh]', padBottom: 'pb-32 sm:pb-40 lg:pb-48' },
9
- }
10
-
11
- /**
12
- * StackHero — full-bleed image hero with a bottom-anchored, centered title +
13
- * description under a bottom-up scrim. Built ON the DS FullBleedHero: the
14
- * background Image plus a tokenized gradient scrim are handed in as the hero's
15
- * media node, and the copy rides in the content slot pinned to the bottom.
16
- * `StackHeroTall` is folded in as `variant="tall"` — a spacing preset, not a
17
- * second file.
18
- *
19
- * De-branded: no CDN `src`/`srcSet` defaults, no brand-copy title/description
20
- * defaults, no dead `aspectRatio` prop. The scrim reads `--kol-surface-primary`
21
- * (theme-aware) instead of the hardcoded dark hex, and `objectFit`/
22
- * `objectPosition` are inline styles (no JIT-invisible dynamic class). The app
23
- * `reveal` entrance utility is dropped. Title/description authored as-is.
24
- *
25
- * @param {string} title heading text
26
- * @param {string} description sub text
27
- * @param {string} src background image src
28
- * @param {string} srcSet responsive srcSet
29
- * @param {string} sizes `sizes` attribute (default '100vw')
30
- * @param {string} alt alt text
31
- * @param {string} objectFit CSS object-fit for the image (default 'cover')
32
- * @param {string} objectPosition CSS object-position for the image (default 'center')
33
- * @param {'default'|'tall'} variant spacing preset (default 'default')
34
- * @param {string} className extra classes on the hero section
35
- */
36
- export default function StackHero({
37
- title,
38
- description,
39
- src,
40
- srcSet,
41
- sizes = '100vw',
42
- alt = '',
43
- objectFit = 'cover',
44
- objectPosition = 'center',
45
- variant = 'default',
46
- className = '',
47
- }) {
48
- const { height, padBottom } = VARIANTS[variant] ?? VARIANTS.default
49
-
50
- const media = (
51
- <>
52
- <Image
53
- src={src}
54
- srcSet={srcSet}
55
- sizes={sizes}
56
- alt={alt}
57
- loading="eager"
58
- className="kol-full-bleed-hero-media"
59
- style={{ objectFit, objectPosition }}
60
- />
61
- <div
62
- aria-hidden="true"
63
- className="absolute inset-0 pointer-events-none"
64
- style={{ background: 'linear-gradient(to top, var(--kol-surface-primary) 0%, transparent 100%)' }}
65
- />
66
- </>
67
- )
68
-
69
- return (
70
- <FullBleedHero
71
- media={media}
72
- height={height}
73
- align="center"
74
- className={className}
75
- panel={
76
- <div className={`self-end w-full max-w-[520px] lg:max-w-[30%] text-center mx-auto lg:mx-0 flex flex-col gap-1 ${padBottom}`}>
77
- <h1 className="kol-prose-display text-center">{title}</h1>
78
- <p className="kol-mono-14 text-center text-fg-80">{description}</p>
79
- </div>
80
- }
81
- />
82
- )
83
- }