mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

docs: embedded search

* update static site

+1796 -76
+1016
docs/designs/semantic-search.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, maximum-scale=1.0, user-scalable=no" /> 6 + <title>Semantic Search - Lazurite</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap" rel="stylesheet" /> 10 + <link 11 + href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" 12 + rel="stylesheet" /> 13 + <link rel="stylesheet" href="styles.css" /> 14 + <style> 15 + .saved-container { 16 + padding-bottom: 88px; 17 + } 18 + 19 + /* Tab bar under header */ 20 + .saved-tabs { 21 + display: flex; 22 + border-bottom: 1px solid var(--border); 23 + background-color: var(--bg); 24 + } 25 + 26 + .saved-tab { 27 + flex: 1; 28 + padding: 14px; 29 + text-align: center; 30 + font-weight: 600; 31 + font-size: 15px; 32 + color: var(--text-secondary); 33 + cursor: pointer; 34 + border-bottom: 2px solid transparent; 35 + transition: all 0.2s ease; 36 + background: none; 37 + border-top: none; 38 + border-left: none; 39 + border-right: none; 40 + } 41 + 42 + .saved-tab:hover { 43 + background-color: var(--surface); 44 + color: var(--text-primary); 45 + } 46 + 47 + .saved-tab.active { 48 + color: var(--text-primary); 49 + border-bottom-color: var(--accent-primary); 50 + } 51 + 52 + /* Search input area */ 53 + .semantic-search-bar { 54 + padding: 12px 16px; 55 + border-bottom: 1px solid var(--border); 56 + } 57 + 58 + .semantic-input-wrapper { 59 + position: relative; 60 + } 61 + 62 + .semantic-input-wrapper svg { 63 + position: absolute; 64 + left: 12px; 65 + top: 50%; 66 + transform: translateY(-50%); 67 + width: 18px; 68 + height: 18px; 69 + color: var(--text-muted); 70 + } 71 + 72 + .semantic-input { 73 + width: 100%; 74 + padding: 10px 12px 10px 38px; 75 + border: 1px solid var(--border); 76 + border-radius: 9999px; 77 + background-color: var(--surface); 78 + color: var(--text-primary); 79 + font-size: 15px; 80 + font-family: inherit; 81 + transition: border-color 0.2s ease; 82 + } 83 + 84 + .semantic-input:focus { 85 + outline: none; 86 + border-color: var(--accent-primary); 87 + } 88 + 89 + .semantic-input::placeholder { 90 + color: var(--text-muted); 91 + } 92 + 93 + /* Scope chips */ 94 + .scope-chips { 95 + display: flex; 96 + gap: 8px; 97 + padding: 10px 16px; 98 + border-bottom: 1px solid var(--border); 99 + } 100 + 101 + .scope-chip { 102 + padding: 6px 14px; 103 + border-radius: 9999px; 104 + font-size: 13px; 105 + font-weight: 500; 106 + border: 1.5px solid var(--border); 107 + background: none; 108 + color: var(--text-secondary); 109 + cursor: pointer; 110 + transition: all 0.2s ease; 111 + font-family: inherit; 112 + } 113 + 114 + .scope-chip:hover { 115 + border-color: var(--accent-primary); 116 + color: var(--accent-primary); 117 + } 118 + 119 + .scope-chip.active { 120 + background-color: var(--accent-primary); 121 + border-color: var(--accent-primary); 122 + color: white; 123 + } 124 + 125 + /* Relevance badge */ 126 + .relevance-badge { 127 + display: inline-flex; 128 + align-items: center; 129 + gap: 4px; 130 + padding: 2px 8px; 131 + border-radius: 9999px; 132 + font-size: 11px; 133 + font-weight: 600; 134 + font-family: var(--font-mono); 135 + white-space: nowrap; 136 + } 137 + 138 + .relevance-high { 139 + background-color: rgba(34, 197, 94, 0.15); 140 + color: var(--accent-success); 141 + } 142 + 143 + .relevance-medium { 144 + background-color: rgba(245, 158, 11, 0.15); 145 + color: var(--accent-warning); 146 + } 147 + 148 + .relevance-low { 149 + background-color: rgba(168, 168, 168, 0.15); 150 + color: var(--text-muted); 151 + } 152 + 153 + /* Source tag */ 154 + .source-tag { 155 + display: inline-flex; 156 + align-items: center; 157 + gap: 4px; 158 + padding: 2px 8px; 159 + border-radius: 4px; 160 + font-size: 11px; 161 + font-weight: 500; 162 + background-color: var(--surface-variant); 163 + color: var(--text-secondary); 164 + } 165 + 166 + .source-tag svg { 167 + width: 12px; 168 + height: 12px; 169 + } 170 + 171 + /* Result meta row */ 172 + .result-meta { 173 + display: flex; 174 + align-items: center; 175 + gap: 8px; 176 + margin-bottom: 8px; 177 + } 178 + 179 + /* Indexing progress bar */ 180 + .indexing-bar { 181 + padding: 12px 16px; 182 + background-color: var(--surface); 183 + border-bottom: 1px solid var(--border); 184 + display: flex; 185 + align-items: center; 186 + gap: 12px; 187 + } 188 + 189 + .indexing-bar-text { 190 + font-size: 13px; 191 + color: var(--text-secondary); 192 + white-space: nowrap; 193 + } 194 + 195 + .indexing-progress { 196 + flex: 1; 197 + height: 4px; 198 + background-color: var(--surface-variant); 199 + border-radius: 2px; 200 + overflow: hidden; 201 + } 202 + 203 + .indexing-progress-fill { 204 + height: 100%; 205 + background-color: var(--accent-primary); 206 + border-radius: 2px; 207 + transition: width 0.3s ease; 208 + } 209 + 210 + .indexing-count { 211 + font-size: 12px; 212 + font-family: var(--font-mono); 213 + color: var(--text-muted); 214 + white-space: nowrap; 215 + } 216 + 217 + /* Settings-specific styles */ 218 + .settings-section { 219 + margin-bottom: 24px; 220 + } 221 + 222 + .settings-section-title { 223 + padding: 16px; 224 + font-size: 13px; 225 + font-weight: 600; 226 + color: var(--text-muted); 227 + text-transform: uppercase; 228 + letter-spacing: 0.5px; 229 + } 230 + 231 + .settings-group { 232 + background-color: var(--surface); 233 + border-top: 1px solid var(--border); 234 + border-bottom: 1px solid var(--border); 235 + } 236 + 237 + .slider-row { 238 + display: flex; 239 + align-items: center; 240 + gap: 12px; 241 + padding: 0 16px 16px; 242 + } 243 + 244 + .slider-track { 245 + flex: 1; 246 + height: 4px; 247 + background-color: var(--surface-variant); 248 + border-radius: 2px; 249 + position: relative; 250 + } 251 + 252 + .slider-fill { 253 + position: absolute; 254 + height: 100%; 255 + background-color: var(--accent-primary); 256 + border-radius: 2px; 257 + width: 25%; 258 + } 259 + 260 + .slider-thumb { 261 + position: absolute; 262 + top: -6px; 263 + left: 25%; 264 + transform: translateX(-50%); 265 + width: 16px; 266 + height: 16px; 267 + border-radius: 50%; 268 + background-color: var(--accent-primary); 269 + border: 2px solid white; 270 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 271 + } 272 + 273 + .slider-value { 274 + font-size: 14px; 275 + font-weight: 600; 276 + font-family: var(--font-mono); 277 + color: var(--text-primary); 278 + min-width: 24px; 279 + text-align: right; 280 + } 281 + 282 + .slider-labels { 283 + display: flex; 284 + justify-content: space-between; 285 + padding: 0 16px 12px; 286 + } 287 + 288 + .slider-label { 289 + font-size: 11px; 290 + color: var(--text-muted); 291 + } 292 + 293 + /* Unavailable banner */ 294 + .unavailable-banner { 295 + margin: 16px; 296 + padding: 16px; 297 + background-color: var(--surface); 298 + border: 1px solid var(--border); 299 + border-radius: 12px; 300 + display: flex; 301 + align-items: flex-start; 302 + gap: 12px; 303 + } 304 + 305 + .unavailable-banner svg { 306 + width: 24px; 307 + height: 24px; 308 + color: var(--accent-warning); 309 + flex-shrink: 0; 310 + margin-top: 2px; 311 + } 312 + 313 + .unavailable-banner-text { 314 + font-size: 14px; 315 + color: var(--text-secondary); 316 + line-height: 1.5; 317 + } 318 + 319 + .unavailable-banner-title { 320 + font-weight: 600; 321 + color: var(--text-primary); 322 + margin-bottom: 4px; 323 + } 324 + 325 + /* View switcher */ 326 + .view-switcher { 327 + display: flex; 328 + gap: 12px; 329 + padding: 16px; 330 + justify-content: center; 331 + } 332 + 333 + .view-switcher-btn { 334 + padding: 8px 20px; 335 + border-radius: 9999px; 336 + font-size: 13px; 337 + font-weight: 600; 338 + border: 1.5px solid var(--border); 339 + background: none; 340 + color: var(--text-secondary); 341 + cursor: pointer; 342 + transition: all 0.2s ease; 343 + font-family: inherit; 344 + } 345 + 346 + .view-switcher-btn:hover { 347 + border-color: var(--accent-primary); 348 + color: var(--accent-primary); 349 + } 350 + 351 + .view-switcher-btn.active-view { 352 + background-color: var(--accent-primary); 353 + border-color: var(--accent-primary); 354 + color: white; 355 + } 356 + </style> 357 + </head> 358 + <body> 359 + <div class="mobile-container"> 360 + <!-- Header --> 361 + <header class="header"> 362 + <div style="display: flex; align-items: center; gap: 12px"> 363 + <button 364 + style="background: none; border: none; color: var(--text-primary); cursor: pointer; padding: 4px" 365 + title="Back"> 366 + <svg 367 + width="24" 368 + height="24" 369 + viewBox="0 0 24 24" 370 + fill="none" 371 + stroke="currentColor" 372 + stroke-width="2" 373 + stroke-linecap="round" 374 + stroke-linejoin="round"> 375 + <polyline points="15 18 9 12 15 6" /> 376 + </svg> 377 + </button> 378 + <h1 class="header-title">Saved Posts</h1> 379 + </div> 380 + </header> 381 + 382 + <!-- Tabs: All Saved / Search --> 383 + <div class="saved-tabs"> 384 + <button class="saved-tab" data-view="all">All Saved</button> 385 + <button class="saved-tab active" data-view="search">Search</button> 386 + </div> 387 + 388 + <!-- ==================== --> 389 + <!-- VIEW: SEARCH (active) --> 390 + <!-- ==================== --> 391 + <div class="saved-container" id="view-search"> 392 + <!-- Search input --> 393 + <div class="semantic-search-bar"> 394 + <div class="semantic-input-wrapper"> 395 + <svg 396 + viewBox="0 0 24 24" 397 + fill="none" 398 + stroke="currentColor" 399 + stroke-width="2" 400 + stroke-linecap="round" 401 + stroke-linejoin="round"> 402 + <circle cx="11" cy="11" r="8" /> 403 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 404 + </svg> 405 + <input class="semantic-input" type="text" placeholder="Search your saved posts..." value="federation decentralized protocol" /> 406 + </div> 407 + </div> 408 + 409 + <!-- Scope chips --> 410 + <div class="scope-chips"> 411 + <button class="scope-chip active">Both</button> 412 + <button class="scope-chip">Saved</button> 413 + <button class="scope-chip">Liked</button> 414 + </div> 415 + 416 + <!-- Search results --> 417 + <article class="post-card"> 418 + <div class="result-meta"> 419 + <span class="relevance-badge relevance-high">94%</span> 420 + <span class="source-tag"> 421 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 422 + <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" /> 423 + </svg> 424 + Saved 425 + </span> 426 + </div> 427 + <div class="post-header"> 428 + <div class="avatar">PF</div> 429 + <div class="post-author"> 430 + <div class="post-author-name">Paul Frazee</div> 431 + <div class="post-author-handle">@pfrazee.com · <span class="post-timestamp">Mar 12</span></div> 432 + </div> 433 + </div> 434 + <div class="post-content"> 435 + We just shipped a major update to the <a href="#" class="post-facet">@atproto</a> federation code. Self-hosting your own PDS is now easier than ever. The decentralized web is happening. 436 + </div> 437 + <div class="post-actions"> 438 + <button class="post-action"> 439 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 440 + <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 441 + </svg> 442 + 67 443 + </button> 444 + <button class="post-action"> 445 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 446 + <polyline points="17 1 21 5 17 9" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><polyline points="7 23 3 19 7 15" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /> 447 + </svg> 448 + 34 449 + </button> 450 + <button class="post-action"> 451 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 452 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 453 + </svg> 454 + 512 455 + </button> 456 + </div> 457 + </article> 458 + 459 + <article class="post-card"> 460 + <div class="result-meta"> 461 + <span class="relevance-badge relevance-high">87%</span> 462 + <span class="source-tag"> 463 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 464 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 465 + </svg> 466 + Liked 467 + </span> 468 + </div> 469 + <div class="post-header"> 470 + <div class="avatar">JG</div> 471 + <div class="post-author"> 472 + <div class="post-author-name">Jake Gold</div> 473 + <div class="post-author-handle">@jake.bsky.social · <span class="post-timestamp">Mar 8</span></div> 474 + </div> 475 + </div> 476 + <div class="post-content"> 477 + The <a href="#" class="post-facet">#atproto</a> ecosystem is growing fast. More and more third-party apps are being built on the open protocol. Federation changes everything about how we think about social networks. 478 + </div> 479 + <div class="post-actions"> 480 + <button class="post-action"> 481 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 482 + <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 483 + </svg> 484 + 23 485 + </button> 486 + <button class="post-action"> 487 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 488 + <polyline points="17 1 21 5 17 9" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><polyline points="7 23 3 19 7 15" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /> 489 + </svg> 490 + 11 491 + </button> 492 + <button class="post-action"> 493 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 494 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 495 + </svg> 496 + 178 497 + </button> 498 + </div> 499 + </article> 500 + 501 + <article class="post-card"> 502 + <div class="result-meta"> 503 + <span class="relevance-badge relevance-medium">72%</span> 504 + <span class="source-tag"> 505 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 506 + <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" /> 507 + </svg> 508 + Saved 509 + </span> 510 + </div> 511 + <div class="post-header"> 512 + <div class="avatar">SW</div> 513 + <div class="post-author"> 514 + <div class="post-author-name">Sarah Wu</div> 515 + <div class="post-author-handle">@sarahwu.dev · <span class="post-timestamp">Feb 21</span></div> 516 + </div> 517 + </div> 518 + <div class="post-content"> 519 + Just set up my own PDS on a $5/mo VPS. The documentation has gotten so much better. If you're interested in running your own node on the AT Protocol network, now is the time. 520 + </div> 521 + <div class="post-actions"> 522 + <button class="post-action"> 523 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 524 + <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 525 + </svg> 526 + 41 527 + </button> 528 + <button class="post-action"> 529 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 530 + <polyline points="17 1 21 5 17 9" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><polyline points="7 23 3 19 7 15" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /> 531 + </svg> 532 + 8 533 + </button> 534 + <button class="post-action"> 535 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 536 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 537 + </svg> 538 + 95 539 + </button> 540 + </div> 541 + </article> 542 + 543 + <article class="post-card"> 544 + <div class="result-meta"> 545 + <span class="relevance-badge relevance-low">58%</span> 546 + <span class="source-tag"> 547 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 548 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 549 + </svg> 550 + Liked 551 + </span> 552 + </div> 553 + <div class="post-header"> 554 + <div class="avatar">MR</div> 555 + <div class="post-author"> 556 + <div class="post-author-name">Mark Rivera</div> 557 + <div class="post-author-handle">@mrivera.bsky.social · <span class="post-timestamp">Jan 30</span></div> 558 + </div> 559 + </div> 560 + <div class="post-content"> 561 + Interesting thread comparing ActivityPub and AT Protocol approaches to decentralization. Both have trade-offs but I think the data portability story is stronger with atproto. 562 + </div> 563 + <div class="post-actions"> 564 + <button class="post-action"> 565 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 566 + <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> 567 + </svg> 568 + 156 569 + </button> 570 + <button class="post-action"> 571 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 572 + <polyline points="17 1 21 5 17 9" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><polyline points="7 23 3 19 7 15" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /> 573 + </svg> 574 + 52 575 + </button> 576 + <button class="post-action"> 577 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 578 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 579 + </svg> 580 + 389 581 + </button> 582 + </div> 583 + </article> 584 + </div> 585 + 586 + <!-- ====================== --> 587 + <!-- VIEW: EMPTY STATE --> 588 + <!-- ====================== --> 589 + <div class="saved-container" id="view-empty" style="display: none"> 590 + <div class="semantic-search-bar"> 591 + <div class="semantic-input-wrapper"> 592 + <svg 593 + viewBox="0 0 24 24" 594 + fill="none" 595 + stroke="currentColor" 596 + stroke-width="2" 597 + stroke-linecap="round" 598 + stroke-linejoin="round"> 599 + <circle cx="11" cy="11" r="8" /> 600 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 601 + </svg> 602 + <input class="semantic-input" type="text" placeholder="Search your saved posts..." /> 603 + </div> 604 + </div> 605 + 606 + <div class="scope-chips"> 607 + <button class="scope-chip active">Both</button> 608 + <button class="scope-chip">Saved</button> 609 + <button class="scope-chip">Liked</button> 610 + </div> 611 + 612 + <div class="empty-state" style="padding-top: 80px"> 613 + <svg 614 + class="empty-state-icon" 615 + viewBox="0 0 24 24" 616 + fill="none" 617 + stroke="currentColor" 618 + stroke-width="1.5" 619 + stroke-linecap="round" 620 + stroke-linejoin="round"> 621 + <circle cx="11" cy="11" r="8" /> 622 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 623 + <path d="M8 11h6" /> 624 + <path d="M11 8v6" /> 625 + </svg> 626 + <div class="empty-state-title">Semantic Search</div> 627 + <div class="empty-state-text">Search your saved and liked posts by meaning, not just keywords</div> 628 + </div> 629 + </div> 630 + 631 + <!-- ======================== --> 632 + <!-- VIEW: NO RESULTS --> 633 + <!-- ======================== --> 634 + <div class="saved-container" id="view-noresults" style="display: none"> 635 + <div class="semantic-search-bar"> 636 + <div class="semantic-input-wrapper"> 637 + <svg 638 + viewBox="0 0 24 24" 639 + fill="none" 640 + stroke="currentColor" 641 + stroke-width="2" 642 + stroke-linecap="round" 643 + stroke-linejoin="round"> 644 + <circle cx="11" cy="11" r="8" /> 645 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 646 + </svg> 647 + <input class="semantic-input" type="text" placeholder="Search your saved posts..." value="quantum computing hardware" /> 648 + </div> 649 + </div> 650 + 651 + <div class="scope-chips"> 652 + <button class="scope-chip active">Both</button> 653 + <button class="scope-chip">Saved</button> 654 + <button class="scope-chip">Liked</button> 655 + </div> 656 + 657 + <div class="empty-state" style="padding-top: 80px"> 658 + <svg 659 + class="empty-state-icon" 660 + viewBox="0 0 24 24" 661 + fill="none" 662 + stroke="currentColor" 663 + stroke-width="1.5" 664 + stroke-linecap="round" 665 + stroke-linejoin="round"> 666 + <circle cx="11" cy="11" r="8" /> 667 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 668 + </svg> 669 + <div class="empty-state-title">No similar posts found</div> 670 + <div class="empty-state-text">Try a different search or broaden your scope</div> 671 + </div> 672 + </div> 673 + 674 + <!-- ============================ --> 675 + <!-- VIEW: INDEXING IN PROGRESS --> 676 + <!-- ============================ --> 677 + <div class="saved-container" id="view-indexing" style="display: none"> 678 + <div class="semantic-search-bar"> 679 + <div class="semantic-input-wrapper"> 680 + <svg 681 + viewBox="0 0 24 24" 682 + fill="none" 683 + stroke="currentColor" 684 + stroke-width="2" 685 + stroke-linecap="round" 686 + stroke-linejoin="round"> 687 + <circle cx="11" cy="11" r="8" /> 688 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 689 + </svg> 690 + <input class="semantic-input" type="text" placeholder="Search your saved posts..." disabled /> 691 + </div> 692 + </div> 693 + 694 + <div class="indexing-bar"> 695 + <div class="indexing-bar-text">Indexing posts...</div> 696 + <div class="indexing-progress"> 697 + <div class="indexing-progress-fill" style="width: 47%"></div> 698 + </div> 699 + <div class="indexing-count">142/300</div> 700 + </div> 701 + 702 + <div class="empty-state" style="padding-top: 60px"> 703 + <svg 704 + class="empty-state-icon" 705 + viewBox="0 0 24 24" 706 + fill="none" 707 + stroke="currentColor" 708 + stroke-width="1.5" 709 + stroke-linecap="round" 710 + stroke-linejoin="round"> 711 + <path d="M12 2v4" /><path d="M12 18v4" /><path d="M4.93 4.93l2.83 2.83" /><path d="M16.24 16.24l2.83 2.83" /><path d="M2 12h4" /><path d="M18 12h4" /><path d="M4.93 19.07l2.83-2.83" /><path d="M16.24 7.76l2.83-2.83" /> 712 + </svg> 713 + <div class="empty-state-title">Building search index</div> 714 + <div class="empty-state-text">Your saved and liked posts are being indexed for semantic search. This happens once.</div> 715 + </div> 716 + </div> 717 + 718 + <!-- ========================== --> 719 + <!-- VIEW: UNAVAILABLE --> 720 + <!-- ========================== --> 721 + <div class="saved-container" id="view-unavailable" style="display: none"> 722 + <div class="unavailable-banner"> 723 + <svg 724 + viewBox="0 0 24 24" 725 + fill="none" 726 + stroke="currentColor" 727 + stroke-width="2" 728 + stroke-linecap="round" 729 + stroke-linejoin="round"> 730 + <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" /> 731 + <line x1="12" y1="9" x2="12" y2="13" /> 732 + <line x1="12" y1="17" x2="12.01" y2="17" /> 733 + </svg> 734 + <div> 735 + <div class="unavailable-banner-title">Semantic search unavailable</div> 736 + <div class="unavailable-banner-text"> 737 + The embedding model could not be loaded on this device. Semantic search requires a device that supports on-device ML inference. 738 + </div> 739 + </div> 740 + </div> 741 + </div> 742 + 743 + <!-- ========================== --> 744 + <!-- VIEW: SETTINGS SECTION --> 745 + <!-- ========================== --> 746 + <div class="saved-container" id="view-settings" style="display: none"> 747 + <div class="settings-section"> 748 + <div class="settings-section-title">Search</div> 749 + 750 + <div class="settings-group"> 751 + <!-- Toggle --> 752 + <div class="settings-item"> 753 + <div class="settings-item-left"> 754 + <svg 755 + class="settings-item-icon" 756 + viewBox="0 0 24 24" 757 + fill="none" 758 + stroke="currentColor" 759 + stroke-width="2" 760 + stroke-linecap="round" 761 + stroke-linejoin="round"> 762 + <circle cx="11" cy="11" r="8" /> 763 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 764 + </svg> 765 + <div class="settings-item-content"> 766 + <div class="settings-item-title">Semantic Search</div> 767 + <div class="settings-item-subtitle">Search saved posts by meaning</div> 768 + </div> 769 + </div> 770 + <div class="settings-item-right"> 771 + <div class="toggle active"> 772 + <div class="toggle-thumb"></div> 773 + </div> 774 + </div> 775 + </div> 776 + 777 + <!-- Search scope --> 778 + <div class="settings-item"> 779 + <div class="settings-item-left"> 780 + <svg 781 + class="settings-item-icon" 782 + viewBox="0 0 24 24" 783 + fill="none" 784 + stroke="currentColor" 785 + stroke-width="2" 786 + stroke-linecap="round" 787 + stroke-linejoin="round"> 788 + <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /> 789 + </svg> 790 + <div class="settings-item-content"> 791 + <div class="settings-item-title">Default Scope</div> 792 + <div class="settings-item-subtitle">Both</div> 793 + </div> 794 + </div> 795 + <div class="settings-item-right"> 796 + <svg 797 + width="20" 798 + height="20" 799 + viewBox="0 0 24 24" 800 + fill="none" 801 + stroke="currentColor" 802 + stroke-width="2" 803 + stroke-linecap="round" 804 + stroke-linejoin="round"> 805 + <polyline points="9 18 15 12 9 6" /> 806 + </svg> 807 + </div> 808 + </div> 809 + 810 + <!-- Index status --> 811 + <div class="settings-item"> 812 + <div class="settings-item-left"> 813 + <svg 814 + class="settings-item-icon" 815 + viewBox="0 0 24 24" 816 + fill="none" 817 + stroke="currentColor" 818 + stroke-width="2" 819 + stroke-linecap="round" 820 + stroke-linejoin="round"> 821 + <ellipse cx="12" cy="5" rx="9" ry="3" /> 822 + <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" /> 823 + <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" /> 824 + </svg> 825 + <div class="settings-item-content"> 826 + <div class="settings-item-title">Index Status</div> 827 + <div class="settings-item-subtitle">847 posts indexed</div> 828 + </div> 829 + </div> 830 + <div class="settings-item-right"> 831 + <button 832 + style=" 833 + padding: 6px 14px; 834 + border-radius: 9999px; 835 + border: 1.5px solid var(--border); 836 + background: none; 837 + color: var(--text-secondary); 838 + font-size: 13px; 839 + font-weight: 500; 840 + cursor: pointer; 841 + font-family: inherit; 842 + "> 843 + Re-index 844 + </button> 845 + </div> 846 + </div> 847 + 848 + <!-- Max results slider --> 849 + <div class="settings-item" style="flex-direction: column; align-items: stretch; gap: 8px"> 850 + <div style="display: flex; align-items: center; justify-content: space-between"> 851 + <div class="settings-item-left"> 852 + <svg 853 + class="settings-item-icon" 854 + viewBox="0 0 24 24" 855 + fill="none" 856 + stroke="currentColor" 857 + stroke-width="2" 858 + stroke-linecap="round" 859 + stroke-linejoin="round"> 860 + <line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" /> 861 + <line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" /> 862 + <line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" /> 863 + <line x1="1" y1="14" x2="7" y2="14" /><line x1="9" y1="8" x2="15" y2="8" /><line x1="17" y1="16" x2="23" y2="16" /> 864 + </svg> 865 + <div class="settings-item-content"> 866 + <div class="settings-item-title">Max Results</div> 867 + </div> 868 + </div> 869 + <div class="slider-value">20</div> 870 + </div> 871 + <div class="slider-row"> 872 + <div class="slider-track"> 873 + <div class="slider-fill"></div> 874 + <div class="slider-thumb"></div> 875 + </div> 876 + </div> 877 + <div class="slider-labels"> 878 + <span class="slider-label">10</span> 879 + <span class="slider-label">50</span> 880 + </div> 881 + </div> 882 + </div> 883 + </div> 884 + </div> 885 + 886 + <!-- View switcher (wireframe navigation) --> 887 + <div style=" 888 + position: fixed; 889 + bottom: 100px; 890 + left: 50%; 891 + transform: translateX(-50%); 892 + z-index: 200; 893 + background-color: var(--surface); 894 + border: 1px solid var(--border); 895 + border-radius: 16px; 896 + padding: 8px; 897 + display: flex; 898 + gap: 6px; 899 + flex-wrap: wrap; 900 + max-width: 390px; 901 + justify-content: center; 902 + box-shadow: 0 4px 12px rgba(0,0,0,0.15); 903 + "> 904 + <button class="view-switcher-btn active-view" data-target="view-search">Results</button> 905 + <button class="view-switcher-btn" data-target="view-empty">Empty</button> 906 + <button class="view-switcher-btn" data-target="view-noresults">No Results</button> 907 + <button class="view-switcher-btn" data-target="view-indexing">Indexing</button> 908 + <button class="view-switcher-btn" data-target="view-unavailable">Unavailable</button> 909 + <button class="view-switcher-btn" data-target="view-settings">Settings</button> 910 + </div> 911 + 912 + <!-- Bottom Navigation --> 913 + <nav class="nav-bar"> 914 + <a href="home.html" class="nav-item"> 915 + <svg 916 + viewBox="0 0 24 24" 917 + fill="none" 918 + stroke="currentColor" 919 + stroke-width="2" 920 + stroke-linecap="round" 921 + stroke-linejoin="round"> 922 + <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> 923 + <polyline points="9 22 9 12 15 12 15 22" /> 924 + </svg> 925 + <span>Home</span> 926 + </a> 927 + 928 + <a href="search.html" class="nav-item"> 929 + <svg 930 + viewBox="0 0 24 24" 931 + fill="none" 932 + stroke="currentColor" 933 + stroke-width="2" 934 + stroke-linecap="round" 935 + stroke-linejoin="round"> 936 + <circle cx="11" cy="11" r="8" /> 937 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 938 + </svg> 939 + <span>Search</span> 940 + </a> 941 + 942 + <a href="profile.html" class="nav-item"> 943 + <svg 944 + viewBox="0 0 24 24" 945 + fill="none" 946 + stroke="currentColor" 947 + stroke-width="2" 948 + stroke-linecap="round" 949 + stroke-linejoin="round"> 950 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> 951 + <circle cx="12" cy="7" r="4" /> 952 + </svg> 953 + <span>Profile</span> 954 + </a> 955 + 956 + <a href="settings.html" class="nav-item"> 957 + <svg 958 + viewBox="0 0 24 24" 959 + fill="none" 960 + stroke="currentColor" 961 + stroke-width="2" 962 + stroke-linecap="round" 963 + stroke-linejoin="round"> 964 + <circle cx="12" cy="12" r="3" /> 965 + <path 966 + d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /> 967 + </svg> 968 + <span>Settings</span> 969 + </a> 970 + </nav> 971 + </div> 972 + 973 + <script> 974 + /* Theme init */ 975 + (function () { 976 + const mode = localStorage.getItem("appearance-mode") || "dark"; 977 + if (mode === "dark") { 978 + document.documentElement.setAttribute("data-theme", "dark"); 979 + } else if (mode === "system") { 980 + const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; 981 + if (prefersDark) document.documentElement.setAttribute("data-theme", "dark"); 982 + } 983 + })(); 984 + 985 + /* Tab switching */ 986 + document.querySelectorAll(".saved-tab").forEach((tab) => { 987 + tab.addEventListener("click", () => { 988 + document.querySelectorAll(".saved-tab").forEach((t) => t.classList.remove("active")); 989 + tab.classList.add("active"); 990 + }); 991 + }); 992 + 993 + /* Scope chip switching */ 994 + document.querySelectorAll(".scope-chips").forEach((row) => { 995 + row.querySelectorAll(".scope-chip").forEach((chip) => { 996 + chip.addEventListener("click", () => { 997 + row.querySelectorAll(".scope-chip").forEach((c) => c.classList.remove("active")); 998 + chip.classList.add("active"); 999 + }); 1000 + }); 1001 + }); 1002 + 1003 + /* View switcher for wireframe preview */ 1004 + document.querySelectorAll(".view-switcher-btn").forEach((btn) => { 1005 + btn.addEventListener("click", () => { 1006 + document.querySelectorAll(".view-switcher-btn").forEach((b) => b.classList.remove("active-view")); 1007 + btn.classList.add("active-view"); 1008 + 1009 + const target = btn.dataset.target; 1010 + document.querySelectorAll(".saved-container").forEach((v) => (v.style.display = "none")); 1011 + document.getElementById(target).style.display = "block"; 1012 + }); 1013 + }); 1014 + </script> 1015 + </body> 1016 + </html>
+199
docs/specs/phase-7.md
··· 1 + --- 2 + title: Phase 7 Spec 3 + updated: 2026-04-09 4 + --- 5 + 6 + ## Semantic Search for Saved & Liked Posts 7 + 8 + On-device vector search over the user's saved and liked posts. 9 + Posts are embedded at save/like time using an on-device text embedding model, stored in ObjectBox with HNSW indexing, and queried via natural-language input. 10 + The entire pipeline runs locally -- no data leaves the device. 11 + 12 + ### Why ObjectBox + TFLite (not MediaPipe) 13 + 14 + **ObjectBox** (`objectbox` ^5.3.1) is the only Flutter-native vector DB with production-grade HNSW support. 15 + It provides `@HnswIndex` annotations, `nearestNeighborsF32` queries, and composable filters -- exactly what's needed. 16 + 17 + **TFLite via `tflite_flutter`** (^0.12.1) is the embedding runtime. 18 + MediaPipe's Flutter package (`mediapipe_text` 0.0.1) requires the Flutter master channel and the experimental `--enable-experiment=native-assets` flag, making it unsuitable for production. 19 + `tflite_flutter` is stable, runs on both iOS and Android, and can load the same TFLite models MediaPipe would use internally. 20 + 21 + **Embedding model:** MiniLM-L6-v2 (all-MiniLM-L6-v2), quantized to INT8. 22 + 384-dimensional output, ~25 MB model file, ~15ms inference on mid-range devices. 23 + Widely deployed, well-understood, Apache 2.0 licensed. Bundled as a Flutter asset. 24 + 25 + > Alternative considered: EmbeddingGemma (768D, ~200 MB). Better quality but 8x the model size -- too large for a bundled mobile asset. 26 + > MiniLM's 384D is sufficient for post-length text and keeps the app install size reasonable. 27 + 28 + ### Data Flow 29 + 30 + ```text 31 + Post saved/liked 32 + → Extract searchable text (post text + alt text from images + link card title/description) 33 + → Run TFLite inference in background Isolate → Float32List[384] 34 + → Store in ObjectBox (EmbeddedPost entity with HNSW-indexed vector) 35 + 36 + User searches 37 + → Embed query string via same model → Float32List[384] 38 + → ObjectBox nearestNeighborsF32(queryVector, maxResults) 39 + → Map results back to cached/saved posts → display 40 + ``` 41 + 42 + ### ObjectBox Entity Model 43 + 44 + ObjectBox runs as a **secondary data store** alongside Drift. It stores only embedding vectors and the metadata needed to join back to Drift's `SavedPosts`/cached posts. 45 + Drift remains the source of truth for post content. 46 + 47 + ```dart 48 + @Entity() 49 + class EmbeddedPost { 50 + @Id() 51 + int id = 0; 52 + 53 + /// AT URI of the post (e.g. at://did:plc:xxx/app.bsky.feed.post/yyy) 54 + @Unique() 55 + String postUri; 56 + 57 + /// Account DID that saved/liked this post 58 + String accountDid; 59 + 60 + /// 'saved' or 'liked' 61 + String source; 62 + 63 + /// Concatenated searchable text at embedding time 64 + String indexedText; 65 + 66 + /// 384-dimensional embedding vector 67 + @HnswIndex(dimensions: 384, distanceType: VectorDistanceType.cosine) 68 + @Property(type: PropertyType.floatVector) 69 + List<double>? embedding; 70 + 71 + /// When the embedding was generated (for staleness checks) 72 + @Property(type: PropertyType.dateNano) 73 + DateTime embeddedAt; 74 + } 75 + ``` 76 + 77 + ### Embedding Service 78 + 79 + `EmbeddingService` wraps the TFLite interpreter, running in a long-lived background `Isolate` to avoid UI jank. 80 + 81 + **Initialization:** 82 + 83 + 1. App startup → spawn isolate 84 + 2. Isolate loads TFLite model from assets (`assets/models/minilm_l6_v2_int8.tflite`) 85 + 3. Load tokenizer vocabulary (`assets/models/vocab.txt`) -- WordPiece tokenizer, max 256 tokens 86 + 4. Isolate listens on `ReceivePort` for embed requests 87 + 88 + **Embedding a post:** 89 + 90 + 1. Concatenate: `post.text + ' ' + altTexts.join(' ') + ' ' + linkCard?.title + ' ' + linkCard?.description` 91 + 2. Tokenize (WordPiece, pad/truncate to 256 tokens) 92 + 3. Run interpreter: input `[1, 256]` int32 tensor → output `[1, 384]` float32 tensor 93 + 4. L2-normalize the output vector 94 + 5. Return `Float32List` to caller via `SendPort` 95 + 96 + **Error handling:** If model fails to load (corrupt asset, unsupported device), semantic search degrades gracefully to unavailable. A flag `EmbeddingService.isAvailable` gates all UI entry points. 97 + 98 + ### Indexing Strategy 99 + 100 + **On save/like (incremental):** When a post is saved or liked, immediately queue it for embedding. The `EmbeddingService` isolate processes the queue serially. This keeps indexing latency invisible to the user -- most posts embed in <20ms. 101 + 102 + **Backfill (first launch or re-index):** On first enable or after clearing the index, batch-embed all existing saved/liked posts. Process in chunks of 50 with `Future.delayed(Duration.zero)` yielding between chunks to avoid hogging the isolate. Show progress in settings UI ("Indexing: 142/300 posts..."). 103 + 104 + **Staleness:** Posts are immutable on ATProto, so embeddings never go stale. If a post is un-saved or un-liked, remove its `EmbeddedPost` entry. 105 + 106 + **Account isolation:** `EmbeddedPost.accountDid` scopes all queries. On account switch, ObjectBox queries filter by the active account's DID. 107 + 108 + ### Search UX 109 + 110 + **Entry point:** New "Semantic Search" tab in the existing saved posts screen. Two tabs: "All Saved" (existing list) and "Search" (vector search). 111 + 112 + **Search tab layout:** 113 + 114 + - Text field with hint "Search your saved posts..." 115 + - Debounce: 500ms after typing stops 116 + - Results: list of post cards (reuse existing `PostCard` widget), ordered by cosine similarity 117 + - Each result shows a relevance badge (percentage, derived from `1 - cosineDistance`) 118 + - Empty state when no query entered: "Search your saved and liked posts by meaning, not just keywords" 119 + - No results state: "No similar posts found" 120 + - Max results: 20 (configurable in settings) 121 + 122 + **Scope toggle:** Chip row above results: "Saved" / "Liked" / "Both" (default: Both). Implemented as an ObjectBox query condition combined with the vector nearest-neighbor query. 123 + 124 + ### Liked Posts Integration 125 + 126 + Liked posts are not currently persisted locally. To include them in semantic search: 127 + 128 + **New Drift table:** 129 + 130 + ```dart 131 + @DataClassName('LikedPostEntry') 132 + class LikedPosts extends Table { 133 + IntColumn get id => integer().autoIncrement(); 134 + TextColumn get accountDid => text(); 135 + TextColumn get postUri => text(); 136 + TextColumn get postJson => text(); 137 + DateTimeColumn get likedAt => dateTime().withDefault(currentDateAndTime); 138 + 139 + @override 140 + List<String> get customConstraints => ['UNIQUE (account_did, post_uri)']; 141 + } 142 + ``` 143 + 144 + **Sync strategy:** Periodic background sync of `bluesky.feed.getActorLikes(actor:, limit:, cursor:)`. Runs on app foreground (if >5 minutes since last sync) and on manual pull-to-refresh. Fetches newest likes until it hits an already-known URI, then stops. Caps at 1000 stored likes per account (evicts oldest on overflow). 145 + 146 + This is a **Drift migration** (schema version 15). 147 + 148 + ### Settings 149 + 150 + Under "Search" section in settings: 151 + 152 + - **Semantic Search** toggle (default: off) -- enables/disables the feature, triggers backfill on first enable 153 + - **Search scope** -- "Saved only" / "Liked only" / "Both" (default: Both) 154 + - **Index status** -- shows count of indexed posts, "Re-index" button 155 + - **Max results** -- slider, 10-50, default 20 156 + 157 + ### Package Dependencies 158 + 159 + | Package | Version | Purpose | 160 + | ------------------------ | ------- | ---------------------------------------------- | 161 + | `objectbox` | ^5.3.1 | Vector storage + HNSW nearest-neighbor queries | 162 + | `objectbox_flutter_libs` | ^5.3.1 | Platform-specific ObjectBox native libraries | 163 + | `tflite_flutter` | ^0.12.1 | On-device TFLite model inference | 164 + 165 + Build tooling: `objectbox_generator` (build_runner) for code generation. 166 + 167 + ### ObjectBox Integration Notes 168 + 169 + ObjectBox requires its own initialization separate from Drift: 170 + 171 + ```dart 172 + final store = await openStore(directory: join(appDocDir, 'objectbox')); 173 + ``` 174 + 175 + This runs once at app startup (after Drift init). The `Store` instance is provided via the service locator / `RepositoryProvider` tree alongside the existing Drift database. 176 + 177 + ObjectBox's generated `objectbox-model.json` and `objectbox.g.dart` must be committed. Run `dart run build_runner build` after entity changes. 178 + 179 + ### Performance Budget 180 + 181 + | Operation | Target | Notes | 182 + | ------------------------------ | ------ | -------------------------------------- | 183 + | Model load (cold) | <500ms | One-time on app start | 184 + | Single post embedding | <20ms | MiniLM INT8 on mid-range device | 185 + | Batch embed 100 posts | <3s | In background isolate | 186 + | Vector query (1000 vectors) | <5ms | ObjectBox HNSW | 187 + | Vector query (10000 vectors) | <15ms | ObjectBox HNSW | 188 + | Model asset size | ~25 MB | INT8 quantized MiniLM-L6-v2 | 189 + | ObjectBox storage (1000 posts) | ~2 MB | 384 floats x 4 bytes x 1000 + metadata | 190 + 191 + ### Limitations & Future Work 192 + 193 + - **Text-only embeddings.** Image content is captured only via alt text and link card metadata. 194 + A future phase could add image embeddings (MobileNet V3 + separate HNSW index), but that doubles model size and complexity. 195 + - **No cross-account search.** Each account's embeddings are isolated. A "search all accounts" mode could be added later. 196 + - **No re-ranking.** Results are pure cosine similarity. A future improvement could apply BM25 re-ranking on the top-K results for hybrid search. 197 + (Highest priority future update) 198 + - **Liked posts sync is incremental, not complete.** The 1000-like cap means very old likes won't be searchable. 199 + This is a pragmatic trade-off for storage.
+101
docs/tasks/phase-7.md
··· 1 + --- 2 + title: Phase 7 Task Breakdown 3 + updated: 2026-04-09 4 + --- 5 + 6 + # Phase 7 Milestones 7 + 8 + ## M26 - Semantic Search for Saved & Liked Posts 9 + 10 + ### Core 11 + 12 + #### ObjectBox Setup 13 + 14 + - [ ] Add `objectbox`, `objectbox_flutter_libs` to `pubspec.yaml`; add `objectbox_generator` to dev deps 15 + - [ ] `EmbeddedPost` entity - `postUri` (unique), `accountDid`, `source` (saved/liked), `indexedText`, `embedding` (384D float vector, HNSW cosine index), `embeddedAt` 16 + - [ ] Run `build_runner` to generate `objectbox.g.dart` and `objectbox-model.json` 17 + - [ ] `ObjectBoxStore` singleton - `openStore()` at app startup (after Drift init), expose via `RepositoryProvider` 18 + - [ ] `EmbeddingRepository` - CRUD operations on `EmbeddedPost`: `upsert`, `deleteByUri`, `queryByAccount`, `countByAccount` 19 + 20 + #### TFLite Embedding Service 21 + 22 + - [ ] Add `tflite_flutter` to `pubspec.yaml` 23 + - [ ] Bundle `minilm_l6_v2_int8.tflite` and `vocab.txt` as Flutter assets 24 + - [ ] `WordPieceTokenizer` - load vocab, tokenize text, pad/truncate to 256 tokens, return `List<int>` 25 + - [ ] `EmbeddingService` - long-lived background `Isolate` with `ReceivePort`/`SendPort` message passing 26 + - [ ] `EmbeddingService.initialize()` - spawn isolate, load TFLite model + tokenizer in isolate 27 + - [ ] `EmbeddingService.embed(String text)` - send text to isolate, receive `Float32List[384]`, L2-normalize 28 + - [ ] `EmbeddingService.isAvailable` - flag gating UI entry points, false if model fails to load 29 + - [ ] `EmbeddingService.dispose()` - close isolate and interpreter 30 + - [ ] `PostTextExtractor` - concatenate post text + image alt texts + link card title/description into a single searchable string 31 + 32 + #### Liked Posts Sync 33 + 34 + - [ ] `LikedPosts` Drift table - `id`, `accountDid`, `postUri`, `postJson`, `likedAt`; unique constraint on `(account_did, post_uri)` 35 + - [ ] Drift migration v15 - add `liked_posts` table 36 + - [ ] `LikedPostsRepository` - `syncLikes(accountDid)`: call `bluesky.feed.getActorLikes(actor:, limit:100, cursor:)`, paginate until hitting known URI or 1000 cap, upsert new entries 37 + - [ ] `LikedPostsRepository.getLikedPosts(accountDid, {limit, offset})` - paginated query 38 + - [ ] `LikedPostsRepository.removeLike(accountDid, postUri)` - delete entry 39 + - [ ] Eviction: drop oldest entries when count exceeds 1000 per account 40 + 41 + #### Indexing Pipeline 42 + 43 + - [ ] `SemanticIndexer` - orchestrates embedding + storage for new posts 44 + - [ ] `indexPost(postUri, postJson, accountDid, source)` - extract text, embed, upsert `EmbeddedPost` 45 + - [ ] `removePost(postUri)` - delete `EmbeddedPost` entry 46 + - [ ] `backfill(accountDid)` - batch-embed all un-indexed saved + liked posts, chunks of 50, yield between chunks 47 + - [ ] `backfillProgress` stream - emits `(int completed, int total)` for UI progress display 48 + - [ ] Hook into `SavedPostsRepository.savePost()` - queue new save for indexing 49 + - [ ] Hook into `LikedPostsRepository.syncLikes()` - queue newly synced likes for indexing 50 + - [ ] Hook into unsave/unlike - remove from `EmbeddedPost` 51 + 52 + #### Vector Search 53 + 54 + - [ ] `SemanticSearchRepository` - depends on `EmbeddingService`, `EmbeddingRepository` 55 + - [ ] `search(query, accountDid, {source, maxResults})` - embed query, run `nearestNeighborsF32`, filter by `accountDid` and optional `source`, return `List<SemanticSearchResult>` 56 + - [ ] `SemanticSearchResult` model - `postUri`, `score` (cosine similarity as percentage), `source` (saved/liked) 57 + - [ ] Join results back to Drift `SavedPosts`/`LikedPosts` to hydrate full post JSON for display 58 + 59 + ### Cubit 60 + 61 + - [ ] `SemanticSearchCubit` - `search(query)` with 500ms debounce, `setScope(source)`, `clearResults()` 62 + - [ ] `SemanticSearchState` - `status` (initial/searching/loaded/error/unavailable), `results`, `query`, `scope` (saved/liked/both) 63 + - [ ] `LikedPostsSyncCubit` - `sync()` triggers like sync, exposes sync progress 64 + - [ ] `SemanticIndexCubit` - exposes `backfillProgress`, `indexedCount`, `reindex()` action 65 + 66 + ### UI 67 + 68 + #### Semantic Search Tab 69 + 70 + - [ ] Saved posts screen - add "Search" tab alongside existing "All Saved" tab 71 + - [ ] Search text field with hint "Search your saved posts..." 72 + - [ ] Scope toggle chips: "Saved" / "Liked" / "Both" (default: Both) 73 + - [ ] Results list - reuse `PostCard`, ordered by similarity score 74 + - [ ] Relevance badge on each result (percentage) 75 + - [ ] Empty state (no query): "Search your saved and liked posts by meaning, not just keywords" 76 + - [ ] No results state: "No similar posts found" 77 + - [ ] Unavailable state: shown when `EmbeddingService.isAvailable` is false, with explanation 78 + 79 + #### Settings 80 + 81 + - [ ] Settings screen - new "Search" section 82 + - [ ] "Semantic Search" toggle (default: off) - enables feature, triggers backfill on first enable 83 + - [ ] "Search scope" dropdown - Saved only / Liked only / Both 84 + - [ ] "Index status" tile - shows indexed post count, "Re-index" button 85 + - [ ] "Max results" slider - 10 to 50, default 20 86 + - [ ] Backfill progress indicator - "Indexing: 142/300 posts..." shown during backfill 87 + 88 + ### Tests 89 + 90 + - [ ] Unit tests: `WordPieceTokenizer` - tokenization, padding, truncation, edge cases (empty string, very long text) 91 + - [ ] Unit tests: `EmbeddingService` - initialization, embed returns correct dimensions, L2 normalization, dispose cleanup 92 + - [ ] Unit tests: `PostTextExtractor` - text concatenation from various post shapes (text-only, images with alt, link cards, combinations) 93 + - [ ] Unit tests: `EmbeddingRepository` - upsert, delete, query by account, count 94 + - [ ] Unit tests: `LikedPostsRepository` - sync pagination, dedup on known URI, 1000-cap eviction 95 + - [ ] Unit tests: `SemanticIndexer` - index/remove/backfill, progress stream, integration with save/like hooks 96 + - [ ] Unit tests: `SemanticSearchRepository` - search returns scored results, scope filtering, account isolation 97 + - [ ] Unit tests: `SemanticSearchCubit` - debounce, state transitions, scope changes 98 + - [ ] Unit tests: `SemanticIndexCubit` - backfill progress, reindex trigger 99 + - [ ] Widget tests: search tab renders, query produces results, scope chips filter, relevance badges display, empty/no-results/unavailable states 100 + - [ ] Widget tests: settings section renders, toggle enables/disables, progress indicator during backfill, re-index button triggers reindex 101 + - [ ] Integration test: save a post → verify it appears in semantic search results for a relevant query
+453 -76
www/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <meta name="description" content="Lazurite - A beautiful Material You Bluesky client for mobile and desktop" /> 6 + <meta 7 + name="description" 8 + content="Lazurite - A beautiful Bluesky client for mobile and desktop. Material You on iOS & Android, native desktop with semantic search." /> 7 9 <meta name="author" content="Stormlight Labs" /> 8 - <title>Lazurite - Coming Soon</title> 10 + <title>Lazurite for BlueSky</title> 9 11 <link rel="icon" type="image/svg+xml" href="static/favicon.svg" /> 10 12 <link rel="preconnect" href="https://fonts.googleapis.com" /> 11 13 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 12 14 <link 13 - href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" 15 + href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap" 14 16 rel="stylesheet" /> 15 17 16 18 <style> 17 19 :root { 18 - --bg-dark: #161616; 19 - --surface-dark: #262626; 20 - --surface-variant: #393939; 21 - --outline: #525252; 22 - --text-primary: #f2f4f8; 23 - --text-secondary: #dde1e6; 24 - --text-tertiary: #697689; 20 + /* Matches LazuriteTheme.dark() from lazurite_theme.dart */ 21 + --bg-dark: #000000; /* darkSurfaceContainerLowest (scaffold) */ 22 + --surface-dark: #191919; /* darkSurfaceContainer */ 23 + --surface-variant: #1f1f1f; /* darkSurfaceContainerHigh */ 24 + --outline: rgba(255, 255, 255, 0.1); /* darkOutlineVariant */ 25 + --outline-bright: rgba(255, 255, 255, 0.2); /* darkOutline */ 26 + --text-primary: #f4f6fb; /* darkOnSurface / darkOnPrimaryContainer */ 27 + --text-secondary: #ababab; /* darkOnSurfaceVariant */ 28 + --text-tertiary: #6f6f6f; 25 29 26 - --primary: #0085ff; 27 - --secondary: #78a9ff; 30 + --primary: #7dafff; /* darkPrimary */ 31 + --secondary: #0073de; /* darkPrimaryContainer */ 28 32 --tertiary: #33b1ff; 29 33 --cyan: #08bdba; 30 34 --purple: #be95ff; 35 + --error: #ff8080; /* darkError */ 31 36 32 - --font-display: "Lora", serif; 33 - --font-body: "DM Sans", sans-serif; 34 - --font-mono: "JetBrains Mono", monospace; 37 + --font-title: "Lora", serif; 38 + --font-display: "Google Sans", sans-serif; 39 + --font-body: "Inter", sans-serif; 40 + --font-mono: "Google Sans Code", monospace; 35 41 } 36 42 37 43 * { ··· 66 72 } 67 73 68 74 .logo { 69 - font-family: var(--font-display); 75 + font-family: var(--font-title); 70 76 font-size: 3.5rem; 71 77 font-weight: 700; 72 78 background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--tertiary) 100%); ··· 75 81 background-clip: text; 76 82 margin-bottom: 1rem; 77 83 letter-spacing: -0.02em; 84 + display: inline-flex; 85 + align-items: center; 86 + gap: 0.5rem; 87 + } 88 + 89 + .logo-icon { 90 + width: 3rem; 91 + height: 3rem; 92 + mask-image: url('./static/logo.svg'); 93 + background-color: var(--primary); 94 + mask-repeat: no-repeat; 95 + mask-size: contain; 96 + mask-position: center; 97 + 98 + -webkit-mask-image: url('./static/logo.svg'); 99 + -webkit-mask-repeat: no-repeat; 100 + -webkit-mask-size: contain; 101 + -webkit-mask-position: center; 78 102 } 79 103 80 104 .tagline { ··· 85 109 } 86 110 87 111 main { 88 - max-width: 800px; 112 + max-width: 900px; 89 113 margin: 0 auto; 90 114 } 91 115 ··· 143 167 } 144 168 } 145 169 170 + /* Platform sections */ 171 + .platforms { 172 + display: grid; 173 + grid-template-columns: 1fr 1fr; 174 + gap: 2rem; 175 + margin-bottom: 4rem; 176 + } 177 + 178 + .platform { 179 + background: var(--surface-dark); 180 + padding: 2rem; 181 + border-radius: 1rem; 182 + border: 1px solid var(--outline); 183 + } 184 + 185 + .platform-header { 186 + display: flex; 187 + align-items: center; 188 + gap: 0.75rem; 189 + margin-bottom: 1.25rem; 190 + } 191 + 192 + .platform-icon { 193 + width: 40px; 194 + height: 40px; 195 + border-radius: 10px; 196 + display: flex; 197 + align-items: center; 198 + justify-content: center; 199 + flex-shrink: 0; 200 + } 201 + 202 + .platform-icon.mobile { 203 + background: linear-gradient(135deg, var(--primary), var(--secondary)); 204 + } 205 + 206 + .platform-icon.desktop { 207 + background: linear-gradient(135deg, var(--purple), var(--tertiary)); 208 + } 209 + 210 + .platform-icon img { 211 + width: 22px; 212 + height: 22px; 213 + } 214 + 215 + .platform h2 { 216 + font-family: var(--font-display); 217 + font-size: 1.375rem; 218 + font-weight: 600; 219 + } 220 + 221 + .platform-badge { 222 + font-size: 0.6875rem; 223 + font-weight: 600; 224 + text-transform: uppercase; 225 + letter-spacing: 0.06em; 226 + padding: 0.2rem 0.5rem; 227 + border-radius: 0.25rem; 228 + margin-left: auto; 229 + } 230 + 231 + .platform-badge.alpha { 232 + background: rgba(190, 149, 255, 0.15); 233 + color: var(--purple); 234 + } 235 + 236 + .platform-badge.beta { 237 + background: rgba(125, 175, 255, 0.15); 238 + color: var(--primary); 239 + } 240 + 241 + .platform-desc { 242 + color: var(--text-secondary); 243 + font-size: 0.9375rem; 244 + line-height: 1.6; 245 + margin-bottom: 1.25rem; 246 + } 247 + 248 + .platform-targets { 249 + display: flex; 250 + gap: 0.5rem; 251 + flex-wrap: wrap; 252 + margin-bottom: 1.25rem; 253 + } 254 + 255 + .target-tag { 256 + background: var(--surface-variant); 257 + padding: 0.3rem 0.625rem; 258 + border-radius: 0.375rem; 259 + font-size: 0.8125rem; 260 + font-family: var(--font-mono); 261 + color: var(--text-secondary); 262 + } 263 + 264 + .platform-features { 265 + list-style: none; 266 + } 267 + 268 + .platform-features li { 269 + color: var(--text-secondary); 270 + font-size: 0.875rem; 271 + padding: 0.3rem 0; 272 + padding-left: 1.25rem; 273 + position: relative; 274 + } 275 + 276 + .platform-features li::before { 277 + content: ""; 278 + position: absolute; 279 + left: 0; 280 + top: 0.7rem; 281 + width: 6px; 282 + height: 6px; 283 + border-radius: 50%; 284 + background: var(--primary); 285 + opacity: 0.6; 286 + } 287 + 288 + .platform-screenshot { 289 + margin-top: 1.5rem; 290 + border-radius: 0.75rem; 291 + overflow: hidden; 292 + border: 1px solid var(--outline); 293 + } 294 + 295 + .platform-screenshot img { 296 + width: 100%; 297 + height: auto; 298 + display: block; 299 + } 300 + 301 + .platform-screenshot .caption { 302 + font-size: 0.75rem; 303 + color: var(--text-tertiary); 304 + text-align: center; 305 + padding: 0.5rem; 306 + background: var(--surface-variant); 307 + } 308 + 309 + /* Hero screenshots */ 310 + .hero-screenshots { 311 + display: flex; 312 + gap: 1.5rem; 313 + justify-content: center; 314 + align-items: flex-end; 315 + margin-bottom: 3rem; 316 + padding: 0 1rem; 317 + } 318 + 319 + .hero-screenshot { 320 + border-radius: 1rem; 321 + overflow: hidden; 322 + border: 1px solid var(--outline); 323 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 324 + } 325 + 326 + .hero-screenshot img { 327 + display: block; 328 + width: 100%; 329 + height: auto; 330 + } 331 + 332 + .hero-screenshot.phone { 333 + width: 200px; 334 + flex-shrink: 0; 335 + } 336 + 337 + .hero-screenshot.desktop-shot { 338 + width: 480px; 339 + flex-shrink: 0; 340 + } 341 + 342 + @media (max-width: 768px) { 343 + .hero-screenshots { 344 + flex-direction: column; 345 + align-items: center; 346 + } 347 + 348 + .hero-screenshot.phone { 349 + width: 160px; 350 + } 351 + 352 + .hero-screenshot.desktop-shot { 353 + width: 100%; 354 + max-width: 360px; 355 + } 356 + } 357 + 358 + /* Features grid */ 359 + .section-title { 360 + font-family: var(--font-display); 361 + font-size: 1.5rem; 362 + font-weight: 600; 363 + margin-bottom: 1.5rem; 364 + color: var(--text-primary); 365 + } 366 + 146 367 .features { 147 368 display: grid; 148 - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 369 + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 149 370 gap: 1.5rem; 150 371 margin-bottom: 4rem; 151 372 } 152 373 153 374 .feature { 154 375 background: var(--surface-dark); 155 - padding: 2rem; 376 + padding: 1.75rem; 156 377 border-radius: 1rem; 157 378 border: 1px solid var(--outline); 158 379 transition: ··· 162 383 163 384 .feature:hover { 164 385 transform: translateY(-2px); 165 - border-color: var(--primary); 386 + border-color: var(--outline-bright); 166 387 } 167 388 168 389 .feature h3 { 169 390 font-family: var(--font-display); 170 - font-size: 1.25rem; 171 - margin-bottom: 0.75rem; 391 + font-size: 1.125rem; 392 + margin-bottom: 0.5rem; 172 393 color: var(--text-primary); 173 394 } 174 395 175 396 .feature p { 176 397 color: var(--text-secondary); 177 - font-size: 0.9375rem; 398 + font-size: 0.875rem; 178 399 line-height: 1.6; 179 400 } 180 401 181 402 .feature-icon { 182 - width: 48px; 183 - height: 48px; 403 + width: 44px; 404 + height: 44px; 184 405 background: linear-gradient(135deg, var(--primary), var(--secondary)); 185 - border-radius: 12px; 406 + border-radius: 10px; 186 407 display: flex; 187 408 align-items: center; 188 409 justify-content: center; 189 - margin-bottom: 1rem; 190 - padding: 12px; 410 + margin-bottom: 0.875rem; 411 + padding: 10px; 412 + } 413 + 414 + .feature-icon.alt { 415 + background: linear-gradient(135deg, var(--purple), var(--tertiary)); 191 416 } 192 417 193 418 .feature-icon img { 194 419 width: 100%; 195 420 height: 100%; 196 - color: var(--bg-dark); 197 421 } 198 422 423 + /* Tech stack */ 199 424 .tech-stack { 200 425 background: var(--surface-dark); 201 426 padding: 2rem; ··· 211 436 color: var(--text-primary); 212 437 } 213 438 439 + .tech-group { 440 + margin-bottom: 1rem; 441 + } 442 + 443 + .tech-group:last-child { 444 + margin-bottom: 0; 445 + } 446 + 447 + .tech-group-label { 448 + font-size: 0.75rem; 449 + font-weight: 600; 450 + text-transform: uppercase; 451 + letter-spacing: 0.08em; 452 + color: var(--text-tertiary); 453 + margin-bottom: 0.5rem; 454 + } 455 + 214 456 .tech-list { 215 457 display: flex; 216 458 flex-wrap: wrap; 217 - gap: 0.75rem; 459 + gap: 0.5rem; 218 460 } 219 461 220 462 .tech-tag { 221 463 background: var(--surface-variant); 222 - padding: 0.5rem 1rem; 464 + padding: 0.4rem 0.875rem; 223 465 border-radius: 0.5rem; 224 - font-size: 0.875rem; 466 + font-size: 0.8125rem; 225 467 font-family: var(--font-mono); 226 468 color: var(--text-secondary); 227 469 border: 1px solid transparent; ··· 229 471 } 230 472 231 473 .tech-tag:hover { 232 - border-color: var(--primary); 233 - } 234 - 235 - footer { 236 - text-align: center; 237 - padding: 2rem; 238 - color: var(--text-tertiary); 239 - font-size: 0.875rem; 240 - border-top: 1px solid var(--outline); 241 - margin-top: 4rem; 242 - } 243 - 244 - footer a { 245 - color: var(--primary); 246 - text-decoration: none; 247 - transition: color 0.2s ease; 248 - } 249 - 250 - footer a:hover { 251 - color: var(--secondary); 474 + border-color: var(--outline-bright); 252 475 } 253 476 254 477 .oauth-info { ··· 273 496 text-decoration: underline; 274 497 } 275 498 499 + footer { 500 + text-align: center; 501 + padding: 2rem; 502 + color: var(--text-tertiary); 503 + font-size: 0.875rem; 504 + border-top: 1px solid var(--outline); 505 + margin-top: 4rem; 506 + } 507 + 508 + footer a { 509 + color: var(--primary); 510 + text-decoration: none; 511 + transition: color 0.2s ease; 512 + } 513 + 514 + footer a:hover { 515 + color: var(--secondary); 516 + } 517 + 276 518 @media (max-width: 768px) { 277 519 .logo { 278 520 font-size: 2.5rem; ··· 286 528 font-size: 1rem; 287 529 } 288 530 531 + .platforms { 532 + grid-template-columns: 1fr; 533 + } 534 + 289 535 .features { 290 536 grid-template-columns: 1fr; 291 537 } ··· 300 546 <body> 301 547 <div class="container"> 302 548 <header> 303 - <div class="logo">Lazurite</div> 304 - <p class="tagline">Coming Soon</p> 549 + <div class="logo"><span class="logo-icon"></span>Lazurite</div> 550 + <p class="tagline">A better Bluesky client</p> 305 551 </header> 306 552 307 553 <main> ··· 311 557 <span>In Active Development</span> 312 558 </div> 313 559 314 - <h1>A better BlueSky client</h1> 560 + <h1>Bluesky, everywhere you are</h1> 315 561 <p> 316 - Lazurite is a cross-platform mobile and desktop client for Bluesky, with a mobile-first design for Android 317 - and iOS, with desktop support for a seamless experience. 562 + Lazurite is a native Bluesky client built for people who want more from their social experience. 563 + A full-featured mobile app for iOS and Android, paired with a powerful desktop companion 564 + for macOS, Windows, and Linux. 318 565 </p> 319 566 </div> 320 567 568 + <div class="hero-screenshots"> 569 + <div class="hero-screenshot phone"> 570 + <!-- TODO: replace with actual mobile app screenshot (portrait, ~390x844) --> 571 + <img 572 + src="https://placehold.co/720x1280/191919/7dafff?text=Mobile%0ATimeline&font=inter" 573 + alt="Lazurite mobile timeline screenshot" /> 574 + </div> 575 + <div class="hero-screenshot desktop-shot"> 576 + <!-- TODO: replace with actual desktop app screenshot (landscape, ~1280x800) --> 577 + <img 578 + src="https://placehold.co/1280x720/0e0e0e/be95ff?text=Desktop%0AWorkspace&font=inter" 579 + alt="Lazurite desktop workspace screenshot" /> 580 + </div> 581 + <div class="hero-screenshot phone"> 582 + <!-- TODO: replace with actual mobile app screenshot (portrait, ~390x844) --> 583 + <img 584 + src="https://placehold.co/720x1280/191919/33b1ff?text=Mobile%0AProfile&font=inter" 585 + alt="Lazurite mobile profile screenshot" /> 586 + </div> 587 + </div> 588 + 589 + <div class="platforms"> 590 + <div class="platform"> 591 + <div class="platform-header"> 592 + <div class="platform-icon mobile"> 593 + <!-- TODO: add mobile device SVG icon (e.g. smartphone outline, white, 22x22) --> 594 + <img src="static/smartphone.svg" alt="Mobile icon" /> 595 + </div> 596 + <h2>Mobile</h2> 597 + <span class="platform-badge beta">Beta</span> 598 + </div> 599 + <p class="platform-desc"> 600 + Material You design with offline support, smart folders, and full AT Protocol coverage. 601 + Built with Flutter for a native feel on both platforms. 602 + </p> 603 + <div class="platform-targets"> 604 + <span class="target-tag">iOS</span> 605 + <span class="target-tag">Android</span> 606 + </div> 607 + <ul class="platform-features"> 608 + <li>Offline-first with local caching and offline compose</li> 609 + <li>Full DM support with conversation requests</li> 610 + <li>Drafts, scheduled posts, and bookmarks</li> 611 + <li>Content moderation with custom labelers</li> 612 + <li>Multi-account switching with data isolation</li> 613 + <li>Themeable: Oxocarbon, Catppuccin, Nord, Ros&eacute; Pine</li> 614 + </ul> 615 + <div class="platform-screenshot"> 616 + <!-- TODO: replace with actual mobile feed screenshot (~390x480 crop) --> 617 + <img 618 + src="https://placehold.co/720x1280/000000/7dafff?text=Feed+%2B+Compose&font=inter" 619 + alt="Mobile feed and compose" /> 620 + <div class="caption">Home feed with compose FAB</div> 621 + </div> 622 + </div> 623 + 624 + <div class="platform"> 625 + <div class="platform-header"> 626 + <div class="platform-icon desktop"> 627 + <img src="static/desktop.svg" alt="Desktop icon" /> 628 + </div> 629 + <h2>Desktop</h2> 630 + <span class="platform-badge alpha">Alpha</span> 631 + </div> 632 + <p class="platform-desc"> 633 + A native desktop experience built with Tauri and Solid.js. Semantic search 634 + across your saved posts, multi-column layouts, and deep AT Protocol tooling. 635 + </p> 636 + <div class="platform-targets"> 637 + <span class="target-tag">macOS</span> 638 + <span class="target-tag">Windows</span> 639 + <span class="target-tag">Linux</span> 640 + </div> 641 + <ul class="platform-features"> 642 + <li>Full-text and vector search over saved and liked posts</li> 643 + <li>Multi-column workspace layouts</li> 644 + <li>PDS browser for exploring repository data</li> 645 + <li>Composer window for distraction-free posting</li> 646 + <li>Deep-link handling for <code>at://</code> URIs</li> 647 + <li>Native notifications and global shortcuts</li> 648 + </ul> 649 + <div class="platform-screenshot"> 650 + <!-- TODO: replace with actual desktop multi-column screenshot (~640x400 crop) --> 651 + <img 652 + src="https://placehold.co/1280x720/000000/be95ff?text=Multi-Column+Layout&font=inter" 653 + alt="Desktop multi-column layout" /> 654 + <div class="caption">Multi-column workspace with search</div> 655 + </div> 656 + </div> 657 + </div> 658 + 659 + <h2 class="section-title" title="Shared Features">Across both platforms</h2> 321 660 <div class="features"> 322 661 <div class="feature"> 323 662 <div class="feature-icon"> 324 - <img src="static/palette.svg" alt="Palette icon" /> 663 + <img src="static/feed.svg" alt="Feed icon" /> 325 664 </div> 326 - <h3>Material You Design</h3> 327 - <p>Beautiful IBM Oxocarbon-inspired theme with dynamic color adaptation and smooth animations.</p> 665 + <h3>Feeds &amp; Timelines</h3> 666 + <p>Home timeline, custom algorithmic feeds, pinned feeds with drag-to-reorder. Full thread and reply chain rendering.</p> 667 + </div> 668 + 669 + <div class="feature"> 670 + <div class="feature-icon alt"> 671 + <img src="static/search.svg" alt="Search icon" /> 672 + </div> 673 + <h3>Search &amp; Discovery</h3> 674 + <p>Search your saved & liked posts, discover starterpacks with the power of local semantic search with embeddings.</p> 328 675 </div> 329 676 330 677 <div class="feature"> 331 678 <div class="feature-icon"> 332 - <img src="static/message.svg" alt="Message icon" /> 679 + <img src="static/lock.svg" alt="Moderation icon" /> 680 + </div> 681 + <h3>Moderation</h3> 682 + <p>Subscribe to labelers, configure per-label preferences, mute and block lists. Adult content controls with granular filtering.</p> 683 + </div> 684 + 685 + <div class="feature"> 686 + <div class="feature-icon alt"> 687 + <img src="static/folder.svg" alt="Lists icon" /> 333 688 </div> 334 - <h3>Full-Featured</h3> 335 - <p>Timeline, threads, profiles, notifications, search, direct messages, and smart folders.</p> 689 + <h3>Lists &amp; Starter Packs</h3> 690 + <p>Create and manage curation and moderation lists. Browse, search, and build starter packs to share with others.</p> 336 691 </div> 337 692 338 693 <div class="feature"> 339 694 <div class="feature-icon"> 340 695 <img src="static/offline.svg" alt="Offline icon" /> 341 696 </div> 342 - <h3>Offline-First</h3> 343 - <p>Smart caching with Drift database for lightning-fast performance and offline compose.</p> 697 + <h3>Offline &amp; Fast</h3> 698 + <p>Smart caching with local databases on both platforms. Browse cached content and queue actions while offline.</p> 344 699 </div> 345 700 346 701 <div class="feature"> 347 - <div class="feature-icon"> 348 - <img src="static/folder.svg" alt="Folder icon" /> 702 + <div class="feature-icon alt"> 703 + <img src="static/graph.svg" alt="Graph icon" /> 349 704 </div> 350 - <h3>Smart Folders</h3> 351 - <p>Create custom local-only feeds with powerful filtering and organization tools.</p> 705 + <h3>Social Graph</h3> 706 + <p>Explore connections with a force-directed graph visualization. See mutual follows, followers, and blocking context at a glance.</p> 352 707 </div> 353 708 </div> 354 709 355 710 <div class="tech-stack"> 356 - <h2>Built with...</h2> 357 - <div class="tech-list"> 358 - <span class="tech-tag">Flutter</span> 359 - <span class="tech-tag">Dart</span> 360 - <span class="tech-tag">Material 3</span> 361 - <span class="tech-tag">AT Protocol</span> 711 + <h2>Built with</h2> 712 + <div class="tech-group"> 713 + <div class="tech-group-label">Mobile</div> 714 + <div class="tech-list"> 715 + <span class="tech-tag">Flutter</span> 716 + <span class="tech-tag">Dart</span> 717 + <span class="tech-tag">Material 3</span> 718 + <span class="tech-tag">Drift</span> 719 + <span class="tech-tag">BLoC</span> 720 + </div> 721 + </div> 722 + <div class="tech-group"> 723 + <div class="tech-group-label">Desktop</div> 724 + <div class="tech-list"> 725 + <span class="tech-tag">Tauri 2</span> 726 + <span class="tech-tag">Rust</span> 727 + <span class="tech-tag">Solid.js</span> 728 + <span class="tech-tag">SQLite + FTS</span> 729 + <span class="tech-tag">fastembed</span> 730 + </div> 731 + </div> 732 + <div class="tech-group"> 733 + <div class="tech-group-label">Protocol</div> 734 + <div class="tech-list"> 735 + <span class="tech-tag">AT Protocol</span> 736 + <span class="tech-tag">OAuth 2.0 + DPoP</span> 737 + <span class="tech-tag">Constellation</span> 738 + </div> 362 739 </div> 363 740 </div> 364 741 ··· 373 750 374 751 <footer> 375 752 <p> 376 - Built with ⚡️ by 753 + Built with &#9889;&#65039; by 377 754 <a href="https://desertthunder.dev" target="_blank">Owais</a> 378 755 at 379 756 <a href="https://stormlightlabs.org" target="_blank">Stormlight Labs</a>
+5
www/static/desktop.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 64 64"> 2 + <path fill="currentColor" 3 + d="M58.25 2H5.75C3.875 2 2 3.875 2 5.75v42.188c0 1.875 1.875 3.75 3.75 3.75H24.5v3.75c0 2.604-3.378 3.75-7.5 3.75V62h30v-2.813c-4.123 0-7.5-1.146-7.5-3.75v-3.75h18.75c1.875 0 3.75-1.875 3.75-3.75V5.75C62 3.875 60.125 2 58.25 2M60 47.938c0 .769-.982 1.75-1.75 1.75H5.75c-.769 0-1.75-.981-1.75-1.75v-3.75h56z" /> 4 + <circle cx="32" cy="47.938" r=".938" fill="currentColor" /> 5 + </svg>
+8
www/static/feed.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"> 2 + <g fill="currentColor"> 3 + <path fill-opacity="0.5" d="M12.552 8a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2zm0 9a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2z" /> 4 + <path fill-opacity="0.8" d="M12.552 5a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2zm0 9a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2z" /> 5 + <path 6 + d="M3.448 4.002a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1zm0 8.996a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1z" /> 7 + </g> 8 + </svg>
+4
www/static/graph.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 256 256"> 2 + <path fill="currentColor" 3 + d="M200 152a31.84 31.84 0 0 0-19.53 6.68l-23.11-18A31.65 31.65 0 0 0 160 128c0-.74 0-1.48-.08-2.21l13.23-4.41A32 32 0 1 0 168 104c0 .74 0 1.48.08 2.21l-13.23 4.41A32 32 0 0 0 128 96a32.6 32.6 0 0 0-5.27.44L115.89 81A32 32 0 1 0 96 88a32.6 32.6 0 0 0 5.27-.44l6.84 15.4a31.92 31.92 0 0 0-8.57 39.64l-25.71 22.84a32.06 32.06 0 1 0 10.63 12l25.71-22.84a31.91 31.91 0 0 0 37.36-1.24l23.11 18A31.65 31.65 0 0 0 168 184a32 32 0 1 0 32-32m0-64a16 16 0 1 1-16 16a16 16 0 0 1 16-16M80 56a16 16 0 1 1 16 16a16 16 0 0 1-16-16M56 208a16 16 0 1 1 16-16a16 16 0 0 1-16 16m144-8a16 16 0 1 1 16-16a16 16 0 0 1-16 16" /> 4 + </svg>
+3
www/static/logo.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 512 512"> 2 + <path fill="currentColor" d="M128 16v99.3l119 118.9V120.1zm256 0L265 120.1v114.1l119-119zM16 128l104 119h114.2L115.3 128zm380.8 0l-119 119h114.1l104-119zM120 265L16 384h99.2l119-119zm157.8 0l119 119h99.1l-104-119zM247 277.8l-119 119V496l119-104.1zm18 0v114.1L384 496v-99.2z" /> 3 + </svg>
+7
www/static/search.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"> 2 + <g fill="none" stroke="currentColor" stroke-width="2"> 3 + <path d="m16.75 2.5l.52 1.23l1.23.52l-1.23.52L16.75 6l-.52-1.23L15 4.25l1.23-.52z" /> 4 + <path stroke-linecap="square" 5 + d="m15.803 15.804l5.303 5.303m-5.303-5.303A7.5 7.5 0 1 1 10 3.017m5.803 12.787A7.47 7.47 0 0 0 17.983 11" /> 6 + </g> 7 + </svg>