@kolkrabbi/kol-component 0.7.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 -5
- 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/chess/ChessPiece.jsx +0 -191
- package/src/chess/apparatus/AlternativeControlsMock.jsx +0 -460
- package/src/chess/apparatus/ChessAnalysisLayout.jsx +0 -24
- package/src/chess/apparatus/ChessBoard.jsx +0 -210
- package/src/chess/apparatus/ChessBoardFullscreen.jsx +0 -49
- package/src/chess/apparatus/ChessBoardWithControls.jsx +0 -41
- package/src/chess/apparatus/ChessBoardWithSidebar.jsx +0 -122
- package/src/chess/apparatus/ChessSidebar.jsx +0 -131
- package/src/chess/apparatus/GameArchiveTable.jsx +0 -493
- package/src/chess/apparatus/GameSelector.jsx +0 -47
- package/src/chess/apparatus/NotationPanel.jsx +0 -70
- package/src/chess/apparatus/PlaybackControls.jsx +0 -52
- package/src/chess/apparatus/VariationTree.jsx +0 -137
- package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +0 -6
- package/src/chess/assets/chess-extra-set/set-1/bishop.svg +0 -6
- package/src/chess/assets/chess-extra-set/set-1/king-1.svg +0 -9
- package/src/chess/assets/chess-extra-set/set-1/king.svg +0 -7
- package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +0 -8
- package/src/chess/assets/chess-extra-set/set-1/knight.svg +0 -7
- package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-1/pawn.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +0 -11
- package/src/chess/assets/chess-extra-set/set-1/queen.svg +0 -6
- package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +0 -7
- package/src/chess/assets/chess-extra-set/set-1/rook.svg +0 -11
- package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/bishop.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/king-1.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/king.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +0 -5
- package/src/chess/assets/chess-extra-set/set-2/knight.svg +0 -5
- package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/pawn.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/queen.svg +0 -4
- package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +0 -5
- package/src/chess/assets/chess-extra-set/set-2/rook.svg +0 -5
- package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/bishop.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/king-1.svg +0 -12
- package/src/chess/assets/chess-extra-set/set-3/king.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/knight.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/pawn.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +0 -10
- package/src/chess/assets/chess-extra-set/set-3/queen.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +0 -3
- package/src/chess/assets/chess-extra-set/set-3/rook.svg +0 -11
- package/src/chess/assets/chess-vector-set/ABoard.svg +0 -164
- package/src/chess/assets/chess-vector-set/BBiskup.svg +0 -12
- package/src/chess/assets/chess-vector-set/BKing.svg +0 -15
- package/src/chess/assets/chess-vector-set/BKnight.svg +0 -13
- package/src/chess/assets/chess-vector-set/BPawn.svg +0 -7
- package/src/chess/assets/chess-vector-set/BQueen.svg +0 -6
- package/src/chess/assets/chess-vector-set/BRook.svg +0 -12
- package/src/chess/assets/chess-vector-set/WBiskup.svg +0 -14
- package/src/chess/assets/chess-vector-set/WKing.svg +0 -13
- package/src/chess/assets/chess-vector-set/WKnight.svg +0 -10
- package/src/chess/assets/chess-vector-set/WPawn.svg +0 -8
- package/src/chess/assets/chess-vector-set/WQueen.svg +0 -17
- package/src/chess/assets/chess-vector-set/WRook.svg +0 -13
- package/src/chess/context/ChessControlsContext.jsx +0 -379
- package/src/chess/dashboards/ChessHero.jsx +0 -303
- package/src/chess/index.js +0 -32
- package/src/chess/utils/parsePgnTree.js +0 -97
- package/src/dashboards/cards/DashAlertCard.jsx +0 -44
- package/src/dashboards/cards/DashChartCard.jsx +0 -44
- package/src/dashboards/cards/DashFeaturedCard.jsx +0 -42
- package/src/dashboards/cards/DashListCard.jsx +0 -87
- package/src/dashboards/cards/DashMetricCard.jsx +0 -59
- package/src/dashboards/cards/DashSlotCard.jsx +0 -45
- package/src/dashboards/cards/DashStackedBarCard.jsx +0 -70
- package/src/dashboards/cards/DashTableCard.jsx +0 -31
- package/src/dashboards/cards/_shared/CardHeader.jsx +0 -15
- package/src/dashboards/cards/_shared/LegendRow.jsx +0 -25
- package/src/dashboards/charts/Candlestick.jsx +0 -79
- package/src/dashboards/charts/DonutChart.jsx +0 -87
- package/src/dashboards/charts/Heatmap.jsx +0 -79
- package/src/dashboards/charts/Histogram.jsx +0 -56
- package/src/dashboards/charts/LineChart.jsx +0 -157
- package/src/dashboards/charts/ScatterPlot.jsx +0 -93
- package/src/dashboards/charts/Sparkline.jsx +0 -63
- package/src/dashboards/index.js +0 -28
- package/src/dashboards/layout/DashboardGrid.jsx +0 -30
- package/src/dashboards/layout/GridCard.jsx +0 -44
- package/src/dashboards/shared/DashTooltip.jsx +0 -6
- package/src/dashboards/shared/formatMetric.js +0 -26
- package/src/dashboards/shared/useChartTooltip.js +0 -25
- package/src/dashboards/shared/useCountUp.js +0 -107
- 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,155 +0,0 @@
|
|
|
1
|
-
import { motion as Motion, useAnimationControls } from 'framer-motion'
|
|
2
|
-
import { useEffect, useRef, useState } from 'react'
|
|
3
|
-
import TextPressure from '../atoms/TextPressure.jsx'
|
|
4
|
-
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* ColorLoader — a full-height branded intro/loading curtain. Fills its parent
|
|
8
|
-
* on a solid backdrop, times in a variable-font wordmark (a live TextPressure
|
|
9
|
-
* effect) after `wordmarkDelay`, then reveals a bouncing down-chevron cue after
|
|
10
|
-
* `scrollDelay`. With `dismissOnClick`, clicking the curtain slides the whole
|
|
11
|
-
* thing up out of view and fires `onComplete`; otherwise the parent owns
|
|
12
|
-
* dismissal (unmount when its work is done). Generalized from the app's
|
|
13
|
-
* KOLKRABBI loader: the cursor trail, `cursor: none`, and window-scroll/keyboard
|
|
14
|
-
* gesture hijack are all dropped — exit is a plain click or parent-driven.
|
|
15
|
-
*
|
|
16
|
-
* Reduced motion → no timers, no tweens: one static frame (wordmark + cue shown
|
|
17
|
-
* at rest) and `onComplete` fires immediately on mount, so the curtain never
|
|
18
|
-
* makes a reduced-motion user wait.
|
|
19
|
-
*
|
|
20
|
-
* The wordmark needs a variable font — see TextPressure's font contract. Pass
|
|
21
|
-
* `fontFamily` / `fontUrl` to point at the KOL variable font (a consumer asset).
|
|
22
|
-
*
|
|
23
|
-
* @param {string} text wordmark string fed to TextPressure
|
|
24
|
-
* @param {string} fontFamily wordmark font family (→ TextPressure)
|
|
25
|
-
* @param {string} [fontUrl] variable-font URL → TextPressure @font-face
|
|
26
|
-
* @param {() => void} onComplete fired after the exit slide-up (or at once, reduced)
|
|
27
|
-
* @param {boolean} dismissOnClick click the curtain to slide up + complete
|
|
28
|
-
* @param {string} bgColor backdrop color (KOL token / CSS color)
|
|
29
|
-
* @param {string} markColor wordmark + chevron color (KOL token / CSS color)
|
|
30
|
-
* @param {number} wordmarkDelay ms before the wordmark fades in
|
|
31
|
-
* @param {number} scrollDelay ms before the chevron cue reveals
|
|
32
|
-
* @param {number} exitDuration seconds for the slide-up exit tween
|
|
33
|
-
*/
|
|
34
|
-
export default function ColorLoader({
|
|
35
|
-
text = 'KOLKRABBI',
|
|
36
|
-
fontFamily = 'var(--kol-font-family-sans)',
|
|
37
|
-
fontUrl,
|
|
38
|
-
onComplete,
|
|
39
|
-
dismissOnClick = false,
|
|
40
|
-
bgColor = 'var(--kol-surface-primary)',
|
|
41
|
-
markColor = 'var(--kol-fg-emphasis)',
|
|
42
|
-
wordmarkDelay = 1000,
|
|
43
|
-
scrollDelay = 2000,
|
|
44
|
-
exitDuration = 1.8,
|
|
45
|
-
}) {
|
|
46
|
-
const reducedMotion = usePrefersReducedMotion()
|
|
47
|
-
const [showMark, setShowMark] = useState(reducedMotion)
|
|
48
|
-
const [showCue, setShowCue] = useState(reducedMotion)
|
|
49
|
-
const [exiting, setExiting] = useState(false)
|
|
50
|
-
const controls = useAnimationControls()
|
|
51
|
-
const firedRef = useRef(false)
|
|
52
|
-
|
|
53
|
-
const fireComplete = () => {
|
|
54
|
-
if (firedRef.current) return
|
|
55
|
-
firedRef.current = true
|
|
56
|
-
onComplete?.()
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
if (reducedMotion) {
|
|
61
|
-
// Skip the whole sequence: static frame, signal done at once.
|
|
62
|
-
setShowMark(true)
|
|
63
|
-
setShowCue(true)
|
|
64
|
-
fireComplete()
|
|
65
|
-
return undefined
|
|
66
|
-
}
|
|
67
|
-
const markTimer = setTimeout(() => setShowMark(true), wordmarkDelay)
|
|
68
|
-
const cueTimer = setTimeout(() => setShowCue(true), scrollDelay)
|
|
69
|
-
return () => {
|
|
70
|
-
clearTimeout(markTimer)
|
|
71
|
-
clearTimeout(cueTimer)
|
|
72
|
-
}
|
|
73
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
-
}, [reducedMotion, wordmarkDelay, scrollDelay])
|
|
75
|
-
|
|
76
|
-
const dismiss = () => {
|
|
77
|
-
if (exiting) return
|
|
78
|
-
setExiting(true)
|
|
79
|
-
if (reducedMotion) {
|
|
80
|
-
fireComplete()
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
controls
|
|
84
|
-
.start({ y: '-100%', transition: { duration: exitDuration, ease: 'easeInOut' } })
|
|
85
|
-
.then(fireComplete)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return (
|
|
89
|
-
<Motion.div
|
|
90
|
-
className={`flex h-full w-full flex-col ${dismissOnClick ? 'cursor-pointer' : ''}`}
|
|
91
|
-
style={{ backgroundColor: bgColor }}
|
|
92
|
-
animate={controls}
|
|
93
|
-
initial={{ y: 0 }}
|
|
94
|
-
onClick={dismissOnClick ? dismiss : undefined}
|
|
95
|
-
>
|
|
96
|
-
{/* Top third — spacer */}
|
|
97
|
-
<div className="flex flex-1 items-end justify-center self-stretch" />
|
|
98
|
-
|
|
99
|
-
{/* Middle third — wordmark + cue */}
|
|
100
|
-
<div className="flex flex-1 flex-col items-center justify-center gap-12 self-stretch">
|
|
101
|
-
<Motion.div
|
|
102
|
-
initial={{ opacity: 0 }}
|
|
103
|
-
animate={{ opacity: showMark ? 1 : 0 }}
|
|
104
|
-
transition={{ duration: reducedMotion ? 0 : 0.5 }}
|
|
105
|
-
className="w-full px-6"
|
|
106
|
-
>
|
|
107
|
-
<div className="mx-auto h-20 w-full max-w-[280px] sm:h-24 sm:max-w-[400px] md:h-32 md:max-w-[600px]">
|
|
108
|
-
<TextPressure
|
|
109
|
-
text={text}
|
|
110
|
-
fontFamily={fontFamily}
|
|
111
|
-
fontUrl={fontUrl}
|
|
112
|
-
textColor={markColor}
|
|
113
|
-
flex
|
|
114
|
-
width
|
|
115
|
-
weight
|
|
116
|
-
italic={false}
|
|
117
|
-
minFontSize={40}
|
|
118
|
-
/>
|
|
119
|
-
</div>
|
|
120
|
-
</Motion.div>
|
|
121
|
-
|
|
122
|
-
<Motion.div
|
|
123
|
-
initial={{ opacity: 0, y: 0 }}
|
|
124
|
-
animate={{
|
|
125
|
-
opacity: showCue ? 0.6 : 0,
|
|
126
|
-
y: showCue && !reducedMotion ? [0, 12, 0] : 0,
|
|
127
|
-
}}
|
|
128
|
-
transition={{
|
|
129
|
-
opacity: { duration: reducedMotion ? 0 : 0.5 },
|
|
130
|
-
y: reducedMotion ? { duration: 0 } : { duration: 1.5, repeat: Infinity, ease: 'easeInOut' },
|
|
131
|
-
}}
|
|
132
|
-
className="mt-12"
|
|
133
|
-
style={{ color: markColor }}
|
|
134
|
-
>
|
|
135
|
-
<svg
|
|
136
|
-
width="32"
|
|
137
|
-
height="32"
|
|
138
|
-
viewBox="0 0 24 24"
|
|
139
|
-
fill="none"
|
|
140
|
-
stroke="currentColor"
|
|
141
|
-
strokeWidth="2"
|
|
142
|
-
strokeLinecap="round"
|
|
143
|
-
strokeLinejoin="round"
|
|
144
|
-
aria-hidden="true"
|
|
145
|
-
>
|
|
146
|
-
<polyline points="6 9 12 15 18 9" />
|
|
147
|
-
</svg>
|
|
148
|
-
</Motion.div>
|
|
149
|
-
</div>
|
|
150
|
-
|
|
151
|
-
{/* Bottom third — spacer */}
|
|
152
|
-
<div className="flex flex-1 items-end justify-center self-stretch" />
|
|
153
|
-
</Motion.div>
|
|
154
|
-
)
|
|
155
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef } from 'react'
|
|
2
|
-
import { gsap } from 'gsap'
|
|
3
|
-
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* DiagonalMarqueeRiver — an auto-scrolling multi-column marquee "river":
|
|
7
|
-
* `items` are dealt round-robin into `colCount` vertical columns, each column
|
|
8
|
-
* runs an infinite constant-velocity GSAP y-tween at its own speed, and the
|
|
9
|
-
* whole block is rotated + scaled so the columns stream diagonally across a
|
|
10
|
-
* tall stage. An IntersectionObserver pauses every tween while the stage is
|
|
11
|
-
* off-screen. Data-agnostic — the card is a `renderItem` slot; the engine only
|
|
12
|
-
* measures scrollHeight and tweens y.
|
|
13
|
-
*
|
|
14
|
-
* Each column's list is rendered twice so the tween can travel exactly one set
|
|
15
|
-
* then repeat with an invisible wrap. On `prefers-reduced-motion` no GSAP runs
|
|
16
|
-
* and the columns render static. The tween set is rebuilt on container resize
|
|
17
|
-
* (a ResizeObserver remeasures scrollHeight — the source keyed only on the item
|
|
18
|
-
* list and drifted when the viewport changed).
|
|
19
|
-
*
|
|
20
|
-
* @param {any[]} items pool dealt round-robin into columns
|
|
21
|
-
* @param {(item:any,i:number)=>ReactNode} renderItem card slot per item
|
|
22
|
-
* @param {number} colCount number of columns (default 4)
|
|
23
|
-
* @param {number[]} columnSpeeds px/s per column (default [28,38,22,34], fallback 30)
|
|
24
|
-
* @param {number} gap vertical gap between cards and gap-between-columns, px (default 24)
|
|
25
|
-
* @param {string} height stage height (default '300vh')
|
|
26
|
-
* @param {number} rotate tilt of the block, deg (default -15)
|
|
27
|
-
* @param {number} scale oversize of the block (default 1.3)
|
|
28
|
-
* @param {boolean} shuffle one-time shuffle of `items` before dealing (default false)
|
|
29
|
-
*/
|
|
30
|
-
export default function DiagonalMarqueeRiver({
|
|
31
|
-
items = [],
|
|
32
|
-
renderItem,
|
|
33
|
-
colCount = 4,
|
|
34
|
-
columnSpeeds = [28, 38, 22, 34],
|
|
35
|
-
gap = 24,
|
|
36
|
-
height = '300vh',
|
|
37
|
-
rotate = -15,
|
|
38
|
-
scale = 1.3,
|
|
39
|
-
shuffle = false,
|
|
40
|
-
}) {
|
|
41
|
-
const containerRef = useRef(null)
|
|
42
|
-
const columnRefs = useRef([])
|
|
43
|
-
const tweensRef = useRef([])
|
|
44
|
-
const reducedMotion = usePrefersReducedMotion()
|
|
45
|
-
|
|
46
|
-
// Deal items round-robin into columns (optionally shuffled once).
|
|
47
|
-
const columns = useMemo(() => {
|
|
48
|
-
const pool = shuffle ? [...items].sort(() => Math.random() - 0.5) : items
|
|
49
|
-
const cols = Array.from({ length: colCount }, () => [])
|
|
50
|
-
pool.forEach((item, i) => cols[i % colCount].push(item))
|
|
51
|
-
return cols
|
|
52
|
-
}, [items, colCount, shuffle])
|
|
53
|
-
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (reducedMotion) return undefined
|
|
56
|
-
|
|
57
|
-
const build = () => {
|
|
58
|
-
const colEls = columnRefs.current.filter(Boolean)
|
|
59
|
-
tweensRef.current.forEach((tw) => tw.kill())
|
|
60
|
-
tweensRef.current = []
|
|
61
|
-
if (!colEls.length) return
|
|
62
|
-
|
|
63
|
-
colEls.forEach((colEl, i) => {
|
|
64
|
-
colEl.style.willChange = 'transform'
|
|
65
|
-
const singleSetHeight = colEl.scrollHeight / 2 // one copy of the doubled list
|
|
66
|
-
const speed = columnSpeeds[i] || 30
|
|
67
|
-
const initialOffset = -(singleSetHeight * ((i * 0.15) % 1)) // staggered per-column start
|
|
68
|
-
gsap.set(colEl, { y: initialOffset })
|
|
69
|
-
const tween = gsap.to(colEl, {
|
|
70
|
-
y: initialOffset - singleSetHeight, // travel exactly one set → seamless
|
|
71
|
-
duration: singleSetHeight / speed, // constant px/s regardless of column length
|
|
72
|
-
ease: 'none',
|
|
73
|
-
repeat: -1,
|
|
74
|
-
})
|
|
75
|
-
tweensRef.current.push(tween)
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
build()
|
|
80
|
-
|
|
81
|
-
// Pause tweens while the stage is off-screen.
|
|
82
|
-
const io = new IntersectionObserver(
|
|
83
|
-
([entry]) => {
|
|
84
|
-
tweensRef.current.forEach((tw) => (entry.isIntersecting ? tw.resume() : tw.pause()))
|
|
85
|
-
},
|
|
86
|
-
{ threshold: 0 },
|
|
87
|
-
)
|
|
88
|
-
if (containerRef.current) io.observe(containerRef.current)
|
|
89
|
-
|
|
90
|
-
// [source-bug] resize-remeasure: source keyed only on the item list, so a
|
|
91
|
-
// viewport change left the tweens measuring a stale scrollHeight. Rebuild
|
|
92
|
-
// on container resize (debounced).
|
|
93
|
-
let raf = 0
|
|
94
|
-
const ro = new ResizeObserver(() => {
|
|
95
|
-
cancelAnimationFrame(raf)
|
|
96
|
-
raf = requestAnimationFrame(build)
|
|
97
|
-
})
|
|
98
|
-
if (containerRef.current) ro.observe(containerRef.current)
|
|
99
|
-
|
|
100
|
-
return () => {
|
|
101
|
-
io.disconnect()
|
|
102
|
-
ro.disconnect()
|
|
103
|
-
cancelAnimationFrame(raf)
|
|
104
|
-
tweensRef.current.forEach((tw) => tw.kill())
|
|
105
|
-
tweensRef.current = []
|
|
106
|
-
}
|
|
107
|
-
}, [columns, columnSpeeds, reducedMotion])
|
|
108
|
-
|
|
109
|
-
return (
|
|
110
|
-
<div ref={containerRef} className="w-full overflow-hidden" style={{ height }}>
|
|
111
|
-
<div
|
|
112
|
-
className="flex gap-6"
|
|
113
|
-
style={{
|
|
114
|
-
transform: `rotate(${rotate}deg) scale(${scale})`,
|
|
115
|
-
transformOrigin: 'center center',
|
|
116
|
-
height: '140%',
|
|
117
|
-
marginTop: '-20%',
|
|
118
|
-
}}
|
|
119
|
-
>
|
|
120
|
-
{columns.map((col, colIndex) => (
|
|
121
|
-
<div key={colIndex} className="flex-1 overflow-hidden">
|
|
122
|
-
<div
|
|
123
|
-
ref={(el) => {
|
|
124
|
-
columnRefs.current[colIndex] = el
|
|
125
|
-
}}
|
|
126
|
-
className="flex flex-col"
|
|
127
|
-
style={{ gap: `${gap}px` }}
|
|
128
|
-
>
|
|
129
|
-
{[...col, ...col].map((item, i) => (
|
|
130
|
-
<div key={i}>{renderItem?.(item, i)}</div>
|
|
131
|
-
))}
|
|
132
|
-
</div>
|
|
133
|
-
</div>
|
|
134
|
-
))}
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
)
|
|
138
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import useEmblaCarousel from 'embla-carousel-react'
|
|
3
|
-
import WorkCard from './WorkCard.jsx'
|
|
4
|
-
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
5
|
-
|
|
6
|
-
/* Scroll-driven parallax strength — fraction of scroll delta applied to the
|
|
7
|
-
* shelf. Eases in quadratically with page-scroll progress (min → max). */
|
|
8
|
-
const SCROLL_PARALLAX_MIN = 0.1
|
|
9
|
-
const SCROLL_PARALLAX_MAX = 0.4
|
|
10
|
-
const DRAG_THRESHOLD_SQ = 25
|
|
11
|
-
/* Aligned edge gets the big gutter so the first/last card lands under a
|
|
12
|
-
* 1400px content column. */
|
|
13
|
-
const EDGE_GUTTER = 'max(4rem, calc((100vw - 1400px) / 2 + 16rem))'
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* ParallaxShelf — one horizontal shelf row: a drag-free Embla carousel of
|
|
17
|
-
* project cards with scroll-driven parallax. As the page scrolls the track is
|
|
18
|
-
* nudged horizontally by `delta × parallax × direction`, the strength easing in
|
|
19
|
-
* the further down the page you are; alternating rows (`fromLeft`) scroll in
|
|
20
|
-
* opposite directions for a woven wall of shelves. Includes a drag-vs-click
|
|
21
|
-
* guard (a drag never fires a card link) and an edge-aligned category label.
|
|
22
|
-
*
|
|
23
|
-
* Calls `useEmblaCarousel` directly (FeaturedCarousel's precedent) because the
|
|
24
|
-
* parallax nudge needs `internalEngine()`, which DS Carousel encapsulates and
|
|
25
|
-
* must not be modified. The source's `WheelGesturesPlugin` is dropped (not a
|
|
26
|
-
* DS dependency). Parallax is disabled under prefers-reduced-motion and on
|
|
27
|
-
* small screens — both fall back to a plain wheel/drag carousel.
|
|
28
|
-
*
|
|
29
|
-
* Card-agnostic via `renderCard`; defaults to DS **WorkCard** with an
|
|
30
|
-
* `index`-staggered ragged height. Pass a plain `items` array of flat project
|
|
31
|
-
* objects (no Sanity keying).
|
|
32
|
-
*
|
|
33
|
-
* Text casing: the label renders verbatim (author `type.label` as e.g.
|
|
34
|
-
* "Client Work") — no text-transform.
|
|
35
|
-
*
|
|
36
|
-
* @param {{label:string}} type category; `label` renders in the edge caption
|
|
37
|
-
* @param {Array} items flat project objects passed to the card renderer
|
|
38
|
-
* @param {boolean} fromLeft direction of alignment / start index / parallax sign / label side (default false)
|
|
39
|
-
* @param {Function} renderCard (item, index) => ReactNode — card renderer (default WorkCard)
|
|
40
|
-
* @param {Function} onNavigate (href, event) => void — forwarded to the default WorkCard
|
|
41
|
-
* @param {string} className extra classes on the section
|
|
42
|
-
*/
|
|
43
|
-
export default function ParallaxShelf({
|
|
44
|
-
type,
|
|
45
|
-
items = [],
|
|
46
|
-
fromLeft = false,
|
|
47
|
-
renderCard,
|
|
48
|
-
onNavigate,
|
|
49
|
-
className = '',
|
|
50
|
-
}) {
|
|
51
|
-
const reduced = usePrefersReducedMotion()
|
|
52
|
-
const [isMobile] = useState(
|
|
53
|
-
() => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches,
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
const [emblaRef, emblaApi] = useEmblaCarousel({
|
|
57
|
-
dragFree: true,
|
|
58
|
-
align: fromLeft ? 'end' : 'start',
|
|
59
|
-
containScroll: 'trimSnaps',
|
|
60
|
-
...(fromLeft && { startIndex: items.length - 1 }),
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
const sectionRef = useRef(null)
|
|
64
|
-
const lastScrollY = useRef(0)
|
|
65
|
-
|
|
66
|
-
// Scroll-driven parallax: page scroll nudges the carousel while in view.
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
if (!emblaApi || reduced || isMobile) return
|
|
69
|
-
lastScrollY.current = window.scrollY
|
|
70
|
-
|
|
71
|
-
const onScroll = () => {
|
|
72
|
-
const section = sectionRef.current
|
|
73
|
-
if (!section) return
|
|
74
|
-
const rect = section.getBoundingClientRect()
|
|
75
|
-
const inView = rect.bottom > 0 && rect.top < window.innerHeight
|
|
76
|
-
if (!inView) {
|
|
77
|
-
lastScrollY.current = window.scrollY
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
const delta = window.scrollY - lastScrollY.current
|
|
81
|
-
lastScrollY.current = window.scrollY
|
|
82
|
-
const scrollProgress = Math.min(window.scrollY / (document.body.scrollHeight - window.innerHeight), 1)
|
|
83
|
-
const parallax = SCROLL_PARALLAX_MIN + (SCROLL_PARALLAX_MAX - SCROLL_PARALLAX_MIN) * (scrollProgress * scrollProgress)
|
|
84
|
-
const engine = emblaApi.internalEngine()
|
|
85
|
-
const offset = delta * parallax * (fromLeft ? 1 : -1)
|
|
86
|
-
engine.scrollBody.useDuration(0)
|
|
87
|
-
engine.scrollTo.distance(offset, false)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
window.addEventListener('scroll', onScroll, { passive: true })
|
|
91
|
-
return () => window.removeEventListener('scroll', onScroll)
|
|
92
|
-
}, [emblaApi, fromLeft, reduced, isMobile])
|
|
93
|
-
|
|
94
|
-
// Drag-vs-click guard (5px): a drag suppresses the ensuing card-link click.
|
|
95
|
-
const dragged = useRef(false)
|
|
96
|
-
const pointerStart = useRef({ x: 0, y: 0 })
|
|
97
|
-
const onPointerDown = useCallback((e) => {
|
|
98
|
-
dragged.current = false
|
|
99
|
-
pointerStart.current = { x: e.clientX, y: e.clientY }
|
|
100
|
-
}, [])
|
|
101
|
-
const onPointerMove = useCallback((e) => {
|
|
102
|
-
if (dragged.current) return
|
|
103
|
-
const dx = e.clientX - pointerStart.current.x
|
|
104
|
-
const dy = e.clientY - pointerStart.current.y
|
|
105
|
-
if (dx * dx + dy * dy > DRAG_THRESHOLD_SQ) dragged.current = true
|
|
106
|
-
}, [])
|
|
107
|
-
const onClickCapture = useCallback((e) => {
|
|
108
|
-
if (dragged.current) e.preventDefault()
|
|
109
|
-
}, [])
|
|
110
|
-
|
|
111
|
-
const card = (item, i) =>
|
|
112
|
-
renderCard ? renderCard(item, i) : <WorkCard key={i} {...item} index={i} onNavigate={onNavigate} />
|
|
113
|
-
|
|
114
|
-
return (
|
|
115
|
-
<section ref={sectionRef} className={`py-6 md:py-16 ${className}`.trim()}>
|
|
116
|
-
<div
|
|
117
|
-
className="overflow-visible select-none"
|
|
118
|
-
ref={emblaRef}
|
|
119
|
-
style={{
|
|
120
|
-
paddingLeft: fromLeft ? undefined : EDGE_GUTTER,
|
|
121
|
-
paddingRight: fromLeft ? EDGE_GUTTER : undefined,
|
|
122
|
-
}}
|
|
123
|
-
>
|
|
124
|
-
<div
|
|
125
|
-
className="flex gap-8 items-end"
|
|
126
|
-
onPointerDown={onPointerDown}
|
|
127
|
-
onPointerMove={onPointerMove}
|
|
128
|
-
onClickCapture={onClickCapture}
|
|
129
|
-
>
|
|
130
|
-
{items.map((item, i) => card(item, i))}
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
{type?.label && (
|
|
135
|
-
<div className={`max-w-[1400px] mx-auto mt-4 md:mt-6 ${fromLeft ? 'pr-4 md:pr-64 text-right' : 'pl-4 md:pl-64'}`}>
|
|
136
|
-
<p className="kol-helper-12 text-auto">{type.label}</p>
|
|
137
|
-
</div>
|
|
138
|
-
)}
|
|
139
|
-
</section>
|
|
140
|
-
)
|
|
141
|
-
}
|
|
@@ -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
|
-
}
|