+545
docs/plans/2025-12-27-web-share-api.md
+545
docs/plans/2025-12-27-web-share-api.md
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
# Web Share API Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add share functionality for galleries and profiles using the Web Share API with clipboard fallback.
6
+
7
+
**Architecture:** Create a shared utility service for share logic. Add share button to gallery engagement bar. Convert profile header ellipsis to action menu with Share option. Create toast component for clipboard feedback.
8
+
9
+
**Tech Stack:** Lit, Web Share API, Clipboard API
10
+
11
+
---
12
+
13
+
## Task 1: Create Share Utility Service
14
+
15
+
**Files:**
16
+
- Create: `src/services/share.js`
17
+
18
+
**Step 1: Create the share service**
19
+
20
+
```javascript
21
+
/**
22
+
* Share utility with Web Share API and clipboard fallback.
23
+
* Returns { success: boolean, method: 'share' | 'clipboard' }
24
+
*/
25
+
export async function share(url) {
26
+
// Try Web Share API first
27
+
if (navigator.share) {
28
+
try {
29
+
await navigator.share({ url });
30
+
return { success: true, method: 'share' };
31
+
} catch (err) {
32
+
// User cancelled or error - fall through to clipboard
33
+
if (err.name === 'AbortError') {
34
+
return { success: false, method: 'share' };
35
+
}
36
+
}
37
+
}
38
+
39
+
// Fallback to clipboard
40
+
try {
41
+
await navigator.clipboard.writeText(url);
42
+
return { success: true, method: 'clipboard' };
43
+
} catch (err) {
44
+
console.error('Failed to copy to clipboard:', err);
45
+
return { success: false, method: 'clipboard' };
46
+
}
47
+
}
48
+
```
49
+
50
+
**Step 2: Commit**
51
+
52
+
```bash
53
+
git add src/services/share.js
54
+
git commit -m "feat: add share utility service with Web Share API and clipboard fallback"
55
+
```
56
+
57
+
---
58
+
59
+
## Task 2: Create Toast Component
60
+
61
+
**Files:**
62
+
- Create: `src/components/atoms/grain-toast.js`
63
+
64
+
**Step 1: Create the toast component**
65
+
66
+
```javascript
67
+
import { LitElement, html, css } from 'lit';
68
+
69
+
export class GrainToast extends LitElement {
70
+
static properties = {
71
+
message: { type: String },
72
+
_visible: { state: true }
73
+
};
74
+
75
+
static styles = css`
76
+
:host {
77
+
position: fixed;
78
+
bottom: calc(var(--nav-height, 56px) + var(--space-md));
79
+
left: 50%;
80
+
transform: translateX(-50%);
81
+
z-index: 1001;
82
+
pointer-events: none;
83
+
}
84
+
.toast {
85
+
background: var(--color-text-primary);
86
+
color: var(--color-bg-primary);
87
+
padding: var(--space-sm) var(--space-md);
88
+
border-radius: var(--border-radius);
89
+
font-size: var(--font-size-sm);
90
+
opacity: 0;
91
+
transition: opacity 0.2s;
92
+
}
93
+
.toast.visible {
94
+
opacity: 1;
95
+
}
96
+
`;
97
+
98
+
constructor() {
99
+
super();
100
+
this.message = '';
101
+
this._visible = false;
102
+
}
103
+
104
+
show(message, duration = 2000) {
105
+
this.message = message;
106
+
this._visible = true;
107
+
setTimeout(() => {
108
+
this._visible = false;
109
+
}, duration);
110
+
}
111
+
112
+
render() {
113
+
return html`
114
+
<div class="toast ${this._visible ? 'visible' : ''}">
115
+
${this.message}
116
+
</div>
117
+
`;
118
+
}
119
+
}
120
+
121
+
customElements.define('grain-toast', GrainToast);
122
+
```
123
+
124
+
**Step 2: Commit**
125
+
126
+
```bash
127
+
git add src/components/atoms/grain-toast.js
128
+
git commit -m "feat: add toast component for transient feedback"
129
+
```
130
+
131
+
---
132
+
133
+
## Task 3: Add Share Button to Engagement Bar
134
+
135
+
**Files:**
136
+
- Modify: `src/components/organisms/grain-engagement-bar.js`
137
+
138
+
**Step 1: Add share button and event**
139
+
140
+
Update `grain-engagement-bar.js` to:
141
+
1. Add a `url` property for the shareable URL
142
+
2. Import the share utility and toast
143
+
3. Add a share button (using existing `share` icon)
144
+
4. Handle share with toast feedback for clipboard fallback
145
+
146
+
```javascript
147
+
import { LitElement, html, css } from 'lit';
148
+
import { share } from '../../services/share.js';
149
+
import '../molecules/grain-stat-count.js';
150
+
import '../atoms/grain-icon.js';
151
+
import '../atoms/grain-toast.js';
152
+
153
+
export class GrainEngagementBar extends LitElement {
154
+
static properties = {
155
+
favoriteCount: { type: Number },
156
+
commentCount: { type: Number },
157
+
url: { type: String }
158
+
};
159
+
160
+
static styles = css`
161
+
:host {
162
+
display: flex;
163
+
align-items: center;
164
+
gap: var(--space-sm);
165
+
padding: var(--space-sm);
166
+
}
167
+
@media (min-width: 600px) {
168
+
:host {
169
+
padding-left: 0;
170
+
padding-right: 0;
171
+
}
172
+
}
173
+
.share-button {
174
+
display: flex;
175
+
align-items: center;
176
+
justify-content: center;
177
+
background: none;
178
+
border: none;
179
+
padding: var(--space-sm);
180
+
margin: calc(-1 * var(--space-sm));
181
+
cursor: pointer;
182
+
color: var(--color-text-primary);
183
+
border-radius: var(--border-radius);
184
+
transition: opacity 0.2s;
185
+
}
186
+
.share-button:hover {
187
+
opacity: 0.7;
188
+
}
189
+
.share-button:active {
190
+
transform: scale(0.95);
191
+
}
192
+
`;
193
+
194
+
constructor() {
195
+
super();
196
+
this.favoriteCount = 0;
197
+
this.commentCount = 0;
198
+
this.url = '';
199
+
}
200
+
201
+
async #handleShare() {
202
+
const result = await share(this.url || window.location.href);
203
+
if (result.success && result.method === 'clipboard') {
204
+
this.shadowRoot.querySelector('grain-toast').show('Link copied');
205
+
}
206
+
}
207
+
208
+
render() {
209
+
return html`
210
+
<grain-stat-count
211
+
icon="heart"
212
+
count=${this.favoriteCount}
213
+
></grain-stat-count>
214
+
<grain-stat-count
215
+
icon="comment"
216
+
count=${this.commentCount}
217
+
></grain-stat-count>
218
+
<button class="share-button" type="button" aria-label="Share" @click=${this.#handleShare}>
219
+
<grain-icon name="share" size="16"></grain-icon>
220
+
</button>
221
+
<grain-toast></grain-toast>
222
+
`;
223
+
}
224
+
}
225
+
226
+
customElements.define('grain-engagement-bar', GrainEngagementBar);
227
+
```
228
+
229
+
**Step 2: Commit**
230
+
231
+
```bash
232
+
git add src/components/organisms/grain-engagement-bar.js
233
+
git commit -m "feat: add share button to engagement bar"
234
+
```
235
+
236
+
---
237
+
238
+
## Task 4: Pass URL to Engagement Bar in Gallery Detail
239
+
240
+
**Files:**
241
+
- Modify: `src/components/pages/grain-gallery-detail.js`
242
+
243
+
**Step 1: Add url property to engagement bar**
244
+
245
+
In `grain-gallery-detail.js`, update the `<grain-engagement-bar>` usage to pass the gallery URL:
246
+
247
+
Find this line (~259):
248
+
```html
249
+
<grain-engagement-bar
250
+
favoriteCount=${this._gallery.favoriteCount}
251
+
commentCount=${this._gallery.commentCount}
252
+
></grain-engagement-bar>
253
+
```
254
+
255
+
Replace with:
256
+
```html
257
+
<grain-engagement-bar
258
+
favoriteCount=${this._gallery.favoriteCount}
259
+
commentCount=${this._gallery.commentCount}
260
+
url=${window.location.href}
261
+
></grain-engagement-bar>
262
+
```
263
+
264
+
**Step 2: Commit**
265
+
266
+
```bash
267
+
git add src/components/pages/grain-gallery-detail.js
268
+
git commit -m "feat: pass gallery URL to engagement bar for sharing"
269
+
```
270
+
271
+
---
272
+
273
+
## Task 5: Convert Profile Header Ellipsis to Action Menu
274
+
275
+
**Files:**
276
+
- Modify: `src/components/organisms/grain-profile-header.js`
277
+
278
+
**Step 1: Add action dialog, share logic, and menu state**
279
+
280
+
Update `grain-profile-header.js` to:
281
+
1. Import share service, toast, and action dialog
282
+
2. Add state for menu open
283
+
3. Show ellipsis for ALL users (not just owner)
284
+
4. Open action dialog with Share (for everyone) and Settings (for owner only)
285
+
5. Handle share action with toast feedback
286
+
287
+
```javascript
288
+
import { LitElement, html, css } from 'lit';
289
+
import { router } from '../../router.js';
290
+
import { auth } from '../../services/auth.js';
291
+
import { share } from '../../services/share.js';
292
+
import '../atoms/grain-avatar.js';
293
+
import '../atoms/grain-icon.js';
294
+
import '../atoms/grain-toast.js';
295
+
import '../molecules/grain-profile-stats.js';
296
+
import '../organisms/grain-action-dialog.js';
297
+
298
+
export class GrainProfileHeader extends LitElement {
299
+
static properties = {
300
+
profile: { type: Object },
301
+
_showFullscreen: { state: true },
302
+
_user: { state: true },
303
+
_menuOpen: { state: true }
304
+
};
305
+
306
+
static styles = css`
307
+
:host {
308
+
display: block;
309
+
padding: var(--space-md) var(--space-sm);
310
+
}
311
+
@media (min-width: 600px) {
312
+
:host {
313
+
padding-left: 0;
314
+
padding-right: 0;
315
+
}
316
+
}
317
+
.top-row {
318
+
display: flex;
319
+
align-items: flex-start;
320
+
gap: var(--space-md);
321
+
margin-bottom: var(--space-sm);
322
+
}
323
+
.right-column {
324
+
flex: 1;
325
+
min-width: 0;
326
+
padding-top: var(--space-xs);
327
+
}
328
+
.handle-row {
329
+
display: flex;
330
+
align-items: center;
331
+
gap: var(--space-sm);
332
+
margin-bottom: var(--space-xs);
333
+
}
334
+
.handle {
335
+
font-size: var(--font-size-lg);
336
+
font-weight: var(--font-weight-semibold);
337
+
color: var(--color-text-primary);
338
+
}
339
+
.name {
340
+
font-size: var(--font-size-sm);
341
+
color: var(--color-text-primary);
342
+
margin-bottom: var(--space-xs);
343
+
}
344
+
.bio {
345
+
font-size: var(--font-size-sm);
346
+
color: var(--color-text-secondary);
347
+
line-height: 1.4;
348
+
white-space: pre-wrap;
349
+
margin-top: var(--space-xs);
350
+
}
351
+
.avatar-button {
352
+
background: none;
353
+
border: none;
354
+
padding: 0;
355
+
cursor: pointer;
356
+
}
357
+
.fullscreen-overlay {
358
+
position: fixed;
359
+
top: 0;
360
+
bottom: 0;
361
+
left: 50%;
362
+
transform: translateX(-50%);
363
+
width: 100%;
364
+
max-width: var(--feed-max-width);
365
+
background: rgba(0, 0, 0, 0.9);
366
+
display: flex;
367
+
align-items: center;
368
+
justify-content: center;
369
+
z-index: 1000;
370
+
cursor: pointer;
371
+
}
372
+
.fullscreen-overlay img {
373
+
max-width: 80%;
374
+
max-height: 80%;
375
+
border-radius: 50%;
376
+
object-fit: cover;
377
+
}
378
+
.menu-button {
379
+
background: none;
380
+
border: none;
381
+
padding: 0;
382
+
cursor: pointer;
383
+
color: var(--color-text-secondary);
384
+
}
385
+
`;
386
+
387
+
constructor() {
388
+
super();
389
+
this._showFullscreen = false;
390
+
this._user = auth.user;
391
+
this._menuOpen = false;
392
+
}
393
+
394
+
connectedCallback() {
395
+
super.connectedCallback();
396
+
this._unsubscribe = auth.subscribe(user => {
397
+
this._user = user;
398
+
});
399
+
}
400
+
401
+
disconnectedCallback() {
402
+
super.disconnectedCallback();
403
+
this._unsubscribe?.();
404
+
}
405
+
406
+
get #isOwnProfile() {
407
+
return this._user?.handle && this._user.handle === this.profile?.handle;
408
+
}
409
+
410
+
get #menuActions() {
411
+
const actions = [{ label: 'Share', action: 'share' }];
412
+
if (this.#isOwnProfile) {
413
+
actions.push({ label: 'Settings', action: 'settings' });
414
+
}
415
+
return actions;
416
+
}
417
+
418
+
#openMenu() {
419
+
this._menuOpen = true;
420
+
}
421
+
422
+
#closeMenu() {
423
+
this._menuOpen = false;
424
+
}
425
+
426
+
async #handleAction(e) {
427
+
const action = e.detail.action;
428
+
this._menuOpen = false;
429
+
430
+
if (action === 'settings') {
431
+
router.push('/settings');
432
+
} else if (action === 'share') {
433
+
const result = await share(window.location.href);
434
+
if (result.success && result.method === 'clipboard') {
435
+
this.shadowRoot.querySelector('grain-toast').show('Link copied');
436
+
}
437
+
}
438
+
}
439
+
440
+
#openFullscreen() {
441
+
this._showFullscreen = true;
442
+
}
443
+
444
+
#closeFullscreen() {
445
+
this._showFullscreen = false;
446
+
}
447
+
448
+
render() {
449
+
if (!this.profile) return null;
450
+
451
+
const { handle, displayName, description, avatarUrl, galleryCount, followerCount, followingCount } = this.profile;
452
+
453
+
return html`
454
+
${this._showFullscreen ? html`
455
+
<div class="fullscreen-overlay" @click=${this.#closeFullscreen}>
456
+
<img src=${avatarUrl || ''} alt=${handle || ''}>
457
+
</div>
458
+
` : ''}
459
+
460
+
<div class="top-row">
461
+
<button class="avatar-button" @click=${this.#openFullscreen}>
462
+
<grain-avatar
463
+
src=${avatarUrl || ''}
464
+
alt=${handle || ''}
465
+
size="lg"
466
+
></grain-avatar>
467
+
</button>
468
+
<div class="right-column">
469
+
<div class="handle-row">
470
+
<span class="handle">${handle}</span>
471
+
<button class="menu-button" @click=${this.#openMenu}>
472
+
<grain-icon name="ellipsisVertical" size="20"></grain-icon>
473
+
</button>
474
+
</div>
475
+
${displayName ? html`<div class="name">${displayName}</div>` : ''}
476
+
<grain-profile-stats
477
+
handle=${handle}
478
+
galleryCount=${galleryCount || 0}
479
+
followerCount=${followerCount || 0}
480
+
followingCount=${followingCount || 0}
481
+
></grain-profile-stats>
482
+
${description ? html`<div class="bio">${description}</div>` : ''}
483
+
</div>
484
+
</div>
485
+
486
+
<grain-action-dialog
487
+
?open=${this._menuOpen}
488
+
.actions=${this.#menuActions}
489
+
@action=${this.#handleAction}
490
+
@close=${this.#closeMenu}
491
+
></grain-action-dialog>
492
+
493
+
<grain-toast></grain-toast>
494
+
`;
495
+
}
496
+
}
497
+
498
+
customElements.define('grain-profile-header', GrainProfileHeader);
499
+
```
500
+
501
+
**Step 2: Commit**
502
+
503
+
```bash
504
+
git add src/components/organisms/grain-profile-header.js
505
+
git commit -m "feat: convert profile ellipsis to action menu with share option"
506
+
```
507
+
508
+
---
509
+
510
+
## Task 6: Manual Testing
511
+
512
+
**Step 1: Test gallery share**
513
+
514
+
1. Navigate to a gallery detail page
515
+
2. Click the share button in the engagement bar
516
+
3. On mobile: Native share sheet should appear
517
+
4. On desktop (no Web Share): "Link copied" toast should appear, verify clipboard contains URL
518
+
519
+
**Step 2: Test profile share**
520
+
521
+
1. Navigate to any profile page
522
+
2. Click the ellipsis menu
523
+
3. Verify "Share" option appears for all users
524
+
4. Verify "Settings" option only appears on your own profile
525
+
5. Click "Share" and verify same behavior as gallery share
526
+
527
+
**Step 3: Final commit**
528
+
529
+
```bash
530
+
git add -A
531
+
git commit -m "feat: add web share API for galleries and profiles"
532
+
```
533
+
534
+
---
535
+
536
+
## Summary
537
+
538
+
| Task | Description |
539
+
|------|-------------|
540
+
| 1 | Create share utility service |
541
+
| 2 | Create toast component |
542
+
| 3 | Add share button to engagement bar |
543
+
| 4 | Pass URL to engagement bar in gallery detail |
544
+
| 5 | Convert profile header to action menu with share |
545
+
| 6 | Manual testing |