experiments in a post-browser web
10
fork

Configure Feed

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

feat: drag hold delay pref, entity quality filters, help docs improvements

- Add dragHoldDelay preference (default 2s) for window drag activation
- Raise entity confidence threshold, add blocklist and email filtering
- Add feedback propagation for entity suppression
- Add entity quality migration to clean existing low-quality entries
- Structured data validation for JSON-LD entities
- Help docs overlay visual and layout tweaks
- Fix page loading glow lifecycle and animation

+244 -57
+2 -2
app/config.js
··· 12 12 hideTitleBar: 'Hide title bars', 13 13 restoreSession: 'Restore session on startup', 14 14 sessionAutosaveInterval: 'Autosave interval (minutes)', 15 - dragHoldDelay: 'Drag hold delay (seconds)', 15 + dragHoldDelay: 'Window drag hold delay', 16 16 } 17 17 }; 18 18 ··· 89 89 "default": 600 90 90 }, 91 91 "dragHoldDelay": { 92 - "description": "Seconds to hold still before drag mode activates (0 = instant)", 92 + "description": "How long (in seconds) to hold the mouse still before a window becomes draggable. Click and hold without moving — the cursor changes to a hand when ready, then drag to move. This allows normal text selection and clicking without accidental window drags. Set to 0 for instant drag. Takes effect on new windows.", 93 93 "type": "number", 94 94 "default": 2 95 95 },
+8 -12
app/page/index.html
··· 43 43 border: none; 44 44 border-radius: 10px; 45 45 overflow: hidden; 46 - -webkit-mask-image: -webkit-radial-gradient(white, white); 47 46 opacity: 0; 48 - transition: opacity 0.15s ease, border-color 0.3s ease, outline-color 0.3s ease, background-color 0.3s ease; 47 + transition: opacity 0.15s ease, box-shadow 0.4s ease, background-color 0.3s ease; 49 48 } 50 49 51 50 @keyframes loading-glow { 52 51 0%, 100% { 53 - border-color: rgba(120, 160, 255, 0.4); 54 - outline-color: rgba(100, 150, 255, 0.15); 52 + box-shadow: 0 0 8px 2px rgba(100, 150, 255, 0.15), 53 + 0 0 20px 6px rgba(100, 150, 255, 0.06); 55 54 } 56 55 50% { 57 - border-color: rgba(120, 160, 255, 0.85); 58 - outline-color: rgba(100, 150, 255, 0.4); 56 + box-shadow: 0 0 14px 4px rgba(100, 150, 255, 0.55), 57 + 0 0 30px 10px rgba(100, 150, 255, 0.2); 59 58 } 60 59 } 61 60 ··· 67 66 webview.loading { 68 67 opacity: 1; 69 68 background-color: rgba(255, 255, 255, 0.95); 70 - animation: bg-fade-in 0.3s ease forwards, loading-glow 1.5s ease-in-out 0.3s infinite; 71 - border: 2px solid rgba(120, 160, 255, 0.4); 72 - outline: 6px solid rgba(100, 150, 255, 0.15); 73 - outline-offset: 0px; 69 + animation: bg-fade-in 0.3s ease forwards, loading-glow 1.2s ease-in-out 0.3s infinite; 70 + overflow: visible; 74 71 } 75 72 76 73 webview.ready { 77 74 opacity: 1; 78 - border: 2px solid transparent; 79 - outline: 6px solid transparent; 75 + box-shadow: none; 80 76 background: initial; 81 77 animation: none; 82 78 }
+20 -6
app/page/page.js
··· 306 306 webview.classList.add('loading'); 307 307 show({ source: 'loading' }); 308 308 309 + // Safety timeout: if loading glow is still active after 10s, force it off. 310 + // Catches edge cases where neither dom-ready nor did-stop-loading fires. 311 + setTimeout(() => { 312 + if (webview.classList.contains('loading')) { 313 + webview.classList.remove('loading'); 314 + webview.classList.add('ready'); 315 + DEBUG && console.log('[page] Loading glow safety timeout — forced off after 10s'); 316 + } 317 + }, 10000); 318 + 309 319 // --- Custom drag --- 310 320 // Two modes: 311 321 // 1. Navbar background: instant drag (no hold delay) ··· 1001 1011 // Detect page background color, set it as backing color on the webview element, 1002 1012 // then fade the webview in. This prevents the white flash when loading pages in dark mode. 1003 1013 webview.addEventListener('dom-ready', async () => { 1014 + // Always end loading state — even if background detection fails 1015 + const endLoading = () => { 1016 + webview.classList.add('ready'); 1017 + webview.classList.remove('loading'); 1018 + if (showSource === 'loading') { 1019 + scheduleHide(); 1020 + } 1021 + }; 1022 + 1004 1023 try { 1005 1024 const bgResult = await webview.executeJavaScript(` 1006 1025 (function() { ··· 1048 1067 console.error('[page] Failed to detect background:', err); 1049 1068 } 1050 1069 1051 - // Fade in the webview now that background is matched 1052 - webview.classList.add('ready'); 1053 - webview.classList.remove('loading'); 1054 - if (showSource === 'loading') { 1055 - scheduleHide(); 1056 - } 1070 + endLoading(); 1057 1071 }); 1058 1072 1059 1073 // Re-add loading glow on new navigations (but keep webview visible — don't reset opacity)
+112
backend/electron/datastore.ts
··· 371 371 migrateStandaloneDateEntities(); 372 372 migrateNoisyDateEntities(); 373 373 migrateUnformattedPhoneEntities(); 374 + migrateLowQualityEntities(); 374 375 dropLegacyAddressTables(); 375 376 376 377 // Validate schema against canonical definition ··· 1719 1720 db.prepare('INSERT OR REPLACE INTO migrations (id, status, completedAt) VALUES (?, ?, ?)').run(MIGRATION_ID, 'complete', Date.now()); 1720 1721 if (removedCount > 0) { 1721 1722 console.log(`[datastore] Removed ${removedCount} unformatted phone entities (bare digit sequences)`); 1723 + } 1724 + } 1725 + 1726 + /** 1727 + * Remove low-quality entities that were stored before quality filters were added. 1728 + * Applies retroactively: blocklisted names, low-confidence addresses, automated emails, 1729 + * sparse JSON-LD entities (name-only persons/orgs), and events without dates. 1730 + */ 1731 + function migrateLowQualityEntities(): void { 1732 + if (!db) return; 1733 + 1734 + const MIGRATION_ID = 'remove_low_quality_entities_v1'; 1735 + 1736 + const migrationRecord = db.prepare('SELECT * FROM migrations WHERE id = ?').get(MIGRATION_ID) as { status: string } | undefined; 1737 + if (migrationRecord && migrationRecord.status === 'complete') return; 1738 + 1739 + // Blocklist of generic names (must match entity-matcher.js BLOCKLIST) 1740 + const blocklist = new Set([ 1741 + 'event', 'article', 'person', 'organization', 'product', 'news', 1742 + 'home', 'about', 'contact', 'menu', 'search', 'loading', 'unknown', 1743 + 'untitled', 'none', 'n/a', 'tbd', 'lorem ipsum', 'test', 'example', 1744 + 'admin', 'user', 'guest', 'anonymous', 'null', 'undefined', 1745 + 'website', 'page', 'site', 'blog', 'shop', 'store', 'app', 1746 + 'privacy policy', 'terms of service', 'cookie policy', 'subscribe', 1747 + 'sign in', 'sign up', 'log in', 'register', 'learn more', 'read more', 1748 + 'click here', 'view more', 'see more', 'show more', 'load more', 1749 + ]); 1750 + 1751 + const blockedEmailPrefixes = [ 1752 + 'noreply', 'no-reply', 'no_reply', 'donotreply', 'do-not-reply', 'do_not_reply', 1753 + 'notification', 'notifications', 'automated', 'mailer-daemon', 'postmaster', 1754 + 'webmaster', 'hostmaster', 'abuse', 'bounce', 'auto', 'daemon', 1755 + ]; 1756 + 1757 + const allEntities = db.prepare(` 1758 + SELECT id, content, metadata FROM items 1759 + WHERE type = 'entity' AND deletedAt = 0 1760 + `).all() as { id: string; content: string; metadata: string }[]; 1761 + 1762 + let removedCount = 0; 1763 + const removeEntity = (id: string) => { 1764 + try { 1765 + db!.prepare('DELETE FROM item_events WHERE itemId = ?').run(id); 1766 + db!.prepare('DELETE FROM item_tags WHERE itemId = ?').run(id); 1767 + db!.prepare('DELETE FROM items WHERE id = ?').run(id); 1768 + removedCount++; 1769 + } catch (error) { 1770 + console.error(`Failed to remove entity ${id}:`, error); 1771 + } 1772 + }; 1773 + 1774 + for (const entity of allEntities) { 1775 + const name = (entity.content || '').trim(); 1776 + const nameLower = name.toLowerCase(); 1777 + let meta: Record<string, unknown> = {}; 1778 + try { meta = JSON.parse(entity.metadata || '{}'); } catch { /* skip */ } 1779 + const entityType = meta.entityType as string || ''; 1780 + const attrs = (meta.attributes || {}) as Record<string, unknown>; 1781 + const confidence = (meta.confidence as number) || 0; 1782 + 1783 + // 1. Blocklisted names 1784 + if (blocklist.has(nameLower)) { removeEntity(entity.id); continue; } 1785 + 1786 + // 2. Too short or all numbers 1787 + if (name.length < 2 || /^\d+$/.test(name)) { removeEntity(entity.id); continue; } 1788 + 1789 + // 3. All punctuation/symbols 1790 + if (/^[\d\s.,;:!?$%/\-@#&*()[\]{}|\\<>]+$/.test(name)) { removeEntity(entity.id); continue; } 1791 + 1792 + // 4. Automated emails 1793 + if (entityType === 'email') { 1794 + const localPart = nameLower.split('@')[0] || ''; 1795 + if (blockedEmailPrefixes.some(p => localPart === p || localPart.startsWith(p + '+'))) { 1796 + removeEntity(entity.id); 1797 + continue; 1798 + } 1799 + } 1800 + 1801 + // 5. Low-confidence addresses (regex-extracted places below new threshold) 1802 + if (entityType === 'place' && confidence < 0.7 && (meta.extractor === 'regex')) { 1803 + removeEntity(entity.id); 1804 + continue; 1805 + } 1806 + 1807 + // 6. JSON-LD persons/orgs with only a name (no other meaningful attributes) 1808 + if ((meta.extractor === 'json-ld') && (entityType === 'person' || entityType === 'organization')) { 1809 + const meaningfulFields = Object.values(attrs).filter(v => 1810 + v && (typeof v === 'string' ? (v as string).trim().length > 0 : true) 1811 + ).length; 1812 + if (meaningfulFields < 1) { 1813 + removeEntity(entity.id); 1814 + continue; 1815 + } 1816 + } 1817 + 1818 + // 7. Events without start date (from JSON-LD) 1819 + if (entityType === 'event' && (meta.extractor === 'json-ld') && !attrs.startDate) { 1820 + removeEntity(entity.id); 1821 + continue; 1822 + } 1823 + 1824 + // 8. Products without price or description (from JSON-LD) 1825 + if (entityType === 'product' && (meta.extractor === 'json-ld') && !attrs.price && !attrs.description) { 1826 + removeEntity(entity.id); 1827 + continue; 1828 + } 1829 + } 1830 + 1831 + db.prepare('INSERT OR REPLACE INTO migrations (id, status, completedAt) VALUES (?, ?, ?)').run(MIGRATION_ID, 'complete', Date.now()); 1832 + if (removedCount > 0) { 1833 + console.log(`[datastore] Removed ${removedCount} low-quality entities (blocklist, automated emails, sparse structured data)`); 1722 1834 } 1723 1835 } 1724 1836
+102 -37
extensions/helpdocs/overlay.js
··· 15 15 // --- Configuration --- 16 16 const CONFIG = { 17 17 // Spawn timing 18 - spawnIntervalMin: 3000, // 3s minimum between spawns 19 - spawnIntervalMax: 8000, // 8s maximum between spawns 18 + spawnIntervalMin: 800, // faster spawning 19 + spawnIntervalMax: 2500, 20 20 21 21 // Strand properties 22 - minSegments: 6, 23 - maxSegments: 14, 24 - minSegmentLength: 12, 25 - maxSegmentLength: 28, 26 - minThickness: 3, 27 - maxThickness: 12, 22 + minSegments: 8, 23 + maxSegments: 18, 24 + minSegmentLength: 14, 25 + maxSegmentLength: 30, 26 + minThickness: 8, 27 + maxThickness: 22, 28 28 29 29 // Animation 30 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 - wobbleSpeed: 0.02, // radians per frame for sinusoidal wobble 34 - wobbleAmplitude: 0.15, // max radians of wobble per segment 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 35 38 36 39 // Lifecycle 37 40 creepInDuration: 3000, // ms to creep in ··· 44 47 fearRecoveryTime: 2000, // ms before strand can return after fear 45 48 46 49 // Visual 47 - maxAlpha: 0.7, 50 + maxAlpha: 1.0, 48 51 colors: [ 49 - { r: 90, g: 60, b: 120 }, // muted purple 50 - { r: 60, g: 90, b: 80 }, // dark teal 51 - { r: 70, g: 70, b: 100 }, // slate blue 52 - { r: 80, g: 60, b: 90 }, // dusty violet 53 - { r: 50, g: 80, b: 70 }, // deep sea green 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 54 58 ], 55 59 56 60 // Max simultaneous strands 57 - maxStrands: 12, 61 + maxStrands: 30, 58 62 }; 59 63 60 64 // --- State --- ··· 76 80 const dist = (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); 77 81 78 82 /** 79 - * Create a new strand emerging from a random edge 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. 80 85 */ 81 - function createStrand() { 82 - const edge = randInt(0, 3); 86 + function createStrand(forceEdge) { 87 + const edge = forceEdge !== undefined ? forceEdge : randInt(0, 3); 83 88 const numSegments = randInt(CONFIG.minSegments, CONFIG.maxSegments); 84 89 const segmentLength = rand(CONFIG.minSegmentLength, CONFIG.maxSegmentLength); 85 90 const thickness = rand(CONFIG.minThickness, CONFIG.maxThickness); ··· 114 119 break; 115 120 } 116 121 117 - // Each segment has a base angle offset and a wobble phase 122 + // Each segment has a base angle offset and independent wobble parameters 118 123 const segments = []; 119 124 for (let i = 0; i < numSegments; i++) { 120 125 segments.push({ 121 126 length: segmentLength * (1 - i * 0.03), // taper slightly 122 - angleOffset: rand(-0.15, 0.15), // slight random curvature 127 + angleOffset: rand(-0.1, 0.1), // slight random curvature 123 128 wobblePhase: rand(0, Math.PI * 2), // random initial phase 124 - wobbleFreq: rand(0.6, 1.4), // varied wobble frequency 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 125 133 }); 126 134 } 127 135 ··· 153 161 // Wobble time accumulator 154 162 wobbleTime: rand(0, 100), 155 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 + 156 168 // Alpha 157 169 alpha: 0, 158 170 }; ··· 161 173 /** 162 174 * Compute the joint positions of a strand 163 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) 164 183 */ 165 184 function computePoints(s) { 166 185 const points = [{ x: s.baseX, y: s.baseY }]; ··· 175 194 for (let i = 0; i < visibleSegments && i < s.segments.length; i++) { 176 195 const seg = s.segments[i]; 177 196 178 - // Wobble: sinusoidal oscillation that propagates down the strand 179 - const wobble = Math.sin(s.wobbleTime * seg.wobbleFreq + seg.wobblePhase + i * 0.8) 180 - * CONFIG.wobbleAmplitude * (1 + i * 0.1); 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; 181 214 182 - currentAngle += seg.angleOffset + wobble; 215 + currentAngle += seg.angleOffset + wobble1 + wobble2 + curl; 183 216 184 217 // If this is the last visible segment and partial, shorten it 185 218 let len = seg.length; ··· 213 246 const p0 = points[i]; 214 247 const p1 = points[i + 1]; 215 248 216 - // Thickness tapers from base to tip 217 - const t0 = s.thickness * (1 - (i / points.length) * 0.7); 218 - const t1 = s.thickness * (1 - ((i + 1) / points.length) * 0.7); 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); 219 252 220 253 // Direction perpendicular to segment 221 254 const dx = p1.x - p0.x; ··· 232 265 ctx.lineTo(p0.x - nx * t0 / 2, p0.y - ny * t0 / 2); 233 266 ctx.closePath(); 234 267 235 - // Gradient alpha along the strand 236 - const segAlpha = alpha * (1 - (i / points.length) * 0.5); 237 - ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${segAlpha})`; 268 + // Solid color — no per-segment alpha fade 269 + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`; 238 270 ctx.fill(); 239 271 } 240 272 ··· 318 350 // Advance wobble time 319 351 s.wobbleTime += CONFIG.wobbleSpeed * (dt / 16); 320 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 + 321 357 // Check for cursor proximity 322 358 const cursorNear = isCursorNear(s); 323 359 ··· 336 372 case 'creeping': 337 373 // Slowly extend 338 374 s.extension = clamp(s.extension + CONFIG.creepSpeed * (dt / 1000), 0, 1); 339 - s.alpha = clamp(s.alpha + 0.01 * (dt / 16), 0, CONFIG.maxAlpha); 375 + s.alpha = clamp(s.alpha + 0.03 * (dt / 16), 0, CONFIG.maxAlpha); 340 376 341 377 if (s.extension >= 1) { 342 378 s.phase = 'lingering'; ··· 404 440 } 405 441 406 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 + /** 407 461 * Spawn a new strand if under the limit 408 462 */ 409 463 function spawnStrand() { 410 464 if (strands.length < CONFIG.maxStrands) { 411 - strands.push(createStrand()); 465 + strands.push(createStrand(nextEdge())); 412 466 } 413 467 scheduleNextSpawn(); 414 468 } ··· 455 509 // Start animation loop 456 510 animationId = requestAnimationFrame(animate); 457 511 458 - // Spawn a batch immediately from all edges 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 459 524 for (let i = 0; i < 6; i++) { 460 - spawnStrand(); 525 + strands.push(createStrand(nextEdge())); 461 526 } 462 527 scheduleNextSpawn(); 463 528 }