experiments in a post-browser web
10
fork

Configure Feed

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

chore: remove unused helpdocs feature

+1 -872
+1 -1
backend/electron/main.ts
··· 76 76 // extracted from features/ into core app code (app/cmd/, app/hud/, app/page/). 77 77 // They're now loaded via initCmd() / initHud() / initPage() from their 78 78 // respective glue files, which run before feature loading. 79 - const CONSOLIDATED_EXTENSION_IDS = ['editor', 'groups', 'lex', 'lists', 'peeks', 'search', 'slides', 'spaces', 'websearch', 'windows', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts', 'timers', 'wonderwall']; 79 + const CONSOLIDATED_EXTENSION_IDS = ['editor', 'groups', 'lex', 'lists', 'peeks', 'search', 'slides', 'spaces', 'websearch', 'windows', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'entities', 'scripts', 'timers', 'wonderwall']; 80 80 81 81 // Extensions that must load eagerly (not lazy) — needed at startup 82 82 const EAGER_EXTENSION_IDS = new Set(['entities']);
-1
docs/command-audit.md
··· 104 104 | groups | restore group | execute | lazy | works | 105 105 | groups | pin | execute | lazy | works | 106 106 | groups | unpin | execute | lazy | works | 107 - | helpdocs | help docs | execute | lazy | works | 108 107 | lex | lexicon studio | window | lazy | works — needs tile:window:open fix + settings.getKey | 109 108 | lex | lex | execute | lazy | works | 110 109 | lists | lists | execute | lazy | works |
-12
docs/feature-tour.md
··· 674 674 7. **Create Record** -- search for a lexicon, select it, and fill in an auto-generated form based on the schema. Toggle to "Raw JSON" mode for full control. Supports blob uploads for image fields. 675 675 8. Successfully created record types appear as sidebar shortcuts under the navigation. Click to jump back, or [x] to remove. 676 676 677 - ### Help Docs (Easter Egg) 678 - 679 - **Summary:** A hidden documentation overlay that shows animated help hints from screen edges. Disguised as "Help docs" in the extension system. Disabled by default. 680 - 681 - **Where in UI:** Command palette. 682 - 683 - **How to try it:** 684 - 1. Type `help docs` in the command palette. 685 - 2. A fullscreen transparent, click-through overlay appears with animated documentation hints. 686 - 3. Type `help docs` again to toggle it off. 687 - 688 677 ### Example Gallery 689 678 690 679 **Summary:** A demonstration extension showcasing the Peek API -- feature detection, image handling, and storage patterns. Useful for developers building extensions. ··· 795 784 | `pagestream` | Open browsing history stream | 796 785 | `Sync now` | Trigger manual sync | 797 786 | `hud` | Toggle HUD overlay | 798 - | `help docs` | Toggle help docs overlay | 799 787 | `devtools` | Open DevTools for active window | 800 788 | `debug <url>` | Open URL with DevTools enabled | 801 789 | `open file` | Open a text file from disk |
-34
features/helpdocs/background.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="utf-8"> 5 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 - <title>Help Docs Extension</title> 7 - </head> 8 - <body> 9 - <script type="module"> 10 - import extension from './background.js'; 11 - 12 - const api = window.app; 13 - const extId = extension.id; 14 - 15 - console.log(`[ext:${extId}] background.html loaded`); 16 - 17 - // ── V2 Tile Runtime ── 18 - console.log(`[ext:${extId}] initializing v2 tile`); 19 - await api.initialize(); 20 - 21 - if (extension.init) { 22 - console.log(`[ext:${extId}] calling init()`); 23 - await extension.init(); 24 - } 25 - 26 - api.onShutdown(() => { 27 - console.log(`[ext:${extId}] received shutdown`); 28 - if (extension.uninit) { 29 - extension.uninit(); 30 - } 31 - }); 32 - </script> 33 - </body> 34 - </html>
-213
features/helpdocs/background.js
··· 1 - /** 2 - * Help Docs Extension Background Script 3 - * 4 - * Spawns a fullscreen transparent overlay window with animated 5 - * documentation hints that appear from screen edges. 6 - * 7 - * Runs in isolated extension process (peek://helpdocs/background.html) 8 - */ 9 - 10 - const api = window.app; 11 - const debug = api.debug; 12 - 13 - console.log('[ext:helpdocs] background init'); 14 - 15 - const OVERLAY_ADDRESS = 'peek://helpdocs/overlay.html'; 16 - const STORAGE_KEY = 'helpdocs_enabled'; 17 - 18 - let enabled = false; 19 - let overlayWindowId = null; 20 - let appFocused = true; 21 - 22 - /** 23 - * Load enabled state 24 - */ 25 - const loadState = async () => { 26 - try { 27 - const stored = localStorage.getItem(STORAGE_KEY); 28 - enabled = stored === 'true'; // Default disabled 29 - console.log('[ext:helpdocs] Loaded state from localStorage - enabled:', enabled); 30 - } catch (err) { 31 - console.log('[ext:helpdocs] Failed to load state from localStorage:', err); 32 - } 33 - return enabled; 34 - }; 35 - 36 - /** 37 - * Save enabled state 38 - */ 39 - const saveState = (value) => { 40 - enabled = value; 41 - try { 42 - localStorage.setItem(STORAGE_KEY, String(value)); 43 - console.log('[ext:helpdocs] Saved state to localStorage - enabled:', value); 44 - } catch (err) { 45 - console.error('[ext:helpdocs] Failed to save state to localStorage:', err); 46 - } 47 - }; 48 - 49 - /** 50 - * Open the overlay window (fullscreen, transparent, click-through) 51 - */ 52 - const openOverlay = async () => { 53 - if (overlayWindowId) { 54 - const exists = await api.window.exists(overlayWindowId); 55 - if (exists.success && exists.data) { 56 - await api.window.show(overlayWindowId); 57 - return; 58 - } 59 - overlayWindowId = null; 60 - } 61 - 62 - const screenW = window.screen.width; 63 - const screenH = window.screen.height; 64 - 65 - const params = { 66 - key: OVERLAY_ADDRESS, 67 - width: screenW, 68 - height: screenH, 69 - x: 0, 70 - y: 0, 71 - transparent: true, 72 - alwaysOnTop: true, 73 - skipTaskbar: true, 74 - focusable: false, 75 - resizable: false, 76 - frame: false, 77 - hasShadow: false, 78 - escapeMode: 'ignore', 79 - title: 'Help Docs', 80 - centerOnParent: false, 81 - show: true 82 - }; 83 - 84 - try { 85 - const result = await api.window.open(OVERLAY_ADDRESS, params); 86 - if (result.success) { 87 - overlayWindowId = result.id; 88 - console.log('[ext:helpdocs] Overlay opened:', overlayWindowId); 89 - 90 - // Make the window click-through but still receive mouse move events 91 - // forward: true means mouse moves are still forwarded to the renderer 92 - setTimeout(async () => { 93 - try { 94 - await api.window.setIgnoreMouseEvents(overlayWindowId, true, true); 95 - console.log('[ext:helpdocs] Set click-through with forward'); 96 - } catch (err) { 97 - console.error('[ext:helpdocs] Failed to set click-through:', err); 98 - } 99 - }, 500); 100 - } else { 101 - console.error('[ext:helpdocs] Failed to open overlay:', result.error); 102 - } 103 - } catch (error) { 104 - console.error('[ext:helpdocs] Error opening overlay:', error); 105 - } 106 - }; 107 - 108 - const showOverlay = async () => { 109 - if (overlayWindowId) { 110 - const exists = await api.window.exists(overlayWindowId); 111 - if (exists.success && exists.data) { 112 - await api.window.show(overlayWindowId); 113 - } 114 - } 115 - }; 116 - 117 - const hideOverlay = async () => { 118 - if (overlayWindowId) { 119 - const exists = await api.window.exists(overlayWindowId); 120 - if (exists.success && exists.data) { 121 - await api.window.hide(overlayWindowId); 122 - } 123 - } 124 - }; 125 - 126 - const closeOverlay = async () => { 127 - if (overlayWindowId) { 128 - await api.window.close(overlayWindowId); 129 - overlayWindowId = null; 130 - console.log('[ext:helpdocs] Overlay closed'); 131 - } 132 - }; 133 - 134 - const toggle = async () => { 135 - const newState = !enabled; 136 - saveState(newState); 137 - if (newState) { 138 - await openOverlay(); 139 - return { output: 'Help docs enabled', mimeType: 'text/plain' }; 140 - } else { 141 - await closeOverlay(); 142 - return { output: 'Help docs disabled', mimeType: 'text/plain' }; 143 - } 144 - }; 145 - 146 - // Commands 147 - const commandDefinitions = [ 148 - { 149 - name: 'help docs', 150 - description: 'Toggle help documentation overlay', 151 - execute: async () => { 152 - console.log('[ext:helpdocs] Toggle command executed'); 153 - return await toggle(); 154 - } 155 - } 156 - ]; 157 - 158 - let registeredCommands = []; 159 - 160 - const initCommands = () => { 161 - commandDefinitions.forEach(cmd => { 162 - api.commands.register(cmd); 163 - registeredCommands.push(cmd.name); 164 - }); 165 - console.log('[ext:helpdocs] Registered commands:', registeredCommands); 166 - }; 167 - 168 - const uninitCommands = () => { 169 - registeredCommands.forEach(name => { 170 - api.commands.unregister(name); 171 - }); 172 - registeredCommands = []; 173 - }; 174 - 175 - const init = async () => { 176 - console.log('[ext:helpdocs] init'); 177 - 178 - await loadState(); 179 - initCommands(); 180 - 181 - // Track app focus to show/hide overlay 182 - api.pubsub.subscribe('app:focus-changed', async (msg) => { 183 - appFocused = !!msg.focused; 184 - if (enabled && overlayWindowId) { 185 - if (appFocused) { 186 - await showOverlay(); 187 - } else { 188 - await hideOverlay(); 189 - } 190 - } 191 - }, api.scopes.GLOBAL); 192 - 193 - if (enabled) { 194 - await openOverlay(); 195 - } 196 - 197 - console.log('[ext:helpdocs] Initialized, enabled:', enabled); 198 - }; 199 - 200 - const uninit = () => { 201 - console.log('[ext:helpdocs] uninit'); 202 - uninitCommands(); 203 - closeOverlay(); 204 - }; 205 - 206 - export default { 207 - id: 'helpdocs', 208 - labels: { 209 - name: 'Help docs' 210 - }, 211 - init, 212 - uninit 213 - };
-51
features/helpdocs/manifest.json
··· 1 - { 2 - "manifestVersion": 2, 3 - "id": "helpdocs", 4 - "shortname": "helpdocs", 5 - "name": "Help docs", 6 - "description": "Helpful documentation and assistance features", 7 - "version": "2.0.0", 8 - "builtin": true, 9 - "tiles": [ 10 - { 11 - "id": "background", 12 - "type": "background", 13 - "url": "background.html", 14 - "lazy": true 15 - }, 16 - { 17 - "id": "overlay", 18 - "type": "window", 19 - "url": "overlay.html", 20 - "windowHints": { 21 - "transparent": true, 22 - "alwaysOnTop": true, 23 - "focusable": false, 24 - "resizable": false, 25 - "frame": false, 26 - "title": "Help Docs" 27 - } 28 - } 29 - ], 30 - "capabilities": { 31 - "pubsub": { 32 - "scopes": ["global"], 33 - "topics": [ 34 - "app:focus-changed" 35 - ] 36 - }, 37 - "window": { 38 - "create": true, 39 - "manage": true, 40 - "urls": ["peek://helpdocs/overlay.html"] 41 - }, 42 - "commands": true 43 - }, 44 - "commands": [ 45 - { 46 - "name": "help docs", 47 - "description": "Toggle help documentation overlay", 48 - "action": { "type": "execute" } 49 - } 50 - ] 51 - }
-30
features/helpdocs/overlay.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="utf-8"> 5 - <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 - <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 - <title>Help Docs Overlay</title> 8 - <style> 9 - * { margin: 0; padding: 0; } 10 - html, body { 11 - width: 100%; 12 - height: 100%; 13 - overflow: hidden; 14 - background: transparent; 15 - -webkit-app-region: no-drag; 16 - } 17 - canvas { 18 - position: absolute; 19 - top: 0; 20 - left: 0; 21 - width: 100%; 22 - height: 100%; 23 - } 24 - </style> 25 - </head> 26 - <body> 27 - <canvas id="overlay-canvas"></canvas> 28 - <script type="module" src="overlay.js"></script> 29 - </body> 30 - </html>
-530
features/helpdocs/overlay.js
··· 1 - /** 2 - * Help Docs Overlay Animation Script 3 - * 4 - * Renders organic animated strands that creep in from screen edges, 5 - * undulate gently, and flee from the cursor. 6 - * 7 - * Each strand is a chain of segments rendered as a smooth bezier curve. 8 - * Control points animate with sinusoidal wobble for organic motion. 9 - * Cursor proximity triggers a fear/recoil response. 10 - */ 11 - 12 - const canvas = document.getElementById('overlay-canvas'); 13 - const ctx = canvas.getContext('2d'); 14 - 15 - // --- Configuration --- 16 - const CONFIG = { 17 - // Spawn timing 18 - spawnIntervalMin: 800, // faster spawning 19 - spawnIntervalMax: 2500, 20 - 21 - // Strand properties 22 - minSegments: 8, 23 - maxSegments: 18, 24 - minSegmentLength: 14, 25 - maxSegmentLength: 30, 26 - minThickness: 8, 27 - maxThickness: 22, 28 - 29 - // Animation 30 - creepSpeed: 0.8, // pixels per frame when creeping in 31 - retreatSpeed: 0.15, // pixels per frame when retreating naturally 32 - fleeSpeed: 3.0, // pixels per frame when fleeing from cursor 33 - wobbleSpeed: 0.015, // base radians per frame for wobble 34 - wobbleAmplitude: 0.08, // base wobble at root (increases toward tip) 35 - tipAmplitude: 0.35, // max wobble at tip 36 - curlStrength: 0.4, // tendency to curl (0 = straight, 1 = tight spiral) 37 - curlDriftSpeed: 0.003, // how fast the curl direction changes 38 - 39 - // Lifecycle 40 - creepInDuration: 3000, // ms to creep in 41 - lingerDurationMin: 4000, // ms to linger at max extension 42 - lingerDurationMax: 10000, // ms to linger at max extension 43 - retreatDuration: 4000, // ms to retreat 44 - 45 - // Fear response 46 - fearRadius: 100, // px distance to trigger fear 47 - fearRecoveryTime: 2000, // ms before strand can return after fear 48 - 49 - // Visual 50 - maxAlpha: 1.0, 51 - colors: [ 52 - { r: 60, g: 30, b: 90 }, // deep purple 53 - { r: 30, g: 65, b: 55 }, // dark teal 54 - { r: 40, g: 40, b: 75 }, // deep slate blue 55 - { r: 55, g: 30, b: 65 }, // dark violet 56 - { r: 25, g: 55, b: 45 }, // deep sea green 57 - { r: 45, g: 25, b: 50 }, // near-black plum 58 - ], 59 - 60 - // Max simultaneous strands 61 - maxStrands: 30, 62 - }; 63 - 64 - // --- State --- 65 - let strands = []; 66 - let mouseX = -1000; 67 - let mouseY = -1000; 68 - let animationId = null; 69 - let spawnTimer = null; 70 - let lastTime = 0; 71 - 72 - // --- Edge enum --- 73 - const EDGE = { TOP: 0, RIGHT: 1, BOTTOM: 2, LEFT: 3 }; 74 - 75 - // --- Utility --- 76 - const rand = (min, max) => Math.random() * (max - min) + min; 77 - const randInt = (min, max) => Math.floor(rand(min, max + 1)); 78 - const lerp = (a, b, t) => a + (b - a) * t; 79 - const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); 80 - const dist = (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); 81 - 82 - /** 83 - * Create a new strand emerging from a specific or random edge. 84 - * @param {number} [forceEdge] - If provided, use this edge (0-3). Otherwise random. 85 - */ 86 - function createStrand(forceEdge) { 87 - const edge = forceEdge !== undefined ? forceEdge : randInt(0, 3); 88 - const numSegments = randInt(CONFIG.minSegments, CONFIG.maxSegments); 89 - const segmentLength = rand(CONFIG.minSegmentLength, CONFIG.maxSegmentLength); 90 - const thickness = rand(CONFIG.minThickness, CONFIG.maxThickness); 91 - const color = CONFIG.colors[randInt(0, CONFIG.colors.length - 1)]; 92 - 93 - // Base position along the edge 94 - let baseX, baseY, angle; 95 - const W = canvas.width; 96 - const H = canvas.height; 97 - const margin = 80; 98 - 99 - switch (edge) { 100 - case EDGE.TOP: 101 - baseX = rand(margin, W - margin); 102 - baseY = 0; 103 - angle = Math.PI / 2 + rand(-0.3, 0.3); // mostly downward 104 - break; 105 - case EDGE.BOTTOM: 106 - baseX = rand(margin, W - margin); 107 - baseY = H; 108 - angle = -Math.PI / 2 + rand(-0.3, 0.3); // mostly upward 109 - break; 110 - case EDGE.LEFT: 111 - baseX = 0; 112 - baseY = rand(margin, H - margin); 113 - angle = rand(-0.3, 0.3); // mostly rightward 114 - break; 115 - case EDGE.RIGHT: 116 - baseX = W; 117 - baseY = rand(margin, H - margin); 118 - angle = Math.PI + rand(-0.3, 0.3); // mostly leftward 119 - break; 120 - } 121 - 122 - // Each segment has a base angle offset and independent wobble parameters 123 - const segments = []; 124 - for (let i = 0; i < numSegments; i++) { 125 - segments.push({ 126 - length: segmentLength * (1 - i * 0.03), // taper slightly 127 - angleOffset: rand(-0.1, 0.1), // slight random curvature 128 - wobblePhase: rand(0, Math.PI * 2), // random initial phase 129 - wobbleFreq: rand(0.5, 1.5), // varied wobble frequency 130 - // Secondary slower wobble for more complex motion 131 - wobblePhase2: rand(0, Math.PI * 2), 132 - wobbleFreq2: rand(0.15, 0.4), // much slower secondary wave 133 - }); 134 - } 135 - 136 - const lingerDuration = rand(CONFIG.lingerDurationMin, CONFIG.lingerDurationMax); 137 - 138 - return { 139 - edge, 140 - baseX, 141 - baseY, 142 - baseAngle: angle, 143 - segments, 144 - thickness, 145 - color, 146 - numSegments, 147 - 148 - // Extension: 0 = fully retracted, 1 = fully extended 149 - extension: 0, 150 - targetExtension: 1, 151 - 152 - // Lifecycle phase: 'creeping', 'lingering', 'retreating', 'dead' 153 - phase: 'creeping', 154 - phaseTime: 0, 155 - lingerDuration, 156 - 157 - // Fear state 158 - afraid: false, 159 - fearTime: 0, 160 - 161 - // Wobble time accumulator 162 - wobbleTime: rand(0, 100), 163 - 164 - // Curl state: a slow-drifting bias that makes segments curl in one direction 165 - curlBias: rand(-1, 1), // current curl direction (-1 to 1) 166 - curlPhase: rand(0, Math.PI * 2), // phase for curl drift 167 - 168 - // Alpha 169 - alpha: 0, 170 - }; 171 - } 172 - 173 - /** 174 - * Compute the joint positions of a strand 175 - * Returns array of {x, y} points from base to tip 176 - * 177 - * Motion model: 178 - * - Base segments are relatively stable (anchored to the edge) 179 - * - Tip segments are much more active and mobile 180 - * - A slow-drifting curl bias makes the whole strand curl/uncurl over time 181 - * - Two wobble frequencies per segment create complex, non-repetitive motion 182 - * - Curl accumulates along the strand (each segment adds to the curve) 183 - */ 184 - function computePoints(s) { 185 - const points = [{ x: s.baseX, y: s.baseY }]; 186 - let currentAngle = s.baseAngle; 187 - let x = s.baseX; 188 - let y = s.baseY; 189 - 190 - // Only draw segments up to current extension 191 - const visibleSegments = Math.ceil(s.extension * s.numSegments); 192 - const partialFrac = (s.extension * s.numSegments) - Math.floor(s.extension * s.numSegments); 193 - 194 - for (let i = 0; i < visibleSegments && i < s.segments.length; i++) { 195 - const seg = s.segments[i]; 196 - 197 - // How far along the strand (0 = base, 1 = tip) 198 - const t = i / s.numSegments; 199 - 200 - // Wobble amplitude increases toward tip (tentacle-like: base stable, tip active) 201 - const amplitude = lerp(CONFIG.wobbleAmplitude, CONFIG.tipAmplitude, t * t); 202 - 203 - // Primary wobble: faster, smaller oscillation 204 - const wobble1 = Math.sin(s.wobbleTime * seg.wobbleFreq + seg.wobblePhase + i * 0.6) 205 - * amplitude; 206 - 207 - // Secondary wobble: slower, creates longer sweeping motions 208 - const wobble2 = Math.sin(s.wobbleTime * seg.wobbleFreq2 + seg.wobblePhase2 + i * 0.3) 209 - * amplitude * 0.7; 210 - 211 - // Curl: accumulates along the strand, creating spiral/curl shapes 212 - // The curl bias drifts slowly so the strand curls and uncurls over time 213 - const curl = s.curlBias * CONFIG.curlStrength * t; 214 - 215 - currentAngle += seg.angleOffset + wobble1 + wobble2 + curl; 216 - 217 - // If this is the last visible segment and partial, shorten it 218 - let len = seg.length; 219 - if (i === visibleSegments - 1 && visibleSegments < s.numSegments) { 220 - len *= partialFrac || 1; 221 - } 222 - 223 - x += Math.cos(currentAngle) * len; 224 - y += Math.sin(currentAngle) * len; 225 - points.push({ x, y }); 226 - } 227 - 228 - return points; 229 - } 230 - 231 - /** 232 - * Draw a smooth strand through points using quadratic bezier curves 233 - */ 234 - function drawStrand(s) { 235 - const points = computePoints(s); 236 - if (points.length < 2) return; 237 - 238 - const { r, g, b } = s.color; 239 - const alpha = s.alpha; 240 - 241 - // Draw the strand as a tapered, smooth path 242 - // We'll draw it as a filled shape with varying width 243 - ctx.save(); 244 - 245 - for (let i = 0; i < points.length - 1; i++) { 246 - const p0 = points[i]; 247 - const p1 = points[i + 1]; 248 - 249 - // Thickness tapers from base to tip — gradual taper, base stays thick 250 - const t0 = s.thickness * (1 - (i / points.length) * 0.5); 251 - const t1 = s.thickness * (1 - ((i + 1) / points.length) * 0.5); 252 - 253 - // Direction perpendicular to segment 254 - const dx = p1.x - p0.x; 255 - const dy = p1.y - p0.y; 256 - const len = Math.sqrt(dx * dx + dy * dy) || 1; 257 - const nx = -dy / len; 258 - const ny = dx / len; 259 - 260 - // Draw a quadrilateral for each segment 261 - ctx.beginPath(); 262 - ctx.moveTo(p0.x + nx * t0 / 2, p0.y + ny * t0 / 2); 263 - ctx.lineTo(p1.x + nx * t1 / 2, p1.y + ny * t1 / 2); 264 - ctx.lineTo(p1.x - nx * t1 / 2, p1.y - ny * t1 / 2); 265 - ctx.lineTo(p0.x - nx * t0 / 2, p0.y - ny * t0 / 2); 266 - ctx.closePath(); 267 - 268 - // Solid color — no per-segment alpha fade 269 - ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`; 270 - ctx.fill(); 271 - } 272 - 273 - // Draw a smooth overlay using bezier curves for the center line 274 - // This gives the "core" of the strand a richer look 275 - ctx.beginPath(); 276 - ctx.moveTo(points[0].x, points[0].y); 277 - 278 - if (points.length === 2) { 279 - ctx.lineTo(points[1].x, points[1].y); 280 - } else { 281 - // Smooth curve through points 282 - for (let i = 0; i < points.length - 1; i++) { 283 - const p0 = points[i]; 284 - const p1 = points[i + 1]; 285 - 286 - if (i === 0) { 287 - // First segment: quadratic to midpoint 288 - const midX = (p0.x + p1.x) / 2; 289 - const midY = (p0.y + p1.y) / 2; 290 - ctx.quadraticCurveTo(p0.x, p0.y, midX, midY); 291 - } else if (i === points.length - 2) { 292 - // Last segment: quadratic to end 293 - ctx.quadraticCurveTo(points[i].x, points[i].y, p1.x, p1.y); 294 - } else { 295 - // Middle segments: quadratic through midpoints 296 - const midX = (p0.x + p1.x) / 2; 297 - const midY = (p0.y + p1.y) / 2; 298 - ctx.quadraticCurveTo(p0.x, p0.y, midX, midY); 299 - } 300 - } 301 - } 302 - 303 - // Core line (slightly lighter, thinner) 304 - ctx.strokeStyle = `rgba(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)}, ${alpha * 0.6})`; 305 - ctx.lineWidth = Math.max(1, s.thickness * 0.3); 306 - ctx.lineCap = 'round'; 307 - ctx.lineJoin = 'round'; 308 - ctx.stroke(); 309 - 310 - // Tip: small circle at the end 311 - if (points.length >= 2) { 312 - const tip = points[points.length - 1]; 313 - const tipRadius = s.thickness * 0.2 * (1 - ((points.length - 1) / s.numSegments) * 0.5); 314 - ctx.beginPath(); 315 - ctx.arc(tip.x, tip.y, Math.max(1, tipRadius), 0, Math.PI * 2); 316 - ctx.fillStyle = `rgba(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, ${Math.min(255, b + 40)}, ${alpha * 0.8})`; 317 - ctx.fill(); 318 - } 319 - 320 - // Spots along the strand for extra organic feel 321 - for (let i = 1; i < points.length - 1; i += 2) { 322 - const p = points[i]; 323 - const spotSize = s.thickness * 0.12 * (1 - (i / points.length) * 0.5); 324 - ctx.beginPath(); 325 - ctx.arc(p.x, p.y, Math.max(0.5, spotSize), 0, Math.PI * 2); 326 - ctx.fillStyle = `rgba(${Math.max(0, r - 20)}, ${Math.max(0, g - 20)}, ${Math.max(0, b - 20)}, ${alpha * 0.4})`; 327 - ctx.fill(); 328 - } 329 - 330 - ctx.restore(); 331 - } 332 - 333 - /** 334 - * Check if the cursor is near any point of a strand 335 - */ 336 - function isCursorNear(s) { 337 - const points = computePoints(s); 338 - for (const p of points) { 339 - if (dist(mouseX, mouseY, p.x, p.y) < CONFIG.fearRadius) { 340 - return true; 341 - } 342 - } 343 - return false; 344 - } 345 - 346 - /** 347 - * Update a single strand's state 348 - */ 349 - function updateStrand(s, dt) { 350 - // Advance wobble time 351 - s.wobbleTime += CONFIG.wobbleSpeed * (dt / 16); 352 - 353 - // Drift the curl bias — slow sinusoidal drift creates curling/uncurling over time 354 - s.curlPhase += CONFIG.curlDriftSpeed * (dt / 16); 355 - s.curlBias = Math.sin(s.curlPhase) * 0.8 + Math.sin(s.curlPhase * 0.37) * 0.2; 356 - 357 - // Check for cursor proximity 358 - const cursorNear = isCursorNear(s); 359 - 360 - if (cursorNear && !s.afraid && s.phase !== 'dead') { 361 - // FEAR! Rapidly retract 362 - s.afraid = true; 363 - s.fearTime = 0; 364 - s.phase = 'fleeing'; 365 - s.targetExtension = 0; 366 - } 367 - 368 - // Update phase 369 - s.phaseTime += dt; 370 - 371 - switch (s.phase) { 372 - case 'creeping': 373 - // Slowly extend 374 - s.extension = clamp(s.extension + CONFIG.creepSpeed * (dt / 1000), 0, 1); 375 - s.alpha = clamp(s.alpha + 0.03 * (dt / 16), 0, CONFIG.maxAlpha); 376 - 377 - if (s.extension >= 1) { 378 - s.phase = 'lingering'; 379 - s.phaseTime = 0; 380 - } 381 - break; 382 - 383 - case 'lingering': 384 - // Just wobble in place 385 - s.alpha = CONFIG.maxAlpha; 386 - if (s.phaseTime >= s.lingerDuration) { 387 - s.phase = 'retreating'; 388 - s.phaseTime = 0; 389 - s.targetExtension = 0; 390 - } 391 - break; 392 - 393 - case 'retreating': 394 - // Slowly retract 395 - s.extension = clamp(s.extension - CONFIG.retreatSpeed * (dt / 1000), 0, 1); 396 - s.alpha = clamp(s.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 397 - 398 - if (s.extension <= 0) { 399 - s.phase = 'dead'; 400 - } 401 - break; 402 - 403 - case 'fleeing': 404 - // Rapidly retract with increased wobble 405 - s.extension = clamp(s.extension - CONFIG.fleeSpeed * (dt / 1000), 0, 1); 406 - s.alpha = clamp(s.extension * CONFIG.maxAlpha, 0, CONFIG.maxAlpha); 407 - 408 - // Extra frantic wobble when afraid 409 - s.wobbleTime += CONFIG.wobbleSpeed * 3 * (dt / 16); 410 - 411 - if (s.extension <= 0) { 412 - s.phase = 'dead'; 413 - } 414 - break; 415 - } 416 - } 417 - 418 - /** 419 - * Main animation loop 420 - */ 421 - function animate(timestamp) { 422 - const dt = lastTime ? Math.min(timestamp - lastTime, 100) : 16; // cap dt to avoid jumps 423 - lastTime = timestamp; 424 - 425 - // Clear canvas 426 - ctx.clearRect(0, 0, canvas.width, canvas.height); 427 - 428 - // Update and draw all strands 429 - for (const s of strands) { 430 - updateStrand(s, dt); 431 - if (s.phase !== 'dead') { 432 - drawStrand(s); 433 - } 434 - } 435 - 436 - // Remove dead strands 437 - strands = strands.filter(s => s.phase !== 'dead'); 438 - 439 - animationId = requestAnimationFrame(animate); 440 - } 441 - 442 - /** 443 - * Edge rotation for spawning — cycles through edges to ensure strands 444 - * come from at least 3 sides. Uses a shuffled queue that refills when empty. 445 - */ 446 - let edgeQueue = []; 447 - 448 - function nextEdge() { 449 - if (edgeQueue.length === 0) { 450 - // Refill with all 4 edges, shuffled 451 - edgeQueue = [0, 1, 2, 3]; 452 - for (let i = edgeQueue.length - 1; i > 0; i--) { 453 - const j = Math.floor(Math.random() * (i + 1)); 454 - [edgeQueue[i], edgeQueue[j]] = [edgeQueue[j], edgeQueue[i]]; 455 - } 456 - } 457 - return edgeQueue.pop(); 458 - } 459 - 460 - /** 461 - * Spawn a new strand if under the limit 462 - */ 463 - function spawnStrand() { 464 - if (strands.length < CONFIG.maxStrands) { 465 - strands.push(createStrand(nextEdge())); 466 - } 467 - scheduleNextSpawn(); 468 - } 469 - 470 - /** 471 - * Schedule the next strand spawn 472 - */ 473 - function scheduleNextSpawn() { 474 - const delay = rand(CONFIG.spawnIntervalMin, CONFIG.spawnIntervalMax); 475 - spawnTimer = setTimeout(spawnStrand, delay); 476 - } 477 - 478 - /** 479 - * Resize canvas to fill the window 480 - */ 481 - function resizeCanvas() { 482 - const dpr = window.devicePixelRatio || 1; 483 - canvas.width = window.innerWidth * dpr; 484 - canvas.height = window.innerHeight * dpr; 485 - canvas.style.width = window.innerWidth + 'px'; 486 - canvas.style.height = window.innerHeight + 'px'; 487 - ctx.scale(dpr, dpr); 488 - } 489 - 490 - /** 491 - * Initialize 492 - */ 493 - function init() { 494 - resizeCanvas(); 495 - window.addEventListener('resize', resizeCanvas); 496 - 497 - // Track mouse position (forwarded even though window is click-through) 498 - document.addEventListener('mousemove', (e) => { 499 - mouseX = e.clientX; 500 - mouseY = e.clientY; 501 - }); 502 - 503 - // Also handle mouse leaving the window 504 - document.addEventListener('mouseleave', () => { 505 - mouseX = -1000; 506 - mouseY = -1000; 507 - }); 508 - 509 - // Start animation loop 510 - animationId = requestAnimationFrame(animate); 511 - 512 - // Spawn initial batch — guarantee at least one strand per edge, 513 - // plus 2 extra from random edges, for immediate multi-side presence. 514 - const initialEdges = [0, 1, 2, 3]; 515 - // Shuffle so the order isn't predictable 516 - for (let i = initialEdges.length - 1; i > 0; i--) { 517 - const j = Math.floor(Math.random() * (i + 1)); 518 - [initialEdges[i], initialEdges[j]] = [initialEdges[j], initialEdges[i]]; 519 - } 520 - for (const edge of initialEdges) { 521 - strands.push(createStrand(edge)); 522 - } 523 - // Plus 6 extra from the rotating queue for a full initial presence 524 - for (let i = 0; i < 6; i++) { 525 - strands.push(createStrand(nextEdge())); 526 - } 527 - scheduleNextSpawn(); 528 - } 529 - 530 - init();