/ native-host / templates / dashboard.html
dashboard.html
   1  <!DOCTYPE html>
   2  <html lang="en">
   3  <head>
   4    <meta charset="UTF-8">
   5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
   6    <title>Mnemonic Dashboard</title>
   7    <style>
   8      /* ================================================================
   9         Design Tokens (from tailwind.config.js / global.css)
  10         ================================================================ */
  11      :root {
  12        --bg: #0d3d3d;
  13        --bg-light: #145454;
  14        --bg-dark: #072929;
  15        --phosphor: #d9892e;
  16        --phosphor-light: #e9a854;
  17        --phosphor-dark: #b66f1a;
  18        --phosphor-glow: #f5c06d;
  19        --text-primary: #d9892e;
  20        --text-secondary: #a3bfbf;
  21        --text-muted: #6b8f8f;
  22        --surface-raised: #145454;
  23        --status-success: #4ade80;
  24        --status-error: #f87171;
  25        --font-mono: 'Berkeley Mono', 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
  26      }
  27  
  28      /* ================================================================
  29         Reset & Base
  30         ================================================================ */
  31      *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  32  
  33      html {
  34        font-family: var(--font-mono);
  35        background: var(--bg);
  36        color: var(--text-primary);
  37        -webkit-text-size-adjust: 100%;
  38      }
  39  
  40      body {
  41        min-height: 100vh;
  42        line-height: 1.5;
  43      }
  44  
  45      a { color: inherit; text-decoration: none; }
  46  
  47      /* ================================================================
  48         Scrollbar (retro theme)
  49         ================================================================ */
  50      ::-webkit-scrollbar { width: 8px; height: 8px; }
  51      ::-webkit-scrollbar-track { background: var(--bg-dark); }
  52      ::-webkit-scrollbar-thumb { background: var(--phosphor-dark); border-radius: 4px; }
  53      ::-webkit-scrollbar-thumb:hover { background: var(--phosphor); }
  54  
  55      * {
  56        scrollbar-width: thin;
  57        scrollbar-color: var(--phosphor-dark) var(--bg-dark);
  58      }
  59  
  60      /* ================================================================
  61         Header (sticky)
  62         ================================================================ */
  63      .header {
  64        position: sticky;
  65        top: 0;
  66        z-index: 100;
  67        background: var(--bg);
  68        border-bottom: 1px solid rgba(182, 111, 26, 0.3);
  69        padding: 12px;
  70      }
  71  
  72      .header-top {
  73        display: flex;
  74        align-items: center;
  75        justify-content: space-between;
  76        gap: 12px;
  77        margin-bottom: 10px;
  78      }
  79  
  80      .logo {
  81        display: flex;
  82        align-items: center;
  83        gap: 8px;
  84        font-size: 1.125rem;
  85        font-weight: 700;
  86        color: var(--phosphor);
  87        text-shadow: 0 0 10px rgba(217, 137, 46, 0.5);
  88        white-space: nowrap;
  89      }
  90  
  91      .logo svg { flex-shrink: 0; }
  92  
  93      .stats {
  94        display: flex;
  95        gap: 12px;
  96        font-size: 0.6875rem;
  97        color: var(--text-muted);
  98        white-space: nowrap;
  99      }
 100  
 101      .stats span { display: flex; align-items: center; gap: 4px; }
 102      .stats .num { color: var(--phosphor-light); font-weight: 600; }
 103  
 104      /* ================================================================
 105         Search
 106         ================================================================ */
 107      .search-wrap {
 108        position: relative;
 109        display: flex;
 110        align-items: center;
 111      }
 112  
 113      .search-icon {
 114        position: absolute;
 115        left: 12px;
 116        width: 20px;
 117        height: 20px;
 118        color: var(--text-muted);
 119        pointer-events: none;
 120      }
 121  
 122      .search-input {
 123        width: 100%;
 124        height: 48px;
 125        padding: 0 44px 0 40px;
 126        font-family: var(--font-mono);
 127        font-size: 16px;
 128        background: var(--bg-dark);
 129        border: 1px solid rgba(182, 111, 26, 0.3);
 130        border-radius: 6px;
 131        color: var(--text-primary);
 132        outline: none;
 133        transition: border-color 0.15s, box-shadow 0.15s;
 134      }
 135  
 136      .search-input::placeholder { color: var(--text-muted); }
 137      .search-input:focus {
 138        border-color: var(--phosphor);
 139        box-shadow: 0 0 0 2px rgba(217, 137, 46, 0.15);
 140      }
 141  
 142      .search-clear {
 143        position: absolute;
 144        right: 8px;
 145        width: 32px;
 146        height: 32px;
 147        display: none;
 148        align-items: center;
 149        justify-content: center;
 150        background: transparent;
 151        border: none;
 152        color: var(--text-muted);
 153        cursor: pointer;
 154        border-radius: 4px;
 155      }
 156  
 157      .search-clear:hover { color: var(--text-primary); }
 158      .search-clear.visible { display: flex; }
 159  
 160      .search-hint {
 161        position: absolute;
 162        right: 44px;
 163        font-size: 0.625rem;
 164        color: var(--text-muted);
 165        opacity: 0.5;
 166        pointer-events: none;
 167      }
 168  
 169      /* ================================================================
 170         Main Content
 171         ================================================================ */
 172      .main {
 173        padding: 12px;
 174        max-width: 100%;
 175      }
 176  
 177      /* ================================================================
 178         Sync Info
 179         ================================================================ */
 180      .sync-info {
 181        display: flex;
 182        align-items: center;
 183        justify-content: space-between;
 184        padding: 8px 12px;
 185        margin-bottom: 16px;
 186        font-size: 0.75rem;
 187        color: var(--text-muted);
 188        background: var(--bg-dark);
 189        border-radius: 6px;
 190        border: 1px solid rgba(182, 111, 26, 0.15);
 191      }
 192  
 193      .sync-info button {
 194        display: flex;
 195        align-items: center;
 196        gap: 4px;
 197        min-height: 44px;
 198        padding: 4px 8px;
 199        background: transparent;
 200        border: 1px solid rgba(182, 111, 26, 0.3);
 201        border-radius: 4px;
 202        color: var(--text-muted);
 203        font-family: var(--font-mono);
 204        font-size: 0.6875rem;
 205        cursor: pointer;
 206        transition: color 0.15s, border-color 0.15s;
 207      }
 208  
 209      .sync-info button:hover {
 210        color: var(--phosphor);
 211        border-color: var(--phosphor);
 212      }
 213  
 214      .sync-info button.refreshing svg {
 215        animation: spin 0.8s linear infinite;
 216      }
 217  
 218      @keyframes spin { to { transform: rotate(360deg); } }
 219  
 220      /* ================================================================
 221         Browser Section
 222         ================================================================ */
 223      .browser-section {
 224        margin-bottom: 20px;
 225      }
 226  
 227      .browser-header {
 228        display: flex;
 229        align-items: center;
 230        gap: 8px;
 231        padding: 8px 0;
 232        margin-bottom: 8px;
 233        font-size: 0.875rem;
 234        font-weight: 600;
 235        color: var(--phosphor-light);
 236        text-transform: capitalize;
 237        border-bottom: 1px solid rgba(182, 111, 26, 0.2);
 238      }
 239  
 240      .browser-header .count {
 241        font-size: 0.6875rem;
 242        font-weight: 400;
 243        color: var(--text-muted);
 244      }
 245  
 246      /* ================================================================
 247         Transcendent Workspace Accent
 248         ================================================================ */
 249      .ws-transcendent .ws-header {
 250        border-color: rgba(217, 137, 46, 0.4);
 251        background: rgba(217, 137, 46, 0.08);
 252      }
 253  
 254      .ws-transcendent .ws-header:hover {
 255        background: rgba(217, 137, 46, 0.18);
 256      }
 257  
 258      .ws-transcendent .ws-header .ws-icon {
 259        color: var(--phosphor-light);
 260      }
 261  
 262      .badge-transcendent-label {
 263        font-size: 0.5625rem;
 264        text-transform: uppercase;
 265        letter-spacing: 0.05em;
 266        color: var(--phosphor-light);
 267        background: rgba(217, 137, 46, 0.25);
 268        padding: 1px 6px;
 269        border-radius: 3px;
 270        flex-shrink: 0;
 271      }
 272  
 273      /* ================================================================
 274         Parent Group
 275         ================================================================ */
 276      .parent-group {
 277        margin-bottom: 12px;
 278      }
 279  
 280      .parent-header {
 281        display: flex;
 282        align-items: center;
 283        gap: 8px;
 284        padding: 8px 12px;
 285        min-height: 44px;
 286        background: var(--surface-raised);
 287        border: 1px solid rgba(182, 111, 26, 0.3);
 288        border-radius: 6px 6px 0 0;
 289        cursor: pointer;
 290        user-select: none;
 291        transition: background 0.15s;
 292      }
 293  
 294      .parent-header:hover { background: rgba(20, 84, 84, 0.8); }
 295  
 296      .parent-header .chevron {
 297        width: 16px;
 298        height: 16px;
 299        flex-shrink: 0;
 300        color: var(--text-muted);
 301        transition: transform 0.2s;
 302      }
 303  
 304      .parent-header.expanded .chevron { transform: rotate(90deg); }
 305  
 306      .parent-header .ws-name {
 307        flex: 1;
 308        font-size: 0.8125rem;
 309        font-weight: 600;
 310        color: var(--phosphor-light);
 311        overflow: hidden;
 312        text-overflow: ellipsis;
 313        white-space: nowrap;
 314      }
 315  
 316      .parent-header .ws-count {
 317        font-size: 0.6875rem;
 318        color: var(--text-muted);
 319        white-space: nowrap;
 320      }
 321  
 322      .parent-children {
 323        display: none;
 324        border: 1px solid rgba(182, 111, 26, 0.2);
 325        border-top: none;
 326        border-radius: 0 0 6px 6px;
 327        overflow: hidden;
 328      }
 329  
 330      .parent-children.open { display: block; }
 331  
 332      .parent-group.collapsed .parent-header {
 333        border-radius: 6px;
 334      }
 335  
 336      .child-workspace {
 337        border-bottom: 1px solid rgba(182, 111, 26, 0.1);
 338      }
 339  
 340      .child-workspace:last-child { border-bottom: none; }
 341  
 342      /* ================================================================
 343         Workspace Card (standalone & child)
 344         ================================================================ */
 345      .ws-card { margin-bottom: 8px; }
 346      .child-workspace .ws-card { margin-bottom: 0; }
 347  
 348      .ws-header {
 349        display: flex;
 350        align-items: center;
 351        gap: 8px;
 352        padding: 8px 12px;
 353        min-height: 44px;
 354        background: var(--surface-raised);
 355        border: 1px solid rgba(182, 111, 26, 0.2);
 356        border-radius: 6px;
 357        cursor: pointer;
 358        user-select: none;
 359        transition: background 0.15s;
 360      }
 361  
 362      .child-workspace .ws-header {
 363        border: none;
 364        border-radius: 0;
 365        padding-left: 28px;
 366        background: rgba(20, 84, 84, 0.3);
 367      }
 368  
 369      .ws-header:hover { background: rgba(20, 84, 84, 0.5); }
 370  
 371      .ws-header .chevron {
 372        width: 14px;
 373        height: 14px;
 374        flex-shrink: 0;
 375        color: var(--text-muted);
 376        transition: transform 0.2s;
 377      }
 378  
 379      .ws-header.expanded .chevron { transform: rotate(90deg); }
 380  
 381      .ws-header .ws-icon {
 382        width: 16px;
 383        height: 16px;
 384        flex-shrink: 0;
 385        color: var(--text-muted);
 386      }
 387  
 388      .ws-header .ws-name {
 389        flex: 1;
 390        font-size: 0.8125rem;
 391        color: var(--text-secondary);
 392        overflow: hidden;
 393        text-overflow: ellipsis;
 394        white-space: nowrap;
 395      }
 396  
 397      .ws-header .ws-count {
 398        font-size: 0.6875rem;
 399        color: var(--text-muted);
 400        white-space: nowrap;
 401      }
 402  
 403      .ws-tabs {
 404        display: none;
 405        border: 1px solid rgba(182, 111, 26, 0.15);
 406        border-top: none;
 407        border-radius: 0 0 6px 6px;
 408        overflow: hidden;
 409      }
 410  
 411      .ws-tabs.open { display: block; }
 412  
 413      .child-workspace .ws-tabs {
 414        border: none;
 415        border-radius: 0;
 416      }
 417  
 418      /* ================================================================
 419         Tab Row
 420         ================================================================ */
 421      .tab-row {
 422        display: flex;
 423        align-items: center;
 424        gap: 8px;
 425        padding: 6px 12px 6px 20px;
 426        min-height: 40px;
 427        color: var(--text-secondary);
 428        text-decoration: none;
 429        transition: background 0.15s;
 430        border-bottom: 1px solid rgba(182, 111, 26, 0.08);
 431      }
 432  
 433      .child-workspace .tab-row { padding-left: 40px; }
 434  
 435      .tab-row:last-child { border-bottom: none; }
 436      .tab-row:hover { background: rgba(217, 137, 46, 0.08); }
 437  
 438      .tab-favicon {
 439        width: 16px;
 440        height: 16px;
 441        flex-shrink: 0;
 442        border-radius: 2px;
 443      }
 444  
 445      .tab-favicon-placeholder {
 446        width: 16px;
 447        height: 16px;
 448        flex-shrink: 0;
 449        display: flex;
 450        align-items: center;
 451        justify-content: center;
 452        color: var(--text-muted);
 453      }
 454  
 455      .tab-favicon-placeholder svg { width: 14px; height: 14px; }
 456  
 457      .tab-info {
 458        flex: 1;
 459        min-width: 0;
 460        display: flex;
 461        flex-direction: column;
 462        gap: 1px;
 463      }
 464  
 465      .tab-title {
 466        font-size: 0.8125rem;
 467        color: var(--text-secondary);
 468        overflow: hidden;
 469        text-overflow: ellipsis;
 470        white-space: nowrap;
 471      }
 472  
 473      .tab-domain {
 474        font-size: 0.6875rem;
 475        color: var(--text-muted);
 476        overflow: hidden;
 477        text-overflow: ellipsis;
 478        white-space: nowrap;
 479      }
 480  
 481      .tab-pin {
 482        width: 12px;
 483        height: 12px;
 484        flex-shrink: 0;
 485        color: var(--phosphor-dark);
 486      }
 487  
 488      /* ================================================================
 489         No Results
 490         ================================================================ */
 491      .no-results {
 492        display: none;
 493        text-align: center;
 494        padding: 48px 16px;
 495        color: var(--text-muted);
 496      }
 497  
 498      .no-results.visible { display: block; }
 499      .no-results p { margin-bottom: 4px; }
 500      .no-results .hint { font-size: 0.75rem; opacity: 0.7; }
 501  
 502      /* ================================================================
 503         Keyboard shortcut hint
 504         ================================================================ */
 505      kbd {
 506        display: inline-block;
 507        padding: 1px 5px;
 508        font-family: var(--font-mono);
 509        font-size: 0.625rem;
 510        background: var(--bg-dark);
 511        border: 1px solid rgba(182, 111, 26, 0.3);
 512        border-radius: 3px;
 513        color: var(--text-muted);
 514      }
 515  
 516      /* ================================================================
 517         Responsive
 518         ================================================================ */
 519      @media (min-width: 640px) {
 520        .header, .main { padding: 12px 20px; }
 521        .main { max-width: 640px; margin: 0 auto; }
 522        .header { max-width: 100%; }
 523      }
 524  
 525      @media (min-width: 1024px) {
 526        .main { max-width: 800px; }
 527      }
 528  
 529      /* ================================================================
 530         Match highlight
 531         ================================================================ */
 532      .search-match { background: rgba(217, 137, 46, 0.25); border-radius: 2px; }
 533  
 534      /* ================================================================
 535         Todoist Integration
 536         ================================================================ */
 537      .todoist-status {
 538        display: inline-flex;
 539        align-items: center;
 540        gap: 4px;
 541        font-size: 0.6875rem;
 542        color: var(--text-muted);
 543        white-space: nowrap;
 544      }
 545  
 546      .todoist-status .dot {
 547        width: 7px;
 548        height: 7px;
 549        border-radius: 50%;
 550        flex-shrink: 0;
 551      }
 552  
 553      .todoist-status .dot.connected { background: var(--status-success); box-shadow: 0 0 4px var(--status-success); }
 554      .todoist-status .dot.disconnected { background: var(--status-error); box-shadow: 0 0 4px var(--status-error); }
 555  
 556      .todoist-btn {
 557        display: inline-flex;
 558        align-items: center;
 559        gap: 3px;
 560        padding: 2px 7px;
 561        font-family: var(--font-mono);
 562        font-size: 0.5625rem;
 563        background: rgba(229, 78, 64, 0.12);
 564        color: #e54e40;
 565        border: 1px solid rgba(229, 78, 64, 0.3);
 566        border-radius: 3px;
 567        cursor: pointer;
 568        white-space: nowrap;
 569        transition: background 0.15s, border-color 0.15s;
 570        flex-shrink: 0;
 571      }
 572  
 573      .todoist-btn:hover {
 574        background: rgba(229, 78, 64, 0.25);
 575        border-color: rgba(229, 78, 64, 0.5);
 576      }
 577  
 578      .todoist-btn:disabled {
 579        opacity: 0.5;
 580        cursor: wait;
 581      }
 582  
 583      .todoist-link {
 584        display: inline-flex;
 585        align-items: center;
 586        gap: 3px;
 587        padding: 2px 7px;
 588        font-family: var(--font-mono);
 589        font-size: 0.5625rem;
 590        color: #e54e40;
 591        text-decoration: none;
 592        border: 1px solid rgba(229, 78, 64, 0.2);
 593        border-radius: 3px;
 594        white-space: nowrap;
 595        transition: background 0.15s;
 596        flex-shrink: 0;
 597      }
 598  
 599      .todoist-link:hover {
 600        background: rgba(229, 78, 64, 0.12);
 601      }
 602  
 603      .todoist-add-btn {
 604        display: inline-flex;
 605        align-items: center;
 606        justify-content: center;
 607        width: 20px;
 608        height: 20px;
 609        font-size: 0.75rem;
 610        font-weight: 700;
 611        background: rgba(229, 78, 64, 0.12);
 612        color: #e54e40;
 613        border: 1px solid rgba(229, 78, 64, 0.3);
 614        border-radius: 3px;
 615        cursor: pointer;
 616        flex-shrink: 0;
 617        transition: background 0.15s;
 618      }
 619  
 620      .todoist-add-btn:hover {
 621        background: rgba(229, 78, 64, 0.25);
 622      }
 623  
 624      .todoist-quick-add {
 625        display: none;
 626        padding: 8px 12px;
 627        background: var(--bg-dark);
 628        border: 1px solid rgba(229, 78, 64, 0.3);
 629        border-radius: 0 0 6px 6px;
 630        margin-top: -1px;
 631      }
 632  
 633      .todoist-quick-add.open { display: flex; gap: 6px; align-items: center; }
 634  
 635      .todoist-quick-add input[type="text"] {
 636        flex: 1;
 637        min-width: 0;
 638        height: 30px;
 639        padding: 0 8px;
 640        font-family: var(--font-mono);
 641        font-size: 0.75rem;
 642        background: var(--bg);
 643        border: 1px solid rgba(182, 111, 26, 0.3);
 644        border-radius: 4px;
 645        color: var(--text-primary);
 646        outline: none;
 647      }
 648  
 649      .todoist-quick-add input[type="text"]:focus {
 650        border-color: #e54e40;
 651      }
 652  
 653      .todoist-quick-add select {
 654        height: 30px;
 655        padding: 0 4px;
 656        font-family: var(--font-mono);
 657        font-size: 0.6875rem;
 658        background: var(--bg);
 659        border: 1px solid rgba(182, 111, 26, 0.3);
 660        border-radius: 4px;
 661        color: var(--text-secondary);
 662        outline: none;
 663      }
 664  
 665      .todoist-quick-add button[type="submit"] {
 666        height: 30px;
 667        padding: 0 10px;
 668        font-family: var(--font-mono);
 669        font-size: 0.6875rem;
 670        font-weight: 600;
 671        background: rgba(229, 78, 64, 0.2);
 672        color: #e54e40;
 673        border: 1px solid rgba(229, 78, 64, 0.4);
 674        border-radius: 4px;
 675        cursor: pointer;
 676        white-space: nowrap;
 677        transition: background 0.15s;
 678      }
 679  
 680      .todoist-quick-add button[type="submit"]:hover {
 681        background: rgba(229, 78, 64, 0.35);
 682      }
 683  
 684      .todoist-error {
 685        font-size: 0.625rem;
 686        color: var(--status-error);
 687        margin-left: 4px;
 688      }
 689  
 690      .todoist-success {
 691        font-size: 0.625rem;
 692        color: var(--status-success);
 693        margin-left: 4px;
 694      }
 695  
 696      /* ---- Cross-browser tab transfer ---- */
 697      .tab-send-btn {
 698        position: relative;
 699        z-index: 2;
 700        display: flex;
 701        align-items: center;
 702        justify-content: center;
 703        width: 22px;
 704        height: 22px;
 705        padding: 0;
 706        margin-left: auto;
 707        flex-shrink: 0;
 708        background: transparent;
 709        border: 1px solid rgba(255,255,255,0.12);
 710        border-radius: 4px;
 711        color: var(--phosphor-dim);
 712        cursor: pointer;
 713        opacity: 0;
 714        transition: opacity 0.15s, background 0.15s, color 0.15s;
 715      }
 716      .tab-row:hover .tab-send-btn { opacity: 1; }
 717      .tab-send-btn:hover {
 718        background: rgba(var(--accent-rgb, 100,180,255), 0.2);
 719        color: var(--phosphor-light);
 720        border-color: rgba(var(--accent-rgb, 100,180,255), 0.5);
 721      }
 722      .tab-send-btn .send-status {
 723        position: absolute;
 724        top: -6px;
 725        right: -8px;
 726        font-size: 0.55rem;
 727        padding: 1px 4px;
 728        border-radius: 3px;
 729        white-space: nowrap;
 730        pointer-events: none;
 731      }
 732      .tab-send-btn .send-status.ok { background: var(--status-success); color: #000; }
 733      .tab-send-btn .send-status.err { background: var(--status-error); color: #fff; }
 734  
 735      .transfer-picker {
 736        position: fixed;
 737        inset: 0;
 738        z-index: 1000;
 739        background: rgba(0,0,0,0.4);
 740      }
 741      .transfer-picker-content {
 742        position: absolute;
 743        width: 280px;
 744        max-height: 360px;
 745        overflow-y: auto;
 746        background: var(--bg-card);
 747        border: 1px solid var(--border);
 748        border-radius: 8px;
 749        box-shadow: 0 8px 24px rgba(0,0,0,0.5);
 750        padding: 6px 0;
 751      }
 752      .transfer-picker-title {
 753        padding: 6px 12px;
 754        font-size: 0.7rem;
 755        font-weight: 700;
 756        color: var(--phosphor-dim);
 757        text-transform: uppercase;
 758        letter-spacing: 0.05em;
 759      }
 760      .transfer-browser-label {
 761        padding: 4px 12px 2px;
 762        font-size: 0.65rem;
 763        font-weight: 600;
 764        color: var(--phosphor-dim);
 765        opacity: 0.7;
 766      }
 767      .transfer-ws-item {
 768        display: flex;
 769        align-items: center;
 770        gap: 8px;
 771        padding: 6px 12px;
 772        font-size: 0.75rem;
 773        color: var(--phosphor-light);
 774        cursor: pointer;
 775        transition: background 0.1s;
 776      }
 777      .transfer-ws-item:hover { background: rgba(255,255,255,0.06); }
 778      .transfer-ws-item .ws-tab-count {
 779        margin-left: auto;
 780        font-size: 0.625rem;
 781        color: var(--phosphor-dim);
 782      }
 783    </style>
 784  </head>
 785  <body>
 786  
 787    <!-- ============================================================
 788         Header
 789         ============================================================ -->
 790    <header class="header">
 791      <div class="header-top">
 792        <div class="logo">
 793          <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
 794            <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
 795          </svg>
 796          <span>Mnemonic</span>
 797        </div>
 798        <div class="stats">
 799          <span><span class="num" id="stat-workspaces">{{ total_workspaces }}</span> workspaces</span>
 800          <span><span class="num" id="stat-tabs">{{ total_tabs }}</span> tabs</span>
 801          {% if todoist_configured %}
 802          <span class="todoist-status">
 803            {% if todoist_status and todoist_status.connected %}
 804            <span class="dot connected"></span>Todoist
 805            {% else %}
 806            <span class="dot disconnected"></span>Todoist
 807            {% endif %}
 808          </span>
 809          {% endif %}
 810        </div>
 811      </div>
 812      <div class="search-wrap">
 813        <svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
 814          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
 815        </svg>
 816        <input
 817          type="text"
 818          class="search-input"
 819          id="search"
 820          placeholder="Search workspaces and tabs..."
 821          autocomplete="off"
 822          spellcheck="false"
 823        />
 824        <span class="search-hint" id="search-hint"><kbd>/</kbd></span>
 825        <button class="search-clear" id="search-clear" type="button" title="Clear search (Esc)">
 826          <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
 827            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
 828          </svg>
 829        </button>
 830      </div>
 831    </header>
 832  
 833    <!-- ============================================================
 834         Main
 835         ============================================================ -->
 836    <main class="main" id="content">
 837  
 838      <div class="sync-info">
 839        <span id="sync-time" {% if last_modified %}data-utc="{{ last_modified }}"{% endif %}>
 840          {% if last_modified %}
 841            Last synced: {{ last_modified }}
 842          {% else %}
 843            No sync data available
 844          {% endif %}
 845        </span>
 846        <button type="button" id="refresh-btn" title="Refresh workspace data">
 847          <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
 848            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
 849          </svg>
 850          Refresh
 851        </button>
 852      </div>
 853  
 854      <div id="workspace-content">
 855        {% for browser_key, section in browser_sections.items() %}
 856        <div class="browser-section" data-browser="{{ browser_key }}">
 857          <div class="browser-header">
 858            <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="currentColor" opacity="0.6"/></svg>
 859            <span>{{ browser_key }}</span>
 860            <span class="count">({{ section.workspaces|length }} workspaces)</span>
 861          </div>
 862  
 863          {# ---- Transcendent workspaces ---- #}
 864          {% for ws in section.transcendent %}
 865          {% set ws_tabs = ws.get('tabs', []) %}
 866          {% set ws_tab_count = ws_tabs|length if ws_tabs is iterable and ws_tabs is not string else 0 %}
 867          <div class="ws-card ws-transcendent" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-search-text="{{ ws.get('name', '')|lower }}">
 868            <div class="ws-header" onclick="toggleWs(this)">
 869              <svg class="chevron" fill="currentColor" viewBox="0 0 20 20">
 870                <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
 871              </svg>
 872              <svg class="ws-icon" fill="currentColor" viewBox="0 0 20 20">
 873                <path d="M14.5 10c0 1.38-1.12 2.5-2.5 2.5-.83 0-1.57-.41-2.02-1.03L8.5 10l1.48-1.47A2.49 2.49 0 0112 7.5c1.38 0 2.5 1.12 2.5 2.5zm-9 0c0-1.38 1.12-2.5 2.5-2.5.83 0 1.57.41 2.02 1.03L11.5 10l-1.48 1.47A2.49 2.49 0 018 12.5c-1.38 0-2.5-1.12-2.5-2.5zm4.5 0l2-2c.78-.78 1.81-1.17 2.83-1.17 2.21 0 4 1.79 4 4s-1.79 4-4 4c-1.02 0-2.05-.39-2.83-1.17l-2-2-2 2c-.78.78-1.81 1.17-2.83 1.17-2.21 0-4-1.79-4-4s1.79-4 4-4c1.02 0 2.05.39 2.83 1.17l2 2z"/>
 874              </svg>
 875              <span class="ws-name">{{ ws.get('name', 'Unnamed') }}</span>
 876              <span class="badge-transcendent-label">transcendent</span>
 877              <span class="ws-count">{{ ws_tab_count }} tabs</span>
 878              {% if todoist_configured and todoist_status and todoist_status.connected %}
 879                {% if ws.get('todoistProjectId') %}
 880              <a class="todoist-link" href="https://todoist.com/app/project/{{ ws.get('todoistProjectId') }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Todoist</a>
 881              <button class="todoist-add-btn" onclick="event.stopPropagation(); toggleQuickAdd(this)" data-project-id="{{ ws.get('todoistProjectId') }}" title="Quick-add task">+</button>
 882                {% else %}
 883              <button class="todoist-btn" onclick="event.stopPropagation(); createTodoistProject(this)" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-browser-key="{{ browser_key }}">+ Todoist</button>
 884                {% endif %}
 885              {% endif %}
 886            </div>
 887            {% if todoist_configured and todoist_status and todoist_status.connected and ws.get('todoistProjectId') %}
 888            <div class="todoist-quick-add" data-project-id="{{ ws.get('todoistProjectId') }}">
 889              <input type="text" placeholder="Add task..." onkeydown="if(event.key==='Enter'){event.preventDefault();submitQuickTask(this.parentElement)}" />
 890              <select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select>
 891              <button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button>
 892            </div>
 893            {% endif %}
 894            <div class="ws-tabs">
 895              {% if ws_tabs is iterable and ws_tabs is not string %}
 896              {% for tab in ws_tabs %}
 897                  {% set tab_url = tab.get('url', '') %}
 898                  <a class="tab-row" href="{{ tab_url }}" target="_blank" rel="noopener"
 899                     data-tab-title="{{ tab.get('title', '')|lower }}" data-tab-url="{{ tab_url|lower }}">
 900                    {% if tab_url and tab_url.startswith('http') %}
 901                    <img class="tab-favicon" src="https://www.google.com/s2/favicons?domain={{ tab_url|domain }}&sz=16"
 902                         alt="" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
 903                    <span class="tab-favicon-placeholder" style="display:none;">
 904                      <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
 905                    </span>
 906                    {% else %}
 907                    <span class="tab-favicon-placeholder">
 908                      <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
 909                    </span>
 910                    {% endif %}
 911                    <div class="tab-info">
 912                      <span class="tab-title">{{ tab.get('title', tab_url or 'Untitled') }}</span>
 913                      <span class="tab-domain">{{ tab_url|domain }}</span>
 914                    </div>
 915                    {% if tab.get('pinned') %}
 916                    <svg class="tab-pin" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v1h-2V5H7v1H5V5zm2 4h6v6l-3 2-3-2V9z"/></svg>
 917                    {% endif %}
 918                    <button class="tab-send-btn" onclick="event.preventDefault();event.stopPropagation();openTransferPicker(this)" data-tab-url="{{ tab_url }}" data-tab-title="{{ tab.get('title', '') }}" data-tab-pinned="{{ 'true' if tab.get('pinned') else 'false' }}" data-source-browser="{{ browser_key }}" title="Send to another browser"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg></button>
 919                  </a>
 920              {% endfor %}
 921              {% endif %}
 922            </div>
 923          </div>
 924          {% endfor %}
 925  
 926          {# ---- Parent workspaces with children ---- #}
 927          {% for parent in section.parents %}
 928          {% set ws = parent.ws %}
 929          {% set children = parent.children %}
 930          {% set ws_tabs = ws.get('tabs', []) %}
 931          {% set ws_tab_count = ws_tabs|length if ws_tabs is iterable and ws_tabs is not string else 0 %}
 932          <div class="parent-group collapsed" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-search-text="{{ ws.get('name', '')|lower }}">
 933            <div class="parent-header" onclick="toggleParent(this)">
 934              <svg class="chevron" fill="currentColor" viewBox="0 0 20 20">
 935                <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
 936              </svg>
 937              <svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20" style="color: var(--phosphor-light); flex-shrink: 0;">
 938                <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
 939              </svg>
 940              <span class="ws-name">{{ ws.get('name', 'Unnamed') }}</span>
 941              <span class="ws-count">{{ ws_tab_count + parent.child_tab_total }} tabs &middot; {{ children|length }} children</span>
 942              {% if todoist_configured and todoist_status and todoist_status.connected %}
 943                {% if ws.get('todoistProjectId') %}
 944              <a class="todoist-link" href="https://todoist.com/app/project/{{ ws.get('todoistProjectId') }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Todoist</a>
 945              <button class="todoist-add-btn" onclick="event.stopPropagation(); toggleQuickAdd(this)" data-project-id="{{ ws.get('todoistProjectId') }}" title="Quick-add task">+</button>
 946                {% else %}
 947              <button class="todoist-btn" onclick="event.stopPropagation(); createTodoistProject(this)" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-browser-key="{{ browser_key }}">+ Todoist</button>
 948                {% endif %}
 949              {% endif %}
 950            </div>
 951            {% if todoist_configured and todoist_status and todoist_status.connected and ws.get('todoistProjectId') %}
 952            <div class="todoist-quick-add" data-project-id="{{ ws.get('todoistProjectId') }}">
 953              <input type="text" placeholder="Add task..." onkeydown="if(event.key==='Enter'){event.preventDefault();submitQuickTask(this.parentElement)}" />
 954              <select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select>
 955              <button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button>
 956            </div>
 957            {% endif %}
 958            <div class="parent-children">
 959              {# Parent's own tabs #}
 960              {% if ws_tabs is iterable and ws_tabs is not string and ws_tabs|length > 0 %}
 961              <div class="ws-card" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}">
 962                <div class="ws-tabs open">
 963                  {% for tab in ws_tabs %}
 964                  {% set tab_url = tab.get('url', '') %}
 965                  <a class="tab-row" href="{{ tab_url }}" target="_blank" rel="noopener"
 966                     data-tab-title="{{ tab.get('title', '')|lower }}" data-tab-url="{{ tab_url|lower }}">
 967                    {% if tab_url and tab_url.startswith('http') %}
 968                    <img class="tab-favicon" src="https://www.google.com/s2/favicons?domain={{ tab_url|domain }}&sz=16"
 969                         alt="" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
 970                    <span class="tab-favicon-placeholder" style="display:none;">
 971                      <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
 972                    </span>
 973                    {% else %}
 974                    <span class="tab-favicon-placeholder">
 975                      <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
 976                    </span>
 977                    {% endif %}
 978                    <div class="tab-info">
 979                      <span class="tab-title">{{ tab.get('title', tab_url or 'Untitled') }}</span>
 980                      <span class="tab-domain">{{ tab_url|domain }}</span>
 981                    </div>
 982                    {% if tab.get('pinned') %}
 983                    <svg class="tab-pin" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v1h-2V5H7v1H5V5zm2 4h6v6l-3 2-3-2V9z"/></svg>
 984                    {% endif %}
 985                    <button class="tab-send-btn" onclick="event.preventDefault();event.stopPropagation();openTransferPicker(this)" data-tab-url="{{ tab_url }}" data-tab-title="{{ tab.get('title', '') }}" data-tab-pinned="{{ 'true' if tab.get('pinned') else 'false' }}" data-source-browser="{{ browser_key }}" title="Send to another browser"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg></button>
 986                  </a>
 987                  {% endfor %}
 988                </div>
 989              </div>
 990              {% endif %}
 991  
 992              {# Children #}
 993              {% for child in children %}
 994              {% set child_tabs = child.get('tabs', []) %}
 995              {% set child_tab_count = child_tabs|length if child_tabs is iterable and child_tabs is not string else 0 %}
 996              <div class="child-workspace" data-ws-id="{{ child.get('id', '') }}" data-ws-name="{{ child.get('name', '') }}" data-search-text="{{ child.get('name', '')|lower }}">
 997                <div class="ws-card">
 998                  <div class="ws-header" onclick="toggleWs(this)">
 999                    <svg class="chevron" fill="currentColor" viewBox="0 0 20 20">
1000                      <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
1001                    </svg>
1002                    <svg class="ws-icon" fill="currentColor" viewBox="0 0 20 20">
1003                      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd"/>
1004                    </svg>
1005                    <span class="ws-name">{{ child.get('name', 'Unnamed') }}</span>
1006                    <span class="ws-count">{{ child_tab_count }} tabs</span>
1007                    {% if todoist_configured and todoist_status and todoist_status.connected %}
1008                      {% if child.get('todoistProjectId') %}
1009                    <a class="todoist-link" href="https://todoist.com/app/project/{{ child.get('todoistProjectId') }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Todoist</a>
1010                    <button class="todoist-add-btn" onclick="event.stopPropagation(); toggleQuickAdd(this)" data-project-id="{{ child.get('todoistProjectId') }}" title="Quick-add task">+</button>
1011                      {% else %}
1012                    <button class="todoist-btn" onclick="event.stopPropagation(); createTodoistProject(this)" data-ws-id="{{ child.get('id', '') }}" data-ws-name="{{ child.get('name', '') }}" data-browser-key="{{ browser_key }}" data-parent-todoist-id="{{ ws.get('todoistProjectId', '') }}">+ Todoist</button>
1013                      {% endif %}
1014                    {% endif %}
1015                  </div>
1016                  {% if todoist_configured and todoist_status and todoist_status.connected and child.get('todoistProjectId') %}
1017                  <div class="todoist-quick-add" data-project-id="{{ child.get('todoistProjectId') }}">
1018                    <input type="text" placeholder="Add task..." onkeydown="if(event.key==='Enter'){event.preventDefault();submitQuickTask(this.parentElement)}" />
1019                    <select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select>
1020                    <button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button>
1021                  </div>
1022                  {% endif %}
1023                  <div class="ws-tabs">
1024                    {% if child_tabs is iterable and child_tabs is not string %}
1025                    {% for tab in child_tabs %}
1026                    {% set tab_url = tab.get('url', '') %}
1027                  <a class="tab-row" href="{{ tab_url }}" target="_blank" rel="noopener"
1028                     data-tab-title="{{ tab.get('title', '')|lower }}" data-tab-url="{{ tab_url|lower }}">
1029                    {% if tab_url and tab_url.startswith('http') %}
1030                    <img class="tab-favicon" src="https://www.google.com/s2/favicons?domain={{ tab_url|domain }}&sz=16"
1031                         alt="" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
1032                    <span class="tab-favicon-placeholder" style="display:none;">
1033                      <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
1034                    </span>
1035                    {% else %}
1036                    <span class="tab-favicon-placeholder">
1037                      <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
1038                    </span>
1039                    {% endif %}
1040                    <div class="tab-info">
1041                      <span class="tab-title">{{ tab.get('title', tab_url or 'Untitled') }}</span>
1042                      <span class="tab-domain">{{ tab_url|domain }}</span>
1043                    </div>
1044                    {% if tab.get('pinned') %}
1045                    <svg class="tab-pin" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v1h-2V5H7v1H5V5zm2 4h6v6l-3 2-3-2V9z"/></svg>
1046                    {% endif %}
1047                    <button class="tab-send-btn" onclick="event.preventDefault();event.stopPropagation();openTransferPicker(this)" data-tab-url="{{ tab_url }}" data-tab-title="{{ tab.get('title', '') }}" data-tab-pinned="{{ 'true' if tab.get('pinned') else 'false' }}" data-source-browser="{{ browser_key }}" title="Send to another browser"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg></button>
1048                  </a>
1049                    {% endfor %}
1050                    {% endif %}
1051                  </div>
1052                </div>
1053              </div>
1054              {% endfor %}
1055            </div>
1056          </div>
1057          {% endfor %}
1058  
1059          {# ---- Standalone workspaces ---- #}
1060          {% for ws in section.standalone %}
1061          {% set ws_tabs = ws.get('tabs', []) %}
1062          {% set ws_tab_count = ws_tabs|length if ws_tabs is iterable and ws_tabs is not string else 0 %}
1063          <div class="ws-card" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-search-text="{{ ws.get('name', '')|lower }}">
1064            <div class="ws-header" onclick="toggleWs(this)">
1065              <svg class="chevron" fill="currentColor" viewBox="0 0 20 20">
1066                <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
1067              </svg>
1068              <svg class="ws-icon" fill="currentColor" viewBox="0 0 20 20">
1069                <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd"/>
1070              </svg>
1071              <span class="ws-name">{{ ws.get('name', 'Unnamed') }}</span>
1072              <span class="ws-count">{{ ws_tab_count }} tabs</span>
1073              {% if todoist_configured and todoist_status and todoist_status.connected %}
1074                {% if ws.get('todoistProjectId') %}
1075              <a class="todoist-link" href="https://todoist.com/app/project/{{ ws.get('todoistProjectId') }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Todoist</a>
1076              <button class="todoist-add-btn" onclick="event.stopPropagation(); toggleQuickAdd(this)" data-project-id="{{ ws.get('todoistProjectId') }}" title="Quick-add task">+</button>
1077                {% else %}
1078              <button class="todoist-btn" onclick="event.stopPropagation(); createTodoistProject(this)" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-browser-key="{{ browser_key }}">+ Todoist</button>
1079                {% endif %}
1080              {% endif %}
1081            </div>
1082            {% if todoist_configured and todoist_status and todoist_status.connected and ws.get('todoistProjectId') %}
1083            <div class="todoist-quick-add" data-project-id="{{ ws.get('todoistProjectId') }}">
1084              <input type="text" placeholder="Add task..." onkeydown="if(event.key==='Enter'){event.preventDefault();submitQuickTask(this.parentElement)}" />
1085              <select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select>
1086              <button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button>
1087            </div>
1088            {% endif %}
1089            <div class="ws-tabs">
1090              {% if ws_tabs is iterable and ws_tabs is not string %}
1091              {% for tab in ws_tabs %}
1092                  {% set tab_url = tab.get('url', '') %}
1093                  <a class="tab-row" href="{{ tab_url }}" target="_blank" rel="noopener"
1094                     data-tab-title="{{ tab.get('title', '')|lower }}" data-tab-url="{{ tab_url|lower }}">
1095                    {% if tab_url and tab_url.startswith('http') %}
1096                    <img class="tab-favicon" src="https://www.google.com/s2/favicons?domain={{ tab_url|domain }}&sz=16"
1097                         alt="" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
1098                    <span class="tab-favicon-placeholder" style="display:none;">
1099                      <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
1100                    </span>
1101                    {% else %}
1102                    <span class="tab-favicon-placeholder">
1103                      <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg>
1104                    </span>
1105                    {% endif %}
1106                    <div class="tab-info">
1107                      <span class="tab-title">{{ tab.get('title', tab_url or 'Untitled') }}</span>
1108                      <span class="tab-domain">{{ tab_url|domain }}</span>
1109                    </div>
1110                    {% if tab.get('pinned') %}
1111                    <svg class="tab-pin" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v1h-2V5H7v1H5V5zm2 4h6v6l-3 2-3-2V9z"/></svg>
1112                    {% endif %}
1113                    <button class="tab-send-btn" onclick="event.preventDefault();event.stopPropagation();openTransferPicker(this)" data-tab-url="{{ tab_url }}" data-tab-title="{{ tab.get('title', '') }}" data-tab-pinned="{{ 'true' if tab.get('pinned') else 'false' }}" data-source-browser="{{ browser_key }}" title="Send to another browser"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg></button>
1114                  </a>
1115              {% endfor %}
1116              {% endif %}
1117            </div>
1118          </div>
1119          {% endfor %}
1120  
1121        </div>
1122        {% endfor %}
1123      </div>
1124  
1125      <div class="no-results" id="no-results">
1126        <p>No matching workspaces or tabs</p>
1127        <p class="hint">Try different keywords</p>
1128      </div>
1129  
1130    </main>
1131  
1132    <!-- Browser workspace data for tab transfer picker -->
1133    <script>const _browserWorkspaces = {{ browser_ws_json|safe }};</script>
1134  
1135    <!-- ============================================================
1136         JavaScript
1137         ============================================================ -->
1138    <script>
1139      (function() {
1140        'use strict';
1141  
1142        const searchInput = document.getElementById('search');
1143        const searchClear = document.getElementById('search-clear');
1144        const searchHint = document.getElementById('search-hint');
1145        const noResults = document.getElementById('no-results');
1146        const content = document.getElementById('workspace-content');
1147        const refreshBtn = document.getElementById('refresh-btn');
1148        const syncTime = document.getElementById('sync-time');
1149  
1150        // ---- Friendly time display (UTC → local timezone) ----
1151        function formatSyncTime(utcIso) {
1152          const date = new Date(utcIso);
1153          if (isNaN(date.getTime())) return utcIso;
1154  
1155          // Local time: "Feb 15, 2:13 PM"
1156          const local = date.toLocaleString(undefined, {
1157            month: 'short', day: 'numeric',
1158            hour: 'numeric', minute: '2-digit',
1159            hour12: true
1160          });
1161  
1162          // Relative: "2 min ago", "3 hours ago", etc.
1163          const now = Date.now();
1164          const diffSec = Math.round((now - date.getTime()) / 1000);
1165          let relative;
1166          if (diffSec < 5) relative = 'just now';
1167          else if (diffSec < 60) relative = diffSec + 's ago';
1168          else if (diffSec < 3600) relative = Math.floor(diffSec / 60) + ' min ago';
1169          else if (diffSec < 86400) relative = Math.floor(diffSec / 3600) + 'h ago';
1170          else relative = Math.floor(diffSec / 86400) + 'd ago';
1171  
1172          return 'Last synced: ' + local + ' (' + relative + ')';
1173        }
1174  
1175        // Format on load if UTC data is available
1176        if (syncTime && syncTime.dataset.utc) {
1177          syncTime.textContent = formatSyncTime(syncTime.dataset.utc);
1178          // Keep it fresh — update relative portion every 30s
1179          setInterval(() => {
1180            syncTime.textContent = formatSyncTime(syncTime.dataset.utc);
1181          }, 30000);
1182        }
1183  
1184        // ---- Build search index ----
1185        const wsIndex = [];
1186  
1187        function buildIndex() {
1188          wsIndex.length = 0;
1189  
1190          // Index parent groups
1191          document.querySelectorAll('.parent-group').forEach(el => {
1192            const wsName = (el.dataset.wsName || '').toLowerCase();
1193            const childEntries = [];
1194  
1195            el.querySelectorAll('.child-workspace').forEach(cEl => {
1196              const cName = (cEl.dataset.wsName || '').toLowerCase();
1197              const tabEls = [];
1198              cEl.querySelectorAll('.tab-row').forEach(tEl => {
1199                tabEls.push({
1200                  el: tEl,
1201                  title: tEl.dataset.tabTitle || '',
1202                  url: tEl.dataset.tabUrl || ''
1203                });
1204              });
1205              childEntries.push({ el: cEl, wsName: cName, tabEls, type: 'child' });
1206            });
1207  
1208            // Parent's own tabs (direct child .ws-card, not inside .child-workspace)
1209            const parentTabs = [];
1210            const ownTabContainer = el.querySelector('.parent-children > .ws-card:not(.child-workspace .ws-card)');
1211            if (ownTabContainer) {
1212              ownTabContainer.querySelectorAll('.tab-row').forEach(tEl => {
1213                parentTabs.push({
1214                  el: tEl,
1215                  title: tEl.dataset.tabTitle || '',
1216                  url: tEl.dataset.tabUrl || ''
1217                });
1218              });
1219            }
1220  
1221            wsIndex.push({
1222              el,
1223              wsName,
1224              tabEls: parentTabs,
1225              children: childEntries,
1226              type: 'parent'
1227            });
1228          });
1229  
1230          // Index standalone cards (direct children of browser-section, not inside parent-group)
1231          document.querySelectorAll('.browser-section > .ws-card').forEach(el => {
1232            const wsName = (el.dataset.wsName || '').toLowerCase();
1233            const tabEls = [];
1234            el.querySelectorAll('.tab-row').forEach(tEl => {
1235              tabEls.push({
1236                el: tEl,
1237                title: tEl.dataset.tabTitle || '',
1238                url: tEl.dataset.tabUrl || ''
1239              });
1240            });
1241            wsIndex.push({ el, wsName, tabEls, children: [], type: 'standalone' });
1242          });
1243        }
1244  
1245        buildIndex();
1246  
1247        // ---- Search ----
1248        let debounceTimer = null;
1249  
1250        function doSearch(query) {
1251          query = query.toLowerCase().trim();
1252          if (!query) { clearSearch(); return; }
1253  
1254          const url = new URL(window.location);
1255          url.searchParams.set('q', query);
1256          history.replaceState(null, '', url);
1257  
1258          let anyVisible = false;
1259  
1260          document.querySelectorAll('.browser-section').forEach(s => s.style.display = 'none');
1261  
1262          wsIndex.forEach(entry => {
1263            if (entry.type === 'parent') {
1264              const parentNameMatch = entry.wsName.includes(query);
1265              let anyChildVisible = false;
1266  
1267              entry.tabEls.forEach(t => {
1268                const match = t.title.includes(query) || t.url.includes(query);
1269                t.el.style.display = match || parentNameMatch ? '' : 'none';
1270              });
1271  
1272              let parentTabMatch = entry.tabEls.some(t => t.title.includes(query) || t.url.includes(query));
1273  
1274              entry.children.forEach(child => {
1275                const childNameMatch = child.wsName.includes(query) || parentNameMatch;
1276                let childTabMatch = false;
1277                child.tabEls.forEach(t => {
1278                  const match = t.title.includes(query) || t.url.includes(query);
1279                  t.el.style.display = match || childNameMatch ? '' : 'none';
1280                  if (match) childTabMatch = true;
1281                });
1282  
1283                const childVisible = childNameMatch || childTabMatch;
1284                child.el.style.display = childVisible ? '' : 'none';
1285                if (childVisible) anyChildVisible = true;
1286  
1287                if (childTabMatch && !childNameMatch) {
1288                  const wsHeader = child.el.querySelector('.ws-header');
1289                  const wsTabs = child.el.querySelector('.ws-tabs');
1290                  if (wsHeader) wsHeader.classList.add('expanded');
1291                  if (wsTabs) wsTabs.classList.add('open');
1292                }
1293              });
1294  
1295              const parentVisible = parentNameMatch || parentTabMatch || anyChildVisible;
1296              entry.el.style.display = parentVisible ? '' : 'none';
1297              if (parentVisible) {
1298                anyVisible = true;
1299                entry.el.closest('.browser-section').style.display = '';
1300                const ph = entry.el.querySelector('.parent-header');
1301                const pc = entry.el.querySelector('.parent-children');
1302                if (ph && pc && (anyChildVisible || parentTabMatch)) {
1303                  ph.classList.add('expanded');
1304                  pc.classList.add('open');
1305                  entry.el.classList.remove('collapsed');
1306                }
1307              }
1308            } else {
1309              const nameMatch = entry.wsName.includes(query);
1310              let tabMatch = false;
1311              entry.tabEls.forEach(t => {
1312                const match = t.title.includes(query) || t.url.includes(query);
1313                t.el.style.display = match || nameMatch ? '' : 'none';
1314                if (match) tabMatch = true;
1315              });
1316  
1317              const visible = nameMatch || tabMatch;
1318              entry.el.style.display = visible ? '' : 'none';
1319              if (visible) {
1320                anyVisible = true;
1321                entry.el.closest('.browser-section').style.display = '';
1322                if (tabMatch) {
1323                  const wh = entry.el.querySelector('.ws-header');
1324                  const wt = entry.el.querySelector('.ws-tabs');
1325                  if (wh) wh.classList.add('expanded');
1326                  if (wt) wt.classList.add('open');
1327                }
1328              }
1329            }
1330          });
1331  
1332          noResults.classList.toggle('visible', !anyVisible);
1333          searchClear.classList.add('visible');
1334          searchHint.style.display = 'none';
1335        }
1336  
1337        function clearSearch() {
1338          searchInput.value = '';
1339          searchClear.classList.remove('visible');
1340          searchHint.style.display = '';
1341          noResults.classList.remove('visible');
1342  
1343          const url = new URL(window.location);
1344          url.searchParams.delete('q');
1345          history.replaceState(null, '', url);
1346  
1347          document.querySelectorAll('.browser-section').forEach(s => s.style.display = '');
1348  
1349          wsIndex.forEach(entry => {
1350            entry.el.style.display = '';
1351            entry.tabEls.forEach(t => t.el.style.display = '');
1352            if (entry.children) {
1353              entry.children.forEach(child => {
1354                child.el.style.display = '';
1355                child.tabEls.forEach(t => t.el.style.display = '');
1356                const wh = child.el.querySelector('.ws-header');
1357                const wt = child.el.querySelector('.ws-tabs');
1358                if (wh) wh.classList.remove('expanded');
1359                if (wt) wt.classList.remove('open');
1360              });
1361            }
1362            if (entry.type === 'parent') {
1363              const ph = entry.el.querySelector('.parent-header');
1364              const pc = entry.el.querySelector('.parent-children');
1365              if (ph) ph.classList.remove('expanded');
1366              if (pc) pc.classList.remove('open');
1367              entry.el.classList.add('collapsed');
1368            } else {
1369              const wh = entry.el.querySelector('.ws-header');
1370              const wt = entry.el.querySelector('.ws-tabs');
1371              if (wh) wh.classList.remove('expanded');
1372              if (wt) wt.classList.remove('open');
1373            }
1374          });
1375        }
1376  
1377        searchInput.addEventListener('input', function() {
1378          clearTimeout(debounceTimer);
1379          const val = this.value;
1380          if (!val) { clearSearch(); return; }
1381          searchClear.classList.add('visible');
1382          searchHint.style.display = 'none';
1383          debounceTimer = setTimeout(() => doSearch(val), 150);
1384        });
1385  
1386        searchClear.addEventListener('click', function() {
1387          clearSearch();
1388          searchInput.focus();
1389        });
1390  
1391        // ---- Keyboard shortcuts ----
1392        document.addEventListener('keydown', function(e) {
1393          if (e.key === '/' && document.activeElement !== searchInput) {
1394            e.preventDefault();
1395            searchInput.focus();
1396          }
1397          if (e.key === 'Escape' && document.activeElement === searchInput) {
1398            clearSearch();
1399            searchInput.blur();
1400          }
1401        });
1402  
1403        searchInput.addEventListener('focus', function() { searchHint.style.display = 'none'; });
1404        searchInput.addEventListener('blur', function() { if (!this.value) searchHint.style.display = ''; });
1405  
1406        // ---- Collapse/expand ----
1407        window.toggleParent = function(header) {
1408          header.classList.toggle('expanded');
1409          const children = header.parentElement.querySelector('.parent-children');
1410          if (children) children.classList.toggle('open');
1411          header.closest('.parent-group').classList.toggle('collapsed');
1412        };
1413  
1414        window.toggleWs = function(header) {
1415          header.classList.toggle('expanded');
1416          const tabs = header.parentElement.querySelector('.ws-tabs');
1417          if (tabs) tabs.classList.toggle('open');
1418        };
1419  
1420        // ---- Todoist integration ----
1421        window.createTodoistProject = async function(btn) {
1422          btn.disabled = true;
1423          btn.textContent = '...';
1424          // Remove any previous error
1425          const prevErr = btn.parentElement.querySelector('.todoist-error');
1426          if (prevErr) prevErr.remove();
1427  
1428          const wsId = btn.dataset.wsId;
1429          const wsName = btn.dataset.wsName;
1430          const browserKey = btn.dataset.browserKey;
1431          const parentTodoistId = btn.dataset.parentTodoistId || null;
1432  
1433          try {
1434            const res = await fetch('/todoist/create-project', {
1435              method: 'POST',
1436              headers: { 'Content-Type': 'application/json' },
1437              body: JSON.stringify({
1438                workspace_id: wsId,
1439                workspace_name: wsName,
1440                browser_key: browserKey,
1441                parent_todoist_project_id: parentTodoistId || undefined,
1442              }),
1443            });
1444            if (!res.ok) {
1445              const err = await res.json().catch(() => ({ detail: res.statusText }));
1446              throw new Error(err.detail || 'Failed');
1447            }
1448            const data = await res.json();
1449            // Replace button with link + add button
1450            const link = document.createElement('a');
1451            link.className = 'todoist-link';
1452            link.href = data.project_url;
1453            link.target = '_blank';
1454            link.rel = 'noopener';
1455            link.textContent = 'Todoist';
1456            link.onclick = function(e) { e.stopPropagation(); };
1457  
1458            const addBtn = document.createElement('button');
1459            addBtn.className = 'todoist-add-btn';
1460            addBtn.title = 'Quick-add task';
1461            addBtn.textContent = '+';
1462            addBtn.dataset.projectId = data.todoist_project_id;
1463            addBtn.onclick = function(e) { e.stopPropagation(); toggleQuickAdd(addBtn); };
1464  
1465            btn.parentElement.insertBefore(link, btn);
1466            btn.parentElement.insertBefore(addBtn, btn);
1467            btn.remove();
1468  
1469            // Add quick-add form after the header's parent element
1470            const header = link.closest('.ws-header, .parent-header');
1471            if (header) {
1472              const form = document.createElement('div');
1473              form.className = 'todoist-quick-add';
1474              form.dataset.projectId = data.todoist_project_id;
1475              form.innerHTML = '<input type="text" placeholder="Add task..." onkeydown="if(event.key===\'Enter\'){event.preventDefault();submitQuickTask(this.parentElement)}" />'
1476                + '<select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select>'
1477                + '<button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button>';
1478              header.parentElement.insertBefore(form, header.nextElementSibling);
1479            }
1480          } catch (e) {
1481            btn.disabled = false;
1482            btn.textContent = '+ Todoist';
1483            const errSpan = document.createElement('span');
1484            errSpan.className = 'todoist-error';
1485            errSpan.textContent = e.message;
1486            btn.parentElement.insertBefore(errSpan, btn.nextSibling);
1487            setTimeout(() => errSpan.remove(), 5000);
1488          }
1489        };
1490  
1491        window.toggleQuickAdd = function(btn) {
1492          const projectId = btn.dataset.projectId;
1493          // Find the quick-add form — it's a sibling of the header container
1494          const header = btn.closest('.ws-header, .parent-header');
1495          if (!header) return;
1496          let form = header.nextElementSibling;
1497          while (form && !form.classList.contains('todoist-quick-add')) {
1498            form = form.nextElementSibling;
1499          }
1500          if (!form) return;
1501          form.classList.toggle('open');
1502          if (form.classList.contains('open')) {
1503            const input = form.querySelector('input[type="text"]');
1504            if (input) input.focus();
1505          }
1506        };
1507  
1508        window.submitQuickTask = async function(form) {
1509          const input = form.querySelector('input[type="text"]');
1510          const select = form.querySelector('select');
1511          const submitBtn = form.querySelector('button[type="submit"]');
1512          const content = input.value.trim();
1513          if (!content) { input.focus(); return; }
1514  
1515          // Remove any previous feedback
1516          const prevFeedback = form.querySelector('.todoist-error, .todoist-success');
1517          if (prevFeedback) prevFeedback.remove();
1518  
1519          submitBtn.disabled = true;
1520          submitBtn.textContent = '...';
1521  
1522          try {
1523            const res = await fetch('/todoist/add-task', {
1524              method: 'POST',
1525              headers: { 'Content-Type': 'application/json' },
1526              body: JSON.stringify({
1527                project_id: form.dataset.projectId,
1528                content: content,
1529                priority: parseInt(select.value, 10),
1530              }),
1531            });
1532            if (!res.ok) {
1533              const err = await res.json().catch(() => ({ detail: res.statusText }));
1534              throw new Error(err.detail || 'Failed');
1535            }
1536            input.value = '';
1537            const ok = document.createElement('span');
1538            ok.className = 'todoist-success';
1539            ok.textContent = 'Added!';
1540            form.appendChild(ok);
1541            setTimeout(() => ok.remove(), 2000);
1542          } catch (e) {
1543            const errSpan = document.createElement('span');
1544            errSpan.className = 'todoist-error';
1545            errSpan.textContent = e.message;
1546            form.appendChild(errSpan);
1547            setTimeout(() => errSpan.remove(), 5000);
1548          } finally {
1549            submitBtn.disabled = false;
1550            submitBtn.textContent = 'Add';
1551          }
1552        };
1553  
1554        // ---- Refresh ----
1555        refreshBtn.addEventListener('click', function() {
1556          this.classList.add('refreshing');
1557          window.location.reload();
1558        });
1559  
1560        // ---- ?q= URL parameter ----
1561        const params = new URLSearchParams(window.location.search);
1562        const initialQuery = params.get('q');
1563        if (initialQuery) {
1564          searchInput.value = initialQuery;
1565          searchClear.classList.add('visible');
1566          searchHint.style.display = 'none';
1567          doSearch(initialQuery);
1568        }
1569  
1570        // ==================================================================
1571        // Cross-browser tab transfer
1572        // ==================================================================
1573  
1574        window.openTransferPicker = function(btn) {
1575          closeTransferPicker();
1576          const sourceBrowser = btn.dataset.sourceBrowser;
1577          const tabUrl = btn.dataset.tabUrl;
1578          const tabTitle = btn.dataset.tabTitle;
1579          const tabPinned = btn.dataset.tabPinned === 'true';
1580  
1581          const overlay = document.createElement('div');
1582          overlay.className = 'transfer-picker';
1583          overlay.addEventListener('click', function(e) {
1584            if (e.target === overlay) closeTransferPicker();
1585          });
1586  
1587          const panel = document.createElement('div');
1588          panel.className = 'transfer-picker-content';
1589  
1590          const title = document.createElement('div');
1591          title.className = 'transfer-picker-title';
1592          title.textContent = 'Send tab to\u2026';
1593          panel.appendChild(title);
1594  
1595          let hasTargets = false;
1596          for (const [bk, workspaces] of Object.entries(_browserWorkspaces)) {
1597            if (bk === sourceBrowser) continue;
1598            if (!workspaces.length) continue;
1599            hasTargets = true;
1600  
1601            const label = document.createElement('div');
1602            label.className = 'transfer-browser-label';
1603            label.textContent = bk;
1604            panel.appendChild(label);
1605  
1606            for (const ws of workspaces) {
1607              const item = document.createElement('div');
1608              item.className = 'transfer-ws-item';
1609              item.innerHTML = '<span>' + (ws.name || 'Unnamed') + '</span>' +
1610                '<span class="ws-tab-count">' + ws.tab_count + ' tabs</span>';
1611              item.addEventListener('click', function() {
1612                sendTabTransfer(sourceBrowser, bk, ws.id, ws.name, {
1613                  url: tabUrl, title: tabTitle, pinned: tabPinned
1614                }, btn);
1615              });
1616              panel.appendChild(item);
1617            }
1618          }
1619  
1620          if (!hasTargets) {
1621            const empty = document.createElement('div');
1622            empty.className = 'transfer-ws-item';
1623            empty.style.color = 'var(--phosphor-dim)';
1624            empty.textContent = 'No other browsers connected';
1625            panel.appendChild(empty);
1626          }
1627  
1628          // Position near button
1629          const rect = btn.getBoundingClientRect();
1630          panel.style.top = Math.min(rect.bottom + 4, window.innerHeight - 380) + 'px';
1631          panel.style.left = Math.min(rect.left, window.innerWidth - 290) + 'px';
1632  
1633          overlay.appendChild(panel);
1634          document.body.appendChild(overlay);
1635  
1636          // Escape to close
1637          overlay._onKey = function(e) { if (e.key === 'Escape') closeTransferPicker(); };
1638          document.addEventListener('keydown', overlay._onKey);
1639        };
1640  
1641        window.closeTransferPicker = function() {
1642          const existing = document.querySelector('.transfer-picker');
1643          if (existing) {
1644            if (existing._onKey) document.removeEventListener('keydown', existing._onKey);
1645            existing.remove();
1646          }
1647        };
1648  
1649        function sendTabTransfer(sourceBrowser, targetBrowser, targetWsId, targetWsName, tabData, btn) {
1650          closeTransferPicker();
1651          fetch('/transfers', {
1652            method: 'POST',
1653            headers: { 'Content-Type': 'application/json' },
1654            body: JSON.stringify({
1655              source_browser: sourceBrowser,
1656              target_browser: targetBrowser,
1657              target_workspace_id: targetWsId,
1658              target_workspace_name: targetWsName,
1659              tab: tabData,
1660            }),
1661          })
1662          .then(function(res) {
1663            if (!res.ok) throw new Error('HTTP ' + res.status);
1664            return res.json();
1665          })
1666          .then(function() {
1667            showSendStatus(btn, 'Sent!', 'ok');
1668          })
1669          .catch(function(err) {
1670            showSendStatus(btn, err.message || 'Error', 'err');
1671          });
1672        }
1673  
1674        function showSendStatus(btn, text, cls) {
1675          const existing = btn.querySelector('.send-status');
1676          if (existing) existing.remove();
1677          const badge = document.createElement('span');
1678          badge.className = 'send-status ' + cls;
1679          badge.textContent = text;
1680          btn.appendChild(badge);
1681          setTimeout(function() { badge.remove(); }, 3000);
1682        }
1683  
1684      })();
1685    </script>
1686  </body>
1687  </html>