bigwolfeman commited on
Commit
990cf29
·
1 Parent(s): e1e6f89
frontend/src/components/GlowParticleEffect.tsx ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * GlowParticleEffect Component
3
+ *
4
+ * A wrapper component that adds soft glowing particle effects to its children.
5
+ * Particles spawn at click locations and float upward while fading.
6
+ *
7
+ * VISUAL ARCHITECTURE:
8
+ * ┌──────────────────────────────────────────────────────────┐
9
+ * │ Wrapper Container (position: relative) │
10
+ * │ ┌────────────────────────────────────────────────────┐ │
11
+ * │ │ Children (note content, wikilinks, etc.) │ │
12
+ * │ │ │ │
13
+ * │ │ Some text with [[wikilink]] that you can click │ │
14
+ * │ │ ↑ │ │
15
+ * │ │ Click here │ │
16
+ * │ └────────────────────────────────────────────────────┘ │
17
+ * │ ┌────────────────────────────────────────────────────┐ │
18
+ * │ │ Canvas Overlay (position: absolute, pointer-events:│ │
19
+ * │ │ none) │ │
20
+ * │ │ ░░▒▓██▓▒░░ │ │
21
+ * │ │ ░░▒▓██▓▒░░ │ │
22
+ * │ │ ░░▒▓██▓▒░░ <- Particles float up │ │
23
+ * │ │ │ │
24
+ * │ └────────────────────────────────────────────────────┘ │
25
+ * └──────────────────────────────────────────────────────────┘
26
+ *
27
+ * VISUAL FLOW:
28
+ * 1. User clicks on wikilink (or any child element)
29
+ * 2. Click event bubbles up to wrapper
30
+ * 3. Particles spawn at click coordinates
31
+ * 4. Particles animate independently on canvas
32
+ * 5. Canvas has pointer-events: none, so clicks pass through
33
+ *
34
+ * ACCESSIBILITY:
35
+ * - Respects prefers-reduced-motion
36
+ * - Canvas is purely decorative (no interaction)
37
+ * - Does not interfere with screen readers
38
+ */
39
+
40
+ import React, { useRef, useCallback, useEffect, useState } from 'react';
41
+ import { useGlowParticles } from '@/hooks/useGlowParticles';
42
+ import { useReducedMotion } from '@/hooks/useReducedMotion';
43
+ import type { ParticleConfig, ParticlePreset } from '@/types/particles';
44
+ import { DEFAULT_PARTICLE_CONFIG, PARTICLE_PRESETS } from '@/types/particles';
45
+
46
+ export interface GlowParticleEffectProps {
47
+ /**
48
+ * Content to wrap with particle effect
49
+ */
50
+ children: React.ReactNode;
51
+
52
+ /**
53
+ * Particle configuration (colors, sizes, speeds, etc.)
54
+ * Can be a preset name or custom config object
55
+ */
56
+ config?: Partial<ParticleConfig> | ParticlePreset;
57
+
58
+ /**
59
+ * CSS class for the wrapper container
60
+ */
61
+ className?: string;
62
+
63
+ /**
64
+ * Whether particles are enabled
65
+ * Automatically disabled when prefers-reduced-motion is set
66
+ */
67
+ enabled?: boolean;
68
+
69
+ /**
70
+ * Optional callback when particles are spawned
71
+ * Useful for analytics or sound effects
72
+ */
73
+ onParticleBurst?: (x: number, y: number) => void;
74
+
75
+ /**
76
+ * Selector for elements that should trigger particles
77
+ * If not provided, ANY click in the container triggers particles
78
+ * Example: '.wikilink' to only trigger on wikilink clicks
79
+ */
80
+ triggerSelector?: string;
81
+ }
82
+
83
+ /**
84
+ * Resolve config from preset name or partial config
85
+ */
86
+ function resolveConfig(
87
+ configProp?: Partial<ParticleConfig> | ParticlePreset
88
+ ): ParticleConfig {
89
+ if (!configProp) {
90
+ return DEFAULT_PARTICLE_CONFIG;
91
+ }
92
+
93
+ // If it's a string, look up the preset
94
+ if (typeof configProp === 'string') {
95
+ return PARTICLE_PRESETS[configProp] || DEFAULT_PARTICLE_CONFIG;
96
+ }
97
+
98
+ // Merge with defaults
99
+ return { ...DEFAULT_PARTICLE_CONFIG, ...configProp };
100
+ }
101
+
102
+ /**
103
+ * GlowParticleEffect Component
104
+ *
105
+ * Wraps children with an invisible canvas overlay that renders particles.
106
+ *
107
+ * USAGE:
108
+ * ```tsx
109
+ * <GlowParticleEffect config="elegant" triggerSelector=".wikilink">
110
+ * <div>Content with [[wikilinks]]</div>
111
+ * </GlowParticleEffect>
112
+ * ```
113
+ */
114
+ export function GlowParticleEffect({
115
+ children,
116
+ config: configProp,
117
+ className = '',
118
+ enabled = true,
119
+ onParticleBurst,
120
+ triggerSelector,
121
+ }: GlowParticleEffectProps) {
122
+ // Check accessibility preference
123
+ const prefersReducedMotion = useReducedMotion();
124
+
125
+ // Resolve final configuration
126
+ const config = resolveConfig(configProp);
127
+
128
+ // Determine if particles should be active
129
+ const isDisabled = prefersReducedMotion || !enabled;
130
+
131
+ // Reference to wrapper for coordinate calculation
132
+ const wrapperRef = useRef<HTMLDivElement>(null);
133
+
134
+ // Canvas dimensions state
135
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
136
+
137
+ // Initialize particle system
138
+ const { canvasRef, createParticleBurst } = useGlowParticles(config, isDisabled);
139
+
140
+ /**
141
+ * UPDATE CANVAS DIMENSIONS
142
+ *
143
+ * VISUAL PURPOSE: Canvas must match container size for correct positioning.
144
+ * If canvas is smaller than container, particles would be clipped.
145
+ * If larger, particles could render in invisible area.
146
+ *
147
+ * We use ResizeObserver to handle:
148
+ * - Initial mount
149
+ * - Window resize
150
+ * - Layout changes
151
+ */
152
+ useEffect(() => {
153
+ const wrapper = wrapperRef.current;
154
+ if (!wrapper) return;
155
+
156
+ const updateDimensions = () => {
157
+ const rect = wrapper.getBoundingClientRect();
158
+ setDimensions({
159
+ width: rect.width,
160
+ height: rect.height,
161
+ });
162
+ };
163
+
164
+ // Initial measurement
165
+ updateDimensions();
166
+
167
+ // Watch for size changes
168
+ const resizeObserver = new ResizeObserver(updateDimensions);
169
+ resizeObserver.observe(wrapper);
170
+
171
+ return () => {
172
+ resizeObserver.disconnect();
173
+ };
174
+ }, []);
175
+
176
+ /**
177
+ * HANDLE CLICK FOR PARTICLE SPAWN
178
+ *
179
+ * VISUAL FLOW:
180
+ * 1. User clicks somewhere in the wrapper
181
+ * 2. We check if click target matches triggerSelector (if provided)
182
+ * 3. We calculate click position relative to canvas
183
+ * 4. We spawn particles at that position
184
+ *
185
+ * The particles appear exactly where the user clicked,
186
+ * creating a direct visual response to the interaction.
187
+ */
188
+ const handleClick = useCallback(
189
+ (event: React.MouseEvent<HTMLDivElement>) => {
190
+ // Skip if disabled
191
+ if (isDisabled) return;
192
+
193
+ // Check if trigger selector is specified
194
+ if (triggerSelector) {
195
+ // Find if click target matches selector (or is inside matching element)
196
+ const target = event.target as HTMLElement;
197
+ const matchingElement = target.closest(triggerSelector);
198
+ if (!matchingElement) {
199
+ // Click was not on a triggering element
200
+ return;
201
+ }
202
+ }
203
+
204
+ const wrapper = wrapperRef.current;
205
+ if (!wrapper) return;
206
+
207
+ /**
208
+ * CALCULATE RELATIVE COORDINATES
209
+ *
210
+ * VISUAL: event.clientX/Y are viewport-relative
211
+ * We need coordinates relative to the canvas origin (top-left of wrapper)
212
+ *
213
+ * rect.left/top give us the wrapper's position in the viewport
214
+ * Subtracting gives us the click position within the wrapper
215
+ */
216
+ const rect = wrapper.getBoundingClientRect();
217
+ const x = event.clientX - rect.left;
218
+ const y = event.clientY - rect.top;
219
+
220
+ // Spawn particles at click location
221
+ createParticleBurst(x, y);
222
+
223
+ // Notify callback if provided
224
+ onParticleBurst?.(x, y);
225
+ },
226
+ [isDisabled, triggerSelector, createParticleBurst, onParticleBurst]
227
+ );
228
+
229
+ return (
230
+ <div
231
+ ref={wrapperRef}
232
+ className={`relative ${className}`}
233
+ onClick={handleClick}
234
+ >
235
+ {/* Children (the actual content) */}
236
+ {children}
237
+
238
+ {/*
239
+ CANVAS OVERLAY
240
+
241
+ VISUAL ARCHITECTURE:
242
+ - Positioned absolutely to cover the entire wrapper
243
+ - pointer-events: none allows clicks to pass through to children
244
+ - z-index ensures particles render above content
245
+
246
+ VISUAL: The canvas is invisible until particles are drawn on it.
247
+ It acts as a transparent layer floating above the content.
248
+ */}
249
+ {!isDisabled && (
250
+ <canvas
251
+ ref={canvasRef}
252
+ width={dimensions.width}
253
+ height={dimensions.height}
254
+ className="absolute inset-0 pointer-events-none z-10"
255
+ style={{
256
+ // Ensure canvas covers entire container
257
+ width: '100%',
258
+ height: '100%',
259
+ }}
260
+ // Accessibility: Mark as decorative/presentation
261
+ role="presentation"
262
+ aria-hidden="true"
263
+ />
264
+ )}
265
+ </div>
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Re-export presets for convenience
271
+ */
272
+ export { PARTICLE_PRESETS } from '@/types/particles';
273
+ export type { ParticleConfig, ParticlePreset } from '@/types/particles';
274
+
275
+ export default GlowParticleEffect;
frontend/src/components/NoteViewer.tsx CHANGED
@@ -3,6 +3,7 @@
3
  * T081-T082: Wikilink click handling and broken link styling
4
  * T009: Font size buttons (A-, A, A+) for content adjustment
5
  * T037-T052: Table of Contents panel integration
 
6
  */
7
  import { useMemo, useEffect } from 'react';
8
  import ReactMarkdown from 'react-markdown';
@@ -26,6 +27,7 @@ import { createWikilinkComponent, resetSlugCache } from '@/lib/markdown.tsx';
26
  import { markdownToPlainText } from '@/lib/markdownToText';
27
  import { useTableOfContents } from '@/hooks/useTableOfContents';
28
  import { TableOfContents } from '@/components/TableOfContents';
 
29
 
30
  type FontSizePreset = 'small' | 'medium' | 'large';
31
 
@@ -238,7 +240,24 @@ export function NoteViewer({
238
  {/* Main content panel */}
239
  <ResizablePanel defaultSize={isTocOpen ? 75 : 100}>
240
  <ScrollArea className="h-full p-6">
241
- <div className="prose prose-slate dark:prose-invert max-w-none animate-fade-in-smooth">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  <ReactMarkdown
243
  remarkPlugins={[remarkGfm]}
244
  components={markdownComponents}
@@ -246,7 +265,7 @@ export function NoteViewer({
246
  >
247
  {processedBody}
248
  </ReactMarkdown>
249
- </div>
250
 
251
  <Separator className="my-8" />
252
 
 
3
  * T081-T082: Wikilink click handling and broken link styling
4
  * T009: Font size buttons (A-, A, A+) for content adjustment
5
  * T037-T052: Table of Contents panel integration
6
+ * PARTICLE EFFECT: Soft glowing particles on wikilink clicks
7
  */
8
  import { useMemo, useEffect } from 'react';
9
  import ReactMarkdown from 'react-markdown';
 
27
  import { markdownToPlainText } from '@/lib/markdownToText';
28
  import { useTableOfContents } from '@/hooks/useTableOfContents';
29
  import { TableOfContents } from '@/components/TableOfContents';
30
+ import { GlowParticleEffect } from '@/components/GlowParticleEffect';
31
 
32
  type FontSizePreset = 'small' | 'medium' | 'large';
33
 
 
240
  {/* Main content panel */}
241
  <ResizablePanel defaultSize={isTocOpen ? 75 : 100}>
242
  <ScrollArea className="h-full p-6">
243
+ {/*
244
+ PARTICLE EFFECT INTEGRATION
245
+
246
+ VISUAL: Wraps the markdown content with a canvas overlay.
247
+ When users click on wikilinks (.wikilink elements), soft
248
+ glowing particles spawn at the click location.
249
+
250
+ TRIGGER: Only wikilink clicks spawn particles (triggerSelector)
251
+ STYLE: "elegant" preset - balanced size, moderate glow, ~1 second fade
252
+
253
+ The particles float upward and fade out, creating a subtle
254
+ visual acknowledgment of the navigation action.
255
+ */}
256
+ <GlowParticleEffect
257
+ config="elegant"
258
+ triggerSelector=".wikilink"
259
+ className="prose prose-slate dark:prose-invert max-w-none animate-fade-in-smooth"
260
+ >
261
  <ReactMarkdown
262
  remarkPlugins={[remarkGfm]}
263
  components={markdownComponents}
 
265
  >
266
  {processedBody}
267
  </ReactMarkdown>
268
+ </GlowParticleEffect>
269
 
270
  <Separator className="my-8" />
271
 
frontend/src/hooks/useGlowParticles.ts ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * useGlowParticles Hook
3
+ *
4
+ * Core particle system hook that manages creation, animation, and rendering
5
+ * of soft glowing particles on a canvas element.
6
+ *
7
+ * VISUAL ARCHITECTURE:
8
+ * ┌─────────────────────────────────────────────────────┐
9
+ * │ Canvas (positioned absolutely over content) │
10
+ * │ │
11
+ * │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
12
+ * │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
13
+ * │ ░░░░░░░░░░░▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ <- Particles float
14
+ * │ ░░░░░░░░░░▓███▓░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░ │ upward and fade
15
+ * │ ░░░░░░░░░░░▓▓▓░░░░░░░░░░▒███▒░░░░░░░░░░░░░░ │
16
+ * │ ░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░ │
17
+ * │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
18
+ * │ ↑ │
19
+ * │ Click here │
20
+ * │ [Wikilink Text] │
21
+ * └─────────────────────────────────────────────────────┘
22
+ *
23
+ * Each particle (▓██▓ above) consists of:
24
+ * - Bright center (█) - solid color
25
+ * - Gradient falloff (▓) - fading to transparent
26
+ * - Soft glow halo (▒) - shadowBlur effect
27
+ */
28
+
29
+ import { useRef, useCallback, useEffect } from 'react';
30
+ import type { Particle, ParticleConfig } from '@/types/particles';
31
+ import { DEFAULT_PARTICLE_CONFIG } from '@/types/particles';
32
+
33
+ /**
34
+ * Parse hex color to RGB components
35
+ *
36
+ * VISUAL PURPOSE: We need RGB values to create gradient stops with
37
+ * varying alpha (transparency) values. Hex colors don't support alpha
38
+ * in the same way, so we convert to rgba() format.
39
+ *
40
+ * Example: '#6366f1' -> { r: 99, g: 102, b: 241 }
41
+ */
42
+ function hexToRgb(hex: string): { r: number; g: number; b: number } {
43
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
44
+ return result
45
+ ? {
46
+ r: parseInt(result[1], 16),
47
+ g: parseInt(result[2], 16),
48
+ b: parseInt(result[3], 16),
49
+ }
50
+ : { r: 99, g: 102, b: 241 }; // Fallback to indigo
51
+ }
52
+
53
+ /**
54
+ * Generate a random number within a range
55
+ */
56
+ function randomBetween(min: number, max: number): number {
57
+ return Math.random() * (max - min) + min;
58
+ }
59
+
60
+ /**
61
+ * Unique ID generator for particles
62
+ */
63
+ let particleIdCounter = 0;
64
+
65
+ export interface UseGlowParticlesReturn {
66
+ /**
67
+ * Ref to attach to the canvas element
68
+ * The canvas should be positioned absolutely over the content area
69
+ */
70
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
71
+
72
+ /**
73
+ * Trigger a particle burst at specific coordinates
74
+ * @param x - X coordinate relative to canvas
75
+ * @param y - Y coordinate relative to canvas
76
+ *
77
+ * VISUAL: Creates particleCount particles at (x,y) that burst outward
78
+ * in random directions
79
+ */
80
+ createParticleBurst: (x: number, y: number) => void;
81
+
82
+ /**
83
+ * Check if animation loop is currently running
84
+ */
85
+ isAnimating: boolean;
86
+ }
87
+
88
+ /**
89
+ * Main particle system hook
90
+ *
91
+ * @param config - Particle visual configuration (colors, sizes, speeds, etc.)
92
+ * @param disabled - If true, particles won't be created (accessibility)
93
+ * @returns Canvas ref and burst trigger function
94
+ *
95
+ * VISUAL LIFECYCLE OVERVIEW:
96
+ * 1. User clicks -> createParticleBurst(x, y) called
97
+ * 2. Particles spawn at click point with random velocities
98
+ * 3. Animation loop runs at 60 FPS:
99
+ * a. Clear previous frame
100
+ * b. Update each particle (position, life, size)
101
+ * c. Draw each particle (gradient + glow)
102
+ * d. Remove dead particles (life <= 0)
103
+ * 4. Loop stops when all particles are gone (performance optimization)
104
+ */
105
+ export function useGlowParticles(
106
+ config: ParticleConfig = DEFAULT_PARTICLE_CONFIG,
107
+ disabled: boolean = false
108
+ ): UseGlowParticlesReturn {
109
+ // Mutable refs that persist across renders without causing re-renders
110
+ const particlesRef = useRef<Particle[]>([]);
111
+ const animationFrameRef = useRef<number | null>(null);
112
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
113
+ const isAnimatingRef = useRef<boolean>(false);
114
+
115
+ /**
116
+ * CREATE PARTICLE BURST
117
+ *
118
+ * VISUAL EFFECT: When called, spawns N particles at the click location.
119
+ * Each particle:
120
+ * - Starts at (x, y)
121
+ * - Gets a random velocity in a random direction
122
+ * - Gets a random size and color from config
123
+ * - Has full life (1.0) at spawn
124
+ *
125
+ * The "burst" pattern comes from random angles - particles explode
126
+ * outward in all directions like a tiny firework.
127
+ *
128
+ * Visual analogy: Like tapping a dandelion and seeds flying off
129
+ */
130
+ const createParticleBurst = useCallback(
131
+ (x: number, y: number) => {
132
+ // Skip if disabled (accessibility) or no canvas
133
+ if (disabled) return;
134
+
135
+ const newParticles: Particle[] = [];
136
+
137
+ for (let i = 0; i < config.particleCount; i++) {
138
+ /**
139
+ * RANDOM ANGLE for burst direction
140
+ *
141
+ * VISUAL: Math.random() * Math.PI * 2 gives angle from 0 to 2π
142
+ * This means particles fly in ALL directions (360 degrees)
143
+ *
144
+ * Alternative patterns:
145
+ * - Math.PI * 1.5 to Math.PI * 2.5 = upward-biased cone
146
+ * - Fixed angle = particles move in same direction
147
+ */
148
+ const angle = Math.random() * Math.PI * 2;
149
+
150
+ /**
151
+ * RANDOM SPEED within configured range
152
+ *
153
+ * VISUAL: Variation in speed creates depth - some particles
154
+ * appear to move faster/closer while others drift slowly
155
+ */
156
+ const speed = randomBetween(config.minSpeed, config.maxSpeed);
157
+
158
+ /**
159
+ * VELOCITY COMPONENTS from angle and speed
160
+ *
161
+ * VISUAL:
162
+ * - vx = speed * cos(angle): horizontal component
163
+ * - vy = speed * sin(angle): vertical component
164
+ *
165
+ * Together, these create the direction of movement
166
+ */
167
+ const vx = Math.cos(angle) * speed;
168
+ const vy = Math.sin(angle) * speed;
169
+
170
+ /**
171
+ * RANDOM SIZE for visual variety
172
+ *
173
+ * VISUAL: Mix of sizes creates depth and interest.
174
+ * Smaller particles appear more distant/delicate.
175
+ */
176
+ const size = randomBetween(config.minSize, config.maxSize);
177
+
178
+ /**
179
+ * RANDOM COLOR from palette
180
+ *
181
+ * VISUAL: Each particle gets one of the configured colors.
182
+ * The gradient will fade from this color (center) to transparent (edge).
183
+ */
184
+ const color = config.colors[Math.floor(Math.random() * config.colors.length)];
185
+
186
+ newParticles.push({
187
+ x,
188
+ y,
189
+ vx,
190
+ vy,
191
+ size,
192
+ life: 1.0, // Full life at spawn (completely visible)
193
+ color,
194
+ id: particleIdCounter++,
195
+ });
196
+ }
197
+
198
+ // Add new particles to existing ones
199
+ particlesRef.current = [...particlesRef.current, ...newParticles];
200
+
201
+ // Start animation if not already running
202
+ if (!isAnimatingRef.current) {
203
+ isAnimatingRef.current = true;
204
+ animationFrameRef.current = requestAnimationFrame(animate);
205
+ }
206
+ },
207
+ [config, disabled]
208
+ );
209
+
210
+ /**
211
+ * DRAW SINGLE PARTICLE
212
+ *
213
+ * This is where the visual magic happens. Each particle is rendered as:
214
+ * 1. A radial gradient (bright center -> transparent edge)
215
+ * 2. With a soft glow effect (shadowBlur)
216
+ *
217
+ * VISUAL BREAKDOWN:
218
+ *
219
+ * ░░░░░░░░░░░░░░░ <- Glow halo (shadowBlur)
220
+ * ░░░░░░░░░░░░░░░░░
221
+ * ░░░░░▒▒▒▒▒▒▒░░░░░░ <- Outer gradient (50% opacity, fading)
222
+ * ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░
223
+ * ░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░ <- Mid gradient (70% opacity)
224
+ * ░░░░▒▒▓▓███▓▓▒▒░░░░░ <- Inner core (100% opacity)
225
+ * ░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░
226
+ * ░░░░▒▒▒▒▒▒▒▒▒░░░░░░
227
+ * ░░░░░▒▒▒▒▒▒▒░░░░░░
228
+ * ░░░░░░░░░░░░░░░░░
229
+ * ░░░░░░░░░░░░░░░
230
+ */
231
+ const drawParticle = useCallback(
232
+ (ctx: CanvasRenderingContext2D, particle: Particle) => {
233
+ // Calculate current visual properties based on life
234
+ // As life decreases, particle becomes smaller and more transparent
235
+ const currentSize = particle.size * particle.life;
236
+ const currentOpacity = particle.life;
237
+
238
+ // Skip if too small to see
239
+ if (currentSize < 0.5) return;
240
+
241
+ // Parse the base color to RGB for gradient creation
242
+ const rgb = hexToRgb(particle.color);
243
+
244
+ /**
245
+ * CREATE RADIAL GRADIENT
246
+ *
247
+ * ctx.createRadialGradient(x0, y0, r0, x1, y1, r1)
248
+ *
249
+ * PARAMETERS EXPLAINED:
250
+ * - (x0, y0, r0): Inner circle - center of particle, radius 0
251
+ * - (x1, y1, r1): Outer circle - center of particle, radius = currentSize/2
252
+ *
253
+ * VISUAL: The gradient will interpolate colors from the inner circle
254
+ * to the outer circle. Since both circles share the same center,
255
+ * this creates a circular gradient radiating outward.
256
+ *
257
+ * Inner (r0=0) Outer (r1=radius)
258
+ * █ ░░░░░░░░░░
259
+ * ↓ ↓
260
+ * [solid] -> [transparent]
261
+ */
262
+ const radius = currentSize / 2;
263
+ const gradient = ctx.createRadialGradient(
264
+ particle.x,
265
+ particle.y,
266
+ 0, // Inner circle: center point, radius 0 (a dot)
267
+ particle.x,
268
+ particle.y,
269
+ radius // Outer circle: same center, radius = particle size
270
+ );
271
+
272
+ /**
273
+ * ADD COLOR STOPS
274
+ *
275
+ * Color stops define how the gradient transitions from center to edge.
276
+ *
277
+ * VISUAL EFFECT OF EACH STOP:
278
+ *
279
+ * Stop 0.0 (center): Full color, full opacity
280
+ * - This is the "hot spot" - brightest point
281
+ * - Like the white center of a candle flame
282
+ *
283
+ * Stop 0.4 (40% radius): Full color, 70% opacity
284
+ * - Still bright but starting to fade
285
+ * - Creates a solid-looking core
286
+ *
287
+ * Stop 0.7 (70% radius): Full color, 30% opacity
288
+ * - Clearly fading now
289
+ * - The "warm glow" zone
290
+ *
291
+ * Stop 1.0 (edge): Full color, 0% opacity
292
+ * - Completely transparent
293
+ * - No hard edge - particle "melts" into background
294
+ *
295
+ * The currentOpacity multiplier makes the whole particle
296
+ * more transparent as it ages (life decreases from 1 to 0)
297
+ */
298
+ gradient.addColorStop(
299
+ 0,
300
+ `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${1.0 * currentOpacity})`
301
+ );
302
+ gradient.addColorStop(
303
+ 0.4,
304
+ `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${0.7 * currentOpacity})`
305
+ );
306
+ gradient.addColorStop(
307
+ 0.7,
308
+ `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${0.3 * currentOpacity})`
309
+ );
310
+ gradient.addColorStop(
311
+ 1,
312
+ `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0)`
313
+ );
314
+
315
+ /**
316
+ * APPLY GLOW EFFECT (shadowBlur)
317
+ *
318
+ * VISUAL: shadowBlur creates a soft halo around whatever we draw.
319
+ * Unlike the radial gradient (which IS the particle shape),
320
+ * the shadow extends BEYOND the particle boundary.
321
+ *
322
+ * Think of it like:
323
+ * - Radial gradient = the physical LED light bulb
324
+ * - shadowBlur = the glow you see around the bulb
325
+ *
326
+ * shadowBlur = 15 means:
327
+ * - A 15-pixel soft blur extends beyond the particle
328
+ * - Color matches shadowColor (the particle's color)
329
+ * - Intensity determined by currentOpacity
330
+ *
331
+ * As particle life decreases:
332
+ * - Glow intensity decreases proportionally
333
+ * - Creates the effect of "cooling down" or "fading away"
334
+ */
335
+ ctx.shadowBlur = config.glowIntensity * currentOpacity;
336
+ ctx.shadowColor = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${0.6 * currentOpacity})`;
337
+
338
+ /**
339
+ * DRAW THE PARTICLE
340
+ *
341
+ * ctx.arc(x, y, radius, startAngle, endAngle)
342
+ * - Draws a circle (full arc = 0 to 2π)
343
+ * - Fill with our radial gradient
344
+ *
345
+ * VISUAL: The combination of radial gradient + shadowBlur creates:
346
+ * - Bright core (gradient center)
347
+ * - Soft falloff (gradient edge)
348
+ * - Extended glow (shadow blur)
349
+ *
350
+ * Together: a soft, glowing orb that looks like a tiny light source
351
+ */
352
+ ctx.beginPath();
353
+ ctx.arc(particle.x, particle.y, radius, 0, Math.PI * 2);
354
+ ctx.fillStyle = gradient;
355
+ ctx.fill();
356
+
357
+ // Reset shadow to avoid affecting other drawings
358
+ ctx.shadowBlur = 0;
359
+ ctx.shadowColor = 'transparent';
360
+ },
361
+ [config.glowIntensity]
362
+ );
363
+
364
+ /**
365
+ * ANIMATION LOOP
366
+ *
367
+ * This runs at approximately 60 frames per second (synced with display).
368
+ * Each frame:
369
+ * 1. Clear the previous frame (canvas becomes transparent)
370
+ * 2. Update each particle's physics (position, life, size)
371
+ * 3. Draw each particle in its new state
372
+ * 4. Remove particles that have "died" (life <= 0)
373
+ * 5. Continue loop if particles remain, else stop
374
+ *
375
+ * VISUAL FLOW:
376
+ * Frame 0: Particles at spawn position, full brightness
377
+ * Frame 30: Particles have moved, 50% life remaining
378
+ * Frame 60: Particles nearly gone, very faint
379
+ * Frame 67: All particles dead, loop stops
380
+ *
381
+ * Performance note: Loop only runs when particles exist.
382
+ * When no particles remain, we stop to save CPU.
383
+ */
384
+ const animate = useCallback(() => {
385
+ const canvas = canvasRef.current;
386
+ if (!canvas) {
387
+ isAnimatingRef.current = false;
388
+ return;
389
+ }
390
+
391
+ const ctx = canvas.getContext('2d');
392
+ if (!ctx) {
393
+ isAnimatingRef.current = false;
394
+ return;
395
+ }
396
+
397
+ /**
398
+ * CLEAR PREVIOUS FRAME
399
+ *
400
+ * VISUAL: Wipes the canvas completely transparent.
401
+ * Without this, particles would leave "trails" as they move.
402
+ *
403
+ * Alternative: Use ctx.fillRect with semi-transparent color
404
+ * for intentional trail/afterimage effects (not desired here).
405
+ */
406
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
407
+
408
+ /**
409
+ * UPDATE AND DRAW EACH PARTICLE
410
+ */
411
+ particlesRef.current = particlesRef.current.filter((particle) => {
412
+ /**
413
+ * UPDATE POSITION
414
+ *
415
+ * VISUAL: Move particle by its velocity
416
+ * - x += vx: horizontal movement
417
+ * - y += vy: vertical movement
418
+ *
419
+ * Result: Particle drifts in its assigned direction
420
+ */
421
+ particle.x += particle.vx;
422
+ particle.y += particle.vy;
423
+
424
+ /**
425
+ * APPLY UPWARD DRIFT
426
+ *
427
+ * VISUAL: Adds small upward acceleration each frame
428
+ * Negative vy = moving up (canvas y increases downward)
429
+ *
430
+ * Effect: Particles gradually curve upward like:
431
+ * - Sparks rising from a fire
432
+ * - Bubbles floating in liquid
433
+ * - Heat shimmer rising
434
+ */
435
+ particle.vy += config.upwardDrift;
436
+
437
+ /**
438
+ * UPDATE LIFE (opacity)
439
+ *
440
+ * VISUAL: Decrease life each frame
441
+ * life goes from 1.0 -> 0.0 over fadeRate * frames
442
+ *
443
+ * At 60 FPS with fadeRate 0.016:
444
+ * 1.0 / 0.016 = 62.5 frames = ~1 second to fade out
445
+ */
446
+ particle.life -= config.fadeRate;
447
+
448
+ /**
449
+ * UPDATE SIZE (shrinking)
450
+ *
451
+ * VISUAL: Multiply size by shrinkRate each frame
452
+ * shrinkRate 0.985 means particle is 98.5% of previous size
453
+ *
454
+ * After 60 frames: 0.985^60 = 0.40 = 40% of original size
455
+ *
456
+ * Combined with fade, creates "dissipating" effect
457
+ */
458
+ particle.size *= config.shrinkRate;
459
+
460
+ /**
461
+ * DRAW THE PARTICLE
462
+ *
463
+ * Only if still alive (visible)
464
+ */
465
+ if (particle.life > 0) {
466
+ drawParticle(ctx, particle);
467
+ }
468
+
469
+ /**
470
+ * KEEP OR REMOVE
471
+ *
472
+ * Return true to keep particle in array, false to remove
473
+ * Remove when life <= 0 (completely faded out)
474
+ */
475
+ return particle.life > 0;
476
+ });
477
+
478
+ /**
479
+ * CONTINUE OR STOP LOOP
480
+ *
481
+ * VISUAL: If particles remain, schedule next frame
482
+ * If no particles, stop the animation loop
483
+ *
484
+ * Performance: Prevents unnecessary CPU usage when idle
485
+ */
486
+ if (particlesRef.current.length > 0) {
487
+ animationFrameRef.current = requestAnimationFrame(animate);
488
+ } else {
489
+ isAnimatingRef.current = false;
490
+ animationFrameRef.current = null;
491
+ }
492
+ }, [config.fadeRate, config.shrinkRate, config.upwardDrift, drawParticle]);
493
+
494
+ /**
495
+ * CLEANUP ON UNMOUNT
496
+ *
497
+ * Cancel any pending animation frame to prevent memory leaks
498
+ * and errors from drawing on unmounted canvas
499
+ */
500
+ useEffect(() => {
501
+ return () => {
502
+ if (animationFrameRef.current !== null) {
503
+ cancelAnimationFrame(animationFrameRef.current);
504
+ }
505
+ };
506
+ }, []);
507
+
508
+ return {
509
+ canvasRef,
510
+ createParticleBurst,
511
+ isAnimating: isAnimatingRef.current,
512
+ };
513
+ }
514
+
515
+ export default useGlowParticles;
frontend/src/hooks/useReducedMotion.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * useReducedMotion Hook
3
+ *
4
+ * Accessibility hook that detects user's motion preference.
5
+ * When enabled, particle effects should be disabled or minimized.
6
+ *
7
+ * VISUAL ACCESSIBILITY CONTEXT:
8
+ * Some users experience motion sickness, vestibular disorders, or simply
9
+ * prefer reduced animation. The "prefers-reduced-motion" media query
10
+ * indicates this preference.
11
+ *
12
+ * When this hook returns true:
13
+ * - Particle effects should NOT be rendered
14
+ * - OR particles should appear statically (no animation)
15
+ * - This respects user autonomy and WCAG guidelines
16
+ */
17
+ import { useState, useEffect } from 'react';
18
+
19
+ /**
20
+ * Detects if the user prefers reduced motion
21
+ *
22
+ * @returns true if user prefers reduced motion, false otherwise
23
+ *
24
+ * USAGE:
25
+ * ```tsx
26
+ * const prefersReducedMotion = useReducedMotion();
27
+ *
28
+ * if (prefersReducedMotion) {
29
+ * // Skip particle animation entirely
30
+ * return null;
31
+ * }
32
+ * ```
33
+ */
34
+ export function useReducedMotion(): boolean {
35
+ // Server-side rendering safety: default to false
36
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
37
+
38
+ useEffect(() => {
39
+ // Check if matchMedia is available (browser environment)
40
+ if (typeof window === 'undefined' || !window.matchMedia) {
41
+ return;
42
+ }
43
+
44
+ // Create media query matcher
45
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
46
+
47
+ // Set initial value
48
+ setPrefersReducedMotion(mediaQuery.matches);
49
+
50
+ // Listen for changes (user might toggle system preference)
51
+ const handleChange = (event: MediaQueryListEvent) => {
52
+ setPrefersReducedMotion(event.matches);
53
+ };
54
+
55
+ // Modern browsers use addEventListener
56
+ mediaQuery.addEventListener('change', handleChange);
57
+
58
+ // Cleanup listener on unmount
59
+ return () => {
60
+ mediaQuery.removeEventListener('change', handleChange);
61
+ };
62
+ }, []);
63
+
64
+ return prefersReducedMotion;
65
+ }
66
+
67
+ export default useReducedMotion;
frontend/src/types/particles.ts ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Particle System Types
3
+ *
4
+ * VISUAL OVERVIEW:
5
+ * These types define the data structures for creating soft, glowing particles
6
+ * that appear when users interact with elements (like wikilinks).
7
+ *
8
+ * Each particle is a small glowing orb (6-12px) with:
9
+ * - A radial gradient from bright center to transparent edge
10
+ * - A soft blur halo that extends beyond the particle boundary
11
+ * - Smooth animation: floating outward, shrinking, and fading
12
+ *
13
+ * Visual Analogy: Think of particles as tiny floating embers or LED lights
14
+ * viewed through frosted glass - soft, warm, and gradually fading.
15
+ */
16
+
17
+ /**
18
+ * Individual Particle State
19
+ *
20
+ * Each particle is an autonomous visual element with its own position,
21
+ * velocity, appearance, and lifecycle state.
22
+ */
23
+ export interface Particle {
24
+ /**
25
+ * Horizontal position in canvas coordinates (pixels from left edge)
26
+ * VISUAL: Determines where the glowing orb appears on screen
27
+ */
28
+ x: number;
29
+
30
+ /**
31
+ * Vertical position in canvas coordinates (pixels from top edge)
32
+ * VISUAL: Combined with x, places the particle's center point
33
+ */
34
+ y: number;
35
+
36
+ /**
37
+ * Horizontal velocity (pixels per animation frame)
38
+ * VISUAL: Positive = moving right, Negative = moving left
39
+ * Typical range: -3 to +3 for gentle floating movement
40
+ */
41
+ vx: number;
42
+
43
+ /**
44
+ * Vertical velocity (pixels per animation frame)
45
+ * VISUAL: Positive = moving down, Negative = moving up
46
+ * Typically starts negative with small positive bias for upward drift
47
+ */
48
+ vy: number;
49
+
50
+ /**
51
+ * Particle diameter in pixels
52
+ * VISUAL: Determines the size of the glowing orb
53
+ * The radial gradient spans from center (0) to this radius (size/2)
54
+ * Typical range: 4-14 pixels for subtle effect
55
+ */
56
+ size: number;
57
+
58
+ /**
59
+ * Lifecycle progress from 1.0 (birth) to 0.0 (death)
60
+ * VISUAL EFFECTS:
61
+ * - Multiplied with opacity: particle becomes more transparent as life decreases
62
+ * - Multiplied with glow intensity: halo shrinks as particle dies
63
+ * - When life <= 0, particle is removed from the system
64
+ *
65
+ * Visual analogy: Like a candle burning down - starts bright, gradually dims
66
+ */
67
+ life: number;
68
+
69
+ /**
70
+ * Base color for the particle gradient (hex format: '#RRGGBB')
71
+ * VISUAL: This is the CENTER color of the radial gradient
72
+ * The edge fades to transparent using the same hue
73
+ *
74
+ * Palette typically includes:
75
+ * - '#6366f1' (indigo/blue) - cool, professional
76
+ * - '#a855f7' (purple) - rich, balanced
77
+ * - '#ec4899' (pink) - warm accent
78
+ */
79
+ color: string;
80
+
81
+ /**
82
+ * Unique identifier for React reconciliation
83
+ * Not visually relevant, but necessary for efficient DOM updates
84
+ */
85
+ id: number;
86
+ }
87
+
88
+ /**
89
+ * Particle System Configuration
90
+ *
91
+ * These settings control the overall visual behavior of the particle system.
92
+ * Adjusting these values changes the "feel" of the effect.
93
+ */
94
+ export interface ParticleConfig {
95
+ /**
96
+ * Number of particles spawned per trigger (click/interaction)
97
+ * VISUAL IMPACT:
98
+ * - Low (5-10): Subtle, minimal effect - professional/elegant
99
+ * - Medium (12-20): Noticeable but not overwhelming
100
+ * - High (25+): Dense, celebratory - can feel tacky if overdone
101
+ *
102
+ * Recommendation: 12-15 for subtle elegance
103
+ */
104
+ particleCount: number;
105
+
106
+ /**
107
+ * Minimum particle diameter in pixels
108
+ * VISUAL: Sets the lower bound for particle size variation
109
+ * Smaller particles appear more distant/delicate
110
+ * Typical value: 4-6 pixels
111
+ */
112
+ minSize: number;
113
+
114
+ /**
115
+ * Maximum particle diameter in pixels
116
+ * VISUAL: Sets the upper bound for particle size variation
117
+ * Larger particles appear closer/more prominent
118
+ * Typical value: 8-12 pixels
119
+ */
120
+ maxSize: number;
121
+
122
+ /**
123
+ * Minimum initial velocity magnitude (pixels per frame)
124
+ * VISUAL: How slow the slowest particles move
125
+ * Lower = more leisurely floating
126
+ * Typical value: 1.5-2.0
127
+ */
128
+ minSpeed: number;
129
+
130
+ /**
131
+ * Maximum initial velocity magnitude (pixels per frame)
132
+ * VISUAL: How fast the fastest particles move
133
+ * Higher = more energetic burst
134
+ * Typical value: 3.0-4.5
135
+ */
136
+ maxSpeed: number;
137
+
138
+ /**
139
+ * Glow halo intensity (shadowBlur value in pixels)
140
+ * VISUAL IMPACT:
141
+ * - 5-10: Subtle glow, barely perceptible halo
142
+ * - 12-18: Moderate glow, clearly visible soft halo
143
+ * - 20+: Strong glow, dramatic ethereal effect
144
+ *
145
+ * This creates the "frosted glass" effect around particles.
146
+ * The glow color matches the particle color.
147
+ *
148
+ * Visual analogy: Like the halo around a streetlight on a foggy night
149
+ */
150
+ glowIntensity: number;
151
+
152
+ /**
153
+ * Life decrement per animation frame (typically at 60 FPS)
154
+ * VISUAL IMPACT:
155
+ * - 0.010: Slow fade (~1.7 seconds to disappear)
156
+ * - 0.015: Medium fade (~1.1 seconds) - RECOMMENDED
157
+ * - 0.025: Fast fade (~0.7 seconds)
158
+ *
159
+ * Controls how quickly particles become transparent and die.
160
+ * Lower values = longer-lasting particles
161
+ */
162
+ fadeRate: number;
163
+
164
+ /**
165
+ * Size multiplier per animation frame (0 < shrinkRate < 1)
166
+ * VISUAL IMPACT:
167
+ * - 0.995: Very slow shrink, particles stay nearly same size
168
+ * - 0.985: Moderate shrink - RECOMMENDED (40% original size after 60 frames)
169
+ * - 0.970: Fast shrink, particles quickly become tiny
170
+ *
171
+ * Combined with fadeRate, creates the "dissipating" visual effect.
172
+ */
173
+ shrinkRate: number;
174
+
175
+ /**
176
+ * Upward drift acceleration per frame (added to vy each frame)
177
+ * VISUAL: Negative values make particles float upward over time
178
+ * Creates the "rising heat" or "bubbles ascending" effect
179
+ *
180
+ * Typical value: -0.02 to -0.05
181
+ * 0 = no drift, particles move in straight lines
182
+ */
183
+ upwardDrift: number;
184
+
185
+ /**
186
+ * Available colors for random selection
187
+ * VISUAL: Each spawned particle gets a random color from this array
188
+ * Colors should be harmonious for a cohesive effect.
189
+ *
190
+ * Default palette: blue (#6366f1), purple (#a855f7), pink (#ec4899)
191
+ * Matches Tailwind indigo-500, violet-500, pink-500
192
+ */
193
+ colors: string[];
194
+ }
195
+
196
+ /**
197
+ * Default configuration for subtle, elegant particles
198
+ *
199
+ * VISUAL RESULT:
200
+ * - 12 small particles (6-10px) spawn per click
201
+ * - Move outward at moderate speed (2-3.5 px/frame)
202
+ * - Have a visible but not overwhelming glow (15px blur)
203
+ * - Fade and shrink over approximately 1 second
204
+ * - Float gently upward as they age
205
+ * - Colors: cool blue/purple/pink palette
206
+ */
207
+ export const DEFAULT_PARTICLE_CONFIG: ParticleConfig = {
208
+ particleCount: 12,
209
+ minSize: 6,
210
+ maxSize: 10,
211
+ minSpeed: 2.0,
212
+ maxSpeed: 3.5,
213
+ glowIntensity: 15,
214
+ fadeRate: 0.016, // ~60 frames = 1 second to fade out
215
+ shrinkRate: 0.985, // ~40% original size after 60 frames
216
+ upwardDrift: -0.03, // Gentle upward float
217
+ colors: [
218
+ '#6366f1', // Indigo (blue-ish)
219
+ '#a855f7', // Violet (purple)
220
+ '#ec4899', // Pink
221
+ ],
222
+ };
223
+
224
+ /**
225
+ * Pre-configured visual presets for different use cases
226
+ *
227
+ * Each preset creates a distinct visual "mood"
228
+ */
229
+ export const PARTICLE_PRESETS = {
230
+ /**
231
+ * SUBTLE preset
232
+ * VISUAL: Small, quick, minimal particles
233
+ * USE CASE: Professional applications, frequent interactions
234
+ * IMPRESSION: Polished, refined, not distracting
235
+ */
236
+ subtle: {
237
+ particleCount: 8,
238
+ minSize: 4,
239
+ maxSize: 7,
240
+ minSpeed: 2.0,
241
+ maxSpeed: 3.0,
242
+ glowIntensity: 10,
243
+ fadeRate: 0.020, // Faster fade (~0.8 seconds)
244
+ shrinkRate: 0.980,
245
+ upwardDrift: -0.02,
246
+ colors: ['#6366f1', '#a855f7', '#ec4899'],
247
+ } satisfies ParticleConfig,
248
+
249
+ /**
250
+ * ELEGANT preset (default)
251
+ * VISUAL: Balanced size and count, moderate glow
252
+ * USE CASE: Standard interactions, wikilink clicks
253
+ * IMPRESSION: Premium UI polish, noticeable but tasteful
254
+ */
255
+ elegant: DEFAULT_PARTICLE_CONFIG,
256
+
257
+ /**
258
+ * VIBRANT preset
259
+ * VISUAL: More particles, larger, longer-lasting
260
+ * USE CASE: Celebrations, achievements, special actions
261
+ * IMPRESSION: Playful, energetic, celebratory
262
+ */
263
+ vibrant: {
264
+ particleCount: 20,
265
+ minSize: 8,
266
+ maxSize: 14,
267
+ minSpeed: 2.5,
268
+ maxSpeed: 4.5,
269
+ glowIntensity: 20,
270
+ fadeRate: 0.012, // Slower fade (~1.4 seconds)
271
+ shrinkRate: 0.990,
272
+ upwardDrift: -0.04,
273
+ colors: ['#6366f1', '#a855f7', '#ec4899', '#f472b6'],
274
+ } satisfies ParticleConfig,
275
+
276
+ /**
277
+ * MINIMAL preset
278
+ * VISUAL: Very few, very small, very quick particles
279
+ * USE CASE: High-frequency interactions, accessibility considerations
280
+ * IMPRESSION: Almost imperceptible polish
281
+ */
282
+ minimal: {
283
+ particleCount: 5,
284
+ minSize: 3,
285
+ maxSize: 5,
286
+ minSpeed: 2.5,
287
+ maxSpeed: 3.5,
288
+ glowIntensity: 8,
289
+ fadeRate: 0.025, // Fast fade (~0.7 seconds)
290
+ shrinkRate: 0.975,
291
+ upwardDrift: -0.01,
292
+ colors: ['#6366f1', '#a855f7'],
293
+ } satisfies ParticleConfig,
294
+ } as const;
295
+
296
+ export type ParticlePreset = keyof typeof PARTICLE_PRESETS;