@mtldev514/retro-portfolio-engine 1.0.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 (43) hide show
  1. package/README.md +408 -0
  2. package/bin/cli.js +103 -0
  3. package/engine/admin/admin.css +720 -0
  4. package/engine/admin/admin.html +801 -0
  5. package/engine/admin/admin_api.py +230 -0
  6. package/engine/admin/scripts/backup.sh +116 -0
  7. package/engine/admin/scripts/config_loader.py +180 -0
  8. package/engine/admin/scripts/init.sh +141 -0
  9. package/engine/admin/scripts/manager.py +308 -0
  10. package/engine/admin/scripts/restore.sh +121 -0
  11. package/engine/admin/scripts/server.py +41 -0
  12. package/engine/admin/scripts/update.sh +321 -0
  13. package/engine/admin/scripts/validate_json.py +62 -0
  14. package/engine/fonts.css +37 -0
  15. package/engine/index.html +190 -0
  16. package/engine/js/config-loader.js +370 -0
  17. package/engine/js/config.js +173 -0
  18. package/engine/js/counter.js +17 -0
  19. package/engine/js/effects.js +97 -0
  20. package/engine/js/i18n.js +68 -0
  21. package/engine/js/init.js +107 -0
  22. package/engine/js/media.js +264 -0
  23. package/engine/js/render.js +282 -0
  24. package/engine/js/router.js +133 -0
  25. package/engine/js/sparkle.js +123 -0
  26. package/engine/js/themes.js +607 -0
  27. package/engine/style.css +2037 -0
  28. package/index.js +35 -0
  29. package/package.json +48 -0
  30. package/scripts/admin.js +67 -0
  31. package/scripts/build.js +142 -0
  32. package/scripts/init.js +237 -0
  33. package/scripts/post-install.js +16 -0
  34. package/scripts/serve.js +54 -0
  35. package/templates/user-portfolio/.github/workflows/deploy.yml +57 -0
  36. package/templates/user-portfolio/config/app.json +36 -0
  37. package/templates/user-portfolio/config/categories.json +241 -0
  38. package/templates/user-portfolio/config/languages.json +15 -0
  39. package/templates/user-portfolio/config/media-types.json +59 -0
  40. package/templates/user-portfolio/data/painting.json +3 -0
  41. package/templates/user-portfolio/data/projects.json +3 -0
  42. package/templates/user-portfolio/lang/en.json +114 -0
  43. package/templates/user-portfolio/lang/fr.json +114 -0
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Unified Render Engine for Alex's Portfolio
3
+ * Loads ALL categories, renders a single grid, supports filter buttons
4
+ */
5
+ const renderer = {
6
+ categories: {},
7
+ categoryIcons: {},
8
+
9
+ allItems: [],
10
+ filteredItems: [],
11
+ activeFilter: 'all',
12
+ sortOrder: 'desc',
13
+ PAGE_SIZE: 24,
14
+ visibleCount: 0,
15
+ _loadMoreObserver: null,
16
+
17
+ // Load category configuration dynamically
18
+ async loadCategoryConfig() {
19
+ if (!window.AppConfig || !window.AppConfig.loaded) {
20
+ await window.AppConfig?.load();
21
+ }
22
+
23
+ const allCategories = window.AppConfig?.getAllCategories() || [];
24
+ const dataDir = window.AppConfig?.getSetting('paths.dataDir') || 'data';
25
+
26
+ this.categories = {};
27
+ this.categoryIcons = {};
28
+
29
+ allCategories.forEach(cat => {
30
+ const fileName = cat.dataFile.split('/').pop();
31
+ this.categories[cat.id] = {
32
+ file: fileName,
33
+ from: cat.id
34
+ };
35
+ this.categoryIcons[cat.id] = cat.icon;
36
+ });
37
+
38
+ // Update PAGE_SIZE from config
39
+ this.PAGE_SIZE = window.AppConfig?.getSetting('pagination.pageSize') || 24;
40
+ },
41
+
42
+ t(field) {
43
+ if (!field) return '';
44
+ if (typeof field === 'object' && !Array.isArray(field)) {
45
+ const lang = (window.i18n && i18n.currentLang) || 'en';
46
+ return field[lang] || field.en || '';
47
+ }
48
+ return field;
49
+ },
50
+
51
+ async init() {
52
+ // Load category configuration first
53
+ await this.loadCategoryConfig();
54
+
55
+ // Skip re-fetch if data is already loaded (returning from detail view)
56
+ if (this.allItems.length === 0) {
57
+ const dataDir = window.AppConfig?.getSetting('paths.dataDir') || 'data';
58
+ const entries = Object.entries(this.categories);
59
+ const fetches = entries.map(async ([category, info]) => {
60
+ try {
61
+ const res = await fetch(`${dataDir}/${info.file}`);
62
+ const items = await res.json();
63
+ return items.map(item => ({
64
+ ...item,
65
+ _category: category,
66
+ _from: info.from
67
+ }));
68
+ } catch (e) {
69
+ return [];
70
+ }
71
+ });
72
+ const results = await Promise.all(fetches);
73
+ this.allItems = results.flat();
74
+ this.sortItems();
75
+ }
76
+
77
+ this.renderGrid();
78
+ this.setupFilters();
79
+ if (window.i18n) window.i18n.updateDOM();
80
+ },
81
+
82
+ sortItems() {
83
+ const dir = this.sortOrder === 'desc' ? -1 : 1;
84
+ this.allItems.sort((a, b) => {
85
+ const da = a.created || a.date || '';
86
+ const db = b.created || b.date || '';
87
+ return dir * da.localeCompare(db);
88
+ });
89
+ },
90
+
91
+ renderGrid() {
92
+ const app = document.getElementById('app');
93
+ if (!app) return;
94
+
95
+ // Remove old grid but keep the filter bar
96
+ const oldGrid = document.getElementById('gallery-container');
97
+ if (oldGrid) oldGrid.remove();
98
+
99
+ // Build filtered list
100
+ this.filteredItems = this.activeFilter === 'all'
101
+ ? [...this.allItems]
102
+ : this.allItems.filter(i => i._category === this.activeFilter);
103
+
104
+ this.visibleCount = 0;
105
+
106
+ const container = document.createElement('div');
107
+ container.id = 'gallery-container';
108
+ container.className = 'gallery-grid';
109
+ app.appendChild(container);
110
+
111
+ this.loadMoreItems();
112
+
113
+ // Empty message
114
+ if (this.filteredItems.length === 0) {
115
+ const emptyMsg = document.createElement('p');
116
+ emptyMsg.id = 'empty-filter-msg';
117
+ emptyMsg.className = 'empty-message';
118
+ emptyMsg.textContent = (window.i18n && i18n.translations.filter_empty) || 'Nothing here yet.';
119
+ container.appendChild(emptyMsg);
120
+ }
121
+ },
122
+
123
+ loadMoreItems() {
124
+ const container = document.getElementById('gallery-container');
125
+ if (!container) return;
126
+
127
+ const batch = this.filteredItems.slice(this.visibleCount, this.visibleCount + this.PAGE_SIZE);
128
+ const frag = document.createDocumentFragment();
129
+ batch.forEach(item => frag.appendChild(this.createGalleryItem(item)));
130
+
131
+ // Remove old load-more button before appending
132
+ const oldBtn = document.getElementById('load-more-btn');
133
+ if (oldBtn) {
134
+ if (this._loadMoreObserver) this._loadMoreObserver.unobserve(oldBtn);
135
+ oldBtn.remove();
136
+ }
137
+
138
+ container.appendChild(frag);
139
+ this.visibleCount += batch.length;
140
+
141
+ // Add "Load More" if more items remain
142
+ if (this.visibleCount < this.filteredItems.length) {
143
+ const remaining = this.filteredItems.length - this.visibleCount;
144
+ const btn = document.createElement('button');
145
+ btn.id = 'load-more-btn';
146
+ btn.className = 'load-more-btn';
147
+ btn.textContent = `Show More (${remaining} remaining)`;
148
+ btn.onclick = () => this.loadMoreItems();
149
+ container.appendChild(btn);
150
+
151
+ // Auto-load on scroll into view
152
+ if (!this._loadMoreObserver) {
153
+ this._loadMoreObserver = new IntersectionObserver((entries) => {
154
+ if (entries[0].isIntersecting) this.loadMoreItems();
155
+ }, { rootMargin: '200px' });
156
+ }
157
+ this._loadMoreObserver.observe(btn);
158
+ }
159
+ },
160
+
161
+ createGalleryItem(item) {
162
+ const div = document.createElement('div');
163
+ div.className = 'gallery-item';
164
+ div.setAttribute('data-category', item._category);
165
+
166
+ const title = this.t(item.title);
167
+ const dateStr = item.created || item.date || 'N/A';
168
+ const dateLabel = item.created ? 'gallery_created_on' : 'gallery_added_on';
169
+ const dateFallback = item.created ? 'Created:' : 'Added:';
170
+ const itemId = item.id || (typeof item.title === 'string' ? item.title : (item.title && item.title.en) || '');
171
+ const from = item._from || 'gallery';
172
+ const detailHref = `detail.html?id=${encodeURIComponent(itemId)}&from=${from}`;
173
+ const icon = this.categoryIcons[item._category] || '';
174
+
175
+ if (item._category === 'music') {
176
+ const genre = this.t(item.genre);
177
+ const playMeLabel = (window.i18n && i18n.translations.music_play_me) || 'Play Me';
178
+ div.innerHTML = `
179
+ <a href="${detailHref}" class="gallery-link">
180
+ ${item.url ? `<button class="music-card-play" data-track-url="${item.url.replace(/"/g, '&quot;')}" title="${playMeLabel}">&#9654;</button>` : `<div class="card-icon">&#127925;</div>`}
181
+ <h3 align="center">${title}</h3>
182
+ ${genre ? `<p align="center" class="gallery-subtitle">${genre}</p>` : ''}
183
+ <p align="center" class="item-date">
184
+ <span data-i18n="${dateLabel}">${dateFallback}</span> ${dateStr}
185
+ </p>
186
+ </a>
187
+ `;
188
+ const playBtn = div.querySelector('.music-card-play');
189
+ if (playBtn) {
190
+ playBtn.addEventListener('click', (e) => {
191
+ e.preventDefault();
192
+ e.stopPropagation();
193
+ const url = playBtn.dataset.trackUrl;
194
+ if (window.media && media.playlist) {
195
+ const idx = media.playlist.findIndex(p => p.src === url);
196
+ if (idx >= 0) media.switchTrack(idx);
197
+ }
198
+ });
199
+ }
200
+ } else {
201
+ const medium = this.t(item.medium);
202
+ const description = this.t(item.description);
203
+ let visibilityEmoji = '';
204
+ const isProject = item.category === 'projects' || item._category === 'projects';
205
+ if (isProject) {
206
+ visibilityEmoji = item.visibility === 'private' ? '🔒' : '🌍';
207
+ }
208
+ const subTitle = isProject ? description : (medium ? `(${medium})` : '');
209
+
210
+ const hasImage = item.url && !isProject;
211
+ const githubLabel = (window.i18n && i18n.translations.card_github) || 'GitHub';
212
+ const websiteLabel = (window.i18n && i18n.translations.card_website) || 'Website';
213
+ const isPublic = isProject && item.visibility === 'public';
214
+
215
+ let cardActionHtml = '';
216
+ if (isPublic) {
217
+ if (item.website) {
218
+ cardActionHtml = `<div class="card-actions">
219
+ <a href="${item.website}" target="_blank" class="card-action-btn" onclick="event.stopPropagation()">
220
+ <svg class="icon" viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg> ${websiteLabel}
221
+ </a>
222
+ </div>`;
223
+ } else if (item.url) {
224
+ cardActionHtml = `<div class="card-actions">
225
+ <a href="${item.url}" target="_blank" class="card-action-btn" onclick="event.stopPropagation()">
226
+ <svg class="icon" viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.8.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg> ${githubLabel}
227
+ </a>
228
+ </div>`;
229
+ }
230
+ }
231
+
232
+ const pileCount = (item.gallery && item.gallery.length) ? item.gallery.length + 1 : 0;
233
+ const pileBadge = pileCount > 1 ? `<span class="pile-badge">📷 ${pileCount}</span>` : '';
234
+ const pileLabel = pileCount > 1 ? `<p align="center" class="gallery-subtitle pile-label">📷 ${pileCount} photos</p>` : '';
235
+
236
+ div.innerHTML = `
237
+ <a href="${detailHref}" class="gallery-link">
238
+ ${hasImage ? `<div class="gallery-img-wrap"><img src="${item.url}" alt="${title}" loading="lazy">${pileBadge}</div>` : `<div class="card-icon">${icon}</div>`}
239
+ <h3 align="center">${title}</h3>
240
+ ${subTitle ? `<p align="center" class="gallery-subtitle">${subTitle}</p>` : ''}
241
+ ${pileLabel}
242
+ <p align="center" class="item-date">
243
+ <span data-i18n="${dateLabel}">${dateFallback}</span> ${dateStr}
244
+ </p>
245
+ </a>
246
+ ${cardActionHtml}
247
+ `;
248
+ }
249
+ return div;
250
+ },
251
+
252
+ setupFilters() {
253
+ const nav = document.getElementById('filter-nav');
254
+ if (!nav) return;
255
+
256
+ nav.addEventListener('click', (e) => {
257
+ // Filter buttons
258
+ const btn = e.target.closest('.filter-btn:not(.filter-btn-back)');
259
+ if (btn) {
260
+ nav.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
261
+ btn.classList.add('active');
262
+ this.activeFilter = btn.dataset.filter;
263
+ this.renderGrid();
264
+ if (window.i18n) window.i18n.updateDOM();
265
+ return;
266
+ }
267
+
268
+ // Sort buttons
269
+ const sortBtn = e.target.closest('.sort-btn');
270
+ if (sortBtn) {
271
+ nav.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
272
+ sortBtn.classList.add('active');
273
+ this.sortOrder = sortBtn.dataset.sort;
274
+ this.sortItems();
275
+ this.renderGrid();
276
+ if (window.i18n) window.i18n.updateDOM();
277
+ }
278
+ });
279
+ }
280
+ };
281
+
282
+ window.renderer = renderer;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * SPA Router for Alex's Portfolio
3
+ * Two states: grid view (unified gallery) and detail view (single item)
4
+ */
5
+ const router = {
6
+ currentRoute: null,
7
+
8
+ async init() {
9
+ // 1. Apply theme immediately (synchronous, no flash)
10
+ if (window.themes) {
11
+ themes.init();
12
+ }
13
+
14
+ // 2. Load translations
15
+ if (window.i18n) {
16
+ await i18n.init();
17
+ }
18
+
19
+ // 2. Intercept internal link clicks (only <a> elements, not <button>)
20
+ document.addEventListener('click', (e) => {
21
+ const link = e.target.closest('a');
22
+ if (link && link.href && link.href.startsWith(window.location.origin) && !link.getAttribute('target')) {
23
+ e.preventDefault();
24
+ this.navigate(link.href);
25
+ }
26
+ });
27
+
28
+ // 3. Handle browser back/forward
29
+ window.addEventListener('popstate', () => {
30
+ this.loadPage(window.location.pathname + window.location.search);
31
+ });
32
+
33
+ // 4. GitHub Pages SPA redirect
34
+ const redirectPath = sessionStorage.getItem('spa-redirect');
35
+ if (redirectPath) {
36
+ sessionStorage.removeItem('spa-redirect');
37
+ window.history.replaceState({}, '', redirectPath);
38
+ }
39
+
40
+ // 5. Load initial page
41
+ await this.loadPage(window.location.pathname + window.location.search);
42
+
43
+ // 6. Init media controller after DOM is populated
44
+ if (window.media) {
45
+ await media.init();
46
+ }
47
+ },
48
+
49
+ isDetailRoute(url) {
50
+ return (typeof url === 'string' ? url : '').split('#')[0].includes('detail.html');
51
+ },
52
+
53
+ async navigate(url) {
54
+ window.history.pushState({}, '', url);
55
+ await this.loadPage(url);
56
+ },
57
+
58
+ async loadPage(url) {
59
+ const app = document.getElementById('app');
60
+
61
+ if (this.isDetailRoute(url)) {
62
+ // DETAIL VIEW: load detail.html fragment
63
+ this.currentRoute = 'detail';
64
+
65
+ // Save filter bar children, then swap in a Back button
66
+ let filterNav = document.getElementById('filter-nav');
67
+ if (filterNav) {
68
+ this._savedFilterNav = filterNav;
69
+ this._savedFilterChildren = Array.from(filterNav.children);
70
+ filterNav.innerHTML = '';
71
+ } else {
72
+ // Direct URL access: create filter bar dynamically
73
+ filterNav = document.createElement('div');
74
+ filterNav.id = 'filter-nav';
75
+ filterNav.className = 'filter-bar';
76
+ this._savedFilterNav = filterNav;
77
+ this._savedFilterChildren = null;
78
+ }
79
+
80
+ // Create full-width Back button
81
+ const backBtn = document.createElement('button');
82
+ backBtn.className = 'filter-btn filter-btn-back';
83
+ const t = (key, fallback) => (window.i18n && i18n.translations[key]) || fallback;
84
+ backBtn.textContent = '\u2B05 ' + t('detail_back', 'Back');
85
+ backBtn.addEventListener('click', () => router.navigate('index.html'));
86
+ filterNav.appendChild(backBtn);
87
+
88
+ try {
89
+ const response = await fetch('pages/detail.html');
90
+ if (!response.ok) throw new Error('Could not load detail page');
91
+ const html = await response.text();
92
+ app.innerHTML = html;
93
+
94
+ // Prepend filter bar (with Back button) above detail content
95
+ app.prepend(this._savedFilterNav);
96
+
97
+ // Re-execute inline scripts
98
+ app.querySelectorAll('script').forEach(oldScript => {
99
+ const newScript = document.createElement('script');
100
+ newScript.textContent = oldScript.textContent;
101
+ oldScript.replaceWith(newScript);
102
+ });
103
+
104
+ if (window.i18n) window.i18n.updateDOM();
105
+ } catch (error) {
106
+ console.error('Detail load error:', error);
107
+ }
108
+ } else {
109
+ // GRID VIEW: render unified gallery
110
+ this.currentRoute = 'grid';
111
+
112
+ // Restore filter bar with original children
113
+ if (this._savedFilterNav) {
114
+ if (this._savedFilterChildren) {
115
+ this._savedFilterNav.innerHTML = '';
116
+ this._savedFilterChildren.forEach(child => this._savedFilterNav.appendChild(child));
117
+ this._savedFilterChildren = null;
118
+ }
119
+ app.innerHTML = '';
120
+ app.appendChild(this._savedFilterNav);
121
+ }
122
+
123
+ if (window.renderer) {
124
+ await renderer.init();
125
+ }
126
+ }
127
+
128
+ window.scrollTo(0, 0);
129
+ }
130
+ };
131
+
132
+ document.addEventListener('DOMContentLoaded', () => router.init());
133
+ window.router = router;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Sparkle Cursor Trail — Geocities-era glitter effect
3
+ * Creates tiny stars that follow the mouse and fade away
4
+ */
5
+ (function () {
6
+ // Read sparkle colors from CSS variables (centralised in style.css :root)
7
+ let colors = [];
8
+ let enabled = false; // Default to disabled, controlled by effects.js
9
+
10
+ function refreshColors() {
11
+ const s = getComputedStyle(document.documentElement);
12
+ colors = [
13
+ s.getPropertyValue('--sparkle-1').trim() || '#ffd700',
14
+ s.getPropertyValue('--sparkle-2').trim() || '#ff69b4',
15
+ s.getPropertyValue('--sparkle-3').trim() || '#00ffff',
16
+ s.getPropertyValue('--sparkle-4').trim() || '#ff00ff',
17
+ s.getPropertyValue('--sparkle-5').trim() || '#fff',
18
+ s.getPropertyValue('--sparkle-6').trim() || '#7fff00',
19
+ s.getPropertyValue('--sparkle-7').trim() || '#ff4500',
20
+ ];
21
+ }
22
+ refreshColors();
23
+
24
+ const shapes = ['✦', '✧', '✶', '★', '·', '✸', '✹'];
25
+ const pool = [];
26
+ const POOL_SIZE = 50;
27
+ let mouseX = 0, mouseY = 0;
28
+ let ticking = false;
29
+
30
+ // Pre-create reusable sparkle elements
31
+ for (let i = 0; i < POOL_SIZE; i++) {
32
+ const el = document.createElement('div');
33
+ el.style.cssText = 'position:fixed;pointer-events:none;z-index:99998;font-size:12px;transition:none;will-change:transform,opacity;';
34
+ el.style.display = 'none';
35
+ document.body.appendChild(el);
36
+ pool.push({ el, active: false });
37
+ }
38
+
39
+ function spawnSparkle() {
40
+ if (!enabled) return;
41
+
42
+ // Find an inactive sparkle from the pool
43
+ const sparkle = pool.find(s => !s.active);
44
+ if (!sparkle) return;
45
+
46
+ sparkle.active = true;
47
+ const el = sparkle.el;
48
+
49
+ // Random offsets so sparkles spread around cursor
50
+ const offsetX = (Math.random() - 0.5) * 30;
51
+ const offsetY = (Math.random() - 0.5) * 30;
52
+ const x = mouseX + offsetX;
53
+ const y = mouseY + offsetY;
54
+
55
+ // Random appearance
56
+ const color = colors[Math.floor(Math.random() * colors.length)];
57
+ const shape = shapes[Math.floor(Math.random() * shapes.length)];
58
+ const size = 8 + Math.random() * 14;
59
+ const drift = (Math.random() - 0.5) * 40;
60
+
61
+ el.textContent = shape;
62
+ el.style.color = color;
63
+ el.style.fontSize = size + 'px';
64
+ el.style.left = x + 'px';
65
+ el.style.top = y + 'px';
66
+ el.style.opacity = '1';
67
+ el.style.transform = 'translate(0, 0) scale(1) rotate(0deg)';
68
+ el.style.display = 'block';
69
+
70
+ const duration = 600 + Math.random() * 500;
71
+ const start = performance.now();
72
+
73
+ function animate(now) {
74
+ const elapsed = now - start;
75
+ const progress = Math.min(elapsed / duration, 1);
76
+
77
+ // Float up and drift sideways, shrink and fade
78
+ const moveY = -30 * progress;
79
+ const moveX = drift * progress;
80
+ const scale = 1 - progress * 0.7;
81
+ const rotation = progress * 180 * (drift > 0 ? 1 : -1);
82
+ const opacity = 1 - progress;
83
+
84
+ el.style.transform = `translate(${moveX}px, ${moveY}px) scale(${scale}) rotate(${rotation}deg)`;
85
+ el.style.opacity = opacity;
86
+
87
+ if (progress < 1) {
88
+ requestAnimationFrame(animate);
89
+ } else {
90
+ el.style.display = 'none';
91
+ sparkle.active = false;
92
+ }
93
+ }
94
+
95
+ requestAnimationFrame(animate);
96
+ }
97
+
98
+ // Throttled spawn on mousemove
99
+ document.addEventListener('mousemove', (e) => {
100
+ mouseX = e.clientX;
101
+ mouseY = e.clientY;
102
+
103
+ if (!ticking && enabled) {
104
+ ticking = true;
105
+ requestAnimationFrame(() => {
106
+ // Spawn 2-3 sparkles per frame for a nice density
107
+ const count = 2 + Math.floor(Math.random() * 2);
108
+ for (let i = 0; i < count; i++) {
109
+ spawnSparkle();
110
+ }
111
+ ticking = false;
112
+ });
113
+ }
114
+ });
115
+
116
+ // Expose public API
117
+ window.sparkle = {
118
+ refreshColors,
119
+ enable: () => { enabled = true; },
120
+ disable: () => { enabled = false; }
121
+ };
122
+
123
+ })();