@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,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
- }
@@ -1,120 +0,0 @@
1
- import { useEffect, useState } from 'react'
2
- import TiltCard from '../atoms/TiltCard.jsx'
3
- import AssetPlaceholder from '../atoms/AssetPlaceholder.jsx'
4
-
5
- /* Ragged-skyline heights, chosen by `index % 3` — a shelf of these reads as an
6
- * uneven horizon. Overridable via the `heights` prop. */
7
- const HEIGHTS = ['h-[408px] md:h-[560px]', 'h-[372px] md:h-[520px]', 'h-[336px] md:h-[480px]']
8
- const EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
9
- const TYPE_LABELS = { client: 'Client', collection: 'Collection', typeface: 'Typeface', tool: 'Tool', system: 'System' }
10
-
11
- /**
12
- * WorkCard — the grid/shelf project tile for a `/work` listing: a fixed-size
13
- * media card (the DS **TiltCard** in its `grounded` variant) with a
14
- * hover-revealed bottom drawer carrying the project title + a meta line
15
- * (client-or-type · year). The whole card is an anchor to the project detail;
16
- * it preloads its own thumbnail and plays a staggered rise-and-untilt entrance
17
- * keyed off its `index` in the shelf.
18
- *
19
- * Composes TiltCard (owns the `<img>` + pointer tilt); this organism adds the
20
- * entrance choreography, the caption drawer, and the link. Router-agnostic:
21
- * renders a plain `<a href>` plus an `onNavigate(href, event)` seam — call
22
- * `preventDefault` inside it for SPA navigation. Flat project props (no Sanity):
23
- * the meta line is composed internally so callers pass raw fields.
24
- *
25
- * Text casing: no text-transform (KOL rule) — the source's presentational
26
- * `uppercase` is dropped; `title`, `client`, `year` render verbatim as authored.
27
- *
28
- * @param {string} title overlay heading (display type)
29
- * @param {string} thumbnail image URL (preloaded, passed to TiltCard `src`); AssetPlaceholder shows when absent
30
- * @param {string} href link target
31
- * @param {string} client first meta segment; falls back to the type label
32
- * @param {string} type 'client'|'collection'|'typeface'|'tool'|'system' — mapped via TYPE_LABELS when no client
33
- * @param {string|number} year second meta segment
34
- * @param {number} index drives stagger height + entrance timing (default 0)
35
- * @param {number} perspective CSS perspective in px on fine pointers (default 800)
36
- * @param {string[]} heights override the ragged-height ramp (chosen by index % heights.length)
37
- * @param {Function} onNavigate (href, event) => void — anchor click seam for SPA routing
38
- */
39
- export default function WorkCard({
40
- title,
41
- thumbnail,
42
- href,
43
- client,
44
- type,
45
- year,
46
- index = 0,
47
- perspective = 800,
48
- heights = HEIGHTS,
49
- onNavigate,
50
- }) {
51
- const [ready, setReady] = useState(false)
52
- // Evaluated once at mount (SSR-safe): drop perspective on coarse/small screens.
53
- const [isMobile] = useState(
54
- () => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches,
55
- )
56
-
57
- useEffect(() => {
58
- if (!thumbnail) {
59
- setReady(true)
60
- return
61
- }
62
- let cancelled = false
63
- const img = new window.Image()
64
- const done = () => { if (!cancelled) setReady(true) }
65
- img.onload = done
66
- img.onerror = done
67
- img.src = thumbnail
68
- return () => { cancelled = true }
69
- }, [thumbnail])
70
-
71
- const height = heights[index % heights.length]
72
- const meta = [client || TYPE_LABELS[type] || type, year].filter(Boolean).join(' · ')
73
-
74
- const drawer = (
75
- <div className="absolute inset-x-0 bottom-0 z-10 p-4 md:p-6 bg-surface-inverse opacity-0 group-hover:opacity-100 transition-opacity duration-300">
76
- <p className="kol-sans-display-02 text-fg-inverse leading-tight">{title}</p>
77
- {meta && (
78
- <p className="kol-mono-12 text-fg-inverse opacity-60 tracking-widest mt-2">{meta}</p>
79
- )}
80
- </div>
81
- )
82
-
83
- return (
84
- <a
85
- href={href}
86
- onClick={onNavigate ? (e) => onNavigate(href, e) : undefined}
87
- className={`flex-none w-[280px] md:w-[400px] ${height} group`}
88
- style={isMobile ? undefined : { perspective }}
89
- >
90
- <div
91
- className="w-full h-full"
92
- style={{
93
- transformOrigin: 'bottom center',
94
- opacity: ready ? 1 : 0,
95
- transform: ready
96
- ? 'rotateX(0deg) translateY(0px)'
97
- : `rotateX(${20 + (index % 3) * 8}deg) translateY(${30 + (index % 4) * 10}px)`,
98
- transition: `opacity ${0.7 + (index % 3) * 0.15}s ${EASE} ${index * 0.07}s, transform ${0.7 + (index % 3) * 0.15}s ${EASE} ${index * 0.07}s`,
99
- }}
100
- >
101
- {thumbnail ? (
102
- <TiltCard
103
- src={thumbnail}
104
- alt={title}
105
- variant="grounded"
106
- perspective={perspective}
107
- className="w-full h-full rounded-[4px] border border-fg-04"
108
- >
109
- {drawer}
110
- </TiltCard>
111
- ) : (
112
- <div className="relative w-full h-full rounded-[4px] border border-fg-04 overflow-hidden">
113
- <AssetPlaceholder category="work" name={title} aspectRatio="auto" className="w-full h-full" />
114
- {drawer}
115
- </div>
116
- )}
117
- </div>
118
- </a>
119
- )
120
- }