@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.
- package/package.json +11 -3
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/atoms/Button.jsx +17 -6
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +66 -1
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- 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
|
+
}
|