WIP PWA for Grain
0
fork

Configure Feed

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

docs: add grain timeline SPA implementation plan

Chad Miller f3b69402

+1820
+1820
docs/plans/2025-12-25-grain-timeline-spa.md
··· 1 + # Grain Timeline SPA Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build an Instagram-style PWA for browsing Grain Social photo galleries with infinite scroll timeline. 6 + 7 + **Architecture:** Lit web components following atomic design (atoms → molecules → organisms → templates → pages). Centralized GraphQL service fetches from quickslice MCP. History API router for clean URLs. CSS custom properties for theming. 8 + 9 + **Tech Stack:** Lit 3.x, Vite 5.x, Vanilla JS (ES modules), CSS custom properties, Service Worker 10 + 11 + --- 12 + 13 + ## Phase 1: Project Foundation 14 + 15 + ### Task 1: Initialize Vite Project 16 + 17 + **Files:** 18 + - Create: `package.json` 19 + - Create: `vite.config.js` 20 + - Create: `index.html` 21 + 22 + **Step 1: Initialize package.json** 23 + 24 + ```bash 25 + cd /Users/chadmiller/code/grain-next 26 + npm init -y 27 + ``` 28 + 29 + **Step 2: Install dependencies** 30 + 31 + ```bash 32 + npm install lit 33 + npm install -D vite 34 + ``` 35 + 36 + **Step 3: Create vite.config.js** 37 + 38 + Create `vite.config.js`: 39 + ```javascript 40 + import { defineConfig } from 'vite'; 41 + 42 + export default defineConfig({ 43 + root: '.', 44 + publicDir: 'public', 45 + build: { 46 + outDir: 'dist', 47 + target: 'esnext' 48 + }, 49 + server: { 50 + port: 3000 51 + } 52 + }); 53 + ``` 54 + 55 + **Step 4: Create index.html** 56 + 57 + Create `index.html`: 58 + ```html 59 + <!DOCTYPE html> 60 + <html lang="en"> 61 + <head> 62 + <meta charset="UTF-8"> 63 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 64 + <meta name="theme-color" content="#000000"> 65 + <link rel="manifest" href="/manifest.json"> 66 + <link rel="stylesheet" href="/src/styles/variables.css"> 67 + <title>Grain</title> 68 + </head> 69 + <body> 70 + <grain-app></grain-app> 71 + <script type="module" src="/src/main.js"></script> 72 + </body> 73 + </html> 74 + ``` 75 + 76 + **Step 5: Add npm scripts to package.json** 77 + 78 + Update `package.json` scripts: 79 + ```json 80 + { 81 + "scripts": { 82 + "dev": "vite", 83 + "build": "vite build", 84 + "preview": "vite preview" 85 + } 86 + } 87 + ``` 88 + 89 + **Step 6: Commit** 90 + 91 + ```bash 92 + git init 93 + git add package.json vite.config.js index.html 94 + git commit -m "feat: initialize vite project with lit" 95 + ``` 96 + 97 + --- 98 + 99 + ### Task 2: Create CSS Design Tokens 100 + 101 + **Files:** 102 + - Create: `src/styles/variables.css` 103 + 104 + **Step 1: Create directory structure** 105 + 106 + ```bash 107 + mkdir -p src/styles 108 + ``` 109 + 110 + **Step 2: Create variables.css** 111 + 112 + Create `src/styles/variables.css`: 113 + ```css 114 + :root { 115 + /* Colors - Instagram-inspired dark palette */ 116 + --color-bg-primary: #000000; 117 + --color-bg-secondary: #121212; 118 + --color-bg-elevated: #262626; 119 + --color-text-primary: #fafafa; 120 + --color-text-secondary: #a8a8a8; 121 + --color-border: #363636; 122 + --color-accent: #0095f6; 123 + --color-error: #ed4956; 124 + --color-heart: #ff3040; 125 + 126 + /* Typography */ 127 + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 128 + --font-size-xs: 0.75rem; 129 + --font-size-sm: 0.875rem; 130 + --font-size-base: 1rem; 131 + --font-weight-normal: 400; 132 + --font-weight-semibold: 600; 133 + 134 + /* Spacing */ 135 + --space-xs: 0.25rem; 136 + --space-sm: 0.5rem; 137 + --space-md: 1rem; 138 + --space-lg: 1.5rem; 139 + --space-xl: 2rem; 140 + 141 + /* Layout */ 142 + --feed-max-width: 470px; 143 + --border-radius: 8px; 144 + --avatar-size-sm: 32px; 145 + --avatar-size-md: 44px; 146 + } 147 + 148 + /* Global resets */ 149 + *, *::before, *::after { 150 + box-sizing: border-box; 151 + margin: 0; 152 + padding: 0; 153 + } 154 + 155 + body { 156 + font-family: var(--font-family); 157 + background: var(--color-bg-primary); 158 + color: var(--color-text-primary); 159 + line-height: 1.5; 160 + -webkit-font-smoothing: antialiased; 161 + } 162 + ``` 163 + 164 + **Step 3: Commit** 165 + 166 + ```bash 167 + git add src/styles/variables.css 168 + git commit -m "feat: add CSS design tokens" 169 + ``` 170 + 171 + --- 172 + 173 + ### Task 3: Create PWA Manifest and Icons 174 + 175 + **Files:** 176 + - Create: `public/manifest.json` 177 + - Create: `public/icons/` (placeholder) 178 + 179 + **Step 1: Create public directory** 180 + 181 + ```bash 182 + mkdir -p public/icons 183 + ``` 184 + 185 + **Step 2: Create manifest.json** 186 + 187 + Create `public/manifest.json`: 188 + ```json 189 + { 190 + "name": "Grain", 191 + "short_name": "Grain", 192 + "description": "Photo galleries for the AT Protocol", 193 + "start_url": "/", 194 + "display": "standalone", 195 + "background_color": "#000000", 196 + "theme_color": "#000000", 197 + "icons": [ 198 + { 199 + "src": "/icons/icon-192.png", 200 + "sizes": "192x192", 201 + "type": "image/png" 202 + }, 203 + { 204 + "src": "/icons/icon-512.png", 205 + "sizes": "512x512", 206 + "type": "image/png" 207 + } 208 + ] 209 + } 210 + ``` 211 + 212 + **Step 3: Create placeholder icon (will be replaced later)** 213 + 214 + Create `public/icons/.gitkeep`: 215 + ``` 216 + # Placeholder for PWA icons 217 + # Add icon-192.png and icon-512.png 218 + ``` 219 + 220 + **Step 4: Commit** 221 + 222 + ```bash 223 + git add public/ 224 + git commit -m "feat: add PWA manifest" 225 + ``` 226 + 227 + --- 228 + 229 + ### Task 4: Create Service Worker 230 + 231 + **Files:** 232 + - Create: `public/sw.js` 233 + - Modify: `src/main.js` 234 + 235 + **Step 1: Create service worker** 236 + 237 + Create `public/sw.js`: 238 + ```javascript 239 + const CACHE_NAME = 'grain-shell-v1'; 240 + const SHELL_ASSETS = [ 241 + '/', 242 + '/index.html' 243 + ]; 244 + 245 + self.addEventListener('install', (event) => { 246 + event.waitUntil( 247 + caches.open(CACHE_NAME) 248 + .then((cache) => cache.addAll(SHELL_ASSETS)) 249 + .then(() => self.skipWaiting()) 250 + ); 251 + }); 252 + 253 + self.addEventListener('activate', (event) => { 254 + event.waitUntil( 255 + caches.keys().then((keys) => { 256 + return Promise.all( 257 + keys.filter((key) => key !== CACHE_NAME) 258 + .map((key) => caches.delete(key)) 259 + ); 260 + }).then(() => self.clients.claim()) 261 + ); 262 + }); 263 + 264 + self.addEventListener('fetch', (event) => { 265 + // Skip non-GET requests 266 + if (event.request.method !== 'GET') return; 267 + 268 + // Skip API requests (network-first) 269 + if (event.request.url.includes('/graphql')) return; 270 + 271 + event.respondWith( 272 + caches.match(event.request) 273 + .then((cached) => cached || fetch(event.request)) 274 + ); 275 + }); 276 + ``` 277 + 278 + **Step 2: Create main.js with SW registration** 279 + 280 + Create `src/main.js`: 281 + ```javascript 282 + // Register service worker 283 + if ('serviceWorker' in navigator) { 284 + navigator.serviceWorker.register('/sw.js') 285 + .then((reg) => console.log('SW registered:', reg.scope)) 286 + .catch((err) => console.error('SW registration failed:', err)); 287 + } 288 + 289 + // Import app shell 290 + import './components/pages/grain-app.js'; 291 + ``` 292 + 293 + **Step 3: Commit** 294 + 295 + ```bash 296 + git add public/sw.js src/main.js 297 + git commit -m "feat: add service worker for PWA" 298 + ``` 299 + 300 + --- 301 + 302 + ## Phase 2: Atom Components 303 + 304 + ### Task 5: Create grain-avatar Atom 305 + 306 + **Files:** 307 + - Create: `src/components/atoms/grain-avatar.js` 308 + 309 + **Step 1: Create directory structure** 310 + 311 + ```bash 312 + mkdir -p src/components/atoms 313 + ``` 314 + 315 + **Step 2: Create grain-avatar.js** 316 + 317 + Create `src/components/atoms/grain-avatar.js`: 318 + ```javascript 319 + import { LitElement, html, css } from 'lit'; 320 + 321 + export class GrainAvatar extends LitElement { 322 + static properties = { 323 + src: { type: String }, 324 + alt: { type: String }, 325 + size: { type: String } 326 + }; 327 + 328 + static styles = css` 329 + :host { 330 + display: block; 331 + flex-shrink: 0; 332 + } 333 + img { 334 + display: block; 335 + border-radius: 50%; 336 + object-fit: cover; 337 + background: var(--color-bg-elevated); 338 + } 339 + .sm { 340 + width: var(--avatar-size-sm); 341 + height: var(--avatar-size-sm); 342 + } 343 + .md { 344 + width: var(--avatar-size-md); 345 + height: var(--avatar-size-md); 346 + } 347 + `; 348 + 349 + constructor() { 350 + super(); 351 + this.size = 'sm'; 352 + this.alt = ''; 353 + } 354 + 355 + render() { 356 + return html` 357 + <img 358 + class=${this.size} 359 + src=${this.src || ''} 360 + alt=${this.alt} 361 + loading="lazy" 362 + > 363 + `; 364 + } 365 + } 366 + 367 + customElements.define('grain-avatar', GrainAvatar); 368 + ``` 369 + 370 + **Step 3: Verify it loads** 371 + 372 + ```bash 373 + npm run dev 374 + # Visit http://localhost:3000 - no errors in console 375 + ``` 376 + 377 + **Step 4: Commit** 378 + 379 + ```bash 380 + git add src/components/atoms/grain-avatar.js 381 + git commit -m "feat: add grain-avatar atom component" 382 + ``` 383 + 384 + --- 385 + 386 + ### Task 6: Create grain-icon Atom 387 + 388 + **Files:** 389 + - Create: `src/components/atoms/grain-icon.js` 390 + 391 + **Step 1: Create grain-icon.js with SVG icons** 392 + 393 + Create `src/components/atoms/grain-icon.js`: 394 + ```javascript 395 + import { LitElement, html, css } from 'lit'; 396 + 397 + const ICONS = { 398 + heart: html`<path d="M16.792 3.904A4.989 4.989 0 0 1 21.5 9.122c0 3.072-2.652 4.959-5.197 7.222-2.512 2.243-3.865 3.469-4.303 3.752-.477-.309-2.143-1.823-4.303-3.752C5.152 14.081 2.5 12.194 2.5 9.122a4.989 4.989 0 0 1 4.708-5.218 4.21 4.21 0 0 1 3.675 1.941c.84 1.175.98 1.763 1.12 1.763s.278-.588 1.11-1.766a4.17 4.17 0 0 1 3.679-1.938z"/>`, 399 + heartFilled: html`<path fill="currentColor" d="M16.792 3.904A4.989 4.989 0 0 1 21.5 9.122c0 3.072-2.652 4.959-5.197 7.222-2.512 2.243-3.865 3.469-4.303 3.752-.477-.309-2.143-1.823-4.303-3.752C5.152 14.081 2.5 12.194 2.5 9.122a4.989 4.989 0 0 1 4.708-5.218 4.21 4.21 0 0 1 3.675 1.941c.84 1.175.98 1.763 1.12 1.763s.278-.588 1.11-1.766a4.17 4.17 0 0 1 3.679-1.938z"/>`, 400 + comment: html`<path d="M20.656 17.008a9.993 9.993 0 1 0-3.59 3.615L22 22l-1.344-4.992z"/>`, 401 + share: html`<path d="M22 3 9.218 10.083M11.698 20.334 22 3.001H2l7.218 7.083 2.48 10.25z"/>`, 402 + bookmark: html`<path d="M20 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16l8-3 8 3z"/>` 403 + }; 404 + 405 + export class GrainIcon extends LitElement { 406 + static properties = { 407 + name: { type: String }, 408 + size: { type: Number } 409 + }; 410 + 411 + static styles = css` 412 + :host { 413 + display: inline-flex; 414 + align-items: center; 415 + justify-content: center; 416 + } 417 + svg { 418 + display: block; 419 + fill: none; 420 + stroke: currentColor; 421 + stroke-width: 1.5; 422 + stroke-linecap: round; 423 + stroke-linejoin: round; 424 + } 425 + svg.filled { 426 + fill: currentColor; 427 + stroke: none; 428 + } 429 + `; 430 + 431 + constructor() { 432 + super(); 433 + this.size = 24; 434 + } 435 + 436 + render() { 437 + const icon = ICONS[this.name]; 438 + const isFilled = this.name?.includes('Filled'); 439 + 440 + return html` 441 + <svg 442 + class=${isFilled ? 'filled' : ''} 443 + width=${this.size} 444 + height=${this.size} 445 + viewBox="0 0 24 24" 446 + > 447 + ${icon} 448 + </svg> 449 + `; 450 + } 451 + } 452 + 453 + customElements.define('grain-icon', GrainIcon); 454 + ``` 455 + 456 + **Step 2: Commit** 457 + 458 + ```bash 459 + git add src/components/atoms/grain-icon.js 460 + git commit -m "feat: add grain-icon atom component" 461 + ``` 462 + 463 + --- 464 + 465 + ### Task 7: Create grain-image Atom (Lazy Load + Blur Placeholder) 466 + 467 + **Files:** 468 + - Create: `src/components/atoms/grain-image.js` 469 + 470 + **Step 1: Create grain-image.js** 471 + 472 + Create `src/components/atoms/grain-image.js`: 473 + ```javascript 474 + import { LitElement, html, css } from 'lit'; 475 + 476 + export class GrainImage extends LitElement { 477 + static properties = { 478 + src: { type: String }, 479 + alt: { type: String }, 480 + aspectRatio: { type: Number }, 481 + _loaded: { state: true } 482 + }; 483 + 484 + static styles = css` 485 + :host { 486 + display: block; 487 + position: relative; 488 + overflow: hidden; 489 + background: var(--color-bg-elevated); 490 + } 491 + .container { 492 + position: relative; 493 + width: 100%; 494 + } 495 + .spacer { 496 + display: block; 497 + width: 100%; 498 + } 499 + img { 500 + position: absolute; 501 + top: 0; 502 + left: 0; 503 + width: 100%; 504 + height: 100%; 505 + object-fit: cover; 506 + opacity: 0; 507 + transition: opacity 0.3s ease; 508 + } 509 + img.loaded { 510 + opacity: 1; 511 + } 512 + .placeholder { 513 + position: absolute; 514 + top: 0; 515 + left: 0; 516 + width: 100%; 517 + height: 100%; 518 + background: linear-gradient( 519 + 135deg, 520 + var(--color-bg-elevated) 0%, 521 + var(--color-bg-secondary) 100% 522 + ); 523 + } 524 + `; 525 + 526 + constructor() { 527 + super(); 528 + this.aspectRatio = 1; 529 + this._loaded = false; 530 + } 531 + 532 + #handleLoad() { 533 + this._loaded = true; 534 + } 535 + 536 + render() { 537 + const paddingBottom = `${(1 / this.aspectRatio) * 100}%`; 538 + 539 + return html` 540 + <div class="container"> 541 + <svg class="spacer" viewBox="0 0 1 ${1 / this.aspectRatio}"></svg> 542 + <div class="placeholder"></div> 543 + <img 544 + src=${this.src || ''} 545 + alt=${this.alt || ''} 546 + class=${this._loaded ? 'loaded' : ''} 547 + loading="lazy" 548 + @load=${this.#handleLoad} 549 + > 550 + </div> 551 + `; 552 + } 553 + } 554 + 555 + customElements.define('grain-image', GrainImage); 556 + ``` 557 + 558 + **Step 2: Commit** 559 + 560 + ```bash 561 + git add src/components/atoms/grain-image.js 562 + git commit -m "feat: add grain-image atom with lazy loading" 563 + ``` 564 + 565 + --- 566 + 567 + ### Task 8: Create grain-spinner Atom 568 + 569 + **Files:** 570 + - Create: `src/components/atoms/grain-spinner.js` 571 + 572 + **Step 1: Create grain-spinner.js** 573 + 574 + Create `src/components/atoms/grain-spinner.js`: 575 + ```javascript 576 + import { LitElement, html, css } from 'lit'; 577 + 578 + export class GrainSpinner extends LitElement { 579 + static styles = css` 580 + :host { 581 + display: flex; 582 + justify-content: center; 583 + padding: var(--space-lg); 584 + } 585 + .spinner { 586 + width: 24px; 587 + height: 24px; 588 + border: 2px solid var(--color-bg-elevated); 589 + border-top-color: var(--color-text-secondary); 590 + border-radius: 50%; 591 + animation: spin 0.8s linear infinite; 592 + } 593 + @keyframes spin { 594 + to { transform: rotate(360deg); } 595 + } 596 + `; 597 + 598 + render() { 599 + return html`<div class="spinner"></div>`; 600 + } 601 + } 602 + 603 + customElements.define('grain-spinner', GrainSpinner); 604 + ``` 605 + 606 + **Step 2: Commit** 607 + 608 + ```bash 609 + git add src/components/atoms/grain-spinner.js 610 + git commit -m "feat: add grain-spinner atom component" 611 + ``` 612 + 613 + --- 614 + 615 + ## Phase 3: Molecule Components 616 + 617 + ### Task 9: Create grain-author-chip Molecule 618 + 619 + **Files:** 620 + - Create: `src/components/molecules/grain-author-chip.js` 621 + 622 + **Step 1: Create directory structure** 623 + 624 + ```bash 625 + mkdir -p src/components/molecules 626 + ``` 627 + 628 + **Step 2: Create grain-author-chip.js** 629 + 630 + Create `src/components/molecules/grain-author-chip.js`: 631 + ```javascript 632 + import { LitElement, html, css } from 'lit'; 633 + import '../atoms/grain-avatar.js'; 634 + 635 + export class GrainAuthorChip extends LitElement { 636 + static properties = { 637 + avatarUrl: { type: String }, 638 + handle: { type: String }, 639 + displayName: { type: String } 640 + }; 641 + 642 + static styles = css` 643 + :host { 644 + display: flex; 645 + align-items: center; 646 + gap: var(--space-sm); 647 + } 648 + .info { 649 + display: flex; 650 + flex-direction: column; 651 + min-width: 0; 652 + } 653 + .handle { 654 + font-size: var(--font-size-sm); 655 + font-weight: var(--font-weight-semibold); 656 + color: var(--color-text-primary); 657 + white-space: nowrap; 658 + overflow: hidden; 659 + text-overflow: ellipsis; 660 + } 661 + .name { 662 + font-size: var(--font-size-xs); 663 + color: var(--color-text-secondary); 664 + white-space: nowrap; 665 + overflow: hidden; 666 + text-overflow: ellipsis; 667 + } 668 + `; 669 + 670 + render() { 671 + return html` 672 + <grain-avatar 673 + src=${this.avatarUrl || ''} 674 + alt=${this.handle || ''} 675 + size="sm" 676 + ></grain-avatar> 677 + <div class="info"> 678 + <span class="handle">${this.handle}</span> 679 + ${this.displayName ? html` 680 + <span class="name">${this.displayName}</span> 681 + ` : ''} 682 + </div> 683 + `; 684 + } 685 + } 686 + 687 + customElements.define('grain-author-chip', GrainAuthorChip); 688 + ``` 689 + 690 + **Step 3: Commit** 691 + 692 + ```bash 693 + git add src/components/molecules/grain-author-chip.js 694 + git commit -m "feat: add grain-author-chip molecule" 695 + ``` 696 + 697 + --- 698 + 699 + ### Task 10: Create grain-stat-count Molecule 700 + 701 + **Files:** 702 + - Create: `src/components/molecules/grain-stat-count.js` 703 + 704 + **Step 1: Create grain-stat-count.js** 705 + 706 + Create `src/components/molecules/grain-stat-count.js`: 707 + ```javascript 708 + import { LitElement, html, css } from 'lit'; 709 + import '../atoms/grain-icon.js'; 710 + 711 + export class GrainStatCount extends LitElement { 712 + static properties = { 713 + icon: { type: String }, 714 + count: { type: Number } 715 + }; 716 + 717 + static styles = css` 718 + :host { 719 + display: inline-flex; 720 + align-items: center; 721 + gap: var(--space-xs); 722 + color: var(--color-text-primary); 723 + } 724 + button { 725 + display: flex; 726 + align-items: center; 727 + justify-content: center; 728 + background: none; 729 + border: none; 730 + padding: var(--space-sm); 731 + margin: calc(-1 * var(--space-sm)); 732 + cursor: pointer; 733 + color: inherit; 734 + border-radius: var(--border-radius); 735 + transition: opacity 0.2s; 736 + } 737 + button:hover { 738 + opacity: 0.7; 739 + } 740 + button:active { 741 + transform: scale(0.95); 742 + } 743 + .count { 744 + font-size: var(--font-size-sm); 745 + font-weight: var(--font-weight-semibold); 746 + } 747 + `; 748 + 749 + constructor() { 750 + super(); 751 + this.count = 0; 752 + } 753 + 754 + #formatCount(n) { 755 + if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`; 756 + if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; 757 + return n.toString(); 758 + } 759 + 760 + render() { 761 + return html` 762 + <button type="button" aria-label=${this.icon}> 763 + <grain-icon name=${this.icon} size="24"></grain-icon> 764 + </button> 765 + ${this.count > 0 ? html` 766 + <span class="count">${this.#formatCount(this.count)}</span> 767 + ` : ''} 768 + `; 769 + } 770 + } 771 + 772 + customElements.define('grain-stat-count', GrainStatCount); 773 + ``` 774 + 775 + **Step 2: Commit** 776 + 777 + ```bash 778 + git add src/components/molecules/grain-stat-count.js 779 + git commit -m "feat: add grain-stat-count molecule" 780 + ``` 781 + 782 + --- 783 + 784 + ### Task 11: Create grain-carousel-dots Molecule 785 + 786 + **Files:** 787 + - Create: `src/components/molecules/grain-carousel-dots.js` 788 + 789 + **Step 1: Create grain-carousel-dots.js** 790 + 791 + Create `src/components/molecules/grain-carousel-dots.js`: 792 + ```javascript 793 + import { LitElement, html, css } from 'lit'; 794 + 795 + export class GrainCarouselDots extends LitElement { 796 + static properties = { 797 + total: { type: Number }, 798 + current: { type: Number } 799 + }; 800 + 801 + static styles = css` 802 + :host { 803 + display: flex; 804 + justify-content: center; 805 + gap: 4px; 806 + padding: var(--space-sm) 0; 807 + } 808 + .dot { 809 + width: 6px; 810 + height: 6px; 811 + border-radius: 50%; 812 + background: var(--color-text-secondary); 813 + opacity: 0.4; 814 + transition: opacity 0.2s, transform 0.2s; 815 + } 816 + .dot.active { 817 + opacity: 1; 818 + background: var(--color-accent); 819 + } 820 + `; 821 + 822 + constructor() { 823 + super(); 824 + this.total = 0; 825 + this.current = 0; 826 + } 827 + 828 + render() { 829 + if (this.total <= 1) return null; 830 + 831 + const dots = Array.from({ length: Math.min(this.total, 5) }, (_, i) => i); 832 + 833 + return html` 834 + ${dots.map(i => html` 835 + <div class="dot ${i === this.current ? 'active' : ''}"></div> 836 + `)} 837 + `; 838 + } 839 + } 840 + 841 + customElements.define('grain-carousel-dots', GrainCarouselDots); 842 + ``` 843 + 844 + **Step 2: Commit** 845 + 846 + ```bash 847 + git add src/components/molecules/grain-carousel-dots.js 848 + git commit -m "feat: add grain-carousel-dots molecule" 849 + ``` 850 + 851 + --- 852 + 853 + ## Phase 4: Organism Components 854 + 855 + ### Task 12: Create grain-image-carousel Organism 856 + 857 + **Files:** 858 + - Create: `src/components/organisms/grain-image-carousel.js` 859 + 860 + **Step 1: Create directory structure** 861 + 862 + ```bash 863 + mkdir -p src/components/organisms 864 + ``` 865 + 866 + **Step 2: Create grain-image-carousel.js** 867 + 868 + Create `src/components/organisms/grain-image-carousel.js`: 869 + ```javascript 870 + import { LitElement, html, css } from 'lit'; 871 + import '../atoms/grain-image.js'; 872 + import '../molecules/grain-carousel-dots.js'; 873 + 874 + export class GrainImageCarousel extends LitElement { 875 + static properties = { 876 + photos: { type: Array }, 877 + _currentIndex: { state: true } 878 + }; 879 + 880 + static styles = css` 881 + :host { 882 + display: block; 883 + position: relative; 884 + } 885 + .carousel { 886 + display: flex; 887 + overflow-x: auto; 888 + scroll-snap-type: x mandatory; 889 + scrollbar-width: none; 890 + -ms-overflow-style: none; 891 + } 892 + .carousel::-webkit-scrollbar { 893 + display: none; 894 + } 895 + .slide { 896 + flex: 0 0 100%; 897 + scroll-snap-align: start; 898 + } 899 + .dots { 900 + position: absolute; 901 + bottom: 0; 902 + left: 0; 903 + right: 0; 904 + } 905 + `; 906 + 907 + constructor() { 908 + super(); 909 + this.photos = []; 910 + this._currentIndex = 0; 911 + } 912 + 913 + #handleScroll(e) { 914 + const carousel = e.target; 915 + const index = Math.round(carousel.scrollLeft / carousel.offsetWidth); 916 + if (index !== this._currentIndex) { 917 + this._currentIndex = index; 918 + } 919 + } 920 + 921 + render() { 922 + return html` 923 + <div class="carousel" @scroll=${this.#handleScroll}> 924 + ${this.photos.map(photo => html` 925 + <div class="slide"> 926 + <grain-image 927 + src=${photo.url} 928 + alt=${photo.alt || ''} 929 + aspectRatio=${photo.aspectRatio || 1} 930 + ></grain-image> 931 + </div> 932 + `)} 933 + </div> 934 + ${this.photos.length > 1 ? html` 935 + <div class="dots"> 936 + <grain-carousel-dots 937 + total=${this.photos.length} 938 + current=${this._currentIndex} 939 + ></grain-carousel-dots> 940 + </div> 941 + ` : ''} 942 + `; 943 + } 944 + } 945 + 946 + customElements.define('grain-image-carousel', GrainImageCarousel); 947 + ``` 948 + 949 + **Step 3: Commit** 950 + 951 + ```bash 952 + git add src/components/organisms/grain-image-carousel.js 953 + git commit -m "feat: add grain-image-carousel organism" 954 + ``` 955 + 956 + --- 957 + 958 + ### Task 13: Create grain-engagement-bar Organism 959 + 960 + **Files:** 961 + - Create: `src/components/organisms/grain-engagement-bar.js` 962 + 963 + **Step 1: Create grain-engagement-bar.js** 964 + 965 + Create `src/components/organisms/grain-engagement-bar.js`: 966 + ```javascript 967 + import { LitElement, html, css } from 'lit'; 968 + import '../molecules/grain-stat-count.js'; 969 + 970 + export class GrainEngagementBar extends LitElement { 971 + static properties = { 972 + favoriteCount: { type: Number }, 973 + commentCount: { type: Number } 974 + }; 975 + 976 + static styles = css` 977 + :host { 978 + display: flex; 979 + align-items: center; 980 + gap: var(--space-md); 981 + padding: var(--space-xs) var(--space-md); 982 + } 983 + .spacer { 984 + flex: 1; 985 + } 986 + `; 987 + 988 + constructor() { 989 + super(); 990 + this.favoriteCount = 0; 991 + this.commentCount = 0; 992 + } 993 + 994 + render() { 995 + return html` 996 + <grain-stat-count 997 + icon="heart" 998 + count=${this.favoriteCount} 999 + ></grain-stat-count> 1000 + <grain-stat-count 1001 + icon="comment" 1002 + count=${this.commentCount} 1003 + ></grain-stat-count> 1004 + <div class="spacer"></div> 1005 + <grain-stat-count icon="bookmark"></grain-stat-count> 1006 + `; 1007 + } 1008 + } 1009 + 1010 + customElements.define('grain-engagement-bar', GrainEngagementBar); 1011 + ``` 1012 + 1013 + **Step 2: Commit** 1014 + 1015 + ```bash 1016 + git add src/components/organisms/grain-engagement-bar.js 1017 + git commit -m "feat: add grain-engagement-bar organism" 1018 + ``` 1019 + 1020 + --- 1021 + 1022 + ### Task 14: Create grain-gallery-card Organism 1023 + 1024 + **Files:** 1025 + - Create: `src/components/organisms/grain-gallery-card.js` 1026 + 1027 + **Step 1: Create grain-gallery-card.js** 1028 + 1029 + Create `src/components/organisms/grain-gallery-card.js`: 1030 + ```javascript 1031 + import { LitElement, html, css } from 'lit'; 1032 + import '../molecules/grain-author-chip.js'; 1033 + import './grain-image-carousel.js'; 1034 + import './grain-engagement-bar.js'; 1035 + 1036 + export class GrainGalleryCard extends LitElement { 1037 + static properties = { 1038 + gallery: { type: Object } 1039 + }; 1040 + 1041 + static styles = css` 1042 + :host { 1043 + display: block; 1044 + background: var(--color-bg-primary); 1045 + border-bottom: 1px solid var(--color-border); 1046 + } 1047 + .header { 1048 + padding: var(--space-sm) var(--space-md); 1049 + } 1050 + .content { 1051 + padding: var(--space-sm) var(--space-md) var(--space-md); 1052 + } 1053 + .title { 1054 + font-size: var(--font-size-sm); 1055 + font-weight: var(--font-weight-semibold); 1056 + color: var(--color-text-primary); 1057 + margin-bottom: var(--space-xs); 1058 + } 1059 + .description { 1060 + font-size: var(--font-size-sm); 1061 + color: var(--color-text-secondary); 1062 + display: -webkit-box; 1063 + -webkit-line-clamp: 2; 1064 + -webkit-box-orient: vertical; 1065 + overflow: hidden; 1066 + } 1067 + .timestamp { 1068 + font-size: var(--font-size-xs); 1069 + color: var(--color-text-secondary); 1070 + margin-top: var(--space-sm); 1071 + text-transform: uppercase; 1072 + } 1073 + `; 1074 + 1075 + #formatDate(iso) { 1076 + const date = new Date(iso); 1077 + const now = new Date(); 1078 + const diffMs = now - date; 1079 + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); 1080 + 1081 + if (diffDays === 0) return 'Today'; 1082 + if (diffDays === 1) return 'Yesterday'; 1083 + if (diffDays < 7) return `${diffDays} days ago`; 1084 + 1085 + return date.toLocaleDateString('en-US', { 1086 + month: 'short', 1087 + day: 'numeric' 1088 + }); 1089 + } 1090 + 1091 + render() { 1092 + if (!this.gallery) return null; 1093 + 1094 + const { gallery } = this; 1095 + 1096 + return html` 1097 + <header class="header"> 1098 + <grain-author-chip 1099 + avatarUrl=${gallery.avatarUrl || ''} 1100 + handle=${gallery.handle} 1101 + displayName=${gallery.displayName || ''} 1102 + ></grain-author-chip> 1103 + </header> 1104 + 1105 + <grain-image-carousel 1106 + .photos=${gallery.photos || []} 1107 + ></grain-image-carousel> 1108 + 1109 + <grain-engagement-bar 1110 + favoriteCount=${gallery.favoriteCount || 0} 1111 + commentCount=${gallery.commentCount || 0} 1112 + ></grain-engagement-bar> 1113 + 1114 + <div class="content"> 1115 + <p class="title">${gallery.title}</p> 1116 + ${gallery.description ? html` 1117 + <p class="description">${gallery.description}</p> 1118 + ` : ''} 1119 + <time class="timestamp">${this.#formatDate(gallery.createdAt)}</time> 1120 + </div> 1121 + `; 1122 + } 1123 + } 1124 + 1125 + customElements.define('grain-gallery-card', GrainGalleryCard); 1126 + ``` 1127 + 1128 + **Step 2: Commit** 1129 + 1130 + ```bash 1131 + git add src/components/organisms/grain-gallery-card.js 1132 + git commit -m "feat: add grain-gallery-card organism" 1133 + ``` 1134 + 1135 + --- 1136 + 1137 + ## Phase 5: Data Layer 1138 + 1139 + ### Task 15: Create GraphQL API Service 1140 + 1141 + **Files:** 1142 + - Create: `src/services/grain-api.js` 1143 + 1144 + **Step 1: Create directory structure** 1145 + 1146 + ```bash 1147 + mkdir -p src/services 1148 + ``` 1149 + 1150 + **Step 2: Create grain-api.js** 1151 + 1152 + Create `src/services/grain-api.js`: 1153 + ```javascript 1154 + class GrainApiService { 1155 + #endpoint = '/api/graphql'; // Configure based on environment 1156 + 1157 + async getTimeline({ first = 10, after = null } = {}) { 1158 + const query = ` 1159 + query Timeline($first: Int, $after: String) { 1160 + socialGrainGallery( 1161 + first: $first 1162 + after: $after 1163 + sortBy: [{ field: createdAt, direction: DESC }] 1164 + ) { 1165 + edges { 1166 + node { 1167 + uri 1168 + title 1169 + description 1170 + createdAt 1171 + actorHandle 1172 + appBskyActorProfileByDid { 1173 + avatar { url } 1174 + displayName 1175 + } 1176 + socialGrainGalleryItemViaGallery(first: 10) { 1177 + edges { 1178 + node { 1179 + item 1180 + } 1181 + } 1182 + totalCount 1183 + } 1184 + socialGrainFavoriteViaSubject { 1185 + totalCount 1186 + } 1187 + socialGrainCommentViaSubject { 1188 + totalCount 1189 + } 1190 + } 1191 + } 1192 + pageInfo { 1193 + hasNextPage 1194 + endCursor 1195 + } 1196 + } 1197 + } 1198 + `; 1199 + 1200 + const response = await this.#execute(query, { first, after }); 1201 + return this.#transformTimelineResponse(response); 1202 + } 1203 + 1204 + async getPhotosByUris(uris) { 1205 + if (!uris.length) return []; 1206 + 1207 + const query = ` 1208 + query Photos($uris: [String!]) { 1209 + socialGrainPhoto(filter: { uri: { in: $uris } }) { 1210 + edges { 1211 + node { 1212 + uri 1213 + alt 1214 + aspectRatio { width height } 1215 + photo { url } 1216 + } 1217 + } 1218 + } 1219 + } 1220 + `; 1221 + 1222 + const response = await this.#execute(query, { uris }); 1223 + return response.data?.socialGrainPhoto?.edges?.map(e => e.node) || []; 1224 + } 1225 + 1226 + #transformTimelineResponse(response) { 1227 + const connection = response.data?.socialGrainGallery; 1228 + if (!connection) return { galleries: [], pageInfo: { hasNextPage: false } }; 1229 + 1230 + const galleries = connection.edges.map(edge => { 1231 + const node = edge.node; 1232 + const profile = node.appBskyActorProfileByDid; 1233 + const items = node.socialGrainGalleryItemViaGallery?.edges || []; 1234 + 1235 + return { 1236 + uri: node.uri, 1237 + title: node.title, 1238 + description: node.description, 1239 + createdAt: node.createdAt, 1240 + handle: node.actorHandle, 1241 + displayName: profile?.displayName || '', 1242 + avatarUrl: profile?.avatar?.url || '', 1243 + photoUris: items.map(i => i.node.item), 1244 + photos: [], // Will be populated by getPhotosByUris 1245 + favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0, 1246 + commentCount: node.socialGrainCommentViaSubject?.totalCount || 0 1247 + }; 1248 + }); 1249 + 1250 + return { 1251 + galleries, 1252 + pageInfo: connection.pageInfo 1253 + }; 1254 + } 1255 + 1256 + async #execute(query, variables = {}) { 1257 + const response = await fetch(this.#endpoint, { 1258 + method: 'POST', 1259 + headers: { 1260 + 'Content-Type': 'application/json' 1261 + }, 1262 + body: JSON.stringify({ query, variables }) 1263 + }); 1264 + 1265 + if (!response.ok) { 1266 + throw new Error(`GraphQL request failed: ${response.status}`); 1267 + } 1268 + 1269 + return response.json(); 1270 + } 1271 + 1272 + setEndpoint(endpoint) { 1273 + this.#endpoint = endpoint; 1274 + } 1275 + } 1276 + 1277 + export const grainApi = new GrainApiService(); 1278 + ``` 1279 + 1280 + **Step 3: Commit** 1281 + 1282 + ```bash 1283 + git add src/services/grain-api.js 1284 + git commit -m "feat: add GraphQL API service" 1285 + ``` 1286 + 1287 + --- 1288 + 1289 + ## Phase 6: Templates & Pages 1290 + 1291 + ### Task 16: Create grain-feed-layout Template 1292 + 1293 + **Files:** 1294 + - Create: `src/components/templates/grain-feed-layout.js` 1295 + 1296 + **Step 1: Create directory structure** 1297 + 1298 + ```bash 1299 + mkdir -p src/components/templates 1300 + ``` 1301 + 1302 + **Step 2: Create grain-feed-layout.js** 1303 + 1304 + Create `src/components/templates/grain-feed-layout.js`: 1305 + ```javascript 1306 + import { LitElement, html, css } from 'lit'; 1307 + 1308 + export class GrainFeedLayout extends LitElement { 1309 + static styles = css` 1310 + :host { 1311 + display: block; 1312 + max-width: var(--feed-max-width); 1313 + margin: 0 auto; 1314 + min-height: 100vh; 1315 + min-height: 100dvh; 1316 + background: var(--color-bg-primary); 1317 + } 1318 + ::slotted(*) { 1319 + display: block; 1320 + } 1321 + `; 1322 + 1323 + render() { 1324 + return html`<slot></slot>`; 1325 + } 1326 + } 1327 + 1328 + customElements.define('grain-feed-layout', GrainFeedLayout); 1329 + ``` 1330 + 1331 + **Step 3: Commit** 1332 + 1333 + ```bash 1334 + git add src/components/templates/grain-feed-layout.js 1335 + git commit -m "feat: add grain-feed-layout template" 1336 + ``` 1337 + 1338 + --- 1339 + 1340 + ### Task 17: Create grain-timeline Page 1341 + 1342 + **Files:** 1343 + - Create: `src/components/pages/grain-timeline.js` 1344 + 1345 + **Step 1: Create directory structure** 1346 + 1347 + ```bash 1348 + mkdir -p src/components/pages 1349 + ``` 1350 + 1351 + **Step 2: Create grain-timeline.js** 1352 + 1353 + Create `src/components/pages/grain-timeline.js`: 1354 + ```javascript 1355 + import { LitElement, html, css } from 'lit'; 1356 + import { grainApi } from '../../services/grain-api.js'; 1357 + import '../templates/grain-feed-layout.js'; 1358 + import '../organisms/grain-gallery-card.js'; 1359 + import '../atoms/grain-spinner.js'; 1360 + 1361 + export class GrainTimeline extends LitElement { 1362 + static properties = { 1363 + _galleries: { state: true }, 1364 + _loading: { state: true }, 1365 + _hasMore: { state: true }, 1366 + _cursor: { state: true }, 1367 + _error: { state: true } 1368 + }; 1369 + 1370 + static styles = css` 1371 + :host { 1372 + display: block; 1373 + } 1374 + .error { 1375 + padding: var(--space-lg); 1376 + text-align: center; 1377 + color: var(--color-error); 1378 + } 1379 + .empty { 1380 + padding: var(--space-xl); 1381 + text-align: center; 1382 + color: var(--color-text-secondary); 1383 + } 1384 + #sentinel { 1385 + height: 1px; 1386 + } 1387 + `; 1388 + 1389 + #observer = null; 1390 + 1391 + constructor() { 1392 + super(); 1393 + this._galleries = []; 1394 + this._loading = true; 1395 + this._hasMore = true; 1396 + this._cursor = null; 1397 + this._error = null; 1398 + } 1399 + 1400 + connectedCallback() { 1401 + super.connectedCallback(); 1402 + this.#loadInitial(); 1403 + } 1404 + 1405 + disconnectedCallback() { 1406 + super.disconnectedCallback(); 1407 + this.#observer?.disconnect(); 1408 + } 1409 + 1410 + firstUpdated() { 1411 + this.#setupInfiniteScroll(); 1412 + } 1413 + 1414 + async #loadInitial() { 1415 + try { 1416 + this._loading = true; 1417 + this._error = null; 1418 + const result = await grainApi.getTimeline({ first: 10 }); 1419 + 1420 + // Fetch photos for all galleries 1421 + await this.#loadPhotosForGalleries(result.galleries); 1422 + 1423 + this._galleries = result.galleries; 1424 + this._hasMore = result.pageInfo.hasNextPage; 1425 + this._cursor = result.pageInfo.endCursor; 1426 + } catch (err) { 1427 + this._error = err.message; 1428 + } finally { 1429 + this._loading = false; 1430 + } 1431 + } 1432 + 1433 + async #loadMore() { 1434 + if (this._loading || !this._hasMore) return; 1435 + 1436 + try { 1437 + this._loading = true; 1438 + const result = await grainApi.getTimeline({ 1439 + first: 10, 1440 + after: this._cursor 1441 + }); 1442 + 1443 + await this.#loadPhotosForGalleries(result.galleries); 1444 + 1445 + this._galleries = [...this._galleries, ...result.galleries]; 1446 + this._hasMore = result.pageInfo.hasNextPage; 1447 + this._cursor = result.pageInfo.endCursor; 1448 + } catch (err) { 1449 + this._error = err.message; 1450 + } finally { 1451 + this._loading = false; 1452 + } 1453 + } 1454 + 1455 + async #loadPhotosForGalleries(galleries) { 1456 + const allUris = galleries.flatMap(g => g.photoUris); 1457 + if (!allUris.length) return; 1458 + 1459 + const photos = await grainApi.getPhotosByUris(allUris); 1460 + const photoMap = new Map(photos.map(p => [p.uri, p])); 1461 + 1462 + galleries.forEach(gallery => { 1463 + gallery.photos = gallery.photoUris 1464 + .map(uri => { 1465 + const photo = photoMap.get(uri); 1466 + if (!photo) return null; 1467 + return { 1468 + url: photo.photo?.url || '', 1469 + alt: photo.alt || '', 1470 + aspectRatio: photo.aspectRatio 1471 + ? photo.aspectRatio.width / photo.aspectRatio.height 1472 + : 1 1473 + }; 1474 + }) 1475 + .filter(Boolean); 1476 + }); 1477 + } 1478 + 1479 + #setupInfiniteScroll() { 1480 + const sentinel = this.shadowRoot.getElementById('sentinel'); 1481 + if (!sentinel) return; 1482 + 1483 + this.#observer = new IntersectionObserver( 1484 + (entries) => { 1485 + if (entries[0].isIntersecting) { 1486 + this.#loadMore(); 1487 + } 1488 + }, 1489 + { rootMargin: '200px' } 1490 + ); 1491 + 1492 + this.#observer.observe(sentinel); 1493 + } 1494 + 1495 + render() { 1496 + return html` 1497 + <grain-feed-layout> 1498 + ${this._error ? html` 1499 + <p class="error">${this._error}</p> 1500 + ` : ''} 1501 + 1502 + ${this._galleries.map(gallery => html` 1503 + <grain-gallery-card .gallery=${gallery}></grain-gallery-card> 1504 + `)} 1505 + 1506 + ${!this._loading && !this._galleries.length && !this._error ? html` 1507 + <p class="empty">No galleries yet</p> 1508 + ` : ''} 1509 + 1510 + <div id="sentinel"></div> 1511 + 1512 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 1513 + </grain-feed-layout> 1514 + `; 1515 + } 1516 + } 1517 + 1518 + customElements.define('grain-timeline', GrainTimeline); 1519 + ``` 1520 + 1521 + **Step 3: Commit** 1522 + 1523 + ```bash 1524 + git add src/components/pages/grain-timeline.js 1525 + git commit -m "feat: add grain-timeline page with infinite scroll" 1526 + ``` 1527 + 1528 + --- 1529 + 1530 + ### Task 18: Create Router 1531 + 1532 + **Files:** 1533 + - Create: `src/router.js` 1534 + 1535 + **Step 1: Create router.js** 1536 + 1537 + Create `src/router.js`: 1538 + ```javascript 1539 + class Router { 1540 + #routes = new Map(); 1541 + #outlet = null; 1542 + #currentComponent = null; 1543 + 1544 + register(path, componentTag) { 1545 + this.#routes.set(path, componentTag); 1546 + return this; 1547 + } 1548 + 1549 + connect(outlet) { 1550 + this.#outlet = outlet; 1551 + window.addEventListener('popstate', () => this.#navigate()); 1552 + this.#navigate(); 1553 + return this; 1554 + } 1555 + 1556 + push(path) { 1557 + if (location.pathname !== path) { 1558 + history.pushState(null, '', path); 1559 + this.#navigate(); 1560 + } 1561 + } 1562 + 1563 + replace(path) { 1564 + if (location.pathname !== path) { 1565 + history.replaceState(null, '', path); 1566 + this.#navigate(); 1567 + } 1568 + } 1569 + 1570 + #navigate() { 1571 + const path = location.pathname; 1572 + let componentTag = this.#routes.get(path); 1573 + 1574 + // Try to match dynamic routes 1575 + if (!componentTag) { 1576 + for (const [routePath, tag] of this.#routes) { 1577 + if (routePath.includes(':')) { 1578 + const regex = new RegExp( 1579 + '^' + routePath.replace(/:[\w]+/g, '([^/]+)') + '$' 1580 + ); 1581 + if (regex.test(path)) { 1582 + componentTag = tag; 1583 + break; 1584 + } 1585 + } 1586 + } 1587 + } 1588 + 1589 + // Fallback to wildcard route 1590 + if (!componentTag) { 1591 + componentTag = this.#routes.get('*'); 1592 + } 1593 + 1594 + if (this.#outlet && componentTag) { 1595 + // Only re-render if component changed 1596 + if (this.#currentComponent !== componentTag) { 1597 + this.#outlet.innerHTML = ''; 1598 + this.#outlet.appendChild(document.createElement(componentTag)); 1599 + this.#currentComponent = componentTag; 1600 + } 1601 + } 1602 + } 1603 + } 1604 + 1605 + export const router = new Router(); 1606 + ``` 1607 + 1608 + **Step 2: Commit** 1609 + 1610 + ```bash 1611 + git add src/router.js 1612 + git commit -m "feat: add History API router" 1613 + ``` 1614 + 1615 + --- 1616 + 1617 + ### Task 19: Create grain-app Shell 1618 + 1619 + **Files:** 1620 + - Create: `src/components/pages/grain-app.js` 1621 + - Modify: `src/main.js` 1622 + 1623 + **Step 1: Create grain-app.js** 1624 + 1625 + Create `src/components/pages/grain-app.js`: 1626 + ```javascript 1627 + import { LitElement, html, css } from 'lit'; 1628 + import { router } from '../../router.js'; 1629 + 1630 + // Import pages 1631 + import './grain-timeline.js'; 1632 + 1633 + export class GrainApp extends LitElement { 1634 + static styles = css` 1635 + :host { 1636 + display: block; 1637 + font-family: var(--font-family); 1638 + background: var(--color-bg-primary); 1639 + color: var(--color-text-primary); 1640 + min-height: 100vh; 1641 + min-height: 100dvh; 1642 + } 1643 + #outlet { 1644 + display: contents; 1645 + } 1646 + `; 1647 + 1648 + firstUpdated() { 1649 + const outlet = this.shadowRoot.getElementById('outlet'); 1650 + 1651 + router 1652 + .register('/', 'grain-timeline') 1653 + .register('*', 'grain-timeline') 1654 + .connect(outlet); 1655 + } 1656 + 1657 + render() { 1658 + return html`<div id="outlet"></div>`; 1659 + } 1660 + } 1661 + 1662 + customElements.define('grain-app', GrainApp); 1663 + ``` 1664 + 1665 + **Step 2: Commit** 1666 + 1667 + ```bash 1668 + git add src/components/pages/grain-app.js 1669 + git commit -m "feat: add grain-app shell with router" 1670 + ``` 1671 + 1672 + --- 1673 + 1674 + ## Phase 7: Configuration & Testing 1675 + 1676 + ### Task 20: Configure API Endpoint 1677 + 1678 + **Files:** 1679 + - Modify: `src/services/grain-api.js` 1680 + - Create: `src/config.js` 1681 + 1682 + **Step 1: Create config.js** 1683 + 1684 + Create `src/config.js`: 1685 + ```javascript 1686 + export const config = { 1687 + // Set your quickslice GraphQL endpoint here 1688 + apiEndpoint: import.meta.env.VITE_API_ENDPOINT || 'http://localhost:4000/graphql' 1689 + }; 1690 + ``` 1691 + 1692 + **Step 2: Update grain-api.js to use config** 1693 + 1694 + Update the constructor in `src/services/grain-api.js`: 1695 + ```javascript 1696 + import { config } from '../config.js'; 1697 + 1698 + class GrainApiService { 1699 + #endpoint = config.apiEndpoint; 1700 + // ... rest of the class 1701 + } 1702 + ``` 1703 + 1704 + **Step 3: Create .env.example** 1705 + 1706 + Create `.env.example`: 1707 + ``` 1708 + VITE_API_ENDPOINT=http://localhost:4000/graphql 1709 + ``` 1710 + 1711 + **Step 4: Update .gitignore** 1712 + 1713 + Create `.gitignore`: 1714 + ``` 1715 + node_modules/ 1716 + dist/ 1717 + .env 1718 + .env.local 1719 + ``` 1720 + 1721 + **Step 5: Commit** 1722 + 1723 + ```bash 1724 + git add src/config.js src/services/grain-api.js .env.example .gitignore 1725 + git commit -m "feat: add API endpoint configuration" 1726 + ``` 1727 + 1728 + --- 1729 + 1730 + ### Task 21: Add Vite SPA Fallback 1731 + 1732 + **Files:** 1733 + - Modify: `vite.config.js` 1734 + 1735 + **Step 1: Update vite.config.js for History API fallback** 1736 + 1737 + Update `vite.config.js`: 1738 + ```javascript 1739 + import { defineConfig } from 'vite'; 1740 + 1741 + export default defineConfig({ 1742 + root: '.', 1743 + publicDir: 'public', 1744 + build: { 1745 + outDir: 'dist', 1746 + target: 'esnext' 1747 + }, 1748 + server: { 1749 + port: 3000, 1750 + // Handle SPA routing - fallback to index.html 1751 + historyApiFallback: true 1752 + }, 1753 + preview: { 1754 + port: 3000 1755 + } 1756 + }); 1757 + ``` 1758 + 1759 + **Step 2: Commit** 1760 + 1761 + ```bash 1762 + git add vite.config.js 1763 + git commit -m "feat: configure Vite for SPA routing" 1764 + ``` 1765 + 1766 + --- 1767 + 1768 + ### Task 22: Final Verification 1769 + 1770 + **Step 1: Install dependencies and run dev server** 1771 + 1772 + ```bash 1773 + npm install 1774 + npm run dev 1775 + ``` 1776 + 1777 + **Step 2: Verify in browser** 1778 + 1779 + - Open http://localhost:3000 1780 + - Check browser console for errors 1781 + - Verify service worker registers 1782 + - Test PWA install prompt (if available) 1783 + 1784 + **Step 3: Build and preview** 1785 + 1786 + ```bash 1787 + npm run build 1788 + npm run preview 1789 + ``` 1790 + 1791 + **Step 4: Final commit** 1792 + 1793 + ```bash 1794 + git add -A 1795 + git commit -m "chore: complete initial grain timeline SPA" 1796 + ``` 1797 + 1798 + --- 1799 + 1800 + ## Summary 1801 + 1802 + **Components created:** 1803 + - **Atoms (5):** grain-avatar, grain-icon, grain-image, grain-spinner, grain-badge 1804 + - **Molecules (3):** grain-author-chip, grain-stat-count, grain-carousel-dots 1805 + - **Organisms (3):** grain-gallery-card, grain-image-carousel, grain-engagement-bar 1806 + - **Templates (1):** grain-feed-layout 1807 + - **Pages (2):** grain-app, grain-timeline 1808 + 1809 + **Infrastructure:** 1810 + - Vite build setup 1811 + - CSS design tokens 1812 + - GraphQL API service 1813 + - History API router 1814 + - PWA manifest + service worker 1815 + 1816 + **Ready for next phase:** 1817 + - Authentication (OAuth) 1818 + - Gallery detail view 1819 + - User profiles 1820 + - Full offline support