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

81 KiB
Raw Permalink 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.

Subsequent extension: tweet detail and conversation loading are implemented by 2026-07-13-tweet-detail-thread.md and specified by ../specs/2026-07-13-tweet-detail-thread-design.md.

Goal: Build a localhost-only TanStack Start reader that loads user timelines and Top/Latest searches through a read-only Bird server boundary.

Architecture: First extend the sibling Bird repository with a typed search product option. Then build Twitter Lite as a TanStack Start application whose server functions call Bird, while TanStack Query owns cursor pagination and the browser renders the Mist Instrument interface.

Tech Stack: Node.js 22, pnpm 11, Nix, TypeScript, React 19, TanStack Start/Router/Query, Zod 4, Nitro, Vitest, Testing Library, Playwright, Biome, hand-written CSS.

Global Constraints

  • Bind development and production to 127.0.0.1 by default; allow an explicit host override for Tailscale testing.
  • Read TWITTER_RELAY_BASE_URL only on the server; pass BIRD_PROFILE_NAME explicitly to Bird.
  • Do not expose a generic relay endpoint or any Bird mutation method.
  • Show no posts until a handle, profile URL, or search query is submitted.
  • Support only Top and Latest search products; Latest remains Bird's default.
  • Keep home, recommendations, trends, notifications, saved history, autocomplete, and all write actions out of scope.
  • Load 20 posts per cursor page and continue automatically without a fixed page limit.
  • Use Mist Instrument tokens and no remote fonts; respect keyboard focus and prefers-reduced-motion.
  • Work test-first for every behavior change and run the pre-commit documentation check in both repositories.
  • Consume the separately authorized and published @yuta/[email protected]; do not modify Bird from the app repository.

File Map

Bird repository (../bird)

  • src/lib/twitter-client-search.ts — typed Top/Latest contract and GraphQL variable.
  • src/lib/index.ts — public type exports.
  • src/commands/search.ts — CLI --product option.
  • tests/twitter-client.search-bookmarks.test.ts — request and pagination behavior.
  • tests/commands.search.test.ts — CLI forwarding behavior.
  • tests/library-exports.test.ts — public type availability.
  • README.md — library and CLI usage.

Twitter Lite repository

  • flake.nix / flake.lock — Node, pnpm, and Chromium development environment.
  • package.json / pnpm-lock.yaml / pnpm-workspace.yaml — pinned toolchain and scripts.
  • vite.config.ts / tsconfig.json / vitest.config.ts / playwright.config.ts / biome.json — build and test configuration.
  • src/router.tsx / src/routes/*.tsx — TanStack Router setup and User/Search routes.
  • src/features/posts/inputs.ts — handle, URL, and raw-query normalization.
  • src/features/posts/types.ts — application page/result/error contracts.
  • src/features/posts/page.ts — page flattening and ID deduplication.
  • src/features/posts/post-service.ts — testable BirdReader adapter.
  • src/features/posts/bird-client.server.ts — the only runtime import of @yuta/bird.
  • src/features/posts/server-functions.ts — validated TanStack Start RPC boundary.
  • src/features/posts/use-post-feed.ts — infinite-query configuration.
  • src/features/posts/components/*.tsx — forms, feed states, post cards, and media.
  • src/styles.css — Mist Instrument tokens and responsive styling.
  • tests/e2e/* — deterministic mock relay and browser flow.
  • tests/live/relay.test.ts — opt-in read-only relay smoke test.
  • README.md — setup, commands, safety boundary, and feature scope.

Task 1: Extend Bird with Top/Latest search products

Files:

  • Clone: ../bird from https://git.yutakobayashi.com/yuta/bird
  • Modify: ../bird/src/lib/twitter-client-search.ts
  • Modify: ../bird/src/lib/index.ts
  • Modify: ../bird/src/commands/search.ts
  • Modify: ../bird/tests/twitter-client.search-bookmarks.test.ts
  • Modify: ../bird/tests/commands.search.test.ts
  • Modify: ../bird/tests/library-exports.test.ts
  • Modify: ../bird/README.md
  • Modify: ../bird/CHANGELOG.md

Interfaces:

  • Produces: SearchProduct = 'Top' | 'Latest'.

  • Produces: SearchFetchOptions.product?: SearchProduct.

  • Produces: TwitterClient.search(query, count, { product }) and getAllSearchResults(query, { product }).

  • Produces: bird search <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: published @yuta/[email protected] through Gitea Packages.

  • Produces: getRouter() with an SSR-aware QueryClient.

  • Produces: a buildable TanStack Start/Nitro application at http://127.0.0.1:3000.

  • Step 1: Add the Nix development shell

Create flake.nix:

{
  description = "Intentional X reader";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

  outputs = { nixpkgs, ... }:
    let
      systems = [ "x86_64-linux" "aarch64-linux" ];
      forAllSystems = nixpkgs.lib.genAttrs systems;
    in {
      devShells = forAllSystems (system:
        let pkgs = import nixpkgs { inherit system; };
        in {
          default = pkgs.mkShell {
            packages = with pkgs; [ nodejs_22 pnpm chromium ];
            PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
            PLAYWRIGHT_CHROMIUM_EXECUTABLE =
              "${pkgs.chromium}/bin/chromium";
          };
        });
    };
}

Run nix flake lock to generate flake.lock.

  • Step 2: Add pinned package and tool configuration

Create .npmrc:

@yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/

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": "0.10.0",
    "nitro": "3.0.260610-beta",
    "react": "19.2.7",
    "react-dom": "19.2.7",
    "zod": "4.4.3"
  },
  "devDependencies": {
    "@biomejs/biome": "2.5.3",
    "@playwright/test": "1.61.1",
    "@tanstack/router-cli": "1.167.18",
    "@testing-library/dom": "10.4.1",
    "@testing-library/jest-dom": "6.9.1",
    "@testing-library/react": "16.3.2",
    "@types/node": "26.1.1",
    "@types/react": "19.2.17",
    "@types/react-dom": "19.2.3",
    "@vitejs/plugin-react": "6.0.3",
    "jsdom": "29.1.1",
    "typescript": "7.0.2",
    "vite": "8.1.4",
    "vitest": "4.1.10"
  }
}

Create pnpm-workspace.yaml:

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 published Bird package.

  • Produces: deterministic desktop/mobile browser coverage and an opt-in read-only live smoke test.

  • Step 1: Add the browser configuration

Create playwright.config.ts:

import { defineConfig, devices } from '@playwright/test'

const appPort = 4173
const relayPort = 6911
const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE

if (!chromiumExecutable) {
  throw new Error('Run E2E tests through `nix develop` to provide Chromium.')
}

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: false,
  workers: 1,
  use: {
    baseURL: `http://127.0.0.1:${appPort}`,
    launchOptions: { executablePath: chromiumExecutable },
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
  },
  webServer: [
    {
      command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
      port: relayPort,
      reuseExistingServer: false,
      timeout: 120_000,
    },
    {
      command: `TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
      url: `http://127.0.0.1:${appPort}`,
      reuseExistingServer: false,
      timeout: 120_000,
    },
  ],
  projects: [
    { name: 'desktop', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile', use: { ...devices['Pixel 7'] } },
  ],
})
  • Step 2: Create a deterministic read-only relay

Create tests/e2e/mock-relay.mjs. The response-shape excerpt below is only part of the mock: the executable implementation must also enforce the request contract listed after it.

const tweet = (id, text, username = 'yuta') => ({
  entryId: `tweet-${id}`,
  content: {
    itemContent: {
      tweet_results: {
        result: {
          rest_id: id,
          legacy: {
            full_text: text,
            created_at: 'Mon Jul 13 00:00:00 +0000 2026',
            reply_count: 1,
            retweet_count: 2,
            favorite_count: 3,
            conversation_id_str: id,
          },
          core: {
            user_results: {
              result: {
                legacy: {
                  screen_name: username,
                  name: 'Yuta',
                },
              },
            },
          },
        },
      },
    },
  },
})

const cursor = (value) => ({
  entryId: 'cursor-bottom',
  content: { cursorType: 'Bottom', value },
})

Route by the operation-name suffix, never the query ID. Require x-profile-name: e2e; require GET for UserByScreenName and UserTweets; and require POST plus JSON { features, queryId } for SearchTimeline, whose variables remain in the URL. Validate only the minimal fixture variables, not exact query IDs or complete feature bags. Return 405 for a wrong method, 403 for a wrong profile, 400 for malformed supported input, and 501 for an unsupported operation. Never return 404 or make an outbound request.

Key transient state by raw query. The first cursor request for each retry-<project> query returns 503 once, then succeeds on explicit retry. A slow-<project> cursor request is delayed long enough to observe the loading rail. Every page carrying a cursor also carries a parseable tweet, and page two omits the cursor.

  • Step 3: Write failing end-to-end flows

Create tests/e2e/reader.spec.ts:

import { expect, test } from '@playwright/test'

let consoleErrors: string[]
let pageErrors: string[]

test.beforeEach(async ({ page }) => {
  consoleErrors = []
  pageErrors = []
  page.on('console', (message) => {
    if (message.type() === 'error') consoleErrors.push(message.text())
  })
  page.on('pageerror', (error) => pageErrors.push(error.message))
})

test.afterEach(() => {
  expect(consoleErrors).toEqual([])
  expect(pageErrors).toEqual([])
})

test('requires intent and infinitely loads a user timeline', async ({ page }) => {
  await page.goto('/')
  await expect(page).toHaveURL(/\/user\?target=/)
  await expect(page.locator('article')).toHaveCount(0)

  await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta')
  await page.getByRole('button', { name: '表示' }).click()

  await expect(page.getByText('user page 1')).toBeVisible()
  await expect(page.getByText('user page 2')).toBeVisible()
  await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
  await expect(page).toHaveScreenshot('mist-user.png', {
    animations: 'disabled',
    fullPage: true,
  })
})

test('searches Top posts from followed accounts', async ({ page }) => {
  await page.goto('/search?q=&product=Latest&following=false')
  await page.getByLabel('検索語').fill('AI lang:ja')
  await page.getByLabel('人気順').check()
  await page.getByLabel('フォロー中のみ').check()
  await page.getByRole('button', { name: '検索' }).click()

  await expect(page.getByText('Top · follows page 1')).toBeVisible()
  await expect(page.getByText('Top · follows page 2')).toBeVisible()
})

test('keeps discovery surfaces absent', async ({ page }) => {
  await page.goto('/user?target=')
  await expect(
    page.getByRole('link', { name: /おすすめ|トレンド|通知/ }),
  ).toHaveCount(0)
  await expect(page.getByRole('button', { name: /いいね|再投稿|フォロー/ })).toHaveCount(0)
})

Wait for the cold SSR page to hydrate before manipulating controlled fields. In addition to the excerpted happy paths, cover a later-page 503 that retains page one and succeeds only after Retry, the exact keyboard Tab order across both navigation links and all form controls, absent discovery/mutation links and controls, and a slow-<project> request under reduced motion whose loading rail pseudo-element has animation-name: none. Run every flow in both projects and let the slow request finish before teardown.

Run:

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:

// @vitest-environment node

import { TwitterClient } from '@yuta/bird'
import { describe, expect, it } from 'vitest'

describe.runIf(process.env.TWITTER_LITE_LIVE === '1')(
  'configured relay',
  () => {
    it(
      'performs one read-only Top search',
      async () => {
        const relayBaseUrl = process.env.TWITTER_RELAY_BASE_URL
        if (!relayBaseUrl) {
          throw new Error('TWITTER_RELAY_BASE_URL is required for the live test')
        }
        const client = new TwitterClient({
          relayBaseUrl,
          profileName: process.env.BIRD_PROFILE_NAME,
          timeoutMs: 20_000,
        })
        const query = process.env.TWITTER_LITE_LIVE_QUERY ?? 'OpenAI'
        const result = await client.search(query, 1, { product: 'Top' })
        expect(result.success).toBe(true)
      },
      30_000,
    )
  },
)

Run:

nix develop -c pnpm test:live

Expected: when relay/profile environment configuration is actually available, one read-only Top search test passes. Do not run this opt-in command unconditionally or log relay configuration, response bodies, or post content.

  • Step 6: Document setup and intentional omissions

Create .env.example:

TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
BIRD_PROFILE_NAME=

Extend .gitignore to exactly:

.superpowers/
.worktrees/
.env*
!.env.example
.output/
dist/
node_modules/
playwright-report/
test-results/

Create README.md with these sections and commands:

# Twitter Lite

An intentional, read-only X reader that shows content only after a deliberate
handle, profile URL, or search query.

## Features

- User timelines from a manually entered handle or profile URL
- Top and Latest search
- Optional `filter:follows` search
- Infinite cursor loading
- Photos, videos, quotes, articles, and quiet engagement counts
- No home feed, recommendations, trends, notifications, history, or writes

## Setup

Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
routes `@yuta` packages to Gitea Packages, and `@yuta/[email protected]` contains
the required Top/Latest interface.

```bash
nix develop -c pnpm install --frozen-lockfile
```

Set `TWITTER_RELAY_BASE_URL` at runtime and, when the relay has multiple
profiles, `BIRD_PROFILE_NAME`. Bird and both values are read only by
server-side code and never reach the browser.

## Commands

```bash
nix develop -c pnpm dev
nix develop -c pnpm dev:tailscale
nix develop -c pnpm test
nix develop -c pnpm test:e2e
nix develop -c pnpm test:live
nix develop -c pnpm build
nix develop -c pnpm start
```

The default bind address is `127.0.0.1`. E2E runs through Nix with the system
Chromium; the live command performs exactly one explicitly configured read.
`dev:tailscale` binds `0.0.0.0`, including LAN interfaces as well as Tailscale,
so use it only on a trusted network. Find the Tailscale address with
`tailscale ip -4`.

## Reliability

Bird uses X's internal web GraphQL operations through the configured safe
relay. X may change query IDs or response shapes without notice.
  • Step 7: Run the complete verification matrix

From ../bird:

nix develop -c pnpm run build:dist
nix develop -c pnpm run lint
nix develop -c pnpm test
git status --short --branch
git rev-list --count origin/main..HEAD
git log --oneline origin/main..HEAD

From Twitter Lite:

nix develop -c pnpm install --frozen-lockfile
nix develop -c pnpm check:routes
nix develop -c pnpm lint
nix develop -c pnpm typecheck
nix develop -c pnpm test
nix develop -c pnpm build
nix develop -c pnpm test:e2e
nix develop -c pnpm test:live # only with configured relay environment
git diff --check

Expected:

  • Bird build, lint, and all non-live tests pass.

  • Twitter Lite lint, typecheck, unit tests, desktop/mobile browser tests, live read test, and production build pass.

  • Bird has one local feature commit and no remote push.

  • Fresh .output/public scans contain no Bird/client or relay environment names; source contains only the two intended server functions and no mutation/generic proxy calls.

  • README.md and the design docs match the delivered commands and behavior.

  • AGENTS.md and CLAUDE.md remain absent because no agent-specific instruction changed.

  • Step 8: Commit final verification assets and docs

git add \
  .env.example .gitignore README.md playwright.config.ts \
  src/routes/__root.tsx tests/e2e tests/live docs
git commit -m "test: verify intentional reader flows"

Run one fresh post-commit audit:

git status --short --branch
git log --oneline --decorate -8
nix develop -c pnpm test
nix develop -c pnpm test:e2e
nix develop -c pnpm build
git diff --check

Expected: clean status, the planned commits are present, all unit tests pass, and the production build exits zero.