@kolkrabbi/kol-component 0.2.0 → 0.3.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 (74) hide show
  1. package/package.json +11 -3
  2. package/src/atoms/AnimatedTitle.jsx +108 -0
  3. package/src/atoms/AssetGrid.jsx +29 -0
  4. package/src/atoms/Button.jsx +17 -6
  5. package/src/atoms/CurveOverlay.jsx +180 -0
  6. package/src/atoms/DocsToc.jsx +48 -0
  7. package/src/atoms/EmptyState.jsx +22 -0
  8. package/src/atoms/Figure.jsx +27 -0
  9. package/src/atoms/HlsVideo.jsx +56 -0
  10. package/src/atoms/OverlayGlassPanel.jsx +40 -0
  11. package/src/atoms/PriceDisplay.jsx +34 -0
  12. package/src/atoms/ProsePreview.jsx +53 -0
  13. package/src/atoms/RotaryDial.jsx +150 -0
  14. package/src/atoms/SearchInput.jsx +108 -0
  15. package/src/atoms/TextPressure.jsx +331 -0
  16. package/src/atoms/TiltCard.jsx +127 -0
  17. package/src/atoms/TypeSample.jsx +50 -0
  18. package/src/atoms/TypeSpecCard.jsx +42 -0
  19. package/src/hooks/cssVar.js +53 -0
  20. package/src/hooks/useAxisAnimation.js +91 -0
  21. package/src/hooks/usePrefersReducedMotion.js +21 -0
  22. package/src/hooks/useTilt.js +49 -0
  23. package/src/index.js +66 -1
  24. package/src/molecules/AlignmentGrid.jsx +53 -0
  25. package/src/molecules/ArticleCard.jsx +178 -0
  26. package/src/molecules/CardFeatureItem.jsx +130 -0
  27. package/src/molecules/ColorInputRow.jsx +179 -0
  28. package/src/molecules/ColorRamp.jsx +114 -0
  29. package/src/molecules/FramedMediaBand.jsx +56 -0
  30. package/src/molecules/ImageBlock.jsx +34 -0
  31. package/src/molecules/SelectionOverlay.jsx +108 -0
  32. package/src/molecules/ShapeDropdown.jsx +92 -0
  33. package/src/molecules/ShellDrawer.jsx +169 -0
  34. package/src/molecules/ShellSearchOverlay.jsx +177 -0
  35. package/src/molecules/SpecList.jsx +30 -0
  36. package/src/molecules/SpectrumControls.jsx +504 -0
  37. package/src/molecules/SwatchControls.jsx +217 -0
  38. package/src/molecules/TabsRow.jsx +87 -0
  39. package/src/molecules/VideoBlock.jsx +86 -0
  40. package/src/molecules/WorkListItem.jsx +83 -0
  41. package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
  42. package/src/organisms/ArticleHeader.jsx +95 -0
  43. package/src/organisms/AsciiCursor.jsx +526 -0
  44. package/src/organisms/BentoCard.jsx +187 -0
  45. package/src/organisms/Canvas.jsx +299 -0
  46. package/src/organisms/ColorLoader.jsx +155 -0
  47. package/src/organisms/CtaGlobal.jsx +67 -0
  48. package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
  49. package/src/organisms/EditorShell.jsx +111 -0
  50. package/src/organisms/ErrorBoundary.jsx +70 -0
  51. package/src/organisms/FeatureSplit.jsx +82 -0
  52. package/src/organisms/FeaturedCarousel.jsx +258 -0
  53. package/src/organisms/FeaturesCardSection.jsx +90 -0
  54. package/src/organisms/FullBleedHero.jsx +111 -0
  55. package/src/organisms/GalleryCarousel.jsx +83 -0
  56. package/src/organisms/LoaderOverlay.jsx +30 -0
  57. package/src/organisms/MediaViewer.jsx +95 -0
  58. package/src/organisms/NewsletterBand.jsx +122 -0
  59. package/src/organisms/ParallaxShelf.jsx +141 -0
  60. package/src/organisms/PortableTextRenderer.jsx +115 -0
  61. package/src/organisms/ProductDetailLayout.jsx +189 -0
  62. package/src/organisms/ScrollDriftGallery.jsx +214 -0
  63. package/src/organisms/SpectrumGrid.jsx +90 -0
  64. package/src/organisms/StackHero.jsx +83 -0
  65. package/src/organisms/WorkCard.jsx +120 -0
  66. package/src/organisms/WorkViewToggle.jsx +170 -0
  67. package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
  68. package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
  69. package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
  70. package/src/organisms/foundry/TypefaceHero.jsx +107 -0
  71. package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
  72. package/src/organisms/foundry/VariableFontSection.jsx +158 -0
  73. package/src/organisms/foundry/glyphData.js +30 -0
  74. package/src/organisms/foundry/index.js +21 -0
@@ -0,0 +1,214 @@
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
+ }
@@ -0,0 +1,90 @@
1
+ import { useEffect, useState } from 'react'
2
+ import ColorSwatch from '../molecules/ColorSwatch.jsx'
3
+ import { resolveCssVar, resolveCssColor, isLight } from '../hooks/cssVar.js'
4
+
5
+ /**
6
+ * SpectrumGrid — matrix view of the whole ramp system: rows = ramps, columns =
7
+ * stops (50 → 900). Each cell is a ColorSwatch tile with the stop number and
8
+ * hex printed over it (text color contrast-corrected via isLight), so the whole
9
+ * palette is scannable in one grid.
10
+ *
11
+ * Values resolve live from CSS custom properties at mount — the inline
12
+ * getComputedStyle fork the source carried is replaced by the shared
13
+ * resolveCssVar (declared value → printed hex) + resolveCssColor (computed rgb →
14
+ * fed to isLight, robust to var() chains / color-mix). Edit a ramp in the theme
15
+ * and the grid follows on next mount.
16
+ *
17
+ * @param {string[]} ramps rows; each resolves `--{ramp}-{stop}`
18
+ * @param {number[]} stops columns (default 50…900)
19
+ * @param {Array<{ramp:string, stop:number}>} brandAnchors cells that get the anchor dot
20
+ */
21
+
22
+ const DEFAULT_STOPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]
23
+
24
+ export default function SpectrumGrid({ ramps, stops = DEFAULT_STOPS, brandAnchors = [] }) {
25
+ const [resolved, setResolved] = useState(null)
26
+
27
+ useEffect(() => {
28
+ const out = {}
29
+ for (const ramp of ramps) {
30
+ out[ramp] = {}
31
+ for (const stop of stops) {
32
+ const token = `--${ramp}-${stop}`
33
+ out[ramp][stop] = { label: resolveCssVar(token), color: resolveCssColor(`var(${token})`) }
34
+ }
35
+ }
36
+ setResolved(out)
37
+ }, [ramps, stops])
38
+
39
+ if (!resolved) return <div className="kol-spectrum-grid w-full min-h-[120px]" aria-busy="true" />
40
+
41
+ const isBrandAnchor = (ramp, stop) => brandAnchors.some((a) => a.ramp === ramp && a.stop === stop)
42
+
43
+ return (
44
+ <div
45
+ className="kol-spectrum-grid grid w-full"
46
+ style={{
47
+ gridTemplateColumns: `var(--kol-sg-label-w, 100px) repeat(${stops.length}, minmax(0, 1fr))`,
48
+ gap: 'var(--kol-sg-gap, 4px)',
49
+ }}
50
+ >
51
+ <div className="contents">
52
+ <span aria-hidden="true" />
53
+ {stops.map((s) => (
54
+ <span key={s} className="kol-helper-12 text-fg-48 self-end pb-1">{s}</span>
55
+ ))}
56
+ </div>
57
+ {ramps.map((ramp) => (
58
+ <div key={ramp} className="contents">
59
+ <span className="kol-helper-12 text-emphasis self-center pr-2">{ramp}</span>
60
+ {stops.map((stop) => {
61
+ const { label, color } = resolved[ramp][stop]
62
+ const hex = label || color
63
+ const textColor = isLight(color) ? '#000' : '#fff'
64
+ const brand = isBrandAnchor(ramp, stop)
65
+ return (
66
+ <div
67
+ key={stop}
68
+ className="relative aspect-[5/3] rounded overflow-hidden transition-transform hover:scale-[1.04]"
69
+ title={`${ramp}-${stop} ${hex}${brand ? ' · brand anchor' : ''}`}
70
+ >
71
+ <ColorSwatch hex={`var(--${ramp}-${stop})`} size="stretch" radius="none" frame={false} className="absolute inset-0" />
72
+ <div className="pointer-events-none absolute inset-0 px-2 py-1.5" style={{ color: textColor }}>
73
+ <span className="kol-helper-12 font-bold block">{stop}</span>
74
+ <span className="kol-helper-10 opacity-[.78] absolute bottom-1.5 left-2 max-[900px]:hidden">{hex}</span>
75
+ {brand && (
76
+ <span
77
+ className="absolute top-1.5 right-2 w-2.5 h-2.5 rounded-full opacity-[.85]"
78
+ style={{ background: 'currentColor' }}
79
+ aria-hidden="true"
80
+ />
81
+ )}
82
+ </div>
83
+ </div>
84
+ )
85
+ })}
86
+ </div>
87
+ ))}
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,83 @@
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
+ }
@@ -0,0 +1,120 @@
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
+ }
@@ -0,0 +1,170 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-loader'
3
+
4
+ /* taxonomy-ok: composites a segmented toggle + an inline search into one
5
+ * space-trading control (organism); nests kol-loader's Icon (infrastructure). */
6
+
7
+ const CUBIC_EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
8
+ /* The pill uses a springier overshoot ease, distinct from CUBIC_EASE. */
9
+ const PILL_EASE = 'cubic-bezier(0.34, 1.2, 0.64, 1)'
10
+
11
+ /**
12
+ * WorkViewToggle — the `/work` header control: a pill-shaped segmented
13
+ * **Shelf ⟷ List** view switch with a sliding-pill highlight, beside an
14
+ * expandable inline search. Resting: the two-option toggle + a collapsed
15
+ * search icon-button. Opening search collapses the toggle to zero width (fades
16
+ * out) and expands the field to full width, revealing a round close button on
17
+ * the left — the three siblings trade horizontal space via animated
18
+ * width/margin/opacity.
19
+ *
20
+ * Its own component, not a variant of DS ViewToggle / SegmentedToggle: the
21
+ * animated sliding pill, the expanding active-option icon, and the embedded
22
+ * search choreography are materially different. All animated numerics are
23
+ * inline `style` (width/left/margin/opacity + the two eases).
24
+ *
25
+ * Controlled seam replacing the app's WorkViewContext: `view`/`onView` +
26
+ * `query`/`onQuery`. `searchOpen` is internal state (open on the search icon,
27
+ * close on the × / Escape); closing clears the controlled query via
28
+ * `onQuery('')`. No router, no context.
29
+ *
30
+ * Text casing: labels are authored data (`shelfLabel`/`listLabel`), no
31
+ * text-transform.
32
+ *
33
+ * @param {string} view 'shelf' | 'list' — active view; positions the pill + swaps icon/color
34
+ * @param {Function} onView (mode) => void — fires on Shelf/List click
35
+ * @param {string} query controlled search value (default '')
36
+ * @param {Function} onQuery (value) => void — fires on input change; called with '' on close
37
+ * @param {string} shelfLabel first option label (default 'Shelf')
38
+ * @param {string} listLabel second option label (default 'List')
39
+ * @param {string} shelfIcon first option icon name (default 'library')
40
+ * @param {string} listIcon second option icon name (default 'list')
41
+ * @param {string} placeholder search input placeholder (default '')
42
+ * @param {string} className extra classes on the root
43
+ */
44
+ export default function WorkViewToggle({
45
+ view = 'shelf',
46
+ onView,
47
+ query = '',
48
+ onQuery,
49
+ shelfLabel = 'Shelf',
50
+ listLabel = 'List',
51
+ shelfIcon = 'library',
52
+ listIcon = 'list',
53
+ placeholder = '',
54
+ className = '',
55
+ }) {
56
+ const [open, setOpen] = useState(false)
57
+ const inputRef = useRef(null)
58
+
59
+ useEffect(() => {
60
+ if (open) inputRef.current?.focus()
61
+ }, [open])
62
+
63
+ const closeSearch = () => {
64
+ setOpen(false)
65
+ onQuery?.('')
66
+ }
67
+
68
+ const isShelf = view === 'shelf'
69
+ const isList = view === 'list'
70
+
71
+ const collapse = `width 600ms ${CUBIC_EASE}, margin 600ms ${CUBIC_EASE}, opacity 300ms ${CUBIC_EASE}`
72
+ const inactiveColor = 'color-mix(in srgb, var(--kol-surface-on-primary) 80%, transparent)'
73
+
74
+ const optionIcon = (name, on) => (
75
+ <span
76
+ className="inline-flex overflow-hidden flex-shrink-0"
77
+ style={{ width: on ? 20 : 0, marginRight: on ? 8 : 0, opacity: on ? 1 : 0, transition: collapse }}
78
+ >
79
+ <Icon name={name} size={20} />
80
+ </span>
81
+ )
82
+
83
+ return (
84
+ <div className={`flex items-center ${className}`.trim()}>
85
+ {/* Close button — appears when search is open */}
86
+ <span
87
+ className="inline-flex overflow-hidden flex-shrink-0"
88
+ style={{ width: open ? 36 : 0, marginRight: open ? 12 : 0, opacity: open ? 1 : 0, transition: collapse }}
89
+ >
90
+ <button
91
+ type="button"
92
+ className="flex items-center justify-center w-9 h-9 rounded-full bg-fg-96 transition-colors hover:bg-fg-88"
93
+ style={{ color: 'var(--kol-surface-primary)' }}
94
+ onClick={closeSearch}
95
+ aria-label="Close search"
96
+ >
97
+ <Icon name="cross" size={20} />
98
+ </button>
99
+ </span>
100
+
101
+ {/* Toggle — collapses when search is open */}
102
+ <div
103
+ className="relative flex items-center rounded-full bg-fg-04 h-9 overflow-hidden"
104
+ style={{ width: open ? 0 : 176, marginRight: open ? 0 : 12, opacity: open ? 0 : 1, transition: collapse }}
105
+ >
106
+ <div
107
+ className="absolute top-0 h-9 rounded-full bg-fg-96"
108
+ style={{ width: 96, left: isShelf ? 0 : 80, transition: `left 600ms ${PILL_EASE}` }}
109
+ />
110
+
111
+ <button
112
+ type="button"
113
+ className="relative z-10 flex items-center justify-center rounded-full h-9 kol-helper-14 transition-colors duration-300"
114
+ style={{
115
+ width: 96,
116
+ letterSpacing: 0,
117
+ color: isShelf ? 'var(--kol-surface-primary)' : inactiveColor,
118
+ paddingRight: isShelf ? undefined : 8,
119
+ }}
120
+ onClick={() => onView?.('shelf')}
121
+ aria-pressed={isShelf}
122
+ >
123
+ {optionIcon(shelfIcon, isShelf)}
124
+ {shelfLabel}
125
+ </button>
126
+ <button
127
+ type="button"
128
+ className="relative z-10 flex items-center justify-center rounded-full h-9 -ml-4 kol-helper-14 transition-colors duration-300"
129
+ style={{
130
+ width: 96,
131
+ letterSpacing: 0,
132
+ color: isList ? 'var(--kol-surface-primary)' : inactiveColor,
133
+ paddingLeft: isList ? undefined : 8,
134
+ }}
135
+ onClick={() => onView?.('list')}
136
+ aria-pressed={isList}
137
+ >
138
+ {optionIcon(listIcon, isList)}
139
+ {listLabel}
140
+ </button>
141
+ </div>
142
+
143
+ {/* Search — icon button expands to a search field */}
144
+ <div
145
+ className="flex items-center bg-fg-04 rounded-full h-9"
146
+ style={{ width: open ? 280 : 36, transition: `width 600ms ${CUBIC_EASE}` }}
147
+ >
148
+ <button
149
+ type="button"
150
+ className={`flex items-center justify-center w-9 h-9 rounded-full text-auto flex-shrink-0 border border-transparent ${open ? '' : 'transition-colors hover:border-fg-12'}`}
151
+ onClick={() => !open && setOpen(true)}
152
+ aria-label="Search projects"
153
+ >
154
+ <Icon name="search" size={16} className="text-fg-80" />
155
+ </button>
156
+ {open && (
157
+ <input
158
+ ref={inputRef}
159
+ type="text"
160
+ value={query}
161
+ onChange={(e) => onQuery?.(e.target.value)}
162
+ placeholder={placeholder}
163
+ className="bg-transparent outline-none kol-helper-14 flex-1 text-fg-80 caret-current pr-4 min-w-0"
164
+ onKeyDown={(e) => { if (e.key === 'Escape') closeSearch() }}
165
+ />
166
+ )}
167
+ </div>
168
+ </div>
169
+ )
170
+ }