80 KiB
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.1by default; allow an explicit host override for Tailscale testing. - Read
TWITTER_RELAY_BASE_URLonly on the server; passBIRD_PROFILE_NAMEexplicitly 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
TopandLatestsearch products;Latestremains 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/birdwithout 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--productoption.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:
../birdfromhttps://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 })andgetAllSearchResults(query, { product }). -
Produces:
bird search <query> --product <Top|Latest>. -
Step 1: Clone Bird and verify the baseline
Run:
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:
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:
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:
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:
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:
const { includeRaw = false, maxPages, product = 'Latest' } = options
Use the captured value in every request:
const variables = {
rawQuery: query,
count: pageCount,
querySource: 'typed_query',
product,
...(pageCursor ? { cursor: pageCursor } : {}),
}
In src/lib/index.ts, export the public types:
export type {
SearchFetchOptions,
SearchPaginationOptions,
SearchProduct,
} from './twitter-client-search.js'
- Step 5: Run the library tests and verify GREEN
Run:
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:
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:
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:
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:
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:
import { type Command, Option } from 'commander'
import type { SearchProduct } from '../lib/twitter-client-search.js'
Register the option after --cursor:
.addOption(
new Option('--product <product>', 'Search product')
.choices(['Top', 'Latest'] satisfies SearchProduct[])
.default('Latest'),
)
Add product?: SearchProduct to the search command option type, then construct:
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:
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:
## Unreleased
### Added
- Add Top and Latest product selection to library and CLI search.
Run:
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
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/birdthroughlink:../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:
{
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:
{
"name": "twitter-lite",
"private": true,
"type": "module",
"packageManager": "[email protected]",
"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:
allowBuilds:
esbuild: true
Create tsconfig.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:
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:
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:
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(cleanup)
Create biome.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:
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:
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(<Home />)
expect(screen.getByText('目的を決めてから開く')).toBeInTheDocument()
expect(screen.queryByRole('article')).not.toBeInTheDocument()
})
})
Run:
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:
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<typeof getRouter>
}
}
Create src/routes/__root.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<RouterContext>()({
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 (
<html lang="ja">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}
Create src/routes/index.tsx:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({ component: Home })
export function Home() {
return (
<main className="landing">
<p className="brand">TWITTER LITE</p>
<h1>目的を決めてから開く</h1>
<p>ユーザー名か検索語を入力するまで、投稿は表示しません。</p>
</main>
)
}
Create src/styles.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:
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:
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:
userPageInputSchemaandsearchPageInputSchema. -
Produces:
flattenPostPages(pages: PostPage[]): Post[]. -
Step 1: Write failing input tests
Create src/features/posts/inputs.test.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:
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:
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:
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:
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:
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:
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:
import type { Post, PostPage } from './types'
export function flattenPostPages(pages: PostPage[]): Post[] {
const seen = new Set<string>()
return pages.flatMap(({ tweets }) =>
tweets.filter(({ id }) => {
if (seen.has(id)) return false
seen.add(id)
return true
}),
)
}
Run:
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
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<LoadResult>. -
Produces:
searchPage(reader, input): Promise<LoadResult>. -
Produces: TanStack Start server functions
loadUserPostsandsearchPosts. -
Step 1: Write failing service tests
Create src/features/posts/post-service.test.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:
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:
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<UserLookupResult>
getUserTweetsPaged(
userId: string,
limit: number,
options: {
cursor?: string
maxPages: number
pageDelayMs: number
},
): Promise<SearchResult>
getAllSearchResults(
query: string,
options: {
product: SearchProduct
cursor?: string
maxPages: number
},
): Promise<SearchResult>
}
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<LoadResult> {
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<LoadResult> {
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:
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:
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:
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:
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
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
/userand/searchroutes with no automatic post fetch. -
Step 1: Write failing form tests
Create src/features/posts/components/forms.test.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(<UserForm initialTarget="" onSubmit={onSubmit} />)
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(<UserForm initialTarget="" onSubmit={onSubmit} />)
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(
<SearchForm
initialQuery=""
initialProduct="Latest"
initialFollowing={false}
onSubmit={onSubmit}
/>,
)
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:
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:
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 (
<form className="intent-form" onSubmit={submit}>
<label className="field">
<span>ハンドルまたはプロフィール URL</span>
<input
autoComplete="off"
aria-describedby={error ? 'target-error' : undefined}
aria-invalid={Boolean(error)}
name="target"
value={target}
onChange={(event) => setTarget(event.currentTarget.value)}
placeholder="@handle または https://x.com/handle"
/>
{error ? (
<span className="field-error" id="target-error" role="alert">
{error}
</span>
) : null}
</label>
<button type="submit">表示</button>
</form>
)
}
Create src/features/posts/components/search-form.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 (
<form className="intent-form" onSubmit={submit}>
<label className="field">
<span>検索語</span>
<input
autoComplete="off"
aria-describedby={error ? 'query-error' : undefined}
aria-invalid={Boolean(error)}
name="q"
value={q}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="検索語や高度な検索演算子"
/>
{error ? (
<span className="field-error" id="query-error" role="alert">
{error}
</span>
) : null}
</label>
<fieldset className="segmented">
<legend>並び順</legend>
{(['Top', 'Latest'] as const).map((value) => (
<label key={value}>
<input
checked={product === value}
name="product"
onChange={() => setProduct(value)}
type="radio"
value={value}
/>
{value === 'Top' ? '人気順' : '最新順'}
</label>
))}
</fieldset>
<label className="check">
<input
checked={following}
onChange={(event) => setFollowing(event.currentTarget.checked)}
type="checkbox"
/>
フォロー中のみ
</label>
<button type="submit">検索</button>
</form>
)
}
- Step 3: Run the form tests and verify GREEN
Run:
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:
import { Link } from '@tanstack/react-router'
export function AppShell({
active,
children,
}: {
active: 'user' | 'search'
children: React.ReactNode
}) {
return (
<main className="app">
<header className="app-header">
<p className="brand">TWITTER LITE</p>
<nav aria-label="閲覧方法" className="tabs">
<Link
aria-current={active === 'user' ? 'page' : undefined}
search={{ target: '' }}
to="/user"
>
ユーザー
</Link>
<Link
aria-current={active === 'search' ? 'page' : undefined}
search={{ q: '', product: 'Latest', following: false }}
to="/search"
>
検索
</Link>
</nav>
</header>
<section className="reader">{children}</section>
</main>
)
}
Replace src/routes/index.tsx with:
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
beforeLoad: () => {
throw redirect({ to: '/user', search: { target: '' } })
},
})
Create src/routes/user.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 (
<AppShell active="user">
<h1>誰の投稿を見ますか?</h1>
<p className="intro">履歴やおすすめは表示しません。</p>
<UserForm
initialTarget={target}
key={target}
onSubmit={(nextTarget) =>
navigate({ search: { target: nextTarget } })
}
/>
</AppShell>
)
}
Create src/routes/search.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 (
<AppShell active="search">
<h1>何を確認しますか?</h1>
<p className="intro">入力した条件だけを検索します。</p>
<SearchForm
initialFollowing={values.following}
initialProduct={values.product}
initialQuery={values.q}
key={`${values.q}:${values.product}:${values.following}`}
onSubmit={(search) => navigate({ search })}
/>
</AppShell>
)
}
Delete src/routes/index.test.tsx because redirect behavior is covered by Playwright in Task 9.
- Step 5: Generate routes, verify, and commit
Run:
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:
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:
Postfrom 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:
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(<PostCard post={richPost} />)
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(<PostCard post={richPost} />)
expect(screen.getByRole('link', { name: '元の投稿を開く' })).toHaveAttribute(
'href',
'https://x.com/yuta/status/123',
)
expect(screen.getByRole('link', { name: '元の投稿を開く' })).toHaveAttribute(
'rel',
'noreferrer noopener',
)
})
})
Run:
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:
import type { Post } from '../types'
type Media = NonNullable<Post['media']>[number]
export function PostMedia({ media }: { media: Media[] }) {
if (media.length === 0) return null
return (
<div className="media-grid">
{media.map((item) =>
item.type === 'photo' ? (
<img
alt="投稿画像"
className="media"
height={item.height}
key={item.url}
loading="lazy"
src={item.url}
width={item.width}
/>
) : (
<video
className="media"
controls
key={item.videoUrl ?? item.url}
loop={item.type === 'animated_gif'}
poster={item.previewUrl ?? item.url}
preload="metadata"
src={item.videoUrl ?? item.url}
/>
),
)}
</div>
)
}
- Step 3: Implement the post card
Create src/features/posts/components/post-card.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://') ? (
<a href={part} key={`link-${index}`} rel="noreferrer noopener" target="_blank">
{part}
</a>
) : (
part
),
)
}
export function PostCard({
post,
quoted = false,
}: {
post: Post
quoted?: boolean
}) {
const original = `https://x.com/${post.author.username}/status/${post.id}`
return (
<article className={quoted ? 'post quote' : 'post'}>
<header className="post-header">
{post.author.profileImageUrl ? (
<img
alt=""
className="avatar"
height="40"
loading="lazy"
src={post.author.profileImageUrl}
width="40"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<span className="handle">@{post.author.username}</span>
</div>
{post.createdAt ? (
<time dateTime={post.createdAt}>
{new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'Asia/Tokyo',
}).format(new Date(post.createdAt))}
</time>
) : null}
</header>
<p className="post-text">{linkedText(post.text)}</p>
{post.media ? <PostMedia media={post.media} /> : null}
{post.article ? (
<section className="article-card">
<strong>{post.article.title}</strong>
{post.article.previewText ? <p>{post.article.previewText}</p> : null}
</section>
) : null}
{post.quotedTweet && !quoted ? (
<PostCard post={post.quotedTweet} quoted />
) : null}
{!quoted ? (
<footer className="post-footer">
<span className="counts">
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
{post.likeCount ?? 0}
</span>
<a href={original} rel="noreferrer noopener" target="_blank">
元の投稿を開く
</a>
</footer>
) : null}
</article>
)
}
- Step 4: Verify and commit post rendering
Run:
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:
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, andflattenPostPages. -
Produces:
FeedRequestandusePostFeed(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:
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:
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:
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<LoadResult>
search: (options: {
data: {
query: string
product: SearchProduct
following: boolean
cursor?: string
}
}) => Promise<LoadResult>
}
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:
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:
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<typeof import('../use-post-feed')>()
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(<PostFeed request={undefined} />)
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(<PostFeed request={{ kind: 'user', target: 'a' }} />)
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(<PostFeed request={{ kind: 'user', target: 'a' }} />)
expect(screen.getByText('kept')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
expect(fetchNextPage).toHaveBeenCalledOnce()
})
})
Run:
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:
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<HTMLDivElement>(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 <p className="state">投稿を取得しています…</p>
}
if (query.isError && posts.length === 0) {
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
return (
<div className="state" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button onClick={() => void query.refetch()} type="button">
再試行
</button>
) : null}
</div>
)
}
if (posts.length === 0) {
return <p className="state">条件に一致する投稿はありません。</p>
}
return (
<section aria-live="polite" className="feed">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
<div aria-hidden ref={sentinel} />
{query.isFetchingNextPage ? (
<div className="loading-rail" role="status">
次の投稿を取得しています…
</div>
) : null}
{query.isFetchNextPageError ? (
<div className="state" role="alert">
<p>続きの投稿を取得できませんでした。</p>
<button onClick={() => void query.fetchNextPage()} type="button">
再試行
</button>
</div>
) : null}
{!query.hasNextPage ? (
<p className="state">これ以上の投稿はありません。</p>
) : null}
</section>
)
}
- Step 6: Connect feeds to route-controlled intent
In src/routes/user.tsx, import PostFeed and render after the form:
<PostFeed
request={target ? { kind: 'user', target } : undefined}
/>
In src/routes/search.tsx, render:
<PostFeed
request={
values.q
? {
kind: 'search',
query: values.q,
product: values.product,
following: values.following,
}
: undefined
}
/>
- Step 7: Verify and commit infinite loading
Run:
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:
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:
it('uses a labelled grouping for ranking controls', () => {
render(
<SearchForm
initialQuery=""
initialProduct="Latest"
initialFollowing={false}
onSubmit={vi.fn()}
/>,
)
expect(
screen.getByRole('group', { name: '並び順' }),
).toBeInTheDocument()
})
Extend post-card.test.tsx with:
it('does not turn the author into a discovery link', () => {
render(<PostCard post={richPost} />)
expect(screen.queryByRole('link', { name: 'Yuta' })).not.toBeInTheDocument()
})
Run:
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:
: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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
BIRD_PROFILE_NAME=
Extend .gitignore to exactly:
.superpowers/
.env
.env.local
.output/
dist/
node_modules/
playwright-report/
test-results/
Create README.md with these sections and commands:
# 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:
nix develop -c pnpm run build:dist
nix develop -c pnpm run lint
nix develop -c pnpm test
git status --short
From Twitter Lite:
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.mdand the design docs match the delivered commands and behavior. -
AGENTS.mdandCLAUDE.mdremain absent because no agent-specific instruction changed. -
Step 8: Commit final verification assets and docs
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:
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.