Files
twitter-lite/docs/superpowers/plans/2026-07-13-twitter-lite.md
T

80 KiB
Raw Blame History

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 <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/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:

{
  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: userPageInputSchema and searchPageInputSchema.

  • 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 loadUserPosts and searchPosts.

  • 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 /user and /search routes 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: 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:

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, 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:

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.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

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.