# Gallery Creation Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a 3-step gallery creation flow (photo selection → metadata → alt text → publish) to hatk-template-grain. **Architecture:** Single `/create` route with step transitions managed by Svelte 5 `$state`. Photos processed client-side, then published via hatk's `callXrpc` (uploadBlob → createRecord for photos → gallery → gallery items). Draft state lives entirely in component memory. **Tech Stack:** SvelteKit 5, Svelte 5 runes, TanStack Svelte Query mutations, hatk client (`callXrpc`), Canvas API for image processing. --- ### Task 1: Image Processing Utility **Files:** - Create: `app/lib/utils/image-resize.ts` **Step 1: Create `app/lib/utils/image-resize.ts`** ```typescript export interface ProcessedPhoto { dataUrl: string; width: number; height: number; alt: string; } export function readFileAsDataURL(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); } function getBase64Size(base64: string): number { const str = base64.split(",")[1] || base64; return Math.ceil((str.length * 3) / 4); } function createResizedImage( dataUrl: string, options: { width: number; height: number; quality: number }, ): Promise<{ dataUrl: string; width: number; height: number }> { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const scale = Math.min(options.width / img.width, options.height / img.height, 1); const w = Math.round(img.width * scale); const h = Math.round(img.height * scale); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d")!; ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, w, h); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, w, h); resolve({ dataUrl: canvas.toDataURL("image/jpeg", options.quality), width: w, height: h }); }; img.onerror = reject; img.src = dataUrl; }); } export async function resizeImage( dataUrl: string, opts: { width: number; height: number; maxSize: number }, ): Promise<{ dataUrl: string; width: number; height: number }> { let bestResult: { dataUrl: string; width: number; height: number } | null = null; let minQuality = 0; let maxQuality = 100; while (maxQuality - minQuality > 1) { const quality = Math.round((minQuality + maxQuality) / 2); const result = await createResizedImage(dataUrl, { width: opts.width, height: opts.height, quality: quality / 100, }); const size = getBase64Size(result.dataUrl); if (size <= opts.maxSize) { bestResult = result; minQuality = quality; } else { maxQuality = quality; } } if (!bestResult) { throw new Error("Failed to compress image within size limit"); } return bestResult; } export async function processPhotos(files: File[]): Promise { const processed: ProcessedPhoto[] = []; for (const file of files) { const dataUrl = await readFileAsDataURL(file); const resized = await resizeImage(dataUrl, { width: 2000, height: 2000, maxSize: 900_000, }); processed.push({ dataUrl: resized.dataUrl, width: resized.width, height: resized.height, alt: "", }); } return processed; } ``` **Step 2: Verify the file compiles** Run: `npx tsc --noEmit app/lib/utils/image-resize.ts` or rely on the dev server's type checking. **Step 3: Commit** ```bash git add app/lib/utils/image-resize.ts git commit -m "feat: add image resize/compress utility for gallery creation" ``` --- ### Task 2: Rich Text Facet Parser **Files:** - Create: `app/lib/utils/rich-text.ts` **Step 1: Create `app/lib/utils/rich-text.ts`** ```typescript interface Facet { index: { byteStart: number; byteEnd: number }; features: Array< | { $type: "app.bsky.richtext.facet#link"; uri: string } | { $type: "app.bsky.richtext.facet#mention"; did: string } | { $type: "app.bsky.richtext.facet#tag"; tag: string } >; } export async function parseTextToFacets( text: string, resolveHandle?: (handle: string) => Promise, ): Promise<{ text: string; facets: Facet[] }> { if (!text) return { text: "", facets: [] }; const facets: Facet[] = []; const encoder = new TextEncoder(); function getByteOffset(str: string, charIndex: number): number { return encoder.encode(str.slice(0, charIndex)).length; } const claimed = new Set(); function isRangeClaimed(start: number, end: number): boolean { for (let i = start; i < end; i++) { if (claimed.has(i)) return true; } return false; } function claimRange(start: number, end: number): void { for (let i = start; i < end; i++) { claimed.add(i); } } // URLs (highest priority) const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; let urlMatch; while ((urlMatch = urlRegex.exec(text)) !== null) { const start = urlMatch.index; const end = start + urlMatch[0].length; if (!isRangeClaimed(start, end)) { claimRange(start, end); facets.push({ index: { byteStart: getByteOffset(text, start), byteEnd: getByteOffset(text, end) }, features: [{ $type: "app.bsky.richtext.facet#link", uri: urlMatch[0] }], }); } } // Mentions 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; let mentionMatch; while ((mentionMatch = mentionRegex.exec(text)) !== null) { const start = mentionMatch.index; const end = start + mentionMatch[0].length; const handle = mentionMatch[0].slice(1); if (!isRangeClaimed(start, end) && resolveHandle) { try { const did = await resolveHandle(handle); if (did) { claimRange(start, end); facets.push({ index: { byteStart: getByteOffset(text, start), byteEnd: getByteOffset(text, end) }, features: [{ $type: "app.bsky.richtext.facet#mention", did }], }); } } catch { // Handle not found — skip } } } // Hashtags const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; let hashtagMatch; while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { const start = hashtagMatch.index; const end = start + hashtagMatch[0].length; const tag = hashtagMatch[1]; if (!isRangeClaimed(start, end)) { claimRange(start, end); facets.push({ index: { byteStart: getByteOffset(text, start), byteEnd: getByteOffset(text, end) }, features: [{ $type: "app.bsky.richtext.facet#tag", tag }], }); } } facets.sort((a, b) => a.index.byteStart - b.index.byteStart); return { text, facets }; } ``` **Step 2: Commit** ```bash git add app/lib/utils/rich-text.ts git commit -m "feat: add rich text facet parser for mentions, URLs, hashtags" ``` --- ### Task 3: Add Create Button to Navigation **Files:** - Modify: `app/lib/components/organisms/Sidebar.svelte` - Modify: `app/lib/components/molecules/MobileBottomBar.svelte` **Step 1: Modify `Sidebar.svelte`** Add a `Plus` icon import and a create button in `.nav-items`, visible only when authenticated. The button should link to `/create`. ```svelte ``` (Keep all existing ` ``` **Step 2: Verify the page renders** Run: `npm run dev`, navigate to `/create` while logged in. Confirm: - Step 1: "Select Photos" button appears - Selecting images processes them and advances to step 2 - Step 2: Thumbnail strip, title input, description textarea, "Next" button - Step 3: Photo previews with alt text inputs, "Post" button **Step 3: Test the full publish flow** 1. Select 2-3 photos 2. Add a title, optionally a description 3. Optionally add alt text 4. Click "Post" 5. Confirm gallery records are created and you're redirected to home **Step 4: Commit** ```bash git add app/routes/create/+page.svelte git commit -m "feat: add gallery creation page with 3-step flow" ``` --- ### Task 5: End-to-End Verification **Step 1: Full flow test** 1. Log in 2. Confirm "+" button appears in sidebar (desktop) and bottom bar (mobile) 3. Click "+" → navigate to `/create` 4. Select photos (verify max 10 enforced) 5. Add title + description 6. Add alt text for at least one photo 7. Post → verify redirect and gallery appears in feed **Step 2: Error handling test** 1. Try posting while logged out → verify redirect 2. Try selecting >10 photos → verify error message 3. Open browser dev tools, throttle network → verify "Posting..." state and error recovery **Step 3: Final commit (if any fixes needed)** ```bash git add -u git commit -m "fix: address issues found during gallery creation testing" ```