personal memory agent
0
fork

Configure Feed

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

Health dashboard: responsive breakpoints, card/CSS consolidation, table-driven observe

- Add @media queries at 768px and 480px for responsive grid reflow
- Extract shared .dashboard-card base CSS, removing 6 duplicated card styles
- Unify .dream-info-item/.sync-info-item into .info-item/.info-label
- Add renderInfoItems() helper used by updateDreamCard() and updateSyncCard()
- Refactor updateObserve() from per-channel if/else to table-driven channel array
- Add aria-label on status dots, aria-live on vitals, role=log on logs viewport

+231 -199
+231 -199
apps/health/workspace.html
··· 137 137 .status-indicator.restarting { background: #fbbf24; } 138 138 .status-indicator.inactive { background: #9ca3af; } 139 139 140 - /* Observe Card (Prominent) */ 141 - .observe-card { 140 + /* Shared card base */ 141 + .dashboard-card { 142 142 background: white; 143 143 border-radius: 12px; 144 144 padding: 1.5em; 145 145 box-shadow: 0 2px 8px rgba(0,0,0,0.1); 146 + } 147 + 148 + /* Observe Card (Prominent) */ 149 + .observe-card { 146 150 border-left: 4px solid #10b981; 147 151 } 148 152 ··· 266 270 267 271 /* Observers Card */ 268 272 .observers-card { 269 - background: white; 270 - border-radius: 12px; 271 - padding: 1.5em; 272 - box-shadow: 0 2px 8px rgba(0,0,0,0.1); 273 273 border-left: 4px solid #8b5cf6; 274 274 } 275 275 ··· 343 343 gap: 1em; 344 344 } 345 345 346 - .activity-section { 347 - background: white; 348 - border-radius: 12px; 349 - padding: 1.5em; 350 - box-shadow: 0 2px 8px rgba(0,0,0,0.1); 351 - } 352 - 353 346 .activity-section.hidden { 354 347 display: none; 355 348 } ··· 463 456 } 464 457 465 458 /* Service Logs */ 466 - .logs-card { 467 - background: white; 468 - border-radius: 12px; 469 - padding: 1.5em; 470 - box-shadow: 0 2px 8px rgba(0,0,0,0.1); 471 - } 472 - 473 459 .logs-card.logs-collapsed .logs-viewport, 474 460 .logs-card.logs-collapsed .logs-controls { 475 461 display: none; ··· 588 574 589 575 /* Dream Card */ 590 576 .dream-card { 591 - background: white; 592 - border-radius: 12px; 593 - padding: 1.5em; 594 - box-shadow: 0 2px 8px rgba(0,0,0,0.1); 595 577 border-left: 4px solid #f59e0b; 596 578 } 597 579 ··· 607 589 font-size: 0.9em; 608 590 } 609 591 610 - .dream-info-item { 592 + .info-item { 611 593 display: flex; 612 594 flex-direction: column; 613 595 gap: 0.2em; 614 596 } 615 597 616 - .dream-info-label { 598 + .info-label { 617 599 font-size: 0.8em; 618 600 color: #6b7280; 619 601 text-transform: uppercase; ··· 651 633 652 634 /* Sync Card */ 653 635 .sync-card { 654 - background: white; 655 - border-radius: 12px; 656 - padding: 1.5em; 657 - box-shadow: 0 2px 8px rgba(0,0,0,0.1); 658 636 border-left: 4px solid #06b6d4; 659 637 } 660 638 ··· 669 647 font-size: 0.9em; 670 648 } 671 649 672 - .sync-info-item { 673 - display: flex; 674 - flex-direction: column; 675 - gap: 0.2em; 676 - } 677 - 678 - .sync-info-label { 679 - font-size: 0.8em; 680 - color: #6b7280; 681 - text-transform: uppercase; 682 - letter-spacing: 0.5px; 683 - } 684 - 685 650 /* Stale heartbeats list */ 686 651 .stale-list { 687 652 font-size: 0.85em; ··· 715 680 #importerGrid .activity-card-provider { 716 681 display: none; 717 682 } 683 + 684 + /* Responsive */ 685 + @media (max-width: 768px) { 686 + .health-dashboard { 687 + padding: 0.5em; 688 + gap: 0.75em; 689 + } 690 + 691 + .vitals-bar { 692 + padding: 1em; 693 + } 694 + 695 + .vitals-header { 696 + flex-direction: column; 697 + align-items: flex-start; 698 + gap: 0.5em; 699 + } 700 + 701 + .vitals-content { 702 + gap: 0.75em; 703 + } 704 + 705 + .dashboard-card { 706 + padding: 1em; 707 + border-radius: 8px; 708 + } 709 + 710 + .observe-group-channels { 711 + grid-template-columns: repeat(3, 1fr); 712 + } 713 + 714 + .observers-grid { 715 + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); 716 + } 717 + 718 + .activity-grid { 719 + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); 720 + } 721 + } 722 + 723 + @media (max-width: 480px) { 724 + .health-dashboard { 725 + padding: 0.25em; 726 + gap: 0.5em; 727 + } 728 + 729 + .vitals-content { 730 + gap: 0.5em; 731 + } 732 + 733 + .observe-group-channels { 734 + grid-template-columns: repeat(2, 1fr); 735 + } 736 + 737 + .observers-grid { 738 + grid-template-columns: 1fr; 739 + } 740 + 741 + .activity-grid { 742 + grid-template-columns: 1fr 1fr; 743 + } 744 + 745 + .card-title { 746 + font-size: 1em; 747 + } 748 + 749 + .vitals-title { 750 + font-size: 1.05em; 751 + } 752 + 753 + .status-summary { 754 + font-size: 0.85em; 755 + padding: 0.6em 1em; 756 + } 757 + } 718 758 </style> 719 759 720 760 <div class="health-dashboard"> ··· 724 764 <div class="vitals-title"> 725 765 System vitals 726 766 </div> 727 - <div class="vitals-status" id="vitalsStatus"> 767 + <div class="vitals-status" id="vitalsStatus" role="status" aria-live="polite"> 728 768 <span class="status-indicator active"></span> 729 769 <span>All Systems Go</span> 730 770 </div> ··· 777 817 </div> 778 818 779 819 <!-- Observe Card --> 780 - <div class="observe-card"> 820 + <div class="dashboard-card observe-card"> 781 821 <div class="card-header"> 782 822 <div class="card-title"> 783 823 Observation status ··· 834 874 </div> 835 875 836 876 <!-- Observers Card --> 837 - <div class="observers-card hidden" id="observersCard"> 877 + <div class="dashboard-card observers-card hidden" id="observersCard"> 838 878 <div class="card-header"> 839 879 <div class="card-title"> 840 880 Connected observers ··· 846 886 <!-- Activity Grids --> 847 887 <div class="activity-grids"> 848 888 <!-- Cortex Agents --> 849 - <div class="activity-section hidden" id="cortexSection"> 889 + <div class="dashboard-card activity-section hidden" id="cortexSection"> 850 890 <div class="activity-section-header"> 851 891 Active agents 852 892 </div> ··· 854 894 </div> 855 895 856 896 <!-- Importers --> 857 - <div class="activity-section hidden" id="importerSection"> 897 + <div class="dashboard-card activity-section hidden" id="importerSection"> 858 898 <div class="activity-section-header"> 859 899 Active imports 860 900 </div> ··· 862 902 </div> 863 903 </div> 864 904 865 - <div class="activity-section hidden" id="errorSummary"> 905 + <div class="dashboard-card activity-section hidden" id="errorSummary"> 866 906 <div class="activity-section-header" style="color: #dc2626;"> 867 907 Recent errors 868 908 </div> 869 909 <div id="errorSummaryContent"></div> 870 910 </div> 871 911 872 - <div class="activity-section hidden" id="allQuietCard" style="text-align: center; color: #6b7280; padding: 2em;"> 912 + <div class="dashboard-card activity-section hidden" id="allQuietCard" style="text-align: center; color: #6b7280; padding: 2em;"> 873 913 Everything is idle — nothing running right now 874 914 </div> 875 915 876 916 <!-- Dream Card (hidden when idle) --> 877 - <div class="dream-card hidden" id="dreamCard"> 917 + <div class="dashboard-card dream-card hidden" id="dreamCard"> 878 918 <div class="card-header"> 879 919 <div class="card-title">Overnight processing</div> 880 920 </div> ··· 884 924 </div> 885 925 886 926 <!-- Sync Card (hidden when idle) --> 887 - <div class="sync-card hidden" id="syncCard"> 927 + <div class="dashboard-card sync-card hidden" id="syncCard"> 888 928 <div class="card-header"> 889 929 <div class="card-title">Cloud sync</div> 890 930 </div> ··· 892 932 </div> 893 933 894 934 <!-- Service Logs --> 895 - <div class="logs-card logs-collapsed"> 935 + <div class="dashboard-card logs-card logs-collapsed"> 896 936 <div class="logs-header"> 897 937 <div class="logs-title">Service logs</div> 898 938 <span class="logs-error-badge hidden" id="logErrorBadge"></span> ··· 911 951 <button id="logClearBtn">Clear</button> 912 952 </div> 913 953 </div> 914 - <div class="logs-viewport" id="logsViewport"></div> 954 + <div class="logs-viewport" id="logsViewport" role="log"></div> 915 955 </div> 916 956 </div> 917 957 ··· 1058 1098 return `in ${days}d`; 1059 1099 } 1060 1100 1101 + function renderInfoItems(items) { 1102 + return items 1103 + .filter(item => item.value != null) 1104 + .map(item => `<div class="info-item"><div class="info-label">${escapeHtml(item.label)}</div><div>${item.value}</div></div>`) 1105 + .join(''); 1106 + } 1107 + 1061 1108 function updateStatusSummary() { 1062 1109 const parts = []; 1063 1110 const observers = Array.from(state.observers.values()); ··· 1183 1230 const restartInfo = crashed ? ' (restarting...)' : ''; 1184 1231 return ` 1185 1232 <div class="${dotClass}" data-service="${escapeHtml(name)}"> 1186 - <span class="status-indicator ${statusClass}"></span> 1233 + <span class="status-indicator ${statusClass}" aria-label="${escapeHtml(serviceName(name))}: ${statusClass}${restartInfo}"></span> 1187 1234 <span title="${escapeHtml(name)}">${escapeHtml(serviceName(name))}${restartInfo}</span> 1188 1235 </div> 1189 1236 `; ··· 1253 1300 bar.classList.remove('warning', 'error'); 1254 1301 if (status === 'ok') { 1255 1302 vitalsStatus.innerHTML = ` 1256 - <span class="status-indicator active"></span> 1303 + <span class="status-indicator active" aria-label="System status: healthy"></span> 1257 1304 <span>All Systems Go</span> 1258 1305 `; 1259 1306 } else if (status === 'warning') { 1260 1307 vitalsStatus.innerHTML = ` 1261 - <span class="status-indicator restarting"></span> 1308 + <span class="status-indicator restarting" aria-label="System status: warning"></span> 1262 1309 <span>Some services slow to respond</span> 1263 1310 `; 1264 1311 bar.classList.add('warning'); 1265 1312 } else if (status === 'error') { 1266 1313 vitalsStatus.innerHTML = ` 1267 - <span class="status-indicator crashed"></span> 1314 + <span class="status-indicator crashed" aria-label="System status: error"></span> 1268 1315 <span>Services need attention</span> 1269 1316 `; 1270 1317 bar.classList.add('error'); ··· 1312 1359 1313 1360 const primary = state.localHost ? state.observers.get(state.localHost) : null; 1314 1361 const tmux = state.localHost ? state.observers.get(state.localHost + '.tmux') : null; 1362 + const channels = [ 1363 + { 1364 + statusEl: elements.screencastStatus, 1365 + detailEl: elements.screencastDetail, 1366 + idleText: 'Not recording', 1367 + extract: () => { 1368 + if (!primary.screencast) return null; 1369 + const recording = primary.screencast.recording; 1370 + if (!recording) return { status: 'Not recording' }; 1371 + const streams = primary.screencast.streams || []; 1372 + const elapsed = primary.screencast.window_elapsed_seconds || 0; 1373 + const streamCount = streams.length; 1374 + const displayLabel = streamCount === 1 ? 'display' : 'displays'; 1375 + const mins = Math.max(1, Math.round(elapsed / 60)); 1376 + return { 1377 + status: `Recording (${streamCount} ${displayLabel}, ~${mins} min)`, 1378 + detail: streamCount > 0 1379 + ? streams.map(s => `${s.position || 'unknown'} ${s.connector || 'unknown'}`).join(', ') 1380 + : '', 1381 + }; 1382 + }, 1383 + }, 1384 + { 1385 + statusEl: elements.tmuxStatus, 1386 + detailEl: elements.tmuxDetail, 1387 + idleText: 'Not capturing', 1388 + extract: () => { 1389 + if (!tmux?.tmux) return null; 1390 + if (!tmux.tmux.capturing) return { status: 'Not capturing' }; 1391 + const captures = tmux.tmux.captures || 0; 1392 + const sessions = tmux.tmux.sessions || []; 1393 + const elapsed = tmux.tmux.window_elapsed_seconds || 0; 1394 + const mins = Math.max(1, Math.round(elapsed / 60)); 1395 + return { 1396 + status: `Capturing (${captures} snapshots, ~${mins} min)`, 1397 + detail: sessions.length > 0 ? sessions.join(', ') : '', 1398 + }; 1399 + }, 1400 + }, 1401 + { 1402 + statusEl: elements.audioStatus, 1403 + detailEl: elements.audioDetail, 1404 + idleText: 'Listening (quiet)', 1405 + extract: () => { 1406 + if (!primary.audio) return null; 1407 + const hits = primary.audio.threshold_hits || 0; 1408 + const willSave = primary.audio.will_save ? ' · saving' : ''; 1409 + return { 1410 + status: hits > 0 1411 + ? `Listening (${hits} sound${hits === 1 ? '' : 's'} detected)${willSave}` 1412 + : 'Listening (quiet)', 1413 + }; 1414 + }, 1415 + }, 1416 + { 1417 + statusEl: elements.activityStatus, 1418 + detailEl: elements.activityDetail, 1419 + idleText: 'Idle', 1420 + extract: () => { 1421 + if (!primary.activity) return null; 1422 + const idleMs = primary.activity.idle_time_ms || 0; 1423 + if (primary.activity.power_save) return { status: 'Power saving' }; 1424 + if (primary.activity.screen_locked) return { status: 'Screen locked' }; 1425 + if (primary.activity.sink_muted) return { status: 'Audio muted' }; 1426 + return { status: `Idle: ${Math.floor(idleMs/1000)}s` }; 1427 + }, 1428 + }, 1429 + { 1430 + statusEl: elements.describeStatus, 1431 + detailEl: elements.describeDetail, 1432 + idleText: 'Idle', 1433 + extract: () => { 1434 + if (!primary.describe) return null; 1435 + return { processor: primary.describe }; 1436 + }, 1437 + }, 1438 + { 1439 + statusEl: elements.transcribeStatus, 1440 + detailEl: elements.transcribeDetail, 1441 + idleText: 'Idle', 1442 + extract: () => { 1443 + if (!primary.transcribe) return null; 1444 + return { processor: primary.transcribe }; 1445 + }, 1446 + }, 1447 + ]; 1315 1448 1316 1449 updateObserveMode(); 1317 1450 1318 1451 if (!state.localHost || !primary) { 1319 - elements.screencastStatus.textContent = 'Waiting...'; 1320 - elements.screencastDetail.textContent = ''; 1321 - elements.tmuxStatus.textContent = 'Waiting...'; 1322 - elements.tmuxDetail.textContent = ''; 1323 - elements.audioStatus.textContent = 'Waiting...'; 1324 - elements.audioDetail.textContent = ''; 1325 - elements.activityStatus.textContent = 'Waiting...'; 1326 - elements.activityDetail.textContent = ''; 1327 - elements.describeStatus.textContent = 'Waiting...'; 1328 - elements.describeDetail.textContent = ''; 1329 - elements.transcribeStatus.textContent = 'Waiting...'; 1330 - elements.transcribeDetail.textContent = ''; 1452 + channels.forEach(ch => { 1453 + ch.statusEl.textContent = 'Waiting...'; 1454 + ch.detailEl.textContent = ''; 1455 + }); 1331 1456 updateStatusSummary(); 1332 1457 return; 1333 1458 } 1334 1459 1335 - // Screencast 1336 - if (primary.screencast) { 1337 - const recording = primary.screencast.recording; 1338 - if (recording) { 1339 - const streams = primary.screencast.streams || []; 1340 - const elapsed = primary.screencast.window_elapsed_seconds || 0; 1341 - const streamCount = streams.length; 1342 - const displayLabel = streamCount === 1 ? 'display' : 'displays'; 1343 - const mins = Math.max(1, Math.round(elapsed / 60)); 1344 - elements.screencastStatus.textContent = `Recording (${streamCount} ${displayLabel}, ~${mins} min)`; 1345 - if (streamCount > 0) { 1346 - elements.screencastDetail.textContent = streams 1347 - .map(s => `${s.position || 'unknown'} ${s.connector || 'unknown'}`) 1348 - .join(', '); 1460 + channels.forEach(ch => { 1461 + const result = ch.extract(); 1462 + if (!result) { 1463 + ch.statusEl.textContent = ch.idleText; 1464 + ch.detailEl.textContent = ''; 1465 + } else if (result.processor) { 1466 + // Describe/transcribe processor logic 1467 + const p = result.processor; 1468 + const isRunning = !!p.running; 1469 + const queued = p.queued?.length || 0; 1470 + if (isRunning && queued > 0) { 1471 + ch.statusEl.textContent = `Running (+${queued} queued)`; 1472 + } else if (isRunning) { 1473 + ch.statusEl.textContent = 'Running'; 1474 + } else if (queued > 0) { 1475 + ch.statusEl.textContent = `Queued: ${queued}`; 1349 1476 } else { 1350 - elements.screencastDetail.textContent = ''; 1477 + ch.statusEl.textContent = 'Idle'; 1351 1478 } 1352 - } else { 1353 - elements.screencastStatus.textContent = 'Not recording'; 1354 - elements.screencastDetail.textContent = ''; 1355 - } 1356 - } else { 1357 - elements.screencastStatus.textContent = 'Not recording'; 1358 - elements.screencastDetail.textContent = ''; 1359 - } 1360 - 1361 - // Tmux 1362 - if (tmux?.tmux) { 1363 - if (tmux.tmux.capturing) { 1364 - const captures = tmux.tmux.captures || 0; 1365 - const sessions = tmux.tmux.sessions || []; 1366 - const elapsed = tmux.tmux.window_elapsed_seconds || 0; 1367 - const mins = Math.max(1, Math.round(elapsed / 60)); 1368 - elements.tmuxStatus.textContent = `Capturing (${captures} snapshots, ~${mins} min)`; 1369 - if (sessions.length > 0) { 1370 - elements.tmuxDetail.textContent = sessions.join(', '); 1479 + if (isRunning && p.running.file) { 1480 + ch.detailEl.textContent = truncate(p.running.file.split('/').pop(), 30); 1371 1481 } else { 1372 - elements.tmuxDetail.textContent = ''; 1482 + ch.detailEl.textContent = ''; 1373 1483 } 1374 1484 } else { 1375 - elements.tmuxStatus.textContent = 'Not capturing'; 1376 - elements.tmuxDetail.textContent = ''; 1485 + ch.statusEl.textContent = result.status; 1486 + ch.detailEl.textContent = result.detail || ''; 1377 1487 } 1378 - } else if (tmux) { 1379 - elements.tmuxStatus.textContent = 'Not capturing'; 1380 - elements.tmuxDetail.textContent = ''; 1381 - } else { 1382 - elements.tmuxStatus.textContent = 'Not capturing'; 1383 - elements.tmuxDetail.textContent = ''; 1384 - } 1385 - 1386 - // Audio 1387 - if (primary.audio) { 1388 - const hits = primary.audio.threshold_hits || 0; 1389 - const willSave = primary.audio.will_save ? ' · saving' : ''; 1390 - if (hits > 0) { 1391 - elements.audioStatus.textContent = `Listening (${hits} sound${hits === 1 ? '' : 's'} detected)${willSave}`; 1392 - } else { 1393 - elements.audioStatus.textContent = 'Listening (quiet)'; 1394 - } 1395 - elements.audioDetail.textContent = ''; 1396 - } else { 1397 - elements.audioStatus.textContent = 'Listening (quiet)'; 1398 - elements.audioDetail.textContent = ''; 1399 - } 1400 - 1401 - // Activity 1402 - if (primary.activity) { 1403 - const idleMs = primary.activity.idle_time_ms || 0; 1404 - if (primary.activity.power_save) { 1405 - elements.activityStatus.textContent = 'Power saving'; 1406 - } else if (primary.activity.screen_locked) { 1407 - elements.activityStatus.textContent = 'Screen locked'; 1408 - } else if (primary.activity.sink_muted) { 1409 - elements.activityStatus.textContent = 'Audio muted'; 1410 - } else { 1411 - elements.activityStatus.textContent = `Idle: ${Math.floor(idleMs/1000)}s`; 1412 - } 1413 - elements.activityDetail.textContent = ''; 1414 - } else { 1415 - elements.activityStatus.textContent = 'Idle'; 1416 - elements.activityDetail.textContent = ''; 1417 - } 1418 - 1419 - // Helper for describe/transcribe status (running is an object {file, ref}, not array) 1420 - function updateProcessorStatus(processor, statusEl, detailEl) { 1421 - if (!processor) return; 1422 - 1423 - const isRunning = !!processor.running; 1424 - const queued = processor.queued?.length || 0; 1425 - 1426 - if (isRunning && queued > 0) { 1427 - statusEl.textContent = `Running (+${queued} queued)`; 1428 - } else if (isRunning) { 1429 - statusEl.textContent = 'Running'; 1430 - } else if (queued > 0) { 1431 - statusEl.textContent = `Queued: ${queued}`; 1432 - } else { 1433 - statusEl.textContent = 'Idle'; 1434 - } 1435 - 1436 - // Show current file being processed 1437 - if (isRunning && processor.running.file) { 1438 - const file = processor.running.file.split('/').pop(); 1439 - detailEl.textContent = truncate(file, 30); 1440 - } else { 1441 - detailEl.textContent = ''; 1442 - } 1443 - } 1444 - 1445 - if (primary.describe) { 1446 - updateProcessorStatus(primary.describe, elements.describeStatus, elements.describeDetail); 1447 - } else { 1448 - elements.describeStatus.textContent = 'Idle'; 1449 - elements.describeDetail.textContent = ''; 1450 - } 1451 - if (primary.transcribe) { 1452 - updateProcessorStatus(primary.transcribe, elements.transcribeStatus, elements.transcribeDetail); 1453 - } else { 1454 - elements.transcribeStatus.textContent = 'Idle'; 1455 - elements.transcribeDetail.textContent = ''; 1456 - } 1488 + }); 1457 1489 1458 1490 updateStatusSummary(); 1459 1491 } ··· 1637 1669 const d = state.dream; 1638 1670 1639 1671 // Info fields 1640 - const infoParts = []; 1641 - if (d.mode) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Mode</div><div>${escapeHtml(d.mode)}</div></div>`); 1642 - if (d.day) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Day</div><div>${escapeHtml(d.day)}</div></div>`); 1643 - if (d.facet) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Facet</div><div>${escapeHtml(d.facet)}</div></div>`); 1644 - if (d.segment) infoParts.push(`<div class="dream-info-item"><div class="dream-info-label">Segment</div><div>${escapeHtml(d.segment)}</div></div>`); 1645 - elements.dreamInfo.innerHTML = infoParts.join(''); 1672 + elements.dreamInfo.innerHTML = renderInfoItems([ 1673 + { label: 'Mode', value: d.mode ? escapeHtml(d.mode) : null }, 1674 + { label: 'Day', value: d.day ? escapeHtml(d.day) : null }, 1675 + { label: 'Facet', value: d.facet ? escapeHtml(d.facet) : null }, 1676 + { label: 'Segment', value: d.segment ? escapeHtml(d.segment) : null }, 1677 + ]); 1646 1678 1647 1679 // Progress bars 1648 1680 let progressHtml = ''; ··· 1697 1729 1698 1730 elements.syncCard.classList.remove('hidden'); 1699 1731 1700 - const infoParts = []; 1701 - if (s.host) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Host</div><div>${escapeHtml(s.host)}</div></div>`); 1702 - if (s.platform) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Platform</div><div>${escapeHtml(s.platform)}</div></div>`); 1703 - infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Queue</div><div>${s.queue_size}</div></div>`); 1704 - if (s.state) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">State</div><div>${escapeHtml(s.state)}${s.confirm_attempt ? ' (' + escapeHtml(s.confirm_attempt) + ')' : ''}</div></div>`); 1705 - if (s.segment) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Segment</div><div>${escapeHtml(s.segment)}</div></div>`); 1706 - elements.syncInfo.innerHTML = infoParts.join(''); 1732 + elements.syncInfo.innerHTML = renderInfoItems([ 1733 + { label: 'Host', value: s.host ? escapeHtml(s.host) : null }, 1734 + { label: 'Platform', value: s.platform ? escapeHtml(s.platform) : null }, 1735 + { label: 'Queue', value: String(s.queue_size) }, 1736 + { label: 'State', value: s.state ? escapeHtml(s.state) + (s.confirm_attempt ? ' (' + escapeHtml(s.confirm_attempt) + ')' : '') : null }, 1737 + { label: 'Segment', value: s.segment ? escapeHtml(s.segment) : null }, 1738 + ]); 1707 1739 if (elements.trustIndicator) { 1708 1740 if (s && s.host) { 1709 1741 elements.trustIndicator.textContent = 'All data stored locally · Syncing to ' + s.host;