personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-3p4rwzfj-remove-submenu-framework'

# Conflicts:
# docs/APPS.md

+1 -392
-88
convey/static/app.css
··· 1093 1093 margin-bottom: 8px; 1094 1094 } 1095 1095 1096 - /* Menu Submenus - hover pop-outs for app quick-links */ 1097 - .menu-submenu { 1098 - position: fixed; 1099 - min-width: 160px; 1100 - background: white; 1101 - border: 1px solid var(--facet-border, #e5e0db); 1102 - border-radius: 8px; 1103 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 1104 - padding: 4px 0; 1105 - opacity: 0; 1106 - pointer-events: none; 1107 - transform: translateX(-8px); 1108 - transition: opacity 0.15s ease, transform 0.15s ease; 1109 - z-index: calc(var(--z-bars) + 10); 1110 - } 1111 - 1112 - /* Show when visible class is added via JS */ 1113 - .menu-submenu.visible { 1114 - opacity: 1; 1115 - pointer-events: auto; 1116 - transform: translateX(0); 1117 - } 1118 - 1119 - .menu-submenu-item { 1120 - display: flex; 1121 - align-items: center; 1122 - padding: 8px 12px; 1123 - text-decoration: none; 1124 - color: inherit; 1125 - font-size: 13px; 1126 - transition: background 0.15s ease; 1127 - gap: 8px; 1128 - white-space: nowrap; 1129 - cursor: pointer; 1130 - } 1131 - 1132 - .menu-submenu-item:hover { 1133 - background: rgba(0, 0, 0, 0.05); 1134 - } 1135 - 1136 - .menu-submenu-item:active { 1137 - background: rgba(0, 0, 0, 0.10); 1138 - } 1139 - 1140 - .menu-submenu-item:focus-visible { 1141 - background: rgba(0, 0, 0, 0.05); 1142 - } 1143 - 1144 - .menu-submenu-item:first-child { 1145 - border-radius: 7px 7px 0 0; 1146 - } 1147 - 1148 - .menu-submenu-item:last-child { 1149 - border-radius: 0 0 7px 7px; 1150 - } 1151 - 1152 - .menu-submenu-item:only-child { 1153 - border-radius: 7px; 1154 - } 1155 - 1156 - .submenu-icon { 1157 - font-size: 14px; 1158 - width: 20px; 1159 - text-align: center; 1160 - flex-shrink: 0; 1161 - } 1162 - 1163 - .submenu-label { 1164 - flex: 1; 1165 - } 1166 - 1167 - .submenu-badge { 1168 - background: var(--facet-color, #b06a1a); 1169 - color: white; 1170 - font-size: 10px; 1171 - font-weight: 600; 1172 - padding: 2px 6px; 1173 - border-radius: 8px; 1174 - min-width: 18px; 1175 - text-align: center; 1176 - line-height: 1.2; 1177 - } 1178 - 1179 1096 /* Menu bar icon badges */ 1180 1097 .menu-bar .menu-item .icon { 1181 1098 position: relative; ··· 2560 2477 transform: none; 2561 2478 } 2562 2479 2563 - .menu-submenu { 2564 - transform: none; 2565 - } 2566 - 2567 2480 a.notification-card:hover { 2568 2481 transform: none; 2569 2482 } ··· 2599 2512 2600 2513 /* Focus-visible states for keyboard navigation */ 2601 2514 .menu-bar .menu-item-link:focus-visible, 2602 - .menu-submenu-item:focus-visible, 2603 2515 .facet-pill:focus-visible, 2604 2516 .facet-add-pill:focus-visible, 2605 2517 #hamburger:focus-visible,
-294
convey/static/app.js
··· 854 854 // Hamburger menu interactions 855 855 if (hamburger && menuBar) { 856 856 function openMobileMenu() { 857 - AppServices.submenus._closeAll(); 858 857 document.body.classList.add('menu-full'); 859 858 hamburger.setAttribute('aria-expanded', 'true'); 860 859 ··· 929 928 // If menu-all is active, remove it before toggling to menu-full 930 929 if (document.body.classList.contains('menu-all')) { 931 930 document.body.classList.remove('menu-all'); 932 - AppServices.submenus._closeAll(); 933 931 const menuExpander = document.querySelector('.menu-expander'); 934 932 if (menuExpander) { 935 933 menuExpander.textContent = '›'; ··· 1095 1093 if (movingItem) return; 1096 1094 const link = e.target.closest('.menu-item-link'); 1097 1095 if (!link) return; 1098 - 1099 - // Open submenu on Enter or ArrowRight if one exists 1100 - if (e.key === 'Enter' || e.key === 'ArrowRight') { 1101 - if (link.getAttribute('aria-haspopup') === 'true' && !document.body.classList.contains('menu-full')) { 1102 - e.preventDefault(); 1103 - const appName = link.closest('.menu-item').dataset.appName; 1104 - AppServices.submenus._open(appName); 1105 - AppServices.submenus._keyboardOpen = true; 1106 - const firstItem = document.querySelector(`#menu-submenu-${appName} [role="menuitem"]`); 1107 - if (firstItem) firstItem.focus(); 1108 - return; 1109 - } 1110 - } 1111 1096 1112 1097 let nextIndex; 1113 1098 const links = getVisibleMenuLinks(); ··· 2044 2029 const div = document.createElement('div'); 2045 2030 div.textContent = text; 2046 2031 return div.innerHTML; 2047 - }, 2048 - 2049 - /** 2050 - * Submenu system for app quick-links 2051 - * Allows apps to define contextual links that appear on hover over menu icons 2052 - */ 2053 - submenus: { 2054 - _data: {}, // {appName: [items]} 2055 - _openSubmenu: null, // {appName, submenu, menuItem, menuItemLink} | null 2056 - _keyboardOpen: false, 2057 - 2058 - /** 2059 - * Set entire submenu for an app (replaces existing) 2060 - * @param {string} appName - Name of the app 2061 - * @param {Array} items - Array of submenu items 2062 - */ 2063 - set(appName, items) { 2064 - this._data[appName] = items.map((item, index) => ({ 2065 - ...item, 2066 - order: item.order !== undefined ? item.order : index 2067 - })); 2068 - this._render(appName); 2069 - }, 2070 - 2071 - /** 2072 - * Add or update a single submenu item 2073 - * @param {string} appName - Name of the app 2074 - * @param {object} item - Item to add/update (must have id) 2075 - */ 2076 - upsert(appName, item) { 2077 - if (!this._data[appName]) { 2078 - this._data[appName] = []; 2079 - } 2080 - 2081 - const existing = this._data[appName].find(i => i.id === item.id); 2082 - if (existing) { 2083 - Object.assign(existing, item); 2084 - } else { 2085 - this._data[appName].push({ 2086 - ...item, 2087 - order: item.order !== undefined ? item.order : this._data[appName].length 2088 - }); 2089 - } 2090 - this._render(appName); 2091 - }, 2092 - 2093 - /** 2094 - * Remove a submenu item by id 2095 - * @param {string} appName - Name of the app 2096 - * @param {string} itemId - ID of item to remove 2097 - */ 2098 - remove(appName, itemId) { 2099 - if (!this._data[appName]) return; 2100 - this._data[appName] = this._data[appName].filter(i => i.id !== itemId); 2101 - this._render(appName); 2102 - }, 2103 - 2104 - /** 2105 - * Clear all submenu items for an app 2106 - * @param {string} appName - Name of the app 2107 - */ 2108 - clear(appName) { 2109 - delete this._data[appName]; 2110 - this._render(appName); 2111 - }, 2112 - 2113 - /** 2114 - * Get submenu items for an app 2115 - * @param {string} appName - Name of the app 2116 - * @returns {Array} Array of submenu items 2117 - */ 2118 - get(appName) { 2119 - return this._data[appName] || []; 2120 - }, 2121 - 2122 - _closeAll() { 2123 - if (this._openSubmenu) { 2124 - this._openSubmenu.submenu.classList.remove('visible'); 2125 - this._openSubmenu.menuItemLink.setAttribute('aria-expanded', 'false'); 2126 - this._openSubmenu = null; 2127 - } 2128 - this._keyboardOpen = false; 2129 - }, 2130 - 2131 - _open(appName) { 2132 - this._closeAll(); 2133 - const menuItem = document.querySelector(`.menu-item[data-app-name="${appName}"]`); 2134 - if (!menuItem) return; 2135 - const submenu = document.getElementById(`menu-submenu-${appName}`); 2136 - if (!submenu) return; 2137 - const menuItemLink = menuItem.querySelector('.menu-item-link'); 2138 - // Position 2139 - const rect = menuItem.getBoundingClientRect(); 2140 - submenu.style.position = 'fixed'; 2141 - submenu.style.top = rect.top + 'px'; 2142 - submenu.style.left = rect.right + 'px'; 2143 - // Show 2144 - submenu.classList.add('visible'); 2145 - if (menuItemLink) menuItemLink.setAttribute('aria-expanded', 'true'); 2146 - this._openSubmenu = { appName, submenu, menuItem, menuItemLink }; 2147 - }, 2148 - 2149 - _updateAriaOnLink(appName) { 2150 - const menuItem = document.querySelector(`.menu-item[data-app-name="${appName}"]`); 2151 - if (!menuItem) return; 2152 - const link = menuItem.querySelector('.menu-item-link'); 2153 - if (!link) return; 2154 - const hasSubmenu = document.getElementById(`menu-submenu-${appName}`); 2155 - if (hasSubmenu) { 2156 - link.setAttribute('aria-haspopup', 'true'); 2157 - if (!link.hasAttribute('aria-expanded')) { 2158 - link.setAttribute('aria-expanded', 'false'); 2159 - } 2160 - } else { 2161 - link.removeAttribute('aria-haspopup'); 2162 - link.removeAttribute('aria-expanded'); 2163 - } 2164 - }, 2165 - 2166 - /** 2167 - * Render submenu for an app 2168 - * @private 2169 - */ 2170 - _render(appName) { 2171 - // Defer render if DOM not ready 2172 - if (document.readyState === 'loading') { 2173 - const self = this; 2174 - document.addEventListener('DOMContentLoaded', function() { 2175 - self._render(appName); 2176 - }); 2177 - return; 2178 - } 2179 - 2180 - const menuItem = document.querySelector(`.menu-item[data-app-name="${appName}"]`); 2181 - if (!menuItem) return; 2182 - 2183 - // Remove existing submenu (could be in body or menu item) 2184 - const existingId = `menu-submenu-${appName}`; 2185 - const existing = document.getElementById(existingId); 2186 - if (existing) { 2187 - existing.remove(); 2188 - } 2189 - 2190 - // Clear stale open state if re-rendering the currently open submenu 2191 - if (this._openSubmenu && this._openSubmenu.appName === appName) { 2192 - const menuItemLink = menuItem.querySelector('.menu-item-link'); 2193 - if (menuItemLink) menuItemLink.setAttribute('aria-expanded', 'false'); 2194 - this._openSubmenu = null; 2195 - this._keyboardOpen = false; 2196 - } 2197 - 2198 - // Get items for this app 2199 - const items = this._data[appName]; 2200 - if (!items || items.length === 0) { 2201 - this._updateAriaOnLink(appName); 2202 - return; 2203 - } 2204 - 2205 - // Sort by order 2206 - const sorted = [...items].sort((a, b) => (a.order || 0) - (b.order || 0)); 2207 - 2208 - // Create submenu container - append to body to escape overflow:hidden 2209 - const submenu = document.createElement('div'); 2210 - submenu.className = 'menu-submenu'; 2211 - submenu.id = existingId; 2212 - submenu.dataset.appName = appName; 2213 - submenu.setAttribute('role', 'menu'); 2214 - const appLabel = menuItem.querySelector('.label'); 2215 - if (appLabel) { 2216 - submenu.setAttribute('aria-label', appLabel.textContent.trim() + ' submenu'); 2217 - } 2218 - 2219 - // Create items 2220 - sorted.forEach(item => { 2221 - const link = document.createElement('a'); 2222 - link.className = 'menu-submenu-item'; 2223 - link.setAttribute('role', 'menuitem'); 2224 - link.tabIndex = -1; 2225 - link.href = item.href || '#'; 2226 - 2227 - if (item.facet) { 2228 - link.dataset.facet = item.facet; 2229 - } 2230 - 2231 - // Build inner HTML 2232 - let html = ''; 2233 - if (item.icon) { 2234 - html += `<span class="submenu-icon">${item.icon}</span>`; 2235 - } 2236 - html += `<span class="submenu-label">${window.AppServices._escapeHtml(item.label)}</span>`; 2237 - if (item.badge) { 2238 - html += `<span class="submenu-badge">${item.badge}</span>`; 2239 - } 2240 - 2241 - link.innerHTML = html; 2242 - 2243 - // Click handler for facet selection 2244 - if (item.facet) { 2245 - link.addEventListener('click', (e) => { 2246 - if (window.selectFacet) { 2247 - window.selectFacet(item.facet); 2248 - } 2249 - }); 2250 - } 2251 - 2252 - submenu.appendChild(link); 2253 - }); 2254 - 2255 - // Append to body instead of menu item 2256 - document.body.appendChild(submenu); 2257 - 2258 - // Show/hide on hover 2259 - menuItem.addEventListener('mouseenter', () => { 2260 - if (document.body.classList.contains('menu-full')) { 2261 - return; 2262 - } 2263 - AppServices.submenus._open(appName); 2264 - }); 2265 - 2266 - menuItem.addEventListener('mouseleave', (e) => { 2267 - if (AppServices.submenus._keyboardOpen) return; 2268 - const related = e.relatedTarget; 2269 - // Look up current submenu DOM (not stale closure ref) to handle re-renders 2270 - const currentSubmenu = document.getElementById(`menu-submenu-${appName}`); 2271 - if (related && currentSubmenu && currentSubmenu.contains(related)) { 2272 - return; 2273 - } 2274 - AppServices.submenus._closeAll(); 2275 - }); 2276 - 2277 - submenu.addEventListener('mouseleave', (e) => { 2278 - if (AppServices.submenus._keyboardOpen) return; 2279 - const related = e.relatedTarget; 2280 - if (related && menuItem.contains(related)) { 2281 - return; 2282 - } 2283 - AppServices.submenus._closeAll(); 2284 - }); 2285 - 2286 - submenu.addEventListener('mouseenter', () => { 2287 - submenu.classList.add('visible'); 2288 - }); 2289 - 2290 - // Keyboard navigation within submenu 2291 - submenu.addEventListener('keydown', (e) => { 2292 - const items = Array.from(submenu.querySelectorAll('[role="menuitem"]')); 2293 - const currentIndex = items.indexOf(document.activeElement); 2294 - 2295 - if (e.key === 'ArrowDown') { 2296 - e.preventDefault(); 2297 - const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0; 2298 - items[next].focus(); 2299 - } else if (e.key === 'ArrowUp') { 2300 - e.preventDefault(); 2301 - const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1; 2302 - items[prev].focus(); 2303 - } else if (e.key === 'Escape' || e.key === 'ArrowLeft') { 2304 - e.preventDefault(); 2305 - const parentLink = menuItem.querySelector('.menu-item-link'); 2306 - AppServices.submenus._closeAll(); 2307 - if (parentLink) parentLink.focus(); 2308 - } else if (e.key === 'Tab') { 2309 - AppServices.submenus._closeAll(); 2310 - // Don't preventDefault — let natural tab order proceed 2311 - } 2312 - }); 2313 - 2314 - // Close on focus loss 2315 - submenu.addEventListener('focusout', () => { 2316 - requestAnimationFrame(() => { 2317 - const active = document.activeElement; 2318 - if (!submenu.contains(active) && active !== menuItem.querySelector('.menu-item-link')) { 2319 - AppServices.submenus._closeAll(); 2320 - } 2321 - }); 2322 - }); 2323 - 2324 - this._updateAriaOnLink(appName); 2325 - } 2326 2032 }, 2327 2033 2328 2034 /**
+1 -10
docs/APPS.md
··· 212 212 } 213 213 ``` 214 214 215 - **Submenu Methods:** 216 - - `AppServices.submenus.set(appName, items)` - Set all submenu items 217 - - `AppServices.submenus.upsert(appName, item)` - Add or update single item 218 - - `AppServices.submenus.remove(appName, itemId)` - Remove item by id 219 - - `AppServices.submenus.clear(appName)` - Clear all items 220 - 221 - Submenus appear as hover pop-outs on menu bar icons. Items support `id`, `label`, `icon`, `href`, `facet`, `badge`, and `order` properties. 222 - 223 - **See implementation:** `convey/static/app.js` - Submenu rendering and positioning 224 - 225 215 **WebSocket Events (`window.appEvents`):** 226 216 - `listen(tract, callback)` - Listen to specific tract ('cortex', 'indexer', 'observe', etc.) 227 217 - `listen('*', callback)` - Listen to all events ··· 230 220 231 221 **Reference implementations:** 232 222 - `apps/todos/background.html` - App icon badge with API fetch 223 + 233 224 **Implementation source:** `convey/static/app.js` - AppServices framework, `convey/static/websocket.js` - WebSocket API 234 225 235 226 ---