From 0bab0099843f53ac477d6e2a696b3373c7526d4b Mon Sep 17 00:00:00 2001
From: yuta
Date: Mon, 28 Sep 2026 20:08:38 +0900
Subject: [PATCH] chore: migrate linting and formatting to Oxc
---
.gitignore | 1 +
.oxfmtrc.json | 4 +
.oxlintrc.json | 203 ++-
README.md | 106 +-
biome.json | 32 -
components.json | 2 +-
.../2026-09-24-mastodon-and-shared-decks.md | 44 +-
docs/plans/2026-09-28-shadcn-ui.md | 4 +
docs/storage-and-oauth.md | 12 +-
.../plans/2026-07-13-tweet-detail-thread.md | 879 +++++-----
.../plans/2026-07-13-twitter-lite.md | 1513 ++++++++---------
.../2026-07-13-tweet-detail-thread-design.md | 29 +-
.../specs/2026-07-13-twitter-lite-design.md | 12 +-
drizzle.config.ts | 10 +-
flake.nix | 2 +-
knip.ts | 6 +-
orval.config.ts | 63 +-
scripts/account-setup.ts | 14 +-
scripts/backup-database.ts | 16 +-
scripts/build-tools.mjs | 10 +-
scripts/bundle-migrations.mjs | 12 +-
scripts/generate-routes.mjs | 8 +-
tests/live/relay.test.ts | 41 +-
tests/setup.ts | 8 +-
tokens.css | 3 +-
vite.config.ts | 12 +-
vitest.config.ts | 14 +-
27 files changed, 1550 insertions(+), 1510 deletions(-)
create mode 100644 .oxfmtrc.json
delete mode 100644 biome.json
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 }) {