Compare changes

Choose any two refs to compare.

Changed files
+9363 -283
docs
lexicons
app
bsky
actor
richtext
com
atproto
social
src
.DS_Store

This is a binary file and will not be displayed.

+2
.gitignore
··· 3 3 .env 4 4 .env.local 5 5 .playwright-mcp/ 6 + .DS_Store 7 + .worktrees/
+201
LICENSE
··· 1 + Apache License 2 + Version 2.0, January 2004 3 + http://www.apache.org/licenses/ 4 + 5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 + 7 + 1. Definitions. 8 + 9 + "License" shall mean the terms and conditions for use, reproduction, 10 + and distribution as defined by Sections 1 through 9 of this document. 11 + 12 + "Licensor" shall mean the copyright owner or entity authorized by 13 + the copyright owner that is granting the License. 14 + 15 + "Legal Entity" shall mean the union of the acting entity and all 16 + other entities that control, are controlled by, or are under common 17 + control with that entity. For the purposes of this definition, 18 + "control" means (i) the power, direct or indirect, to cause the 19 + direction or management of such entity, whether by contract or 20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 + outstanding shares, or (iii) beneficial ownership of such entity. 22 + 23 + "You" (or "Your") shall mean an individual or Legal Entity 24 + exercising permissions granted by this License. 25 + 26 + "Source" form shall mean the preferred form for making modifications, 27 + including but not limited to software source code, documentation 28 + source, and configuration files. 29 + 30 + "Object" form shall mean any form resulting from mechanical 31 + transformation or translation of a Source form, including but 32 + not limited to compiled object code, generated documentation, 33 + and conversions to other media types. 34 + 35 + "Work" shall mean the work of authorship, whether in Source or 36 + Object form, made available under the License, as indicated by a 37 + copyright notice that is included in or attached to the work 38 + (an example is provided in the Appendix below). 39 + 40 + "Derivative Works" shall mean any work, whether in Source or Object 41 + form, that is based on (or derived from) the Work and for which the 42 + editorial revisions, annotations, elaborations, or other modifications 43 + represent, as a whole, an original work of authorship. For the purposes 44 + of this License, Derivative Works shall not include works that remain 45 + separable from, or merely link (or bind by name) to the interfaces of, 46 + the Work and Derivative Works thereof. 47 + 48 + "Contribution" shall mean any work of authorship, including 49 + the original version of the Work and any modifications or additions 50 + to that Work or Derivative Works thereof, that is intentionally 51 + submitted to Licensor for inclusion in the Work by the copyright owner 52 + or by an individual or Legal Entity authorized to submit on behalf of 53 + the copyright owner. For the purposes of this definition, "submitted" 54 + means any form of electronic, verbal, or written communication sent 55 + to the Licensor or its representatives, including but not limited to 56 + communication on electronic mailing lists, source code control systems, 57 + and issue tracking systems that are managed by, or on behalf of, the 58 + Licensor for the purpose of discussing and improving the Work, but 59 + excluding communication that is conspicuously marked or otherwise 60 + designated in writing by the copyright owner as "Not a Contribution." 61 + 62 + "Contributor" shall mean Licensor and any individual or Legal Entity 63 + on behalf of whom a Contribution has been received by Licensor and 64 + subsequently incorporated within the Work. 65 + 66 + 2. Grant of Copyright License. Subject to the terms and conditions of 67 + this License, each Contributor hereby grants to You a perpetual, 68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 + copyright license to reproduce, prepare Derivative Works of, 70 + publicly display, publicly perform, sublicense, and distribute the 71 + Work and such Derivative Works in Source or Object form. 72 + 73 + 3. Grant of Patent License. Subject to the terms and conditions of 74 + this License, each Contributor hereby grants to You a perpetual, 75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 + (except as stated in this section) patent license to make, have made, 77 + use, offer to sell, sell, import, and otherwise transfer the Work, 78 + where such license applies only to those patent claims licensable 79 + by such Contributor that are necessarily infringed by their 80 + Contribution(s) alone or by combination of their Contribution(s) 81 + with the Work to which such Contribution(s) was submitted. If You 82 + institute patent litigation against any entity (including a 83 + cross-claim or counterclaim in a lawsuit) alleging that the Work 84 + or a Contribution incorporated within the Work constitutes direct 85 + or contributory patent infringement, then any patent licenses 86 + granted to You under this License for that Work shall terminate 87 + as of the date such litigation is filed. 88 + 89 + 4. Redistribution. You may reproduce and distribute copies of the 90 + Work or Derivative Works thereof in any medium, with or without 91 + modifications, and in Source or Object form, provided that You 92 + meet the following conditions: 93 + 94 + (a) You must give any other recipients of the Work or 95 + Derivative Works a copy of this License; and 96 + 97 + (b) You must cause any modified files to carry prominent notices 98 + stating that You changed the files; and 99 + 100 + (c) You must retain, in the Source form of any Derivative Works 101 + that You distribute, all copyright, patent, trademark, and 102 + attribution notices from the Source form of the Work, 103 + excluding those notices that do not pertain to any part of 104 + the Derivative Works; and 105 + 106 + (d) If the Work includes a "NOTICE" text file as part of its 107 + distribution, then any Derivative Works that You distribute must 108 + include a readable copy of the attribution notices contained 109 + within such NOTICE file, excluding those notices that do not 110 + pertain to any part of the Derivative Works, in at least one 111 + of the following places: within a NOTICE text file distributed 112 + as part of the Derivative Works; within the Source form or 113 + documentation, if provided along with the Derivative Works; or, 114 + within a display generated by the Derivative Works, if and 115 + wherever such third-party notices normally appear. The contents 116 + of the NOTICE file are for informational purposes only and 117 + do not modify the License. You may add Your own attribution 118 + notices within Derivative Works that You distribute, alongside 119 + or as an addendum to the NOTICE text from the Work, provided 120 + that such additional attribution notices cannot be construed 121 + as modifying the License. 122 + 123 + You may add Your own copyright statement to Your modifications and 124 + may provide additional or different license terms and conditions 125 + for use, reproduction, or distribution of Your modifications, or 126 + for any such Derivative Works as a whole, provided Your use, 127 + reproduction, and distribution of the Work otherwise complies with 128 + the conditions stated in this License. 129 + 130 + 5. Submission of Contributions. Unless You explicitly state otherwise, 131 + any Contribution intentionally submitted for inclusion in the Work 132 + by You to the Licensor shall be under the terms and conditions of 133 + this License, without any additional terms or conditions. 134 + Notwithstanding the above, nothing herein shall supersede or modify 135 + the terms of any separate license agreement you may have executed 136 + with Licensor regarding such Contributions. 137 + 138 + 6. Trademarks. This License does not grant permission to use the trade 139 + names, trademarks, service marks, or product names of the Licensor, 140 + except as required for reasonable and customary use in describing the 141 + origin of the Work and reproducing the content of the NOTICE file. 142 + 143 + 7. Disclaimer of Warranty. Unless required by applicable law or 144 + agreed to in writing, Licensor provides the Work (and each 145 + Contributor provides its Contributions) on an "AS IS" BASIS, 146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 + implied, including, without limitation, any warranties or conditions 148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 + PARTICULAR PURPOSE. You are solely responsible for determining the 150 + appropriateness of using or redistributing the Work and assume any 151 + risks associated with Your exercise of permissions under this License. 152 + 153 + 8. Limitation of Liability. In no event and under no legal theory, 154 + whether in tort (including negligence), contract, or otherwise, 155 + unless required by applicable law (such as deliberate and grossly 156 + negligent acts) or agreed to in writing, shall any Contributor be 157 + liable to You for damages, including any direct, indirect, special, 158 + incidental, or consequential damages of any character arising as a 159 + result of this License or out of the use or inability to use the 160 + Work (including but not limited to damages for loss of goodwill, 161 + work stoppage, computer failure or malfunction, or any and all 162 + other commercial damages or losses), even if such Contributor 163 + has been advised of the possibility of such damages. 164 + 165 + 9. Accepting Warranty or Additional Liability. While redistributing 166 + the Work or Derivative Works thereof, You may choose to offer, 167 + and charge a fee for, acceptance of support, warranty, indemnity, 168 + or other liability obligations and/or rights consistent with this 169 + License. However, in accepting such obligations, You may act only 170 + on Your own behalf and on Your sole responsibility, not on behalf 171 + of any other Contributor, and only if You agree to indemnify, 172 + defend, and hold each Contributor harmless for any liability 173 + incurred by, or claims asserted against, such Contributor by reason 174 + of your accepting any such warranty or additional liability. 175 + 176 + END OF TERMS AND CONDITIONS 177 + 178 + APPENDIX: How to apply the Apache License to your work. 179 + 180 + To apply the Apache License to your work, attach the following 181 + boilerplate notice, with the fields enclosed by brackets "[]" 182 + replaced with your own identifying information. (Don't include 183 + the brackets!) The text should be enclosed in the appropriate 184 + comment syntax for the file format. We also recommend that a 185 + file or class name and description of purpose be included on the 186 + same "printed page" as the copyright notice for easier 187 + identification within third-party archives. 188 + 189 + Copyright [yyyy] [name of copyright owner] 190 + 191 + Licensed under the Apache License, Version 2.0 (the "License"); 192 + you may not use this file except in compliance with the License. 193 + You may obtain a copy of the License at 194 + 195 + http://www.apache.org/licenses/LICENSE-2.0 196 + 197 + Unless required by applicable law or agreed to in writing, software 198 + distributed under the License is distributed on an "AS IS" BASIS, 199 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 + See the License for the specific language governing permissions and 201 + limitations under the License.
+4
README.md
··· 15 15 npm run build 16 16 npm run preview 17 17 ``` 18 + 19 + --- 20 + 21 + Copyright ยฉ 2025 Grain Social. All rights reserved.
+82
docs/plans/2025-12-29-optimistic-favoriting.md
··· 1 + # Optimistic Favoriting Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Make the favorite button respond instantly by updating UI before API completes, rolling back on failure. 6 + 7 + **Architecture:** Store previous state before updating, apply optimistic update immediately, restore on API error with toast notification. 8 + 9 + **Tech Stack:** Lit, existing mutations service, existing toast component. 10 + 11 + --- 12 + 13 + ### Task 1: Implement Optimistic Update 14 + 15 + **Files:** 16 + - Modify: `src/components/organisms/grain-engagement-bar.js:72-92` 17 + 18 + **Step 1: Replace `#handleFavoriteClick` with optimistic version** 19 + 20 + Replace the entire `#handleFavoriteClick` method (lines 72-92) with: 21 + 22 + ```javascript 23 + async #handleFavoriteClick() { 24 + if (!auth.isAuthenticated || this._loading || !this.galleryUri) return; 25 + 26 + this._loading = true; 27 + 28 + // Store previous state for rollback 29 + const previousState = { 30 + viewerHasFavorited: this.viewerHasFavorited, 31 + viewerFavoriteUri: this.viewerFavoriteUri, 32 + favoriteCount: this.favoriteCount 33 + }; 34 + 35 + // Optimistic update - apply immediately 36 + this.viewerHasFavorited = !this.viewerHasFavorited; 37 + this.favoriteCount += this.viewerHasFavorited ? 1 : -1; 38 + if (!this.viewerHasFavorited) { 39 + this.viewerFavoriteUri = null; 40 + } 41 + 42 + try { 43 + const update = await mutations.toggleFavorite( 44 + this.galleryUri, 45 + previousState.viewerHasFavorited, 46 + previousState.viewerFavoriteUri, 47 + previousState.favoriteCount 48 + ); 49 + // Update with real URI from server (needed for future deletes) 50 + this.viewerFavoriteUri = update.viewerFavoriteUri; 51 + } catch (err) { 52 + // Rollback on failure 53 + console.error('Failed to toggle favorite:', err); 54 + this.viewerHasFavorited = previousState.viewerHasFavorited; 55 + this.viewerFavoriteUri = previousState.viewerFavoriteUri; 56 + this.favoriteCount = previousState.favoriteCount; 57 + this.shadowRoot.querySelector('grain-toast').show('Failed to update'); 58 + } finally { 59 + this._loading = false; 60 + } 61 + } 62 + ``` 63 + 64 + **Step 2: Verify in browser** 65 + 66 + 1. Open the app and navigate to a post 67 + 2. Click the heart - it should fill immediately 68 + 3. Check Network tab - request still goes out 69 + 4. Test error case: disable network, click heart, verify rollback + toast 70 + 71 + **Step 3: Commit** 72 + 73 + ```bash 74 + git add src/components/organisms/grain-engagement-bar.js 75 + git commit -m "feat: add optimistic UI for favoriting" 76 + ``` 77 + 78 + --- 79 + 80 + ## Done 81 + 82 + Single task - the change is small and self-contained.
+191
docs/plans/2025-12-29-profile-skeleton-implementation.md
··· 1 + # Profile Header Skeleton Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a skeleton loader for the profile header to eliminate layout shift on fresh profile loads. 6 + 7 + **Architecture:** Create a new Lit component that mirrors the exact layout dimensions of `grain-profile-header`, using animated placeholder shapes. Swap the spinner for this skeleton during loading state. 8 + 9 + **Tech Stack:** Lit 3.x, CSS custom properties, CSS animations 10 + 11 + --- 12 + 13 + ### Task 1: Create the Skeleton Component 14 + 15 + **Files:** 16 + - Create: `src/components/molecules/grain-profile-header-skeleton.js` 17 + 18 + **Step 1: Create the skeleton component file** 19 + 20 + Create `src/components/molecules/grain-profile-header-skeleton.js`: 21 + 22 + ```javascript 23 + import { LitElement, html, css } from 'lit'; 24 + 25 + export class GrainProfileHeaderSkeleton extends LitElement { 26 + static styles = css` 27 + :host { 28 + display: block; 29 + padding: var(--space-md) var(--space-sm); 30 + } 31 + @media (min-width: 600px) { 32 + :host { 33 + padding-left: 0; 34 + padding-right: 0; 35 + } 36 + } 37 + .top-row { 38 + display: flex; 39 + align-items: flex-start; 40 + gap: var(--space-md); 41 + margin-bottom: var(--space-sm); 42 + } 43 + .right-column { 44 + flex: 1; 45 + min-width: 0; 46 + padding-top: var(--space-xs); 47 + } 48 + .placeholder { 49 + background: var(--color-bg-elevated); 50 + border-radius: 4px; 51 + animation: pulse 1.5s ease-in-out infinite; 52 + } 53 + .avatar { 54 + width: var(--avatar-size-lg, 80px); 55 + height: var(--avatar-size-lg, 80px); 56 + border-radius: 50%; 57 + flex-shrink: 0; 58 + } 59 + .handle { 60 + width: 120px; 61 + height: 20px; 62 + margin-bottom: var(--space-xs); 63 + } 64 + .name { 65 + width: 80px; 66 + height: 14px; 67 + margin-bottom: var(--space-xs); 68 + } 69 + .stats { 70 + display: flex; 71 + gap: var(--space-sm); 72 + margin-bottom: var(--space-xs); 73 + } 74 + .stat { 75 + width: 70px; 76 + height: 14px; 77 + } 78 + .bio-line { 79 + height: 14px; 80 + margin-top: var(--space-xs); 81 + } 82 + .bio-line:first-of-type { 83 + width: 100%; 84 + } 85 + .bio-line:last-of-type { 86 + width: 60%; 87 + } 88 + .button { 89 + width: 100%; 90 + height: 40px; 91 + border-radius: 8px; 92 + margin-top: var(--space-sm); 93 + } 94 + @keyframes pulse { 95 + 0%, 100% { opacity: 0.4; } 96 + 50% { opacity: 1; } 97 + } 98 + `; 99 + 100 + render() { 101 + return html` 102 + <div class="top-row"> 103 + <div class="placeholder avatar"></div> 104 + <div class="right-column"> 105 + <div class="placeholder handle"></div> 106 + <div class="placeholder name"></div> 107 + <div class="stats"> 108 + <div class="placeholder stat"></div> 109 + <div class="placeholder stat"></div> 110 + <div class="placeholder stat"></div> 111 + </div> 112 + <div class="placeholder bio-line"></div> 113 + <div class="placeholder bio-line"></div> 114 + </div> 115 + </div> 116 + <div class="placeholder button"></div> 117 + `; 118 + } 119 + } 120 + 121 + customElements.define('grain-profile-header-skeleton', GrainProfileHeaderSkeleton); 122 + ``` 123 + 124 + **Step 2: Commit the new component** 125 + 126 + ```bash 127 + git add src/components/molecules/grain-profile-header-skeleton.js 128 + git commit -m "feat: add profile header skeleton component" 129 + ``` 130 + 131 + --- 132 + 133 + ### Task 2: Integrate Skeleton into Profile Page 134 + 135 + **Files:** 136 + - Modify: `src/components/pages/grain-profile.js:4` (add import) 137 + - Modify: `src/components/pages/grain-profile.js:171` (swap spinner for skeleton) 138 + 139 + **Step 1: Add import for skeleton component** 140 + 141 + In `src/components/pages/grain-profile.js`, add import after line 8 (after `grain-avatar-crop.js`): 142 + 143 + ```javascript 144 + import '../molecules/grain-profile-header-skeleton.js'; 145 + ``` 146 + 147 + **Step 2: Replace spinner with skeleton** 148 + 149 + In `src/components/pages/grain-profile.js`, change line 171 from: 150 + 151 + ```javascript 152 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 153 + ``` 154 + 155 + To: 156 + 157 + ```javascript 158 + ${this._loading ? html`<grain-profile-header-skeleton></grain-profile-header-skeleton>` : ''} 159 + ``` 160 + 161 + **Step 3: Commit the integration** 162 + 163 + ```bash 164 + git add src/components/pages/grain-profile.js 165 + git commit -m "feat: use skeleton loader for profile page" 166 + ``` 167 + 168 + --- 169 + 170 + ### Task 3: Manual Verification 171 + 172 + **Step 1: Test fresh profile load** 173 + 174 + 1. Run the dev server: `npm run dev` 175 + 2. Clear browser cache or open incognito 176 + 3. Navigate to a profile page (e.g., `/profile/somehandle`) 177 + 4. Observe: skeleton should appear with pulsing animation 178 + 5. Verify: no layout shift when profile data loads 179 + 180 + **Step 2: Test cached navigation** 181 + 182 + 1. Navigate away from profile 183 + 2. Navigate back to same profile 184 + 3. Verify: if cached, profile loads instantly (no skeleton) 185 + 4. Verify: if not cached, skeleton appears 186 + 187 + **Step 3: Verify responsive layout** 188 + 189 + 1. Test on mobile viewport (< 600px) 190 + 2. Test on desktop viewport (> 600px) 191 + 3. Verify: skeleton matches header padding in both views
+922
docs/plans/2025-12-29-richtext-facets.md
··· 1 + # Rich Text Facets Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add Bluesky-compatible rich text facets (mentions, links, hashtags) to comments, gallery descriptions, and profile descriptions. 6 + 7 + **Architecture:** Adapt `~/code/tools/richtext.js` for Bluesky facet types. Parse facets on save for comments/galleries, parse on render for profiles. Create `<grain-rich-text>` component for display. 8 + 9 + **Tech Stack:** Lit components, Bluesky `app.bsky.richtext.facet` format, TextEncoder for UTF-8 byte positions. 10 + 11 + --- 12 + 13 + ## Task 1: Create richtext.js Library 14 + 15 + **Files:** 16 + - Create: `src/lib/richtext.js` 17 + 18 + **Step 1: Create the richtext library with Bluesky facet parsing** 19 + 20 + ```javascript 21 + // src/lib/richtext.js - Bluesky-compatible richtext parsing and rendering 22 + 23 + /** 24 + * Parse text for Bluesky facets: mentions, links, hashtags. 25 + * Returns { text, facets } with byte-indexed positions. 26 + * 27 + * @param {string} text - Plain text to parse 28 + * @param {function} resolveHandle - Optional async function to resolve @handle to DID 29 + * @returns {Promise<{ text: string, facets: Array }>} 30 + */ 31 + export async function parseTextToFacets(text, resolveHandle = null) { 32 + if (!text) return { text: '', facets: [] }; 33 + 34 + const facets = []; 35 + const encoder = new TextEncoder(); 36 + 37 + function getByteOffset(str, charIndex) { 38 + return encoder.encode(str.slice(0, charIndex)).length; 39 + } 40 + 41 + // Track claimed positions to avoid overlaps 42 + const claimedPositions = new Set(); 43 + 44 + function isRangeClaimed(start, end) { 45 + for (let i = start; i < end; i++) { 46 + if (claimedPositions.has(i)) return true; 47 + } 48 + return false; 49 + } 50 + 51 + function claimRange(start, end) { 52 + for (let i = start; i < end; i++) { 53 + claimedPositions.add(i); 54 + } 55 + } 56 + 57 + // URLs first (highest priority) 58 + const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 59 + let urlMatch; 60 + while ((urlMatch = urlRegex.exec(text)) !== null) { 61 + const start = urlMatch.index; 62 + const end = start + urlMatch[0].length; 63 + 64 + if (!isRangeClaimed(start, end)) { 65 + claimRange(start, end); 66 + facets.push({ 67 + index: { 68 + byteStart: getByteOffset(text, start), 69 + byteEnd: getByteOffset(text, end), 70 + }, 71 + features: [{ 72 + $type: 'app.bsky.richtext.facet#link', 73 + uri: urlMatch[0], 74 + }], 75 + }); 76 + } 77 + } 78 + 79 + // Mentions: @handle or @handle.domain.tld 80 + const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 81 + let mentionMatch; 82 + while ((mentionMatch = mentionRegex.exec(text)) !== null) { 83 + const start = mentionMatch.index; 84 + const end = start + mentionMatch[0].length; 85 + const handle = mentionMatch[0].slice(1); // Remove @ 86 + 87 + if (!isRangeClaimed(start, end)) { 88 + // Try to resolve handle to DID 89 + let did = null; 90 + if (resolveHandle) { 91 + try { 92 + did = await resolveHandle(handle); 93 + } catch (e) { 94 + // Skip this mention if resolution fails 95 + continue; 96 + } 97 + } 98 + 99 + if (did) { 100 + claimRange(start, end); 101 + facets.push({ 102 + index: { 103 + byteStart: getByteOffset(text, start), 104 + byteEnd: getByteOffset(text, end), 105 + }, 106 + features: [{ 107 + $type: 'app.bsky.richtext.facet#mention', 108 + did, 109 + }], 110 + }); 111 + } 112 + } 113 + } 114 + 115 + // Hashtags: #tag (alphanumeric, no leading numbers) 116 + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 117 + let hashtagMatch; 118 + while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 119 + const start = hashtagMatch.index; 120 + const end = start + hashtagMatch[0].length; 121 + const tag = hashtagMatch[1]; // Without # 122 + 123 + if (!isRangeClaimed(start, end)) { 124 + claimRange(start, end); 125 + facets.push({ 126 + index: { 127 + byteStart: getByteOffset(text, start), 128 + byteEnd: getByteOffset(text, end), 129 + }, 130 + features: [{ 131 + $type: 'app.bsky.richtext.facet#tag', 132 + tag, 133 + }], 134 + }); 135 + } 136 + } 137 + 138 + // Sort by byte position 139 + facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 140 + 141 + return { text, facets }; 142 + } 143 + 144 + /** 145 + * Synchronous parsing for client-side render (no DID resolution). 146 + * Mentions display as-is without profile links. 147 + */ 148 + export function parseTextToFacetsSync(text) { 149 + if (!text) return { text: '', facets: [] }; 150 + 151 + const facets = []; 152 + const encoder = new TextEncoder(); 153 + 154 + function getByteOffset(str, charIndex) { 155 + return encoder.encode(str.slice(0, charIndex)).length; 156 + } 157 + 158 + const claimedPositions = new Set(); 159 + 160 + function isRangeClaimed(start, end) { 161 + for (let i = start; i < end; i++) { 162 + if (claimedPositions.has(i)) return true; 163 + } 164 + return false; 165 + } 166 + 167 + function claimRange(start, end) { 168 + for (let i = start; i < end; i++) { 169 + claimedPositions.add(i); 170 + } 171 + } 172 + 173 + // URLs 174 + const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 175 + let urlMatch; 176 + while ((urlMatch = urlRegex.exec(text)) !== null) { 177 + const start = urlMatch.index; 178 + const end = start + urlMatch[0].length; 179 + 180 + if (!isRangeClaimed(start, end)) { 181 + claimRange(start, end); 182 + facets.push({ 183 + index: { 184 + byteStart: getByteOffset(text, start), 185 + byteEnd: getByteOffset(text, end), 186 + }, 187 + features: [{ 188 + $type: 'app.bsky.richtext.facet#link', 189 + uri: urlMatch[0], 190 + }], 191 + }); 192 + } 193 + } 194 + 195 + // Hashtags 196 + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 197 + let hashtagMatch; 198 + while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 199 + const start = hashtagMatch.index; 200 + const end = start + hashtagMatch[0].length; 201 + const tag = hashtagMatch[1]; 202 + 203 + if (!isRangeClaimed(start, end)) { 204 + claimRange(start, end); 205 + facets.push({ 206 + index: { 207 + byteStart: getByteOffset(text, start), 208 + byteEnd: getByteOffset(text, end), 209 + }, 210 + features: [{ 211 + $type: 'app.bsky.richtext.facet#tag', 212 + tag, 213 + }], 214 + }); 215 + } 216 + } 217 + 218 + facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 219 + return { text, facets }; 220 + } 221 + 222 + /** 223 + * Render text with facets as HTML. 224 + * 225 + * @param {string} text - The text content 226 + * @param {Array} facets - Array of facet objects 227 + * @param {Object} options - Rendering options 228 + * @returns {string} HTML string 229 + */ 230 + export function renderFacetedText(text, facets, options = {}) { 231 + if (!text) return ''; 232 + 233 + // If no facets, just escape and return 234 + if (!facets || facets.length === 0) { 235 + return escapeHtml(text); 236 + } 237 + 238 + const encoder = new TextEncoder(); 239 + const decoder = new TextDecoder(); 240 + const bytes = encoder.encode(text); 241 + 242 + // Sort facets by start position 243 + const sortedFacets = [...facets].sort( 244 + (a, b) => a.index.byteStart - b.index.byteStart 245 + ); 246 + 247 + let result = ''; 248 + let lastEnd = 0; 249 + 250 + for (const facet of sortedFacets) { 251 + // Validate byte indices 252 + if (facet.index.byteStart < 0 || facet.index.byteEnd > bytes.length) { 253 + continue; // Skip invalid facets 254 + } 255 + 256 + // Add text before this facet 257 + if (facet.index.byteStart > lastEnd) { 258 + const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart); 259 + result += escapeHtml(decoder.decode(beforeBytes)); 260 + } 261 + 262 + // Get the faceted text 263 + const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd); 264 + const facetText = decoder.decode(facetBytes); 265 + 266 + // Determine facet type and render 267 + const feature = facet.features?.[0]; 268 + if (!feature) { 269 + result += escapeHtml(facetText); 270 + lastEnd = facet.index.byteEnd; 271 + continue; 272 + } 273 + 274 + const type = feature.$type || feature.__typename || ''; 275 + 276 + if (type.includes('link')) { 277 + const uri = feature.uri || ''; 278 + result += `<a href="${escapeHtml(uri)}" target="_blank" rel="noopener noreferrer" class="facet-link">${escapeHtml(facetText)}</a>`; 279 + } else if (type.includes('mention')) { 280 + // Extract handle from text (remove @) 281 + const handle = facetText.startsWith('@') ? facetText.slice(1) : facetText; 282 + result += `<a href="/profile/${escapeHtml(handle)}" class="facet-mention">${escapeHtml(facetText)}</a>`; 283 + } else if (type.includes('tag')) { 284 + // Hashtag - styled but not clickable for now 285 + result += `<span class="facet-tag">${escapeHtml(facetText)}</span>`; 286 + } else { 287 + result += escapeHtml(facetText); 288 + } 289 + 290 + lastEnd = facet.index.byteEnd; 291 + } 292 + 293 + // Add remaining text 294 + if (lastEnd < bytes.length) { 295 + const remainingBytes = bytes.slice(lastEnd); 296 + result += escapeHtml(decoder.decode(remainingBytes)); 297 + } 298 + 299 + return result; 300 + } 301 + 302 + function escapeHtml(text) { 303 + return text 304 + .replace(/&/g, '&amp;') 305 + .replace(/</g, '&lt;') 306 + .replace(/>/g, '&gt;') 307 + .replace(/"/g, '&quot;') 308 + .replace(/'/g, '&#039;'); 309 + } 310 + ``` 311 + 312 + **Step 2: Verify file exists** 313 + 314 + Run: `ls -la src/lib/richtext.js` 315 + Expected: File exists with correct permissions 316 + 317 + **Step 3: Commit** 318 + 319 + ```bash 320 + git add src/lib/richtext.js 321 + git commit -m "feat: add richtext library for Bluesky facets" 322 + ``` 323 + 324 + --- 325 + 326 + ## Task 2: Add Handle Resolution to grain-api 327 + 328 + **Files:** 329 + - Modify: `src/services/grain-api.js` 330 + 331 + **Step 1: Add resolveHandle method to GrainApiService** 332 + 333 + Add after the `getComments` method (around line 1095): 334 + 335 + ```javascript 336 + async resolveHandle(handle) { 337 + const query = ` 338 + query ResolveHandle($handle: String!) { 339 + socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { 340 + edges { 341 + node { did } 342 + } 343 + } 344 + } 345 + `; 346 + 347 + const response = await this.#execute(query, { handle }); 348 + const did = response.data?.socialGrainActorProfile?.edges?.[0]?.node?.did; 349 + 350 + if (!did) { 351 + throw new Error(`Handle not found: ${handle}`); 352 + } 353 + 354 + return did; 355 + } 356 + ``` 357 + 358 + **Step 2: Verify syntax** 359 + 360 + Run: `node --check src/services/grain-api.js` 361 + Expected: No syntax errors 362 + 363 + **Step 3: Commit** 364 + 365 + ```bash 366 + git add src/services/grain-api.js 367 + git commit -m "feat: add resolveHandle method for mention facets" 368 + ``` 369 + 370 + --- 371 + 372 + ## Task 3: Create grain-rich-text Component 373 + 374 + **Files:** 375 + - Create: `src/components/atoms/grain-rich-text.js` 376 + 377 + **Step 1: Create the component** 378 + 379 + ```javascript 380 + // src/components/atoms/grain-rich-text.js 381 + import { LitElement, html, css } from 'lit'; 382 + import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 383 + import { renderFacetedText, parseTextToFacetsSync } from '../../lib/richtext.js'; 384 + 385 + export class GrainRichText extends LitElement { 386 + static properties = { 387 + text: { type: String }, 388 + facets: { type: Array }, 389 + parse: { type: Boolean } 390 + }; 391 + 392 + static styles = css` 393 + :host { 394 + display: inline; 395 + } 396 + .facet-link { 397 + color: var(--color-link, #0066cc); 398 + text-decoration: none; 399 + } 400 + .facet-link:hover { 401 + text-decoration: underline; 402 + } 403 + .facet-mention { 404 + color: var(--color-link, #0066cc); 405 + text-decoration: none; 406 + } 407 + .facet-mention:hover { 408 + text-decoration: underline; 409 + } 410 + .facet-tag { 411 + color: var(--color-link, #0066cc); 412 + } 413 + `; 414 + 415 + constructor() { 416 + super(); 417 + this.text = ''; 418 + this.facets = null; 419 + this.parse = false; 420 + } 421 + 422 + render() { 423 + if (!this.text) return ''; 424 + 425 + let facetsToUse = this.facets; 426 + 427 + // If parse mode and no facets provided, parse on the fly 428 + if (this.parse && (!this.facets || this.facets.length === 0)) { 429 + const parsed = parseTextToFacetsSync(this.text); 430 + facetsToUse = parsed.facets; 431 + } 432 + 433 + const htmlContent = renderFacetedText(this.text, facetsToUse || []); 434 + return html`${unsafeHTML(htmlContent)}`; 435 + } 436 + } 437 + 438 + customElements.define('grain-rich-text', GrainRichText); 439 + ``` 440 + 441 + **Step 2: Verify syntax** 442 + 443 + Run: `node --check src/components/atoms/grain-rich-text.js` 444 + Expected: No syntax errors 445 + 446 + **Step 3: Commit** 447 + 448 + ```bash 449 + git add src/components/atoms/grain-rich-text.js 450 + git commit -m "feat: add grain-rich-text component for facet rendering" 451 + ``` 452 + 453 + --- 454 + 455 + ## Task 4: Integrate Facet Parsing into Comment Creation 456 + 457 + **Files:** 458 + - Modify: `src/services/mutations.js` 459 + 460 + **Step 1: Add import at top of file** 461 + 462 + ```javascript 463 + import { parseTextToFacets } from '../lib/richtext.js'; 464 + import { grainApi } from './grain-api.js'; 465 + ``` 466 + 467 + **Step 2: Modify createComment method (around line 103)** 468 + 469 + Replace the existing `createComment` method: 470 + 471 + ```javascript 472 + async createComment(galleryUri, text, replyToUri = null, focusUri = null) { 473 + const client = auth.getClient(); 474 + 475 + // Parse text for facets with handle resolution 476 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 477 + const { facets } = await parseTextToFacets(text, resolveHandle); 478 + 479 + const input = { 480 + subject: galleryUri, 481 + text, 482 + createdAt: new Date().toISOString() 483 + }; 484 + 485 + // Only include facets if we found any 486 + if (facets && facets.length > 0) { 487 + input.facets = facets; 488 + } 489 + 490 + if (replyToUri) { 491 + input.replyTo = replyToUri; 492 + } 493 + 494 + if (focusUri) { 495 + input.focus = focusUri; 496 + } 497 + 498 + const result = await client.mutate(` 499 + mutation CreateComment($input: SocialGrainCommentInput!) { 500 + createSocialGrainComment(input: $input) { uri } 501 + } 502 + `, { input }); 503 + 504 + return result.createSocialGrainComment.uri; 505 + } 506 + ``` 507 + 508 + **Step 3: Verify syntax** 509 + 510 + Run: `node --check src/services/mutations.js` 511 + Expected: No syntax errors 512 + 513 + **Step 4: Commit** 514 + 515 + ```bash 516 + git add src/services/mutations.js 517 + git commit -m "feat: parse facets when creating comments" 518 + ``` 519 + 520 + --- 521 + 522 + ## Task 5: Integrate Facet Parsing into Gallery Creation 523 + 524 + **Files:** 525 + - Modify: `src/components/pages/grain-create-gallery.js` 526 + 527 + **Step 1: Add import at top of file** 528 + 529 + ```javascript 530 + import { parseTextToFacets } from '../../lib/richtext.js'; 531 + import { grainApi } from '../../services/grain-api.js'; 532 + ``` 533 + 534 + **Step 2: Modify the gallery creation in #handlePost (around line 219)** 535 + 536 + Find this code block: 537 + ```javascript 538 + // Create gallery record 539 + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 540 + input: { 541 + title: this._title.trim(), 542 + ...(this._description.trim() && { description: this._description.trim() }), 543 + createdAt: now 544 + } 545 + }); 546 + ``` 547 + 548 + Replace with: 549 + ```javascript 550 + // Parse description for facets 551 + let facets = null; 552 + if (this._description.trim()) { 553 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 554 + const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); 555 + if (parsed.facets.length > 0) { 556 + facets = parsed.facets; 557 + } 558 + } 559 + 560 + // Create gallery record 561 + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 562 + input: { 563 + title: this._title.trim(), 564 + ...(this._description.trim() && { description: this._description.trim() }), 565 + ...(facets && { facets }), 566 + createdAt: now 567 + } 568 + }); 569 + ``` 570 + 571 + **Step 3: Verify syntax** 572 + 573 + Run: `node --check src/components/pages/grain-create-gallery.js` 574 + Expected: No syntax errors 575 + 576 + **Step 4: Commit** 577 + 578 + ```bash 579 + git add src/components/pages/grain-create-gallery.js 580 + git commit -m "feat: parse facets when creating galleries" 581 + ``` 582 + 583 + --- 584 + 585 + ## Task 6: Add Facets to GraphQL Queries 586 + 587 + **Files:** 588 + - Modify: `src/services/grain-api.js` 589 + 590 + **Step 1: Add facets to getGalleryDetail comment query (around line 636)** 591 + 592 + Find the comment query section in `getGalleryDetail`: 593 + ```javascript 594 + socialGrainCommentViaSubject( 595 + first: 20 596 + sortBy: [{ field: createdAt, direction: ASC }] 597 + ) { 598 + totalCount 599 + edges { 600 + node { 601 + uri 602 + text 603 + createdAt 604 + ``` 605 + 606 + Add `facets` field after `text`: 607 + ```javascript 608 + uri 609 + text 610 + facets 611 + createdAt 612 + ``` 613 + 614 + **Step 2: Add facets field to gallery description query** 615 + 616 + In the same `getGalleryDetail` query, find where gallery fields are queried: 617 + ```javascript 618 + uri 619 + did 620 + actorHandle 621 + title 622 + description 623 + createdAt 624 + ``` 625 + 626 + Add `facets` after `description`: 627 + ```javascript 628 + uri 629 + did 630 + actorHandle 631 + title 632 + description 633 + facets 634 + createdAt 635 + ``` 636 + 637 + **Step 3: Update the comment transform (around line 700)** 638 + 639 + Find the comment mapping: 640 + ```javascript 641 + const comments = galleryNode.socialGrainCommentViaSubject?.edges?.map(edge => { 642 + const node = edge.node; 643 + const commentProfile = node.socialGrainActorProfileByDid; 644 + const focusPhoto = node.focusResolved; 645 + return { 646 + uri: node.uri, 647 + text: node.text, 648 + createdAt: node.createdAt, 649 + ``` 650 + 651 + Add facets to the returned object: 652 + ```javascript 653 + return { 654 + uri: node.uri, 655 + text: node.text, 656 + facets: node.facets || [], 657 + createdAt: node.createdAt, 658 + ``` 659 + 660 + **Step 4: Update gallery return object (around line 717)** 661 + 662 + Find the return statement: 663 + ```javascript 664 + return { 665 + uri: galleryNode.uri, 666 + title: galleryNode.title, 667 + description: galleryNode.description, 668 + ``` 669 + 670 + Add facets: 671 + ```javascript 672 + return { 673 + uri: galleryNode.uri, 674 + title: galleryNode.title, 675 + description: galleryNode.description, 676 + facets: galleryNode.facets || [], 677 + ``` 678 + 679 + **Step 5: Verify syntax** 680 + 681 + Run: `node --check src/services/grain-api.js` 682 + Expected: No syntax errors 683 + 684 + **Step 6: Commit** 685 + 686 + ```bash 687 + git add src/services/grain-api.js 688 + git commit -m "feat: query facets for comments and gallery descriptions" 689 + ``` 690 + 691 + --- 692 + 693 + ## Task 7: Update grain-comment to Render Facets 694 + 695 + **Files:** 696 + - Modify: `src/components/molecules/grain-comment.js` 697 + 698 + **Step 1: Add import and facets property** 699 + 700 + Add at top of file: 701 + ```javascript 702 + import '../atoms/grain-rich-text.js'; 703 + ``` 704 + 705 + Add to static properties: 706 + ```javascript 707 + static properties = { 708 + uri: { type: String }, 709 + handle: { type: String }, 710 + displayName: { type: String }, 711 + avatarUrl: { type: String }, 712 + text: { type: String }, 713 + facets: { type: Array }, // Add this line 714 + createdAt: { type: String }, 715 + ``` 716 + 717 + **Step 2: Initialize facets in constructor** 718 + 719 + Add after `this.text = '';`: 720 + ```javascript 721 + this.facets = []; 722 + ``` 723 + 724 + **Step 3: Update render method (around line 162)** 725 + 726 + Find: 727 + ```javascript 728 + <span class="text">${this.text}</span> 729 + ``` 730 + 731 + Replace with: 732 + ```javascript 733 + <span class="text"><grain-rich-text .text=${this.text} .facets=${this.facets}></grain-rich-text></span> 734 + ``` 735 + 736 + **Step 4: Verify syntax** 737 + 738 + Run: `node --check src/components/molecules/grain-comment.js` 739 + Expected: No syntax errors 740 + 741 + **Step 5: Commit** 742 + 743 + ```bash 744 + git add src/components/molecules/grain-comment.js 745 + git commit -m "feat: render comment facets with grain-rich-text" 746 + ``` 747 + 748 + --- 749 + 750 + ## Task 8: Pass Facets to grain-comment in Comment Sheet 751 + 752 + **Files:** 753 + - Modify: `src/components/organisms/grain-comment-sheet.js` 754 + 755 + **Step 1: Find comment rendering and add facets** 756 + 757 + Find where `<grain-comment>` is used and add `.facets` property. 758 + 759 + Look for pattern like: 760 + ```javascript 761 + <grain-comment 762 + uri=${comment.uri} 763 + handle=${comment.handle} 764 + ... 765 + text=${comment.text} 766 + ``` 767 + 768 + Add facets: 769 + ```javascript 770 + .facets=${comment.facets || []} 771 + ``` 772 + 773 + **Step 2: Verify syntax** 774 + 775 + Run: `node --check src/components/organisms/grain-comment-sheet.js` 776 + Expected: No syntax errors 777 + 778 + **Step 3: Commit** 779 + 780 + ```bash 781 + git add src/components/organisms/grain-comment-sheet.js 782 + git commit -m "feat: pass facets to grain-comment in comment sheet" 783 + ``` 784 + 785 + --- 786 + 787 + ## Task 9: Update Gallery Detail to Render Description Facets 788 + 789 + **Files:** 790 + - Modify: `src/components/pages/grain-gallery-detail.js` 791 + 792 + **Step 1: Add import** 793 + 794 + Add at top: 795 + ```javascript 796 + import '../atoms/grain-rich-text.js'; 797 + ``` 798 + 799 + **Step 2: Update description rendering (around line 399)** 800 + 801 + Find: 802 + ```javascript 803 + ${this._gallery.description ? html` 804 + <p class="description">${this._gallery.description}</p> 805 + ` : ''} 806 + ``` 807 + 808 + Replace with: 809 + ```javascript 810 + ${this._gallery.description ? html` 811 + <p class="description"><grain-rich-text .text=${this._gallery.description} .facets=${this._gallery.facets || []}></grain-rich-text></p> 812 + ` : ''} 813 + ``` 814 + 815 + **Step 3: Verify syntax** 816 + 817 + Run: `node --check src/components/pages/grain-gallery-detail.js` 818 + Expected: No syntax errors 819 + 820 + **Step 4: Commit** 821 + 822 + ```bash 823 + git add src/components/pages/grain-gallery-detail.js 824 + git commit -m "feat: render gallery description facets" 825 + ``` 826 + 827 + --- 828 + 829 + ## Task 10: Update Profile Header to Render Bio Facets 830 + 831 + **Files:** 832 + - Modify: `src/components/organisms/grain-profile-header.js` 833 + 834 + **Step 1: Add import** 835 + 836 + Add at top: 837 + ```javascript 838 + import '../atoms/grain-rich-text.js'; 839 + ``` 840 + 841 + **Step 2: Update bio rendering (around line 302)** 842 + 843 + Find: 844 + ```javascript 845 + ${description ? html`<div class="bio">${description}</div>` : ''} 846 + ``` 847 + 848 + Replace with: 849 + ```javascript 850 + ${description ? html`<div class="bio"><grain-rich-text .text=${description} parse></grain-rich-text></div>` : ''} 851 + ``` 852 + 853 + Note: Using `parse` attribute since profile lexicon doesn't store facets yet. 854 + 855 + **Step 3: Verify syntax** 856 + 857 + Run: `node --check src/components/organisms/grain-profile-header.js` 858 + Expected: No syntax errors 859 + 860 + **Step 4: Commit** 861 + 862 + ```bash 863 + git add src/components/organisms/grain-profile-header.js 864 + git commit -m "feat: render profile bio with client-side facet parsing" 865 + ``` 866 + 867 + --- 868 + 869 + ## Task 11: Final Verification 870 + 871 + **Step 1: Check all files exist and have no syntax errors** 872 + 873 + Run: 874 + ```bash 875 + node --check src/lib/richtext.js && \ 876 + node --check src/components/atoms/grain-rich-text.js && \ 877 + node --check src/services/mutations.js && \ 878 + node --check src/services/grain-api.js && \ 879 + node --check src/components/pages/grain-create-gallery.js && \ 880 + node --check src/components/molecules/grain-comment.js && \ 881 + node --check src/components/pages/grain-gallery-detail.js && \ 882 + node --check src/components/organisms/grain-profile-header.js && \ 883 + echo "All files OK" 884 + ``` 885 + 886 + Expected: "All files OK" 887 + 888 + **Step 2: Run the dev server and test manually** 889 + 890 + Run: `npm run dev` 891 + 892 + Test checklist: 893 + - [ ] Create a comment with a URL - verify it becomes clickable 894 + - [ ] Create a comment with @mention - verify it links to profile 895 + - [ ] Create a comment with #hashtag - verify it's styled 896 + - [ ] Create a gallery with description containing URL - verify rendering 897 + - [ ] View a profile with URLs in bio - verify they're clickable 898 + 899 + **Step 3: Final commit with all changes** 900 + 901 + ```bash 902 + git add -A 903 + git status 904 + # If all looks good: 905 + git commit -m "feat: complete richtext facet support for comments, galleries, and profiles" 906 + ``` 907 + 908 + --- 909 + 910 + ## Summary 911 + 912 + | File | Change | 913 + |------|--------| 914 + | `src/lib/richtext.js` | New - Parsing and rendering library | 915 + | `src/components/atoms/grain-rich-text.js` | New - Display component | 916 + | `src/services/grain-api.js` | Add `resolveHandle`, query facets | 917 + | `src/services/mutations.js` | Parse facets on comment creation | 918 + | `src/components/pages/grain-create-gallery.js` | Parse facets on gallery creation | 919 + | `src/components/molecules/grain-comment.js` | Render facets | 920 + | `src/components/organisms/grain-comment-sheet.js` | Pass facets to comment | 921 + | `src/components/pages/grain-gallery-detail.js` | Render description facets | 922 + | `src/components/organisms/grain-profile-header.js` | Client-side facet parsing for bio |
+265
docs/plans/2025-12-29-scroll-to-top.md
··· 1 + # Scroll-to-Top Button Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a floating action button that appears when scrolled down, scrolls to top and refreshes feed on click. 6 + 7 + **Architecture:** New `grain-scroll-to-top` atom component with visibility controlled by parent. Timeline tracks scroll position and handles click to scroll + refresh. 8 + 9 + **Tech Stack:** Lit, CSS custom properties, existing `grain-icon` component 10 + 11 + --- 12 + 13 + ### Task 1: Add arrow-up icon to grain-icon 14 + 15 + **Files:** 16 + - Modify: `src/components/atoms/grain-icon.js:4-26` 17 + 18 + **Step 1: Add arrowUp to ICONS object** 19 + 20 + Add after line 8 (`back: 'fa-solid fa-arrow-left',`): 21 + 22 + ```javascript 23 + arrowUp: 'fa-solid fa-arrow-up', 24 + ``` 25 + 26 + **Step 2: Verify icon renders** 27 + 28 + Open app in browser, temporarily add `<grain-icon name="arrowUp"></grain-icon>` anywhere to confirm it renders. 29 + 30 + **Step 3: Commit** 31 + 32 + ```bash 33 + git add src/components/atoms/grain-icon.js 34 + git commit -m "feat: add arrowUp icon" 35 + ``` 36 + 37 + --- 38 + 39 + ### Task 2: Create grain-scroll-to-top component 40 + 41 + **Files:** 42 + - Create: `src/components/atoms/grain-scroll-to-top.js` 43 + 44 + **Step 1: Create the component file** 45 + 46 + ```javascript 47 + import { LitElement, html, css } from 'lit'; 48 + import './grain-icon.js'; 49 + 50 + export class GrainScrollToTop extends LitElement { 51 + static properties = { 52 + visible: { type: Boolean } 53 + }; 54 + 55 + static styles = css` 56 + :host { 57 + position: fixed; 58 + bottom: 20px; 59 + left: 20px; 60 + z-index: 100; 61 + } 62 + button { 63 + display: flex; 64 + align-items: center; 65 + justify-content: center; 66 + width: 48px; 67 + height: 48px; 68 + border-radius: 50%; 69 + border: 1px solid var(--color-border); 70 + background: var(--color-surface-secondary); 71 + color: var(--color-accent); 72 + cursor: pointer; 73 + opacity: 0; 74 + pointer-events: none; 75 + transition: opacity 0.2s ease-in-out; 76 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 77 + } 78 + button.visible { 79 + opacity: 1; 80 + pointer-events: auto; 81 + } 82 + button:hover { 83 + filter: brightness(1.1); 84 + } 85 + button:active { 86 + transform: scale(0.95); 87 + } 88 + `; 89 + 90 + constructor() { 91 + super(); 92 + this.visible = false; 93 + } 94 + 95 + #handleClick() { 96 + this.dispatchEvent(new CustomEvent('scroll-top', { 97 + bubbles: true, 98 + composed: true 99 + })); 100 + } 101 + 102 + render() { 103 + return html` 104 + <button 105 + class=${this.visible ? 'visible' : ''} 106 + @click=${this.#handleClick} 107 + aria-label="Scroll to top" 108 + > 109 + <grain-icon name="arrowUp" size="20"></grain-icon> 110 + </button> 111 + `; 112 + } 113 + } 114 + 115 + customElements.define('grain-scroll-to-top', GrainScrollToTop); 116 + ``` 117 + 118 + **Step 2: Commit** 119 + 120 + ```bash 121 + git add src/components/atoms/grain-scroll-to-top.js 122 + git commit -m "feat: add grain-scroll-to-top component" 123 + ``` 124 + 125 + --- 126 + 127 + ### Task 3: Add scroll tracking to grain-timeline 128 + 129 + **Files:** 130 + - Modify: `src/components/pages/grain-timeline.js` 131 + 132 + **Step 1: Add state property for scroll button visibility** 133 + 134 + Add to static properties (line 13-24): 135 + 136 + ```javascript 137 + _showScrollTop: { state: true }, 138 + ``` 139 + 140 + **Step 2: Initialize state in constructor** 141 + 142 + Add after line 59 (`this._focusPhotoUrl = null;`): 143 + 144 + ```javascript 145 + this._showScrollTop = false; 146 + ``` 147 + 148 + **Step 3: Add scroll listener setup/teardown** 149 + 150 + Add bound handler property after line 46 (`#initialized = false;`): 151 + 152 + ```javascript 153 + #boundHandleScroll = null; 154 + ``` 155 + 156 + Add to connectedCallback (after line 88): 157 + 158 + ```javascript 159 + this.#boundHandleScroll = this.#handleScroll.bind(this); 160 + window.addEventListener('scroll', this.#boundHandleScroll, { passive: true }); 161 + ``` 162 + 163 + Add to disconnectedCallback (after line 93): 164 + 165 + ```javascript 166 + if (this.#boundHandleScroll) { 167 + window.removeEventListener('scroll', this.#boundHandleScroll); 168 + } 169 + ``` 170 + 171 + **Step 4: Add scroll handler method** 172 + 173 + Add after `#handleCommentSheetClose()` method (after line 194): 174 + 175 + ```javascript 176 + #handleScroll() { 177 + this._showScrollTop = window.scrollY > 150; 178 + } 179 + ``` 180 + 181 + **Step 5: Commit** 182 + 183 + ```bash 184 + git add src/components/pages/grain-timeline.js 185 + git commit -m "feat: add scroll position tracking to timeline" 186 + ``` 187 + 188 + --- 189 + 190 + ### Task 4: Add scroll-to-top button and handler to timeline 191 + 192 + **Files:** 193 + - Modify: `src/components/pages/grain-timeline.js` 194 + 195 + **Step 1: Import the component** 196 + 197 + Add after line 10 (`import '../atoms/grain-spinner.js';`): 198 + 199 + ```javascript 200 + import '../atoms/grain-scroll-to-top.js'; 201 + ``` 202 + 203 + **Step 2: Add click handler method** 204 + 205 + Add after `#handleScroll()` method: 206 + 207 + ```javascript 208 + async #handleScrollTop() { 209 + if (this._refreshing) return; 210 + 211 + window.scrollTo({ top: 0, behavior: 'smooth' }); 212 + 213 + // Wait for scroll to complete before refreshing 214 + await new Promise(resolve => setTimeout(resolve, 400)); 215 + 216 + await this.#handleRefresh(); 217 + } 218 + ``` 219 + 220 + **Step 3: Add component to render** 221 + 222 + Add after the closing `</grain-feed-layout>` tag (before line 229's closing backtick), outside the feed-layout: 223 + 224 + ```javascript 225 + <grain-scroll-to-top 226 + ?visible=${this._showScrollTop} 227 + @scroll-top=${this.#handleScrollTop} 228 + ></grain-scroll-to-top> 229 + ``` 230 + 231 + **Step 4: Commit** 232 + 233 + ```bash 234 + git add src/components/pages/grain-timeline.js 235 + git commit -m "feat: integrate scroll-to-top button in timeline" 236 + ``` 237 + 238 + --- 239 + 240 + ### Task 5: Manual verification 241 + 242 + **Step 1: Test scroll appearance** 243 + 244 + 1. Open the app timeline 245 + 2. Scroll down past 150px 246 + 3. Verify button fades in at bottom-left 247 + 248 + **Step 2: Test click behavior** 249 + 250 + 1. Click the button 251 + 2. Verify smooth scroll to top 252 + 3. Verify feed refreshes (loading spinner appears briefly) 253 + 4. Verify button fades out when at top 254 + 255 + **Step 3: Test edge cases** 256 + 257 + 1. Scroll down, click button multiple times rapidly - should not double-refresh 258 + 2. Button should not appear when already at top 259 + 260 + **Step 4: Final commit if any fixes needed** 261 + 262 + ```bash 263 + git add -A 264 + git commit -m "fix: scroll-to-top refinements" 265 + ```
+1046
docs/plans/2025-12-30-alt-text-feature.md
··· 1 + # Alt Text Feature Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add alt text input during gallery creation and display an ALT badge on images that have alt text. 6 + 7 + **Architecture:** Two-step gallery creation flow (title/description โ†’ image descriptions), plus an ALT badge component that shows alt text in an overlay when clicked. 8 + 9 + **Tech Stack:** Lit, CSS positioning, existing grain-icon component 10 + 11 + --- 12 + 13 + ### Task 1: Update Draft Gallery Service 14 + 15 + **Files:** 16 + - Modify: `src/services/draft-gallery.js` 17 + 18 + **Step 1: Add updatePhotoAlt method** 19 + 20 + Update the service to support setting alt text on individual photos: 21 + 22 + ```javascript 23 + class DraftGalleryService { 24 + #photos = []; 25 + 26 + setPhotos(photos) { 27 + // Ensure each photo has an alt property 28 + this.#photos = photos.map(p => ({ ...p, alt: p.alt || '' })); 29 + } 30 + 31 + getPhotos() { 32 + return this.#photos; 33 + } 34 + 35 + updatePhotoAlt(index, alt) { 36 + if (index >= 0 && index < this.#photos.length) { 37 + this.#photos[index] = { ...this.#photos[index], alt }; 38 + } 39 + } 40 + 41 + clear() { 42 + this.#photos = []; 43 + } 44 + 45 + get hasPhotos() { 46 + return this.#photos.length > 0; 47 + } 48 + } 49 + 50 + export const draftGallery = new DraftGalleryService(); 51 + ``` 52 + 53 + **Step 2: Commit** 54 + 55 + ```bash 56 + git add src/services/draft-gallery.js 57 + git commit -m "feat: add alt text support to draft gallery service" 58 + ``` 59 + 60 + --- 61 + 62 + ### Task 2: Create Image Descriptions Page 63 + 64 + **Files:** 65 + - Create: `src/components/pages/grain-image-descriptions.js` 66 + 67 + **Step 1: Create the page component** 68 + 69 + ```javascript 70 + import { LitElement, html, css } from 'lit'; 71 + import { router } from '../../router.js'; 72 + import { auth } from '../../services/auth.js'; 73 + import { draftGallery } from '../../services/draft-gallery.js'; 74 + import { parseTextToFacets } from '../../lib/richtext.js'; 75 + import { grainApi } from '../../services/grain-api.js'; 76 + import '../atoms/grain-icon.js'; 77 + import '../atoms/grain-button.js'; 78 + 79 + const UPLOAD_BLOB_MUTATION = ` 80 + mutation UploadBlob($data: String!, $mimeType: String!) { 81 + uploadBlob(data: $data, mimeType: $mimeType) { 82 + ref 83 + mimeType 84 + size 85 + } 86 + } 87 + `; 88 + 89 + const CREATE_PHOTO_MUTATION = ` 90 + mutation CreatePhoto($input: SocialGrainPhotoInput!) { 91 + createSocialGrainPhoto(input: $input) { 92 + uri 93 + } 94 + } 95 + `; 96 + 97 + const CREATE_GALLERY_MUTATION = ` 98 + mutation CreateGallery($input: SocialGrainGalleryInput!) { 99 + createSocialGrainGallery(input: $input) { 100 + uri 101 + } 102 + } 103 + `; 104 + 105 + const CREATE_GALLERY_ITEM_MUTATION = ` 106 + mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) { 107 + createSocialGrainGalleryItem(input: $input) { 108 + uri 109 + } 110 + } 111 + `; 112 + 113 + export class GrainImageDescriptions extends LitElement { 114 + static properties = { 115 + _photos: { state: true }, 116 + _title: { state: true }, 117 + _description: { state: true }, 118 + _posting: { state: true }, 119 + _error: { state: true } 120 + }; 121 + 122 + static styles = css` 123 + :host { 124 + display: block; 125 + width: 100%; 126 + max-width: var(--feed-max-width); 127 + min-height: 100%; 128 + background: var(--color-bg-primary); 129 + align-self: center; 130 + } 131 + .header { 132 + display: flex; 133 + align-items: center; 134 + justify-content: space-between; 135 + padding: var(--space-sm); 136 + border-bottom: 1px solid var(--color-border); 137 + } 138 + .header-left { 139 + display: flex; 140 + align-items: center; 141 + gap: var(--space-xs); 142 + } 143 + .back-button { 144 + background: none; 145 + border: none; 146 + padding: 8px; 147 + margin-left: -8px; 148 + cursor: pointer; 149 + color: var(--color-text-primary); 150 + } 151 + .header-title { 152 + font-size: var(--font-size-md); 153 + font-weight: 600; 154 + } 155 + .photo-list { 156 + padding: var(--space-sm); 157 + } 158 + .photo-row { 159 + display: flex; 160 + gap: var(--space-sm); 161 + margin-bottom: var(--space-md); 162 + } 163 + .photo-thumb { 164 + flex-shrink: 0; 165 + width: 80px; 166 + height: 80px; 167 + border-radius: 4px; 168 + object-fit: cover; 169 + } 170 + .alt-input { 171 + flex: 1; 172 + display: flex; 173 + flex-direction: column; 174 + } 175 + .alt-input textarea { 176 + flex: 1; 177 + min-height: 60px; 178 + padding: var(--space-xs); 179 + border: 1px solid var(--color-border); 180 + border-radius: 4px; 181 + font-family: inherit; 182 + font-size: var(--font-size-sm); 183 + resize: none; 184 + background: var(--color-bg-primary); 185 + color: var(--color-text-primary); 186 + } 187 + .alt-input textarea:focus { 188 + outline: none; 189 + border-color: var(--color-accent); 190 + } 191 + .alt-input textarea::placeholder { 192 + color: var(--color-text-tertiary); 193 + } 194 + .char-count { 195 + font-size: var(--font-size-xs); 196 + color: var(--color-text-tertiary); 197 + text-align: right; 198 + margin-top: 4px; 199 + } 200 + .error { 201 + color: #ff4444; 202 + padding: var(--space-sm); 203 + text-align: center; 204 + } 205 + `; 206 + 207 + constructor() { 208 + super(); 209 + this._photos = []; 210 + this._title = ''; 211 + this._description = ''; 212 + this._posting = false; 213 + this._error = null; 214 + } 215 + 216 + connectedCallback() { 217 + super.connectedCallback(); 218 + 219 + if (!auth.isAuthenticated) { 220 + router.replace('/'); 221 + return; 222 + } 223 + 224 + this._photos = draftGallery.getPhotos(); 225 + this._title = sessionStorage.getItem('draft_title') || ''; 226 + this._description = sessionStorage.getItem('draft_description') || ''; 227 + 228 + if (!this._photos.length) { 229 + router.push('/'); 230 + } 231 + } 232 + 233 + #handleBack() { 234 + router.push('/create'); 235 + } 236 + 237 + #handleAltChange(index, e) { 238 + const alt = e.target.value.slice(0, 1000); 239 + draftGallery.updatePhotoAlt(index, alt); 240 + this._photos = [...draftGallery.getPhotos()]; 241 + } 242 + 243 + async #handlePost() { 244 + if (this._posting) return; 245 + 246 + this._posting = true; 247 + this._error = null; 248 + 249 + try { 250 + const client = auth.getClient(); 251 + const now = new Date().toISOString(); 252 + 253 + const photoUris = []; 254 + for (const photo of this._photos) { 255 + const base64Data = photo.dataUrl.split(',')[1]; 256 + const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { 257 + data: base64Data, 258 + mimeType: 'image/jpeg' 259 + }); 260 + 261 + if (!uploadResult.uploadBlob) { 262 + throw new Error('Failed to upload image'); 263 + } 264 + 265 + const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, { 266 + input: { 267 + photo: { 268 + $type: 'blob', 269 + ref: { $link: uploadResult.uploadBlob.ref }, 270 + mimeType: uploadResult.uploadBlob.mimeType, 271 + size: uploadResult.uploadBlob.size 272 + }, 273 + aspectRatio: { 274 + width: photo.width, 275 + height: photo.height 276 + }, 277 + ...(photo.alt && { alt: photo.alt }), 278 + createdAt: now 279 + } 280 + }); 281 + 282 + photoUris.push(photoResult.createSocialGrainPhoto.uri); 283 + } 284 + 285 + let facets = null; 286 + if (this._description.trim()) { 287 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 288 + const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); 289 + if (parsed.facets.length > 0) { 290 + facets = parsed.facets; 291 + } 292 + } 293 + 294 + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 295 + input: { 296 + title: this._title.trim(), 297 + ...(this._description.trim() && { description: this._description.trim() }), 298 + ...(facets && { facets }), 299 + createdAt: now 300 + } 301 + }); 302 + 303 + const galleryUri = galleryResult.createSocialGrainGallery.uri; 304 + 305 + for (let i = 0; i < photoUris.length; i++) { 306 + await client.mutate(CREATE_GALLERY_ITEM_MUTATION, { 307 + input: { 308 + gallery: galleryUri, 309 + item: photoUris[i], 310 + position: i, 311 + createdAt: now 312 + } 313 + }); 314 + } 315 + 316 + draftGallery.clear(); 317 + sessionStorage.removeItem('draft_title'); 318 + sessionStorage.removeItem('draft_description'); 319 + const rkey = galleryUri.split('/').pop(); 320 + router.push(`/profile/${auth.user.handle}/gallery/${rkey}`); 321 + 322 + } catch (err) { 323 + console.error('Failed to create gallery:', err); 324 + this._error = err.message || 'Failed to create gallery. Please try again.'; 325 + } finally { 326 + this._posting = false; 327 + } 328 + } 329 + 330 + render() { 331 + return html` 332 + <div class="header"> 333 + <div class="header-left"> 334 + <button class="back-button" @click=${this.#handleBack}> 335 + <grain-icon name="back" size="20"></grain-icon> 336 + </button> 337 + <span class="header-title">Add image descriptions</span> 338 + </div> 339 + <grain-button 340 + ?loading=${this._posting} 341 + loadingText="Posting..." 342 + @click=${this.#handlePost} 343 + >Post</grain-button> 344 + </div> 345 + 346 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 347 + 348 + <div class="photo-list"> 349 + ${this._photos.map((photo, i) => html` 350 + <div class="photo-row"> 351 + <img class="photo-thumb" src=${photo.dataUrl} alt="Photo ${i + 1}"> 352 + <div class="alt-input"> 353 + <textarea 354 + placeholder="Describe this image for people who can't see it" 355 + .value=${photo.alt || ''} 356 + @input=${(e) => this.#handleAltChange(i, e)} 357 + ></textarea> 358 + <span class="char-count">${(photo.alt || '').length}/1000</span> 359 + </div> 360 + </div> 361 + `)} 362 + </div> 363 + `; 364 + } 365 + } 366 + 367 + customElements.define('grain-image-descriptions', GrainImageDescriptions); 368 + ``` 369 + 370 + **Step 2: Commit** 371 + 372 + ```bash 373 + git add src/components/pages/grain-image-descriptions.js 374 + git commit -m "feat: add image descriptions page for alt text entry" 375 + ``` 376 + 377 + --- 378 + 379 + ### Task 3: Update Create Gallery Page 380 + 381 + **Files:** 382 + - Modify: `src/components/pages/grain-create-gallery.js` 383 + 384 + **Step 1: Change Post button to Next and navigate to descriptions page** 385 + 386 + Remove the posting logic (moved to descriptions page) and update the button: 387 + 388 + Replace the `#handlePost` method with `#handleNext`: 389 + 390 + ```javascript 391 + #handleNext() { 392 + if (!this.#canProceed) return; 393 + 394 + // Save title/description to sessionStorage for the next page 395 + sessionStorage.setItem('draft_title', this._title); 396 + sessionStorage.setItem('draft_description', this._description); 397 + 398 + // Update draft with current photos (in case any were removed) 399 + draftGallery.setPhotos(this._photos); 400 + 401 + router.push('/create/descriptions'); 402 + } 403 + ``` 404 + 405 + Update `#canPost` to `#canProceed`: 406 + 407 + ```javascript 408 + get #canProceed() { 409 + return this._title.trim().length > 0 && this._photos.length > 0; 410 + } 411 + ``` 412 + 413 + Remove the `_posting` and `_error` properties and their usage. 414 + 415 + Remove the mutation constants (UPLOAD_BLOB_MUTATION, CREATE_PHOTO_MUTATION, CREATE_GALLERY_MUTATION, CREATE_GALLERY_ITEM_MUTATION). 416 + 417 + Remove imports for `parseTextToFacets` and `grainApi`. 418 + 419 + Update the button in render: 420 + 421 + ```javascript 422 + <grain-button 423 + ?disabled=${!this.#canProceed} 424 + @click=${this.#handleNext} 425 + >Next</grain-button> 426 + ``` 427 + 428 + Remove the error display from render. 429 + 430 + **Step 2: Full updated file** 431 + 432 + ```javascript 433 + import { LitElement, html, css } from 'lit'; 434 + import { router } from '../../router.js'; 435 + import { auth } from '../../services/auth.js'; 436 + import { draftGallery } from '../../services/draft-gallery.js'; 437 + import '../atoms/grain-icon.js'; 438 + import '../atoms/grain-button.js'; 439 + import '../atoms/grain-input.js'; 440 + import '../atoms/grain-textarea.js'; 441 + import '../molecules/grain-form-field.js'; 442 + 443 + export class GrainCreateGallery extends LitElement { 444 + static properties = { 445 + _photos: { state: true }, 446 + _title: { state: true }, 447 + _description: { state: true } 448 + }; 449 + 450 + static styles = css` 451 + :host { 452 + display: block; 453 + width: 100%; 454 + max-width: var(--feed-max-width); 455 + min-height: 100%; 456 + background: var(--color-bg-primary); 457 + align-self: center; 458 + } 459 + .header { 460 + display: flex; 461 + align-items: center; 462 + justify-content: space-between; 463 + padding: var(--space-sm); 464 + border-bottom: 1px solid var(--color-border); 465 + } 466 + .header-left { 467 + display: flex; 468 + align-items: center; 469 + gap: var(--space-xs); 470 + } 471 + .back-button { 472 + background: none; 473 + border: none; 474 + padding: 8px; 475 + margin-left: -8px; 476 + cursor: pointer; 477 + color: var(--color-text-primary); 478 + } 479 + .header-title { 480 + font-size: var(--font-size-md); 481 + font-weight: 600; 482 + } 483 + .photo-strip { 484 + display: flex; 485 + gap: var(--space-xs); 486 + padding: var(--space-sm); 487 + overflow-x: auto; 488 + border-bottom: 1px solid var(--color-border); 489 + } 490 + .photo-thumb { 491 + position: relative; 492 + flex-shrink: 0; 493 + } 494 + .photo-thumb img { 495 + width: 80px; 496 + height: 80px; 497 + object-fit: cover; 498 + border-radius: 4px; 499 + } 500 + .remove-photo { 501 + position: absolute; 502 + top: -6px; 503 + right: -6px; 504 + width: 20px; 505 + height: 20px; 506 + border-radius: 50%; 507 + background: var(--color-text-primary); 508 + color: var(--color-bg-primary); 509 + border: none; 510 + cursor: pointer; 511 + font-size: 12px; 512 + display: flex; 513 + align-items: center; 514 + justify-content: center; 515 + } 516 + .form { 517 + padding: var(--space-sm); 518 + } 519 + `; 520 + 521 + constructor() { 522 + super(); 523 + this._photos = []; 524 + this._title = ''; 525 + this._description = ''; 526 + } 527 + 528 + connectedCallback() { 529 + super.connectedCallback(); 530 + 531 + if (!auth.isAuthenticated) { 532 + router.replace('/'); 533 + return; 534 + } 535 + 536 + this._photos = draftGallery.getPhotos(); 537 + 538 + // Restore title/description if returning from descriptions page 539 + this._title = sessionStorage.getItem('draft_title') || ''; 540 + this._description = sessionStorage.getItem('draft_description') || ''; 541 + 542 + if (!this._photos.length) { 543 + router.push('/'); 544 + } 545 + } 546 + 547 + #handleBack() { 548 + if (confirm('Discard this gallery?')) { 549 + draftGallery.clear(); 550 + sessionStorage.removeItem('draft_title'); 551 + sessionStorage.removeItem('draft_description'); 552 + history.back(); 553 + } 554 + } 555 + 556 + #removePhoto(index) { 557 + this._photos = this._photos.filter((_, i) => i !== index); 558 + draftGallery.setPhotos(this._photos); 559 + if (this._photos.length === 0) { 560 + draftGallery.clear(); 561 + sessionStorage.removeItem('draft_title'); 562 + sessionStorage.removeItem('draft_description'); 563 + router.push('/'); 564 + } 565 + } 566 + 567 + #handleTitleChange(e) { 568 + this._title = e.detail.value.slice(0, 100); 569 + } 570 + 571 + #handleDescriptionChange(e) { 572 + this._description = e.detail.value.slice(0, 1000); 573 + } 574 + 575 + get #canProceed() { 576 + return this._title.trim().length > 0 && this._photos.length > 0; 577 + } 578 + 579 + #handleNext() { 580 + if (!this.#canProceed) return; 581 + 582 + sessionStorage.setItem('draft_title', this._title); 583 + sessionStorage.setItem('draft_description', this._description); 584 + draftGallery.setPhotos(this._photos); 585 + 586 + router.push('/create/descriptions'); 587 + } 588 + 589 + render() { 590 + return html` 591 + <div class="header"> 592 + <div class="header-left"> 593 + <button class="back-button" @click=${this.#handleBack}> 594 + <grain-icon name="back" size="20"></grain-icon> 595 + </button> 596 + <span class="header-title">Create a gallery</span> 597 + </div> 598 + <grain-button 599 + ?disabled=${!this.#canProceed} 600 + @click=${this.#handleNext} 601 + >Next</grain-button> 602 + </div> 603 + 604 + <div class="photo-strip"> 605 + ${this._photos.map((photo, i) => html` 606 + <div class="photo-thumb"> 607 + <img src=${photo.dataUrl} alt="Photo ${i + 1}"> 608 + <button class="remove-photo" @click=${() => this.#removePhoto(i)}>x</button> 609 + </div> 610 + `)} 611 + </div> 612 + 613 + <div class="form"> 614 + <grain-form-field .value=${this._title} .maxlength=${100}> 615 + <grain-input 616 + placeholder="Add a title..." 617 + .value=${this._title} 618 + @input=${this.#handleTitleChange} 619 + ></grain-input> 620 + </grain-form-field> 621 + 622 + <grain-form-field .value=${this._description} .maxlength=${1000}> 623 + <grain-textarea 624 + placeholder="Add a description (optional)..." 625 + .value=${this._description} 626 + .maxlength=${1000} 627 + @input=${this.#handleDescriptionChange} 628 + ></grain-textarea> 629 + </grain-form-field> 630 + </div> 631 + `; 632 + } 633 + } 634 + 635 + customElements.define('grain-create-gallery', GrainCreateGallery); 636 + ``` 637 + 638 + **Step 3: Commit** 639 + 640 + ```bash 641 + git add src/components/pages/grain-create-gallery.js 642 + git commit -m "refactor: change create gallery to two-step flow with Next button" 643 + ``` 644 + 645 + --- 646 + 647 + ### Task 4: Register Route 648 + 649 + **Files:** 650 + - Modify: `src/components/pages/grain-app.js` 651 + 652 + **Step 1: Import the new page component** 653 + 654 + Add after the other page imports: 655 + 656 + ```javascript 657 + import './grain-image-descriptions.js'; 658 + ``` 659 + 660 + **Step 2: Register the route** 661 + 662 + Add after `.register('/create', 'grain-create-gallery')`: 663 + 664 + ```javascript 665 + .register('/create/descriptions', 'grain-image-descriptions') 666 + ``` 667 + 668 + **Step 3: Commit** 669 + 670 + ```bash 671 + git add src/components/pages/grain-app.js 672 + git commit -m "feat: add route for image descriptions page" 673 + ``` 674 + 675 + --- 676 + 677 + ### Task 5: Create ALT Badge Component 678 + 679 + **Files:** 680 + - Create: `src/components/atoms/grain-alt-badge.js` 681 + 682 + **Step 1: Create the badge component with overlay functionality** 683 + 684 + ```javascript 685 + import { LitElement, html, css } from 'lit'; 686 + 687 + export class GrainAltBadge extends LitElement { 688 + static properties = { 689 + alt: { type: String }, 690 + _showOverlay: { state: true } 691 + }; 692 + 693 + static styles = css` 694 + :host { 695 + position: absolute; 696 + bottom: 8px; 697 + right: 8px; 698 + z-index: 2; 699 + } 700 + .badge { 701 + background: rgba(0, 0, 0, 0.7); 702 + color: white; 703 + font-size: 10px; 704 + font-weight: 600; 705 + padding: 2px 4px; 706 + border-radius: 4px; 707 + cursor: pointer; 708 + user-select: none; 709 + } 710 + .badge:hover { 711 + background: rgba(0, 0, 0, 0.85); 712 + } 713 + .overlay { 714 + position: fixed; 715 + bottom: 0; 716 + left: 0; 717 + right: 0; 718 + background: rgba(0, 0, 0, 0.8); 719 + color: white; 720 + padding: var(--space-sm); 721 + font-size: var(--font-size-sm); 722 + line-height: 1.4; 723 + max-height: 40vh; 724 + overflow-y: auto; 725 + z-index: 100; 726 + } 727 + `; 728 + 729 + constructor() { 730 + super(); 731 + this.alt = ''; 732 + this._showOverlay = false; 733 + } 734 + 735 + #handleClick(e) { 736 + e.stopPropagation(); 737 + this._showOverlay = !this._showOverlay; 738 + } 739 + 740 + #handleOverlayClick(e) { 741 + e.stopPropagation(); 742 + this._showOverlay = false; 743 + } 744 + 745 + render() { 746 + if (!this.alt) return null; 747 + 748 + return html` 749 + <span class="badge" @click=${this.#handleClick}>ALT</span> 750 + ${this._showOverlay ? html` 751 + <div class="overlay" @click=${this.#handleOverlayClick}> 752 + ${this.alt} 753 + </div> 754 + ` : ''} 755 + `; 756 + } 757 + } 758 + 759 + customElements.define('grain-alt-badge', GrainAltBadge); 760 + ``` 761 + 762 + **Step 2: Commit** 763 + 764 + ```bash 765 + git add src/components/atoms/grain-alt-badge.js 766 + git commit -m "feat: add ALT badge component with overlay" 767 + ``` 768 + 769 + --- 770 + 771 + ### Task 6: Add ALT Badge to Carousel 772 + 773 + **Files:** 774 + - Modify: `src/components/organisms/grain-image-carousel.js` 775 + 776 + **Step 1: Import the badge component** 777 + 778 + Add after the other imports: 779 + 780 + ```javascript 781 + import '../atoms/grain-alt-badge.js'; 782 + ``` 783 + 784 + **Step 2: Add styles for slide positioning** 785 + 786 + Add to the `.slide` rule to enable absolute positioning of badge: 787 + 788 + ```css 789 + .slide { 790 + flex: 0 0 100%; 791 + scroll-snap-align: start; 792 + position: relative; 793 + } 794 + ``` 795 + 796 + **Step 3: Add badge to each slide** 797 + 798 + Update the slide rendering to include the badge: 799 + 800 + ```javascript 801 + ${this.photos.map((photo, index) => html` 802 + <div class="slide ${hasPortrait ? 'centered' : ''}"> 803 + <grain-image 804 + src=${this.#shouldLoad(index) ? photo.url : ''} 805 + alt=${photo.alt || ''} 806 + aspectRatio=${photo.aspectRatio || 1} 807 + style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 808 + ></grain-image> 809 + ${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''} 810 + </div> 811 + `)} 812 + ``` 813 + 814 + **Step 4: Full updated file** 815 + 816 + ```javascript 817 + import { LitElement, html, css } from 'lit'; 818 + import '../atoms/grain-image.js'; 819 + import '../atoms/grain-icon.js'; 820 + import '../atoms/grain-alt-badge.js'; 821 + import '../molecules/grain-carousel-dots.js'; 822 + 823 + export class GrainImageCarousel extends LitElement { 824 + static properties = { 825 + photos: { type: Array }, 826 + rkey: { type: String }, 827 + _currentIndex: { state: true } 828 + }; 829 + 830 + static styles = css` 831 + :host { 832 + display: block; 833 + position: relative; 834 + } 835 + .carousel { 836 + display: flex; 837 + overflow-x: auto; 838 + scroll-snap-type: x mandatory; 839 + scrollbar-width: none; 840 + -ms-overflow-style: none; 841 + } 842 + .carousel::-webkit-scrollbar { 843 + display: none; 844 + } 845 + .slide { 846 + flex: 0 0 100%; 847 + scroll-snap-align: start; 848 + position: relative; 849 + } 850 + .slide.centered { 851 + display: flex; 852 + align-items: center; 853 + justify-content: center; 854 + } 855 + .slide.centered grain-image { 856 + width: 100%; 857 + } 858 + .dots { 859 + position: absolute; 860 + bottom: 0; 861 + left: 0; 862 + right: 0; 863 + } 864 + .nav-arrow { 865 + position: absolute; 866 + top: 50%; 867 + transform: translateY(-50%); 868 + width: 24px; 869 + height: 24px; 870 + border-radius: 50%; 871 + border: none; 872 + background: rgba(255, 255, 255, 0.7); 873 + color: rgba(120, 100, 90, 1); 874 + cursor: pointer; 875 + display: flex; 876 + align-items: center; 877 + justify-content: center; 878 + padding: 0; 879 + z-index: 1; 880 + } 881 + .nav-arrow:hover { 882 + background: rgba(255, 255, 255, 1); 883 + } 884 + .nav-arrow:focus { 885 + outline: none; 886 + } 887 + .nav-arrow:focus-visible { 888 + outline: 2px solid rgba(120, 100, 90, 0.5); 889 + outline-offset: 2px; 890 + } 891 + .nav-arrow-left { 892 + left: 8px; 893 + } 894 + .nav-arrow-right { 895 + right: 8px; 896 + } 897 + `; 898 + 899 + constructor() { 900 + super(); 901 + this.photos = []; 902 + this._currentIndex = 0; 903 + } 904 + 905 + get #hasPortrait() { 906 + return this.photos.some(photo => (photo.aspectRatio || 1) < 1); 907 + } 908 + 909 + get #minAspectRatio() { 910 + if (!this.photos.length) return 1; 911 + return Math.min(...this.photos.map(photo => photo.aspectRatio || 1)); 912 + } 913 + 914 + #handleScroll(e) { 915 + const carousel = e.target; 916 + const index = Math.round(carousel.scrollLeft / carousel.offsetWidth); 917 + if (index !== this._currentIndex) { 918 + this._currentIndex = index; 919 + } 920 + } 921 + 922 + #goToPrevious(e) { 923 + e.stopPropagation(); 924 + if (this._currentIndex > 0) { 925 + const carousel = this.shadowRoot.querySelector('.carousel'); 926 + const slides = carousel.querySelectorAll('.slide'); 927 + slides[this._currentIndex - 1].scrollIntoView({ 928 + behavior: 'smooth', 929 + block: 'nearest', 930 + inline: 'start' 931 + }); 932 + } 933 + } 934 + 935 + #goToNext(e) { 936 + e.stopPropagation(); 937 + if (this._currentIndex < this.photos.length - 1) { 938 + const carousel = this.shadowRoot.querySelector('.carousel'); 939 + const slides = carousel.querySelectorAll('.slide'); 940 + slides[this._currentIndex + 1].scrollIntoView({ 941 + behavior: 'smooth', 942 + block: 'nearest', 943 + inline: 'start' 944 + }); 945 + } 946 + } 947 + 948 + #shouldLoad(index) { 949 + return Math.abs(index - this._currentIndex) <= 1; 950 + } 951 + 952 + getCurrentPhoto() { 953 + return this.photos[this._currentIndex] || null; 954 + } 955 + 956 + render() { 957 + const hasPortrait = this.#hasPortrait; 958 + const minAspectRatio = this.#minAspectRatio; 959 + const carouselStyle = hasPortrait 960 + ? `aspect-ratio: ${minAspectRatio};` 961 + : ''; 962 + 963 + const showLeftArrow = this.photos.length > 1 && this._currentIndex > 0; 964 + const showRightArrow = this.photos.length > 1 && this._currentIndex < this.photos.length - 1; 965 + 966 + return html` 967 + <div class="carousel" style=${carouselStyle} @scroll=${this.#handleScroll}> 968 + ${this.photos.map((photo, index) => html` 969 + <div class="slide ${hasPortrait ? 'centered' : ''}"> 970 + <grain-image 971 + src=${this.#shouldLoad(index) ? photo.url : ''} 972 + alt=${photo.alt || ''} 973 + aspectRatio=${photo.aspectRatio || 1} 974 + style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 975 + ></grain-image> 976 + ${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''} 977 + </div> 978 + `)} 979 + </div> 980 + ${showLeftArrow ? html` 981 + <button class="nav-arrow nav-arrow-left" @click=${this.#goToPrevious} aria-label="Previous image"> 982 + <grain-icon name="chevronLeft" size="12"></grain-icon> 983 + </button> 984 + ` : ''} 985 + ${showRightArrow ? html` 986 + <button class="nav-arrow nav-arrow-right" @click=${this.#goToNext} aria-label="Next image"> 987 + <grain-icon name="chevronRight" size="12"></grain-icon> 988 + </button> 989 + ` : ''} 990 + ${this.photos.length > 1 ? html` 991 + <div class="dots"> 992 + <grain-carousel-dots 993 + total=${this.photos.length} 994 + current=${this._currentIndex} 995 + ></grain-carousel-dots> 996 + </div> 997 + ` : ''} 998 + `; 999 + } 1000 + } 1001 + 1002 + customElements.define('grain-image-carousel', GrainImageCarousel); 1003 + ``` 1004 + 1005 + **Step 5: Commit** 1006 + 1007 + ```bash 1008 + git add src/components/organisms/grain-image-carousel.js 1009 + git commit -m "feat: add ALT badge to carousel images" 1010 + ``` 1011 + 1012 + --- 1013 + 1014 + ### Task 7: Manual Testing 1015 + 1016 + **Step 1: Test gallery creation flow** 1017 + 1018 + 1. Click + button in nav to select photos 1019 + 2. Enter title and description on first screen 1020 + 3. Click "Next" - should navigate to image descriptions page 1021 + 4. Verify photos appear with text areas 1022 + 5. Add alt text to one or more images 1023 + 6. Click "Post" - should create gallery and navigate to it 1024 + 1025 + **Step 2: Test ALT badge display** 1026 + 1027 + 1. Navigate to a gallery with alt text (the one you just created) 1028 + 2. Verify "ALT" badge appears in bottom right of images that have alt text 1029 + 3. Click the badge - overlay should appear at bottom with alt text 1030 + 4. Click overlay or elsewhere - overlay should dismiss 1031 + 5. Verify badge does NOT appear on images without alt text 1032 + 1033 + **Step 3: Test edge cases** 1034 + 1035 + - Back button on descriptions page returns to create page with data preserved 1036 + - Back button on create page with "Discard" clears everything 1037 + - Single image gallery works 1038 + - Multi-image gallery works 1039 + - Very long alt text (up to 1000 chars) works 1040 + 1041 + **Step 4: Final commit if any fixes needed** 1042 + 1043 + ```bash 1044 + git add -A 1045 + git commit -m "fix: alt text feature refinements" 1046 + ```
+211
docs/plans/2026-01-02-oauth-callback-route.md
··· 1 + # OAuth Callback Route Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a dedicated `/oauth/callback` route so users can log in from any page and return to where they were. 6 + 7 + **Architecture:** Store the current path in sessionStorage before OAuth redirect, then read it back after the callback completes and navigate there. 8 + 9 + **Tech Stack:** Lit web components, quickslice-client-js OAuth 10 + 11 + --- 12 + 13 + ### Task 1: Create OAuth Callback Page Component 14 + 15 + **Files:** 16 + - Create: `src/components/pages/grain-oauth-callback.js` 17 + 18 + **Step 1: Create the callback component** 19 + 20 + ```javascript 21 + import { LitElement, html, css } from 'lit'; 22 + import '../atoms/grain-spinner.js'; 23 + 24 + export class GrainOAuthCallback extends LitElement { 25 + static styles = css` 26 + :host { 27 + display: flex; 28 + flex-direction: column; 29 + align-items: center; 30 + justify-content: center; 31 + min-height: 100%; 32 + gap: var(--space-md); 33 + } 34 + p { 35 + color: var(--color-text-secondary); 36 + font-size: var(--font-size-sm); 37 + } 38 + `; 39 + 40 + render() { 41 + return html` 42 + <grain-spinner size="32"></grain-spinner> 43 + <p>Signing in...</p> 44 + `; 45 + } 46 + } 47 + 48 + customElements.define('grain-oauth-callback', GrainOAuthCallback); 49 + ``` 50 + 51 + **Step 2: Verify file exists** 52 + 53 + Run: `cat src/components/pages/grain-oauth-callback.js` 54 + Expected: File contents match above 55 + 56 + **Step 3: Commit** 57 + 58 + ```bash 59 + git add src/components/pages/grain-oauth-callback.js 60 + git commit -m "feat: add OAuth callback page component" 61 + ``` 62 + 63 + --- 64 + 65 + ### Task 2: Register the OAuth Callback Route 66 + 67 + **Files:** 68 + - Modify: `src/components/pages/grain-app.js` 69 + 70 + **Step 1: Add import for callback component** 71 + 72 + Add after line 17 (after grain-copyright import): 73 + ```javascript 74 + import './grain-oauth-callback.js'; 75 + ``` 76 + 77 + **Step 2: Register the route** 78 + 79 + Add after line 67 (before the `*` wildcard route): 80 + ```javascript 81 + .register('/oauth/callback', 'grain-oauth-callback') 82 + ``` 83 + 84 + **Step 3: Verify build passes** 85 + 86 + Run: `npm run build` 87 + Expected: Build succeeds 88 + 89 + **Step 4: Commit** 90 + 91 + ```bash 92 + git add src/components/pages/grain-app.js 93 + git commit -m "feat: register /oauth/callback route" 94 + ``` 95 + 96 + --- 97 + 98 + ### Task 3: Store Return URL Before OAuth Redirect 99 + 100 + **Files:** 101 + - Modify: `src/services/auth.js` 102 + 103 + **Step 1: Update login method to store return URL** 104 + 105 + Replace the login method (lines 58-60): 106 + 107 + ```javascript 108 + async login(handle) { 109 + sessionStorage.setItem('oauth_return_url', window.location.pathname); 110 + await this.#client.loginWithRedirect({ handle }); 111 + } 112 + ``` 113 + 114 + **Step 2: Verify build passes** 115 + 116 + Run: `npm run build` 117 + Expected: Build succeeds 118 + 119 + **Step 3: Commit** 120 + 121 + ```bash 122 + git add src/services/auth.js 123 + git commit -m "feat: store return URL before OAuth redirect" 124 + ``` 125 + 126 + --- 127 + 128 + ### Task 4: Navigate to Return URL After OAuth Callback 129 + 130 + **Files:** 131 + - Modify: `src/services/auth.js` 132 + 133 + **Step 1: Add router import at top of file** 134 + 135 + Add after line 1: 136 + ```javascript 137 + import { router } from '../router.js'; 138 + ``` 139 + 140 + **Step 2: Update callback handling to navigate to return URL** 141 + 142 + Replace lines 18-22 (the callback handling block): 143 + 144 + ```javascript 145 + // Handle OAuth callback if present 146 + if (window.location.search.includes('code=')) { 147 + await this.#client.handleRedirectCallback(); 148 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 149 + sessionStorage.removeItem('oauth_return_url'); 150 + router.replace(returnUrl); 151 + } 152 + ``` 153 + 154 + **Step 3: Verify build passes** 155 + 156 + Run: `npm run build` 157 + Expected: Build succeeds 158 + 159 + **Step 4: Commit** 160 + 161 + ```bash 162 + git add src/services/auth.js 163 + git commit -m "feat: navigate to return URL after OAuth callback" 164 + ``` 165 + 166 + --- 167 + 168 + ### Task 5: Manual Testing Checklist 169 + 170 + **Step 1: Start dev server** 171 + 172 + Run: `npm run dev` 173 + 174 + **Step 2: Test login from timeline** 175 + 176 + 1. Navigate to `http://localhost:5173/` 177 + 2. Click login, enter handle 178 + 3. Complete OAuth flow 179 + 4. Verify you return to `/` 180 + 181 + **Step 3: Test login from profile page** 182 + 183 + 1. Navigate to `http://localhost:5173/profile/grain.social` 184 + 2. Click login, enter handle 185 + 3. Complete OAuth flow 186 + 4. Verify you return to `/profile/grain.social` 187 + 188 + **Step 4: Test login from explore page** 189 + 190 + 1. Navigate to `http://localhost:5173/explore` 191 + 2. Click login, enter handle 192 + 3. Complete OAuth flow 193 + 4. Verify you return to `/explore` 194 + 195 + **Step 5: Test direct visit to callback** 196 + 197 + 1. Navigate directly to `http://localhost:5173/oauth/callback` 198 + 2. Verify it shows "Signing in..." briefly then redirects to `/` 199 + 200 + --- 201 + 202 + ### Task 6: Final Build Verification 203 + 204 + **Step 1: Run production build** 205 + 206 + Run: `npm run build` 207 + Expected: Build succeeds with no errors 208 + 209 + **Step 2: Commit any remaining changes** 210 + 211 + If all tests pass and no changes needed, this task is complete.
+661
docs/plans/2026-01-02-onboarding-flow.md
··· 1 + # Onboarding Flow Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Redirect first-time users to an onboarding page after OAuth, prefilling their Grain profile form with their Bluesky profile data. 6 + 7 + **Architecture:** After OAuth callback, check if `socialGrainActorProfile` exists. If not, redirect to `/onboarding`. The onboarding component fetches `appBskyActorProfile` to prefill displayName, description, and avatar. Users can save or skip; both create a profile record to mark them as onboarded. 8 + 9 + **Tech Stack:** Lit web components, GraphQL via Quickslice client, existing avatar crop component 10 + 11 + --- 12 + 13 + ## Task 1: Add Profile Queries to grain-api.js 14 + 15 + **Files:** 16 + - Modify: `src/services/grain-api.js` 17 + 18 + **Step 1: Add hasGrainProfile method** 19 + 20 + Add this method to the GrainApiService class (after `resolveHandle` method, around line 1123): 21 + 22 + ```javascript 23 + async hasGrainProfile(client) { 24 + const result = await client.query(` 25 + query { 26 + viewer { 27 + socialGrainActorProfileByDid { 28 + displayName 29 + } 30 + } 31 + } 32 + `); 33 + return !!result.viewer?.socialGrainActorProfileByDid; 34 + } 35 + ``` 36 + 37 + **Step 2: Add getBlueskyProfile method** 38 + 39 + Add this method after `hasGrainProfile`: 40 + 41 + ```javascript 42 + async getBlueskyProfile(client) { 43 + const result = await client.query(` 44 + query { 45 + viewer { 46 + did 47 + handle 48 + appBskyActorProfileByDid { 49 + displayName 50 + description 51 + avatar { url ref mimeType size } 52 + } 53 + } 54 + } 55 + `); 56 + 57 + const viewer = result.viewer; 58 + const profile = viewer?.appBskyActorProfileByDid; 59 + const avatar = profile?.avatar; 60 + 61 + return { 62 + did: viewer?.did || '', 63 + handle: viewer?.handle || '', 64 + displayName: profile?.displayName || '', 65 + description: profile?.description || '', 66 + avatarUrl: avatar?.url || '', 67 + avatarBlob: avatar ? { 68 + $type: 'blob', 69 + ref: { $link: avatar.ref }, 70 + mimeType: avatar.mimeType, 71 + size: avatar.size 72 + } : null 73 + }; 74 + } 75 + ``` 76 + 77 + **Step 3: Commit** 78 + 79 + ```bash 80 + git add src/services/grain-api.js 81 + git commit -m "feat: add hasGrainProfile and getBlueskyProfile queries" 82 + ``` 83 + 84 + --- 85 + 86 + ## Task 2: Add Profile Mutations to mutations.js 87 + 88 + **Files:** 89 + - Modify: `src/services/mutations.js` 90 + 91 + **Step 1: Add updateProfile method** 92 + 93 + Add this method to the MutationsService class (after `updateAvatar` method, around line 200): 94 + 95 + ```javascript 96 + async updateProfile(input) { 97 + const client = auth.getClient(); 98 + 99 + await client.mutate(` 100 + mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) { 101 + updateSocialGrainActorProfile(rkey: $rkey, input: $input) { 102 + uri 103 + } 104 + } 105 + `, { rkey: 'self', input }); 106 + 107 + await auth.refreshUser(); 108 + } 109 + 110 + async createEmptyProfile() { 111 + return this.updateProfile({ 112 + displayName: null, 113 + description: null 114 + }); 115 + } 116 + ``` 117 + 118 + **Step 2: Commit** 119 + 120 + ```bash 121 + git add src/services/mutations.js 122 + git commit -m "feat: add updateProfile and createEmptyProfile mutations" 123 + ``` 124 + 125 + --- 126 + 127 + ## Task 3: Register Onboarding Route 128 + 129 + **Files:** 130 + - Modify: `src/components/pages/grain-app.js` 131 + 132 + **Step 1: Add import** 133 + 134 + Add import at line 19 (after `grain-oauth-callback.js`): 135 + 136 + ```javascript 137 + import './grain-onboarding.js'; 138 + ``` 139 + 140 + **Step 2: Add route registration** 141 + 142 + Add route registration at line 69 (before the oauth/callback route): 143 + 144 + ```javascript 145 + .register('/onboarding', 'grain-onboarding') 146 + ``` 147 + 148 + **Step 3: Commit** 149 + 150 + ```bash 151 + git add src/components/pages/grain-app.js 152 + git commit -m "feat: register /onboarding route" 153 + ``` 154 + 155 + --- 156 + 157 + ## Task 4: Create Onboarding Component 158 + 159 + **Files:** 160 + - Create: `src/components/pages/grain-onboarding.js` 161 + 162 + **Step 1: Create the component** 163 + 164 + Create `src/components/pages/grain-onboarding.js`: 165 + 166 + ```javascript 167 + import { LitElement, html, css } from 'lit'; 168 + import { router } from '../../router.js'; 169 + import { auth } from '../../services/auth.js'; 170 + import { grainApi } from '../../services/grain-api.js'; 171 + import { mutations } from '../../services/mutations.js'; 172 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 173 + import '../atoms/grain-icon.js'; 174 + import '../atoms/grain-button.js'; 175 + import '../atoms/grain-input.js'; 176 + import '../atoms/grain-textarea.js'; 177 + import '../atoms/grain-avatar.js'; 178 + import '../atoms/grain-spinner.js'; 179 + import '../molecules/grain-form-field.js'; 180 + import '../molecules/grain-avatar-crop.js'; 181 + 182 + export class GrainOnboarding extends LitElement { 183 + static properties = { 184 + _loading: { state: true }, 185 + _saving: { state: true }, 186 + _error: { state: true }, 187 + _displayName: { state: true }, 188 + _description: { state: true }, 189 + _avatarUrl: { state: true }, 190 + _avatarBlob: { state: true }, 191 + _newAvatarDataUrl: { state: true }, 192 + _showAvatarCrop: { state: true }, 193 + _cropImageUrl: { state: true } 194 + }; 195 + 196 + static styles = css` 197 + :host { 198 + display: block; 199 + width: 100%; 200 + max-width: var(--feed-max-width); 201 + min-height: 100%; 202 + padding-bottom: 80px; 203 + background: var(--color-bg-primary); 204 + align-self: center; 205 + } 206 + .header { 207 + display: flex; 208 + flex-direction: column; 209 + align-items: center; 210 + gap: var(--space-xs); 211 + padding: var(--space-xl) var(--space-sm) var(--space-lg); 212 + text-align: center; 213 + } 214 + h1 { 215 + font-size: var(--font-size-xl); 216 + font-weight: var(--font-weight-semibold); 217 + color: var(--color-text-primary); 218 + margin: 0; 219 + } 220 + .subtitle { 221 + font-size: var(--font-size-sm); 222 + color: var(--color-text-secondary); 223 + margin: 0; 224 + } 225 + .content { 226 + padding: 0 var(--space-sm); 227 + } 228 + @media (min-width: 600px) { 229 + .content { 230 + padding: 0; 231 + } 232 + } 233 + .avatar-section { 234 + display: flex; 235 + flex-direction: column; 236 + align-items: center; 237 + margin-bottom: var(--space-lg); 238 + } 239 + .avatar-wrapper { 240 + position: relative; 241 + cursor: pointer; 242 + } 243 + .avatar-overlay { 244 + position: absolute; 245 + bottom: 0; 246 + right: 0; 247 + width: 28px; 248 + height: 28px; 249 + border-radius: 50%; 250 + background: var(--color-bg-primary); 251 + border: 2px solid var(--color-border); 252 + display: flex; 253 + align-items: center; 254 + justify-content: center; 255 + color: var(--color-text-primary); 256 + } 257 + .avatar-preview { 258 + width: 80px; 259 + height: 80px; 260 + border-radius: 50%; 261 + object-fit: cover; 262 + background: var(--color-bg-elevated); 263 + } 264 + input[type="file"] { 265 + display: none; 266 + } 267 + .actions { 268 + display: flex; 269 + flex-direction: column; 270 + gap: var(--space-sm); 271 + padding: var(--space-lg) var(--space-sm); 272 + border-top: 1px solid var(--color-border); 273 + margin-top: var(--space-lg); 274 + } 275 + @media (min-width: 600px) { 276 + .actions { 277 + padding-left: 0; 278 + padding-right: 0; 279 + } 280 + } 281 + .skip-button { 282 + background: none; 283 + border: none; 284 + color: var(--color-text-secondary); 285 + font-size: var(--font-size-sm); 286 + cursor: pointer; 287 + padding: var(--space-sm); 288 + text-align: center; 289 + } 290 + .skip-button:hover { 291 + color: var(--color-text-primary); 292 + text-decoration: underline; 293 + } 294 + .error { 295 + color: var(--color-danger, #dc3545); 296 + font-size: var(--font-size-sm); 297 + padding: var(--space-sm); 298 + text-align: center; 299 + } 300 + .loading { 301 + display: flex; 302 + flex-direction: column; 303 + align-items: center; 304 + justify-content: center; 305 + gap: var(--space-md); 306 + padding: var(--space-xl); 307 + min-height: 300px; 308 + } 309 + .loading p { 310 + color: var(--color-text-secondary); 311 + font-size: var(--font-size-sm); 312 + } 313 + `; 314 + 315 + constructor() { 316 + super(); 317 + this._loading = true; 318 + this._saving = false; 319 + this._error = null; 320 + this._displayName = ''; 321 + this._description = ''; 322 + this._avatarUrl = ''; 323 + this._avatarBlob = null; 324 + this._newAvatarDataUrl = null; 325 + this._showAvatarCrop = false; 326 + this._cropImageUrl = null; 327 + } 328 + 329 + async connectedCallback() { 330 + super.connectedCallback(); 331 + 332 + if (!auth.isAuthenticated) { 333 + router.replace('/'); 334 + return; 335 + } 336 + 337 + await this.#checkAndLoad(); 338 + } 339 + 340 + async #checkAndLoad() { 341 + try { 342 + const client = auth.getClient(); 343 + 344 + // Check if user already has a Grain profile 345 + const hasProfile = await grainApi.hasGrainProfile(client); 346 + if (hasProfile) { 347 + this.#redirectToDestination(); 348 + return; 349 + } 350 + 351 + // Fetch Bluesky profile to prefill 352 + const bskyProfile = await grainApi.getBlueskyProfile(client); 353 + this._displayName = bskyProfile.displayName; 354 + this._description = bskyProfile.description; 355 + this._avatarUrl = bskyProfile.avatarUrl; 356 + this._avatarBlob = bskyProfile.avatarBlob; 357 + } catch (err) { 358 + console.error('Failed to load profile data:', err); 359 + } finally { 360 + this._loading = false; 361 + } 362 + } 363 + 364 + #redirectToDestination() { 365 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 366 + sessionStorage.removeItem('oauth_return_url'); 367 + router.replace(returnUrl); 368 + } 369 + 370 + #handleDisplayNameChange(e) { 371 + this._displayName = e.detail.value.slice(0, 64); 372 + } 373 + 374 + #handleDescriptionChange(e) { 375 + this._description = e.detail.value.slice(0, 256); 376 + } 377 + 378 + #handleAvatarClick() { 379 + this.shadowRoot.querySelector('#avatar-input').click(); 380 + } 381 + 382 + async #handleAvatarChange(e) { 383 + const input = e.target; 384 + const file = input.files?.[0]; 385 + if (!file) return; 386 + 387 + input.value = ''; 388 + 389 + try { 390 + const dataUrl = await readFileAsDataURL(file); 391 + const resized = await resizeImage(dataUrl, { 392 + width: 2000, 393 + height: 2000, 394 + maxSize: 900000 395 + }); 396 + this._cropImageUrl = resized.dataUrl; 397 + this._showAvatarCrop = true; 398 + } catch (err) { 399 + console.error('Failed to process avatar:', err); 400 + this._error = 'Failed to process image'; 401 + } 402 + } 403 + 404 + #handleCropCancel() { 405 + this._showAvatarCrop = false; 406 + this._cropImageUrl = null; 407 + } 408 + 409 + #handleCrop(e) { 410 + this._showAvatarCrop = false; 411 + this._cropImageUrl = null; 412 + this._newAvatarDataUrl = e.detail.dataUrl; 413 + this._avatarBlob = null; 414 + } 415 + 416 + get #displayedAvatarUrl() { 417 + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; 418 + return this._avatarUrl; 419 + } 420 + 421 + async #handleSave() { 422 + if (this._saving) return; 423 + 424 + this._saving = true; 425 + this._error = null; 426 + 427 + try { 428 + const input = { 429 + displayName: this._displayName.trim() || null, 430 + description: this._description.trim() || null 431 + }; 432 + 433 + if (this._newAvatarDataUrl) { 434 + const base64Data = this._newAvatarDataUrl.split(',')[1]; 435 + const blob = await mutations.uploadBlob(base64Data, 'image/jpeg'); 436 + input.avatar = { 437 + $type: 'blob', 438 + ref: { $link: blob.ref }, 439 + mimeType: blob.mimeType, 440 + size: blob.size 441 + }; 442 + } else if (this._avatarBlob) { 443 + input.avatar = this._avatarBlob; 444 + } 445 + 446 + await mutations.updateProfile(input); 447 + this.#redirectToDestination(); 448 + } catch (err) { 449 + console.error('Failed to save profile:', err); 450 + this._error = err.message || 'Failed to save profile. Please try again.'; 451 + } finally { 452 + this._saving = false; 453 + } 454 + } 455 + 456 + async #handleSkip() { 457 + if (this._saving) return; 458 + 459 + this._saving = true; 460 + this._error = null; 461 + 462 + try { 463 + await mutations.createEmptyProfile(); 464 + this.#redirectToDestination(); 465 + } catch (err) { 466 + console.error('Failed to skip onboarding:', err); 467 + this._error = err.message || 'Something went wrong. Please try again.'; 468 + } finally { 469 + this._saving = false; 470 + } 471 + } 472 + 473 + render() { 474 + if (this._loading) { 475 + return html` 476 + <div class="loading"> 477 + <grain-spinner size="32"></grain-spinner> 478 + <p>Loading...</p> 479 + </div> 480 + `; 481 + } 482 + 483 + return html` 484 + <div class="header"> 485 + <h1>Welcome to Grain</h1> 486 + <p class="subtitle">Set up your profile to get started</p> 487 + </div> 488 + 489 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 490 + 491 + <div class="content"> 492 + <div class="avatar-section"> 493 + <div class="avatar-wrapper" @click=${this.#handleAvatarClick}> 494 + ${this.#displayedAvatarUrl ? html` 495 + <img class="avatar-preview" src=${this.#displayedAvatarUrl} alt="Profile avatar"> 496 + ` : html` 497 + <grain-avatar size="lg"></grain-avatar> 498 + `} 499 + <div class="avatar-overlay"> 500 + <grain-icon name="camera" size="14"></grain-icon> 501 + </div> 502 + </div> 503 + <input 504 + type="file" 505 + id="avatar-input" 506 + accept="image/png,image/jpeg" 507 + @change=${this.#handleAvatarChange} 508 + > 509 + </div> 510 + 511 + <grain-form-field label="Display Name" .value=${this._displayName} .maxlength=${64}> 512 + <grain-input 513 + placeholder="Display name" 514 + .value=${this._displayName} 515 + @input=${this.#handleDisplayNameChange} 516 + ></grain-input> 517 + </grain-form-field> 518 + 519 + <grain-form-field label="Bio" .value=${this._description} .maxlength=${256}> 520 + <grain-textarea 521 + placeholder="Tell us about yourself" 522 + .value=${this._description} 523 + .maxlength=${256} 524 + @input=${this.#handleDescriptionChange} 525 + ></grain-textarea> 526 + </grain-form-field> 527 + </div> 528 + 529 + <div class="actions"> 530 + <grain-button 531 + variant="primary" 532 + ?loading=${this._saving} 533 + loadingText="Saving..." 534 + @click=${this.#handleSave} 535 + >Save & Continue</grain-button> 536 + <button 537 + class="skip-button" 538 + ?disabled=${this._saving} 539 + @click=${this.#handleSkip} 540 + >Skip for now</button> 541 + </div> 542 + 543 + <grain-avatar-crop 544 + ?open=${this._showAvatarCrop} 545 + image-url=${this._cropImageUrl || ''} 546 + @crop=${this.#handleCrop} 547 + @cancel=${this.#handleCropCancel} 548 + ></grain-avatar-crop> 549 + `; 550 + } 551 + } 552 + 553 + customElements.define('grain-onboarding', GrainOnboarding); 554 + ``` 555 + 556 + **Step 2: Verify the component compiles** 557 + 558 + Run: `npm run dev` 559 + Navigate to: `http://localhost:5173/onboarding` 560 + Expected: Page loads (redirects to home if not authenticated or already has profile) 561 + 562 + **Step 3: Commit** 563 + 564 + ```bash 565 + git add src/components/pages/grain-onboarding.js 566 + git commit -m "feat: add onboarding component with Bluesky profile prefill" 567 + ``` 568 + 569 + --- 570 + 571 + ## Task 5: Modify OAuth Callback to Check Profile 572 + 573 + **Files:** 574 + - Modify: `src/services/auth.js` 575 + 576 + **Step 1: Add import for grainApi** 577 + 578 + Add at line 2 (after router import): 579 + 580 + ```javascript 581 + import { grainApi } from './grain-api.js'; 582 + ``` 583 + 584 + **Step 2: Update the redirect callback handler** 585 + 586 + Replace lines 19-26 in `src/services/auth.js` with: 587 + 588 + ```javascript 589 + // Handle OAuth callback if present 590 + if (window.location.search.includes('code=')) { 591 + await this.#client.handleRedirectCallback(); 592 + 593 + // Check if user has a Grain profile 594 + const hasProfile = await grainApi.hasGrainProfile(this.#client); 595 + 596 + if (!hasProfile) { 597 + // First-time user - redirect to onboarding 598 + window.location.replace('/onboarding'); 599 + return; 600 + } 601 + 602 + // Existing user - redirect to their destination 603 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 604 + sessionStorage.removeItem('oauth_return_url'); 605 + window.location.replace(returnUrl); 606 + return; 607 + } 608 + ``` 609 + 610 + **Step 3: Commit** 611 + 612 + ```bash 613 + git add src/services/auth.js 614 + git commit -m "feat: redirect first-time users to onboarding after OAuth" 615 + ``` 616 + 617 + --- 618 + 619 + ## Task 6: Test Complete Flow 620 + 621 + **Files:** 622 + - None (manual testing) 623 + 624 + **Step 1: Test new user flow** 625 + 626 + 1. Clear localStorage/sessionStorage (or use incognito) 627 + 2. Navigate to `/explore` 628 + 3. Click login, authenticate with Bluesky 629 + 4. Expected: Redirected to `/onboarding` with Bluesky profile prefilled 630 + 5. Click "Save & Continue" 631 + 6. Expected: Redirected to `/explore` (original return URL) 632 + 633 + **Step 2: Test skip flow** 634 + 635 + 1. Use a different account without Grain profile 636 + 2. Go through OAuth 637 + 3. On onboarding, click "Skip for now" 638 + 4. Expected: Redirected to return URL, profile record created 639 + 640 + **Step 3: Test returning user flow** 641 + 642 + 1. Log out and log back in with same account 643 + 2. Expected: Goes directly to return URL (no onboarding) 644 + 645 + **Step 4: Test manual onboarding access** 646 + 647 + 1. While logged in with existing profile, navigate to `/onboarding` 648 + 2. Expected: Immediately redirected to home 649 + 650 + --- 651 + 652 + ## Summary 653 + 654 + | Task | Description | Files | 655 + |------|-------------|-------| 656 + | 1 | Add profile queries | `grain-api.js` | 657 + | 2 | Add profile mutations | `mutations.js` | 658 + | 3 | Register route | `grain-app.js` | 659 + | 4 | Create onboarding component | `grain-onboarding.js` (new) | 660 + | 5 | Modify OAuth callback | `auth.js` | 661 + | 6 | Test complete flow | Manual testing |
+257
docs/plans/2026-01-04-alt-overlay-scroll-fix.md
··· 1 + # Alt Text Overlay Scroll Fix 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix alt text overlay position drifting when page scrolls by moving overlay rendering from the badge to the carousel. 6 + 7 + **Architecture:** Move overlay from `grain-alt-badge` (which uses `position: fixed` with JS positioning) to `grain-image-carousel` (which renders it inside the slide with `position: absolute; inset: 0`). The badge becomes a simple button that emits an event. 8 + 9 + **Tech Stack:** Lit, Web Components, CSS 10 + 11 + --- 12 + 13 + ### Task 1: Simplify grain-alt-badge to emit event 14 + 15 + **Files:** 16 + - Modify: `src/components/atoms/grain-alt-badge.js` 17 + 18 + **Step 1: Remove overlay state and scroll listener logic** 19 + 20 + Replace the entire file with: 21 + 22 + ```js 23 + import { LitElement, html, css } from 'lit'; 24 + 25 + export class GrainAltBadge extends LitElement { 26 + static properties = { 27 + alt: { type: String } 28 + }; 29 + 30 + static styles = css` 31 + :host { 32 + position: absolute; 33 + bottom: 8px; 34 + right: 8px; 35 + z-index: 2; 36 + } 37 + .badge { 38 + background: rgba(0, 0, 0, 0.7); 39 + color: white; 40 + font-size: 10px; 41 + font-weight: 600; 42 + padding: 2px 4px; 43 + border-radius: 4px; 44 + cursor: pointer; 45 + user-select: none; 46 + border: none; 47 + font-family: inherit; 48 + } 49 + .badge:hover { 50 + background: rgba(0, 0, 0, 0.85); 51 + } 52 + .badge:focus { 53 + outline: 2px solid white; 54 + outline-offset: 1px; 55 + } 56 + `; 57 + 58 + constructor() { 59 + super(); 60 + this.alt = ''; 61 + } 62 + 63 + #handleClick(e) { 64 + e.stopPropagation(); 65 + this.dispatchEvent(new CustomEvent('alt-click', { 66 + bubbles: true, 67 + composed: true, 68 + detail: { alt: this.alt } 69 + })); 70 + } 71 + 72 + render() { 73 + if (!this.alt) return null; 74 + 75 + return html` 76 + <button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button> 77 + `; 78 + } 79 + } 80 + 81 + customElements.define('grain-alt-badge', GrainAltBadge); 82 + ``` 83 + 84 + **Step 2: Verify badge still renders** 85 + 86 + Run the app, navigate to a gallery with alt text, confirm "ALT" button appears. 87 + 88 + --- 89 + 90 + ### Task 2: Add overlay state and styles to grain-image-carousel 91 + 92 + **Files:** 93 + - Modify: `src/components/organisms/grain-image-carousel.js` 94 + 95 + **Step 1: Add `_activeAltIndex` state property** 96 + 97 + In `static properties`, add: 98 + 99 + ```js 100 + static properties = { 101 + photos: { type: Array }, 102 + rkey: { type: String }, 103 + _currentIndex: { state: true }, 104 + _activeAltIndex: { state: true } 105 + }; 106 + ``` 107 + 108 + **Step 2: Initialize state in constructor** 109 + 110 + Add to constructor: 111 + 112 + ```js 113 + this._activeAltIndex = null; 114 + ``` 115 + 116 + **Step 3: Add overlay styles** 117 + 118 + Add to `static styles` (after `.nav-arrow-right`): 119 + 120 + ```css 121 + .alt-overlay { 122 + position: absolute; 123 + inset: 0; 124 + background: rgba(0, 0, 0, 0.75); 125 + color: white; 126 + padding: 16px; 127 + font-size: 14px; 128 + line-height: 1.5; 129 + overflow-y: auto; 130 + display: flex; 131 + align-items: center; 132 + justify-content: center; 133 + text-align: center; 134 + box-sizing: border-box; 135 + z-index: 3; 136 + cursor: pointer; 137 + } 138 + ``` 139 + 140 + --- 141 + 142 + ### Task 3: Add overlay event handlers to carousel 143 + 144 + **Files:** 145 + - Modify: `src/components/organisms/grain-image-carousel.js` 146 + 147 + **Step 1: Add handler for alt-click event** 148 + 149 + Add method: 150 + 151 + ```js 152 + #handleAltClick(e, index) { 153 + e.stopPropagation(); 154 + this._activeAltIndex = index; 155 + } 156 + ``` 157 + 158 + **Step 2: Add handler for overlay click (dismiss)** 159 + 160 + Add method: 161 + 162 + ```js 163 + #handleOverlayClick(e) { 164 + e.stopPropagation(); 165 + this._activeAltIndex = null; 166 + } 167 + ``` 168 + 169 + **Step 3: Dismiss overlay on slide change** 170 + 171 + Modify `#handleScroll` to clear overlay when swiping: 172 + 173 + ```js 174 + #handleScroll(e) { 175 + const carousel = e.target; 176 + const index = Math.round(carousel.scrollLeft / carousel.offsetWidth); 177 + if (index !== this._currentIndex) { 178 + this._currentIndex = index; 179 + this._activeAltIndex = null; 180 + } 181 + } 182 + ``` 183 + 184 + --- 185 + 186 + ### Task 4: Render overlay in slide template 187 + 188 + **Files:** 189 + - Modify: `src/components/organisms/grain-image-carousel.js` 190 + 191 + **Step 1: Update slide template in render()** 192 + 193 + Replace the slide mapping (lines 153-162) with: 194 + 195 + ```js 196 + ${this.photos.map((photo, index) => html` 197 + <div class="slide ${hasPortrait ? 'centered' : ''}"> 198 + <grain-image 199 + src=${this.#shouldLoad(index) ? photo.url : ''} 200 + alt=${photo.alt || ''} 201 + aspectRatio=${photo.aspectRatio || 1} 202 + style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 203 + ></grain-image> 204 + ${photo.alt ? html` 205 + <grain-alt-badge 206 + .alt=${photo.alt} 207 + @alt-click=${(e) => this.#handleAltClick(e, index)} 208 + ></grain-alt-badge> 209 + ` : ''} 210 + ${this._activeAltIndex === index ? html` 211 + <div class="alt-overlay" @click=${this.#handleOverlayClick}> 212 + ${photo.alt} 213 + </div> 214 + ` : ''} 215 + </div> 216 + `)} 217 + ``` 218 + 219 + --- 220 + 221 + ### Task 5: Manual testing 222 + 223 + **Step 1: Test overlay appears correctly** 224 + 225 + 1. Navigate to a gallery with alt text 226 + 2. Click "ALT" button 227 + 3. Confirm overlay appears covering the image 228 + 229 + **Step 2: Test overlay dismisses on click** 230 + 231 + 1. With overlay open, click the overlay 232 + 2. Confirm overlay closes 233 + 234 + **Step 3: Test overlay dismisses on swipe** 235 + 236 + 1. Open alt overlay on first image 237 + 2. Swipe to second image 238 + 3. Confirm overlay closes 239 + 240 + **Step 4: Test scroll behavior (the bug fix)** 241 + 242 + 1. Open alt overlay 243 + 2. Scroll the page up/down 244 + 3. Confirm overlay stays attached to the image (doesn't drift) 245 + 246 + --- 247 + 248 + ### Task 6: Commit 249 + 250 + ```bash 251 + git add src/components/atoms/grain-alt-badge.js src/components/organisms/grain-image-carousel.js 252 + git commit -m "fix: alt text overlay stays attached on page scroll 253 + 254 + Move overlay rendering from grain-alt-badge to grain-image-carousel. 255 + The overlay now uses position:absolute within the slide instead of 256 + position:fixed with JS positioning, so it naturally scrolls with content." 257 + ```
+597
docs/plans/2026-01-04-app-level-dialog-system.md
··· 1 + # App-Level Dialog System Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Move all dialogs to app level so they render above the fixed-position outlet and display correctly on all screen sizes. 6 + 7 + **Architecture:** Pages dispatch `open-dialog` events with type and props. `grain-app` maintains a dialog registry mapping types to render functions. Dialogs render at app root level, outside the constrained `#outlet`. Dialog-specific callbacks re-dispatch as events for pages to handle. 8 + 9 + **Tech Stack:** Lit 3, Custom Events 10 + 11 + --- 12 + 13 + ## Task 1: Add Dialog System to grain-app 14 + 15 + **Files:** 16 + - Modify: `src/components/pages/grain-app.js` 17 + 18 + **Step 1: Add imports** 19 + 20 + Add after existing imports: 21 + 22 + ```javascript 23 + import '../organisms/grain-action-dialog.js'; 24 + import '../organisms/grain-report-dialog.js'; 25 + import '../atoms/grain-toast.js'; 26 + ``` 27 + 28 + **Step 2: Add state properties** 29 + 30 + Add static properties to the class: 31 + 32 + ```javascript 33 + static properties = { 34 + _dialogType: { state: true }, 35 + _dialogProps: { state: true } 36 + }; 37 + ``` 38 + 39 + **Step 3: Add constructor** 40 + 41 + ```javascript 42 + constructor() { 43 + super(); 44 + this._dialogType = null; 45 + this._dialogProps = {}; 46 + } 47 + ``` 48 + 49 + **Step 4: Add lifecycle and event handlers** 50 + 51 + Add after constructor: 52 + 53 + ```javascript 54 + connectedCallback() { 55 + super.connectedCallback(); 56 + this.addEventListener('open-dialog', this.#handleOpenDialog); 57 + } 58 + 59 + disconnectedCallback() { 60 + this.removeEventListener('open-dialog', this.#handleOpenDialog); 61 + super.disconnectedCallback(); 62 + } 63 + 64 + #handleOpenDialog = (e) => { 65 + this._dialogType = e.detail.type; 66 + this._dialogProps = e.detail.props || {}; 67 + }; 68 + 69 + #closeDialog = () => { 70 + this._dialogType = null; 71 + this._dialogProps = {}; 72 + }; 73 + 74 + #handleReportSubmitted = () => { 75 + this.#closeDialog(); 76 + this.shadowRoot.querySelector('grain-toast')?.show('Report submitted'); 77 + }; 78 + 79 + #handleDialogAction = (e) => { 80 + this.dispatchEvent(new CustomEvent('dialog-action', { 81 + bubbles: true, 82 + composed: true, 83 + detail: e.detail 84 + })); 85 + }; 86 + ``` 87 + 88 + **Step 5: Add dialog registry** 89 + 90 + Add after the event handlers: 91 + 92 + ```javascript 93 + #renderDialog() { 94 + switch (this._dialogType) { 95 + case 'report': 96 + return html` 97 + <grain-report-dialog 98 + open 99 + galleryUri=${this._dialogProps.galleryUri || ''} 100 + @close=${this.#closeDialog} 101 + @submitted=${this.#handleReportSubmitted} 102 + ></grain-report-dialog> 103 + `; 104 + case 'action': 105 + return html` 106 + <grain-action-dialog 107 + open 108 + .actions=${this._dialogProps.actions || []} 109 + ?loading=${this._dialogProps.loading} 110 + loadingText=${this._dialogProps.loadingText || ''} 111 + @close=${this.#closeDialog} 112 + @action=${this.#handleDialogAction} 113 + ></grain-action-dialog> 114 + `; 115 + default: 116 + return ''; 117 + } 118 + } 119 + ``` 120 + 121 + **Step 6: Update render method** 122 + 123 + Replace the render method: 124 + 125 + ```javascript 126 + render() { 127 + return html` 128 + <grain-header></grain-header> 129 + <div id="outlet"></div> 130 + <grain-bottom-nav></grain-bottom-nav> 131 + ${this.#renderDialog()} 132 + <grain-toast></grain-toast> 133 + `; 134 + } 135 + ``` 136 + 137 + **Step 7: Verify syntax** 138 + 139 + Run: `npm run build` 140 + Expected: Build succeeds 141 + 142 + **Step 8: Commit** 143 + 144 + ```bash 145 + git add src/components/pages/grain-app.js 146 + git commit -m "feat: add app-level dialog system with registry" 147 + ``` 148 + 149 + --- 150 + 151 + ## Task 2: Revert Dialog Overlay CSS 152 + 153 + **Files:** 154 + - Modify: `src/components/organisms/grain-action-dialog.js` 155 + - Modify: `src/components/organisms/grain-report-dialog.js` 156 + 157 + **Step 1: Fix grain-action-dialog overlay** 158 + 159 + In `grain-action-dialog.js`, replace the `.overlay` CSS: 160 + 161 + ```css 162 + .overlay { 163 + position: fixed; 164 + inset: 0; 165 + background: rgba(0, 0, 0, 0.5); 166 + display: flex; 167 + align-items: center; 168 + justify-content: center; 169 + z-index: 1000; 170 + padding: var(--space-md); 171 + } 172 + ``` 173 + 174 + **Step 2: Fix grain-report-dialog overlay** 175 + 176 + In `grain-report-dialog.js`, replace the `.overlay` CSS: 177 + 178 + ```css 179 + .overlay { 180 + position: fixed; 181 + inset: 0; 182 + background: rgba(0, 0, 0, 0.5); 183 + display: flex; 184 + align-items: center; 185 + justify-content: center; 186 + z-index: 1000; 187 + padding: var(--space-md); 188 + } 189 + ``` 190 + 191 + **Step 3: Verify syntax** 192 + 193 + Run: `npm run build` 194 + Expected: Build succeeds 195 + 196 + **Step 4: Commit** 197 + 198 + ```bash 199 + git add src/components/organisms/grain-action-dialog.js src/components/organisms/grain-report-dialog.js 200 + git commit -m "fix: revert dialog overlay CSS to simple inset:0" 201 + ``` 202 + 203 + --- 204 + 205 + ## Task 3: Update grain-timeline to Use Dialog Events 206 + 207 + **Files:** 208 + - Modify: `src/components/pages/grain-timeline.js` 209 + 210 + **Step 1: Remove dialog imports** 211 + 212 + Remove these lines: 213 + 214 + ```javascript 215 + import '../organisms/grain-action-dialog.js'; 216 + import '../organisms/grain-report-dialog.js'; 217 + import '../atoms/grain-toast.js'; 218 + ``` 219 + 220 + **Step 2: Simplify state properties** 221 + 222 + Remove these properties: 223 + 224 + ```javascript 225 + _menuOpen: { state: true }, 226 + _menuGallery: { state: true }, 227 + _menuIsOwner: { state: true }, 228 + _deleting: { state: true }, 229 + _reportDialogOpen: { state: true } 230 + ``` 231 + 232 + Add this one property: 233 + 234 + ```javascript 235 + _pendingGallery: { state: true } 236 + ``` 237 + 238 + **Step 3: Simplify constructor** 239 + 240 + Remove these initializations: 241 + 242 + ```javascript 243 + this._menuOpen = false; 244 + this._menuGallery = null; 245 + this._menuIsOwner = false; 246 + this._deleting = false; 247 + this._reportDialogOpen = false; 248 + ``` 249 + 250 + Add: 251 + 252 + ```javascript 253 + this._pendingGallery = null; 254 + ``` 255 + 256 + **Step 4: Add dialog-action listener in connectedCallback** 257 + 258 + After the existing scroll listener setup: 259 + 260 + ```javascript 261 + this.addEventListener('dialog-action', this.#handleDialogAction); 262 + ``` 263 + 264 + **Step 5: Add cleanup in disconnectedCallback** 265 + 266 + In the existing disconnectedCallback, add: 267 + 268 + ```javascript 269 + this.removeEventListener('dialog-action', this.#handleDialogAction); 270 + ``` 271 + 272 + **Step 6: Replace menu handlers** 273 + 274 + Remove these methods: 275 + - `#handleMenuClose` 276 + - `#handleMenuAction` 277 + - `#handleReportDialogClose` 278 + - `#handleReportSubmitted` 279 + 280 + Replace `#handleGalleryMenu` with: 281 + 282 + ```javascript 283 + #handleGalleryMenu(e) { 284 + const { gallery, isOwner } = e.detail; 285 + this._pendingGallery = gallery; 286 + 287 + this.dispatchEvent(new CustomEvent('open-dialog', { 288 + bubbles: true, 289 + composed: true, 290 + detail: { 291 + type: 'action', 292 + props: { 293 + actions: isOwner 294 + ? [{ label: 'Delete', action: 'delete', danger: true }] 295 + : [{ label: 'Report gallery', action: 'report' }] 296 + } 297 + } 298 + })); 299 + } 300 + ``` 301 + 302 + Add new `#handleDialogAction`: 303 + 304 + ```javascript 305 + #handleDialogAction = (e) => { 306 + if (e.detail.action === 'delete') { 307 + this.#handleDelete(); 308 + } else if (e.detail.action === 'report') { 309 + this.dispatchEvent(new CustomEvent('open-dialog', { 310 + bubbles: true, 311 + composed: true, 312 + detail: { 313 + type: 'report', 314 + props: { galleryUri: this._pendingGallery?.uri } 315 + } 316 + })); 317 + } 318 + }; 319 + ``` 320 + 321 + **Step 7: Update #handleDelete** 322 + 323 + Replace the method with: 324 + 325 + ```javascript 326 + async #handleDelete() { 327 + if (!this._pendingGallery) return; 328 + 329 + // Show loading state 330 + this.dispatchEvent(new CustomEvent('open-dialog', { 331 + bubbles: true, 332 + composed: true, 333 + detail: { 334 + type: 'action', 335 + props: { 336 + actions: [{ label: 'Delete', action: 'delete', danger: true }], 337 + loading: true, 338 + loadingText: 'Deleting...' 339 + } 340 + } 341 + })); 342 + 343 + try { 344 + const client = auth.getClient(); 345 + const rkey = this._pendingGallery.uri.split('/').pop(); 346 + 347 + await client.mutate(` 348 + mutation DeleteGallery($rkey: String!) { 349 + deleteSocialGrainGallery(rkey: $rkey) { uri } 350 + } 351 + `, { rkey }); 352 + 353 + this._galleries = this._galleries.filter(g => g.uri !== this._pendingGallery.uri); 354 + this._pendingGallery = null; 355 + 356 + // Close dialog by dispatching close (app listens) 357 + this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true })); 358 + } catch (err) { 359 + console.error('Failed to delete gallery:', err); 360 + this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true })); 361 + } 362 + } 363 + ``` 364 + 365 + **Step 8: Remove dialogs from render** 366 + 367 + Remove these elements from the render method: 368 + 369 + ```javascript 370 + <grain-action-dialog ...></grain-action-dialog> 371 + <grain-report-dialog ...></grain-report-dialog> 372 + <grain-toast></grain-toast> 373 + ``` 374 + 375 + **Step 9: Verify syntax** 376 + 377 + Run: `npm run build` 378 + Expected: Build succeeds 379 + 380 + **Step 10: Commit** 381 + 382 + ```bash 383 + git add src/components/pages/grain-timeline.js 384 + git commit -m "refactor: use app-level dialog events in timeline" 385 + ``` 386 + 387 + --- 388 + 389 + ## Task 4: Update grain-gallery-detail to Use Dialog Events 390 + 391 + **Files:** 392 + - Modify: `src/components/pages/grain-gallery-detail.js` 393 + 394 + **Step 1: Remove dialog imports** 395 + 396 + Remove these lines: 397 + 398 + ```javascript 399 + import '../organisms/grain-report-dialog.js'; 400 + import '../atoms/grain-toast.js'; 401 + ``` 402 + 403 + **Step 2: Remove state property** 404 + 405 + Remove: 406 + 407 + ```javascript 408 + _reportDialogOpen: { state: true } 409 + ``` 410 + 411 + **Step 3: Remove constructor initialization** 412 + 413 + Remove: 414 + 415 + ```javascript 416 + this._reportDialogOpen = false; 417 + ``` 418 + 419 + **Step 4: Add dialog-action listener** 420 + 421 + In connectedCallback (add the method if it doesn't exist): 422 + 423 + ```javascript 424 + connectedCallback() { 425 + super.connectedCallback(); 426 + this.#loadGallery(); 427 + this.addEventListener('dialog-action', this.#handleDialogAction); 428 + } 429 + ``` 430 + 431 + Add disconnectedCallback cleanup: 432 + 433 + ```javascript 434 + // In existing disconnectedCallback, add: 435 + this.removeEventListener('dialog-action', this.#handleDialogAction); 436 + ``` 437 + 438 + **Step 5: Replace report dialog handlers** 439 + 440 + Remove: 441 + - `#handleReportDialogClose` 442 + - `#handleReportSubmitted` 443 + 444 + Update `#handleAction`: 445 + 446 + ```javascript 447 + async #handleAction(e) { 448 + if (e.detail.action === 'delete') { 449 + await this.#handleDelete(); 450 + } else if (e.detail.action === 'report') { 451 + this.dispatchEvent(new CustomEvent('open-dialog', { 452 + bubbles: true, 453 + composed: true, 454 + detail: { 455 + type: 'report', 456 + props: { galleryUri: this._gallery?.uri } 457 + } 458 + })); 459 + } 460 + } 461 + ``` 462 + 463 + Add `#handleDialogAction`: 464 + 465 + ```javascript 466 + #handleDialogAction = (e) => { 467 + // Handle actions dispatched back from app-level dialog 468 + if (e.detail.action === 'delete') { 469 + this.#handleDelete(); 470 + } else if (e.detail.action === 'report') { 471 + this.dispatchEvent(new CustomEvent('open-dialog', { 472 + bubbles: true, 473 + composed: true, 474 + detail: { 475 + type: 'report', 476 + props: { galleryUri: this._gallery?.uri } 477 + } 478 + })); 479 + } 480 + }; 481 + ``` 482 + 483 + **Step 6: Update menu to use app-level action dialog** 484 + 485 + Replace the inline action dialog approach. Update `#handleMenuOpen`: 486 + 487 + ```javascript 488 + #handleMenuOpen() { 489 + this.dispatchEvent(new CustomEvent('open-dialog', { 490 + bubbles: true, 491 + composed: true, 492 + detail: { 493 + type: 'action', 494 + props: { 495 + actions: this.#isOwner 496 + ? [{ label: 'Delete', action: 'delete', danger: true }] 497 + : [{ label: 'Report gallery', action: 'report' }] 498 + } 499 + } 500 + })); 501 + } 502 + ``` 503 + 504 + Remove `#handleMenuClose` method. 505 + 506 + Remove `_menuOpen` state property and constructor initialization. 507 + 508 + **Step 7: Remove dialogs from render** 509 + 510 + Remove these elements from render: 511 + 512 + ```javascript 513 + <grain-action-dialog ...></grain-action-dialog> 514 + <grain-report-dialog ...></grain-report-dialog> 515 + <grain-toast></grain-toast> 516 + ``` 517 + 518 + **Step 8: Verify syntax** 519 + 520 + Run: `npm run build` 521 + Expected: Build succeeds 522 + 523 + **Step 9: Commit** 524 + 525 + ```bash 526 + git add src/components/pages/grain-gallery-detail.js 527 + git commit -m "refactor: use app-level dialog events in gallery detail" 528 + ``` 529 + 530 + --- 531 + 532 + ## Task 5: Add close-dialog Event Support to grain-app 533 + 534 + **Files:** 535 + - Modify: `src/components/pages/grain-app.js` 536 + 537 + **Step 1: Add close-dialog listener** 538 + 539 + In connectedCallback, add: 540 + 541 + ```javascript 542 + this.addEventListener('close-dialog', this.#closeDialog); 543 + ``` 544 + 545 + In disconnectedCallback, add: 546 + 547 + ```javascript 548 + this.removeEventListener('close-dialog', this.#closeDialog); 549 + ``` 550 + 551 + **Step 2: Verify syntax** 552 + 553 + Run: `npm run build` 554 + Expected: Build succeeds 555 + 556 + **Step 3: Commit** 557 + 558 + ```bash 559 + git add src/components/pages/grain-app.js 560 + git commit -m "feat: add close-dialog event support" 561 + ``` 562 + 563 + --- 564 + 565 + ## Task 6: Final Verification 566 + 567 + **Step 1: Full build** 568 + 569 + Run: `npm run build` 570 + Expected: Build succeeds with no errors 571 + 572 + **Step 2: Manual testing** 573 + 574 + Run: `npm run dev` 575 + 576 + Test these scenarios: 577 + 1. Timeline: Click ellipsis on other's gallery โ†’ "Report gallery" action โ†’ Report dialog opens fullscreen 578 + 2. Timeline: Click ellipsis on own gallery โ†’ "Delete" action 579 + 3. Gallery detail: Same tests as above 580 + 4. Report submission shows toast 581 + 5. Dialogs close when clicking overlay 582 + 6. Dialogs display correctly on small screens (no cut-off) 583 + 584 + **Step 3: Commit any fixes if needed** 585 + 586 + --- 587 + 588 + ## Summary 589 + 590 + | Task | Files | Description | 591 + |------|-------|-------------| 592 + | 1 | grain-app.js | Add dialog registry system | 593 + | 2 | grain-action-dialog.js, grain-report-dialog.js | Revert overlay CSS | 594 + | 3 | grain-timeline.js | Use dialog events | 595 + | 4 | grain-gallery-detail.js | Use dialog events | 596 + | 5 | grain-app.js | Add close-dialog support | 597 + | 6 | - | Final verification |
+172
docs/plans/2026-01-04-haptic-feedback.md
··· 1 + # Haptic Feedback Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add haptic feedback to bottom navigation taps for iOS 18+ and Android PWA users. 6 + 7 + **Architecture:** A small utility module (`haptics.js`) that uses the iOS 18 checkbox-switch hack for Safari and falls back to `navigator.vibrate()` for Android. The bottom nav component imports and calls the trigger function in each navigation handler. 8 + 9 + **Tech Stack:** Vanilla JS, Lit elements, Web Vibration API, iOS 18 checkbox switch attribute 10 + 11 + --- 12 + 13 + ### Task 1: Create Haptics Utility 14 + 15 + **Files:** 16 + - Create: `src/utils/haptics.js` 17 + 18 + **Step 1: Create the haptics utility file** 19 + 20 + ```js 21 + /** 22 + * Haptic feedback utility for PWA 23 + * - iOS 18+: Uses checkbox switch element hack 24 + * - Android: Uses Vibration API 25 + * - Other: Silently does nothing 26 + */ 27 + 28 + // Platform detection 29 + const isIOS = /iPhone|iPad/.test(navigator.userAgent); 30 + const hasVibrate = 'vibrate' in navigator; 31 + 32 + // Lazy-initialized hidden elements for iOS 33 + let checkbox = null; 34 + let label = null; 35 + 36 + function ensureElements() { 37 + if (checkbox) return; 38 + 39 + checkbox = document.createElement('input'); 40 + checkbox.type = 'checkbox'; 41 + checkbox.setAttribute('switch', ''); 42 + checkbox.id = 'haptic-trigger'; 43 + checkbox.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;'; 44 + 45 + label = document.createElement('label'); 46 + label.htmlFor = 'haptic-trigger'; 47 + label.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;'; 48 + 49 + document.body.append(checkbox, label); 50 + } 51 + 52 + /** 53 + * Trigger a light haptic tap 54 + */ 55 + export function trigger() { 56 + if (isIOS) { 57 + ensureElements(); 58 + label.click(); 59 + } else if (hasVibrate) { 60 + navigator.vibrate(10); 61 + } 62 + } 63 + 64 + /** 65 + * Check if haptics are supported on this device 66 + */ 67 + export function isSupported() { 68 + return isIOS || hasVibrate; 69 + } 70 + ``` 71 + 72 + **Step 2: Commit the utility** 73 + 74 + ```bash 75 + git add src/utils/haptics.js 76 + git commit -m "feat: add haptics utility for iOS 18+ and Android" 77 + ``` 78 + 79 + --- 80 + 81 + ### Task 2: Integrate Haptics into Bottom Nav 82 + 83 + **Files:** 84 + - Modify: `src/components/organisms/grain-bottom-nav.js` 85 + 86 + **Step 1: Add the import** 87 + 88 + At the top of the file with other imports, add: 89 + 90 + ```js 91 + import { trigger as haptic } from '../../utils/haptics.js'; 92 + ``` 93 + 94 + **Step 2: Add haptic calls to navigation handlers** 95 + 96 + Find each handler method and add `haptic();` as the first line: 97 + 98 + ```js 99 + #handleHome() { 100 + haptic(); 101 + router.push('/'); 102 + } 103 + 104 + #handleProfile() { 105 + haptic(); 106 + router.push(`/profile/${this._user.handle}`); 107 + } 108 + 109 + #handleExplore() { 110 + haptic(); 111 + router.push('/explore'); 112 + } 113 + 114 + #handleNotifications() { 115 + haptic(); 116 + router.push('/notifications'); 117 + } 118 + 119 + #handleCreate() { 120 + haptic(); 121 + this._fileInput.click(); 122 + } 123 + ``` 124 + 125 + **Step 3: Commit the integration** 126 + 127 + ```bash 128 + git add src/components/organisms/grain-bottom-nav.js 129 + git commit -m "feat: add haptic feedback to bottom nav taps" 130 + ``` 131 + 132 + --- 133 + 134 + ### Task 3: Manual Testing 135 + 136 + **Step 1: Start the dev server** 137 + 138 + ```bash 139 + npm run dev 140 + ``` 141 + 142 + **Step 2: Test on iOS 18+ device** 143 + 144 + - Open the app in Safari on iPhone/iPad running iOS 18+ 145 + - Add to Home Screen (PWA mode) 146 + - Tap each bottom nav item 147 + - Expected: Light haptic tap on each navigation 148 + 149 + **Step 3: Test on Android device** 150 + 151 + - Open the app in Chrome on Android 152 + - Add to Home Screen or test in browser 153 + - Tap each bottom nav item 154 + - Expected: Light vibration (10ms) on each navigation 155 + 156 + **Step 4: Test on desktop** 157 + 158 + - Open the app in any desktop browser 159 + - Click each bottom nav item 160 + - Expected: No errors, navigation works normally, no haptic (graceful degradation) 161 + 162 + --- 163 + 164 + ## Summary 165 + 166 + | Task | Description | Files | 167 + |------|-------------|-------| 168 + | 1 | Create haptics utility | `src/utils/haptics.js` | 169 + | 2 | Integrate into bottom nav | `src/components/organisms/grain-bottom-nav.js` | 170 + | 3 | Manual testing | N/A | 171 + 172 + **Total changes:** 2 files, ~40 lines of code
+74
lexicons/app/bsky/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.actor.profile", 4 + "defs": { 5 + "main": { 6 + "key": "literal:self", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "avatar": { 12 + "type": "blob", 13 + "accept": [ 14 + "image/png", 15 + "image/jpeg" 16 + ], 17 + "maxSize": 1000000, 18 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" 19 + }, 20 + "banner": { 21 + "type": "blob", 22 + "accept": [ 23 + "image/png", 24 + "image/jpeg" 25 + ], 26 + "maxSize": 1000000, 27 + "description": "Larger horizontal image to display behind profile view." 28 + }, 29 + "labels": { 30 + "refs": [ 31 + "com.atproto.label.defs#selfLabels" 32 + ], 33 + "type": "union", 34 + "description": "Self-label values, specific to the Bluesky application, on the overall account." 35 + }, 36 + "website": { 37 + "type": "string", 38 + "format": "uri" 39 + }, 40 + "pronouns": { 41 + "type": "string", 42 + "maxLength": 200, 43 + "description": "Free-form pronouns text.", 44 + "maxGraphemes": 20 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + }, 50 + "pinnedPost": { 51 + "ref": "com.atproto.repo.strongRef", 52 + "type": "ref" 53 + }, 54 + "description": { 55 + "type": "string", 56 + "maxLength": 2560, 57 + "description": "Free-form profile description text.", 58 + "maxGraphemes": 256 59 + }, 60 + "displayName": { 61 + "type": "string", 62 + "maxLength": 640, 63 + "maxGraphemes": 64 64 + }, 65 + "joinedViaStarterPack": { 66 + "ref": "com.atproto.repo.strongRef", 67 + "type": "ref" 68 + } 69 + } 70 + }, 71 + "description": "A declaration of a Bluesky account profile." 72 + } 73 + } 74 + }
+73
lexicons/app/bsky/richtext/factet.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.richtext.facet", 4 + "defs": { 5 + "tag": { 6 + "type": "object", 7 + "required": ["tag"], 8 + "properties": { 9 + "tag": { 10 + "type": "string", 11 + "maxLength": 640, 12 + "maxGraphemes": 64 13 + } 14 + }, 15 + "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags')." 16 + }, 17 + "link": { 18 + "type": "object", 19 + "required": ["uri"], 20 + "properties": { 21 + "uri": { 22 + "type": "string", 23 + "format": "uri" 24 + } 25 + }, 26 + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL." 27 + }, 28 + "main": { 29 + "type": "object", 30 + "required": ["index", "features"], 31 + "properties": { 32 + "index": { 33 + "ref": "#byteSlice", 34 + "type": "ref" 35 + }, 36 + "features": { 37 + "type": "array", 38 + "items": { 39 + "refs": ["#mention", "#link", "#tag"], 40 + "type": "union" 41 + } 42 + } 43 + }, 44 + "description": "Annotation of a sub-string within rich text." 45 + }, 46 + "mention": { 47 + "type": "object", 48 + "required": ["did"], 49 + "properties": { 50 + "did": { 51 + "type": "string", 52 + "format": "did" 53 + } 54 + }, 55 + "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID." 56 + }, 57 + "byteSlice": { 58 + "type": "object", 59 + "required": ["byteStart", "byteEnd"], 60 + "properties": { 61 + "byteEnd": { 62 + "type": "integer", 63 + "minimum": 0 64 + }, 65 + "byteStart": { 66 + "type": "integer", 67 + "minimum": 0 68 + } 69 + }, 70 + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets." 71 + } 72 + } 73 + }
+192
lexicons/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "required": [ 8 + "src", 9 + "uri", 10 + "val", 11 + "cts" 12 + ], 13 + "properties": { 14 + "cid": { 15 + "type": "string", 16 + "format": "cid", 17 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 18 + }, 19 + "cts": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when this label was created." 23 + }, 24 + "exp": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp at which this label expires (no longer applies)." 28 + }, 29 + "neg": { 30 + "type": "boolean", 31 + "description": "If true, this is a negation label, overwriting a previous label." 32 + }, 33 + "sig": { 34 + "type": "bytes", 35 + "description": "Signature of dag-cbor encoded label." 36 + }, 37 + "src": { 38 + "type": "string", 39 + "format": "did", 40 + "description": "DID of the actor who created this label." 41 + }, 42 + "uri": { 43 + "type": "string", 44 + "format": "uri", 45 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 46 + }, 47 + "val": { 48 + "type": "string", 49 + "maxLength": 128, 50 + "description": "The short string name of the value or type of this label." 51 + }, 52 + "ver": { 53 + "type": "integer", 54 + "description": "The AT Protocol version of the label object." 55 + } 56 + }, 57 + "description": "Metadata tag on an atproto resource (eg, repo or record)." 58 + }, 59 + "selfLabel": { 60 + "type": "object", 61 + "required": [ 62 + "val" 63 + ], 64 + "properties": { 65 + "val": { 66 + "type": "string", 67 + "maxLength": 128, 68 + "description": "The short string name of the value or type of this label." 69 + } 70 + }, 71 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." 72 + }, 73 + "labelValue": { 74 + "type": "string", 75 + "knownValues": [ 76 + "!hide", 77 + "!no-promote", 78 + "!warn", 79 + "!no-unauthenticated", 80 + "dmca-violation", 81 + "doxxing", 82 + "porn", 83 + "sexual", 84 + "nudity", 85 + "nsfl", 86 + "gore" 87 + ] 88 + }, 89 + "selfLabels": { 90 + "type": "object", 91 + "required": [ 92 + "values" 93 + ], 94 + "properties": { 95 + "values": { 96 + "type": "array", 97 + "items": { 98 + "ref": "#selfLabel", 99 + "type": "ref" 100 + }, 101 + "maxLength": 10 102 + } 103 + }, 104 + "description": "Metadata tags on an atproto record, published by the author within the record." 105 + }, 106 + "labelValueDefinition": { 107 + "type": "object", 108 + "required": [ 109 + "identifier", 110 + "severity", 111 + "blurs", 112 + "locales" 113 + ], 114 + "properties": { 115 + "blurs": { 116 + "type": "string", 117 + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 118 + "knownValues": [ 119 + "content", 120 + "media", 121 + "none" 122 + ] 123 + }, 124 + "locales": { 125 + "type": "array", 126 + "items": { 127 + "ref": "#labelValueDefinitionStrings", 128 + "type": "ref" 129 + } 130 + }, 131 + "severity": { 132 + "type": "string", 133 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 134 + "knownValues": [ 135 + "inform", 136 + "alert", 137 + "none" 138 + ] 139 + }, 140 + "adultOnly": { 141 + "type": "boolean", 142 + "description": "Does the user need to have adult content enabled in order to configure this label?" 143 + }, 144 + "identifier": { 145 + "type": "string", 146 + "maxLength": 100, 147 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 148 + "maxGraphemes": 100 149 + }, 150 + "defaultSetting": { 151 + "type": "string", 152 + "default": "warn", 153 + "description": "The default setting for this label.", 154 + "knownValues": [ 155 + "ignore", 156 + "warn", 157 + "hide" 158 + ] 159 + } 160 + }, 161 + "description": "Declares a label value and its expected interpretations and behaviors." 162 + }, 163 + "labelValueDefinitionStrings": { 164 + "type": "object", 165 + "required": [ 166 + "lang", 167 + "name", 168 + "description" 169 + ], 170 + "properties": { 171 + "lang": { 172 + "type": "string", 173 + "format": "language", 174 + "description": "The code of the language these strings are written in." 175 + }, 176 + "name": { 177 + "type": "string", 178 + "maxLength": 640, 179 + "description": "A short human-readable name for the label.", 180 + "maxGraphemes": 64 181 + }, 182 + "description": { 183 + "type": "string", 184 + "maxLength": 100000, 185 + "description": "A longer description of what the label means and why it might be applied.", 186 + "maxGraphemes": 10000 187 + } 188 + }, 189 + "description": "Strings which describe the label in the UI, localized into a specific language." 190 + } 191 + } 192 + }
+24
lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": [ 9 + "uri", 10 + "cid" 11 + ], 12 + "properties": { 13 + "cid": { 14 + "type": "string", 15 + "format": "cid" 16 + }, 17 + "uri": { 18 + "type": "string", 19 + "format": "at-uri" 20 + } 21 + } 22 + } 23 + } 24 + }
+77
lexicons/social/grain/actor/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.actor.defs", 4 + "defs": { 5 + "profileView": { 6 + "type": "object", 7 + "required": ["cid", "did", "handle"], 8 + "properties": { 9 + "cid": { "type": "string", "format": "cid" }, 10 + "did": { "type": "string", "format": "did" }, 11 + "handle": { "type": "string", "format": "handle" }, 12 + "displayName": { 13 + "type": "string", 14 + "maxGraphemes": 64, 15 + "maxLength": 640 16 + }, 17 + "description": { 18 + "type": "string", 19 + "maxLength": 2560, 20 + "maxGraphemes": 256 21 + }, 22 + "labels": { 23 + "type": "array", 24 + "items": { 25 + "ref": "com.atproto.label.defs#label", 26 + "type": "ref" 27 + } 28 + }, 29 + "avatar": { "type": "string", "format": "uri" }, 30 + "createdAt": { "type": "string", "format": "datetime" } 31 + } 32 + }, 33 + "profileViewDetailed": { 34 + "type": "object", 35 + "required": ["cid", "did", "handle"], 36 + "properties": { 37 + "cid": { "type": "string", "format": "cid" }, 38 + "did": { "type": "string", "format": "did" }, 39 + "handle": { "type": "string", "format": "handle" }, 40 + "displayName": { 41 + "type": "string", 42 + "maxGraphemes": 64, 43 + "maxLength": 640 44 + }, 45 + "description": { 46 + "type": "string", 47 + "maxGraphemes": 256, 48 + "maxLength": 2560 49 + }, 50 + "avatar": { "type": "string", "format": "uri" }, 51 + "cameras": { 52 + "type": "array", 53 + "items": { "type": "string" }, 54 + "description": "List of camera make and models used by this actor derived from EXIF data of photos linked to galleries." 55 + }, 56 + "followersCount": { "type": "integer" }, 57 + "followsCount": { "type": "integer" }, 58 + "galleryCount": { "type": "integer" }, 59 + "indexedAt": { "type": "string", "format": "datetime" }, 60 + "createdAt": { "type": "string", "format": "datetime" }, 61 + "viewer": { "type": "ref", "ref": "#viewerState" }, 62 + "labels": { 63 + "type": "array", 64 + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 65 + } 66 + } 67 + }, 68 + "viewerState": { 69 + "type": "object", 70 + "description": "Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests.", 71 + "properties": { 72 + "following": { "type": "string", "format": "at-uri" }, 73 + "followedBy": { "type": "string", "format": "at-uri" } 74 + } 75 + } 76 + } 77 + }
+34
lexicons/social/grain/actor/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a basic account profile.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "displayName": { 13 + "type": "string", 14 + "maxGraphemes": 64, 15 + "maxLength": 640 16 + }, 17 + "description": { 18 + "type": "string", 19 + "description": "Free-form profile description text.", 20 + "maxGraphemes": 256, 21 + "maxLength": 2560 22 + }, 23 + "avatar": { 24 + "type": "blob", 25 + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", 26 + "accept": ["image/png", "image/jpeg"], 27 + "maxSize": 1000000 28 + }, 29 + "createdAt": { "type": "string", "format": "datetime" } 30 + } 31 + } 32 + } 33 + } 34 + }
+42
lexicons/social/grain/comment/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.comment", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["text", "subject", "createdAt"], 11 + "properties": { 12 + "text": { 13 + "type": "string", 14 + "maxLength": 3000, 15 + "maxGraphemes": 300 16 + }, 17 + "facets": { 18 + "type": "array", 19 + "description": "Annotations of description text (mentions and URLs, hashtags, etc)", 20 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 21 + }, 22 + "subject": { 23 + "type": "string", 24 + "format": "at-uri" 25 + }, 26 + "focus": { 27 + "type": "string", 28 + "format": "at-uri" 29 + }, 30 + "replyTo": { 31 + "type": "string", 32 + "format": "at-uri" 33 + }, 34 + "createdAt": { 35 + "type": "string", 36 + "format": "datetime" 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+52
lexicons/social/grain/comment/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.comment.defs", 4 + "defs": { 5 + "commentView": { 6 + "type": "object", 7 + "required": ["uri", "cid", "author", "text", "createdAt"], 8 + "properties": { 9 + "uri": { "type": "string", "format": "at-uri" }, 10 + "cid": { "type": "string", "format": "cid" }, 11 + "author": { 12 + "type": "ref", 13 + "ref": "social.grain.actor.defs#profileView" 14 + }, 15 + "record": { "type": "unknown" }, 16 + "text": { 17 + "type": "string", 18 + "maxLength": 3000, 19 + "maxGraphemes": 300 20 + }, 21 + "facets": { 22 + "type": "array", 23 + "description": "Annotations of description text (mentions and URLs, hashtags, etc)", 24 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 25 + }, 26 + "subject": { 27 + "type": "union", 28 + "refs": [ 29 + "social.grain.gallery.defs#galleryView" 30 + ], 31 + "description": "The subject of the comment, which can be a gallery or a photo." 32 + }, 33 + "focus": { 34 + "type": "union", 35 + "refs": [ 36 + "social.grain.photo.defs#photoView" 37 + ], 38 + "description": "The photo that the comment is focused on, if applicable." 39 + }, 40 + "replyTo": { 41 + "type": "string", 42 + "format": "at-uri", 43 + "description": "The URI of the comment this comment is replying to, if applicable." 44 + }, 45 + "createdAt": { 46 + "type": "string", 47 + "format": "datetime" 48 + } 49 + } 50 + } 51 + } 52 + }
+15
lexicons/social/grain/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.defs", 4 + "defs": { 5 + "aspectRatio": { 6 + "type": "object", 7 + "description": "width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.", 8 + "required": ["width", "height"], 9 + "properties": { 10 + "width": { "type": "integer", "minimum": 1 }, 11 + "height": { "type": "integer", "minimum": 1 } 12 + } 13 + } 14 + } 15 + }
+24
lexicons/social/grain/favorite/favorite.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.favorite", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["createdAt", "subject"], 11 + "properties": { 12 + "createdAt": { 13 + "type": "string", 14 + "format": "datetime" 15 + }, 16 + "subject": { 17 + "type": "string", 18 + "format": "at-uri" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+56
lexicons/social/grain/gallery/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.gallery.defs", 4 + "defs": { 5 + "galleryView": { 6 + "type": "object", 7 + "required": ["uri", "cid", "creator", "record", "indexedAt"], 8 + "properties": { 9 + "uri": { "type": "string", "format": "at-uri" }, 10 + "cid": { "type": "string", "format": "cid" }, 11 + "title": { "type": "string" }, 12 + "description": { "type": "string" }, 13 + "cameras": { 14 + "type": "array", 15 + "description": "List of camera make and models used in this gallery derived from EXIF data.", 16 + "items": { "type": "string" } 17 + }, 18 + "facets": { 19 + "type": "array", 20 + "description": "Annotations of description text (mentions, URLs, hashtags, etc)", 21 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 22 + }, 23 + "creator": { 24 + "type": "ref", 25 + "ref": "social.grain.actor.defs#profileView" 26 + }, 27 + "record": { "type": "unknown" }, 28 + "items": { 29 + "type": "array", 30 + "items": { 31 + "type": "union", 32 + "refs": [ 33 + "social.grain.photo.defs#photoView" 34 + ] 35 + } 36 + }, 37 + "favCount": { "type": "integer" }, 38 + "commentCount": { "type": "integer" }, 39 + "labels": { 40 + "type": "array", 41 + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 42 + }, 43 + "createdAt": { "type": "string", "format": "datetime" }, 44 + "indexedAt": { "type": "string", "format": "datetime" }, 45 + "viewer": { "type": "ref", "ref": "#viewerState" } 46 + } 47 + }, 48 + "viewerState": { 49 + "type": "object", 50 + "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", 51 + "properties": { 52 + "fav": { "type": "string", "format": "at-uri" } 53 + } 54 + } 55 + } 56 + }
+30
lexicons/social/grain/gallery/gallery.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.gallery", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["title", "createdAt"], 11 + "properties": { 12 + "title": { "type": "string", "maxLength": 100 }, 13 + "description": { "type": "string", "maxLength": 1000 }, 14 + "facets": { 15 + "type": "array", 16 + "description": "Annotations of description text (mentions, URLs, hashtags, etc)", 17 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 18 + }, 19 + "labels": { 20 + "type": "union", 21 + "description": "Self-label values for this post. Effectively content warnings.", 22 + "refs": ["com.atproto.label.defs#selfLabels"] 23 + }, 24 + "updatedAt": { "type": "string", "format": "datetime" }, 25 + "createdAt": { "type": "string", "format": "datetime" } 26 + } 27 + } 28 + } 29 + } 30 + }
+32
lexicons/social/grain/gallery/item.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.gallery.item", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["createdAt", "gallery", "item"], 11 + "properties": { 12 + "createdAt": { 13 + "type": "string", 14 + "format": "datetime" 15 + }, 16 + "gallery": { 17 + "type": "string", 18 + "format": "at-uri" 19 + }, 20 + "item": { 21 + "type": "string", 22 + "format": "at-uri" 23 + }, 24 + "position": { 25 + "type": "integer", 26 + "default": 0 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+27
lexicons/social/grain/graph/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.graph.follow", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "subject", 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "subject": { 16 + "type": "string", 17 + "format": "did" 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime" 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+69
lexicons/social/grain/photo/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo.defs", 4 + "defs": { 5 + "photoView": { 6 + "type": "object", 7 + "required": ["uri", "cid", "thumb", "fullsize", "aspectRatio"], 8 + "properties": { 9 + "uri": { "type": "string", "format": "at-uri" }, 10 + "cid": { "type": "string", "format": "cid" }, 11 + "thumb": { 12 + "type": "string", 13 + "format": "uri", 14 + "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." 15 + }, 16 + "fullsize": { 17 + "type": "string", 18 + "format": "uri", 19 + "description": "Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View." 20 + }, 21 + "alt": { 22 + "type": "string", 23 + "description": "Alt text description of the image, for accessibility." 24 + }, 25 + "aspectRatio": { 26 + "type": "ref", 27 + "ref": "social.grain.defs#aspectRatio" 28 + }, 29 + "exif": { 30 + "type": "ref", 31 + "ref": "social.grain.photo.defs#exifView", 32 + "description": "EXIF metadata for the photo, if available." 33 + }, 34 + "gallery": { "type": "ref", "ref": "#galleryState" } 35 + } 36 + }, 37 + "exifView": { 38 + "type": "object", 39 + "required": ["uri", "cid", "photo", "record", "createdAt"], 40 + "properties": { 41 + "uri": { "type": "string", "format": "at-uri" }, 42 + "cid": { "type": "string", "format": "cid" }, 43 + "photo": { "type": "string", "format": "at-uri" }, 44 + "record": { "type": "unknown" }, 45 + "createdAt": { "type": "string", "format": "datetime" }, 46 + "dateTimeOriginal": { "type": "string" }, 47 + "exposureTime": { "type": "string" }, 48 + "fNumber": { "type": "string" }, 49 + "flash": { "type": "string" }, 50 + "focalLengthIn35mmFormat": { "type": "string" }, 51 + "iSO": { "type": "integer" }, 52 + "lensMake": { "type": "string" }, 53 + "lensModel": { "type": "string" }, 54 + "make": { "type": "string" }, 55 + "model": { "type": "string" } 56 + } 57 + }, 58 + "galleryState": { 59 + "type": "object", 60 + "required": ["item", "itemCreatedAt", "itemPosition"], 61 + "description": "Metadata about the photo's relationship with the subject content. Only has meaningful content when photo is attached to a gallery.", 62 + "properties": { 63 + "item": { "type": "string", "format": "at-uri" }, 64 + "itemCreatedAt": { "type": "string", "format": "datetime" }, 65 + "itemPosition": { "type": "integer" } 66 + } 67 + } 68 + } 69 + }
+32
lexicons/social/grain/photo/exif.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo.exif", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "photo", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "photo": { "type": "string", "format": "at-uri" }, 17 + "createdAt": { "type": "string", "format": "datetime" }, 18 + "dateTimeOriginal": { "type": "string", "format": "datetime" }, 19 + "exposureTime": { "type": "integer" }, 20 + "fNumber": { "type": "integer" }, 21 + "flash": { "type": "string" }, 22 + "focalLengthIn35mmFormat": { "type": "integer" }, 23 + "iSO": { "type": "integer" }, 24 + "lensMake": { "type": "string" }, 25 + "lensModel": { "type": "string" }, 26 + "make": { "type": "string" }, 27 + "model": { "type": "string" } 28 + } 29 + } 30 + } 31 + } 32 + }
+30
lexicons/social/grain/photo/photo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["photo", "aspectRatio", "createdAt"], 11 + "properties": { 12 + "photo": { 13 + "type": "blob", 14 + "accept": ["image/*"], 15 + "maxSize": 1000000 16 + }, 17 + "alt": { 18 + "type": "string", 19 + "description": "Alt text description of the image, for accessibility." 20 + }, 21 + "aspectRatio": { 22 + "type": "ref", 23 + "ref": "social.grain.defs#aspectRatio" 24 + }, 25 + "createdAt": { "type": "string", "format": "datetime" } 26 + } 27 + } 28 + } 29 + } 30 + }
+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",
+3 -2
package.json
··· 1 1 { 2 - "name": "grain-next", 2 + "name": "grain", 3 3 "version": "1.0.0", 4 + "private": true, 4 5 "description": "", 5 6 "main": "index.js", 6 7 "directories": { ··· 13 14 }, 14 15 "keywords": [], 15 16 "author": "", 16 - "license": "ISC", 17 + "license": "Apache-2.0", 17 18 "dependencies": { 18 19 "@fortawesome/fontawesome-free": "^7.1.0", 19 20 "lit": "^3.3.2",
+59
src/components/atoms/grain-alt-badge.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + 3 + export class GrainAltBadge extends LitElement { 4 + static properties = { 5 + alt: { type: String } 6 + }; 7 + 8 + static styles = css` 9 + :host { 10 + position: absolute; 11 + bottom: 8px; 12 + right: 8px; 13 + z-index: 2; 14 + } 15 + .badge { 16 + background: rgba(0, 0, 0, 0.7); 17 + color: white; 18 + font-size: 10px; 19 + font-weight: 600; 20 + padding: 2px 4px; 21 + border-radius: 4px; 22 + cursor: pointer; 23 + user-select: none; 24 + border: none; 25 + font-family: inherit; 26 + } 27 + .badge:hover { 28 + background: rgba(0, 0, 0, 0.85); 29 + } 30 + .badge:focus { 31 + outline: 2px solid white; 32 + outline-offset: 1px; 33 + } 34 + `; 35 + 36 + constructor() { 37 + super(); 38 + this.alt = ''; 39 + } 40 + 41 + #handleClick(e) { 42 + e.stopPropagation(); 43 + this.dispatchEvent(new CustomEvent('alt-click', { 44 + bubbles: true, 45 + composed: true, 46 + detail: { alt: this.alt } 47 + })); 48 + } 49 + 50 + render() { 51 + if (!this.alt) return null; 52 + 53 + return html` 54 + <button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button> 55 + `; 56 + } 57 + } 58 + 59 + customElements.define('grain-alt-badge', GrainAltBadge);
+41
src/components/atoms/grain-close-button.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import './grain-icon.js'; 3 + 4 + export class GrainCloseButton extends LitElement { 5 + static styles = css` 6 + :host { 7 + display: inline-flex; 8 + } 9 + button { 10 + display: flex; 11 + align-items: center; 12 + justify-content: center; 13 + background: none; 14 + border: none; 15 + padding: var(--space-xs); 16 + cursor: pointer; 17 + color: var(--color-text-secondary); 18 + touch-action: manipulation; 19 + } 20 + button:hover { 21 + color: var(--color-text-primary); 22 + } 23 + `; 24 + 25 + #handleClick() { 26 + this.dispatchEvent(new CustomEvent('close', { 27 + bubbles: true, 28 + composed: true 29 + })); 30 + } 31 + 32 + render() { 33 + return html` 34 + <button type="button" @click=${this.#handleClick} aria-label="Close"> 35 + <grain-icon name="close" size="20"></grain-icon> 36 + </button> 37 + `; 38 + } 39 + } 40 + 41 + customElements.define('grain-close-button', GrainCloseButton);
+4 -1
src/components/atoms/grain-icon.js
··· 6 6 heartFilled: 'fa-solid fa-heart', 7 7 comment: 'fa-regular fa-comment', 8 8 back: 'fa-solid fa-arrow-left', 9 + arrowUp: 'fa-solid fa-arrow-up', 9 10 home: 'fa-solid fa-house', 10 11 homeLine: 'fa-regular fa-house', 11 12 user: 'fa-regular fa-user', ··· 22 23 share: 'fa-solid fa-arrow-up-from-bracket', 23 24 camera: 'fa-solid fa-camera', 24 25 paperPlane: 'fa-regular fa-paper-plane', 25 - close: 'fa-solid fa-xmark' 26 + close: 'fa-solid fa-xmark', 27 + chevronLeft: 'fa-solid fa-chevron-left', 28 + chevronRight: 'fa-solid fa-chevron-right' 26 29 }; 27 30 28 31 export class GrainIcon extends LitElement {
+90
src/components/atoms/grain-rich-text.js
··· 1 + // src/components/atoms/grain-rich-text.js 2 + import { LitElement, html, css } from 'lit'; 3 + import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 4 + import { renderFacetedText, parseTextToFacetsSync } from '../../lib/richtext.js'; 5 + import { router } from '../../router.js'; 6 + 7 + export class GrainRichText extends LitElement { 8 + static properties = { 9 + text: { type: String }, 10 + facets: { type: Array }, 11 + parse: { type: Boolean }, 12 + _ready: { state: true } 13 + }; 14 + 15 + static styles = css` 16 + :host { 17 + display: inline; 18 + visibility: hidden; 19 + } 20 + :host([ready]) { 21 + visibility: visible; 22 + } 23 + .facet-link { 24 + color: var(--color-text-primary, #fff); 25 + font-weight: var(--font-weight-semibold, 600); 26 + text-decoration: none; 27 + } 28 + .facet-link:hover { 29 + text-decoration: underline; 30 + } 31 + .facet-mention { 32 + color: var(--color-text-primary, #fff); 33 + font-weight: var(--font-weight-semibold, 600); 34 + text-decoration: none; 35 + } 36 + .facet-mention:hover { 37 + text-decoration: underline; 38 + } 39 + .facet-tag { 40 + color: var(--color-text-primary, #fff); 41 + font-weight: var(--font-weight-semibold, 600); 42 + } 43 + `; 44 + 45 + constructor() { 46 + super(); 47 + this.text = ''; 48 + this.facets = null; 49 + this.parse = false; 50 + this._ready = false; 51 + } 52 + 53 + #handleClick = (e) => { 54 + const link = e.target.closest('.facet-mention'); 55 + if (link) { 56 + e.preventDefault(); 57 + const href = link.getAttribute('href'); 58 + if (href) { 59 + router.push(href); 60 + } 61 + } 62 + }; 63 + 64 + firstUpdated() { 65 + this.renderRoot.addEventListener('click', this.#handleClick); 66 + this.setAttribute('ready', ''); 67 + } 68 + 69 + disconnectedCallback() { 70 + super.disconnectedCallback(); 71 + this.renderRoot.removeEventListener('click', this.#handleClick); 72 + } 73 + 74 + render() { 75 + if (!this.text) return ''; 76 + 77 + let facetsToUse = this.facets; 78 + 79 + // If parse mode and no facets provided, parse on the fly 80 + if (this.parse && (!this.facets || this.facets.length === 0)) { 81 + const parsed = parseTextToFacetsSync(this.text); 82 + facetsToUse = parsed.facets; 83 + } 84 + 85 + const htmlContent = renderFacetedText(this.text, facetsToUse || []); 86 + return html`${unsafeHTML(htmlContent)}`; 87 + } 88 + } 89 + 90 + customElements.define('grain-rich-text', GrainRichText);
+81
src/components/atoms/grain-scroll-to-top.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import './grain-icon.js'; 3 + 4 + export class GrainScrollToTop extends LitElement { 5 + static properties = { 6 + visible: { type: Boolean } 7 + }; 8 + 9 + static styles = css` 10 + :host { 11 + position: sticky; 12 + bottom: var(--space-sm); 13 + left: 0; 14 + align-self: flex-start; 15 + z-index: 100; 16 + margin-top: auto; 17 + margin-left: var(--space-sm); 18 + } 19 + @media (min-width: 768px) { 20 + :host { 21 + position: fixed; 22 + bottom: calc(57px + env(safe-area-inset-bottom, 0px) + var(--space-lg)); 23 + left: calc(50% - var(--feed-max-width) / 2 - 64px); 24 + margin-left: 0; 25 + margin-top: 0; 26 + } 27 + } 28 + button { 29 + display: flex; 30 + align-items: center; 31 + justify-content: center; 32 + width: 42px; 33 + height: 42px; 34 + border-radius: 50%; 35 + border: 1px solid var(--color-border); 36 + background: var(--color-bg-primary); 37 + color: white; 38 + cursor: pointer; 39 + opacity: 0; 40 + pointer-events: none; 41 + transition: opacity 0.2s ease-in-out; 42 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 43 + } 44 + button.visible { 45 + opacity: 1; 46 + pointer-events: auto; 47 + } 48 + button:hover { 49 + filter: brightness(1.1); 50 + } 51 + button:active { 52 + transform: scale(0.95); 53 + } 54 + `; 55 + 56 + constructor() { 57 + super(); 58 + this.visible = false; 59 + } 60 + 61 + #handleClick() { 62 + this.dispatchEvent(new CustomEvent('scroll-top', { 63 + bubbles: true, 64 + composed: true 65 + })); 66 + } 67 + 68 + render() { 69 + return html` 70 + <button 71 + class=${this.visible ? 'visible' : ''} 72 + @click=${this.#handleClick} 73 + aria-label="Scroll to top" 74 + > 75 + <grain-icon name="arrowUp" size="20"></grain-icon> 76 + </button> 77 + `; 78 + } 79 + } 80 + 81 + customElements.define('grain-scroll-to-top', GrainScrollToTop);
+2
src/components/atoms/grain-textarea.js
··· 41 41 } 42 42 43 43 #handleInput(e) { 44 + // Stop native input event from bubbling out of shadow DOM 45 + e.stopPropagation(); 44 46 this.value = this.maxlength 45 47 ? e.target.value.slice(0, this.maxlength) 46 48 : e.target.value;
+1 -1
src/components/atoms/grain-toast.js
··· 9 9 static styles = css` 10 10 :host { 11 11 position: fixed; 12 - bottom: calc(var(--nav-height, 56px) + var(--space-md)); 12 + bottom: calc(var(--nav-height, 57px) + var(--space-md)); 13 13 left: 50%; 14 14 transform: translateX(-50%); 15 15 z-index: 1001;
+1 -1
src/components/molecules/grain-avatar-crop.js
··· 40 40 .overlay { 41 41 position: fixed; 42 42 top: 48px; 43 - bottom: calc(48px + env(safe-area-inset-bottom, 0px)); 43 + bottom: calc(57px + env(safe-area-inset-bottom, 0px)); 44 44 left: 50%; 45 45 transform: translateX(-50%); 46 46 width: 100%;
+4 -1
src/components/molecules/grain-comment.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 2 import { router } from '../../router.js'; 3 3 import '../atoms/grain-avatar.js'; 4 + import '../atoms/grain-rich-text.js'; 4 5 5 6 export class GrainComment extends LitElement { 6 7 static properties = { ··· 9 10 displayName: { type: String }, 10 11 avatarUrl: { type: String }, 11 12 text: { type: String }, 13 + facets: { type: Array }, 12 14 createdAt: { type: String }, 13 15 isReply: { type: Boolean }, 14 16 isOwner: { type: Boolean }, ··· 101 103 this.displayName = ''; 102 104 this.avatarUrl = ''; 103 105 this.text = ''; 106 + this.facets = []; 104 107 this.createdAt = ''; 105 108 this.isReply = false; 106 109 this.isOwner = false; ··· 159 162 <span class="handle" @click=${this.#handleProfileClick}> 160 163 ${this.handle} 161 164 </span> 162 - <span class="text">${this.text}</span> 165 + <span class="text"><grain-rich-text .text=${this.text} .facets=${this.facets}></grain-rich-text></span> 163 166 </div> 164 167 <div class="meta"> 165 168 <span class="time">${this.#formatTime(this.createdAt)}</span>
+38 -6
src/components/molecules/grain-login-dialog.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 + import '../atoms/grain-close-button.js'; 2 3 3 4 export class GrainLoginDialog extends LitElement { 4 5 static properties = { ··· 24 25 z-index: 1000; 25 26 } 26 27 .dialog { 27 - background: var(--color-bg-primary); 28 - border-radius: 12px; 28 + position: relative; 29 + background: var(--color-bg-elevated); 30 + border-radius: 20px; 29 31 padding: var(--space-lg); 30 32 width: 90%; 31 33 max-width: 320px; 34 + } 35 + grain-close-button { 36 + position: absolute; 37 + top: var(--space-sm); 38 + right: var(--space-sm); 32 39 } 33 40 h2 { 34 41 margin: 0 0 var(--space-md); ··· 40 47 display: block; 41 48 width: 100%; 42 49 margin-bottom: var(--space-md); 43 - --qs-input-bg: #000000; 50 + --qs-input-bg: #1a1a1a; 44 51 --qs-input-border: #363636; 45 52 --qs-input-border-focus: #fafafa; 46 53 --qs-input-text: #fafafa; ··· 53 60 --qs-handle-color: #fafafa; 54 61 --qs-name-color: #a8a8a8; 55 62 } 56 - button { 63 + button[type="submit"] { 57 64 width: 100%; 58 65 padding: var(--space-sm); 59 66 background: var(--color-text-primary); ··· 64 71 font-weight: var(--font-weight-semibold); 65 72 cursor: pointer; 66 73 } 67 - button:disabled { 74 + button[type="submit"]:disabled { 68 75 opacity: 0.5; 69 76 cursor: not-allowed; 70 77 } 78 + .legal-links { 79 + margin-top: var(--space-md); 80 + text-align: center; 81 + font-size: var(--font-size-xs); 82 + color: var(--color-text-secondary); 83 + } 84 + .legal-links a { 85 + color: var(--color-text-secondary); 86 + text-decoration: underline; 87 + } 88 + .legal-links a:hover { 89 + color: var(--color-text-primary); 90 + } 71 91 `; 72 92 73 93 constructor() { ··· 81 101 this._open = true; 82 102 this._handle = ''; 83 103 this._loading = false; 104 + document.addEventListener('keydown', this.#handleKeyDown); 84 105 } 85 106 86 107 close() { 87 108 this._open = false; 88 109 this._handle = ''; 89 110 this._loading = false; 111 + document.removeEventListener('keydown', this.#handleKeyDown); 90 112 } 91 113 114 + #handleKeyDown = (e) => { 115 + if (e.key === 'Escape') { 116 + this.close(); 117 + } 118 + }; 119 + 92 120 #handleSubmit(e) { 93 121 e.preventDefault(); 94 122 if (!this._handle.trim()) return; ··· 111 139 return html` 112 140 <div class="overlay" @click=${this.#handleOverlayClick}> 113 141 <form class="dialog" @submit=${this.#handleSubmit}> 114 - <h2>Login with Bluesky</h2> 142 + <grain-close-button @close=${() => this.close()}></grain-close-button> 143 + <h2>Login with AT Protocol</h2> 115 144 <qs-actor-autocomplete 116 145 name="handle" 117 146 placeholder="handle.bsky.social" ··· 123 152 <button type="submit" ?disabled=${this._loading || !this._handle.trim()}> 124 153 ${this._loading ? 'Redirecting...' : 'Continue'} 125 154 </button> 155 + <p class="legal-links"> 156 + By continuing, you agree to our <a href="/legal/terms">Terms</a> and <a href="/legal/privacy">Privacy Policy</a> 157 + </p> 126 158 </form> 127 159 </div> 128 160 `;
+102
src/components/molecules/grain-profile-header-skeleton.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + 3 + export class GrainProfileHeaderSkeleton extends LitElement { 4 + static styles = css` 5 + :host { 6 + display: block; 7 + padding: var(--space-md) var(--space-sm); 8 + } 9 + @media (min-width: 600px) { 10 + :host { 11 + padding-left: 0; 12 + padding-right: 0; 13 + } 14 + } 15 + .top-row { 16 + display: flex; 17 + align-items: flex-start; 18 + gap: var(--space-md); 19 + margin-bottom: var(--space-sm); 20 + } 21 + .right-column { 22 + flex: 1; 23 + min-width: 0; 24 + padding-top: var(--space-xs); 25 + } 26 + .placeholder { 27 + background: var(--color-bg-elevated); 28 + border-radius: 4px; 29 + animation: pulse 1.5s ease-in-out infinite; 30 + } 31 + .avatar { 32 + width: var(--avatar-size-lg, 80px); 33 + height: var(--avatar-size-lg, 80px); 34 + border-radius: 50%; 35 + flex-shrink: 0; 36 + } 37 + .handle-row { 38 + display: flex; 39 + align-items: center; 40 + gap: var(--space-sm); 41 + margin-bottom: var(--space-xs); 42 + } 43 + .handle { 44 + width: 60%; 45 + max-width: 200px; 46 + height: 22px; 47 + } 48 + .menu-icon { 49 + width: 20px; 50 + height: 20px; 51 + border-radius: 50%; 52 + } 53 + .name { 54 + width: 40%; 55 + max-width: 120px; 56 + height: 16px; 57 + margin-bottom: var(--space-xs); 58 + } 59 + .stats { 60 + display: flex; 61 + gap: var(--space-sm); 62 + margin-bottom: var(--space-xs); 63 + } 64 + .stat { 65 + width: 80px; 66 + height: 16px; 67 + } 68 + .bio { 69 + width: 70%; 70 + max-width: 250px; 71 + height: 16px; 72 + margin-top: var(--space-xs); 73 + } 74 + @keyframes pulse { 75 + 0%, 100% { opacity: 0.4; } 76 + 50% { opacity: 1; } 77 + } 78 + `; 79 + 80 + render() { 81 + return html` 82 + <div class="top-row"> 83 + <div class="placeholder avatar"></div> 84 + <div class="right-column"> 85 + <div class="handle-row"> 86 + <div class="placeholder handle"></div> 87 + <div class="placeholder menu-icon"></div> 88 + </div> 89 + <div class="placeholder name"></div> 90 + <div class="stats"> 91 + <div class="placeholder stat"></div> 92 + <div class="placeholder stat"></div> 93 + <div class="placeholder stat"></div> 94 + </div> 95 + <div class="placeholder bio"></div> 96 + </div> 97 + </div> 98 + `; 99 + } 100 + } 101 + 102 + customElements.define('grain-profile-header-skeleton', GrainProfileHeaderSkeleton);
+37 -3
src/components/molecules/grain-pull-to-refresh.js
··· 13 13 14 14 static styles = css` 15 15 :host { 16 - display: block; 16 + display: flex; 17 + flex-direction: column; 18 + flex: 1; 17 19 overflow: hidden; 20 + min-height: 100%; 18 21 } 19 22 .container { 20 23 position: relative; 24 + flex: 1; 25 + display: flex; 26 + flex-direction: column; 21 27 } 22 28 .indicator { 23 29 position: absolute; ··· 41 47 42 48 #startY = 0; 43 49 #currentY = 0; 50 + #scrollContainer = null; 44 51 45 52 constructor() { 46 53 super(); ··· 56 63 this.addEventListener('touchend', this.#onTouchEnd, { passive: true }); 57 64 } 58 65 66 + #findScrollContainer() { 67 + let el = this; 68 + while (el) { 69 + // Get next parent, crossing shadow DOM boundaries 70 + const parent = el.parentElement || el.getRootNode()?.host; 71 + if (!parent || parent === document.documentElement) break; 72 + 73 + const style = getComputedStyle(parent); 74 + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { 75 + return parent; 76 + } 77 + el = parent; 78 + } 79 + return null; 80 + } 81 + 82 + #getScrollTop() { 83 + // Find scroll container lazily (content may not be loaded at connectedCallback) 84 + if (!this.#scrollContainer) { 85 + this.#scrollContainer = this.#findScrollContainer(); 86 + } 87 + if (this.#scrollContainer) { 88 + return this.#scrollContainer.scrollTop; 89 + } 90 + return window.scrollY; 91 + } 92 + 59 93 disconnectedCallback() { 60 94 this.removeEventListener('touchstart', this.#onTouchStart); 61 95 this.removeEventListener('touchmove', this.#onTouchMove); ··· 68 102 69 103 #onTouchStart = (e) => { 70 104 if (this.refreshing) return; 71 - if (window.scrollY > 0) return; 105 + if (this.#getScrollTop() > 0) return; 72 106 73 107 this.#startY = e.touches[0].clientY; 74 108 this._pulling = true; ··· 80 114 this.#currentY = e.touches[0].clientY; 81 115 const diff = this.#currentY - this.#startY; 82 116 83 - if (diff > 0 && window.scrollY === 0) { 117 + if (diff > 0 && this.#getScrollTop() === 0) { 84 118 e.preventDefault(); 85 119 // Apply resistance 86 120 this._pullDistance = Math.min(diff * 0.5, MAX_PULL);
+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) {
+7 -1
src/components/organisms/grain-bottom-nav.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 2 import { router } from '../../router.js'; 3 3 import { auth } from '../../services/auth.js'; 4 + import { trigger as haptic } from '../../utils/haptics.js'; 4 5 import { draftGallery } from '../../services/draft-gallery.js'; 5 6 import { processPhotos } from '../../utils/image-resize.js'; 6 7 import '../atoms/grain-icon.js'; ··· 29 30 align-items: center; 30 31 max-width: var(--feed-max-width); 31 32 margin: 0 auto; 32 - padding: 6px 0; 33 + height: 57px; 33 34 } 34 35 button { 35 36 display: flex; ··· 100 101 } 101 102 102 103 #handleHome() { 104 + haptic(); 103 105 router.push('/'); 104 106 } 105 107 106 108 #handleProfile() { 109 + haptic(); 107 110 if (this._user?.handle) { 108 111 router.push(`/profile/${this._user.handle}`); 109 112 } 110 113 } 111 114 112 115 #handleExplore() { 116 + haptic(); 113 117 router.push('/explore'); 114 118 } 115 119 116 120 #handleNotifications() { 121 + haptic(); 117 122 router.push('/notifications'); 118 123 } 119 124 120 125 #handleCreate() { 126 + haptic(); 121 127 this.shadowRoot.getElementById('photo-input').click(); 122 128 } 123 129
+16 -16
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 = { ··· 42 42 } 43 43 .sheet-container { 44 44 position: fixed; 45 - bottom: 0; 45 + bottom: calc(57px + env(safe-area-inset-bottom, 0px)); 46 46 left: 0; 47 47 right: 0; 48 48 display: flex; ··· 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 84 } 90 85 .comments-list { 91 86 flex: 1; ··· 212 207 } 213 208 214 209 #handleClose() { 215 - this.open = false; 216 - this._replyToUri = null; 217 - this._replyToHandle = null; 218 - this._inputValue = ''; 219 - this.dispatchEvent(new CustomEvent('close')); 210 + // Blur active element first to release iOS focus/scroll context 211 + document.activeElement?.blur(); 212 + 213 + // Small delay to let iOS finish processing touch before hiding 214 + requestAnimationFrame(() => { 215 + this.open = false; 216 + this._replyToUri = null; 217 + this._replyToHandle = null; 218 + this._inputValue = ''; 219 + this.dispatchEvent(new CustomEvent('close')); 220 + }); 220 221 } 221 222 222 223 #handleOverlayClick() { ··· 343 344 <div class="sheet"> 344 345 <div class="header"> 345 346 <h2>Comments</h2> 346 - <button class="close-button" @click=${this.#handleClose}> 347 - <grain-icon name="close" size="20"></grain-icon> 348 - </button> 347 + <grain-close-button @close=${this.#handleClose}></grain-close-button> 349 348 </div> 350 349 351 350 <div class="comments-list"> ··· 372 371 displayName=${comment.displayName} 373 372 avatarUrl=${comment.avatarUrl} 374 373 text=${comment.text} 374 + .facets=${comment.facets || []} 375 375 createdAt=${comment.createdAt} 376 376 ?is-reply=${comment.isReply} 377 377 ?isOwner=${comment.handle === auth.user?.handle}
+23 -5
src/components/organisms/grain-engagement-bar.js
··· 73 73 if (!auth.isAuthenticated || this._loading || !this.galleryUri) return; 74 74 75 75 this._loading = true; 76 + 77 + // Store previous state for rollback 78 + const previousState = { 79 + viewerHasFavorited: this.viewerHasFavorited, 80 + viewerFavoriteUri: this.viewerFavoriteUri, 81 + favoriteCount: this.favoriteCount 82 + }; 83 + 84 + // Optimistic update - apply immediately 85 + this.viewerHasFavorited = !this.viewerHasFavorited; 86 + this.favoriteCount += this.viewerHasFavorited ? 1 : -1; 87 + if (!this.viewerHasFavorited) { 88 + this.viewerFavoriteUri = null; 89 + } 90 + 76 91 try { 77 92 const update = await mutations.toggleFavorite( 78 93 this.galleryUri, 79 - this.viewerHasFavorited, 80 - this.viewerFavoriteUri, 81 - this.favoriteCount 94 + previousState.viewerHasFavorited, 95 + previousState.viewerFavoriteUri, 96 + previousState.favoriteCount 82 97 ); 83 - this.viewerHasFavorited = update.viewerHasFavorited; 98 + // Update with real URI from server (needed for future deletes) 84 99 this.viewerFavoriteUri = update.viewerFavoriteUri; 85 - this.favoriteCount = update.favoriteCount; 86 100 } catch (err) { 101 + // Rollback on failure 87 102 console.error('Failed to toggle favorite:', err); 103 + this.viewerHasFavorited = previousState.viewerHasFavorited; 104 + this.viewerFavoriteUri = previousState.viewerFavoriteUri; 105 + this.favoriteCount = previousState.favoriteCount; 88 106 this.shadowRoot.querySelector('grain-toast').show('Failed to update'); 89 107 } finally { 90 108 this._loading = false;
+3 -1
src/components/organisms/grain-header.js
··· 12 12 static styles = css` 13 13 :host { 14 14 display: block; 15 - position: sticky; 15 + position: fixed; 16 16 top: 0; 17 + left: 0; 18 + right: 0; 17 19 background: var(--color-bg-primary); 18 20 border-bottom: 1px solid var(--color-border); 19 21 z-index: 100;
+117 -3
src/components/organisms/grain-image-carousel.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 2 import '../atoms/grain-image.js'; 3 + import '../atoms/grain-icon.js'; 4 + import '../atoms/grain-alt-badge.js'; 3 5 import '../molecules/grain-carousel-dots.js'; 4 6 5 7 export class GrainImageCarousel extends LitElement { 6 8 static properties = { 7 9 photos: { type: Array }, 8 10 rkey: { type: String }, 9 - _currentIndex: { state: true } 11 + _currentIndex: { state: true }, 12 + _activeAltIndex: { state: true } 10 13 }; 11 14 12 15 static styles = css` ··· 27 30 .slide { 28 31 flex: 0 0 100%; 29 32 scroll-snap-align: start; 33 + position: relative; 30 34 } 31 35 .slide.centered { 32 36 display: flex; ··· 42 46 left: 0; 43 47 right: 0; 44 48 } 49 + .nav-arrow { 50 + position: absolute; 51 + top: 50%; 52 + transform: translateY(-50%); 53 + width: 24px; 54 + height: 24px; 55 + border-radius: 50%; 56 + border: none; 57 + background: rgba(255, 255, 255, 0.7); 58 + color: rgba(120, 100, 90, 1); 59 + cursor: pointer; 60 + display: flex; 61 + align-items: center; 62 + justify-content: center; 63 + padding: 0; 64 + z-index: 1; 65 + } 66 + .nav-arrow:hover { 67 + background: rgba(255, 255, 255, 1); 68 + } 69 + .nav-arrow:focus { 70 + outline: none; 71 + } 72 + .nav-arrow:focus-visible { 73 + outline: 2px solid rgba(120, 100, 90, 0.5); 74 + outline-offset: 2px; 75 + } 76 + .nav-arrow-left { 77 + left: 8px; 78 + } 79 + .nav-arrow-right { 80 + right: 8px; 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 + } 45 99 `; 46 100 47 101 constructor() { 48 102 super(); 49 103 this.photos = []; 50 104 this._currentIndex = 0; 105 + this._activeAltIndex = null; 51 106 } 52 107 53 108 get #hasPortrait() { ··· 64 119 const index = Math.round(carousel.scrollLeft / carousel.offsetWidth); 65 120 if (index !== this._currentIndex) { 66 121 this._currentIndex = index; 122 + this._activeAltIndex = null; 123 + } 124 + } 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 + 136 + #goToPrevious(e) { 137 + e.stopPropagation(); 138 + if (this._currentIndex > 0) { 139 + const carousel = this.shadowRoot.querySelector('.carousel'); 140 + const slides = carousel.querySelectorAll('.slide'); 141 + slides[this._currentIndex - 1].scrollIntoView({ 142 + behavior: 'smooth', 143 + block: 'nearest', 144 + inline: 'start' 145 + }); 146 + } 147 + } 148 + 149 + #goToNext(e) { 150 + e.stopPropagation(); 151 + if (this._currentIndex < this.photos.length - 1) { 152 + const carousel = this.shadowRoot.querySelector('.carousel'); 153 + const slides = carousel.querySelectorAll('.slide'); 154 + slides[this._currentIndex + 1].scrollIntoView({ 155 + behavior: 'smooth', 156 + block: 'nearest', 157 + inline: 'start' 158 + }); 67 159 } 68 160 } 69 161 ··· 79 171 render() { 80 172 const hasPortrait = this.#hasPortrait; 81 173 const minAspectRatio = this.#minAspectRatio; 82 - 83 - // Calculate height based on tallest image when portrait exists 84 174 const carouselStyle = hasPortrait 85 175 ? `aspect-ratio: ${minAspectRatio};` 86 176 : ''; 87 177 178 + const showLeftArrow = this.photos.length > 1 && this._currentIndex > 0; 179 + const showRightArrow = this.photos.length > 1 && this._currentIndex < this.photos.length - 1; 180 + 88 181 return html` 89 182 <div class="carousel" style=${carouselStyle} @scroll=${this.#handleScroll}> 90 183 ${this.photos.map((photo, index) => html` ··· 95 188 aspectRatio=${photo.aspectRatio || 1} 96 189 style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 97 190 ></grain-image> 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 + ` : ''} 98 202 </div> 99 203 `)} 100 204 </div> 205 + ${showLeftArrow ? html` 206 + <button class="nav-arrow nav-arrow-left" @click=${this.#goToPrevious} aria-label="Previous image"> 207 + <grain-icon name="chevronLeft" size="12"></grain-icon> 208 + </button> 209 + ` : ''} 210 + ${showRightArrow ? html` 211 + <button class="nav-arrow nav-arrow-right" @click=${this.#goToNext} aria-label="Next image"> 212 + <grain-icon name="chevronRight" size="12"></grain-icon> 213 + </button> 214 + ` : ''} 101 215 ${this.photos.length > 1 ? html` 102 216 <div class="dots"> 103 217 <grain-carousel-dots
+31 -7
src/components/organisms/grain-profile-header.js
··· 8 8 import '../atoms/grain-icon.js'; 9 9 import '../atoms/grain-spinner.js'; 10 10 import '../atoms/grain-toast.js'; 11 + import '../atoms/grain-rich-text.js'; 11 12 import '../molecules/grain-profile-stats.js'; 12 13 13 14 export class GrainProfileHeader extends LitElement { ··· 237 238 if (!this._user || this._followLoading || !this.profile) return; 238 239 239 240 this._followLoading = true; 241 + 242 + // Store previous state for rollback 243 + const previousState = { 244 + viewerIsFollowing: this.profile.viewerIsFollowing, 245 + viewerFollowUri: this.profile.viewerFollowUri, 246 + followerCount: this.profile.followerCount || 0 247 + }; 248 + 249 + // Optimistic update - apply immediately 250 + const newIsFollowing = !previousState.viewerIsFollowing; 251 + this.profile = { 252 + ...this.profile, 253 + viewerIsFollowing: newIsFollowing, 254 + viewerFollowUri: newIsFollowing ? this.profile.viewerFollowUri : null, 255 + followerCount: previousState.followerCount + (newIsFollowing ? 1 : -1) 256 + }; 257 + 240 258 try { 241 259 const update = await mutations.toggleFollow( 242 260 this.profile.handle, 243 261 this.profile.did, 244 - this.profile.viewerIsFollowing, 245 - this.profile.viewerFollowUri, 246 - this.profile.followerCount || 0 262 + previousState.viewerIsFollowing, 263 + previousState.viewerFollowUri, 264 + previousState.followerCount 247 265 ); 266 + // Update with real URI from server (needed for future unfollows) 248 267 this.profile = { 249 268 ...this.profile, 250 - viewerIsFollowing: update.viewerIsFollowing, 251 - viewerFollowUri: update.viewerFollowUri, 252 - followerCount: update.followerCount 269 + viewerFollowUri: update.viewerFollowUri 253 270 }; 254 271 } catch (err) { 272 + // Rollback on failure 255 273 console.error('Failed to toggle follow:', err); 274 + this.profile = { 275 + ...this.profile, 276 + viewerIsFollowing: previousState.viewerIsFollowing, 277 + viewerFollowUri: previousState.viewerFollowUri, 278 + followerCount: previousState.followerCount 279 + }; 256 280 this.shadowRoot.querySelector('grain-toast')?.show('Failed to update'); 257 281 } finally { 258 282 this._followLoading = false; ··· 299 323 followerCount=${followerCount || 0} 300 324 followingCount=${followingCount || 0} 301 325 ></grain-profile-stats> 302 - ${description ? html`<div class="bio">${description}</div>` : ''} 326 + ${description ? html`<div class="bio"><grain-rich-text .text=${description} parse></grain-rich-text></div>` : ''} 303 327 </div> 304 328 </div> 305 329
+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);
+103 -6
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'; ··· 10 13 import './grain-settings.js'; 11 14 import './grain-edit-profile.js'; 12 15 import './grain-create-gallery.js'; 16 + import './grain-image-descriptions.js'; 13 17 import './grain-explore.js'; 14 18 import './grain-notifications.js'; 15 19 import './grain-terms.js'; 16 20 import './grain-privacy.js'; 17 21 import './grain-copyright.js'; 22 + import './grain-oauth-callback.js'; 23 + import './grain-onboarding.js'; 18 24 import '../organisms/grain-header.js'; 19 25 import '../organisms/grain-bottom-nav.js'; 20 26 21 27 export class GrainApp extends LitElement { 28 + static properties = { 29 + _dialogType: { state: true }, 30 + _dialogProps: { state: true } 31 + }; 32 + 22 33 static styles = css` 23 34 :host { 24 35 display: block; 25 36 font-family: var(--font-family); 26 37 background: var(--color-bg-primary); 27 38 color: var(--color-text-primary); 28 - min-height: 100vh; 29 - min-height: 100dvh; 39 + height: 100vh; 40 + height: 100dvh; 41 + overflow: hidden; 30 42 } 31 43 #outlet { 32 - display: block; 44 + display: flex; 45 + flex-direction: column; 46 + position: fixed; 47 + top: 48px; 48 + left: 0; 49 + right: 0; 50 + bottom: calc(57px + env(safe-area-inset-bottom, 0px)); 51 + overflow-y: auto; 52 + -webkit-overflow-scrolling: touch; 53 + } 54 + #outlet > * { 55 + flex: 0 0 auto; 56 + min-height: 100%; 33 57 } 34 58 `; 35 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 + 36 128 firstUpdated() { 37 129 const outlet = this.shadowRoot.getElementById('outlet'); 38 130 ··· 44 136 .register('/profile/:handle', 'grain-profile') 45 137 .register('/settings', 'grain-settings') 46 138 .register('/settings/profile', 'grain-edit-profile') 47 - .register('/settings/terms', 'grain-terms') 48 - .register('/settings/privacy', 'grain-privacy') 49 - .register('/settings/copyright', 'grain-copyright') 139 + .register('/legal/terms', 'grain-terms') 140 + .register('/legal/privacy', 'grain-privacy') 141 + .register('/legal/copyright', 'grain-copyright') 50 142 .register('/create', 'grain-create-gallery') 143 + .register('/create/descriptions', 'grain-image-descriptions') 51 144 .register('/explore', 'grain-explore') 52 145 .register('/notifications', 'grain-notifications') 146 + .register('/onboarding', 'grain-onboarding') 147 + .register('/oauth/callback', 'grain-oauth-callback') 53 148 .register('*', 'grain-timeline') 54 149 .connect(outlet); 55 150 } ··· 59 154 <grain-header></grain-header> 60 155 <div id="outlet"></div> 61 156 <grain-bottom-nav></grain-bottom-nav> 157 + ${this.#renderDialog()} 158 + <grain-toast></grain-toast> 62 159 `; 63 160 } 64 161 }
+3 -3
src/components/pages/grain-copyright.js
··· 5 5 static styles = css` 6 6 :host { 7 7 display: block; 8 + width: 100%; 8 9 max-width: var(--feed-max-width); 9 - margin: 0 auto; 10 - min-height: 100vh; 11 - min-height: 100dvh; 10 + min-height: 100%; 12 11 padding-bottom: 80px; 13 12 background: var(--color-bg-primary); 13 + align-self: center; 14 14 } 15 15 .header { 16 16 display: flex;
+31 -132
src/components/pages/grain-create-gallery.js
··· 8 8 import '../atoms/grain-textarea.js'; 9 9 import '../molecules/grain-form-field.js'; 10 10 11 - const UPLOAD_BLOB_MUTATION = ` 12 - mutation UploadBlob($data: String!, $mimeType: String!) { 13 - uploadBlob(data: $data, mimeType: $mimeType) { 14 - ref 15 - mimeType 16 - size 17 - } 18 - } 19 - `; 20 - 21 - const CREATE_PHOTO_MUTATION = ` 22 - mutation CreatePhoto($input: SocialGrainPhotoInput!) { 23 - createSocialGrainPhoto(input: $input) { 24 - uri 25 - } 26 - } 27 - `; 28 - 29 - const CREATE_GALLERY_MUTATION = ` 30 - mutation CreateGallery($input: SocialGrainGalleryInput!) { 31 - createSocialGrainGallery(input: $input) { 32 - uri 33 - } 34 - } 35 - `; 36 - 37 - const CREATE_GALLERY_ITEM_MUTATION = ` 38 - mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) { 39 - createSocialGrainGalleryItem(input: $input) { 40 - uri 41 - } 42 - } 43 - `; 44 - 45 11 export class GrainCreateGallery extends LitElement { 46 12 static properties = { 47 13 _photos: { state: true }, 48 14 _title: { state: true }, 49 - _description: { state: true }, 50 - _posting: { state: true }, 51 - _error: { state: true } 15 + _description: { state: true } 52 16 }; 53 17 54 18 static styles = css` 55 19 :host { 56 20 display: block; 21 + width: 100%; 57 22 max-width: var(--feed-max-width); 58 - margin: 0 auto; 59 - min-height: 100vh; 60 - min-height: 100dvh; 23 + min-height: 100%; 61 24 background: var(--color-bg-primary); 25 + align-self: center; 62 26 } 63 27 .header { 64 28 display: flex; ··· 120 84 .form { 121 85 padding: var(--space-sm); 122 86 } 123 - .error { 124 - color: #ff4444; 125 - padding: var(--space-sm); 126 - text-align: center; 127 - } 128 87 `; 129 88 130 89 constructor() { ··· 132 91 this._photos = []; 133 92 this._title = ''; 134 93 this._description = ''; 135 - this._posting = false; 136 - this._error = null; 137 94 } 138 95 139 96 connectedCallback() { 140 97 super.connectedCallback(); 98 + 99 + if (!auth.isAuthenticated) { 100 + router.replace('/'); 101 + return; 102 + } 103 + 141 104 this._photos = draftGallery.getPhotos(); 105 + 106 + // Restore title/description if returning from descriptions page 107 + this._title = sessionStorage.getItem('draft_title') || ''; 108 + this._description = sessionStorage.getItem('draft_description') || ''; 109 + 142 110 if (!this._photos.length) { 143 111 router.push('/'); 144 112 } ··· 147 115 #handleBack() { 148 116 if (confirm('Discard this gallery?')) { 149 117 draftGallery.clear(); 118 + sessionStorage.removeItem('draft_title'); 119 + sessionStorage.removeItem('draft_description'); 150 120 history.back(); 151 121 } 152 122 } 153 123 154 124 #removePhoto(index) { 155 125 this._photos = this._photos.filter((_, i) => i !== index); 126 + draftGallery.setPhotos(this._photos); 156 127 if (this._photos.length === 0) { 157 128 draftGallery.clear(); 129 + sessionStorage.removeItem('draft_title'); 130 + sessionStorage.removeItem('draft_description'); 158 131 router.push('/'); 159 132 } 160 133 } ··· 167 140 this._description = e.detail.value.slice(0, 1000); 168 141 } 169 142 170 - get #canPost() { 171 - return this._title.trim().length > 0 && this._photos.length > 0 && !this._posting; 143 + get #canProceed() { 144 + return this._title.trim().length > 0 && this._photos.length > 0; 172 145 } 173 146 174 - async #handlePost() { 175 - if (!this.#canPost) return; 147 + #handleNext() { 148 + if (!this.#canProceed) return; 176 149 177 - this._posting = true; 178 - this._error = null; 150 + sessionStorage.setItem('draft_title', this._title); 151 + sessionStorage.setItem('draft_description', this._description); 152 + draftGallery.setPhotos(this._photos); 179 153 180 - try { 181 - const client = auth.getClient(); 182 - const now = new Date().toISOString(); 183 - 184 - // Upload photos and create photo records 185 - const photoUris = []; 186 - for (const photo of this._photos) { 187 - // Upload blob 188 - const base64Data = photo.dataUrl.split(',')[1]; 189 - const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { 190 - data: base64Data, 191 - mimeType: 'image/jpeg' 192 - }); 193 - 194 - if (!uploadResult.uploadBlob) { 195 - throw new Error('Failed to upload image'); 196 - } 197 - 198 - // Create photo record 199 - const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, { 200 - input: { 201 - photo: { 202 - $type: 'blob', 203 - ref: { $link: uploadResult.uploadBlob.ref }, 204 - mimeType: uploadResult.uploadBlob.mimeType, 205 - size: uploadResult.uploadBlob.size 206 - }, 207 - aspectRatio: { 208 - width: photo.width, 209 - height: photo.height 210 - }, 211 - createdAt: now 212 - } 213 - }); 214 - 215 - photoUris.push(photoResult.createSocialGrainPhoto.uri); 216 - } 217 - 218 - // Create gallery record 219 - const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 220 - input: { 221 - title: this._title.trim(), 222 - ...(this._description.trim() && { description: this._description.trim() }), 223 - createdAt: now 224 - } 225 - }); 226 - 227 - const galleryUri = galleryResult.createSocialGrainGallery.uri; 228 - 229 - // Create gallery items linking photos to gallery 230 - for (let i = 0; i < photoUris.length; i++) { 231 - await client.mutate(CREATE_GALLERY_ITEM_MUTATION, { 232 - input: { 233 - gallery: galleryUri, 234 - item: photoUris[i], 235 - position: i, 236 - createdAt: now 237 - } 238 - }); 239 - } 240 - 241 - // Clear draft and navigate to new gallery 242 - draftGallery.clear(); 243 - const rkey = galleryUri.split('/').pop(); 244 - router.push(`/profile/${auth.user.handle}/gallery/${rkey}`); 245 - 246 - } catch (err) { 247 - console.error('Failed to create gallery:', err); 248 - this._error = err.message || 'Failed to create gallery. Please try again.'; 249 - } finally { 250 - this._posting = false; 251 - } 154 + router.push('/create/descriptions'); 252 155 } 253 156 254 157 render() { ··· 261 164 <span class="header-title">Create a gallery</span> 262 165 </div> 263 166 <grain-button 264 - ?disabled=${!this.#canPost} 265 - ?loading=${this._posting} 266 - loadingText="Posting..." 267 - @click=${this.#handlePost} 268 - >Post</grain-button> 167 + ?disabled=${!this.#canProceed} 168 + @click=${this.#handleNext} 169 + >Next</grain-button> 269 170 </div> 270 171 271 172 <div class="photo-strip"> ··· 276 177 </div> 277 178 `)} 278 179 </div> 279 - 280 - ${this._error ? html`<p class="error">${this._error}</p>` : ''} 281 180 282 181 <div class="form"> 283 182 <grain-form-field .value=${this._title} .maxlength=${100}>
+30 -3
src/components/pages/grain-edit-profile.js
··· 2 2 import { router } from '../../router.js'; 3 3 import { auth } from '../../services/auth.js'; 4 4 import { grainApi } from '../../services/grain-api.js'; 5 + import { recordCache } from '../../services/record-cache.js'; 5 6 import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 6 7 import '../atoms/grain-icon.js'; 7 8 import '../atoms/grain-button.js'; ··· 47 48 static styles = css` 48 49 :host { 49 50 display: block; 51 + width: 100%; 50 52 max-width: var(--feed-max-width); 51 - margin: 0 auto; 52 - min-height: 100vh; 53 - min-height: 100dvh; 53 + min-height: 100%; 54 54 padding-bottom: 80px; 55 55 background: var(--color-bg-primary); 56 + align-self: center; 56 57 } 57 58 .header { 58 59 display: flex; ··· 175 176 176 177 async connectedCallback() { 177 178 super.connectedCallback(); 179 + 180 + // Redirect to timeline if not authenticated 181 + if (!auth.isAuthenticated) { 182 + router.replace('/'); 183 + return; 184 + } 185 + 178 186 await this.#loadProfile(); 179 187 } 180 188 ··· 318 326 319 327 // Refresh user data in header 320 328 await auth.refreshUser(); 329 + 330 + // Update profile cache if it exists (merge preserves galleries etc) 331 + if (recordCache.has(`profile:${auth.user.handle}`)) { 332 + // Determine avatar URL: new upload uses refreshed URL, removal uses null, unchanged keeps original 333 + let avatarUrl; 334 + if (this._newAvatarDataUrl) { 335 + avatarUrl = auth.user.avatar; 336 + } else if (this._avatarRemoved) { 337 + avatarUrl = null; 338 + } else { 339 + avatarUrl = this._avatarUrl; 340 + } 341 + 342 + recordCache.set(`profile:${auth.user.handle}`, { 343 + displayName: this._displayName.trim() || null, 344 + description: this._description.trim() || null, 345 + avatarUrl 346 + }); 347 + } 321 348 322 349 // Go back to settings 323 350 history.back();
+8 -1
src/components/pages/grain-explore.js
··· 23 23 } 24 24 .search-container { 25 25 position: sticky; 26 - top: 48px; 26 + top: 0; 27 27 background: var(--color-bg-primary); 28 28 padding: var(--space-sm); 29 29 z-index: 10; 30 + } 31 + .search-container::after { 32 + content: ''; 33 + display: block; 34 + max-width: var(--feed-max-width); 35 + margin: 0 auto; 30 36 border-bottom: 1px solid var(--color-border); 37 + margin-top: var(--space-sm); 31 38 } 32 39 .search-container grain-input { 33 40 max-width: var(--feed-max-width);
+290
src/components/pages/grain-image-descriptions.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { router } from '../../router.js'; 3 + import { auth } from '../../services/auth.js'; 4 + import { draftGallery } from '../../services/draft-gallery.js'; 5 + import { parseTextToFacets } from '../../lib/richtext.js'; 6 + import { grainApi } from '../../services/grain-api.js'; 7 + import '../atoms/grain-icon.js'; 8 + import '../atoms/grain-button.js'; 9 + import '../atoms/grain-textarea.js'; 10 + 11 + const UPLOAD_BLOB_MUTATION = ` 12 + mutation UploadBlob($data: String!, $mimeType: String!) { 13 + uploadBlob(data: $data, mimeType: $mimeType) { 14 + ref 15 + mimeType 16 + size 17 + } 18 + } 19 + `; 20 + 21 + const CREATE_PHOTO_MUTATION = ` 22 + mutation CreatePhoto($input: SocialGrainPhotoInput!) { 23 + createSocialGrainPhoto(input: $input) { 24 + uri 25 + } 26 + } 27 + `; 28 + 29 + const CREATE_GALLERY_MUTATION = ` 30 + mutation CreateGallery($input: SocialGrainGalleryInput!) { 31 + createSocialGrainGallery(input: $input) { 32 + uri 33 + } 34 + } 35 + `; 36 + 37 + const CREATE_GALLERY_ITEM_MUTATION = ` 38 + mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) { 39 + createSocialGrainGalleryItem(input: $input) { 40 + uri 41 + } 42 + } 43 + `; 44 + 45 + export class GrainImageDescriptions extends LitElement { 46 + static properties = { 47 + _photos: { state: true }, 48 + _title: { state: true }, 49 + _description: { state: true }, 50 + _posting: { state: true }, 51 + _error: { state: true } 52 + }; 53 + 54 + static styles = css` 55 + :host { 56 + display: block; 57 + width: 100%; 58 + max-width: var(--feed-max-width); 59 + min-height: 100%; 60 + background: var(--color-bg-primary); 61 + align-self: center; 62 + } 63 + .header { 64 + display: flex; 65 + align-items: center; 66 + justify-content: space-between; 67 + padding: var(--space-sm); 68 + border-bottom: 1px solid var(--color-border); 69 + } 70 + .header-left { 71 + display: flex; 72 + align-items: center; 73 + gap: var(--space-xs); 74 + } 75 + .back-button { 76 + background: none; 77 + border: none; 78 + padding: 8px; 79 + margin-left: -8px; 80 + cursor: pointer; 81 + color: var(--color-text-primary); 82 + } 83 + .header-title { 84 + font-size: var(--font-size-md); 85 + font-weight: 600; 86 + } 87 + .photo-list { 88 + padding: var(--space-sm); 89 + } 90 + .photo-row { 91 + display: flex; 92 + gap: var(--space-sm); 93 + margin-bottom: var(--space-md); 94 + } 95 + .photo-thumb { 96 + flex-shrink: 0; 97 + max-width: 80px; 98 + max-height: 120px; 99 + width: auto; 100 + height: auto; 101 + border-radius: 4px; 102 + object-fit: contain; 103 + } 104 + .info { 105 + margin: 0; 106 + padding: var(--space-sm); 107 + font-size: var(--font-size-sm); 108 + color: var(--color-text-secondary); 109 + border-bottom: 1px solid var(--color-border); 110 + } 111 + .alt-input { 112 + flex: 1; 113 + } 114 + .alt-input grain-textarea { 115 + --textarea-min-height: 60px; 116 + } 117 + .alt-input grain-textarea::part(textarea) { 118 + min-height: 60px; 119 + } 120 + .error { 121 + color: #ff4444; 122 + padding: var(--space-sm); 123 + text-align: center; 124 + } 125 + `; 126 + 127 + constructor() { 128 + super(); 129 + this._photos = []; 130 + this._title = ''; 131 + this._description = ''; 132 + this._posting = false; 133 + this._error = null; 134 + } 135 + 136 + connectedCallback() { 137 + super.connectedCallback(); 138 + 139 + if (!auth.isAuthenticated) { 140 + router.replace('/'); 141 + return; 142 + } 143 + 144 + this._photos = draftGallery.getPhotos(); 145 + this._title = sessionStorage.getItem('draft_title') || ''; 146 + this._description = sessionStorage.getItem('draft_description') || ''; 147 + 148 + if (!this._photos.length) { 149 + router.push('/'); 150 + } 151 + } 152 + 153 + #handleBack() { 154 + router.push('/create'); 155 + } 156 + 157 + #handleAltChange(index, e) { 158 + const alt = e.detail.value; 159 + draftGallery.updatePhotoAlt(index, alt); 160 + } 161 + 162 + async #handlePost() { 163 + if (this._posting) return; 164 + 165 + // Refresh photos from draftGallery to get latest alt text values 166 + this._photos = draftGallery.getPhotos(); 167 + this._posting = true; 168 + this._error = null; 169 + 170 + try { 171 + const client = auth.getClient(); 172 + const now = new Date().toISOString(); 173 + 174 + const photoUris = []; 175 + for (const photo of this._photos) { 176 + const base64Data = photo.dataUrl.split(',')[1]; 177 + const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { 178 + data: base64Data, 179 + mimeType: 'image/jpeg' 180 + }); 181 + 182 + if (!uploadResult.uploadBlob) { 183 + throw new Error('Failed to upload image'); 184 + } 185 + 186 + const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, { 187 + input: { 188 + photo: { 189 + $type: 'blob', 190 + ref: { $link: uploadResult.uploadBlob.ref }, 191 + mimeType: uploadResult.uploadBlob.mimeType, 192 + size: uploadResult.uploadBlob.size 193 + }, 194 + aspectRatio: { 195 + width: photo.width, 196 + height: photo.height 197 + }, 198 + ...(photo.alt && { alt: photo.alt }), 199 + createdAt: now 200 + } 201 + }); 202 + 203 + photoUris.push(photoResult.createSocialGrainPhoto.uri); 204 + } 205 + 206 + let facets = null; 207 + if (this._description.trim()) { 208 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 209 + const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); 210 + if (parsed.facets.length > 0) { 211 + facets = parsed.facets; 212 + } 213 + } 214 + 215 + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 216 + input: { 217 + title: this._title.trim(), 218 + ...(this._description.trim() && { description: this._description.trim() }), 219 + ...(facets && { facets }), 220 + createdAt: now 221 + } 222 + }); 223 + 224 + const galleryUri = galleryResult.createSocialGrainGallery.uri; 225 + 226 + for (let i = 0; i < photoUris.length; i++) { 227 + await client.mutate(CREATE_GALLERY_ITEM_MUTATION, { 228 + input: { 229 + gallery: galleryUri, 230 + item: photoUris[i], 231 + position: i, 232 + createdAt: now 233 + } 234 + }); 235 + } 236 + 237 + draftGallery.clear(); 238 + sessionStorage.removeItem('draft_title'); 239 + sessionStorage.removeItem('draft_description'); 240 + const rkey = galleryUri.split('/').pop(); 241 + router.push(`/profile/${auth.user.handle}/gallery/${rkey}`); 242 + 243 + } catch (err) { 244 + console.error('Failed to create gallery:', err); 245 + this._error = err.message || 'Failed to create gallery. Please try again.'; 246 + } finally { 247 + this._posting = false; 248 + } 249 + } 250 + 251 + render() { 252 + return html` 253 + <div class="header"> 254 + <div class="header-left"> 255 + <button class="back-button" @click=${this.#handleBack}> 256 + <grain-icon name="back" size="20"></grain-icon> 257 + </button> 258 + <span class="header-title">Add image descriptions</span> 259 + </div> 260 + <grain-button 261 + ?loading=${this._posting} 262 + loadingText="Posting..." 263 + @click=${this.#handlePost} 264 + >Post</grain-button> 265 + </div> 266 + 267 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 268 + 269 + <p class="info">Alt text describes images for blind and low-vision users, and helps give context to everyone.</p> 270 + 271 + <div class="photo-list"> 272 + ${this._photos.map((photo, i) => html` 273 + <div class="photo-row"> 274 + <img class="photo-thumb" src=${photo.dataUrl} alt="Photo ${i + 1}"> 275 + <div class="alt-input"> 276 + <grain-textarea 277 + placeholder="Alt text" 278 + .value=${photo.alt || ''} 279 + .maxlength=${1000} 280 + @input=${(e) => this.#handleAltChange(i, e)} 281 + ></grain-textarea> 282 + </div> 283 + </div> 284 + `)} 285 + </div> 286 + `; 287 + } 288 + } 289 + 290 + customElements.define('grain-image-descriptions', GrainImageDescriptions);
+47 -15
src/components/pages/grain-notifications.js
··· 3 3 import { grainApi } from '../../services/grain-api.js'; 4 4 import { router } from '../../router.js'; 5 5 import '../templates/grain-feed-layout.js'; 6 + import '../molecules/grain-pull-to-refresh.js'; 6 7 import '../atoms/grain-spinner.js'; 7 8 import '../atoms/grain-avatar.js'; 8 9 ··· 10 11 static properties = { 11 12 _notifications: { state: true }, 12 13 _loading: { state: true }, 14 + _refreshing: { state: true }, 13 15 _error: { state: true }, 14 16 _user: { state: true }, 15 17 _hasMore: { state: true }, ··· 26 28 padding: var(--space-sm); 27 29 border-bottom: 1px solid var(--color-border); 28 30 position: sticky; 29 - top: 48px; 31 + top: 0; 30 32 background: var(--color-bg-primary); 31 33 z-index: 10; 32 34 } ··· 132 134 super(); 133 135 this._notifications = []; 134 136 this._loading = true; 137 + this._refreshing = false; 135 138 this._error = null; 136 139 this._user = auth.user; 137 140 this._hasMore = true; ··· 140 143 141 144 connectedCallback() { 142 145 super.connectedCallback(); 146 + 147 + // Redirect to timeline if not authenticated 148 + if (!auth.isAuthenticated) { 149 + router.replace('/'); 150 + return; 151 + } 152 + 143 153 this._unsubscribe = auth.subscribe(user => { 144 154 this._user = user; 145 155 if (user) { ··· 223 233 } 224 234 } 225 235 236 + async #handleRefresh() { 237 + if (!this._user?.did) return; 238 + 239 + this._refreshing = true; 240 + try { 241 + const result = await grainApi.getNotifications(this._user.did, { first: 20 }); 242 + this._notifications = result.notifications; 243 + this._hasMore = result.pageInfo.hasNextPage; 244 + this._cursor = result.pageInfo.endCursor; 245 + this._error = null; 246 + } catch (err) { 247 + console.error('Failed to refresh notifications:', err); 248 + this._error = err.message; 249 + } finally { 250 + this._refreshing = false; 251 + } 252 + } 253 + 226 254 #formatRelativeTime(dateStr) { 227 255 const date = new Date(dateStr); 228 256 const now = new Date(); ··· 387 415 return html` 388 416 <grain-feed-layout> 389 417 <div class="header">Notifications</div> 390 - 391 - ${this._error ? html` 392 - <p class="error">${this._error}</p> 393 - ` : ''} 418 + <grain-pull-to-refresh 419 + ?refreshing=${this._refreshing} 420 + @refresh=${this.#handleRefresh} 421 + > 422 + ${this._error ? html` 423 + <p class="error">${this._error}</p> 424 + ` : ''} 394 425 395 - ${!this._loading && !this._error && this._notifications.length === 0 ? html` 396 - <p class="empty">No notifications yet</p> 397 - ` : ''} 426 + ${!this._loading && !this._error && this._notifications.length === 0 ? html` 427 + <p class="empty">No notifications yet</p> 428 + ` : ''} 398 429 399 - ${this._notifications.length > 0 ? html` 400 - <ul class="notification-list"> 401 - ${this._notifications.map(n => this.#renderNotification(n))} 402 - </ul> 403 - ` : ''} 430 + ${this._notifications.length > 0 ? html` 431 + <ul class="notification-list"> 432 + ${this._notifications.map(n => this.#renderNotification(n))} 433 + </ul> 434 + ` : ''} 404 435 405 - <div id="sentinel"></div> 436 + <div id="sentinel"></div> 406 437 407 - ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 438 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 439 + </grain-pull-to-refresh> 408 440 </grain-feed-layout> 409 441 `; 410 442 }
+38
src/components/pages/grain-oauth-callback.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { auth } from '../../services/auth.js'; 3 + import '../atoms/grain-spinner.js'; 4 + 5 + export class GrainOAuthCallback extends LitElement { 6 + static styles = css` 7 + :host { 8 + display: flex; 9 + flex-direction: column; 10 + align-items: center; 11 + justify-content: center; 12 + min-height: 100%; 13 + gap: var(--space-md); 14 + } 15 + p { 16 + color: var(--color-text-secondary); 17 + font-size: var(--font-size-sm); 18 + } 19 + `; 20 + 21 + async connectedCallback() { 22 + super.connectedCallback(); 23 + const params = new URLSearchParams(window.location.search); 24 + if (params.get('start') === '1') { 25 + window.history.replaceState({}, '', '/oauth/callback'); 26 + await auth.startOAuthFromCallback(); 27 + } 28 + } 29 + 30 + render() { 31 + return html` 32 + <grain-spinner size="32"></grain-spinner> 33 + <p>Signing in...</p> 34 + `; 35 + } 36 + } 37 + 38 + customElements.define('grain-oauth-callback', GrainOAuthCallback);
+391
src/components/pages/grain-onboarding.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { router } from '../../router.js'; 3 + import { auth } from '../../services/auth.js'; 4 + import { grainApi } from '../../services/grain-api.js'; 5 + import { mutations } from '../../services/mutations.js'; 6 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 7 + import '../atoms/grain-icon.js'; 8 + import '../atoms/grain-button.js'; 9 + import '../atoms/grain-input.js'; 10 + import '../atoms/grain-textarea.js'; 11 + import '../atoms/grain-avatar.js'; 12 + import '../atoms/grain-spinner.js'; 13 + import '../molecules/grain-form-field.js'; 14 + import '../molecules/grain-avatar-crop.js'; 15 + 16 + export class GrainOnboarding extends LitElement { 17 + static properties = { 18 + _loading: { state: true }, 19 + _saving: { state: true }, 20 + _error: { state: true }, 21 + _displayName: { state: true }, 22 + _description: { state: true }, 23 + _avatarUrl: { state: true }, 24 + _avatarBlob: { state: true }, 25 + _newAvatarDataUrl: { state: true }, 26 + _showAvatarCrop: { state: true }, 27 + _cropImageUrl: { state: true } 28 + }; 29 + 30 + static styles = css` 31 + :host { 32 + display: block; 33 + width: 100%; 34 + max-width: var(--feed-max-width); 35 + min-height: 100%; 36 + padding-bottom: 80px; 37 + background: var(--color-bg-primary); 38 + align-self: center; 39 + } 40 + .header { 41 + display: flex; 42 + flex-direction: column; 43 + align-items: center; 44 + gap: var(--space-xs); 45 + padding: var(--space-xl) var(--space-sm) var(--space-lg); 46 + text-align: center; 47 + } 48 + h1 { 49 + font-size: var(--font-size-xl); 50 + font-weight: var(--font-weight-semibold); 51 + color: var(--color-text-primary); 52 + margin: 0; 53 + } 54 + .subtitle { 55 + font-size: var(--font-size-sm); 56 + color: var(--color-text-secondary); 57 + margin: 0; 58 + } 59 + .content { 60 + padding: 0 var(--space-sm); 61 + } 62 + @media (min-width: 600px) { 63 + .content { 64 + padding: 0; 65 + } 66 + } 67 + .avatar-section { 68 + display: flex; 69 + flex-direction: column; 70 + align-items: center; 71 + margin-bottom: var(--space-lg); 72 + } 73 + .avatar-wrapper { 74 + position: relative; 75 + cursor: pointer; 76 + } 77 + .avatar-overlay { 78 + position: absolute; 79 + bottom: 0; 80 + right: 0; 81 + width: 28px; 82 + height: 28px; 83 + border-radius: 50%; 84 + background: var(--color-bg-primary); 85 + border: 2px solid var(--color-border); 86 + display: flex; 87 + align-items: center; 88 + justify-content: center; 89 + color: var(--color-text-primary); 90 + } 91 + .avatar-preview { 92 + width: 80px; 93 + height: 80px; 94 + border-radius: 50%; 95 + object-fit: cover; 96 + background: var(--color-bg-elevated); 97 + } 98 + input[type="file"] { 99 + display: none; 100 + } 101 + .actions { 102 + display: flex; 103 + flex-direction: column; 104 + gap: var(--space-sm); 105 + padding: var(--space-lg) var(--space-sm); 106 + border-top: 1px solid var(--color-border); 107 + margin-top: var(--space-lg); 108 + } 109 + @media (min-width: 600px) { 110 + .actions { 111 + padding-left: 0; 112 + padding-right: 0; 113 + } 114 + } 115 + .skip-button { 116 + background: none; 117 + border: none; 118 + color: var(--color-text-secondary); 119 + font-size: var(--font-size-sm); 120 + cursor: pointer; 121 + padding: var(--space-sm); 122 + text-align: center; 123 + } 124 + .skip-button:hover { 125 + color: var(--color-text-primary); 126 + text-decoration: underline; 127 + } 128 + .error { 129 + color: var(--color-danger, #dc3545); 130 + font-size: var(--font-size-sm); 131 + padding: var(--space-sm); 132 + text-align: center; 133 + } 134 + .loading { 135 + display: flex; 136 + flex-direction: column; 137 + align-items: center; 138 + justify-content: center; 139 + gap: var(--space-md); 140 + padding: var(--space-xl); 141 + min-height: 300px; 142 + } 143 + .loading p { 144 + color: var(--color-text-secondary); 145 + font-size: var(--font-size-sm); 146 + } 147 + `; 148 + 149 + constructor() { 150 + super(); 151 + this._loading = true; 152 + this._saving = false; 153 + this._error = null; 154 + this._displayName = ''; 155 + this._description = ''; 156 + this._avatarUrl = ''; 157 + this._avatarBlob = null; 158 + this._newAvatarDataUrl = null; 159 + this._showAvatarCrop = false; 160 + this._cropImageUrl = null; 161 + } 162 + 163 + async connectedCallback() { 164 + super.connectedCallback(); 165 + 166 + if (!auth.isAuthenticated) { 167 + router.replace('/'); 168 + return; 169 + } 170 + 171 + await this.#checkAndLoad(); 172 + } 173 + 174 + async #checkAndLoad() { 175 + try { 176 + const client = auth.getClient(); 177 + 178 + // Check if user already has a Grain profile 179 + const hasProfile = await grainApi.hasGrainProfile(client); 180 + if (hasProfile) { 181 + this.#redirectToDestination(); 182 + return; 183 + } 184 + 185 + // Fetch Bluesky profile to prefill 186 + const bskyProfile = await grainApi.getBlueskyProfile(client); 187 + this._displayName = bskyProfile.displayName; 188 + this._description = bskyProfile.description; 189 + this._avatarUrl = bskyProfile.avatarUrl; 190 + this._avatarBlob = bskyProfile.avatarBlob; 191 + } catch (err) { 192 + console.error('Failed to load profile data:', err); 193 + } finally { 194 + this._loading = false; 195 + } 196 + } 197 + 198 + #redirectToDestination() { 199 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 200 + sessionStorage.removeItem('oauth_return_url'); 201 + router.replace(returnUrl); 202 + } 203 + 204 + #handleDisplayNameChange(e) { 205 + this._displayName = e.detail.value.slice(0, 64); 206 + } 207 + 208 + #handleDescriptionChange(e) { 209 + this._description = e.detail.value.slice(0, 256); 210 + } 211 + 212 + #handleAvatarClick() { 213 + this.shadowRoot.querySelector('#avatar-input').click(); 214 + } 215 + 216 + async #handleAvatarChange(e) { 217 + const input = e.target; 218 + const file = input.files?.[0]; 219 + if (!file) return; 220 + 221 + input.value = ''; 222 + 223 + try { 224 + const dataUrl = await readFileAsDataURL(file); 225 + const resized = await resizeImage(dataUrl, { 226 + width: 2000, 227 + height: 2000, 228 + maxSize: 900000 229 + }); 230 + this._cropImageUrl = resized.dataUrl; 231 + this._showAvatarCrop = true; 232 + } catch (err) { 233 + console.error('Failed to process avatar:', err); 234 + this._error = 'Failed to process image'; 235 + } 236 + } 237 + 238 + #handleCropCancel() { 239 + this._showAvatarCrop = false; 240 + this._cropImageUrl = null; 241 + } 242 + 243 + #handleCrop(e) { 244 + this._showAvatarCrop = false; 245 + this._cropImageUrl = null; 246 + this._newAvatarDataUrl = e.detail.dataUrl; 247 + this._avatarBlob = null; 248 + } 249 + 250 + get #displayedAvatarUrl() { 251 + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; 252 + return this._avatarUrl; 253 + } 254 + 255 + async #handleSave() { 256 + if (this._saving) return; 257 + 258 + this._saving = true; 259 + this._error = null; 260 + 261 + try { 262 + const input = { 263 + createdAt: new Date().toISOString() 264 + }; 265 + 266 + const displayName = this._displayName.trim(); 267 + const description = this._description.trim(); 268 + if (displayName) input.displayName = displayName; 269 + if (description) input.description = description; 270 + 271 + if (this._newAvatarDataUrl) { 272 + const base64Data = this._newAvatarDataUrl.split(',')[1]; 273 + const blob = await mutations.uploadBlob(base64Data, 'image/jpeg'); 274 + input.avatar = { 275 + $type: 'blob', 276 + ref: { $link: blob.ref }, 277 + mimeType: blob.mimeType, 278 + size: blob.size 279 + }; 280 + } else if (this._avatarBlob) { 281 + input.avatar = this._avatarBlob; 282 + } 283 + 284 + await mutations.updateProfile(input); 285 + this.#redirectToDestination(); 286 + } catch (err) { 287 + console.error('Failed to save profile:', err); 288 + this._error = err.message || 'Failed to save profile. Please try again.'; 289 + } finally { 290 + this._saving = false; 291 + } 292 + } 293 + 294 + async #handleSkip() { 295 + if (this._saving) return; 296 + 297 + this._saving = true; 298 + this._error = null; 299 + 300 + try { 301 + await mutations.createEmptyProfile(); 302 + this.#redirectToDestination(); 303 + } catch (err) { 304 + console.error('Failed to skip onboarding:', err); 305 + this._error = err.message || 'Something went wrong. Please try again.'; 306 + } finally { 307 + this._saving = false; 308 + } 309 + } 310 + 311 + render() { 312 + if (this._loading) { 313 + return html` 314 + <div class="loading"> 315 + <grain-spinner size="32"></grain-spinner> 316 + <p>Loading...</p> 317 + </div> 318 + `; 319 + } 320 + 321 + return html` 322 + <div class="header"> 323 + <h1>Welcome to Grain</h1> 324 + <p class="subtitle">Set up your profile to get started</p> 325 + </div> 326 + 327 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 328 + 329 + <div class="content"> 330 + <div class="avatar-section"> 331 + <div class="avatar-wrapper" @click=${this.#handleAvatarClick}> 332 + ${this.#displayedAvatarUrl ? html` 333 + <img class="avatar-preview" src=${this.#displayedAvatarUrl} alt="Profile avatar"> 334 + ` : html` 335 + <grain-avatar size="lg"></grain-avatar> 336 + `} 337 + <div class="avatar-overlay"> 338 + <grain-icon name="camera" size="14"></grain-icon> 339 + </div> 340 + </div> 341 + <input 342 + type="file" 343 + id="avatar-input" 344 + accept="image/png,image/jpeg" 345 + @change=${this.#handleAvatarChange} 346 + > 347 + </div> 348 + 349 + <grain-form-field label="Display Name" .value=${this._displayName} .maxlength=${64}> 350 + <grain-input 351 + placeholder="Display name" 352 + .value=${this._displayName} 353 + @input=${this.#handleDisplayNameChange} 354 + ></grain-input> 355 + </grain-form-field> 356 + 357 + <grain-form-field label="Bio" .value=${this._description} .maxlength=${256}> 358 + <grain-textarea 359 + placeholder="Tell us about yourself" 360 + .value=${this._description} 361 + .maxlength=${256} 362 + @input=${this.#handleDescriptionChange} 363 + ></grain-textarea> 364 + </grain-form-field> 365 + </div> 366 + 367 + <div class="actions"> 368 + <grain-button 369 + variant="primary" 370 + ?loading=${this._saving} 371 + loadingText="Saving..." 372 + @click=${this.#handleSave} 373 + >Save & Continue</grain-button> 374 + <button 375 + class="skip-button" 376 + ?disabled=${this._saving} 377 + @click=${this.#handleSkip} 378 + >Skip for now</button> 379 + </div> 380 + 381 + <grain-avatar-crop 382 + ?open=${this._showAvatarCrop} 383 + image-url=${this._cropImageUrl || ''} 384 + @crop=${this.#handleCrop} 385 + @cancel=${this.#handleCropCancel} 386 + ></grain-avatar-crop> 387 + `; 388 + } 389 + } 390 + 391 + customElements.define('grain-onboarding', GrainOnboarding);
+3 -3
src/components/pages/grain-privacy.js
··· 5 5 static styles = css` 6 6 :host { 7 7 display: block; 8 + width: 100%; 8 9 max-width: var(--feed-max-width); 9 - margin: 0 auto; 10 - min-height: 100vh; 11 - min-height: 100dvh; 10 + min-height: 100%; 12 11 padding-bottom: 80px; 13 12 background: var(--color-bg-primary); 13 + align-self: center; 14 14 } 15 15 .header { 16 16 display: flex;
+21 -3
src/components/pages/grain-profile.js
··· 6 6 import '../organisms/grain-gallery-grid.js'; 7 7 import '../molecules/grain-pull-to-refresh.js'; 8 8 import '../molecules/grain-avatar-crop.js'; 9 + import '../molecules/grain-profile-header-skeleton.js'; 9 10 import '../atoms/grain-spinner.js'; 10 11 import '../organisms/grain-action-dialog.js'; 11 12 ··· 25 26 26 27 static styles = css` 27 28 :host { 28 - display: block; 29 + display: flex; 30 + flex-direction: column; 31 + min-height: 100%; 29 32 } 30 33 .error { 31 34 padding: var(--space-lg); ··· 119 122 } else { 120 123 this.#fetchProfile(); 121 124 } 125 + 126 + // Subscribe to cache updates 127 + this.#cacheCallback = (data) => { 128 + this._profile = { ...this._profile, ...data }; 129 + }; 130 + recordCache.subscribe(`profile:${this.handle}`, this.#cacheCallback); 122 131 } 123 132 133 + disconnectedCallback() { 134 + super.disconnectedCallback(); 135 + if (this.#cacheCallback) { 136 + recordCache.unsubscribe(`profile:${this.handle}`, this.#cacheCallback); 137 + } 138 + } 139 + 140 + #cacheCallback = null; 141 + 124 142 async #fetchProfile() { 125 143 try { 126 144 this._error = null; ··· 151 169 ?refreshing=${this._refreshing} 152 170 @refresh=${this.#handleRefresh} 153 171 > 154 - ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 172 + ${this._loading ? html`<grain-profile-header-skeleton></grain-profile-header-skeleton>` : ''} 155 173 156 174 ${this._error ? html` 157 175 <p class="error">${this._error}</p> ··· 166 184 @avatar-updated=${this.#handleRefresh} 167 185 ></grain-profile-header> 168 186 169 - ${this._profile.galleries.length > 0 ? html` 187 + ${this._profile.galleries?.length > 0 ? html` 170 188 <grain-gallery-grid 171 189 handle=${this.handle} 172 190 .galleries=${this._profile.galleries}
+14 -7
src/components/pages/grain-settings.js
··· 12 12 static styles = css` 13 13 :host { 14 14 display: block; 15 + width: 100%; 15 16 max-width: var(--feed-max-width); 16 - margin: 0 auto; 17 - min-height: 100vh; 18 - min-height: 100dvh; 17 + min-height: 100%; 19 18 padding-bottom: 80px; 20 19 background: var(--color-bg-primary); 20 + align-self: center; 21 21 } 22 22 .header { 23 23 display: flex; ··· 91 91 92 92 connectedCallback() { 93 93 super.connectedCallback(); 94 + 95 + // Redirect to timeline if not authenticated 96 + if (!auth.isAuthenticated) { 97 + router.replace('/'); 98 + return; 99 + } 100 + 94 101 this._canInstall = pwa.canInstall; 95 102 this._showIOSInstructions = pwa.showIOSInstructions; 96 103 this.#unsubscribe = pwa.subscribe((canInstall) => { ··· 116 123 } 117 124 118 125 #signOut() { 119 - auth.logout(); 120 126 router.push('/'); 127 + auth.logout(); 121 128 } 122 129 123 130 #goToTerms() { 124 - router.push('/settings/terms'); 131 + router.push('/legal/terms'); 125 132 } 126 133 127 134 #goToPrivacy() { 128 - router.push('/settings/privacy'); 135 + router.push('/legal/privacy'); 129 136 } 130 137 131 138 #goToCopyright() { 132 - router.push('/settings/copyright'); 139 + router.push('/legal/copyright'); 133 140 } 134 141 135 142 render() {
+3 -3
src/components/pages/grain-terms.js
··· 5 5 static styles = css` 6 6 :host { 7 7 display: block; 8 + width: 100%; 8 9 max-width: var(--feed-max-width); 9 - margin: 0 auto; 10 - min-height: 100vh; 11 - min-height: 100dvh; 10 + min-height: 100%; 12 11 padding-bottom: 80px; 13 12 background: var(--color-bg-primary); 13 + align-self: center; 14 14 } 15 15 .header { 16 16 display: flex;
+128 -1
src/components/pages/grain-timeline.js
··· 8 8 import '../organisms/grain-comment-sheet.js'; 9 9 import '../molecules/grain-pull-to-refresh.js'; 10 10 import '../atoms/grain-spinner.js'; 11 + import '../atoms/grain-scroll-to-top.js'; 11 12 12 13 export class GrainTimeline extends LitElement { 13 14 static properties = { ··· 20 21 _commentSheetOpen: { state: true }, 21 22 _commentGalleryUri: { state: true }, 22 23 _focusPhotoUri: { state: true }, 23 - _focusPhotoUrl: { state: true } 24 + _focusPhotoUrl: { state: true }, 25 + _showScrollTop: { state: true }, 26 + _pendingGallery: { state: true } 24 27 }; 25 28 26 29 static styles = css` ··· 44 47 45 48 #observer = null; 46 49 #initialized = false; 50 + #boundHandleScroll = null; 51 + #scrollContainer = null; 47 52 48 53 constructor() { 49 54 super(); ··· 57 62 this._commentGalleryUri = ''; 58 63 this._focusPhotoUri = null; 59 64 this._focusPhotoUrl = null; 65 + this._showScrollTop = false; 66 + this._pendingGallery = null; 60 67 61 68 // Check cache synchronously to avoid flash 62 69 this.#initFromCache(); ··· 86 93 if (!this.#initialized) { 87 94 this.#fetchTimeline(); 88 95 } 96 + this.#boundHandleScroll = this.#handleScroll.bind(this); 97 + this.#scrollContainer = this.#findScrollContainer(); 98 + (this.#scrollContainer || window).addEventListener('scroll', this.#boundHandleScroll, { passive: true }); 99 + document.addEventListener('dialog-action', this.#handleDialogAction); 100 + } 101 + 102 + #findScrollContainer() { 103 + let el = this; 104 + while (el) { 105 + const parent = el.parentElement || el.getRootNode()?.host; 106 + if (!parent || parent === document.documentElement) break; 107 + 108 + const style = getComputedStyle(parent); 109 + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { 110 + return parent; 111 + } 112 + el = parent; 113 + } 114 + return null; 89 115 } 90 116 91 117 disconnectedCallback() { 92 118 super.disconnectedCallback(); 93 119 this.#observer?.disconnect(); 120 + if (this.#boundHandleScroll) { 121 + (this.#scrollContainer || window).removeEventListener('scroll', this.#boundHandleScroll); 122 + } 123 + document.removeEventListener('dialog-action', this.#handleDialogAction); 94 124 } 95 125 96 126 firstUpdated() { ··· 193 223 this._focusPhotoUrl = null; 194 224 } 195 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 + 297 + #handleScroll() { 298 + const scrollTop = this.#scrollContainer ? this.#scrollContainer.scrollTop : window.scrollY; 299 + this._showScrollTop = scrollTop > 150; 300 + } 301 + 302 + async #handleScrollTop() { 303 + if (this._refreshing) return; 304 + 305 + if (this.#scrollContainer) { 306 + this.#scrollContainer.scrollTo({ top: 0, behavior: 'smooth' }); 307 + } else { 308 + window.scrollTo({ top: 0, behavior: 'smooth' }); 309 + } 310 + 311 + // Wait for scroll to complete before refreshing 312 + await new Promise(resolve => setTimeout(resolve, 400)); 313 + 314 + await this.#handleRefresh(); 315 + } 316 + 196 317 render() { 197 318 return html` 198 319 <grain-feed-layout> ··· 200 321 ?refreshing=${this._refreshing} 201 322 @refresh=${this.#handleRefresh} 202 323 @comment-click=${this.#handleCommentClick} 324 + @open-gallery-menu=${this.#handleGalleryMenu} 203 325 > 204 326 ${this._error ? html` 205 327 <p class="error">${this._error}</p> ··· 225 347 focusPhotoUrl=${this._focusPhotoUrl || ''} 226 348 @close=${this.#handleCommentSheetClose} 227 349 ></grain-comment-sheet> 350 + 351 + <grain-scroll-to-top 352 + ?visible=${this._showScrollTop} 353 + @scroll-top=${this.#handleScrollTop} 354 + ></grain-scroll-to-top> 228 355 </grain-feed-layout> 229 356 `; 230 357 }
+8 -4
src/components/templates/grain-feed-layout.js
··· 3 3 export class GrainFeedLayout extends LitElement { 4 4 static styles = css` 5 5 :host { 6 - display: block; 6 + display: flex; 7 + flex-direction: column; 8 + flex: 1; 9 + width: 100%; 7 10 max-width: var(--feed-max-width); 8 11 margin: 0 auto; 9 - min-height: 100vh; 10 - min-height: 100dvh; 11 - padding-bottom: calc(48px + env(safe-area-inset-bottom)); 12 + min-height: 100%; 12 13 background: var(--color-bg-primary); 14 + } 15 + ::slotted(grain-pull-to-refresh) { 16 + flex: 1; 13 17 } 14 18 `; 15 19
+311
src/lib/richtext.js
··· 1 + // src/lib/richtext.js - Bluesky-compatible richtext parsing and rendering 2 + 3 + /** 4 + * Parse text for Bluesky facets: mentions, links, hashtags. 5 + * Returns { text, facets } with byte-indexed positions. 6 + * 7 + * @param {string} text - Plain text to parse 8 + * @param {function} resolveHandle - Optional async function to resolve @handle to DID 9 + * @returns {Promise<{ text: string, facets: Array }>} 10 + */ 11 + export async function parseTextToFacets(text, resolveHandle = null) { 12 + if (!text) return { text: '', facets: [] }; 13 + 14 + const facets = []; 15 + const encoder = new TextEncoder(); 16 + 17 + function getByteOffset(str, charIndex) { 18 + return encoder.encode(str.slice(0, charIndex)).length; 19 + } 20 + 21 + // Track claimed positions to avoid overlaps 22 + const claimedPositions = new Set(); 23 + 24 + function isRangeClaimed(start, end) { 25 + for (let i = start; i < end; i++) { 26 + if (claimedPositions.has(i)) return true; 27 + } 28 + return false; 29 + } 30 + 31 + function claimRange(start, end) { 32 + for (let i = start; i < end; i++) { 33 + claimedPositions.add(i); 34 + } 35 + } 36 + 37 + // URLs first (highest priority) 38 + const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 39 + let urlMatch; 40 + while ((urlMatch = urlRegex.exec(text)) !== null) { 41 + const start = urlMatch.index; 42 + const end = start + urlMatch[0].length; 43 + 44 + if (!isRangeClaimed(start, end)) { 45 + claimRange(start, end); 46 + facets.push({ 47 + index: { 48 + byteStart: getByteOffset(text, start), 49 + byteEnd: getByteOffset(text, end), 50 + }, 51 + features: [{ 52 + $type: 'app.bsky.richtext.facet#link', 53 + uri: urlMatch[0], 54 + }], 55 + }); 56 + } 57 + } 58 + 59 + // Mentions: @handle or @handle.domain.tld 60 + const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 61 + let mentionMatch; 62 + while ((mentionMatch = mentionRegex.exec(text)) !== null) { 63 + const start = mentionMatch.index; 64 + const end = start + mentionMatch[0].length; 65 + const handle = mentionMatch[0].slice(1); // Remove @ 66 + 67 + if (!isRangeClaimed(start, end)) { 68 + // Try to resolve handle to DID 69 + let did = null; 70 + if (resolveHandle) { 71 + try { 72 + did = await resolveHandle(handle); 73 + } catch (e) { 74 + // Handle not found - skip this mention 75 + continue; 76 + } 77 + } 78 + 79 + if (did) { 80 + claimRange(start, end); 81 + facets.push({ 82 + index: { 83 + byteStart: getByteOffset(text, start), 84 + byteEnd: getByteOffset(text, end), 85 + }, 86 + features: [{ 87 + $type: 'app.bsky.richtext.facet#mention', 88 + did, 89 + }], 90 + }); 91 + } 92 + } 93 + } 94 + 95 + // Hashtags: #tag (alphanumeric, no leading numbers) 96 + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 97 + let hashtagMatch; 98 + while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 99 + const start = hashtagMatch.index; 100 + const end = start + hashtagMatch[0].length; 101 + const tag = hashtagMatch[1]; // Without # 102 + 103 + if (!isRangeClaimed(start, end)) { 104 + claimRange(start, end); 105 + facets.push({ 106 + index: { 107 + byteStart: getByteOffset(text, start), 108 + byteEnd: getByteOffset(text, end), 109 + }, 110 + features: [{ 111 + $type: 'app.bsky.richtext.facet#tag', 112 + tag, 113 + }], 114 + }); 115 + } 116 + } 117 + 118 + // Sort by byte position 119 + facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 120 + 121 + return { text, facets }; 122 + } 123 + 124 + /** 125 + * Synchronous parsing for client-side render (no DID resolution). 126 + * Mentions display as-is without profile links. 127 + */ 128 + export function parseTextToFacetsSync(text) { 129 + if (!text) return { text: '', facets: [] }; 130 + 131 + const facets = []; 132 + const encoder = new TextEncoder(); 133 + 134 + function getByteOffset(str, charIndex) { 135 + return encoder.encode(str.slice(0, charIndex)).length; 136 + } 137 + 138 + const claimedPositions = new Set(); 139 + 140 + function isRangeClaimed(start, end) { 141 + for (let i = start; i < end; i++) { 142 + if (claimedPositions.has(i)) return true; 143 + } 144 + return false; 145 + } 146 + 147 + function claimRange(start, end) { 148 + for (let i = start; i < end; i++) { 149 + claimedPositions.add(i); 150 + } 151 + } 152 + 153 + // URLs 154 + const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 155 + let urlMatch; 156 + while ((urlMatch = urlRegex.exec(text)) !== null) { 157 + const start = urlMatch.index; 158 + const end = start + urlMatch[0].length; 159 + 160 + if (!isRangeClaimed(start, end)) { 161 + claimRange(start, end); 162 + facets.push({ 163 + index: { 164 + byteStart: getByteOffset(text, start), 165 + byteEnd: getByteOffset(text, end), 166 + }, 167 + features: [{ 168 + $type: 'app.bsky.richtext.facet#link', 169 + uri: urlMatch[0], 170 + }], 171 + }); 172 + } 173 + } 174 + 175 + // Mentions: @handle or @handle.domain.tld (no DID resolution in sync mode) 176 + const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 177 + let mentionMatch; 178 + while ((mentionMatch = mentionRegex.exec(text)) !== null) { 179 + const start = mentionMatch.index; 180 + const end = start + mentionMatch[0].length; 181 + 182 + if (!isRangeClaimed(start, end)) { 183 + claimRange(start, end); 184 + facets.push({ 185 + index: { 186 + byteStart: getByteOffset(text, start), 187 + byteEnd: getByteOffset(text, end), 188 + }, 189 + features: [{ 190 + $type: 'app.bsky.richtext.facet#mention', 191 + did: null, // No DID in sync mode 192 + }], 193 + }); 194 + } 195 + } 196 + 197 + // Hashtags 198 + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 199 + let hashtagMatch; 200 + while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 201 + const start = hashtagMatch.index; 202 + const end = start + hashtagMatch[0].length; 203 + const tag = hashtagMatch[1]; 204 + 205 + if (!isRangeClaimed(start, end)) { 206 + claimRange(start, end); 207 + facets.push({ 208 + index: { 209 + byteStart: getByteOffset(text, start), 210 + byteEnd: getByteOffset(text, end), 211 + }, 212 + features: [{ 213 + $type: 'app.bsky.richtext.facet#tag', 214 + tag, 215 + }], 216 + }); 217 + } 218 + } 219 + 220 + facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 221 + return { text, facets }; 222 + } 223 + 224 + /** 225 + * Render text with facets as HTML. 226 + * 227 + * @param {string} text - The text content 228 + * @param {Array} facets - Array of facet objects 229 + * @param {Object} options - Rendering options 230 + * @returns {string} HTML string 231 + */ 232 + export function renderFacetedText(text, facets, options = {}) { 233 + if (!text) return ''; 234 + 235 + // If no facets, just escape and return 236 + if (!facets || facets.length === 0) { 237 + return escapeHtml(text); 238 + } 239 + 240 + const encoder = new TextEncoder(); 241 + const decoder = new TextDecoder(); 242 + const bytes = encoder.encode(text); 243 + 244 + // Sort facets by start position 245 + const sortedFacets = [...facets].sort( 246 + (a, b) => a.index.byteStart - b.index.byteStart 247 + ); 248 + 249 + let result = ''; 250 + let lastEnd = 0; 251 + 252 + for (const facet of sortedFacets) { 253 + // Validate byte indices 254 + if (facet.index.byteStart < 0 || facet.index.byteEnd > bytes.length) { 255 + continue; // Skip invalid facets 256 + } 257 + 258 + // Add text before this facet 259 + if (facet.index.byteStart > lastEnd) { 260 + const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart); 261 + result += escapeHtml(decoder.decode(beforeBytes)); 262 + } 263 + 264 + // Get the faceted text 265 + const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd); 266 + const facetText = decoder.decode(facetBytes); 267 + 268 + // Determine facet type and render 269 + const feature = facet.features?.[0]; 270 + if (!feature) { 271 + result += escapeHtml(facetText); 272 + lastEnd = facet.index.byteEnd; 273 + continue; 274 + } 275 + 276 + const type = feature.$type || feature.__typename || ''; 277 + 278 + if (type.includes('link')) { 279 + const uri = feature.uri || ''; 280 + result += `<a href="${escapeHtml(uri)}" target="_blank" rel="noopener noreferrer" class="facet-link">${escapeHtml(facetText)}</a>`; 281 + } else if (type.includes('mention')) { 282 + // Extract handle from text (remove @) 283 + const handle = facetText.startsWith('@') ? facetText.slice(1) : facetText; 284 + result += `<a href="/profile/${escapeHtml(handle)}" class="facet-mention">${escapeHtml(facetText)}</a>`; 285 + } else if (type.includes('tag')) { 286 + // Hashtag - styled but not clickable for now 287 + result += `<span class="facet-tag">${escapeHtml(facetText)}</span>`; 288 + } else { 289 + result += escapeHtml(facetText); 290 + } 291 + 292 + lastEnd = facet.index.byteEnd; 293 + } 294 + 295 + // Add remaining text 296 + if (lastEnd < bytes.length) { 297 + const remainingBytes = bytes.slice(lastEnd); 298 + result += escapeHtml(decoder.decode(remainingBytes)); 299 + } 300 + 301 + return result; 302 + } 303 + 304 + function escapeHtml(text) { 305 + return text 306 + .replace(/&/g, '&amp;') 307 + .replace(/</g, '&lt;') 308 + .replace(/>/g, '&gt;') 309 + .replace(/"/g, '&quot;') 310 + .replace(/'/g, '&#039;'); 311 + }
+14 -13
src/router.js
··· 5 5 #pageCache = new Map(); // path -> { element } 6 6 #scrollCache = new Map(); // path -> scrollY (persists after element eviction) 7 7 8 - // Only cache these route patterns (timeline and profiles) 8 + // Only cache these route patterns (timeline, profiles, notifications, explore) 9 9 #cacheablePatterns = [ 10 10 /^\/$/, // timeline 11 - /^\/profile\/[^/]+$/ // profile (not followers/following/gallery) 11 + /^\/profile\/[^/]+$/, // profile (not followers/following/gallery) 12 + /^\/notifications$/, // notifications 13 + /^\/explore$/ // explore/search 12 14 ]; 13 15 14 16 register(path, componentTag) { ··· 18 20 19 21 connect(outlet) { 20 22 this.#outlet = outlet; 21 - window.addEventListener('popstate', () => { 22 - if (document.startViewTransition) { 23 - document.startViewTransition(() => this.#navigate()); 24 - } else { 25 - this.#navigate(); 26 - } 27 - }); 23 + // Skip View Transitions for popstate - browser gestures provide their own 24 + window.addEventListener('popstate', () => this.#navigate()); 28 25 this.#navigate(); 29 26 return this; 30 27 } ··· 98 95 const pathname = location.pathname; 99 96 100 97 // Save scroll position of current page before switching 101 - if (this.#currentPath) { 102 - this.#scrollCache.set(this.#currentPath, window.scrollY); 98 + if (this.#currentPath && this.#outlet) { 99 + this.#scrollCache.set(this.#currentPath, this.#outlet.scrollTop); 103 100 } 104 101 105 102 // Skip if same path ··· 130 127 cached.element.dispatchEvent(new CustomEvent('grain:activated')); 131 128 // Restore scroll position after paint 132 129 requestAnimationFrame(() => { 133 - window.scrollTo(0, this.#scrollCache.get(pathname) || 0); 130 + if (this.#outlet) { 131 + this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0; 132 + } 134 133 }); 135 134 return; 136 135 } ··· 148 147 149 148 // Restore saved scroll position, or start at top for new pages 150 149 requestAnimationFrame(() => { 151 - window.scrollTo(0, this.#scrollCache.get(pathname) || 0); 150 + if (this.#outlet) { 151 + this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0; 152 + } 152 153 }); 153 154 } 154 155 }
+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
+8 -1
src/services/draft-gallery.js
··· 2 2 #photos = []; 3 3 4 4 setPhotos(photos) { 5 - this.#photos = [...photos]; 5 + // Ensure each photo has an alt property 6 + this.#photos = photos.map(p => ({ ...p, alt: p.alt || '' })); 6 7 } 7 8 8 9 getPhotos() { 9 10 return this.#photos; 11 + } 12 + 13 + updatePhotoAlt(index, alt) { 14 + if (index >= 0 && index < this.#photos.length) { 15 + this.#photos[index] = { ...this.#photos[index], alt }; 16 + } 10 17 } 11 18 12 19 clear() {
+125 -1
src/services/grain-api.js
··· 176 176 `; 177 177 178 178 const response = await this.#execute(gqlQuery, { query, first, after }); 179 - return this.#transformTimelineResponse(response); 179 + return this.#transformSearchResponse(response); 180 + } 181 + 182 + #transformSearchResponse(response) { 183 + const connection = response.data?.socialGrainGallery; 184 + if (!connection) return { galleries: [], pageInfo: { hasNextPage: false } }; 185 + 186 + const galleries = connection.edges.map(edge => { 187 + const node = edge.node; 188 + const profile = node.socialGrainActorProfileByDid; 189 + const items = node.socialGrainGalleryItemViaGallery?.edges || []; 190 + 191 + const photos = items 192 + .map(i => { 193 + const photo = i.node.itemResolved; 194 + if (!photo) return null; 195 + return { 196 + url: photo.photo?.url || '', 197 + alt: photo.alt || '', 198 + aspectRatio: photo.aspectRatio 199 + ? photo.aspectRatio.width / photo.aspectRatio.height 200 + : 1 201 + }; 202 + }) 203 + .filter(Boolean); 204 + 205 + return { 206 + uri: node.uri, 207 + title: node.title, 208 + description: node.description, 209 + createdAt: node.createdAt, 210 + handle: node.actorHandle, 211 + displayName: profile?.displayName || '', 212 + avatarUrl: profile?.avatar?.url || '', 213 + photos, 214 + favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0, 215 + commentCount: node.socialGrainCommentViaSubject?.totalCount || 0, 216 + viewerHasFavorited: false, 217 + viewerFavoriteUri: null 218 + }; 219 + }).filter(gallery => gallery.photos.length > 0); 220 + 221 + // Cache each gallery record by URI (but don't update timeline query cache) 222 + galleries.forEach(gallery => { 223 + recordCache.set(gallery.uri, gallery); 224 + }); 225 + 226 + return { 227 + galleries, 228 + pageInfo: connection.pageInfo 229 + }; 180 230 } 181 231 182 232 async searchProfiles(query, { first = 20, after = null } = {}) { ··· 560 610 actorHandle 561 611 title 562 612 description 613 + facets 563 614 createdAt 564 615 socialGrainActorProfileByDid { 565 616 displayName ··· 592 643 node { 593 644 uri 594 645 text 646 + facets 595 647 createdAt 596 648 actorHandle 597 649 replyTo ··· 654 706 return { 655 707 uri: node.uri, 656 708 text: node.text, 709 + facets: node.facets || [], 657 710 createdAt: node.createdAt, 658 711 handle: node.actorHandle, 659 712 displayName: commentProfile?.displayName || '', ··· 668 721 uri: galleryNode.uri, 669 722 title: galleryNode.title, 670 723 description: galleryNode.description, 724 + facets: galleryNode.facets || [], 671 725 createdAt: galleryNode.createdAt, 672 726 handle: galleryNode.actorHandle, 673 727 displayName: profile?.displayName || '', ··· 988 1042 node { 989 1043 uri 990 1044 text 1045 + facets 991 1046 createdAt 992 1047 actorHandle 993 1048 replyTo ··· 1028 1083 return { 1029 1084 uri: node.uri, 1030 1085 text: node.text, 1086 + facets: node.facets || [], 1031 1087 createdAt: node.createdAt, 1032 1088 handle: node.actorHandle, 1033 1089 displayName: profile?.displayName || '', ··· 1042 1098 comments, 1043 1099 pageInfo: connection.pageInfo || { hasNextPage: false, endCursor: null }, 1044 1100 totalCount: connection.totalCount || 0 1101 + }; 1102 + } 1103 + 1104 + async resolveHandle(handle) { 1105 + const query = ` 1106 + query ResolveHandle($handle: String!) { 1107 + socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) { 1108 + edges { 1109 + node { did } 1110 + } 1111 + } 1112 + } 1113 + `; 1114 + 1115 + const response = await this.#execute(query, { handle }); 1116 + const did = response.data?.socialGrainActorProfile?.edges?.[0]?.node?.did; 1117 + 1118 + if (!did) { 1119 + throw new Error(`Handle not found: ${handle}`); 1120 + } 1121 + 1122 + return did; 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 1045 1169 }; 1046 1170 } 1047 1171 }
+47
src/services/mutations.js
··· 1 1 import { auth } from './auth.js'; 2 2 import { recordCache } from './record-cache.js'; 3 + import { parseTextToFacets } from '../lib/richtext.js'; 4 + import { grainApi } from './grain-api.js'; 3 5 4 6 class MutationsService { 5 7 async createFavorite(galleryUri) { ··· 102 104 103 105 async createComment(galleryUri, text, replyToUri = null, focusUri = null) { 104 106 const client = auth.getClient(); 107 + 108 + // Parse text for facets with handle resolution 109 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 110 + const { facets } = await parseTextToFacets(text, resolveHandle); 111 + 105 112 const input = { 106 113 subject: galleryUri, 107 114 text, 108 115 createdAt: new Date().toISOString() 109 116 }; 117 + 118 + // Only include facets if we found any 119 + if (facets && facets.length > 0) { 120 + input.facets = facets; 121 + } 110 122 111 123 if (replyToUri) { 112 124 input.replyTo = replyToUri; ··· 185 197 186 198 // Refresh user data 187 199 await auth.refreshUser(); 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; 188 235 } 189 236 } 190 237
+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 + }