experiments in a post-browser web
10
fork

Configure Feed

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

feat(page): add page info and entities panels to navbar overlay

Two new floating panels appear alongside the navbar when activated:
- Page Info panel (top-left): shows title, domain, description, keywords,
OG tags, author, generator, and OG image preview
- Entities panel (top-right): shows detected entities grouped by type
(people, orgs, places, emails, phones, links) with color-coded badges

Both panels share the navbar show/hide lifecycle (hover trigger, Cmd+L,
scheduleHide). Hovering either panel keeps the navbar visible. Panels
use matching visual style (dark translucent background, blur, rounded
corners) with slide-in animations from their respective sides.

Entity data comes from the entities extension via pubsub, with a
fallback DOM extraction for external links, mailto:, and tel: links.

+635 -2
+245
app/page/index.html
··· 333 333 background: var(--theme-bg-tertiary, rgba(255, 255, 255, 0.1)); 334 334 color: #6dca6d; 335 335 } 336 + 337 + /* --- Page Info Panel (top-left) --- */ 338 + 339 + .page-info-panel { 340 + position: absolute; 341 + z-index: 140; 342 + pointer-events: auto; 343 + background: rgba(30, 30, 30, 0.85); 344 + backdrop-filter: blur(20px); 345 + -webkit-backdrop-filter: blur(20px); 346 + color: var(--theme-text, #e0e0e0); 347 + font-family: var(--theme-font-sans, system-ui, -apple-system, BlinkMacSystemFont, sans-serif); 348 + font-size: 12px; 349 + border-radius: 10px; 350 + max-width: 280px; 351 + min-width: 200px; 352 + overflow: hidden; 353 + opacity: 0; 354 + transform: translateX(-12px); 355 + transition: opacity 0.2s ease, transform 0.2s ease; 356 + display: none; 357 + } 358 + 359 + .page-info-panel.visible { 360 + display: block; 361 + opacity: 1; 362 + transform: translateX(0); 363 + } 364 + 365 + .page-info-panel .panel-header { 366 + display: flex; 367 + align-items: center; 368 + gap: 8px; 369 + padding: 8px 10px 4px 10px; 370 + user-select: none; 371 + -webkit-user-select: none; 372 + } 373 + 374 + .page-info-panel .panel-title { 375 + flex: 1; 376 + font-size: 10px; 377 + font-weight: 600; 378 + text-transform: uppercase; 379 + letter-spacing: 0.05em; 380 + color: var(--theme-text-secondary, #aaa); 381 + } 382 + 383 + .page-info-panel .panel-body { 384 + padding: 4px 10px 10px 10px; 385 + } 386 + 387 + .page-info-panel .info-row { 388 + display: flex; 389 + flex-direction: column; 390 + gap: 2px; 391 + padding: 4px 0; 392 + border-bottom: 1px solid rgba(255, 255, 255, 0.06); 393 + } 394 + 395 + .page-info-panel .info-row:last-child { 396 + border-bottom: none; 397 + } 398 + 399 + .page-info-panel .info-label { 400 + font-size: 10px; 401 + font-weight: 500; 402 + text-transform: uppercase; 403 + letter-spacing: 0.04em; 404 + color: var(--theme-text-muted, #777); 405 + } 406 + 407 + .page-info-panel .info-value { 408 + font-size: 12px; 409 + color: var(--theme-text, #e0e0e0); 410 + word-break: break-word; 411 + line-height: 1.4; 412 + } 413 + 414 + .page-info-panel .info-value.truncated { 415 + display: -webkit-box; 416 + -webkit-line-clamp: 3; 417 + -webkit-box-orient: vertical; 418 + overflow: hidden; 419 + } 420 + 421 + .page-info-panel .og-image-preview { 422 + width: 100%; 423 + max-height: 100px; 424 + object-fit: cover; 425 + border-radius: 6px; 426 + margin-top: 4px; 427 + } 428 + 429 + .page-info-panel .info-empty { 430 + color: var(--theme-text-muted, #555); 431 + font-style: italic; 432 + font-size: 11px; 433 + padding: 8px 0; 434 + } 435 + 436 + /* --- Entities Panel (right side) --- */ 437 + 438 + .entities-panel { 439 + position: absolute; 440 + z-index: 140; 441 + pointer-events: auto; 442 + background: rgba(30, 30, 30, 0.85); 443 + backdrop-filter: blur(20px); 444 + -webkit-backdrop-filter: blur(20px); 445 + color: var(--theme-text, #e0e0e0); 446 + font-family: var(--theme-font-sans, system-ui, -apple-system, BlinkMacSystemFont, sans-serif); 447 + font-size: 12px; 448 + border-radius: 10px; 449 + max-width: 280px; 450 + min-width: 200px; 451 + overflow: hidden; 452 + opacity: 0; 453 + transform: translateX(12px); 454 + transition: opacity 0.2s ease, transform 0.2s ease; 455 + display: none; 456 + } 457 + 458 + .entities-panel.visible { 459 + display: block; 460 + opacity: 1; 461 + transform: translateX(0); 462 + } 463 + 464 + .entities-panel .panel-header { 465 + display: flex; 466 + align-items: center; 467 + gap: 8px; 468 + padding: 8px 10px 4px 10px; 469 + user-select: none; 470 + -webkit-user-select: none; 471 + } 472 + 473 + .entities-panel .panel-title { 474 + flex: 1; 475 + font-size: 10px; 476 + font-weight: 600; 477 + text-transform: uppercase; 478 + letter-spacing: 0.05em; 479 + color: var(--theme-text-secondary, #aaa); 480 + } 481 + 482 + .entities-panel .panel-body { 483 + padding: 4px 10px 10px 10px; 484 + max-height: 400px; 485 + overflow-y: auto; 486 + } 487 + 488 + .entities-panel .panel-body::-webkit-scrollbar { 489 + width: 4px; 490 + } 491 + 492 + .entities-panel .panel-body::-webkit-scrollbar-track { 493 + background: transparent; 494 + } 495 + 496 + .entities-panel .panel-body::-webkit-scrollbar-thumb { 497 + background: rgba(255, 255, 255, 0.15); 498 + border-radius: 2px; 499 + } 500 + 501 + .entities-panel .entity-group { 502 + margin-bottom: 8px; 503 + } 504 + 505 + .entities-panel .entity-group:last-child { 506 + margin-bottom: 0; 507 + } 508 + 509 + .entities-panel .entity-group-label { 510 + font-size: 10px; 511 + font-weight: 600; 512 + text-transform: uppercase; 513 + letter-spacing: 0.04em; 514 + color: var(--theme-text-muted, #777); 515 + margin-bottom: 4px; 516 + } 517 + 518 + .entities-panel .entity-item { 519 + padding: 3px 0; 520 + font-size: 12px; 521 + color: var(--theme-text, #e0e0e0); 522 + line-height: 1.3; 523 + overflow: hidden; 524 + text-overflow: ellipsis; 525 + white-space: nowrap; 526 + } 527 + 528 + .entities-panel .entity-badge { 529 + display: inline-block; 530 + width: 6px; 531 + height: 6px; 532 + border-radius: 50%; 533 + margin-right: 6px; 534 + vertical-align: middle; 535 + } 536 + 537 + .entities-panel .entity-badge.person { background: #6dca6d; } 538 + .entities-panel .entity-badge.organization { background: #5b9bd5; } 539 + .entities-panel .entity-badge.place { background: #e6a23c; } 540 + .entities-panel .entity-badge.email { background: #c084fc; } 541 + .entities-panel .entity-badge.phone { background: #f472b6; } 542 + .entities-panel .entity-badge.event { background: #fb923c; } 543 + .entities-panel .entity-badge.product { background: #67e8f9; } 544 + .entities-panel .entity-badge.date { background: #fbbf24; } 545 + .entities-panel .entity-badge.tracking_number { background: #a78bfa; } 546 + .entities-panel .entity-badge.link { background: #38bdf8; } 547 + .entities-panel .entity-badge.other { background: #94a3b8; } 548 + 549 + .entities-panel .entity-empty { 550 + color: var(--theme-text-muted, #555); 551 + font-style: italic; 552 + font-size: 11px; 553 + padding: 8px 0; 554 + } 555 + 556 + .entities-panel .entity-loading { 557 + color: var(--theme-text-muted, #666); 558 + font-size: 11px; 559 + padding: 8px 0; 560 + } 336 561 </style> 337 562 </head> 338 563 <body data-no-drag> ··· 354 579 <div class="resize-handle" id="resize-nw" data-dir="nw"></div> 355 580 <div class="resize-handle" id="resize-nav-ne" data-dir="ne"></div> 356 581 <div class="resize-handle" id="resize-nav-nw" data-dir="nw"></div> 582 + <!-- Page Info Panel (top-left, shown with navbar) --> 583 + <div class="page-info-panel" id="page-info-panel"> 584 + <div class="panel-header"> 585 + <span class="panel-title">Page Info</span> 586 + </div> 587 + <div class="panel-body" id="page-info-body"> 588 + <div class="info-empty">Loading page info...</div> 589 + </div> 590 + </div> 591 + 592 + <!-- Entities Panel (right side, shown with navbar) --> 593 + <div class="entities-panel" id="entities-panel"> 594 + <div class="panel-header"> 595 + <span class="panel-title">Entities</span> 596 + </div> 597 + <div class="panel-body" id="entities-body"> 598 + <div class="entity-empty">No entities detected yet</div> 599 + </div> 600 + </div> 601 + 357 602 <div class="widget-container" id="widget-container"></div> 358 603 <script type="module" src="page.js"></script> 359 604 </body>
+390 -2
app/page/page.js
··· 126 126 }; 127 127 const modeIndicator = document.getElementById('mode-indicator'); 128 128 const widgetContainer = document.getElementById('widget-container'); 129 + const pageInfoPanel = document.getElementById('page-info-panel'); 130 + const pageInfoBody = document.getElementById('page-info-body'); 131 + const entitiesPanel = document.getElementById('entities-panel'); 132 + const entitiesBody = document.getElementById('entities-body'); 129 133 130 134 console.log('[page] DOM elements initialized'); 131 135 ··· 267 271 const widgetGap = 12; 268 272 widgetContainer.style.left = `${webviewLeft + webviewWidth + widgetGap}px`; 269 273 widgetContainer.style.top = `${webviewTop}px`; 274 + } 275 + 276 + // Page Info Panel — top-left, below navbar when visible 277 + if (pageInfoPanel) { 278 + pageInfoPanel.style.left = `${webviewLeft}px`; 279 + pageInfoPanel.style.top = `${webviewTop + 8}px`; 280 + } 281 + 282 + // Entities Panel — right side, below navbar when visible 283 + if (entitiesPanel) { 284 + entitiesPanel.style.left = `${webviewLeft + webviewWidth - 280}px`; 285 + entitiesPanel.style.top = `${webviewTop + 8}px`; 270 286 } 271 287 } 272 288 ··· 892 908 if (opts?.focusUrl) { 893 909 navbar.focusUrl(); 894 910 } 911 + 912 + // Show page info and entities panels alongside navbar 913 + if (pageInfoPanel) pageInfoPanel.classList.add('visible'); 914 + if (entitiesPanel) entitiesPanel.classList.add('visible'); 895 915 } 896 916 897 917 function hide() { ··· 903 923 hideTimer = null; 904 924 } 905 925 navbar.classList.remove('visible'); 926 + // Hide page info and entities panels alongside navbar 927 + if (pageInfoPanel) pageInfoPanel.classList.remove('visible'); 928 + if (entitiesPanel) entitiesPanel.classList.remove('visible'); 906 929 // Window stays the same size (always includes navbar space). 907 930 // Just update trigger zone and resize handle visibility. 908 931 updatePositions(); ··· 911 934 DEBUG && console.log('[page] Navbar hidden'); 912 935 } 913 936 914 - // Dismiss on click outside the navbar 937 + // Dismiss on click outside the navbar (also check panels) 915 938 document.addEventListener('mousedown', (e) => { 916 - if (navbar.classList.contains('visible') && !navbar.contains(e.target) && e.target !== triggerZone && !e.target.closest('.resize-handle')) { 939 + if (navbar.classList.contains('visible') && !navbar.contains(e.target) && e.target !== triggerZone && !e.target.closest('.resize-handle') 940 + && !(pageInfoPanel && pageInfoPanel.contains(e.target)) 941 + && !(entitiesPanel && entitiesPanel.contains(e.target))) { 917 942 hide(); 918 943 } 919 944 }); ··· 977 1002 navbar.addEventListener('mouseleave', () => { 978 1003 if (showSource === 'hover' || showSource === 'shortcut') scheduleHide(); 979 1004 }); 1005 + 1006 + // Keep navbar visible when hovering page info or entities panels 1007 + if (pageInfoPanel) { 1008 + pageInfoPanel.addEventListener('mouseenter', () => { cancelHide(); }); 1009 + pageInfoPanel.addEventListener('mouseleave', () => { 1010 + if (showSource === 'hover' || showSource === 'shortcut') scheduleHide(); 1011 + }); 1012 + } 1013 + if (entitiesPanel) { 1014 + entitiesPanel.addEventListener('mouseenter', () => { cancelHide(); }); 1015 + entitiesPanel.addEventListener('mouseleave', () => { 1016 + if (showSource === 'hover' || showSource === 'shortcut') scheduleHide(); 1017 + }); 1018 + } 980 1019 981 1020 // Cmd+L: show navbar with URL focus 982 1021 api.subscribe('page:show-navbar', (msg) => { ··· 1541 1580 }); 1542 1581 DEBUG && console.log('[page] Opener shim will be injected (openerUrl:', openerUrl, ')'); 1543 1582 } 1583 + 1584 + // --- Page Info Panel: extract metadata from webview --- 1585 + 1586 + let lastPageInfoUrl = null; 1587 + 1588 + async function extractPageInfo() { 1589 + let currentUrl; 1590 + try { 1591 + currentUrl = webview.getURL(); 1592 + } catch { 1593 + return; 1594 + } 1595 + if (!currentUrl || currentUrl === lastPageInfoUrl) return; 1596 + if (!currentUrl.startsWith('http://') && !currentUrl.startsWith('https://')) return; 1597 + 1598 + lastPageInfoUrl = currentUrl; 1599 + 1600 + try { 1601 + const info = await webview.executeJavaScript(` 1602 + (function() { 1603 + function getMeta(name) { 1604 + var el = document.querySelector('meta[property="' + name + '"], meta[name="' + name + '"]'); 1605 + return el ? (el.getAttribute('content') || '').trim() : ''; 1606 + } 1607 + 1608 + return { 1609 + title: document.title || '', 1610 + hostname: window.location.hostname || '', 1611 + url: window.location.href || '', 1612 + description: getMeta('description') || getMeta('og:description') || '', 1613 + keywords: getMeta('keywords') || '', 1614 + ogTitle: getMeta('og:title') || '', 1615 + ogDescription: getMeta('og:description') || '', 1616 + ogImage: getMeta('og:image') || '', 1617 + ogSiteName: getMeta('og:site_name') || '', 1618 + ogType: getMeta('og:type') || '', 1619 + author: getMeta('author') || '', 1620 + generator: getMeta('generator') || '', 1621 + canonical: (function() { 1622 + var link = document.querySelector('link[rel="canonical"]'); 1623 + return link ? link.href : ''; 1624 + })(), 1625 + favicon: (function() { 1626 + var link = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]'); 1627 + return link ? link.href : ''; 1628 + })() 1629 + }; 1630 + })(); 1631 + `); 1632 + 1633 + renderPageInfo(info); 1634 + } catch (err) { 1635 + console.warn('[page] Failed to extract page info:', err.message); 1636 + if (pageInfoBody) { 1637 + pageInfoBody.innerHTML = '<div class="info-empty">Could not load page info</div>'; 1638 + } 1639 + } 1640 + } 1641 + 1642 + function renderPageInfo(info) { 1643 + if (!pageInfoBody) return; 1644 + 1645 + const rows = []; 1646 + 1647 + if (info.title) { 1648 + rows.push(makeInfoRow('Title', info.title)); 1649 + } 1650 + 1651 + if (info.hostname) { 1652 + rows.push(makeInfoRow('Domain', info.hostname)); 1653 + } 1654 + 1655 + if (info.ogSiteName && info.ogSiteName !== info.hostname) { 1656 + rows.push(makeInfoRow('Site', info.ogSiteName)); 1657 + } 1658 + 1659 + const desc = info.ogDescription || info.description; 1660 + if (desc) { 1661 + rows.push(makeInfoRow('Description', desc, true)); 1662 + } 1663 + 1664 + if (info.keywords) { 1665 + rows.push(makeInfoRow('Keywords', info.keywords, true)); 1666 + } 1667 + 1668 + if (info.author) { 1669 + rows.push(makeInfoRow('Author', info.author)); 1670 + } 1671 + 1672 + if (info.ogType) { 1673 + rows.push(makeInfoRow('Type', info.ogType)); 1674 + } 1675 + 1676 + if (info.ogImage) { 1677 + const imgRow = document.createElement('div'); 1678 + imgRow.className = 'info-row'; 1679 + const label = document.createElement('div'); 1680 + label.className = 'info-label'; 1681 + label.textContent = 'OG Image'; 1682 + imgRow.appendChild(label); 1683 + const img = document.createElement('img'); 1684 + img.className = 'og-image-preview'; 1685 + img.src = info.ogImage; 1686 + img.alt = 'Open Graph image'; 1687 + img.onerror = () => { imgRow.remove(); }; 1688 + imgRow.appendChild(img); 1689 + rows.push(imgRow); 1690 + } 1691 + 1692 + if (info.generator) { 1693 + rows.push(makeInfoRow('Generator', info.generator)); 1694 + } 1695 + 1696 + if (rows.length === 0) { 1697 + pageInfoBody.innerHTML = '<div class="info-empty">No metadata available</div>'; 1698 + return; 1699 + } 1700 + 1701 + pageInfoBody.innerHTML = ''; 1702 + for (const row of rows) { 1703 + pageInfoBody.appendChild(row); 1704 + } 1705 + } 1706 + 1707 + function makeInfoRow(labelText, valueText, truncated = false) { 1708 + const row = document.createElement('div'); 1709 + row.className = 'info-row'; 1710 + 1711 + const label = document.createElement('div'); 1712 + label.className = 'info-label'; 1713 + label.textContent = labelText; 1714 + row.appendChild(label); 1715 + 1716 + const value = document.createElement('div'); 1717 + value.className = 'info-value' + (truncated ? ' truncated' : ''); 1718 + value.textContent = valueText; 1719 + row.appendChild(value); 1720 + 1721 + return row; 1722 + } 1723 + 1724 + // Extract page info after page finishes loading 1725 + webview.addEventListener('did-finish-load', () => { 1726 + // Small delay to ensure meta tags are populated (some SPAs add them after load) 1727 + setTimeout(extractPageInfo, 500); 1728 + }); 1729 + 1730 + // Re-extract on in-page navigation 1731 + webview.addEventListener('did-navigate', () => { 1732 + lastPageInfoUrl = null; // Reset to allow re-extraction 1733 + setTimeout(extractPageInfo, 500); 1734 + }); 1735 + 1736 + // --- Entities Panel: show detected entities --- 1737 + 1738 + let currentPageEntities = []; 1739 + 1740 + function renderEntities(entities) { 1741 + if (!entitiesBody) return; 1742 + 1743 + if (!entities || entities.length === 0) { 1744 + entitiesBody.innerHTML = '<div class="entity-empty">No entities detected</div>'; 1745 + return; 1746 + } 1747 + 1748 + // Group by type 1749 + const groups = {}; 1750 + for (const entity of entities) { 1751 + const type = entity.type || entity.entityType || 'other'; 1752 + if (!groups[type]) groups[type] = []; 1753 + groups[type].push(entity); 1754 + } 1755 + 1756 + // Display order for entity types 1757 + const typeOrder = ['person', 'organization', 'place', 'event', 'email', 'phone', 'product', 'date', 'tracking_number', 'link', 'other']; 1758 + const typeLabels = { 1759 + person: 'People', 1760 + organization: 'Organizations', 1761 + place: 'Places', 1762 + event: 'Events', 1763 + email: 'Emails', 1764 + phone: 'Phones', 1765 + product: 'Products', 1766 + date: 'Dates', 1767 + tracking_number: 'Tracking', 1768 + link: 'External Links', 1769 + other: 'Other', 1770 + }; 1771 + 1772 + const sortedTypes = Object.keys(groups).sort((a, b) => { 1773 + const ai = typeOrder.indexOf(a); 1774 + const bi = typeOrder.indexOf(b); 1775 + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); 1776 + }); 1777 + 1778 + entitiesBody.innerHTML = ''; 1779 + 1780 + for (const type of sortedTypes) { 1781 + const groupEl = document.createElement('div'); 1782 + groupEl.className = 'entity-group'; 1783 + 1784 + const label = document.createElement('div'); 1785 + label.className = 'entity-group-label'; 1786 + label.textContent = typeLabels[type] || type; 1787 + groupEl.appendChild(label); 1788 + 1789 + for (const entity of groups[type]) { 1790 + const item = document.createElement('div'); 1791 + item.className = 'entity-item'; 1792 + 1793 + const badge = document.createElement('span'); 1794 + badge.className = `entity-badge ${type}`; 1795 + item.appendChild(badge); 1796 + 1797 + const name = document.createTextNode(entity.name || entity.content || ''); 1798 + item.appendChild(name); 1799 + 1800 + groupEl.appendChild(item); 1801 + } 1802 + 1803 + entitiesBody.appendChild(groupEl); 1804 + } 1805 + } 1806 + 1807 + // Listen for entity extraction results (from entities extension) 1808 + api.subscribe('entities:extracted', (msg) => { 1809 + if (!msg || !msg.url || !msg.entities) return; 1810 + // Check if this is for the current page 1811 + try { 1812 + const currentUrl = webview.getURL(); 1813 + if (msg.url === currentUrl) { 1814 + currentPageEntities = msg.entities; 1815 + renderEntities(currentPageEntities); 1816 + } 1817 + } catch { 1818 + // webview not ready 1819 + } 1820 + }, api.scopes.GLOBAL); 1821 + 1822 + // Also listen for get-for-url responses 1823 + api.subscribe('entities:get-for-url:response', (msg) => { 1824 + if (!msg || !msg.url || !msg.entities) return; 1825 + try { 1826 + const currentUrl = webview.getURL(); 1827 + if (msg.url === currentUrl && msg.entities.length > 0) { 1828 + // Convert stored entity format to display format 1829 + currentPageEntities = msg.entities.map(e => { 1830 + let meta = {}; 1831 + try { meta = JSON.parse(e.metadata || '{}'); } catch {} 1832 + return { 1833 + name: e.content || e.title, 1834 + type: meta.entityType || 'other', 1835 + confidence: meta.confidence || 0 1836 + }; 1837 + }); 1838 + renderEntities(currentPageEntities); 1839 + } 1840 + } catch { 1841 + // webview not ready 1842 + } 1843 + }, api.scopes.GLOBAL); 1844 + 1845 + // On navigation, clear entities and request for current URL 1846 + webview.addEventListener('did-navigate', (e) => { 1847 + currentPageEntities = []; 1848 + if (entitiesBody) { 1849 + entitiesBody.innerHTML = '<div class="entity-loading">Scanning for entities...</div>'; 1850 + } 1851 + 1852 + // Request entities for this URL from the entities extension 1853 + if (e.url && e.url.startsWith('http')) { 1854 + api.publish('entities:get-for-url', { url: e.url }, api.scopes.GLOBAL); 1855 + } 1856 + }); 1857 + 1858 + // Also do a simple in-page entity extraction as a fallback 1859 + // This catches links, emails, etc. directly from the webview DOM 1860 + async function extractSimpleEntities() { 1861 + let currentUrl; 1862 + try { 1863 + currentUrl = webview.getURL(); 1864 + } catch { 1865 + return; 1866 + } 1867 + if (!currentUrl || !currentUrl.startsWith('http')) return; 1868 + 1869 + // Only run if we have no entities yet (the extension hasn't responded) 1870 + if (currentPageEntities.length > 0) return; 1871 + 1872 + try { 1873 + const extracted = await webview.executeJavaScript(` 1874 + (function() { 1875 + var entities = []; 1876 + var seen = new Set(); 1877 + 1878 + // Extract external links 1879 + var links = document.querySelectorAll('a[href]'); 1880 + for (var i = 0; i < Math.min(links.length, 200); i++) { 1881 + var href = links[i].href; 1882 + if (!href || !href.startsWith('http')) continue; 1883 + try { 1884 + var host = new URL(href).hostname; 1885 + if (host === window.location.hostname) continue; 1886 + if (seen.has(host)) continue; 1887 + seen.add(host); 1888 + var text = links[i].textContent.trim().slice(0, 60); 1889 + if (text && text.length > 2) { 1890 + entities.push({ name: text + ' (' + host + ')', type: 'link' }); 1891 + } 1892 + } catch(e) {} 1893 + } 1894 + 1895 + // Extract mailto links 1896 + var mailLinks = document.querySelectorAll('a[href^="mailto:"]'); 1897 + for (var j = 0; j < mailLinks.length; j++) { 1898 + var email = mailLinks[j].href.replace('mailto:', '').split('?')[0]; 1899 + if (email && !seen.has(email)) { 1900 + seen.add(email); 1901 + entities.push({ name: email, type: 'email' }); 1902 + } 1903 + } 1904 + 1905 + // Extract tel links 1906 + var telLinks = document.querySelectorAll('a[href^="tel:"]'); 1907 + for (var k = 0; k < telLinks.length; k++) { 1908 + var phone = telLinks[k].href.replace('tel:', ''); 1909 + if (phone && !seen.has(phone)) { 1910 + seen.add(phone); 1911 + entities.push({ name: phone, type: 'phone' }); 1912 + } 1913 + } 1914 + 1915 + return entities.slice(0, 50); 1916 + })(); 1917 + `); 1918 + 1919 + if (extracted && extracted.length > 0 && currentPageEntities.length === 0) { 1920 + currentPageEntities = extracted; 1921 + renderEntities(extracted); 1922 + } 1923 + } catch (err) { 1924 + DEBUG && console.log('[page] Simple entity extraction failed:', err.message); 1925 + } 1926 + } 1927 + 1928 + // Run simple extraction after page loads (as fallback) 1929 + webview.addEventListener('did-finish-load', () => { 1930 + setTimeout(extractSimpleEntities, 2000); 1931 + }); 1544 1932 1545 1933 DEBUG && console.log('[page] Minimal page host initialized for:', targetUrl);