personal memory agent
0
fork

Configure Feed

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

convey: unify app surface states

+366 -386
+18 -40
apps/graph/workspace.html
··· 26 26 27 27 <!-- Graph container --> 28 28 <div id="graph-container" style="flex:1;position:relative;min-height:200px;"> 29 - <div class="graph-loading" id="graph-loading"><div class="graph-spinner"></div>loading knowledge graph...</div> 29 + <div class="graph-loading" id="graph-loading"> 30 + <div class="surface-state surface-state--loading" role="status" aria-busy="true"> 31 + <div class="surface-state-spinner" aria-hidden="true"></div> 32 + <span class="surface-state-text" data-role="loading-status">loading knowledge graph...</span> 33 + </div> 34 + </div> 30 35 <div class="graph-empty" id="graph-empty" style="display:none;"> 31 - <div class="graph-empty-icon">🕸️</div> 32 - <h2>Your knowledge graph builds itself from daily use</h2> 33 - <p>As solstone captures your meetings, conversations, and work, entities and relationships appear here automatically.</p> 34 - <a href="/app/home" class="graph-empty-action">get started with solstone →</a> 36 + <div class="surface-state surface-state--empty"> 37 + <div class="surface-state-icon" aria-hidden="true">🕸️</div> 38 + <h2 class="surface-state-heading">Your knowledge graph builds itself from daily use</h2> 39 + <p class="surface-state-desc">As solstone captures your meetings, conversations, and work, entities and relationships appear here automatically.</p> 40 + <div class="surface-state-action"><a href="/app/home" class="graph-empty-action">get started with solstone →</a></div> 41 + </div> 35 42 </div> 36 43 <div class="graph-error" id="graph-error" style="display:none;"> 37 - <div class="graph-error-icon">⚠️</div> 38 - <h2>couldn't load the knowledge graph</h2> 39 - <p>this usually means the server didn't respond. <a href="#" id="graph-retry">try again</a></p> 44 + <div class="surface-state surface-state--error" role="alert"> 45 + <div class="surface-state-icon" aria-hidden="true">⚠️</div> 46 + <h2 class="surface-state-heading">couldn't load the knowledge graph</h2> 47 + <p class="surface-state-desc">this usually means the server didn't respond.</p> 48 + <div class="surface-state-action"><a href="#" id="graph-retry">try again</a></div> 49 + </div> 40 50 </div> 41 51 <div id="graph-canvas" tabindex="0" role="img" aria-label="Knowledge graph" style="width:100%;height:100%;display:none;"></div> 42 52 <div id="graph-stabilize"><div id="graph-stabilize-bar"></div></div> ··· 240 250 max-width: 450px; 241 251 margin: 2em auto; 242 252 } 243 - .graph-empty-icon { 244 - font-size: 4em; 245 - margin-bottom: 0.25em; 246 - } 247 - .graph-error-icon { 248 - font-size: 4em; 249 - margin-bottom: 0.25em; 250 - } 251 - .graph-empty h2 { 252 - margin: 0 0 0.5em 0; 253 - font-size: 1.3em; 254 - font-weight: 600; 255 - color: #333; 256 - line-height: 1.3; 257 - } 258 - .graph-error h2 { 259 - margin: 0 0 0.5em 0; 260 - font-size: 1.3em; 261 - font-weight: 600; 262 - color: #333; 263 - line-height: 1.3; 264 - } 265 - .graph-empty p { 266 - margin: 0; 267 - color: #666; 268 - line-height: 1.5; 269 - } 270 253 .graph-empty-action { 271 254 display: inline-block; 272 255 margin-top: 1em; ··· 277 260 .graph-empty-action:hover { text-decoration: underline; } 278 261 .graph-empty-action:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; } 279 262 #graph-canvas:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; } 280 - .graph-error p { 281 - margin: 0; 282 - color: #666; 283 - line-height: 1.5; 284 - } 285 263 #graph-retry { 286 264 color: #2563eb; 287 265 text-decoration: none;
+6 -24
apps/health/workspace.html
··· 325 325 display: none; 326 326 } 327 327 328 - .observe-empty { 329 - text-align: left; 330 - color: #6b7280; 331 - padding: 1em 1.5em; 332 - font-size: 0.95em; 333 - } 334 - 335 - .observe-empty.hidden { 328 + #observeEmpty.hidden { 336 329 display: none; 337 - } 338 - 339 - .observe-empty a { 340 - color: #10b981; 341 - text-decoration: none; 342 - font-weight: 500; 343 - } 344 - 345 - .observe-empty a:hover { 346 - text-decoration: underline; 347 - } 348 - 349 - .observe-empty a:active { 350 - color: #059669; 351 330 } 352 331 353 332 .observe-section { ··· 1131 1110 <span id="observeModeLabel">Waiting...</span> 1132 1111 </div> 1133 1112 </div> 1134 - <div class="observe-empty" id="observeEmpty"> 1135 - No observers connected — <a href="/app/observer/">Manage observers →</a> 1113 + <div id="observeEmpty"> 1114 + <div class="surface-state surface-state--empty"> 1115 + <h2 class="surface-state-heading">No observers connected</h2> 1116 + <div class="surface-state-action"><a href="/app/observer/">Manage observers →</a></div> 1117 + </div> 1136 1118 </div> 1137 1119 <div class="observe-content hidden" id="observeContent"> 1138 1120 <div class="observe-section">
+43 -129
apps/search/workspace.html
··· 324 324 color: #aaa; 325 325 } 326 326 327 - /* Empty state */ 328 - .search-empty { 329 - text-align: center; 330 - padding: 0 1rem; 331 - margin: auto 0; 332 - color: #6b7280; 333 - } 334 - 335 - .search-empty-icon { 336 - font-size: 2.5rem; 337 - margin-bottom: 1rem; 338 - } 339 - 340 - .search-empty-text { 341 - font-size: 0.9rem; 342 - } 343 - 344 - /* Error state */ 345 - .search-error { 346 - display: flex; 347 - flex-direction: column; 348 - align-items: center; 349 - justify-content: center; 350 - padding: 3rem 1.5rem; 351 - text-align: center; 352 - gap: 0.5rem; 353 - } 354 - 355 - .search-error-icon { 356 - font-size: 2.5rem; 357 - } 358 - 359 - .search-error-text { 360 - font-size: 0.95rem; 361 - color: #92400e; 362 - font-weight: 500; 363 - } 364 - 365 - .search-error-hint { 366 - font-size: 0.85rem; 367 - color: #6b7280; 368 - } 369 - 370 - .search-error-retry { 371 - margin-top: 0.75rem; 372 - padding: 0.4rem 1rem; 373 - border: 1px solid #d1d5db; 374 - border-radius: 0.375rem; 375 - background: #fff; 376 - color: #374151; 377 - font-size: 0.85rem; 378 - cursor: pointer; 379 - transition: background 0.1s ease, border-color 0.1s ease; 380 - } 381 - 382 - .search-error-retry:hover { 383 - background: #f3f4f6; 384 - border-color: #9ca3af; 385 - } 386 - 387 - .search-error-retry:active { 388 - background: #e5e7eb; 389 - border-color: #6b7280; 390 - } 391 - 392 327 /* No-results suggestions */ 393 328 .search-noresults-suggestions { 394 329 margin-top: 1rem; ··· 430 365 padding: 0.25rem 0.5rem; 431 366 } 432 367 433 - .search-empty h2 { 434 - font-size: 1.1rem; 435 - font-weight: 600; 436 - color: #374151; 437 - margin: 0 0 0.5rem 0; 438 - } 439 - 440 - .search-empty p { 441 - font-size: 0.9rem; 442 - color: #6b7280; 443 - margin: 0 0 1.25rem 0; 444 - line-height: 1.5; 445 - } 446 - 447 368 .search-example-chips { 448 369 display: flex; 449 370 flex-wrap: wrap; ··· 480 401 } 481 402 482 403 /* Loading state */ 483 - .search-loading { 484 - text-align: center; 485 - padding: 2rem; 486 - color: #6b7280; 487 - } 488 - 489 404 /* Responsive */ 490 405 @media (max-width: 768px) { 491 406 .search-container { ··· 564 479 <div id="search-announcer" class="visually-hidden" aria-live="polite"></div> 565 480 566 481 <div id="results-container"> 567 - <div class="search-empty" id="search-empty"> 568 - <div class="search-empty-icon">🔍</div> 569 - <h2>search your journal</h2> 570 - <p>find conversations, ideas, and moments — try one of these</p> 571 - <div class="search-example-chips"> 572 - <button class="search-example-chip" type="button">a recent meeting</button> 573 - <button class="search-example-chip" type="button">something I learned</button> 574 - <button class="search-example-chip" type="button">project ideas</button> 482 + <div class="surface-state surface-state--empty" id="search-empty"> 483 + <div class="surface-state-icon" aria-hidden="true">🔍</div> 484 + <h2 class="surface-state-heading">search your journal</h2> 485 + <p class="surface-state-desc">find conversations, ideas, and moments — try one of these</p> 486 + <div class="surface-state-action"> 487 + <div class="search-example-chips"> 488 + <button class="search-example-chip" type="button">a recent meeting</button> 489 + <button class="search-example-chip" type="button">something I learned</button> 490 + <button class="search-example-chip" type="button">project ideas</button> 491 + </div> 575 492 </div> 576 493 </div> 577 494 </div> ··· 609 526 const searchInputForm = document.getElementById('search-input-form'); 610 527 const searchAnnouncer = document.getElementById('search-announcer'); 611 528 612 - // Initialize 613 - loadFromHash(); 614 - searchInput.focus(); 529 + function bootstrapSearch() { 530 + loadFromHash(); 531 + searchInput.focus(); 532 + } 533 + 534 + if (document.readyState === 'loading') { 535 + document.addEventListener('DOMContentLoaded', bootstrapSearch, { once: true }); 536 + } else { 537 + bootstrapSearch(); 538 + } 615 539 616 540 // Example chip delegation 617 541 resultsContainer.addEventListener('click', function(e) { ··· 706 630 707 631 if (reset) { 708 632 dayOffset = 0; 709 - resultsContainer.innerHTML = '<div class="search-loading">Searching...</div>'; 633 + resultsContainer.innerHTML = window.SurfaceState.loading({ text: 'Searching...' }); 710 634 announce('searching…'); 711 635 } 712 636 ··· 734 658 .catch(err => { 735 659 if (err.name === 'AbortError') return; 736 660 console.error('Search error:', err); 737 - resultsContainer.innerHTML = ` 738 - <div class="search-error"> 739 - <div class="search-error-icon">⚠️</div> 740 - <div class="search-error-text">something went wrong with that search</div> 741 - <div class="search-error-hint">could be a network issue or the server might be busy — give it a moment</div> 742 - <button class="search-error-retry">try again</button> 743 - </div> 744 - `; 661 + resultsContainer.innerHTML = window.SurfaceState.error({ 662 + icon: '⚠️', 663 + heading: 'something went wrong with that search', 664 + desc: 'could be a network issue or the server might be busy — give it a moment', 665 + action: '<button class="search-error-retry">try again</button>' 666 + }); 745 667 resultsContainer.querySelector('.search-error-retry').onclick = () => doSearch(true); 746 668 announce('search failed, use the try again button to retry'); 747 669 }); ··· 828 750 829 751 function renderResults(data, reset) { 830 752 if (data.days.length === 0 && reset) { 831 - const container = document.createElement('div'); 832 - container.className = 'search-empty'; 833 - 834 - container.innerHTML = ` 835 - <div class="search-empty-icon">🔍</div> 836 - <div class="search-empty-text">no results found for "${escapeHtml(currentQuery)}"</div> 837 - `; 753 + const suggestionsHtml = '<div class="search-noresults-suggestions">' 754 + + '<span>try different keywords or check your spelling</span>' 755 + + (currentFacet ? `<button type="button" data-clear="facet">search all facets instead of ${escapeHtml(currentFacet)}</button>` : '') 756 + + (currentAgent ? `<button type="button" data-clear="agent">search all agents instead of ${escapeHtml(currentAgent)}</button>` : '') 757 + + '</div>'; 838 758 839 - const suggestions = document.createElement('div'); 840 - suggestions.className = 'search-noresults-suggestions'; 841 - suggestions.innerHTML = '<span>try different keywords or check your spelling</span>'; 759 + resultsContainer.innerHTML = window.SurfaceState.empty({ 760 + icon: '🔍', 761 + heading: `no results found for "${currentQuery}"`, 762 + action: suggestionsHtml 763 + }); 842 764 843 - if (currentFacet) { 844 - const btn = document.createElement('button'); 845 - btn.textContent = 'search all facets instead of ' + currentFacet; 846 - btn.onclick = () => { 765 + const clearFacetBtn = resultsContainer.querySelector('[data-clear="facet"]'); 766 + if (clearFacetBtn) { 767 + clearFacetBtn.onclick = () => { 847 768 currentFacet = ''; 848 769 updateHash(); 849 770 doSearch(true); 850 771 }; 851 - suggestions.appendChild(btn); 852 772 } 853 773 854 - if (currentAgent) { 855 - const btn = document.createElement('button'); 856 - btn.textContent = 'search all agents instead of ' + currentAgent; 857 - btn.onclick = () => { 774 + const clearAgentBtn = resultsContainer.querySelector('[data-clear="agent"]'); 775 + if (clearAgentBtn) { 776 + clearAgentBtn.onclick = () => { 858 777 currentAgent = ''; 859 778 updateHash(); 860 779 doSearch(true); 861 780 }; 862 - suggestions.appendChild(btn); 863 781 } 864 - 865 - container.appendChild(suggestions); 866 - resultsContainer.innerHTML = ''; 867 - resultsContainer.appendChild(container); 868 782 869 783 let msg = 'no results found'; 870 784 if (currentFacet || currentAgent) msg += ', try clearing filters';
+29 -58
apps/speakers/workspace.html
··· 106 106 font-size: 14px; 107 107 } 108 108 109 - .spk-empty-state { 110 - display: flex; 111 - flex-direction: column; 112 - align-items: center; 113 - justify-content: flex-start; 114 - height: 100%; 115 - text-align: center; 116 - color: #9ca3af; 117 - padding: 80px 24px 24px; 118 - gap: 12px; 119 - } 120 - 121 - .spk-segments .spk-empty-state { 122 - justify-content: flex-start; 109 + .spk-segments .surface-state { 123 110 padding: 24px 16px; 124 111 } 125 112 126 - .spk-detail > .spk-empty-state { 113 + .spk-detail > .surface-state { 114 + flex: 1; 127 115 background: #f9fafb; 128 - border-radius: 12px; 129 116 border: 1px dashed #e5e7eb; 117 + border-radius: 12px; 130 118 margin: 20px; 131 - flex: 1; 132 - } 133 - 134 - .spk-empty-icon svg { 135 - width: 48px; 136 - height: 48px; 137 - stroke: #d1d5db; 138 - fill: none; 139 - stroke-width: 1.5; 140 - stroke-linecap: round; 141 - stroke-linejoin: round; 142 - } 143 - 144 - .spk-empty-heading { 145 - font-size: 16px; 146 - font-weight: 500; 147 - color: #4b5563; 148 - margin: 0; 149 - } 150 - 151 - .spk-empty-desc { 152 - font-size: 14px; 153 - color: #9ca3af; 154 - line-height: 1.5; 155 - max-width: 36ch; 156 - margin: 0; 119 + padding: 80px 24px 24px; 157 120 } 158 121 159 122 .spk-detail { ··· 1087 1050 </nav> 1088 1051 1089 1052 <div class="spk-detail" id="spkDetail" aria-live="polite"> 1090 - <div class="spk-empty-state"> 1091 - <div class="spk-empty-icon"><svg viewBox="0 0 24 24"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"></path></svg></div> 1092 - <p class="spk-empty-heading">ready to review</p> 1093 - <p class="spk-empty-desc">select a segment from the list to review speaker assignments</p> 1053 + <div class="surface-state surface-state--empty"> 1054 + <div class="surface-state-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"></path></svg></div> 1055 + <h2 class="surface-state-heading">ready to review</h2> 1056 + <p class="surface-state-desc">select a segment from the list to review speaker assignments</p> 1094 1057 </div> 1095 1058 </div> 1096 1059 </div> ··· 1141 1104 audio.style.display = 'none'; 1142 1105 }); 1143 1106 }); 1144 - } 1145 - 1146 - function emptyStateHTML(icon, heading, desc) { 1147 - return '<div class="spk-empty-state">' + 1148 - '<div class="spk-empty-icon">' + icon + '</div>' + 1149 - '<p class="spk-empty-heading">' + heading + '</p>' + 1150 - '<p class="spk-empty-desc">' + desc + '</p>' + 1151 - '</div>'; 1152 1107 } 1153 1108 1154 1109 const METHOD_DISPLAY = { ··· 1524 1479 1525 1480 function renderSegmentList() { 1526 1481 if (segments.length === 0) { 1527 - segmentList.innerHTML = '<li>' + emptyStateHTML(emptyIcons.segment, 'no speakers discovered today', 'solstone hasn\'t found any speaker segments for this day yet') + '</li>'; 1482 + segmentList.innerHTML = '<li>' + window.SurfaceState.empty({ 1483 + icon: emptyIcons.segment, 1484 + heading: 'no speakers discovered today', 1485 + desc: 'solstone hasn\'t found any speaker segments for this day yet' 1486 + }) + '</li>'; 1528 1487 const header = document.querySelector('.spk-segments-header'); 1529 1488 if (header) { 1530 1489 header.textContent = 'Segments'; ··· 1613 1572 1614 1573 function renderDetail(seg) { 1615 1574 if (!seg.sources.length) { 1616 - detailPanel.innerHTML = emptyStateHTML(emptyIcons.audio, 'no audio sources', 'this segment doesn\'t have any observed audio media'); 1575 + detailPanel.innerHTML = window.SurfaceState.empty({ 1576 + icon: emptyIcons.audio, 1577 + heading: 'no audio sources', 1578 + desc: 'this segment doesn\'t have any observed audio media' 1579 + }); 1617 1580 return; 1618 1581 } 1619 1582 ··· 1713 1676 const unmatchedCount = unmatched ? unmatched.length : 0; 1714 1677 1715 1678 if (!hasMatched && !hasUnmatched) { 1716 - container.innerHTML = emptyStateHTML(emptyIcons.people, 'no speakers found', 'no speaker voices were detected in this segment'); 1679 + container.innerHTML = window.SurfaceState.empty({ 1680 + icon: emptyIcons.people, 1681 + heading: 'no speakers found', 1682 + desc: 'no speaker voices were detected in this segment' 1683 + }); 1717 1684 return; 1718 1685 } 1719 1686 ··· 1806 1773 const countLabel = countLabelForFilter(sentences.length, total); 1807 1774 1808 1775 if (total === 0) { 1809 - container.innerHTML = emptyStateHTML(emptyIcons.text, 'no sentences to review', 'this segment doesn\'t have any sentences to work with yet'); 1776 + container.innerHTML = window.SurfaceState.empty({ 1777 + icon: emptyIcons.text, 1778 + heading: 'no sentences to review', 1779 + desc: 'this segment doesn\'t have any sentences to work with yet' 1780 + }); 1810 1781 return; 1811 1782 } 1812 1783
+12 -58
apps/tokens/workspace.html
··· 1 1 <main class="tokens-dashboard"> 2 - <div class="loading-state" id="loading"> 3 - <p>Loading token usage data...</p> 2 + <div id="loading"> 3 + <div class="surface-state surface-state--loading" role="status" aria-busy="true"> 4 + <div class="surface-state-spinner" aria-hidden="true"></div> 5 + <span class="surface-state-text" data-role="loading-status">Loading token usage data...</span> 6 + </div> 4 7 </div> 5 8 6 9 <div class="dashboard-content" id="dashboard" style="display: none;"> ··· 166 169 padding: 1em 1.5em; 167 170 } 168 171 169 - .loading-state { 172 + #loading { 170 173 display: flex; 171 174 align-items: center; 172 175 justify-content: center; 173 176 min-height: 50vh; 174 - color: #666; 175 - } 176 - 177 - .loading-state p { 178 - font-size: 0.95em; 179 - font-weight: 500; 180 177 } 181 178 182 179 .summary-card { ··· 418 415 border: 0; 419 416 } 420 417 421 - .error-state { 422 - text-align: center; 423 - padding: 2em; 424 - } 425 - 426 - .error-state h2 { 427 - color: #dc2626; 428 - margin-bottom: 0.5em; 429 - font-size: 1.1em; 430 - } 431 - 432 - .error-hint { 433 - color: #888; 434 - font-size: 0.9em; 435 - margin-bottom: 1.5em; 436 - } 437 - 438 - .error-state button { 439 - background: var(--facet-color, #b06a1a); 440 - color: white; 441 - border: none; 442 - padding: 0.6em 1.8em; 443 - border-radius: 6px; 444 - cursor: pointer; 445 - font-size: 0.95em; 446 - transition: opacity 0.15s ease, transform 0.15s ease; 447 - } 448 - 449 - .error-state button:hover { 450 - opacity: 0.9; 451 - } 452 - 453 - .error-state button:active { 454 - opacity: 0.8; 455 - transform: scale(0.97); 456 - } 457 - 458 - .error-state button:focus-visible { 459 - outline: 2px solid var(--facet-color, #b06a1a); 460 - outline-offset: 2px; 461 - } 462 - 463 418 .skipped-notice { 464 419 background: rgba(0, 0, 0, 0.03); 465 420 border: 1px solid var(--facet-border, #e5e0db); ··· 526 481 .breakdown-table th.sortable, 527 482 .breakdown-table th.sortable::after, 528 483 .breakdown-table tbody tr, 529 - .search-box input, 530 - .error-state button { 484 + .search-box input { 531 485 transition-duration: 0.01ms !important; 532 486 } 533 487 } ··· 679 633 } catch (error) { 680 634 console.error('Failed to load token data:', error); 681 635 document.getElementById('loading').innerHTML = 682 - '<div class="error-state">' + 683 - '<h2>Unable to load token data</h2>' + 684 - '<p class="error-hint">Try refreshing, or check the health dashboard if this persists.</p>' + 685 - '<button onclick="document.getElementById(\'loading\').innerHTML = \'<p>Loading token usage data...</p>\'; loadTokenData(currentDay);">Retry</button>' + 686 - '</div>'; 636 + window.SurfaceState.error({ 637 + heading: 'Unable to load token data', 638 + desc: 'Try refreshing, or check the health dashboard if this persists.', 639 + action: '<button onclick="document.getElementById(\'loading\').innerHTML = window.SurfaceState.loading({ text: \'Loading token usage data...\' }); loadTokenData(currentDay);">Retry</button>' 640 + }); 687 641 } 688 642 } 689 643
+63 -77
apps/transcripts/workspace.html
··· 573 573 bottom: 12px; 574 574 } 575 575 576 + .tr-zoom-segments > .surface-state { 577 + position: absolute; 578 + inset: 0; 579 + padding: 16px 12px; 580 + gap: 8px; 581 + } 582 + 583 + .tr-zoom-segments > .surface-state .surface-state-icon svg { 584 + width: 32px; 585 + height: 32px; 586 + } 587 + 588 + .tr-zoom-segments > .surface-state .surface-state-heading { 589 + font-size: 13px; 590 + } 591 + 592 + .tr-zoom-segments > .surface-state .surface-state-desc { 593 + font-size: 11px; 594 + max-width: 24ch; 595 + } 596 + 576 597 /* Segment pills in zoom view */ 577 598 .tr-zoom-pill { 578 599 position: absolute; ··· 616 637 .tr-zoom-pill-both { 617 638 background: linear-gradient(to right, rgba(134, 239, 172, 0.75), rgba(194, 234, 155, 0.7) 50%, rgba(253, 230, 138, 0.75)); 618 639 border: 1px solid #84cc16; 619 - } 620 - 621 - .tr-zoom-empty { 622 - position: absolute; 623 - inset: 0; 624 - display: flex; 625 - align-items: center; 626 - justify-content: center; 627 - color: #6b7280; 628 - font-style: italic; 629 - font-size: 11px; 630 - text-align: center; 631 - padding: 24px; 632 640 } 633 641 634 642 /* Dragging state */ ··· 1143 1151 background: #eab308; 1144 1152 } 1145 1153 1146 - .tr-unified-empty { 1147 - text-align: center; 1148 - color: #9ca3af; 1149 - padding: 24px; 1150 - } 1151 - 1152 - .tr-empty-state { 1153 - display: flex; 1154 - flex-direction: column; 1155 - align-items: center; 1156 - justify-content: center; 1157 - height: 100%; 1158 - text-align: center; 1159 - color: #9ca3af; 1160 - padding: 24px; 1161 - gap: 12px; 1162 - } 1163 - 1164 - .tr-empty-icon svg { 1165 - width: 48px; 1166 - height: 48px; 1167 - stroke: #d1d5db; 1168 - fill: none; 1169 - stroke-width: 1.5; 1170 - stroke-linecap: round; 1171 - stroke-linejoin: round; 1172 - } 1173 - 1174 - .tr-empty-heading { 1175 - font-size: 17px; 1176 - font-weight: 500; 1177 - color: #4b5563; 1178 - margin: 0; 1179 - } 1180 - 1181 - .tr-empty-desc { 1182 - font-size: 14px; 1183 - color: #9ca3af; 1184 - margin: 0; 1185 - line-height: 1.5; 1186 - max-width: 32ch; 1154 + .tr-panel > .surface-state { 1155 + min-height: 100%; 1187 1156 } 1188 1157 1189 1158 .tr-warning-notice { ··· 1428 1397 <svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> 1429 1398 <span id="trWarningText"></span> 1430 1399 </div> 1431 - <div class="tr-panel" id="trPanel"></div> 1400 + <div class="tr-panel" id="trPanel"> 1401 + <div class="surface-state surface-state--empty"> 1402 + <div class="surface-state-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg></div> 1403 + <h2 class="surface-state-heading">your day at a glance</h2> 1404 + <p class="surface-state-desc">select a segment from the timeline to view its transcript</p> 1405 + </div> 1406 + </div> 1432 1407 </div> 1433 1408 </div> 1434 1409 </div> ··· 1509 1484 audio: '<svg viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>', 1510 1485 screen: '<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>' 1511 1486 }; 1512 - 1513 - function emptyStateHTML(icon, heading, desc) { 1514 - return '<div class="tr-empty-state">' + 1515 - '<div class="tr-empty-icon">' + icon + '</div>' + 1516 - '<p class="tr-empty-heading">' + heading + '</p>' + 1517 - '<p class="tr-empty-desc">' + desc + '</p>' + 1518 - '</div>'; 1519 - } 1520 - 1521 - panel.innerHTML = emptyStateHTML(emptyIcons.day, 'your day at a glance', 'select a segment from the timeline to view its transcript'); 1522 1487 1523 1488 // State 1524 1489 let height = timeline.clientHeight; ··· 2142 2107 2143 2108 if (filtered.length === 0) { 2144 2109 document.getElementById('trNavHint').classList.remove('visible'); 2145 - const empty = document.createElement('div'); 2146 - empty.className = 'tr-zoom-empty'; 2147 - empty.textContent = 'No segments in selected range'; 2148 - zoomSegments.appendChild(empty); 2110 + zoomSegments.innerHTML = window.SurfaceState.empty({ 2111 + icon: emptyIcons.nothing, 2112 + heading: 'no segments in this range', 2113 + desc: 'widen the time range or pick a different day' 2114 + }); 2149 2115 return; 2150 2116 } 2151 2117 ··· 2281 2247 tabsContainer.classList.remove('visible'); 2282 2248 tabsContainer.innerHTML = ''; 2283 2249 document.getElementById('trWarningNotice').classList.remove('visible'); 2284 - panel.innerHTML = '<div class="tr-unified-empty"><p>Loading segment...</p></div>'; 2250 + panel.innerHTML = window.SurfaceState.loading({ text: 'Loading segment...' }); 2285 2251 panel.classList.add('loading'); 2286 2252 2287 2253 fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`, { signal }) ··· 2321 2287 panel.classList.remove('loading'); 2322 2288 tabsContainer.classList.remove('visible'); 2323 2289 tabsContainer.innerHTML = ''; 2324 - panel.innerHTML = emptyStateHTML(emptyIcons.transcript, 'couldn\'t load this segment', 'something went wrong loading the transcript. try selecting the segment again, or refresh the page.'); 2290 + panel.innerHTML = window.SurfaceState.error({ 2291 + icon: emptyIcons.transcript, 2292 + heading: 'couldn\'t load this segment', 2293 + desc: 'something went wrong loading the transcript. try selecting the segment again, or refresh the page.' 2294 + }); 2325 2295 }); 2326 2296 } 2327 2297 ··· 2487 2457 renderSegmentTimeline(segmentData, true, false, pane); 2488 2458 } else if (tabId === 'screen') { 2489 2459 const segmentToken = selectedSegment?.key; 2490 - pane.innerHTML = '<div class="tr-unified-empty"><p data-role="loading-status">Loading screen entries...</p></div>'; 2460 + pane.innerHTML = window.SurfaceState.loading({ text: 'Loading screen entries...' }); 2491 2461 prepareScreenFrames( 2492 2462 segmentData, 2493 2463 pane, ··· 2510 2480 if (tabPanes[tabId] !== pane) { 2511 2481 return; 2512 2482 } 2513 - pane.innerHTML = emptyStateHTML(emptyIcons.screen, 'couldn\'t load screen entries', 'something went wrong decoding the screen data. try selecting the segment again.'); 2483 + pane.innerHTML = window.SurfaceState.error({ 2484 + icon: emptyIcons.screen, 2485 + heading: 'couldn\'t load screen entries', 2486 + desc: 'something went wrong decoding the screen data. try selecting the segment again.' 2487 + }); 2514 2488 }); 2515 2489 } else if (tabId.startsWith('md-')) { 2516 2490 const stem = tabId.slice(3); ··· 2547 2521 screen: { icon: emptyIcons.screen, heading: 'no screen entries', desc: 'this segment has no screen captures' } 2548 2522 }; 2549 2523 const emptyInfo = tabEmptyMap[tabType] || tabEmptyMap.transcript; 2550 - targetEl.innerHTML = emptyStateHTML(emptyInfo.icon, emptyInfo.heading, emptyInfo.desc); 2524 + targetEl.innerHTML = window.SurfaceState.empty(emptyInfo); 2551 2525 return; 2552 2526 } 2553 2527 ··· 3159 3133 allSegments = data.segments || []; 3160 3134 updateZoom(); 3161 3135 if (allSegments.length === 0) { 3162 - panel.innerHTML = emptyStateHTML(emptyIcons.nothing, 'nothing captured', 'no recordings were found for this day'); 3136 + panel.innerHTML = window.SurfaceState.empty({ 3137 + icon: emptyIcons.nothing, 3138 + heading: 'nothing captured', 3139 + desc: 'no recordings were found for this day' 3140 + }); 3163 3141 } 3164 3142 3165 3143 // Check for hash fragment to auto-select segment ··· 3184 3162 .catch(err => { 3185 3163 console.error('Failed to load transcript data:', err); 3186 3164 zoomSegments.innerHTML = ''; 3187 - panel.innerHTML = emptyStateHTML(emptyIcons.transcript, 'couldn\'t load transcripts', 'the data service may be offline. try refreshing the page.'); 3165 + panel.innerHTML = window.SurfaceState.error({ 3166 + icon: emptyIcons.transcript, 3167 + heading: 'couldn\'t load transcripts', 3168 + desc: 'the data service may be offline. try refreshing the page.' 3169 + }); 3188 3170 }); 3189 3171 3190 3172 // Handle browser back/forward ··· 3524 3506 tabsContainer.innerHTML = ''; 3525 3507 tabsContainer.classList.remove('visible'); 3526 3508 document.getElementById('trWarningNotice').classList.remove('visible'); 3527 - panel.innerHTML = emptyStateHTML(emptyIcons.day, 'your day at a glance', 'select a segment from the timeline to view its transcript'); 3509 + panel.innerHTML = window.SurfaceState.empty({ 3510 + icon: emptyIcons.day, 3511 + heading: 'your day at a glance', 3512 + desc: 'select a segment from the timeline to view its transcript' 3513 + }); 3528 3514 3529 3515 // Clear active state in zoom view 3530 3516 zoomSegments.querySelectorAll('.tr-zoom-pill').forEach(pill => {
+136
convey/static/app.css
··· 2584 2584 cursor: not-allowed; 2585 2585 } 2586 2586 2587 + .surface-state { 2588 + display: flex; 2589 + flex-direction: column; 2590 + align-items: center; 2591 + justify-content: center; 2592 + gap: 12px; 2593 + width: 100%; 2594 + box-sizing: border-box; 2595 + padding: 24px; 2596 + text-align: center; 2597 + color: #9ca3af; 2598 + } 2599 + 2600 + .surface-state--loading { 2601 + color: #6b7280; 2602 + } 2603 + 2604 + .surface-state-icon { 2605 + color: #d1d5db; 2606 + font-size: 48px; 2607 + line-height: 1; 2608 + } 2609 + 2610 + .surface-state-icon svg { 2611 + width: 48px; 2612 + height: 48px; 2613 + stroke: currentColor; 2614 + fill: none; 2615 + stroke-width: 1.5; 2616 + stroke-linecap: round; 2617 + stroke-linejoin: round; 2618 + } 2619 + 2620 + .surface-state-heading { 2621 + margin: 0; 2622 + font-size: 17px; 2623 + font-weight: 500; 2624 + line-height: 1.3; 2625 + color: #4b5563; 2626 + } 2627 + 2628 + .surface-state-desc { 2629 + margin: 0; 2630 + max-width: 36ch; 2631 + font-size: 14px; 2632 + line-height: 1.5; 2633 + color: #9ca3af; 2634 + } 2635 + 2636 + .surface-state-text { 2637 + display: block; 2638 + margin: 0; 2639 + font-size: 14px; 2640 + font-weight: 500; 2641 + line-height: 1.5; 2642 + color: #6b7280; 2643 + } 2644 + 2645 + .surface-state-action { 2646 + display: flex; 2647 + flex-direction: column; 2648 + align-items: center; 2649 + gap: 8px; 2650 + } 2651 + 2652 + .surface-state-action > button { 2653 + min-height: 40px; 2654 + padding: 0.6em 1.2em; 2655 + border: 1px solid #d1d5db; 2656 + border-radius: 6px; 2657 + background: #fff; 2658 + color: #374151; 2659 + font: inherit; 2660 + cursor: pointer; 2661 + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; 2662 + } 2663 + 2664 + .surface-state-action > button:hover { 2665 + background: #f3f4f6; 2666 + border-color: #9ca3af; 2667 + } 2668 + 2669 + .surface-state-action > button:active { 2670 + background: #e5e7eb; 2671 + border-color: #6b7280; 2672 + transform: scale(0.98); 2673 + } 2674 + 2675 + .surface-state-action > button:focus-visible { 2676 + outline: 2px solid var(--facet-color, #b06a1a); 2677 + outline-offset: 2px; 2678 + } 2679 + 2680 + .surface-state-action > a:not([class]) { 2681 + color: var(--facet-color, #2563eb); 2682 + text-decoration: none; 2683 + font-weight: 500; 2684 + } 2685 + 2686 + .surface-state-action > a:not([class]):hover { 2687 + text-decoration: underline; 2688 + } 2689 + 2690 + .surface-state-action > a:not([class]):focus-visible { 2691 + outline: 2px solid var(--facet-color, #b06a1a); 2692 + outline-offset: 2px; 2693 + border-radius: 4px; 2694 + } 2695 + 2696 + .surface-state--error .surface-state-icon { 2697 + color: #f59e0b; 2698 + } 2699 + 2700 + .surface-state--error .surface-state-heading { 2701 + color: #92400e; 2702 + } 2703 + 2704 + .surface-state-spinner { 2705 + width: 24px; 2706 + height: 24px; 2707 + border: 3px solid #e5e7eb; 2708 + border-top-color: var(--facet-color, #b06a1a); 2709 + border-radius: 50%; 2710 + animation: surface-state-spin 0.8s linear infinite; 2711 + } 2712 + 2713 + @keyframes surface-state-spin { 2714 + to { transform: rotate(360deg); } 2715 + } 2716 + 2587 2717 @media (max-width: 768px) { 2588 2718 .chat-app { 2589 2719 padding: 0 12px 16px; ··· 2598 2728 2599 2729 .chat-composer { 2600 2730 flex-direction: column; 2731 + } 2732 + } 2733 + 2734 + @media (prefers-reduced-motion: reduce) { 2735 + .surface-state-spinner { 2736 + animation: none; 2601 2737 } 2602 2738 } 2603 2739
+59
convey/static/app.js
··· 1508 1508 })(); 1509 1509 1510 1510 /** 1511 + * Shared loading / empty / error surface-state renderer. 1512 + * Contract: text fields are escaped; icon and action slots accept raw HTML. 1513 + * Examples: SurfaceState.loading({ text: 'Loading…' }), SurfaceState.empty({ icon: '🔍', heading: 'No results' }), SurfaceState.error({ heading: 'Request failed', action: '<button>Retry</button>' }). 1514 + * Load order: call only after DOMContentLoaded or from later event/callback code. 1515 + */ 1516 + window.SurfaceState = (() => { 1517 + const HEADING_LEVELS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); 1518 + 1519 + function escapeHtml(value) { 1520 + return String(value ?? '') 1521 + .replace(/&/g, '&amp;') 1522 + .replace(/</g, '&lt;') 1523 + .replace(/>/g, '&gt;') 1524 + .replace(/"/g, '&quot;') 1525 + .replace(/'/g, '&#39;'); 1526 + } 1527 + 1528 + function normalizeHeadingLevel(level) { 1529 + return HEADING_LEVELS.has(level) ? level : 'h2'; 1530 + } 1531 + 1532 + function render(kind, { 1533 + icon = '', 1534 + heading = '', 1535 + desc = '', 1536 + action = '', 1537 + headingLevel = 'h2', 1538 + role = '' 1539 + } = {}) { 1540 + const tag = normalizeHeadingLevel(headingLevel); 1541 + const roleAttr = role ? ` role="${role}"` : ''; 1542 + 1543 + return `<div class="surface-state surface-state--${kind}"${roleAttr}>` 1544 + + `${icon ? `<div class="surface-state-icon" aria-hidden="true">${icon}</div>` : ''}` 1545 + + `${heading ? `<${tag} class="surface-state-heading">${escapeHtml(heading)}</${tag}>` : ''}` 1546 + + `${desc ? `<p class="surface-state-desc">${escapeHtml(desc)}</p>` : ''}` 1547 + + `${action ? `<div class="surface-state-action">${action}</div>` : ''}` 1548 + + `</div>`; 1549 + } 1550 + 1551 + return { 1552 + loading({ text = '' } = {}) { 1553 + return `<div class="surface-state surface-state--loading" role="status" aria-busy="true">` 1554 + + `<div class="surface-state-spinner" aria-hidden="true"></div>` 1555 + + `${text ? `<span class="surface-state-text" data-role="loading-status">${escapeHtml(text)}</span>` : ''}` 1556 + + `</div>`; 1557 + }, 1558 + 1559 + empty(options = {}) { 1560 + return render('empty', options); 1561 + }, 1562 + 1563 + error(options = {}) { 1564 + return render('error', { ...options, role: 'alert' }); 1565 + }, 1566 + }; 1567 + })(); 1568 + 1569 + /** 1511 1570 * App Services Framework 1512 1571 * Global API for apps to register background services, update badges, and show notifications 1513 1572 */