@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,526 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { motion, useMotionValue, animate } from 'framer-motion'
4
+ import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
5
+
6
+ /**
7
+ * True on touch-first devices. A cursor-follow effect is meaningless
8
+ * without a hover pointer, so AsciiCursor gates on this — local to the
9
+ * file, same shape as usePrefersReducedMotion.
10
+ */
11
+ function useCoarsePointer() {
12
+ const [coarse, setCoarse] = useState(
13
+ () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
14
+ )
15
+
16
+ useEffect(() => {
17
+ const mq = window.matchMedia('(pointer: coarse)')
18
+ const onChange = () => setCoarse(mq.matches)
19
+ mq.addEventListener('change', onChange)
20
+ return () => mq.removeEventListener('change', onChange)
21
+ }, [])
22
+
23
+ return coarse
24
+ }
25
+
26
+ // ── Firework ────────────────────────────────────────────────────────
27
+
28
+ // Type A — cross burst (original)
29
+ const framesA = [
30
+ [
31
+ ' * ',
32
+ ' /|\\ ',
33
+ ' * ─ * ',
34
+ ' \\|/ ',
35
+ ' * ',
36
+ ],
37
+ [
38
+ ' · * · ',
39
+ " ' | ' ",
40
+ '· ─ * ─ ·',
41
+ " ' | ' ",
42
+ ' · * · ',
43
+ ],
44
+ [
45
+ ' · · ',
46
+ " ' ' ",
47
+ '· * ·',
48
+ " ' ' ",
49
+ ' · · ',
50
+ ],
51
+ [
52
+ ' · · ',
53
+ ' · ',
54
+ ' · · ',
55
+ ' · ',
56
+ ' · · ',
57
+ ],
58
+ [
59
+ ' ',
60
+ ' · ',
61
+ ' · · ',
62
+ ' · ',
63
+ ' ',
64
+ ],
65
+ ]
66
+
67
+ // Type B — ring burst
68
+ const framesB = [
69
+ [
70
+ ' ░░░░░ ',
71
+ ' ░ ░ ',
72
+ '░ ▒ ░',
73
+ ' ░ ░ ',
74
+ ' ░░░░░ ',
75
+ ],
76
+ [
77
+ ' ▒ ░ ░ ▒ ',
78
+ '░ ░',
79
+ ' · ',
80
+ '░ ░',
81
+ ' ▒ ░ ░ ▒ ',
82
+ ],
83
+ [
84
+ '· ·',
85
+ ' ░ ░ ',
86
+ ' · ',
87
+ ' ░ ░ ',
88
+ '· ·',
89
+ ],
90
+ [
91
+ ' ',
92
+ ' · · ',
93
+ ' ',
94
+ ' · · ',
95
+ ' ',
96
+ ],
97
+ [
98
+ ' ',
99
+ ' · ',
100
+ ' ',
101
+ ' · ',
102
+ ' ',
103
+ ],
104
+ ]
105
+
106
+ // Type C — diamond scatter
107
+ const framesC = [
108
+ [
109
+ ' ◆ ',
110
+ ' ◆ ◆ ◆ ',
111
+ '◆ ◆ ◆ ◆ ◆',
112
+ ' ◆ ◆ ◆ ',
113
+ ' ◆ ',
114
+ ],
115
+ [
116
+ ' · ◇ · ',
117
+ ' ◇ ◇ ',
118
+ '· ◆ ·',
119
+ ' ◇ ◇ ',
120
+ ' · ◇ · ',
121
+ ],
122
+ [
123
+ ' · · ',
124
+ ' ◇ ◇ ',
125
+ ' ◇ ◇ ',
126
+ ' ◇ ◇ ',
127
+ ' · · ',
128
+ ],
129
+ [
130
+ ' ',
131
+ ' · · ',
132
+ ' · ',
133
+ ' · · ',
134
+ ' ',
135
+ ],
136
+ [
137
+ ' ',
138
+ ' · ',
139
+ ' · · ',
140
+ ' · ',
141
+ ' ',
142
+ ],
143
+ ]
144
+
145
+ const allFrameSets = [framesA, framesB, framesC]
146
+
147
+ function Firework({ x, y, type, onDone }) {
148
+ const [frame, setFrame] = useState(0)
149
+ const frames = allFrameSets[type]
150
+
151
+ useEffect(() => {
152
+ const interval = setInterval(() => {
153
+ setFrame((f) => {
154
+ if (f >= frames.length - 1) {
155
+ onDone()
156
+ return f
157
+ }
158
+ return f + 1
159
+ })
160
+ }, 130)
161
+ return () => clearInterval(interval)
162
+ }, [frames, onDone])
163
+
164
+ return (
165
+ <div
166
+ className="fixed font-mono text-[14px] leading-none pointer-events-none select-none"
167
+ style={{
168
+ left: x,
169
+ top: y,
170
+ color: 'var(--kol-surface-on-primary)',
171
+ opacity: Math.max(0.15, 1 - frame * 0.2),
172
+ transform: 'translate(-50%, -50%)',
173
+ whiteSpace: 'pre',
174
+ textAlign: 'center',
175
+ }}
176
+ >
177
+ {frames[frame]?.map((line, i) => (
178
+ <div key={i}>{line}</div>
179
+ ))}
180
+ </div>
181
+ )
182
+ }
183
+
184
+ // ── Space Invader ───────────────────────────────────────────────────
185
+
186
+ const invaderSprite = [
187
+ ' ████████ ',
188
+ ' ████████████ ',
189
+ '████████████████',
190
+ '████ ████ ████',
191
+ '████████████████',
192
+ '████████████████',
193
+ ' ████ ████ ',
194
+ ' ██ ██ ',
195
+ '████ ████',
196
+ ]
197
+
198
+ function Bullet({ startX, startY, delay }) {
199
+ const y = useMotionValue(startY)
200
+
201
+ useEffect(() => {
202
+ const timeout = setTimeout(() => {
203
+ animate(y, startY + 300, { duration: 0.5, ease: 'linear' })
204
+ }, delay)
205
+ return () => clearTimeout(timeout)
206
+ }, [y, startY, delay])
207
+
208
+ return (
209
+ <motion.div
210
+ style={{ x: startX, y }}
211
+ className="absolute font-mono text-[4px] pointer-events-none select-none"
212
+ >
213
+ <div style={{ color: 'var(--kol-surface-on-primary)' }}>│</div>
214
+ </motion.div>
215
+ )
216
+ }
217
+
218
+ function Invader({ x, y, onDone }) {
219
+ const [bullets, setBullets] = useState([])
220
+ const posX = useMotionValue(x)
221
+ const posY = useMotionValue(y - 60)
222
+
223
+ useEffect(() => {
224
+ // Drop in
225
+ animate(posY, y, { duration: 0.3, ease: 'easeOut' })
226
+
227
+ // Shoot 3 bullets
228
+ const shot1 = setTimeout(() => setBullets((prev) => [...prev, { id: 1, x }]), 250)
229
+ const shot2 = setTimeout(() => setBullets((prev) => [...prev, { id: 2, x }]), 450)
230
+ const shot3 = setTimeout(() => setBullets((prev) => [...prev, { id: 3, x }]), 650)
231
+
232
+ // Exit right
233
+ const exit = setTimeout(() => {
234
+ animate(posX, x + window.innerWidth * 0.6, { duration: 0.7, ease: 'easeIn' })
235
+ }, 800)
236
+
237
+ // Cleanup
238
+ const done = setTimeout(onDone, 1800)
239
+
240
+ return () => {
241
+ clearTimeout(shot1)
242
+ clearTimeout(shot2)
243
+ clearTimeout(shot3)
244
+ clearTimeout(exit)
245
+ clearTimeout(done)
246
+ }
247
+ }, [x, y, posX, posY, onDone])
248
+
249
+ return (
250
+ <>
251
+ <motion.div
252
+ style={{ x: posX, y: posY }}
253
+ className="absolute font-mono text-[3px] leading-none pointer-events-none select-none whitespace-pre"
254
+ >
255
+ <pre style={{ color: 'var(--kol-surface-on-primary)', opacity: 0.6, transform: 'translate(-50%, -50%)' }}>
256
+ {invaderSprite.join('\n')}
257
+ </pre>
258
+ </motion.div>
259
+ {bullets.map((bullet) => (
260
+ <Bullet key={bullet.id} startX={bullet.x} startY={posY.get() + 15} delay={0} />
261
+ ))}
262
+ </>
263
+ )
264
+ }
265
+
266
+ // ── Cursor Stars ────────────────────────────────────────────────────
267
+
268
+ const starChars = ['·', '+', '*', '+', '·', '-', '·']
269
+
270
+ function CursorStars() {
271
+ const [stars, setStars] = useState([])
272
+ const mouseRef = useRef({ x: -100, y: -100 })
273
+ const nextStarId = useRef(0)
274
+
275
+ useEffect(() => {
276
+ const handleMove = (e) => { mouseRef.current = { x: e.clientX, y: e.clientY } }
277
+ window.addEventListener('mousemove', handleMove)
278
+ return () => window.removeEventListener('mousemove', handleMove)
279
+ }, [])
280
+
281
+ // Spawn a star at a random offset from cursor every 400–1200ms
282
+ useEffect(() => {
283
+ const spawn = () => {
284
+ const angle = Math.random() * Math.PI * 2
285
+ const dist = 30 + Math.random() * 50
286
+ const id = ++nextStarId.current
287
+ setStars((prev) => [...prev, {
288
+ id,
289
+ x: mouseRef.current.x + Math.cos(angle) * dist,
290
+ y: mouseRef.current.y + Math.sin(angle) * dist,
291
+ char: starChars[Math.floor(Math.random() * starChars.length)],
292
+ }])
293
+ // Self-remove after fade completes
294
+ setTimeout(() => setStars((prev) => prev.filter((s) => s.id !== id)), 1200)
295
+ // Schedule next spawn
296
+ timeout = setTimeout(spawn, 400 + Math.random() * 800)
297
+ }
298
+ let timeout = setTimeout(spawn, 500)
299
+ return () => clearTimeout(timeout)
300
+ }, [])
301
+
302
+ return (
303
+ <>
304
+ {stars.map((star) => (
305
+ <motion.div
306
+ key={star.id}
307
+ className="fixed font-mono pointer-events-none select-none"
308
+ style={{
309
+ left: star.x,
310
+ top: star.y,
311
+ color: 'var(--kol-surface-on-primary)',
312
+ fontSize: '16px',
313
+ }}
314
+ initial={{ opacity: 0 }}
315
+ animate={{ opacity: [0, 0.35, 0] }}
316
+ transition={{ duration: 1.2, ease: 'easeInOut' }}
317
+ >
318
+ {star.char}
319
+ </motion.div>
320
+ ))}
321
+ </>
322
+ )
323
+ }
324
+
325
+ // ── ASCII Crosshair ─────────────────────────────────────────────────
326
+
327
+ // Default crosshair — heavy ticks
328
+ const defaultCrosshair = [
329
+ ' ╻ ',
330
+ '╺ · ╸',
331
+ ' ╹ ',
332
+ ]
333
+
334
+ // Hover crosshair — diamond
335
+ const hoverCrosshair = [
336
+ ' ◇ ',
337
+ '◇ · ◇',
338
+ ' ◇ ',
339
+ ]
340
+
341
+ function AsciiCrosshair({ overlayRef }) {
342
+ const [mousePos, setMousePos] = useState({ x: -100, y: -100 })
343
+ const [hovering, setHovering] = useState(false)
344
+
345
+ useEffect(() => {
346
+ const handleMove = (e) => {
347
+ setMousePos({ x: e.clientX, y: e.clientY })
348
+ // Temporarily hide overlay so elementFromPoint sees through it
349
+ const overlay = overlayRef.current
350
+ if (overlay) overlay.style.pointerEvents = 'none'
351
+ const el = document.elementFromPoint(e.clientX, e.clientY)
352
+ if (overlay) overlay.style.pointerEvents = ''
353
+ setHovering(!!el?.closest('a, button, [role="button"], input, textarea, select, label[for]'))
354
+ }
355
+ window.addEventListener('mousemove', handleMove)
356
+ return () => window.removeEventListener('mousemove', handleMove)
357
+ }, [overlayRef])
358
+
359
+ const crosshair = hovering ? hoverCrosshair : defaultCrosshair
360
+
361
+ return (
362
+ <div
363
+ className="fixed font-mono text-[10px] leading-[1.1] pointer-events-none select-none"
364
+ style={{
365
+ left: mousePos.x,
366
+ top: mousePos.y,
367
+ transform: 'translate(-50%, -50%)',
368
+ whiteSpace: 'pre',
369
+ textAlign: 'center',
370
+ }}
371
+ >
372
+ {/* Dark backing layer — offset by 1px in each direction */}
373
+ {[-1, 1].map((dx) =>
374
+ [-1, 1].map((dy) => (
375
+ <div
376
+ key={`${dx}${dy}`}
377
+ className="absolute inset-0"
378
+ style={{
379
+ color: 'var(--kol-surface-primary)',
380
+ opacity: 0.8,
381
+ transform: `translate(${dx}px, ${dy}px)`,
382
+ }}
383
+ >
384
+ {crosshair.map((line, i) => (
385
+ <div key={i}>{line}</div>
386
+ ))}
387
+ </div>
388
+ ))
389
+ )}
390
+ {/* Foreground layer */}
391
+ <div style={{ color: 'var(--kol-surface-on-primary)', opacity: 0.5, position: 'relative' }}>
392
+ {crosshair.map((line, i) => (
393
+ <div key={i}>{line}</div>
394
+ ))}
395
+ </div>
396
+ </div>
397
+ )
398
+ }
399
+
400
+ // ── Main overlay ────────────────────────────────────────────────────
401
+
402
+ let nextId = 0
403
+
404
+ /**
405
+ * All effect state and window listeners live here, below the gate, so a
406
+ * gated AsciiCursor mounts nothing — no listeners, no intervals, no
407
+ * style injection.
408
+ */
409
+ function AsciiCursorOverlay({ hideCursor }) {
410
+ const [fireworks, setFireworks] = useState([])
411
+ const [invaders, setInvaders] = useState([])
412
+ const containerRef = useRef(null)
413
+ const lastInvaderTime = useRef(0)
414
+
415
+ const removeFirework = useCallback((id) => {
416
+ setFireworks((prev) => prev.filter((f) => f.id !== id))
417
+ }, [])
418
+
419
+ const removeInvader = useCallback((id) => {
420
+ setInvaders((prev) => prev.filter((inv) => inv.id !== id))
421
+ }, [])
422
+
423
+ // Opt-in: hide the native cursor everywhere — restore on mouseleave or
424
+ // window blur (dev tools), remove the injected style on unmount.
425
+ useEffect(() => {
426
+ if (!hideCursor) return
427
+
428
+ const style = document.createElement('style')
429
+ style.id = 'kol-ascii-cursor-hide'
430
+
431
+ const hide = () => {
432
+ style.textContent = '*, *::before, *::after { cursor: none !important; }'
433
+ if (!style.parentNode) document.head.appendChild(style)
434
+ }
435
+ const show = () => {
436
+ style.textContent = ''
437
+ }
438
+
439
+ document.addEventListener('mouseenter', hide)
440
+ document.addEventListener('mouseleave', show)
441
+ window.addEventListener('blur', show)
442
+ window.addEventListener('focus', hide)
443
+ hide()
444
+
445
+ return () => {
446
+ document.removeEventListener('mouseenter', hide)
447
+ document.removeEventListener('mouseleave', show)
448
+ window.removeEventListener('blur', show)
449
+ window.removeEventListener('focus', hide)
450
+ style.remove()
451
+ }
452
+ }, [hideCursor])
453
+
454
+ useEffect(() => {
455
+ const handleClick = (e) => {
456
+ const id = ++nextId
457
+ const roll = Math.random()
458
+ const type = roll < 0.6 ? 0 : roll < 0.8 ? 1 : 2
459
+ setFireworks((prev) => [...prev, { id, x: e.clientX, y: e.clientY, type }])
460
+ }
461
+
462
+ const handleContextMenu = (e) => {
463
+ e.preventDefault()
464
+ const now = Date.now()
465
+ if (now - lastInvaderTime.current < 2500) return
466
+ lastInvaderTime.current = now
467
+ const id = ++nextId
468
+ setInvaders((prev) => [...prev, { id, x: e.clientX, y: e.clientY }])
469
+ }
470
+
471
+ window.addEventListener('click', handleClick)
472
+ window.addEventListener('contextmenu', handleContextMenu)
473
+ return () => {
474
+ window.removeEventListener('click', handleClick)
475
+ window.removeEventListener('contextmenu', handleContextMenu)
476
+ }
477
+ }, [])
478
+
479
+ return (
480
+ <div
481
+ ref={containerRef}
482
+ className="kol-ascii-cursor fixed inset-0 z-[100] pointer-events-none"
483
+ style={{ cursor: 'none' }}
484
+ >
485
+ <AsciiCrosshair overlayRef={containerRef} />
486
+ <CursorStars />
487
+ {fireworks.map((fw) => (
488
+ <Firework key={fw.id} x={fw.x} y={fw.y} type={fw.type} onDone={() => removeFirework(fw.id)} />
489
+ ))}
490
+ {invaders.map((inv) => (
491
+ <Invader key={inv.id} x={inv.x} y={inv.y} onDone={() => removeInvader(inv.id)} />
492
+ ))}
493
+ </div>
494
+ )
495
+ }
496
+
497
+ /**
498
+ * AsciiCursor — full-viewport decorative overlay that shadows the pointer
499
+ * with an ASCII crosshair (diamond variant over interactive elements) and
500
+ * layers ambient effects on top of the page: drifting cursor stars,
501
+ * click-triggered ASCII fireworks, and a right-click Space Invader that
502
+ * drops in, fires, and exits (rate-limited to one per 2.5s; contextmenu is
503
+ * suppressed while mounted).
504
+ *
505
+ * Singleton — mount once at the app root. Portals into `document.body` so
506
+ * it floats above any scroll context. Pure decoration, so it hard-gates:
507
+ * renders NOTHING when the user prefers reduced motion or the device has a
508
+ * coarse pointer (touch) — the effect vanishes, it does not degrade.
509
+ *
510
+ * Glyphs read `--kol-surface-on-primary`; the crosshair's legibility
511
+ * backing reads `--kol-surface-primary` — theme-correct on any background.
512
+ *
513
+ * @param {boolean} hideCursor also hide the NATIVE cursor page-wide via an
514
+ * injected `cursor: none !important` style —
515
+ * opt-in because it is app-global behavior;
516
+ * restored on window blur / mouseleave and
517
+ * cleaned up on unmount
518
+ */
519
+ export default function AsciiCursor({ hideCursor = false }) {
520
+ const reducedMotion = usePrefersReducedMotion()
521
+ const coarsePointer = useCoarsePointer()
522
+
523
+ if (reducedMotion || coarsePointer || typeof document === 'undefined') return null
524
+
525
+ return createPortal(<AsciiCursorOverlay hideCursor={hideCursor} />, document.body)
526
+ }