@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.
Files changed (128) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -5
  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/chess/ChessPiece.jsx +0 -191
  19. package/src/chess/apparatus/AlternativeControlsMock.jsx +0 -460
  20. package/src/chess/apparatus/ChessAnalysisLayout.jsx +0 -24
  21. package/src/chess/apparatus/ChessBoard.jsx +0 -210
  22. package/src/chess/apparatus/ChessBoardFullscreen.jsx +0 -49
  23. package/src/chess/apparatus/ChessBoardWithControls.jsx +0 -41
  24. package/src/chess/apparatus/ChessBoardWithSidebar.jsx +0 -122
  25. package/src/chess/apparatus/ChessSidebar.jsx +0 -131
  26. package/src/chess/apparatus/GameArchiveTable.jsx +0 -493
  27. package/src/chess/apparatus/GameSelector.jsx +0 -47
  28. package/src/chess/apparatus/NotationPanel.jsx +0 -70
  29. package/src/chess/apparatus/PlaybackControls.jsx +0 -52
  30. package/src/chess/apparatus/VariationTree.jsx +0 -137
  31. package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +0 -6
  32. package/src/chess/assets/chess-extra-set/set-1/bishop.svg +0 -6
  33. package/src/chess/assets/chess-extra-set/set-1/king-1.svg +0 -9
  34. package/src/chess/assets/chess-extra-set/set-1/king.svg +0 -7
  35. package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +0 -8
  36. package/src/chess/assets/chess-extra-set/set-1/knight.svg +0 -7
  37. package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +0 -3
  38. package/src/chess/assets/chess-extra-set/set-1/pawn.svg +0 -3
  39. package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +0 -11
  40. package/src/chess/assets/chess-extra-set/set-1/queen.svg +0 -6
  41. package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +0 -7
  42. package/src/chess/assets/chess-extra-set/set-1/rook.svg +0 -11
  43. package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +0 -4
  44. package/src/chess/assets/chess-extra-set/set-2/bishop.svg +0 -4
  45. package/src/chess/assets/chess-extra-set/set-2/king-1.svg +0 -4
  46. package/src/chess/assets/chess-extra-set/set-2/king.svg +0 -4
  47. package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +0 -5
  48. package/src/chess/assets/chess-extra-set/set-2/knight.svg +0 -5
  49. package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +0 -4
  50. package/src/chess/assets/chess-extra-set/set-2/pawn.svg +0 -4
  51. package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +0 -4
  52. package/src/chess/assets/chess-extra-set/set-2/queen.svg +0 -4
  53. package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +0 -5
  54. package/src/chess/assets/chess-extra-set/set-2/rook.svg +0 -5
  55. package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +0 -10
  56. package/src/chess/assets/chess-extra-set/set-3/bishop.svg +0 -3
  57. package/src/chess/assets/chess-extra-set/set-3/king-1.svg +0 -12
  58. package/src/chess/assets/chess-extra-set/set-3/king.svg +0 -3
  59. package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +0 -3
  60. package/src/chess/assets/chess-extra-set/set-3/knight.svg +0 -10
  61. package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +0 -10
  62. package/src/chess/assets/chess-extra-set/set-3/pawn.svg +0 -10
  63. package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +0 -10
  64. package/src/chess/assets/chess-extra-set/set-3/queen.svg +0 -3
  65. package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +0 -3
  66. package/src/chess/assets/chess-extra-set/set-3/rook.svg +0 -11
  67. package/src/chess/assets/chess-vector-set/ABoard.svg +0 -164
  68. package/src/chess/assets/chess-vector-set/BBiskup.svg +0 -12
  69. package/src/chess/assets/chess-vector-set/BKing.svg +0 -15
  70. package/src/chess/assets/chess-vector-set/BKnight.svg +0 -13
  71. package/src/chess/assets/chess-vector-set/BPawn.svg +0 -7
  72. package/src/chess/assets/chess-vector-set/BQueen.svg +0 -6
  73. package/src/chess/assets/chess-vector-set/BRook.svg +0 -12
  74. package/src/chess/assets/chess-vector-set/WBiskup.svg +0 -14
  75. package/src/chess/assets/chess-vector-set/WKing.svg +0 -13
  76. package/src/chess/assets/chess-vector-set/WKnight.svg +0 -10
  77. package/src/chess/assets/chess-vector-set/WPawn.svg +0 -8
  78. package/src/chess/assets/chess-vector-set/WQueen.svg +0 -17
  79. package/src/chess/assets/chess-vector-set/WRook.svg +0 -13
  80. package/src/chess/context/ChessControlsContext.jsx +0 -379
  81. package/src/chess/dashboards/ChessHero.jsx +0 -303
  82. package/src/chess/index.js +0 -32
  83. package/src/chess/utils/parsePgnTree.js +0 -97
  84. package/src/dashboards/cards/DashAlertCard.jsx +0 -44
  85. package/src/dashboards/cards/DashChartCard.jsx +0 -44
  86. package/src/dashboards/cards/DashFeaturedCard.jsx +0 -42
  87. package/src/dashboards/cards/DashListCard.jsx +0 -87
  88. package/src/dashboards/cards/DashMetricCard.jsx +0 -59
  89. package/src/dashboards/cards/DashSlotCard.jsx +0 -45
  90. package/src/dashboards/cards/DashStackedBarCard.jsx +0 -70
  91. package/src/dashboards/cards/DashTableCard.jsx +0 -31
  92. package/src/dashboards/cards/_shared/CardHeader.jsx +0 -15
  93. package/src/dashboards/cards/_shared/LegendRow.jsx +0 -25
  94. package/src/dashboards/charts/Candlestick.jsx +0 -79
  95. package/src/dashboards/charts/DonutChart.jsx +0 -87
  96. package/src/dashboards/charts/Heatmap.jsx +0 -79
  97. package/src/dashboards/charts/Histogram.jsx +0 -56
  98. package/src/dashboards/charts/LineChart.jsx +0 -157
  99. package/src/dashboards/charts/ScatterPlot.jsx +0 -93
  100. package/src/dashboards/charts/Sparkline.jsx +0 -63
  101. package/src/dashboards/index.js +0 -28
  102. package/src/dashboards/layout/DashboardGrid.jsx +0 -30
  103. package/src/dashboards/layout/GridCard.jsx +0 -44
  104. package/src/dashboards/shared/DashTooltip.jsx +0 -6
  105. package/src/dashboards/shared/formatMetric.js +0 -26
  106. package/src/dashboards/shared/useChartTooltip.js +0 -25
  107. package/src/dashboards/shared/useCountUp.js +0 -107
  108. package/src/molecules/ArticleCard.jsx +0 -178
  109. package/src/molecules/WorkListItem.jsx +0 -83
  110. package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
  111. package/src/organisms/ArticleHeader.jsx +0 -95
  112. package/src/organisms/ColorLoader.jsx +0 -155
  113. package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
  114. package/src/organisms/ParallaxShelf.jsx +0 -141
  115. package/src/organisms/PortableTextRenderer.jsx +0 -115
  116. package/src/organisms/ProductDetailLayout.jsx +0 -189
  117. package/src/organisms/ScrollDriftGallery.jsx +0 -214
  118. package/src/organisms/StackHero.jsx +0 -83
  119. package/src/organisms/WorkCard.jsx +0 -120
  120. package/src/organisms/WorkViewToggle.jsx +0 -170
  121. package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
  122. package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
  123. package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
  124. package/src/organisms/foundry/TypefaceHero.jsx +0 -107
  125. package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
  126. package/src/organisms/foundry/VariableFontSection.jsx +0 -158
  127. package/src/organisms/foundry/glyphData.js +0 -30
  128. 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
- }