personal memory agent
0
fork

Configure Feed

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

feat: replace toast system with persistent notification cards

Replace transient toast notifications with iOS-style persistent notification
cards that stack in top-right corner. Cards remain visible until dismissed
or clicked, providing better visibility for important events.

Notification Cards:
- Persistent until user dismisses or clicks
- Stack vertically below facet-bar (max 5 visible)
- Slide-in animation from right (only on new cards)
- Click card body → navigate to action URL
- Click X button → dismiss immediately
- Auto-dismiss optional per notification
- Relative timestamps updated every minute
- Badge counts on individual cards
- Browser notification integration

Implementation:
- Moved AppServices framework from app.html to app.js
- Added notification center container to app.html
- Complete notification styles in app.css
- Incremental DOM updates prevent re-animating existing cards
- AppServices.notifications.show/dismiss/update API
- Updated home service example to use cards

Dev Tools App:
- Created apps/dev/ for testing notification system
- Quick test buttons for common scenarios
- Custom notification builder with form
- Management controls (update, dismiss, stats)
- Stress tests (spawn multiple, sequential)
- Real-time console log of notification events

Documentation:
- Updated DESIGN.md with notification card components
- Complete API reference for notification system
- Usage examples and patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+1139 -266
+21
apps/dev/__init__.py
··· 1 + """Dev app - testing and development tools.""" 2 + 3 + from __future__ import annotations 4 + 5 + from apps import BaseApp 6 + 7 + 8 + class DevApp(BaseApp): 9 + """Dev app for testing notification system and other features.""" 10 + 11 + name = "dev" 12 + icon = "🛠️" 13 + label = "Dev Tools" 14 + 15 + def get_blueprint(self): 16 + from .routes import dev_bp 17 + 18 + return dev_bp 19 + 20 + def get_workspace_template(self): 21 + return "workspace.html"
+18
apps/dev/routes.py
··· 1 + """Dev app routes.""" 2 + 3 + from __future__ import annotations 4 + 5 + from flask import Blueprint, render_template 6 + 7 + dev_bp = Blueprint( 8 + "dev_app", 9 + __name__, 10 + template_folder="templates", 11 + url_prefix="/app/dev", 12 + ) 13 + 14 + 15 + @dev_bp.route("/") 16 + def index(): 17 + """Render the dev tools view.""" 18 + return render_template("app.html", app="dev")
+464
apps/dev/templates/workspace.html
··· 1 + <style> 2 + .dev-container { 3 + max-width: 1200px; 4 + margin: 0 auto; 5 + padding: 2rem; 6 + } 7 + 8 + .dev-section { 9 + background: white; 10 + border: 1px solid #e5e7eb; 11 + border-radius: 8px; 12 + padding: 1.5rem; 13 + margin-bottom: 1.5rem; 14 + } 15 + 16 + .dev-section h2 { 17 + margin: 0 0 1rem 0; 18 + font-size: 1.25rem; 19 + font-weight: 600; 20 + color: #1f2937; 21 + } 22 + 23 + .dev-section p { 24 + margin: 0 0 1rem 0; 25 + color: #6b7280; 26 + font-size: 0.9rem; 27 + } 28 + 29 + .dev-controls { 30 + display: flex; 31 + flex-wrap: wrap; 32 + gap: 0.75rem; 33 + } 34 + 35 + .dev-button { 36 + padding: 0.5rem 1rem; 37 + background: #667eea; 38 + color: white; 39 + border: none; 40 + border-radius: 6px; 41 + cursor: pointer; 42 + font-size: 0.9rem; 43 + font-weight: 500; 44 + transition: background 0.2s, transform 0.1s; 45 + } 46 + 47 + .dev-button:hover { 48 + background: #5568d3; 49 + transform: translateY(-1px); 50 + } 51 + 52 + .dev-button:active { 53 + transform: translateY(0); 54 + } 55 + 56 + .dev-button.secondary { 57 + background: #6b7280; 58 + } 59 + 60 + .dev-button.secondary:hover { 61 + background: #4b5563; 62 + } 63 + 64 + .dev-button.danger { 65 + background: #ef4444; 66 + } 67 + 68 + .dev-button.danger:hover { 69 + background: #dc2626; 70 + } 71 + 72 + .dev-form { 73 + display: flex; 74 + flex-direction: column; 75 + gap: 1rem; 76 + max-width: 500px; 77 + } 78 + 79 + .dev-form-row { 80 + display: flex; 81 + flex-direction: column; 82 + gap: 0.25rem; 83 + } 84 + 85 + .dev-form-row label { 86 + font-size: 0.85rem; 87 + font-weight: 500; 88 + color: #374151; 89 + } 90 + 91 + .dev-form-row input, 92 + .dev-form-row textarea, 93 + .dev-form-row select { 94 + padding: 0.5rem; 95 + border: 1px solid #d1d5db; 96 + border-radius: 4px; 97 + font-size: 0.9rem; 98 + } 99 + 100 + .dev-form-row textarea { 101 + resize: vertical; 102 + min-height: 60px; 103 + } 104 + 105 + .dev-output { 106 + background: #f9fafb; 107 + border: 1px solid #e5e7eb; 108 + border-radius: 4px; 109 + padding: 1rem; 110 + font-family: monospace; 111 + font-size: 0.85rem; 112 + color: #374151; 113 + max-height: 200px; 114 + overflow-y: auto; 115 + } 116 + 117 + .dev-stats { 118 + display: grid; 119 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 120 + gap: 1rem; 121 + margin-top: 1rem; 122 + } 123 + 124 + .dev-stat { 125 + background: #f9fafb; 126 + border: 1px solid #e5e7eb; 127 + border-radius: 4px; 128 + padding: 1rem; 129 + text-align: center; 130 + } 131 + 132 + .dev-stat-value { 133 + font-size: 2rem; 134 + font-weight: 600; 135 + color: #667eea; 136 + } 137 + 138 + .dev-stat-label { 139 + font-size: 0.75rem; 140 + color: #6b7280; 141 + text-transform: uppercase; 142 + letter-spacing: 0.05em; 143 + margin-top: 0.25rem; 144 + } 145 + </style> 146 + 147 + <div class="dev-container"> 148 + <h1>🛠️ Dev Tools - Notification Testing</h1> 149 + 150 + <!-- Quick Tests --> 151 + <div class="dev-section"> 152 + <h2>Quick Tests</h2> 153 + <p>Click buttons to trigger different notification types</p> 154 + <div class="dev-controls"> 155 + <button class="dev-button" onclick="testBasic()">Basic Notification</button> 156 + <button class="dev-button" onclick="testWithBadge()">With Badge (5)</button> 157 + <button class="dev-button" onclick="testAutoDismiss()">Auto-Dismiss (5s)</button> 158 + <button class="dev-button" onclick="testNonDismissible()">Non-Dismissible</button> 159 + <button class="dev-button" onclick="testLongMessage()">Long Message</button> 160 + <button class="dev-button" onclick="testNoAction()">No Click Action</button> 161 + </div> 162 + </div> 163 + 164 + <!-- Custom Notification --> 165 + <div class="dev-section"> 166 + <h2>Custom Notification</h2> 167 + <p>Build your own notification with custom parameters</p> 168 + <div class="dev-form"> 169 + <div class="dev-form-row"> 170 + <label>App Name</label> 171 + <input type="text" id="custom-app" value="dev" /> 172 + </div> 173 + <div class="dev-form-row"> 174 + <label>Icon (emoji)</label> 175 + <input type="text" id="custom-icon" value="🛠️" maxlength="2" /> 176 + </div> 177 + <div class="dev-form-row"> 178 + <label>Title</label> 179 + <input type="text" id="custom-title" value="Custom Notification" /> 180 + </div> 181 + <div class="dev-form-row"> 182 + <label>Message</label> 183 + <textarea id="custom-message">This is a custom notification message</textarea> 184 + </div> 185 + <div class="dev-form-row"> 186 + <label>Action URL</label> 187 + <input type="text" id="custom-action" value="/app/dev" /> 188 + </div> 189 + <div class="dev-form-row"> 190 + <label>Badge Count (0 = none)</label> 191 + <input type="number" id="custom-badge" value="0" min="0" /> 192 + </div> 193 + <div class="dev-form-row"> 194 + <label>Auto-Dismiss (ms, 0 = never)</label> 195 + <input type="number" id="custom-autodismiss" value="0" min="0" step="1000" /> 196 + </div> 197 + <div class="dev-form-row"> 198 + <label> 199 + <input type="checkbox" id="custom-dismissible" checked /> Dismissible (show X button) 200 + </label> 201 + </div> 202 + <button class="dev-button" onclick="testCustom()">Show Custom Notification</button> 203 + </div> 204 + </div> 205 + 206 + <!-- Notification Management --> 207 + <div class="dev-section"> 208 + <h2>Notification Management</h2> 209 + <p>Test dismissing and managing notifications</p> 210 + <div class="dev-controls"> 211 + <button class="dev-button secondary" onclick="updateLast()">Update Last Notification</button> 212 + <button class="dev-button secondary" onclick="dismissLast()">Dismiss Last</button> 213 + <button class="dev-button danger" onclick="dismissAll()">Dismiss All</button> 214 + <button class="dev-button secondary" onclick="showStats()">Show Stats</button> 215 + </div> 216 + <div class="dev-stats" id="stats"></div> 217 + </div> 218 + 219 + <!-- Stress Test --> 220 + <div class="dev-section"> 221 + <h2>Stress Test</h2> 222 + <p>Generate multiple notifications to test stacking and performance</p> 223 + <div class="dev-controls"> 224 + <button class="dev-button" onclick="spawnThree()">Spawn 3 Notifications</button> 225 + <button class="dev-button" onclick="spawnTen()">Spawn 10 Notifications</button> 226 + <button class="dev-button" onclick="spawnSequential()">Spawn Sequential (5s intervals)</button> 227 + </div> 228 + </div> 229 + 230 + <!-- Console Log --> 231 + <div class="dev-section"> 232 + <h2>Console Log</h2> 233 + <p>Notification IDs and events</p> 234 + <div class="dev-output" id="console"></div> 235 + </div> 236 + </div> 237 + 238 + <script> 239 + let lastNotificationId = null; 240 + let sequentialTimer = null; 241 + 242 + // Helper to log to console 243 + function log(message) { 244 + const consoleEl = document.getElementById('console'); 245 + const timestamp = new Date().toLocaleTimeString(); 246 + consoleEl.innerHTML = `[${timestamp}] ${message}<br>` + consoleEl.innerHTML; 247 + } 248 + 249 + // Quick Tests 250 + function testBasic() { 251 + const id = window.AppServices.notifications.show({ 252 + app: 'dev', 253 + icon: '🛠️', 254 + title: 'Basic Notification', 255 + message: 'This is a basic notification with all default settings', 256 + action: '/app/dev' 257 + }); 258 + lastNotificationId = id; 259 + log(`Created basic notification (ID: ${id})`); 260 + } 261 + 262 + function testWithBadge() { 263 + const id = window.AppServices.notifications.show({ 264 + app: 'dev', 265 + icon: '🔔', 266 + title: 'Notification with Badge', 267 + message: 'This notification has a badge count', 268 + badge: 5, 269 + action: '/app/dev' 270 + }); 271 + lastNotificationId = id; 272 + log(`Created notification with badge (ID: ${id})`); 273 + } 274 + 275 + function testAutoDismiss() { 276 + const id = window.AppServices.notifications.show({ 277 + app: 'dev', 278 + icon: '⏱️', 279 + title: 'Auto-Dismiss Test', 280 + message: 'This notification will disappear after 5 seconds', 281 + action: '/app/dev', 282 + autoDismiss: 5000 283 + }); 284 + lastNotificationId = id; 285 + log(`Created auto-dismiss notification (ID: ${id})`); 286 + } 287 + 288 + function testNonDismissible() { 289 + const id = window.AppServices.notifications.show({ 290 + app: 'dev', 291 + icon: '🔒', 292 + title: 'Non-Dismissible', 293 + message: 'This notification has no X button', 294 + dismissible: false, 295 + action: '/app/dev' 296 + }); 297 + lastNotificationId = id; 298 + log(`Created non-dismissible notification (ID: ${id})`); 299 + } 300 + 301 + function testLongMessage() { 302 + const id = window.AppServices.notifications.show({ 303 + app: 'dev', 304 + icon: '📝', 305 + title: 'Long Message Test', 306 + message: 'This is a very long notification message that should be truncated with ellipsis after two lines to prevent the card from becoming too tall and taking up too much space in the notification center.', 307 + action: '/app/dev' 308 + }); 309 + lastNotificationId = id; 310 + log(`Created long message notification (ID: ${id})`); 311 + } 312 + 313 + function testNoAction() { 314 + const id = window.AppServices.notifications.show({ 315 + app: 'dev', 316 + icon: '🚫', 317 + title: 'No Click Action', 318 + message: 'Clicking this card does nothing (no action URL)', 319 + action: null 320 + }); 321 + lastNotificationId = id; 322 + log(`Created no-action notification (ID: ${id})`); 323 + } 324 + 325 + // Custom Notification 326 + function testCustom() { 327 + const app = document.getElementById('custom-app').value; 328 + const icon = document.getElementById('custom-icon').value; 329 + const title = document.getElementById('custom-title').value; 330 + const message = document.getElementById('custom-message').value; 331 + const action = document.getElementById('custom-action').value || null; 332 + const badge = parseInt(document.getElementById('custom-badge').value) || null; 333 + const autoDismiss = parseInt(document.getElementById('custom-autodismiss').value) || null; 334 + const dismissible = document.getElementById('custom-dismissible').checked; 335 + 336 + const id = window.AppServices.notifications.show({ 337 + app, 338 + icon, 339 + title, 340 + message, 341 + action, 342 + badge: badge > 0 ? badge : null, 343 + autoDismiss: autoDismiss > 0 ? autoDismiss : null, 344 + dismissible 345 + }); 346 + lastNotificationId = id; 347 + log(`Created custom notification (ID: ${id})`); 348 + } 349 + 350 + // Management 351 + function updateLast() { 352 + if (!lastNotificationId) { 353 + log('No notification to update'); 354 + return; 355 + } 356 + 357 + window.AppServices.notifications.update(lastNotificationId, { 358 + title: 'Updated Notification', 359 + message: 'This notification was updated!', 360 + badge: 99 361 + }); 362 + log(`Updated notification (ID: ${lastNotificationId})`); 363 + } 364 + 365 + function dismissLast() { 366 + if (!lastNotificationId) { 367 + log('No notification to dismiss'); 368 + return; 369 + } 370 + 371 + window.AppServices.notifications.dismiss(lastNotificationId); 372 + log(`Dismissed notification (ID: ${lastNotificationId})`); 373 + lastNotificationId = null; 374 + } 375 + 376 + function dismissAll() { 377 + window.AppServices.notifications.dismissAll(); 378 + log('Dismissed all notifications'); 379 + lastNotificationId = null; 380 + } 381 + 382 + function showStats() { 383 + const count = window.AppServices.notifications.count(); 384 + const statsEl = document.getElementById('stats'); 385 + statsEl.innerHTML = ` 386 + <div class="dev-stat"> 387 + <div class="dev-stat-value">${count}</div> 388 + <div class="dev-stat-label">Active Notifications</div> 389 + </div> 390 + <div class="dev-stat"> 391 + <div class="dev-stat-value">${lastNotificationId || 'N/A'}</div> 392 + <div class="dev-stat-label">Last ID</div> 393 + </div> 394 + `; 395 + log(`Stats: ${count} active notifications`); 396 + } 397 + 398 + // Stress Tests 399 + function spawnThree() { 400 + for (let i = 1; i <= 3; i++) { 401 + setTimeout(() => { 402 + const id = window.AppServices.notifications.show({ 403 + app: 'dev', 404 + icon: '🚀', 405 + title: `Notification ${i}/3`, 406 + message: `Testing notification stacking (${i} of 3)`, 407 + action: '/app/dev', 408 + badge: i 409 + }); 410 + log(`Spawned notification ${i}/3 (ID: ${id})`); 411 + }, (i - 1) * 300); 412 + } 413 + } 414 + 415 + function spawnTen() { 416 + for (let i = 1; i <= 10; i++) { 417 + setTimeout(() => { 418 + const id = window.AppServices.notifications.show({ 419 + app: 'dev', 420 + icon: '💥', 421 + title: `Batch ${i}/10`, 422 + message: `Testing max visible limit (only last 5 should show)`, 423 + action: '/app/dev', 424 + badge: i 425 + }); 426 + log(`Spawned notification ${i}/10 (ID: ${id})`); 427 + }, (i - 1) * 200); 428 + } 429 + } 430 + 431 + function spawnSequential() { 432 + if (sequentialTimer) { 433 + clearInterval(sequentialTimer); 434 + sequentialTimer = null; 435 + log('Stopped sequential spawn'); 436 + return; 437 + } 438 + 439 + let count = 1; 440 + sequentialTimer = setInterval(() => { 441 + const id = window.AppServices.notifications.show({ 442 + app: 'dev', 443 + icon: '⏰', 444 + title: `Sequential #${count}`, 445 + message: 'New notification every 5 seconds', 446 + action: '/app/dev', 447 + autoDismiss: 8000 448 + }); 449 + log(`Sequential spawn #${count} (ID: ${id})`); 450 + count++; 451 + 452 + if (count > 10) { 453 + clearInterval(sequentialTimer); 454 + sequentialTimer = null; 455 + log('Sequential spawn complete'); 456 + } 457 + }, 5000); 458 + 459 + log('Started sequential spawn (10 notifications, 5s intervals)'); 460 + } 461 + 462 + // Initial log 463 + log('Dev Tools loaded - ready to test notifications'); 464 + </script>
+25 -28
apps/home/templates/service.html
··· 18 18 // Log all events for debugging 19 19 console.log('[Home Service] Event:', msg.tract, msg); 20 20 21 - // Example: Show notification for specific events 21 + // Example: Show notification card for specific events 22 22 if (msg.tract === 'cortex' && msg.event === 'agent_complete') { 23 23 this.notifyAgentComplete(msg); 24 24 } ··· 39 39 } 40 40 41 41 const agentName = msg.agent || 'Agent'; 42 - window.AppServices.notify( 43 - 'Agent Complete', 44 - `${agentName} finished processing`, 45 - { 46 - icon: '🤖', 47 - tag: 'agent-complete', 48 - duration: 4000 49 - } 50 - ); 42 + window.AppServices.notifications.show({ 43 + app: 'home', 44 + icon: '🤖', 45 + title: 'Agent Complete', 46 + message: `${agentName} finished processing`, 47 + action: '/app/home', 48 + autoDismiss: 10000 // Auto-dismiss after 10 seconds 49 + }); 51 50 }, 52 51 53 52 notifyDayIndexed(msg) { ··· 56 55 } 57 56 58 57 const day = msg.day || 'today'; 59 - window.AppServices.notify( 60 - 'Indexing Complete', 61 - `${day} has been indexed`, 62 - { 63 - icon: '📇', 64 - tag: 'day-indexed', 65 - duration: 3000 66 - } 67 - ); 58 + window.AppServices.notifications.show({ 59 + app: 'home', 60 + icon: '📇', 61 + title: 'Indexing Complete', 62 + message: `${day} has been indexed`, 63 + action: '/app/home', 64 + autoDismiss: 8000 65 + }); 68 66 }, 69 67 70 68 notifyTaskComplete(msg) { ··· 73 71 } 74 72 75 73 const taskName = msg.task || 'Task'; 76 - window.AppServices.notify( 77 - 'Task Complete', 78 - `${taskName} finished`, 79 - { 80 - icon: '✅', 81 - tag: 'task-complete', 82 - duration: 4000 83 - } 84 - ); 74 + window.AppServices.notifications.show({ 75 + app: 'home', 76 + icon: '✅', 77 + title: 'Task Complete', 78 + message: `${taskName} finished`, 79 + action: '/app/home', 80 + autoDismiss: 10000 81 + }); 85 82 }, 86 83 87 84 isCurrentApp() {
+60 -7
convey/DESIGN.md
··· 58 58 **Service Capabilities** 59 59 - Listen to WebSocket events (Callosum tracts) 60 60 - Update badge counts dynamically 61 - - Show browser or in-app notifications 61 + - Show persistent notification cards 62 62 - Update submenu items in real-time 63 63 - Run custom background logic 64 64 65 65 **AppServices API** 66 + 67 + Core Methods: 66 68 - `AppServices.register(appName, service)` - Register background service 67 69 - `AppServices.updateBadge(appName, facetName, count)` - Update badge counts 68 70 - `AppServices.updateSubmenu(appName, items)` - Update submenu items 69 - - `AppServices.notify(title, body, options)` - Show notification 70 71 - `AppServices.requestNotificationPermission()` - Request browser notifications 71 72 73 + Notification System: 74 + - `AppServices.notifications.show(options)` - Show persistent notification card 75 + - `AppServices.notifications.dismiss(id)` - Dismiss specific notification 76 + - `AppServices.notifications.dismissApp(appName)` - Dismiss all for app 77 + - `AppServices.notifications.dismissAll()` - Dismiss all notifications 78 + - `AppServices.notifications.count()` - Get active notification count 79 + - `AppServices.notifications.update(id, options)` - Update notification 80 + 81 + **Notification Options** 82 + ```javascript 83 + { 84 + app: 'inbox', // App name (required) 85 + icon: '📬', // Emoji icon (optional) 86 + title: 'New Message', // Title (required) 87 + message: 'You have...', // Message body (optional) 88 + action: '/app/inbox', // Click action URL (optional) 89 + badge: 5, // Badge count (optional) 90 + dismissible: true, // Show X button (default: true) 91 + autoDismiss: 10000 // Auto-dismiss ms (optional) 92 + } 93 + ``` 94 + 95 + **Notification Cards** 96 + - Persistent until dismissed or clicked 97 + - Stack vertically in top-right (max 5 visible) 98 + - Click card → navigate to action URL 99 + - Click X → dismiss immediately 100 + - Auto-dismiss optional per notification 101 + - Relative timestamps (e.g., "5m ago") 102 + - Also triggers browser notification if permitted 103 + 72 104 **Service Registration** 73 105 Services are automatically loaded and executed on page load. Each service runs in an isolated function scope and can listen to WebSocket events via `window.appEvents`. 74 106 ··· 82 114 83 115 handleCortexEvent(msg) { 84 116 if (msg.event === 'agent_complete') { 85 - window.AppServices.notify( 86 - 'Agent Complete', 87 - `${msg.agent} finished processing`, 88 - { icon: '🤖', duration: 4000 } 89 - ); 117 + // Show persistent notification card 118 + window.AppServices.notifications.show({ 119 + app: 'home', 120 + icon: '🤖', 121 + title: 'Agent Complete', 122 + message: `${msg.agent} finished processing`, 123 + action: '/app/home', 124 + autoDismiss: 10000 125 + }); 90 126 } 91 127 } 92 128 }); ··· 153 189 - Dropdown panel anchored below status-icon 154 190 - Displays system health and service status information 155 191 - Closes on outside click or icon re-click 192 + 193 + **`notification-center`** 194 + - Fixed position top-right below facet-bar 195 + - Displays persistent notification cards from apps 196 + - Cards stack vertically (max 5 visible) 197 + - Animated slide-in from right 198 + - Each card shows app icon, title, message, timestamp 199 + - Click card to navigate, click X to dismiss 200 + - Auto-dismiss optional per notification 201 + 202 + **`notification-card`** 203 + - Individual notification in notification-center 204 + - Contains header (icon, app name, close button) 205 + - Body with title, message, optional badge 206 + - Footer with relative timestamp 207 + - Hover effect shifts card left slightly 208 + - Dismissible or persistent based on options 156 209 157 210 **`workspace`** 158 211 - Main content area between facet-bar and app-bar
+152
convey/static/app.css
··· 389 389 z-index: 9998; 390 390 box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.5); 391 391 } 392 + 393 + /* Notification Center */ 394 + .notification-center { 395 + position: fixed; 396 + top: 64px; 397 + right: 20px; 398 + width: 280px; 399 + z-index: 9999; 400 + pointer-events: none; 401 + display: flex; 402 + flex-direction: column; 403 + gap: 8px; 404 + } 405 + 406 + /* Notification Card */ 407 + .notification-card { 408 + background: white; 409 + border: 1px solid #e5e7eb; 410 + border-radius: 8px; 411 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 412 + padding: 12px; 413 + pointer-events: all; 414 + animation: slideIn 0.3s ease; 415 + transition: transform 0.2s ease, box-shadow 0.2s ease; 416 + cursor: default; 417 + } 418 + 419 + .notification-card[data-action]:not([data-action=""]) { 420 + cursor: pointer; 421 + } 422 + 423 + .notification-card:hover { 424 + transform: translateX(-4px); 425 + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); 426 + } 427 + 428 + @keyframes slideIn { 429 + from { 430 + opacity: 0; 431 + transform: translateX(100%); 432 + } 433 + to { 434 + opacity: 1; 435 + transform: translateX(0); 436 + } 437 + } 438 + 439 + /* Notification Header */ 440 + .notification-header { 441 + display: flex; 442 + align-items: center; 443 + gap: 8px; 444 + margin-bottom: 8px; 445 + } 446 + 447 + .notification-app-icon { 448 + font-size: 16px; 449 + line-height: 1; 450 + } 451 + 452 + .notification-app-name { 453 + font-size: 11px; 454 + color: #9ca3af; 455 + text-transform: capitalize; 456 + flex: 1; 457 + } 458 + 459 + .notification-close { 460 + background: none; 461 + border: none; 462 + font-size: 20px; 463 + line-height: 1; 464 + color: #9ca3af; 465 + cursor: pointer; 466 + padding: 0; 467 + width: 20px; 468 + height: 20px; 469 + display: flex; 470 + align-items: center; 471 + justify-content: center; 472 + border-radius: 4px; 473 + transition: background 0.2s, color 0.2s; 474 + } 475 + 476 + .notification-close:hover { 477 + background: #f3f4f6; 478 + color: #374151; 479 + } 480 + 481 + /* Notification Body */ 482 + .notification-body { 483 + position: relative; 484 + } 485 + 486 + .notification-title { 487 + font-weight: 600; 488 + font-size: 14px; 489 + color: #1f2937; 490 + margin-bottom: 4px; 491 + line-height: 1.3; 492 + } 493 + 494 + .notification-message { 495 + font-size: 13px; 496 + color: #6b7280; 497 + line-height: 1.4; 498 + display: -webkit-box; 499 + -webkit-line-clamp: 2; 500 + -webkit-box-orient: vertical; 501 + overflow: hidden; 502 + } 503 + 504 + .notification-badge { 505 + position: absolute; 506 + top: 0; 507 + right: 0; 508 + background: #ef4444; 509 + color: white; 510 + font-size: 11px; 511 + font-weight: 600; 512 + padding: 2px 6px; 513 + border-radius: 10px; 514 + min-width: 20px; 515 + text-align: center; 516 + line-height: 1.2; 517 + } 518 + 519 + /* Notification Footer */ 520 + .notification-footer { 521 + margin-top: 8px; 522 + padding-top: 8px; 523 + border-top: 1px solid #f3f4f6; 524 + } 525 + 526 + .notification-time { 527 + font-size: 11px; 528 + color: #9ca3af; 529 + } 530 + 531 + /* Responsive */ 532 + @media (max-width: 768px) { 533 + .notification-center { 534 + top: 64px; 535 + right: 8px; 536 + left: 8px; 537 + width: auto; 538 + } 539 + 540 + .notification-card { 541 + width: 100%; 542 + } 543 + }
+395
convey/static/app.js
··· 290 290 init(); 291 291 } 292 292 })(); 293 + 294 + /** 295 + * App Services Framework 296 + * Global API for apps to register background services, update badges, and show notifications 297 + */ 298 + window.AppServices = { 299 + services: {}, 300 + 301 + /** 302 + * Register an app background service 303 + * @param {string} appName - Name of the app 304 + * @param {object} service - Service object with initialize() method 305 + */ 306 + register(appName, service) { 307 + this.services[appName] = service; 308 + if (service.initialize) { 309 + try { 310 + service.initialize(); 311 + } catch (err) { 312 + console.error(`[AppServices] Failed to initialize ${appName} service:`, err); 313 + } 314 + } 315 + }, 316 + 317 + /** 318 + * Update badge count for a facet or app 319 + * @param {string} appName - Name of the app 320 + * @param {string|null} facetName - Facet name, or null for app-level badge 321 + * @param {number} count - Badge count (0 to hide) 322 + */ 323 + updateBadge(appName, facetName, count) { 324 + if (facetName) { 325 + // Update facet pill badge 326 + const facetPill = document.querySelector(`.facet-pill[data-facet="${facetName}"]`); 327 + if (facetPill) { 328 + let badge = facetPill.querySelector('.facet-badge'); 329 + if (!badge) { 330 + badge = document.createElement('span'); 331 + badge.className = 'facet-badge'; 332 + const emojiContainer = facetPill.querySelector('.emoji-container'); 333 + if (emojiContainer) { 334 + emojiContainer.appendChild(badge); 335 + } 336 + } 337 + badge.textContent = count || ''; 338 + badge.style.display = count > 0 ? 'inline-block' : 'none'; 339 + } 340 + 341 + // Update submenu badge for facet 342 + const submenuItem = document.querySelector( 343 + `.menu-item[data-app="${appName}"] .submenu-item[data-facet="${facetName}"]` 344 + ); 345 + if (submenuItem) { 346 + let badge = submenuItem.querySelector('.submenu-badge'); 347 + if (!badge) { 348 + badge = document.createElement('span'); 349 + badge.className = 'submenu-badge'; 350 + submenuItem.appendChild(badge); 351 + } 352 + badge.textContent = count || ''; 353 + badge.style.display = count > 0 ? 'inline-block' : 'none'; 354 + } 355 + } else { 356 + // Update app-level badge in menu 357 + const menuItem = document.querySelector(`.menu-item[data-app="${appName}"]`); 358 + if (menuItem) { 359 + let badge = menuItem.querySelector('.app-badge'); 360 + if (!badge) { 361 + badge = document.createElement('span'); 362 + badge.className = 'app-badge'; 363 + const link = menuItem.querySelector('a') || menuItem; 364 + link.appendChild(badge); 365 + } 366 + badge.textContent = count || ''; 367 + badge.style.display = count > 0 ? 'inline-block' : 'none'; 368 + } 369 + } 370 + }, 371 + 372 + /** 373 + * Update submenu items for an app 374 + * @param {string} appName - Name of the app 375 + * @param {Array} items - Array of {label, path, facet?, count?} objects 376 + */ 377 + updateSubmenu(appName, items) { 378 + const submenu = document.querySelector(`.menu-item[data-app="${appName}"] .submenu`); 379 + if (!submenu) return; 380 + 381 + submenu.innerHTML = items.map(item => ` 382 + <div class="submenu-item" ${item.facet ? `data-facet="${item.facet}"` : ''}> 383 + <a href="${item.path}">${this._escapeHtml(item.label)}</a> 384 + ${item.count ? `<span class="submenu-badge">${item.count}</span>` : ''} 385 + </div> 386 + `).join(''); 387 + }, 388 + 389 + /** 390 + * Notification system 391 + */ 392 + notifications: { 393 + _stack: [], 394 + _nextId: 1, 395 + _container: null, 396 + 397 + /** 398 + * Show a persistent notification card 399 + * @param {object} options - {app, icon, title, message, action, dismissible, badge, autoDismiss} 400 + * @returns {number} Notification ID 401 + */ 402 + show(options) { 403 + const notif = { 404 + id: this._nextId++, 405 + app: options.app || 'system', 406 + icon: options.icon || '📬', 407 + title: options.title || 'Notification', 408 + message: options.message || '', 409 + action: options.action || null, 410 + dismissible: options.dismissible !== false, 411 + badge: options.badge || null, 412 + timestamp: Date.now(), 413 + autoDismiss: options.autoDismiss || null 414 + }; 415 + 416 + this._stack.push(notif); 417 + this._render(); 418 + 419 + // Browser notification if permitted 420 + if ('Notification' in window && Notification.permission === 'granted') { 421 + new Notification(notif.title, { 422 + body: notif.message, 423 + icon: notif.icon, 424 + tag: `${notif.app}-${notif.id}` 425 + }); 426 + } 427 + 428 + // Auto-dismiss timer 429 + if (notif.autoDismiss) { 430 + setTimeout(() => this.dismiss(notif.id), notif.autoDismiss); 431 + } 432 + 433 + return notif.id; 434 + }, 435 + 436 + /** 437 + * Dismiss a specific notification 438 + * @param {number} id - Notification ID 439 + */ 440 + dismiss(id) { 441 + this._stack = this._stack.filter(n => n.id !== id); 442 + this._render(); 443 + }, 444 + 445 + /** 446 + * Dismiss all notifications for an app 447 + * @param {string} appName - App name 448 + */ 449 + dismissApp(appName) { 450 + this._stack = this._stack.filter(n => n.app !== appName); 451 + this._render(); 452 + }, 453 + 454 + /** 455 + * Dismiss all notifications 456 + */ 457 + dismissAll() { 458 + this._stack = []; 459 + this._render(); 460 + }, 461 + 462 + /** 463 + * Get count of active notifications 464 + * @returns {number} 465 + */ 466 + count() { 467 + return this._stack.length; 468 + }, 469 + 470 + /** 471 + * Update existing notification 472 + * @param {number} id - Notification ID 473 + * @param {object} options - Fields to update 474 + */ 475 + update(id, options) { 476 + const notif = this._stack.find(n => n.id === id); 477 + if (!notif) return; 478 + 479 + Object.assign(notif, options); 480 + this._render(); 481 + }, 482 + 483 + /** 484 + * Render notification cards 485 + * @private 486 + */ 487 + _render() { 488 + if (!this._container) { 489 + this._container = document.getElementById('notification-center'); 490 + if (!this._container) return; 491 + } 492 + 493 + // Limit to 5 most recent 494 + const visible = this._stack.slice(-5); 495 + const visibleIds = visible.map(n => n.id); 496 + 497 + // Get existing card IDs 498 + const existingCards = Array.from(this._container.querySelectorAll('.notification-card')); 499 + const existingIds = existingCards.map(card => parseInt(card.getAttribute('data-id'))); 500 + 501 + // Remove cards that are no longer in visible stack 502 + existingCards.forEach(card => { 503 + const id = parseInt(card.getAttribute('data-id')); 504 + if (!visibleIds.includes(id)) { 505 + card.remove(); 506 + } 507 + }); 508 + 509 + // Add or update cards 510 + visible.forEach(n => { 511 + let card = this._container.querySelector(`.notification-card[data-id="${n.id}"]`); 512 + 513 + if (!card) { 514 + // New card - create and animate 515 + card = this._createCard(n); 516 + this._container.appendChild(card); 517 + } else { 518 + // Existing card - update content (no animation) 519 + this._updateCard(card, n); 520 + } 521 + }); 522 + 523 + // Start timestamp updater if not already running 524 + if (visible.length > 0 && !this._updateInterval) { 525 + this._updateInterval = setInterval(() => this._updateTimestamps(), 60000); 526 + } else if (visible.length === 0 && this._updateInterval) { 527 + clearInterval(this._updateInterval); 528 + this._updateInterval = null; 529 + } 530 + }, 531 + 532 + /** 533 + * Create a new notification card element 534 + * @private 535 + */ 536 + _createCard(n) { 537 + const card = document.createElement('div'); 538 + card.className = 'notification-card'; 539 + card.setAttribute('data-id', n.id); 540 + card.setAttribute('data-action', n.action || ''); 541 + card.setAttribute('data-app', n.app); 542 + 543 + const relativeTime = this._getRelativeTime(n.timestamp); 544 + card.innerHTML = ` 545 + <div class="notification-header"> 546 + <span class="notification-app-icon">${n.icon}</span> 547 + <span class="notification-app-name">${window.AppServices._escapeHtml(n.app)}</span> 548 + ${n.dismissible ? `<button class="notification-close" onclick="window.AppServices.notifications.dismiss(${n.id}); event.stopPropagation();">×</button>` : ''} 549 + </div> 550 + <div class="notification-body"> 551 + <div class="notification-title">${window.AppServices._escapeHtml(n.title)}</div> 552 + ${n.message ? `<div class="notification-message">${window.AppServices._escapeHtml(n.message)}</div>` : ''} 553 + ${n.badge ? `<span class="notification-badge">${n.badge}</span>` : ''} 554 + </div> 555 + <div class="notification-footer"> 556 + <span class="notification-time">${relativeTime}</span> 557 + </div> 558 + `; 559 + 560 + // Add click handler if action exists 561 + if (n.action) { 562 + card.style.cursor = 'pointer'; 563 + card.onclick = (e) => { 564 + if (!e.target.classList.contains('notification-close')) { 565 + window.location.href = n.action; 566 + } 567 + }; 568 + } 569 + 570 + return card; 571 + }, 572 + 573 + /** 574 + * Update existing notification card content 575 + * @private 576 + */ 577 + _updateCard(card, n) { 578 + // Update title 579 + const titleEl = card.querySelector('.notification-title'); 580 + if (titleEl) { 581 + titleEl.textContent = n.title; 582 + } 583 + 584 + // Update message 585 + const messageEl = card.querySelector('.notification-message'); 586 + if (n.message) { 587 + if (messageEl) { 588 + messageEl.textContent = n.message; 589 + } else { 590 + const bodyEl = card.querySelector('.notification-body'); 591 + const newMessage = document.createElement('div'); 592 + newMessage.className = 'notification-message'; 593 + newMessage.textContent = n.message; 594 + bodyEl.insertBefore(newMessage, bodyEl.querySelector('.notification-badge')); 595 + } 596 + } else if (messageEl) { 597 + messageEl.remove(); 598 + } 599 + 600 + // Update badge 601 + const badgeEl = card.querySelector('.notification-badge'); 602 + if (n.badge) { 603 + if (badgeEl) { 604 + badgeEl.textContent = n.badge; 605 + } else { 606 + const bodyEl = card.querySelector('.notification-body'); 607 + const newBadge = document.createElement('span'); 608 + newBadge.className = 'notification-badge'; 609 + newBadge.textContent = n.badge; 610 + bodyEl.appendChild(newBadge); 611 + } 612 + } else if (badgeEl) { 613 + badgeEl.remove(); 614 + } 615 + 616 + // Update time 617 + const timeEl = card.querySelector('.notification-time'); 618 + if (timeEl) { 619 + timeEl.textContent = this._getRelativeTime(n.timestamp); 620 + } 621 + 622 + // Update action 623 + card.setAttribute('data-action', n.action || ''); 624 + if (n.action) { 625 + card.style.cursor = 'pointer'; 626 + } else { 627 + card.style.cursor = 'default'; 628 + } 629 + }, 630 + 631 + /** 632 + * Update timestamps on visible notifications 633 + * @private 634 + */ 635 + _updateTimestamps() { 636 + const cards = this._container?.querySelectorAll('.notification-card'); 637 + if (!cards) return; 638 + 639 + cards.forEach(card => { 640 + const id = parseInt(card.getAttribute('data-id')); 641 + const notif = this._stack.find(n => n.id === id); 642 + if (notif) { 643 + const timeEl = card.querySelector('.notification-time'); 644 + if (timeEl) { 645 + timeEl.textContent = this._getRelativeTime(notif.timestamp); 646 + } 647 + } 648 + }); 649 + }, 650 + 651 + /** 652 + * Get relative time string 653 + * @private 654 + */ 655 + _getRelativeTime(timestamp) { 656 + const seconds = Math.floor((Date.now() - timestamp) / 1000); 657 + if (seconds < 60) return 'just now'; 658 + const minutes = Math.floor(seconds / 60); 659 + if (minutes < 60) return `${minutes}m ago`; 660 + const hours = Math.floor(minutes / 60); 661 + if (hours < 24) return `${hours}h ago`; 662 + const days = Math.floor(hours / 24); 663 + return `${days}d ago`; 664 + } 665 + }, 666 + 667 + /** 668 + * Request browser notification permission 669 + * @returns {Promise<string>} Permission state 670 + */ 671 + async requestNotificationPermission() { 672 + if ('Notification' in window && Notification.permission === 'default') { 673 + return await Notification.requestPermission(); 674 + } 675 + return Notification.permission; 676 + }, 677 + 678 + /** 679 + * Escape HTML to prevent XSS 680 + * @private 681 + */ 682 + _escapeHtml(text) { 683 + const div = document.createElement('div'); 684 + div.textContent = text; 685 + return div.innerHTML; 686 + } 687 + };
+4 -231
convey/templates/app.html
··· 49 49 <!-- Status Pane --> 50 50 {% include "status_pane.html" %} 51 51 52 + <!-- Notification Center --> 53 + <div class="notification-center" id="notification-center"></div> 54 + 52 55 <!-- App Bar (bottom) - only render if app provides template --> 53 56 {% set app_bar_template = app_registry.apps[app].get_app_bar_template() %} 54 57 {% if app_bar_template %} ··· 64 67 {% include app_registry.apps[app].get_workspace_template() %} 65 68 </div> 66 69 67 - <!-- App JavaScript --> 70 + <!-- App JavaScript (includes AppServices framework) --> 68 71 <script src="{{ url_for('review.static', filename='app.js') }}"></script> 69 72 70 - <!-- App Services Framework --> 71 - <script> 72 - /** 73 - * Global App Services Framework 74 - * 75 - * Allows apps to register background services that run globally (even when 76 - * the app is not active). Services can listen to WebSocket events, update 77 - * badges, show notifications, and run custom logic. 78 - * 79 - * Similar to iOS background notification handlers. 80 - */ 81 - window.AppServices = { 82 - services: {}, 83 - 84 - /** 85 - * Register an app background service 86 - * @param {string} appName - Name of the app 87 - * @param {object} service - Service object with initialize() method 88 - */ 89 - register(appName, service) { 90 - this.services[appName] = service; 91 - if (service.initialize) { 92 - try { 93 - service.initialize(); 94 - } catch (err) { 95 - console.error(`[AppServices] Failed to initialize ${appName} service:`, err); 96 - } 97 - } 98 - }, 99 - 100 - /** 101 - * Update badge count for a facet or app 102 - * @param {string} appName - Name of the app 103 - * @param {string|null} facetName - Facet name, or null for app-level badge 104 - * @param {number} count - Badge count (0 to hide) 105 - */ 106 - updateBadge(appName, facetName, count) { 107 - if (facetName) { 108 - // Update facet pill badge 109 - const facetPill = document.querySelector(`.facet-pill[data-facet="${facetName}"]`); 110 - if (facetPill) { 111 - let badge = facetPill.querySelector('.facet-badge'); 112 - if (!badge) { 113 - badge = document.createElement('span'); 114 - badge.className = 'facet-badge'; 115 - facetPill.appendChild(badge); 116 - } 117 - badge.textContent = count || ''; 118 - badge.style.display = count > 0 ? 'inline-block' : 'none'; 119 - } 120 - 121 - // Update submenu badge for facet 122 - const submenuItem = document.querySelector( 123 - `.menu-item[data-app="${appName}"] .submenu-item[data-facet="${facetName}"]` 124 - ); 125 - if (submenuItem) { 126 - let badge = submenuItem.querySelector('.submenu-badge'); 127 - if (!badge) { 128 - badge = document.createElement('span'); 129 - badge.className = 'submenu-badge'; 130 - submenuItem.appendChild(badge); 131 - } 132 - badge.textContent = count || ''; 133 - badge.style.display = count > 0 ? 'inline-block' : 'none'; 134 - } 135 - } else { 136 - // Update app-level badge in menu 137 - const menuItem = document.querySelector(`.menu-item[data-app="${appName}"]`); 138 - if (menuItem) { 139 - let badge = menuItem.querySelector('.app-badge'); 140 - if (!badge) { 141 - badge = document.createElement('span'); 142 - badge.className = 'app-badge'; 143 - menuItem.querySelector('a').appendChild(badge); 144 - } 145 - badge.textContent = count || ''; 146 - badge.style.display = count > 0 ? 'inline-block' : 'none'; 147 - } 148 - } 149 - }, 150 - 151 - /** 152 - * Update submenu items for an app 153 - * @param {string} appName - Name of the app 154 - * @param {Array} items - Array of {label, path, facet?, count?} objects 155 - */ 156 - updateSubmenu(appName, items) { 157 - const submenu = document.querySelector(`.menu-item[data-app="${appName}"] .submenu`); 158 - if (!submenu) return; 159 - 160 - submenu.innerHTML = items.map(item => ` 161 - <div class="submenu-item" ${item.facet ? `data-facet="${item.facet}"` : ''}> 162 - <a href="${item.path}">${item.label}</a> 163 - ${item.count ? `<span class="submenu-badge">${item.count}</span>` : ''} 164 - </div> 165 - `).join(''); 166 - }, 167 - 168 - /** 169 - * Show a notification (browser or in-app toast) 170 - * @param {string} title - Notification title 171 - * @param {string} body - Notification body 172 - * @param {object} options - {icon, tag, duration} 173 - */ 174 - notify(title, body, options = {}) { 175 - // Try browser notification if permitted 176 - if ('Notification' in window && Notification.permission === 'granted') { 177 - new Notification(title, { 178 - body, 179 - icon: options.icon, 180 - tag: options.tag 181 - }); 182 - } 183 - 184 - // Always show in-app toast 185 - const toast = document.createElement('div'); 186 - toast.className = 'app-notification'; 187 - toast.innerHTML = ` 188 - <div class="notification-icon">${options.icon || '📬'}</div> 189 - <div class="notification-content"> 190 - <strong>${this._escapeHtml(title)}</strong> 191 - <p>${this._escapeHtml(body)}</p> 192 - </div> 193 - `; 194 - document.body.appendChild(toast); 195 - 196 - // Animate in 197 - setTimeout(() => toast.classList.add('show'), 10); 198 - 199 - // Auto-dismiss 200 - const duration = options.duration || 5000; 201 - setTimeout(() => { 202 - toast.classList.remove('show'); 203 - setTimeout(() => toast.remove(), 300); 204 - }, duration); 205 - }, 206 - 207 - /** 208 - * Request browser notification permission 209 - * @returns {Promise<string>} Permission state 210 - */ 211 - async requestNotificationPermission() { 212 - if ('Notification' in window && Notification.permission === 'default') { 213 - return await Notification.requestPermission(); 214 - } 215 - return Notification.permission; 216 - }, 217 - 218 - /** 219 - * Escape HTML to prevent XSS in notifications 220 - * @private 221 - */ 222 - _escapeHtml(text) { 223 - const div = document.createElement('div'); 224 - div.textContent = text; 225 - return div.innerHTML; 226 - } 227 - }; 228 - </script> 229 - 230 73 <!-- Load all app background services --> 231 74 {% for app_name, app_instance in app_registry.apps.items() %} 232 75 {% set service_tpl = app_instance.get_service_template() %} ··· 246 89 </script> 247 90 {% endif %} 248 91 {% endfor %} 249 - 250 - <!-- Notification Toast Styles --> 251 - <style> 252 - .app-notification { 253 - position: fixed; 254 - bottom: 80px; 255 - right: 20px; 256 - background: white; 257 - border: 1px solid #e5e7eb; 258 - border-radius: 8px; 259 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 260 - padding: 1rem; 261 - display: flex; 262 - gap: 0.75rem; 263 - align-items: flex-start; 264 - max-width: 360px; 265 - opacity: 0; 266 - transform: translateY(20px); 267 - transition: opacity 0.3s ease, transform 0.3s ease; 268 - z-index: 10000; 269 - } 270 - 271 - .app-notification.show { 272 - opacity: 1; 273 - transform: translateY(0); 274 - } 275 - 276 - .app-notification .notification-icon { 277 - font-size: 1.5rem; 278 - flex-shrink: 0; 279 - } 280 - 281 - .app-notification .notification-content { 282 - flex: 1; 283 - min-width: 0; 284 - } 285 - 286 - .app-notification .notification-content strong { 287 - display: block; 288 - margin-bottom: 0.25rem; 289 - color: #1f2937; 290 - font-size: 0.95rem; 291 - } 292 - 293 - .app-notification .notification-content p { 294 - margin: 0; 295 - color: #6b7280; 296 - font-size: 0.875rem; 297 - line-height: 1.4; 298 - } 299 - 300 - /* Badge styles (if not already defined) */ 301 - .facet-badge, .submenu-badge, .app-badge { 302 - display: inline-block; 303 - background: #ef4444; 304 - color: white; 305 - font-size: 0.75rem; 306 - font-weight: bold; 307 - padding: 0.125rem 0.375rem; 308 - border-radius: 10px; 309 - margin-left: 0.5rem; 310 - min-width: 1.25rem; 311 - text-align: center; 312 - } 313 - 314 - .app-badge { 315 - margin-left: auto; 316 - margin-right: 0; 317 - } 318 - </style> 319 92 320 93 <!-- Error Modal --> 321 94 <div id="errorModal" class="modal">