experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: group auto-tagging reactivity and mode inheritance

Three fixes for group mode auto-tagging:

1. Group mode inheritance via lastFocusedVisibleWindowId: When opening
URLs from cmd palette while in group mode, the new window now inherits
group mode from the last focused visible window (fallback when the
direct opener like cmd palette doesn't have group mode).

2. Publish tag:item-added events on auto-tag: All auto-tag code paths
(autoTagIfGroupMode, openWindow trackWindowLoad, did-navigate, popup)
now publish the tag:item-added event so the groups UI refreshes
reactively. Previously they called tagItem() directly without
publishing, so the UI never knew items were added.

3. Groups addresses view refresh: The debounced refresh handler now
reloads addresses for the current tag when in addresses view,
not just tag metadata.

Also: rename tentacles extension to helpdocs, disable by default,
update page loading glow to use border/outline (avoids webkit-mask clip).

+210 -153
+11 -9
app/page/index.html
··· 45 45 overflow: hidden; 46 46 -webkit-mask-image: -webkit-radial-gradient(white, white); 47 47 opacity: 0; 48 - transition: opacity 0.15s ease, box-shadow 0.3s ease; 48 + transition: opacity 0.15s ease, border-color 0.3s ease, outline-color 0.3s ease; 49 49 } 50 50 51 51 @keyframes loading-glow { 52 52 0%, 100% { 53 - box-shadow: 54 - inset 0 0 0 1px rgba(140, 170, 255, 0.15), 55 - 0 0 12px rgba(120, 160, 255, 0.08); 53 + border-color: rgba(140, 170, 255, 0.25); 54 + outline-color: rgba(120, 160, 255, 0.08); 56 55 } 57 56 50% { 58 - box-shadow: 59 - inset 0 0 0 1.5px rgba(140, 170, 255, 0.3), 60 - 0 0 20px rgba(120, 160, 255, 0.2); 57 + border-color: rgba(140, 170, 255, 0.6); 58 + outline-color: rgba(120, 160, 255, 0.25); 61 59 } 62 60 } 63 61 64 62 webview.loading { 65 63 opacity: 1; 66 - background: rgba(255, 255, 255, 0.03); 64 + background: rgba(255, 255, 255, 0.04); 65 + border: 1.5px solid rgba(140, 170, 255, 0.25); 66 + outline: 4px solid rgba(120, 160, 255, 0.08); 67 + outline-offset: 0px; 67 68 animation: loading-glow 2s ease-in-out infinite; 68 69 } 69 70 70 71 webview.ready { 71 72 opacity: 1; 72 - box-shadow: none; 73 + border: none; 74 + outline: none; 73 75 background: initial; 74 76 } 75 77
+64 -12
backend/electron/ipc.ts
··· 292 292 } 293 293 294 294 /** 295 - * Auto-tag an item with the group tag if the calling window is in group mode. 295 + * Auto-tag an item with the group tag if the calling window (or the last 296 + * focused visible window) is in group mode. This handles the case where 297 + * items are created via cmd palette or other intermediary windows while 298 + * the user is working in a group context. 296 299 * Returns true if the item was tagged. 297 300 */ 301 + /** 302 + * Tag an item and publish the event so UIs (groups, tags) update reactively 303 + */ 304 + function tagItemAndPublish(itemId: string, tagId: string): void { 305 + const result = tagItem(itemId, tagId); 306 + if (!result.alreadyExists) { 307 + const tag = getDb().prepare('SELECT name FROM tags WHERE id = ?').get(tagId) as { name: string } | undefined; 308 + publish('system', PubSubScopes.GLOBAL, 'tag:item-added', { 309 + tagId, 310 + tagName: tag?.name, 311 + itemId 312 + }); 313 + } 314 + } 315 + 298 316 function autoTagIfGroupMode(ev: Electron.IpcMainInvokeEvent, itemId: string): boolean { 299 317 try { 300 318 const callingWin = BrowserWindow.fromWebContents(ev.sender); 301 - if (!callingWin || callingWin.isDestroyed()) return false; 302 - const modeEntry = getContextEntry('mode', callingWin.id); 303 - if (modeEntry && modeEntry.value === 'group' && modeEntry.metadata?.groupId) { 304 - const groupId = modeEntry.metadata.groupId as string; 305 - tagItem(itemId, groupId); 306 - DEBUG && console.log('[ipc] Auto-tagged item', itemId, 'with group', groupId); 319 + const callerWinId = callingWin && !callingWin.isDestroyed() ? callingWin.id : null; 320 + const callerMode = callerWinId ? getContextEntry('mode', callerWinId) : null; 321 + const lastVisibleMode = lastFocusedVisibleWindowId ? getContextEntry('mode', lastFocusedVisibleWindowId) : null; 322 + 323 + if (callerMode && callerMode.value === 'group' && callerMode.metadata?.groupId) { 324 + const groupId = callerMode.metadata.groupId as string; 325 + tagItemAndPublish(itemId, groupId); 326 + console.log('[ipc] Auto-tagged item', itemId, 'with group', groupId, '(from caller)'); 327 + return true; 328 + } 329 + // Fallback: check last focused visible window (handles cmd palette, modal callers) 330 + if (lastVisibleMode && lastVisibleMode.value === 'group' && lastVisibleMode.metadata?.groupId) { 331 + const groupId = lastVisibleMode.metadata.groupId as string; 332 + tagItemAndPublish(itemId, groupId); 333 + console.log('[ipc] Auto-tagged item', itemId, 'with group', groupId, '(from last visible)'); 307 334 return true; 308 335 } 309 336 } catch (e) { 310 - DEBUG && console.log('[ipc] autoTagIfGroupMode error:', e); 337 + console.log('[ipc] autoTagIfGroupMode error:', e); 311 338 } 312 339 return false; 313 340 } ··· 2366 2393 } 2367 2394 }); 2368 2395 DEBUG && console.log('Inherited group mode from opener:', openerGroupMode.metadata?.groupName); 2396 + } else if (lastFocusedVisibleWindowId) { 2397 + // Fallback: check lastFocusedVisibleWindowId for group mode 2398 + // This handles the case where a non-group window (e.g. cmd palette) 2399 + // opens a URL while the user was previously focused on a group window 2400 + const lastVisibleContext = getContextEntry('mode', lastFocusedVisibleWindowId); 2401 + if (lastVisibleContext && lastVisibleContext.value === 'group' && lastVisibleContext.metadata) { 2402 + addContextEntry('mode', 'group', { 2403 + windowId: win.id, 2404 + source: msg.source, 2405 + metadata: { 2406 + ...lastVisibleContext.metadata, 2407 + url: modeUrl, 2408 + inheritedFrom: lastFocusedVisibleWindowId 2409 + } 2410 + }); 2411 + console.log('[openWindow] Inherited group mode from lastFocusedVisibleWindow:', lastFocusedVisibleWindowId, lastVisibleContext.metadata?.groupName); 2412 + } else { 2413 + const detectedMode = detectModeFromUrl(modeUrl); 2414 + addContextEntry('mode', detectedMode, { 2415 + windowId: win.id, 2416 + source: msg.source, 2417 + metadata: { url: modeUrl } 2418 + }); 2419 + } 2369 2420 } else { 2370 2421 // Set mode based on URL detection (original behavior) 2371 2422 const detectedMode = detectModeFromUrl(modeUrl); ··· 2470 2521 } 2471 2522 // Auto-tag with group if this window is in group mode 2472 2523 const loadModeEntry = getContextEntry('mode', win.id); 2524 + console.log('[openWindow] trackWindowLoad auto-tag check:', { winId: win.id, mode: loadModeEntry?.value, groupId: loadModeEntry?.metadata?.groupId, itemId: trackResult.itemId }); 2473 2525 if (loadModeEntry && loadModeEntry.value === 'group' && loadModeEntry.metadata?.groupId) { 2474 - tagItem(trackResult.itemId, loadModeEntry.metadata.groupId as string); 2475 - DEBUG && console.log('[openWindow] Auto-tagged item', trackResult.itemId, 'with group', loadModeEntry.metadata.groupId); 2526 + tagItemAndPublish(trackResult.itemId, loadModeEntry.metadata.groupId as string); 2527 + console.log('[openWindow] Auto-tagged item', trackResult.itemId, 'with group', loadModeEntry.metadata.groupId); 2476 2528 } 2477 2529 } catch (e) { 2478 2530 DEBUG && console.log('Failed to track window load:', e); ··· 2504 2556 // Auto-tag with group if this window is in group mode 2505 2557 const navModeEntry = getContextEntry('mode', win.id); 2506 2558 if (navModeEntry && navModeEntry.value === 'group' && navModeEntry.metadata?.groupId) { 2507 - tagItem(navTrack.itemId, navModeEntry.metadata.groupId as string); 2559 + tagItemAndPublish(navTrack.itemId, navModeEntry.metadata.groupId as string); 2508 2560 DEBUG && console.log('[did-navigate] Auto-tagged item', navTrack.itemId, 'with group', navModeEntry.metadata.groupId); 2509 2561 } 2510 2562 } catch (e) { ··· 2607 2659 // Auto-tag popup item with the group tag 2608 2660 if (popupItemId) { 2609 2661 try { 2610 - tagItem(popupItemId, groupMode.groupId); 2662 + tagItemAndPublish(popupItemId, groupMode.groupId); 2611 2663 DEBUG && console.log('[webview-popup] Auto-tagged popup item', popupItemId, 'with group', groupMode.groupId); 2612 2664 } catch (e) { 2613 2665 DEBUG && console.log('[webview-popup] Failed to auto-tag popup:', e);
+8 -2
extensions/groups/home.js
··· 499 499 const debouncedRefresh = debounce(async () => { 500 500 debug && console.log('[groups] debounced refresh triggered'); 501 501 await loadTags(); 502 - if (state.view === VIEW_GROUPS) renderGroups(); 503 - else if (state.view === VIEW_ADDRESSES) renderAddresses(); 502 + if (state.view === VIEW_GROUPS) { 503 + renderGroups(); 504 + } else if (state.view === VIEW_ADDRESSES && state.currentTag) { 505 + // Reload addresses for the current tag — loadTags only refreshes tag metadata, 506 + // not the items list shown in the addresses view 507 + await loadAddressesForTag(state.currentTag.id); 508 + renderAddresses(); 509 + } 504 510 }, 150); 505 511 506 512 // Subscribe to tag events for reactive updates
+1 -1
extensions/tentacles/background.html extensions/helpdocs/background.html
··· 3 3 <head> 4 4 <meta charset="utf-8"> 5 5 <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 - <title>Tentacles Extension</title> 6 + <title>Help Docs Extension</title> 7 7 </head> 8 8 <body> 9 9 <script type="module">
+28 -28
extensions/tentacles/background.js extensions/helpdocs/background.js
··· 1 1 /** 2 - * Tentacles Extension Background Script 2 + * Help Docs Extension Background Script 3 3 * 4 - * Spawns a fullscreen transparent overlay window where animated tentacles 5 - * creep in from screen edges and flee from the cursor. 4 + * Spawns a fullscreen transparent overlay window with animated 5 + * documentation hints that appear from screen edges. 6 6 * 7 - * Runs in isolated extension process (peek://ext/tentacles/background.html) 7 + * Runs in isolated extension process (peek://ext/helpdocs/background.html) 8 8 */ 9 9 10 10 const api = window.app; 11 11 const debug = api.debug; 12 12 13 - console.log('[ext:tentacles] background init'); 13 + console.log('[ext:helpdocs] background init'); 14 14 15 - const OVERLAY_ADDRESS = 'peek://ext/tentacles/tentacles.html'; 16 - const STORAGE_KEY = 'tentacles_enabled'; 15 + const OVERLAY_ADDRESS = 'peek://ext/helpdocs/overlay.html'; 16 + const STORAGE_KEY = 'helpdocs_enabled'; 17 17 18 18 let enabled = false; 19 19 let overlayWindowId = null; ··· 28 28 const result = await api.settings.getKey('overlay_visible'); 29 29 if (result.success && result.data !== undefined && result.data !== null) { 30 30 enabled = !!result.data; 31 - console.log('[ext:tentacles] Loaded state from datastore - enabled:', enabled); 31 + console.log('[ext:helpdocs] Loaded state from datastore - enabled:', enabled); 32 32 return enabled; 33 33 } 34 34 } catch (err) { 35 - console.log('[ext:tentacles] Failed to load from datastore:', err); 35 + console.log('[ext:helpdocs] Failed to load from datastore:', err); 36 36 } 37 37 } 38 38 const stored = localStorage.getItem(STORAGE_KEY); 39 - enabled = stored === null ? true : stored === 'true'; // Default enabled 40 - console.log('[ext:tentacles] Loaded state - enabled:', enabled); 39 + enabled = stored === 'true'; // Default disabled 40 + console.log('[ext:helpdocs] Loaded state - enabled:', enabled); 41 41 return enabled; 42 42 }; 43 43 ··· 49 49 try { 50 50 localStorage.setItem(STORAGE_KEY, String(value)); 51 51 } catch (err) { 52 - debug && console.log('[ext:tentacles] Failed to cache state:', err); 52 + debug && console.log('[ext:helpdocs] Failed to cache state:', err); 53 53 } 54 54 if (api?.settings?.setKey) { 55 55 api.settings.setKey('overlay_visible', value).catch(err => { 56 - console.error('[ext:tentacles] Failed to save state:', err); 56 + console.error('[ext:helpdocs] Failed to save state:', err); 57 57 }); 58 58 } 59 59 }; ··· 88 88 frame: false, 89 89 hasShadow: false, 90 90 escapeMode: 'ignore', 91 - title: 'Tentacles', 91 + title: 'Help Docs', 92 92 centerOnParent: false, 93 93 show: true 94 94 }; ··· 97 97 const result = await api.window.open(OVERLAY_ADDRESS, params); 98 98 if (result.success) { 99 99 overlayWindowId = result.id; 100 - console.log('[ext:tentacles] Overlay opened:', overlayWindowId); 100 + console.log('[ext:helpdocs] Overlay opened:', overlayWindowId); 101 101 102 102 // Make the window click-through but still receive mouse move events 103 103 // forward: true means mouse moves are still forwarded to the renderer 104 104 setTimeout(async () => { 105 105 try { 106 106 await api.window.setIgnoreMouseEvents(overlayWindowId, true, true); 107 - console.log('[ext:tentacles] Set click-through with forward'); 107 + console.log('[ext:helpdocs] Set click-through with forward'); 108 108 } catch (err) { 109 - console.error('[ext:tentacles] Failed to set click-through:', err); 109 + console.error('[ext:helpdocs] Failed to set click-through:', err); 110 110 } 111 111 }, 500); 112 112 } else { 113 - console.error('[ext:tentacles] Failed to open overlay:', result.error); 113 + console.error('[ext:helpdocs] Failed to open overlay:', result.error); 114 114 } 115 115 } catch (error) { 116 - console.error('[ext:tentacles] Error opening overlay:', error); 116 + console.error('[ext:helpdocs] Error opening overlay:', error); 117 117 } 118 118 }; 119 119 ··· 139 139 if (overlayWindowId) { 140 140 await api.window.close(overlayWindowId); 141 141 overlayWindowId = null; 142 - console.log('[ext:tentacles] Overlay closed'); 142 + console.log('[ext:helpdocs] Overlay closed'); 143 143 } 144 144 }; 145 145 ··· 148 148 saveState(newState); 149 149 if (newState) { 150 150 await openOverlay(); 151 - return { output: 'Tentacles enabled', mimeType: 'text/plain' }; 151 + return { output: 'Help docs enabled', mimeType: 'text/plain' }; 152 152 } else { 153 153 await closeOverlay(); 154 - return { output: 'Tentacles disabled', mimeType: 'text/plain' }; 154 + return { output: 'Help docs disabled', mimeType: 'text/plain' }; 155 155 } 156 156 }; 157 157 ··· 161 161 name: 'help docs', 162 162 description: 'Toggle help documentation overlay', 163 163 execute: async () => { 164 - console.log('[ext:tentacles] Toggle command executed'); 164 + console.log('[ext:helpdocs] Toggle command executed'); 165 165 return await toggle(); 166 166 } 167 167 } ··· 174 174 api.commands.register(cmd); 175 175 registeredCommands.push(cmd.name); 176 176 }); 177 - console.log('[ext:tentacles] Registered commands:', registeredCommands); 177 + console.log('[ext:helpdocs] Registered commands:', registeredCommands); 178 178 }; 179 179 180 180 const uninitCommands = () => { ··· 185 185 }; 186 186 187 187 const init = async () => { 188 - console.log('[ext:tentacles] init'); 188 + console.log('[ext:helpdocs] init'); 189 189 190 190 await loadState(); 191 191 initCommands(); ··· 206 206 await openOverlay(); 207 207 } 208 208 209 - console.log('[ext:tentacles] Initialized, enabled:', enabled); 209 + console.log('[ext:helpdocs] Initialized, enabled:', enabled); 210 210 }; 211 211 212 212 const uninit = () => { 213 - console.log('[ext:tentacles] uninit'); 213 + console.log('[ext:helpdocs] uninit'); 214 214 uninitCommands(); 215 215 closeOverlay(); 216 216 }; 217 217 218 218 export default { 219 - id: 'tentacles', 219 + id: 'helpdocs', 220 220 labels: { 221 221 name: 'Help docs' 222 222 },
+2 -2
extensions/tentacles/manifest.json extensions/helpdocs/manifest.json
··· 1 1 { 2 - "id": "tentacles", 3 - "shortname": "tentacles", 2 + "id": "helpdocs", 3 + "shortname": "helpdocs", 4 4 "name": "Help docs", 5 5 "description": "Helpful documentation and assistance features", 6 6 "version": "1.0.0",
+3 -3
extensions/tentacles/tentacles.html extensions/helpdocs/overlay.html
··· 4 4 <meta charset="utf-8"> 5 5 <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 6 <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 - <title>Tentacles</title> 7 + <title>Help Docs Overlay</title> 8 8 <style> 9 9 * { margin: 0; padding: 0; } 10 10 html, body { ··· 24 24 </style> 25 25 </head> 26 26 <body> 27 - <canvas id="tentacle-canvas"></canvas> 28 - <script type="module" src="tentacles.js"></script> 27 + <canvas id="overlay-canvas"></canvas> 28 + <script type="module" src="overlay.js"></script> 29 29 </body> 30 30 </html>
+93 -96
extensions/tentacles/tentacles.js extensions/helpdocs/overlay.js
··· 1 1 /** 2 - * Tentacles Animation Script 2 + * Help Docs Overlay Animation Script 3 3 * 4 - * Renders organic tentacles that creep in from screen edges, 4 + * Renders organic animated strands that creep in from screen edges, 5 5 * undulate gently, and flee from the cursor. 6 6 * 7 - * Each tentacle is a chain of segments rendered as a smooth bezier curve. 7 + * Each strand is a chain of segments rendered as a smooth bezier curve. 8 8 * Control points animate with sinusoidal wobble for organic motion. 9 9 * Cursor proximity triggers a fear/recoil response. 10 10 */ 11 11 12 - const canvas = document.getElementById('tentacle-canvas'); 12 + const canvas = document.getElementById('overlay-canvas'); 13 13 const ctx = canvas.getContext('2d'); 14 14 15 15 // --- Configuration --- 16 16 const CONFIG = { 17 17 // Spawn timing 18 - spawnIntervalMin: 30000, // 30s minimum between spawns 19 - spawnIntervalMax: 60000, // 60s maximum between spawns 18 + spawnIntervalMin: 3000, // 3s minimum between spawns 19 + spawnIntervalMax: 8000, // 8s maximum between spawns 20 20 21 - // Tentacle properties 21 + // Strand properties 22 22 minSegments: 6, 23 23 maxSegments: 14, 24 24 minSegmentLength: 12, ··· 27 27 maxThickness: 12, 28 28 29 29 // Animation 30 - creepSpeed: 0.3, // pixels per frame when creeping in 30 + creepSpeed: 0.8, // pixels per frame when creeping in 31 31 retreatSpeed: 0.15, // pixels per frame when retreating naturally 32 32 fleeSpeed: 3.0, // pixels per frame when fleeing from cursor 33 33 wobbleSpeed: 0.02, // radians per frame for sinusoidal wobble ··· 41 41 42 42 // Fear response 43 43 fearRadius: 100, // px distance to trigger fear 44 - fearRecoveryTime: 2000, // ms before tentacle can return after fear 44 + fearRecoveryTime: 2000, // ms before strand can return after fear 45 45 46 46 // Visual 47 - maxAlpha: 0.35, 47 + maxAlpha: 0.7, 48 48 colors: [ 49 49 { r: 90, g: 60, b: 120 }, // muted purple 50 50 { r: 60, g: 90, b: 80 }, // dark teal ··· 53 53 { r: 50, g: 80, b: 70 }, // deep sea green 54 54 ], 55 55 56 - // Max simultaneous tentacles 57 - maxTentacles: 4, 56 + // Max simultaneous strands 57 + maxStrands: 12, 58 58 }; 59 59 60 60 // --- State --- 61 - let tentacles = []; 61 + let strands = []; 62 62 let mouseX = -1000; 63 63 let mouseY = -1000; 64 64 let animationId = null; ··· 76 76 const dist = (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); 77 77 78 78 /** 79 - * Create a new tentacle emerging from a random edge 79 + * Create a new strand emerging from a random edge 80 80 */ 81 - function createTentacle() { 81 + function createStrand() { 82 82 const edge = randInt(0, 3); 83 83 const numSegments = randInt(CONFIG.minSegments, CONFIG.maxSegments); 84 84 const segmentLength = rand(CONFIG.minSegmentLength, CONFIG.maxSegmentLength); ··· 159 159 } 160 160 161 161 /** 162 - * Compute the joint positions of a tentacle 162 + * Compute the joint positions of a strand 163 163 * Returns array of {x, y} points from base to tip 164 164 */ 165 - function computePoints(t) { 166 - const points = [{ x: t.baseX, y: t.baseY }]; 167 - let currentAngle = t.baseAngle; 168 - let x = t.baseX; 169 - let y = t.baseY; 165 + function computePoints(s) { 166 + const points = [{ x: s.baseX, y: s.baseY }]; 167 + let currentAngle = s.baseAngle; 168 + let x = s.baseX; 169 + let y = s.baseY; 170 170 171 171 // Only draw segments up to current extension 172 - const visibleSegments = Math.ceil(t.extension * t.numSegments); 173 - const partialFrac = (t.extension * t.numSegments) - Math.floor(t.extension * t.numSegments); 172 + const visibleSegments = Math.ceil(s.extension * s.numSegments); 173 + const partialFrac = (s.extension * s.numSegments) - Math.floor(s.extension * s.numSegments); 174 174 175 - for (let i = 0; i < visibleSegments && i < t.segments.length; i++) { 176 - const seg = t.segments[i]; 175 + for (let i = 0; i < visibleSegments && i < s.segments.length; i++) { 176 + const seg = s.segments[i]; 177 177 178 - // Wobble: sinusoidal oscillation that propagates down the tentacle 179 - const wobble = Math.sin(t.wobbleTime * seg.wobbleFreq + seg.wobblePhase + i * 0.8) 178 + // Wobble: sinusoidal oscillation that propagates down the strand 179 + const wobble = Math.sin(s.wobbleTime * seg.wobbleFreq + seg.wobblePhase + i * 0.8) 180 180 * CONFIG.wobbleAmplitude * (1 + i * 0.1); 181 181 182 182 currentAngle += seg.angleOffset + wobble; 183 183 184 184 // If this is the last visible segment and partial, shorten it 185 185 let len = seg.length; 186 - if (i === visibleSegments - 1 && visibleSegments < t.numSegments) { 186 + if (i === visibleSegments - 1 && visibleSegments < s.numSegments) { 187 187 len *= partialFrac || 1; 188 188 } 189 189 ··· 196 196 } 197 197 198 198 /** 199 - * Draw a smooth tentacle through points using quadratic bezier curves 199 + * Draw a smooth strand through points using quadratic bezier curves 200 200 */ 201 - function drawTentacle(t) { 202 - const points = computePoints(t); 201 + function drawStrand(s) { 202 + const points = computePoints(s); 203 203 if (points.length < 2) return; 204 204 205 - const { r, g, b } = t.color; 206 - const alpha = t.alpha; 205 + const { r, g, b } = s.color; 206 + const alpha = s.alpha; 207 207 208 - // Draw the tentacle as a tapered, smooth path 208 + // Draw the strand as a tapered, smooth path 209 209 // We'll draw it as a filled shape with varying width 210 210 ctx.save(); 211 211 ··· 214 214 const p1 = points[i + 1]; 215 215 216 216 // Thickness tapers from base to tip 217 - const t0 = t.thickness * (1 - (i / points.length) * 0.7); 218 - const t1 = t.thickness * (1 - ((i + 1) / points.length) * 0.7); 217 + const t0 = s.thickness * (1 - (i / points.length) * 0.7); 218 + const t1 = s.thickness * (1 - ((i + 1) / points.length) * 0.7); 219 219 220 220 // Direction perpendicular to segment 221 221 const dx = p1.x - p0.x; ··· 232 232 ctx.lineTo(p0.x - nx * t0 / 2, p0.y - ny * t0 / 2); 233 233 ctx.closePath(); 234 234 235 - // Gradient alpha along the tentacle 235 + // Gradient alpha along the strand 236 236 const segAlpha = alpha * (1 - (i / points.length) * 0.5); 237 237 ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${segAlpha})`; 238 238 ctx.fill(); 239 239 } 240 240 241 241 // Draw a smooth overlay using bezier curves for the center line 242 - // This gives the "core" of the tentacle a richer look 242 + // This gives the "core" of the strand a richer look 243 243 ctx.beginPath(); 244 244 ctx.moveTo(points[0].x, points[0].y); 245 245 ··· 270 270 271 271 // Core line (slightly lighter, thinner) 272 272 ctx.strokeStyle = `rgba(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)}, ${alpha * 0.6})`; 273 - ctx.lineWidth = Math.max(1, t.thickness * 0.3); 273 + ctx.lineWidth = Math.max(1, s.thickness * 0.3); 274 274 ctx.lineCap = 'round'; 275 275 ctx.lineJoin = 'round'; 276 276 ctx.stroke(); ··· 278 278 // Tip: small circle at the end 279 279 if (points.length >= 2) { 280 280 const tip = points[points.length - 1]; 281 - const tipRadius = t.thickness * 0.2 * (1 - ((points.length - 1) / t.numSegments) * 0.5); 281 + const tipRadius = s.thickness * 0.2 * (1 - ((points.length - 1) / s.numSegments) * 0.5); 282 282 ctx.beginPath(); 283 283 ctx.arc(tip.x, tip.y, Math.max(1, tipRadius), 0, Math.PI * 2); 284 284 ctx.fillStyle = `rgba(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, ${Math.min(255, b + 40)}, ${alpha * 0.8})`; 285 285 ctx.fill(); 286 286 } 287 287 288 - // Suckers/spots along the tentacle for extra organic feel 288 + // Spots along the strand for extra organic feel 289 289 for (let i = 1; i < points.length - 1; i += 2) { 290 290 const p = points[i]; 291 - const spotSize = t.thickness * 0.12 * (1 - (i / points.length) * 0.5); 291 + const spotSize = s.thickness * 0.12 * (1 - (i / points.length) * 0.5); 292 292 ctx.beginPath(); 293 293 ctx.arc(p.x, p.y, Math.max(0.5, spotSize), 0, Math.PI * 2); 294 294 ctx.fillStyle = `rgba(${Math.max(0, r - 20)}, ${Math.max(0, g - 20)}, ${Math.max(0, b - 20)}, ${alpha * 0.4})`; ··· 299 299 } 300 300 301 301 /** 302 - * Check if the cursor is near any point of a tentacle 302 + * Check if the cursor is near any point of a strand 303 303 */ 304 - function isCursorNear(t) { 305 - const points = computePoints(t); 304 + function isCursorNear(s) { 305 + const points = computePoints(s); 306 306 for (const p of points) { 307 307 if (dist(mouseX, mouseY, p.x, p.y) < CONFIG.fearRadius) { 308 308 return true; ··· 312 312 } 313 313 314 314 /** 315 - * Update a single tentacle's state 315 + * Update a single strand's state 316 316 */ 317 - function updateTentacle(t, dt) { 317 + function updateStrand(s, dt) { 318 318 // Advance wobble time 319 - t.wobbleTime += CONFIG.wobbleSpeed * (dt / 16); 319 + s.wobbleTime += CONFIG.wobbleSpeed * (dt / 16); 320 320 321 321 // Check for cursor proximity 322 - const cursorNear = isCursorNear(t); 322 + const cursorNear = isCursorNear(s); 323 323 324 - if (cursorNear && !t.afraid && t.phase !== 'dead') { 324 + if (cursorNear && !s.afraid && s.phase !== 'dead') { 325 325 // FEAR! Rapidly retract 326 - t.afraid = true; 327 - t.fearTime = 0; 328 - t.phase = 'fleeing'; 329 - t.targetExtension = 0; 326 + s.afraid = true; 327 + s.fearTime = 0; 328 + s.phase = 'fleeing'; 329 + s.targetExtension = 0; 330 330 } 331 331 332 332 // Update phase 333 - t.phaseTime += dt; 333 + s.phaseTime += dt; 334 334 335 - switch (t.phase) { 335 + switch (s.phase) { 336 336 case 'creeping': 337 337 // Slowly extend 338 - t.extension = clamp(t.extension + CONFIG.creepSpeed * (dt / 1000), 0, 1); 339 - t.alpha = clamp(t.alpha + 0.01 * (dt / 16), 0, CONFIG.maxAlpha); 338 + s.extension = clamp(s.extension + CONFIG.creepSpeed * (dt / 1000), 0, 1); 339 + s.alpha = clamp(s.alpha + 0.01 * (dt / 16), 0, CONFIG.maxAlpha); 340 340 341 - if (t.extension >= 1) { 342 - t.phase = 'lingering'; 343 - t.phaseTime = 0; 341 + if (s.extension >= 1) { 342 + s.phase = 'lingering'; 343 + s.phaseTime = 0; 344 344 } 345 345 break; 346 346 347 347 case 'lingering': 348 348 // Just wobble in place 349 - t.alpha = CONFIG.maxAlpha; 350 - if (t.phaseTime >= t.lingerDuration) { 351 - t.phase = 'retreating'; 352 - t.phaseTime = 0; 353 - t.targetExtension = 0; 349 + s.alpha = CONFIG.maxAlpha; 350 + if (s.phaseTime >= s.lingerDuration) { 351 + s.phase = 'retreating'; 352 + s.phaseTime = 0; 353 + s.targetExtension = 0; 354 354 } 355 355 break; 356 356 357 357 case 'retreating': 358 358 // Slowly retract 359 - t.extension = clamp(t.extension - CONFIG.retreatSpeed * (dt / 1000), 0, 1); 360 - t.alpha = clamp(t.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 359 + s.extension = clamp(s.extension - CONFIG.retreatSpeed * (dt / 1000), 0, 1); 360 + s.alpha = clamp(s.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 361 361 362 - if (t.extension <= 0) { 363 - t.phase = 'dead'; 362 + if (s.extension <= 0) { 363 + s.phase = 'dead'; 364 364 } 365 365 break; 366 366 367 367 case 'fleeing': 368 368 // Rapidly retract with increased wobble 369 - t.extension = clamp(t.extension - CONFIG.fleeSpeed * (dt / 1000), 0, 1); 370 - t.alpha = clamp(t.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 369 + s.extension = clamp(s.extension - CONFIG.fleeSpeed * (dt / 1000), 0, 1); 370 + s.alpha = clamp(s.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 371 371 372 372 // Extra frantic wobble when afraid 373 - t.wobbleTime += CONFIG.wobbleSpeed * 3 * (dt / 16); 373 + s.wobbleTime += CONFIG.wobbleSpeed * 3 * (dt / 16); 374 374 375 - if (t.extension <= 0) { 376 - t.phase = 'dead'; 375 + if (s.extension <= 0) { 376 + s.phase = 'dead'; 377 377 } 378 378 break; 379 379 } ··· 389 389 // Clear canvas 390 390 ctx.clearRect(0, 0, canvas.width, canvas.height); 391 391 392 - // Update and draw all tentacles 393 - for (const t of tentacles) { 394 - updateTentacle(t, dt); 395 - if (t.phase !== 'dead') { 396 - drawTentacle(t); 392 + // Update and draw all strands 393 + for (const s of strands) { 394 + updateStrand(s, dt); 395 + if (s.phase !== 'dead') { 396 + drawStrand(s); 397 397 } 398 398 } 399 399 400 - // Remove dead tentacles 401 - tentacles = tentacles.filter(t => t.phase !== 'dead'); 400 + // Remove dead strands 401 + strands = strands.filter(s => s.phase !== 'dead'); 402 402 403 403 animationId = requestAnimationFrame(animate); 404 404 } 405 405 406 406 /** 407 - * Spawn a new tentacle if under the limit 407 + * Spawn a new strand if under the limit 408 408 */ 409 - function spawnTentacle() { 410 - if (tentacles.length < CONFIG.maxTentacles) { 411 - tentacles.push(createTentacle()); 412 - console.log('[tentacles] Spawned tentacle, total:', tentacles.length); 409 + function spawnStrand() { 410 + if (strands.length < CONFIG.maxStrands) { 411 + strands.push(createStrand()); 413 412 } 414 413 scheduleNextSpawn(); 415 414 } 416 415 417 416 /** 418 - * Schedule the next tentacle spawn 417 + * Schedule the next strand spawn 419 418 */ 420 419 function scheduleNextSpawn() { 421 420 const delay = rand(CONFIG.spawnIntervalMin, CONFIG.spawnIntervalMax); 422 - spawnTimer = setTimeout(spawnTentacle, delay); 421 + spawnTimer = setTimeout(spawnStrand, delay); 423 422 } 424 423 425 424 /** ··· 438 437 * Initialize 439 438 */ 440 439 function init() { 441 - console.log('[tentacles] Initializing canvas'); 442 440 resizeCanvas(); 443 441 window.addEventListener('resize', resizeCanvas); 444 442 ··· 457 455 // Start animation loop 458 456 animationId = requestAnimationFrame(animate); 459 457 460 - // Spawn first tentacle after a short delay, then schedule periodic spawns 461 - setTimeout(() => { 462 - spawnTentacle(); 463 - }, 3000); 464 - 465 - console.log('[tentacles] Animation started'); 458 + // Spawn a batch immediately from all edges 459 + for (let i = 0; i < 6; i++) { 460 + spawnStrand(); 461 + } 462 + scheduleNextSpawn(); 466 463 } 467 464 468 465 init();