3241 lines
81 KiB
Markdown
3241 lines
81 KiB
Markdown
# 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:
|
||
|
||
```bash
|
||
git clone https://git.yutakobayashi.com/yuta/bird ../bird
|
||
cd ../bird
|
||
nix develop -c pnpm install --frozen-lockfile
|
||
nix develop -c pnpm exec vitest run \
|
||
tests/twitter-client.search-bookmarks.test.ts \
|
||
tests/commands.search.test.ts \
|
||
tests/library-exports.test.ts
|
||
```
|
||
|
||
Expected: the checkout is at or after `0caf2677` and the existing focused tests pass.
|
||
|
||
- [ ] **Step 2: Add failing library tests**
|
||
|
||
In `tests/twitter-client.search-bookmarks.test.ts`, extend `retries on 404 and posts search payload` so the parsed request type and assertion are:
|
||
|
||
```ts
|
||
const parsed = JSON.parse(urlVars as string) as {
|
||
rawQuery?: string
|
||
product?: string
|
||
}
|
||
expect(parsed.rawQuery).toBe('needle')
|
||
expect(parsed.product).toBe('Latest')
|
||
```
|
||
|
||
Change `paginates search results using the bottom cursor` to call and assert:
|
||
|
||
```ts
|
||
const result = await client.search('needle', 3, { product: 'Top' })
|
||
|
||
const firstVars = JSON.parse(
|
||
new URL(mockFetch.mock.calls[0][0] as string).searchParams.get(
|
||
'variables',
|
||
) as string,
|
||
) as { cursor?: string; product?: string }
|
||
const secondVars = JSON.parse(
|
||
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get(
|
||
'variables',
|
||
) as string,
|
||
) as { cursor?: string; product?: string }
|
||
|
||
expect(firstVars.product).toBe('Top')
|
||
expect(secondVars.product).toBe('Top')
|
||
```
|
||
|
||
- [ ] **Step 3: Run the library tests and verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm exec vitest run tests/twitter-client.search-bookmarks.test.ts
|
||
```
|
||
|
||
Expected: TypeScript reports that `product` is not part of `SearchFetchOptions`, or the Top assertion receives `Latest`.
|
||
|
||
- [ ] **Step 4: Implement the minimal library contract**
|
||
|
||
In `src/lib/twitter-client-search.ts`, replace the search option definitions with:
|
||
|
||
```ts
|
||
export type SearchProduct = 'Top' | 'Latest'
|
||
|
||
/** Options for search methods */
|
||
export interface SearchFetchOptions {
|
||
/** Include raw GraphQL response in `_raw` field */
|
||
includeRaw?: boolean
|
||
/** Search result ranking (default: Latest) */
|
||
product?: SearchProduct
|
||
}
|
||
|
||
/** Options for paged search methods */
|
||
export interface SearchPaginationOptions extends SearchFetchOptions {
|
||
maxPages?: number
|
||
/** Starting cursor for pagination (resume from previous fetch) */
|
||
cursor?: string
|
||
}
|
||
```
|
||
|
||
In `searchPaged()`, destructure once:
|
||
|
||
```ts
|
||
const { includeRaw = false, maxPages, product = 'Latest' } = options
|
||
```
|
||
|
||
Use the captured value in every request:
|
||
|
||
```ts
|
||
const variables = {
|
||
rawQuery: query,
|
||
count: pageCount,
|
||
querySource: 'typed_query',
|
||
product,
|
||
...(pageCursor ? { cursor: pageCursor } : {}),
|
||
}
|
||
```
|
||
|
||
In `src/lib/index.ts`, export the public types:
|
||
|
||
```ts
|
||
export type {
|
||
SearchFetchOptions,
|
||
SearchPaginationOptions,
|
||
SearchProduct,
|
||
} from './twitter-client-search.js'
|
||
```
|
||
|
||
- [ ] **Step 5: Run the library tests and verify GREEN**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm exec vitest run tests/twitter-client.search-bookmarks.test.ts
|
||
```
|
||
|
||
Expected: every search/bookmark test passes.
|
||
|
||
- [ ] **Step 6: Add failing CLI and export tests**
|
||
|
||
Add to `tests/commands.search.test.ts`:
|
||
|
||
```ts
|
||
it('passes --product to non-paged search', async () => {
|
||
registerSearchCommands(program, mockContext as CliContext)
|
||
const searchSpy = vi
|
||
.spyOn(TwitterClient.prototype, 'search')
|
||
.mockResolvedValue({ success: true, tweets: [] })
|
||
|
||
try {
|
||
await program.parseAsync([
|
||
'node',
|
||
'bird',
|
||
'search',
|
||
'cats',
|
||
'--product',
|
||
'Top',
|
||
])
|
||
expect(searchSpy).toHaveBeenCalledWith('cats', 10, {
|
||
includeRaw: false,
|
||
product: 'Top',
|
||
})
|
||
} finally {
|
||
searchSpy.mockRestore()
|
||
}
|
||
})
|
||
```
|
||
|
||
Update the existing paged-search test to pass `--product Top` and require `product: 'Top'` in the expected options.
|
||
|
||
Add explicit invalid-choice coverage:
|
||
|
||
```ts
|
||
it('rejects unsupported search products', async () => {
|
||
registerSearchCommands(program, mockContext as CliContext)
|
||
program.exitOverride()
|
||
|
||
await expect(
|
||
program.parseAsync([
|
||
'node',
|
||
'bird',
|
||
'search',
|
||
'cats',
|
||
'--product',
|
||
'Media',
|
||
]),
|
||
).rejects.toMatchObject({ code: 'commander.invalidArgument' })
|
||
})
|
||
```
|
||
|
||
In `tests/library-exports.test.ts`, import and exercise the types:
|
||
|
||
```ts
|
||
import {
|
||
type SearchFetchOptions,
|
||
type SearchProduct,
|
||
TwitterClient,
|
||
} from '../src/index.js'
|
||
|
||
it('exposes search product types', () => {
|
||
const product: SearchProduct = 'Top'
|
||
const options: SearchFetchOptions = { product }
|
||
|
||
expect(options.product).toBe('Top')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 7: Run the CLI/export tests and verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm exec vitest run \
|
||
tests/commands.search.test.ts \
|
||
tests/library-exports.test.ts
|
||
```
|
||
|
||
Expected: the CLI rejects or ignores `--product` before the command implementation changes.
|
||
|
||
- [ ] **Step 8: Implement the CLI option**
|
||
|
||
In `src/commands/search.ts`, use Commander's choice validation:
|
||
|
||
```ts
|
||
import { type Command, Option } from 'commander'
|
||
import type { SearchProduct } from '../lib/twitter-client-search.js'
|
||
```
|
||
|
||
Register the option after `--cursor`:
|
||
|
||
```ts
|
||
.addOption(
|
||
new Option('--product <product>', 'Search product')
|
||
.choices(['Top', 'Latest'] satisfies SearchProduct[])
|
||
.default('Latest'),
|
||
)
|
||
```
|
||
|
||
Add `product?: SearchProduct` to the search command option type, then construct:
|
||
|
||
```ts
|
||
const includeRaw = cmdOpts.jsonFull ?? false
|
||
const product = cmdOpts.product ?? 'Latest'
|
||
const searchOptions = { includeRaw, product }
|
||
const paginationOptions = {
|
||
includeRaw,
|
||
maxPages,
|
||
cursor: pagination.cursor,
|
||
product,
|
||
}
|
||
```
|
||
|
||
Leave `mentions` unchanged so it continues using Bird's Latest default.
|
||
|
||
- [ ] **Step 9: Update Bird documentation and verify the repository**
|
||
|
||
Add these concrete examples to `README.md`:
|
||
|
||
```md
|
||
bird search "AI lang:ja" --product Top -n 20
|
||
bird search "from:steipete" --product Latest -n 20
|
||
|
||
const popular = await client.search('AI lang:ja', 20, {
|
||
product: 'Top',
|
||
})
|
||
```
|
||
|
||
Document that Latest is the default and add `[--product Top|Latest]` to the command synopsis. Add this new section above the released entries in `CHANGELOG.md`:
|
||
|
||
```md
|
||
## Unreleased
|
||
|
||
### Added
|
||
|
||
- Add Top and Latest product selection to library and CLI search.
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm exec vitest run \
|
||
tests/twitter-client.search-bookmarks.test.ts \
|
||
tests/commands.search.test.ts \
|
||
tests/library-exports.test.ts
|
||
nix develop -c pnpm run build:dist
|
||
nix develop -c pnpm run lint
|
||
nix develop -c pnpm test
|
||
git diff --check
|
||
```
|
||
|
||
Expected: focused tests, all 416+ non-live tests, build, and lint pass with no diff whitespace errors.
|
||
|
||
- [ ] **Step 10: Commit Bird**
|
||
|
||
```bash
|
||
git add \
|
||
src/lib/twitter-client-search.ts \
|
||
src/lib/index.ts \
|
||
src/commands/search.ts \
|
||
tests/twitter-client.search-bookmarks.test.ts \
|
||
tests/commands.search.test.ts \
|
||
tests/library-exports.test.ts \
|
||
README.md CHANGELOG.md
|
||
git commit -m "feat: support ranked tweet search"
|
||
```
|
||
|
||
### Task 2: Create the reproducible TanStack Start foundation
|
||
|
||
**Files:**
|
||
- Create: `flake.nix`
|
||
- Generate: `flake.lock`
|
||
- Create: `package.json`
|
||
- Generate: `pnpm-lock.yaml`
|
||
- Create: `pnpm-workspace.yaml`
|
||
- Create: `tsconfig.json`
|
||
- Create: `vite.config.ts`
|
||
- Create: `vitest.config.ts`
|
||
- Create: `biome.json`
|
||
- Create: `src/router.tsx`
|
||
- Create: `src/routes/__root.tsx`
|
||
- Create: `src/routes/index.tsx`
|
||
- Create: `src/routes/index.test.tsx`
|
||
- Create: `src/styles.css`
|
||
- Create: `tests/setup.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: published `@yuta/[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`:
|
||
|
||
```nix
|
||
{
|
||
description = "Intentional X reader";
|
||
|
||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||
|
||
outputs = { nixpkgs, ... }:
|
||
let
|
||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||
in {
|
||
devShells = forAllSystems (system:
|
||
let pkgs = import nixpkgs { inherit system; };
|
||
in {
|
||
default = pkgs.mkShell {
|
||
packages = with pkgs; [ nodejs_22 pnpm chromium ];
|
||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||
PLAYWRIGHT_CHROMIUM_EXECUTABLE =
|
||
"${pkgs.chromium}/bin/chromium";
|
||
};
|
||
});
|
||
};
|
||
}
|
||
```
|
||
|
||
Run `nix flake lock` to generate `flake.lock`.
|
||
|
||
- [ ] **Step 2: Add pinned package and tool configuration**
|
||
|
||
Create `.npmrc`:
|
||
|
||
```ini
|
||
@yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/
|
||
```
|
||
|
||
Create `package.json`:
|
||
|
||
```json
|
||
{
|
||
"name": "twitter-lite",
|
||
"private": true,
|
||
"type": "module",
|
||
"packageManager": "[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`:
|
||
|
||
```yaml
|
||
allowBuilds:
|
||
esbuild: true
|
||
```
|
||
|
||
Create `tsconfig.json`:
|
||
|
||
```json
|
||
{
|
||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"],
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"jsx": "react-jsx",
|
||
"module": "ESNext",
|
||
"moduleResolution": "Bundler",
|
||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||
"types": ["vite/client", "node"],
|
||
"paths": { "#/*": ["./src/*"] },
|
||
"allowImportingTsExtensions": true,
|
||
"noEmit": true,
|
||
"skipLibCheck": true,
|
||
"strict": true,
|
||
"noUncheckedIndexedAccess": true,
|
||
"noUnusedLocals": true,
|
||
"noUnusedParameters": true
|
||
}
|
||
}
|
||
```
|
||
|
||
Create `vite.config.ts`:
|
||
|
||
```ts
|
||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||
import viteReact from '@vitejs/plugin-react'
|
||
import { defineConfig } from 'vite'
|
||
import { nitro } from 'nitro/vite'
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
nitro(),
|
||
tanstackStart(),
|
||
viteReact(),
|
||
],
|
||
})
|
||
```
|
||
|
||
Create `vitest.config.ts`:
|
||
|
||
```ts
|
||
import path from 'node:path'
|
||
import { defineConfig } from 'vitest/config'
|
||
|
||
export default defineConfig({
|
||
resolve: { alias: { '#': path.resolve(import.meta.dirname, 'src') } },
|
||
test: {
|
||
environment: 'jsdom',
|
||
setupFiles: ['./tests/setup.ts'],
|
||
exclude: ['tests/e2e/**'],
|
||
},
|
||
})
|
||
```
|
||
|
||
Create `tests/setup.ts`:
|
||
|
||
```ts
|
||
import '@testing-library/jest-dom/vitest'
|
||
import { cleanup } from '@testing-library/react'
|
||
import { afterEach } from 'vitest'
|
||
|
||
afterEach(cleanup)
|
||
```
|
||
|
||
Create `biome.json`:
|
||
|
||
```json
|
||
{
|
||
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
||
"files": { "includes": ["**", "!src/routeTree.gen.ts"] },
|
||
"formatter": { "enabled": true, "indentStyle": "space" },
|
||
"linter": { "enabled": true, "rules": { "recommended": true } },
|
||
"javascript": {
|
||
"formatter": { "quoteStyle": "single", "semicolons": "asNeeded" }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Install dependencies and generate the route tree**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm install
|
||
nix develop -c pnpm generate-routes
|
||
```
|
||
|
||
Expected: `pnpm-lock.yaml` and `src/routeTree.gen.ts` are generated without ignored-build warnings.
|
||
|
||
- [ ] **Step 4: Write the failing foundation test**
|
||
|
||
Create `src/routes/index.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { Home } from './index'
|
||
|
||
describe('Home', () => {
|
||
it('starts without ambient post content', () => {
|
||
render(<Home />)
|
||
|
||
expect(screen.getByText('目的を決めてから開く')).toBeInTheDocument()
|
||
expect(screen.queryByRole('article')).not.toBeInTheDocument()
|
||
})
|
||
})
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/routes/index.test.tsx
|
||
```
|
||
|
||
Expected: FAIL because `Home` and its intentional-reading copy do not exist.
|
||
|
||
- [ ] **Step 5: Add the minimal router and root document**
|
||
|
||
Create `src/router.tsx`:
|
||
|
||
```tsx
|
||
import { QueryClient } from '@tanstack/react-query'
|
||
import { createRouter } from '@tanstack/react-router'
|
||
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||
import { routeTree } from './routeTree.gen'
|
||
|
||
export function getRouter() {
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: {
|
||
queries: { retry: false, staleTime: 0, gcTime: 0 },
|
||
},
|
||
})
|
||
const router = createRouter({
|
||
routeTree,
|
||
context: { queryClient },
|
||
scrollRestoration: true,
|
||
defaultPreload: false,
|
||
})
|
||
|
||
setupRouterSsrQueryIntegration({ router, queryClient })
|
||
return router
|
||
}
|
||
|
||
declare module '@tanstack/react-router' {
|
||
interface Register {
|
||
router: ReturnType<typeof getRouter>
|
||
}
|
||
}
|
||
```
|
||
|
||
Create `src/routes/__root.tsx`:
|
||
|
||
```tsx
|
||
import type { QueryClient } from '@tanstack/react-query'
|
||
import {
|
||
HeadContent,
|
||
Scripts,
|
||
createRootRouteWithContext,
|
||
} from '@tanstack/react-router'
|
||
import appCss from '../styles.css?url'
|
||
|
||
type RouterContext = { queryClient: QueryClient }
|
||
|
||
export const Route = createRootRouteWithContext<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`:
|
||
|
||
```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`:
|
||
|
||
```css
|
||
:root {
|
||
color: #1e3238;
|
||
background: #dfe7e9;
|
||
font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
min-width: 320px;
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
}
|
||
|
||
button,
|
||
input {
|
||
font: inherit;
|
||
}
|
||
|
||
.landing {
|
||
width: min(42rem, calc(100% - 2rem));
|
||
margin: 15vh auto;
|
||
padding: 2rem;
|
||
border: 1px solid #c1cfd2;
|
||
border-radius: 1rem;
|
||
background: #f8faf9;
|
||
}
|
||
|
||
.brand {
|
||
color: #70858a;
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.2em;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify and commit the foundation**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm generate-routes
|
||
nix develop -c pnpm test -- src/routes/index.test.tsx
|
||
nix develop -c pnpm typecheck
|
||
nix develop -c pnpm lint
|
||
nix develop -c pnpm build
|
||
git diff --check
|
||
```
|
||
|
||
Expected: the route test, typecheck, lint, and Nitro build pass.
|
||
|
||
Commit:
|
||
|
||
```bash
|
||
git add \
|
||
flake.nix flake.lock package.json pnpm-lock.yaml pnpm-workspace.yaml \
|
||
tsconfig.json vite.config.ts vitest.config.ts biome.json \
|
||
src/router.tsx src/routeTree.gen.ts src/routes src/styles.css tests/setup.ts
|
||
git commit -m "chore: scaffold twitter lite"
|
||
```
|
||
|
||
### Task 3: Define validated inputs and page merging
|
||
|
||
**Files:**
|
||
- Create: `src/features/posts/types.ts`
|
||
- Create: `src/features/posts/inputs.ts`
|
||
- Create: `src/features/posts/inputs.test.ts`
|
||
- Create: `src/features/posts/page.ts`
|
||
- Create: `src/features/posts/page.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces: `normalizeUserTarget(value: string): string`.
|
||
- Produces: `buildSearchQuery(value: string, following: boolean): string`.
|
||
- Produces: `userPageInputSchema` and `searchPageInputSchema`.
|
||
- Produces: `flattenPostPages(pages: PostPage[]): Post[]`.
|
||
|
||
- [ ] **Step 1: Write failing input tests**
|
||
|
||
Create `src/features/posts/inputs.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { buildSearchQuery, normalizeUserTarget } from './inputs'
|
||
|
||
describe('normalizeUserTarget', () => {
|
||
it.each([
|
||
['@tan_stack', 'tan_stack'],
|
||
['tan_stack', 'tan_stack'],
|
||
['https://x.com/tan_stack', 'tan_stack'],
|
||
['https://twitter.com/tan_stack/', 'tan_stack'],
|
||
])('normalizes %s', (input, expected) => {
|
||
expect(normalizeUserTarget(input)).toBe(expected)
|
||
})
|
||
|
||
it.each([
|
||
'',
|
||
'not valid',
|
||
'https://example.com/tan_stack',
|
||
'https://x.com/tan_stack/status/1',
|
||
])('rejects %s', (input) => {
|
||
expect(() => normalizeUserTarget(input)).toThrow()
|
||
})
|
||
})
|
||
|
||
describe('buildSearchQuery', () => {
|
||
it('keeps a deliberate query unchanged', () => {
|
||
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja')
|
||
})
|
||
|
||
it('adds the follows operator once', () => {
|
||
expect(buildSearchQuery('AI lang:ja', true)).toBe(
|
||
'AI lang:ja filter:follows',
|
||
)
|
||
expect(buildSearchQuery('AI filter:follows', true)).toBe(
|
||
'AI filter:follows',
|
||
)
|
||
})
|
||
|
||
it('rejects an empty query', () => {
|
||
expect(() => buildSearchQuery(' ', false)).toThrow()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run the input tests and verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/inputs.test.ts
|
||
```
|
||
|
||
Expected: FAIL because `./inputs` does not exist.
|
||
|
||
- [ ] **Step 3: Implement shared contracts and input validation**
|
||
|
||
Create `src/features/posts/types.ts`:
|
||
|
||
```ts
|
||
import type { SearchProduct, TweetData } from '@yuta/bird'
|
||
|
||
export type Post = TweetData
|
||
|
||
export type PostPage = {
|
||
tweets: Post[]
|
||
nextCursor?: string
|
||
}
|
||
|
||
export type LoadErrorCode =
|
||
| 'invalid-input'
|
||
| 'user-not-found'
|
||
| 'user-unavailable'
|
||
| 'relay-config'
|
||
| 'timeout'
|
||
| 'upstream'
|
||
|
||
export type LoadError = {
|
||
code: LoadErrorCode
|
||
message: string
|
||
retryable: boolean
|
||
}
|
||
|
||
export type LoadResult =
|
||
| { ok: true; page: PostPage }
|
||
| { ok: false; error: LoadError }
|
||
|
||
export type UserPageInput = {
|
||
target: string
|
||
cursor?: string
|
||
}
|
||
|
||
export type SearchPageInput = {
|
||
query: string
|
||
product: SearchProduct
|
||
following: boolean
|
||
cursor?: string
|
||
}
|
||
```
|
||
|
||
Create `src/features/posts/inputs.ts`:
|
||
|
||
```ts
|
||
import { z } from 'zod'
|
||
|
||
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
||
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
||
|
||
export class InputError extends Error {}
|
||
|
||
export const userPageInputSchema = z.object({
|
||
target: z.string().trim().min(1).max(256),
|
||
cursor: z.string().min(1).optional(),
|
||
})
|
||
|
||
export const searchPageInputSchema = z.object({
|
||
query: z.string().trim().min(1).max(512),
|
||
product: z.enum(['Top', 'Latest']),
|
||
following: z.boolean(),
|
||
cursor: z.string().min(1).optional(),
|
||
})
|
||
|
||
export const userRouteSearchSchema = z.object({
|
||
target: z.string().catch(''),
|
||
})
|
||
|
||
export const postSearchRouteSchema = z.object({
|
||
q: z.string().catch(''),
|
||
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
||
following: z.boolean().catch(false),
|
||
})
|
||
|
||
function requireHandle(value: string): string {
|
||
if (!HANDLE.test(value)) {
|
||
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
|
||
}
|
||
return value
|
||
}
|
||
|
||
export function normalizeUserTarget(raw: string): string {
|
||
const value = raw.trim()
|
||
if (!value) {
|
||
throw new InputError('ハンドルまたはプロフィール URL を入力してください。')
|
||
}
|
||
if (value.startsWith('@')) {
|
||
return requireHandle(value.slice(1))
|
||
}
|
||
if (!value.includes('://')) {
|
||
return requireHandle(value)
|
||
}
|
||
|
||
let url: URL
|
||
try {
|
||
url = new URL(value)
|
||
} catch {
|
||
throw new InputError('プロフィール URL の形式を確認してください。')
|
||
}
|
||
if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) {
|
||
throw new InputError('x.com または twitter.com の URL を入力してください。')
|
||
}
|
||
const segments = url.pathname.split('/').filter(Boolean)
|
||
if (segments.length !== 1) {
|
||
throw new InputError('プロフィール URL を入力してください。')
|
||
}
|
||
return requireHandle(segments[0] ?? '')
|
||
}
|
||
|
||
export function buildSearchQuery(raw: string, following: boolean): string {
|
||
const query = raw.trim()
|
||
if (!query) {
|
||
throw new InputError('検索語を入力してください。')
|
||
}
|
||
if (query.length > 512) {
|
||
throw new InputError('検索語は 512 文字以内で入力してください。')
|
||
}
|
||
return following && !FOLLOWS.test(query)
|
||
? `${query} filter:follows`
|
||
: query
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the input tests and verify GREEN**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/inputs.test.ts
|
||
```
|
||
|
||
Expected: all normalization and query-building tests pass.
|
||
|
||
- [ ] **Step 5: Write failing page-merging tests**
|
||
|
||
Create `src/features/posts/page.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { flattenPostPages } from './page'
|
||
import type { Post, PostPage } from './types'
|
||
|
||
const post = (id: string): Post => ({
|
||
id,
|
||
text: `post-${id}`,
|
||
author: { username: `user-${id}`, name: `User ${id}` },
|
||
})
|
||
|
||
describe('flattenPostPages', () => {
|
||
it('preserves API order and removes duplicate IDs', () => {
|
||
const pages: PostPage[] = [
|
||
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
|
||
{ tweets: [post('2'), post('3')] },
|
||
]
|
||
|
||
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual([
|
||
'1',
|
||
'2',
|
||
'3',
|
||
])
|
||
})
|
||
})
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/page.test.ts
|
||
```
|
||
|
||
Expected: FAIL because `flattenPostPages` does not exist.
|
||
|
||
- [ ] **Step 6: Implement page merging and verify**
|
||
|
||
Create `src/features/posts/page.ts`:
|
||
|
||
```ts
|
||
import type { Post, PostPage } from './types'
|
||
|
||
export function flattenPostPages(pages: PostPage[]): Post[] {
|
||
const seen = new Set<string>()
|
||
return pages.flatMap(({ tweets }) =>
|
||
tweets.filter(({ id }) => {
|
||
if (seen.has(id)) return false
|
||
seen.add(id)
|
||
return true
|
||
}),
|
||
)
|
||
}
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- \
|
||
src/features/posts/inputs.test.ts \
|
||
src/features/posts/page.test.ts
|
||
nix develop -c pnpm typecheck
|
||
```
|
||
|
||
Expected: both suites and typecheck pass.
|
||
|
||
- [ ] **Step 7: Commit the domain layer**
|
||
|
||
```bash
|
||
git add src/features/posts
|
||
git commit -m "feat: validate intentional post requests"
|
||
```
|
||
|
||
### Task 4: Add the read-only Bird server boundary
|
||
|
||
**Files:**
|
||
- Create: `src/features/posts/post-service.ts`
|
||
- Create: `src/features/posts/post-service.test.ts`
|
||
- Create: `src/features/posts/bird-client.server.ts`
|
||
- Create: `src/features/posts/server-functions.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `normalizeUserTarget`, `buildSearchQuery`, and Bird's read methods.
|
||
- Produces: `loadUserPage(reader, input): Promise<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`:
|
||
|
||
```ts
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import { loadUserPage, searchPage, type BirdReader } from './post-service'
|
||
|
||
const reader = (): BirdReader => ({
|
||
getUserIdByUsername: vi
|
||
.fn()
|
||
.mockResolvedValue({ success: true, userId: '42' }),
|
||
getUserTweetsPaged: vi.fn().mockResolvedValue({
|
||
success: true,
|
||
tweets: [
|
||
{ id: '1', text: 'hello', author: { username: 'yuta', name: 'Yuta' } },
|
||
],
|
||
nextCursor: 'user-next',
|
||
}),
|
||
getAllSearchResults: vi.fn().mockResolvedValue({
|
||
success: true,
|
||
tweets: [],
|
||
nextCursor: 'search-next',
|
||
}),
|
||
})
|
||
|
||
describe('loadUserPage', () => {
|
||
it('resolves a handle and fetches exactly one page', async () => {
|
||
const client = reader()
|
||
const result = await loadUserPage(client, {
|
||
target: 'https://x.com/yuta',
|
||
cursor: 'cursor-1',
|
||
})
|
||
|
||
expect(client.getUserIdByUsername).toHaveBeenCalledWith('yuta')
|
||
expect(client.getUserTweetsPaged).toHaveBeenCalledWith('42', 20, {
|
||
cursor: 'cursor-1',
|
||
maxPages: 1,
|
||
pageDelayMs: 0,
|
||
})
|
||
expect(result).toMatchObject({
|
||
ok: true,
|
||
page: { nextCursor: 'user-next' },
|
||
})
|
||
})
|
||
|
||
it('returns a safe not-found error', async () => {
|
||
const client = reader()
|
||
vi.mocked(client.getUserIdByUsername).mockResolvedValue({
|
||
success: false,
|
||
error: 'User not found: private relay detail',
|
||
})
|
||
|
||
expect(await loadUserPage(client, { target: 'missing' })).toEqual({
|
||
ok: false,
|
||
error: {
|
||
code: 'user-not-found',
|
||
message: 'ユーザーが見つかりませんでした。',
|
||
retryable: false,
|
||
},
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('searchPage', () => {
|
||
it('forwards Top and appends follows once', async () => {
|
||
const client = reader()
|
||
await searchPage(client, {
|
||
query: 'AI lang:ja',
|
||
product: 'Top',
|
||
following: true,
|
||
cursor: 'cursor-2',
|
||
})
|
||
|
||
expect(client.getAllSearchResults).toHaveBeenCalledWith(
|
||
'AI lang:ja filter:follows',
|
||
{ product: 'Top', cursor: 'cursor-2', maxPages: 1 },
|
||
)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run the service tests and verify RED**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/post-service.test.ts
|
||
```
|
||
|
||
Expected: FAIL because `post-service.ts` does not exist.
|
||
|
||
- [ ] **Step 3: Implement the testable BirdReader adapter**
|
||
|
||
Create `src/features/posts/post-service.ts`:
|
||
|
||
```ts
|
||
import type { SearchProduct, SearchResult } from '@yuta/bird'
|
||
import { InputError, buildSearchQuery, normalizeUserTarget } from './inputs'
|
||
import type {
|
||
LoadError,
|
||
LoadResult,
|
||
SearchPageInput,
|
||
UserPageInput,
|
||
} from './types'
|
||
|
||
type UserLookupResult = {
|
||
success: boolean
|
||
userId?: string
|
||
error?: string
|
||
}
|
||
|
||
export interface BirdReader {
|
||
getUserIdByUsername(username: string): Promise<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:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/post-service.test.ts
|
||
```
|
||
|
||
Expected: all service behavior passes without a relay.
|
||
|
||
- [ ] **Step 5: Add the server-only client and RPCs**
|
||
|
||
Create `src/features/posts/bird-client.server.ts`:
|
||
|
||
```ts
|
||
import { TwitterClient } from '@yuta/bird'
|
||
import type { BirdReader } from './post-service'
|
||
|
||
let client: TwitterClient | undefined
|
||
|
||
export function getBirdReader(): BirdReader {
|
||
client ??= new TwitterClient({
|
||
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
|
||
profileName: process.env.BIRD_PROFILE_NAME,
|
||
timeoutMs: 20_000,
|
||
})
|
||
return client
|
||
}
|
||
```
|
||
|
||
Create `src/features/posts/server-functions.ts`:
|
||
|
||
```ts
|
||
import { createServerFn } from '@tanstack/react-start'
|
||
import { searchPageInputSchema, userPageInputSchema } from './inputs'
|
||
import { loadUserPage, searchPage } from './post-service'
|
||
import type { LoadResult } from './types'
|
||
|
||
const configFailure = (): LoadResult => ({
|
||
ok: false,
|
||
error: {
|
||
code: 'relay-config',
|
||
message: 'TWITTER_RELAY_BASE_URL を設定してください。',
|
||
retryable: false,
|
||
},
|
||
})
|
||
|
||
async function reader() {
|
||
const { getBirdReader } = await import('./bird-client.server')
|
||
return getBirdReader()
|
||
}
|
||
|
||
export const loadUserPosts = createServerFn({ method: 'GET' })
|
||
.validator(userPageInputSchema)
|
||
.handler(async ({ data }) => {
|
||
try {
|
||
return await loadUserPage(await reader(), data)
|
||
} catch {
|
||
return configFailure()
|
||
}
|
||
})
|
||
|
||
export const searchPosts = createServerFn({ method: 'GET' })
|
||
.validator(searchPageInputSchema)
|
||
.handler(async ({ data }) => {
|
||
try {
|
||
return await searchPage(await reader(), data)
|
||
} catch {
|
||
return configFailure()
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: Prove Bird stays server-only**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm typecheck
|
||
nix develop -c pnpm build
|
||
rg -n "TWITTER_RELAY_BASE_URL|BIRD_PROFILE_NAME|node:fs" dist/client .output/public 2>/dev/null
|
||
```
|
||
|
||
Expected: typecheck and build pass; `rg` returns no client-bundle match for relay environment names or Node filesystem imports.
|
||
|
||
- [ ] **Step 7: Commit the server boundary**
|
||
|
||
```bash
|
||
git add src/features/posts
|
||
git commit -m "feat: add read-only bird boundary"
|
||
```
|
||
|
||
### Task 5: Add intentional User and Search routes
|
||
|
||
**Files:**
|
||
- Create: `src/components/app-shell.tsx`
|
||
- Create: `src/features/posts/components/user-form.tsx`
|
||
- Create: `src/features/posts/components/search-form.tsx`
|
||
- Create: `src/features/posts/components/forms.test.tsx`
|
||
- Create: `src/routes/user.tsx`
|
||
- Create: `src/routes/search.tsx`
|
||
- Modify: `src/routes/index.tsx`
|
||
- Delete: `src/routes/index.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: typed route schemas from Task 3.
|
||
- Produces: `UserForm({ initialTarget, onSubmit })`.
|
||
- Produces: `SearchForm({ initialQuery, initialProduct, initialFollowing, onSubmit })`.
|
||
- Produces: typed `/user` and `/search` routes with no automatic post fetch.
|
||
|
||
- [ ] **Step 1: Write failing form tests**
|
||
|
||
Create `src/features/posts/components/forms.test.tsx`:
|
||
|
||
```tsx
|
||
import { fireEvent, render, screen } from '@testing-library/react'
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import { SearchForm } from './search-form'
|
||
import { UserForm } from './user-form'
|
||
|
||
describe('UserForm', () => {
|
||
it('submits only after the user enters a target', () => {
|
||
const onSubmit = vi.fn()
|
||
render(<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:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/components/forms.test.tsx
|
||
```
|
||
|
||
Expected: FAIL because both form components are missing.
|
||
|
||
- [ ] **Step 2: Implement the focused forms**
|
||
|
||
Create `src/features/posts/components/user-form.tsx`:
|
||
|
||
```tsx
|
||
import { type FormEvent, useState } from 'react'
|
||
import { normalizeUserTarget } from '../inputs'
|
||
|
||
type Props = {
|
||
initialTarget: string
|
||
onSubmit: (target: string) => void
|
||
}
|
||
|
||
export function UserForm({ initialTarget, onSubmit }: Props) {
|
||
const [target, setTarget] = useState(initialTarget)
|
||
const [error, setError] = useState('')
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault()
|
||
try {
|
||
const handle = normalizeUserTarget(target)
|
||
setError('')
|
||
onSubmit(handle)
|
||
} catch (cause) {
|
||
setError(
|
||
cause instanceof Error
|
||
? cause.message
|
||
: '入力内容を確認してください。',
|
||
)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<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`:
|
||
|
||
```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:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/components/forms.test.tsx
|
||
```
|
||
|
||
Expected: both form behaviors pass.
|
||
|
||
- [ ] **Step 4: Add the shell and typed routes**
|
||
|
||
Create `src/components/app-shell.tsx`:
|
||
|
||
```tsx
|
||
import { Link } from '@tanstack/react-router'
|
||
|
||
export function AppShell({
|
||
active,
|
||
children,
|
||
}: {
|
||
active: 'user' | 'search'
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<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:
|
||
|
||
```tsx
|
||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||
|
||
export const Route = createFileRoute('/')({
|
||
beforeLoad: () => {
|
||
throw redirect({ to: '/user', search: { target: '' } })
|
||
},
|
||
})
|
||
```
|
||
|
||
Create `src/routes/user.tsx`:
|
||
|
||
```tsx
|
||
import { createFileRoute } from '@tanstack/react-router'
|
||
import { AppShell } from '#/components/app-shell'
|
||
import { UserForm } from '#/features/posts/components/user-form'
|
||
import { userRouteSearchSchema } from '#/features/posts/inputs'
|
||
|
||
export const Route = createFileRoute('/user')({
|
||
validateSearch: (search) => userRouteSearchSchema.parse(search),
|
||
component: UserRoute,
|
||
})
|
||
|
||
function UserRoute() {
|
||
const { target } = Route.useSearch()
|
||
const navigate = Route.useNavigate()
|
||
return (
|
||
<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`:
|
||
|
||
```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:
|
||
|
||
```bash
|
||
nix develop -c pnpm generate-routes
|
||
nix develop -c pnpm test -- src/features/posts/components/forms.test.tsx
|
||
nix develop -c pnpm typecheck
|
||
```
|
||
|
||
Expected: tests and typecheck pass; route generation includes `/user` and `/search`.
|
||
|
||
Commit:
|
||
|
||
```bash
|
||
git add src/components src/features/posts/components src/routes src/routeTree.gen.ts
|
||
git commit -m "feat: add intentional reader routes"
|
||
```
|
||
|
||
### Task 6: Render rich read-only post cards
|
||
|
||
**Files:**
|
||
- Create: `src/features/posts/components/post-card.tsx`
|
||
- Create: `src/features/posts/components/post-media.tsx`
|
||
- Create: `src/features/posts/components/post-card.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Post` from Task 3.
|
||
- Produces: `PostCard({ post, quoted? })` with text, media, quote, article, counts, and an explicit original link.
|
||
|
||
- [ ] **Step 1: Write failing post-card tests**
|
||
|
||
Create `src/features/posts/components/post-card.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from '@testing-library/react'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { PostCard } from './post-card'
|
||
import type { Post } from '../types'
|
||
|
||
const richPost: Post = {
|
||
id: '123',
|
||
text: '詳細 https://example.com/article',
|
||
author: {
|
||
username: 'yuta',
|
||
name: 'Yuta',
|
||
profileImageUrl: 'https://pbs.twimg.com/avatar.jpg',
|
||
},
|
||
createdAt: '2026-07-13T00:00:00.000Z',
|
||
replyCount: 3,
|
||
retweetCount: 4,
|
||
likeCount: 5,
|
||
media: [
|
||
{
|
||
type: 'photo',
|
||
url: 'https://pbs.twimg.com/photo.jpg',
|
||
width: 1200,
|
||
height: 800,
|
||
},
|
||
],
|
||
article: { title: 'Article title', previewText: 'Preview' },
|
||
quotedTweet: {
|
||
id: '122',
|
||
text: 'quoted',
|
||
author: { username: 'other', name: 'Other' },
|
||
},
|
||
}
|
||
|
||
describe('PostCard', () => {
|
||
it('renders rich content without mutation controls', () => {
|
||
render(<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:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/components/post-card.test.tsx
|
||
```
|
||
|
||
Expected: FAIL because `PostCard` does not exist.
|
||
|
||
- [ ] **Step 2: Implement media rendering**
|
||
|
||
Create `src/features/posts/components/post-media.tsx`:
|
||
|
||
```tsx
|
||
import type { Post } from '../types'
|
||
|
||
type Media = NonNullable<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`:
|
||
|
||
```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:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/components/post-card.test.tsx
|
||
nix develop -c pnpm typecheck
|
||
```
|
||
|
||
Expected: rich-content and safe-link tests pass.
|
||
|
||
Commit:
|
||
|
||
```bash
|
||
git add src/features/posts/components
|
||
git commit -m "feat: render rich read-only posts"
|
||
```
|
||
|
||
### Task 7: Add infinite cursor loading and feed states
|
||
|
||
**Files:**
|
||
- Create: `src/features/posts/use-post-feed.ts`
|
||
- Create: `src/features/posts/use-post-feed.test.ts`
|
||
- Create: `src/features/posts/components/post-feed.tsx`
|
||
- Create: `src/features/posts/components/post-feed.test.tsx`
|
||
- Modify: `src/routes/user.tsx`
|
||
- Modify: `src/routes/search.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `loadUserPosts`, `searchPosts`, `PostPage`, and `flattenPostPages`.
|
||
- Produces: `FeedRequest` and `usePostFeed(request)`.
|
||
- Produces: `PostFeed({ request })` with loading, empty, error, retry, auto-page, and end states.
|
||
|
||
- [ ] **Step 1: Write failing query-option tests**
|
||
|
||
Create `src/features/posts/use-post-feed.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import { createPostFeedOptions } from './use-post-feed'
|
||
|
||
describe('createPostFeedOptions', () => {
|
||
it('loads the next user cursor and does not retry automatically', async () => {
|
||
const loadUser = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
page: { tweets: [], nextCursor: 'next' },
|
||
})
|
||
const options = createPostFeedOptions(
|
||
{ kind: 'user', target: '@yuta' },
|
||
{ loadUser, search: vi.fn() },
|
||
)
|
||
|
||
const page = await options.queryFn({
|
||
pageParam: 'cursor',
|
||
} as never)
|
||
expect(loadUser).toHaveBeenCalledWith({
|
||
data: { target: '@yuta', cursor: 'cursor' },
|
||
})
|
||
expect(options.getNextPageParam(page)).toBe('next')
|
||
expect(options.retry).toBe(false)
|
||
})
|
||
|
||
it('includes every search control in the query key', () => {
|
||
const options = createPostFeedOptions(
|
||
{
|
||
kind: 'search',
|
||
query: 'AI',
|
||
product: 'Top',
|
||
following: true,
|
||
},
|
||
{ loadUser: vi.fn(), search: vi.fn() },
|
||
)
|
||
|
||
expect(options.queryKey).toEqual([
|
||
'posts',
|
||
{ kind: 'search', query: 'AI', product: 'Top', following: true },
|
||
])
|
||
})
|
||
})
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/use-post-feed.test.ts
|
||
```
|
||
|
||
Expected: FAIL because query options do not exist.
|
||
|
||
- [ ] **Step 2: Implement infinite-query options and hook**
|
||
|
||
Create `src/features/posts/use-post-feed.ts`:
|
||
|
||
```ts
|
||
import type { SearchProduct } from '@yuta/bird'
|
||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||
import { useServerFn } from '@tanstack/react-start'
|
||
import { loadUserPosts, searchPosts } from './server-functions'
|
||
import type { LoadError, LoadResult, PostPage } from './types'
|
||
|
||
export type FeedRequest =
|
||
| { kind: 'user'; target: string }
|
||
| {
|
||
kind: 'search'
|
||
query: string
|
||
product: SearchProduct
|
||
following: boolean
|
||
}
|
||
|
||
type Loaders = {
|
||
loadUser: (options: {
|
||
data: { target: string; cursor?: string }
|
||
}) => Promise<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:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/use-post-feed.test.ts
|
||
```
|
||
|
||
Expected: query key, cursor forwarding, and retry behavior pass.
|
||
|
||
- [ ] **Step 4: Write failing feed-state tests**
|
||
|
||
Create `src/features/posts/components/post-feed.test.tsx` using a module mock:
|
||
|
||
```tsx
|
||
import { fireEvent, render, screen } from '@testing-library/react'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { PostFeed } from './post-feed'
|
||
import { usePostFeed } from '../use-post-feed'
|
||
|
||
vi.mock('../use-post-feed', async (importOriginal) => {
|
||
const original = await importOriginal<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:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/components/post-feed.test.tsx
|
||
```
|
||
|
||
Expected: FAIL because `PostFeed` does not exist.
|
||
|
||
- [ ] **Step 5: Implement feed states and IntersectionObserver**
|
||
|
||
Create `src/features/posts/components/post-feed.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useRef } from 'react'
|
||
import { flattenPostPages } from '../page'
|
||
import {
|
||
PostLoadError,
|
||
type FeedRequest,
|
||
usePostFeed,
|
||
} from '../use-post-feed'
|
||
import { PostCard } from './post-card'
|
||
|
||
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||
const query = usePostFeed(request)
|
||
const sentinel = useRef<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:
|
||
|
||
```tsx
|
||
<PostFeed
|
||
request={target ? { kind: 'user', target } : undefined}
|
||
/>
|
||
```
|
||
|
||
In `src/routes/search.tsx`, render:
|
||
|
||
```tsx
|
||
<PostFeed
|
||
request={
|
||
values.q
|
||
? {
|
||
kind: 'search',
|
||
query: values.q,
|
||
product: values.product,
|
||
following: values.following,
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 7: Verify and commit infinite loading**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- \
|
||
src/features/posts/use-post-feed.test.ts \
|
||
src/features/posts/components/post-feed.test.tsx
|
||
nix develop -c pnpm typecheck
|
||
nix develop -c pnpm build
|
||
```
|
||
|
||
Expected: query options, feed states, typecheck, and build pass.
|
||
|
||
Commit:
|
||
|
||
```bash
|
||
git add src/features/posts src/routes src/routeTree.gen.ts
|
||
git commit -m "feat: load post cursors infinitely"
|
||
```
|
||
|
||
### Task 8: Apply the Mist Instrument responsive interface
|
||
|
||
**Files:**
|
||
- Modify: `src/styles.css`
|
||
- Modify: `src/features/posts/components/forms.test.tsx`
|
||
- Modify: `src/features/posts/components/post-card.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: existing semantic class names and controls.
|
||
- Produces: responsive Mist Instrument layout, visible keyboard focus, stable media geometry, and reduced-motion behavior.
|
||
|
||
- [ ] **Step 1: Add failing accessibility assertions**
|
||
|
||
Extend `forms.test.tsx` with:
|
||
|
||
```tsx
|
||
it('uses a labelled grouping for ranking controls', () => {
|
||
render(
|
||
<SearchForm
|
||
initialQuery=""
|
||
initialProduct="Latest"
|
||
initialFollowing={false}
|
||
onSubmit={vi.fn()}
|
||
/>,
|
||
)
|
||
|
||
expect(
|
||
screen.getByRole('group', { name: '並び順' }),
|
||
).toBeInTheDocument()
|
||
})
|
||
```
|
||
|
||
Extend `post-card.test.tsx` with:
|
||
|
||
```tsx
|
||
it('does not turn the author into a discovery link', () => {
|
||
render(<PostCard post={richPost} />)
|
||
|
||
expect(screen.queryByRole('link', { name: 'Yuta' })).not.toBeInTheDocument()
|
||
})
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/components
|
||
```
|
||
|
||
Expected: tests expose any missing semantics before visual work proceeds.
|
||
|
||
- [ ] **Step 2: Replace the base stylesheet with Mist Instrument**
|
||
|
||
Replace `src/styles.css` with:
|
||
|
||
```css
|
||
:root {
|
||
--canvas: #dfe7e9;
|
||
--surface: #f8faf9;
|
||
--surface-muted: #edf3f3;
|
||
--ink: #1e3238;
|
||
--muted: #70858a;
|
||
--accent: #255f6e;
|
||
--accent-hover: #194d59;
|
||
--border: #c1cfd2;
|
||
--error: #8a3034;
|
||
color: var(--ink);
|
||
background: var(--canvas);
|
||
font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
||
font-synthesis: none;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html {
|
||
min-width: 320px;
|
||
background: var(--canvas);
|
||
}
|
||
|
||
body {
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
}
|
||
|
||
button,
|
||
input {
|
||
font: inherit;
|
||
}
|
||
|
||
a {
|
||
color: var(--accent);
|
||
text-underline-offset: 0.18em;
|
||
}
|
||
|
||
button,
|
||
input,
|
||
a {
|
||
border-radius: 0.55rem;
|
||
}
|
||
|
||
:focus-visible {
|
||
outline: 3px solid color-mix(in srgb, var(--accent) 42%, transparent);
|
||
outline-offset: 3px;
|
||
}
|
||
|
||
.app {
|
||
width: min(46rem, calc(100% - 2rem));
|
||
min-height: calc(100vh - 2rem);
|
||
margin: 1rem auto;
|
||
overflow: clip;
|
||
border: 1px solid var(--border);
|
||
border-radius: 1.15rem;
|
||
background: var(--surface);
|
||
box-shadow: 0 1.2rem 3rem rgb(30 50 56 / 10%);
|
||
}
|
||
|
||
.app-header {
|
||
display: flex;
|
||
align-items: end;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
padding: 1.2rem 1.4rem 0;
|
||
border-bottom: 1px solid #dce5e6;
|
||
}
|
||
|
||
.brand {
|
||
margin: 0 0 0.85rem;
|
||
color: var(--muted);
|
||
font-size: 0.68rem;
|
||
letter-spacing: 0.2em;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 1.1rem;
|
||
}
|
||
|
||
.tabs a {
|
||
padding: 0.85rem 0.2rem 0.75rem;
|
||
border-radius: 0;
|
||
color: var(--muted);
|
||
font-size: 0.9rem;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.tabs a[aria-current="page"] {
|
||
border-bottom: 2px solid var(--accent);
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.reader {
|
||
padding: 2rem 1.4rem 3rem;
|
||
}
|
||
|
||
.reader > h1 {
|
||
margin: 0;
|
||
font-size: clamp(1.45rem, 4vw, 2rem);
|
||
letter-spacing: -0.03em;
|
||
}
|
||
|
||
.intro {
|
||
margin: 0.45rem 0 1.5rem;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.intent-form {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 0.85rem;
|
||
align-items: end;
|
||
padding: 1rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 0.9rem;
|
||
background: var(--surface-muted);
|
||
}
|
||
|
||
.field {
|
||
display: grid;
|
||
gap: 0.4rem;
|
||
min-width: 0;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.field input {
|
||
width: 100%;
|
||
padding: 0.75rem 0.85rem;
|
||
border: 1px solid #aebfc3;
|
||
background: white;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.field-error {
|
||
color: var(--error);
|
||
font-size: 0.76rem;
|
||
}
|
||
|
||
.intent-form button,
|
||
.state button {
|
||
min-height: 2.65rem;
|
||
padding: 0.65rem 1rem;
|
||
border: 1px solid var(--accent);
|
||
background: var(--accent);
|
||
color: white;
|
||
cursor: pointer;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.intent-form button:hover,
|
||
.state button:hover {
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
.segmented {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: 0;
|
||
}
|
||
|
||
.segmented legend {
|
||
width: 100%;
|
||
margin-bottom: 0.4rem;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.segmented label {
|
||
position: relative;
|
||
padding: 0.5rem 0.7rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 0.55rem;
|
||
cursor: pointer;
|
||
font-size: 0.84rem;
|
||
}
|
||
|
||
.segmented input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
}
|
||
|
||
.segmented label:has(input:checked) {
|
||
border-color: var(--accent);
|
||
background: var(--accent);
|
||
color: white;
|
||
}
|
||
|
||
.check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
min-height: 2.65rem;
|
||
font-size: 0.84rem;
|
||
}
|
||
|
||
.feed {
|
||
display: grid;
|
||
gap: 0.85rem;
|
||
margin-top: 1.4rem;
|
||
}
|
||
|
||
.post {
|
||
min-width: 0;
|
||
padding: 1rem;
|
||
border: 1px solid #d7e0e2;
|
||
border-radius: 0.9rem;
|
||
background: white;
|
||
}
|
||
|
||
.post-header {
|
||
display: grid;
|
||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||
gap: 0.65rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.avatar {
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.handle,
|
||
.post-header time {
|
||
display: block;
|
||
color: var(--muted);
|
||
font-size: 0.76rem;
|
||
}
|
||
|
||
.post-header time {
|
||
text-align: right;
|
||
}
|
||
|
||
.post-text {
|
||
margin: 0.9rem 0;
|
||
overflow-wrap: anywhere;
|
||
line-height: 1.75;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.media-grid {
|
||
display: grid;
|
||
gap: 0.4rem;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
margin-top: 0.8rem;
|
||
overflow: hidden;
|
||
border-radius: 0.75rem;
|
||
}
|
||
|
||
.media-grid:has(> :only-child) {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.media {
|
||
width: 100%;
|
||
max-height: 32rem;
|
||
object-fit: cover;
|
||
background: var(--surface-muted);
|
||
}
|
||
|
||
.article-card,
|
||
.quote {
|
||
margin-top: 0.8rem;
|
||
padding: 0.85rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 0.75rem;
|
||
background: var(--surface);
|
||
}
|
||
|
||
.article-card p {
|
||
margin-bottom: 0;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.quote .post-header time,
|
||
.quote .post-footer {
|
||
display: none;
|
||
}
|
||
|
||
.post-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
margin-top: 1rem;
|
||
color: var(--muted);
|
||
font-size: 0.74rem;
|
||
}
|
||
|
||
.counts {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.state {
|
||
padding: 1rem;
|
||
color: var(--muted);
|
||
text-align: center;
|
||
}
|
||
|
||
[role="alert"].state {
|
||
color: var(--error);
|
||
}
|
||
|
||
.loading-rail {
|
||
position: relative;
|
||
padding: 0.8rem;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.loading-rail::after {
|
||
position: absolute;
|
||
right: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
height: 3px;
|
||
background: linear-gradient(
|
||
90deg,
|
||
transparent 0%,
|
||
var(--accent) 45%,
|
||
transparent 100%
|
||
);
|
||
content: "";
|
||
animation: rail 1.1s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes rail {
|
||
0%,
|
||
100% {
|
||
opacity: 0.25;
|
||
transform: scaleX(0.35);
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
transform: scaleX(1);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 42rem) {
|
||
.app {
|
||
width: 100%;
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
border-right: 0;
|
||
border-left: 0;
|
||
border-radius: 0;
|
||
}
|
||
|
||
.app-header,
|
||
.reader {
|
||
padding-right: 1rem;
|
||
padding-left: 1rem;
|
||
}
|
||
|
||
.intent-form {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.post-header {
|
||
grid-template-columns: auto minmax(0, 1fr);
|
||
}
|
||
|
||
.post-header time {
|
||
grid-column: 2;
|
||
text-align: left;
|
||
}
|
||
|
||
.post-footer {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
scroll-behavior: auto !important;
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify responsive semantics and commit**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test -- src/features/posts/components
|
||
nix develop -c pnpm lint
|
||
nix develop -c pnpm typecheck
|
||
nix develop -c pnpm build
|
||
```
|
||
|
||
Expected: component tests, lint, typecheck, and build pass.
|
||
|
||
Commit:
|
||
|
||
```bash
|
||
git add src/styles.css src/features/posts/components
|
||
git commit -m "feat: apply mist instrument interface"
|
||
```
|
||
|
||
### Task 9: Verify browser flows, live relay reads, and documentation
|
||
|
||
**Files:**
|
||
- Create: `playwright.config.ts`
|
||
- Create: `tests/e2e/mock-relay.mjs`
|
||
- Create: `tests/e2e/reader.spec.ts`
|
||
- Create: `tests/live/relay.test.ts`
|
||
- Create: `.env.example`
|
||
- Modify: `.gitignore`
|
||
- Create: `README.md`
|
||
- Verify: `docs/superpowers/specs/2026-07-13-twitter-lite-design.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the complete app and published Bird package.
|
||
- Produces: deterministic desktop/mobile browser coverage and an opt-in read-only live smoke test.
|
||
|
||
- [ ] **Step 1: Add the browser configuration**
|
||
|
||
Create `playwright.config.ts`:
|
||
|
||
```ts
|
||
import { defineConfig, devices } from '@playwright/test'
|
||
|
||
const appPort = 4173
|
||
const relayPort = 6911
|
||
const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||
|
||
if (!chromiumExecutable) {
|
||
throw new Error('Run E2E tests through `nix develop` to provide Chromium.')
|
||
}
|
||
|
||
export default defineConfig({
|
||
testDir: './tests/e2e',
|
||
fullyParallel: false,
|
||
workers: 1,
|
||
use: {
|
||
baseURL: `http://127.0.0.1:${appPort}`,
|
||
launchOptions: { executablePath: chromiumExecutable },
|
||
screenshot: 'only-on-failure',
|
||
trace: 'retain-on-failure',
|
||
},
|
||
webServer: [
|
||
{
|
||
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
|
||
port: relayPort,
|
||
reuseExistingServer: false,
|
||
timeout: 120_000,
|
||
},
|
||
{
|
||
command: `TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
|
||
url: `http://127.0.0.1:${appPort}`,
|
||
reuseExistingServer: false,
|
||
timeout: 120_000,
|
||
},
|
||
],
|
||
projects: [
|
||
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
||
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
|
||
],
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Create a deterministic read-only relay**
|
||
|
||
Create `tests/e2e/mock-relay.mjs`. The response-shape excerpt below is only
|
||
part of the mock: the executable implementation must also enforce the request
|
||
contract listed after it.
|
||
|
||
```js
|
||
const tweet = (id, text, username = 'yuta') => ({
|
||
entryId: `tweet-${id}`,
|
||
content: {
|
||
itemContent: {
|
||
tweet_results: {
|
||
result: {
|
||
rest_id: id,
|
||
legacy: {
|
||
full_text: text,
|
||
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
||
reply_count: 1,
|
||
retweet_count: 2,
|
||
favorite_count: 3,
|
||
conversation_id_str: id,
|
||
},
|
||
core: {
|
||
user_results: {
|
||
result: {
|
||
legacy: {
|
||
screen_name: username,
|
||
name: 'Yuta',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
const cursor = (value) => ({
|
||
entryId: 'cursor-bottom',
|
||
content: { cursorType: 'Bottom', value },
|
||
})
|
||
```
|
||
|
||
Route by the operation-name suffix, never the query ID. Require
|
||
`x-profile-name: e2e`; require GET for `UserByScreenName` and `UserTweets`; and
|
||
require POST plus JSON `{ features, queryId }` for `SearchTimeline`, whose
|
||
`variables` remain in the URL. Validate only the minimal fixture variables,
|
||
not exact query IDs or complete feature bags. Return 405 for a wrong method,
|
||
403 for a wrong profile, 400 for malformed supported input, and 501 for an
|
||
unsupported operation. Never return 404 or make an outbound request.
|
||
|
||
Key transient state by raw query. The first cursor request for each
|
||
`retry-<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`:
|
||
|
||
```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:
|
||
|
||
```bash
|
||
nix develop -c pnpm test:e2e
|
||
```
|
||
|
||
Expected: FAIL because approved visual snapshots do not exist yet. If a functional assertion fails first, reproduce it in the nearest focused unit test before changing application code.
|
||
|
||
- [ ] **Step 4: Make browser flows GREEN and capture screenshots**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm exec playwright test --update-snapshots
|
||
nix develop -c pnpm test:e2e
|
||
```
|
||
|
||
Expected: the first run creates reviewed desktop and mobile `mist-user.png` snapshots; the second run passes all projects with no browser console errors.
|
||
|
||
- [ ] **Step 5: Add the opt-in live read test**
|
||
|
||
Create `tests/live/relay.test.ts`:
|
||
|
||
```ts
|
||
// @vitest-environment node
|
||
|
||
import { TwitterClient } from '@yuta/bird'
|
||
import { describe, expect, it } from 'vitest'
|
||
|
||
describe.runIf(process.env.TWITTER_LITE_LIVE === '1')(
|
||
'configured relay',
|
||
() => {
|
||
it(
|
||
'performs one read-only Top search',
|
||
async () => {
|
||
const relayBaseUrl = process.env.TWITTER_RELAY_BASE_URL
|
||
if (!relayBaseUrl) {
|
||
throw new Error('TWITTER_RELAY_BASE_URL is required for the live test')
|
||
}
|
||
const client = new TwitterClient({
|
||
relayBaseUrl,
|
||
profileName: process.env.BIRD_PROFILE_NAME,
|
||
timeoutMs: 20_000,
|
||
})
|
||
const query = process.env.TWITTER_LITE_LIVE_QUERY ?? 'OpenAI'
|
||
const result = await client.search(query, 1, { product: 'Top' })
|
||
expect(result.success).toBe(true)
|
||
},
|
||
30_000,
|
||
)
|
||
},
|
||
)
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
nix develop -c pnpm test:live
|
||
```
|
||
|
||
Expected: when relay/profile environment configuration is actually available,
|
||
one read-only Top search test passes. Do not run this opt-in command
|
||
unconditionally or log relay configuration, response bodies, or post content.
|
||
|
||
- [ ] **Step 6: Document setup and intentional omissions**
|
||
|
||
Create `.env.example`:
|
||
|
||
```dotenv
|
||
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
|
||
BIRD_PROFILE_NAME=
|
||
```
|
||
|
||
Extend `.gitignore` to exactly:
|
||
|
||
```gitignore
|
||
.superpowers/
|
||
.worktrees/
|
||
.env*
|
||
!.env.example
|
||
.output/
|
||
dist/
|
||
node_modules/
|
||
playwright-report/
|
||
test-results/
|
||
```
|
||
|
||
Create `README.md` with these sections and commands:
|
||
|
||
````md
|
||
# Twitter Lite
|
||
|
||
An intentional, read-only X reader that shows content only after a deliberate
|
||
handle, profile URL, or search query.
|
||
|
||
## Features
|
||
|
||
- User timelines from a manually entered handle or profile URL
|
||
- Top and Latest search
|
||
- Optional `filter:follows` search
|
||
- Infinite cursor loading
|
||
- Photos, videos, quotes, articles, and quiet engagement counts
|
||
- No home feed, recommendations, trends, notifications, history, or writes
|
||
|
||
## Setup
|
||
|
||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||
routes `@yuta` packages to Gitea Packages, and `@yuta/bird@0.10.0` contains
|
||
the required Top/Latest interface.
|
||
|
||
```bash
|
||
nix develop -c pnpm install --frozen-lockfile
|
||
```
|
||
|
||
Set `TWITTER_RELAY_BASE_URL` at runtime and, when the relay has multiple
|
||
profiles, `BIRD_PROFILE_NAME`. Bird and both values are read only by
|
||
server-side code and never reach the browser.
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
nix develop -c pnpm dev
|
||
nix develop -c pnpm dev:tailscale
|
||
nix develop -c pnpm test
|
||
nix develop -c pnpm test:e2e
|
||
nix develop -c pnpm test:live
|
||
nix develop -c pnpm build
|
||
nix develop -c pnpm start
|
||
```
|
||
|
||
The default bind address is `127.0.0.1`. E2E runs through Nix with the system
|
||
Chromium; the live command performs exactly one explicitly configured read.
|
||
`dev:tailscale` binds `0.0.0.0`, including LAN interfaces as well as Tailscale,
|
||
so use it only on a trusted network. Find the Tailscale address with
|
||
`tailscale ip -4`.
|
||
|
||
## Reliability
|
||
|
||
Bird uses X's internal web GraphQL operations through the configured safe
|
||
relay. X may change query IDs or response shapes without notice.
|
||
````
|
||
|
||
- [ ] **Step 7: Run the complete verification matrix**
|
||
|
||
From `../bird`:
|
||
|
||
```bash
|
||
nix develop -c pnpm run build:dist
|
||
nix develop -c pnpm run lint
|
||
nix develop -c pnpm test
|
||
git status --short --branch
|
||
git rev-list --count origin/main..HEAD
|
||
git log --oneline origin/main..HEAD
|
||
```
|
||
|
||
From Twitter Lite:
|
||
|
||
```bash
|
||
nix develop -c pnpm install --frozen-lockfile
|
||
nix develop -c pnpm check:routes
|
||
nix develop -c pnpm lint
|
||
nix develop -c pnpm typecheck
|
||
nix develop -c pnpm test
|
||
nix develop -c pnpm build
|
||
nix develop -c pnpm test:e2e
|
||
nix develop -c pnpm test:live # only with configured relay environment
|
||
git diff --check
|
||
```
|
||
|
||
Expected:
|
||
|
||
- Bird build, lint, and all non-live tests pass.
|
||
- Twitter Lite lint, typecheck, unit tests, desktop/mobile browser tests, live read test, and production build pass.
|
||
- Bird has one local feature commit and no remote push.
|
||
- Fresh `.output/public` scans contain no Bird/client or relay environment names;
|
||
source contains only the two intended server functions and no mutation/generic
|
||
proxy calls.
|
||
- `README.md` and the design docs match the delivered commands and behavior.
|
||
- `AGENTS.md` and `CLAUDE.md` remain absent because no agent-specific instruction changed.
|
||
|
||
- [ ] **Step 8: Commit final verification assets and docs**
|
||
|
||
```bash
|
||
git add \
|
||
.env.example .gitignore README.md playwright.config.ts \
|
||
src/routes/__root.tsx tests/e2e tests/live docs
|
||
git commit -m "test: verify intentional reader flows"
|
||
```
|
||
|
||
Run one fresh post-commit audit:
|
||
|
||
```bash
|
||
git status --short --branch
|
||
git log --oneline --decorate -8
|
||
nix develop -c pnpm test
|
||
nix develop -c pnpm test:e2e
|
||
nix develop -c pnpm build
|
||
git diff --check
|
||
```
|
||
|
||
Expected: clean status, the planned commits are present, all unit tests pass, and the production build exits zero.
|