# Twitter Lite Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > Subsequent extension: tweet detail and conversation loading are implemented > by `2026-07-13-tweet-detail-thread.md` and specified by > `../specs/2026-07-13-tweet-detail-thread-design.md`. **Goal:** Build a localhost-only TanStack Start reader that loads user timelines and Top/Latest searches through a read-only Bird server boundary. **Architecture:** First extend the sibling Bird repository with a typed search product option. Then build Twitter Lite as a TanStack Start application whose server functions call Bird, while TanStack Query owns cursor pagination and the browser renders the Mist Instrument interface. **Tech Stack:** Node.js 22, pnpm 11, Nix, TypeScript, React 19, TanStack Start/Router/Query, Zod 4, Nitro, Vitest, Testing Library, Playwright, Biome, hand-written CSS. ## Global Constraints - Bind development and production to `127.0.0.1` by default; allow an explicit host override for Tailscale testing. - Read `TWITTER_RELAY_BASE_URL` only on the server; pass `BIRD_PROFILE_NAME` explicitly to Bird. - Do not expose a generic relay endpoint or any Bird mutation method. - Show no posts until a handle, profile URL, or search query is submitted. - Support only `Top` and `Latest` search products; `Latest` remains Bird's default. - Keep home, recommendations, trends, notifications, saved history, autocomplete, and all write actions out of scope. - Load 20 posts per cursor page and continue automatically without a fixed page limit. - Use Mist Instrument tokens and no remote fonts; respect keyboard focus and `prefers-reduced-motion`. - Work test-first for every behavior change and run the pre-commit documentation check in both repositories. - Consume the separately authorized and published `@yuta/bird@0.10.0`; do not modify Bird from the app repository. ## File Map ### Bird repository (`../bird`) - `src/lib/twitter-client-search.ts` — typed Top/Latest contract and GraphQL variable. - `src/lib/index.ts` — public type exports. - `src/commands/search.ts` — CLI `--product` option. - `tests/twitter-client.search-bookmarks.test.ts` — request and pagination behavior. - `tests/commands.search.test.ts` — CLI forwarding behavior. - `tests/library-exports.test.ts` — public type availability. - `README.md` — library and CLI usage. ### Twitter Lite repository - `flake.nix` / `flake.lock` — Node, pnpm, and Chromium development environment. - `package.json` / `pnpm-lock.yaml` / `pnpm-workspace.yaml` — pinned toolchain and scripts. - `vite.config.ts` / `tsconfig.json` / `vitest.config.ts` / `playwright.config.ts` / `biome.json` — build and test configuration. - `src/router.tsx` / `src/routes/*.tsx` — TanStack Router setup and User/Search routes. - `src/features/posts/inputs.ts` — handle, URL, and raw-query normalization. - `src/features/posts/types.ts` — application page/result/error contracts. - `src/features/posts/page.ts` — page flattening and ID deduplication. - `src/features/posts/post-service.ts` — testable BirdReader adapter. - `src/features/posts/bird-client.server.ts` — the only runtime import of `@yuta/bird`. - `src/features/posts/server-functions.ts` — validated TanStack Start RPC boundary. - `src/features/posts/use-post-feed.ts` — infinite-query configuration. - `src/features/posts/components/*.tsx` — forms, feed states, post cards, and media. - `src/styles.css` — Mist Instrument tokens and responsive styling. - `tests/e2e/*` — deterministic mock relay and browser flow. - `tests/live/relay.test.ts` — opt-in read-only relay smoke test. - `README.md` — setup, commands, safety boundary, and feature scope. --- ### Task 1: Extend Bird with Top/Latest search products **Files:** - Clone: `../bird` from `https://git.yutakobayashi.com/yuta/bird` - Modify: `../bird/src/lib/twitter-client-search.ts` - Modify: `../bird/src/lib/index.ts` - Modify: `../bird/src/commands/search.ts` - Modify: `../bird/tests/twitter-client.search-bookmarks.test.ts` - Modify: `../bird/tests/commands.search.test.ts` - Modify: `../bird/tests/library-exports.test.ts` - Modify: `../bird/README.md` - Modify: `../bird/CHANGELOG.md` **Interfaces:** - Produces: `SearchProduct = 'Top' | 'Latest'`. - Produces: `SearchFetchOptions.product?: SearchProduct`. - Produces: `TwitterClient.search(query, count, { product })` and `getAllSearchResults(query, { product })`. - Produces: `bird search --product `. - [ ] **Step 1: Clone Bird and verify the baseline** Run: ```bash git clone https://git.yutakobayashi.com/yuta/bird ../bird cd ../bird nix develop -c pnpm install --frozen-lockfile nix develop -c pnpm exec vitest run \ tests/twitter-client.search-bookmarks.test.ts \ tests/commands.search.test.ts \ tests/library-exports.test.ts ``` Expected: the checkout is at or after `0caf2677` and the existing focused tests pass. - [ ] **Step 2: Add failing library tests** In `tests/twitter-client.search-bookmarks.test.ts`, extend `retries on 404 and posts search payload` so the parsed request type and assertion are: ```ts const parsed = JSON.parse(urlVars as string) as { rawQuery?: string product?: string } expect(parsed.rawQuery).toBe('needle') expect(parsed.product).toBe('Latest') ``` Change `paginates search results using the bottom cursor` to call and assert: ```ts const result = await client.search('needle', 3, { product: 'Top' }) const firstVars = JSON.parse( new URL(mockFetch.mock.calls[0][0] as string).searchParams.get( 'variables', ) as string, ) as { cursor?: string; product?: string } const secondVars = JSON.parse( new URL(mockFetch.mock.calls[1][0] as string).searchParams.get( 'variables', ) as string, ) as { cursor?: string; product?: string } expect(firstVars.product).toBe('Top') expect(secondVars.product).toBe('Top') ``` - [ ] **Step 3: Run the library tests and verify RED** Run: ```bash nix develop -c pnpm exec vitest run tests/twitter-client.search-bookmarks.test.ts ``` Expected: TypeScript reports that `product` is not part of `SearchFetchOptions`, or the Top assertion receives `Latest`. - [ ] **Step 4: Implement the minimal library contract** In `src/lib/twitter-client-search.ts`, replace the search option definitions with: ```ts export type SearchProduct = 'Top' | 'Latest' /** Options for search methods */ export interface SearchFetchOptions { /** Include raw GraphQL response in `_raw` field */ includeRaw?: boolean /** Search result ranking (default: Latest) */ product?: SearchProduct } /** Options for paged search methods */ export interface SearchPaginationOptions extends SearchFetchOptions { maxPages?: number /** Starting cursor for pagination (resume from previous fetch) */ cursor?: string } ``` In `searchPaged()`, destructure once: ```ts const { includeRaw = false, maxPages, product = 'Latest' } = options ``` Use the captured value in every request: ```ts const variables = { rawQuery: query, count: pageCount, querySource: 'typed_query', product, ...(pageCursor ? { cursor: pageCursor } : {}), } ``` In `src/lib/index.ts`, export the public types: ```ts export type { SearchFetchOptions, SearchPaginationOptions, SearchProduct, } from './twitter-client-search.js' ``` - [ ] **Step 5: Run the library tests and verify GREEN** Run: ```bash nix develop -c pnpm exec vitest run tests/twitter-client.search-bookmarks.test.ts ``` Expected: every search/bookmark test passes. - [ ] **Step 6: Add failing CLI and export tests** Add to `tests/commands.search.test.ts`: ```ts it('passes --product to non-paged search', async () => { registerSearchCommands(program, mockContext as CliContext) const searchSpy = vi .spyOn(TwitterClient.prototype, 'search') .mockResolvedValue({ success: true, tweets: [] }) try { await program.parseAsync([ 'node', 'bird', 'search', 'cats', '--product', 'Top', ]) expect(searchSpy).toHaveBeenCalledWith('cats', 10, { includeRaw: false, product: 'Top', }) } finally { searchSpy.mockRestore() } }) ``` Update the existing paged-search test to pass `--product Top` and require `product: 'Top'` in the expected options. Add explicit invalid-choice coverage: ```ts it('rejects unsupported search products', async () => { registerSearchCommands(program, mockContext as CliContext) program.exitOverride() await expect( program.parseAsync([ 'node', 'bird', 'search', 'cats', '--product', 'Media', ]), ).rejects.toMatchObject({ code: 'commander.invalidArgument' }) }) ``` In `tests/library-exports.test.ts`, import and exercise the types: ```ts import { type SearchFetchOptions, type SearchProduct, TwitterClient, } from '../src/index.js' it('exposes search product types', () => { const product: SearchProduct = 'Top' const options: SearchFetchOptions = { product } expect(options.product).toBe('Top') }) ``` - [ ] **Step 7: Run the CLI/export tests and verify RED** Run: ```bash nix develop -c pnpm exec vitest run \ tests/commands.search.test.ts \ tests/library-exports.test.ts ``` Expected: the CLI rejects or ignores `--product` before the command implementation changes. - [ ] **Step 8: Implement the CLI option** In `src/commands/search.ts`, use Commander's choice validation: ```ts import { type Command, Option } from 'commander' import type { SearchProduct } from '../lib/twitter-client-search.js' ``` Register the option after `--cursor`: ```ts .addOption( new Option('--product ', 'Search product') .choices(['Top', 'Latest'] satisfies SearchProduct[]) .default('Latest'), ) ``` Add `product?: SearchProduct` to the search command option type, then construct: ```ts const includeRaw = cmdOpts.jsonFull ?? false const product = cmdOpts.product ?? 'Latest' const searchOptions = { includeRaw, product } const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor, product, } ``` Leave `mentions` unchanged so it continues using Bird's Latest default. - [ ] **Step 9: Update Bird documentation and verify the repository** Add these concrete examples to `README.md`: ```md bird search "AI lang:ja" --product Top -n 20 bird search "from:steipete" --product Latest -n 20 const popular = await client.search('AI lang:ja', 20, { product: 'Top', }) ``` Document that Latest is the default and add `[--product Top|Latest]` to the command synopsis. Add this new section above the released entries in `CHANGELOG.md`: ```md ## Unreleased ### Added - Add Top and Latest product selection to library and CLI search. ``` Run: ```bash nix develop -c pnpm exec vitest run \ tests/twitter-client.search-bookmarks.test.ts \ tests/commands.search.test.ts \ tests/library-exports.test.ts nix develop -c pnpm run build:dist nix develop -c pnpm run lint nix develop -c pnpm test git diff --check ``` Expected: focused tests, all 416+ non-live tests, build, and lint pass with no diff whitespace errors. - [ ] **Step 10: Commit Bird** ```bash git add \ src/lib/twitter-client-search.ts \ src/lib/index.ts \ src/commands/search.ts \ tests/twitter-client.search-bookmarks.test.ts \ tests/commands.search.test.ts \ tests/library-exports.test.ts \ README.md CHANGELOG.md git commit -m "feat: support ranked tweet search" ``` ### Task 2: Create the reproducible TanStack Start foundation **Files:** - Create: `flake.nix` - Generate: `flake.lock` - Create: `package.json` - Generate: `pnpm-lock.yaml` - Create: `pnpm-workspace.yaml` - Create: `tsconfig.json` - Create: `vite.config.ts` - Create: `vitest.config.ts` - Create: `biome.json` - Create: `src/router.tsx` - Create: `src/routes/__root.tsx` - Create: `src/routes/index.tsx` - Create: `src/routes/index.test.tsx` - Create: `src/styles.css` - Create: `tests/setup.ts` **Interfaces:** - Consumes: published `@yuta/bird@0.10.0` through Gitea Packages. - Produces: `getRouter()` with an SSR-aware QueryClient. - Produces: a buildable TanStack Start/Nitro application at `http://127.0.0.1:3000`. - [ ] **Step 1: Add the Nix development shell** Create `flake.nix`: ```nix { description = "Intentional X reader"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; outputs = { nixpkgs, ... }: let systems = [ "x86_64-linux" "aarch64-linux" ]; forAllSystems = nixpkgs.lib.genAttrs systems; in { devShells = forAllSystems (system: let pkgs = import nixpkgs { inherit system; }; in { default = pkgs.mkShell { packages = with pkgs; [ nodejs_22 pnpm chromium ]; PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; PLAYWRIGHT_CHROMIUM_EXECUTABLE = "${pkgs.chromium}/bin/chromium"; }; }); }; } ``` Run `nix flake lock` to generate `flake.lock`. - [ ] **Step 2: Add pinned package and tool configuration** Create `.npmrc`: ```ini @yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/ ``` Create `package.json`: ```json { "name": "twitter-lite", "private": true, "type": "module", "packageManager": "pnpm@11.9.0", "engines": { "node": ">=22.12.0" }, "imports": { "#/*": "./src/*" }, "scripts": { "dev": "vite dev --host 127.0.0.1 --port 3000", "dev:tailscale": "vite dev --host 0.0.0.0 --port 3000", "generate-routes": "tsr generate", "build": "vite build", "start": "HOST=127.0.0.1 node .output/server/index.mjs", "typecheck": "tsc --noEmit", "lint": "biome check .", "format": "biome check --write .", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", "test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts" }, "dependencies": { "@tanstack/react-query": "5.101.2", "@tanstack/react-router": "1.170.17", "@tanstack/react-router-ssr-query": "1.167.1", "@tanstack/react-start": "1.168.27", "@yuta/bird": "0.10.0", "nitro": "3.0.260610-beta", "react": "19.2.7", "react-dom": "19.2.7", "zod": "4.4.3" }, "devDependencies": { "@biomejs/biome": "2.5.3", "@playwright/test": "1.61.1", "@tanstack/router-cli": "1.167.18", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@types/node": "26.1.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.3", "jsdom": "29.1.1", "typescript": "7.0.2", "vite": "8.1.4", "vitest": "4.1.10" } } ``` Create `pnpm-workspace.yaml`: ```yaml allowBuilds: esbuild: true ``` Create `tsconfig.json`: ```json { "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"], "compilerOptions": { "target": "ES2022", "jsx": "react-jsx", "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client", "node"], "paths": { "#/*": ["./src/*"] }, "allowImportingTsExtensions": true, "noEmit": true, "skipLibCheck": true, "strict": true, "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true } } ``` Create `vite.config.ts`: ```ts import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' import { defineConfig } from 'vite' import { nitro } from 'nitro/vite' export default defineConfig({ plugins: [ nitro(), tanstackStart(), viteReact(), ], }) ``` Create `vitest.config.ts`: ```ts import path from 'node:path' import { defineConfig } from 'vitest/config' export default defineConfig({ resolve: { alias: { '#': path.resolve(import.meta.dirname, 'src') } }, test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], exclude: ['tests/e2e/**'], }, }) ``` Create `tests/setup.ts`: ```ts import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' import { afterEach } from 'vitest' afterEach(cleanup) ``` Create `biome.json`: ```json { "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", "files": { "includes": ["**", "!src/routeTree.gen.ts"] }, "formatter": { "enabled": true, "indentStyle": "space" }, "linter": { "enabled": true, "rules": { "recommended": true } }, "javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } } } ``` - [ ] **Step 3: Install dependencies and generate the route tree** Run: ```bash nix develop -c pnpm install nix develop -c pnpm generate-routes ``` Expected: `pnpm-lock.yaml` and `src/routeTree.gen.ts` are generated without ignored-build warnings. - [ ] **Step 4: Write the failing foundation test** Create `src/routes/index.test.tsx`: ```tsx import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { Home } from './index' describe('Home', () => { it('starts without ambient post content', () => { render() expect(screen.getByText('目的を決めてから開く')).toBeInTheDocument() expect(screen.queryByRole('article')).not.toBeInTheDocument() }) }) ``` Run: ```bash nix develop -c pnpm test -- src/routes/index.test.tsx ``` Expected: FAIL because `Home` and its intentional-reading copy do not exist. - [ ] **Step 5: Add the minimal router and root document** Create `src/router.tsx`: ```tsx import { QueryClient } from '@tanstack/react-query' import { createRouter } from '@tanstack/react-router' import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' import { routeTree } from './routeTree.gen' export function getRouter() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 }, }, }) const router = createRouter({ routeTree, context: { queryClient }, scrollRestoration: true, defaultPreload: false, }) setupRouterSsrQueryIntegration({ router, queryClient }) return router } declare module '@tanstack/react-router' { interface Register { router: ReturnType } } ``` Create `src/routes/__root.tsx`: ```tsx import type { QueryClient } from '@tanstack/react-query' import { HeadContent, Scripts, createRootRouteWithContext, } from '@tanstack/react-router' import appCss from '../styles.css?url' type RouterContext = { queryClient: QueryClient } export const Route = createRootRouteWithContext()({ head: () => ({ meta: [ { charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1', }, { title: 'Twitter Lite' }, ], links: [{ rel: 'stylesheet', href: appCss }], }), shellComponent: RootDocument, }) function RootDocument({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` Create `src/routes/index.tsx`: ```tsx import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/')({ component: Home }) export function Home() { return (

TWITTER LITE

目的を決めてから開く

ユーザー名か検索語を入力するまで、投稿は表示しません。

) } ``` Create `src/styles.css`: ```css :root { color: #1e3238; background: #dfe7e9; font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif; } * { box-sizing: border-box; } body { min-width: 320px; min-height: 100vh; margin: 0; } button, input { font: inherit; } .landing { width: min(42rem, calc(100% - 2rem)); margin: 15vh auto; padding: 2rem; border: 1px solid #c1cfd2; border-radius: 1rem; background: #f8faf9; } .brand { color: #70858a; font-size: 0.7rem; letter-spacing: 0.2em; } ``` - [ ] **Step 6: Verify and commit the foundation** Run: ```bash nix develop -c pnpm generate-routes nix develop -c pnpm test -- src/routes/index.test.tsx nix develop -c pnpm typecheck nix develop -c pnpm lint nix develop -c pnpm build git diff --check ``` Expected: the route test, typecheck, lint, and Nitro build pass. Commit: ```bash git add \ flake.nix flake.lock package.json pnpm-lock.yaml pnpm-workspace.yaml \ tsconfig.json vite.config.ts vitest.config.ts biome.json \ src/router.tsx src/routeTree.gen.ts src/routes src/styles.css tests/setup.ts git commit -m "chore: scaffold twitter lite" ``` ### Task 3: Define validated inputs and page merging **Files:** - Create: `src/features/posts/types.ts` - Create: `src/features/posts/inputs.ts` - Create: `src/features/posts/inputs.test.ts` - Create: `src/features/posts/page.ts` - Create: `src/features/posts/page.test.ts` **Interfaces:** - Produces: `normalizeUserTarget(value: string): string`. - Produces: `buildSearchQuery(value: string, following: boolean): string`. - Produces: `userPageInputSchema` and `searchPageInputSchema`. - Produces: `flattenPostPages(pages: PostPage[]): Post[]`. - [ ] **Step 1: Write failing input tests** Create `src/features/posts/inputs.test.ts`: ```ts import { describe, expect, it } from 'vitest' import { buildSearchQuery, normalizeUserTarget } from './inputs' describe('normalizeUserTarget', () => { it.each([ ['@tan_stack', 'tan_stack'], ['tan_stack', 'tan_stack'], ['https://x.com/tan_stack', 'tan_stack'], ['https://twitter.com/tan_stack/', 'tan_stack'], ])('normalizes %s', (input, expected) => { expect(normalizeUserTarget(input)).toBe(expected) }) it.each([ '', 'not valid', 'https://example.com/tan_stack', 'https://x.com/tan_stack/status/1', ])('rejects %s', (input) => { expect(() => normalizeUserTarget(input)).toThrow() }) }) describe('buildSearchQuery', () => { it('keeps a deliberate query unchanged', () => { expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja') }) it('adds the follows operator once', () => { expect(buildSearchQuery('AI lang:ja', true)).toBe( 'AI lang:ja filter:follows', ) expect(buildSearchQuery('AI filter:follows', true)).toBe( 'AI filter:follows', ) }) it('rejects an empty query', () => { expect(() => buildSearchQuery(' ', false)).toThrow() }) }) ``` - [ ] **Step 2: Run the input tests and verify RED** Run: ```bash nix develop -c pnpm test -- src/features/posts/inputs.test.ts ``` Expected: FAIL because `./inputs` does not exist. - [ ] **Step 3: Implement shared contracts and input validation** Create `src/features/posts/types.ts`: ```ts import type { SearchProduct, TweetData } from '@yuta/bird' export type Post = TweetData export type PostPage = { tweets: Post[] nextCursor?: string } export type LoadErrorCode = | 'invalid-input' | 'user-not-found' | 'user-unavailable' | 'relay-config' | 'timeout' | 'upstream' export type LoadError = { code: LoadErrorCode message: string retryable: boolean } export type LoadResult = | { ok: true; page: PostPage } | { ok: false; error: LoadError } export type UserPageInput = { target: string cursor?: string } export type SearchPageInput = { query: string product: SearchProduct following: boolean cursor?: string } ``` Create `src/features/posts/inputs.ts`: ```ts import { z } from 'zod' const HANDLE = /^[A-Za-z0-9_]{1,15}$/ const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i export class InputError extends Error {} export const userPageInputSchema = z.object({ target: z.string().trim().min(1).max(256), cursor: z.string().min(1).optional(), }) export const searchPageInputSchema = z.object({ query: z.string().trim().min(1).max(512), product: z.enum(['Top', 'Latest']), following: z.boolean(), cursor: z.string().min(1).optional(), }) export const userRouteSearchSchema = z.object({ target: z.string().catch(''), }) export const postSearchRouteSchema = z.object({ q: z.string().catch(''), product: z.enum(['Top', 'Latest']).catch('Latest'), following: z.boolean().catch(false), }) function requireHandle(value: string): string { if (!HANDLE.test(value)) { throw new InputError('ハンドルは英数字とアンダースコアで入力してください。') } return value } export function normalizeUserTarget(raw: string): string { const value = raw.trim() if (!value) { throw new InputError('ハンドルまたはプロフィール URL を入力してください。') } if (value.startsWith('@')) { return requireHandle(value.slice(1)) } if (!value.includes('://')) { return requireHandle(value) } let url: URL try { url = new URL(value) } catch { throw new InputError('プロフィール URL の形式を確認してください。') } if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) { throw new InputError('x.com または twitter.com の URL を入力してください。') } const segments = url.pathname.split('/').filter(Boolean) if (segments.length !== 1) { throw new InputError('プロフィール URL を入力してください。') } return requireHandle(segments[0] ?? '') } export function buildSearchQuery(raw: string, following: boolean): string { const query = raw.trim() if (!query) { throw new InputError('検索語を入力してください。') } if (query.length > 512) { throw new InputError('検索語は 512 文字以内で入力してください。') } return following && !FOLLOWS.test(query) ? `${query} filter:follows` : query } ``` - [ ] **Step 4: Run the input tests and verify GREEN** Run: ```bash nix develop -c pnpm test -- src/features/posts/inputs.test.ts ``` Expected: all normalization and query-building tests pass. - [ ] **Step 5: Write failing page-merging tests** Create `src/features/posts/page.test.ts`: ```ts import { describe, expect, it } from 'vitest' import { flattenPostPages } from './page' import type { Post, PostPage } from './types' const post = (id: string): Post => ({ id, text: `post-${id}`, author: { username: `user-${id}`, name: `User ${id}` }, }) describe('flattenPostPages', () => { it('preserves API order and removes duplicate IDs', () => { const pages: PostPage[] = [ { tweets: [post('1'), post('2')], nextCursor: 'next' }, { tweets: [post('2'), post('3')] }, ] expect(flattenPostPages(pages).map(({ id }) => id)).toEqual([ '1', '2', '3', ]) }) }) ``` Run: ```bash nix develop -c pnpm test -- src/features/posts/page.test.ts ``` Expected: FAIL because `flattenPostPages` does not exist. - [ ] **Step 6: Implement page merging and verify** Create `src/features/posts/page.ts`: ```ts import type { Post, PostPage } from './types' export function flattenPostPages(pages: PostPage[]): Post[] { const seen = new Set() return pages.flatMap(({ tweets }) => tweets.filter(({ id }) => { if (seen.has(id)) return false seen.add(id) return true }), ) } ``` Run: ```bash nix develop -c pnpm test -- \ src/features/posts/inputs.test.ts \ src/features/posts/page.test.ts nix develop -c pnpm typecheck ``` Expected: both suites and typecheck pass. - [ ] **Step 7: Commit the domain layer** ```bash git add src/features/posts git commit -m "feat: validate intentional post requests" ``` ### Task 4: Add the read-only Bird server boundary **Files:** - Create: `src/features/posts/post-service.ts` - Create: `src/features/posts/post-service.test.ts` - Create: `src/features/posts/bird-client.server.ts` - Create: `src/features/posts/server-functions.ts` **Interfaces:** - Consumes: `normalizeUserTarget`, `buildSearchQuery`, and Bird's read methods. - Produces: `loadUserPage(reader, input): Promise`. - Produces: `searchPage(reader, input): Promise`. - Produces: TanStack Start server functions `loadUserPosts` and `searchPosts`. - [ ] **Step 1: Write failing service tests** Create `src/features/posts/post-service.test.ts`: ```ts import { describe, expect, it, vi } from 'vitest' import { loadUserPage, searchPage, type BirdReader } from './post-service' const reader = (): BirdReader => ({ getUserIdByUsername: vi .fn() .mockResolvedValue({ success: true, userId: '42' }), getUserTweetsPaged: vi.fn().mockResolvedValue({ success: true, tweets: [ { id: '1', text: 'hello', author: { username: 'yuta', name: 'Yuta' } }, ], nextCursor: 'user-next', }), getAllSearchResults: vi.fn().mockResolvedValue({ success: true, tweets: [], nextCursor: 'search-next', }), }) describe('loadUserPage', () => { it('resolves a handle and fetches exactly one page', async () => { const client = reader() const result = await loadUserPage(client, { target: 'https://x.com/yuta', cursor: 'cursor-1', }) expect(client.getUserIdByUsername).toHaveBeenCalledWith('yuta') expect(client.getUserTweetsPaged).toHaveBeenCalledWith('42', 20, { cursor: 'cursor-1', maxPages: 1, pageDelayMs: 0, }) expect(result).toMatchObject({ ok: true, page: { nextCursor: 'user-next' }, }) }) it('returns a safe not-found error', async () => { const client = reader() vi.mocked(client.getUserIdByUsername).mockResolvedValue({ success: false, error: 'User not found: private relay detail', }) expect(await loadUserPage(client, { target: 'missing' })).toEqual({ ok: false, error: { code: 'user-not-found', message: 'ユーザーが見つかりませんでした。', retryable: false, }, }) }) }) describe('searchPage', () => { it('forwards Top and appends follows once', async () => { const client = reader() await searchPage(client, { query: 'AI lang:ja', product: 'Top', following: true, cursor: 'cursor-2', }) expect(client.getAllSearchResults).toHaveBeenCalledWith( 'AI lang:ja filter:follows', { product: 'Top', cursor: 'cursor-2', maxPages: 1 }, ) }) }) ``` - [ ] **Step 2: Run the service tests and verify RED** Run: ```bash nix develop -c pnpm test -- src/features/posts/post-service.test.ts ``` Expected: FAIL because `post-service.ts` does not exist. - [ ] **Step 3: Implement the testable BirdReader adapter** Create `src/features/posts/post-service.ts`: ```ts import type { SearchProduct, SearchResult } from '@yuta/bird' import { InputError, buildSearchQuery, normalizeUserTarget } from './inputs' import type { LoadError, LoadResult, SearchPageInput, UserPageInput, } from './types' type UserLookupResult = { success: boolean userId?: string error?: string } export interface BirdReader { getUserIdByUsername(username: string): Promise getUserTweetsPaged( userId: string, limit: number, options: { cursor?: string maxPages: number pageDelayMs: number }, ): Promise getAllSearchResults( query: string, options: { product: SearchProduct cursor?: string maxPages: number }, ): Promise } const failure = ( code: LoadError['code'], message: string, retryable: boolean, ): LoadResult => ({ ok: false, error: { code, message, retryable } }) function upstreamFailure(message = ''): LoadResult { const lower = message.toLowerCase() if (lower.includes('timeout') || lower.includes('aborted')) { return failure('timeout', '取得がタイムアウトしました。', true) } if (lower.includes('not found')) { return failure('user-not-found', 'ユーザーが見つかりませんでした。', false) } if (lower.includes('suspended') || lower.includes('protected')) { return failure('user-unavailable', 'このユーザーの投稿は取得できません。', false) } return failure('upstream', 'X から投稿を取得できませんでした。', true) } function resultPage(result: SearchResult): LoadResult { return result.success ? { ok: true, page: { tweets: result.tweets, nextCursor: result.nextCursor }, } : upstreamFailure(result.error) } export async function loadUserPage( reader: BirdReader, input: UserPageInput, ): Promise { try { const handle = normalizeUserTarget(input.target) const user = await reader.getUserIdByUsername(handle) if (!user.success || !user.userId) { return upstreamFailure(user.error) } return resultPage( await reader.getUserTweetsPaged(user.userId, 20, { cursor: input.cursor, maxPages: 1, pageDelayMs: 0, }), ) } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return failure('timeout', '取得がタイムアウトしました。', true) } if (error instanceof InputError) { return failure('invalid-input', error.message, false) } return upstreamFailure(error instanceof Error ? error.message : '') } } export async function searchPage( reader: BirdReader, input: SearchPageInput, ): Promise { try { return resultPage( await reader.getAllSearchResults( buildSearchQuery(input.query, input.following), { product: input.product, cursor: input.cursor, maxPages: 1, }, ), ) } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return failure('timeout', '取得がタイムアウトしました。', true) } if (error instanceof InputError) { return failure('invalid-input', error.message, false) } return upstreamFailure(error instanceof Error ? error.message : '') } } ``` - [ ] **Step 4: Run the service tests and verify GREEN** Run: ```bash nix develop -c pnpm test -- src/features/posts/post-service.test.ts ``` Expected: all service behavior passes without a relay. - [ ] **Step 5: Add the server-only client and RPCs** Create `src/features/posts/bird-client.server.ts`: ```ts import { TwitterClient } from '@yuta/bird' import type { BirdReader } from './post-service' let client: TwitterClient | undefined export function getBirdReader(): BirdReader { client ??= new TwitterClient({ relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL, profileName: process.env.BIRD_PROFILE_NAME, timeoutMs: 20_000, }) return client } ``` Create `src/features/posts/server-functions.ts`: ```ts import { createServerFn } from '@tanstack/react-start' import { searchPageInputSchema, userPageInputSchema } from './inputs' import { loadUserPage, searchPage } from './post-service' import type { LoadResult } from './types' const configFailure = (): LoadResult => ({ ok: false, error: { code: 'relay-config', message: 'TWITTER_RELAY_BASE_URL を設定してください。', retryable: false, }, }) async function reader() { const { getBirdReader } = await import('./bird-client.server') return getBirdReader() } export const loadUserPosts = createServerFn({ method: 'GET' }) .validator(userPageInputSchema) .handler(async ({ data }) => { try { return await loadUserPage(await reader(), data) } catch { return configFailure() } }) export const searchPosts = createServerFn({ method: 'GET' }) .validator(searchPageInputSchema) .handler(async ({ data }) => { try { return await searchPage(await reader(), data) } catch { return configFailure() } }) ``` - [ ] **Step 6: Prove Bird stays server-only** Run: ```bash nix develop -c pnpm typecheck nix develop -c pnpm build rg -n "TWITTER_RELAY_BASE_URL|BIRD_PROFILE_NAME|node:fs" dist/client .output/public 2>/dev/null ``` Expected: typecheck and build pass; `rg` returns no client-bundle match for relay environment names or Node filesystem imports. - [ ] **Step 7: Commit the server boundary** ```bash git add src/features/posts git commit -m "feat: add read-only bird boundary" ``` ### Task 5: Add intentional User and Search routes **Files:** - Create: `src/components/app-shell.tsx` - Create: `src/features/posts/components/user-form.tsx` - Create: `src/features/posts/components/search-form.tsx` - Create: `src/features/posts/components/forms.test.tsx` - Create: `src/routes/user.tsx` - Create: `src/routes/search.tsx` - Modify: `src/routes/index.tsx` - Delete: `src/routes/index.test.tsx` **Interfaces:** - Consumes: typed route schemas from Task 3. - Produces: `UserForm({ initialTarget, onSubmit })`. - Produces: `SearchForm({ initialQuery, initialProduct, initialFollowing, onSubmit })`. - Produces: typed `/user` and `/search` routes with no automatic post fetch. - [ ] **Step 1: Write failing form tests** Create `src/features/posts/components/forms.test.tsx`: ```tsx import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { SearchForm } from './search-form' import { UserForm } from './user-form' describe('UserForm', () => { it('submits only after the user enters a target', () => { const onSubmit = vi.fn() render() fireEvent.change(screen.getByLabelText('ハンドルまたはプロフィール URL'), { target: { value: '@yuta' }, }) fireEvent.click(screen.getByRole('button', { name: '表示' })) expect(onSubmit).toHaveBeenCalledWith('yuta') }) it('explains invalid input beside the field', () => { const onSubmit = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: '表示' })) expect(screen.getByRole('alert')).toHaveTextContent( 'ハンドルまたはプロフィール URL を入力してください。', ) expect(onSubmit).not.toHaveBeenCalled() }) }) describe('SearchForm', () => { it('submits the selected ranking and follows filter', () => { const onSubmit = vi.fn() render( , ) fireEvent.change(screen.getByLabelText('検索語'), { target: { value: 'AI lang:ja' }, }) fireEvent.click(screen.getByLabelText('人気順')) fireEvent.click(screen.getByLabelText('フォロー中のみ')) fireEvent.click(screen.getByRole('button', { name: '検索' })) expect(onSubmit).toHaveBeenCalledWith({ q: 'AI lang:ja', product: 'Top', following: true, }) }) }) ``` Run: ```bash nix develop -c pnpm test -- src/features/posts/components/forms.test.tsx ``` Expected: FAIL because both form components are missing. - [ ] **Step 2: Implement the focused forms** Create `src/features/posts/components/user-form.tsx`: ```tsx import { type FormEvent, useState } from 'react' import { normalizeUserTarget } from '../inputs' type Props = { initialTarget: string onSubmit: (target: string) => void } export function UserForm({ initialTarget, onSubmit }: Props) { const [target, setTarget] = useState(initialTarget) const [error, setError] = useState('') const submit = (event: FormEvent) => { event.preventDefault() try { const handle = normalizeUserTarget(target) setError('') onSubmit(handle) } catch (cause) { setError( cause instanceof Error ? cause.message : '入力内容を確認してください。', ) } } return (
) } ``` Create `src/features/posts/components/search-form.tsx`: ```tsx import type { SearchProduct } from '@yuta/bird' import { type FormEvent, useState } from 'react' import { buildSearchQuery } from '../inputs' type SearchValues = { q: string product: SearchProduct following: boolean } type Props = { initialQuery: string initialProduct: SearchProduct initialFollowing: boolean onSubmit: (values: SearchValues) => void } export function SearchForm({ initialQuery, initialProduct, initialFollowing, onSubmit, }: Props) { const [q, setQuery] = useState(initialQuery) const [product, setProduct] = useState(initialProduct) const [following, setFollowing] = useState(initialFollowing) const [error, setError] = useState('') const submit = (event: FormEvent) => { event.preventDefault() try { const query = buildSearchQuery(q, false) setError('') onSubmit({ q: query, product, following }) } catch (cause) { setError( cause instanceof Error ? cause.message : '入力内容を確認してください。', ) } } return (
並び順 {(['Top', 'Latest'] as const).map((value) => ( ))}
) } ``` - [ ] **Step 3: Run the form tests and verify GREEN** Run: ```bash nix develop -c pnpm test -- src/features/posts/components/forms.test.tsx ``` Expected: both form behaviors pass. - [ ] **Step 4: Add the shell and typed routes** Create `src/components/app-shell.tsx`: ```tsx import { Link } from '@tanstack/react-router' export function AppShell({ active, children, }: { active: 'user' | 'search' children: React.ReactNode }) { return (

TWITTER LITE

{children}
) } ``` Replace `src/routes/index.tsx` with: ```tsx import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/')({ beforeLoad: () => { throw redirect({ to: '/user', search: { target: '' } }) }, }) ``` Create `src/routes/user.tsx`: ```tsx import { createFileRoute } from '@tanstack/react-router' import { AppShell } from '#/components/app-shell' import { UserForm } from '#/features/posts/components/user-form' import { userRouteSearchSchema } from '#/features/posts/inputs' export const Route = createFileRoute('/user')({ validateSearch: (search) => userRouteSearchSchema.parse(search), component: UserRoute, }) function UserRoute() { const { target } = Route.useSearch() const navigate = Route.useNavigate() return (

誰の投稿を見ますか?

履歴やおすすめは表示しません。

navigate({ search: { target: nextTarget } }) } />
) } ``` Create `src/routes/search.tsx`: ```tsx import { createFileRoute } from '@tanstack/react-router' import { AppShell } from '#/components/app-shell' import { SearchForm } from '#/features/posts/components/search-form' import { postSearchRouteSchema } from '#/features/posts/inputs' export const Route = createFileRoute('/search')({ validateSearch: (search) => postSearchRouteSchema.parse(search), component: SearchRoute, }) function SearchRoute() { const values = Route.useSearch() const navigate = Route.useNavigate() return (

何を確認しますか?

入力した条件だけを検索します。

navigate({ search })} />
) } ``` Delete `src/routes/index.test.tsx` because redirect behavior is covered by Playwright in Task 9. - [ ] **Step 5: Generate routes, verify, and commit** Run: ```bash nix develop -c pnpm generate-routes nix develop -c pnpm test -- src/features/posts/components/forms.test.tsx nix develop -c pnpm typecheck ``` Expected: tests and typecheck pass; route generation includes `/user` and `/search`. Commit: ```bash git add src/components src/features/posts/components src/routes src/routeTree.gen.ts git commit -m "feat: add intentional reader routes" ``` ### Task 6: Render rich read-only post cards **Files:** - Create: `src/features/posts/components/post-card.tsx` - Create: `src/features/posts/components/post-media.tsx` - Create: `src/features/posts/components/post-card.test.tsx` **Interfaces:** - Consumes: `Post` from Task 3. - Produces: `PostCard({ post, quoted? })` with text, media, quote, article, counts, and an explicit original link. - [ ] **Step 1: Write failing post-card tests** Create `src/features/posts/components/post-card.test.tsx`: ```tsx import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { PostCard } from './post-card' import type { Post } from '../types' const richPost: Post = { id: '123', text: '詳細 https://example.com/article', author: { username: 'yuta', name: 'Yuta', profileImageUrl: 'https://pbs.twimg.com/avatar.jpg', }, createdAt: '2026-07-13T00:00:00.000Z', replyCount: 3, retweetCount: 4, likeCount: 5, media: [ { type: 'photo', url: 'https://pbs.twimg.com/photo.jpg', width: 1200, height: 800, }, ], article: { title: 'Article title', previewText: 'Preview' }, quotedTweet: { id: '122', text: 'quoted', author: { username: 'other', name: 'Other' }, }, } describe('PostCard', () => { it('renders rich content without mutation controls', () => { render() expect(screen.getByText('Yuta')).toBeInTheDocument() expect(screen.getByRole('img', { name: '投稿画像' })).toBeInTheDocument() expect(screen.getByText('Article title')).toBeInTheDocument() expect(screen.getByText('quoted')).toBeInTheDocument() expect(screen.queryByRole('button', { name: /いいね/ })).not.toBeInTheDocument() }) it('uses deliberate safe external links', () => { render() expect(screen.getByRole('link', { name: '元の投稿を開く' })).toHaveAttribute( 'href', 'https://x.com/yuta/status/123', ) expect(screen.getByRole('link', { name: '元の投稿を開く' })).toHaveAttribute( 'rel', 'noreferrer noopener', ) }) }) ``` Run: ```bash nix develop -c pnpm test -- src/features/posts/components/post-card.test.tsx ``` Expected: FAIL because `PostCard` does not exist. - [ ] **Step 2: Implement media rendering** Create `src/features/posts/components/post-media.tsx`: ```tsx import type { Post } from '../types' type Media = NonNullable[number] export function PostMedia({ media }: { media: Media[] }) { if (media.length === 0) return null return (
{media.map((item) => item.type === 'photo' ? ( 投稿画像 ) : (
) } ``` - [ ] **Step 3: Implement the post card** Create `src/features/posts/components/post-card.tsx`: ```tsx import type { ReactNode } from 'react' import type { Post } from '../types' import { PostMedia } from './post-media' const URL = /(https?:\/\/[^\s]+)/g function linkedText(text: string): ReactNode[] { return text.split(URL).map((part, index) => part.startsWith('http://') || part.startsWith('https://') ? ( {part} ) : ( part ), ) } export function PostCard({ post, quoted = false, }: { post: Post quoted?: boolean }) { const original = `https://x.com/${post.author.username}/status/${post.id}` return (
{post.author.profileImageUrl ? ( ) : null}
{post.author.name} @{post.author.username}
{post.createdAt ? ( ) : null}

{linkedText(post.text)}

{post.media ? : null} {post.article ? (
{post.article.title} {post.article.previewText ?

{post.article.previewText}

: null}
) : null} {post.quotedTweet && !quoted ? ( ) : null} {!quoted ? (
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '} {post.likeCount ?? 0} 元の投稿を開く
) : null}
) } ``` - [ ] **Step 4: Verify and commit post rendering** Run: ```bash nix develop -c pnpm test -- src/features/posts/components/post-card.test.tsx nix develop -c pnpm typecheck ``` Expected: rich-content and safe-link tests pass. Commit: ```bash git add src/features/posts/components git commit -m "feat: render rich read-only posts" ``` ### Task 7: Add infinite cursor loading and feed states **Files:** - Create: `src/features/posts/use-post-feed.ts` - Create: `src/features/posts/use-post-feed.test.ts` - Create: `src/features/posts/components/post-feed.tsx` - Create: `src/features/posts/components/post-feed.test.tsx` - Modify: `src/routes/user.tsx` - Modify: `src/routes/search.tsx` **Interfaces:** - Consumes: `loadUserPosts`, `searchPosts`, `PostPage`, and `flattenPostPages`. - Produces: `FeedRequest` and `usePostFeed(request)`. - Produces: `PostFeed({ request })` with loading, empty, error, retry, auto-page, and end states. - [ ] **Step 1: Write failing query-option tests** Create `src/features/posts/use-post-feed.test.ts`: ```ts import { describe, expect, it, vi } from 'vitest' import { createPostFeedOptions } from './use-post-feed' describe('createPostFeedOptions', () => { it('loads the next user cursor and does not retry automatically', async () => { const loadUser = vi.fn().mockResolvedValue({ ok: true, page: { tweets: [], nextCursor: 'next' }, }) const options = createPostFeedOptions( { kind: 'user', target: '@yuta' }, { loadUser, search: vi.fn() }, ) const page = await options.queryFn({ pageParam: 'cursor', } as never) expect(loadUser).toHaveBeenCalledWith({ data: { target: '@yuta', cursor: 'cursor' }, }) expect(options.getNextPageParam(page)).toBe('next') expect(options.retry).toBe(false) }) it('includes every search control in the query key', () => { const options = createPostFeedOptions( { kind: 'search', query: 'AI', product: 'Top', following: true, }, { loadUser: vi.fn(), search: vi.fn() }, ) expect(options.queryKey).toEqual([ 'posts', { kind: 'search', query: 'AI', product: 'Top', following: true }, ]) }) }) ``` Run: ```bash nix develop -c pnpm test -- src/features/posts/use-post-feed.test.ts ``` Expected: FAIL because query options do not exist. - [ ] **Step 2: Implement infinite-query options and hook** Create `src/features/posts/use-post-feed.ts`: ```ts import type { SearchProduct } from '@yuta/bird' import { useInfiniteQuery } from '@tanstack/react-query' import { useServerFn } from '@tanstack/react-start' import { loadUserPosts, searchPosts } from './server-functions' import type { LoadError, LoadResult, PostPage } from './types' export type FeedRequest = | { kind: 'user'; target: string } | { kind: 'search' query: string product: SearchProduct following: boolean } type Loaders = { loadUser: (options: { data: { target: string; cursor?: string } }) => Promise search: (options: { data: { query: string product: SearchProduct following: boolean cursor?: string } }) => Promise } export class PostLoadError extends Error { constructor(readonly detail: LoadError) { super(detail.message) } } const unwrap = (result: LoadResult): PostPage => { if (!result.ok) throw new PostLoadError(result.error) return result.page } export function createPostFeedOptions( request: FeedRequest, loaders: Loaders, ) { return { queryKey: ['posts', request] as const, initialPageParam: undefined as string | undefined, retry: false as const, queryFn: async ({ pageParam }: { pageParam: string | undefined }) => request.kind === 'user' ? unwrap( await loaders.loadUser({ data: { target: request.target, cursor: pageParam }, }), ) : unwrap( await loaders.search({ data: { query: request.query, product: request.product, following: request.following, cursor: pageParam, }, }), ), getNextPageParam: (page: PostPage) => page.nextCursor, } } export function usePostFeed(request: FeedRequest | undefined) { const loadUser = useServerFn(loadUserPosts) const search = useServerFn(searchPosts) const disabled = { kind: 'user', target: '', } satisfies FeedRequest return useInfiniteQuery({ ...createPostFeedOptions(request ?? disabled, { loadUser, search }), enabled: request !== undefined, }) } ``` - [ ] **Step 3: Run query-option tests and verify GREEN** Run: ```bash nix develop -c pnpm test -- src/features/posts/use-post-feed.test.ts ``` Expected: query key, cursor forwarding, and retry behavior pass. - [ ] **Step 4: Write failing feed-state tests** Create `src/features/posts/components/post-feed.test.tsx` using a module mock: ```tsx import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PostFeed } from './post-feed' import { usePostFeed } from '../use-post-feed' vi.mock('../use-post-feed', async (importOriginal) => { const original = await importOriginal() return { ...original, usePostFeed: vi.fn() } }) const result = { data: undefined, error: null, fetchNextPage: vi.fn(), hasNextPage: false, isError: false, isFetchNextPageError: false, isFetchingNextPage: false, isPending: false, refetch: vi.fn(), } describe('PostFeed', () => { beforeEach(() => vi.mocked(usePostFeed).mockReturnValue(result as never)) it('renders no posts before a deliberate request', () => { render() expect(screen.queryByRole('article')).not.toBeInTheDocument() }) it('renders a deduplicated page and a quiet end', () => { vi.mocked(usePostFeed).mockReturnValue({ ...result, data: { pages: [ { tweets: [ { id: '1', text: 'first', author: { username: 'a', name: 'A' } }, { id: '1', text: 'first', author: { username: 'a', name: 'A' } }, ], }, ], }, } as never) render() expect(screen.getAllByRole('article')).toHaveLength(1) expect(screen.getByText('これ以上の投稿はありません。')).toBeInTheDocument() }) it('keeps loaded posts while retrying a later page', () => { const fetchNextPage = vi.fn() vi.mocked(usePostFeed).mockReturnValue({ ...result, data: { pages: [ { tweets: [ { id: '1', text: 'kept', author: { username: 'a', name: 'A' } }, ], }, ], }, fetchNextPage, hasNextPage: true, isFetchNextPageError: true, } as never) render() expect(screen.getByText('kept')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '再試行' })) expect(fetchNextPage).toHaveBeenCalledOnce() }) }) ``` Run: ```bash nix develop -c pnpm test -- src/features/posts/components/post-feed.test.tsx ``` Expected: FAIL because `PostFeed` does not exist. - [ ] **Step 5: Implement feed states and IntersectionObserver** Create `src/features/posts/components/post-feed.tsx`: ```tsx import { useEffect, useRef } from 'react' import { flattenPostPages } from '../page' import { PostLoadError, type FeedRequest, usePostFeed, } from '../use-post-feed' import { PostCard } from './post-card' export function PostFeed({ request }: { request: FeedRequest | undefined }) { const query = usePostFeed(request) const sentinel = useRef(null) const posts = query.data ? flattenPostPages(query.data.pages) : [] useEffect(() => { if (!sentinel.current || !query.hasNextPage) return const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting && !query.isFetchingNextPage) { void query.fetchNextPage() } }, { rootMargin: '600px 0px' }, ) observer.observe(sentinel.current) return () => observer.disconnect() }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]) if (!request) return null if (query.isPending) { return

投稿を取得しています…

} if (query.isError && posts.length === 0) { const error = query.error instanceof PostLoadError ? query.error.detail : { message: '投稿を取得できませんでした。', retryable: true } return (

{error.message}

{error.retryable ? ( ) : null}
) } if (posts.length === 0) { return

条件に一致する投稿はありません。

} return (
{posts.map((post) => ( ))}
{query.isFetchingNextPage ? (
次の投稿を取得しています…
) : null} {query.isFetchNextPageError ? (

続きの投稿を取得できませんでした。

) : null} {!query.hasNextPage ? (

これ以上の投稿はありません。

) : null}
) } ``` - [ ] **Step 6: Connect feeds to route-controlled intent** In `src/routes/user.tsx`, import `PostFeed` and render after the form: ```tsx ``` In `src/routes/search.tsx`, render: ```tsx ``` - [ ] **Step 7: Verify and commit infinite loading** Run: ```bash nix develop -c pnpm test -- \ src/features/posts/use-post-feed.test.ts \ src/features/posts/components/post-feed.test.tsx nix develop -c pnpm typecheck nix develop -c pnpm build ``` Expected: query options, feed states, typecheck, and build pass. Commit: ```bash git add src/features/posts src/routes src/routeTree.gen.ts git commit -m "feat: load post cursors infinitely" ``` ### Task 8: Apply the Mist Instrument responsive interface **Files:** - Modify: `src/styles.css` - Modify: `src/features/posts/components/forms.test.tsx` - Modify: `src/features/posts/components/post-card.test.tsx` **Interfaces:** - Consumes: existing semantic class names and controls. - Produces: responsive Mist Instrument layout, visible keyboard focus, stable media geometry, and reduced-motion behavior. - [ ] **Step 1: Add failing accessibility assertions** Extend `forms.test.tsx` with: ```tsx it('uses a labelled grouping for ranking controls', () => { render( , ) expect( screen.getByRole('group', { name: '並び順' }), ).toBeInTheDocument() }) ``` Extend `post-card.test.tsx` with: ```tsx it('does not turn the author into a discovery link', () => { render() expect(screen.queryByRole('link', { name: 'Yuta' })).not.toBeInTheDocument() }) ``` Run: ```bash nix develop -c pnpm test -- src/features/posts/components ``` Expected: tests expose any missing semantics before visual work proceeds. - [ ] **Step 2: Replace the base stylesheet with Mist Instrument** Replace `src/styles.css` with: ```css :root { --canvas: #dfe7e9; --surface: #f8faf9; --surface-muted: #edf3f3; --ink: #1e3238; --muted: #70858a; --accent: #255f6e; --accent-hover: #194d59; --border: #c1cfd2; --error: #8a3034; color: var(--ink); background: var(--canvas); font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif; font-synthesis: none; } * { box-sizing: border-box; } html { min-width: 320px; background: var(--canvas); } body { min-height: 100vh; margin: 0; } button, input { font: inherit; } a { color: var(--accent); text-underline-offset: 0.18em; } button, input, a { border-radius: 0.55rem; } :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 42%, transparent); outline-offset: 3px; } .app { width: min(46rem, calc(100% - 2rem)); min-height: calc(100vh - 2rem); margin: 1rem auto; overflow: clip; border: 1px solid var(--border); border-radius: 1.15rem; background: var(--surface); box-shadow: 0 1.2rem 3rem rgb(30 50 56 / 10%); } .app-header { display: flex; align-items: end; justify-content: space-between; gap: 1rem; padding: 1.2rem 1.4rem 0; border-bottom: 1px solid #dce5e6; } .brand { margin: 0 0 0.85rem; color: var(--muted); font-size: 0.68rem; letter-spacing: 0.2em; } .tabs { display: flex; gap: 1.1rem; } .tabs a { padding: 0.85rem 0.2rem 0.75rem; border-radius: 0; color: var(--muted); font-size: 0.9rem; text-decoration: none; } .tabs a[aria-current="page"] { border-bottom: 2px solid var(--accent); color: var(--accent); font-weight: 700; } .reader { padding: 2rem 1.4rem 3rem; } .reader > h1 { margin: 0; font-size: clamp(1.45rem, 4vw, 2rem); letter-spacing: -0.03em; } .intro { margin: 0.45rem 0 1.5rem; color: var(--muted); } .intent-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.85rem; align-items: end; padding: 1rem; border: 1px solid var(--border); border-radius: 0.9rem; background: var(--surface-muted); } .field { display: grid; gap: 0.4rem; min-width: 0; color: var(--muted); font-size: 0.78rem; } .field input { width: 100%; padding: 0.75rem 0.85rem; border: 1px solid #aebfc3; background: white; color: var(--ink); } .field-error { color: var(--error); font-size: 0.76rem; } .intent-form button, .state button { min-height: 2.65rem; padding: 0.65rem 1rem; border: 1px solid var(--accent); background: var(--accent); color: white; cursor: pointer; font-weight: 700; } .intent-form button:hover, .state button:hover { background: var(--accent-hover); } .segmented { display: flex; gap: 0.35rem; margin: 0; padding: 0; border: 0; } .segmented legend { width: 100%; margin-bottom: 0.4rem; color: var(--muted); font-size: 0.78rem; } .segmented label { position: relative; padding: 0.5rem 0.7rem; border: 1px solid var(--border); border-radius: 0.55rem; cursor: pointer; font-size: 0.84rem; } .segmented input { position: absolute; opacity: 0; } .segmented label:has(input:checked) { border-color: var(--accent); background: var(--accent); color: white; } .check { display: flex; align-items: center; gap: 0.45rem; min-height: 2.65rem; font-size: 0.84rem; } .feed { display: grid; gap: 0.85rem; margin-top: 1.4rem; } .post { min-width: 0; padding: 1rem; border: 1px solid #d7e0e2; border-radius: 0.9rem; background: white; } .post-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.65rem; align-items: center; } .avatar { border-radius: 50%; } .handle, .post-header time { display: block; color: var(--muted); font-size: 0.76rem; } .post-header time { text-align: right; } .post-text { margin: 0.9rem 0; overflow-wrap: anywhere; line-height: 1.75; white-space: pre-wrap; } .media-grid { display: grid; gap: 0.4rem; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 0.8rem; overflow: hidden; border-radius: 0.75rem; } .media-grid:has(> :only-child) { grid-template-columns: 1fr; } .media { width: 100%; max-height: 32rem; object-fit: cover; background: var(--surface-muted); } .article-card, .quote { margin-top: 0.8rem; padding: 0.85rem; border: 1px solid var(--border); border-radius: 0.75rem; background: var(--surface); } .article-card p { margin-bottom: 0; color: var(--muted); } .quote .post-header time, .quote .post-footer { display: none; } .post-footer { display: flex; justify-content: space-between; gap: 1rem; margin-top: 1rem; color: var(--muted); font-size: 0.74rem; } .counts { font-variant-numeric: tabular-nums; } .state { padding: 1rem; color: var(--muted); text-align: center; } [role="alert"].state { color: var(--error); } .loading-rail { position: relative; padding: 0.8rem; color: var(--muted); font-size: 0.78rem; text-align: center; } .loading-rail::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; background: linear-gradient( 90deg, transparent 0%, var(--accent) 45%, transparent 100% ); content: ""; animation: rail 1.1s ease-in-out infinite; } @keyframes rail { 0%, 100% { opacity: 0.25; transform: scaleX(0.35); } 50% { opacity: 1; transform: scaleX(1); } } @media (max-width: 42rem) { .app { width: 100%; min-height: 100vh; margin: 0; border-right: 0; border-left: 0; border-radius: 0; } .app-header, .reader { padding-right: 1rem; padding-left: 1rem; } .intent-form { grid-template-columns: 1fr; } .post-header { grid-template-columns: auto minmax(0, 1fr); } .post-header time { grid-column: 2; text-align: left; } .post-footer { align-items: flex-start; flex-direction: column; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } ``` - [ ] **Step 3: Verify responsive semantics and commit** Run: ```bash nix develop -c pnpm test -- src/features/posts/components nix develop -c pnpm lint nix develop -c pnpm typecheck nix develop -c pnpm build ``` Expected: component tests, lint, typecheck, and build pass. Commit: ```bash git add src/styles.css src/features/posts/components git commit -m "feat: apply mist instrument interface" ``` ### Task 9: Verify browser flows, live relay reads, and documentation **Files:** - Create: `playwright.config.ts` - Create: `tests/e2e/mock-relay.mjs` - Create: `tests/e2e/reader.spec.ts` - Create: `tests/live/relay.test.ts` - Create: `.env.example` - Modify: `.gitignore` - Create: `README.md` - Verify: `docs/superpowers/specs/2026-07-13-twitter-lite-design.md` **Interfaces:** - Consumes: the complete app and published Bird package. - Produces: deterministic desktop/mobile browser coverage and an opt-in read-only live smoke test. - [ ] **Step 1: Add the browser configuration** Create `playwright.config.ts`: ```ts import { defineConfig, devices } from '@playwright/test' const appPort = 4173 const relayPort = 6911 const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE if (!chromiumExecutable) { throw new Error('Run E2E tests through `nix develop` to provide Chromium.') } export default defineConfig({ testDir: './tests/e2e', fullyParallel: false, workers: 1, use: { baseURL: `http://127.0.0.1:${appPort}`, launchOptions: { executablePath: chromiumExecutable }, screenshot: 'only-on-failure', trace: 'retain-on-failure', }, webServer: [ { command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`, port: relayPort, reuseExistingServer: false, timeout: 120_000, }, { command: `TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`, url: `http://127.0.0.1:${appPort}`, reuseExistingServer: false, timeout: 120_000, }, ], projects: [ { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile', use: { ...devices['Pixel 7'] } }, ], }) ``` - [ ] **Step 2: Create a deterministic read-only relay** Create `tests/e2e/mock-relay.mjs`. The response-shape excerpt below is only part of the mock: the executable implementation must also enforce the request contract listed after it. ```js const tweet = (id, text, username = 'yuta') => ({ entryId: `tweet-${id}`, content: { itemContent: { tweet_results: { result: { rest_id: id, legacy: { full_text: text, created_at: 'Mon Jul 13 00:00:00 +0000 2026', reply_count: 1, retweet_count: 2, favorite_count: 3, conversation_id_str: id, }, core: { user_results: { result: { legacy: { screen_name: username, name: 'Yuta', }, }, }, }, }, }, }, }, }) const cursor = (value) => ({ entryId: 'cursor-bottom', content: { cursorType: 'Bottom', value }, }) ``` Route by the operation-name suffix, never the query ID. Require `x-profile-name: e2e`; require GET for `UserByScreenName` and `UserTweets`; and require POST plus JSON `{ features, queryId }` for `SearchTimeline`, whose `variables` remain in the URL. Validate only the minimal fixture variables, not exact query IDs or complete feature bags. Return 405 for a wrong method, 403 for a wrong profile, 400 for malformed supported input, and 501 for an unsupported operation. Never return 404 or make an outbound request. Key transient state by raw query. The first cursor request for each `retry-` query returns 503 once, then succeeds on explicit retry. A `slow-` cursor request is delayed long enough to observe the loading rail. Every page carrying a cursor also carries a parseable tweet, and page two omits the cursor. - [ ] **Step 3: Write failing end-to-end flows** Create `tests/e2e/reader.spec.ts`: ```ts import { expect, test } from '@playwright/test' let consoleErrors: string[] let pageErrors: string[] test.beforeEach(async ({ page }) => { consoleErrors = [] pageErrors = [] page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) }) page.on('pageerror', (error) => pageErrors.push(error.message)) }) test.afterEach(() => { expect(consoleErrors).toEqual([]) expect(pageErrors).toEqual([]) }) test('requires intent and infinitely loads a user timeline', async ({ page }) => { await page.goto('/') await expect(page).toHaveURL(/\/user\?target=/) await expect(page.locator('article')).toHaveCount(0) await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta') await page.getByRole('button', { name: '表示' }).click() await expect(page.getByText('user page 1')).toBeVisible() await expect(page.getByText('user page 2')).toBeVisible() await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() await expect(page).toHaveScreenshot('mist-user.png', { animations: 'disabled', fullPage: true, }) }) test('searches Top posts from followed accounts', async ({ page }) => { await page.goto('/search?q=&product=Latest&following=false') await page.getByLabel('検索語').fill('AI lang:ja') await page.getByLabel('人気順').check() await page.getByLabel('フォロー中のみ').check() await page.getByRole('button', { name: '検索' }).click() await expect(page.getByText('Top · follows page 1')).toBeVisible() await expect(page.getByText('Top · follows page 2')).toBeVisible() }) test('keeps discovery surfaces absent', async ({ page }) => { await page.goto('/user?target=') await expect( page.getByRole('link', { name: /おすすめ|トレンド|通知/ }), ).toHaveCount(0) await expect(page.getByRole('button', { name: /いいね|再投稿|フォロー/ })).toHaveCount(0) }) ``` Wait for the cold SSR page to hydrate before manipulating controlled fields. In addition to the excerpted happy paths, cover a later-page 503 that retains page one and succeeds only after Retry, the exact keyboard Tab order across both navigation links and all form controls, absent discovery/mutation links and controls, and a `slow-` request under reduced motion whose loading rail pseudo-element has `animation-name: none`. Run every flow in both projects and let the slow request finish before teardown. Run: ```bash nix develop -c pnpm test:e2e ``` Expected: FAIL because approved visual snapshots do not exist yet. If a functional assertion fails first, reproduce it in the nearest focused unit test before changing application code. - [ ] **Step 4: Make browser flows GREEN and capture screenshots** Run: ```bash nix develop -c pnpm exec playwright test --update-snapshots nix develop -c pnpm test:e2e ``` Expected: the first run creates reviewed desktop and mobile `mist-user.png` snapshots; the second run passes all projects with no browser console errors. - [ ] **Step 5: Add the opt-in live read test** Create `tests/live/relay.test.ts`: ```ts // @vitest-environment node import { TwitterClient } from '@yuta/bird' import { describe, expect, it } from 'vitest' describe.runIf(process.env.TWITTER_LITE_LIVE === '1')( 'configured relay', () => { it( 'performs one read-only Top search', async () => { const relayBaseUrl = process.env.TWITTER_RELAY_BASE_URL if (!relayBaseUrl) { throw new Error('TWITTER_RELAY_BASE_URL is required for the live test') } const client = new TwitterClient({ relayBaseUrl, profileName: process.env.BIRD_PROFILE_NAME, timeoutMs: 20_000, }) const query = process.env.TWITTER_LITE_LIVE_QUERY ?? 'OpenAI' const result = await client.search(query, 1, { product: 'Top' }) expect(result.success).toBe(true) }, 30_000, ) }, ) ``` Run: ```bash nix develop -c pnpm test:live ``` Expected: when relay/profile environment configuration is actually available, one read-only Top search test passes. Do not run this opt-in command unconditionally or log relay configuration, response bodies, or post content. - [ ] **Step 6: Document setup and intentional omissions** Create `.env.example`: ```dotenv TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 BIRD_PROFILE_NAME= ``` Extend `.gitignore` to exactly: ```gitignore .superpowers/ .worktrees/ .env* !.env.example .output/ dist/ node_modules/ playwright-report/ test-results/ ``` Create `README.md` with these sections and commands: ````md # Twitter Lite An intentional, read-only X reader that shows content only after a deliberate handle, profile URL, or search query. ## Features - User timelines from a manually entered handle or profile URL - Top and Latest search - Optional `filter:follows` search - Infinite cursor loading - Photos, videos, quotes, articles, and quiet engagement counts - No home feed, recommendations, trends, notifications, history, or writes ## Setup Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc` routes `@yuta` packages to Gitea Packages, and `@yuta/bird@0.10.0` contains the required Top/Latest interface. ```bash nix develop -c pnpm install --frozen-lockfile ``` Set `TWITTER_RELAY_BASE_URL` at runtime and, when the relay has multiple profiles, `BIRD_PROFILE_NAME`. Bird and both values are read only by server-side code and never reach the browser. ## Commands ```bash nix develop -c pnpm dev nix develop -c pnpm dev:tailscale nix develop -c pnpm test nix develop -c pnpm test:e2e nix develop -c pnpm test:live nix develop -c pnpm build nix develop -c pnpm start ``` The default bind address is `127.0.0.1`. E2E runs through Nix with the system Chromium; the live command performs exactly one explicitly configured read. `dev:tailscale` binds `0.0.0.0`, including LAN interfaces as well as Tailscale, so use it only on a trusted network. Find the Tailscale address with `tailscale ip -4`. ## Reliability Bird uses X's internal web GraphQL operations through the configured safe relay. X may change query IDs or response shapes without notice. ```` - [ ] **Step 7: Run the complete verification matrix** From `../bird`: ```bash nix develop -c pnpm run build:dist nix develop -c pnpm run lint nix develop -c pnpm test git status --short --branch git rev-list --count origin/main..HEAD git log --oneline origin/main..HEAD ``` From Twitter Lite: ```bash nix develop -c pnpm install --frozen-lockfile nix develop -c pnpm check:routes nix develop -c pnpm lint nix develop -c pnpm typecheck nix develop -c pnpm test nix develop -c pnpm build nix develop -c pnpm test:e2e nix develop -c pnpm test:live # only with configured relay environment git diff --check ``` Expected: - Bird build, lint, and all non-live tests pass. - Twitter Lite lint, typecheck, unit tests, desktop/mobile browser tests, live read test, and production build pass. - Bird has one local feature commit and no remote push. - Fresh `.output/public` scans contain no Bird/client or relay environment names; source contains only the two intended server functions and no mutation/generic proxy calls. - `README.md` and the design docs match the delivered commands and behavior. - `AGENTS.md` and `CLAUDE.md` remain absent because no agent-specific instruction changed. - [ ] **Step 8: Commit final verification assets and docs** ```bash git add \ .env.example .gitignore README.md playwright.config.ts \ src/routes/__root.tsx tests/e2e tests/live docs git commit -m "test: verify intentional reader flows" ``` Run one fresh post-commit audit: ```bash git status --short --branch git log --oneline --decorate -8 nix develop -c pnpm test nix develop -c pnpm test:e2e nix develop -c pnpm build git diff --check ``` Expected: clean status, the planned commits are present, all unit tests pass, and the production build exits zero.