# Stories Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add ephemeral 24-hour photo stories with a story strip at the top of the feed and a full-screen viewer. **Architecture:** New `social.grain.story` record type stores a single photo with optional location. Two XRPC queries serve the story strip (authors with active stories) and viewer (a user's stories). Client adds a story strip component above the feed, a full-screen overlay viewer with progress bars, and a simple create modal. **Tech Stack:** Svelte 5, TanStack Query, hatk framework (defineQuery, callXrpc, processPhotos), Lucide icons. --- ### Task 1: Story Record Lexicon **Files:** - Create: `lexicons/social/grain/story/story.json` **Step 1: Create the lexicon** ```json { "lexicon": 1, "id": "social.grain.story", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "required": ["media", "aspectRatio", "createdAt"], "properties": { "media": { "type": "blob", "accept": ["image/*", "video/*"], "maxSize": 5000000 }, "aspectRatio": { "type": "ref", "ref": "social.grain.defs#aspectRatio" }, "location": { "type": "ref", "ref": "social.grain.defs#location" }, "createdAt": { "type": "string", "format": "datetime" } } } } } } ``` --- ### Task 2: Story View Defs Lexicon **Files:** - Create: `lexicons/social/grain/story/defs.json` **Step 1: Create the defs lexicon** ```json { "lexicon": 1, "id": "social.grain.story.defs", "defs": { "storyView": { "type": "object", "required": ["uri", "cid", "creator", "thumb", "fullsize", "aspectRatio", "createdAt"], "properties": { "uri": { "type": "string", "format": "at-uri" }, "cid": { "type": "string", "format": "cid" }, "creator": { "type": "ref", "ref": "social.grain.actor.defs#profileView" }, "thumb": { "type": "string", "format": "uri", "description": "Thumbnail URL for the story image." }, "fullsize": { "type": "string", "format": "uri", "description": "Full-size URL for the story image." }, "aspectRatio": { "type": "ref", "ref": "social.grain.defs#aspectRatio" }, "location": { "type": "ref", "ref": "social.grain.defs#location" }, "createdAt": { "type": "string", "format": "datetime" } } } } } ``` --- ### Task 3: XRPC Lexicons for Story Queries **Files:** - Create: `lexicons/social/grain/unspecced/getStoryAuthors.json` - Create: `lexicons/social/grain/unspecced/getStories.json` **Step 1: Create getStoryAuthors lexicon** ```json { "lexicon": 1, "id": "social.grain.unspecced.getStoryAuthors", "defs": { "main": { "type": "query", "description": "Get authors who have active stories (posted within the last 24 hours).", "output": { "encoding": "application/json", "schema": { "type": "object", "properties": { "authors": { "type": "array", "items": { "type": "ref", "ref": "social.grain.unspecced.getStoryAuthors#storyAuthor" } } } } } }, "storyAuthor": { "type": "object", "required": ["profile", "storyCount", "latestAt"], "properties": { "profile": { "type": "ref", "ref": "social.grain.actor.defs#profileView" }, "storyCount": { "type": "integer" }, "latestAt": { "type": "string", "format": "datetime" } } } } } ``` **Step 2: Create getStories lexicon** ```json { "lexicon": 1, "id": "social.grain.unspecced.getStories", "defs": { "main": { "type": "query", "description": "Get a user's active stories (posted within the last 24 hours).", "parameters": { "type": "params", "required": ["actor"], "properties": { "actor": { "type": "string", "format": "did" } } }, "output": { "encoding": "application/json", "schema": { "type": "object", "properties": { "stories": { "type": "array", "items": { "type": "ref", "ref": "social.grain.story.defs#storyView" } } } } } } } } ``` --- ### Task 4: Register Story Collection **Files:** - Modify: `hatk.config.ts` **Step 1: Add story scope** In `hatk.config.ts`, add `"repo:social.grain.story"` to the `grainScopes` array: ```typescript const grainScopes = [ "atproto", "blob:image/*", "repo:social.grain.gallery", "repo:social.grain.gallery.item", "repo:social.grain.photo", "repo:social.grain.photo.exif", "repo:social.grain.actor.profile", "repo:social.grain.graph.follow", "repo:social.grain.favorite", "repo:social.grain.comment", "repo:social.grain.story", ].join(" "); ``` **Step 2: Regenerate types** Run: `npx hatk generate` This updates `hatk.generated.ts` and `hatk.generated.client.ts` with the new story lexicons. --- ### Task 5: Server — getStoryAuthors XRPC Query **Files:** - Create: `server/xrpc/getStoryAuthors.ts` **Step 1: Implement the query** ```typescript import { defineQuery } from "$hatk"; import { views } from "$hatk"; import type { GrainActorProfile } from "$hatk"; const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; export default defineQuery("social.grain.unspecced.getStoryAuthors", async (ctx) => { const { db, ok } = ctx; const cutoff = new Date(Date.now() - TWENTY_FOUR_HOURS).toISOString(); // Get authors with active stories, ordered by most recent const rows = (await db.query( `SELECT did, COUNT(*) AS story_count, MAX(created_at) AS latest_at FROM "social.grain.story" WHERE created_at > $1 GROUP BY did ORDER BY latest_at DESC`, [cutoff], )) as { did: string; story_count: number; latest_at: string }[]; const dids = rows.map((r) => r.did); const profiles = await ctx.lookup("social.grain.actor.profile", "did", dids); const authors = rows.map((row) => { const author = profiles.get(row.did); return { profile: author ? views.grainActorDefsProfileView({ cid: author.cid, did: author.did, handle: author.handle ?? author.did, displayName: author.value.displayName, avatar: ctx.blobUrl(author.did, author.value.avatar) ?? undefined, }) : views.grainActorDefsProfileView({ cid: "", did: row.did, handle: row.did, }), storyCount: row.story_count, latestAt: row.latest_at, }; }); return ok({ authors }); }); ``` --- ### Task 6: Server — getStories XRPC Query **Files:** - Create: `server/xrpc/getStories.ts` **Step 1: Implement the query** ```typescript import { defineQuery } from "$hatk"; import { views } from "$hatk"; import type { GrainActorProfile } from "$hatk"; const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; export default defineQuery("social.grain.unspecced.getStories", async (ctx) => { const { db, ok } = ctx; const actor = ctx.params.actor; if (!actor) return ok({ stories: [] }); const cutoff = new Date(Date.now() - TWENTY_FOUR_HOURS).toISOString(); const rows = (await db.query( `SELECT uri, cid, did, media, aspect_ratio_width, aspect_ratio_height, location, created_at FROM "social.grain.story" WHERE did = $1 AND created_at > $2 ORDER BY created_at ASC`, [actor, cutoff], )) as { uri: string; cid: string; did: string; media: string; aspect_ratio_width: number; aspect_ratio_height: number; location: string | null; created_at: string; }[]; // Resolve author profile const profiles = await ctx.lookup("social.grain.actor.profile", "did", [ actor, ]); const author = profiles.get(actor); const profileView = author ? views.grainActorDefsProfileView({ cid: author.cid, did: author.did, handle: author.handle ?? author.did, displayName: author.value.displayName, avatar: ctx.blobUrl(author.did, author.value.avatar) ?? undefined, }) : views.grainActorDefsProfileView({ cid: "", did: actor, handle: actor, }); const stories = rows.map((row) => { // hatk stores blobs as JSON — parse to get the blob ref for URL generation let blobRef: any; try { blobRef = typeof row.media === "string" ? JSON.parse(row.media) : row.media; } catch { blobRef = row.media; } const location = row.location ? JSON.parse(row.location) : null; return { uri: row.uri, cid: row.cid, creator: profileView, thumb: ctx.blobUrl(row.did, blobRef, "feed_thumbnail") ?? "", fullsize: ctx.blobUrl(row.did, blobRef, "feed_fullsize") ?? "", aspectRatio: { width: row.aspect_ratio_width ?? 4, height: row.aspect_ratio_height ?? 3, }, ...(location ? { location: { name: location.name, value: location.value, ...(location.region ? { region: location.region } : {}), }, } : {}), createdAt: row.created_at, }; }); return ok({ stories }); }); ``` **Important note about hatk column naming:** hatk stores nested objects (like `aspectRatio`) as expanded columns using snake_case. So `aspectRatio: { width, height }` becomes columns `aspect_ratio_width` and `aspect_ratio_height`. Blob fields are stored as JSON strings. Location is stored as a single JSON column. If the exact column names differ from what's here, check with `SELECT * FROM "social.grain.story" LIMIT 1` after creating a test record. --- ### Task 7: Client Queries **Files:** - Modify: `app/lib/queries.ts` **Step 1: Add story queries** Add these to `app/lib/queries.ts`: ```typescript export const storyAuthorsQuery = (f?: Fetch) => queryOptions({ queryKey: ["storyAuthors"], queryFn: () => callXrpc("social.grain.unspecced.getStoryAuthors", undefined, f).then( (r) => r?.authors ?? [], ), staleTime: 60_000, }); export const storiesQuery = (did: string, f?: Fetch) => queryOptions({ queryKey: ["stories", did], queryFn: () => callXrpc("social.grain.unspecced.getStories", { actor: did }, f).then( (r) => r?.stories ?? [], ), staleTime: 30_000, }); ``` --- ### Task 8: StoryStrip Component **Files:** - Create: `app/lib/components/molecules/StoryStrip.svelte` **Step 1: Create the component** This is the horizontal scrollable row of avatar circles at the top of the feed. The first circle is "+" for creating your own story (only shown when authenticated). ```svelte {#if $isAuthenticated || (authors.data && authors.data.length > 0)}
{#if $isAuthenticated} {/if} {#if authors.data} {#each authors.data as author (author.profile.did)} {/each} {/if}
{/if} ``` --- ### Task 9: StoryViewer Component **Files:** - Create: `app/lib/components/organisms/StoryViewer.svelte` **Step 1: Create the full-screen story viewer** This is the most complex component — full-screen overlay with progress bars, auto-advance, and tap navigation. ```svelte
{#each Array(totalStories) as _, i}
{/each}
{#if currentStory}
{#if currentStory.creator.avatar} {/if} {currentStory.creator.displayName ?? currentStory.creator.handle} {timeAgo(currentStory.createdAt)}
{#if currentStory.location}
{currentStory.location.name}
{/if} {/if}
``` --- ### Task 10: StoryCreate Component **Files:** - Create: `app/lib/components/molecules/StoryCreate.svelte` **Step 1: Create the story creation modal** Minimal create flow: file picker → optional location → post. Opens as a modal overlay. ```svelte
New Story {#if photo} {:else}
{/if}
{#if error}

{error}

{/if} {#if !photo}
{:else}
Story preview
{/if}
``` --- ### Task 11: Wire Up Story Components in Home Feed **Files:** - Modify: `app/routes/+page.svelte` **Step 1: Add story strip and viewer to the home page** ```svelte {#if feed.isLoading} {:else} {/if} {#if showViewer} {/if} {#if showCreate} {/if} ``` --- ### Task 12: Add Seed Data **Files:** - Modify: `seeds/seed.ts` **Step 1: Add story records to seed data** Add after the existing gallery seed data, before the end of the seed function. Create a couple of stories for Alice and Bob: ```typescript // ── Stories ── await createRecord( alice, "social.grain.story", { media: cityNight, // reuse existing blob aspectRatio: { width: 4, height: 3 }, location: { name: "Shibuya Crossing", value: "8a2f5a363ba7fff", region: "Tokyo, Japan" }, createdAt: new Date().toISOString(), // must be within 24h to be active }, { rkey: "story-alice-1" }, ); await createRecord( bob, "social.grain.story", { media: forest, // reuse existing blob aspectRatio: { width: 4, height: 3 }, createdAt: new Date().toISOString(), }, { rkey: "story-bob-1" }, ); ``` **Note:** Stories use `new Date().toISOString()` (current time) rather than `ago()` so they appear as active (within 24h) when seeded. --- ## Execution Order 1. **Tasks 1–3**: Lexicons (schema foundation) 2. **Task 4**: Config + regenerate types 3. **Tasks 5–6**: Server queries 4. **Task 7**: Client queries 5. **Tasks 8–10**: UI components (strip, viewer, create) 6. **Task 11**: Wire into home page 7. **Task 12**: Seed data for testing