~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
1663 lines · 39.8 KB Raw History

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

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

cd ~/code/hatk-template-grain
git init

Step 3: Verify the copy

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

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

cd ~/code/hatk-template-grain
rm -rf lexicons/fm lexicons/xyz lexicons/community

Step 2: Copy Grain lexicons

cp -r ~/code/grain-next/lexicons/social lexicons/social

Step 3: Verify

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

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:

"name": "hatk-template-grain"

Step 2: Update hatk.config.ts

Replace the entire file with:

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

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 <title>teal</title> to <title>grain</title>.

Step 2: Commit

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

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

ls server/feeds/recent.ts
ls server/on-login.ts

Step 3: Commit

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:

import { defineFeed } from "$hatk";
import type { HydrateContext } from "$hatk";

export default defineFeed({
  collection: "social.grain.gallery",
  label: "Recent Galleries",

  async hydrate(ctx: HydrateContext<any>) {
    const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))];
    const profiles = await ctx.lookup<any>("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

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:

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

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:

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<PinnedFeed[]>(DEFAULT_PINNED);

export function resetPreferences(): void {
  pinnedFeeds.set(DEFAULT_PINNED);
}

Step 2: Commit

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:

<script lang="ts">
  import type { Snippet } from 'svelte'
  import '../app.css'
  import Shell from '$lib/components/templates/Shell.svelte'
  import '$lib/auth'
  import { QueryClientProvider } from '@tanstack/svelte-query'
  import { isAuthenticated, viewer } from '$lib/stores'

  let { data, children }: { data: any; children: Snippet } = $props()

  $effect(() => {
    if (data.viewer) {
      $isAuthenticated = true
      $viewer = { did: data.viewer.did, handle: data.viewer.handle ?? null, displayName: data.viewer.handle ?? data.viewer.did.slice(0, 18), avatar: null }
    } else {
      $isAuthenticated = false
      $viewer = null
    }
  })
</script>

<QueryClientProvider client={data.queryClient}>
  <Shell>
    {@render children()}
  </Shell>
</QueryClientProvider>

Step 2: Commit

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:

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

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:

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

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

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

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

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

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:

<script lang="ts">
  import { Home } from 'lucide-svelte'
  import AuthBar from './AuthBar.svelte'
  import Avatar from '../atoms/Avatar.svelte'
  import { isAuthenticated, viewer } from '$lib/stores'
  import { page } from '$app/state'
</script>

<nav class="sidebar-left">
  <div class="sidebar-top">
    {#if $isAuthenticated && $viewer}
      <a href="/profile/{$viewer.did}" class="sidebar-avatar-link">
        <Avatar did={$viewer.did} src={$viewer.avatar} name={$viewer.displayName || $viewer.handle} size={42} />
      </a>
    {:else}
      <a href="/" class="logo-text">grain</a>
    {/if}
  </div>
  <div class="nav-items">
    <a href="/" class="nav-item" class:active={page.url.pathname === '/'} title="Home">
      <Home size={22} />
    </a>
  </div>
  <div class="sidebar-bottom">
    <AuthBar />
  </div>
</nav>

<style>
  .sidebar-left {
    position: sticky;
    top: 0;
    height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    padding: 16px 12px 16px 0;
    border-right: 1px solid var(--border);
    z-index: 101;
  }
  .sidebar-top {
    margin-bottom: 28px;
  }
  .logo-text {
    font-family: var(--font-display);
    font-weight: 800;
    font-size: 15px;
    display: block;
    text-align: center;
    color: var(--teal);
    text-decoration: none;
  }
  .sidebar-avatar-link { text-decoration: none; }
  .nav-items {
    display: flex;
    flex-direction: column;
    gap: 4px;
    align-items: center;
  }
  .nav-item {
    width: 42px;
    height: 42px;
    border-radius: 10px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--text-muted);
    cursor: pointer;
    transition: all 0.15s;
    text-decoration: none;
    background: none;
    border: none;
    padding: 0;
    font: inherit;
  }
  .nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
  .nav-item.active { color: var(--teal); background: var(--teal-glow); }
  .sidebar-bottom {
    margin-top: auto;
    display: flex;
    flex-direction: column;
    gap: 4px;
    align-items: flex-end;
  }

  @media (max-width: 600px) {
    .sidebar-left { display: none; }
  }
</style>

Step 2: Commit

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:

<script lang="ts">
  import { Search } from 'lucide-svelte'
  import { page } from '$app/state'
  import { pinnedFeeds } from '$lib/preferences'
</script>

<aside class="sidebar-right">
  <form action="/search" class="search-wrapper">
    <span class="search-icon"><Search size={16} /></span>
    <input
      type="text"
      name="q"
      class="search-input"
      placeholder="Search..."
    />
  </form>

  <div class="sidebar-card">
    <div class="sidebar-card-header">Feeds</div>
    {#each $pinnedFeeds as feed (feed.id)}
      <a
        href={feed.path}
        class="sidebar-link"
        class:active={page.url.pathname === feed.path}
      >
        {feed.label}
      </a>
    {/each}
  </div>

  <div class="sidebar-footer">Powered by <a href="https://atproto.com">AT Protocol</a></div>
</aside>

<style>
  .sidebar-right {
    position: sticky;
    top: 0;
    height: 100vh;
    overflow-y: auto;
    padding: 12px 16px;
    display: flex;
    flex-direction: column;
    gap: 16px;
  }
  .sidebar-right::-webkit-scrollbar { width: 0; }

  .search-wrapper { position: relative; }
  .search-icon {
    position: absolute;
    left: 12px;
    top: 50%;
    transform: translateY(-50%);
    color: var(--text-faint);
    font-size: 14px;
    pointer-events: none;
    display: flex;
    align-items: center;
  }
  .search-input {
    width: 100%;
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: 20px;
    padding: 9px 16px 9px 36px;
    color: var(--text-primary);
    font-family: var(--font-body);
    font-size: 16px;
    outline: none;
    transition: border-color 0.15s, background 0.15s;
  }
  .search-input::placeholder { color: var(--text-faint); }
  .search-input:focus { border-color: var(--teal); background: var(--bg-root); }

  .sidebar-card {
    background: var(--bg-surface);
    border: 1px solid var(--border);
    border-radius: 14px;
  }
  .sidebar-card-header {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 17px;
    padding: 12px 16px;
  }
  .sidebar-link {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 16px;
    font-size: 14px;
    color: var(--text-secondary);
    cursor: pointer;
    transition: background 0.12s;
    text-decoration: none;
  }
  .sidebar-link:hover { background: var(--bg-hover); color: var(--text-primary); }
  .sidebar-link:last-child { border-radius: 0 0 14px 14px; }
  .sidebar-link.active { color: var(--teal); font-weight: 600; }

  .sidebar-footer {
    padding: 12px 16px;
    font-size: 11px;
    color: var(--text-faint);
    line-height: 1.8;
  }
  .sidebar-footer a { color: var(--text-muted); text-decoration: none; }
  .sidebar-footer a:hover { text-decoration: underline; }

  @media (max-width: 1060px) { .sidebar-right { display: none; } }
</style>

Step 2: Commit

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:

<span class="mobile-logo">teal</span>

to:

<span class="mobile-logo">grain</span>

Step 2: Update MobileBottomBar.svelte

Remove the scrobble button and the ScrobbleModal import. Replace the file with:

<script lang="ts">
  import { Image, Search } from 'lucide-svelte'
  import { goto } from '$app/navigation'
  import { page } from '$app/state'
  import { isAuthenticated, viewer } from '$lib/stores'
  import Avatar from '../atoms/Avatar.svelte'

  let { onSearch }: { onSearch: () => void } = $props()
</script>

<div class="mobile-bottom">
  <button
    class="mobile-tab"
    class:active={page.url.pathname === '/'}
    onclick={() => goto('/')}
  >
    <Image size={22} />
  </button>
  <button class="mobile-tab" onclick={onSearch}>
    <Search size={22} />
  </button>
  {#if $isAuthenticated && $viewer}
    <button
      class="mobile-tab"
      class:active={page.url.pathname.startsWith('/profile/')}
      onclick={() => goto(`/profile/${encodeURIComponent($viewer!.did)}`)}
    >
      <Avatar did={$viewer.did} src={$viewer.avatar} name={$viewer.displayName || $viewer.handle} size={24} />
    </button>
  {/if}
</div>

<style>
  .mobile-bottom {
    display: none;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: var(--bg-root);
    border-top: 1px solid var(--border);
    padding: 0 0 env(safe-area-inset-bottom, 0px);
    z-index: 100;
    justify-content: space-around;
    align-items: center;
    height: calc(50px + env(safe-area-inset-bottom, 0px));
  }
  .mobile-tab {
    background: none;
    border: none;
    color: var(--text-muted);
    font-size: 22px;
    padding: 8px 16px;
    cursor: pointer;
    transition: color 0.12s;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .mobile-tab.active {
    color: var(--teal);
  }

  @media (max-width: 600px) {
    .mobile-bottom {
      display: flex;
      touch-action: none;
    }
  }
</style>

Step 3: Update MobileDrawer.svelte

Replace file — remove trending artists, genres, scrobble references. Change "teal" to "grain":

<script lang="ts">
  import { Image } from 'lucide-svelte'
  import { goto } from '$app/navigation'
  import { isAuthenticated, viewer } from '$lib/stores'
  import Avatar from '../atoms/Avatar.svelte'
  import { pinnedFeeds, resetPreferences } from '$lib/preferences'
  import { logout } from '$lib/auth'
  import LoginModal from './LoginModal.svelte'

  let { open = $bindable(false) }: { open: boolean } = $props()
  let loginOpen = $state(false)

  function nav(path: string) {
    open = false
    goto(path)
  }

  async function doLogout() {
    await logout()
    resetPreferences()
    $isAuthenticated = false
    $viewer = null
    window.location.reload()
  }
</script>

<LoginModal bind:open={loginOpen} />

<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="drawer-overlay" class:open onclick={() => open = false}></div>
<div class="drawer" class:open>
  <div class="drawer-header">
    <button type="button" class="drawer-logo" onclick={() => nav('/')}>grain</button>
  </div>

  {#each $pinnedFeeds as feed (feed.id)}
    <button class="drawer-link" onclick={() => nav(feed.path)}>
      <span class="drawer-link-icon"><Image size={18} /></span> {feed.label}
    </button>
  {/each}

  <div class="drawer-auth">
    {#if $isAuthenticated && $viewer}
      <div class="drawer-auth-info">
        <Avatar did={$viewer.did} src={$viewer.avatar} name={$viewer.displayName || $viewer.handle} size={32} />
        <span class="drawer-handle">{$viewer.handle || $viewer.displayName}</span>
      </div>
      <button class="drawer-btn drawer-btn-ghost" onclick={() => { open = false; doLogout() }}>Sign Out</button>
    {:else}
      <button class="drawer-btn drawer-btn-primary" onclick={() => { open = false; loginOpen = true }}>Sign In</button>
    {/if}
  </div>
</div>

<style>
  .drawer-overlay {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.5);
    z-index: 200;
  }
  .drawer-overlay.open {
    display: block;
  }
  .drawer {
    position: fixed;
    top: 0;
    left: -280px;
    bottom: 0;
    width: 280px;
    background: var(--bg-surface);
    border-right: 1px solid var(--border);
    z-index: 201;
    transition: left 0.25s ease;
    overflow-y: auto;
    padding: 20px 16px;
    display: flex;
    flex-direction: column;
  }
  .drawer.open {
    left: 0;
  }
  .drawer-header {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 24px;
  }
  .drawer-logo {
    font-family: var(--font-display);
    font-weight: 800;
    font-size: 18px;
    color: var(--teal);
    cursor: pointer;
    background: none;
    border: none;
    padding: 0;
  }
  .drawer-link {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 8px;
    border-radius: 8px;
    color: var(--text-secondary);
    font-size: 15px;
    cursor: pointer;
    transition: background 0.12s;
    background: none;
    border: none;
    font-family: var(--font-body);
    width: 100%;
    text-align: left;
  }
  .drawer-link:hover {
    background: var(--bg-hover);
    color: var(--text-primary);
  }
  .drawer-link-icon {
    width: 24px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .drawer-auth {
    margin-top: auto;
    padding-top: 16px;
    border-top: 1px solid var(--border);
  }
  .drawer-auth-info {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 8px 0;
  }
  .drawer-handle {
    font-size: 14px;
    color: var(--text-secondary);
  }
  .drawer-btn {
    width: 100%;
    padding: 10px;
    border-radius: 20px;
    border: none;
    font-family: var(--font-body);
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    margin-top: 8px;
  }
  .drawer-btn-primary {
    background: var(--teal);
    color: #000;
  }
  .drawer-btn-ghost {
    background: var(--bg-elevated);
    color: var(--text-secondary);
    border: 1px solid var(--border);
  }
</style>

Step 4: Commit

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

<script lang="ts">
  import Avatar from '../atoms/Avatar.svelte'
  import { relativeTime } from '$lib/utils'

  let { gallery }: { gallery: any } = $props()

  const displayName = $derived(
    gallery.creator?.displayName || (gallery.creator?.handle ? `@${gallery.creator.handle}` : gallery.creator?.did?.slice(0, 18) + '\u2026')
  )
  const avatarSrc = $derived(gallery.creator?.avatar ?? null)
  const timeStr = $derived(relativeTime(gallery.createdAt || ''))
  const photoCount = $derived(gallery.items?.length ?? 0)
</script>

<div class="gallery-card">
  <div class="gallery-row">
    <a href="/profile/{gallery.creator?.did}" class="avatar-link">
      <Avatar did={gallery.creator?.did ?? ''} src={avatarSrc} size={40} />
    </a>
    <div class="gallery-body">
      <div class="gallery-meta">
        <a href="/profile/{gallery.creator?.did}" class="gallery-author">{displayName}</a>
        <span class="gallery-handle">
          {gallery.creator?.handle ? `@${gallery.creator.handle}` : ''}
        </span>
        <span class="gallery-time">{timeStr}</span>
      </div>
      <div class="gallery-content">
        <div class="gallery-title">{gallery.title}</div>
        {#if gallery.description}
          <div class="gallery-description">{gallery.description}</div>
        {/if}
        {#if photoCount > 0}
          <div class="gallery-photo-count">{photoCount} photo{photoCount !== 1 ? 's' : ''}</div>
        {/if}
      </div>
    </div>
  </div>
</div>

<style>
  .gallery-card {
    padding: 16px;
    border-bottom: 1px solid var(--border);
    transition: background 0.12s;
  }
  .gallery-card:hover { background: var(--bg-surface); }
  .gallery-row { display: flex; gap: 12px; }
  .avatar-link { flex-shrink: 0; text-decoration: none; }
  .gallery-body { flex: 1; min-width: 0; }
  .gallery-meta {
    display: flex;
    align-items: baseline;
    gap: 6px;
    margin-bottom: 6px;
  }
  .gallery-author {
    font-weight: 600;
    font-size: 14px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    text-decoration: none;
    color: inherit;
    cursor: pointer;
  }
  .gallery-author:hover { text-decoration: underline; }
  .gallery-handle {
    font-size: 12px;
    color: var(--text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .gallery-time {
    font-size: 12px;
    color: var(--text-muted);
    margin-left: auto;
    flex-shrink: 0;
  }
  .gallery-content {
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 12px;
  }
  .gallery-title {
    font-weight: 600;
    font-size: 15px;
    margin-bottom: 4px;
  }
  .gallery-description {
    font-size: 13px;
    color: var(--text-secondary);
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    margin-bottom: 4px;
  }
  .gallery-photo-count {
    font-size: 12px;
    color: var(--text-muted);
    margin-top: 6px;
  }
</style>

Step 2: Commit

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:

<script lang="ts">
  import { RefreshCw } from 'lucide-svelte'
  import GalleryCard from '../molecules/GalleryCard.svelte'
  import Skeleton from '../atoms/Skeleton.svelte'
  import { queryFeed } from '$lib/feed'

  let {
    feed,
    params = {},
    initialItems,
    initialCursor,
    skeleton = false,
  }: {
    feed: string
    params?: Record<string, string>
    initialItems?: any[]
    initialCursor?: string
    skeleton?: boolean
  } = $props()

  let items: any[] = $state([])
  let cursor: string | undefined = $state(undefined)
  let loading = $state(true)
  let loadingMore = $state(false)
  let error: string | null = $state(null)

  async function load() {
    loading = true
    error = null
    try {
      const result = await queryFeed(feed, { limit: '50', ...params })
      items = result.items ?? []
      cursor = result.cursor
    } catch (e: any) {
      error = e.message
    } finally {
      loading = false
    }
  }

  async function loadMore() {
    if (!cursor || loadingMore) return
    loadingMore = true
    try {
      const result = await queryFeed(feed, { limit: '50', cursor, ...params })
      items = [...items, ...(result.items ?? [])]
      cursor = result.cursor
    } finally {
      loadingMore = false
    }
  }

  $effect(() => {
    if (skeleton) {
      loading = true
      return
    }
    void feed
    void params
    if (initialItems) {
      items = initialItems
      cursor = initialCursor
      loading = false
    } else {
      load()
    }
  })
</script>

{#if !loading || items.length > 0}
  <div class="feed-status">
    <span>{items.length} gallerie{items.length !== 1 ? 's' : 'y'}{cursor ? '+' : ''}</span>
    <button class="refresh" onclick={() => load()} title="Refresh"><RefreshCw size={14} /></button>
  </div>
{/if}

{#if loading && items.length === 0}
  {#each {length: 4} as _}
    <div class="skeleton-card">
      <div class="skeleton-card-row">
        <Skeleton circle height="40px" />
        <div class="skeleton-card-info">
          <Skeleton width="120px" height="14px" />
          <div style="margin-top:6px"><Skeleton width="80px" height="12px" /></div>
        </div>
      </div>
      <div class="skeleton-card-body">
        <Skeleton width="100%" height="60px" radius="10px" />
      </div>
    </div>
  {/each}
{:else if error && items.length === 0}
  <div class="error-state">{error}</div>
{:else if items.length === 0 && !loading}
  <div class="empty-state">
    <span class="empty-icon">&#128247;</span>
    No galleries found.
  </div>
{:else}
  {#each items as item, i (`${item.uri}:${i}`)}
    <GalleryCard gallery={item} />
  {/each}

  {#if cursor}
    <div class="load-more">
      <button class="load-more-btn" onclick={() => loadMore()} disabled={loadingMore}>
        {loadingMore ? 'Loading\u2026' : 'Load more'}
      </button>
    </div>
  {/if}
{/if}

<style>
  .feed-status {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px 16px;
    font-size: 12px;
    color: var(--text-muted);
    border-bottom: 1px solid var(--border);
  }
  .refresh {
    margin-left: auto;
    cursor: pointer;
    font-size: 14px;
    color: var(--text-muted);
    transition: color 0.15s;
    background: none;
    border: none;
    font-family: inherit;
  }
  .refresh:hover { color: var(--teal); }
  .skeleton-card {
    padding: 12px 16px;
    border-bottom: 1px solid var(--border);
  }
  .skeleton-card-row {
    display: flex; gap: 10px; align-items: center;
  }
  .skeleton-card-info { flex: 1; }
  .skeleton-card-body { padding-left: 50px; margin-top: 8px; }
  .error-state, .empty-state {
    padding: 48px;
    text-align: center;
    color: var(--text-secondary);
  }
  .error-state { color: var(--danger); }
  .empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; }
  .empty-icon { font-size: 32px; opacity: 0.5; }
  .load-more { padding: 16px; text-align: center; }
  .load-more-btn {
    padding: 10px 24px;
    border-radius: 20px;
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    color: var(--text-secondary);
    font-family: var(--font-body);
    font-size: 14px;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.12s;
  }
  .load-more-btn:hover:not(:disabled) {
    background: var(--bg-hover);
    color: var(--text-primary);
  }
  .load-more-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>

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

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:

<script lang="ts">
  import { createQuery } from '@tanstack/svelte-query'
  import FeedList from '$lib/components/organisms/FeedList.svelte'
  import FeedTabs from '$lib/components/molecules/FeedTabs.svelte'
  import { recentFeedQuery } from '$lib/queries'

  const feed = createQuery(() => recentFeedQuery())
</script>

<FeedTabs />
{#if feed.isLoading}
  <FeedList feed="recent" skeleton />
{:else}
  <FeedList feed="recent" initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} />
{/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

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:

<script lang="ts">
  import { page } from '$app/state'

  const did = $derived(decodeURIComponent(page.params.did))
</script>

<div class="profile-placeholder">
  <h2>Profile</h2>
  <p class="profile-did">{did}</p>
  <p class="profile-coming-soon">Full profile coming soon.</p>
</div>

<style>
  .profile-placeholder {
    padding: 48px 24px;
    text-align: center;
  }
  .profile-placeholder h2 {
    font-family: var(--font-display);
    font-size: 24px;
    font-weight: 700;
    margin-bottom: 8px;
  }
  .profile-did {
    font-size: 12px;
    color: var(--text-muted);
    font-family: monospace;
    word-break: break-all;
    margin-bottom: 16px;
  }
  .profile-coming-soon {
    font-size: 14px;
    color: var(--text-secondary);
  }
</style>

Replace app/routes/profile/[did]/+page.ts with:

// Profile page — no data loading for MVP

Step 2: Commit

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

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

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

cd ~/code/hatk-template-grain
npm install

Expected: installs successfully.

Step 2: Generate types from lexicons

npx hatk generate types

Expected: generates hatk.generated.ts and hatk.generated.client.ts with Grain types.

Step 3: Run type check

npx svelte-check

Expected: no type errors (may have warnings).

Step 4: Commit generated types

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

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

git add -A
git commit -m "feat: grain hatk template MVP complete"