+661
docs/plans/2026-01-02-onboarding-flow.md
+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 |
+802
docs/plans/2026-01-03-gallery-reporting-design.md
+802
docs/plans/2026-01-03-gallery-reporting-design.md
···
1
+
# Gallery Reporting Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add the ability to report galleries via action dialogs on both gallery detail pages and timeline cards.
6
+
7
+
**Architecture:** New `grain-report-dialog` component handles the report flow with 6 reason options. Existing `grain-action-dialog` triggers it. The `mutations.js` service calls the QuickSlice `createReport` mutation.
8
+
9
+
**Tech Stack:** Lit 3, QuickSlice GraphQL API, Vite
10
+
11
+
---
12
+
13
+
## Task 1: Add createReport Mutation
14
+
15
+
**Files:**
16
+
- Modify: `src/services/mutations.js`
17
+
18
+
**Step 1: Add the createReport method**
19
+
20
+
Add this method to the `MutationsService` class in `src/services/mutations.js`:
21
+
22
+
```javascript
23
+
async createReport(subjectUri, reasonType, reason = null) {
24
+
const client = auth.getClient();
25
+
const result = await client.mutate(`
26
+
mutation CreateReport($subjectUri: String!, $reasonType: ReportReasonType!, $reason: String) {
27
+
createReport(subjectUri: $subjectUri, reasonType: $reasonType, reason: $reason) {
28
+
id
29
+
status
30
+
createdAt
31
+
}
32
+
}
33
+
`, { subjectUri, reasonType, reason });
34
+
35
+
return result.createReport;
36
+
}
37
+
```
38
+
39
+
**Step 2: Verify syntax**
40
+
41
+
Run: `npm run build`
42
+
Expected: Build succeeds with no errors
43
+
44
+
**Step 3: Commit**
45
+
46
+
```bash
47
+
git add src/services/mutations.js
48
+
git commit -m "feat: add createReport mutation for gallery reporting"
49
+
```
50
+
51
+
---
52
+
53
+
## Task 2: Create Report Dialog Component
54
+
55
+
**Files:**
56
+
- Create: `src/components/organisms/grain-report-dialog.js`
57
+
58
+
**Step 1: Create the component file**
59
+
60
+
Create `src/components/organisms/grain-report-dialog.js` with this content:
61
+
62
+
```javascript
63
+
import { LitElement, html, css } from 'lit';
64
+
import { mutations } from '../../services/mutations.js';
65
+
import '../atoms/grain-button.js';
66
+
import '../atoms/grain-spinner.js';
67
+
68
+
const REPORT_REASONS = [
69
+
{ type: 'SPAM', label: 'Spam', description: 'Unwanted commercial content or repetitive posts' },
70
+
{ type: 'MISLEADING', label: 'Misleading', description: 'False or deceptive information' },
71
+
{ type: 'SEXUAL', label: 'Sexual content', description: 'Adult or inappropriate imagery' },
72
+
{ type: 'RUDE', label: 'Rude or offensive', description: 'Harassment, hate speech, or bullying' },
73
+
{ type: 'VIOLATION', label: 'Rule violation', description: 'Breaking community guidelines' },
74
+
{ type: 'OTHER', label: 'Other', description: 'Something else not listed above' }
75
+
];
76
+
77
+
export class GrainReportDialog extends LitElement {
78
+
static properties = {
79
+
open: { type: Boolean, reflect: true },
80
+
galleryUri: { type: String },
81
+
_selectedReason: { state: true },
82
+
_details: { state: true },
83
+
_submitting: { state: true },
84
+
_error: { state: true }
85
+
};
86
+
87
+
static styles = css`
88
+
:host {
89
+
display: none;
90
+
}
91
+
:host([open]) {
92
+
display: block;
93
+
}
94
+
.overlay {
95
+
position: fixed;
96
+
inset: 0;
97
+
background: rgba(0, 0, 0, 0.5);
98
+
display: flex;
99
+
align-items: center;
100
+
justify-content: center;
101
+
z-index: 1000;
102
+
padding: var(--space-md);
103
+
}
104
+
.dialog {
105
+
background: var(--color-bg-primary);
106
+
border-radius: 12px;
107
+
width: 100%;
108
+
max-width: 400px;
109
+
max-height: 90vh;
110
+
overflow-y: auto;
111
+
}
112
+
.header {
113
+
padding: 16px;
114
+
border-bottom: 1px solid var(--color-border);
115
+
font-weight: var(--font-weight-semibold);
116
+
font-size: var(--font-size-md);
117
+
}
118
+
.content {
119
+
padding: 16px;
120
+
}
121
+
.reason-card {
122
+
display: block;
123
+
width: 100%;
124
+
padding: 12px;
125
+
margin-bottom: 8px;
126
+
background: var(--color-bg-secondary);
127
+
border: 2px solid var(--color-border);
128
+
border-radius: 8px;
129
+
cursor: pointer;
130
+
text-align: left;
131
+
font-family: inherit;
132
+
}
133
+
.reason-card:hover {
134
+
border-color: var(--color-text-secondary);
135
+
}
136
+
.reason-card.selected {
137
+
border-color: var(--color-accent);
138
+
background: var(--color-bg-primary);
139
+
}
140
+
.reason-label {
141
+
display: flex;
142
+
align-items: center;
143
+
gap: 8px;
144
+
font-size: var(--font-size-sm);
145
+
font-weight: var(--font-weight-medium);
146
+
color: var(--color-text-primary);
147
+
}
148
+
.radio {
149
+
width: 18px;
150
+
height: 18px;
151
+
border: 2px solid var(--color-border);
152
+
border-radius: 50%;
153
+
display: flex;
154
+
align-items: center;
155
+
justify-content: center;
156
+
}
157
+
.reason-card.selected .radio {
158
+
border-color: var(--color-accent);
159
+
}
160
+
.radio-dot {
161
+
width: 10px;
162
+
height: 10px;
163
+
border-radius: 50%;
164
+
background: var(--color-accent);
165
+
display: none;
166
+
}
167
+
.reason-card.selected .radio-dot {
168
+
display: block;
169
+
}
170
+
.reason-description {
171
+
font-size: var(--font-size-xs);
172
+
color: var(--color-text-secondary);
173
+
margin-top: 4px;
174
+
margin-left: 26px;
175
+
}
176
+
.details-section {
177
+
margin-top: 16px;
178
+
}
179
+
.details-label {
180
+
font-size: var(--font-size-sm);
181
+
color: var(--color-text-secondary);
182
+
margin-bottom: 8px;
183
+
}
184
+
.details-textarea {
185
+
width: 100%;
186
+
min-height: 80px;
187
+
padding: 12px;
188
+
border: 1px solid var(--color-border);
189
+
border-radius: 8px;
190
+
font-family: inherit;
191
+
font-size: var(--font-size-sm);
192
+
resize: vertical;
193
+
background: var(--color-bg-secondary);
194
+
color: var(--color-text-primary);
195
+
box-sizing: border-box;
196
+
}
197
+
.details-textarea:focus {
198
+
outline: none;
199
+
border-color: var(--color-accent);
200
+
}
201
+
.char-count {
202
+
font-size: var(--font-size-xs);
203
+
color: var(--color-text-secondary);
204
+
text-align: right;
205
+
margin-top: 4px;
206
+
}
207
+
.error {
208
+
color: var(--color-error, #ff4444);
209
+
font-size: var(--font-size-sm);
210
+
margin-top: 12px;
211
+
padding: 8px 12px;
212
+
background: rgba(255, 68, 68, 0.1);
213
+
border-radius: 6px;
214
+
}
215
+
.footer {
216
+
display: flex;
217
+
gap: 12px;
218
+
padding: 16px;
219
+
border-top: 1px solid var(--color-border);
220
+
}
221
+
.footer button {
222
+
flex: 1;
223
+
padding: 12px 16px;
224
+
border-radius: 8px;
225
+
font-family: inherit;
226
+
font-size: var(--font-size-sm);
227
+
font-weight: var(--font-weight-medium);
228
+
cursor: pointer;
229
+
}
230
+
.cancel-button {
231
+
background: var(--color-bg-secondary);
232
+
border: 1px solid var(--color-border);
233
+
color: var(--color-text-primary);
234
+
}
235
+
.submit-button {
236
+
background: var(--color-accent);
237
+
border: none;
238
+
color: white;
239
+
display: flex;
240
+
align-items: center;
241
+
justify-content: center;
242
+
gap: 8px;
243
+
}
244
+
.submit-button:disabled {
245
+
opacity: 0.5;
246
+
cursor: not-allowed;
247
+
}
248
+
`;
249
+
250
+
constructor() {
251
+
super();
252
+
this.open = false;
253
+
this.galleryUri = '';
254
+
this._selectedReason = null;
255
+
this._details = '';
256
+
this._submitting = false;
257
+
this._error = null;
258
+
}
259
+
260
+
updated(changedProperties) {
261
+
if (changedProperties.has('open') && this.open) {
262
+
this.#reset();
263
+
}
264
+
}
265
+
266
+
#reset() {
267
+
this._selectedReason = null;
268
+
this._details = '';
269
+
this._submitting = false;
270
+
this._error = null;
271
+
}
272
+
273
+
#handleOverlayClick(e) {
274
+
if (e.target.classList.contains('overlay') && !this._submitting) {
275
+
this.#close();
276
+
}
277
+
}
278
+
279
+
#close() {
280
+
this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
281
+
}
282
+
283
+
#selectReason(type) {
284
+
this._selectedReason = type;
285
+
this._error = null;
286
+
}
287
+
288
+
#handleDetailsInput(e) {
289
+
const value = e.target.value;
290
+
if (value.length <= 300) {
291
+
this._details = value;
292
+
}
293
+
}
294
+
295
+
async #submit() {
296
+
if (!this._selectedReason || this._submitting) return;
297
+
298
+
this._submitting = true;
299
+
this._error = null;
300
+
301
+
try {
302
+
await mutations.createReport(
303
+
this.galleryUri,
304
+
this._selectedReason,
305
+
this._details || null
306
+
);
307
+
308
+
this.dispatchEvent(new CustomEvent('submitted', { bubbles: true, composed: true }));
309
+
this.#close();
310
+
} catch (err) {
311
+
console.error('Failed to submit report:', err);
312
+
this._error = 'Failed to submit report. Please try again.';
313
+
} finally {
314
+
this._submitting = false;
315
+
}
316
+
}
317
+
318
+
render() {
319
+
return html`
320
+
<div class="overlay" @click=${this.#handleOverlayClick}>
321
+
<div class="dialog">
322
+
<div class="header">Report gallery</div>
323
+
324
+
<div class="content">
325
+
${REPORT_REASONS.map(reason => html`
326
+
<button
327
+
class="reason-card ${this._selectedReason === reason.type ? 'selected' : ''}"
328
+
@click=${() => this.#selectReason(reason.type)}
329
+
?disabled=${this._submitting}
330
+
>
331
+
<div class="reason-label">
332
+
<div class="radio">
333
+
<div class="radio-dot"></div>
334
+
</div>
335
+
${reason.label}
336
+
</div>
337
+
<div class="reason-description">${reason.description}</div>
338
+
</button>
339
+
`)}
340
+
341
+
<div class="details-section">
342
+
<div class="details-label">Add details (optional)</div>
343
+
<textarea
344
+
class="details-textarea"
345
+
placeholder="Provide additional context..."
346
+
.value=${this._details}
347
+
@input=${this.#handleDetailsInput}
348
+
?disabled=${this._submitting}
349
+
></textarea>
350
+
<div class="char-count">${this._details.length}/300</div>
351
+
</div>
352
+
353
+
${this._error ? html`
354
+
<div class="error">${this._error}</div>
355
+
` : ''}
356
+
</div>
357
+
358
+
<div class="footer">
359
+
<button
360
+
class="cancel-button"
361
+
@click=${this.#close}
362
+
?disabled=${this._submitting}
363
+
>
364
+
Cancel
365
+
</button>
366
+
<button
367
+
class="submit-button"
368
+
@click=${this.#submit}
369
+
?disabled=${!this._selectedReason || this._submitting}
370
+
>
371
+
${this._submitting ? html`<grain-spinner size="16"></grain-spinner>` : ''}
372
+
Submit
373
+
</button>
374
+
</div>
375
+
</div>
376
+
</div>
377
+
`;
378
+
}
379
+
}
380
+
381
+
customElements.define('grain-report-dialog', GrainReportDialog);
382
+
```
383
+
384
+
**Step 2: Verify syntax**
385
+
386
+
Run: `npm run build`
387
+
Expected: Build succeeds with no errors
388
+
389
+
**Step 3: Commit**
390
+
391
+
```bash
392
+
git add src/components/organisms/grain-report-dialog.js
393
+
git commit -m "feat: add grain-report-dialog component"
394
+
```
395
+
396
+
---
397
+
398
+
## Task 3: Add Report to Gallery Detail Page
399
+
400
+
**Files:**
401
+
- Modify: `src/components/pages/grain-gallery-detail.js`
402
+
403
+
**Step 1: Add import for auth and report dialog**
404
+
405
+
The file already imports `auth`. Add the report dialog import after the action dialog import (around line 15):
406
+
407
+
```javascript
408
+
import '../organisms/grain-report-dialog.js';
409
+
```
410
+
411
+
**Step 2: Add _reportDialogOpen state property**
412
+
413
+
Add to the `static properties` object:
414
+
415
+
```javascript
416
+
_reportDialogOpen: { state: true }
417
+
```
418
+
419
+
**Step 3: Initialize _reportDialogOpen in constructor**
420
+
421
+
Add to the constructor:
422
+
423
+
```javascript
424
+
this._reportDialogOpen = false;
425
+
```
426
+
427
+
**Step 4: Update the #isOwner getter usage - show menu for all authenticated users**
428
+
429
+
Find the menu button conditional (around line 361-365):
430
+
431
+
```javascript
432
+
${this.#isOwner ? html`
433
+
<button class="menu-button" @click=${this.#handleMenuOpen}>
434
+
<grain-icon name="ellipsis" size="20"></grain-icon>
435
+
</button>
436
+
` : ''}
437
+
```
438
+
439
+
Replace with:
440
+
441
+
```javascript
442
+
${auth.isAuthenticated ? html`
443
+
<button class="menu-button" @click=${this.#handleMenuOpen}>
444
+
<grain-icon name="ellipsis" size="20"></grain-icon>
445
+
</button>
446
+
` : ''}
447
+
```
448
+
449
+
**Step 5: Update actions array to show Report for non-owners**
450
+
451
+
Find the grain-action-dialog element (around line 407-414):
452
+
453
+
```javascript
454
+
<grain-action-dialog
455
+
?open=${this._menuOpen}
456
+
?loading=${this._deleting}
457
+
loadingText="Deleting..."
458
+
.actions=${[{ label: 'Delete', action: 'delete', danger: true }]}
459
+
@action=${this.#handleAction}
460
+
@close=${this.#handleMenuClose}
461
+
></grain-action-dialog>
462
+
```
463
+
464
+
Replace with:
465
+
466
+
```javascript
467
+
<grain-action-dialog
468
+
?open=${this._menuOpen}
469
+
?loading=${this._deleting}
470
+
loadingText="Deleting..."
471
+
.actions=${this.#isOwner
472
+
? [{ label: 'Delete', action: 'delete', danger: true }]
473
+
: [{ label: 'Report gallery', action: 'report' }]}
474
+
@action=${this.#handleAction}
475
+
@close=${this.#handleMenuClose}
476
+
></grain-action-dialog>
477
+
```
478
+
479
+
**Step 6: Add report dialog after action dialog**
480
+
481
+
Add after the grain-action-dialog closing tag:
482
+
483
+
```javascript
484
+
<grain-report-dialog
485
+
?open=${this._reportDialogOpen}
486
+
galleryUri=${this._gallery?.uri || ''}
487
+
@close=${this.#handleReportDialogClose}
488
+
@submitted=${this.#handleReportSubmitted}
489
+
></grain-report-dialog>
490
+
```
491
+
492
+
**Step 7: Update #handleAction to handle report action**
493
+
494
+
Find the #handleAction method:
495
+
496
+
```javascript
497
+
async #handleAction(e) {
498
+
if (e.detail.action === 'delete') {
499
+
await this.#handleDelete();
500
+
}
501
+
}
502
+
```
503
+
504
+
Replace with:
505
+
506
+
```javascript
507
+
async #handleAction(e) {
508
+
if (e.detail.action === 'delete') {
509
+
await this.#handleDelete();
510
+
} else if (e.detail.action === 'report') {
511
+
this._menuOpen = false;
512
+
this._reportDialogOpen = true;
513
+
}
514
+
}
515
+
```
516
+
517
+
**Step 8: Add report dialog handlers**
518
+
519
+
Add these methods after `#handleMenuClose`:
520
+
521
+
```javascript
522
+
#handleReportDialogClose() {
523
+
this._reportDialogOpen = false;
524
+
}
525
+
526
+
#handleReportSubmitted() {
527
+
this._reportDialogOpen = false;
528
+
this.dispatchEvent(new CustomEvent('show-toast', {
529
+
bubbles: true,
530
+
composed: true,
531
+
detail: { message: 'Report submitted' }
532
+
}));
533
+
}
534
+
```
535
+
536
+
**Step 9: Verify syntax and test manually**
537
+
538
+
Run: `npm run dev`
539
+
Expected:
540
+
- Navigate to a gallery you don't own
541
+
- Ellipsis menu appears
542
+
- Clicking shows "Report gallery" option
543
+
- Clicking "Report gallery" opens report dialog
544
+
545
+
**Step 10: Commit**
546
+
547
+
```bash
548
+
git add src/components/pages/grain-gallery-detail.js
549
+
git commit -m "feat: add report gallery option to gallery detail page"
550
+
```
551
+
552
+
---
553
+
554
+
## Task 4: Add Action Menu to Gallery Card
555
+
556
+
**Files:**
557
+
- Modify: `src/components/organisms/grain-gallery-card.js`
558
+
559
+
**Step 1: Add imports**
560
+
561
+
Add after existing imports (around line 5):
562
+
563
+
```javascript
564
+
import { auth } from '../../services/auth.js';
565
+
import './grain-action-dialog.js';
566
+
import './grain-report-dialog.js';
567
+
```
568
+
569
+
**Step 2: Add state properties**
570
+
571
+
Add to `static properties`:
572
+
573
+
```javascript
574
+
_menuOpen: { state: true },
575
+
_reportDialogOpen: { state: true },
576
+
_deleting: { state: true }
577
+
```
578
+
579
+
**Step 3: Add CSS for menu button**
580
+
581
+
Add to `static styles` before the closing backtick:
582
+
583
+
```css
584
+
.header-row {
585
+
display: flex;
586
+
align-items: center;
587
+
justify-content: space-between;
588
+
}
589
+
.menu-button {
590
+
display: flex;
591
+
align-items: center;
592
+
justify-content: center;
593
+
background: none;
594
+
border: none;
595
+
padding: var(--space-sm);
596
+
margin-right: calc(-1 * var(--space-sm));
597
+
cursor: pointer;
598
+
color: var(--color-text-primary);
599
+
}
600
+
```
601
+
602
+
**Step 4: Add constructor to initialize state**
603
+
604
+
Add after `static styles`:
605
+
606
+
```javascript
607
+
constructor() {
608
+
super();
609
+
this._menuOpen = false;
610
+
this._reportDialogOpen = false;
611
+
this._deleting = false;
612
+
}
613
+
```
614
+
615
+
**Step 5: Add #isOwner getter**
616
+
617
+
Add after constructor:
618
+
619
+
```javascript
620
+
get #isOwner() {
621
+
return auth.user?.handle === this.gallery?.handle;
622
+
}
623
+
```
624
+
625
+
**Step 6: Update header template**
626
+
627
+
Find the header section in render():
628
+
629
+
```javascript
630
+
<header class="header">
631
+
<grain-author-chip
632
+
avatarUrl=${gallery.avatarUrl || ''}
633
+
handle=${gallery.handle}
634
+
displayName=${gallery.displayName || ''}
635
+
></grain-author-chip>
636
+
</header>
637
+
```
638
+
639
+
Replace with:
640
+
641
+
```javascript
642
+
<header class="header">
643
+
<div class="header-row">
644
+
<grain-author-chip
645
+
avatarUrl=${gallery.avatarUrl || ''}
646
+
handle=${gallery.handle}
647
+
displayName=${gallery.displayName || ''}
648
+
></grain-author-chip>
649
+
${auth.isAuthenticated ? html`
650
+
<button class="menu-button" @click=${this.#handleMenuOpen}>
651
+
<grain-icon name="ellipsis" size="20"></grain-icon>
652
+
</button>
653
+
` : ''}
654
+
</div>
655
+
</header>
656
+
```
657
+
658
+
**Step 7: Add grain-icon import**
659
+
660
+
Add to imports at top:
661
+
662
+
```javascript
663
+
import '../atoms/grain-icon.js';
664
+
```
665
+
666
+
**Step 8: Add dialogs to template**
667
+
668
+
Add before the closing `\`;\` of the render return, after the content div:
669
+
670
+
```javascript
671
+
<grain-action-dialog
672
+
?open=${this._menuOpen}
673
+
?loading=${this._deleting}
674
+
loadingText="Deleting..."
675
+
.actions=${this.#isOwner
676
+
? [{ label: 'Delete', action: 'delete', danger: true }]
677
+
: [{ label: 'Report gallery', action: 'report' }]}
678
+
@action=${this.#handleAction}
679
+
@close=${this.#handleMenuClose}
680
+
></grain-action-dialog>
681
+
682
+
<grain-report-dialog
683
+
?open=${this._reportDialogOpen}
684
+
galleryUri=${gallery.uri || ''}
685
+
@close=${this.#handleReportDialogClose}
686
+
@submitted=${this.#handleReportSubmitted}
687
+
></grain-report-dialog>
688
+
```
689
+
690
+
**Step 9: Add event handler methods**
691
+
692
+
Add before the render() method:
693
+
694
+
```javascript
695
+
#handleMenuOpen(e) {
696
+
e.stopPropagation();
697
+
this._menuOpen = true;
698
+
}
699
+
700
+
#handleMenuClose() {
701
+
this._menuOpen = false;
702
+
}
703
+
704
+
async #handleAction(e) {
705
+
if (e.detail.action === 'delete') {
706
+
await this.#handleDelete();
707
+
} else if (e.detail.action === 'report') {
708
+
this._menuOpen = false;
709
+
this._reportDialogOpen = true;
710
+
}
711
+
}
712
+
713
+
async #handleDelete() {
714
+
this._deleting = true;
715
+
try {
716
+
const client = auth.getClient();
717
+
const rkey = this.#rkey;
718
+
719
+
// Note: Full delete requires fetching gallery details first to get photo/item URIs
720
+
// For now, just delete the gallery record
721
+
await client.mutate(`
722
+
mutation DeleteGallery($rkey: String!) {
723
+
deleteSocialGrainGallery(rkey: $rkey) { uri }
724
+
}
725
+
`, { rkey });
726
+
727
+
// Dispatch event to notify parent to remove from list
728
+
this.dispatchEvent(new CustomEvent('gallery-deleted', {
729
+
bubbles: true,
730
+
composed: true,
731
+
detail: { uri: this.gallery.uri }
732
+
}));
733
+
} catch (err) {
734
+
console.error('Failed to delete gallery:', err);
735
+
} finally {
736
+
this._deleting = false;
737
+
this._menuOpen = false;
738
+
}
739
+
}
740
+
741
+
#handleReportDialogClose() {
742
+
this._reportDialogOpen = false;
743
+
}
744
+
745
+
#handleReportSubmitted() {
746
+
this._reportDialogOpen = false;
747
+
this.dispatchEvent(new CustomEvent('show-toast', {
748
+
bubbles: true,
749
+
composed: true,
750
+
detail: { message: 'Report submitted' }
751
+
}));
752
+
}
753
+
```
754
+
755
+
**Step 10: Verify syntax and test manually**
756
+
757
+
Run: `npm run dev`
758
+
Expected:
759
+
- Timeline shows ellipsis button on each gallery card (when logged in)
760
+
- Clicking shows appropriate action (Delete for own, Report for others)
761
+
- Report flow works from card
762
+
763
+
**Step 11: Commit**
764
+
765
+
```bash
766
+
git add src/components/organisms/grain-gallery-card.js
767
+
git commit -m "feat: add action menu with report option to gallery cards"
768
+
```
769
+
770
+
---
771
+
772
+
## Task 5: Final Build Verification
773
+
774
+
**Step 1: Full build**
775
+
776
+
Run: `npm run build`
777
+
Expected: Build succeeds with no errors
778
+
779
+
**Step 2: Manual end-to-end test**
780
+
781
+
Run: `npm run dev`
782
+
Test these scenarios:
783
+
1. Logged out: No menu buttons visible anywhere
784
+
2. Logged in, own gallery detail: Menu shows "Delete"
785
+
3. Logged in, other's gallery detail: Menu shows "Report gallery"
786
+
4. Logged in, own gallery card: Menu shows "Delete"
787
+
5. Logged in, other's gallery card: Menu shows "Report gallery"
788
+
6. Submit report: Dialog closes, toast shows "Report submitted"
789
+
790
+
**Step 3: Commit any fixes if needed**
791
+
792
+
---
793
+
794
+
## Summary
795
+
796
+
| Task | Files | Description |
797
+
|------|-------|-------------|
798
+
| 1 | mutations.js | Add createReport mutation |
799
+
| 2 | grain-report-dialog.js | Create report dialog component |
800
+
| 3 | grain-gallery-detail.js | Add report option to detail page |
801
+
| 4 | grain-gallery-card.js | Add action menu to cards |
802
+
| 5 | - | Final verification |
+257
docs/plans/2026-01-04-alt-overlay-scroll-fix.md
+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
+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
+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
+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
+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
}
+2
src/components/atoms/grain-textarea.js
+2
src/components/atoms/grain-textarea.js
+3
-16
src/components/molecules/grain-login-dialog.js
+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
+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) {
+3
-12
src/components/organisms/grain-comment-sheet.js
+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">
+46
-5
src/components/organisms/grain-gallery-card.js
+46
-5
src/components/organisms/grain-gallery-card.js
···
1
1
import { LitElement, html, css } from 'lit';
2
2
import { router } from '../../router.js';
3
+
import { auth } from '../../services/auth.js';
3
4
import '../molecules/grain-author-chip.js';
5
+
import '../atoms/grain-icon.js';
4
6
import './grain-image-carousel.js';
5
7
import './grain-engagement-bar.js';
6
8
···
54
56
.clickable {
55
57
cursor: pointer;
56
58
}
59
+
.header-row {
60
+
display: flex;
61
+
align-items: center;
62
+
justify-content: space-between;
63
+
}
64
+
.menu-button {
65
+
display: flex;
66
+
align-items: center;
67
+
justify-content: center;
68
+
background: none;
69
+
border: none;
70
+
padding: var(--space-sm);
71
+
margin-right: calc(-1 * var(--space-sm));
72
+
cursor: pointer;
73
+
color: var(--color-text-primary);
74
+
}
57
75
`;
58
76
77
+
get #isOwner() {
78
+
return auth.user?.handle === this.gallery?.handle;
79
+
}
80
+
81
+
#handleMenuOpen(e) {
82
+
e.stopPropagation();
83
+
this.dispatchEvent(new CustomEvent('open-gallery-menu', {
84
+
bubbles: true,
85
+
composed: true,
86
+
detail: {
87
+
gallery: this.gallery,
88
+
isOwner: this.#isOwner
89
+
}
90
+
}));
91
+
}
92
+
59
93
#formatDate(iso) {
60
94
const date = new Date(iso);
61
95
const now = new Date();
···
89
123
90
124
return html`
91
125
<header class="header">
92
-
<grain-author-chip
93
-
avatarUrl=${gallery.avatarUrl || ''}
94
-
handle=${gallery.handle}
95
-
displayName=${gallery.displayName || ''}
96
-
></grain-author-chip>
126
+
<div class="header-row">
127
+
<grain-author-chip
128
+
avatarUrl=${gallery.avatarUrl || ''}
129
+
handle=${gallery.handle}
130
+
displayName=${gallery.displayName || ''}
131
+
></grain-author-chip>
132
+
${auth.isAuthenticated ? html`
133
+
<button class="menu-button" @click=${this.#handleMenuOpen}>
134
+
<grain-icon name="ellipsis" size="20"></grain-icon>
135
+
</button>
136
+
` : ''}
137
+
</div>
97
138
</header>
98
139
99
140
<div class="clickable" @click=${this.#handleClick}>
+42
-2
src/components/organisms/grain-image-carousel.js
+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
+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
+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
}
+45
-27
src/components/pages/grain-gallery-detail.js
+45
-27
src/components/pages/grain-gallery-detail.js
···
12
12
import '../atoms/grain-spinner.js';
13
13
import '../atoms/grain-icon.js';
14
14
import '../atoms/grain-rich-text.js';
15
-
import '../organisms/grain-action-dialog.js';
16
15
17
16
const DELETE_GALLERY_MUTATION = `
18
17
mutation DeleteGallery($rkey: String!) {
···
51
50
_gallery: { state: true },
52
51
_loading: { state: true },
53
52
_error: { state: true },
54
-
_menuOpen: { state: true },
55
-
_deleting: { state: true },
56
53
_commentSheetOpen: { state: true },
57
54
_focusPhotoUri: { state: true },
58
55
_focusPhotoUrl: { state: true }
···
139
136
this._gallery = null;
140
137
this._loading = true;
141
138
this._error = null;
142
-
this._menuOpen = false;
143
-
this._deleting = false;
144
139
this._commentSheetOpen = false;
145
140
this._focusPhotoUri = null;
146
141
this._focusPhotoUrl = null;
···
149
144
connectedCallback() {
150
145
super.connectedCallback();
151
146
this.#loadGallery();
147
+
document.addEventListener('dialog-action', this.#handleDialogAction);
152
148
}
153
149
154
150
disconnectedCallback() {
155
151
if (this.#currentUri) {
156
152
recordCache.unsubscribe(this.#currentUri, this.#onCacheUpdate);
157
153
}
154
+
document.removeEventListener('dialog-action', this.#handleDialogAction);
158
155
super.disconnectedCallback();
159
156
}
160
157
···
304
301
}
305
302
306
303
#handleMenuOpen() {
307
-
this._menuOpen = true;
308
-
}
309
-
310
-
#handleMenuClose() {
311
-
this._menuOpen = false;
304
+
this.dispatchEvent(new CustomEvent('open-dialog', {
305
+
bubbles: true,
306
+
composed: true,
307
+
detail: {
308
+
type: 'action',
309
+
props: {
310
+
actions: this.#isOwner
311
+
? [{ label: 'Delete', action: 'delete', danger: true }]
312
+
: [{ label: 'Report gallery', action: 'report' }]
313
+
}
314
+
}
315
+
}));
312
316
}
313
317
314
-
async #handleAction(e) {
318
+
#handleDialogAction = (e) => {
315
319
if (e.detail.action === 'delete') {
316
-
await this.#handleDelete();
320
+
this.#handleDelete();
321
+
} else if (e.detail.action === 'report') {
322
+
this.dispatchEvent(new CustomEvent('open-dialog', {
323
+
bubbles: true,
324
+
composed: true,
325
+
detail: {
326
+
type: 'report',
327
+
props: { galleryUri: this._gallery?.uri }
328
+
}
329
+
}));
317
330
}
318
-
}
331
+
};
319
332
320
333
async #handleDelete() {
321
-
this._deleting = true;
334
+
// Show loading state
335
+
this.dispatchEvent(new CustomEvent('open-dialog', {
336
+
bubbles: true,
337
+
composed: true,
338
+
detail: {
339
+
type: 'action',
340
+
props: {
341
+
actions: [{ label: 'Delete', action: 'delete', danger: true }],
342
+
loading: true,
343
+
loadingText: 'Deleting...'
344
+
}
345
+
}
346
+
}));
347
+
322
348
try {
323
349
const client = auth.getClient();
324
350
···
340
366
341
367
// Delete the gallery
342
368
await client.mutate(DELETE_GALLERY_MUTATION, { rkey: this.rkey });
369
+
370
+
// Close dialog and navigate
371
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
343
372
router.push(`/profile/${this.handle}`);
344
373
} catch (err) {
345
374
console.error('Failed to delete gallery:', err);
346
375
this._error = 'Failed to delete gallery. Please try again.';
347
-
this._menuOpen = false;
348
-
} finally {
349
-
this._deleting = false;
376
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
350
377
}
351
378
}
352
379
···
358
385
<grain-icon name="back" size="20"></grain-icon>
359
386
</button>
360
387
<div class="header-spacer"></div>
361
-
${this.#isOwner ? html`
388
+
${auth.isAuthenticated ? html`
362
389
<button class="menu-button" @click=${this.#handleMenuOpen}>
363
390
<grain-icon name="ellipsis" size="20"></grain-icon>
364
391
</button>
···
403
430
<time class="timestamp">${this.#formatDate(this._gallery.createdAt)}</time>
404
431
</div>
405
432
` : ''}
406
-
407
-
<grain-action-dialog
408
-
?open=${this._menuOpen}
409
-
?loading=${this._deleting}
410
-
loadingText="Deleting..."
411
-
.actions=${[{ label: 'Delete', action: 'delete', danger: true }]}
412
-
@action=${this.#handleAction}
413
-
@close=${this.#handleMenuClose}
414
-
></grain-action-dialog>
415
433
416
434
<grain-comment-sheet
417
435
?open=${this._commentSheetOpen}
+2
-1
src/components/pages/grain-image-descriptions.js
+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
+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
+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
+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
+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
+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
+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
+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
+
}