@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,187 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { motion } from 'framer-motion'
3
+ import HlsVideo from '../atoms/HlsVideo.jsx'
4
+ import AssetPlaceholder from '../atoms/AssetPlaceholder.jsx'
5
+ import Button from '../atoms/Button.jsx'
6
+ import Image from '../molecules/Image.jsx'
7
+ import useTilt from '../hooks/useTilt.js'
8
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
9
+
10
+ /* taxonomy-ok: nests the same-file Media helper plus DS atoms/molecules
11
+ * (HlsVideo, AssetPlaceholder, Button, Image) it composes — an organism. */
12
+
13
+ /**
14
+ * True on coarse-pointer (touch/no-hover) devices. Local to BentoCard, mirrors
15
+ * TiltCard's copy; re-evaluates on device/orientation change via the
16
+ * media-query change event (the monorepo source froze `useIsTouchDevice` in a
17
+ * module-load const — fixed on recreate).
18
+ */
19
+ function useCoarsePointer() {
20
+ const [coarse, setCoarse] = useState(
21
+ () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
22
+ )
23
+
24
+ useEffect(() => {
25
+ const mq = window.matchMedia('(pointer: coarse)')
26
+ const onChange = () => setCoarse(mq.matches)
27
+ mq.addEventListener('change', onChange)
28
+ return () => mq.removeEventListener('change', onChange)
29
+ }, [])
30
+
31
+ return coarse
32
+ }
33
+
34
+ /**
35
+ * Media — internal, NOT exported. Sniffs `src` by extension and renders the
36
+ * right full-bleed media element: `.m3u8` → HlsVideo, `.mov|.mp4|.webm` →
37
+ * autoplay <video>, any other src → the Image molecule (its own broken-asset
38
+ * fallback), and no src → AssetPlaceholder. Positioning/fit arrive via
39
+ * `className`; Image gets an inline height so its base `h-auto` can't beat the
40
+ * `size-full` cover fill.
41
+ */
42
+ function Media({ src, poster, className }) {
43
+ if (!src) return <AssetPlaceholder className={className} />
44
+ if (/\.m3u8$/i.test(src)) return <HlsVideo src={src} poster={poster} className={className} />
45
+ if (/\.(mov|mp4|webm)$/i.test(src)) {
46
+ return (
47
+ <video
48
+ src={src}
49
+ poster={poster}
50
+ autoPlay
51
+ muted
52
+ loop
53
+ playsInline
54
+ preload="auto"
55
+ className={className}
56
+ />
57
+ )
58
+ }
59
+ return <Image src={src} alt="" className={className} style={{ width: '100%', height: '100%' }} />
60
+ }
61
+
62
+ /**
63
+ * BentoCard — media hover-card for grid/bento walls. Full-bleed auto-detected
64
+ * media (HLS / video / image / placeholder) sits behind a content stack; on a
65
+ * fine pointer, hover reveals a darkening scrim plus subtitle / description /
66
+ * CTA over an always-visible title. No-hover (coarse-pointer) devices show
67
+ * everything statically and drop the media's pointer capture.
68
+ *
69
+ * Motion is gated: the pointer-following 3D tilt (shared `useTilt` framer
70
+ * springs — the monorepo's forked CSS `useBentoTilt` is gone) renders only on
71
+ * a fine pointer with motion allowed; reduced-motion, coarse pointer, or
72
+ * `enableTilt={false}` all fall back to a static card, and reduced-motion also
73
+ * drops the reveal's opacity transition.
74
+ *
75
+ * Zero CMS coupling — flat props. The CTA is a DS Button link (`<a href>`, no
76
+ * router import): `http*`/`mailto` open a new tab; any other href is a
77
+ * same-tab anchor whose `onNavigate(event)` seam lets an SPA intercept
78
+ * (preventDefault + its router) — wired capture-phase so it fires before the
79
+ * default navigation. Title/subtitle/description render exactly as authored;
80
+ * no casing transforms (author strings in their final case at the call site).
81
+ *
82
+ * @param {string} src media source; type auto-detected by extension
83
+ * @param {string} poster poster frame for HLS/video
84
+ * @param {ReactNode} title always-visible heading
85
+ * @param {ReactNode} subtitle hover-revealed line
86
+ * @param {ReactNode} description hover-revealed paragraph
87
+ * @param {string} href CTA target; `http*`/`mailto` → new-tab anchor, else same-tab (onNavigate seam)
88
+ * @param {Function} onNavigate (event) => void — same-tab CTA click seam (SPA intercept)
89
+ * @param {ReactNode} buttonLabel CTA label (no default — author it)
90
+ * @param {ReactNode} bodyContent extra content injected into the stack
91
+ * @param {number} overlayOpacity scrim darkness % over the media, 0 disables (default 60)
92
+ * @param {boolean} alignRight right-align the card (ms-auto) vs fill (size-full)
93
+ * @param {boolean} enableTilt master tilt switch (default true)
94
+ * @param {string} titleClassName title classes
95
+ * @param {string} contentClassName inner content-box classes
96
+ * @param {string} imageClassName media fit/position classes
97
+ * @param {string} contentStackClassName stack layout classes
98
+ * @param {string} className extra classes on the root
99
+ */
100
+ export default function BentoCard({
101
+ src,
102
+ poster,
103
+ title,
104
+ subtitle,
105
+ description,
106
+ href,
107
+ onNavigate,
108
+ buttonLabel,
109
+ bodyContent = null,
110
+ overlayOpacity = 60,
111
+ alignRight = false,
112
+ enableTilt = true,
113
+ titleClassName = 'kol-sans-heading-01 text-absolute-white',
114
+ contentClassName = 'max-w-[384px]',
115
+ imageClassName = 'object-cover object-center',
116
+ contentStackClassName = 'relative z-20 h-full flex flex-col justify-start items-start gap-4 p-6 md:p-8',
117
+ className = '',
118
+ ...rest
119
+ }) {
120
+ const reduced = usePrefersReducedMotion()
121
+ const coarse = useCoarsePointer()
122
+ const tilt = useTilt()
123
+
124
+ const tiltOff = !enableTilt || reduced || coarse
125
+ const Component = tiltOff ? 'div' : motion.div
126
+ const rootStyle = {
127
+ ...(tiltOff ? {} : tilt.style),
128
+ backfaceVisibility: 'hidden',
129
+ WebkitBackfaceVisibility: 'hidden',
130
+ }
131
+ const tiltHandlers = tiltOff
132
+ ? {}
133
+ : { ref: tilt.ref, onMouseMove: tilt.onMouseMove, onMouseLeave: tilt.onMouseLeave }
134
+
135
+ // Reveal choreography. Coarse (no-hover) devices show everything statically;
136
+ // fine pointers reveal on group-hover. The opacity transition is motion, so
137
+ // reduced-motion drops it (content still reveals, just without the fade).
138
+ const fade = reduced ? '' : 'transition-opacity duration-300'
139
+ const revealClass = `${coarse ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} ${fade}`.trim()
140
+
141
+ const mediaClass =
142
+ `absolute left-0 top-0 size-full rounded overflow-hidden ${imageClassName} ${coarse ? 'pointer-events-none' : ''}`.trim()
143
+
144
+ const isExternal = href && (href.startsWith('http') || href.startsWith('mailto'))
145
+
146
+ return (
147
+ <Component
148
+ className={`relative group ${alignRight ? 'ms-auto' : 'size-full'} ${className}`.trim()}
149
+ {...tiltHandlers}
150
+ {...rest}
151
+ style={{ ...rootStyle, ...rest.style }}
152
+ >
153
+ <Media src={src} poster={poster} className={mediaClass} />
154
+
155
+ <div className="relative z-10 flex size-full h-full flex-col justify-start items-start text-auto">
156
+ <div className={`relative z-10 ${contentClassName} w-full h-full self-stretch`}>
157
+ {overlayOpacity > 0 && (
158
+ <div
159
+ className={`absolute -inset-1 rounded ${coarse ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'} ${fade} pointer-events-none`.trim()}
160
+ style={{ backgroundColor: `rgba(0, 0, 0, ${overlayOpacity / 100})` }}
161
+ />
162
+ )}
163
+ <div className={contentStackClassName}>
164
+ {title && <h3 className={titleClassName}>{title}</h3>}
165
+ {subtitle && <p className={`kol-mono-text text-absolute-white ${revealClass}`}>{subtitle}</p>}
166
+ {description && <p className={`kol-mono-12 text-absolute-white pb-6 ${revealClass}`}>{description}</p>}
167
+ {bodyContent}
168
+ {href && (
169
+ <div className={revealClass}>
170
+ <Button
171
+ href={href}
172
+ variant="primary"
173
+ size="sm"
174
+ {...(isExternal
175
+ ? { target: '_blank', rel: 'noreferrer noopener' }
176
+ : { onClickCapture: onNavigate })}
177
+ >
178
+ {buttonLabel}
179
+ </Button>
180
+ </div>
181
+ )}
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </Component>
186
+ )
187
+ }
@@ -0,0 +1,299 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * Canvas — the editor's aspect-ratio stage.
5
+ *
6
+ * A letterboxed frame (dashed guide border + mono ratio label) that hosts its
7
+ * children in a **fixed 1080-virtual-pixel coordinate space** and scales the
8
+ * whole layer to fit the viewport via a single CSS `transform`. Because
9
+ * children always render at 1080px-wide logical coords, a `168px` element is
10
+ * always `168/1080` of the frame regardless of zoom or viewport size — the
11
+ * scale seam is the only place real pixels enter. This 1080-virtual contract
12
+ * is the whole point: anything placed inside (SelectionOverlay, boxes, guides)
13
+ * shares it, so a consumer's drag math converts screen deltas to virtual by
14
+ * dividing by the live scale (frameWidth / 1080).
15
+ *
16
+ * Three parts ship from this file: `CanvasFrame` (the bare scale layer),
17
+ * `Canvas` (the letterbox, default export), and `PanViewport` (Space+drag pan).
18
+ * Export all three so a consumer can place a bare frame or compose their own
19
+ * viewport.
20
+ *
21
+ * Ported from the brand editor with three app-couplings dropped (per lobby
22
+ * spec): the static `ASPECTS` table → an `aspects` prop (+ DEFAULT_ASPECTS);
23
+ * the `#0E0E11` dark bg default → `transparent` (let the theme own the stage);
24
+ * and the hardwired `kol-grid-bg` grid → a `backdrop` slot the consumer fills.
25
+ */
26
+
27
+ /* Fixed virtual canvas width — children render in this pixel space and the
28
+ * outer rect scales to fit the viewport via CSS transform. Height is derived
29
+ * from the active aspect ratio. Load-bearing: the coordinate contract. */
30
+ export const CANVAS_VIRTUAL_W = 1080
31
+
32
+ /* Sensible DS default ratio table; a consumer passes its own `aspects` to
33
+ * override. Each entry is { id, label, ratio }. */
34
+ export const DEFAULT_ASPECTS = [
35
+ { id: '1:1', label: '1:1', ratio: 1 },
36
+ { id: '4:5', label: '4:5', ratio: 4 / 5 },
37
+ { id: '9:16', label: '9:16', ratio: 9 / 16 },
38
+ { id: '5:4', label: '5:4', ratio: 5 / 4 },
39
+ { id: '16:9', label: '16:9', ratio: 16 / 9 },
40
+ ]
41
+
42
+ export const CANVAS_DEFAULTS = {
43
+ guideColor: '#F5F3EF',
44
+ }
45
+
46
+ function resolveAspect(aspect, customRatio, aspects) {
47
+ const table = aspects?.length ? aspects : DEFAULT_ASPECTS
48
+ const found = table.find((x) => x.id === aspect) ?? table[0]
49
+ const ratio = aspect === 'custom' && customRatio ? customRatio : found.ratio
50
+ const label = aspect === 'custom' && customRatio
51
+ ? `Custom · ${Number(customRatio).toFixed(2)}`
52
+ : found.label
53
+ return { ratio, label }
54
+ }
55
+
56
+ /**
57
+ * Bare aspect frame — dashed guide border + label + virtual-pixel scale layer.
58
+ * No outer letterbox. Sizes to its parent (`width: 100%; aspect-ratio: ratio`)
59
+ * so the parent decides the frame's width/height.
60
+ *
61
+ * @param {string} aspect aspect id, selects ratio+label from the table
62
+ * @param {number} customRatio numeric ratio + `Custom · N.NN` label when aspect==='custom'
63
+ * @param {Array} aspects [{ id, label, ratio }] ratio table (default DEFAULT_ASPECTS)
64
+ * @param {string} bgColor frame background fill (default transparent)
65
+ * @param {string} guideColor dashed border (@24%) + label (@70%) color
66
+ * @param {ReactNode} children rendered in the 1080-virtual scale layer
67
+ */
68
+ export function CanvasFrame({
69
+ aspect,
70
+ customRatio,
71
+ aspects,
72
+ bgColor,
73
+ guideColor = CANVAS_DEFAULTS.guideColor,
74
+ children,
75
+ }) {
76
+ const { ratio, label } = resolveAspect(aspect, customRatio, aspects)
77
+ const virtualH = CANVAS_VIRTUAL_W / ratio
78
+
79
+ const rectRef = useRef(null)
80
+ const [scale, setScale] = useState(0)
81
+
82
+ useEffect(() => {
83
+ const node = rectRef.current
84
+ if (!node) return
85
+ const compute = () => {
86
+ const w = node.getBoundingClientRect().width
87
+ if (w > 0) setScale(w / CANVAS_VIRTUAL_W)
88
+ }
89
+ compute()
90
+ const ro = new ResizeObserver(compute)
91
+ ro.observe(node)
92
+ return () => ro.disconnect()
93
+ }, [ratio])
94
+
95
+ return (
96
+ <div
97
+ ref={rectRef}
98
+ className="relative w-full"
99
+ style={{
100
+ aspectRatio: ratio,
101
+ background: bgColor ?? 'transparent',
102
+ }}
103
+ >
104
+ <div
105
+ className="absolute inset-0 pointer-events-none z-[2]"
106
+ style={{
107
+ border: '1px solid',
108
+ borderColor: `color-mix(in srgb, ${guideColor} 24%, transparent)`,
109
+ }}
110
+ />
111
+ <span
112
+ className="z-[2]"
113
+ style={{
114
+ position: 'absolute',
115
+ top: 6,
116
+ left: 8,
117
+ fontSize: 10,
118
+ fontFamily: 'var(--kol-font-family-mono)',
119
+ letterSpacing: '0.1em',
120
+ color: `color-mix(in srgb, ${guideColor} 70%, transparent)`,
121
+ pointerEvents: 'none',
122
+ }}
123
+ >
124
+ {label}
125
+ </span>
126
+ {scale > 0 && (
127
+ <div
128
+ className="absolute top-0 left-0 z-[1]"
129
+ style={{
130
+ width: `${CANVAS_VIRTUAL_W}px`,
131
+ height: `${virtualH}px`,
132
+ transformOrigin: 'top left',
133
+ transform: `scale(${scale})`,
134
+ }}
135
+ >
136
+ {children}
137
+ </div>
138
+ )}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Canvas — letterboxed stage (default export).
145
+ *
146
+ * The OUTER wrapper letterboxes the inner `<CanvasFrame>` (rect carries the
147
+ * optional `bgColor`, dashed border, label, and virtual-pixel scale layer).
148
+ *
149
+ * `panEnabled` opts into Space-key + drag panning. When held, the cursor
150
+ * flips to grab/grabbing and the frame translates with the cursor; pointer
151
+ * events on the frame are suppressed while Space is held so layer handlers
152
+ * don't fire mid-pan. Decorative chrome (grid bg / dark bg / inset borders)
153
+ * is the consumer's responsibility — pass a `backdrop` node to fill the pan
154
+ * viewport behind the frame.
155
+ *
156
+ * @param {string} aspect aspect id (see aspects table)
157
+ * @param {number} customRatio numeric ratio when aspect==='custom'
158
+ * @param {Array} aspects [{ id, label, ratio }] ratio table
159
+ * @param {string} bgColor frame background fill
160
+ * @param {string} guideColor guide border + label color
161
+ * @param {'center'|'start'} align vertical placement in the letterbox
162
+ * @param {boolean} panEnabled wrap in a Space+drag PanViewport
163
+ * @param {ReactNode} backdrop node placed behind the frame in the pan viewport (e.g. a grid)
164
+ * @param {ReactNode} children rendered in the 1080-virtual scale layer
165
+ */
166
+ export default function Canvas({
167
+ aspect,
168
+ customRatio,
169
+ aspects,
170
+ bgColor,
171
+ guideColor = CANVAS_DEFAULTS.guideColor,
172
+ align = 'center',
173
+ panEnabled = false,
174
+ backdrop,
175
+ children,
176
+ }) {
177
+ const { ratio } = resolveAspect(aspect, customRatio, aspects)
178
+
179
+ const letterbox = (
180
+ <div
181
+ className={`flex ${align === 'start' ? 'items-start' : 'items-center'} justify-center w-full h-full`}
182
+ style={{ containerType: 'size' }}
183
+ >
184
+ <div
185
+ style={{
186
+ width: `min(calc(100cqw - 48px), calc((100cqh - 48px) * ${ratio}))`,
187
+ }}
188
+ >
189
+ <CanvasFrame
190
+ aspect={aspect}
191
+ customRatio={customRatio}
192
+ aspects={aspects}
193
+ bgColor={bgColor}
194
+ guideColor={guideColor}
195
+ >
196
+ {children}
197
+ </CanvasFrame>
198
+ </div>
199
+ </div>
200
+ )
201
+
202
+ if (!panEnabled) return letterbox
203
+ return <PanViewport backdrop={backdrop}>{letterbox}</PanViewport>
204
+ }
205
+
206
+ /**
207
+ * PanViewport — Space + drag pan wrapper.
208
+ *
209
+ * Hold Space → cursor `grab`. Mousedown while held → drag-pan, cursor
210
+ * `grabbing`. The pan transform applies to the child div that holds the frame;
211
+ * the viewport itself stays fixed. Pointer events on the transform layer
212
+ * disable while Space is held so layer-level mousedowns don't fire mid-pan.
213
+ * At rest the cursor is left unset so a consumer's tool cursor shows through.
214
+ *
215
+ * @param {ReactNode} backdrop placed behind the frame (grid / dark bg); the
216
+ * consumer owns its sizing (oversize it so panning
217
+ * never reveals an edge)
218
+ * @param {ReactNode} children the letterboxed frame
219
+ */
220
+ export function PanViewport({ backdrop, children }) {
221
+ const [spaceHeld, setSpaceHeld] = useState(false)
222
+ const [pan, setPan] = useState({ x: 0, y: 0 })
223
+ const [dragging, setDragging] = useState(false)
224
+ const dragStart = useRef(null)
225
+
226
+ useEffect(() => {
227
+ const isInputTarget = (el) =>
228
+ el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
229
+ const onKeyDown = (e) => {
230
+ if (e.code === 'Space' && !isInputTarget(e.target)) {
231
+ e.preventDefault()
232
+ setSpaceHeld(true)
233
+ }
234
+ }
235
+ const onKeyUp = (e) => {
236
+ if (e.code === 'Space') {
237
+ setSpaceHeld(false)
238
+ setDragging(false)
239
+ dragStart.current = null
240
+ }
241
+ }
242
+ window.addEventListener('keydown', onKeyDown)
243
+ window.addEventListener('keyup', onKeyUp)
244
+ return () => {
245
+ window.removeEventListener('keydown', onKeyDown)
246
+ window.removeEventListener('keyup', onKeyUp)
247
+ }
248
+ }, [])
249
+
250
+ useEffect(() => {
251
+ if (!dragging) return
252
+ const onMove = (e) => {
253
+ if (!dragStart.current) return
254
+ setPan({
255
+ x: e.clientX - dragStart.current.x,
256
+ y: e.clientY - dragStart.current.y,
257
+ })
258
+ }
259
+ const onUp = () => {
260
+ setDragging(false)
261
+ dragStart.current = null
262
+ }
263
+ window.addEventListener('mousemove', onMove)
264
+ window.addEventListener('mouseup', onUp)
265
+ return () => {
266
+ window.removeEventListener('mousemove', onMove)
267
+ window.removeEventListener('mouseup', onUp)
268
+ }
269
+ }, [dragging])
270
+
271
+ const onMouseDown = (e) => {
272
+ if (!spaceHeld) return
273
+ e.preventDefault()
274
+ dragStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }
275
+ setDragging(true)
276
+ }
277
+
278
+ const cursor = dragging ? 'grabbing' : spaceHeld ? 'grab' : undefined
279
+
280
+ return (
281
+ <div
282
+ className="relative w-full h-full overflow-hidden select-none"
283
+ style={cursor ? { cursor } : undefined}
284
+ onMouseDown={onMouseDown}
285
+ >
286
+ <div
287
+ className="absolute inset-0"
288
+ style={{
289
+ transform: `translate(${pan.x}px, ${pan.y}px)`,
290
+ transition: dragging ? 'none' : 'transform 120ms ease-out',
291
+ pointerEvents: spaceHeld ? 'none' : 'auto',
292
+ }}
293
+ >
294
+ {backdrop}
295
+ <div className="relative w-full h-full">{children}</div>
296
+ </div>
297
+ </div>
298
+ )
299
+ }
@@ -0,0 +1,155 @@
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
+ }