Compare changes

Choose any two refs to compare.

+211
docs/plans/2026-01-02-oauth-callback-route.md
··· 1 + # OAuth Callback Route Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a dedicated `/oauth/callback` route so users can log in from any page and return to where they were. 6 + 7 + **Architecture:** Store the current path in sessionStorage before OAuth redirect, then read it back after the callback completes and navigate there. 8 + 9 + **Tech Stack:** Lit web components, quickslice-client-js OAuth 10 + 11 + --- 12 + 13 + ### Task 1: Create OAuth Callback Page Component 14 + 15 + **Files:** 16 + - Create: `src/components/pages/grain-oauth-callback.js` 17 + 18 + **Step 1: Create the callback component** 19 + 20 + ```javascript 21 + import { LitElement, html, css } from 'lit'; 22 + import '../atoms/grain-spinner.js'; 23 + 24 + export class GrainOAuthCallback extends LitElement { 25 + static styles = css` 26 + :host { 27 + display: flex; 28 + flex-direction: column; 29 + align-items: center; 30 + justify-content: center; 31 + min-height: 100%; 32 + gap: var(--space-md); 33 + } 34 + p { 35 + color: var(--color-text-secondary); 36 + font-size: var(--font-size-sm); 37 + } 38 + `; 39 + 40 + render() { 41 + return html` 42 + <grain-spinner size="32"></grain-spinner> 43 + <p>Signing in...</p> 44 + `; 45 + } 46 + } 47 + 48 + customElements.define('grain-oauth-callback', GrainOAuthCallback); 49 + ``` 50 + 51 + **Step 2: Verify file exists** 52 + 53 + Run: `cat src/components/pages/grain-oauth-callback.js` 54 + Expected: File contents match above 55 + 56 + **Step 3: Commit** 57 + 58 + ```bash 59 + git add src/components/pages/grain-oauth-callback.js 60 + git commit -m "feat: add OAuth callback page component" 61 + ``` 62 + 63 + --- 64 + 65 + ### Task 2: Register the OAuth Callback Route 66 + 67 + **Files:** 68 + - Modify: `src/components/pages/grain-app.js` 69 + 70 + **Step 1: Add import for callback component** 71 + 72 + Add after line 17 (after grain-copyright import): 73 + ```javascript 74 + import './grain-oauth-callback.js'; 75 + ``` 76 + 77 + **Step 2: Register the route** 78 + 79 + Add after line 67 (before the `*` wildcard route): 80 + ```javascript 81 + .register('/oauth/callback', 'grain-oauth-callback') 82 + ``` 83 + 84 + **Step 3: Verify build passes** 85 + 86 + Run: `npm run build` 87 + Expected: Build succeeds 88 + 89 + **Step 4: Commit** 90 + 91 + ```bash 92 + git add src/components/pages/grain-app.js 93 + git commit -m "feat: register /oauth/callback route" 94 + ``` 95 + 96 + --- 97 + 98 + ### Task 3: Store Return URL Before OAuth Redirect 99 + 100 + **Files:** 101 + - Modify: `src/services/auth.js` 102 + 103 + **Step 1: Update login method to store return URL** 104 + 105 + Replace the login method (lines 58-60): 106 + 107 + ```javascript 108 + async login(handle) { 109 + sessionStorage.setItem('oauth_return_url', window.location.pathname); 110 + await this.#client.loginWithRedirect({ handle }); 111 + } 112 + ``` 113 + 114 + **Step 2: Verify build passes** 115 + 116 + Run: `npm run build` 117 + Expected: Build succeeds 118 + 119 + **Step 3: Commit** 120 + 121 + ```bash 122 + git add src/services/auth.js 123 + git commit -m "feat: store return URL before OAuth redirect" 124 + ``` 125 + 126 + --- 127 + 128 + ### Task 4: Navigate to Return URL After OAuth Callback 129 + 130 + **Files:** 131 + - Modify: `src/services/auth.js` 132 + 133 + **Step 1: Add router import at top of file** 134 + 135 + Add after line 1: 136 + ```javascript 137 + import { router } from '../router.js'; 138 + ``` 139 + 140 + **Step 2: Update callback handling to navigate to return URL** 141 + 142 + Replace lines 18-22 (the callback handling block): 143 + 144 + ```javascript 145 + // Handle OAuth callback if present 146 + if (window.location.search.includes('code=')) { 147 + await this.#client.handleRedirectCallback(); 148 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 149 + sessionStorage.removeItem('oauth_return_url'); 150 + router.replace(returnUrl); 151 + } 152 + ``` 153 + 154 + **Step 3: Verify build passes** 155 + 156 + Run: `npm run build` 157 + Expected: Build succeeds 158 + 159 + **Step 4: Commit** 160 + 161 + ```bash 162 + git add src/services/auth.js 163 + git commit -m "feat: navigate to return URL after OAuth callback" 164 + ``` 165 + 166 + --- 167 + 168 + ### Task 5: Manual Testing Checklist 169 + 170 + **Step 1: Start dev server** 171 + 172 + Run: `npm run dev` 173 + 174 + **Step 2: Test login from timeline** 175 + 176 + 1. Navigate to `http://localhost:5173/` 177 + 2. Click login, enter handle 178 + 3. Complete OAuth flow 179 + 4. Verify you return to `/` 180 + 181 + **Step 3: Test login from profile page** 182 + 183 + 1. Navigate to `http://localhost:5173/profile/grain.social` 184 + 2. Click login, enter handle 185 + 3. Complete OAuth flow 186 + 4. Verify you return to `/profile/grain.social` 187 + 188 + **Step 4: Test login from explore page** 189 + 190 + 1. Navigate to `http://localhost:5173/explore` 191 + 2. Click login, enter handle 192 + 3. Complete OAuth flow 193 + 4. Verify you return to `/explore` 194 + 195 + **Step 5: Test direct visit to callback** 196 + 197 + 1. Navigate directly to `http://localhost:5173/oauth/callback` 198 + 2. Verify it shows "Signing in..." briefly then redirects to `/` 199 + 200 + --- 201 + 202 + ### Task 6: Final Build Verification 203 + 204 + **Step 1: Run production build** 205 + 206 + Run: `npm run build` 207 + Expected: Build succeeds with no errors 208 + 209 + **Step 2: Commit any remaining changes** 210 + 211 + If all tests pass and no changes needed, this task is complete.
+661
docs/plans/2026-01-02-onboarding-flow.md
··· 1 + # Onboarding Flow Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Redirect first-time users to an onboarding page after OAuth, prefilling their Grain profile form with their Bluesky profile data. 6 + 7 + **Architecture:** After OAuth callback, check if `socialGrainActorProfile` exists. If not, redirect to `/onboarding`. The onboarding component fetches `appBskyActorProfile` to prefill displayName, description, and avatar. Users can save or skip; both create a profile record to mark them as onboarded. 8 + 9 + **Tech Stack:** Lit web components, GraphQL via Quickslice client, existing avatar crop component 10 + 11 + --- 12 + 13 + ## Task 1: Add Profile Queries to grain-api.js 14 + 15 + **Files:** 16 + - Modify: `src/services/grain-api.js` 17 + 18 + **Step 1: Add hasGrainProfile method** 19 + 20 + Add this method to the GrainApiService class (after `resolveHandle` method, around line 1123): 21 + 22 + ```javascript 23 + async hasGrainProfile(client) { 24 + const result = await client.query(` 25 + query { 26 + viewer { 27 + socialGrainActorProfileByDid { 28 + displayName 29 + } 30 + } 31 + } 32 + `); 33 + return !!result.viewer?.socialGrainActorProfileByDid; 34 + } 35 + ``` 36 + 37 + **Step 2: Add getBlueskyProfile method** 38 + 39 + Add this method after `hasGrainProfile`: 40 + 41 + ```javascript 42 + async getBlueskyProfile(client) { 43 + const result = await client.query(` 44 + query { 45 + viewer { 46 + did 47 + handle 48 + appBskyActorProfileByDid { 49 + displayName 50 + description 51 + avatar { url ref mimeType size } 52 + } 53 + } 54 + } 55 + `); 56 + 57 + const viewer = result.viewer; 58 + const profile = viewer?.appBskyActorProfileByDid; 59 + const avatar = profile?.avatar; 60 + 61 + return { 62 + did: viewer?.did || '', 63 + handle: viewer?.handle || '', 64 + displayName: profile?.displayName || '', 65 + description: profile?.description || '', 66 + avatarUrl: avatar?.url || '', 67 + avatarBlob: avatar ? { 68 + $type: 'blob', 69 + ref: { $link: avatar.ref }, 70 + mimeType: avatar.mimeType, 71 + size: avatar.size 72 + } : null 73 + }; 74 + } 75 + ``` 76 + 77 + **Step 3: Commit** 78 + 79 + ```bash 80 + git add src/services/grain-api.js 81 + git commit -m "feat: add hasGrainProfile and getBlueskyProfile queries" 82 + ``` 83 + 84 + --- 85 + 86 + ## Task 2: Add Profile Mutations to mutations.js 87 + 88 + **Files:** 89 + - Modify: `src/services/mutations.js` 90 + 91 + **Step 1: Add updateProfile method** 92 + 93 + Add this method to the MutationsService class (after `updateAvatar` method, around line 200): 94 + 95 + ```javascript 96 + async updateProfile(input) { 97 + const client = auth.getClient(); 98 + 99 + await client.mutate(` 100 + mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) { 101 + updateSocialGrainActorProfile(rkey: $rkey, input: $input) { 102 + uri 103 + } 104 + } 105 + `, { rkey: 'self', input }); 106 + 107 + await auth.refreshUser(); 108 + } 109 + 110 + async createEmptyProfile() { 111 + return this.updateProfile({ 112 + displayName: null, 113 + description: null 114 + }); 115 + } 116 + ``` 117 + 118 + **Step 2: Commit** 119 + 120 + ```bash 121 + git add src/services/mutations.js 122 + git commit -m "feat: add updateProfile and createEmptyProfile mutations" 123 + ``` 124 + 125 + --- 126 + 127 + ## Task 3: Register Onboarding Route 128 + 129 + **Files:** 130 + - Modify: `src/components/pages/grain-app.js` 131 + 132 + **Step 1: Add import** 133 + 134 + Add import at line 19 (after `grain-oauth-callback.js`): 135 + 136 + ```javascript 137 + import './grain-onboarding.js'; 138 + ``` 139 + 140 + **Step 2: Add route registration** 141 + 142 + Add route registration at line 69 (before the oauth/callback route): 143 + 144 + ```javascript 145 + .register('/onboarding', 'grain-onboarding') 146 + ``` 147 + 148 + **Step 3: Commit** 149 + 150 + ```bash 151 + git add src/components/pages/grain-app.js 152 + git commit -m "feat: register /onboarding route" 153 + ``` 154 + 155 + --- 156 + 157 + ## Task 4: Create Onboarding Component 158 + 159 + **Files:** 160 + - Create: `src/components/pages/grain-onboarding.js` 161 + 162 + **Step 1: Create the component** 163 + 164 + Create `src/components/pages/grain-onboarding.js`: 165 + 166 + ```javascript 167 + import { LitElement, html, css } from 'lit'; 168 + import { router } from '../../router.js'; 169 + import { auth } from '../../services/auth.js'; 170 + import { grainApi } from '../../services/grain-api.js'; 171 + import { mutations } from '../../services/mutations.js'; 172 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 173 + import '../atoms/grain-icon.js'; 174 + import '../atoms/grain-button.js'; 175 + import '../atoms/grain-input.js'; 176 + import '../atoms/grain-textarea.js'; 177 + import '../atoms/grain-avatar.js'; 178 + import '../atoms/grain-spinner.js'; 179 + import '../molecules/grain-form-field.js'; 180 + import '../molecules/grain-avatar-crop.js'; 181 + 182 + export class GrainOnboarding extends LitElement { 183 + static properties = { 184 + _loading: { state: true }, 185 + _saving: { state: true }, 186 + _error: { state: true }, 187 + _displayName: { state: true }, 188 + _description: { state: true }, 189 + _avatarUrl: { state: true }, 190 + _avatarBlob: { state: true }, 191 + _newAvatarDataUrl: { state: true }, 192 + _showAvatarCrop: { state: true }, 193 + _cropImageUrl: { state: true } 194 + }; 195 + 196 + static styles = css` 197 + :host { 198 + display: block; 199 + width: 100%; 200 + max-width: var(--feed-max-width); 201 + min-height: 100%; 202 + padding-bottom: 80px; 203 + background: var(--color-bg-primary); 204 + align-self: center; 205 + } 206 + .header { 207 + display: flex; 208 + flex-direction: column; 209 + align-items: center; 210 + gap: var(--space-xs); 211 + padding: var(--space-xl) var(--space-sm) var(--space-lg); 212 + text-align: center; 213 + } 214 + h1 { 215 + font-size: var(--font-size-xl); 216 + font-weight: var(--font-weight-semibold); 217 + color: var(--color-text-primary); 218 + margin: 0; 219 + } 220 + .subtitle { 221 + font-size: var(--font-size-sm); 222 + color: var(--color-text-secondary); 223 + margin: 0; 224 + } 225 + .content { 226 + padding: 0 var(--space-sm); 227 + } 228 + @media (min-width: 600px) { 229 + .content { 230 + padding: 0; 231 + } 232 + } 233 + .avatar-section { 234 + display: flex; 235 + flex-direction: column; 236 + align-items: center; 237 + margin-bottom: var(--space-lg); 238 + } 239 + .avatar-wrapper { 240 + position: relative; 241 + cursor: pointer; 242 + } 243 + .avatar-overlay { 244 + position: absolute; 245 + bottom: 0; 246 + right: 0; 247 + width: 28px; 248 + height: 28px; 249 + border-radius: 50%; 250 + background: var(--color-bg-primary); 251 + border: 2px solid var(--color-border); 252 + display: flex; 253 + align-items: center; 254 + justify-content: center; 255 + color: var(--color-text-primary); 256 + } 257 + .avatar-preview { 258 + width: 80px; 259 + height: 80px; 260 + border-radius: 50%; 261 + object-fit: cover; 262 + background: var(--color-bg-elevated); 263 + } 264 + input[type="file"] { 265 + display: none; 266 + } 267 + .actions { 268 + display: flex; 269 + flex-direction: column; 270 + gap: var(--space-sm); 271 + padding: var(--space-lg) var(--space-sm); 272 + border-top: 1px solid var(--color-border); 273 + margin-top: var(--space-lg); 274 + } 275 + @media (min-width: 600px) { 276 + .actions { 277 + padding-left: 0; 278 + padding-right: 0; 279 + } 280 + } 281 + .skip-button { 282 + background: none; 283 + border: none; 284 + color: var(--color-text-secondary); 285 + font-size: var(--font-size-sm); 286 + cursor: pointer; 287 + padding: var(--space-sm); 288 + text-align: center; 289 + } 290 + .skip-button:hover { 291 + color: var(--color-text-primary); 292 + text-decoration: underline; 293 + } 294 + .error { 295 + color: var(--color-danger, #dc3545); 296 + font-size: var(--font-size-sm); 297 + padding: var(--space-sm); 298 + text-align: center; 299 + } 300 + .loading { 301 + display: flex; 302 + flex-direction: column; 303 + align-items: center; 304 + justify-content: center; 305 + gap: var(--space-md); 306 + padding: var(--space-xl); 307 + min-height: 300px; 308 + } 309 + .loading p { 310 + color: var(--color-text-secondary); 311 + font-size: var(--font-size-sm); 312 + } 313 + `; 314 + 315 + constructor() { 316 + super(); 317 + this._loading = true; 318 + this._saving = false; 319 + this._error = null; 320 + this._displayName = ''; 321 + this._description = ''; 322 + this._avatarUrl = ''; 323 + this._avatarBlob = null; 324 + this._newAvatarDataUrl = null; 325 + this._showAvatarCrop = false; 326 + this._cropImageUrl = null; 327 + } 328 + 329 + async connectedCallback() { 330 + super.connectedCallback(); 331 + 332 + if (!auth.isAuthenticated) { 333 + router.replace('/'); 334 + return; 335 + } 336 + 337 + await this.#checkAndLoad(); 338 + } 339 + 340 + async #checkAndLoad() { 341 + try { 342 + const client = auth.getClient(); 343 + 344 + // Check if user already has a Grain profile 345 + const hasProfile = await grainApi.hasGrainProfile(client); 346 + if (hasProfile) { 347 + this.#redirectToDestination(); 348 + return; 349 + } 350 + 351 + // Fetch Bluesky profile to prefill 352 + const bskyProfile = await grainApi.getBlueskyProfile(client); 353 + this._displayName = bskyProfile.displayName; 354 + this._description = bskyProfile.description; 355 + this._avatarUrl = bskyProfile.avatarUrl; 356 + this._avatarBlob = bskyProfile.avatarBlob; 357 + } catch (err) { 358 + console.error('Failed to load profile data:', err); 359 + } finally { 360 + this._loading = false; 361 + } 362 + } 363 + 364 + #redirectToDestination() { 365 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 366 + sessionStorage.removeItem('oauth_return_url'); 367 + router.replace(returnUrl); 368 + } 369 + 370 + #handleDisplayNameChange(e) { 371 + this._displayName = e.detail.value.slice(0, 64); 372 + } 373 + 374 + #handleDescriptionChange(e) { 375 + this._description = e.detail.value.slice(0, 256); 376 + } 377 + 378 + #handleAvatarClick() { 379 + this.shadowRoot.querySelector('#avatar-input').click(); 380 + } 381 + 382 + async #handleAvatarChange(e) { 383 + const input = e.target; 384 + const file = input.files?.[0]; 385 + if (!file) return; 386 + 387 + input.value = ''; 388 + 389 + try { 390 + const dataUrl = await readFileAsDataURL(file); 391 + const resized = await resizeImage(dataUrl, { 392 + width: 2000, 393 + height: 2000, 394 + maxSize: 900000 395 + }); 396 + this._cropImageUrl = resized.dataUrl; 397 + this._showAvatarCrop = true; 398 + } catch (err) { 399 + console.error('Failed to process avatar:', err); 400 + this._error = 'Failed to process image'; 401 + } 402 + } 403 + 404 + #handleCropCancel() { 405 + this._showAvatarCrop = false; 406 + this._cropImageUrl = null; 407 + } 408 + 409 + #handleCrop(e) { 410 + this._showAvatarCrop = false; 411 + this._cropImageUrl = null; 412 + this._newAvatarDataUrl = e.detail.dataUrl; 413 + this._avatarBlob = null; 414 + } 415 + 416 + get #displayedAvatarUrl() { 417 + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; 418 + return this._avatarUrl; 419 + } 420 + 421 + async #handleSave() { 422 + if (this._saving) return; 423 + 424 + this._saving = true; 425 + this._error = null; 426 + 427 + try { 428 + const input = { 429 + displayName: this._displayName.trim() || null, 430 + description: this._description.trim() || null 431 + }; 432 + 433 + if (this._newAvatarDataUrl) { 434 + const base64Data = this._newAvatarDataUrl.split(',')[1]; 435 + const blob = await mutations.uploadBlob(base64Data, 'image/jpeg'); 436 + input.avatar = { 437 + $type: 'blob', 438 + ref: { $link: blob.ref }, 439 + mimeType: blob.mimeType, 440 + size: blob.size 441 + }; 442 + } else if (this._avatarBlob) { 443 + input.avatar = this._avatarBlob; 444 + } 445 + 446 + await mutations.updateProfile(input); 447 + this.#redirectToDestination(); 448 + } catch (err) { 449 + console.error('Failed to save profile:', err); 450 + this._error = err.message || 'Failed to save profile. Please try again.'; 451 + } finally { 452 + this._saving = false; 453 + } 454 + } 455 + 456 + async #handleSkip() { 457 + if (this._saving) return; 458 + 459 + this._saving = true; 460 + this._error = null; 461 + 462 + try { 463 + await mutations.createEmptyProfile(); 464 + this.#redirectToDestination(); 465 + } catch (err) { 466 + console.error('Failed to skip onboarding:', err); 467 + this._error = err.message || 'Something went wrong. Please try again.'; 468 + } finally { 469 + this._saving = false; 470 + } 471 + } 472 + 473 + render() { 474 + if (this._loading) { 475 + return html` 476 + <div class="loading"> 477 + <grain-spinner size="32"></grain-spinner> 478 + <p>Loading...</p> 479 + </div> 480 + `; 481 + } 482 + 483 + return html` 484 + <div class="header"> 485 + <h1>Welcome to Grain</h1> 486 + <p class="subtitle">Set up your profile to get started</p> 487 + </div> 488 + 489 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 490 + 491 + <div class="content"> 492 + <div class="avatar-section"> 493 + <div class="avatar-wrapper" @click=${this.#handleAvatarClick}> 494 + ${this.#displayedAvatarUrl ? html` 495 + <img class="avatar-preview" src=${this.#displayedAvatarUrl} alt="Profile avatar"> 496 + ` : html` 497 + <grain-avatar size="lg"></grain-avatar> 498 + `} 499 + <div class="avatar-overlay"> 500 + <grain-icon name="camera" size="14"></grain-icon> 501 + </div> 502 + </div> 503 + <input 504 + type="file" 505 + id="avatar-input" 506 + accept="image/png,image/jpeg" 507 + @change=${this.#handleAvatarChange} 508 + > 509 + </div> 510 + 511 + <grain-form-field label="Display Name" .value=${this._displayName} .maxlength=${64}> 512 + <grain-input 513 + placeholder="Display name" 514 + .value=${this._displayName} 515 + @input=${this.#handleDisplayNameChange} 516 + ></grain-input> 517 + </grain-form-field> 518 + 519 + <grain-form-field label="Bio" .value=${this._description} .maxlength=${256}> 520 + <grain-textarea 521 + placeholder="Tell us about yourself" 522 + .value=${this._description} 523 + .maxlength=${256} 524 + @input=${this.#handleDescriptionChange} 525 + ></grain-textarea> 526 + </grain-form-field> 527 + </div> 528 + 529 + <div class="actions"> 530 + <grain-button 531 + variant="primary" 532 + ?loading=${this._saving} 533 + loadingText="Saving..." 534 + @click=${this.#handleSave} 535 + >Save & Continue</grain-button> 536 + <button 537 + class="skip-button" 538 + ?disabled=${this._saving} 539 + @click=${this.#handleSkip} 540 + >Skip for now</button> 541 + </div> 542 + 543 + <grain-avatar-crop 544 + ?open=${this._showAvatarCrop} 545 + image-url=${this._cropImageUrl || ''} 546 + @crop=${this.#handleCrop} 547 + @cancel=${this.#handleCropCancel} 548 + ></grain-avatar-crop> 549 + `; 550 + } 551 + } 552 + 553 + customElements.define('grain-onboarding', GrainOnboarding); 554 + ``` 555 + 556 + **Step 2: Verify the component compiles** 557 + 558 + Run: `npm run dev` 559 + Navigate to: `http://localhost:5173/onboarding` 560 + Expected: Page loads (redirects to home if not authenticated or already has profile) 561 + 562 + **Step 3: Commit** 563 + 564 + ```bash 565 + git add src/components/pages/grain-onboarding.js 566 + git commit -m "feat: add onboarding component with Bluesky profile prefill" 567 + ``` 568 + 569 + --- 570 + 571 + ## Task 5: Modify OAuth Callback to Check Profile 572 + 573 + **Files:** 574 + - Modify: `src/services/auth.js` 575 + 576 + **Step 1: Add import for grainApi** 577 + 578 + Add at line 2 (after router import): 579 + 580 + ```javascript 581 + import { grainApi } from './grain-api.js'; 582 + ``` 583 + 584 + **Step 2: Update the redirect callback handler** 585 + 586 + Replace lines 19-26 in `src/services/auth.js` with: 587 + 588 + ```javascript 589 + // Handle OAuth callback if present 590 + if (window.location.search.includes('code=')) { 591 + await this.#client.handleRedirectCallback(); 592 + 593 + // Check if user has a Grain profile 594 + const hasProfile = await grainApi.hasGrainProfile(this.#client); 595 + 596 + if (!hasProfile) { 597 + // First-time user - redirect to onboarding 598 + window.location.replace('/onboarding'); 599 + return; 600 + } 601 + 602 + // Existing user - redirect to their destination 603 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 604 + sessionStorage.removeItem('oauth_return_url'); 605 + window.location.replace(returnUrl); 606 + return; 607 + } 608 + ``` 609 + 610 + **Step 3: Commit** 611 + 612 + ```bash 613 + git add src/services/auth.js 614 + git commit -m "feat: redirect first-time users to onboarding after OAuth" 615 + ``` 616 + 617 + --- 618 + 619 + ## Task 6: Test Complete Flow 620 + 621 + **Files:** 622 + - None (manual testing) 623 + 624 + **Step 1: Test new user flow** 625 + 626 + 1. Clear localStorage/sessionStorage (or use incognito) 627 + 2. Navigate to `/explore` 628 + 3. Click login, authenticate with Bluesky 629 + 4. Expected: Redirected to `/onboarding` with Bluesky profile prefilled 630 + 5. Click "Save & Continue" 631 + 6. Expected: Redirected to `/explore` (original return URL) 632 + 633 + **Step 2: Test skip flow** 634 + 635 + 1. Use a different account without Grain profile 636 + 2. Go through OAuth 637 + 3. On onboarding, click "Skip for now" 638 + 4. Expected: Redirected to return URL, profile record created 639 + 640 + **Step 3: Test returning user flow** 641 + 642 + 1. Log out and log back in with same account 643 + 2. Expected: Goes directly to return URL (no onboarding) 644 + 645 + **Step 4: Test manual onboarding access** 646 + 647 + 1. While logged in with existing profile, navigate to `/onboarding` 648 + 2. Expected: Immediately redirected to home 649 + 650 + --- 651 + 652 + ## Summary 653 + 654 + | Task | Description | Files | 655 + |------|-------------|-------| 656 + | 1 | Add profile queries | `grain-api.js` | 657 + | 2 | Add profile mutations | `mutations.js` | 658 + | 3 | Register route | `grain-app.js` | 659 + | 4 | Create onboarding component | `grain-onboarding.js` (new) | 660 + | 5 | Modify OAuth callback | `auth.js` | 661 + | 6 | Test complete flow | Manual testing |
+257
docs/plans/2026-01-04-alt-overlay-scroll-fix.md
··· 1 + # Alt Text Overlay Scroll Fix 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix alt text overlay position drifting when page scrolls by moving overlay rendering from the badge to the carousel. 6 + 7 + **Architecture:** Move overlay from `grain-alt-badge` (which uses `position: fixed` with JS positioning) to `grain-image-carousel` (which renders it inside the slide with `position: absolute; inset: 0`). The badge becomes a simple button that emits an event. 8 + 9 + **Tech Stack:** Lit, Web Components, CSS 10 + 11 + --- 12 + 13 + ### Task 1: Simplify grain-alt-badge to emit event 14 + 15 + **Files:** 16 + - Modify: `src/components/atoms/grain-alt-badge.js` 17 + 18 + **Step 1: Remove overlay state and scroll listener logic** 19 + 20 + Replace the entire file with: 21 + 22 + ```js 23 + import { LitElement, html, css } from 'lit'; 24 + 25 + export class GrainAltBadge extends LitElement { 26 + static properties = { 27 + alt: { type: String } 28 + }; 29 + 30 + static styles = css` 31 + :host { 32 + position: absolute; 33 + bottom: 8px; 34 + right: 8px; 35 + z-index: 2; 36 + } 37 + .badge { 38 + background: rgba(0, 0, 0, 0.7); 39 + color: white; 40 + font-size: 10px; 41 + font-weight: 600; 42 + padding: 2px 4px; 43 + border-radius: 4px; 44 + cursor: pointer; 45 + user-select: none; 46 + border: none; 47 + font-family: inherit; 48 + } 49 + .badge:hover { 50 + background: rgba(0, 0, 0, 0.85); 51 + } 52 + .badge:focus { 53 + outline: 2px solid white; 54 + outline-offset: 1px; 55 + } 56 + `; 57 + 58 + constructor() { 59 + super(); 60 + this.alt = ''; 61 + } 62 + 63 + #handleClick(e) { 64 + e.stopPropagation(); 65 + this.dispatchEvent(new CustomEvent('alt-click', { 66 + bubbles: true, 67 + composed: true, 68 + detail: { alt: this.alt } 69 + })); 70 + } 71 + 72 + render() { 73 + if (!this.alt) return null; 74 + 75 + return html` 76 + <button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button> 77 + `; 78 + } 79 + } 80 + 81 + customElements.define('grain-alt-badge', GrainAltBadge); 82 + ``` 83 + 84 + **Step 2: Verify badge still renders** 85 + 86 + Run the app, navigate to a gallery with alt text, confirm "ALT" button appears. 87 + 88 + --- 89 + 90 + ### Task 2: Add overlay state and styles to grain-image-carousel 91 + 92 + **Files:** 93 + - Modify: `src/components/organisms/grain-image-carousel.js` 94 + 95 + **Step 1: Add `_activeAltIndex` state property** 96 + 97 + In `static properties`, add: 98 + 99 + ```js 100 + static properties = { 101 + photos: { type: Array }, 102 + rkey: { type: String }, 103 + _currentIndex: { state: true }, 104 + _activeAltIndex: { state: true } 105 + }; 106 + ``` 107 + 108 + **Step 2: Initialize state in constructor** 109 + 110 + Add to constructor: 111 + 112 + ```js 113 + this._activeAltIndex = null; 114 + ``` 115 + 116 + **Step 3: Add overlay styles** 117 + 118 + Add to `static styles` (after `.nav-arrow-right`): 119 + 120 + ```css 121 + .alt-overlay { 122 + position: absolute; 123 + inset: 0; 124 + background: rgba(0, 0, 0, 0.75); 125 + color: white; 126 + padding: 16px; 127 + font-size: 14px; 128 + line-height: 1.5; 129 + overflow-y: auto; 130 + display: flex; 131 + align-items: center; 132 + justify-content: center; 133 + text-align: center; 134 + box-sizing: border-box; 135 + z-index: 3; 136 + cursor: pointer; 137 + } 138 + ``` 139 + 140 + --- 141 + 142 + ### Task 3: Add overlay event handlers to carousel 143 + 144 + **Files:** 145 + - Modify: `src/components/organisms/grain-image-carousel.js` 146 + 147 + **Step 1: Add handler for alt-click event** 148 + 149 + Add method: 150 + 151 + ```js 152 + #handleAltClick(e, index) { 153 + e.stopPropagation(); 154 + this._activeAltIndex = index; 155 + } 156 + ``` 157 + 158 + **Step 2: Add handler for overlay click (dismiss)** 159 + 160 + Add method: 161 + 162 + ```js 163 + #handleOverlayClick(e) { 164 + e.stopPropagation(); 165 + this._activeAltIndex = null; 166 + } 167 + ``` 168 + 169 + **Step 3: Dismiss overlay on slide change** 170 + 171 + Modify `#handleScroll` to clear overlay when swiping: 172 + 173 + ```js 174 + #handleScroll(e) { 175 + const carousel = e.target; 176 + const index = Math.round(carousel.scrollLeft / carousel.offsetWidth); 177 + if (index !== this._currentIndex) { 178 + this._currentIndex = index; 179 + this._activeAltIndex = null; 180 + } 181 + } 182 + ``` 183 + 184 + --- 185 + 186 + ### Task 4: Render overlay in slide template 187 + 188 + **Files:** 189 + - Modify: `src/components/organisms/grain-image-carousel.js` 190 + 191 + **Step 1: Update slide template in render()** 192 + 193 + Replace the slide mapping (lines 153-162) with: 194 + 195 + ```js 196 + ${this.photos.map((photo, index) => html` 197 + <div class="slide ${hasPortrait ? 'centered' : ''}"> 198 + <grain-image 199 + src=${this.#shouldLoad(index) ? photo.url : ''} 200 + alt=${photo.alt || ''} 201 + aspectRatio=${photo.aspectRatio || 1} 202 + style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 203 + ></grain-image> 204 + ${photo.alt ? html` 205 + <grain-alt-badge 206 + .alt=${photo.alt} 207 + @alt-click=${(e) => this.#handleAltClick(e, index)} 208 + ></grain-alt-badge> 209 + ` : ''} 210 + ${this._activeAltIndex === index ? html` 211 + <div class="alt-overlay" @click=${this.#handleOverlayClick}> 212 + ${photo.alt} 213 + </div> 214 + ` : ''} 215 + </div> 216 + `)} 217 + ``` 218 + 219 + --- 220 + 221 + ### Task 5: Manual testing 222 + 223 + **Step 1: Test overlay appears correctly** 224 + 225 + 1. Navigate to a gallery with alt text 226 + 2. Click "ALT" button 227 + 3. Confirm overlay appears covering the image 228 + 229 + **Step 2: Test overlay dismisses on click** 230 + 231 + 1. With overlay open, click the overlay 232 + 2. Confirm overlay closes 233 + 234 + **Step 3: Test overlay dismisses on swipe** 235 + 236 + 1. Open alt overlay on first image 237 + 2. Swipe to second image 238 + 3. Confirm overlay closes 239 + 240 + **Step 4: Test scroll behavior (the bug fix)** 241 + 242 + 1. Open alt overlay 243 + 2. Scroll the page up/down 244 + 3. Confirm overlay stays attached to the image (doesn't drift) 245 + 246 + --- 247 + 248 + ### Task 6: Commit 249 + 250 + ```bash 251 + git add src/components/atoms/grain-alt-badge.js src/components/organisms/grain-image-carousel.js 252 + git commit -m "fix: alt text overlay stays attached on page scroll 253 + 254 + Move overlay rendering from grain-alt-badge to grain-image-carousel. 255 + The overlay now uses position:absolute within the slide instead of 256 + position:fixed with JS positioning, so it naturally scrolls with content." 257 + ```
+597
docs/plans/2026-01-04-app-level-dialog-system.md
··· 1 + # App-Level Dialog System Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Move all dialogs to app level so they render above the fixed-position outlet and display correctly on all screen sizes. 6 + 7 + **Architecture:** Pages dispatch `open-dialog` events with type and props. `grain-app` maintains a dialog registry mapping types to render functions. Dialogs render at app root level, outside the constrained `#outlet`. Dialog-specific callbacks re-dispatch as events for pages to handle. 8 + 9 + **Tech Stack:** Lit 3, Custom Events 10 + 11 + --- 12 + 13 + ## Task 1: Add Dialog System to grain-app 14 + 15 + **Files:** 16 + - Modify: `src/components/pages/grain-app.js` 17 + 18 + **Step 1: Add imports** 19 + 20 + Add after existing imports: 21 + 22 + ```javascript 23 + import '../organisms/grain-action-dialog.js'; 24 + import '../organisms/grain-report-dialog.js'; 25 + import '../atoms/grain-toast.js'; 26 + ``` 27 + 28 + **Step 2: Add state properties** 29 + 30 + Add static properties to the class: 31 + 32 + ```javascript 33 + static properties = { 34 + _dialogType: { state: true }, 35 + _dialogProps: { state: true } 36 + }; 37 + ``` 38 + 39 + **Step 3: Add constructor** 40 + 41 + ```javascript 42 + constructor() { 43 + super(); 44 + this._dialogType = null; 45 + this._dialogProps = {}; 46 + } 47 + ``` 48 + 49 + **Step 4: Add lifecycle and event handlers** 50 + 51 + Add after constructor: 52 + 53 + ```javascript 54 + connectedCallback() { 55 + super.connectedCallback(); 56 + this.addEventListener('open-dialog', this.#handleOpenDialog); 57 + } 58 + 59 + disconnectedCallback() { 60 + this.removeEventListener('open-dialog', this.#handleOpenDialog); 61 + super.disconnectedCallback(); 62 + } 63 + 64 + #handleOpenDialog = (e) => { 65 + this._dialogType = e.detail.type; 66 + this._dialogProps = e.detail.props || {}; 67 + }; 68 + 69 + #closeDialog = () => { 70 + this._dialogType = null; 71 + this._dialogProps = {}; 72 + }; 73 + 74 + #handleReportSubmitted = () => { 75 + this.#closeDialog(); 76 + this.shadowRoot.querySelector('grain-toast')?.show('Report submitted'); 77 + }; 78 + 79 + #handleDialogAction = (e) => { 80 + this.dispatchEvent(new CustomEvent('dialog-action', { 81 + bubbles: true, 82 + composed: true, 83 + detail: e.detail 84 + })); 85 + }; 86 + ``` 87 + 88 + **Step 5: Add dialog registry** 89 + 90 + Add after the event handlers: 91 + 92 + ```javascript 93 + #renderDialog() { 94 + switch (this._dialogType) { 95 + case 'report': 96 + return html` 97 + <grain-report-dialog 98 + open 99 + galleryUri=${this._dialogProps.galleryUri || ''} 100 + @close=${this.#closeDialog} 101 + @submitted=${this.#handleReportSubmitted} 102 + ></grain-report-dialog> 103 + `; 104 + case 'action': 105 + return html` 106 + <grain-action-dialog 107 + open 108 + .actions=${this._dialogProps.actions || []} 109 + ?loading=${this._dialogProps.loading} 110 + loadingText=${this._dialogProps.loadingText || ''} 111 + @close=${this.#closeDialog} 112 + @action=${this.#handleDialogAction} 113 + ></grain-action-dialog> 114 + `; 115 + default: 116 + return ''; 117 + } 118 + } 119 + ``` 120 + 121 + **Step 6: Update render method** 122 + 123 + Replace the render method: 124 + 125 + ```javascript 126 + render() { 127 + return html` 128 + <grain-header></grain-header> 129 + <div id="outlet"></div> 130 + <grain-bottom-nav></grain-bottom-nav> 131 + ${this.#renderDialog()} 132 + <grain-toast></grain-toast> 133 + `; 134 + } 135 + ``` 136 + 137 + **Step 7: Verify syntax** 138 + 139 + Run: `npm run build` 140 + Expected: Build succeeds 141 + 142 + **Step 8: Commit** 143 + 144 + ```bash 145 + git add src/components/pages/grain-app.js 146 + git commit -m "feat: add app-level dialog system with registry" 147 + ``` 148 + 149 + --- 150 + 151 + ## Task 2: Revert Dialog Overlay CSS 152 + 153 + **Files:** 154 + - Modify: `src/components/organisms/grain-action-dialog.js` 155 + - Modify: `src/components/organisms/grain-report-dialog.js` 156 + 157 + **Step 1: Fix grain-action-dialog overlay** 158 + 159 + In `grain-action-dialog.js`, replace the `.overlay` CSS: 160 + 161 + ```css 162 + .overlay { 163 + position: fixed; 164 + inset: 0; 165 + background: rgba(0, 0, 0, 0.5); 166 + display: flex; 167 + align-items: center; 168 + justify-content: center; 169 + z-index: 1000; 170 + padding: var(--space-md); 171 + } 172 + ``` 173 + 174 + **Step 2: Fix grain-report-dialog overlay** 175 + 176 + In `grain-report-dialog.js`, replace the `.overlay` CSS: 177 + 178 + ```css 179 + .overlay { 180 + position: fixed; 181 + inset: 0; 182 + background: rgba(0, 0, 0, 0.5); 183 + display: flex; 184 + align-items: center; 185 + justify-content: center; 186 + z-index: 1000; 187 + padding: var(--space-md); 188 + } 189 + ``` 190 + 191 + **Step 3: Verify syntax** 192 + 193 + Run: `npm run build` 194 + Expected: Build succeeds 195 + 196 + **Step 4: Commit** 197 + 198 + ```bash 199 + git add src/components/organisms/grain-action-dialog.js src/components/organisms/grain-report-dialog.js 200 + git commit -m "fix: revert dialog overlay CSS to simple inset:0" 201 + ``` 202 + 203 + --- 204 + 205 + ## Task 3: Update grain-timeline to Use Dialog Events 206 + 207 + **Files:** 208 + - Modify: `src/components/pages/grain-timeline.js` 209 + 210 + **Step 1: Remove dialog imports** 211 + 212 + Remove these lines: 213 + 214 + ```javascript 215 + import '../organisms/grain-action-dialog.js'; 216 + import '../organisms/grain-report-dialog.js'; 217 + import '../atoms/grain-toast.js'; 218 + ``` 219 + 220 + **Step 2: Simplify state properties** 221 + 222 + Remove these properties: 223 + 224 + ```javascript 225 + _menuOpen: { state: true }, 226 + _menuGallery: { state: true }, 227 + _menuIsOwner: { state: true }, 228 + _deleting: { state: true }, 229 + _reportDialogOpen: { state: true } 230 + ``` 231 + 232 + Add this one property: 233 + 234 + ```javascript 235 + _pendingGallery: { state: true } 236 + ``` 237 + 238 + **Step 3: Simplify constructor** 239 + 240 + Remove these initializations: 241 + 242 + ```javascript 243 + this._menuOpen = false; 244 + this._menuGallery = null; 245 + this._menuIsOwner = false; 246 + this._deleting = false; 247 + this._reportDialogOpen = false; 248 + ``` 249 + 250 + Add: 251 + 252 + ```javascript 253 + this._pendingGallery = null; 254 + ``` 255 + 256 + **Step 4: Add dialog-action listener in connectedCallback** 257 + 258 + After the existing scroll listener setup: 259 + 260 + ```javascript 261 + this.addEventListener('dialog-action', this.#handleDialogAction); 262 + ``` 263 + 264 + **Step 5: Add cleanup in disconnectedCallback** 265 + 266 + In the existing disconnectedCallback, add: 267 + 268 + ```javascript 269 + this.removeEventListener('dialog-action', this.#handleDialogAction); 270 + ``` 271 + 272 + **Step 6: Replace menu handlers** 273 + 274 + Remove these methods: 275 + - `#handleMenuClose` 276 + - `#handleMenuAction` 277 + - `#handleReportDialogClose` 278 + - `#handleReportSubmitted` 279 + 280 + Replace `#handleGalleryMenu` with: 281 + 282 + ```javascript 283 + #handleGalleryMenu(e) { 284 + const { gallery, isOwner } = e.detail; 285 + this._pendingGallery = gallery; 286 + 287 + this.dispatchEvent(new CustomEvent('open-dialog', { 288 + bubbles: true, 289 + composed: true, 290 + detail: { 291 + type: 'action', 292 + props: { 293 + actions: isOwner 294 + ? [{ label: 'Delete', action: 'delete', danger: true }] 295 + : [{ label: 'Report gallery', action: 'report' }] 296 + } 297 + } 298 + })); 299 + } 300 + ``` 301 + 302 + Add new `#handleDialogAction`: 303 + 304 + ```javascript 305 + #handleDialogAction = (e) => { 306 + if (e.detail.action === 'delete') { 307 + this.#handleDelete(); 308 + } else if (e.detail.action === 'report') { 309 + this.dispatchEvent(new CustomEvent('open-dialog', { 310 + bubbles: true, 311 + composed: true, 312 + detail: { 313 + type: 'report', 314 + props: { galleryUri: this._pendingGallery?.uri } 315 + } 316 + })); 317 + } 318 + }; 319 + ``` 320 + 321 + **Step 7: Update #handleDelete** 322 + 323 + Replace the method with: 324 + 325 + ```javascript 326 + async #handleDelete() { 327 + if (!this._pendingGallery) return; 328 + 329 + // Show loading state 330 + this.dispatchEvent(new CustomEvent('open-dialog', { 331 + bubbles: true, 332 + composed: true, 333 + detail: { 334 + type: 'action', 335 + props: { 336 + actions: [{ label: 'Delete', action: 'delete', danger: true }], 337 + loading: true, 338 + loadingText: 'Deleting...' 339 + } 340 + } 341 + })); 342 + 343 + try { 344 + const client = auth.getClient(); 345 + const rkey = this._pendingGallery.uri.split('/').pop(); 346 + 347 + await client.mutate(` 348 + mutation DeleteGallery($rkey: String!) { 349 + deleteSocialGrainGallery(rkey: $rkey) { uri } 350 + } 351 + `, { rkey }); 352 + 353 + this._galleries = this._galleries.filter(g => g.uri !== this._pendingGallery.uri); 354 + this._pendingGallery = null; 355 + 356 + // Close dialog by dispatching close (app listens) 357 + this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true })); 358 + } catch (err) { 359 + console.error('Failed to delete gallery:', err); 360 + this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true })); 361 + } 362 + } 363 + ``` 364 + 365 + **Step 8: Remove dialogs from render** 366 + 367 + Remove these elements from the render method: 368 + 369 + ```javascript 370 + <grain-action-dialog ...></grain-action-dialog> 371 + <grain-report-dialog ...></grain-report-dialog> 372 + <grain-toast></grain-toast> 373 + ``` 374 + 375 + **Step 9: Verify syntax** 376 + 377 + Run: `npm run build` 378 + Expected: Build succeeds 379 + 380 + **Step 10: Commit** 381 + 382 + ```bash 383 + git add src/components/pages/grain-timeline.js 384 + git commit -m "refactor: use app-level dialog events in timeline" 385 + ``` 386 + 387 + --- 388 + 389 + ## Task 4: Update grain-gallery-detail to Use Dialog Events 390 + 391 + **Files:** 392 + - Modify: `src/components/pages/grain-gallery-detail.js` 393 + 394 + **Step 1: Remove dialog imports** 395 + 396 + Remove these lines: 397 + 398 + ```javascript 399 + import '../organisms/grain-report-dialog.js'; 400 + import '../atoms/grain-toast.js'; 401 + ``` 402 + 403 + **Step 2: Remove state property** 404 + 405 + Remove: 406 + 407 + ```javascript 408 + _reportDialogOpen: { state: true } 409 + ``` 410 + 411 + **Step 3: Remove constructor initialization** 412 + 413 + Remove: 414 + 415 + ```javascript 416 + this._reportDialogOpen = false; 417 + ``` 418 + 419 + **Step 4: Add dialog-action listener** 420 + 421 + In connectedCallback (add the method if it doesn't exist): 422 + 423 + ```javascript 424 + connectedCallback() { 425 + super.connectedCallback(); 426 + this.#loadGallery(); 427 + this.addEventListener('dialog-action', this.#handleDialogAction); 428 + } 429 + ``` 430 + 431 + Add disconnectedCallback cleanup: 432 + 433 + ```javascript 434 + // In existing disconnectedCallback, add: 435 + this.removeEventListener('dialog-action', this.#handleDialogAction); 436 + ``` 437 + 438 + **Step 5: Replace report dialog handlers** 439 + 440 + Remove: 441 + - `#handleReportDialogClose` 442 + - `#handleReportSubmitted` 443 + 444 + Update `#handleAction`: 445 + 446 + ```javascript 447 + async #handleAction(e) { 448 + if (e.detail.action === 'delete') { 449 + await this.#handleDelete(); 450 + } else if (e.detail.action === 'report') { 451 + this.dispatchEvent(new CustomEvent('open-dialog', { 452 + bubbles: true, 453 + composed: true, 454 + detail: { 455 + type: 'report', 456 + props: { galleryUri: this._gallery?.uri } 457 + } 458 + })); 459 + } 460 + } 461 + ``` 462 + 463 + Add `#handleDialogAction`: 464 + 465 + ```javascript 466 + #handleDialogAction = (e) => { 467 + // Handle actions dispatched back from app-level dialog 468 + if (e.detail.action === 'delete') { 469 + this.#handleDelete(); 470 + } else if (e.detail.action === 'report') { 471 + this.dispatchEvent(new CustomEvent('open-dialog', { 472 + bubbles: true, 473 + composed: true, 474 + detail: { 475 + type: 'report', 476 + props: { galleryUri: this._gallery?.uri } 477 + } 478 + })); 479 + } 480 + }; 481 + ``` 482 + 483 + **Step 6: Update menu to use app-level action dialog** 484 + 485 + Replace the inline action dialog approach. Update `#handleMenuOpen`: 486 + 487 + ```javascript 488 + #handleMenuOpen() { 489 + this.dispatchEvent(new CustomEvent('open-dialog', { 490 + bubbles: true, 491 + composed: true, 492 + detail: { 493 + type: 'action', 494 + props: { 495 + actions: this.#isOwner 496 + ? [{ label: 'Delete', action: 'delete', danger: true }] 497 + : [{ label: 'Report gallery', action: 'report' }] 498 + } 499 + } 500 + })); 501 + } 502 + ``` 503 + 504 + Remove `#handleMenuClose` method. 505 + 506 + Remove `_menuOpen` state property and constructor initialization. 507 + 508 + **Step 7: Remove dialogs from render** 509 + 510 + Remove these elements from render: 511 + 512 + ```javascript 513 + <grain-action-dialog ...></grain-action-dialog> 514 + <grain-report-dialog ...></grain-report-dialog> 515 + <grain-toast></grain-toast> 516 + ``` 517 + 518 + **Step 8: Verify syntax** 519 + 520 + Run: `npm run build` 521 + Expected: Build succeeds 522 + 523 + **Step 9: Commit** 524 + 525 + ```bash 526 + git add src/components/pages/grain-gallery-detail.js 527 + git commit -m "refactor: use app-level dialog events in gallery detail" 528 + ``` 529 + 530 + --- 531 + 532 + ## Task 5: Add close-dialog Event Support to grain-app 533 + 534 + **Files:** 535 + - Modify: `src/components/pages/grain-app.js` 536 + 537 + **Step 1: Add close-dialog listener** 538 + 539 + In connectedCallback, add: 540 + 541 + ```javascript 542 + this.addEventListener('close-dialog', this.#closeDialog); 543 + ``` 544 + 545 + In disconnectedCallback, add: 546 + 547 + ```javascript 548 + this.removeEventListener('close-dialog', this.#closeDialog); 549 + ``` 550 + 551 + **Step 2: Verify syntax** 552 + 553 + Run: `npm run build` 554 + Expected: Build succeeds 555 + 556 + **Step 3: Commit** 557 + 558 + ```bash 559 + git add src/components/pages/grain-app.js 560 + git commit -m "feat: add close-dialog event support" 561 + ``` 562 + 563 + --- 564 + 565 + ## Task 6: Final Verification 566 + 567 + **Step 1: Full build** 568 + 569 + Run: `npm run build` 570 + Expected: Build succeeds with no errors 571 + 572 + **Step 2: Manual testing** 573 + 574 + Run: `npm run dev` 575 + 576 + Test these scenarios: 577 + 1. Timeline: Click ellipsis on other's gallery โ†’ "Report gallery" action โ†’ Report dialog opens fullscreen 578 + 2. Timeline: Click ellipsis on own gallery โ†’ "Delete" action 579 + 3. Gallery detail: Same tests as above 580 + 4. Report submission shows toast 581 + 5. Dialogs close when clicking overlay 582 + 6. Dialogs display correctly on small screens (no cut-off) 583 + 584 + **Step 3: Commit any fixes if needed** 585 + 586 + --- 587 + 588 + ## Summary 589 + 590 + | Task | Files | Description | 591 + |------|-------|-------------| 592 + | 1 | grain-app.js | Add dialog registry system | 593 + | 2 | grain-action-dialog.js, grain-report-dialog.js | Revert overlay CSS | 594 + | 3 | grain-timeline.js | Use dialog events | 595 + | 4 | grain-gallery-detail.js | Use dialog events | 596 + | 5 | grain-app.js | Add close-dialog support | 597 + | 6 | - | Final verification |
+172
docs/plans/2026-01-04-haptic-feedback.md
··· 1 + # Haptic Feedback Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add haptic feedback to bottom navigation taps for iOS 18+ and Android PWA users. 6 + 7 + **Architecture:** A small utility module (`haptics.js`) that uses the iOS 18 checkbox-switch hack for Safari and falls back to `navigator.vibrate()` for Android. The bottom nav component imports and calls the trigger function in each navigation handler. 8 + 9 + **Tech Stack:** Vanilla JS, Lit elements, Web Vibration API, iOS 18 checkbox switch attribute 10 + 11 + --- 12 + 13 + ### Task 1: Create Haptics Utility 14 + 15 + **Files:** 16 + - Create: `src/utils/haptics.js` 17 + 18 + **Step 1: Create the haptics utility file** 19 + 20 + ```js 21 + /** 22 + * Haptic feedback utility for PWA 23 + * - iOS 18+: Uses checkbox switch element hack 24 + * - Android: Uses Vibration API 25 + * - Other: Silently does nothing 26 + */ 27 + 28 + // Platform detection 29 + const isIOS = /iPhone|iPad/.test(navigator.userAgent); 30 + const hasVibrate = 'vibrate' in navigator; 31 + 32 + // Lazy-initialized hidden elements for iOS 33 + let checkbox = null; 34 + let label = null; 35 + 36 + function ensureElements() { 37 + if (checkbox) return; 38 + 39 + checkbox = document.createElement('input'); 40 + checkbox.type = 'checkbox'; 41 + checkbox.setAttribute('switch', ''); 42 + checkbox.id = 'haptic-trigger'; 43 + checkbox.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;'; 44 + 45 + label = document.createElement('label'); 46 + label.htmlFor = 'haptic-trigger'; 47 + label.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;'; 48 + 49 + document.body.append(checkbox, label); 50 + } 51 + 52 + /** 53 + * Trigger a light haptic tap 54 + */ 55 + export function trigger() { 56 + if (isIOS) { 57 + ensureElements(); 58 + label.click(); 59 + } else if (hasVibrate) { 60 + navigator.vibrate(10); 61 + } 62 + } 63 + 64 + /** 65 + * Check if haptics are supported on this device 66 + */ 67 + export function isSupported() { 68 + return isIOS || hasVibrate; 69 + } 70 + ``` 71 + 72 + **Step 2: Commit the utility** 73 + 74 + ```bash 75 + git add src/utils/haptics.js 76 + git commit -m "feat: add haptics utility for iOS 18+ and Android" 77 + ``` 78 + 79 + --- 80 + 81 + ### Task 2: Integrate Haptics into Bottom Nav 82 + 83 + **Files:** 84 + - Modify: `src/components/organisms/grain-bottom-nav.js` 85 + 86 + **Step 1: Add the import** 87 + 88 + At the top of the file with other imports, add: 89 + 90 + ```js 91 + import { trigger as haptic } from '../../utils/haptics.js'; 92 + ``` 93 + 94 + **Step 2: Add haptic calls to navigation handlers** 95 + 96 + Find each handler method and add `haptic();` as the first line: 97 + 98 + ```js 99 + #handleHome() { 100 + haptic(); 101 + router.push('/'); 102 + } 103 + 104 + #handleProfile() { 105 + haptic(); 106 + router.push(`/profile/${this._user.handle}`); 107 + } 108 + 109 + #handleExplore() { 110 + haptic(); 111 + router.push('/explore'); 112 + } 113 + 114 + #handleNotifications() { 115 + haptic(); 116 + router.push('/notifications'); 117 + } 118 + 119 + #handleCreate() { 120 + haptic(); 121 + this._fileInput.click(); 122 + } 123 + ``` 124 + 125 + **Step 3: Commit the integration** 126 + 127 + ```bash 128 + git add src/components/organisms/grain-bottom-nav.js 129 + git commit -m "feat: add haptic feedback to bottom nav taps" 130 + ``` 131 + 132 + --- 133 + 134 + ### Task 3: Manual Testing 135 + 136 + **Step 1: Start the dev server** 137 + 138 + ```bash 139 + npm run dev 140 + ``` 141 + 142 + **Step 2: Test on iOS 18+ device** 143 + 144 + - Open the app in Safari on iPhone/iPad running iOS 18+ 145 + - Add to Home Screen (PWA mode) 146 + - Tap each bottom nav item 147 + - Expected: Light haptic tap on each navigation 148 + 149 + **Step 3: Test on Android device** 150 + 151 + - Open the app in Chrome on Android 152 + - Add to Home Screen or test in browser 153 + - Tap each bottom nav item 154 + - Expected: Light vibration (10ms) on each navigation 155 + 156 + **Step 4: Test on desktop** 157 + 158 + - Open the app in any desktop browser 159 + - Click each bottom nav item 160 + - Expected: No errors, navigation works normally, no haptic (graceful degradation) 161 + 162 + --- 163 + 164 + ## Summary 165 + 166 + | Task | Description | Files | 167 + |------|-------------|-------| 168 + | 1 | Create haptics utility | `src/utils/haptics.js` | 169 + | 2 | Integrate into bottom nav | `src/components/organisms/grain-bottom-nav.js` | 170 + | 3 | Manual testing | N/A | 171 + 172 + **Total changes:** 2 files, ~40 lines of code
+3 -3
package-lock.json
··· 1 1 { 2 - "name": "grain-next", 2 + "name": "grain", 3 3 "version": "1.0.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 - "name": "grain-next", 8 + "name": "grain", 9 9 "version": "1.0.0", 10 - "license": "ISC", 10 + "license": "Apache-2.0", 11 11 "dependencies": { 12 12 "@fortawesome/fontawesome-free": "^7.1.0", 13 13 "lit": "^3.3.2",
+6 -85
src/components/atoms/grain-alt-badge.js
··· 2 2 3 3 export class GrainAltBadge extends LitElement { 4 4 static properties = { 5 - alt: { type: String }, 6 - _showOverlay: { state: true } 5 + alt: { type: String } 7 6 }; 8 7 9 8 static styles = css` ··· 32 31 outline: 2px solid white; 33 32 outline-offset: 1px; 34 33 } 35 - .overlay { 36 - position: absolute; 37 - bottom: 8px; 38 - right: 8px; 39 - left: -8px; 40 - top: -8px; 41 - transform: translate(-100%, -100%); 42 - transform: none; 43 - bottom: 0; 44 - right: 0; 45 - left: 0; 46 - top: 0; 47 - position: fixed; 48 - background: rgba(0, 0, 0, 0.75); 49 - color: white; 50 - padding: 16px; 51 - font-size: 14px; 52 - line-height: 1.5; 53 - overflow-y: auto; 54 - display: flex; 55 - align-items: center; 56 - justify-content: center; 57 - text-align: center; 58 - box-sizing: border-box; 59 - } 60 34 `; 61 35 62 - #scrollHandler = null; 63 - #carousel = null; 64 - 65 36 constructor() { 66 37 super(); 67 38 this.alt = ''; 68 - this._showOverlay = false; 69 - } 70 - 71 - disconnectedCallback() { 72 - super.disconnectedCallback(); 73 - this.#removeScrollListener(); 74 39 } 75 40 76 41 #handleClick(e) { 77 42 e.stopPropagation(); 78 - this._showOverlay = !this._showOverlay; 79 - } 80 - 81 - #handleOverlayClick(e) { 82 - e.stopPropagation(); 83 - this._showOverlay = false; 84 - } 85 - 86 - #removeScrollListener() { 87 - if (this.#scrollHandler && this.#carousel) { 88 - this.#carousel.removeEventListener('scroll', this.#scrollHandler); 89 - this.#scrollHandler = null; 90 - this.#carousel = null; 91 - } 92 - } 93 - 94 - updated(changedProperties) { 95 - if (changedProperties.has('_showOverlay')) { 96 - if (this._showOverlay) { 97 - // Position overlay to cover the parent slide 98 - const slide = this.closest('.slide'); 99 - if (slide) { 100 - const rect = slide.getBoundingClientRect(); 101 - const overlay = this.shadowRoot.querySelector('.overlay'); 102 - if (overlay) { 103 - overlay.style.top = `${rect.top}px`; 104 - overlay.style.left = `${rect.left}px`; 105 - overlay.style.width = `${rect.width}px`; 106 - overlay.style.height = `${rect.height}px`; 107 - } 108 - 109 - // Listen for carousel scroll to dismiss overlay 110 - this.#carousel = slide.parentElement; 111 - if (this.#carousel) { 112 - this.#scrollHandler = () => { 113 - this._showOverlay = false; 114 - }; 115 - this.#carousel.addEventListener('scroll', this.#scrollHandler, { passive: true }); 116 - } 117 - } 118 - } else { 119 - this.#removeScrollListener(); 120 - } 121 - } 43 + this.dispatchEvent(new CustomEvent('alt-click', { 44 + bubbles: true, 45 + composed: true, 46 + detail: { alt: this.alt } 47 + })); 122 48 } 123 49 124 50 render() { ··· 126 52 127 53 return html` 128 54 <button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button> 129 - ${this._showOverlay ? html` 130 - <div class="overlay" @click=${this.#handleOverlayClick}> 131 - ${this.alt} 132 - </div> 133 - ` : ''} 134 55 `; 135 56 } 136 57 }
+41
src/components/atoms/grain-close-button.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import './grain-icon.js'; 3 + 4 + export class GrainCloseButton extends LitElement { 5 + static styles = css` 6 + :host { 7 + display: inline-flex; 8 + } 9 + button { 10 + display: flex; 11 + align-items: center; 12 + justify-content: center; 13 + background: none; 14 + border: none; 15 + padding: var(--space-xs); 16 + cursor: pointer; 17 + color: var(--color-text-secondary); 18 + touch-action: manipulation; 19 + } 20 + button:hover { 21 + color: var(--color-text-primary); 22 + } 23 + `; 24 + 25 + #handleClick() { 26 + this.dispatchEvent(new CustomEvent('close', { 27 + bubbles: true, 28 + composed: true 29 + })); 30 + } 31 + 32 + render() { 33 + return html` 34 + <button type="button" @click=${this.#handleClick} aria-label="Close"> 35 + <grain-icon name="close" size="20"></grain-icon> 36 + </button> 37 + `; 38 + } 39 + } 40 + 41 + customElements.define('grain-close-button', GrainCloseButton);
+2
src/components/atoms/grain-textarea.js
··· 41 41 } 42 42 43 43 #handleInput(e) { 44 + // Stop native input event from bubbling out of shadow DOM 45 + e.stopPropagation(); 44 46 this.value = this.maxlength 45 47 ? e.target.value.slice(0, this.maxlength) 46 48 : e.target.value;
+3 -16
src/components/molecules/grain-login-dialog.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 - import '../atoms/grain-icon.js'; 2 + import '../atoms/grain-close-button.js'; 3 3 4 4 export class GrainLoginDialog extends LitElement { 5 5 static properties = { ··· 32 32 width: 90%; 33 33 max-width: 320px; 34 34 } 35 - .close-button { 35 + grain-close-button { 36 36 position: absolute; 37 37 top: var(--space-sm); 38 38 right: var(--space-sm); 39 - background: none; 40 - border: none; 41 - padding: var(--space-xs); 42 - cursor: pointer; 43 - color: var(--color-text-secondary); 44 - display: flex; 45 - align-items: center; 46 - justify-content: center; 47 - } 48 - .close-button:hover { 49 - color: var(--color-text-primary); 50 39 } 51 40 h2 { 52 41 margin: 0 0 var(--space-md); ··· 150 139 return html` 151 140 <div class="overlay" @click=${this.#handleOverlayClick}> 152 141 <form class="dialog" @submit=${this.#handleSubmit}> 153 - <button type="button" class="close-button" @click=${() => this.close()}> 154 - <grain-icon name="close" size="20"></grain-icon> 155 - </button> 142 + <grain-close-button @close=${() => this.close()}></grain-close-button> 156 143 <h2>Login with AT Protocol</h2> 157 144 <qs-actor-autocomplete 158 145 name="handle"
+30 -1
src/components/organisms/grain-action-dialog.js
··· 28 28 } 29 29 .dialog { 30 30 background: var(--color-bg-primary); 31 + border: 1px solid var(--color-border); 31 32 border-radius: 12px; 32 33 min-width: 280px; 33 34 max-width: 320px; ··· 53 54 background: var(--color-bg-secondary); 54 55 } 55 56 .action-button.danger { 56 - color: #ff4444; 57 + color: var(--color-error); 57 58 } 58 59 .action-button.cancel { 59 60 color: var(--color-text-secondary); ··· 69 70 } 70 71 `; 71 72 73 + #boundHandleKeydown = null; 74 + 72 75 constructor() { 73 76 super(); 74 77 this.open = false; 75 78 this.actions = []; 76 79 this.loading = false; 77 80 this.loadingText = 'Loading...'; 81 + this.#boundHandleKeydown = this.#handleKeydown.bind(this); 82 + } 83 + 84 + connectedCallback() { 85 + super.connectedCallback(); 86 + document.addEventListener('keydown', this.#boundHandleKeydown); 87 + } 88 + 89 + disconnectedCallback() { 90 + document.removeEventListener('keydown', this.#boundHandleKeydown); 91 + super.disconnectedCallback(); 92 + } 93 + 94 + #handleKeydown(e) { 95 + if (e.key === 'Escape' && this.open && !this.loading) { 96 + this.#close(); 97 + } 98 + } 99 + 100 + updated(changedProperties) { 101 + if (changedProperties.has('open') && this.open) { 102 + // Focus first action button when dialog opens 103 + requestAnimationFrame(() => { 104 + this.shadowRoot.querySelector('.action-button')?.focus(); 105 + }); 106 + } 78 107 } 79 108 80 109 #handleOverlayClick(e) {
+6
src/components/organisms/grain-bottom-nav.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 2 import { router } from '../../router.js'; 3 3 import { auth } from '../../services/auth.js'; 4 + import { trigger as haptic } from '../../utils/haptics.js'; 4 5 import { draftGallery } from '../../services/draft-gallery.js'; 5 6 import { processPhotos } from '../../utils/image-resize.js'; 6 7 import '../atoms/grain-icon.js'; ··· 100 101 } 101 102 102 103 #handleHome() { 104 + haptic(); 103 105 router.push('/'); 104 106 } 105 107 106 108 #handleProfile() { 109 + haptic(); 107 110 if (this._user?.handle) { 108 111 router.push(`/profile/${this._user.handle}`); 109 112 } 110 113 } 111 114 112 115 #handleExplore() { 116 + haptic(); 113 117 router.push('/explore'); 114 118 } 115 119 116 120 #handleNotifications() { 121 + haptic(); 117 122 router.push('/notifications'); 118 123 } 119 124 120 125 #handleCreate() { 126 + haptic(); 121 127 this.shadowRoot.getElementById('photo-input').click(); 122 128 } 123 129
+3 -12
src/components/organisms/grain-comment-sheet.js
··· 6 6 import '../molecules/grain-comment.js'; 7 7 import '../molecules/grain-comment-input.js'; 8 8 import '../atoms/grain-spinner.js'; 9 - import '../atoms/grain-icon.js'; 9 + import '../atoms/grain-close-button.js'; 10 10 11 11 export class GrainCommentSheet extends LitElement { 12 12 static properties = { ··· 78 78 font-size: var(--font-size-md); 79 79 font-weight: var(--font-weight-semibold); 80 80 } 81 - .close-button { 81 + grain-close-button { 82 82 position: absolute; 83 83 right: var(--space-sm); 84 - background: none; 85 - border: none; 86 - padding: var(--space-sm); 87 - cursor: pointer; 88 - color: var(--color-text-primary); 89 - touch-action: manipulation; 90 - -webkit-tap-highlight-color: transparent; 91 84 } 92 85 .comments-list { 93 86 flex: 1; ··· 351 344 <div class="sheet"> 352 345 <div class="header"> 353 346 <h2>Comments</h2> 354 - <button class="close-button" @click=${this.#handleClose}> 355 - <grain-icon name="close" size="20"></grain-icon> 356 - </button> 347 + <grain-close-button @close=${this.#handleClose}></grain-close-button> 357 348 </div> 358 349 359 350 <div class="comments-list">
+42 -2
src/components/organisms/grain-image-carousel.js
··· 8 8 static properties = { 9 9 photos: { type: Array }, 10 10 rkey: { type: String }, 11 - _currentIndex: { state: true } 11 + _currentIndex: { state: true }, 12 + _activeAltIndex: { state: true } 12 13 }; 13 14 14 15 static styles = css` ··· 78 79 .nav-arrow-right { 79 80 right: 8px; 80 81 } 82 + .alt-overlay { 83 + position: absolute; 84 + inset: 0; 85 + background: rgba(0, 0, 0, 0.75); 86 + color: white; 87 + padding: 16px; 88 + font-size: 14px; 89 + line-height: 1.5; 90 + overflow-y: auto; 91 + display: flex; 92 + align-items: center; 93 + justify-content: center; 94 + text-align: center; 95 + box-sizing: border-box; 96 + z-index: 3; 97 + cursor: pointer; 98 + } 81 99 `; 82 100 83 101 constructor() { 84 102 super(); 85 103 this.photos = []; 86 104 this._currentIndex = 0; 105 + this._activeAltIndex = null; 87 106 } 88 107 89 108 get #hasPortrait() { ··· 100 119 const index = Math.round(carousel.scrollLeft / carousel.offsetWidth); 101 120 if (index !== this._currentIndex) { 102 121 this._currentIndex = index; 122 + this._activeAltIndex = null; 103 123 } 104 124 } 105 125 126 + #handleAltClick(e, index) { 127 + e.stopPropagation(); 128 + this._activeAltIndex = index; 129 + } 130 + 131 + #handleOverlayClick(e) { 132 + e.stopPropagation(); 133 + this._activeAltIndex = null; 134 + } 135 + 106 136 #goToPrevious(e) { 107 137 e.stopPropagation(); 108 138 if (this._currentIndex > 0) { ··· 158 188 aspectRatio=${photo.aspectRatio || 1} 159 189 style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 160 190 ></grain-image> 161 - ${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''} 191 + ${photo.alt ? html` 192 + <grain-alt-badge 193 + .alt=${photo.alt} 194 + @alt-click=${(e) => this.#handleAltClick(e, index)} 195 + ></grain-alt-badge> 196 + ` : ''} 197 + ${this._activeAltIndex === index ? html` 198 + <div class="alt-overlay" @click=${this.#handleOverlayClick}> 199 + ${photo.alt} 200 + </div> 201 + ` : ''} 162 202 </div> 163 203 `)} 164 204 </div>
+356
src/components/organisms/grain-report-dialog.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { mutations } from '../../services/mutations.js'; 3 + import '../atoms/grain-button.js'; 4 + import '../atoms/grain-spinner.js'; 5 + import '../atoms/grain-close-button.js'; 6 + 7 + const REPORT_REASONS = [ 8 + { type: 'SPAM', label: 'Spam', description: 'Unwanted commercial content or repetitive posts' }, 9 + { type: 'MISLEADING', label: 'Misleading', description: 'False or deceptive information' }, 10 + { type: 'SEXUAL', label: 'Sexual content', description: 'Adult or inappropriate imagery' }, 11 + { type: 'RUDE', label: 'Rude or offensive', description: 'Harassment, hate speech, or bullying' }, 12 + { type: 'VIOLATION', label: 'Rule violation', description: 'Breaking community guidelines' }, 13 + { type: 'OTHER', label: 'Other', description: 'Something else not listed above' } 14 + ]; 15 + 16 + export class GrainReportDialog extends LitElement { 17 + static properties = { 18 + open: { type: Boolean, reflect: true }, 19 + galleryUri: { type: String }, 20 + _selectedReason: { state: true }, 21 + _details: { state: true }, 22 + _submitting: { state: true }, 23 + _error: { state: true } 24 + }; 25 + 26 + static styles = css` 27 + :host { 28 + display: none; 29 + } 30 + :host([open]) { 31 + display: block; 32 + } 33 + .overlay { 34 + position: fixed; 35 + inset: 0; 36 + background: rgba(0, 0, 0, 0.5); 37 + display: flex; 38 + align-items: center; 39 + justify-content: center; 40 + z-index: 1000; 41 + padding: var(--space-md); 42 + } 43 + .dialog { 44 + background: var(--color-bg-primary); 45 + border: 1px solid var(--color-border); 46 + border-radius: 12px; 47 + width: 100%; 48 + max-width: 400px; 49 + max-height: 90vh; 50 + display: flex; 51 + flex-direction: column; 52 + } 53 + .header { 54 + display: flex; 55 + align-items: center; 56 + justify-content: space-between; 57 + padding: var(--space-md); 58 + border-bottom: 1px solid var(--color-border); 59 + font-weight: var(--font-weight-semibold); 60 + font-size: var(--font-size-md); 61 + } 62 + .content { 63 + flex: 1; 64 + overflow-y: auto; 65 + padding: var(--space-md); 66 + } 67 + .reason-card { 68 + display: flex; 69 + align-items: flex-start; 70 + gap: var(--space-sm); 71 + width: 100%; 72 + padding: var(--space-sm) var(--space-md); 73 + margin-bottom: var(--space-sm); 74 + background: var(--color-bg-secondary); 75 + border: 2px solid var(--color-border); 76 + border-radius: var(--border-radius); 77 + cursor: pointer; 78 + text-align: left; 79 + font-family: inherit; 80 + } 81 + .reason-card:hover { 82 + border-color: var(--color-text-secondary); 83 + } 84 + .reason-card.selected { 85 + border-color: var(--color-accent); 86 + background: var(--color-bg-primary); 87 + } 88 + .radio { 89 + flex-shrink: 0; 90 + width: 18px; 91 + height: 18px; 92 + margin-top: 2px; 93 + border: 2px solid var(--color-border); 94 + border-radius: 50%; 95 + display: flex; 96 + align-items: center; 97 + justify-content: center; 98 + } 99 + .reason-card.selected .radio { 100 + border-color: var(--color-accent); 101 + } 102 + .radio-dot { 103 + width: 10px; 104 + height: 10px; 105 + border-radius: 50%; 106 + background: var(--color-accent); 107 + display: none; 108 + } 109 + .reason-card.selected .radio-dot { 110 + display: block; 111 + } 112 + .reason-content { 113 + flex: 1; 114 + } 115 + .reason-label { 116 + font-size: var(--font-size-sm); 117 + font-weight: var(--font-weight-medium); 118 + color: var(--color-text-primary); 119 + } 120 + .reason-description { 121 + font-size: var(--font-size-xs); 122 + color: var(--color-text-secondary); 123 + margin-top: var(--space-xs); 124 + } 125 + .details-section { 126 + margin-top: var(--space-md); 127 + } 128 + .details-label { 129 + font-size: var(--font-size-sm); 130 + color: var(--color-text-secondary); 131 + margin-bottom: var(--space-sm); 132 + } 133 + .details-textarea { 134 + width: 100%; 135 + min-height: 80px; 136 + padding: var(--space-sm) var(--space-md); 137 + border: 1px solid var(--color-border); 138 + border-radius: var(--border-radius); 139 + font-family: inherit; 140 + font-size: var(--font-size-sm); 141 + resize: vertical; 142 + background: var(--color-bg-secondary); 143 + color: var(--color-text-primary); 144 + box-sizing: border-box; 145 + } 146 + .details-textarea:focus { 147 + outline: none; 148 + border-color: var(--color-accent); 149 + } 150 + .char-count { 151 + font-size: var(--font-size-xs); 152 + color: var(--color-text-secondary); 153 + text-align: right; 154 + margin-top: var(--space-xs); 155 + } 156 + .error { 157 + color: var(--color-error); 158 + font-size: var(--font-size-sm); 159 + margin-top: var(--space-sm); 160 + padding: var(--space-sm) var(--space-md); 161 + background: rgba(255, 68, 68, 0.1); 162 + border-radius: var(--border-radius); 163 + } 164 + .footer { 165 + display: flex; 166 + gap: var(--space-sm); 167 + padding: var(--space-md); 168 + border-top: 1px solid var(--color-border); 169 + } 170 + .footer button { 171 + flex: 1; 172 + padding: var(--space-sm) var(--space-md); 173 + border-radius: var(--border-radius); 174 + font-family: inherit; 175 + font-size: var(--font-size-sm); 176 + font-weight: var(--font-weight-medium); 177 + cursor: pointer; 178 + } 179 + .cancel-button { 180 + background: var(--color-bg-secondary); 181 + border: 1px solid var(--color-border); 182 + color: var(--color-text-primary); 183 + } 184 + .submit-button { 185 + background: var(--color-accent); 186 + border: none; 187 + color: white; 188 + display: flex; 189 + align-items: center; 190 + justify-content: center; 191 + gap: 8px; 192 + } 193 + .submit-button:disabled { 194 + opacity: 0.5; 195 + cursor: not-allowed; 196 + } 197 + `; 198 + 199 + #boundHandleKeydown = null; 200 + 201 + constructor() { 202 + super(); 203 + this.open = false; 204 + this.galleryUri = ''; 205 + this._selectedReason = null; 206 + this._details = ''; 207 + this._submitting = false; 208 + this._error = null; 209 + this.#boundHandleKeydown = this.#handleKeydown.bind(this); 210 + } 211 + 212 + connectedCallback() { 213 + super.connectedCallback(); 214 + document.addEventListener('keydown', this.#boundHandleKeydown); 215 + } 216 + 217 + disconnectedCallback() { 218 + document.removeEventListener('keydown', this.#boundHandleKeydown); 219 + super.disconnectedCallback(); 220 + } 221 + 222 + #handleKeydown(e) { 223 + if (e.key === 'Escape' && this.open && !this._submitting) { 224 + this.#close(); 225 + } 226 + } 227 + 228 + updated(changedProperties) { 229 + if (changedProperties.has('open') && this.open) { 230 + this.#reset(); 231 + // Focus first reason card when dialog opens 232 + requestAnimationFrame(() => { 233 + this.shadowRoot.querySelector('.reason-card')?.focus(); 234 + }); 235 + } 236 + } 237 + 238 + #reset() { 239 + this._selectedReason = null; 240 + this._details = ''; 241 + this._submitting = false; 242 + this._error = null; 243 + } 244 + 245 + #handleOverlayClick(e) { 246 + if (e.target.classList.contains('overlay') && !this._submitting) { 247 + this.#close(); 248 + } 249 + } 250 + 251 + #close() { 252 + this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true })); 253 + } 254 + 255 + #selectReason(type) { 256 + this._selectedReason = type; 257 + this._error = null; 258 + } 259 + 260 + #handleDetailsInput(e) { 261 + const value = e.target.value; 262 + if (value.length <= 300) { 263 + this._details = value; 264 + } 265 + } 266 + 267 + async #submit() { 268 + if (!this._selectedReason || this._submitting) return; 269 + 270 + this._submitting = true; 271 + this._error = null; 272 + 273 + try { 274 + await mutations.createReport( 275 + this.galleryUri, 276 + this._selectedReason, 277 + this._details || null 278 + ); 279 + 280 + this.dispatchEvent(new CustomEvent('submitted', { bubbles: true, composed: true })); 281 + this.#close(); 282 + } catch (err) { 283 + console.error('Failed to submit report:', err); 284 + this._error = 'Failed to submit report. Please try again.'; 285 + } finally { 286 + this._submitting = false; 287 + } 288 + } 289 + 290 + render() { 291 + return html` 292 + <div class="overlay" @click=${this.#handleOverlayClick}> 293 + <div class="dialog"> 294 + <div class="header"> 295 + <span>Report gallery</span> 296 + <grain-close-button @close=${this.#close}></grain-close-button> 297 + </div> 298 + 299 + <div class="content"> 300 + ${REPORT_REASONS.map(reason => html` 301 + <button 302 + class="reason-card ${this._selectedReason === reason.type ? 'selected' : ''}" 303 + @click=${() => this.#selectReason(reason.type)} 304 + ?disabled=${this._submitting} 305 + > 306 + <div class="radio"> 307 + <div class="radio-dot"></div> 308 + </div> 309 + <div class="reason-content"> 310 + <div class="reason-label">${reason.label}</div> 311 + <div class="reason-description">${reason.description}</div> 312 + </div> 313 + </button> 314 + `)} 315 + 316 + <div class="details-section"> 317 + <div class="details-label">Add details (optional)</div> 318 + <textarea 319 + class="details-textarea" 320 + placeholder="Provide additional context..." 321 + .value=${this._details} 322 + @input=${this.#handleDetailsInput} 323 + ?disabled=${this._submitting} 324 + ></textarea> 325 + <div class="char-count">${this._details.length}/300</div> 326 + </div> 327 + 328 + ${this._error ? html` 329 + <div class="error">${this._error}</div> 330 + ` : ''} 331 + </div> 332 + 333 + <div class="footer"> 334 + <button 335 + class="cancel-button" 336 + @click=${this.#close} 337 + ?disabled=${this._submitting} 338 + > 339 + Cancel 340 + </button> 341 + <button 342 + class="submit-button" 343 + @click=${this.#submit} 344 + ?disabled=${!this._selectedReason || this._submitting} 345 + > 346 + ${this._submitting ? html`<grain-spinner size="16"></grain-spinner>` : ''} 347 + Submit 348 + </button> 349 + </div> 350 + </div> 351 + </div> 352 + `; 353 + } 354 + } 355 + 356 + customElements.define('grain-report-dialog', GrainReportDialog);
+82
src/components/pages/grain-app.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 2 import { router } from '../../router.js'; 3 + import '../organisms/grain-action-dialog.js'; 4 + import '../organisms/grain-report-dialog.js'; 5 + import '../atoms/grain-toast.js'; 3 6 4 7 // Import pages 5 8 import './grain-timeline.js'; ··· 16 19 import './grain-terms.js'; 17 20 import './grain-privacy.js'; 18 21 import './grain-copyright.js'; 22 + import './grain-oauth-callback.js'; 23 + import './grain-onboarding.js'; 19 24 import '../organisms/grain-header.js'; 20 25 import '../organisms/grain-bottom-nav.js'; 21 26 22 27 export class GrainApp extends LitElement { 28 + static properties = { 29 + _dialogType: { state: true }, 30 + _dialogProps: { state: true } 31 + }; 32 + 23 33 static styles = css` 24 34 :host { 25 35 display: block; ··· 47 57 } 48 58 `; 49 59 60 + constructor() { 61 + super(); 62 + this._dialogType = null; 63 + this._dialogProps = {}; 64 + } 65 + 66 + connectedCallback() { 67 + super.connectedCallback(); 68 + this.addEventListener('open-dialog', this.#handleOpenDialog); 69 + this.addEventListener('close-dialog', this.#closeDialog); 70 + } 71 + 72 + disconnectedCallback() { 73 + this.removeEventListener('open-dialog', this.#handleOpenDialog); 74 + this.removeEventListener('close-dialog', this.#closeDialog); 75 + super.disconnectedCallback(); 76 + } 77 + 78 + #handleOpenDialog = (e) => { 79 + this._dialogType = e.detail.type; 80 + this._dialogProps = e.detail.props || {}; 81 + }; 82 + 83 + #closeDialog = () => { 84 + this._dialogType = null; 85 + this._dialogProps = {}; 86 + }; 87 + 88 + #handleReportSubmitted = () => { 89 + this.#closeDialog(); 90 + this.shadowRoot.querySelector('grain-toast')?.show('Report submitted'); 91 + }; 92 + 93 + #handleDialogAction = (e) => { 94 + this.dispatchEvent(new CustomEvent('dialog-action', { 95 + bubbles: true, 96 + composed: true, 97 + detail: e.detail 98 + })); 99 + }; 100 + 101 + #renderDialog() { 102 + switch (this._dialogType) { 103 + case 'report': 104 + return html` 105 + <grain-report-dialog 106 + open 107 + galleryUri=${this._dialogProps.galleryUri || ''} 108 + @close=${this.#closeDialog} 109 + @submitted=${this.#handleReportSubmitted} 110 + ></grain-report-dialog> 111 + `; 112 + case 'action': 113 + return html` 114 + <grain-action-dialog 115 + open 116 + .actions=${this._dialogProps.actions || []} 117 + ?loading=${this._dialogProps.loading} 118 + loadingText=${this._dialogProps.loadingText || ''} 119 + @close=${this.#closeDialog} 120 + @action=${this.#handleDialogAction} 121 + ></grain-action-dialog> 122 + `; 123 + default: 124 + return ''; 125 + } 126 + } 127 + 50 128 firstUpdated() { 51 129 const outlet = this.shadowRoot.getElementById('outlet'); 52 130 ··· 65 143 .register('/create/descriptions', 'grain-image-descriptions') 66 144 .register('/explore', 'grain-explore') 67 145 .register('/notifications', 'grain-notifications') 146 + .register('/onboarding', 'grain-onboarding') 147 + .register('/oauth/callback', 'grain-oauth-callback') 68 148 .register('*', 'grain-timeline') 69 149 .connect(outlet); 70 150 } ··· 74 154 <grain-header></grain-header> 75 155 <div id="outlet"></div> 76 156 <grain-bottom-nav></grain-bottom-nav> 157 + ${this.#renderDialog()} 158 + <grain-toast></grain-toast> 77 159 `; 78 160 } 79 161 }
+2 -1
src/components/pages/grain-image-descriptions.js
··· 157 157 #handleAltChange(index, e) { 158 158 const alt = e.detail.value; 159 159 draftGallery.updatePhotoAlt(index, alt); 160 - this._photos = [...draftGallery.getPhotos()]; 161 160 } 162 161 163 162 async #handlePost() { 164 163 if (this._posting) return; 165 164 165 + // Refresh photos from draftGallery to get latest alt text values 166 + this._photos = draftGallery.getPhotos(); 166 167 this._posting = true; 167 168 this._error = null; 168 169
+38
src/components/pages/grain-oauth-callback.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { auth } from '../../services/auth.js'; 3 + import '../atoms/grain-spinner.js'; 4 + 5 + export class GrainOAuthCallback extends LitElement { 6 + static styles = css` 7 + :host { 8 + display: flex; 9 + flex-direction: column; 10 + align-items: center; 11 + justify-content: center; 12 + min-height: 100%; 13 + gap: var(--space-md); 14 + } 15 + p { 16 + color: var(--color-text-secondary); 17 + font-size: var(--font-size-sm); 18 + } 19 + `; 20 + 21 + async connectedCallback() { 22 + super.connectedCallback(); 23 + const params = new URLSearchParams(window.location.search); 24 + if (params.get('start') === '1') { 25 + window.history.replaceState({}, '', '/oauth/callback'); 26 + await auth.startOAuthFromCallback(); 27 + } 28 + } 29 + 30 + render() { 31 + return html` 32 + <grain-spinner size="32"></grain-spinner> 33 + <p>Signing in...</p> 34 + `; 35 + } 36 + } 37 + 38 + customElements.define('grain-oauth-callback', GrainOAuthCallback);
+391
src/components/pages/grain-onboarding.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { router } from '../../router.js'; 3 + import { auth } from '../../services/auth.js'; 4 + import { grainApi } from '../../services/grain-api.js'; 5 + import { mutations } from '../../services/mutations.js'; 6 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 7 + import '../atoms/grain-icon.js'; 8 + import '../atoms/grain-button.js'; 9 + import '../atoms/grain-input.js'; 10 + import '../atoms/grain-textarea.js'; 11 + import '../atoms/grain-avatar.js'; 12 + import '../atoms/grain-spinner.js'; 13 + import '../molecules/grain-form-field.js'; 14 + import '../molecules/grain-avatar-crop.js'; 15 + 16 + export class GrainOnboarding extends LitElement { 17 + static properties = { 18 + _loading: { state: true }, 19 + _saving: { state: true }, 20 + _error: { state: true }, 21 + _displayName: { state: true }, 22 + _description: { state: true }, 23 + _avatarUrl: { state: true }, 24 + _avatarBlob: { state: true }, 25 + _newAvatarDataUrl: { state: true }, 26 + _showAvatarCrop: { state: true }, 27 + _cropImageUrl: { state: true } 28 + }; 29 + 30 + static styles = css` 31 + :host { 32 + display: block; 33 + width: 100%; 34 + max-width: var(--feed-max-width); 35 + min-height: 100%; 36 + padding-bottom: 80px; 37 + background: var(--color-bg-primary); 38 + align-self: center; 39 + } 40 + .header { 41 + display: flex; 42 + flex-direction: column; 43 + align-items: center; 44 + gap: var(--space-xs); 45 + padding: var(--space-xl) var(--space-sm) var(--space-lg); 46 + text-align: center; 47 + } 48 + h1 { 49 + font-size: var(--font-size-xl); 50 + font-weight: var(--font-weight-semibold); 51 + color: var(--color-text-primary); 52 + margin: 0; 53 + } 54 + .subtitle { 55 + font-size: var(--font-size-sm); 56 + color: var(--color-text-secondary); 57 + margin: 0; 58 + } 59 + .content { 60 + padding: 0 var(--space-sm); 61 + } 62 + @media (min-width: 600px) { 63 + .content { 64 + padding: 0; 65 + } 66 + } 67 + .avatar-section { 68 + display: flex; 69 + flex-direction: column; 70 + align-items: center; 71 + margin-bottom: var(--space-lg); 72 + } 73 + .avatar-wrapper { 74 + position: relative; 75 + cursor: pointer; 76 + } 77 + .avatar-overlay { 78 + position: absolute; 79 + bottom: 0; 80 + right: 0; 81 + width: 28px; 82 + height: 28px; 83 + border-radius: 50%; 84 + background: var(--color-bg-primary); 85 + border: 2px solid var(--color-border); 86 + display: flex; 87 + align-items: center; 88 + justify-content: center; 89 + color: var(--color-text-primary); 90 + } 91 + .avatar-preview { 92 + width: 80px; 93 + height: 80px; 94 + border-radius: 50%; 95 + object-fit: cover; 96 + background: var(--color-bg-elevated); 97 + } 98 + input[type="file"] { 99 + display: none; 100 + } 101 + .actions { 102 + display: flex; 103 + flex-direction: column; 104 + gap: var(--space-sm); 105 + padding: var(--space-lg) var(--space-sm); 106 + border-top: 1px solid var(--color-border); 107 + margin-top: var(--space-lg); 108 + } 109 + @media (min-width: 600px) { 110 + .actions { 111 + padding-left: 0; 112 + padding-right: 0; 113 + } 114 + } 115 + .skip-button { 116 + background: none; 117 + border: none; 118 + color: var(--color-text-secondary); 119 + font-size: var(--font-size-sm); 120 + cursor: pointer; 121 + padding: var(--space-sm); 122 + text-align: center; 123 + } 124 + .skip-button:hover { 125 + color: var(--color-text-primary); 126 + text-decoration: underline; 127 + } 128 + .error { 129 + color: var(--color-danger, #dc3545); 130 + font-size: var(--font-size-sm); 131 + padding: var(--space-sm); 132 + text-align: center; 133 + } 134 + .loading { 135 + display: flex; 136 + flex-direction: column; 137 + align-items: center; 138 + justify-content: center; 139 + gap: var(--space-md); 140 + padding: var(--space-xl); 141 + min-height: 300px; 142 + } 143 + .loading p { 144 + color: var(--color-text-secondary); 145 + font-size: var(--font-size-sm); 146 + } 147 + `; 148 + 149 + constructor() { 150 + super(); 151 + this._loading = true; 152 + this._saving = false; 153 + this._error = null; 154 + this._displayName = ''; 155 + this._description = ''; 156 + this._avatarUrl = ''; 157 + this._avatarBlob = null; 158 + this._newAvatarDataUrl = null; 159 + this._showAvatarCrop = false; 160 + this._cropImageUrl = null; 161 + } 162 + 163 + async connectedCallback() { 164 + super.connectedCallback(); 165 + 166 + if (!auth.isAuthenticated) { 167 + router.replace('/'); 168 + return; 169 + } 170 + 171 + await this.#checkAndLoad(); 172 + } 173 + 174 + async #checkAndLoad() { 175 + try { 176 + const client = auth.getClient(); 177 + 178 + // Check if user already has a Grain profile 179 + const hasProfile = await grainApi.hasGrainProfile(client); 180 + if (hasProfile) { 181 + this.#redirectToDestination(); 182 + return; 183 + } 184 + 185 + // Fetch Bluesky profile to prefill 186 + const bskyProfile = await grainApi.getBlueskyProfile(client); 187 + this._displayName = bskyProfile.displayName; 188 + this._description = bskyProfile.description; 189 + this._avatarUrl = bskyProfile.avatarUrl; 190 + this._avatarBlob = bskyProfile.avatarBlob; 191 + } catch (err) { 192 + console.error('Failed to load profile data:', err); 193 + } finally { 194 + this._loading = false; 195 + } 196 + } 197 + 198 + #redirectToDestination() { 199 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 200 + sessionStorage.removeItem('oauth_return_url'); 201 + router.replace(returnUrl); 202 + } 203 + 204 + #handleDisplayNameChange(e) { 205 + this._displayName = e.detail.value.slice(0, 64); 206 + } 207 + 208 + #handleDescriptionChange(e) { 209 + this._description = e.detail.value.slice(0, 256); 210 + } 211 + 212 + #handleAvatarClick() { 213 + this.shadowRoot.querySelector('#avatar-input').click(); 214 + } 215 + 216 + async #handleAvatarChange(e) { 217 + const input = e.target; 218 + const file = input.files?.[0]; 219 + if (!file) return; 220 + 221 + input.value = ''; 222 + 223 + try { 224 + const dataUrl = await readFileAsDataURL(file); 225 + const resized = await resizeImage(dataUrl, { 226 + width: 2000, 227 + height: 2000, 228 + maxSize: 900000 229 + }); 230 + this._cropImageUrl = resized.dataUrl; 231 + this._showAvatarCrop = true; 232 + } catch (err) { 233 + console.error('Failed to process avatar:', err); 234 + this._error = 'Failed to process image'; 235 + } 236 + } 237 + 238 + #handleCropCancel() { 239 + this._showAvatarCrop = false; 240 + this._cropImageUrl = null; 241 + } 242 + 243 + #handleCrop(e) { 244 + this._showAvatarCrop = false; 245 + this._cropImageUrl = null; 246 + this._newAvatarDataUrl = e.detail.dataUrl; 247 + this._avatarBlob = null; 248 + } 249 + 250 + get #displayedAvatarUrl() { 251 + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; 252 + return this._avatarUrl; 253 + } 254 + 255 + async #handleSave() { 256 + if (this._saving) return; 257 + 258 + this._saving = true; 259 + this._error = null; 260 + 261 + try { 262 + const input = { 263 + createdAt: new Date().toISOString() 264 + }; 265 + 266 + const displayName = this._displayName.trim(); 267 + const description = this._description.trim(); 268 + if (displayName) input.displayName = displayName; 269 + if (description) input.description = description; 270 + 271 + if (this._newAvatarDataUrl) { 272 + const base64Data = this._newAvatarDataUrl.split(',')[1]; 273 + const blob = await mutations.uploadBlob(base64Data, 'image/jpeg'); 274 + input.avatar = { 275 + $type: 'blob', 276 + ref: { $link: blob.ref }, 277 + mimeType: blob.mimeType, 278 + size: blob.size 279 + }; 280 + } else if (this._avatarBlob) { 281 + input.avatar = this._avatarBlob; 282 + } 283 + 284 + await mutations.updateProfile(input); 285 + this.#redirectToDestination(); 286 + } catch (err) { 287 + console.error('Failed to save profile:', err); 288 + this._error = err.message || 'Failed to save profile. Please try again.'; 289 + } finally { 290 + this._saving = false; 291 + } 292 + } 293 + 294 + async #handleSkip() { 295 + if (this._saving) return; 296 + 297 + this._saving = true; 298 + this._error = null; 299 + 300 + try { 301 + await mutations.createEmptyProfile(); 302 + this.#redirectToDestination(); 303 + } catch (err) { 304 + console.error('Failed to skip onboarding:', err); 305 + this._error = err.message || 'Something went wrong. Please try again.'; 306 + } finally { 307 + this._saving = false; 308 + } 309 + } 310 + 311 + render() { 312 + if (this._loading) { 313 + return html` 314 + <div class="loading"> 315 + <grain-spinner size="32"></grain-spinner> 316 + <p>Loading...</p> 317 + </div> 318 + `; 319 + } 320 + 321 + return html` 322 + <div class="header"> 323 + <h1>Welcome to Grain</h1> 324 + <p class="subtitle">Set up your profile to get started</p> 325 + </div> 326 + 327 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 328 + 329 + <div class="content"> 330 + <div class="avatar-section"> 331 + <div class="avatar-wrapper" @click=${this.#handleAvatarClick}> 332 + ${this.#displayedAvatarUrl ? html` 333 + <img class="avatar-preview" src=${this.#displayedAvatarUrl} alt="Profile avatar"> 334 + ` : html` 335 + <grain-avatar size="lg"></grain-avatar> 336 + `} 337 + <div class="avatar-overlay"> 338 + <grain-icon name="camera" size="14"></grain-icon> 339 + </div> 340 + </div> 341 + <input 342 + type="file" 343 + id="avatar-input" 344 + accept="image/png,image/jpeg" 345 + @change=${this.#handleAvatarChange} 346 + > 347 + </div> 348 + 349 + <grain-form-field label="Display Name" .value=${this._displayName} .maxlength=${64}> 350 + <grain-input 351 + placeholder="Display name" 352 + .value=${this._displayName} 353 + @input=${this.#handleDisplayNameChange} 354 + ></grain-input> 355 + </grain-form-field> 356 + 357 + <grain-form-field label="Bio" .value=${this._description} .maxlength=${256}> 358 + <grain-textarea 359 + placeholder="Tell us about yourself" 360 + .value=${this._description} 361 + .maxlength=${256} 362 + @input=${this.#handleDescriptionChange} 363 + ></grain-textarea> 364 + </grain-form-field> 365 + </div> 366 + 367 + <div class="actions"> 368 + <grain-button 369 + variant="primary" 370 + ?loading=${this._saving} 371 + loadingText="Saving..." 372 + @click=${this.#handleSave} 373 + >Save & Continue</grain-button> 374 + <button 375 + class="skip-button" 376 + ?disabled=${this._saving} 377 + @click=${this.#handleSkip} 378 + >Skip for now</button> 379 + </div> 380 + 381 + <grain-avatar-crop 382 + ?open=${this._showAvatarCrop} 383 + image-url=${this._cropImageUrl || ''} 384 + @crop=${this.#handleCrop} 385 + @cancel=${this.#handleCropCancel} 386 + ></grain-avatar-crop> 387 + `; 388 + } 389 + } 390 + 391 + customElements.define('grain-onboarding', GrainOnboarding);
+77 -1
src/components/pages/grain-timeline.js
··· 22 22 _commentGalleryUri: { state: true }, 23 23 _focusPhotoUri: { state: true }, 24 24 _focusPhotoUrl: { state: true }, 25 - _showScrollTop: { state: true } 25 + _showScrollTop: { state: true }, 26 + _pendingGallery: { state: true } 26 27 }; 27 28 28 29 static styles = css` ··· 62 63 this._focusPhotoUri = null; 63 64 this._focusPhotoUrl = null; 64 65 this._showScrollTop = false; 66 + this._pendingGallery = null; 65 67 66 68 // Check cache synchronously to avoid flash 67 69 this.#initFromCache(); ··· 94 96 this.#boundHandleScroll = this.#handleScroll.bind(this); 95 97 this.#scrollContainer = this.#findScrollContainer(); 96 98 (this.#scrollContainer || window).addEventListener('scroll', this.#boundHandleScroll, { passive: true }); 99 + document.addEventListener('dialog-action', this.#handleDialogAction); 97 100 } 98 101 99 102 #findScrollContainer() { ··· 117 120 if (this.#boundHandleScroll) { 118 121 (this.#scrollContainer || window).removeEventListener('scroll', this.#boundHandleScroll); 119 122 } 123 + document.removeEventListener('dialog-action', this.#handleDialogAction); 120 124 } 121 125 122 126 firstUpdated() { ··· 219 223 this._focusPhotoUrl = null; 220 224 } 221 225 226 + #handleGalleryMenu(e) { 227 + const { gallery, isOwner } = e.detail; 228 + this._pendingGallery = gallery; 229 + 230 + this.dispatchEvent(new CustomEvent('open-dialog', { 231 + bubbles: true, 232 + composed: true, 233 + detail: { 234 + type: 'action', 235 + props: { 236 + actions: isOwner 237 + ? [{ label: 'Delete', action: 'delete', danger: true }] 238 + : [{ label: 'Report gallery', action: 'report' }] 239 + } 240 + } 241 + })); 242 + } 243 + 244 + #handleDialogAction = (e) => { 245 + if (e.detail.action === 'delete') { 246 + this.#handleDelete(); 247 + } else if (e.detail.action === 'report') { 248 + this.dispatchEvent(new CustomEvent('open-dialog', { 249 + bubbles: true, 250 + composed: true, 251 + detail: { 252 + type: 'report', 253 + props: { galleryUri: this._pendingGallery?.uri } 254 + } 255 + })); 256 + } 257 + }; 258 + 259 + async #handleDelete() { 260 + if (!this._pendingGallery) return; 261 + 262 + // Show loading state 263 + this.dispatchEvent(new CustomEvent('open-dialog', { 264 + bubbles: true, 265 + composed: true, 266 + detail: { 267 + type: 'action', 268 + props: { 269 + actions: [{ label: 'Delete', action: 'delete', danger: true }], 270 + loading: true, 271 + loadingText: 'Deleting...' 272 + } 273 + } 274 + })); 275 + 276 + try { 277 + const client = auth.getClient(); 278 + const rkey = this._pendingGallery.uri.split('/').pop(); 279 + 280 + await client.mutate(` 281 + mutation DeleteGallery($rkey: String!) { 282 + deleteSocialGrainGallery(rkey: $rkey) { uri } 283 + } 284 + `, { rkey }); 285 + 286 + this._galleries = this._galleries.filter(g => g.uri !== this._pendingGallery.uri); 287 + this._pendingGallery = null; 288 + 289 + // Close dialog by dispatching close (app listens) 290 + this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true })); 291 + } catch (err) { 292 + console.error('Failed to delete gallery:', err); 293 + this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true })); 294 + } 295 + } 296 + 222 297 #handleScroll() { 223 298 const scrollTop = this.#scrollContainer ? this.#scrollContainer.scrollTop : window.scrollY; 224 299 this._showScrollTop = scrollTop > 150; ··· 246 321 ?refreshing=${this._refreshing} 247 322 @refresh=${this.#handleRefresh} 248 323 @comment-click=${this.#handleCommentClick} 324 + @open-gallery-menu=${this.#handleGalleryMenu} 249 325 > 250 326 ${this._error ? html` 251 327 <p class="error">${this._error}</p>
+29 -1
src/services/auth.js
··· 1 1 import { createQuicksliceClient } from 'quickslice-client-js'; 2 + import { router } from '../router.js'; 3 + import { grainApi } from './grain-api.js'; 2 4 3 5 class AuthService { 4 6 #client = null; ··· 18 20 // Handle OAuth callback if present 19 21 if (window.location.search.includes('code=')) { 20 22 await this.#client.handleRedirectCallback(); 21 - window.history.replaceState({}, '', window.location.pathname); 23 + 24 + // Check if user has a Grain profile 25 + const hasProfile = await grainApi.hasGrainProfile(this.#client); 26 + 27 + if (!hasProfile) { 28 + // First-time user - redirect to onboarding 29 + window.location.replace('/onboarding'); 30 + return; 31 + } 32 + 33 + // Existing user - redirect to their destination 34 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 35 + sessionStorage.removeItem('oauth_return_url'); 36 + window.location.replace(returnUrl); 37 + return; 22 38 } 23 39 24 40 // Load user if authenticated ··· 56 72 } 57 73 58 74 async login(handle) { 75 + sessionStorage.setItem('oauth_return_url', window.location.pathname); 76 + sessionStorage.setItem('oauth_handle', handle); 77 + window.location.href = '/oauth/callback?start=1'; 78 + } 79 + 80 + async startOAuthFromCallback() { 81 + const handle = sessionStorage.getItem('oauth_handle'); 82 + sessionStorage.removeItem('oauth_handle'); 83 + if (!handle) { 84 + router.replace('/'); 85 + return; 86 + } 59 87 await this.#client.loginWithRedirect({ handle }); 60 88 } 61 89
+47
src/services/grain-api.js
··· 1121 1121 1122 1122 return did; 1123 1123 } 1124 + 1125 + async hasGrainProfile(client) { 1126 + const result = await client.query(` 1127 + query { 1128 + viewer { 1129 + socialGrainActorProfileByDid { 1130 + displayName 1131 + } 1132 + } 1133 + } 1134 + `); 1135 + return !!result.viewer?.socialGrainActorProfileByDid; 1136 + } 1137 + 1138 + async getBlueskyProfile(client) { 1139 + const result = await client.query(` 1140 + query { 1141 + viewer { 1142 + did 1143 + handle 1144 + appBskyActorProfileByDid { 1145 + displayName 1146 + description 1147 + avatar { url ref mimeType size } 1148 + } 1149 + } 1150 + } 1151 + `); 1152 + 1153 + const viewer = result.viewer; 1154 + const profile = viewer?.appBskyActorProfileByDid; 1155 + const avatar = profile?.avatar; 1156 + 1157 + return { 1158 + did: viewer?.did || '', 1159 + handle: viewer?.handle || '', 1160 + displayName: profile?.displayName || '', 1161 + description: profile?.description || '', 1162 + avatarUrl: avatar?.url || '', 1163 + avatarBlob: avatar ? { 1164 + $type: 'blob', 1165 + ref: { $link: avatar.ref }, 1166 + mimeType: avatar.mimeType, 1167 + size: avatar.size 1168 + } : null 1169 + }; 1170 + } 1124 1171 } 1125 1172 1126 1173 export const grainApi = new GrainApiService();
+35
src/services/mutations.js
··· 198 198 // Refresh user data 199 199 await auth.refreshUser(); 200 200 } 201 + 202 + async updateProfile(input) { 203 + const client = auth.getClient(); 204 + 205 + await client.mutate(` 206 + mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) { 207 + updateSocialGrainActorProfile(rkey: $rkey, input: $input) { 208 + uri 209 + } 210 + } 211 + `, { rkey: 'self', input }); 212 + 213 + await auth.refreshUser(); 214 + } 215 + 216 + async createEmptyProfile() { 217 + return this.updateProfile({ 218 + createdAt: new Date().toISOString() 219 + }); 220 + } 221 + 222 + async createReport(subjectUri, reasonType, reason = null) { 223 + const client = auth.getClient(); 224 + const result = await client.mutate(` 225 + mutation CreateReport($subjectUri: String!, $reasonType: ReportReasonType!, $reason: String) { 226 + createReport(subjectUri: $subjectUri, reasonType: $reasonType, reason: $reason) { 227 + id 228 + status 229 + createdAt 230 + } 231 + } 232 + `, { subjectUri, reasonType, reason }); 233 + 234 + return result.createReport; 235 + } 201 236 } 202 237 203 238 export const mutations = new MutationsService();
+49
src/utils/haptics.js
··· 1 + /** 2 + * Haptic feedback utility for PWA 3 + * - iOS 18+: Uses checkbox switch element hack 4 + * - Android: Uses Vibration API 5 + * - Other: Silently does nothing 6 + */ 7 + 8 + // Platform detection 9 + const isIOS = /iPhone|iPad/.test(navigator.userAgent); 10 + const hasVibrate = 'vibrate' in navigator; 11 + 12 + // Lazy-initialized hidden elements for iOS 13 + let checkbox = null; 14 + let label = null; 15 + 16 + function ensureElements() { 17 + if (checkbox) return; 18 + 19 + checkbox = document.createElement('input'); 20 + checkbox.type = 'checkbox'; 21 + checkbox.setAttribute('switch', ''); 22 + checkbox.id = 'haptic-trigger'; 23 + checkbox.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;'; 24 + 25 + label = document.createElement('label'); 26 + label.htmlFor = 'haptic-trigger'; 27 + label.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;'; 28 + 29 + document.body.append(checkbox, label); 30 + } 31 + 32 + /** 33 + * Trigger a light haptic tap 34 + */ 35 + export function trigger() { 36 + if (isIOS) { 37 + ensureElements(); 38 + label.click(); 39 + } else if (hasVibrate) { 40 + navigator.vibrate(10); 41 + } 42 + } 43 + 44 + /** 45 + * Check if haptics are supported on this device 46 + */ 47 + export function isSupported() { 48 + return isIOS || hasVibrate; 49 + }