diff --git a/.gitignore b/.gitignore index ac80fcd..ff4c36e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .env* !.env.example .output/ +.tanstack/ dist/ node_modules/ playwright-report/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..2dbb681 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [".output/**", ".tanstack/**", "src/routeTree.gen.ts"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json index e3c0915..3c40cc7 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,9 +1,206 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "categories": { "correctness": "off" }, + "categories": { + "correctness": "error" + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "plugins": [ + "eslint", + "typescript", + "unicorn", + "oxc", + "import", + "node", + "promise", + "react", + "react-perf", + "jsx-a11y" + ], "jsPlugins": ["@shadcn/lint"], "rules": { - "shadcn/no-raw-colors": "error" + "no-console": "error", + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_" + } + ], + "typescript/consistent-type-exports": "error", + "typescript/consistent-type-imports": [ + "error", + { + "disallowTypeAnnotations": false + } + ], + "typescript/no-non-null-assertion": "error", + "typescript/no-explicit-any": "error", + "unicorn/no-useless-spread": "error", + "jsx-a11y/no-autofocus": "error", + "jsx-a11y/no-static-element-interactions": "error", + "jsx-a11y/prefer-tag-over-role": "off", + "react/exhaustive-deps": "error", + "react/purity": "error", + "react/rules-of-hooks": "error", + "react/set-state-in-effect": "error", + "shadcn/no-arbitrary-values": [ + "error", + { + "allow": ["layout"] + } + ], + "shadcn/no-inline-styles": "error", + "shadcn/no-raw-colors": "error", + "shadcn/no-restyle": [ + "error", + { + "allow": ["layout"], + "contracts": [ + { + "pattern": "^Sidebar$", + "allow": [ + "layout", + "border-r", + "transition-none", + "inbox-sidebar", + "inbox-list-panel", + "support-sidebar", + "support-list" + ] + }, + { + "pattern": "^SidebarHeader$", + "allow": ["layout", "py-4", "gap-4", "border-b", "p-5"] + }, + { + "pattern": "^SidebarGroup$", + "allow": ["layout", "px-3"] + }, + { + "pattern": "^SidebarFooter$", + "allow": ["layout", "px-3", "pb-4"] + }, + { + "pattern": "^SidebarMenuButton$", + "allow": ["layout", "p-0"] + }, + { + "pattern": "^Label$", + "allow": ["layout", "deck-checkbox"] + }, + { + "pattern": "^Textarea$", + "allow": ["layout", "text-sm", "leading-7", "journal-summary"] + }, + { + "pattern": "^MessageScroller$", + "allow": ["layout", "research-conversation"] + }, + { + "pattern": "^MessageScrollerContent$", + "allow": ["layout", "gap-5", "p-4"] + }, + { + "pattern": "^Marker$", + "allow": ["layout", "research-tool-message"] + }, + { + "pattern": "^Message$", + "allow": ["layout", "research-message"] + }, + { + "pattern": "^Collapsible$", + "allow": ["layout", "research-account-picker"] + }, + { + "pattern": "^SidebarProvider$", + "allow": [ + "layout", + "workspace", + "home-workspace", + "inbox-workspace", + "vital-workspace", + "support-workspace", + "journal-workspace" + ] + }, + { + "pattern": "^Button$", + "allow": ["layout", "auth-submit", "inbox-back", "support-back"] + }, + { + "pattern": "^SidebarInset$", + "allow": ["layout", "home-main", "inbox-reader", "support-main", "journal-main"] + }, + { + "pattern": "^Input$", + "allow": ["layout", "pl-9"] + }, + { + "pattern": "^SidebarContent$", + "allow": ["layout", "gap-0"] + }, + { + "pattern": "^Badge$", + "allow": ["layout", "deck-source-label"] + } + ] + } + ], + "shadcn/no-unknown-classes": "error", + "shadcn/require-static-classes": "error" }, - "ignorePatterns": ["src/routeTree.gen.ts"] + "overrides": [ + { + "files": ["scripts/**"], + "rules": { + "no-console": "off" + } + }, + { + "files": ["src/components/ui/**"], + "rules": { + "shadcn/no-arbitrary-values": "off", + "shadcn/no-restyle": "off", + "shadcn/require-static-classes": "off" + } + }, + { + "files": ["src/**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"], + "plugins": ["vitest"], + "rules": { + "typescript/unbound-method": "error", + "vitest/expect-expect": "error", + "vitest/no-conditional-expect": "error", + "vitest/no-standalone-expect": "error", + "vitest/require-mock-type-parameters": "error" + } + }, + { + "files": ["e2e/**/*.{ts,tsx}"], + "rules": { + "react/rules-of-hooks": "off" + } + } + ], + "ignorePatterns": [ + "**/.pnpm-store/**", + "**/.direnv/**", + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/coverage/**", + "**/playwright-report/**", + "**/test-results/**", + "**/.vercel/**", + "**/drizzle/**", + "**/*.tsbuildinfo", + ".output/**", + ".tanstack/**", + "src/routeTree.gen.ts", + "e2e/generated/**", + "src/features/storage/migrations.generated.ts" + ] } diff --git a/README.md b/README.md index 1f5560a..f634782 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ original language. ## Workspaces and current status -| Workspace | Route | Current implementation | -| --- | --- | --- | -| Home | `/` | State-derived daily brief, flexible tasks and routines, and natural-language action proposals. Local prototype. | -| Messages | `/support` | Sample conversations, editable replies, scheduling previews, and deferral. Sending is simulated. | -| Journal | `/journal` | Notes, resume points, medication and weight entries, append-only corrections, and summary previews. Local prototype. | -| Reader | `/inbox` | Searchable sample articles and a responsive article reader. Recommendation from activity history is not connected yet. | -| Vitals | `/vitals` | Body and Environment dashboards with sample readings, charts, and simulated controls. No health or device connection. | -| Research | `/deck` | Twitter and Mastodon columns, connected accounts, persistent decks, and optional Codex research chat. | +| Workspace | Route | Current implementation | +| --------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| Home | `/` | State-derived daily brief, flexible tasks and routines, and natural-language action proposals. Local prototype. | +| Messages | `/support` | Sample conversations, editable replies, scheduling previews, and deferral. Sending is simulated. | +| Journal | `/journal` | Notes, resume points, medication and weight entries, append-only corrections, and summary previews. Local prototype. | +| Reader | `/inbox` | Searchable sample articles and a responsive article reader. Recommendation from activity history is not connected yet. | +| Vitals | `/vitals` | Body and Environment dashboards with sample readings, charts, and simulated controls. No health or device connection. | +| Research | `/deck` | Twitter and Mastodon columns, connected accounts, persistent decks, and optional Codex research chat. | Home, Messages, and Journal share in-memory state across navigation; reloading resets it. The home input uses a deterministic English/Japanese interpreter to @@ -27,6 +27,21 @@ connections, and chat history use server-side persistence. See [execution support](docs/execution-support.md) and the [vitals preview](docs/vitals-preview.md) for prototype scope. +## URL state + +Reader search and article selection, Messages views and conversations, Home +previews, account management, Journal review/correction views, and Vitals +navigation are represented in search parameters. Research uses deck, run, and +citation IDs. Reloading or browser Back/Forward restores the view while its +underlying data remains available. Login preserves the requested workspace URL. + +Explicit selections add browser history; search input replaces it after a +300 ms debounce using TanStack Pacer. Navigation cancels pending search updates, +and IME composition waits until confirmed. URL navigation never submits prompts, +sends messages, or repeats device actions. Drafts, secrets, results, and task +contents remain outside the URL. Unsaved mock records and manual temporary +decks still disappear on reload; a URL does not persist their contents. + ## Direction: personal context and execution The intended connection with the separate private `life` / Rensheng repository @@ -52,7 +67,7 @@ or change direction. - Native X search syntax, Top/Latest ranking, and optional `filter:follows` - List discovery for the account selected in the column editor - Column editing, ordering, deletion/undo, refresh, and cursor pagination -- SQLite-backed shared decks, revision conflicts, and device-local selection +- SQLite-backed shared decks, revision conflicts, and URL-based selection - Temporary views for exploration, with explicit saving - Server-only encrypted Mastodon credentials and configurable Tailscale owner access - Read-only post cards with source links, text, media, and quotes @@ -106,7 +121,8 @@ nix develop -c pnpm dev nix develop -c pnpm dev:tailscale nix develop -c pnpm generate:e2e-openapi nix develop -c pnpm lint -nix develop -c pnpm lint:ui +nix develop -c pnpm format +nix develop -c pnpm format:check nix develop -c pnpm typecheck nix develop -c pnpm knip nix develop -c pnpm test @@ -116,27 +132,37 @@ nix develop -c pnpm build nix develop -c pnpm start nix develop -c pnpm account:setup nix develop -c pnpm db:generate -nix develop -c pnpm codex:serve ``` ## Home Codex research prototype -Run `codex login` as the host user, then keep `pnpm codex:serve` running separately -from the web server. It starts a resident app-server at `ws://127.0.0.1:4500` -using that user's existing Codex login. The launcher passes a small environment -allowlist, without the app's database, relay or SNS secret settings. Configured -MCP servers are disabled for this process without changing the user's settings. -The prototype was developed against Codex CLI 0.156.1; its WebSocket and dynamic -tool APIs are experimental. +Run `codex login` as the host user. The backend uses Vercel AI SDK 7 and +`ai-sdk-provider-codex-cli` 2.3 to manage a local Codex app-server over stdio. +A separate WebSocket service is no longer required. Use Codex CLI 0.156.0 or +newer; set an explicit executable path if it is not on PATH. +The child receives an environment allowlist; inherited MCP servers, hooks, +web search and multi-agent execution are disabled for these research turns. +The user's Codex settings are not changed. -Configure the web app with these values in `.env.local` (absolute report path): +Configure the web app in `.env.local`: ```dotenv -TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500 TWITTER_LITE_CODEX_MODEL=gpt-6-astra TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research +# Optional: +# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex ``` +This provider does not register AI SDK `tools` as Codex dynamic tools. +The existing validated, account-scoped research functions are exposed through +its in-process `createSdkMcpServer` bridge instead. Streaming uses `streamText` +and `smoothStream` with 15 ms pacing and Japanese-aware chunking: +`/[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/`. +Streamdown renders the Markdown and animates new text only while the final +assistant message is live; completed history is immediate and reduced-motion +preferences are respected. `@shadcn/helpers/ai-sdk` supplies deterministic +streaming fixtures for renderer tests. + Use the chat on the left while browsing horizontally scrollable deck columns on the right. Messages continue the same Codex thread and include the currently open deck as context. The account selector controls which connections Codex can @@ -152,15 +178,15 @@ Manual deck creation and column editing remain available. The backend handles tools even when the browser is closed. Use **New chat** to leave the current conversation and start a fresh Codex thread with your next message. The open deck and host reports remain available. -If a turn is running, stop it first. The conversation reset is shared across -open devices. **Chat history** reopens saved conversations with their account +A running turn continues in the background when you open another chat. +Selection belongs to the current URL; another browser does not change it. **Chat history** reopens saved conversations with their account selection, deck and citations; the next message resumes the same Codex thread. -SQLite stores conversation snapshots and the active conversation selection. +SQLite stores conversation snapshots. Active selection is not stored globally. Each turn is limited to 6 columns, 12 upstream requests shared between list discovery and post retrieval (at most 20 posts per fetch), and 15 minutes. The prototype accepts up to 100 messages per backend process. -SSE pushes conversation messages, tool activity and generated deck updates to -open browsers, without polling. +SSE pushes messages, tool activity and deck updates for the requested conversation +ID, without polling. Separate conversations have independent executions. Codex replies render as Markdown, including tables, lists and code. Clicking a cited post link scrolls to its card and briefly highlights it. Loaded cards take priority; otherwise the app shows the post captured during research in a column @@ -183,12 +209,11 @@ not independently verified. Report contents remain on the host filesystem; the app DB stores the conversation, thread ID, deck context and citation metadata. Browser disconnection does not cancel research; reconnecting restores the latest -state through `/api/research/events`, under the same owner access checks as the +state through `/api/research/events?run=`, under the same owner access checks as the rest of the app. After a backend restart, unfinished turns appear as interrupted; -sending another message resumes the same thread. Before starting that message, -the backend checks the resumed thread and interrupts any orphaned active turn. +sending another message explicitly resumes the same thread through the provider. It does not automatically rerun interrupted requests. Files already written remain. -Cancellation is explicit. The launcher and report directory must +Cancellation is explicit. The backend and report directory must run under a user able to write those files. NixOS DynamicUser service integration and dedicated service credentials are outside this prototype. @@ -216,22 +241,31 @@ editing, ordering, and deletion; refresh stays available directly in each header Shared UI components live in `src/components/ui`; `components.json` configures the registry and theme entrypoint. Chat uses MessageScroller, Message, Bubble, -Marker and InputGroup while retaining the existing Codex transport and saved -conversation model. The icon rail switches between chat and deck management; +Marker and InputGroup with Streamdown, reconnectable snapshot SSE, and saved +conversations. The icon rail switches between chat and deck management; closing the mobile sheet or crossing the desktop breakpoint preserves chat drafts, selected accounts and live deck updates. Theme colors are declared in -`src/ui.css`. +`src/ui.css`. The shared entrypoint `src/styles.css` loads the theme, tokens, +base styles and feature styles in that order, so the UI lint rules can resolve +application classes through the same stylesheet used by the app. -`pnpm lint` runs Biome and the UI checks. `pnpm lint:ui` runs -[@shadcn/lint](https://github.com/shadcn-ui/lint) through Oxlint. The +`pnpm lint` runs Oxlint with type-aware linting and TypeScript checking through +`oxlint-tsgolint`, including the +[@shadcn/lint](https://github.com/shadcn-ui/lint) UI checks. The `shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and class helpers, including shared UI components. Layout values remain available -for the sidebar and deck. Biome continues to handle formatting and general -code checks. Knip checks application usage while retaining the public exports +for the sidebar and deck. Component contracts allow only the existing workspace +styles; Next.js-specific rules are omitted because this app uses TanStack Start. +`pnpm format` formats files with Oxfmt defaults, and +`pnpm format:check` checks formatting without changing files. Knip checks application usage while retaining the public exports of registry components for composition. Additional design rules can be configured in `.oxlintrc.json`; see the [available rules](https://github.com/shadcn-ui/lint#rules). +The database and OpenAPI generators format their output with Oxfmt. TanStack +Router owns the formatting of `src/routeTree.gen.ts`, which is excluded from +formatting; `pnpm check:routes` verifies that regeneration leaves it unchanged. + Create a deck for an investigation, then add columns for each perspective. Choose a connected account and one of its supported sources. Account changes affect only the edited column. Matching source conditions and accounts diff --git a/biome.json b/biome.json deleted file mode 100644 index d279779..0000000 --- a/biome.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", - "files": { - "includes": [ - "**", - "!src/routeTree.gen.ts", - "!!**/.output", - "!!**/test-results" - ] - }, - "formatter": { - "enabled": true, - "indentStyle": "space" - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "semicolons": "asNeeded" - } - }, - "css": { - "parser": { - "tailwindDirectives": true - } - } -} diff --git a/components.json b/components.json index 3ed5f69..4942881 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/ui.css", + "css": "src/styles.css", "baseColor": "neutral", "cssVariables": true }, diff --git a/docs/plans/2026-09-24-mastodon-and-shared-decks.md b/docs/plans/2026-09-24-mastodon-and-shared-decks.md index f8e1973..709646e 100644 --- a/docs/plans/2026-09-24-mastodon-and-shared-decks.md +++ b/docs/plans/2026-09-24-mastodon-and-shared-decks.md @@ -34,10 +34,10 @@ flowchart LR SQLiteを採用する案。端末が増えても、各端末がSQLを実行するのではなく、同じアプリサーバーへアクセスする。デッキ設定とOAuth情報の小さな更新には単一サーバーのSQLiteで始められると判断する。DBファイルを端末間コピーしたり、NAS上のファイルを複数サーバーから直接開いたりしない。 -| 候補 | 今回の評価 | -| --- | --- | -| SQLite | 推奨。別DBサービス不要。ローカルディスク、短いトランザクション、マイグレーション、復元試験を用意する | -| PostgreSQL | 複数アプリサーバーや大量の並列収集へ進む時に再評価。現時点では運用対象が増える | +| 候補 | 今回の評価 | +| -------------------- | ------------------------------------------------------------------------------------------------------------ | +| SQLite | 推奨。別DBサービス不要。ローカルディスク、短いトランザクション、マイグレーション、復元試験を用意する | +| PostgreSQL | 複数アプリサーバーや大量の並列収集へ進む時に再評価。現時点では運用対象が増える | | ブラウザDB+同期基盤 | 採用しない。今回必要なのはオンラインで同じサーバー状態を読むこと。オフライン同期エンジンを導入する必要はない | SQLiteは同時書き込みが1つという制約がある。複数サーバー・高い書き込み並列度ではclient/server DBを検討する。[SQLiteの用途](https://www.sqlite.org/whentouse.html) @@ -52,14 +52,14 @@ OAuth callbackは固定のHTTPS URLにする。認可後に戻る主体はブラ ## 保存モデル案 -| 保存対象 | 主な内容 | -| --- | --- | -| `decks` | ID、名前、revision、更新日時 | -| `deck_columns` | ID、deck ID、並び順、connection ID、名前、platform固有のsource JSON | -| `connections` | ID、platform、接続先origin、外部アカウントIDまたはrelay profile参照、表示名、接続状態 | -| `connection_credentials` | connection ID、暗号化したアクセストークン、鍵の識別子。通常の接続一覧とは分離 | -| `oauth_apps` | インスタンスorigin、callback・scope構成、client ID、暗号化したclient secret | -| `oauth_attempts` | 短時間有効なstate、開始ブラウザとの束縛、接続先、PKCE verifier、期限、一度限りの消費状態 | +| 保存対象 | 主な内容 | +| ------------------------ | ---------------------------------------------------------------------------------------- | +| `decks` | ID、名前、revision、更新日時 | +| `deck_columns` | ID、deck ID、並び順、connection ID、名前、platform固有のsource JSON | +| `connections` | ID、platform、接続先origin、外部アカウントIDまたはrelay profile参照、表示名、接続状態 | +| `connection_credentials` | connection ID、暗号化したアクセストークン、鍵の識別子。通常の接続一覧とは分離 | +| `oauth_apps` | インスタンスorigin、callback・scope構成、client ID、暗号化したclient secret | +| `oauth_attempts` | 短時間有効なstate、開始ブラウザとの束縛、接続先、PKCE verifier、期限、一度限りの消費状態 | 単一利用者なので、この段階ではusers/organizations/roles等のテーブルを作らない。 @@ -69,16 +69,16 @@ DBには秘密を暗号化して保存し、暗号鍵はDB外の実行時credent ## PR単位のタスク -| ID | タスク | 依存 | 完了条件 | -| --- | --- | --- | --- | -| T0 | 実行環境とアクセス経路を固定 | なし | 本番HTTPS origin/callback候補を決め、PC・スマホから同じ利用者として接続。Serve以外からのヘッダー偽装を許さない構成を確認。対象Mastodonのバージョン・OAuthメタデータも調べる | -| T1 | SQLiteと永続ディレクトリを導入 | T0 | 現行Node/Nixで動くdriver・migration方式を実証。`StateDirectory`等でDBを永続化。再起動・アプリ更新後も残り、バックアップから復元できる。依存追加時はflakeのpnpm hash更新まで行う | -| T2 | connectionモデルへTwitterを移行 | T1 | relay profile一覧から接続を作り、カラム・取得・キャッシュ・ページ送りがconnection IDを使う。2つのTwitter接続を混ぜずに並列表示。不明・削除済みの接続はエラーとして残す | -| T3 | デッキをサーバー保存し端末間共有 | T1,T2 | PCで作ったデッキが別ブラウザコンテキストに表示。revision競合で上書きを拒否。WebMCPも同じ保存処理を使う。既存localStorageからの明示インポートを提供し、重複取り込みと既存DBの破壊を防ぐ | -| T4 | Mastodon OAuthとトークン保管 | T0,T1,T2 | 接続先登録→認可→callback→本人確認→暗号化保存。同一インスタンス2アカウント・別インスタンス・再接続・解除が動く。拒否/state不一致・期限切れ・再利用を検証 | -| T5 | Mastodon取得と投稿正規化 | T4 | ユーザー投稿・リスト・ハッシュタグのカラムを実装し、全文検索を別の能力として扱う。CW・sensitiveメディア・boost・HTML本文を安全に表示。元投稿URLと取得インスタンスのローカルIDを区別 | -| T6 | 複数接続UIとWebMCPを統合 | T3,T5 | 接続管理から追加・再接続・解除。カラムは接続に応じたsourceを選べる。Twitter/Mastodonを同じデッキに並べ、AIも接続一覧を発見して作成・取得できる | -| T7 | 2端末・障害・運用の通し検証 | T3,T4,T6 | 端末AのOAuth接続を端末Bで再認可せず利用。編集競合・接続失効・429・再起動・DB復元の検証。UI/API/WebMCP/ログにトークンが出ないことを確認 | +| ID | タスク | 依存 | 完了条件 | +| --- | -------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T0 | 実行環境とアクセス経路を固定 | なし | 本番HTTPS origin/callback候補を決め、PC・スマホから同じ利用者として接続。Serve以外からのヘッダー偽装を許さない構成を確認。対象Mastodonのバージョン・OAuthメタデータも調べる | +| T1 | SQLiteと永続ディレクトリを導入 | T0 | 現行Node/Nixで動くdriver・migration方式を実証。`StateDirectory`等でDBを永続化。再起動・アプリ更新後も残り、バックアップから復元できる。依存追加時はflakeのpnpm hash更新まで行う | +| T2 | connectionモデルへTwitterを移行 | T1 | relay profile一覧から接続を作り、カラム・取得・キャッシュ・ページ送りがconnection IDを使う。2つのTwitter接続を混ぜずに並列表示。不明・削除済みの接続はエラーとして残す | +| T3 | デッキをサーバー保存し端末間共有 | T1,T2 | PCで作ったデッキが別ブラウザコンテキストに表示。revision競合で上書きを拒否。WebMCPも同じ保存処理を使う。既存localStorageからの明示インポートを提供し、重複取り込みと既存DBの破壊を防ぐ | +| T4 | Mastodon OAuthとトークン保管 | T0,T1,T2 | 接続先登録→認可→callback→本人確認→暗号化保存。同一インスタンス2アカウント・別インスタンス・再接続・解除が動く。拒否/state不一致・期限切れ・再利用を検証 | +| T5 | Mastodon取得と投稿正規化 | T4 | ユーザー投稿・リスト・ハッシュタグのカラムを実装し、全文検索を別の能力として扱う。CW・sensitiveメディア・boost・HTML本文を安全に表示。元投稿URLと取得インスタンスのローカルIDを区別 | +| T6 | 複数接続UIとWebMCPを統合 | T3,T5 | 接続管理から追加・再接続・解除。カラムは接続に応じたsourceを選べる。Twitter/Mastodonを同じデッキに並べ、AIも接続一覧を発見して作成・取得できる | +| T7 | 2端末・障害・運用の通し検証 | T3,T4,T6 | 端末AのOAuth接続を端末Bで再認可せず利用。編集競合・接続失効・429・再起動・DB復元の検証。UI/API/WebMCP/ログにトークンが出ないことを確認 | 各PRに必要な単体・統合テストを含める。T7までテストを先送りしない。 diff --git a/docs/plans/2026-09-28-shadcn-ui.md b/docs/plans/2026-09-28-shadcn-ui.md index e3c6c09..e7b60ec 100644 --- a/docs/plans/2026-09-28-shadcn-ui.md +++ b/docs/plans/2026-09-28-shadcn-ui.md @@ -1,5 +1,9 @@ # shadcn workspace migration +> Tooling update: Biome has since been replaced by Oxlint for linting (including +> the shadcn rules) and Oxfmt for formatting. The checks recorded below describe +> the tooling used at the time of this migration; see README for current commands. + ## Target Replace the custom workspace UI with the Base UI version of shadcn sidebar-09 diff --git a/docs/storage-and-oauth.md b/docs/storage-and-oauth.md index cc3b530..3798337 100644 --- a/docs/storage-and-oauth.md +++ b/docs/storage-and-oauth.md @@ -9,12 +9,12 @@ tailnet HTTPS address after Mastodon authorization. ## Runtime configuration -| Variable | Value | -| --- | --- | -| `TWITTER_LITE_ORIGIN` | Exact Serve HTTPS origin, without a trailing slash | -| `TWITTER_LITE_ALLOWED_LOGIN` | Owner's Tailscale login | -| `TWITTER_LITE_DB_PATH` | Absolute path to the SQLite database on local disk | -| `TWITTER_LITE_MASTODON_ORIGINS` | Comma-separated approved HTTPS instance origins | +| Variable | Value | +| ---------------------------------- | --------------------------------------------------------- | +| `TWITTER_LITE_ORIGIN` | Exact Serve HTTPS origin, without a trailing slash | +| `TWITTER_LITE_ALLOWED_LOGIN` | Owner's Tailscale login | +| `TWITTER_LITE_DB_PATH` | Absolute path to the SQLite database on local disk | +| `TWITTER_LITE_MASTODON_ORIGINS` | Comma-separated approved HTTPS instance origins | | `TWITTER_LITE_CREDENTIAL_KEY_FILE` | Runtime file containing 32 random bytes encoded as base64 | The credential key is required for Mastodon, but not for Twitter-only use. diff --git a/docs/superpowers/plans/2026-07-13-tweet-detail-thread.md b/docs/superpowers/plans/2026-07-13-tweet-detail-thread.md index 644adea..87e5d0d 100644 --- a/docs/superpowers/plans/2026-07-13-tweet-detail-thread.md +++ b/docs/superpowers/plans/2026-07-13-tweet-detail-thread.md @@ -45,6 +45,7 @@ ### Task 1: Validate and Load One Conversation Page **Files:** + - Modify: `src/features/posts/types.ts` - Modify: `src/features/posts/inputs.ts` - Modify: `src/features/posts/inputs.test.ts` @@ -52,6 +53,7 @@ - Modify: `src/features/posts/post-service.test.ts` **Interfaces:** + - Consumes: Bird's exported `GetTweetResult`, `SearchResult`, and `TweetData` types plus existing `getTweet()` and `getThreadPaged()` methods. - Produces: `ThreadPageInput`, `ThreadPage`, `ThreadLoadResult`, `threadPageInputSchema`, `normalizeTweetId()`, the extended `BirdReader`, and `loadThreadPage(reader, input)`. @@ -65,48 +67,46 @@ import { normalizeTweetId, normalizeUserTarget, threadPageInputSchema, -} from './inputs' +} from "./inputs"; -describe('threadPageInputSchema', () => { - it('accepts initial and complete continuation requests', () => { - expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({ - tweetId: '123', - }) +describe("threadPageInputSchema", () => { + it("accepts initial and complete continuation requests", () => { + expect(threadPageInputSchema.parse({ tweetId: "123" })).toEqual({ + tweetId: "123", + }); expect( threadPageInputSchema.parse({ - tweetId: '123', - conversationId: '100', - cursor: 'thread-next', + tweetId: "123", + conversationId: "100", + cursor: "thread-next", }), ).toEqual({ - tweetId: '123', - conversationId: '100', - cursor: 'thread-next', - }) - }) + tweetId: "123", + conversationId: "100", + cursor: "thread-next", + }); + }); it.each([ - { tweetId: '' }, - { tweetId: 'abc' }, - { tweetId: '123', cursor: 'thread-next' }, - { tweetId: '123', conversationId: '100' }, - { tweetId: '123', conversationId: 'root', cursor: 'thread-next' }, - ])('rejects an invalid thread request %#', (input) => { - expect(() => threadPageInputSchema.parse(input)).toThrow() - }) -}) + { tweetId: "" }, + { tweetId: "abc" }, + { tweetId: "123", cursor: "thread-next" }, + { tweetId: "123", conversationId: "100" }, + { tweetId: "123", conversationId: "root", cursor: "thread-next" }, + ])("rejects an invalid thread request %#", (input) => { + expect(() => threadPageInputSchema.parse(input)).toThrow(); + }); +}); -describe('normalizeTweetId', () => { - it('keeps a decimal post ID', () => { - expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890') - }) +describe("normalizeTweetId", () => { + it("keeps a decimal post ID", () => { + expect(normalizeTweetId(" 1234567890 ")).toBe("1234567890"); + }); - it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => { - expect(() => normalizeTweetId(input)).toThrow( - '投稿 ID を確認してください。', - ) - }) -}) + it.each(["", "123/status", "@123", "123"])("rejects %s", (input) => { + expect(() => normalizeTweetId(input)).toThrow("投稿 ID を確認してください。"); + }); +}); ``` - [ ] **Step 2: Run the focused input tests and verify RED** @@ -125,29 +125,27 @@ Add to `src/features/posts/types.ts`: ```ts export type ThreadPage = PostPage & { - focalPost?: Post - conversationId: string -} + focalPost?: Post; + conversationId: string; +}; -export type LoadFailure = { ok: false; error: LoadError } +export type LoadFailure = { ok: false; error: LoadError }; -export type LoadResult = - | { ok: true; page: TPage } - | LoadFailure +export type LoadResult = { ok: true; page: TPage } | LoadFailure; -export type ThreadLoadResult = LoadResult +export type ThreadLoadResult = LoadResult; export type ThreadPageInput = | { - tweetId: string - conversationId?: never - cursor?: never + tweetId: string; + conversationId?: never; + cursor?: never; } | { - tweetId: string - conversationId: string - cursor: string - } + tweetId: string; + conversationId: string; + cursor: string; + }; ``` Replace the existing non-generic `LoadResult` declaration with `LoadFailure` and the generic declaration above. Extend `LoadErrorCode` with: @@ -160,11 +158,8 @@ Replace the existing non-generic `LoadResult` declaration with `LoadFailure` and Add to `src/features/posts/inputs.ts`: ```ts -const TWEET_ID = /^\d{1,32}$/ -const tweetIdSchema = z - .string() - .trim() - .regex(TWEET_ID, '投稿 ID を確認してください。') +const TWEET_ID = /^\d{1,32}$/; +const tweetIdSchema = z.string().trim().regex(TWEET_ID, "投稿 ID を確認してください。"); export const threadPageInputSchema = z.union([ z.object({ @@ -177,14 +172,14 @@ export const threadPageInputSchema = z.union([ conversationId: tweetIdSchema, cursor: z.string().min(1), }), -]) +]); export function normalizeTweetId(raw: string): string { - const value = raw.trim() + const value = raw.trim(); if (!TWEET_ID.test(value)) { - throw new InputError('投稿 ID を確認してください。') + throw new InputError("投稿 ID を確認してください。"); } - return value + return value; } ``` @@ -229,129 +224,129 @@ Extend the `reader()` fixture in `src/features/posts/post-service.test.ts` with: Import `loadThreadPage`, then add: ```ts -describe('loadThreadPage', () => { - it('resolves the focal post and fetches one root conversation page', async () => { - const client = reader() - const result = await loadThreadPage(client, { tweetId: '123' }) +describe("loadThreadPage", () => { + it("resolves the focal post and fetches one root conversation page", async () => { + const client = reader(); + const result = await loadThreadPage(client, { tweetId: "123" }); - expect(client.getTweet).toHaveBeenCalledWith('123') - expect(client.getThreadPaged).toHaveBeenCalledWith('100', { + expect(client.getTweet).toHaveBeenCalledWith("123"); + expect(client.getThreadPaged).toHaveBeenCalledWith("100", { maxPages: 1, pageDelayMs: 0, - }) + }); expect(result).toEqual({ ok: true, page: { focalPost: { - id: '123', - text: 'focal', - conversationId: '100', - author: { username: 'focus', name: 'Focus' }, + id: "123", + text: "focal", + conversationId: "100", + author: { username: "focus", name: "Focus" }, }, tweets: [ { - id: '100', - text: 'root', - conversationId: '100', - author: { username: 'root', name: 'Root' }, + id: "100", + text: "root", + conversationId: "100", + author: { username: "root", name: "Root" }, }, ], - conversationId: '100', - nextCursor: 'thread-next', + conversationId: "100", + nextCursor: "thread-next", }, - }) - }) + }); + }); - it('uses the carried root for exactly one continuation read', async () => { - const client = reader() + it("uses the carried root for exactly one continuation read", async () => { + const client = reader(); const result = await loadThreadPage(client, { - tweetId: '123', - conversationId: '100', - cursor: 'thread-next', - }) + tweetId: "123", + conversationId: "100", + cursor: "thread-next", + }); - expect(client.getTweet).not.toHaveBeenCalled() - expect(client.getThreadPaged).toHaveBeenCalledOnce() - expect(client.getThreadPaged).toHaveBeenCalledWith('100', { - cursor: 'thread-next', + expect(client.getTweet).not.toHaveBeenCalled(); + expect(client.getThreadPaged).toHaveBeenCalledOnce(); + expect(client.getThreadPaged).toHaveBeenCalledWith("100", { + cursor: "thread-next", maxPages: 1, pageDelayMs: 0, - }) + }); expect(result).toMatchObject({ ok: true, - page: { conversationId: '100', focalPost: undefined }, - }) - }) + page: { conversationId: "100", focalPost: undefined }, + }); + }); - it('removes raw data from focal, conversation, and quotes', async () => { - const client = reader() + it("removes raw data from focal, conversation, and quotes", async () => { + const client = reader(); vi.mocked(client.getTweet).mockResolvedValue({ success: true, tweet: { - id: '123', - text: 'focal', - conversationId: '100', - author: { username: 'focus', name: 'Focus' }, + id: "123", + text: "focal", + conversationId: "100", + author: { username: "focus", name: "Focus" }, quotedTweet: { - id: '90', - text: 'quote', - author: { username: 'quote', name: 'Quote' }, - _raw: { rest_id: 'private-quote' }, + id: "90", + text: "quote", + author: { username: "quote", name: "Quote" }, + _raw: { rest_id: "private-quote" }, }, - _raw: { rest_id: 'private-focal' }, + _raw: { rest_id: "private-focal" }, }, - }) + }); vi.mocked(client.getThreadPaged).mockResolvedValue({ success: true, tweets: [ { - id: '100', - text: 'root', - conversationId: '100', - author: { username: 'root', name: 'Root' }, - _raw: { rest_id: 'private-thread' }, + id: "100", + text: "root", + conversationId: "100", + author: { username: "root", name: "Root" }, + _raw: { rest_id: "private-thread" }, }, ], - }) + }); - const result = await loadThreadPage(client, { tweetId: '123' }) + const result = await loadThreadPage(client, { tweetId: "123" }); - expect(JSON.stringify(result)).not.toContain('private-') - }) + expect(JSON.stringify(result)).not.toContain("private-"); + }); - it('returns a safe non-retryable missing-post error', async () => { - const client = reader() + it("returns a safe non-retryable missing-post error", async () => { + const client = reader(); vi.mocked(client.getTweet).mockResolvedValue({ success: false, - error: 'Tweet not found: private relay detail', - }) + error: "Tweet not found: private relay detail", + }); - expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({ + expect(await loadThreadPage(client, { tweetId: "404" })).toEqual({ ok: false, error: { - code: 'post-not-found', - message: '投稿が見つかりませんでした。', + code: "post-not-found", + message: "投稿が見つかりませんでした。", retryable: false, }, - }) - expect(client.getThreadPaged).not.toHaveBeenCalled() - }) + }); + expect(client.getThreadPaged).not.toHaveBeenCalled(); + }); - it('rejects an invalid ID without a Bird call', async () => { - const client = reader() + it("rejects an invalid ID without a Bird call", async () => { + const client = reader(); - expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({ + expect(await loadThreadPage(client, { tweetId: "not-an-id" })).toEqual({ ok: false, error: { - code: 'invalid-input', - message: '投稿 ID を確認してください。', + code: "invalid-input", + message: "投稿 ID を確認してください。", retryable: false, }, - }) - expect(client.getTweet).not.toHaveBeenCalled() - expect(client.getThreadPaged).not.toHaveBeenCalled() - }) -}) + }); + expect(client.getTweet).not.toHaveBeenCalled(); + expect(client.getThreadPaged).not.toHaveBeenCalled(); + }); +}); ``` - [ ] **Step 6: Run the focused service tests and verify RED** @@ -383,36 +378,29 @@ In `src/features/posts/post-service.ts`, import `GetTweetResult`, `normalizeTwee Change `upstreamFailure` to accept a subject while keeping existing user/search behavior: ```ts -function upstreamFailure( - diagnostic: unknown = '', - subject: 'user' | 'post' = 'user', -): LoadFailure { +function upstreamFailure(diagnostic: unknown = "", subject: "user" | "post" = "user"): LoadFailure { const message = diagnostic instanceof Error ? diagnostic.message - : typeof diagnostic === 'string' + : typeof diagnostic === "string" ? diagnostic - : '' - const lower = message.toLowerCase() - if (lower.includes('timeout') || lower.includes('aborted')) { - return failure('timeout', '取得がタイムアウトしました。', true) + : ""; + const lower = message.toLowerCase(); + if (lower.includes("timeout") || lower.includes("aborted")) { + return failure("timeout", "取得がタイムアウトしました。", true); } - if (lower.includes('not found')) { - return subject === 'post' - ? failure('post-not-found', '投稿が見つかりませんでした。', false) - : failure('user-not-found', 'ユーザーが見つかりませんでした。', false) + if (lower.includes("not found")) { + return subject === "post" + ? failure("post-not-found", "投稿が見つかりませんでした。", false) + : failure("user-not-found", "ユーザーが見つかりませんでした。", false); } - if (lower.includes('suspended') || lower.includes('protected')) { - return subject === 'post' - ? failure('post-unavailable', 'この投稿は取得できません。', false) - : failure( - 'user-unavailable', - 'このユーザーの投稿は取得できません。', - false, - ) + if (lower.includes("suspended") || lower.includes("protected")) { + return subject === "post" + ? failure("post-unavailable", "この投稿は取得できません。", false) + : failure("user-unavailable", "このユーザーの投稿は取得できません。", false); } - console.error('Unexpected Bird failure:', diagnostic) - return failure('upstream', 'X から投稿を取得できませんでした。', true) + console.error("Unexpected Bird failure:", diagnostic); + return failure("upstream", "X から投稿を取得できませんでした。", true); } ``` @@ -424,30 +412,28 @@ export async function loadThreadPage( input: ThreadPageInput, ): Promise { try { - const tweetId = normalizeTweetId(input.tweetId) - let focalPost: Post | undefined - let conversationId: string + const tweetId = normalizeTweetId(input.tweetId); + let focalPost: Post | undefined; + let conversationId: string; if (input.cursor && input.conversationId) { - conversationId = normalizeTweetId(input.conversationId) + conversationId = normalizeTweetId(input.conversationId); } else { - const focal = await reader.getTweet(tweetId) + const focal = await reader.getTweet(tweetId); if (!focal.success || !focal.tweet) { - return upstreamFailure(focal.error ?? 'Tweet not found', 'post') + return upstreamFailure(focal.error ?? "Tweet not found", "post"); } - focalPost = publicPost(focal.tweet) - conversationId = normalizeTweetId( - focal.tweet.conversationId ?? focal.tweet.id, - ) + focalPost = publicPost(focal.tweet); + conversationId = normalizeTweetId(focal.tweet.conversationId ?? focal.tweet.id); } const result = await reader.getThreadPaged(conversationId, { ...(input.cursor ? { cursor: input.cursor } : {}), maxPages: 1, pageDelayMs: 0, - }) + }); if (!result.success) { - return upstreamFailure(result.error, 'post') + return upstreamFailure(result.error, "post"); } return { @@ -458,12 +444,12 @@ export async function loadThreadPage( conversationId, nextCursor: result.nextCursor, }, - } + }; } catch (error) { if (error instanceof InputError) { - return failure('invalid-input', error.message, false) + return failure("invalid-input", error.message, false); } - return upstreamFailure(error, 'post') + return upstreamFailure(error, "post"); } } ``` @@ -500,6 +486,7 @@ git commit -m "feat: load tweet conversations" ### Task 2: Carry Conversation State Through the Server and Infinite Query **Files:** + - Modify: `src/features/posts/server-functions.ts` - Modify: `src/features/posts/use-post-feed.ts` - Modify: `src/features/posts/use-post-feed.test.ts` @@ -507,6 +494,7 @@ git commit -m "feat: load tweet conversations" - Modify: `src/features/posts/page.test.ts` **Interfaces:** + - Consumes: `loadThreadPage`, `threadPageInputSchema`, `ThreadLoadResult`, and `ThreadPage` from Task 1. - Produces: `loadThreadPosts`, thread `FeedRequest`, object continuation page parameters, `focalPostFromPages()`, and `flattenConversationPages()`. @@ -515,38 +503,32 @@ git commit -m "feat: load tweet conversations" Replace the type and function imports in `src/features/posts/page.test.ts` with these imports, then add: ```ts -import { - flattenConversationPages, - flattenPostPages, - focalPostFromPages, -} from './page' -import type { Post, PostPage, ThreadPage } from './types' +import { flattenConversationPages, flattenPostPages, focalPostFromPages } from "./page"; +import type { Post, PostPage, ThreadPage } from "./types"; -describe('thread page helpers', () => { - const focal = post('2') +describe("thread page helpers", () => { + const focal = post("2"); const pages: ThreadPage[] = [ { focalPost: focal, - conversationId: '1', - nextCursor: 'next', - tweets: [post('1'), focal], + conversationId: "1", + nextCursor: "next", + tweets: [post("1"), focal], }, { - conversationId: '1', - tweets: [focal, post('3')], + conversationId: "1", + tweets: [focal, post("3")], }, - ] + ]; - it('takes the focal post from the first page', () => { - expect(focalPostFromPages(pages)).toEqual(focal) - }) + it("takes the focal post from the first page", () => { + expect(focalPostFromPages(pages)).toEqual(focal); + }); - it('deduplicates the conversation and removes the focal post', () => { - expect( - flattenConversationPages(pages, focal.id).map(({ id }) => id), - ).toEqual(['1', '3']) - }) -}) + it("deduplicates the conversation and removes the focal post", () => { + expect(flattenConversationPages(pages, focal.id).map(({ id }) => id)).toEqual(["1", "3"]); + }); +}); ``` - [ ] **Step 2: Run the page tests and verify RED** @@ -564,20 +546,18 @@ Expected: FAIL because the thread helpers do not exist. Add to `src/features/posts/page.ts`: ```ts -import type { Post, PostPage, ThreadPage } from './types' +import type { Post, PostPage, ThreadPage } from "./types"; -export function focalPostFromPages( - pages: Array, -): Post | undefined { - const first = pages[0] - return first && 'focalPost' in first ? first.focalPost : undefined +export function focalPostFromPages(pages: Array): Post | undefined { + const first = pages[0]; + return first && "focalPost" in first ? first.focalPost : undefined; } export function flattenConversationPages( pages: Array, focalPostId: string, ): Post[] { - return flattenPostPages(pages).filter(({ id }) => id !== focalPostId) + return flattenPostPages(pages).filter(({ id }) => id !== focalPostId); } ``` @@ -590,75 +570,66 @@ In `src/features/posts/use-post-feed.test.ts`, add a `thread` loader to every lo ```ts const loaders = ( overrides: Partial<{ - loadUser: ReturnType - search: ReturnType - thread: ReturnType + loadUser: ReturnType; + search: ReturnType; + thread: ReturnType; }> = {}, ) => ({ loadUser: vi.fn(), search: vi.fn(), thread: vi.fn(), ...overrides, -}) +}); ``` Add: ```ts -it('loads the initial thread and carries its root with the next cursor', async () => { +it("loads the initial thread and carries its root with the next cursor", async () => { const thread = vi.fn().mockResolvedValue({ ok: true, page: { tweets: [], focalPost: { - id: '123', - text: 'focal', - author: { username: 'focus', name: 'Focus' }, + id: "123", + text: "focal", + author: { username: "focus", name: "Focus" }, }, - conversationId: '100', - nextCursor: 'thread-next', + conversationId: "100", + nextCursor: "thread-next", }, - }) - const options = createPostFeedOptions( - { kind: 'thread', tweetId: '123' }, - loaders({ thread }), - ) + }); + const options = createPostFeedOptions({ kind: "thread", tweetId: "123" }, loaders({ thread })); - const page = await options.queryFn({ pageParam: undefined } as never) + const page = await options.queryFn({ pageParam: undefined } as never); - expect(thread).toHaveBeenCalledWith({ data: { tweetId: '123' } }) - expect(options.queryKey).toEqual([ - 'posts', - { kind: 'thread', tweetId: '123' }, - ]) + expect(thread).toHaveBeenCalledWith({ data: { tweetId: "123" } }); + expect(options.queryKey).toEqual(["posts", { kind: "thread", tweetId: "123" }]); expect(options.getNextPageParam(page)).toEqual({ - cursor: 'thread-next', - conversationId: '100', - }) -}) + cursor: "thread-next", + conversationId: "100", + }); +}); -it('forwards a thread continuation page parameter intact', async () => { +it("forwards a thread continuation page parameter intact", async () => { const thread = vi.fn().mockResolvedValue({ ok: true, - page: { tweets: [], conversationId: '100' }, - }) - const options = createPostFeedOptions( - { kind: 'thread', tweetId: '123' }, - loaders({ thread }), - ) + page: { tweets: [], conversationId: "100" }, + }); + const options = createPostFeedOptions({ kind: "thread", tweetId: "123" }, loaders({ thread })); await options.queryFn({ - pageParam: { cursor: 'thread-next', conversationId: '100' }, - } as never) + pageParam: { cursor: "thread-next", conversationId: "100" }, + } as never); expect(thread).toHaveBeenCalledWith({ data: { - tweetId: '123', - cursor: 'thread-next', - conversationId: '100', + tweetId: "123", + cursor: "thread-next", + conversationId: "100", }, - }) -}) + }); +}); ``` - [ ] **Step 5: Run the query tests and verify RED** @@ -678,15 +649,15 @@ Expected: page helpers PASS; query tests FAIL because thread requests are unsupp In `src/features/posts/server-functions.ts`, import `threadPageInputSchema`, `loadThreadPage`, and `LoadFailure`. Replace the current `configFailure` return annotation with `LoadFailure`, then add: ```ts -export const loadThreadPosts = createServerFn({ method: 'GET' }) +export const loadThreadPosts = createServerFn({ method: "GET" }) .validator(threadPageInputSchema) .handler(async ({ data }) => { try { - return await loadThreadPage(await reader(), data) + return await loadThreadPage(await reader(), data); } catch (error) { - return configFailure(error) + return configFailure(error); } - }) + }); ``` Do not create another client, dispatcher, or proxy. @@ -703,11 +674,11 @@ Add: ```ts type ThreadPageParam = { - cursor: string - conversationId: string -} + cursor: string; + conversationId: string; +}; -type FeedPageParam = string | ThreadPageParam | undefined +type FeedPageParam = string | ThreadPageParam | undefined; ``` Change the option's initial page parameter annotation to: @@ -719,15 +690,15 @@ Change the option's initial page parameter annotation to: Extend `Loaders` with: ```ts - thread: (options: { data: ThreadPageInput }) => Promise +thread: (options: { data: ThreadPageInput }) => Promise; ``` Make `unwrap` generic: ```ts function unwrap(result: LoadResult): TPage { - if (!result.ok) throw new PostLoadError(result.error) - return result.page + if (!result.ok) throw new PostLoadError(result.error); + return result.page; } ``` @@ -784,16 +755,16 @@ Replace `queryFn` and `getNextPageParam` inside `createPostFeedOptions` with: In `usePostFeed`, bind the third server function and pass all loaders: ```ts - const thread = useServerFn(loadThreadPosts) +const thread = useServerFn(loadThreadPosts); - return useInfiniteQuery({ - ...createPostFeedOptions(request ?? disabled, { - loadUser, - search, - thread, - }), - enabled: request !== undefined, - }) +return useInfiniteQuery({ + ...createPostFeedOptions(request ?? disabled, { + loadUser, + search, + thread, + }), + enabled: request !== undefined, +}); ``` - [ ] **Step 8: Run focused and regression tests** @@ -829,6 +800,7 @@ git commit -m "feat: paginate tweet conversations" ### Task 3: Add the Detail Route and Focal Conversation Interface **Files:** + - Create: `src/routes/status.$tweetId.tsx` - Modify: `src/routeTree.gen.ts` through the route generator - Modify: `src/components/app-shell.tsx` @@ -840,6 +812,7 @@ git commit -m "feat: paginate tweet conversations" - Modify: `src/styles.css` **Interfaces:** + - Consumes: thread `FeedRequest`, `focalPostFromPages()`, and `flattenConversationPages()` from Task 2. - Produces: `/status/$tweetId`, internal `詳細・スレッド` anchors, neutral shell navigation, focal-card presentation, and a reusable thread feed with existing retry/observer behavior. @@ -987,11 +960,9 @@ In `src/features/posts/components/post-card.tsx`, add `current = false` to the p Within the non-quoted footer, keep the counts and external link and add: ```tsx - {!current ? ( - - 詳細・スレッド - - ) : null} +{ + !current ? 詳細・スレッド : null; +} ``` The complete public prop contract becomes: @@ -1002,10 +973,10 @@ export function PostCard({ quoted = false, current = false, }: { - post: Post - quoted?: boolean - current?: boolean -}) + post: Post; + quoted?: boolean; + current?: boolean; +}); ``` - [ ] **Step 5: Render focal and conversation content without duplicating query behavior** @@ -1013,12 +984,9 @@ export function PostCard({ In `src/features/posts/components/post-feed.tsx`, import `type Post`, the new page helpers, and derive: ```ts - const pages = query.data?.pages ?? [] - const focalPost = - request?.kind === 'thread' ? focalPostFromPages(pages) : undefined - const posts = focalPost - ? flattenConversationPages(pages, focalPost.id) - : flattenPostPages(pages) +const pages = query.data?.pages ?? []; +const focalPost = request?.kind === "thread" ? focalPostFromPages(pages) : undefined; +const posts = focalPost ? flattenConversationPages(pages, focalPost.id) : flattenPostPages(pages); ``` Add a small local renderer: @@ -1032,19 +1000,19 @@ function FocalPost({ post }: { post: Post }) {

- ) + ); } ``` For a successful thread query, render `FocalPost` before the existing feed section. If `posts.length === 0` and there is no next page, render: ```tsx - {focalPost ? : null} -

- {focalPost - ? '会話にほかの投稿はありません。' - : '条件に一致する投稿はありません。'} -

+{ + focalPost ? : null; +} +

+ {focalPost ? "会話にほかの投稿はありません。" : "条件に一致する投稿はありません。"} +

; ``` Keep the current observer, loading rail, later-page alert, Retry calls, and terminal state unchanged. The normal success return begins with: @@ -1073,34 +1041,26 @@ Expected: all card/feed component tests PASS. Add to `src/routes/-feed-wiring.test.tsx`: ```ts -it('turns a decimal status path into a deliberate thread request', async () => { - await renderRoute('/status/123') +it("turns a decimal status path into a deliberate thread request", async () => { + await renderRoute("/status/123"); - expect( - await screen.findByRole('heading', { name: '会話' }), - ).toBeVisible() + expect(await screen.findByRole("heading", { name: "会話" })).toBeVisible(); await waitFor(() => expect(usePostFeed).toHaveBeenCalledWith({ - kind: 'thread', - tweetId: '123', + kind: "thread", + tweetId: "123", }), - ) - expect( - screen.queryByRole('link', { name: 'ユーザー', current: 'page' }), - ).toBeNull() - expect( - screen.queryByRole('link', { name: '検索', current: 'page' }), - ).toBeNull() -}) + ); + expect(screen.queryByRole("link", { name: "ユーザー", current: "page" })).toBeNull(); + expect(screen.queryByRole("link", { name: "検索", current: "page" })).toBeNull(); +}); -it('keeps an invalid status path local and idle', async () => { - await renderRoute('/status/not-a-tweet') +it("keeps an invalid status path local and idle", async () => { + await renderRoute("/status/not-a-tweet"); - expect(await screen.findByRole('alert')).toHaveTextContent( - '投稿 ID を確認してください。', - ) - expect(usePostFeed).toHaveBeenCalledWith(undefined) -}) + expect(await screen.findByRole("alert")).toHaveTextContent("投稿 ID を確認してください。"); + expect(usePostFeed).toHaveBeenCalledWith(undefined); +}); ``` - [ ] **Step 8: Run the route test before adding the route and verify RED** @@ -1124,27 +1084,24 @@ Change `AppShell`'s active prop to optional: Create the complete `src/routes/status.$tweetId.tsx`: ```tsx -import { createFileRoute } from '@tanstack/react-router' -import { AppShell } from '#/components/app-shell' -import { PostFeed } from '#/features/posts/components/post-feed' -import { InputError, normalizeTweetId } from '#/features/posts/inputs' +import { createFileRoute } from "@tanstack/react-router"; +import { AppShell } from "#/components/app-shell"; +import { PostFeed } from "#/features/posts/components/post-feed"; +import { InputError, normalizeTweetId } from "#/features/posts/inputs"; -export const Route = createFileRoute('/status/$tweetId')({ +export const Route = createFileRoute("/status/$tweetId")({ component: StatusRoute, -}) +}); function StatusRoute() { - const { tweetId: rawTweetId } = Route.useParams() - let tweetId: string | undefined - let error: string | undefined + const { tweetId: rawTweetId } = Route.useParams(); + let tweetId: string | undefined; + let error: string | undefined; try { - tweetId = normalizeTweetId(rawTweetId) + tweetId = normalizeTweetId(rawTweetId); } catch (cause) { - error = - cause instanceof InputError - ? cause.message - : '投稿 ID を確認してください。' + error = cause instanceof InputError ? cause.message : "投稿 ID を確認してください。"; } return ( @@ -1156,11 +1113,9 @@ function StatusRoute() { {error}

) : null} - + - ) + ); } ``` @@ -1240,6 +1195,7 @@ git commit -m "feat: add tweet detail page" ### Task 4: Prove Thread Navigation and Infinite Scrolling in Real Browsers **Files:** + - Modify: `tests/e2e/mock-relay.mjs` - Modify: `tests/e2e/reader.spec.ts` - Modify: `tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png` @@ -1248,6 +1204,7 @@ git commit -m "feat: add tweet detail page" - Create: `tests/e2e/reader.spec.ts-snapshots/mist-thread-mobile-linux.png` **Interfaces:** + - Consumes: GET `TweetDetail` calls emitted by Bird and the `/status/$tweetId` route from Task 3. - Produces: deterministic desktop/mobile evidence for card navigation, reply-root resolution, focal de-duplication, cursor loading, later-page failure/retry, console safety, and visual quality. @@ -1256,16 +1213,11 @@ git commit -m "feat: add tweet detail page" Refactor the current tweet helper in `tests/e2e/mock-relay.mjs` into: ```js -const tweetResult = ( - id, - text, - username = 'yuta', - { conversationId = id, inReplyTo } = {}, -) => ({ +const tweetResult = (id, text, username = "yuta", { conversationId = id, inReplyTo } = {}) => ({ rest_id: id, legacy: { full_text: text, - created_at: 'Mon Jul 13 00:00:00 +0000 2026', + created_at: "Mon Jul 13 00:00:00 +0000 2026", reply_count: 1, retweet_count: 2, favorite_count: 3, @@ -1275,14 +1227,14 @@ const tweetResult = ( core: { user_results: { result: { - rest_id: '42', - legacy: { screen_name: username, name: 'Yuta' }, + rest_id: "42", + legacy: { screen_name: username, name: "Yuta" }, }, }, }, -}) +}); -const tweet = (id, text, username = 'yuta', options) => ({ +const tweet = (id, text, username = "yuta", options) => ({ entryId: `tweet-${id}`, content: { itemContent: { @@ -1291,7 +1243,7 @@ const tweet = (id, text, username = 'yuta', options) => ({ }, }, }, -}) +}); ``` Change the first user fixture ID from `u1` to `1001` and give it conversation ID `1000`; change the second user fixture ID from `u2` to `1010`. @@ -1302,116 +1254,115 @@ Add `TweetDetail` to the supported-operation suffix list. Before SearchTimeline ```js const rootByTarget = new Map([ - ['1001', '1000'], - ['2001', '2000'], -]) + ["1001", "1000"], + ["2001", "2000"], +]); const targetByRoot = new Map([ - ['1000', '1001'], - ['2000', '2001'], -]) -const failedThreadRoots = new Set() + ["1000", "1001"], + ["2000", "2001"], +]); +const failedThreadRoots = new Set(); ``` The branch is: ```js - if (operation === 'TweetDetail') { - const focalTweetId = variables.focalTweetId - if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) { - fail(response, 400, 'invalid focalTweetId') - return - } - const features = url.searchParams.get('features') - const fieldToggles = url.searchParams.get('fieldToggles') - try { - if (!features || !isRecord(JSON.parse(features))) throw new Error() - if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) { - throw new Error() - } - } catch { - fail(response, 400, 'invalid TweetDetail feature locks') - return +if (operation === "TweetDetail") { + const focalTweetId = variables.focalTweetId; + if (typeof focalTweetId !== "string" || !/^\d+$/.test(focalTweetId)) { + fail(response, 400, "invalid focalTweetId"); + return; + } + const features = url.searchParams.get("features"); + const fieldToggles = url.searchParams.get("fieldToggles"); + try { + if (!features || !isRecord(JSON.parse(features))) throw new Error(); + if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) { + throw new Error(); } + } catch { + fail(response, 400, "invalid TweetDetail feature locks"); + return; + } - const targetRoot = rootByTarget.get(focalTweetId) - if (targetRoot && variables.cursor === undefined) { - const text = focalTweetId === '1001' ? 'user page 1' : 'retry focal' - const result = tweetResult(focalTweetId, text, 'focus', { - conversationId: targetRoot, - inReplyTo: targetRoot, - }) - send(response, { - data: { - tweetResult: { result }, - threaded_conversation_with_injections_v2: { - instructions: [{ entries: [tweet(focalTweetId, text, 'focus', { - conversationId: targetRoot, - inReplyTo: targetRoot, - })] }], - }, - }, - }) - return - } - - const targetId = targetByRoot.get(focalTweetId) - if (!targetId) { - fail(response, 400, 'unsupported TweetDetail fixture') - return - } - const expectedCursor = `thread-next:${focalTweetId}` - if ( - variables.cursor !== undefined && - variables.cursor !== expectedCursor - ) { - fail(response, 400, 'invalid thread cursor') - return - } - if ( - variables.cursor && - focalTweetId === '2000' && - !failedThreadRoots.has(focalTweetId) - ) { - failedThreadRoots.add(focalTweetId) - fail(response, 503, 'transient thread fixture failure') - return - } - - const entries = variables.cursor - ? [ - tweet(`${focalTweetId}3`, 'thread page 2', 'reply2', { - conversationId: focalTweetId, - inReplyTo: targetId, - }), - ] - : [ - tweet(focalTweetId, 'thread root', 'root', { - conversationId: focalTweetId, - }), - tweet(targetId, focalTweetId === '1000' ? 'user page 1' : 'retry focal', 'focus', { - conversationId: focalTweetId, - inReplyTo: focalTweetId, - }), - tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', { - conversationId: focalTweetId, - inReplyTo: targetId, - }), - cursor(expectedCursor), - ] + const targetRoot = rootByTarget.get(focalTweetId); + if (targetRoot && variables.cursor === undefined) { + const text = focalTweetId === "1001" ? "user page 1" : "retry focal"; + const result = tweetResult(focalTweetId, text, "focus", { + conversationId: targetRoot, + inReplyTo: targetRoot, + }); send(response, { data: { - tweetResult: { - result: tweetResult(focalTweetId, 'thread root', 'root', { - conversationId: focalTweetId, - }), - }, + tweetResult: { result }, threaded_conversation_with_injections_v2: { - instructions: [{ entries }], + instructions: [ + { + entries: [ + tweet(focalTweetId, text, "focus", { + conversationId: targetRoot, + inReplyTo: targetRoot, + }), + ], + }, + ], }, }, - }) - return + }); + return; } + + const targetId = targetByRoot.get(focalTweetId); + if (!targetId) { + fail(response, 400, "unsupported TweetDetail fixture"); + return; + } + const expectedCursor = `thread-next:${focalTweetId}`; + if (variables.cursor !== undefined && variables.cursor !== expectedCursor) { + fail(response, 400, "invalid thread cursor"); + return; + } + if (variables.cursor && focalTweetId === "2000" && !failedThreadRoots.has(focalTweetId)) { + failedThreadRoots.add(focalTweetId); + fail(response, 503, "transient thread fixture failure"); + return; + } + + const entries = variables.cursor + ? [ + tweet(`${focalTweetId}3`, "thread page 2", "reply2", { + conversationId: focalTweetId, + inReplyTo: targetId, + }), + ] + : [ + tweet(focalTweetId, "thread root", "root", { + conversationId: focalTweetId, + }), + tweet(targetId, focalTweetId === "1000" ? "user page 1" : "retry focal", "focus", { + conversationId: focalTweetId, + inReplyTo: focalTweetId, + }), + tweet(`${focalTweetId}2`, "thread page 1", "reply1", { + conversationId: focalTweetId, + inReplyTo: targetId, + }), + cursor(expectedCursor), + ]; + send(response, { + data: { + tweetResult: { + result: tweetResult(focalTweetId, "thread root", "root", { + conversationId: focalTweetId, + }), + }, + threaded_conversation_with_injections_v2: { + instructions: [{ entries }], + }, + }, + }); + return; +} ``` Keep unknown operations at 501, wrong methods at 405, wrong profile at 403, and malformed supported input at 400. Do not add 404 or `fetch()`. @@ -1421,52 +1372,42 @@ Keep unknown operations at 501, wrong methods at 405, wrong profile at 403, and Add to `tests/e2e/reader.spec.ts`: ```ts -test('opens a card detail and infinitely loads its conversation', async ({ - page, -}) => { - await openReader(page, '/user?target=yuta') - const sourceCard = page - .getByRole('article') - .filter({ hasText: 'user page 1' }) - await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click() +test("opens a card detail and infinitely loads its conversation", async ({ page }) => { + await openReader(page, "/user?target=yuta"); + const sourceCard = page.getByRole("article").filter({ hasText: "user page 1" }); + await sourceCard.getByRole("link", { name: "詳細・スレッド" }).click(); - await expect(page).toHaveURL(/\/status\/1001$/) - await expect(page.getByRole('heading', { name: '会話' })).toBeVisible() - await expect(page.getByText('表示中の投稿')).toBeVisible() - await expect(page.getByText('user page 1')).toHaveCount(1) - await expect(page.getByText('thread root')).toBeVisible() - await expect(page.getByText('thread page 1')).toBeVisible() - await expect(page.getByText('thread page 2')).toBeVisible() - await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() - await expect(page).toHaveScreenshot('mist-thread.png', { - animations: 'disabled', + await expect(page).toHaveURL(/\/status\/1001$/); + await expect(page.getByRole("heading", { name: "会話" })).toBeVisible(); + await expect(page.getByText("表示中の投稿")).toBeVisible(); + await expect(page.getByText("user page 1")).toHaveCount(1); + await expect(page.getByText("thread root")).toBeVisible(); + await expect(page.getByText("thread page 1")).toBeVisible(); + await expect(page.getByText("thread page 2")).toBeVisible(); + await expect(page.getByText("これ以上の投稿はありません。")).toBeVisible(); + await expect(page).toHaveScreenshot("mist-thread.png", { + animations: "disabled", fullPage: true, - }) -}) + }); +}); -test('resolves a reply root and retries only the failed continuation', async ({ - page, -}) => { - await openReader(page, '/status/2001') +test("resolves a reply root and retries only the failed continuation", async ({ page }) => { + await openReader(page, "/status/2001"); - await expect(page.getByText('retry focal')).toHaveCount(1) - await expect(page.getByText('thread root')).toBeVisible() - await expect(page.getByText('thread page 1')).toBeVisible() - const alert = page - .getByRole('alert') - .filter({ hasText: '続きの投稿を取得できませんでした。' }) - await expect(alert).toHaveCount(1) - await expect(page.getByText('retry focal')).toHaveCount(1) + await expect(page.getByText("retry focal")).toHaveCount(1); + await expect(page.getByText("thread root")).toBeVisible(); + await expect(page.getByText("thread page 1")).toBeVisible(); + const alert = page.getByRole("alert").filter({ hasText: "続きの投稿を取得できませんでした。" }); + await expect(alert).toHaveCount(1); + await expect(page.getByText("retry focal")).toHaveCount(1); - await alert.getByRole('button', { name: '再試行' }).click() + await alert.getByRole("button", { name: "再試行" }).click(); - await expect(page.getByText('thread page 2')).toBeVisible() - await expect(alert).toHaveCount(0) - await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() - await expect( - page.getByRole('button', { name: mutationControlNames }), - ).toHaveCount(0) -}) + await expect(page.getByText("thread page 2")).toBeVisible(); + await expect(alert).toHaveCount(0); + await expect(page.getByText("これ以上の投稿はありません。")).toBeVisible(); + await expect(page.getByRole("button", { name: mutationControlNames })).toHaveCount(0); +}); ``` - [ ] **Step 4: Run E2E and verify intentional RED** @@ -1523,6 +1464,7 @@ git commit -m "test: verify tweet conversation flows" ### Task 5: Document and Verify the Delivered Boundary **Files:** + - Modify: `README.md` - Modify: `docs/superpowers/specs/2026-07-13-twitter-lite-design.md` - Modify: `docs/superpowers/plans/2026-07-13-twitter-lite.md` @@ -1530,6 +1472,7 @@ git commit -m "test: verify tweet conversation flows" - Verify: `docs/superpowers/plans/2026-07-13-tweet-detail-thread.md` **Interfaces:** + - Consumes: the complete detail route and deterministic evidence from Tasks 1–4. - Produces: accurate setup/scope documentation, a three-function security boundary, a clean feature branch, and a Tailscale-accessible development handoff. diff --git a/docs/superpowers/plans/2026-07-13-twitter-lite.md b/docs/superpowers/plans/2026-07-13-twitter-lite.md index c6d9010..a784744 100644 --- a/docs/superpowers/plans/2026-07-13-twitter-lite.md +++ b/docs/superpowers/plans/2026-07-13-twitter-lite.md @@ -1,5 +1,9 @@ # Twitter Lite Implementation Plan +> Tooling update: the Biome configuration and commands below are historical. +> Current development uses Oxlint for linting and Oxfmt for formatting; see +> README for current commands. + > **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 @@ -61,6 +65,7 @@ ### 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` @@ -72,6 +77,7 @@ - Modify: `../bird/CHANGELOG.md` **Interfaces:** + - Produces: `SearchProduct = 'Top' | 'Latest'`. - Produces: `SearchFetchOptions.product?: SearchProduct`. - Produces: `TwitterClient.search(query, count, { product })` and `getAllSearchResults(query, { product })`. @@ -99,31 +105,27 @@ In `tests/twitter-client.search-bookmarks.test.ts`, extend `retries on 404 and p ```ts const parsed = JSON.parse(urlVars as string) as { - rawQuery?: string - product?: string -} -expect(parsed.rawQuery).toBe('needle') -expect(parsed.product).toBe('Latest') + 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 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 } + 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 } + 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') +expect(firstVars.product).toBe("Top"); +expect(secondVars.product).toBe("Top"); ``` - [ ] **Step 3: Run the library tests and verify RED** @@ -141,28 +143,28 @@ Expected: TypeScript reports that `product` is not part of `SearchFetchOptions`, In `src/lib/twitter-client-search.ts`, replace the search option definitions with: ```ts -export type SearchProduct = 'Top' | 'Latest' +export type SearchProduct = "Top" | "Latest"; /** Options for search methods */ export interface SearchFetchOptions { /** Include raw GraphQL response in `_raw` field */ - includeRaw?: boolean + includeRaw?: boolean; /** Search result ranking (default: Latest) */ - product?: SearchProduct + product?: SearchProduct; } /** Options for paged search methods */ export interface SearchPaginationOptions extends SearchFetchOptions { - maxPages?: number + maxPages?: number; /** Starting cursor for pagination (resume from previous fetch) */ - cursor?: string + cursor?: string; } ``` In `searchPaged()`, destructure once: ```ts -const { includeRaw = false, maxPages, product = 'Latest' } = options +const { includeRaw = false, maxPages, product = "Latest" } = options; ``` Use the captured value in every request: @@ -171,10 +173,10 @@ Use the captured value in every request: const variables = { rawQuery: query, count: pageCount, - querySource: 'typed_query', + querySource: "typed_query", product, ...(pageCursor ? { cursor: pageCursor } : {}), -} +}; ``` In `src/lib/index.ts`, export the public types: @@ -184,7 +186,7 @@ export type { SearchFetchOptions, SearchPaginationOptions, SearchProduct, -} from './twitter-client-search.js' +} from "./twitter-client-search.js"; ``` - [ ] **Step 5: Run the library tests and verify GREEN** @@ -202,29 +204,22 @@ Expected: every search/bookmark test passes. Add to `tests/commands.search.test.ts`: ```ts -it('passes --product to non-paged search', async () => { - registerSearchCommands(program, mockContext as CliContext) +it("passes --product to non-paged search", async () => { + registerSearchCommands(program, mockContext as CliContext); const searchSpy = vi - .spyOn(TwitterClient.prototype, 'search') - .mockResolvedValue({ success: true, tweets: [] }) + .spyOn(TwitterClient.prototype, "search") + .mockResolvedValue({ success: true, tweets: [] }); try { - await program.parseAsync([ - 'node', - 'bird', - 'search', - 'cats', - '--product', - 'Top', - ]) - expect(searchSpy).toHaveBeenCalledWith('cats', 10, { + await program.parseAsync(["node", "bird", "search", "cats", "--product", "Top"]); + expect(searchSpy).toHaveBeenCalledWith("cats", 10, { includeRaw: false, - product: 'Top', - }) + product: "Top", + }); } finally { - searchSpy.mockRestore() + searchSpy.mockRestore(); } -}) +}); ``` Update the existing paged-search test to pass `--product Top` and require `product: 'Top'` in the expected options. @@ -232,38 +227,27 @@ Update the existing paged-search test to pass `--product Top` and require `produ Add explicit invalid-choice coverage: ```ts -it('rejects unsupported search products', async () => { - registerSearchCommands(program, mockContext as CliContext) - program.exitOverride() +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' }) -}) + 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' +import { type SearchFetchOptions, type SearchProduct, TwitterClient } from "../src/index.js"; -it('exposes search product types', () => { - const product: SearchProduct = 'Top' - const options: SearchFetchOptions = { product } +it("exposes search product types", () => { + const product: SearchProduct = "Top"; + const options: SearchFetchOptions = { product }; - expect(options.product).toBe('Top') -}) + expect(options.product).toBe("Top"); +}); ``` - [ ] **Step 7: Run the CLI/export tests and verify RED** @@ -283,8 +267,8 @@ Expected: the CLI rejects or ignores `--product` before the command implementati 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' +import { type Command, Option } from "commander"; +import type { SearchProduct } from "../lib/twitter-client-search.js"; ``` Register the option after `--cursor`: @@ -300,15 +284,15 @@ Register the option after `--cursor`: 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 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. @@ -322,7 +306,7 @@ 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', +product: 'Top', }) ``` @@ -368,6 +352,7 @@ 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` @@ -385,6 +370,7 @@ git commit -m "feat: support ranked tweet search" - Create: `tests/setup.ts` **Interfaces:** + - Consumes: published `@yuta/bird@0.10.0` through Gitea Packages. - Produces: `getRouter()` with an SSR-aware QueryClient. - Produces: a buildable TanStack Start/Nitro application at `http://127.0.0.1:3000`. @@ -516,44 +502,40 @@ Create `tsconfig.json`: 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' +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(), - ], -}) + plugins: [nitro(), tanstackStart(), viteReact()], +}); ``` Create `vitest.config.ts`: ```ts -import path from 'node:path' -import { defineConfig } from 'vitest/config' +import path from "node:path"; +import { defineConfig } from "vitest/config"; export default defineConfig({ - resolve: { alias: { '#': path.resolve(import.meta.dirname, 'src') } }, + resolve: { alias: { "#": path.resolve(import.meta.dirname, "src") } }, test: { - environment: 'jsdom', - setupFiles: ['./tests/setup.ts'], - exclude: ['tests/e2e/**'], + 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' +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; -afterEach(cleanup) +afterEach(cleanup); ``` Create `biome.json`: @@ -586,18 +568,18 @@ Expected: `pnpm-lock.yaml` and `src/routeTree.gen.ts` are generated without igno Create `src/routes/index.test.tsx`: ```tsx -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import { Home } from './index' +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() +describe("Home", () => { + it("starts without ambient post content", () => { + render(); - expect(screen.getByText('目的を決めてから開く')).toBeInTheDocument() - expect(screen.queryByRole('article')).not.toBeInTheDocument() - }) -}) + expect(screen.getByText("目的を決めてから開く")).toBeInTheDocument(); + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + }); +}); ``` Run: @@ -613,31 +595,31 @@ Expected: FAIL because `Home` and its intentional-reading copy do not exist. 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' +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 + setupRouterSsrQueryIntegration({ router, queryClient }); + return router; } -declare module '@tanstack/react-router' { +declare module "@tanstack/react-router" { interface Register { - router: ReturnType + router: ReturnType; } } ``` @@ -645,30 +627,26 @@ declare module '@tanstack/react-router' { 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' +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 } +type RouterContext = { queryClient: QueryClient }; export const Route = createRootRouteWithContext()({ head: () => ({ meta: [ - { charSet: 'utf-8' }, + { charSet: "utf-8" }, { - name: 'viewport', - content: 'width=device-width, initial-scale=1', + name: "viewport", + content: "width=device-width, initial-scale=1", }, - { title: 'Twitter Lite' }, + { title: "Twitter Lite" }, ], - links: [{ rel: 'stylesheet', href: appCss }], + links: [{ rel: "stylesheet", href: appCss }], }), shellComponent: RootDocument, -}) +}); function RootDocument({ children }: { children: React.ReactNode }) { return ( @@ -681,16 +659,16 @@ function RootDocument({ children }: { children: React.ReactNode }) { - ) + ); } ``` Create `src/routes/index.tsx`: ```tsx -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute('/')({ component: Home }) +export const Route = createFileRoute("/")({ component: Home }); export function Home() { return ( @@ -699,7 +677,7 @@ export function Home() {

目的を決めてから開く

ユーザー名か検索語を入力するまで、投稿は表示しません。

- ) + ); } ``` @@ -771,6 +749,7 @@ 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` @@ -778,6 +757,7 @@ git commit -m "chore: scaffold twitter lite" - Create: `src/features/posts/page.test.ts` **Interfaces:** + - Produces: `normalizeUserTarget(value: string): string`. - Produces: `buildSearchQuery(value: string, following: boolean): string`. - Produces: `userPageInputSchema` and `searchPageInputSchema`. @@ -788,47 +768,41 @@ git commit -m "chore: scaffold twitter lite" Create `src/features/posts/inputs.test.ts`: ```ts -import { describe, expect, it } from 'vitest' -import { buildSearchQuery, normalizeUserTarget } from './inputs' +import { describe, expect, it } from "vitest"; +import { buildSearchQuery, normalizeUserTarget } from "./inputs"; -describe('normalizeUserTarget', () => { +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) - }) + ["@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() - }) -}) + 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') - }) +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("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() - }) -}) + it("rejects an empty query", () => { + expect(() => buildSearchQuery(" ", false)).toThrow(); + }); +}); ``` - [ ] **Step 2: Run the input tests and verify RED** @@ -846,124 +820,115 @@ Expected: FAIL because `./inputs` does not exist. Create `src/features/posts/types.ts`: ```ts -import type { SearchProduct, TweetData } from '@yuta/bird' +import type { SearchProduct, TweetData } from "@yuta/bird"; -export type Post = TweetData +export type Post = TweetData; export type PostPage = { - tweets: Post[] - nextCursor?: string -} + tweets: Post[]; + nextCursor?: string; +}; export type LoadErrorCode = - | 'invalid-input' - | 'user-not-found' - | 'user-unavailable' - | 'relay-config' - | 'timeout' - | 'upstream' + "invalid-input" | "user-not-found" | "user-unavailable" | "relay-config" | "timeout" | "upstream"; export type LoadError = { - code: LoadErrorCode - message: string - retryable: boolean -} + code: LoadErrorCode; + message: string; + retryable: boolean; +}; -export type LoadResult = - | { ok: true; page: PostPage } - | { ok: false; error: LoadError } +export type LoadResult = { ok: true; page: PostPage } | { ok: false; error: LoadError }; export type UserPageInput = { - target: string - cursor?: string -} + target: string; + cursor?: string; +}; export type SearchPageInput = { - query: string - product: SearchProduct - following: boolean - cursor?: string -} + query: string; + product: SearchProduct; + following: boolean; + cursor?: string; +}; ``` Create `src/features/posts/inputs.ts`: ```ts -import { z } from 'zod' +import { z } from "zod"; -const HANDLE = /^[A-Za-z0-9_]{1,15}$/ -const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i +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']), + product: z.enum(["Top", "Latest"]), following: z.boolean(), cursor: z.string().min(1).optional(), -}) +}); export const userRouteSearchSchema = z.object({ - target: z.string().catch(''), -}) + target: z.string().catch(""), +}); export const postSearchRouteSchema = z.object({ - q: z.string().catch(''), - product: z.enum(['Top', 'Latest']).catch('Latest'), + 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('ハンドルは英数字とアンダースコアで入力してください。') + throw new InputError("ハンドルは英数字とアンダースコアで入力してください。"); } - return value + return value; } export function normalizeUserTarget(raw: string): string { - const value = raw.trim() + const value = raw.trim(); if (!value) { - throw new InputError('ハンドルまたはプロフィール URL を入力してください。') + throw new InputError("ハンドルまたはプロフィール URL を入力してください。"); } - if (value.startsWith('@')) { - return requireHandle(value.slice(1)) + if (value.startsWith("@")) { + return requireHandle(value.slice(1)); } - if (!value.includes('://')) { - return requireHandle(value) + if (!value.includes("://")) { + return requireHandle(value); } - let url: URL + let url: URL; try { - url = new URL(value) + url = new URL(value); } catch { - throw new InputError('プロフィール URL の形式を確認してください。') + throw new InputError("プロフィール URL の形式を確認してください。"); } - if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) { - throw new InputError('x.com または twitter.com の 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) + const segments = url.pathname.split("/").filter(Boolean); if (segments.length !== 1) { - throw new InputError('プロフィール URL を入力してください。') + throw new InputError("プロフィール URL を入力してください。"); } - return requireHandle(segments[0] ?? '') + return requireHandle(segments[0] ?? ""); } export function buildSearchQuery(raw: string, following: boolean): string { - const query = raw.trim() + const query = raw.trim(); if (!query) { - throw new InputError('検索語を入力してください。') + throw new InputError("検索語を入力してください。"); } if (query.length > 512) { - throw new InputError('検索語は 512 文字以内で入力してください。') + throw new InputError("検索語は 512 文字以内で入力してください。"); } - return following && !FOLLOWS.test(query) - ? `${query} filter:follows` - : query + return following && !FOLLOWS.test(query) ? `${query} filter:follows` : query; } ``` @@ -982,30 +947,26 @@ Expected: all normalization and query-building tests pass. Create `src/features/posts/page.test.ts`: ```ts -import { describe, expect, it } from 'vitest' -import { flattenPostPages } from './page' -import type { Post, PostPage } from './types' +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', () => { +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')] }, - ] + { tweets: [post("1"), post("2")], nextCursor: "next" }, + { tweets: [post("2"), post("3")] }, + ]; - expect(flattenPostPages(pages).map(({ id }) => id)).toEqual([ - '1', - '2', - '3', - ]) - }) -}) + expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(["1", "2", "3"]); + }); +}); ``` Run: @@ -1021,17 +982,17 @@ Expected: FAIL because `flattenPostPages` does not exist. Create `src/features/posts/page.ts`: ```ts -import type { Post, PostPage } from './types' +import type { Post, PostPage } from "./types"; export function flattenPostPages(pages: PostPage[]): Post[] { - const seen = new Set() + const seen = new Set(); return pages.flatMap(({ tweets }) => tweets.filter(({ id }) => { - if (seen.has(id)) return false - seen.add(id) - return true + if (seen.has(id)) return false; + seen.add(id); + return true; }), - ) + ); } ``` @@ -1056,12 +1017,14 @@ git commit -m "feat: validate intentional post requests" ### Task 4: Add the read-only Bird server boundary **Files:** + - Create: `src/features/posts/post-service.ts` - Create: `src/features/posts/post-service.test.ts` - Create: `src/features/posts/bird-client.server.ts` - Create: `src/features/posts/server-functions.ts` **Interfaces:** + - Consumes: `normalizeUserTarget`, `buildSearchQuery`, and Bird's read methods. - Produces: `loadUserPage(reader, input): Promise`. - Produces: `searchPage(reader, input): Promise`. @@ -1072,81 +1035,78 @@ git commit -m "feat: validate intentional post requests" Create `src/features/posts/post-service.test.ts`: ```ts -import { describe, expect, it, vi } from 'vitest' -import { loadUserPage, searchPage, type BirdReader } from './post-service' +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' }), + 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', + tweets: [{ id: "1", text: "hello", author: { username: "yuta", name: "Yuta" } }], + nextCursor: "user-next", }), getAllSearchResults: vi.fn().mockResolvedValue({ success: true, tweets: [], - nextCursor: 'search-next', + nextCursor: "search-next", }), -}) +}); -describe('loadUserPage', () => { - it('resolves a handle and fetches exactly one page', async () => { - const client = reader() +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', - }) + target: "https://x.com/yuta", + cursor: "cursor-1", + }); - expect(client.getUserIdByUsername).toHaveBeenCalledWith('yuta') - expect(client.getUserTweetsPaged).toHaveBeenCalledWith('42', 20, { - 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' }, - }) - }) + page: { nextCursor: "user-next" }, + }); + }); - it('returns a safe not-found error', async () => { - const client = reader() + 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', - }) + error: "User not found: private relay detail", + }); - expect(await loadUserPage(client, { target: 'missing' })).toEqual({ + expect(await loadUserPage(client, { target: "missing" })).toEqual({ ok: false, error: { - code: 'user-not-found', - message: 'ユーザーが見つかりませんでした。', + code: "user-not-found", + message: "ユーザーが見つかりませんでした。", retryable: false, }, - }) - }) -}) + }); + }); +}); -describe('searchPage', () => { - it('forwards Top and appends follows once', async () => { - const client = reader() +describe("searchPage", () => { + it("forwards Top and appends follows once", async () => { + const client = reader(); await searchPage(client, { - query: 'AI lang:ja', - product: 'Top', + query: "AI lang:ja", + product: "Top", following: true, - cursor: 'cursor-2', - }) + cursor: "cursor-2", + }); - expect(client.getAllSearchResults).toHaveBeenCalledWith( - 'AI lang:ja filter:follows', - { product: 'Top', cursor: 'cursor-2', maxPages: 1 }, - ) - }) -}) + 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** @@ -1164,60 +1124,54 @@ Expected: FAIL because `post-service.ts` does not exist. 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' +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 -} + success: boolean; + userId?: string; + error?: string; +}; export interface BirdReader { - getUserIdByUsername(username: string): Promise + getUserIdByUsername(username: string): Promise; getUserTweetsPaged( userId: string, limit: number, options: { - cursor?: string - maxPages: number - pageDelayMs: number + cursor?: string; + maxPages: number; + pageDelayMs: number; }, - ): Promise + ): Promise; getAllSearchResults( query: string, options: { - product: SearchProduct - cursor?: string - maxPages: number + product: SearchProduct; + cursor?: string; + maxPages: number; }, - ): Promise + ): Promise; } -const failure = ( - code: LoadError['code'], - message: string, - retryable: boolean, -): LoadResult => ({ ok: false, error: { code, message, retryable } }) +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) +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("not found")) { + return failure("user-not-found", "ユーザーが見つかりませんでした。", false); } - if (lower.includes('suspended') || lower.includes('protected')) { - return failure('user-unavailable', 'このユーザーの投稿は取得できません。', false) + if (lower.includes("suspended") || lower.includes("protected")) { + return failure("user-unavailable", "このユーザーの投稿は取得できません。", false); } - return failure('upstream', 'X から投稿を取得できませんでした。', true) + return failure("upstream", "X から投稿を取得できませんでした。", true); } function resultPage(result: SearchResult): LoadResult { @@ -1226,18 +1180,15 @@ function resultPage(result: SearchResult): LoadResult { ok: true, page: { tweets: result.tweets, nextCursor: result.nextCursor }, } - : upstreamFailure(result.error) + : upstreamFailure(result.error); } -export async function loadUserPage( - reader: BirdReader, - input: UserPageInput, -): Promise { +export async function loadUserPage(reader: BirdReader, input: UserPageInput): Promise { try { - const handle = normalizeUserTarget(input.target) - const user = await reader.getUserIdByUsername(handle) + const handle = normalizeUserTarget(input.target); + const user = await reader.getUserIdByUsername(handle); if (!user.success || !user.userId) { - return upstreamFailure(user.error) + return upstreamFailure(user.error); } return resultPage( await reader.getUserTweetsPaged(user.userId, 20, { @@ -1245,41 +1196,35 @@ export async function loadUserPage( maxPages: 1, pageDelayMs: 0, }), - ) + ); } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return failure('timeout', '取得がタイムアウトしました。', true) + if (error instanceof Error && error.name === "AbortError") { + return failure("timeout", "取得がタイムアウトしました。", true); } if (error instanceof InputError) { - return failure('invalid-input', error.message, false) + return failure("invalid-input", error.message, false); } - return upstreamFailure(error instanceof Error ? error.message : '') + return upstreamFailure(error instanceof Error ? error.message : ""); } } -export async function searchPage( - reader: BirdReader, - input: SearchPageInput, -): Promise { +export async function searchPage(reader: BirdReader, input: SearchPageInput): Promise { try { return resultPage( - await reader.getAllSearchResults( - buildSearchQuery(input.query, input.following), - { - product: input.product, - cursor: input.cursor, - maxPages: 1, - }, - ), - ) + 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 Error && error.name === "AbortError") { + return failure("timeout", "取得がタイムアウトしました。", true); } if (error instanceof InputError) { - return failure('invalid-input', error.message, false) + return failure("invalid-input", error.message, false); } - return upstreamFailure(error instanceof Error ? error.message : '') + return upstreamFailure(error instanceof Error ? error.message : ""); } } ``` @@ -1299,62 +1244,62 @@ Expected: all service behavior passes without a relay. Create `src/features/posts/bird-client.server.ts`: ```ts -import { TwitterClient } from '@yuta/bird' -import type { BirdReader } from './post-service' +import { TwitterClient } from "@yuta/bird"; +import type { BirdReader } from "./post-service"; -let client: TwitterClient | undefined +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 + }); + 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' +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 を設定してください。', + code: "relay-config", + message: "TWITTER_RELAY_BASE_URL を設定してください。", retryable: false, }, -}) +}); async function reader() { - const { getBirdReader } = await import('./bird-client.server') - return getBirdReader() + const { getBirdReader } = await import("./bird-client.server"); + return getBirdReader(); } -export const loadUserPosts = createServerFn({ method: 'GET' }) +export const loadUserPosts = createServerFn({ method: "GET" }) .validator(userPageInputSchema) .handler(async ({ data }) => { try { - return await loadUserPage(await reader(), data) + return await loadUserPage(await reader(), data); } catch { - return configFailure() + return configFailure(); } - }) + }); -export const searchPosts = createServerFn({ method: 'GET' }) +export const searchPosts = createServerFn({ method: "GET" }) .validator(searchPageInputSchema) .handler(async ({ data }) => { try { - return await searchPage(await reader(), data) + return await searchPage(await reader(), data); } catch { - return configFailure() + return configFailure(); } - }) + }); ``` - [ ] **Step 6: Prove Bird stays server-only** @@ -1379,6 +1324,7 @@ 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` @@ -1389,6 +1335,7 @@ git commit -m "feat: add read-only bird boundary" - Delete: `src/routes/index.test.tsx` **Interfaces:** + - Consumes: typed route schemas from Task 3. - Produces: `UserForm({ initialTarget, onSubmit })`. - Produces: `SearchForm({ initialQuery, initialProduct, initialFollowing, onSubmit })`. @@ -1399,40 +1346,40 @@ git commit -m "feat: add read-only bird boundary" 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' +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() +describe("UserForm", () => { + it("submits only after the user enters a target", () => { + const onSubmit = vi.fn(); + render(); - fireEvent.change(screen.getByLabelText('ハンドルまたはプロフィール URL'), { - target: { value: '@yuta' }, - }) - fireEvent.click(screen.getByRole('button', { name: '表示' })) + fireEvent.change(screen.getByLabelText("ハンドルまたはプロフィール URL"), { + target: { value: "@yuta" }, + }); + fireEvent.click(screen.getByRole("button", { name: "表示" })); - expect(onSubmit).toHaveBeenCalledWith('yuta') - }) + expect(onSubmit).toHaveBeenCalledWith("yuta"); + }); - it('explains invalid input beside the field', () => { - const onSubmit = vi.fn() - render() + it("explains invalid input beside the field", () => { + const onSubmit = vi.fn(); + render(); - fireEvent.click(screen.getByRole('button', { name: '表示' })) + fireEvent.click(screen.getByRole("button", { name: "表示" })); - expect(screen.getByRole('alert')).toHaveTextContent( - 'ハンドルまたはプロフィール URL を入力してください。', - ) - expect(onSubmit).not.toHaveBeenCalled() - }) -}) + expect(screen.getByRole("alert")).toHaveTextContent( + "ハンドルまたはプロフィール URL を入力してください。", + ); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); -describe('SearchForm', () => { - it('submits the selected ranking and follows filter', () => { - const onSubmit = vi.fn() +describe("SearchForm", () => { + it("submits the selected ranking and follows filter", () => { + const onSubmit = vi.fn(); render( { 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: '検索' })) + 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', + q: "AI lang:ja", + product: "Top", following: true, - }) - }) -}) + }); + }); +}); ``` Run: @@ -1471,31 +1418,27 @@ Expected: FAIL because both form components are missing. Create `src/features/posts/components/user-form.tsx`: ```tsx -import { type FormEvent, useState } from 'react' -import { normalizeUserTarget } from '../inputs' +import { type FormEvent, useState } from "react"; +import { normalizeUserTarget } from "../inputs"; type Props = { - initialTarget: string - onSubmit: (target: string) => void -} + initialTarget: string; + onSubmit: (target: string) => void; +}; export function UserForm({ initialTarget, onSubmit }: Props) { - const [target, setTarget] = useState(initialTarget) - const [error, setError] = useState('') + const [target, setTarget] = useState(initialTarget); + const [error, setError] = useState(""); const submit = (event: FormEvent) => { - event.preventDefault() + event.preventDefault(); try { - const handle = normalizeUserTarget(target) - setError('') - onSubmit(handle) + const handle = normalizeUserTarget(target); + setError(""); + onSubmit(handle); } catch (cause) { - setError( - cause instanceof Error - ? cause.message - : '入力内容を確認してください。', - ) + setError(cause instanceof Error ? cause.message : "入力内容を確認してください。"); } - } + }; return (
@@ -1503,7 +1446,7 @@ export function UserForm({ initialTarget, onSubmit }: Props) { ハンドルまたはプロフィール URL
- ) + ); } ``` 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' +import type { SearchProduct } from "@yuta/bird"; +import { type FormEvent, useState } from "react"; +import { buildSearchQuery } from "../inputs"; type SearchValues = { - q: string - product: SearchProduct - following: boolean -} + q: string; + product: SearchProduct; + following: boolean; +}; type Props = { - initialQuery: string - initialProduct: SearchProduct - initialFollowing: boolean - onSubmit: (values: SearchValues) => void -} + 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('') +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() + event.preventDefault(); try { - const query = buildSearchQuery(q, false) - setError('') - onSubmit({ q: query, product, following }) + const query = buildSearchQuery(q, false); + setError(""); + onSubmit({ q: query, product, following }); } catch (cause) { - setError( - cause instanceof Error - ? cause.message - : '入力内容を確認してください。', - ) + setError(cause instanceof Error ? cause.message : "入力内容を確認してください。"); } - } + }; return (
@@ -1574,7 +1508,7 @@ export function SearchForm({ 検索語
並び順 - {(['Top', 'Latest'] as const).map((value) => ( + {(["Top", "Latest"] as const).map((value) => ( ))}
@@ -1612,7 +1546,7 @@ export function SearchForm({
- ) + ); } ``` @@ -1631,14 +1565,14 @@ Expected: both form behaviors pass. Create `src/components/app-shell.tsx`: ```tsx -import { Link } from '@tanstack/react-router' +import { Link } from "@tanstack/react-router"; export function AppShell({ active, children, }: { - active: 'user' | 'search' - children: React.ReactNode + active: "user" | "search"; + children: React.ReactNode; }) { return (
@@ -1646,15 +1580,15 @@ export function AppShell({

TWITTER LITE

- ) + ); } ``` Replace `src/routes/index.tsx` with: ```tsx -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute, redirect } from "@tanstack/react-router"; -export const Route = createFileRoute('/')({ +export const Route = createFileRoute("/")({ beforeLoad: () => { - throw redirect({ to: '/user', search: { target: '' } }) + 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' +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')({ +export const Route = createFileRoute("/user")({ validateSearch: (search) => userRouteSearchSchema.parse(search), component: UserRoute, -}) +}); function UserRoute() { - const { target } = Route.useSearch() - const navigate = Route.useNavigate() + const { target } = Route.useSearch(); + const navigate = Route.useNavigate(); return (

誰の投稿を見ますか?

@@ -1702,31 +1636,29 @@ function UserRoute() { - navigate({ search: { target: nextTarget } }) - } + onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })} />
- ) + ); } ``` Create `src/routes/search.tsx`: ```tsx -import { createFileRoute } from '@tanstack/react-router' -import { AppShell } from '#/components/app-shell' -import { SearchForm } from '#/features/posts/components/search-form' -import { postSearchRouteSchema } from '#/features/posts/inputs' +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')({ +export const Route = createFileRoute("/search")({ validateSearch: (search) => postSearchRouteSchema.parse(search), component: SearchRoute, -}) +}); function SearchRoute() { - const values = Route.useSearch() - const navigate = Route.useNavigate() + const values = Route.useSearch(); + const navigate = Route.useNavigate(); return (

何を確認しますか?

@@ -1739,7 +1671,7 @@ function SearchRoute() { onSubmit={(search) => navigate({ search })} />
- ) + ); } ``` @@ -1767,11 +1699,13 @@ 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. @@ -1780,63 +1714,63 @@ git commit -m "feat: add intentional reader routes" 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' +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', + id: "123", + text: "詳細 https://example.com/article", author: { - username: 'yuta', - name: 'Yuta', - profileImageUrl: 'https://pbs.twimg.com/avatar.jpg', + username: "yuta", + name: "Yuta", + profileImageUrl: "https://pbs.twimg.com/avatar.jpg", }, - createdAt: '2026-07-13T00:00:00.000Z', + createdAt: "2026-07-13T00:00:00.000Z", replyCount: 3, retweetCount: 4, likeCount: 5, media: [ { - type: 'photo', - url: 'https://pbs.twimg.com/photo.jpg', + type: "photo", + url: "https://pbs.twimg.com/photo.jpg", width: 1200, height: 800, }, ], - article: { title: 'Article title', previewText: 'Preview' }, + article: { title: "Article title", previewText: "Preview" }, quotedTweet: { - id: '122', - text: 'quoted', - author: { username: 'other', name: 'Other' }, + id: "122", + text: "quoted", + author: { username: "other", name: "Other" }, }, -} +}; -describe('PostCard', () => { - it('renders rich content without mutation controls', () => { - render() +describe("PostCard", () => { + it("renders rich content without mutation controls", () => { + render(); - expect(screen.getByText('Yuta')).toBeInTheDocument() - expect(screen.getByRole('img', { name: '投稿画像' })).toBeInTheDocument() - expect(screen.getByText('Article title')).toBeInTheDocument() - expect(screen.getByText('quoted')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /いいね/ })).not.toBeInTheDocument() - }) + 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() + it("uses deliberate safe external links", () => { + render(); - expect(screen.getByRole('link', { name: '元の投稿を開く' })).toHaveAttribute( - 'href', - 'https://x.com/yuta/status/123', - ) - expect(screen.getByRole('link', { name: '元の投稿を開く' })).toHaveAttribute( - 'rel', - 'noreferrer noopener', - ) - }) -}) + expect(screen.getByRole("link", { name: "元の投稿を開く" })).toHaveAttribute( + "href", + "https://x.com/yuta/status/123", + ); + expect(screen.getByRole("link", { name: "元の投稿を開く" })).toHaveAttribute( + "rel", + "noreferrer noopener", + ); + }); +}); ``` Run: @@ -1852,16 +1786,16 @@ Expected: FAIL because `PostCard` does not exist. Create `src/features/posts/components/post-media.tsx`: ```tsx -import type { Post } from '../types' +import type { Post } from "../types"; -type Media = NonNullable[number] +type Media = NonNullable[number]; export function PostMedia({ media }: { media: Media[] }) { - if (media.length === 0) return null + if (media.length === 0) return null; return (
{media.map((item) => - item.type === 'photo' ? ( + item.type === "photo" ? ( 投稿画像 - ) + ); } ``` @@ -1893,34 +1827,28 @@ export function PostMedia({ media }: { media: Media[] }) { Create `src/features/posts/components/post-card.tsx`: ```tsx -import type { ReactNode } from 'react' -import type { Post } from '../types' -import { PostMedia } from './post-media' +import type { ReactNode } from "react"; +import type { Post } from "../types"; +import { PostMedia } from "./post-media"; -const URL = /(https?:\/\/[^\s]+)/g +const URL = /(https?:\/\/[^\s]+)/g; function linkedText(text: string): ReactNode[] { return text.split(URL).map((part, index) => - part.startsWith('http://') || part.startsWith('https://') ? ( + part.startsWith("http://") || part.startsWith("https://") ? ( {part} ) : ( part ), - ) + ); } -export function PostCard({ - post, - quoted = false, -}: { - post: Post - quoted?: boolean -}) { - const original = `https://x.com/${post.author.username}/status/${post.id}` +export function PostCard({ post, quoted = false }: { post: Post; quoted?: boolean }) { + const original = `https://x.com/${post.author.username}/status/${post.id}`; return ( -
+
{post.author.profileImageUrl ? ( {post.createdAt ? ( ) : null} @@ -1954,13 +1882,11 @@ export function PostCard({ {post.article.previewText ?

{post.article.previewText}

: null} ) : null} - {post.quotedTweet && !quoted ? ( - - ) : null} + {post.quotedTweet && !quoted ? : null} {!quoted ? ( ) : null}
- ) + ); } ``` @@ -1994,6 +1920,7 @@ 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` @@ -2002,6 +1929,7 @@ git commit -m "feat: render rich read-only posts" - 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. @@ -2011,47 +1939,47 @@ git commit -m "feat: render rich read-only posts" Create `src/features/posts/use-post-feed.test.ts`: ```ts -import { describe, expect, it, vi } from 'vitest' -import { createPostFeedOptions } from './use-post-feed' +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 () => { +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' }, - }) + page: { tweets: [], nextCursor: "next" }, + }); const options = createPostFeedOptions( - { kind: 'user', target: '@yuta' }, + { kind: "user", target: "@yuta" }, { loadUser, search: vi.fn() }, - ) + ); const page = await options.queryFn({ - pageParam: 'cursor', - } as never) + pageParam: "cursor", + } as never); expect(loadUser).toHaveBeenCalledWith({ - data: { target: '@yuta', cursor: 'cursor' }, - }) - expect(options.getNextPageParam(page)).toBe('next') - expect(options.retry).toBe(false) - }) + 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', () => { + it("includes every search control in the query key", () => { const options = createPostFeedOptions( { - kind: 'search', - query: 'AI', - product: 'Top', + 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 }, - ]) - }) -}) + "posts", + { kind: "search", query: "AI", product: "Top", following: true }, + ]); + }); +}); ``` Run: @@ -2067,56 +1995,51 @@ Expected: FAIL because query options do not exist. 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' +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: "user"; target: string } | { - kind: 'search' - query: string - product: SearchProduct - following: boolean - } + kind: "search"; + query: string; + product: SearchProduct; + following: boolean; + }; type Loaders = { - loadUser: (options: { - data: { target: string; cursor?: string } - }) => Promise + loadUser: (options: { data: { target: string; cursor?: string } }) => Promise; search: (options: { data: { - query: string - product: SearchProduct - following: boolean - cursor?: string - } - }) => Promise -} + query: string; + product: SearchProduct; + following: boolean; + cursor?: string; + }; + }) => Promise; +}; export class PostLoadError extends Error { constructor(readonly detail: LoadError) { - super(detail.message) + super(detail.message); } } const unwrap = (result: LoadResult): PostPage => { - if (!result.ok) throw new PostLoadError(result.error) - return result.page -} + if (!result.ok) throw new PostLoadError(result.error); + return result.page; +}; -export function createPostFeedOptions( - request: FeedRequest, - loaders: Loaders, -) { +export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) { return { - queryKey: ['posts', request] as const, + queryKey: ["posts", request] as const, initialPageParam: undefined as string | undefined, retry: false as const, queryFn: async ({ pageParam }: { pageParam: string | undefined }) => - request.kind === 'user' + request.kind === "user" ? unwrap( await loaders.loadUser({ data: { target: request.target, cursor: pageParam }, @@ -2133,21 +2056,21 @@ export function createPostFeedOptions( }), ), getNextPageParam: (page: PostPage) => page.nextCursor, - } + }; } export function usePostFeed(request: FeedRequest | undefined) { - const loadUser = useServerFn(loadUserPosts) - const search = useServerFn(searchPosts) + const loadUser = useServerFn(loadUserPosts); + const search = useServerFn(searchPosts); const disabled = { - kind: 'user', - target: '', - } satisfies FeedRequest + kind: "user", + target: "", + } satisfies FeedRequest; return useInfiniteQuery({ ...createPostFeedOptions(request ?? disabled, { loadUser, search }), enabled: request !== undefined, - }) + }); } ``` @@ -2166,15 +2089,15 @@ Expected: query key, cursor forwarding, and retry behavior pass. 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' +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PostFeed } from "./post-feed"; +import { usePostFeed } from "../use-post-feed"; -vi.mock('../use-post-feed', async (importOriginal) => { - const original = await importOriginal() - return { ...original, usePostFeed: vi.fn() } -}) +vi.mock("../use-post-feed", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, usePostFeed: vi.fn() }; +}); const result = { data: undefined, @@ -2186,60 +2109,58 @@ const result = { isFetchingNextPage: false, isPending: false, refetch: vi.fn(), -} +}; -describe('PostFeed', () => { - beforeEach(() => vi.mocked(usePostFeed).mockReturnValue(result as never)) +describe("PostFeed", () => { + beforeEach(() => vi.mocked(usePostFeed).mockReturnValue(result as never)); - it('renders no posts before a deliberate request', () => { - render() - expect(screen.queryByRole('article')).not.toBeInTheDocument() - }) + it("renders no posts before a deliberate request", () => { + render(); + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + }); - it('renders a deduplicated page and a quiet end', () => { + 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' } }, + { id: "1", text: "first", author: { username: "a", name: "A" } }, + { id: "1", text: "first", author: { username: "a", name: "A" } }, ], }, ], }, - } as never) + } as never); - render() - expect(screen.getAllByRole('article')).toHaveLength(1) - expect(screen.getByText('これ以上の投稿はありません。')).toBeInTheDocument() - }) + render(); + expect(screen.getAllByRole("article")).toHaveLength(1); + expect(screen.getByText("これ以上の投稿はありません。")).toBeInTheDocument(); + }); - it('keeps loaded posts while retrying a later page', () => { - const fetchNextPage = vi.fn() + 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' } }, - ], + tweets: [{ id: "1", text: "kept", author: { username: "a", name: "A" } }], }, ], }, fetchNextPage, hasNextPage: true, isFetchNextPageError: true, - } as never) + } as never); - render() - expect(screen.getByText('kept')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '再試行' })) - expect(fetchNextPage).toHaveBeenCalledOnce() - }) -}) + render(); + expect(screen.getByText("kept")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "再試行" })); + expect(fetchNextPage).toHaveBeenCalledOnce(); + }); +}); ``` Run: @@ -2255,43 +2176,39 @@ Expected: FAIL because `PostFeed` does not exist. 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' +import { useEffect, useRef } from "react"; +import { flattenPostPages } from "../page"; +import { PostLoadError, type FeedRequest, usePostFeed } from "../use-post-feed"; +import { PostCard } from "./post-card"; export function PostFeed({ request }: { request: FeedRequest | undefined }) { - const query = usePostFeed(request) - const sentinel = useRef(null) - const posts = query.data ? flattenPostPages(query.data.pages) : [] + const query = usePostFeed(request); + const sentinel = useRef(null); + const posts = query.data ? flattenPostPages(query.data.pages) : []; useEffect(() => { - if (!sentinel.current || !query.hasNextPage) return + if (!sentinel.current || !query.hasNextPage) return; const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting && !query.isFetchingNextPage) { - void query.fetchNextPage() + void query.fetchNextPage(); } }, - { rootMargin: '600px 0px' }, - ) - observer.observe(sentinel.current) - return () => observer.disconnect() - }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]) + { rootMargin: "600px 0px" }, + ); + observer.observe(sentinel.current); + return () => observer.disconnect(); + }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); - if (!request) return null + if (!request) return null; if (query.isPending) { - return

投稿を取得しています…

+ return

投稿を取得しています…

; } if (query.isError && posts.length === 0) { const error = query.error instanceof PostLoadError ? query.error.detail - : { message: '投稿を取得できませんでした。', retryable: true } + : { message: "投稿を取得できませんでした。", retryable: true }; return (

{error.message}

@@ -2301,10 +2218,10 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) { ) : null}
- ) + ); } if (posts.length === 0) { - return

条件に一致する投稿はありません。

+ return

条件に一致する投稿はありません。

; } return ( @@ -2326,11 +2243,9 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) {
) : null} - {!query.hasNextPage ? ( -

これ以上の投稿はありません。

- ) : null} + {!query.hasNextPage ?

これ以上の投稿はありません。

: null} - ) + ); } ``` @@ -2339,9 +2254,7 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) { In `src/routes/user.tsx`, import `PostFeed` and render after the form: ```tsx - + ``` In `src/routes/search.tsx`, render: @@ -2351,7 +2264,7 @@ In `src/routes/search.tsx`, render: request={ values.q ? { - kind: 'search', + kind: "search", query: values.q, product: values.product, following: values.following, @@ -2385,11 +2298,13 @@ 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. @@ -2398,7 +2313,7 @@ git commit -m "feat: load post cursors infinitely" Extend `forms.test.tsx` with: ```tsx -it('uses a labelled grouping for ranking controls', () => { +it("uses a labelled grouping for ranking controls", () => { render( { initialFollowing={false} onSubmit={vi.fn()} />, - ) + ); - expect( - screen.getByRole('group', { name: '並び順' }), - ).toBeInTheDocument() -}) + expect(screen.getByRole("group", { name: "並び順" })).toBeInTheDocument(); +}); ``` Extend `post-card.test.tsx` with: ```tsx -it('does not turn the author into a discovery link', () => { - render() +it("does not turn the author into a discovery link", () => { + render(); - expect(screen.queryByRole('link', { name: 'Yuta' })).not.toBeInTheDocument() -}) + expect(screen.queryByRole("link", { name: "Yuta" })).not.toBeInTheDocument(); +}); ``` Run: @@ -2759,12 +2672,7 @@ a { bottom: 0; left: 0; height: 3px; - background: linear-gradient( - 90deg, - transparent 0%, - var(--accent) 45%, - transparent 100% - ); + background: linear-gradient(90deg, transparent 0%, var(--accent) 45%, transparent 100%); content: ""; animation: rail 1.1s ease-in-out infinite; } @@ -2851,6 +2759,7 @@ 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` @@ -2861,6 +2770,7 @@ git commit -m "feat: apply mist instrument interface" - 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. @@ -2869,25 +2779,25 @@ git commit -m "feat: apply mist instrument interface" Create `playwright.config.ts`: ```ts -import { defineConfig, devices } from '@playwright/test' +import { defineConfig, devices } from "@playwright/test"; -const appPort = 4173 -const relayPort = 6911 -const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE +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.') + throw new Error("Run E2E tests through `nix develop` to provide Chromium."); } export default defineConfig({ - testDir: './tests/e2e', + 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', + screenshot: "only-on-failure", + trace: "retain-on-failure", }, webServer: [ { @@ -2904,10 +2814,10 @@ export default defineConfig({ }, ], projects: [ - { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, - { name: 'mobile', use: { ...devices['Pixel 7'] } }, + { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile", use: { ...devices["Pixel 7"] } }, ], -}) +}); ``` - [ ] **Step 2: Create a deterministic read-only relay** @@ -2917,7 +2827,7 @@ part of the mock: the executable implementation must also enforce the request contract listed after it. ```js -const tweet = (id, text, username = 'yuta') => ({ +const tweet = (id, text, username = "yuta") => ({ entryId: `tweet-${id}`, content: { itemContent: { @@ -2926,7 +2836,7 @@ const tweet = (id, text, username = 'yuta') => ({ rest_id: id, legacy: { full_text: text, - created_at: 'Mon Jul 13 00:00:00 +0000 2026', + created_at: "Mon Jul 13 00:00:00 +0000 2026", reply_count: 1, retweet_count: 2, favorite_count: 3, @@ -2937,7 +2847,7 @@ const tweet = (id, text, username = 'yuta') => ({ result: { legacy: { screen_name: username, - name: 'Yuta', + name: "Yuta", }, }, }, @@ -2946,12 +2856,12 @@ const tweet = (id, text, username = 'yuta') => ({ }, }, }, -}) +}); const cursor = (value) => ({ - entryId: 'cursor-bottom', - content: { cursorType: 'Bottom', value }, -}) + entryId: "cursor-bottom", + content: { cursorType: "Bottom", value }, +}); ``` Route by the operation-name suffix, never the query ID. Require @@ -2973,60 +2883,58 @@ omits the cursor. Create `tests/e2e/reader.spec.ts`: ```ts -import { expect, test } from '@playwright/test' +import { expect, test } from "@playwright/test"; -let consoleErrors: string[] -let pageErrors: string[] +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)) -}) + 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([]) -}) + 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) +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 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', + 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() +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() -}) + 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) -}) +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. @@ -3063,32 +2971,25 @@ Create `tests/live/relay.test.ts`: ```ts // @vitest-environment node -import { TwitterClient } from '@yuta/bird' -import { describe, expect, it } from 'vitest' +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, - ) - }, -) +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: diff --git a/docs/superpowers/specs/2026-07-13-tweet-detail-thread-design.md b/docs/superpowers/specs/2026-07-13-tweet-detail-thread-design.md index b8b355e..eec88f3 100644 --- a/docs/superpowers/specs/2026-07-13-tweet-detail-thread-design.md +++ b/docs/superpowers/specs/2026-07-13-tweet-detail-thread-design.md @@ -89,14 +89,14 @@ The server input has two valid states: ```ts type InitialThreadInput = { - tweetId: string -} + tweetId: string; +}; type ContinuedThreadInput = { - tweetId: string - conversationId: string - cursor: string -} + tweetId: string; + conversationId: string; + cursor: string; +}; ``` All IDs must be decimal strings. Cursor values are opaque non-empty strings. @@ -106,11 +106,11 @@ The successful page shape is: ```ts type ThreadPage = { - tweets: Post[] - focalPost?: Post - conversationId: string - nextCursor?: string -} + tweets: Post[]; + focalPost?: Post; + conversationId: string; + nextCursor?: string; +}; ``` `focalPost` is present only on the initial page. All returned posts pass through @@ -137,7 +137,7 @@ cannot rediscover the conversation root if the cursor page omits that reply. 1. Validate the supplied `tweetId`, `conversationId`, and cursor. 2. Call `getThreadPaged(conversationId, { - cursor, maxPages: 1, pageDelayMs: 0 })` once. +cursor, maxPages: 1, pageDelayMs: 0 })` once. 3. Return the sanitized tweets with the same conversation ID and new cursor. The original `tweetId` remains part of the query identity and validation @@ -149,7 +149,10 @@ continuation requests. Extend the existing feed request union with: ```ts -{ kind: 'thread'; tweetId: string } +{ + kind: "thread"; + tweetId: string; +} ``` The thread query key includes the focal tweet ID. Its initial page parameter is diff --git a/docs/superpowers/specs/2026-07-13-twitter-lite-design.md b/docs/superpowers/specs/2026-07-13-twitter-lite-design.md index 680fe45..666c007 100644 --- a/docs/superpowers/specs/2026-07-13-twitter-lite-design.md +++ b/docs/superpowers/specs/2026-07-13-twitter-lite-design.md @@ -68,16 +68,16 @@ Work spans two repositories and the Gitea package registry: Bird currently hard-codes `product: 'Latest'` in SearchTimeline requests. Add the following public contract: ```ts -export type SearchProduct = 'Top' | 'Latest' +export type SearchProduct = "Top" | "Latest"; export interface SearchFetchOptions { - includeRaw?: boolean - product?: SearchProduct + includeRaw?: boolean; + product?: SearchProduct; } export interface SearchPaginationOptions extends SearchFetchOptions { - maxPages?: number - cursor?: string + maxPages?: number; + cursor?: string; } ``` @@ -126,7 +126,7 @@ new TwitterClient({ relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL, profileName: process.env.BIRD_PROFILE_NAME, timeoutMs: 20_000, -}) +}); ``` `TWITTER_RELAY_BASE_URL` is required. `BIRD_PROFILE_NAME` is optional but must be passed explicitly because the Bird library does not read that environment variable itself. diff --git a/drizzle.config.ts b/drizzle.config.ts index a05cbba..efc01de 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'drizzle-kit' +import { defineConfig } from "drizzle-kit"; export default defineConfig({ - dialect: 'sqlite', - schema: './src/features/storage/schema.ts', - out: './drizzle', -}) + dialect: "sqlite", + schema: "./src/features/storage/schema.ts", + out: "./drizzle", +}); diff --git a/flake.nix b/flake.nix index 391f8f5..3d63249 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ inherit (finalAttrs) pname version src; pnpm = pkgs.pnpm_11; fetcherVersion = 4; - hash = "sha256-+DqNt+58TrI0W8SU//JNdYYJtCdKNIG7gfWRxno36QQ="; + hash = "sha256-60nQAoDWoO15P7TFLhABqGNYpJy/R+w+Zz3oo7qD8dM="; }; nativeBuildInputs = with pkgs; [ diff --git a/knip.ts b/knip.ts index 6ab2dc2..32d4166 100644 --- a/knip.ts +++ b/knip.ts @@ -1,6 +1,6 @@ -import type { KnipConfig } from 'knip' +import type { KnipConfig } from "knip"; export default { - entry: ['playwright.config.ts', 'e2e/**/*.test.ts', 'e2e/mock-relay.mjs'], + entry: ["playwright.config.ts", "e2e/**/*.test.ts", "e2e/mock-relay.mjs"], playwright: false, -} satisfies KnipConfig +} satisfies KnipConfig; diff --git a/orval.config.ts b/orval.config.ts index 4d868e6..c0efa01 100644 --- a/orval.config.ts +++ b/orval.config.ts @@ -1,64 +1,61 @@ -import { defineConfig, defineTransformer } from 'orval' +import { defineConfig, defineTransformer } from "orval"; -const selectedTag = 'twitter-lite-e2e' +const selectedTag = "twitter-lite-e2e"; const selectedOperationIds = new Set([ - 'getUserByScreenName', - 'getUserTweets', - 'getTweetDetail', - 'getSearchTimeline', - 'getListLatestTweetsTimeline', -]) + "getUserByScreenName", + "getUserTweets", + "getTweetDetail", + "getSearchTimeline", + "getListLatestTweetsTimeline", +]); const selectE2eOperations = defineTransformer((document) => { - const foundOperationIds = new Set() + const foundOperationIds = new Set(); for (const pathItem of Object.values(document.paths ?? {})) { - if (!pathItem || typeof pathItem !== 'object') continue + if (!pathItem || typeof pathItem !== "object") continue; for (const candidate of Object.values(pathItem)) { if ( !candidate || - typeof candidate !== 'object' || + typeof candidate !== "object" || Array.isArray(candidate) || - !('operationId' in candidate) + !("operationId" in candidate) ) { - continue + continue; } const operation = candidate as { - operationId?: string - tags?: string[] - } + operationId?: string; + tags?: string[]; + }; - if ( - operation.operationId && - selectedOperationIds.has(operation.operationId) - ) { - foundOperationIds.add(operation.operationId) - operation.tags = [...(operation.tags ?? []), selectedTag] + if (operation.operationId && selectedOperationIds.has(operation.operationId)) { + foundOperationIds.add(operation.operationId); + operation.tags = [...(operation.tags ?? []), selectedTag]; } } } const missingOperationIds = [...selectedOperationIds].filter( (operationId) => !foundOperationIds.has(operationId), - ) + ); if (missingOperationIds.length > 0) { throw new Error( - `twitter-openapi is missing selected operations: ${missingOperationIds.join(', ')}`, - ) + `twitter-openapi is missing selected operations: ${missingOperationIds.join(", ")}`, + ); } - return document -}) + return document; +}); export default defineConfig({ twitterOpenApi: { input: { target: - 'https://raw.githubusercontent.com/fa0311/twitter-openapi/590dae5c9f8575abc91d3774946bfe6f23960aba/dist/docs/openapi-3.0.yaml', + "https://raw.githubusercontent.com/fa0311/twitter-openapi/590dae5c9f8575abc91d3774946bfe6f23960aba/dist/docs/openapi-3.0.yaml", filters: { - mode: 'include', + mode: "include", tags: [selectedTag], }, override: { @@ -67,9 +64,9 @@ export default defineConfig({ }, output: { clean: true, - client: 'fetch', - mode: 'single', - target: './e2e/generated/twitter-openapi/client.ts', + client: "fetch", + mode: "single", + target: "./e2e/generated/twitter-openapi/client.ts", }, }, -}) +}); diff --git a/scripts/account-setup.ts b/scripts/account-setup.ts index 2ec2fce..2c3ae38 100644 --- a/scripts/account-setup.ts +++ b/scripts/account-setup.ts @@ -1,13 +1,9 @@ -import { getSetupToken, readAuthState } from '../src/features/auth/auth.server' +import { getSetupToken, readAuthState } from "../src/features/auth/auth.server"; if (!readAuthState().needsSetup) { - console.error( - 'This workspace already has an owner. Sign in with your existing account.', - ) - process.exitCode = 1 + console.error("This workspace already has an owner. Sign in with your existing account."); + process.exitCode = 1; } else { - console.log( - 'Open /setup in your workspace and enter this one-time setup code:', - ) - console.log(getSetupToken()) + console.log("Open /setup in your workspace and enter this one-time setup code:"); + console.log(getSetupToken()); } diff --git a/scripts/backup-database.ts b/scripts/backup-database.ts index 95e2946..3248a44 100644 --- a/scripts/backup-database.ts +++ b/scripts/backup-database.ts @@ -1,13 +1,11 @@ -import { backupDatabase } from '../src/features/storage/backup.server' +import { backupDatabase } from "../src/features/storage/backup.server"; -const source = process.env.TWITTER_LITE_DB_PATH -const destination = process.argv[2] +const source = process.env.TWITTER_LITE_DB_PATH; +const destination = process.argv[2]; if (!source || !destination || process.argv.length !== 3) { - console.error( - 'Usage: TWITTER_LITE_DB_PATH=/absolute/app.db pnpm db:backup /absolute/backup.db', - ) - process.exitCode = 1 + console.error("Usage: TWITTER_LITE_DB_PATH=/absolute/app.db pnpm db:backup /absolute/backup.db"); + process.exitCode = 1; } else { - await backupDatabase(source, destination) - console.log(`Backup verified: ${destination}`) + await backupDatabase(source, destination); + console.log(`Backup verified: ${destination}`); } diff --git a/scripts/build-tools.mjs b/scripts/build-tools.mjs index d9a4291..44567fd 100644 --- a/scripts/build-tools.mjs +++ b/scripts/build-tools.mjs @@ -1,13 +1,13 @@ -import { build } from 'vite' +import { build } from "vite"; -for (const entry of ['backup-database', 'account-setup']) { +for (const entry of ["backup-database", "account-setup"]) { await build({ configFile: false, build: { ssr: `scripts/${entry}.ts`, - outDir: '.output/server/tools', + outDir: ".output/server/tools", emptyOutDir: false, - rollupOptions: { external: ['better-sqlite3'] }, + rollupOptions: { external: ["better-sqlite3"] }, }, - }) + }); } diff --git a/scripts/bundle-migrations.mjs b/scripts/bundle-migrations.mjs index f8f7dee..97a1ff0 100644 --- a/scripts/bundle-migrations.mjs +++ b/scripts/bundle-migrations.mjs @@ -1,10 +1,10 @@ -import { writeFileSync } from 'node:fs' -import { readMigrationFiles } from 'drizzle-orm/migrator' +import { writeFileSync } from "node:fs"; +import { readMigrationFiles } from "drizzle-orm/migrator"; const migrations = readMigrationFiles({ - migrationsFolder: new URL('../drizzle', import.meta.url).pathname, -}) + migrationsFolder: new URL("../drizzle", import.meta.url).pathname, +}); writeFileSync( - new URL('../src/features/storage/migrations.generated.ts', import.meta.url), + new URL("../src/features/storage/migrations.generated.ts", import.meta.url), `// Generated by pnpm db:generate. Do not edit.\nexport const migrations = ${JSON.stringify(migrations, null, 2)}\n`, -) +); diff --git a/scripts/generate-routes.mjs b/scripts/generate-routes.mjs index 3acef38..85fc2f9 100644 --- a/scripts/generate-routes.mjs +++ b/scripts/generate-routes.mjs @@ -1,9 +1,9 @@ -import { tanstackStart } from '@tanstack/react-start/plugin/vite' -import { createServer } from 'vite' +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import { createServer } from "vite"; const server = await createServer({ configFile: false, plugins: [tanstackStart()], server: { middlewareMode: true }, -}) -await server.close() +}); +await server.close(); diff --git a/tests/live/relay.test.ts b/tests/live/relay.test.ts index c48d832..dadd7e1 100644 --- a/tests/live/relay.test.ts +++ b/tests/live/relay.test.ts @@ -1,28 +1,23 @@ // @vitest-environment node -import { TwitterClient } from '@yuta/bird' -import { describe, expect, it } from 'vitest' +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 opt-in live smoke test', - ) - } +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 opt-in live smoke 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' }) + 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) - }, -) + expect(result.success).toBe(true); + }, 30_000); +}); diff --git a/tests/setup.ts b/tests/setup.ts index f6d3ce3..bb32bff 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,5 @@ -import '@testing-library/jest-dom/vitest' -import { cleanup } from '@testing-library/react' -import { afterEach } from 'vitest' +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; -afterEach(cleanup) +afterEach(cleanup); diff --git a/tokens.css b/tokens.css index fe4856a..9b4d2df 100644 --- a/tokens.css +++ b/tokens.css @@ -19,8 +19,7 @@ --color-shadow: oklch(10% 0.015 250 / 45%); --color-overlay: oklch(10% 0.015 250 / 70%); --font-body: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", - "Noto Sans JP", sans-serif; + -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif; --font-display: var(--font-body); --font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace; --space-3xs: 0.125rem; diff --git a/vite.config.ts b/vite.config.ts index a703f53..f89e086 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,9 @@ -import tailwindcss from '@tailwindcss/vite' -import { tanstackStart } from '@tanstack/react-start/plugin/vite' -import viteReact from '@vitejs/plugin-react' -import { nitro } from 'nitro/vite' -import { defineConfig } from 'vite' +import tailwindcss from "@tailwindcss/vite"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { nitro } from "nitro/vite"; +import { defineConfig } from "vite"; export default defineConfig({ plugins: [tailwindcss(), nitro(), tanstackStart(), viteReact()], -}) +}); diff --git a/vitest.config.ts b/vitest.config.ts index 807bd15..f162ddc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,11 @@ -import path from 'node:path' -import { configDefaults, defineConfig } from 'vitest/config' +import path from "node:path"; +import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ - resolve: { alias: { '#': path.resolve(import.meta.dirname, 'src') } }, + resolve: { alias: { "#": path.resolve(import.meta.dirname, "src") } }, test: { - environment: 'jsdom', - setupFiles: ['./tests/setup.ts'], - exclude: [...configDefaults.exclude, 'e2e/**'], + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + exclude: [...configDefaults.exclude, "e2e/**"], }, -}) +});