# Grain hatk Template — MVP Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Copy hatk-template-teal and rework it into a Grain photo-sharing app with shell + auth + timeline gallery cards, running end-to-end. **Architecture:** SvelteKit 5 + hatk framework with Grain's AT Protocol lexicons. The teal template provides the application shell, OAuth flow, and component structure. We swap the music domain (plays, artists, tracks) for the photo domain (galleries, photos, items) and write one feed (recent galleries) with hydration. **Tech Stack:** SvelteKit 5, Svelte 5, hatk, TanStack Svelte Query, SQLite, Vite --- ### Task 1: Copy teal template files into grain project **Files:** - Copy from: `~/code/hatk-template-teal/` (everything except `.git/`, `node_modules/`, `.svelte-kit/`, `data/`, `*.db`) - Copy to: `~/code/hatk-template-grain/` **Step 1: Copy the template** ```bash cd ~/code/hatk-template-grain rsync -av --exclude='.git' --exclude='node_modules' --exclude='.svelte-kit' --exclude='data' --exclude='*.db' --exclude='.DS_Store' ~/code/hatk-template-teal/ . ``` **Step 2: Initialize git** ```bash cd ~/code/hatk-template-grain git init ``` **Step 3: Verify the copy** ```bash ls ~/code/hatk-template-grain/app/routes/+layout.svelte ls ~/code/hatk-template-grain/server/feeds/recent.ts ls ~/code/hatk-template-grain/hatk.config.ts ls ~/code/hatk-template-grain/package.json ``` Expected: all files exist. **Step 4: Commit** ```bash git add -A git commit -m "chore: copy hatk-template-teal scaffold" ``` --- ### Task 2: Replace lexicons **Files:** - Delete: `lexicons/fm/` (teal-specific) - Delete: `lexicons/xyz/` (teal-specific appview defs) - Delete: `lexicons/community/` (teal-specific bookmarks) - Copy from: `~/code/grain-next/lexicons/social/` → `lexicons/social/` - Keep: `lexicons/app/bsky/`, `lexicons/com/atproto/`, `lexicons/dev/hatk/` **Step 1: Remove teal-specific lexicons** ```bash cd ~/code/hatk-template-grain rm -rf lexicons/fm lexicons/xyz lexicons/community ``` **Step 2: Copy Grain lexicons** ```bash cp -r ~/code/grain-next/lexicons/social lexicons/social ``` **Step 3: Verify** ```bash ls lexicons/social/grain/gallery/gallery.json ls lexicons/social/grain/photo/photo.json ls lexicons/social/grain/actor/profile.json ls lexicons/social/grain/graph/follow.json ls lexicons/social/grain/favorite/favorite.json ls lexicons/social/grain/comment/comment.json ``` Expected: all files exist. **Step 4: Commit** ```bash git add -A git commit -m "chore: replace teal lexicons with grain lexicons" ``` --- ### Task 3: Update package.json and hatk.config.ts **Files:** - Modify: `package.json` - Modify: `hatk.config.ts` **Step 1: Update package.json** Change the `name` field: ```json "name": "hatk-template-grain" ``` **Step 2: Update hatk.config.ts** Replace the entire file with: ```typescript import { defineConfig } from "@hatk/hatk/config"; const isProd = process.env.NODE_ENV === "production"; const prodDomain = process.env.RAILWAY_PUBLIC_DOMAIN; const grainScopes = [ "atproto", "repo:social.grain.gallery", "repo:social.grain.gallery.item", "repo:social.grain.photo", "repo:social.grain.actor.profile", "repo:social.grain.graph.follow", "repo:social.grain.favorite", "repo:social.grain.comment", ].join(" "); export default defineConfig({ relay: isProd ? "wss://bsky.network" : "ws://localhost:2583", plc: isProd ? "https://plc.directory" : "http://localhost:2582", port: 3000, databaseEngine: "sqlite", database: isProd ? "/data/grain.db" : "data/grain.db", backfill: { signalCollections: ["social.grain.actor.profile"], fullNetwork: false, parallelism: 5, }, oauth: { issuer: isProd && prodDomain ? `https://${prodDomain}` : undefined, scopes: grainScopes.split(" "), clients: [ ...(prodDomain ? [ { client_id: `https://${prodDomain}/oauth-client-metadata.json`, client_name: "grain", scope: grainScopes, redirect_uris: [`https://${prodDomain}/oauth/callback`], }, ] : []), { client_id: "http://127.0.0.1:3000/oauth-client-metadata.json", client_name: "grain", scope: grainScopes, redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], }, ], }, }); ``` **Step 3: Commit** ```bash git add package.json hatk.config.ts git commit -m "chore: update config for grain" ``` --- ### Task 4: Update app.html title and branding strings **Files:** - Modify: `app/app.html` **Step 1: Update title** Change `teal` to `grain`. **Step 2: Commit** ```bash git add app/app.html git commit -m "chore: update HTML title to grain" ``` --- ### Task 5: Strip teal-specific server files **Files:** - Delete: `server/feeds/actor.ts` - Delete: `server/feeds/artist.ts` - Delete: `server/feeds/bookmarks.ts` - Delete: `server/feeds/following.ts` - Delete: `server/feeds/genre.ts` - Delete: `server/feeds/release.ts` - Delete: `server/feeds/track.ts` - Delete: `server/feeds/_hydrate.ts` - Delete: `server/xrpc/` (all files) - Delete: `server/og/` (all files) - Delete: `server/setup/` (all files) - Keep: `server/feeds/recent.ts`, `server/on-login.ts` **Step 1: Remove teal-specific server files** ```bash cd ~/code/hatk-template-grain rm server/feeds/actor.ts server/feeds/artist.ts server/feeds/bookmarks.ts rm server/feeds/following.ts server/feeds/genre.ts server/feeds/release.ts rm server/feeds/track.ts server/feeds/_hydrate.ts rm -rf server/xrpc server/og server/setup ``` **Step 2: Verify remaining** ```bash ls server/feeds/recent.ts ls server/on-login.ts ``` **Step 3: Commit** ```bash git add -A git commit -m "chore: remove teal-specific server code" ``` --- ### Task 6: Write the recent galleries feed **Files:** - Modify: `server/feeds/recent.ts` **Step 1: Write the feed** Replace `server/feeds/recent.ts` with: ```typescript import { defineFeed } from "$hatk"; import type { HydrateContext } from "$hatk"; export default defineFeed({ collection: "social.grain.gallery", label: "Recent Galleries", async hydrate(ctx: HydrateContext) { const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))]; const profiles = await ctx.lookup("social.grain.actor.profile", "did", dids); return ctx.items.map((item) => { const author = profiles.get(item.did); // Look up gallery items (photos linked to this gallery) return { uri: item.uri, cid: item.cid, title: item.value.title, description: item.value.description, createdAt: item.value.createdAt, creator: author ? { did: author.did, handle: author.handle, displayName: author.value.displayName, avatar: ctx.blobUrl(author.did, author.value.avatar), } : { did: item.did, handle: item.handle }, items: [], }; }); }, async generate(ctx) { const { rows, cursor } = await ctx.paginate<{ uri: string }>( `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t LEFT JOIN _repos r ON t.did = r.did WHERE (r.status IS NULL OR r.status != 'takendown')`, { orderBy: "t.created_at" }, ); return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); }, }); ``` **Step 2: Commit** ```bash git add server/feeds/recent.ts git commit -m "feat: add recent galleries feed" ``` --- ### Task 7: Update queries.ts for grain **Files:** - Modify: `app/lib/queries.ts` **Step 1: Replace queries.ts** Replace the entire file with: ```typescript import { queryOptions } from "@tanstack/svelte-query"; import { callXrpc } from "$hatk/client"; type Fetch = typeof globalThis.fetch; // ─── Feeds ────────────────────────────────────────────────────────── export const recentFeedQuery = (limit = 50, f?: Fetch) => queryOptions({ queryKey: ["getFeed", "recent"], queryFn: () => callXrpc("dev.hatk.getFeed", { feed: "recent", limit }, f), staleTime: 60_000, }); ``` **Step 2: Commit** ```bash git add app/lib/queries.ts git commit -m "feat: replace teal queries with grain feed query" ``` --- ### Task 8: Simplify preferences.ts **Files:** - Modify: `app/lib/preferences.ts` **Step 1: Replace preferences.ts** Replace the entire file with: ```typescript import { writable } from "svelte/store"; export interface PinnedFeed { id: string; label: string; type: string; path: string; } export const DEFAULT_PINNED: PinnedFeed[] = [ { id: "recent", label: "Recent", type: "feed", path: "/" }, ]; export const pinnedFeeds = writable(DEFAULT_PINNED); export function resetPreferences(): void { pinnedFeeds.set(DEFAULT_PINNED); } ``` **Step 2: Commit** ```bash git add app/lib/preferences.ts git commit -m "feat: simplify preferences to single recent feed" ``` --- ### Task 9: Update +layout.svelte to remove preferences fetching **Files:** - Modify: `app/routes/+layout.svelte` **Step 1: Replace +layout.svelte** Replace the entire file with: ```svelte {@render children()} ``` **Step 2: Commit** ```bash git add app/routes/+layout.svelte git commit -m "feat: simplify layout to remove teal-specific data" ``` --- ### Task 10: Update +layout.server.ts to remove teal profile fetch **Files:** - Modify: `app/routes/+layout.server.ts` **Step 1: Replace +layout.server.ts** Replace the entire file with: ```typescript import type { LayoutServerLoad } from "./$types"; export const load: LayoutServerLoad = async ({ cookies }) => { const parseSessionCookie = (globalThis as any).__hatk_parseSessionCookie; const cookieName: string = (globalThis as any).__hatk_sessionCookieName ?? "__hatk_session"; let viewer: { did: string; handle?: string } | null = null; if (parseSessionCookie) { const cookieValue = cookies.get(cookieName); if (cookieValue) { try { const request = new Request("http://localhost", { headers: { cookie: `${cookieName}=${cookieValue}` }, }); viewer = await parseSessionCookie(request); } catch {} } } if (viewer) { (globalThis as any).__hatk_viewer = viewer; } return { viewer }; }; ``` **Step 2: Commit** ```bash git add app/routes/+layout.server.ts git commit -m "feat: simplify layout server to basic session parsing" ``` --- ### Task 11: Update +layout.ts to remove teal prefetch queries **Files:** - Modify: `app/routes/+layout.ts` **Step 1: Replace +layout.ts** Replace the entire file with: ```typescript import { browser } from "$app/environment"; import { QueryClient } from "@tanstack/svelte-query"; import type { LayoutLoad } from "./$types"; let browserClient: QueryClient; function getQueryClient() { if (browser) { if (!browserClient) { browserClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, gcTime: 5 * 60_000, refetchOnWindowFocus: true, }, }, }); } return browserClient; } return new QueryClient({ defaultOptions: { queries: { enabled: false, staleTime: 60_000, }, }, }); } export const load: LayoutLoad = async ({ data }) => { const queryClient = getQueryClient(); return { ...data, queryClient }; }; ``` **Step 2: Commit** ```bash git add app/routes/+layout.ts git commit -m "feat: simplify layout loader to basic query client" ``` --- ### Task 12: Strip teal-specific routes **Files:** - Delete: `app/routes/artist/` (entire directory) - Delete: `app/routes/bookmarks/` (entire directory) - Delete: `app/routes/feeds/` (entire directory) - Delete: `app/routes/following/` (entire directory) - Delete: `app/routes/genre/` (entire directory) - Delete: `app/routes/play/` (entire directory) - Delete: `app/routes/release/` (entire directory) - Delete: `app/routes/search/` (entire directory) - Delete: `app/routes/track/` (entire directory) - Delete: `app/routes/preferences.remote.ts` - Keep: `app/routes/+page.svelte`, `app/routes/+page.ts`, `app/routes/+layout.*`, `app/routes/+error.svelte`, `app/routes/oauth/`, `app/routes/profile/` **Step 1: Remove teal routes** ```bash cd ~/code/hatk-template-grain rm -rf app/routes/artist app/routes/bookmarks app/routes/feeds rm -rf app/routes/following app/routes/genre app/routes/play rm -rf app/routes/release app/routes/search app/routes/track rm app/routes/preferences.remote.ts ``` **Step 2: Commit** ```bash git add -A git commit -m "chore: remove teal-specific routes" ``` --- ### Task 13: Strip teal-specific components **Files:** - Delete: `app/lib/components/atoms/AlbumArt.svelte` - Delete: `app/lib/components/atoms/AvatarLightbox.svelte` - Delete: `app/lib/components/atoms/Badge.svelte` - Delete: `app/lib/components/atoms/OGMeta.svelte` - Delete: `app/lib/components/atoms/TimeAgo.svelte` - Delete: `app/lib/components/molecules/BookmarkButton.svelte` - Delete: `app/lib/components/molecules/DetailHeader.svelte` - Delete: `app/lib/components/molecules/ExternalLink.svelte` - Delete: `app/lib/components/molecules/PinButton.svelte` - Delete: `app/lib/components/molecules/PlayCard.svelte` - Delete: `app/lib/components/molecules/ProfileChip.svelte` - Delete: `app/lib/components/molecules/ShareMenu.svelte` - Delete: `app/lib/components/molecules/StatBar.svelte` - Delete: `app/lib/components/molecules/TrendingStrip.svelte` - Delete: `app/lib/components/organisms/ScrobbleModal.svelte` **Step 1: Remove teal-specific components** ```bash cd ~/code/hatk-template-grain rm app/lib/components/atoms/AlbumArt.svelte rm app/lib/components/atoms/AvatarLightbox.svelte rm app/lib/components/atoms/Badge.svelte rm app/lib/components/atoms/OGMeta.svelte rm app/lib/components/atoms/TimeAgo.svelte rm app/lib/components/molecules/BookmarkButton.svelte rm app/lib/components/molecules/DetailHeader.svelte rm app/lib/components/molecules/ExternalLink.svelte rm app/lib/components/molecules/PinButton.svelte rm app/lib/components/molecules/PlayCard.svelte rm app/lib/components/molecules/ProfileChip.svelte rm app/lib/components/molecules/ShareMenu.svelte rm app/lib/components/molecules/StatBar.svelte rm app/lib/components/molecules/TrendingStrip.svelte rm app/lib/components/organisms/ScrobbleModal.svelte ``` **Step 2: Commit** ```bash git add -A git commit -m "chore: remove teal-specific components" ``` --- ### Task 14: Update Sidebar.svelte for grain **Files:** - Modify: `app/lib/components/organisms/Sidebar.svelte` **Step 1: Replace Sidebar.svelte** Remove the scrobble button and update branding: ```svelte ``` **Step 2: Commit** ```bash git add app/lib/components/organisms/Sidebar.svelte git commit -m "feat: update sidebar for grain branding" ``` --- ### Task 15: Update SidebarRight.svelte for grain **Files:** - Modify: `app/lib/components/organisms/SidebarRight.svelte` **Step 1: Replace SidebarRight.svelte** Strip trending artists and genres, keep search bar and basic feeds: ```svelte ``` **Step 2: Commit** ```bash git add app/lib/components/organisms/SidebarRight.svelte git commit -m "feat: simplify right sidebar for grain" ``` --- ### Task 16: Update MobileTopBar, MobileBottomBar, MobileDrawer for grain **Files:** - Modify: `app/lib/components/molecules/MobileTopBar.svelte` - Modify: `app/lib/components/molecules/MobileBottomBar.svelte` - Modify: `app/lib/components/organisms/MobileDrawer.svelte` **Step 1: Update MobileTopBar.svelte** Change the logo text from "teal" to "grain": In `MobileTopBar.svelte`, change: ```html ``` to: ```html ``` **Step 2: Update MobileBottomBar.svelte** Remove the scrobble button and the `ScrobbleModal` import. Replace the file with: ```svelte
{#if $isAuthenticated && $viewer} {/if}
``` **Step 3: Update MobileDrawer.svelte** Replace file — remove trending artists, genres, scrobble references. Change "teal" to "grain": ```svelte
open = false}>
{#each $pinnedFeeds as feed (feed.id)} {/each}
{#if $isAuthenticated && $viewer}
{$viewer.handle || $viewer.displayName}
{:else} {/if}
``` **Step 4: Commit** ```bash git add app/lib/components/molecules/MobileTopBar.svelte app/lib/components/molecules/MobileBottomBar.svelte app/lib/components/organisms/MobileDrawer.svelte git commit -m "feat: update mobile components for grain" ``` --- ### Task 17: Create GalleryCard.svelte **Files:** - Create: `app/lib/components/molecules/GalleryCard.svelte` **Step 1: Create the component** ```svelte ``` **Step 2: Commit** ```bash git add app/lib/components/molecules/GalleryCard.svelte git commit -m "feat: add GalleryCard component" ``` --- ### Task 18: Update FeedList.svelte and FeedTabs.svelte for grain **Files:** - Modify: `app/lib/components/organisms/FeedList.svelte` - Modify: `app/lib/components/molecules/FeedTabs.svelte` **Step 1: Update FeedList.svelte** Replace the file — swap `PlayCard` for `GalleryCard`, remove `PlayView` type: ```svelte {#if !loading || items.length > 0}
{items.length} gallerie{items.length !== 1 ? 's' : 'y'}{cursor ? '+' : ''}
{/if} {#if loading && items.length === 0} {#each {length: 4} as _}
{/each} {:else if error && items.length === 0}
{error}
{:else if items.length === 0 && !loading}
📷 No galleries found.
{:else} {#each items as item, i (`${item.uri}:${i}`)} {/each} {#if cursor}
{/if} {/if} ``` **Step 2: FeedTabs.svelte is already fine** The `FeedTabs.svelte` component reads from `$pinnedFeeds` which now only has "Recent". No changes needed. **Step 3: Commit** ```bash git add app/lib/components/organisms/FeedList.svelte git commit -m "feat: update FeedList to render GalleryCards" ``` --- ### Task 19: Update home page (+page.svelte and +page.ts) **Files:** - Modify: `app/routes/+page.svelte` - Modify: `app/routes/+page.ts` **Step 1: Update +page.svelte** Replace the file — remove TrendingStrip, simplify: ```svelte {#if feed.isLoading} {:else} {/if} ``` **Step 2: +page.ts is already correct** The existing `+page.ts` uses `recentFeedQuery` which we've already updated. No changes needed. **Step 3: Commit** ```bash git add app/routes/+page.svelte git commit -m "feat: update home page for grain timeline" ``` --- ### Task 20: Update profile placeholder page **Files:** - Modify: `app/routes/profile/[did]/+page.svelte` - Modify: `app/routes/profile/[did]/+page.ts` **Step 1: Simplify profile page to placeholder** Replace `app/routes/profile/[did]/+page.svelte` with: ```svelte

Profile

{did}

Full profile coming soon.

``` Replace `app/routes/profile/[did]/+page.ts` with: ```typescript // Profile page — no data loading for MVP ``` **Step 2: Commit** ```bash git add app/routes/profile/ git commit -m "feat: add placeholder profile page" ``` --- ### Task 21: Clean up remaining teal references **Files:** - Modify: `app/lib/utils.ts` (keep as-is, all helpers are generic) - Delete: `test/` (teal-specific test fixtures and tests) - Delete: `seeds/` (teal-specific seed data) - Delete: `data/` contents (MusicBrainz artist data) - Delete: `Dockerfile` and `docker-compose.yml` (rebuild later) - Delete: `.dockerignore` and `.railwayignore` (rebuild later) **Step 1: Remove teal-specific data and tests** ```bash cd ~/code/hatk-template-grain rm -rf test seeds rm -rf data/artist data/artist.tar.xz rm -f Dockerfile docker-compose.yml .dockerignore .railwayignore 2>/dev/null ``` **Step 2: Commit** ```bash git add -A git commit -m "chore: remove teal-specific test data, seeds, and deploy config" ``` --- ### Task 22: Install dependencies and verify build **Step 1: Install dependencies** ```bash cd ~/code/hatk-template-grain npm install ``` Expected: installs successfully. **Step 2: Generate types from lexicons** ```bash npx hatk generate types ``` Expected: generates `hatk.generated.ts` and `hatk.generated.client.ts` with Grain types. **Step 3: Run type check** ```bash npx svelte-check ``` Expected: no type errors (may have warnings). **Step 4: Commit generated types** ```bash git add hatk.generated.ts hatk.generated.client.ts git commit -m "chore: generate types from grain lexicons" ``` --- ### Task 23: Final verification **Step 1: Start dev server** ```bash cd ~/code/hatk-template-grain npm run dev ``` Expected: SvelteKit dev server starts on port 3000. The page should load showing the 3-column shell with "grain" branding, empty feed (no data yet), login button, and search placeholder. **Step 2: Verify in browser** Open `http://localhost:3000` and verify: - 3-column layout renders - Left sidebar shows "grain" logo and home icon - Right sidebar shows search bar and "Feeds" card with "Recent" - Main content shows "No galleries found" empty state - No console errors related to missing components or imports **Step 3: Final commit if any fixes needed** ```bash git add -A git commit -m "feat: grain hatk template MVP complete" ```