# 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
teal
```
to:
```html
grain
```
**Step 2: Update MobileBottomBar.svelte**
Remove the scrobble button and the `ScrobbleModal` import. Replace the file with:
```svelte
goto('/')}
>
{#if $isAuthenticated && $viewer}
goto(`/profile/${encodeURIComponent($viewer!.did)}`)}
>
{/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)}
nav(feed.path)}>
{feed.label}
{/each}
{#if $isAuthenticated && $viewer}
{$viewer.handle || $viewer.displayName}
{ open = false; doLogout() }}>Sign Out
{:else}
{ open = false; loginOpen = true }}>Sign In
{/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
{gallery.title}
{#if gallery.description}
{gallery.description}
{/if}
{#if photoCount > 0}
{photoCount} photo{photoCount !== 1 ? 's' : ''}
{/if}
```
**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 ? '+' : ''}
load()} title="Refresh">
{/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}
loadMore()} disabled={loadingMore}>
{loadingMore ? 'Loading\u2026' : 'Load more'}
{/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"
```