diff --git a/docs/superpowers/plans/2026-07-13-twitter-lite.md b/docs/superpowers/plans/2026-07-13-twitter-lite.md new file mode 100644 index 0000000..a1bb96a --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-twitter-lite.md @@ -0,0 +1,3245 @@ +# 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. + +**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. +- Do not push Bird or publish `@yuta/bird` without separate user authorization. + +## 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: local `@yuta/bird` through `link:../bird`. +- 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 `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": "link:../bird", + "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 local Bird build. +- 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' + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + use: { + baseURL: 'http://127.0.0.1:3000', + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, + }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: [ + { + command: 'node tests/e2e/mock-relay.mjs', + port: 6901, + reuseExistingServer: false, + }, + { + command: + 'TWITTER_RELAY_BASE_URL=http://127.0.0.1:6901 BIRD_PROFILE_NAME=e2e pnpm dev', + url: 'http://127.0.0.1:3000/user?target=', + reuseExistingServer: false, + }, + ], + 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`: + +```js +import { createServer } from 'node:http' + +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 }, +}) + +const send = (response, payload, status = 200) => { + response.writeHead(status, { 'content-type': 'application/json' }) + response.end(JSON.stringify(payload)) +} + +createServer((request, response) => { + if (request.headers['x-profile-name'] !== 'e2e') { + send(response, { errors: [{ message: 'missing profile' }] }, 403) + return + } + const url = new URL(request.url ?? '/', 'http://127.0.0.1:6901') + const variables = JSON.parse(url.searchParams.get('variables') ?? '{}') + + if (url.pathname.endsWith('/UserByScreenName')) { + send(response, { + data: { + user: { + result: { + rest_id: '42', + legacy: { screen_name: variables.screen_name, name: 'Yuta' }, + }, + }, + }, + }) + return + } + + if (url.pathname.endsWith('/UserTweets')) { + const second = Boolean(variables.cursor) + send(response, { + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: second + ? [tweet('u2', 'user page 2')] + : [tweet('u1', 'user page 1'), cursor('user-next')], + }, + ], + }, + }, + }, + }, + }, + }) + return + } + + if (url.pathname.endsWith('/SearchTimeline')) { + const second = Boolean(variables.cursor) + const label = `${variables.product} · ${ + String(variables.rawQuery).includes('filter:follows') + ? 'follows' + : 'all' + }` + send(response, { + data: { + search_by_raw_query: { + search_timeline: { + timeline: { + instructions: [ + { + entries: second + ? [tweet('s2', `${label} page 2`)] + : [tweet('s1', `${label} page 1`), cursor('search-next')], + }, + ], + }, + }, + }, + }, + }) + return + } + + send(response, { errors: [{ message: 'unsupported read operation' }] }, 404) +}).listen(6901, '127.0.0.1') +``` + +- [ ] **Step 3: Write failing end-to-end flows** + +Create `tests/e2e/reader.spec.ts`: + +```ts +import { expect, test } from '@playwright/test' + +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) +}) +``` + +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 +import { TwitterClient } from '@yuta/bird' +import { describe, expect, it } from 'vitest' + +describe.runIf(process.env.TWITTER_LITE_LIVE === '1')( + 'configured relay', + () => { + it('reads one Top search result without a mutation', async () => { + const client = new TwitterClient({ + relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL, + profileName: process.env.BIRD_PROFILE_NAME, + timeoutMs: 20_000, + }) + + const result = await client.search('OpenAI', 1, { product: 'Top' }) + expect(result.success).toBe(true) + if (result.success) expect(result.tweets.length).toBeGreaterThan(0) + }) + }, +) +``` + +Run: + +```bash +nix develop -c pnpm test:live +``` + +Expected: one read-only Top search test passes using the configured relay. + +- [ ] **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/ +.env +.env.local +.output/ +dist/ +node_modules/ +playwright-report/ +test-results/ +``` + +Create `README.md` with these sections and commands: + +````md +# Twitter Lite + +A localhost-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 + +Clone the modified Bird checkout beside this repository: + +```bash +git clone https://git.yutakobayashi.com/yuta/bird ../bird +cd ../bird +nix develop -c pnpm install --frozen-lockfile +nix develop -c pnpm run build:dist +cd ../twitter-lite +nix develop -c pnpm install --frozen-lockfile +``` + +Set `TWITTER_RELAY_BASE_URL` and, when the relay has multiple profiles, +`BIRD_PROFILE_NAME`. Bird and both values remain in the server bundle. + +## 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`. Use `dev:tailscale` only on a trusted +tailnet. + +## 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 +``` + +From Twitter Lite: + +```bash +nix develop -c pnpm install --frozen-lockfile +nix develop -c pnpm generate-routes +nix develop -c pnpm lint +nix develop -c pnpm typecheck +nix develop -c pnpm test +nix develop -c pnpm test:e2e +nix develop -c pnpm test:live +nix develop -c pnpm build +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. +- Browser bundles contain no relay environment value and expose no mutation endpoint. +- `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 \ + tests/e2e tests/live docs +git commit -m "test: verify intentional reader flows" +``` + +Run one fresh post-commit audit: + +```bash +git status --short +git log --oneline --decorate -8 +nix develop -c pnpm test +nix develop -c pnpm build +``` + +Expected: clean status, the planned commits are present, all unit tests pass, and the production build exits zero.