@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.
- package/README.md +8 -0
- package/package.json +2 -3
- package/src/atoms/RotaryDial.jsx +35 -21
- package/src/index.js +6 -18
- package/src/molecules/ButtonGroup.jsx +45 -0
- package/src/molecules/ColorInputRow.jsx +146 -126
- package/src/molecules/Slider.jsx +29 -14
- package/src/molecules/SplitToolButton.jsx +133 -0
- package/src/organisms/ContentFilters.jsx +2 -2
- package/src/organisms/GalleryCarousel.jsx +2 -1
- package/src/organisms/LoaderOverlay.jsx +11 -14
- package/src/organisms/MediaTileGallery.jsx +57 -0
- package/src/organisms/MediaViewer.jsx +89 -64
- package/src/atoms/PriceDisplay.jsx +0 -34
- package/src/atoms/TextPressure.jsx +0 -331
- package/src/atoms/TypeSample.jsx +0 -50
- package/src/atoms/TypeSpecCard.jsx +0 -42
- package/src/molecules/ArticleCard.jsx +0 -178
- package/src/molecules/WorkListItem.jsx +0 -83
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
- package/src/organisms/ArticleHeader.jsx +0 -95
- package/src/organisms/ColorLoader.jsx +0 -155
- package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
- package/src/organisms/ParallaxShelf.jsx +0 -141
- package/src/organisms/PortableTextRenderer.jsx +0 -115
- package/src/organisms/ProductDetailLayout.jsx +0 -189
- package/src/organisms/ScrollDriftGallery.jsx +0 -214
- package/src/organisms/StackHero.jsx +0 -83
- package/src/organisms/WorkCard.jsx +0 -120
- package/src/organisms/WorkViewToggle.jsx +0 -170
- package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
- package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
- package/src/organisms/foundry/TypefaceHero.jsx +0 -107
- package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
- package/src/organisms/foundry/VariableFontSection.jsx +0 -158
- package/src/organisms/foundry/glyphData.js +0 -30
- 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
|
-
}
|