3 Commits
236 changed files with 16868 additions and 17536 deletions
+2 -2
View File
@@ -5,8 +5,8 @@ TWITTER_LITE_DB_PATH=/absolute/path/to/twitter-lite/.data/workspace.sqlite
# Required when connecting Mastodon; keep this runtime file outside Git.
# TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key
TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com
# Optional home Codex research prototype. Start pnpm codex:serve separately.
# TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500
# Optional Codex research. The AI SDK provider manages its local app-server.
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
+1
View File
@@ -4,6 +4,7 @@
.env*
!.env.example
.output/
.tanstack/
dist/
node_modules/
playwright-report/
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": [".output/**", ".tanstack/**", "src/routeTree.gen.ts"]
}
+200 -3
View File
@@ -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"
]
}
+70 -36
View File
@@ -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=<id>`, 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
-32
View File
@@ -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
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "src/ui.css",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true
},
@@ -1,5 +1,8 @@
# Home-agent research
Historical design: the WebSocket transport and global active selection described
here were replaced by the [AI SDK research runtime](../research-runtime.md).
## Product boundary
The user starts a research task from the deck workspace. A home agent chooses
@@ -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までテストを先送りしない。
+4
View File
@@ -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
+19 -12
View File
@@ -24,15 +24,15 @@ list, user-profile, and conversation pages are not provided.
unique column IDs within each deck. Each column contains `id`, `title`,
`connectionId`, and a platform-specific `source`.
| Platform | Source kind | Conditions |
| --- | --- | --- |
| Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` |
| Twitter | `user` | `target`: handle or X/Twitter profile URL |
| Twitter | `list` | `target`: numeric list ID or X/Twitter list URL |
| Mastodon | `search` | `query`; results depend on the instance's search configuration |
| Mastodon | `user` | `target`: account handle or the connected instance's numeric account ID |
| Mastodon | `list` | `target`: numeric list ID for the connected account |
| Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores |
| Platform | Source kind | Conditions |
| -------- | ----------- | ----------------------------------------------------------------------- |
| Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` |
| Twitter | `user` | `target`: handle or X/Twitter profile URL |
| Twitter | `list` | `target`: numeric list ID or X/Twitter list URL |
| Mastodon | `search` | `query`; results depend on the instance's search configuration |
| Mastodon | `user` | `target`: account handle or the connected instance's numeric account ID |
| Mastodon | `list` | `target`: numeric list ID for the connected account |
| Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores |
`connectionId` is the account binding, distinct from a deck's name. Twitter
connections resolve to profiles discovered from the configured relay.
@@ -76,9 +76,9 @@ in a transaction. A stale revision is rejected. Save failures remain visible
and do not report unsaved edits as persisted.
Saved decks refresh on focus and every five seconds while visible. Refresh is
held while an editor is open. Active selection is a local preference under
`twitter-lite-active-deck`; selecting a deck does not switch another device's
view. Deleting the active deck selects a remaining one. When none remain, the
held while an editor is open. The `deck` URL parameter selects the active view;
selecting a deck does not switch another device's view. Browser history restores
selection, and saved decks can be opened from a copied URL. Deleting the active deck selects a remaining one. When none remain, the
app opens an empty temporary view.
WebMCP-created views are temporary by default. Temporary copies of saved decks
@@ -139,3 +139,10 @@ uses an isolated SQLite database and mock relay through real server functions
to exercise shared decks across browser contexts, editing, persistence,
pagination, and native WebMCP. Deterministic automated tests do not require
live SNS credentials.
## Conversation execution
The [research runtime](research-runtime.md) uses AI SDK and the Codex app-server
provider. Conversations are selected by URL, with per-conversation background
execution and reconnectable SSE. Opening New chat or another saved conversation
does not stop an existing turn or change another browser's selection.
+54
View File
@@ -0,0 +1,54 @@
# Research runtime
Research uses Vercel AI SDK 7 `streamText` with
`ai-sdk-provider-codex-cli` 2.3's app-server provider. The provider owns its local
stdio child, thread resumption, protocol decoding, and abort handling. The app
owns accepted requests, account-scoped tools, snapshots, and report validation.
The separate WebSocket launcher is removed.
## Tools
The provider does not register AI SDK `tools` as Codex dynamic tools. Existing
research definitions and executors are exposed with `createSdkMcpServer` instead.
Validation, connected-account scope, request budgets, evidence tracking, and
read-only SNS behavior remain in `agent-tools.server.ts`. Provider execution is
one agent turn; there is no second AI SDK tool loop replaying those calls.
Resumed threads that already registered dynamic tools route their calls through
`onDynamicToolCall` to the same validated executor.
## Lifecycle and selection
The background runner is keyed by conversation ID. Acceptance saves the user
message before starting the model. A request ID identifies a new conversation
and deduplicates retries within the process. Overlapping turns of the same
conversation are rejected, while different conversations can run independently.
The URL selects a conversation. GET status and SSE subscribe to that ID;
selecting history never starts or cancels a turn. Reconnection sends the latest
complete snapshot. Closing a browser or changing its URL only detaches a viewer.
Only Stop, the execution timeout, or a server-side failure aborts generation.
Authentication expiry or logout closes the viewer's stream.
SQLite retains messages, thread IDs, generated decks, and citations. It no
longer stores a global active conversation pointer. Browser views cannot change
another browser's selection. After a backend restart, unfinished conversations
are marked interrupted and require an explicit follow-up. No turn is replayed
automatically. Reports remain files under the configured report root.
## Streaming presentation
The model stream uses `smoothStream` with 15 ms pacing and the supplied chunking
pattern `/[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/`. The trailing buffer flushes at the
text-end event. The existing snapshot SSE remains the UI's reconnectable data
channel; an HTTP request's lifetime never owns the model execution.
Streamdown displays partial Markdown and animates only the latest live answer.
Completed messages render immediately. Reduced-motion preferences disable the
animation. HTML is skipped, unsafe link protocols are rejected, images remain
links, and retrieved post citations keep their deck navigation behavior.
`@shadcn/helpers/ai-sdk` provides deterministic streaming fixtures for UI tests.
References: [Codex provider](https://github.com/ben-vargas/ai-sdk-provider-codex-cli),
[AI SDK smoothing](https://ai-sdk.dev/docs/reference/ai-sdk-core/smooth-stream),
[Streamdown animation](https://streamdown.ai/docs/animation),
[shadcn AI SDK helpers](https://ui.shadcn.com/docs/helpers/ai-sdk).
+6 -6
View File
@@ -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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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.
+4
View File
@@ -16,3 +16,7 @@ as the anatomy illustration source and clipped in CSS. Source media:
https://pbs.twimg.com/media/HTE4JWFWUAAnPBt.jpg
The rest of the interface is HTML/CSS with SVG data charts. Reference-specific
colors are scoped to `--vital-*` tokens in `vitals.css`.
Sections, tabs, history, chart filters, and detail dialogs are URL state and
restore on reload or browser Back/Forward. Simulated climate control values
remain local; opening a URL never issues a device command.
+9 -9
View File
@@ -13,15 +13,15 @@ operations use the same access controls and persistence rules as the UI.
## Workspace tools
| Tool | Input | Behavior |
| --- | --- | --- |
| `list_connections` | `{}` | Return connection IDs, platforms, origins, account IDs, display names, and states; never credentials |
| `list_decks` | `{}` | Return saved decks and this tab's temporary views, `activeDeckId`, and `storageError` |
| `get_deck` | Optional `deckId` | Read the named or active view, including `persisted` and saved `revision` |
| `set_deck` | Optional `deckId` and `expectedRevision`, required `title` and `columns` | Without an ID, create a temporary view; with an ID, replace and activate that existing view |
| `save_deck` | `deckId` | Explicitly persist a temporary view; an already saved deck is unchanged |
| `select_deck` | `deckId` | Activate a view; selection remains device-local |
| `delete_deck` | `deckId`, optional `expectedRevision` | Discard a temporary view or delete a saved deck for all devices |
| Tool | Input | Behavior |
| ------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `list_connections` | `{}` | Return connection IDs, platforms, origins, account IDs, display names, and states; never credentials |
| `list_decks` | `{}` | Return saved decks and this tab's temporary views, `activeDeckId`, and `storageError` |
| `get_deck` | Optional `deckId` | Read the named or active view, including `persisted` and saved `revision` |
| `set_deck` | Optional `deckId` and `expectedRevision`, required `title` and `columns` | Without an ID, create a temporary view; with an ID, replace and activate that existing view |
| `save_deck` | `deckId` | Explicitly persist a temporary view; an already saved deck is unchanged |
| `select_deck` | `deckId` | Activate a view and update the URL; other tabs remain unchanged |
| `delete_deck` | `deckId`, optional `expectedRevision` | Discard a temporary view or delete a saved deck for all devices |
Read tools return the workspace's current client snapshot, not a fresh server
request. Saved definitions refresh on focus and while visible, except during
+5 -5
View File
@@ -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",
});
+1
View File
@@ -0,0 +1 @@
DROP TABLE `research_state`;
+657
View File
@@ -0,0 +1,657 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7a44ef34-89ad-4ab4-829d-f6669bd72471",
"prevId": "52f68a9d-b9da-4739-9635-b0b5e3e8040b",
"tables": {
"auth_sessions": {
"name": "auth_sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"auth_sessions_owner_id_workspace_owner_id_fk": {
"name": "auth_sessions_owner_id_workspace_owner_id_fk",
"tableFrom": "auth_sessions",
"tableTo": "workspace_owner",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"auth_throttle": {
"name": "auth_throttle",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reset_at": {
"name": "reset_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"auth_throttle_singleton": {
"name": "auth_throttle_singleton",
"value": "\"auth_throttle\".\"id\" = 1"
}
}
},
"connection_credentials": {
"name": "connection_credentials",
"columns": {
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"encrypted_token": {
"name": "encrypted_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"connection_credentials_connection_id_connections_id_fk": {
"name": "connection_credentials_connection_id_connections_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"connection_credentials_app_id_oauth_apps_id_fk": {
"name": "connection_credentials_app_id_oauth_apps_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"connections": {
"name": "connections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"relay_profile": {
"name": "relay_profile",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connections_account": {
"name": "connections_account",
"columns": ["platform", "origin", "account_id"],
"isUnique": true
},
"connections_relay_profile": {
"name": "connections_relay_profile",
"columns": ["origin", "relay_profile"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"deck_columns": {
"name": "deck_columns",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deck_id": {
"name": "deck_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"deck_columns_position": {
"name": "deck_columns_position",
"columns": ["deck_id", "position"],
"isUnique": true
}
},
"foreignKeys": {
"deck_columns_deck_id_decks_id_fk": {
"name": "deck_columns_deck_id_decks_id_fk",
"tableFrom": "deck_columns",
"tableTo": "decks",
"columnsFrom": ["deck_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deck_columns_connection_id_connections_id_fk": {
"name": "deck_columns_connection_id_connections_id_fk",
"tableFrom": "deck_columns",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"deck_columns_deck_id_id_pk": {
"columns": ["deck_id", "id"],
"name": "deck_columns_deck_id_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {
"deck_columns_valid_position": {
"name": "deck_columns_valid_position",
"value": "\"deck_columns\".\"position\" >= 0"
}
}
},
"decks": {
"name": "decks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision": {
"name": "revision",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"decks_positive_revision": {
"name": "decks_positive_revision",
"value": "\"decks\".\"revision\" >= 1"
}
}
},
"legacy_imports": {
"name": "legacy_imports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"payload_hash": {
"name": "payload_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deck_ids": {
"name": "deck_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_apps": {
"name": "oauth_apps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"redirect_uri": {
"name": "redirect_uri",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scopes": {
"name": "scopes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_client_secret": {
"name": "encrypted_client_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"oauth_apps_configuration": {
"name": "oauth_apps_configuration",
"columns": ["origin", "redirect_uri", "scopes"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_attempts": {
"name": "oauth_attempts",
"columns": {
"state_hash": {
"name": "state_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"browser_hash": {
"name": "browser_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_verifier": {
"name": "encrypted_verifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consumed_at": {
"name": "consumed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"oauth_attempts_app_id_oauth_apps_id_fk": {
"name": "oauth_attempts_app_id_oauth_apps_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"oauth_attempts_connection_id_connections_id_fk": {
"name": "oauth_attempts_connection_id_connections_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"research_sessions": {
"name": "research_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"snapshot": {
"name": "snapshot",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workspace_owner": {
"name": "workspace_owner",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"onboarding_completed_at": {
"name": "onboarding_completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"workspace_owner_singleton": {
"name": "workspace_owner_singleton",
"value": "\"workspace_owner\".\"id\" = 1"
}
}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -36,6 +36,13 @@
"when": 1790589494499,
"tag": "0004_good_natasha_romanoff",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1790591720807,
"tag": "0005_salty_madripoor",
"breakpoints": true
}
]
}
+42 -43
View File
@@ -1,89 +1,88 @@
import { basename, dirname, isAbsolute } from 'node:path'
import AxeBuilder from '@axe-core/playwright'
import { test as base } from '@playwright/test'
import { basename, dirname, isAbsolute } from "node:path";
import AxeBuilder from "@axe-core/playwright";
import { test as base } from "@playwright/test";
import {
completeOnboarding,
createOwner,
readAuthState,
signIn,
} from '../src/features/auth/auth.server'
import { openDatabase } from '../src/features/storage/database.server'
} from "../src/features/auth/auth.server";
import { openDatabase } from "../src/features/storage/database.server";
export const test = base.extend<{
a11y: () => AxeBuilder
resetDecks: undefined
sessionToken: string
a11y: () => AxeBuilder;
resetDecks: undefined;
sessionToken: string;
}>({
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments.
// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments.
sessionToken: async ({}, use) => {
const path = process.env.TWITTER_LITE_E2E_DB_PATH
if (!path || !basename(dirname(path)).startsWith('twitter-lite-e2e-'))
throw new Error('Isolated E2E database is required.')
const database = openDatabase(path)
const path = process.env.TWITTER_LITE_E2E_DB_PATH;
if (!path || !basename(dirname(path)).startsWith("twitter-lite-e2e-"))
throw new Error("Isolated E2E database is required.");
const database = openDatabase(path);
const credentials = {
email: '[email protected]',
password: 'E2E-only-passphrase-2026',
}
process.env.WORKSPACE_SETUP_TOKEN =
'isolated-e2e-setup-token-not-for-production'
email: "[email protected]",
password: "E2E-only-passphrase-2026",
};
process.env.WORKSPACE_SETUP_TOKEN = "isolated-e2e-setup-token-not-for-production";
const result = readAuthState(database).needsSetup
? await createOwner(
{
...credentials,
name: 'Yuta',
name: "Yuta",
setupToken: process.env.WORKSPACE_SETUP_TOKEN,
},
database,
)
: await signIn(credentials, database)
: await signIn(credentials, database);
if (!result.owner.onboardingCompletedAt)
completeOnboarding(result.sessionToken, { name: 'Yuta' }, database)
database.$client.close()
await use(result.sessionToken)
completeOnboarding(result.sessionToken, { name: "Yuta" }, database);
database.$client.close();
await use(result.sessionToken);
},
storageState: async ({ sessionToken }, use) => {
await use({
cookies: [
{
name: 'workspace_session',
name: "workspace_session",
value: sessionToken,
domain: '127.0.0.1',
path: '/',
domain: "127.0.0.1",
path: "/",
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: true,
secure: false,
sameSite: 'Lax',
sameSite: "Lax",
},
],
origins: [],
})
});
},
resetDecks: [
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments.
// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructured fixture arguments.
async ({}, use) => {
const path = process.env.TWITTER_LITE_E2E_DB_PATH
const path = process.env.TWITTER_LITE_E2E_DB_PATH;
if (
!path ||
!isAbsolute(path) ||
!basename(dirname(path)).startsWith('twitter-lite-e2e-') ||
basename(path) !== 'workspace.sqlite'
!basename(dirname(path)).startsWith("twitter-lite-e2e-") ||
basename(path) !== "workspace.sqlite"
)
throw new Error('Isolated E2E database is required.')
const database = openDatabase(path).$client
database.pragma('foreign_keys = ON')
database.exec('DELETE FROM decks; DELETE FROM legacy_imports;')
database.close()
await use(undefined)
throw new Error("Isolated E2E database is required.");
const database = openDatabase(path).$client;
database.pragma("foreign_keys = ON");
database.exec("DELETE FROM decks; DELETE FROM legacy_imports;");
database.close();
await use(undefined);
},
{ auto: true },
],
a11y: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.disableRules(['meta-viewport']),
)
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.disableRules(["meta-viewport"]),
);
},
})
});
export { expect } from '@playwright/test'
export { expect } from "@playwright/test";
+9 -13
View File
@@ -1,24 +1,20 @@
import type { TweetLegacy } from '../generated/twitter-openapi/client.ts'
import type { TweetLegacy } from "../generated/twitter-openapi/client.ts";
interface TweetLegacyFixtureOptions {
conversationId?: string
inReplyTo?: string
replyCount?: number
conversationId?: string;
inReplyTo?: string;
replyCount?: number;
}
export const tweetLegacyFixture = (
id: string,
text: string,
{
conversationId = id,
inReplyTo,
replyCount = 1,
}: TweetLegacyFixtureOptions = {},
{ conversationId = id, inReplyTo, replyCount = 1 }: TweetLegacyFixtureOptions = {},
): TweetLegacy => ({
bookmark_count: 0,
bookmarked: false,
conversation_id_str: conversationId,
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
created_at: "Mon Jul 13 00:00:00 +0000 2026",
display_text_range: [0, text.length],
favorite_count: 3,
favorited: false,
@@ -26,10 +22,10 @@ export const tweetLegacyFixture = (
id_str: id,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
is_quote_status: false,
lang: 'ja',
lang: "ja",
quote_count: 0,
reply_count: replyCount,
retweet_count: 2,
retweeted: false,
user_id_str: '42',
})
user_id_str: "42",
});
File diff suppressed because it is too large Load Diff
+25 -35
View File
@@ -1,63 +1,53 @@
import { expect, test } from '../fixtures'
import { expect, test } from "../fixtures";
test('owner can open the app but missing or foreign identity is forbidden', async ({
request,
}) => {
expect((await request.get('/')).status()).toBe(200)
test("owner can open the app but missing or foreign identity is forbidden", async ({ request }) => {
expect((await request.get("/")).status()).toBe(200);
expect((await request.get("/", { headers: { "Tailscale-User-Login": "" } })).status()).toBe(403);
expect(
(
await request.get('/', { headers: { 'Tailscale-User-Login': '' } })
).status(),
).toBe(403)
expect(
(
await request.get('/', {
headers: { 'Tailscale-User-Login': '[email protected]' },
await request.get("/", {
headers: { "Tailscale-User-Login": "[email protected]" },
})
).status(),
).toBe(403)
})
).toBe(403);
});
test('mutations require exact Origin even with same-origin Fetch Metadata', async ({
request,
}) => {
expect((await request.post('/')).status()).toBe(403)
test("mutations require exact Origin even with same-origin Fetch Metadata", async ({ request }) => {
expect((await request.post("/")).status()).toBe(403);
expect(
(
await request.post('/', {
await request.post("/", {
headers: {
Origin: 'https://other.invalid',
'Sec-Fetch-Site': 'same-origin',
Origin: "https://other.invalid",
"Sec-Fetch-Site": "same-origin",
},
})
).status(),
).toBe(403)
})
).toBe(403);
});
test('server functions also reject foreign identity and cross-site calls', async ({
test("server functions also reject foreign identity and cross-site calls", async ({
page,
request,
}) => {
const serverRequest = page.waitForRequest((request) =>
request.url().includes('/_serverFn/'),
)
await page.goto('/deck')
const url = (await serverRequest).url()
const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/"));
await page.goto("/deck");
const url = (await serverRequest).url();
expect(
(
await request.get(url, {
headers: {
'Tailscale-User-Login': '[email protected]',
'Sec-Fetch-Site': 'same-origin',
"Tailscale-User-Login": "[email protected]",
"Sec-Fetch-Site": "same-origin",
},
})
).status(),
).toBe(403)
).toBe(403);
expect(
(
await request.get(url, {
headers: { 'Sec-Fetch-Site': 'cross-site' },
headers: { "Sec-Fetch-Site": "cross-site" },
})
).status(),
).toBe(403)
})
).toBe(403);
});
+98 -136
View File
@@ -1,172 +1,134 @@
import { openDatabase } from '../../src/features/storage/database.server'
import { expect, test } from '../fixtures'
import { openDatabase } from "../../src/features/storage/database.server";
import { expect, test } from "../fixtures";
const origin = 'http://127.0.0.1:4173'
const origin = "http://127.0.0.1:4173";
test('creates the owner once and enters onboarding with a real session', async ({
test("creates the owner once and enters onboarding with a real session", async ({
page,
context,
a11y,
}) => {
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '')
db.$client.exec('DELETE FROM workspace_owner; DELETE FROM auth_throttle;')
db.$client.close()
await context.clearCookies()
await page.goto('/', { waitUntil: 'networkidle' })
await expect(page).toHaveURL(/\/setup$/)
expect((await a11y().analyze()).violations).toEqual([])
await page.getByLabel('Your name', { exact: true }).fill('Yuta')
await page.getByLabel('Email address').fill('[email protected]')
await page
.getByLabel('Password', { exact: true })
.fill('E2E-only-passphrase-2026')
await page
.getByLabel('Confirm password', { exact: true })
.fill('E2E-only-passphrase-2026')
await page
.getByLabel('Setup code')
.fill('isolated-e2e-setup-token-not-for-production')
await page
.getByRole('button', { name: 'Create account', exact: true })
.click()
await expect(page).toHaveURL(/\/onboarding$/)
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Continue', exact: true }).click()
await page.getByRole('button', { name: 'Open workspace' }).click()
await expect(
page.getByRole('heading', { name: 'Good morning, Yuta' }),
).toBeVisible()
})
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
db.$client.exec("DELETE FROM workspace_owner; DELETE FROM auth_throttle;");
db.$client.close();
await context.clearCookies();
await page.goto("/", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/setup$/);
expect((await a11y().analyze()).violations).toEqual([]);
await page.getByLabel("Your name", { exact: true }).fill("Yuta");
await page.getByLabel("Email address").fill("[email protected]");
await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026");
await page.getByLabel("Confirm password", { exact: true }).fill("E2E-only-passphrase-2026");
await page.getByLabel("Setup code").fill("isolated-e2e-setup-token-not-for-production");
await page.getByRole("button", { name: "Create account", exact: true }).click();
await expect(page).toHaveURL(/\/onboarding$/);
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: "Open workspace" }).click();
await expect(page.getByRole("heading", { name: "Good morning, Yuta" })).toBeVisible();
});
test('requires login for documents, server functions, and live updates', async ({
test("requires login for documents, server functions, and live updates", async ({
page,
context,
}) => {
const serverRequest = page.waitForRequest((request) =>
request.url().includes('/_serverFn/'),
)
await page.goto('/deck')
const serverUrl = (await serverRequest).url()
await context.clearCookies()
for (const path of ['/api/research/events', serverUrl]) {
const serverRequest = page.waitForRequest((request) => request.url().includes("/_serverFn/"));
await page.goto("/deck");
const serverUrl = (await serverRequest).url();
await context.clearCookies();
for (const path of ["/api/research/events", serverUrl]) {
const result = await context.request.get(path, {
headers: { Origin: origin, 'Sec-Fetch-Site': 'same-origin' },
})
expect(result.status()).toBe(401)
headers: { Origin: origin, "Sec-Fetch-Site": "same-origin" },
});
expect(result.status()).toBe(401);
}
await page.goto('/journal')
await expect(page).toHaveURL(/\/login$/)
await expect(
page.getByRole('heading', { name: 'Welcome back.' }),
).toBeVisible()
await expect(
page.getByRole('link', { name: 'Journal', exact: true }),
).toHaveCount(0)
})
await page.goto("/journal");
await expect(page).toHaveURL(/\/login(?:\?|$)/);
await expect(page.getByRole("heading", { name: "Welcome back." })).toBeVisible();
await expect(page.getByRole("link", { name: "Journal", exact: true })).toHaveCount(0);
});
test('signs in and revokes the session on sign out', async ({
page,
context,
a11y,
}) => {
await context.clearCookies()
await page.goto('/login', { waitUntil: 'networkidle' })
expect((await a11y().analyze()).violations).toEqual([])
await page.getByLabel('Email address').fill('[email protected]')
await page
.getByLabel('Password', { exact: true })
.fill('Wrong-passphrase-2026')
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await expect(page.getByRole('alert')).toContainText(
'Invalid email or password.',
)
await page
.getByLabel('Password', { exact: true })
.fill('E2E-only-passphrase-2026')
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await expect(page).toHaveURL(`${origin}/`)
await page.waitForLoadState('networkidle')
const token = (await context.cookies()).find(
(cookie) => cookie.name === 'workspace_session',
)
expect(token?.httpOnly).toBe(true)
expect(token?.sameSite).toBe('Lax')
await page.getByRole('button', { name: 'Manage connected accounts' }).click()
await page.getByRole('button', { name: 'Sign out', exact: true }).click()
await expect(page).toHaveURL(/\/login$/)
const replay = await context.request.get('/api/auth', {
test("signs in and revokes the session on sign out", async ({ page, context, a11y }) => {
await context.clearCookies();
await page.goto("/login", { waitUntil: "networkidle" });
expect((await a11y().analyze()).violations).toEqual([]);
await page.getByLabel("Email address").fill("[email protected]");
await page.getByLabel("Password", { exact: true }).fill("Wrong-passphrase-2026");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Invalid email or password.");
await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).toHaveURL(`${origin}/`);
await page.waitForLoadState("networkidle");
const token = (await context.cookies()).find((cookie) => cookie.name === "workspace_session");
expect(token?.httpOnly).toBe(true);
expect(token?.sameSite).toBe("Lax");
await page.getByRole("button", { name: "Manage connected accounts" }).click();
await page.getByRole("button", { name: "Sign out", exact: true }).click();
await expect(page).toHaveURL(/\/login(?:\?|$)/);
const replay = await context.request.get("/api/auth", {
headers: { Cookie: `workspace_session=${token?.value}` },
})
expect((await replay.json()).owner).toBeNull()
await page.goBack()
await expect(
page.getByRole('link', { name: 'Home', exact: true }),
).toHaveCount(0)
})
});
expect((await replay.json()).owner).toBeNull();
await page.goBack();
await expect(page.getByRole("link", { name: "Home", exact: true })).toHaveCount(0);
});
test('requires onboarding and remembers its completion and name', async ({
test("requires onboarding and remembers its completion and name", async ({
page,
request,
a11y,
}) => {
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? '')
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
db.$client
.prepare(
'UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1',
)
.run()
.prepare("UPDATE workspace_owner SET onboarding_completed_at = NULL WHERE id = 1")
.run();
try {
expect((await request.get('/api/research/events')).status()).toBe(403)
await page.goto('/', { waitUntil: 'networkidle' })
await expect(page).toHaveURL(/\/onboarding$/)
expect((await a11y().analyze()).violations).toEqual([])
await page.getByLabel('What should we call you?').fill('Yuta Test')
await page.getByRole('button', { name: 'Continue', exact: true }).click()
await page.getByRole('button', { name: 'Open workspace' }).click()
await expect(
page.getByRole('heading', { name: 'Good morning, Yuta Test' }),
).toBeVisible()
await page.reload()
await expect(
page.getByRole('heading', { name: 'Good morning, Yuta Test' }),
).toBeVisible()
await page.goto('/onboarding')
await expect(page).toHaveURL(`${origin}/`)
expect((await request.get("/api/research/events")).status()).toBe(403);
await page.goto("/", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/onboarding$/);
expect((await a11y().analyze()).violations).toEqual([]);
await page.getByLabel("What should we call you?").fill("Yuta Test");
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: "Open workspace" }).click();
await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible();
await page.reload();
await expect(page.getByRole("heading", { name: "Good morning, Yuta Test" })).toBeVisible();
await page.goto("/onboarding");
await expect(page).toHaveURL(`${origin}/`);
} finally {
db.$client
.prepare(
'UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1',
)
.run('Yuta', Date.now())
db.$client.close()
.prepare("UPDATE workspace_owner SET name = ?, onboarding_completed_at = ? WHERE id = 1")
.run("Yuta", Date.now());
db.$client.close();
}
})
});
test('rejects cross-origin login and further account registration', async ({
test("rejects cross-origin login and further account registration", async ({
request,
page,
context,
}) => {
expect(
(
await request.post('/api/auth', {
headers: { Origin: 'https://other.invalid' },
data: { action: 'logout' },
await request.post("/api/auth", {
headers: { Origin: "https://other.invalid" },
data: { action: "logout" },
})
).status(),
).toBe(403)
const result = await request.post('/api/auth', {
).toBe(403);
const result = await request.post("/api/auth", {
headers: { Origin: origin },
data: {
action: 'setup',
email: '[email protected]',
name: 'Intruder',
password: 'Long-enough-password',
setupToken: 'arbitrary-token',
action: "setup",
email: "[email protected]",
name: "Intruder",
password: "Long-enough-password",
setupToken: "arbitrary-token",
},
})
expect(result.status()).toBe(409)
await context.clearCookies()
await page.goto('/setup')
await expect(page).toHaveURL(/\/login$/)
})
});
expect(result.status()).toBe(409);
await context.clearCookies();
await page.goto("/setup");
await expect(page).toHaveURL(/\/login(?:\?|$)/);
});
+40 -58
View File
@@ -1,70 +1,52 @@
import { expect, test } from '../fixtures'
import { expect, test } from "../fixtures";
test('opens account management after hydration without inheriting real OAuth configuration', async ({
test("opens account management after hydration without inheriting real OAuth configuration", async ({
page,
}) => {
await page.goto('/deck')
await expect(
page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled()
const manage = page.getByRole('button', { name: 'Manage connected accounts' })
await page.goto("/deck");
await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
const manage = page.getByRole("button", { name: "Manage connected accounts" });
if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
await page.getByRole("button", { name: "Toggle sidebar" }).click();
}
await expect(manage).toBeEnabled()
await manage.click()
const dialog = page.getByRole('dialog', { name: 'Connected accounts' })
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('No Mastodon servers have been configured yet.'),
).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Authorize with Mastodon' }),
).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(manage).toBeFocused()
})
await expect(manage).toBeEnabled();
await manage.click();
const dialog = page.getByRole("dialog", { name: "Connected accounts" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible();
await expect(dialog.getByRole("button", { name: "Authorize with Mastodon" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(manage).toBeFocused();
});
test('opens account management from the inbox footer and restores focus on close', async ({
test("opens account management from the inbox footer and restores focus on close", async ({
page,
a11y,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const manage = page.getByRole('button', { name: 'Manage connected accounts' })
await expect(manage).toBeVisible()
await manage.click()
const dialog = page.getByRole('dialog', { name: 'Connected accounts' })
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('No Mastodon servers have been configured yet.'),
).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([])
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(manage).toBeFocused()
await expect(
page.getByRole('textbox', { name: 'Search articles' }),
).toBeVisible()
})
await page.goto("/inbox");
await page.waitForLoadState("networkidle");
const manage = page.getByRole("button", { name: "Manage connected accounts" });
await expect(manage).toBeVisible();
await manage.click();
const dialog = page.getByRole("dialog", { name: "Connected accounts" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("No Mastodon servers have been configured yet.")).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(manage).toBeFocused();
await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
});
test('rejects an unbound OAuth callback without contacting an instance', async ({
page,
}) => {
await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code')
await expect(page).toHaveURL(/\/deck\?mastodon=failed$/)
await expect(page.getByRole('alert')).toContainText(
'Could not complete Mastodon authorization.',
)
await expect(
page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled()
const manage = page.getByRole('button', { name: 'Manage connected accounts' })
test("rejects an unbound OAuth callback without contacting an instance", async ({ page }) => {
await page.goto("/oauth/mastodon/callback?state=invalid&code=not-a-real-code");
await expect(page).toHaveURL(/\/deck\?mastodon=failed$/);
await expect(page.getByRole("alert")).toContainText("Could not complete Mastodon authorization.");
await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
const manage = page.getByRole("button", { name: "Manage connected accounts" });
if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
await page.getByRole("button", { name: "Toggle sidebar" }).click();
}
await expect(
page.getByRole('button', { name: 'Manage connected accounts' }),
).toBeEnabled()
})
await expect(page.getByRole("button", { name: "Manage connected accounts" })).toBeEnabled();
});
+248 -342
View File
@@ -1,296 +1,230 @@
import type { Page } from '@playwright/test'
import { expect, test } from '../fixtures'
import { closeMobileSidebar, showDeckManagement } from '../workspace'
import type { Page } from "@playwright/test";
import { expect, test } from "../fixtures";
import { closeMobileSidebar, showDeckManagement } from "../workspace";
async function addColumn(
page: Page,
title: string,
query: string,
profile = 'e2e',
kind = 'search',
profile = "e2e",
kind = "search",
) {
await showDeckManagement(page)
await page.getByRole('button', { name: 'Add column', exact: true }).click()
const editor = page.getByRole('form', { name: 'Add column', exact: true })
await editor.getByLabel('Column name', { exact: true }).fill(title)
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
const editor = page.getByRole("form", { name: "Add column", exact: true });
await editor.getByLabel("Column name", { exact: true }).fill(title);
await editor
.getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: profile })
.getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: profile });
await editor.getByRole("combobox", { name: "Column type", exact: true }).selectOption(kind);
await editor
.getByRole('combobox', { name: 'Column type', exact: true })
.selectOption(kind)
await editor
.getByLabel(
kind === 'search'
? 'Twitter search query'
: kind === 'user'
? 'User'
: 'List',
{ exact: true },
)
.fill(query)
await editor.getByRole('button', { name: 'Add and search' }).click()
await closeMobileSidebar(page)
.getByLabel(kind === "search" ? "Twitter search query" : kind === "user" ? "User" : "List", {
exact: true,
})
.fill(query);
await editor.getByRole("button", { name: "Add and search" }).click();
await closeMobileSidebar(page);
await expect(
page
.getByRole('region', { name: title, exact: true })
.locator('article')
.first(),
).toBeVisible()
page.getByRole("region", { name: title, exact: true }).locator("article").first(),
).toBeVisible();
}
test.beforeEach(async ({ page }) => {
await page.goto('/deck')
await expect(
page.getByRole('button', { name: 'Save as deck', exact: true }),
).toBeEnabled()
await page.getByRole('button', { name: 'Save as deck', exact: true }).click()
await showDeckManagement(page)
await expect(
page.getByText('Saved to server · Shared across devices'),
).toBeVisible()
await closeMobileSidebar(page)
})
await page.goto("/deck");
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Save as deck", exact: true }).click();
await showDeckManagement(page);
await expect(page.getByText("Saved to server · Shared across devices")).toBeVisible();
await closeMobileSidebar(page);
});
test('binds identical searches to independent profiles through paging, editing and reload', async ({
test("binds identical searches to independent profiles through paging, editing and reload", async ({
page,
}) => {
await addColumn(page, '通常の接続', 'same-query')
await addColumn(page, '別の接続', 'same-query', 'alternate')
const first = page.getByRole('region', { name: '通常の接続', exact: true })
const second = page.getByRole('region', { name: '別の接続', exact: true })
await expect(
first.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(
second.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
await first.getByRole('button', { name: 'Load more' }).click()
await expect(first.locator('article')).toHaveCount(2)
await expect(second.locator('article')).toHaveCount(1)
await second.getByRole('button', { name: 'Load more' }).click()
await expect(
second.getByText('alternate · Latest · all page 2'),
).toBeVisible()
await second.getByRole('button', { name: 'Actions for 別の接続' }).click()
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click()
await addColumn(page, "通常の接続", "same-query");
await addColumn(page, "別の接続", "same-query", "alternate");
const first = page.getByRole("region", { name: "通常の接続", exact: true });
const second = page.getByRole("region", { name: "別の接続", exact: true });
await expect(first.getByText("Latest · all page 1", { exact: true })).toBeVisible();
await expect(second.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible();
await first.getByRole("button", { name: "Load more" }).click();
await expect(first.locator("article")).toHaveCount(2);
await expect(second.locator("article")).toHaveCount(1);
await second.getByRole("button", { name: "Load more" }).click();
await expect(second.getByText("alternate · Latest · all page 2")).toBeVisible();
await second.getByRole("button", { name: "Actions for 別の接続" }).click();
await page.getByRole("menuitem", { name: "Edit", exact: true }).click();
await page
.getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'e2e' })
await page.getByRole('button', { name: 'Save changes' }).click()
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount(
0,
)
await page.reload()
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(first.locator('article')).toHaveCount(1)
})
.getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: "e2e" });
await page.getByRole("button", { name: "Save changes" }).click();
await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible();
await expect(second.getByText("alternate · Latest · all page 1")).toHaveCount(0);
await page.reload();
await expect(second.getByText("Latest · all page 1", { exact: true })).toBeVisible();
await expect(first.locator("article")).toHaveCount(1);
});
test('creates, switches, renames and deletes persisted deck profiles', async ({
test("creates, switches, renames and deletes persisted deck profiles", async ({ page }) => {
await page.getByRole("button", { name: "Rename" }).click();
await page.getByLabel("Research topic / deck name").fill("最初の調査");
await page.getByRole("button", { name: "Save name" }).click();
await addColumn(page, "最初の観点", "first-deck");
await showDeckManagement(page);
await page.getByRole("button", { name: "Create deck", exact: true }).click();
await page.getByLabel("New deck name").fill("別の調査");
await page.getByRole("button", { name: "Create", exact: true }).click();
await expect(page.locator(".deck-column")).toHaveCount(0);
await addColumn(page, "別の観点", "second-deck", "alternate");
await showDeckManagement(page);
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "最初の調査" });
await closeMobileSidebar(page);
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
await page.reload();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査");
await showDeckManagement(page);
await page.getByLabel("Deck profile", { exact: true }).selectOption({ label: "別の調査" });
await closeMobileSidebar(page);
await expect(page.locator(".deck-column h2")).toHaveText(["別の観点"]);
await showDeckManagement(page);
await page.getByRole("button", { name: "Delete deck", exact: true }).click();
await page.getByRole("button", { name: "Delete", exact: true }).click();
await page.reload();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("最初の調査");
await showDeckManagement(page);
await expect(page.getByLabel("Deck profile", { exact: true }).locator("option")).toHaveCount(1);
});
test("reads user and list timelines within columns and reorders, deletes and restores them", async ({
page,
}) => {
await page.getByRole('button', { name: 'Rename' }).click()
await page.getByLabel('Research topic / deck name').fill('最初の調査')
await page.getByRole('button', { name: 'Save name' }).click()
await addColumn(page, '最初の観点', 'first-deck')
await showDeckManagement(page)
await page.getByRole('button', { name: 'Create deck', exact: true }).click()
await page.getByLabel('New deck name').fill('別の調査')
await page.getByRole('button', { name: 'Create', exact: true }).click()
await expect(page.locator('.deck-column')).toHaveCount(0)
await addColumn(page, '別の観点', 'second-deck', 'alternate')
await showDeckManagement(page)
await page
.getByLabel('Deck profile', { exact: true })
.selectOption({ label: '最初の調査' })
await closeMobileSidebar(page)
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page)
await page
.getByLabel('Deck profile', { exact: true })
.selectOption({ label: '別の調査' })
await closeMobileSidebar(page)
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
await showDeckManagement(page)
await page.getByRole('button', { name: 'Delete deck', exact: true }).click()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page)
await expect(
page.getByLabel('Deck profile', { exact: true }).locator('option'),
).toHaveCount(1)
})
await addColumn(page, "アカウント", "yuta", "alternate", "user");
await addColumn(page, "リストの投稿", "1234567890", "e2e", "list");
const user = page.getByRole("region", { name: "アカウント", exact: true });
const list = page.getByRole("region", { name: "リストの投稿", exact: true });
await expect(user.getByText("alternate profile page 1")).toBeVisible();
await expect(list.getByText("list page 1")).toBeVisible();
await list.getByRole("button", { name: "Load more" }).click();
await expect(list.getByText("list page 2")).toBeVisible();
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
await page.getByRole("menuitem", { name: "Move リストの投稿 left" }).click();
await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
await list.getByRole("button", { name: "Actions for リストの投稿" }).click();
await page.getByRole("menuitem", { name: "Delete リストの投稿" }).click();
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator(".deck-column h2")).toHaveText(["リストの投稿", "アカウント"]);
await page.reload();
await expect(user.getByText("alternate profile page 1")).toBeVisible();
await expect(list.getByText("list page 1")).toBeVisible();
});
test('reads user and list timelines within columns and reorders, deletes and restores them', async ({
page,
}) => {
await addColumn(page, 'アカウント', 'yuta', 'alternate', 'user')
await addColumn(page, 'リストの投稿', '1234567890', 'e2e', 'list')
const user = page.getByRole('region', { name: 'アカウント', exact: true })
const list = page.getByRole('region', { name: 'リストの投稿', exact: true })
await expect(user.getByText('alternate profile page 1')).toBeVisible()
await expect(list.getByText('list page 1')).toBeVisible()
await list.getByRole('button', { name: 'Load more' }).click()
await expect(list.getByText('list page 2')).toBeVisible()
await list.getByRole('button', { name: 'Actions for リストの投稿' }).click()
await page.getByRole('menuitem', { name: 'Move リストの投稿 left' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿',
'アカウント',
])
await list.getByRole('button', { name: 'Actions for リストの投稿' }).click()
await page.getByRole('menuitem', { name: 'Delete リストの投稿' }).click()
await page.getByRole('button', { name: 'Undo' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿',
'アカウント',
])
await page.reload()
await expect(user.getByText('alternate profile page 1')).toBeVisible()
await expect(list.getByText('list page 1')).toBeVisible()
})
test('keeps other columns usable when pagination fails and retries that column', async ({
test("keeps other columns usable when pagination fails and retries that column", async ({
page,
}, testInfo) => {
await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`)
await addColumn(page, '別の検索', 'independent-deck')
const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
const other = page.getByRole('region', { name: '別の検索', exact: true })
await failed.getByRole('button', { name: 'Load more' }).click()
await expect(failed.getByRole('alert')).toBeVisible()
await expect(failed.locator('article')).toHaveCount(1)
await other.getByRole('button', { name: 'Load more' }).click()
await expect(other.locator('article')).toHaveCount(2)
await failed.getByRole('button', { name: 'Retry' }).click()
await expect(failed.locator('article')).toHaveCount(2)
})
await addColumn(page, "失敗する検索", `retry-deck-${testInfo.project.name}`);
await addColumn(page, "別の検索", "independent-deck");
const failed = page.getByRole("region", { name: "失敗する検索", exact: true });
const other = page.getByRole("region", { name: "別の検索", exact: true });
await failed.getByRole("button", { name: "Load more" }).click();
await expect(failed.getByRole("alert")).toBeVisible();
await expect(failed.locator("article")).toHaveCount(1);
await other.getByRole("button", { name: "Load more" }).click();
await expect(other.locator("article")).toHaveCount(2);
await failed.getByRole("button", { name: "Retry" }).click();
await expect(failed.locator("article")).toHaveCount(2);
});
test('keeps the deck and editor accessible within narrow viewports', async ({
test("keeps the deck and editor accessible within narrow viewports", async ({
page,
a11y,
}, testInfo) => {
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
await addColumn(page, '海外の反応', 'WebMCP lang:en', 'alternate')
await addColumn(page, '公式アカウント', 'OpenAI', 'e2e', 'user')
await addColumn(page, "日本語の反応", "WebMCP lang:ja");
await addColumn(page, "海外の反応", "WebMCP lang:en", "alternate");
await addColumn(page, "公式アカウント", "OpenAI", "e2e", "user");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > innerWidth,
),
).toBe(false)
expect(
await page.evaluate(
() => document.documentElement.scrollHeight <= innerHeight,
),
).toBe(true)
const board = await page.locator('.deck-board').boundingBox()
expect(board).not.toBeNull()
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901)
await page.setViewportSize({ width, height: 900 });
expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)).toBe(
false,
);
expect(await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight)).toBe(
true,
);
const board = await page.locator(".deck-board").boundingBox();
expect(board).not.toBeNull();
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901);
}
await page.setViewportSize({
width: testInfo.project.name === 'desktop' ? 1440 : 393,
width: testInfo.project.name === "desktop" ? 1440 : 393,
height: 900,
})
await expect(page.getByRole('navigation', { name: 'Columns' })).toHaveCount(0)
await expect(
page.getByRole('heading', { name: '公式アカウント', exact: true }),
).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([])
});
await expect(page.getByRole("navigation", { name: "Columns" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "公式アカウント", exact: true })).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({
path: testInfo.outputPath('deck-populated.png'),
path: testInfo.outputPath("deck-populated.png"),
fullPage: true,
})
await showDeckManagement(page)
await page.getByRole('button', { name: 'Add column', exact: true }).click()
expect((await a11y().analyze()).violations).toEqual([])
});
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
expect((await a11y().analyze()).violations).toEqual([]);
await page.screenshot({
path: testInfo.outputPath('deck.png'),
path: testInfo.outputPath("deck.png"),
fullPage: true,
})
})
});
});
test('closes the column editor with Escape and restores focus without applying edits', async ({
test("closes the column editor with Escape and restores focus without applying edits", async ({
page,
}) => {
await showDeckManagement(page)
const opener = page.getByRole('button', { name: 'Add column', exact: true })
await opener.click()
await expect(
page.getByRole('dialog', { name: 'Add column', exact: true }),
).toBeVisible()
await page
.getByLabel('Column name', { exact: true })
.fill('キャンセルする観点')
await page.keyboard.press('Escape')
await expect(
page.getByRole('dialog', { name: 'Add column', exact: true }),
).toHaveCount(0)
await expect(opener).toBeFocused()
await expect(page.locator('.deck-column')).toHaveCount(0)
await opener.click()
await expect(page.getByLabel('Column name', { exact: true })).toHaveValue('')
})
await showDeckManagement(page);
const opener = page.getByRole("button", { name: "Add column", exact: true });
await opener.click();
await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toBeVisible();
await page.getByLabel("Column name", { exact: true }).fill("キャンセルする観点");
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Add column", exact: true })).toHaveCount(0);
await expect(opener).toBeFocused();
await expect(page.locator(".deck-column")).toHaveCount(0);
await opener.click();
await expect(page.getByLabel("Column name", { exact: true })).toHaveValue("");
});
test('does not overwrite unreadable storage on reload', async ({ page }) => {
await page.evaluate(() =>
localStorage.setItem('twitter-lite-research-deck', '{broken'),
)
await page.reload()
await expect(page.getByRole('alert')).toContainText('Unable to load')
expect(
await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
),
).toBe('{broken')
})
test("does not overwrite unreadable storage on reload", async ({ page }) => {
await page.evaluate(() => localStorage.setItem("twitter-lite-research-deck", "{broken"));
await page.reload();
await expect(page.getByRole("alert")).toContainText("Unable to load");
expect(await page.evaluate(() => localStorage.getItem("twitter-lite-research-deck"))).toBe(
"{broken",
);
});
test('selects a list from the column profile catalog and refreshes it on profile change', async ({
test("selects a list from the column profile catalog and refreshes it on profile change", async ({
page,
}) => {
await showDeckManagement(page)
await page.getByRole('button', { name: 'Add column', exact: true }).click()
await page.getByLabel('Column name', { exact: true }).fill('購読リスト')
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
await page.getByLabel("Column name", { exact: true }).fill("購読リスト");
await page
.getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'e2e' })
.getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: "e2e" });
await page.getByRole("combobox", { name: "Column type", exact: true }).selectOption("list");
await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toBeVisible();
await page
.getByRole('combobox', { name: 'Column type', exact: true })
.selectOption('list')
await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }),
).toBeVisible()
await page
.getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'alternate' })
await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }),
).toHaveCount(0)
await page
.getByRole('button', { name: 'Alternate List', exact: true })
.click()
await expect(page.getByLabel('List', { exact: true })).toHaveValue(
'1234567890',
)
await page.getByRole('button', { name: 'Add and search' }).click()
await closeMobileSidebar(page)
.getByRole("combobox", { name: "Connection profile", exact: true })
.selectOption({ label: "alternate" });
await expect(page.getByRole("button", { name: "My E2E List", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Alternate List", exact: true }).click();
await expect(page.getByLabel("List", { exact: true })).toHaveValue("1234567890");
await page.getByRole("button", { name: "Add and search" }).click();
await closeMobileSidebar(page);
await expect(
page
.getByRole('region', { name: '購読リスト', exact: true })
.getByText('list page 1', { exact: true }),
).toBeVisible()
})
.getByRole("region", { name: "購読リスト", exact: true })
.getByText("list page 1", { exact: true }),
).toBeVisible();
});
test('keeps an open draft through remote edits and rejects its stale save', async ({
test("keeps an open draft through remote edits and rejects its stale save", async ({
page,
browser,
baseURL,
@@ -298,124 +232,96 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn
const other = await browser.newContext({
storageState: await page.context().storageState(),
baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
})
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
});
try {
const device = await other.newPage()
await device.goto('/deck')
await page.getByRole('button', { name: 'Rename' }).click()
await page.getByLabel('Research topic / deck name').fill('編集中の名前')
await device.getByRole('button', { name: 'Rename' }).click()
await device.getByLabel('Research topic / deck name').fill('別の端末の変更')
await device.getByRole('button', { name: 'Save name' }).click()
await expect(device.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更',
)
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
await expect(page.getByLabel('Research topic / deck name')).toHaveValue(
'編集中の名前',
)
await page.getByRole('button', { name: 'Save name' }).click()
await expect(page.getByRole('alert')).toContainText(
'This deck changed on another device',
)
await expect(page.getByLabel('Research topic / deck name')).toHaveValue(
'編集中の名前',
)
await page.keyboard.press('Escape')
await page.getByRole('button', { name: 'Reload latest deck' }).click()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更',
)
const device = await other.newPage();
await device.goto("/deck");
await page.getByRole("button", { name: "Rename" }).click();
await page.getByLabel("Research topic / deck name").fill("編集中の名前");
await device.getByRole("button", { name: "Rename" }).click();
await device.getByLabel("Research topic / deck name").fill("別の端末の変更");
await device.getByRole("button", { name: "Save name" }).click();
await expect(device.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更");
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(page.getByLabel("Research topic / deck name")).toHaveValue("編集中の名前");
await page.getByRole("button", { name: "Save name" }).click();
await expect(page.getByRole("alert")).toContainText("This deck changed on another device");
await expect(page.getByLabel("Research topic / deck name")).toHaveValue("編集中の名前");
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Reload latest deck" }).click();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("別の端末の変更");
} finally {
await other.close()
await other.close();
}
})
});
test('imports old browser decks explicitly and avoids duplicate imports after retry', async ({
test("imports old browser decks explicitly and avoids duplicate imports after retry", async ({
page,
}) => {
const legacy = {
version: 2,
activeDeckId: 'old',
activeDeckId: "old",
decks: [
{
id: 'old',
title: '以前の調査',
id: "old",
title: "以前の調査",
columns: [
{
id: 'a',
title: '以前の観点',
profileName: 'e2e',
id: "a",
title: "以前の観点",
profileName: "e2e",
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "WebMCP",
product: "Latest",
following: false,
},
},
],
},
],
}
};
await page.evaluate(
(legacy) =>
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify(legacy),
),
(legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)),
legacy,
)
await page.reload()
await showDeckManagement(page)
const selector = page.getByLabel('Deck profile', { exact: true })
await expect(selector.locator('option')).toHaveCount(1)
await closeMobileSidebar(page)
await page.getByRole('button', { name: 'Import legacy decks' }).click()
await showDeckManagement(page)
await expect(selector.locator('option')).toHaveCount(2)
await selector.selectOption({ label: '以前の調査' })
await closeMobileSidebar(page)
await expect(
page.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
);
await page.reload();
await showDeckManagement(page);
const selector = page.getByLabel("Deck profile", { exact: true });
await expect(selector.locator("option")).toHaveCount(1);
await closeMobileSidebar(page);
await page.getByRole("button", { name: "Import legacy decks" }).click();
await showDeckManagement(page);
await expect(selector.locator("option")).toHaveCount(2);
await selector.selectOption({ label: "以前の調査" });
await closeMobileSidebar(page);
await expect(page.getByText("Latest · all page 1", { exact: true })).toBeVisible();
await page.evaluate(
(legacy) =>
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify(legacy),
),
(legacy) => localStorage.setItem("twitter-lite-research-deck", JSON.stringify(legacy)),
legacy,
)
await page.reload()
await page.getByRole('button', { name: 'Import legacy decks' }).click()
await expect(
page.getByRole('button', { name: 'Import legacy decks' }),
).toHaveCount(0)
await showDeckManagement(page)
await expect(selector.locator('option')).toHaveCount(2)
})
);
await page.reload();
await page.getByRole("button", { name: "Import legacy decks" }).click();
await expect(page.getByRole("button", { name: "Import legacy decks" })).toHaveCount(0);
await showDeckManagement(page);
await expect(selector.locator("option")).toHaveCount(2);
});
test('preserves a new deck dialog and its draft across the mobile breakpoint', async ({
page,
}) => {
await showDeckManagement(page)
await page.getByRole('button', { name: 'Create deck', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Create deck', exact: true })
await dialog.getByLabel('New deck name').fill('画面を回転しても残る調査')
test("preserves a new deck dialog and its draft across the mobile breakpoint", async ({ page }) => {
await showDeckManagement(page);
await page.getByRole("button", { name: "Create deck", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Create deck", exact: true });
await dialog.getByLabel("New deck name").fill("画面を回転しても残る調査");
for (const width of [1024, 375, 1024]) {
await page.setViewportSize({ width, height: 900 })
await expect(dialog).toHaveCount(1)
await expect(dialog).toBeVisible()
await expect(dialog.getByLabel('New deck name')).toHaveValue(
'画面を回転しても残る調査',
)
await expect(page.locator('#new-deck-title')).toHaveCount(1)
await page.setViewportSize({ width, height: 900 });
await expect(dialog).toHaveCount(1);
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel("New deck name")).toHaveValue("画面を回転しても残る調査");
await expect(page.locator("#new-deck-title")).toHaveCount(1);
}
await dialog.getByRole('button', { name: 'Create', exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'画面を回転しても残る調査',
)
})
await dialog.getByRole("button", { name: "Create", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("画面を回転しても残る調査");
});
+94 -120
View File
@@ -1,139 +1,113 @@
import { expect, test } from '../fixtures'
import { expect, test } from "../fixtures";
test('previews a natural-language memo and shares the executed record with journal', async ({
test("previews a natural-language memo and shares the executed record with journal", async ({
page,
}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const memo = '明日のレビューは導線から確認する'
await page.getByRole('textbox', { name: 'Home input' }).fill(`Note: ${memo}`)
await page.getByRole('button', { name: 'Preview action' }).click()
const execute = page.getByRole('button', { name: 'Apply', exact: true })
await expect(execute).toBeVisible()
await execute.click()
await page.getByRole('link', { name: 'Journal', exact: true }).first().click()
await expect(page.getByRole('article', { name: 'Note entry' })).toContainText(
memo,
)
await page.getByRole('link', { name: 'Home', exact: true }).first().click()
await expect(page.getByRole('textbox', { name: 'Home input' })).toBeVisible()
})
await page.goto("/");
await page.waitForLoadState("networkidle");
const memo = "明日のレビューは導線から確認する";
await page.getByRole("textbox", { name: "Home input" }).fill(`Note: ${memo}`);
await page.getByRole("button", { name: "Preview action" }).click();
const execute = page.getByRole("button", { name: "Apply", exact: true });
await expect(execute).toBeVisible();
await execute.click();
await page.getByRole("link", { name: "Journal", exact: true }).first().click();
await expect(page.getByRole("article", { name: "Note entry" })).toContainText(memo);
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible();
});
test('keeps the home usable on narrow screens', async ({
page,
a11y,
}, testInfo) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
for (const width of [320, 375, 768]) {
await page.setViewportSize({ width, height: 850 })
await expect(
page.getByRole('textbox', { name: 'Home input' }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.screenshot({ path: testInfo.outputPath(`home-${width}.png`) })
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
await page.setViewportSize({ width, height: 850 });
await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({ path: testInfo.outputPath(`home-${width}.png`) });
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
}
})
});
test('adds tasks only after confirmation and can defer and restore them', async ({
page,
}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const input = page.getByRole('textbox', { name: 'Home input' })
const complete = page.getByRole('button', {
name: 'Complete grocery shopping',
test("adds tasks only after confirmation and can defer and restore them", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
const input = page.getByRole("textbox", { name: "Home input" });
const complete = page.getByRole("button", {
name: "Complete grocery shopping",
exact: true,
})
await input.fill('Add grocery shopping')
await page.getByRole('button', { name: 'Preview action' }).click()
await expect(complete).toHaveCount(0)
await page.getByRole('button', { name: 'Apply', exact: true }).click()
await expect(complete).toBeVisible()
await input.fill('Defer grocery shopping')
await page.getByRole('button', { name: 'Preview action' }).click()
await expect(complete).toBeVisible()
await page.getByRole('button', { name: 'Apply', exact: true }).click()
await expect(complete).toHaveCount(0)
await page.locator('.home-rest summary').click()
await page
.getByRole('button', { name: 'Restore grocery shopping', exact: true })
.click()
await complete.click()
await expect(complete).toHaveCount(0)
await page.locator('.home-rest summary').click()
});
await input.fill("Add grocery shopping");
await page.getByRole("button", { name: "Preview action" }).click();
await expect(complete).toHaveCount(0);
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(complete).toBeVisible();
await input.fill("Defer grocery shopping");
await page.getByRole("button", { name: "Preview action" }).click();
await expect(complete).toBeVisible();
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(complete).toHaveCount(0);
await page.locator(".home-rest summary").click();
await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click();
await complete.click();
await expect(complete).toHaveCount(0);
await page.locator(".home-rest summary").click();
await expect(
page.getByRole('button', { name: 'Restore grocery shopping', exact: true }),
).toBeVisible()
})
page.getByRole("button", { name: "Restore grocery shopping", exact: true }),
).toBeVisible();
});
test('resolves communication within home and carries the status to the shared queue', async ({
test("resolves communication within home and carries the status to the shared queue", async ({
page,
a11y,
}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await page
.getByRole('button', { name: 'Open conversation with Haruka Aoki' })
.click()
const dialog = page.getByRole('dialog', {
name: 'Conversation with Haruka Aoki',
})
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Open conversation with Haruka Aoki" }).click();
const dialog = page.getByRole("dialog", {
name: "Conversation with Haruka Aoki",
});
await expect(dialog.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await dialog.getByRole("button", { name: "Leave for later" }).click();
await expect(dialog).toHaveCount(0);
await expect(
dialog.getByRole('textbox', { name: 'Reply', exact: true }),
).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([])
await dialog.getByRole('button', { name: 'Leave for later' }).click()
await expect(dialog).toHaveCount(0)
page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
).toHaveCount(0);
await page.getByRole("link", { name: "Messages", exact: true }).first().click();
const rows = page.locator(".support-row");
await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0);
await page.getByRole("button", { name: "Later", exact: true }).click();
await rows.filter({ hasText: "Schedule next week’s design review" }).click();
await page.getByRole("button", { name: "Move to Now" }).click();
const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click();
await page.getByRole("link", { name: "Home", exact: true }).first().click();
await expect(
page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }),
).toHaveCount(0)
await page
.getByRole('link', { name: 'Messages', exact: true })
.first()
.click()
const rows = page.locator('.support-row')
await expect(
rows.filter({ hasText: 'Schedule next week’s design review' }),
).toHaveCount(0)
await page.getByRole('button', { name: 'Later', exact: true }).click()
await rows.filter({ hasText: 'Schedule next week’s design review' }).click()
await page.getByRole('button', { name: 'Move to Now' }).click()
const back = page.getByRole('button', { name: 'Back to messages' })
if (await back.isVisible()) await back.click()
await page.getByRole('link', { name: 'Home', exact: true }).first().click()
await expect(
page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }),
).toBeVisible()
})
page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
).toBeVisible();
});
test('updates the prose brief as priorities change and opens the referenced conversation', async ({
test("updates the prose brief as priorities change and opens the referenced conversation", async ({
page,
}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const brief = page.getByRole('region', { name: 'Daily brief' })
await expect(brief).toContainText('Review the documents')
await expect(brief).toContainText('4 conversations')
await page
.getByRole('button', { name: 'Complete Review the documents', exact: true })
.click()
await expect(brief).not.toContainText('Review the documents')
await brief.getByRole('button', { name: 'Reply to Haruka Aoki' }).click()
const dialog = page.getByRole('dialog', {
name: 'Conversation with Haruka Aoki',
await page.goto("/");
await page.waitForLoadState("networkidle");
const brief = page.getByRole("region", { name: "Daily brief" });
await expect(brief).toContainText("Review the documents");
await expect(brief).toContainText("4 conversations");
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
await expect(brief).not.toContainText("Review the documents");
await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click();
const dialog = page.getByRole("dialog", {
name: "Conversation with Haruka Aoki",
exact: true,
})
await dialog.getByRole('button', { name: 'Leave for later' }).click()
await expect(brief).toContainText('3 conversations')
await expect(brief).not.toContainText('Haruka Aoki')
await page
.getByRole('textbox', { name: 'Home input' })
.fill('再開地点:資料の2ページ目から')
await page.getByRole('button', { name: 'Preview action' }).click()
await page.getByRole('button', { name: 'Apply', exact: true }).click()
await expect(brief).toContainText('資料の2ページ目から')
})
});
await dialog.getByRole("button", { name: "Leave for later" }).click();
await expect(brief).toContainText("3 conversations");
await expect(brief).not.toContainText("Haruka Aoki");
await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から");
await page.getByRole("button", { name: "Preview action" }).click();
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(brief).toContainText("資料の2ページ目から");
});
+60 -86
View File
@@ -1,101 +1,75 @@
import { expect, test } from '../fixtures'
import { expect, test } from "../fixtures";
test('selects articles and keeps the reader focused on their content', async ({
page,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row')
await expect(rows).toHaveCount(9)
const articleTitle = 'Context for an agent can be brief'
await rows.filter({ hasText: articleTitle }).click()
const reader = page.getByRole('article', { name: articleTitle, exact: true })
await expect(reader).toBeVisible()
test("selects articles and keeps the reader focused on their content", async ({ page }) => {
await page.goto("/inbox");
await page.waitForLoadState("networkidle");
const rows = page.locator(".inbox-article-row");
await expect(rows).toHaveCount(9);
const articleTitle = "Context for an agent can be brief";
await rows.filter({ hasText: articleTitle }).click();
const reader = page.getByRole("article", { name: articleTitle, exact: true });
await expect(reader).toBeVisible();
await expect(reader.getByRole("heading", { name: articleTitle, exact: true })).toBeVisible();
await expect(
reader.getByRole('heading', { name: articleTitle, exact: true }),
).toBeVisible()
reader.getByText("Start with a one-sentence goal, then add the constraints that must hold.", {
exact: false,
}),
).toBeVisible();
await expect(page.getByText("FOR YOU", { exact: true })).toHaveCount(0);
await expect(page.getByText("Mock", { exact: true })).toHaveCount(0);
await expect(page.getByText("Why this article", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: /For you|Read later|Unread only/ })).toHaveCount(0);
const back = page.getByRole("button", { name: "Back to articles" });
if (await back.isVisible()) await back.click();
await rows.filter({ hasText: "Three columns create a rhythm for reading" }).click();
await expect(
reader.getByText(
'Start with a one-sentence goal, then add the constraints that must hold.',
{ exact: false },
),
).toBeVisible()
await expect(page.getByText('FOR YOU', { exact: true })).toHaveCount(0)
await expect(page.getByText('Mock', { exact: true })).toHaveCount(0)
await expect(page.getByText('Why this article', { exact: true })).toHaveCount(
0,
)
await expect(
page.getByRole('button', { name: /For you|Read later|Unread only/ }),
).toHaveCount(0)
const back = page.getByRole('button', { name: 'Back to articles' })
if (await back.isVisible()) await back.click()
await rows
.filter({ hasText: 'Three columns create a rhythm for reading' })
.click()
await expect(
page.getByRole('article', {
name: 'Three columns create a rhythm for reading',
page.getByRole("article", {
name: "Three columns create a rhythm for reading",
exact: true,
}),
).toBeVisible()
})
).toBeVisible();
});
test('recovers from empty search and shares navigation with Research', async ({
page,
a11y,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row')
await page
.getByRole('textbox', { name: 'Search articles' })
.fill('nonexistent-query-xyz')
await expect(rows).toHaveCount(0)
await expect(
page.getByText('No articles found', { exact: true }),
).toBeVisible()
await page.getByRole('button', { name: 'Clear search' }).click()
await expect(rows).toHaveCount(9)
expect((await a11y().analyze()).violations).toEqual([])
await expect(
page.getByRole('button', { name: 'Edit interests', exact: true }),
).toHaveCount(0)
await page.getByRole('link', { name: 'Research', exact: true }).click()
await expect(page).toHaveURL(/\/deck$/)
await page.getByRole('link', { name: 'Reader', exact: true }).click()
await expect(page).toHaveURL(/\/inbox$/)
await expect(rows).toHaveCount(9)
})
test("recovers from empty search and shares navigation with Research", async ({ page, a11y }) => {
await page.goto("/inbox");
await page.waitForLoadState("networkidle");
const rows = page.locator(".inbox-article-row");
await page.getByRole("textbox", { name: "Search articles" }).fill("nonexistent-query-xyz");
await expect(rows).toHaveCount(0);
await expect(page.getByText("No articles found", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear search" }).click();
await expect(rows).toHaveCount(9);
expect((await a11y().analyze()).violations).toEqual([]);
await expect(page.getByRole("button", { name: "Edit interests", exact: true })).toHaveCount(0);
await page.getByRole("link", { name: "Research", exact: true }).click();
await expect(page).toHaveURL(/\/deck$/);
await page.getByRole("link", { name: "Reader", exact: true }).click();
await expect(page).toHaveURL(/\/inbox$/);
await expect(rows).toHaveCount(9);
});
test('keeps article navigation readable without horizontal overflow', async ({
test("keeps article navigation readable without horizontal overflow", async ({
page,
a11y,
}, testInfo) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
await page.goto("/inbox");
await page.waitForLoadState("networkidle");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 })
await expect(
page.getByRole('textbox', { name: 'Search articles' }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.locator('.inbox-article-row').first().click()
const reader = page.getByRole('article', {
name: 'Thinking in the browser: a new workflow with AI agents',
await page.setViewportSize({ width, height: 850 });
await expect(page.getByRole("textbox", { name: "Search articles" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.locator(".inbox-article-row").first().click();
const reader = page.getByRole("article", {
name: "Thinking in the browser: a new workflow with AI agents",
exact: true,
})
await expect(reader).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
});
await expect(reader).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({
path: testInfo.outputPath(`inbox-reader-${width}.png`),
})
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
const back = page.getByRole('button', { name: 'Back to articles' })
if (await back.isVisible()) await back.click()
});
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
const back = page.getByRole("button", { name: "Back to articles" });
if (await back.isVisible()) await back.click();
}
})
});
+293 -266
View File
@@ -1,351 +1,378 @@
import type { DeckColumn } from '../../src/features/decks/model'
import type { ResearchRun } from '../../src/features/research/model'
import { expect, test } from '../fixtures'
import {
closeMobileSidebar,
showDeckManagement,
showResearch,
} from '../workspace'
import type { DeckColumn } from "../../src/features/decks/model";
import type { ResearchRun } from "../../src/features/research/model";
import { expect, test } from "../fixtures";
import { closeMobileSidebar, showDeckManagement, showResearch } from "../workspace";
test('pushes the current research snapshot on each SSE connection', async ({
page,
}) => {
await page.goto('/deck')
test("pushes the current research snapshot on each SSE connection", async ({ page }) => {
await page.goto("/deck");
const snapshots = await page.evaluate(async () => {
const connect = () =>
new Promise<unknown>((resolve, reject) => {
const events = new EventSource('/api/research/events')
const events = new EventSource("/api/research/events");
const timer = setTimeout(() => {
events.close()
reject(new Error('SSE timed out'))
}, 5000)
events.close();
reject(new Error("SSE timed out"));
}, 5000);
events.onmessage = (event) => {
clearTimeout(timer)
events.close()
resolve(JSON.parse(event.data))
}
clearTimeout(timer);
events.close();
resolve(JSON.parse(event.data));
};
events.onerror = () => {
clearTimeout(timer)
events.close()
reject(new Error('SSE failed'))
}
})
return [await connect(), await connect()]
})
clearTimeout(timer);
events.close();
reject(new Error("SSE failed"));
};
});
return [await connect(), await connect()];
});
expect(snapshots).toEqual([
{ configured: false, run: null, history: [] },
{ configured: false, run: null, history: [] },
])
})
]);
});
test('restores the conversation and growing temporary deck from reconnect snapshots', async ({
test("restores the conversation and growing temporary deck from reconnect snapshots", async ({
page,
}) => {
await page.goto('/deck')
await showDeckManagement(page)
await page.getByRole('button', { name: 'Add column', exact: true }).click()
const accounts = page.getByRole('combobox', {
name: 'Connection profile',
await page.goto("/deck");
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
const accounts = page.getByRole("combobox", {
name: "Connection profile",
exact: true,
})
await accounts.selectOption({ label: 'e2e' })
const connectionId = await accounts.inputValue()
await page.keyboard.press('Escape')
await closeMobileSidebar(page)
});
await accounts.selectOption({ label: "e2e" });
const connectionId = await accounts.inputValue();
await page.keyboard.press("Escape");
await closeMobileSidebar(page);
const run: ResearchRun = {
id: '36ad8cc7-318c-46ae-94fa-28d30336f027',
threadId: 'conversation-test',
id: "36ad8cc7-318c-46ae-94fa-28d30336f027",
threadId: "conversation-test",
connectionIds: [connectionId],
topic: '既存の観点を使って調査して',
status: 'running',
topic: "既存の観点を使って調査して",
status: "running",
startedAt: Date.now(),
deckVersion: 1,
citations: [],
message: '既存の検索条件を再利用しています。',
message: "既存の検索条件を再利用しています。",
messages: [
{ id: 'user-1', role: 'user', text: '既存の観点を使って調査して' },
{ id: 'tool-1', role: 'tool', text: 'list_decks: 完了' },
{ id: "user-1", role: "user", text: "既存の観点を使って調査して" },
{ id: "tool-1", role: "tool", text: "list_decks: 完了" },
{
id: 'assistant-1',
role: 'assistant',
text: '既存の検索条件を再利用しています。',
id: "assistant-1",
role: "assistant",
text: "既存の検索条件を再利用しています。",
},
],
deck: {
id: 'generated-plan',
title: '会話からの調査',
id: "generated-plan",
title: "会話からの調査",
columns: [
{
id: 'one',
title: '最初の観点',
id: "one",
title: "最初の観点",
connectionId,
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "WebMCP",
product: "Latest",
following: false,
},
},
],
},
}
await page.route('**/api/research/events', (route) =>
};
await page.route("**/api/research/events*", (route) =>
route.fulfill({
contentType: 'text/event-stream',
contentType: "text/event-stream",
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
}),
)
await page.reload()
await showResearch(page)
const conversation = page.getByRole('log', { name: 'Research conversation' })
await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('list_decks: 完了')
await expect(conversation).toContainText('既存の検索条件を再利用しています。')
await expect(
conversation.locator('[data-slot="bubble-content"]'),
).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
run.deckVersion = 2
run.status = 'complete'
);
await page.goto(`/deck?run=${run.id}`);
await showResearch(page);
const conversation = page.getByRole("log", { name: "Research conversation" });
await expect(conversation).toContainText("既存の観点を使って調査して");
await expect(conversation).toContainText("list_decks: 完了");
await expect(conversation).toContainText("既存の検索条件を再利用しています。");
await expect(conversation.locator('[data-slot="bubble-content"]')).not.toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
run.deckVersion = 2;
run.status = "complete";
run.messages.push({
id: 'assistant-2',
role: 'assistant',
text: '比較する観点を追加しました。',
})
id: "assistant-2",
role: "assistant",
text: "比較する観点を追加しました。",
});
run.deck?.columns.push({
id: 'two',
title: '比較の観点',
id: "two",
title: "比較の観点",
connectionId,
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP lang:ja',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "WebMCP lang:ja",
product: "Latest",
following: false,
},
})
await page.reload()
await showResearch(page)
await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('比較する観点を追加しました。')
await expect(page.locator('.deck-column h2')).toHaveText([
'最初の観点',
'比較の観点',
])
await closeMobileSidebar(page)
await expect(
page.getByRole('button', { name: 'Save as deck', exact: true }),
).toBeEnabled()
await showResearch(page)
const accountPicker = page.getByRole('button', {
name: 'Connected accounts · 1',
})
await accountPicker.click()
await expect(accountPicker).toHaveAttribute('aria-expanded', 'true')
await expect(
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).toBeChecked()
await accountPicker.click()
await expect(accountPicker).toHaveAttribute('aria-expanded', 'false')
await page.getByLabel('Message', { exact: true }).fill('この観点を詳しく')
await expect(
page.getByRole('button', { name: 'Send', exact: true }),
).toBeEnabled()
await page.getByRole('button', { name: 'Close sidebar', exact: true }).click()
run.deckVersion = 3
if (run.deck) run.deck.title = '閉じている間に更新された調査'
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'閉じている間に更新された調査',
{ timeout: 15000 },
)
await showResearch(page)
await expect(page.getByLabel('Message', { exact: true })).toHaveValue(
'この観点を詳しく',
)
await page.setViewportSize({ width: 600, height: 900 })
await expect(
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(1)
await showResearch(page)
await expect(page.getByLabel('Message', { exact: true })).toHaveValue(
'この観点を詳しく',
)
await page.getByRole('button', { name: 'Connected accounts · 1' }).click()
await page.getByRole('checkbox', { name: 'e2e · Twitter' }).uncheck()
await page.setViewportSize({ width: 1100, height: 900 })
await expect(
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(0)
await showResearch(page)
await expect(page.getByLabel('Message', { exact: true })).toHaveValue(
'この観点を詳しく',
)
await page.getByRole('button', { name: 'Connected accounts · 0' }).click()
await expect(
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).not.toBeChecked()
await page.setViewportSize({ width: 600, height: 900 })
await expect(
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(1)
await showResearch(page)
await expect(page.getByLabel('Message', { exact: true })).toHaveValue(
'この観点を詳しく',
)
await page.getByRole('button', { name: 'Connected accounts · 0' }).click()
await expect(
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).not.toBeChecked()
})
});
await page.reload();
await showResearch(page);
await expect(conversation).toContainText("既存の観点を使って調査して");
await expect(conversation).toContainText("比較する観点を追加しました。");
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点", "比較の観点"]);
await closeMobileSidebar(page);
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
await showResearch(page);
const accountPicker = page.getByRole("button", {
name: "Connected accounts · 1",
});
await accountPicker.click();
await expect(accountPicker).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).toBeChecked();
await accountPicker.click();
await expect(accountPicker).toHaveAttribute("aria-expanded", "false");
await page.getByLabel("Message", { exact: true }).fill("この観点を詳しく");
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
run.deckVersion = 3;
if (run.deck) run.deck.title = "閉じている間に更新された調査";
await expect(page.getByRole("heading", { level: 1 })).toHaveText("閉じている間に更新された調査", {
timeout: 15000,
});
await showResearch(page);
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await page.setViewportSize({ width: 600, height: 900 });
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1);
await showResearch(page);
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await page.getByRole("button", { name: "Connected accounts · 1" }).click();
await page.getByRole("checkbox", { name: "e2e · Twitter" }).uncheck();
await page.setViewportSize({ width: 1100, height: 900 });
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(0);
await showResearch(page);
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await page.getByRole("button", { name: "Connected accounts · 0" }).click();
await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked();
await page.setViewportSize({ width: 600, height: 900 });
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1);
await showResearch(page);
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
await page.getByRole("button", { name: "Connected accounts · 0" }).click();
await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked();
});
test('rejects a different tailnet owner at the SSE endpoint', async ({
request,
}) => {
const response = await request.get('/api/research/events', {
headers: { 'Tailscale-User-Login': '[email protected]' },
})
expect(response.status()).toBe(403)
})
test("rejects a different tailnet owner at the SSE endpoint", async ({ request }) => {
const response = await request.get("/api/research/events", {
headers: { "Tailscale-User-Login": "[email protected]" },
});
expect(response.status()).toBe(403);
});
test('renders Markdown and reveals an unloaded citation in an offscreen deck column', async ({
test("renders Markdown and reveals an unloaded citation in an offscreen deck column", async ({
page,
a11y,
}) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/deck')
await showDeckManagement(page)
await page.getByRole('button', { name: 'Add column', exact: true }).click()
const accounts = page.getByRole('combobox', {
name: 'Connection profile',
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/deck");
await showDeckManagement(page);
await page.getByRole("button", { name: "Add column", exact: true }).click();
const accounts = page.getByRole("combobox", {
name: "Connection profile",
exact: true,
})
await accounts.selectOption({ label: 'e2e' })
const connectionId = await accounts.inputValue()
await page.keyboard.press('Escape')
await closeMobileSidebar(page)
});
await accounts.selectOption({ label: "e2e" });
const connectionId = await accounts.inputValue();
await page.keyboard.press("Escape");
await closeMobileSidebar(page);
const source: DeckColumn = {
id: 'citation-column-2',
title: '引用の観点',
id: "citation-column-2",
title: "引用の観点",
connectionId,
source: {
platform: 'twitter',
kind: 'search',
query: 'citation-query-2',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "citation-query-2",
product: "Latest",
following: false,
},
}
const columns: DeckColumn[] = ['概要', '比較'].map((title, index) => ({
};
const columns: DeckColumn[] = ["概要", "比較"].map((title, index) => ({
id: `citation-column-${index}`,
title,
connectionId,
source: {
platform: 'twitter',
kind: 'search',
platform: "twitter",
kind: "search",
query: `citation-query-${index}`,
product: 'Latest',
product: "Latest",
following: false,
},
}))
columns.push(source)
}));
columns.push(source);
const run: ResearchRun = {
id: '973ae084-18ed-4674-8899-c75851e51d1b',
topic: '引用を確認',
status: 'complete',
id: "973ae084-18ed-4674-8899-c75851e51d1b",
topic: "引用を確認",
status: "complete",
startedAt: Date.now(),
deckVersion: 1,
message: '',
deck: { id: 'citation-deck', title: '引用付きの調査', columns },
message: "",
deck: { id: "citation-deck", title: "引用付きの調査", columns },
messages: [
{
id: 'answer',
role: 'assistant',
text: '## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。',
id: "answer",
role: "assistant",
text: "## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。",
},
],
citations: [
{
column: source,
post: {
key: 'twitter:123456789',
nativeId: '123456789',
platform: 'twitter',
url: 'https://twitter.com/author/status/123456789',
text: 'AIが調査時に取得した引用本文',
author: { name: '引用元の著者', handle: 'author' },
key: "twitter:123456789",
nativeId: "123456789",
platform: "twitter",
url: "https://twitter.com/author/status/123456789",
text: "AIが調査時に取得した引用本文",
author: { name: "引用元の著者", handle: "author" },
},
},
],
}
await page.route('**/api/research/events', (route) =>
};
await page.route("**/api/research/events*", (route) =>
route.fulfill({
contentType: 'text/event-stream',
contentType: "text/event-stream",
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
}),
)
await page.reload()
await showResearch(page)
const chat = page.getByRole('log', { name: 'Research conversation' })
await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible()
await expect(chat.getByRole('heading', { name: '調査結果' })).toHaveCSS(
'font-weight',
'600',
)
await expect(chat.locator('ul')).toHaveCSS('list-style-type', 'disc')
await expect(
chat.locator('strong').filter({ hasText: '重要な発見' }),
).toBeVisible()
await expect(chat.getByRole('table')).toBeVisible()
await expect(
page.getByText('AIが調査時に取得した引用本文', { exact: true }),
).toHaveCount(0)
await chat.getByRole('link', { name: '根拠の投稿' }).click()
);
await page.goto(`/deck?run=${run.id}`);
await showResearch(page);
const chat = page.getByRole("log", { name: "Research conversation" });
await expect(chat.getByRole("heading", { name: "調査結果" })).toBeVisible();
await expect(chat.getByRole("heading", { name: "調査結果" })).toHaveCSS("font-weight", "600");
await expect(chat.locator("ul")).toHaveCSS("list-style-type", "disc");
await expect(chat.locator("strong").filter({ hasText: "重要な発見" })).toBeVisible();
await expect(chat.getByRole("table")).toBeVisible();
await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0);
await chat.getByRole("link", { name: "根拠の投稿" }).click();
const card = page.locator(
'.research-post[data-post-url="https://twitter.com/author/status/123456789"]',
)
await expect(card).toBeVisible()
await expect(card).toHaveClass(/citation-highlight/)
await expect(card).toBeFocused()
await expect(card).toContainText('Post saved during research')
await expect(card).toContainText('AIが調査時に取得した引用本文')
);
await expect(card).toBeVisible();
await expect(card).toHaveClass(/citation-highlight/);
await expect(card).toBeFocused();
await expect(card).toContainText("Post saved during research");
await expect(card).toContainText("AIが調査時に取得した引用本文");
expect(
await page.locator('.deck-board').evaluate((element) => element.scrollLeft),
).toBeGreaterThan(0)
expect(page.context().pages()).toHaveLength(1)
expect((await a11y().analyze()).violations).toEqual([])
})
await page.locator(".deck-board").evaluate((element) => element.scrollLeft),
).toBeGreaterThan(0);
expect(page.context().pages()).toHaveLength(1);
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
const bookmarked = new URL(page.url());
bookmarked.searchParams.set("run", run.id);
bookmarked.searchParams.set("deck", "citation-deck");
const submitted: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST") submitted.push(request.url());
});
await page.goto(bookmarked.toString());
await expect(card).toBeVisible();
await expect(card).toHaveClass(/citation-highlight/);
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
await expect(page.locator(".deck-column")).toHaveCount(3);
expect(submitted).toEqual([]);
expect((await a11y().analyze()).violations).toEqual([]);
});
test('explains unconfigured Codex and prevents starting research without affecting the deck', async ({
test("explains unconfigured Codex and prevents starting research without affecting the deck", async ({
page,
a11y,
}) => {
await page.goto('/deck')
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
const originalTitle = await heading.textContent()
await showResearch(page)
const panel = page.getByRole('complementary', { name: 'Research chat' })
await expect(panel).toBeVisible()
await page.goto("/deck");
const heading = page.getByRole("heading", { level: 1 });
await expect(heading).toBeVisible();
const originalTitle = await heading.textContent();
await showResearch(page);
const panel = page.getByRole("complementary", { name: "Research chat" });
await expect(panel).toBeVisible();
await expect(
panel.getByText(
'The connection to Codex on your home server is not configured.',
),
).toBeVisible()
await expect(
panel.getByRole('button', { name: 'Send', exact: true }),
).toBeDisabled()
await expect(panel.getByLabel('Message', { exact: true })).toBeDisabled()
const accessibility = await a11y().analyze()
expect(accessibility.violations).toEqual([])
await closeMobileSidebar(page)
await expect(heading).toHaveText(originalTitle ?? '')
await showDeckManagement(page)
await page.getByRole('button', { name: 'Create deck', exact: true }).click()
await page.getByLabel('New deck name').fill('手動の調査')
await page.getByRole('button', { name: 'Create', exact: true }).click()
await closeMobileSidebar(page)
await expect(heading).toHaveText('手動の調査')
await showResearch(page)
await expect(panel).toBeVisible()
})
panel.getByText("The connection to Codex on your home server is not configured."),
).toBeVisible();
await expect(panel.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
await expect(panel.getByLabel("Message", { exact: true })).toBeDisabled();
const accessibility = await a11y().analyze();
expect(accessibility.violations).toEqual([]);
await closeMobileSidebar(page);
await expect(heading).toHaveText(originalTitle ?? "");
await showDeckManagement(page);
await page.getByRole("button", { name: "Create deck", exact: true }).click();
await page.getByLabel("New deck name").fill("手動の調査");
await page.getByRole("button", { name: "Create", exact: true }).click();
await closeMobileSidebar(page);
await expect(heading).toHaveText("手動の調査");
await showResearch(page);
await expect(panel).toBeVisible();
});
test("keeps browser conversation selection independent and restores it on reload", async ({
page,
browser,
context,
}) => {
const { openDatabase } = await import("../../src/features/storage/database.server");
const { createResearchStore } = await import("../../src/features/research/repository.server");
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
const store = createResearchStore(db);
const first = crypto.randomUUID();
const second = crypto.randomUUID();
for (const [id, text] of [
[first, "Browser one conversation"],
[second, "Browser two conversation"],
] as const) {
store.save({
id,
topic: text,
status: "complete",
startedAt: Date.now(),
deckVersion: 0,
message: text,
messages: [{ id: `${id}-message`, role: "assistant", text }],
citations: [],
});
}
const other = await browser.newContext({
storageState: await context.storageState(),
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
});
try {
const secondPage = await other.newPage();
await page.goto(`/deck?run=${first}`, { waitUntil: "domcontentloaded" });
await showResearch(page);
await secondPage.goto(`/deck?run=${second}`, {
waitUntil: "domcontentloaded",
});
await showResearch(secondPage);
await expect(page.getByRole("log", { name: "Research conversation" })).toContainText(
"Browser one conversation",
);
await expect(secondPage.getByRole("log", { name: "Research conversation" })).toContainText(
"Browser two conversation",
);
await secondPage.getByRole("button", { name: "New chat", exact: true }).click();
await expect(secondPage).toHaveURL(/run=new/);
await page.reload({ waitUntil: "domcontentloaded" });
await showResearch(page);
await expect(page).toHaveURL(new RegExp(`run=${first}`));
await expect(page.getByRole("log", { name: "Research conversation" })).toContainText(
"Browser one conversation",
);
} finally {
await other.close();
db.$client.prepare("DELETE FROM research_sessions WHERE id IN (?, ?)").run(first, second);
db.$client.close();
}
});
+110 -141
View File
@@ -1,158 +1,127 @@
import { expect, test } from '../fixtures'
import { expect, test } from "../fixtures";
const reviewSubject = 'Schedule next week’s design review'
const coffeeSubject = 'Catch up over coffee'
const reviewSubject = "Schedule next week’s design review";
const coffeeSubject = "Catch up over coffee";
test('reviews an edited reply and keeps the simulated result in history', async ({
test("reviews an edited reply and keeps the simulated result in history", async ({
page,
a11y,
}) => {
await page.goto('/support')
await page.waitForLoadState('networkidle')
const rows = page.locator('.support-row')
await expect(rows).toHaveCount(3)
await rows.filter({ hasText: reviewSubject }).click()
await page.getByRole('button', { name: 'Polite', exact: true }).click()
const reply = page.getByRole('textbox', { name: 'Reply', exact: true })
await expect(reply).toHaveValue(/Thank you for sharing/)
const edited =
'I reviewed the materials. Let’s discuss the first-time experience.'
await reply.fill(edited)
const slot = 'Mon, Oct 5, 14:00–14:30'
await page
.getByRole('combobox', { name: 'Suggested times' })
.selectOption(slot)
await page.getByRole('button', { name: 'Add to reply' }).click()
await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`)
await page.getByRole('button', { name: 'Review reply' }).click()
const dialog = page.getByRole('dialog', { name: 'Review reply' })
await expect(dialog).toContainText(edited)
await expect(dialog).toContainText(slot)
await expect(dialog).toContainText('No message will be sent')
expect((await a11y().analyze()).violations).toEqual([])
await dialog.getByRole('button', { name: 'Back', exact: true }).click()
await expect(reply).toHaveValue(new RegExp(edited))
await page.getByRole('button', { name: 'Review reply' }).click()
await dialog.getByRole('button', { name: 'Try sending' }).click()
await expect(dialog).toHaveCount(0)
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0)
await page.getByRole('button', { name: 'History', exact: true }).click()
await rows.filter({ hasText: reviewSubject }).click()
await expect(page.locator('.support-outcome')).toContainText(edited)
await expect(page.locator('.support-outcome')).toContainText(slot)
})
await page.goto("/support");
await page.waitForLoadState("networkidle");
const rows = page.locator(".support-row");
await expect(rows).toHaveCount(3);
await rows.filter({ hasText: reviewSubject }).click();
await page.getByRole("button", { name: "Polite", exact: true }).click();
const reply = page.getByRole("textbox", { name: "Reply", exact: true });
await expect(reply).toHaveValue(/Thank you for sharing/);
const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
await reply.fill(edited);
const slot = "Mon, Oct 5, 14:00–14:30";
await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
await page.getByRole("button", { name: "Add to reply" }).click();
await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`);
await page.getByRole("button", { name: "Review reply" }).click();
const dialog = page.getByRole("dialog", { name: "Review reply" });
await expect(dialog).toContainText(edited);
await expect(dialog).toContainText(slot);
await expect(dialog).toContainText("No message will be sent");
expect((await a11y().analyze()).violations).toEqual([]);
await dialog.getByRole("button", { name: "Back", exact: true }).click();
await expect(reply).toHaveValue(new RegExp(edited));
await page.getByRole("button", { name: "Review reply" }).click();
await dialog.getByRole("button", { name: "Try sending" }).click();
await expect(dialog).toHaveCount(0);
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
await page.getByRole("button", { name: "History", exact: true }).click();
await rows.filter({ hasText: reviewSubject }).click();
await expect(page.locator(".support-outcome")).toContainText(edited);
await expect(page.locator(".support-outcome")).toContainText(slot);
});
test('defers a conversation and restores it to the current queue', async ({
page,
}) => {
await page.goto('/support')
await page.waitForLoadState('networkidle')
const rows = page.locator('.support-row')
await rows.filter({ hasText: coffeeSubject }).click()
test("defers a conversation and restores it to the current queue", async ({ page }) => {
await page.goto("/support");
await page.waitForLoadState("networkidle");
const rows = page.locator(".support-row");
await rows.filter({ hasText: coffeeSubject }).click();
await page
.getByRole('textbox', { name: 'Reply', exact: true })
.fill('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: 'Leave for later' }).click()
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0)
await page.getByRole('button', { name: 'Later', exact: true }).click()
await rows.filter({ hasText: coffeeSubject }).click()
await expect(
page.getByRole('textbox', { name: 'Reply', exact: true }),
).toHaveValue('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: 'Move to Now' }).click()
const back = page.getByRole('button', { name: 'Back to messages' })
if (await back.isVisible()) await back.click()
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible()
await expect(
page.getByRole('button', { name: 'Now', exact: true }),
).toHaveAttribute('aria-pressed', 'true')
})
.getByRole("textbox", { name: "Reply", exact: true })
.fill("土曜の予定を確認してから返事するね。");
await page.getByRole("button", { name: "Leave for later" }).click();
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0);
await page.getByRole("button", { name: "Later", exact: true }).click();
await rows.filter({ hasText: coffeeSubject }).click();
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue(
"土曜の予定を確認してから返事するね。",
);
await page.getByRole("button", { name: "Move to Now" }).click();
const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click();
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible();
await expect(page.getByRole("button", { name: "Now", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
});
test('keeps corrections as new records and summarizes only selected current facts', async ({
test("keeps corrections as new records and summarizes only selected current facts", async ({
page,
a11y,
}) => {
await page.goto('/journal')
await page.waitForLoadState('networkidle')
await expect(
page.getByRole('button', { name: 'Save entry', exact: true }),
).toBeDisabled()
await page.goto("/journal");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("button", { name: "Save entry", exact: true })).toBeDisabled();
await page.getByRole("textbox", { name: "Entry text" }).fill("次はレビュー資料の導線を確認する");
await page.getByRole("button", { name: "Save entry", exact: true }).click();
await expect(page.getByRole("article", { name: "Note entry" })).toContainText(
"Occurred: Unknown",
);
await page.getByRole("button", { name: "Weight", exact: true }).click();
await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2");
await page.getByRole("textbox", { name: "Source" }).fill("自宅の体重計");
await page.getByRole("button", { name: "Save entry", exact: true }).click();
await page
.getByRole('textbox', { name: 'Entry text' })
.fill('次はレビュー資料の導線を確認する')
await page.getByRole('button', { name: 'Save entry', exact: true }).click()
await expect(page.getByRole('article', { name: 'Note entry' })).toContainText(
'Occurred: Unknown',
)
await page.getByRole('button', { name: 'Weight', exact: true }).click()
await page
.getByRole('spinbutton', { name: 'Weight', exact: true })
.fill('65.2')
await page.getByRole('textbox', { name: 'Source' }).fill('自宅の体重計')
await page.getByRole('button', { name: 'Save entry', exact: true }).click()
await page
.getByRole('article', { name: 'Weight entry' })
.getByRole('button', { name: 'Correct' })
.click()
const correction = page.getByRole('dialog', { name: 'Correct entry' })
await correction
.getByRole('spinbutton', { name: 'Weight', exact: true })
.fill('65.8')
await correction.getByRole('button', { name: 'Add correction' }).click()
const weights = page.getByRole('article', { name: 'Weight entry' })
await expect(weights).toHaveCount(2)
await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('Original')
await expect(weights.filter({ hasText: '65.8 kg' })).toContainText(
'Correction',
)
await page
.getByRole('button', { name: 'Summarize entries', exact: true })
.click()
const review = page.getByRole('dialog', { name: 'Summarize entries' })
const summary = review.getByRole('textbox', { name: 'Entry summary' })
await expect(summary).toHaveValue(/65.8 kg/)
await expect(summary).not.toHaveValue(/65.2 kg/)
await expect(summary).toHaveValue(/Source: 自宅の体重計/)
await expect(summary).toHaveValue(
/Entries with an unknown event time: 2 entries/,
)
await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck()
await expect(summary).not.toHaveValue(/次はレビュー資料/)
.getByRole("article", { name: "Weight entry" })
.getByRole("button", { name: "Correct" })
.click();
const correction = page.getByRole("dialog", { name: "Correct entry" });
await correction.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.8");
await correction.getByRole("button", { name: "Add correction" }).click();
const weights = page.getByRole("article", { name: "Weight entry" });
await expect(weights).toHaveCount(2);
await expect(weights.filter({ hasText: "65.2 kg" })).toContainText("Original");
await expect(weights.filter({ hasText: "65.8 kg" })).toContainText("Correction");
await page.getByRole("button", { name: "Summarize entries", exact: true }).click();
const review = page.getByRole("dialog", { name: "Summarize entries" });
const summary = review.getByRole("textbox", { name: "Entry summary" });
await expect(summary).toHaveValue(/65.8 kg/);
await expect(summary).not.toHaveValue(/65.2 kg/);
await expect(summary).toHaveValue(/Source: 自宅の体重計/);
await expect(summary).toHaveValue(/Entries with an unknown event time: 2 entries/);
await review.getByRole("checkbox", { name: /次はレビュー資料/ }).uncheck();
await expect(summary).not.toHaveValue(/次はレビュー資料/);
await expect(summary).toHaveValue(
/Medication and weight are unknown for periods without entries/,
)
expect((await a11y().analyze()).violations).toEqual([])
})
);
expect((await a11y().analyze()).violations).toEqual([]);
});
test('fits communication and journal interactions in narrow screens', async ({
page,
a11y,
}) => {
test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => {
for (const width of [320, 375, 768]) {
await page.setViewportSize({ width, height: 850 })
await page.goto('/support')
await page.waitForLoadState('networkidle')
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.locator('.support-row').first().click()
await expect(
page.getByRole('textbox', { name: 'Reply', exact: true }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
const back = page.getByRole('button', { name: 'Back to messages' })
if (await back.isVisible()) await back.click()
await page.getByRole('link', { name: 'Journal', exact: true }).click()
await page.getByRole('button', { name: 'Weight', exact: true }).click()
await expect(
page.getByRole('spinbutton', { name: 'Weight', exact: true }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
await page.setViewportSize({ width, height: 850 });
await page.goto("/support");
await page.waitForLoadState("networkidle");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.locator(".support-row").first().click();
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
const back = page.getByRole("button", { name: "Back to messages" });
if (await back.isVisible()) await back.click();
await page.getByRole("link", { name: "Journal", exact: true }).click();
await page.getByRole("button", { name: "Weight", exact: true }).click();
await expect(page.getByRole("spinbutton", { name: "Weight", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
}
})
});
+106
View File
@@ -0,0 +1,106 @@
import { expect, test } from "../fixtures";
test("restores article selection with browser history and reload", async ({ page }) => {
await page.goto("/inbox", { waitUntil: "networkidle" });
const rows = page.locator(".inbox-article-row");
const heading = await rows.nth(1).locator(".inbox-row-title").innerText();
await rows.nth(1).click();
await expect(page.locator(".inbox-article-title")).toHaveText(heading);
const selectedUrl = page.url();
expect(new URL(selectedUrl).searchParams.get("article")).toBeTruthy();
await page.reload({ waitUntil: "networkidle" });
await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? "");
await page.goBack();
await expect(page.locator(".inbox-workspace")).toHaveAttribute("data-reading", "false");
await page.goForward();
await expect(page).toHaveURL(selectedUrl);
await expect(page.locator(".inbox-article-title")).toHaveText(heading ?? "");
});
test("restores search and replaces typing history", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" });
await page.getByRole("link", { name: "Reader", exact: true }).click();
const search = page.getByRole("textbox", { name: "Search articles" });
await search.pressSequentially("browser");
await expect(search).toHaveValue("browser");
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser");
const count = await page.locator(".inbox-article-row").count();
await page.reload({ waitUntil: "networkidle" });
await expect(search).toHaveValue("browser");
await expect(page.locator(".inbox-article-row")).toHaveCount(count);
await page.goBack();
await expect(page).toHaveURL("http://127.0.0.1:4173/");
});
test("restores account dialog without losing underlying article", async ({ page }) => {
await page.goto("/inbox", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Manage connected accounts" }).click();
await expect.poll(() => new URL(page.url()).searchParams.get("accounts")).toBe("true");
await page.reload({ waitUntil: "networkidle" });
await expect(page.getByRole("dialog")).toBeVisible();
await page.goBack();
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.goForward();
await expect(page.getByRole("dialog")).toBeVisible();
});
test("restores a message view and conversation from its URL", async ({ page }) => {
await page.goto("/support", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "All", exact: true }).click();
const row = page.locator(".support-row").nth(1);
const heading = await row.locator("span").first().innerText();
await row.click();
await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading);
const url = page.url();
expect(new URL(url).searchParams.get("view")).toBe("all");
expect(new URL(url).searchParams.get("conversation")).toBeTruthy();
await page.reload({ waitUntil: "networkidle" });
await expect(page.locator(".support-toolbar > span").first()).toHaveText(heading ?? "");
await expect(page.locator(".support-workspace")).toHaveAttribute("data-reading", "true");
});
test("keeps a deep link through login", async ({ page, context }) => {
await context.clearCookies();
await page.goto("/inbox?q=browser", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/login\?returnTo=/);
await page.getByLabel("Email address").fill("[email protected]");
await page.getByLabel("Password", { exact: true }).fill("E2E-only-passphrase-2026");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).toHaveURL(/\/inbox\?q=browser$/);
await expect(page.getByRole("textbox", { name: "Search articles" })).toHaveValue("browser");
});
test("debounces search and cancels pending input on history navigation", async ({ page }) => {
await page.goto("/support", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "All", exact: true }).click();
await expect(page).toHaveURL(/view=all/);
await page.clock.install();
const input = page.getByRole("textbox", { name: "Search messages" });
await input.fill("not-committed");
await expect(input).toHaveValue("not-committed");
expect(new URL(page.url()).searchParams.has("q")).toBe(false);
await page.clock.runFor(200);
expect(new URL(page.url()).searchParams.has("q")).toBe(false);
await page.goBack();
await expect(page).toHaveURL("http://127.0.0.1:4173/support");
await page.clock.runFor(400);
await expect(input).toHaveValue("");
expect(new URL(page.url()).searchParams.has("q")).toBe(false);
await input.fill("final search");
await page.clock.runFor(300);
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("final search");
});
test("waits for IME composition to finish before searching", async ({ page }) => {
await page.goto("/inbox", { waitUntil: "networkidle" });
await page.clock.install();
const input = page.getByRole("textbox", { name: "Search articles" });
await input.dispatchEvent("compositionstart");
await input.fill("browser");
await page.clock.runFor(1000);
await expect(input).toHaveValue("browser");
expect(new URL(page.url()).searchParams.has("q")).toBe(false);
await input.dispatchEvent("compositionend", { data: "browser" });
await page.clock.runFor(300);
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("browser");
});
+110 -101
View File
@@ -1,115 +1,124 @@
import { expect, test } from '../fixtures'
import { expect, test } from "../fixtures";
test('filters body systems and opens lab history', async ({ page, a11y }) => {
await page.goto('/vitals')
await page.waitForLoadState('networkidle')
await expect(
page.getByRole('heading', { name: 'Body', exact: true }),
).toBeVisible()
const systems = page.locator('.vital-system-card')
await expect(systems).toHaveCount(8)
test("filters body systems and opens lab history", async ({ page, a11y }) => {
await page.goto("/vitals");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Body", exact: true })).toBeVisible();
const systems = page.locator(".vital-system-card");
await expect(systems).toHaveCount(8);
await page
.locator('.vital-body-page > .vital-tabs')
.getByRole('button', { name: 'Cardiovascular', exact: true })
.click()
await expect(systems).toHaveCount(1)
await systems.first().click()
const dialog = page.getByRole('dialog', { name: 'Cardiovascular' })
await expect(dialog).toContainText('68')
await dialog
.getByRole('button', { name: 'Close', exact: true })
.last()
.click()
await page.getByRole('button', { name: 'Lab history', exact: true }).click()
await expect(page.locator('.vital-history')).toContainText('Jul 28')
await expect(page.locator('.vital-history')).toContainText('93')
await page.getByRole('button', { name: 'Systems', exact: true }).click()
.getByRole("group", { name: "Body systems", exact: true })
.getByRole("button", { name: "Cardiovascular", exact: true })
.click();
await expect(systems).toHaveCount(1);
await expect(page).toHaveURL(/vitalTab=Cardiovascular/);
await page.reload();
await page.waitForLoadState("networkidle");
await expect(systems).toHaveCount(1);
await systems.first().click();
const dialog = page.getByRole("dialog", { name: "Cardiovascular" });
await expect(dialog).toContainText("68");
await page.goBack();
await expect(dialog).not.toBeVisible();
await page.goForward();
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
await page.getByRole("button", { name: "Lab history", exact: true }).click();
await expect(page.locator(".vital-history")).toContainText("Jul 28");
await expect(page.locator(".vital-history")).toContainText("93");
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.locator(".vital-history")).toContainText("Jul 28");
await page.getByRole("button", { name: "Systems", exact: true }).click();
await page
.locator('.vital-body-page > .vital-tabs')
.getByRole('button', { name: 'Overview', exact: true })
.click()
await expect(systems).toHaveCount(8)
expect((await a11y().analyze()).violations).toEqual([])
})
.getByRole("group", { name: "Body systems", exact: true })
.getByRole("button", { name: "Overview", exact: true })
.click();
await expect(systems).toHaveCount(8);
expect((await a11y().analyze()).violations).toEqual([]);
});
test('explores environment charts and simulated climate controls', async ({
page,
a11y,
}) => {
await page.goto('/vitals')
await page.waitForLoadState('networkidle')
const menu = page.getByRole('button', { name: 'Open Health menu' })
if (await menu.isVisible()) await menu.click()
test("explores environment charts and simulated climate controls", async ({ page, a11y }) => {
await page.goto("/vitals");
await page.waitForLoadState("networkidle");
const menu = page.getByRole("button", { name: "Open Health menu" });
if (await menu.isVisible()) await menu.click();
await page
.getByRole('complementary', { name: 'Health navigation' })
.getByRole('button', { name: 'Environment', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Environment', exact: true }),
).toBeVisible()
await page.getByRole('button', { name: '7D', exact: true }).click()
.getByRole("complementary", { name: "Health navigation" })
.getByRole("button", { name: "Environment", exact: true })
.click();
await expect(page.getByRole("heading", { name: "Environment", exact: true })).toBeVisible();
await page.getByRole("button", { name: "7D", exact: true }).click();
await page
.getByRole('group', { name: 'Trend metric' })
.getByRole('button', { name: 'CO₂', exact: true })
.click()
.getByRole("group", { name: "Trend metric" })
.getByRole("button", { name: "CO₂", exact: true })
.click();
await expect(
page.getByRole('img', { name: '7D CO₂ simulated environmental readings' }),
).toBeVisible()
await page
.locator('.vital-metric')
.filter({ hasText: 'Room temperature' })
.click()
const dialog = page.getByRole('dialog', { name: 'Room temperature' })
await expect(dialog).toContainText('64.0')
await dialog.getByRole('button', { name: 'Back to overview' }).click()
await page.getByRole('button', { name: 'Sync now', exact: true }).click()
page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
).toBeVisible();
await page.reload();
await page.waitForLoadState("networkidle");
await expect(
page.getByRole('status').filter({ hasText: 'Sample readings refreshed' }),
).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([])
await page
.getByRole('button', { name: 'Climate control', exact: true })
.click()
await page.getByRole('button', { name: 'Cool', exact: true }).click()
page.getByRole("img", { name: "7D CO₂ simulated environmental readings" }),
).toBeVisible();
await page.locator(".vital-metric").filter({ hasText: "Room temperature" }).click();
const dialog = page.getByRole("dialog", { name: "Room temperature" });
await expect(dialog).toContainText("64.0");
await page.reload();
await page.waitForLoadState("networkidle");
await expect(dialog).toContainText("64.0");
await dialog.getByRole("button", { name: "Back to overview" }).click();
await page.getByRole("button", { name: "Sync now", exact: true }).click();
await expect(
page.getByRole('button', { name: 'Cool', exact: true }),
).toHaveAttribute('aria-pressed', 'true')
const temperature = page.getByRole('slider', { name: 'Target temperature' })
await temperature.focus()
await temperature.press('ArrowRight')
await expect(temperature).toHaveValue('69')
await page.getByRole('button', { name: 'Air purifier · On' }).click()
await expect(
page.getByRole('button', { name: 'Air purifier · Off' }),
).toHaveAttribute('aria-pressed', 'false')
})
page.getByRole("status").filter({ hasText: "Sample readings refreshed" }),
).toBeVisible();
expect((await a11y().analyze()).violations).toEqual([]);
await page.getByRole("button", { name: "Climate control", exact: true }).click();
await page.getByRole("button", { name: "Cool", exact: true }).click();
await expect(page.getByRole("button", { name: "Cool", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
const temperature = page.getByRole("slider", { name: "Target temperature" });
await temperature.focus();
await temperature.press("ArrowRight");
await expect(temperature).toHaveValue("69");
await page.getByRole("button", { name: "Air purifier · On" }).click();
await expect(page.getByRole("button", { name: "Air purifier · Off" })).toHaveAttribute(
"aria-pressed",
"false",
);
await page.reload();
await page.waitForLoadState("networkidle");
await expect(temperature).toHaveValue("68");
await expect(page.getByRole("button", { name: "Auto", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByRole("button", { name: "Air purifier · On" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
test('keeps both dashboards within the viewport', async ({
page,
}, testInfo) => {
await page.goto('/vitals')
await page.waitForLoadState('networkidle')
test("keeps both dashboards within the viewport", async ({ page }, testInfo) => {
await page.goto("/vitals");
await page.waitForLoadState("networkidle");
for (const width of [375, 768, 1440]) {
await page.setViewportSize({ width, height: 1000 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) })
const menu = page.getByRole('button', { name: 'Open Health menu' })
if (await menu.isVisible()) await menu.click()
const navigation = page.getByRole('complementary', {
name: 'Health navigation',
})
await navigation
.getByRole('button', { name: 'Environment', exact: true })
.click()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.setViewportSize({ width, height: 1000 });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) });
const menu = page.getByRole("button", { name: "Open Health menu" });
if (await menu.isVisible()) await menu.click();
const navigation = page.getByRole("complementary", {
name: "Health navigation",
});
await navigation.getByRole("button", { name: "Environment", exact: true }).click();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({
path: testInfo.outputPath(`environment-${width}.png`),
})
if (await menu.isVisible()) await menu.click()
await navigation.getByRole('button', { name: 'Body', exact: true }).click()
});
if (await menu.isVisible()) await menu.click();
await navigation.getByRole("button", { name: "Body", exact: true }).click();
}
})
});
+162 -211
View File
@@ -1,30 +1,28 @@
import type { Page } from '@playwright/test'
import Database from 'better-sqlite3'
import { expect, test } from '../fixtures'
import type { Page } from "@playwright/test";
import Database from "better-sqlite3";
import { expect, test } from "../fixtures";
type NativeTesting = {
listTools(): { name: string }[]
executeTool(name: string, input: string): Promise<string | null>
}
listTools(): { name: string }[];
executeTool(name: string, input: string): Promise<string | null>;
};
type ToolResult = {
content: { type: string; text: string }[]
isError?: boolean
}
content: { type: string; text: string }[];
isError?: boolean;
};
test.use({
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
args: ['--enable-blink-features=WebMCP,WebMCPTesting'],
args: ["--enable-blink-features=WebMCP,WebMCPTesting"],
},
})
});
async function toolNames(page: Page) {
return page.evaluate(() =>
(
navigator as Navigator & { modelContextTesting: NativeTesting }
).modelContextTesting
(navigator as Navigator & { modelContextTesting: NativeTesting }).modelContextTesting
.listTools()
.map((tool) => tool.name)
.sort(),
)
);
}
async function executeTool(
page: Page,
@@ -37,296 +35,249 @@ async function executeTool(
navigator as Navigator & { modelContextTesting: NativeTesting }
).modelContextTesting.executeTool(name, JSON.stringify(input)),
{ name, input },
)
expect(result).not.toBeNull()
return JSON.parse(result ?? '{}')
);
expect(result).not.toBeNull();
return JSON.parse(result ?? "{}");
}
function decoded(result: ToolResult) {
return JSON.parse(result.content[0]?.text ?? '{}')
return JSON.parse(result.content[0]?.text ?? "{}");
}
const column = (title: string, connectionId: string) => ({
title,
connectionId,
source: { kind: 'search', query: 'WebMCP' },
})
source: { kind: "search", query: "WebMCP" },
});
function savedCount() {
const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? '', {
const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? "", {
readonly: true,
})
});
try {
return db.prepare('SELECT count(*) AS count FROM decks').get()
return db.prepare("SELECT count(*) AS count FROM decks").get();
} finally {
db.close()
db.close();
}
}
let accounts: Record<string, string>
let pageErrors: string[]
let accounts: Record<string, string>;
let pageErrors: string[];
test.beforeEach(async ({ page }) => {
pageErrors = []
page.on('pageerror', (error) => pageErrors.push(error.message))
await page.goto('/deck')
pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto("/deck");
await expect
.poll(() => toolNames(page))
.toEqual([
'delete_deck',
'get_column_posts',
'get_deck',
'list_connections',
'list_decks',
'load_more_column',
'save_deck',
'select_deck',
'set_deck',
])
"delete_deck",
"get_column_posts",
"get_deck",
"list_connections",
"list_decks",
"load_more_column",
"save_deck",
"select_deck",
"set_deck",
]);
await expect
.poll(
async () =>
decoded(await executeTool(page, 'list_connections')).connections
?.length,
)
.toBe(2)
.poll(async () => decoded(await executeTool(page, "list_connections")).connections?.length)
.toBe(2);
accounts = Object.fromEntries(
decoded(await executeTool(page, 'list_connections')).connections.map(
(connection: { displayName: string; id: string }) => [
connection.displayName,
connection.id,
],
decoded(await executeTool(page, "list_connections")).connections.map(
(connection: { displayName: string; id: string }) => [connection.displayName, connection.id],
),
)
})
test.afterEach(() => expect(pageErrors).toEqual([]))
);
});
test.afterEach(() => expect(pageErrors).toEqual([]));
test('creates temporary research, edits it, persists explicitly and reopens it on another device', async ({
test("creates temporary research, edits it, persists explicitly and reopens it on another device", async ({
page,
browser,
baseURL,
}) => {
const created = await executeTool(page, 'set_deck', {
title: 'WebMCPの反応',
const created = await executeTool(page, "set_deck", {
title: "WebMCPの反応",
columns: [
{ ...column('日本語', accounts.e2e ?? ''), id: 'new' },
column('別の接続', accounts.alternate ?? ''),
{ ...column("日本語", accounts.e2e ?? ""), id: "new" },
column("別の接続", accounts.alternate ?? ""),
],
})
expect(created.isError).not.toBe(true)
const deck = decoded(created).deck
expect(deck.persisted).toBe(false)
expect(savedCount()).toEqual({ count: 0 })
await expect(
page.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
});
expect(created.isError).not.toBe(true);
const deck = decoded(created).deck;
expect(deck.persisted).toBe(false);
expect(savedCount()).toEqual({ count: 0 });
await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible();
await page
.getByRole('region', { name: '日本語', exact: true })
.getByRole('button', { name: 'Actions for 日本語' })
.click()
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click()
const replaced = await executeTool(page, 'set_deck', {
.getByRole("region", { name: "日本語", exact: true })
.getByRole("button", { name: "Actions for 日本語" })
.click();
await page.getByRole("menuitem", { name: "Edit", exact: true }).click();
const replaced = await executeTool(page, "set_deck", {
deckId: deck.id,
title: '更新した調査',
title: "更新した調査",
columns: [deck.columns[1]],
})
expect(replaced.isError).not.toBe(true)
await expect(page.getByRole('dialog')).toHaveCount(0)
expect(savedCount()).toEqual({ count: 0 })
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).not.toBe(true)
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).not.toBe(true)
expect(savedCount()).toEqual({ count: 1 })
});
expect(replaced.isError).not.toBe(true);
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(savedCount()).toEqual({ count: 0 });
expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
expect(savedCount()).toEqual({ count: 1 });
const other = await browser.newContext({
storageState: await page.context().storageState(),
baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
})
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
});
try {
const device = await other.newPage()
await device.goto('/deck')
await expect(device.getByRole('heading', { level: 1 })).toHaveText(
'更新した調査',
)
const device = await other.newPage();
await device.goto("/deck");
await expect(device.getByRole("heading", { level: 1 })).toHaveText("更新した調査");
await expect(
device.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
device.getByText("alternate · Latest · all page 1", { exact: true }),
).toBeVisible();
} finally {
await other.close()
await other.close();
}
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'更新した調査',
)
})
await page.reload();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("更新した調査");
});
test('requires revisions for saved mutations and rejects stale overwrites', async ({
page,
}) => {
test("requires revisions for saved mutations and rejects stale overwrites", async ({ page }) => {
const deck = decoded(
await executeTool(page, 'set_deck', { title: '保存済み', columns: [] }),
).deck
await executeTool(page, 'save_deck', { deckId: deck.id })
await executeTool(page, "set_deck", { title: "保存済み", columns: [] }),
).deck;
await executeTool(page, "save_deck", { deckId: deck.id });
expect(
(
await executeTool(page, 'set_deck', {
await executeTool(page, "set_deck", {
deckId: deck.id,
title: '変更',
title: "変更",
columns: [],
})
).isError,
).toBe(true)
).toBe(true);
expect(
(
await executeTool(page, 'set_deck', {
await executeTool(page, "set_deck", {
deckId: deck.id,
expectedRevision: 1,
title: '変更',
title: "変更",
columns: [],
})
).isError,
).not.toBe(true)
).not.toBe(true);
expect(
(
await executeTool(page, 'set_deck', {
await executeTool(page, "set_deck", {
deckId: deck.id,
expectedRevision: 1,
title: '古い変更',
title: "古い変更",
columns: [],
})
).isError,
).toBe(true)
await expect(page.getByRole('heading', { level: 1 })).toHaveText('変更')
).toBe(true);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("変更");
expect(
(
await executeTool(page, 'delete_deck', {
await executeTool(page, "delete_deck", {
deckId: deck.id,
expectedRevision: 1,
})
).isError,
).toBe(true)
).toBe(true);
expect(
(
await executeTool(page, 'delete_deck', {
await executeTool(page, "delete_deck", {
deckId: deck.id,
expectedRevision: 2,
})
).isError,
).not.toBe(true)
expect(savedCount()).toEqual({ count: 0 })
})
).not.toBe(true);
expect(savedCount()).toEqual({ count: 0 });
});
test('keeps temporary views independent and loses only unsaved views on reload', async ({
test("keeps temporary views independent and loses only unsaved views on reload", async ({
page,
}) => {
const first = decoded(
await executeTool(page, 'set_deck', { title: '一時の調査A', columns: [] }),
).deck
await executeTool(page, "set_deck", { title: "一時の調査A", columns: [] }),
).deck;
const second = decoded(
await executeTool(page, 'set_deck', { title: '一時の調査B', columns: [] }),
).deck
await executeTool(page, 'select_deck', { deckId: first.id })
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'一時の調査A',
)
await executeTool(page, 'delete_deck', { deckId: second.id })
await executeTool(page, "set_deck", { title: "一時の調査B", columns: [] }),
).deck;
await executeTool(page, "select_deck", { deckId: first.id });
await expect(page.getByRole("heading", { level: 1 })).toHaveText("一時の調査A");
await executeTool(page, "delete_deck", { deckId: second.id });
expect(
decoded(await executeTool(page, 'list_decks')).decks.some(
decoded(await executeTool(page, "list_decks")).decks.some(
(deck: { id: string }) => deck.id === second.id,
),
).toBe(false)
expect(savedCount()).toEqual({ count: 0 })
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'New research',
)
})
).toBe(false);
expect(savedCount()).toEqual({ count: 0 });
await page.reload();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("New research");
});
test('rejects invalid input without changing the active view', async ({
page,
}) => {
await executeTool(page, 'set_deck', {
title: '残す調査',
columns: [column('残す観点', accounts.e2e ?? '')],
})
const duplicate = { ...column('不正', accounts.e2e ?? ''), id: 'duplicate' }
test("rejects invalid input without changing the active view", async ({ page }) => {
await executeTool(page, "set_deck", {
title: "残す調査",
columns: [column("残す観点", accounts.e2e ?? "")],
});
const duplicate = { ...column("不正", accounts.e2e ?? ""), id: "duplicate" };
for (const columns of [
[duplicate, duplicate],
[column('不明', 'missing')],
[column("不明", "missing")],
[
{
...column('不正', accounts.e2e ?? ''),
source: { kind: 'list', target: 'invalid' },
...column("不正", accounts.e2e ?? ""),
source: { kind: "list", target: "invalid" },
},
],
]) {
expect(
(await executeTool(page, 'set_deck', { title: '変更しない', columns }))
.isError,
).toBe(true)
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
expect((await executeTool(page, "set_deck", { title: "変更しない", columns })).isError).toBe(
true,
);
await expect(page.locator(".deck-column h2")).toHaveText(["残す観点"]);
}
expect(savedCount()).toEqual({ count: 0 })
})
expect(savedCount()).toEqual({ count: 0 });
});
test('keeps failed temporary saves visible and allows retry without duplicate decks', async ({
test("keeps failed temporary saves visible and allows retry without duplicate decks", async ({
page,
}) => {
const deck = decoded(
await executeTool(page, 'set_deck', { title: '未保存の調査', columns: [] }),
).deck
await page.route('**/_serverFn/**', (route) =>
route.request().method() === 'POST' ? route.abort() : route.continue(),
)
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).toBe(true)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'未保存の調査',
)
expect(decoded(await executeTool(page, 'get_deck')).deck.persisted).toBe(
false,
)
await page.unroute('**/_serverFn/**')
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).not.toBe(true)
expect(savedCount()).toEqual({ count: 1 })
})
await executeTool(page, "set_deck", { title: "未保存の調査", columns: [] }),
).deck;
await page.route("**/_serverFn/**", (route) =>
route.request().method() === "POST" ? route.abort() : route.continue(),
);
expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).toBe(true);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("未保存の調査");
expect(decoded(await executeTool(page, "get_deck")).deck.persisted).toBe(false);
await page.unroute("**/_serverFn/**");
expect((await executeTool(page, "save_deck", { deckId: deck.id })).isError).not.toBe(true);
expect(savedCount()).toEqual({ count: 1 });
});
test('reads and pages bound accounts without mixing sibling columns', async ({
page,
}) => {
test("reads and pages bound accounts without mixing sibling columns", async ({ page }) => {
const deck = decoded(
await executeTool(page, 'set_deck', {
title: '接続を比較',
columns: [
column('通常', accounts.e2e ?? ''),
column('別の接続', accounts.alternate ?? ''),
],
await executeTool(page, "set_deck", {
title: "接続を比較",
columns: [column("通常", accounts.e2e ?? ""), column("別の接続", accounts.alternate ?? "")],
}),
).deck
await expect(
page.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
const id = deck.columns[1].id
const loaded = decoded(
await executeTool(page, 'get_column_posts', { columnId: id }),
)
expect(loaded.column.connectionId).toBe(accounts.alternate)
).deck;
await expect(page.getByText("alternate · Latest · all page 1", { exact: true })).toBeVisible();
const id = deck.columns[1].id;
const loaded = decoded(await executeTool(page, "get_column_posts", { columnId: id }));
expect(loaded.column.connectionId).toBe(accounts.alternate);
expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
'alternate · Latest · all page 1',
])
const more = decoded(
await executeTool(page, 'load_more_column', { columnId: id }),
)
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
"alternate · Latest · all page 1",
]);
const more = decoded(await executeTool(page, "load_more_column", { columnId: id }));
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false });
expect(more.posts.map((post: { text: string }) => post.text)).toEqual([
'alternate · Latest · all page 2',
])
"alternate · Latest · all page 2",
]);
await expect(
page.getByRole('region', { name: '通常', exact: true }).locator('article'),
).toHaveCount(1)
await executeTool(page, 'set_deck', { title: '別の一時ビュー', columns: [] })
expect(
(await executeTool(page, 'get_column_posts', { columnId: id })).isError,
).toBe(true)
})
page.getByRole("region", { name: "通常", exact: true }).locator("article"),
).toHaveCount(1);
await executeTool(page, "set_deck", { title: "別の一時ビュー", columns: [] });
expect((await executeTool(page, "get_column_posts", { columnId: id })).isError).toBe(true);
});
+26 -32
View File
@@ -1,41 +1,35 @@
import { expect, test } from '../fixtures'
import { closeMobileSidebar, showResearch } from '../workspace'
import { expect, test } from "../fixtures";
import { closeMobileSidebar, showResearch } from "../workspace";
test('keeps the sidebar, composer and deck reachable at compact widths', async ({
test("keeps the sidebar, composer and deck reachable at compact widths", async ({
page,
a11y,
}, testInfo) => {
await page.goto('/deck')
await page.goto("/deck");
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 })
await showResearch(page)
const chat = page.getByRole('complementary', { name: 'Research chat' })
await expect(chat).toBeVisible()
const composer = chat.getByRole('textbox', {
name: 'Message',
await page.setViewportSize({ width, height: 850 });
await showResearch(page);
const chat = page.getByRole("complementary", { name: "Research chat" });
await expect(chat).toBeVisible();
const composer = chat.getByRole("textbox", {
name: "Message",
exact: true,
})
await expect(composer).toBeVisible()
const bounds = await composer.boundingBox()
if (!bounds) throw new Error('Composer must have a visible bounding box')
expect(bounds.x).toBeGreaterThanOrEqual(0)
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(850)
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
});
await expect(composer).toBeVisible();
const bounds = await composer.boundingBox();
if (!bounds) throw new Error("Composer must have a visible bounding box");
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(850);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
await page.screenshot({
path: testInfo.outputPath(`workspace-${width}.png`),
})
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
await closeMobileSidebar(page)
});
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]);
await closeMobileSidebar(page);
}
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
await expect(
page.getByRole('complementary', { name: 'Research chat' }),
).toBeHidden()
await page.getByRole('button', { name: 'Research chat', exact: true }).click()
await expect(
page.getByRole('complementary', { name: 'Research chat' }),
).toBeVisible()
})
await page.getByRole("button", { name: "Toggle sidebar" }).click();
await expect(page.getByRole("complementary", { name: "Research chat" })).toBeHidden();
await page.getByRole("button", { name: "Research chat", exact: true }).click();
await expect(page.getByRole("complementary", { name: "Research chat" })).toBeVisible();
});
+199 -226
View File
@@ -1,33 +1,32 @@
import { createServer } from 'node:http'
import { tweetLegacyFixture } from './fixtures/twitter.ts'
import { createServer } from "node:http";
import { tweetLegacyFixture } from "./fixtures/twitter.ts";
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT
const port = Number(portValue)
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT;
const port = Number(portValue);
if (!portValue || !Number.isInteger(port) || port < 1) {
throw new Error('TWITTER_LITE_MOCK_RELAY_PORT must be a valid port')
throw new Error("TWITTER_LITE_MOCK_RELAY_PORT must be a valid port");
}
const failedRetryQueries = new Set()
const failedRetryQueries = new Set();
const rootByTarget = new Map([
['1001', '1000'],
['2001', '2000'],
['3001', '3000'],
])
["1001", "1000"],
["2001", "2000"],
["3001", "3000"],
]);
const targetByRoot = new Map([
['1000', '1001'],
['2000', '2001'],
['3000', '3001'],
])
const failedThreadRoots = new Set()
["1000", "1001"],
["2000", "2001"],
["3000", "3001"],
]);
const failedThreadRoots = new Set();
const isRecord = (value) =>
value !== null && typeof value === 'object' && !Array.isArray(value)
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const tweetResult = (
id,
text,
username = 'yuta',
username = "yuta",
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
) => ({
rest_id: id,
@@ -39,14 +38,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: {
@@ -55,149 +54,140 @@ const tweet = (id, text, username = 'yuta', options) => ({
},
},
},
})
});
const cursor = (value) => ({
entryId: 'cursor-bottom',
content: { cursorType: 'Bottom', value },
})
entryId: "cursor-bottom",
content: { cursorType: "Bottom", value },
});
const send = (response, payload, status = 200, headers = {}) => {
response.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
"content-type": "application/json; charset=utf-8",
...headers,
})
response.end(JSON.stringify(payload))
}
});
response.end(JSON.stringify(payload));
};
const fail = (response, status, message, headers) =>
send(response, { errors: [{ message }] }, status, headers)
send(response, { errors: [{ message }] }, status, headers);
const variablesFrom = (url) => {
const encoded = url.searchParams.get('variables')
if (!encoded) throw new Error('variables are required')
const variables = JSON.parse(encoded)
if (!isRecord(variables)) throw new Error('variables must be an object')
return variables
}
const encoded = url.searchParams.get("variables");
if (!encoded) throw new Error("variables are required");
const variables = JSON.parse(encoded);
if (!isRecord(variables)) throw new Error("variables must be an object");
return variables;
};
const jsonBodyFrom = async (request) => {
let body = ''
let body = "";
for await (const chunk of request) {
body += chunk
if (body.length > 1_000_000) throw new Error('request body is too large')
body += chunk;
if (body.length > 1_000_000) throw new Error("request body is too large");
}
const parsed = JSON.parse(body)
if (!isRecord(parsed)) throw new Error('JSON body must be an object')
return parsed
}
const parsed = JSON.parse(body);
if (!isRecord(parsed)) throw new Error("JSON body must be an object");
return parsed;
};
const timeline = (entries) => ({ instructions: [{ entries }] })
const timeline = (entries) => ({ instructions: [{ entries }] });
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
if (request.method === 'GET' && url.pathname === '/profiles') {
send(response, { profiles: ['e2e', 'alternate'] })
return
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
if (request.method === "GET" && url.pathname === "/profiles") {
send(response, { profiles: ["e2e", "alternate"] });
return;
}
const operation = [
'UserByScreenName',
'UserTweets',
'TweetDetail',
'SearchTimeline',
'ListsManagementPageTimeline',
'ListLatestTweetsTimeline',
].find((name) => url.pathname.endsWith(`/${name}`))
"UserByScreenName",
"UserTweets",
"TweetDetail",
"SearchTimeline",
"ListsManagementPageTimeline",
"ListLatestTweetsTimeline",
].find((name) => url.pathname.endsWith(`/${name}`));
if (!operation) {
fail(response, 501, 'unsupported read operation')
return
fail(response, 501, "unsupported read operation");
return;
}
const expectedMethod = operation === 'SearchTimeline' ? 'POST' : 'GET'
const expectedMethod = operation === "SearchTimeline" ? "POST" : "GET";
if (request.method !== expectedMethod) {
fail(response, 405, `expected ${expectedMethod}`, {
allow: expectedMethod,
})
return
});
return;
}
const profileName = request.headers['x-profile-name']
if (profileName !== 'e2e' && profileName !== 'alternate') {
fail(response, 403, 'invalid profile')
return
const profileName = request.headers["x-profile-name"];
if (profileName !== "e2e" && profileName !== "alternate") {
fail(response, 403, "invalid profile");
return;
}
let variables
let variables;
try {
variables = variablesFrom(url)
variables = variablesFrom(url);
} catch {
fail(response, 400, 'malformed variables')
return
fail(response, 400, "malformed variables");
return;
}
if (operation === 'UserByScreenName') {
if (
typeof variables.screen_name !== 'string' ||
variables.screen_name.length === 0
) {
fail(response, 400, 'screen_name is required')
return
if (operation === "UserByScreenName") {
if (typeof variables.screen_name !== "string" || variables.screen_name.length === 0) {
fail(response, 400, "screen_name is required");
return;
}
send(response, {
data: {
user: {
result: {
rest_id: '42',
legacy: { screen_name: variables.screen_name, name: 'Yuta' },
rest_id: "42",
legacy: { screen_name: variables.screen_name, name: "Yuta" },
},
},
},
})
return
});
return;
}
if (operation === 'UserTweets') {
const expectedCursor =
variables.cursor === undefined || variables.cursor === 'user-next'
if (
variables.userId !== '42' ||
variables.count !== 20 ||
!expectedCursor
) {
fail(response, 400, 'invalid user timeline variables')
return
if (operation === "UserTweets") {
const expectedCursor = variables.cursor === undefined || variables.cursor === "user-next";
if (variables.userId !== "42" || variables.count !== 20 || !expectedCursor) {
fail(response, 400, "invalid user timeline variables");
return;
}
const secondPage = variables.cursor === 'user-next'
const firstPageText =
profileName === 'alternate' ? 'alternate profile page 1' : 'user page 1'
const secondPage = variables.cursor === "user-next";
const firstPageText = profileName === "alternate" ? "alternate profile page 1" : "user page 1";
const entries = secondPage
? [tweet('1010', 'user page 2')]
? [tweet("1010", "user page 2")]
: [
tweet('1001', firstPageText, 'yuta', {
conversationId: '1000',
tweet("1001", firstPageText, "yuta", {
conversationId: "1000",
}),
cursor('user-next'),
]
cursor("user-next"),
];
send(response, {
data: {
user: { result: { timeline: { timeline: timeline(entries) } } },
},
})
return
});
return;
}
if (operation === 'ListsManagementPageTimeline') {
if (operation === "ListsManagementPageTimeline") {
if (variables.count !== 100) {
fail(response, 400, 'invalid lists management variables')
return
fail(response, 400, "invalid lists management variables");
return;
}
const features = url.searchParams.get('features')
const features = url.searchParams.get("features");
try {
if (!features || !isRecord(JSON.parse(features))) throw new Error()
if (!features || !isRecord(JSON.parse(features))) throw new Error();
} catch {
fail(response, 400, 'invalid lists management feature lock')
return
fail(response, 400, "invalid lists management feature lock");
return;
}
send(response, {
data: {
@@ -210,28 +200,26 @@ const server = createServer(async (request, response) => {
{
content: {
clientEventInfo: {
component: 'suggest_owned_subscribed_list',
component: "suggest_owned_subscribed_list",
},
items: [
{
item: {
itemContent: {
list: {
id_str: '1234567890',
id_str: "1234567890",
name:
profileName === 'alternate'
? 'Alternate List'
: 'My E2E List',
description: 'Accounts selected for testing',
profileName === "alternate" ? "Alternate List" : "My E2E List",
description: "Accounts selected for testing",
member_count: 10,
mode: 'Public',
mode: "Public",
created_at: 1_704_067_200_000,
user_results: {
result: {
rest_id: '42',
rest_id: "42",
core: {
screen_name: 'yuta',
name: 'Yuta',
screen_name: "yuta",
name: "Yuta",
},
},
},
@@ -249,66 +237,65 @@ const server = createServer(async (request, response) => {
},
},
},
errors: [{ message: 'optional banner field failed' }],
})
return
errors: [{ message: "optional banner field failed" }],
});
return;
}
if (operation === 'ListLatestTweetsTimeline') {
const expectedCursor =
variables.cursor === undefined || variables.cursor === 'list-next'
if (operation === "ListLatestTweetsTimeline") {
const expectedCursor = variables.cursor === undefined || variables.cursor === "list-next";
if (
typeof variables.listId !== 'string' ||
typeof variables.listId !== "string" ||
!/^\d+$/.test(variables.listId) ||
variables.count !== 20 ||
!expectedCursor
) {
fail(response, 400, 'invalid list timeline variables')
return
fail(response, 400, "invalid list timeline variables");
return;
}
const secondPage = variables.cursor === 'list-next'
const secondPage = variables.cursor === "list-next";
const entries = secondPage
? [tweet('3010', 'list page 2', 'listed')]
: [tweet('3001', 'list page 1', 'listed'), cursor('list-next')]
? [tweet("3010", "list page 2", "listed")]
: [tweet("3001", "list page 1", "listed"), cursor("list-next")];
send(response, {
data: {
list: { tweets_timeline: { timeline: timeline(entries) } },
},
})
return
});
return;
}
if (operation === 'TweetDetail') {
const focalTweetId = variables.focalTweetId
if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) {
fail(response, 400, 'invalid focalTweetId')
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')
const features = url.searchParams.get("features");
const fieldToggles = url.searchParams.get("fieldToggles");
try {
if (!features || !isRecord(JSON.parse(features))) throw new Error()
if (!features || !isRecord(JSON.parse(features))) throw new Error();
if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) {
throw new Error()
throw new Error();
}
} catch {
fail(response, 400, 'invalid TweetDetail feature locks')
return
fail(response, 400, "invalid TweetDetail feature locks");
return;
}
const targetRoot = rootByTarget.get(focalTweetId)
const targetRoot = rootByTarget.get(focalTweetId);
if (targetRoot && variables.cursor === undefined) {
const noReplies = focalTweetId === '3001'
const noReplies = focalTweetId === "3001";
const text = noReplies
? 'no replies'
: focalTweetId === '1001'
? 'user page 1'
: 'retry focal'
const result = tweetResult(focalTweetId, text, 'focus', {
? "no replies"
: focalTweetId === "1001"
? "user page 1"
: "retry focal";
const result = tweetResult(focalTweetId, text, "focus", {
conversationId: targetRoot,
inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
})
});
send(response, {
data: {
tweetResult: { result },
@@ -316,7 +303,7 @@ const server = createServer(async (request, response) => {
instructions: [
{
entries: [
tweet(focalTweetId, text, 'focus', {
tweet(focalTweetId, text, "focus", {
conversationId: targetRoot,
inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
@@ -326,47 +313,43 @@ const server = createServer(async (request, response) => {
],
},
},
})
return
});
return;
}
const targetId = targetByRoot.get(focalTweetId)
const targetId = targetByRoot.get(focalTweetId);
if (!targetId) {
fail(response, 400, 'unsupported TweetDetail fixture')
return
fail(response, 400, "unsupported TweetDetail fixture");
return;
}
const expectedCursor = `thread-next:${focalTweetId}`
const expectedCursor = `thread-next:${focalTweetId}`;
if (variables.cursor !== undefined && variables.cursor !== expectedCursor) {
fail(response, 400, 'invalid thread cursor')
return
fail(response, 400, "invalid thread cursor");
return;
}
if (variables.cursor && focalTweetId === '3000') {
fail(response, 500, 'zero-reply thread must not request a continuation')
return
if (variables.cursor && focalTweetId === "3000") {
fail(response, 500, "zero-reply thread must not request a continuation");
return;
}
if (
variables.cursor &&
focalTweetId === '2000' &&
!failedThreadRoots.has(focalTweetId)
) {
failedThreadRoots.add(focalTweetId)
fail(response, 503, 'transient thread fixture failure')
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', {
tweet(`${focalTweetId}3`, "thread page 2", "reply2", {
conversationId: focalTweetId,
inReplyTo: targetId,
}),
]
: focalTweetId === '3000'
: focalTweetId === "3000"
? [
tweet(focalTweetId, 'thread root', 'root', {
tweet(focalTweetId, "thread root", "root", {
conversationId: focalTweetId,
}),
tweet(targetId, 'no replies', 'focus', {
tweet(targetId, "no replies", "focus", {
conversationId: focalTweetId,
inReplyTo: focalTweetId,
replyCount: 0,
@@ -374,28 +357,23 @@ const server = createServer(async (request, response) => {
cursor(expectedCursor),
]
: [
tweet(focalTweetId, 'thread root', 'root', {
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', {
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', {
result: tweetResult(focalTweetId, "thread root", "root", {
conversationId: focalTweetId,
}),
},
@@ -403,67 +381,62 @@ const server = createServer(async (request, response) => {
instructions: [{ entries }],
},
},
})
if (variables.cursor && focalTweetId === '2000') {
failedThreadRoots.delete(focalTweetId)
});
if (variables.cursor && focalTweetId === "2000") {
failedThreadRoots.delete(focalTweetId);
}
return
return;
}
const contentType = request.headers['content-type'] ?? ''
const contentType = request.headers["content-type"] ?? "";
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
fail(response, 400, 'SearchTimeline requires JSON')
return
fail(response, 400, "SearchTimeline requires JSON");
return;
}
let body
let body;
try {
body = await jsonBodyFrom(request)
body = await jsonBodyFrom(request);
} catch {
fail(response, 400, 'malformed JSON body')
return
fail(response, 400, "malformed JSON body");
return;
}
const rawQuery = variables.rawQuery
const rawQuery = variables.rawQuery;
const expectedCursor =
variables.cursor === undefined ||
variables.cursor === `search-next:${rawQuery}`
variables.cursor === undefined || variables.cursor === `search-next:${rawQuery}`;
if (
typeof rawQuery !== 'string' ||
typeof rawQuery !== "string" ||
rawQuery.length === 0 ||
variables.count !== 20 ||
variables.querySource !== 'typed_query' ||
!['Top', 'Latest'].includes(variables.product) ||
variables.querySource !== "typed_query" ||
!["Top", "Latest"].includes(variables.product) ||
!expectedCursor ||
!isRecord(body.features) ||
typeof body.queryId !== 'string' ||
typeof body.queryId !== "string" ||
body.queryId.length === 0 ||
Object.hasOwn(body, 'variables')
Object.hasOwn(body, "variables")
) {
fail(response, 400, 'invalid search request')
return
fail(response, 400, "invalid search request");
return;
}
const secondPage = variables.cursor !== undefined
if (
secondPage &&
rawQuery.startsWith('retry-') &&
!failedRetryQueries.has(rawQuery)
) {
failedRetryQueries.add(rawQuery)
fail(response, 503, 'transient fixture failure')
return
const secondPage = variables.cursor !== undefined;
if (secondPage && rawQuery.startsWith("retry-") && !failedRetryQueries.has(rawQuery)) {
failedRetryQueries.add(rawQuery);
fail(response, 503, "transient fixture failure");
return;
}
if (secondPage && rawQuery.startsWith('slow-')) {
await new Promise((resolve) => setTimeout(resolve, 1_200))
if (secondPage && rawQuery.startsWith("slow-")) {
await new Promise((resolve) => setTimeout(resolve, 1_200));
}
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all'
const label = `${profileName === 'alternate' ? 'alternate · ' : ''}${variables.product} · ${follows}`
const id = encodeURIComponent(rawQuery)
const follows = rawQuery.includes("filter:follows") ? "follows" : "all";
const label = `${profileName === "alternate" ? "alternate · " : ""}${variables.product} · ${follows}`;
const id = encodeURIComponent(rawQuery);
const entries = secondPage
? [tweet(`s-${id}-2`, `${label} page 2`)]
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)]
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)];
send(response, {
data: {
@@ -471,7 +444,7 @@ const server = createServer(async (request, response) => {
search_timeline: { timeline: timeline(entries) },
},
},
})
})
});
});
server.listen(port, '127.0.0.1')
server.listen(port, "127.0.0.1");
+16 -22
View File
@@ -1,39 +1,33 @@
import { expect, type Page } from '@playwright/test'
import { expect, type Page } from "@playwright/test";
export async function showDeckManagement(page: Page) {
await expect(
page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled()
const navigation = page.getByRole('button', {
name: 'Deck manager',
await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
const navigation = page.getByRole("button", {
name: "Deck manager",
exact: true,
})
});
if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
await page.getByRole("button", { name: "Toggle sidebar" }).click();
}
await navigation.click()
await navigation.click();
}
export async function showResearch(page: Page) {
await expect(
page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled()
const navigation = page.getByRole('button', {
name: 'Research chat',
await expect(page.getByRole("button", { name: "Rename", includeHidden: true })).toBeEnabled();
const navigation = page.getByRole("button", {
name: "Research chat",
exact: true,
})
});
if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
await page.getByRole("button", { name: "Toggle sidebar" }).click();
}
await navigation.click()
await navigation.click();
}
export async function closeMobileSidebar(page: Page) {
const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]')
const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]');
if (await sheet.isVisible()) {
await page
.getByRole('button', { name: 'Close sidebar', exact: true })
.click()
await expect(sheet).toBeHidden()
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
await expect(sheet).toBeHidden();
}
}
+1 -1
View File
@@ -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; [
+3 -3
View File
@@ -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;
+30 -33
View File
@@ -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<string>()
const foundOperationIds = new Set<string>();
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",
},
},
})
});
+27 -26
View File
@@ -2,56 +2,41 @@
"name": "twitter-lite",
"private": true,
"type": "module",
"packageManager": "[email protected]",
"engines": {
"node": ">=22.12.0"
},
"knip": {
"ignoreBinaries": [
"codex"
],
"ignore": [
"e2e/generated/**"
],
"ignoreIssues": {
"src/components/ui/**": [
"exports"
]
}
},
"imports": {
"#/*": "./src/*"
},
"scripts": {
"codex:serve": "node scripts/serve-codex.mjs",
"db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && biome format --write src/features/storage/migrations.generated.ts",
"db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && oxfmt --write src/features/storage/migrations.generated.ts",
"db:backup": "tsx scripts/backup-database.ts",
"dev": "vite dev --host 127.0.0.1 --port 3000",
"dev:tailscale": "vite dev --host 127.0.0.1 --port 3000",
"generate:e2e-openapi": "orval --config orval.config.ts --formatter biome --fail-on-warnings",
"generate:e2e-openapi": "orval --config orval.config.ts --fail-on-warnings && oxfmt --write e2e/generated",
"generate-routes": "node scripts/generate-routes.mjs",
"check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts",
"build": "vite build && node scripts/build-tools.mjs",
"start": "HOST=127.0.0.1 node .output/server/index.mjs",
"typecheck": "tsc --noEmit",
"lint": "biome check . && pnpm lint:ui",
"lint": "oxlint",
"knip": "knip",
"format": "biome check --write .",
"format": "oxfmt --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts",
"lint:ui": "oxlint src",
"account:setup": "node --env-file-if-exists=.env.local --import tsx scripts/account-setup.ts"
"account:setup": "node --env-file-if-exists=.env.local --import tsx scripts/account-setup.ts",
"format:check": "oxfmt --check ."
},
"dependencies": {
"@base-ui/react": "1.8.0",
"@shadcn/react": "^0.3.1",
"@tanstack/react-pacer": "^0.23.0",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-router": "1.170.17",
"@tanstack/react-router-ssr-query": "1.167.1",
"@tanstack/react-start": "1.168.27",
"@yuta/bird": "0.10.1",
"ai": "7.0.118",
"ai-sdk-provider-codex-cli": "2.3.0",
"better-sqlite3": "13.0.3",
"class-variance-authority": "0.7.1",
"cn": "^0.4.0",
@@ -60,18 +45,18 @@
"nitro": "3.0.260610-beta",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"sanitize-html": "2.17.7",
"shadcn": "4.21.0",
"streamdown": "^2.6.0",
"tw-animate-css": "1.4.0",
"usewebmcp": "5.1.0",
"zod": "4.4.3"
},
"devDependencies": {
"@axe-core/playwright": "4.11.1",
"@biomejs/biome": "2.5.3",
"@playwright/test": "1.61.1",
"@shadcn/helpers": "0.2.0",
"@shadcn/lint": "^0.2.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "10.4.1",
@@ -87,11 +72,27 @@
"jsdom": "29.1.1",
"knip": "6.27.0",
"orval": "8.24.0",
"oxfmt": "^0.70.0",
"oxlint": "^1.85.0",
"oxlint-tsgolint": "^7.0.2003",
"tailwindcss": "^4.3.3",
"tsx": "4.23.12",
"typescript": "7.0.2",
"vite": "8.1.4",
"vitest": "4.1.10"
},
"engines": {
"node": ">=22.12.0"
},
"packageManager": "[email protected]",
"knip": {
"ignore": [
"e2e/generated/**"
],
"ignoreIssues": {
"src/components/ui/**": [
"exports"
]
}
}
}
+19 -19
View File
@@ -1,32 +1,32 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineConfig, devices } from '@playwright/test'
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
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;
const databasePath =
process.env.TWITTER_LITE_E2E_DB_PATH ??
join(mkdtempSync(join(tmpdir(), 'twitter-lite-e2e-')), 'workspace.sqlite')
process.env.TWITTER_LITE_E2E_DB_PATH = databasePath
join(mkdtempSync(join(tmpdir(), "twitter-lite-e2e-")), "workspace.sqlite");
process.env.TWITTER_LITE_E2E_DB_PATH = databasePath;
if (!chromiumExecutable) {
throw new Error(
'PLAYWRIGHT_CHROMIUM_EXECUTABLE is required; run E2E tests with `nix develop -c pnpm test:e2e`.',
)
"PLAYWRIGHT_CHROMIUM_EXECUTABLE is required; run E2E tests with `nix develop -c pnpm test:e2e`.",
);
}
export default defineConfig({
testDir: './e2e',
testDir: "./e2e",
fullyParallel: false,
workers: 1,
use: {
baseURL: `http://127.0.0.1:${appPort}`,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
launchOptions: { executablePath: chromiumExecutable },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: [
{
@@ -36,14 +36,14 @@ export default defineConfig({
timeout: 120_000,
},
{
command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_URL= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} [email protected] TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
command: `WORKSPACE_SETUP_TOKEN=isolated-e2e-setup-token-not-for-production TWITTER_LITE_CODEX_PATH= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} [email protected] TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
port: appPort,
reuseExistingServer: false,
timeout: 120_000,
},
],
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"] } },
],
})
});
+706 -124
View File
File diff suppressed because it is too large Load Diff
+5 -9
View File
@@ -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());
}
+7 -9
View File
@@ -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}`);
}
+5 -5
View File
@@ -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"] },
},
})
});
}
+6 -6
View File
@@ -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`,
)
);
+4 -4
View File
@@ -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();
-94
View File
@@ -1,94 +0,0 @@
import { spawn, spawnSync } from 'node:child_process'
import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
// This process is independent of Vite / the web backend. Keep it running while
// restarting the app. Codex owns its own login and refreshes that login normally.
const reportRoot = resolve(
process.env.TWITTER_LITE_REPORT_ROOT || '.data/research',
)
const endpoint = new URL(
process.env.TWITTER_LITE_CODEX_URL || 'ws://127.0.0.1:4500',
)
if (
endpoint.protocol !== 'ws:' ||
endpoint.hostname !== '127.0.0.1' ||
endpoint.username ||
endpoint.password
) {
throw new Error('The prototype Codex endpoint must be ws://127.0.0.1:<port>.')
}
await mkdir(reportRoot, { recursive: true, mode: 0o700 })
const env = {
HOME: homedir(),
PATH: process.env.PATH,
CODEX_HOME: process.env.CODEX_HOME || resolve(homedir(), '.codex'),
}
for (const name of [
'LANG',
'LC_ALL',
'SSL_CERT_FILE',
'SSL_CERT_DIR',
'NIX_SSL_CERT_FILE',
'TMPDIR',
]) {
if (process.env[name]) env[name] = process.env[name]
}
// Enumerate the effective config, including CLI-wrapper and project settings.
// Keep this output private: MCP transport settings can contain credentials.
const configured = spawnSync('codex', ['mcp', 'list', '--json'], {
cwd: reportRoot,
env,
encoding: 'utf8',
timeout: 15_000,
maxBuffer: 4 * 1024 * 1024,
})
let mcpOverrides
try {
if (configured.error || configured.status !== 0) throw new Error()
const servers = JSON.parse(configured.stdout)
if (!Array.isArray(servers)) throw new Error()
mcpOverrides = servers.flatMap((server) => {
if (
typeof server?.name !== 'string' ||
!/^[A-Za-z0-9_-]+$/.test(server.name)
)
throw new Error()
return ['-c', `mcp_servers.${server.name}.enabled=false`]
})
} catch {
throw new Error(
'Could not inspect Codex MCP configuration safely. Check codex mcp list locally.',
)
}
const child = spawn(
'codex',
[
// Keep overrides before the subcommand so they merge with wrapper settings.
'-c',
'shell_environment_policy.inherit="none"',
'-c',
'features.multi_agent=false',
'-c',
'features.hooks=false',
'-c',
'web_search="disabled"',
...mcpOverrides,
'app-server',
'--listen',
endpoint.origin,
],
{ cwd: reportRoot, env, stdio: 'inherit' },
)
child.on('error', () => {
console.error(
'Could not start Codex. Install codex and run codex login first.',
)
process.exitCode = 1
})
child.on('exit', (code) => {
process.exitCode = code ?? 1
})
for (const signal of ['SIGINT', 'SIGTERM'])
process.on(signal, () => child.kill(signal))
+75
View File
@@ -0,0 +1,75 @@
:root {
--canvas: var(--color-canvas);
--surface: var(--color-paper-2);
--paper: var(--color-paper);
--ink: var(--color-ink);
--accent-hover: var(--color-accent-hover);
--control-border: var(--color-control-rule);
--error: var(--color-error);
color: var(--ink);
background: var(--canvas);
color-scheme: dark;
font-family: var(--font-body);
font-size: 14px;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html,
body {
min-width: 320px;
overflow-x: clip;
background: var(--canvas);
}
body {
margin: 0;
}
button,
input,
select {
font: inherit;
}
a {
color: var(--primary);
text-underline-offset: 0.18em;
}
a:active {
color: var(--accent-hover);
}
:focus-visible {
outline: var(--rule-active) solid var(--color-focus);
outline-offset: 2px;
}
h1 {
margin: 0;
min-width: 0;
font-family: var(--font-display);
font-size: var(--text-display-s);
font-weight: 700;
line-height: 1.3;
overflow-wrap: anywhere;
}
.handle {
display: block;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.workspace .reader {
overflow: hidden;
}
.sidebar-note {
color: var(--muted-foreground);
font-size: var(--text-xs);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* Respect reduced motion over registry component animation utilities. */
animation-duration: 0.01ms !important;
/* Respect reduced motion over registry component transition utilities. */
transition-duration: 0.01ms !important;
}
}
+30 -51
View File
@@ -1,5 +1,5 @@
import { Columns3, MessageSquare, PanelLeftClose } from 'lucide-react'
import { type CSSProperties, type ReactNode, useState } from 'react'
import { Columns3, MessageSquare, PanelLeftClose } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from "react";
import {
Sidebar,
SidebarInset,
@@ -8,16 +8,16 @@ import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
} from "#/components/ui/sidebar";
import { TooltipProvider } from "#/components/ui/tooltip";
import { WorkspaceNavigation } from "#/components/workspace-navigation";
type ShellProps = {
hasTemporaryDecks: boolean
sidebar: ReactNode
researchChat: (closeMobileSidebar: () => void) => ReactNode
children: ReactNode
}
hasTemporaryDecks: boolean;
sidebar: ReactNode;
researchChat: (closeMobileSidebar: () => void) => ReactNode;
children: ReactNode;
};
export function AppShell(props: ShellProps) {
return (
@@ -26,29 +26,24 @@ export function AppShell(props: ShellProps) {
className="workspace h-dvh min-h-0 overflow-hidden"
style={
{
'--sidebar-width': '25rem',
'--sidebar-width-icon': '4rem',
"--sidebar-width": "25rem",
"--sidebar-width-icon": "4rem",
} as CSSProperties
}
>
<Workspace {...props} />
</SidebarProvider>
</TooltipProvider>
)
);
}
function Workspace({
sidebar,
researchChat,
children,
hasTemporaryDecks,
}: ShellProps) {
const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
const { setOpen, setOpenMobile, isMobile } = useSidebar()
function Workspace({ sidebar, researchChat, children, hasTemporaryDecks }: ShellProps) {
const [panel, setPanel] = useState<"chat" | "decks">("chat");
const { setOpen, setOpenMobile, isMobile } = useSidebar();
const navigation = [
{ id: 'chat', title: 'Research chat', icon: MessageSquare },
{ id: 'decks', title: 'Deck manager', icon: Columns3 },
] as const
{ id: "chat", title: "Research chat", icon: MessageSquare },
{ id: "decks", title: "Deck manager", icon: Columns3 },
] as const;
return (
<>
@@ -62,10 +57,8 @@ function Workspace({
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Close sidebar"
tooltip={{ children: 'Close sidebar', hidden: false }}
onClick={() =>
isMobile ? setOpenMobile(false) : setOpen(false)
}
tooltip={{ children: "Close sidebar", hidden: false }}
onClick={() => (isMobile ? setOpenMobile(false) : setOpen(false))}
className="size-10 justify-center p-0"
>
<PanelLeftClose />
@@ -80,9 +73,9 @@ function Workspace({
tooltip={{ children: item.title, hidden: false }}
isActive={panel === item.id}
onClick={() => {
setPanel(item.id)
if (isMobile) setOpenMobile(true)
else setOpen(true)
setPanel(item.id);
if (isMobile) setOpenMobile(true);
else setOpen(true);
}}
className="size-10 justify-center p-0"
>
@@ -92,23 +85,14 @@ function Workspace({
</SidebarMenuItem>
))}
</WorkspaceNavigation>
<Sidebar
collapsible="none"
className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"
>
<div
className={
panel === 'chat' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'
}
>
<Sidebar collapsible="none" className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<div className={panel === "chat" ? "flex min-h-0 flex-1 flex-col" : "hidden"}>
{researchChat(() => setOpenMobile(false))}
</div>
<section
aria-label="Deck manager"
className={
panel === 'decks'
? 'flex min-h-0 flex-1 flex-col overflow-y-auto p-4'
: 'hidden'
panel === "decks" ? "flex min-h-0 flex-1 flex-col overflow-y-auto p-4" : "hidden"
}
>
<h2 className="mb-4 text-base font-semibold">Decks</h2>
@@ -120,17 +104,12 @@ function Workspace({
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger aria-label="Toggle sidebar" />
<span className="text-sm font-medium">Research</span>
<span className="ml-auto text-xs text-muted-foreground">
Research workspace
</span>
<span className="ml-auto text-xs text-muted-foreground">Research workspace</span>
</header>
<section
className="reader flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="Decks"
>
<section className="reader flex min-h-0 min-w-0 flex-1 flex-col" aria-label="Decks">
{children}
</section>
</SidebarInset>
</>
)
);
}
+11 -11
View File
@@ -1,13 +1,13 @@
import { cn } from 'cn'
import { Icon } from './icon'
import { Button } from './ui/button'
import { cn } from "cn";
import { Icon } from "./icon";
import { Button } from "./ui/button";
import {
DialogClose,
DialogContent,
DialogHeader,
Dialog as DialogRoot,
DialogTitle,
} from './ui/dialog'
} from "./ui/dialog";
export function Dialog({
title,
@@ -15,21 +15,21 @@ export function Dialog({
children,
className,
}: {
title: string
className?: string
onClose: () => void
children: React.ReactNode
title: string;
className?: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<DialogRoot
open
onOpenChange={(open) => {
if (!open) onClose()
if (!open) onClose();
}}
>
<DialogContent
showCloseButton={false}
className={cn('max-h-[calc(100dvh-2rem)] overflow-y-auto', className)}
className={cn("max-h-[calc(100dvh-2rem)] overflow-y-auto", className)}
aria-describedby={undefined}
>
<DialogHeader>
@@ -50,5 +50,5 @@ export function Dialog({
{children}
</DialogContent>
</DialogRoot>
)
);
}
+14 -14
View File
@@ -1,17 +1,17 @@
const paths = {
columns: 'M3 4h7v16H3z M14 4h7v16h-7z',
hashtag: 'M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16',
search: 'M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',
user: 'M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0',
list: 'M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01',
plus: 'M12 5v14 M5 12h14',
close: 'M6 6l12 12 M18 6 6 18',
refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
arrow: 'M7 17 17 7 M7 7h10v10',
edit: 'm16 3 5 5-12 12H4v-5z M14 5l5 5',
trash: 'M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',
} as const
columns: "M3 4h7v16H3z M14 4h7v16h-7z",
hashtag: "M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16",
search: "M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0",
user: "M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0",
list: "M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01",
plus: "M12 5v14 M5 12h14",
close: "M6 6l12 12 M18 6 6 18",
refresh: "M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6",
more: "M5 12h.01 M12 12h.01 M19 12h.01",
arrow: "M7 17 17 7 M7 7h10v10",
edit: "m16 3 5 5-12 12H4v-5z M14 5l5 5",
trash: "M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7",
} as const;
export function Icon({ name }: { name: keyof typeof paths }) {
return (
@@ -28,5 +28,5 @@ export function Icon({ name }: { name: keyof typeof paths }) {
>
<path d={paths[name]} />
</svg>
)
);
}
+25 -30
View File
@@ -1,49 +1,44 @@
import type { ReactNode } from 'react'
import type { ReactNode } from "react";
const URL = /(https?:\/\/[^\s]+)/g
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
const URL = /(https?:\/\/[^\s]+)/g;
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi;
const NAMED_ENTITIES: Record<string, string> = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
"&amp;": "&",
"&apos;": "'",
"&gt;": ">",
"&lt;": "<",
"&quot;": '"',
};
function decodeHtmlEntities(text: string): string {
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
if (!decimal && !hexadecimal) {
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity;
}
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
})
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16);
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
});
}
function linkedText(text: string): ReactNode[] {
const decoded = decodeHtmlEntities(text)
const parts: ReactNode[] = []
let cursor = 0
const decoded = decodeHtmlEntities(text);
const parts: ReactNode[] = [];
let cursor = 0;
for (const match of decoded.matchAll(URL)) {
const href = match[0]
const start = match.index
if (start > cursor) parts.push(decoded.slice(cursor, start))
const href = match[0];
const start = match.index;
if (start > cursor) parts.push(decoded.slice(cursor, start));
parts.push(
<a
href={href}
key={`link-${start}`}
rel="noreferrer noopener"
target="_blank"
>
<a href={href} key={`link-${start}`} rel="noreferrer noopener" target="_blank">
{href}
</a>,
)
cursor = start + href.length
);
cursor = start + href.length;
}
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
return parts
if (cursor < decoded.length) parts.push(decoded.slice(cursor));
return parts;
}
export function PostText({ text }: { text: string }) {
return <>{linkedText(text)}</>
return <>{linkedText(text)}</>;
}
+25 -41
View File
@@ -1,106 +1,90 @@
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'
import { cn } from 'cn'
import type * as React from 'react'
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "cn";
import type * as React from "react";
function Avatar({
className,
size = 'default',
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg'
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
)
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
'aspect-square size-full rounded-full object-cover',
className,
)}
className={cn("aspect-square size-full rounded-full object-cover", className)}
{...props}
/>
)
);
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
)
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
)
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
)
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
)
);
}
export {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
}
export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
+20 -23
View File
@@ -1,40 +1,37 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary:
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
outline:
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost:
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function Badge({
className,
variant = 'default',
variant = "default",
render,
...props
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: 'span',
props: mergeProps<'span'>(
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
@@ -42,10 +39,10 @@ function Badge({
),
render,
state: {
slot: 'badge',
slot: "badge",
variant,
},
})
});
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+44 -48
View File
@@ -1,54 +1,54 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type * as React from "react";
function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn('flex min-w-0 flex-col gap-2', className)}
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
);
}
const bubbleVariants = cva(
'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full',
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
'*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
'*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
'*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
'*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
'*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50',
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
'*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function Bubble({
variant = 'default',
align = 'start',
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<'div'> &
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: 'start' | 'end'
align?: "start" | "end";
}) {
return (
<div
@@ -58,20 +58,16 @@ function Bubble({
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
);
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<'div'>) {
function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50',
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className,
),
},
@@ -79,39 +75,39 @@ function BubbleContent({
),
render,
state: {
slot: 'bubble-content',
slot: "bubble-content",
},
})
});
}
const bubbleReactionsVariants = cva(
'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0',
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: 'left-3',
end: 'right-3',
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
side: "bottom",
align: "end",
},
},
)
);
function BubbleReactions({
side = 'bottom',
align = 'end',
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<'div'> & {
align?: 'start' | 'end'
side?: 'top' | 'bottom'
}: React.ComponentProps<"div"> & {
align?: "start" | "end";
side?: "top" | "bottom";
}) {
return (
<div
@@ -121,7 +117,7 @@ function BubbleReactions({
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
);
}
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions }
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions };
+23 -23
View File
@@ -1,48 +1,48 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function Button({
className,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
@@ -51,7 +51,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+6 -6
View File
@@ -1,13 +1,13 @@
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
import { cn } from 'cn'
import { CheckIcon } from 'lucide-react'
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { cn } from "cn";
import { CheckIcon } from "lucide-react";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary',
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className,
)}
{...props}
@@ -19,7 +19,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };
+5 -9
View File
@@ -1,19 +1,15 @@
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
return <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
+30 -48
View File
@@ -1,39 +1,36 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import { cn } from 'cn'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "cn";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "#/components/ui/button.tsx";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 isolate z-50 bg-overlay duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
"fixed inset-0 isolate z-50 bg-overlay duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -42,7 +39,7 @@ function DialogContent({
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
@@ -50,7 +47,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
@@ -59,13 +56,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
@@ -73,17 +64,13 @@ function DialogContent({
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
);
}
function DialogFooter({
@@ -91,52 +78,47 @@ function DialogFooter({
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-base leading-none font-medium', className)}
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
)
);
}
export {
@@ -150,4 +132,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+40 -54
View File
@@ -1,34 +1,31 @@
'use client'
"use client";
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
import { cn } from 'cn'
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
import type * as React from 'react'
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "cn";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import type * as React from "react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = 'start',
align = "start",
alignOffset = 0,
side = 'bottom',
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
@@ -41,18 +38,18 @@ function DropdownMenuContent({
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
@@ -60,29 +57,29 @@ function DropdownMenuLabel({
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className,
)}
{...props}
/>
)
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: 'default' | 'destructive'
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
@@ -95,11 +92,11 @@ function DropdownMenuItem({
)}
{...props}
/>
)
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -108,7 +105,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
@@ -123,13 +120,13 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
);
}
function DropdownMenuSubContent({
align = 'start',
align = "start",
alignOffset = -3,
side = 'right',
side = "right",
sideOffset = 0,
className,
...props
@@ -138,7 +135,7 @@ function DropdownMenuSubContent({
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
align={align}
@@ -147,7 +144,7 @@ function DropdownMenuSubContent({
sideOffset={sideOffset}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -157,7 +154,7 @@ function DropdownMenuCheckboxItem({
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
@@ -180,16 +177,11 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
@@ -198,7 +190,7 @@ function DropdownMenuRadioItem({
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
@@ -220,36 +212,30 @@ function DropdownMenuRadioItem({
</span>
{children}
</MenuPrimitive.RadioItem>
)
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
)
);
}
export {
@@ -268,4 +254,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
};
+25 -32
View File
@@ -1,49 +1,49 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance',
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className,
)}
{...props}
/>
)
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn('flex max-w-sm flex-col items-center gap-2', className)}
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
);
}
const emptyMediaVariants = cva(
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: 'bg-transparent',
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: 'default',
variant: "default",
},
},
)
);
function EmptyMedia({
className,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
@@ -51,50 +51,43 @@ function EmptyMedia({
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn('text-sm font-medium tracking-tight', className)}
className={cn("text-sm font-medium tracking-tight", className)}
{...props}
/>
)
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
)
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance',
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className,
)}
{...props}
/>
)
);
}
export {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
}
export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
+51 -64
View File
@@ -1,24 +1,24 @@
'use client'
"use client";
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type * as React from "react";
import { Button } from '#/components/ui/button.tsx'
import { Input } from '#/components/ui/input.tsx'
import { Textarea } from '#/components/ui/textarea.tsx'
import { Button } from "#/components/ui/button.tsx";
import { Input } from "#/components/ui/input.tsx";
import { Textarea } from "#/components/ui/textarea.tsx";
function InputGroup({ className, ...props }: React.ComponentProps<'fieldset'>) {
function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="input-group"
className={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
)
);
}
const inputGroupAddonVariants = cva(
@@ -26,72 +26,65 @@ const inputGroupAddonVariants = cva(
{
variants: {
align: {
'inline-start':
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
'inline-end':
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
'block-start':
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
'block-end':
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: 'inline-start',
align: "inline-start",
},
},
)
);
function InputGroupAddon({
className,
align = 'inline-start',
align = "inline-start",
...props
}: React.ComponentProps<'fieldset'> &
VariantProps<typeof inputGroupAddonVariants>) {
}: React.ComponentProps<"fieldset"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: Pointer convenience only; the text control remains independently keyboard-focusable.
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- Pointer convenience only; the text control remains independently keyboard-focusable.
<fieldset
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
)
);
}
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
)
defaultVariants: {
size: "xs",
},
});
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: 'button' | 'submit' | 'reset'
type?: "button" | "submit" | "reset";
}) {
return (
<Button
@@ -101,10 +94,10 @@ function InputGroupButton({
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
@@ -113,39 +106,33 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
)}
{...props}
/>
)
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
)
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
)
);
}
export {
@@ -155,4 +142,4 @@ export {
InputGroupInput,
InputGroupText,
InputGroupTextarea,
}
};
+7 -7
View File
@@ -1,19 +1,19 @@
import { Input as InputPrimitive } from '@base-ui/react/input'
import { cn } from 'cn'
import type * as React from 'react'
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "cn";
import type * as React from "react";
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };
+8 -8
View File
@@ -1,20 +1,20 @@
'use client'
"use client";
import { cn } from 'cn'
import type * as React from 'react'
import { cn } from "cn";
import type * as React from "react";
function Label({ className, ...props }: React.ComponentProps<'label'>) {
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: Association and content are supplied by the caller through props.
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- Association and content are supplied by the caller through props.
<label
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
)
);
}
export { Label }
export { Label };
+22 -25
View File
@@ -1,32 +1,32 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import type * as React from "react";
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: '',
default: "",
separator:
'before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border',
border: 'border-b border-border pb-2',
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
},
)
);
function Marker({
className,
variant = 'default',
variant = "default",
render,
...props
}: useRender.ComponentProps<'div'> & VariantProps<typeof markerVariants>) {
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(markerVariants({ variant, className })),
},
@@ -34,37 +34,34 @@ function Marker({
),
render,
state: {
slot: 'marker',
slot: "marker",
variant,
},
})
});
}
function MarkerIcon({ className, ...props }: React.ComponentProps<'span'>) {
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
className={cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
/>
)
);
}
function MarkerContent({ className, ...props }: React.ComponentProps<'span'>) {
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
'min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
)
);
}
export { Marker, MarkerContent, MarkerIcon, markerVariants }
export { Marker, MarkerContent, MarkerIcon, markerVariants };
+23 -23
View File
@@ -1,20 +1,20 @@
'use client'
"use client";
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from '@shadcn/react/message-scroller'
import { cn } from 'cn'
import { ArrowDownIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
} from "@shadcn/react/message-scroller";
import { cn } from "cn";
import { ArrowDownIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "#/components/ui/button.tsx";
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
) {
return <MessageScrollerPrimitive.Provider {...props} />
return <MessageScrollerPrimitive.Provider {...props} />;
}
function MessageScroller({
@@ -25,12 +25,12 @@ function MessageScroller({
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden',
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className,
)}
{...props}
/>
)
);
}
function MessageScrollerViewport({
@@ -41,12 +41,12 @@ function MessageScrollerViewport({
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
'size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible',
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",
className,
)}
{...props}
/>
)
);
}
function MessageScrollerContent({
@@ -56,10 +56,10 @@ function MessageScrollerContent({
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn('flex h-max min-h-full flex-col gap-6', className)}
className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props}
/>
)
);
}
function MessageScrollerItem({
@@ -72,24 +72,24 @@ function MessageScrollerItem({
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]',
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className,
)}
{...props}
/>
)
);
}
function MessageScrollerButton({
direction = 'end',
direction = "end",
className,
children,
render,
variant = 'secondary',
size = 'icon-sm',
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
@@ -98,7 +98,7 @@ function MessageScrollerButton({
data-size={size}
direction={direction}
className={cn(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180',
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className,
)}
render={render ?? <Button variant={variant} size={size} />}
@@ -108,12 +108,12 @@ function MessageScrollerButton({
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === 'end' ? 'Scroll to end' : 'Scroll to start'}
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
);
}
export {
@@ -126,4 +126,4 @@ export {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}
};
+22 -29
View File
@@ -1,91 +1,84 @@
import { cn } from 'cn'
import type * as React from 'react'
import { cn } from "cn";
import type * as React from "react";
function MessageGroup({ className, ...props }: React.ComponentProps<'div'>) {
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn('flex min-w-0 flex-col gap-2', className)}
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
);
}
function Message({
className,
align = 'start',
align = "start",
...props
}: React.ComponentProps<'div'> & { align?: 'start' | 'end' }) {
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
'group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse',
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className,
)}
{...props}
/>
)
);
}
function MessageAvatar({ className, ...props }: React.ComponentProps<'div'>) {
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
'flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8',
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className,
)}
{...props}
/>
)
);
}
function MessageContent({ className, ...props }: React.ComponentProps<'div'>) {
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
'flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end',
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className,
)}
{...props}
/>
)
);
}
function MessageHeader({ className, ...props }: React.ComponentProps<'div'>) {
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0',
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className,
)}
{...props}
/>
)
);
}
function MessageFooter({ className, ...props }: React.ComponentProps<'div'>) {
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end',
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className,
)}
{...props}
/>
)
);
}
export {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
}
export { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader };
+16 -26
View File
@@ -1,20 +1,16 @@
import { cn } from 'cn'
import { ChevronDownIcon } from 'lucide-react'
import type * as React from 'react'
import { cn } from "cn";
import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
type NativeSelectProps = Omit<React.ComponentProps<'select'>, 'size'> & {
size?: 'sm' | 'default'
}
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default";
};
function NativeSelect({
className,
size = 'default',
...props
}: NativeSelectProps) {
function NativeSelect({ className, size = "default", ...props }: NativeSelectProps) {
return (
<div
className={cn(
'group/native-select relative w-fit has-[select:disabled]:opacity-50',
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className,
)}
data-slot="native-select-wrapper"
@@ -32,33 +28,27 @@ function NativeSelect({
data-slot="native-select-icon"
/>
</div>
)
);
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<'option'>) {
function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn('bg-[Canvas] text-[CanvasText]', className)}
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
);
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<'optgroup'>) {
function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn('bg-[Canvas] text-[CanvasText]', className)}
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
);
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
+6 -10
View File
@@ -1,22 +1,18 @@
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
import { cn } from 'cn'
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "cn";
function Separator({
className,
orientation = 'horizontal',
...props
}: SeparatorPrimitive.Props) {
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };
+30 -39
View File
@@ -1,23 +1,23 @@
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
import { cn } from 'cn'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { cn } from "cn";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "#/components/ui/button.tsx";
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
@@ -25,25 +25,25 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs',
"fixed inset-0 z-50 bg-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
)
);
}
function SheetContent({
className,
children,
side = 'right',
side = "right",
showCloseButton = true,
keepMounted = false,
...props
}: SheetPrimitive.Popup.Props & {
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
keepMounted?: boolean
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
keepMounted?: boolean;
}) {
return (
<SheetPortal keepMounted={keepMounted}>
@@ -52,7 +52,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
@@ -61,13 +61,7 @@ function SheetContent({
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
@@ -75,50 +69,47 @@ function SheetContent({
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-0.5 p-4', className)}
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
);
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-base font-medium text-foreground', className)}
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
);
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-sm text-muted-foreground', className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
export {
@@ -130,4 +121,4 @@ export {
SheetHeader,
SheetTitle,
SheetTrigger,
}
};
+205 -238
View File
@@ -1,51 +1,47 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { PanelLeftIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
import { Input } from '#/components/ui/input.tsx'
import { Separator } from '#/components/ui/separator.tsx'
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { Button } from "#/components/ui/button.tsx";
import { Input } from "#/components/ui/input.tsx";
import { Separator } from "#/components/ui/separator.tsx";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '#/components/ui/sheet.tsx'
import { Skeleton } from '#/components/ui/skeleton.tsx'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '#/components/ui/tooltip.tsx'
import { useIsMobile } from '#/hooks/use-mobile.ts'
} from "#/components/ui/sheet.tsx";
import { Skeleton } from "#/components/ui/skeleton.tsx";
import { Tooltip, TooltipContent, TooltipTrigger } from "#/components/ui/tooltip.tsx";
import { useIsMobile } from "#/hooks/use-mobile.ts";
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = 'min(25rem, 100vw)'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "min(25rem, 100vw)";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context
return context;
}
function SidebarProvider({
@@ -56,54 +52,51 @@ function SidebarProvider({
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
},
[setOpenProp, open],
)
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen])
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -116,7 +109,7 @@ function SidebarProvider({
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
)
);
return (
<SidebarContext.Provider value={contextValue}>
@@ -124,13 +117,13 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className,
)}
{...props}
@@ -138,37 +131,37 @@ function SidebarProvider({
{children}
</div>
</SidebarContext.Provider>
)
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
{...props}
>
{children}
</div>
)
);
}
if (isMobile) {
@@ -183,28 +176,26 @@ function Sidebar({
className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Workspace</SheetTitle>
<SheetDescription>
Research chat and deck management
</SheetDescription>
<SheetDescription>Research chat and deck management</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-row">{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
@@ -213,23 +204,23 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
@@ -243,15 +234,11 @@ function Sidebar({
</div>
</div>
</div>
)
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
@@ -261,19 +248,19 @@ function SidebarTrigger({
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
@@ -284,118 +271,112 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
)
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('h-8 w-full bg-background shadow-none', className)}
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
);
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) {
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
@@ -403,23 +384,23 @@ function SidebarGroupLabel({
),
render,
state: {
slot: 'sidebar-group-label',
sidebar: 'group-label',
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
});
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) {
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
@@ -427,87 +408,84 @@ function SidebarGroupAction({
),
render,
state: {
slot: 'sidebar-group-action',
sidebar: 'group-action',
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
});
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
className={cn("w-full text-sm", className)}
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-0', className)}
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
className={cn("group/menu-item relative", className)}
{...props}
/>
)
);
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]',
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function SidebarMenuButton({
render,
isActive = false,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const { isMobile, state } = useSidebar();
const comp = useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
@@ -515,21 +493,21 @@ function SidebarMenuButton({
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: 'sidebar-menu-button',
sidebar: 'menu-button',
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
});
if (!tooltip) {
return comp
return comp;
}
if (typeof tooltip === 'string') {
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
};
}
return (
@@ -538,11 +516,11 @@ function SidebarMenuButton({
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@@ -550,18 +528,18 @@ function SidebarMenuAction({
render,
showOnHover = false,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
showOnHover?: boolean
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
}) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className,
),
},
@@ -569,112 +547,101 @@ function SidebarMenuAction({
),
render,
state: {
slot: 'sidebar-menu-action',
sidebar: 'menu-action',
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
});
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground',
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
);
}
function SidebarMenuSubButton({
render,
size = 'md',
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<'a'> &
React.ComponentProps<'a'> & {
size?: 'sm' | 'md'
isActive?: boolean
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
}) {
return useRender({
defaultTagName: 'a',
props: mergeProps<'a'>(
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className,
),
},
@@ -682,12 +649,12 @@ function SidebarMenuSubButton({
),
render,
state: {
slot: 'sidebar-menu-sub-button',
sidebar: 'menu-sub-button',
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
});
}
export {
@@ -715,4 +682,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};
+5 -5
View File
@@ -1,13 +1,13 @@
import { cn } from 'cn'
import { cn } from "cn";
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-muted', className)}
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };
+6 -6
View File
@@ -1,17 +1,17 @@
import { cn } from 'cn'
import type * as React from 'react'
import { cn } from "cn";
import type * as React from "react";
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
)
);
}
export { Textarea }
export { Textarea };
+13 -25
View File
@@ -1,42 +1,30 @@
'use client'
"use client";
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
import { cn } from 'cn'
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "cn";
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = 'top',
side = "top",
sideOffset = 4,
align = 'center',
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
@@ -49,7 +37,7 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
@@ -59,7 +47,7 @@ function TooltipContent({
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+38 -48
View File
@@ -1,15 +1,7 @@
import { Link, useLocation, useRouteContext } from '@tanstack/react-router'
import {
Activity,
House,
Inbox,
Layers3,
MessagesSquare,
NotebookPen,
Search,
} from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
import { Link, useLocation, useRouteContext } from "@tanstack/react-router";
import { Activity, House, Inbox, Layers3, MessagesSquare, NotebookPen, Search } from "lucide-react";
import type { ReactNode } from "react";
import { Avatar, AvatarFallback } from "#/components/ui/avatar";
import {
Sidebar,
SidebarContent,
@@ -21,62 +13,62 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from '#/components/ui/sidebar'
import { ConnectionManagerDialog } from '#/features/connections/connection-manager-dialog'
} from "#/components/ui/sidebar";
import { ConnectionManagerDialog } from "#/features/connections/connection-manager-dialog";
import { useUrlState } from "#/features/navigation/use-url-state";
type WorkspaceNavigationProps = {
children?: ReactNode
footer?: ReactNode
hasTemporaryDecks?: boolean
}
children?: ReactNode;
footer?: ReactNode;
hasTemporaryDecks?: boolean;
};
export function WorkspaceNavigation({
children,
footer,
hasTemporaryDecks,
}: WorkspaceNavigationProps) {
const { auth } = useRouteContext({ from: '__root__' })
const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname })
const { auth } = useRouteContext({ from: "__root__" });
const [search, updateSearch] = useUrlState();
const managingAccounts = search.accounts;
const setManagingAccounts = (open: boolean) => updateSearch({ accounts: open || undefined });
const pathname = useLocation({ select: (location) => location.pathname });
const navigation = [
{ title: 'Home', to: '/', icon: House, active: pathname === '/' },
{ title: "Home", to: "/", icon: House, active: pathname === "/" },
{
title: 'Vitals',
to: '/vitals',
title: "Vitals",
to: "/vitals",
icon: Activity,
active: pathname === '/vitals',
active: pathname === "/vitals",
},
{
title: 'Messages',
to: '/support',
title: "Messages",
to: "/support",
icon: MessagesSquare,
active: pathname === '/support',
active: pathname === "/support",
},
{
title: 'Journal',
to: '/journal',
title: "Journal",
to: "/journal",
icon: NotebookPen,
active: pathname === '/journal',
active: pathname === "/journal",
},
{
title: 'Reader',
to: '/inbox',
title: "Reader",
to: "/inbox",
icon: Inbox,
active: pathname === '/inbox',
active: pathname === "/inbox",
},
{
title: 'Research',
to: '/deck',
title: "Research",
to: "/deck",
icon: Search,
active: pathname === '/deck',
active: pathname === "/deck",
},
] as const
] as const;
return (
<Sidebar
collapsible="none"
className="workspace-navigation w-16! shrink-0 border-r"
>
<Sidebar collapsible="none" className="w-16! shrink-0 border-r">
<SidebarHeader className="items-center py-4">
<div
className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
@@ -94,7 +86,7 @@ export function WorkspaceNavigation({
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
aria-label={item.title}
aria-current={item.active ? 'page' : undefined}
aria-current={item.active ? "page" : undefined}
tooltip={{ children: item.title, hidden: false }}
isActive={item.active}
render={<Link to={item.to} />}
@@ -125,14 +117,12 @@ export function WorkspaceNavigation({
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Manage connected accounts"
tooltip={{ children: 'Manage connected accounts', hidden: false }}
tooltip={{ children: "Manage connected accounts", hidden: false }}
className="size-10 justify-center p-0"
onClick={() => setManagingAccounts(true)}
>
<Avatar className="size-8">
<AvatarFallback>
{auth.owner?.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
<AvatarFallback>{auth.owner?.name.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -145,5 +135,5 @@ export function WorkspaceNavigation({
/>
)}
</Sidebar>
)
);
}
+24 -27
View File
@@ -1,45 +1,42 @@
type AccessConfig = { origin: string } & (
| { mode: 'tailscale'; allowedLogin: string }
| { mode: 'none' }
)
| { mode: "tailscale"; allowedLogin: string }
| { mode: "none" }
);
export function readAccessConfig(): AccessConfig | null {
const origin = process.env.TWITTER_LITE_ORIGIN
const mode = process.env.TWITTER_LITE_AUTH_MODE ?? 'tailscale'
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN
if (!origin || (mode !== 'tailscale' && mode !== 'none')) return null
const origin = process.env.TWITTER_LITE_ORIGIN;
const mode = process.env.TWITTER_LITE_AUTH_MODE ?? "tailscale";
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN;
if (!origin || (mode !== "tailscale" && mode !== "none")) return null;
try {
const url = new URL(origin)
const secure = url.protocol === 'https:'
const local = url.protocol === 'http:' && url.hostname === '127.0.0.1'
if ((!secure && !local) || url.origin !== origin) return null
if (mode === 'none') return { origin, mode }
if (!allowedLogin?.trim()) return null
return { origin, mode, allowedLogin }
const url = new URL(origin);
const secure = url.protocol === "https:";
const local = url.protocol === "http:" && url.hostname === "127.0.0.1";
if ((!secure && !local) || url.origin !== origin) return null;
if (mode === "none") return { origin, mode };
if (!allowedLogin?.trim()) return null;
return { origin, mode, allowedLogin };
} catch {
return null
return null;
}
}
/** Use loopback behind Serve for identity, or a private network for mode none. */
export function checkAccess(
request: Request,
config: AccessConfig | null,
): Response | null {
export function checkAccess(request: Request, config: AccessConfig | null): Response | null {
if (!config) {
return new Response('Access configuration is required.', { status: 503 })
return new Response("Access configuration is required.", { status: 503 });
}
if (
config.mode === 'tailscale' &&
request.headers.get('Tailscale-User-Login') !== config.allowedLogin
config.mode === "tailscale" &&
request.headers.get("Tailscale-User-Login") !== config.allowedLogin
) {
return new Response('Forbidden', { status: 403 })
return new Response("Forbidden", { status: 403 });
}
if (
!['GET', 'HEAD', 'OPTIONS'].includes(request.method) &&
request.headers.get('Origin') !== config.origin
!["GET", "HEAD", "OPTIONS"].includes(request.method) &&
request.headers.get("Origin") !== config.origin
) {
return new Response('Forbidden', { status: 403 })
return new Response("Forbidden", { status: 403 });
}
return null
return null;
}
+131 -155
View File
@@ -1,171 +1,147 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { checkAccess, readAccessConfig } from './policy.server'
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { checkAccess, readAccessConfig } from "./policy.server";
const config = {
mode: 'tailscale' as const,
origin: 'https://deck.invalid',
allowedLogin: '[email protected]',
}
mode: "tailscale" as const,
origin: "https://deck.invalid",
allowedLogin: "[email protected]",
};
beforeEach(() => vi.stubEnv('TWITTER_LITE_AUTH_MODE', undefined))
afterEach(() => vi.unstubAllEnvs())
beforeEach(() => vi.stubEnv("TWITTER_LITE_AUTH_MODE", undefined));
afterEach(() => vi.unstubAllEnvs());
describe('access boundary', () => {
it.each([
undefined,
'tailscale',
])('requires an owner in identity mode: %s', (mode) => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode)
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
expect(readAccessConfig()).toBeNull()
})
describe("access boundary", () => {
it.each([undefined, "tailscale"])("requires an owner in identity mode: %s", (mode) => {
vi.stubEnv("TWITTER_LITE_AUTH_MODE", mode);
vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
expect(readAccessConfig()).toBeNull();
});
it.each([
'',
'off',
'NONE',
])('fails closed for an unknown auth mode: %s', (mode) => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode)
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(
checkAccess(new Request(config.origin), readAccessConfig())?.status,
).toBe(503)
})
it.each(["", "off", "NONE"])("fails closed for an unknown auth mode: %s", (mode) => {
vi.stubEnv("TWITTER_LITE_AUTH_MODE", mode);
vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
expect(checkAccess(new Request(config.origin), readAccessConfig())?.status).toBe(503);
});
it('allows private-proxy navigation without an identity only when explicitly configured', () => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none')
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
const access = readAccessConfig()
expect(access).toEqual({ origin: config.origin, mode: 'none' })
expect(checkAccess(new Request(config.origin), access)).toBeNull()
})
it("allows private-proxy navigation without an identity only when explicitly configured", () => {
vi.stubEnv("TWITTER_LITE_AUTH_MODE", "none");
vi.stubEnv("TWITTER_LITE_ORIGIN", config.origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
const access = readAccessConfig();
expect(access).toEqual({ origin: config.origin, mode: "none" });
expect(checkAccess(new Request(config.origin), access)).toBeNull();
});
it('still requires a configured origin without identity authentication', () => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none')
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
expect(readAccessConfig()).toBeNull()
})
it("still requires a configured origin without identity authentication", () => {
vi.stubEnv("TWITTER_LITE_AUTH_MODE", "none");
vi.stubEnv("TWITTER_LITE_ORIGIN", "");
expect(readAccessConfig()).toBeNull();
});
it.each([
'POST',
'PUT',
'PATCH',
'DELETE',
])('requires exact Origin without identity authentication for %s', (method) => {
const access = { origin: config.origin, mode: 'none' as const }
expect(
checkAccess(new Request(config.origin, { method }), access)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: {
Origin: 'https://other.invalid',
'Sec-Fetch-Site': 'same-origin',
},
}),
access,
)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { Origin: config.origin },
}),
access,
),
).toBeNull()
})
it.each(["POST", "PUT", "PATCH", "DELETE"])(
"requires exact Origin without identity authentication for %s",
(method) => {
const access = { origin: config.origin, mode: "none" as const };
expect(checkAccess(new Request(config.origin, { method }), access)?.status).toBe(403);
expect(
checkAccess(
new Request(config.origin, {
method,
headers: {
Origin: "https://other.invalid",
"Sec-Fetch-Site": "same-origin",
},
}),
access,
)?.status,
).toBe(403);
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { Origin: config.origin },
}),
access,
),
).toBeNull();
},
);
it('fails closed when the deployment is not configured', () => {
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
expect(readAccessConfig()).toBeNull()
expect(checkAccess(new Request(config.origin), null)?.status).toBe(503)
})
it("fails closed when the deployment is not configured", () => {
vi.stubEnv("TWITTER_LITE_ORIGIN", "");
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", "");
expect(readAccessConfig()).toBeNull();
expect(checkAccess(new Request(config.origin), null)?.status).toBe(503);
});
it.each([
'',
'https://deck.invalid/path',
'http://deck.invalid',
'not a URL',
])('rejects an invalid configured origin: %s', (origin) => {
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(readAccessConfig()).toBeNull()
})
it.each(["", "https://deck.invalid/path", "http://deck.invalid", "not a URL"])(
"rejects an invalid configured origin: %s",
(origin) => {
vi.stubEnv("TWITTER_LITE_ORIGIN", origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
expect(readAccessConfig()).toBeNull();
},
);
it.each([
'https://deck.invalid',
'http://127.0.0.1:4173',
])('accepts an explicit deployment origin: %s', (origin) => {
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(readAccessConfig()).toEqual({ ...config, origin })
})
it.each(["https://deck.invalid", "http://127.0.0.1:4173"])(
"accepts an explicit deployment origin: %s",
(origin) => {
vi.stubEnv("TWITTER_LITE_ORIGIN", origin);
vi.stubEnv("TWITTER_LITE_ALLOWED_LOGIN", config.allowedLogin);
expect(readAccessConfig()).toEqual({ ...config, origin });
},
);
it.each([
undefined,
'[email protected]',
'[email protected], [email protected]',
])('rejects absent, foreign, or ambiguous identities: %s', (login) => {
const headers = new Headers()
if (login) headers.set('Tailscale-User-Login', login)
expect(
checkAccess(new Request(config.origin, { headers }), config)?.status,
).toBe(403)
})
it.each([undefined, "[email protected]", "[email protected], [email protected]"])(
"rejects absent, foreign, or ambiguous identities: %s",
(login) => {
const headers = new Headers();
if (login) headers.set("Tailscale-User-Login", login);
expect(checkAccess(new Request(config.origin, { headers }), config)?.status).toBe(403);
},
);
it('permits owner navigation back from an OAuth provider without Origin', () => {
const request = new Request(
`${config.origin}/oauth/mastodon/callback?code=code`,
{
headers: {
'Tailscale-User-Login': config.allowedLogin,
'Sec-Fetch-Site': 'cross-site',
},
it("permits owner navigation back from an OAuth provider without Origin", () => {
const request = new Request(`${config.origin}/oauth/mastodon/callback?code=code`, {
headers: {
"Tailscale-User-Login": config.allowedLogin,
"Sec-Fetch-Site": "cross-site",
},
)
expect(checkAccess(request, config)).toBeNull()
})
});
expect(checkAccess(request, config)).toBeNull();
});
it.each([
'POST',
'PUT',
'PATCH',
'DELETE',
])('requires exact Origin for %s even with same-origin Fetch Metadata', (method) => {
const headers = {
'Tailscale-User-Login': config.allowedLogin,
'Sec-Fetch-Site': 'same-origin',
}
expect(
checkAccess(new Request(config.origin, { method, headers }), config)
?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: 'https://other.invalid' },
}),
config,
)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: config.origin },
}),
config,
),
).toBeNull()
})
})
it.each(["POST", "PUT", "PATCH", "DELETE"])(
"requires exact Origin for %s even with same-origin Fetch Metadata",
(method) => {
const headers = {
"Tailscale-User-Login": config.allowedLogin,
"Sec-Fetch-Site": "same-origin",
};
expect(checkAccess(new Request(config.origin, { method, headers }), config)?.status).toBe(
403,
);
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: "https://other.invalid" },
}),
config,
)?.status,
).toBe(403);
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: config.origin },
}),
config,
),
).toBeNull();
},
);
});
+22 -22
View File
@@ -1,34 +1,34 @@
export type SafeOwner = {
id: number
email: string
name: string
onboardingCompletedAt: number | null
}
id: number;
email: string;
name: string;
onboardingCompletedAt: number | null;
};
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null }
export type AuthState = { needsSetup: boolean; owner: SafeOwner | null };
type AuthInput =
| { action: 'login'; email: string; password: string }
| { action: "login"; email: string; password: string }
| {
action: 'setup'
email: string
password: string
name: string
setupToken: string
action: "setup";
email: string;
password: string;
name: string;
setupToken: string;
}
| { action: 'logout' }
| { action: 'onboard'; name: string }
| { action: "logout" }
| { action: "onboard"; name: string };
export async function authRequest(input: AuthInput): Promise<AuthState> {
const response = await fetch('/api/auth', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
const response = await fetch("/api/auth", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
const result = await response.json()
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Something went wrong. Please try again.')
throw new Error(result.error || "Something went wrong. Please try again.");
}
return result
return result;
}
+51 -64
View File
@@ -1,10 +1,10 @@
import { ArrowRight, LockKeyhole } from 'lucide-react'
import { type FormEvent, type ReactNode, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { authRequest } from './auth-client'
import './auth.css'
import { ArrowRight, LockKeyhole } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { authRequest } from "./auth-client";
import { safeReturnTo } from "./return-to";
export function AuthFrame({ children }: { children: ReactNode }) {
return (
@@ -17,78 +17,70 @@ export function AuthFrame({ children }: { children: ReactNode }) {
{children}
</div>
</main>
)
);
}
export function LoginPage() {
return <CredentialsForm setup={false} />
return <CredentialsForm setup={false} />;
}
export function SetupPage() {
return <CredentialsForm setup />
return <CredentialsForm setup />;
}
function formText(data: FormData, name: string) {
const value = data.get(name);
return typeof value === "string" ? value : "";
}
function CredentialsForm({ setup }: { setup: boolean }) {
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (pending) return
const data = new FormData(event.currentTarget)
const email = String(data.get('email') ?? '').trim()
const password = String(data.get('password') ?? '')
if (setup && password !== data.get('confirmPassword')) {
setError('Passwords do not match.')
return
event.preventDefault();
if (pending) return;
const data = new FormData(event.currentTarget);
const email = formText(data, "email").trim();
const password = formText(data, "password");
if (setup && password !== data.get("confirmPassword")) {
setError("Passwords do not match.");
return;
}
setPending(true)
setError('')
setPending(true);
setError("");
try {
const state = await authRequest(
setup
? {
action: 'setup',
action: "setup",
email,
password,
name: String(data.get('name') ?? '').trim(),
setupToken: String(data.get('setupToken') ?? '').trim(),
name: formText(data, "name").trim(),
setupToken: formText(data, "setupToken").trim(),
}
: { action: 'login', email, password },
)
: { action: "login", email, password },
);
if (!state.owner?.onboardingCompletedAt) {
window.location.assign('/onboarding')
return
window.location.assign("/onboarding");
return;
}
const returnTo = new URLSearchParams(window.location.search).get(
'returnTo',
)
const destination =
returnTo &&
['/', '/deck', '/support', '/journal', '/inbox', '/vitals'].includes(
returnTo,
)
? returnTo
: '/'
window.location.assign(destination)
const returnTo = new URLSearchParams(window.location.search).get("returnTo");
window.location.assign(safeReturnTo(returnTo));
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to sign in. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to sign in. Please try again.");
setPending(false);
}
}
return (
<AuthFrame>
<header className="auth-heading">
<h1>{setup ? 'Make yourself at home.' : 'Welcome back.'}</h1>
<h1>{setup ? "Make yourself at home." : "Welcome back."}</h1>
<p>
{setup
? 'Create the owner account for your personal workspace.'
: 'Sign in to your personal workspace.'}
? "Create the owner account for your personal workspace."
: "Sign in to your personal workspace."}
</p>
</header>
<form onSubmit={submit} className="auth-form" aria-busy={pending}>
@@ -123,11 +115,11 @@ function CredentialsForm({ setup }: { setup: boolean }) {
id="password"
name="password"
type="password"
autoComplete={setup ? 'new-password' : 'current-password'}
autoComplete={setup ? "new-password" : "current-password"}
required
minLength={setup ? 15 : undefined}
maxLength={128}
aria-describedby={setup ? 'password-hint' : undefined}
aria-describedby={setup ? "password-hint" : undefined}
disabled={pending}
/>
{setup && (
@@ -173,27 +165,22 @@ function CredentialsForm({ setup }: { setup: boolean }) {
{error}
</p>
)}
<Button
type="submit"
size="lg"
disabled={pending}
className="auth-submit"
>
<Button type="submit" size="lg" disabled={pending} className="auth-submit">
{pending
? setup
? 'Creating account…'
: 'Signing in…'
? "Creating account…"
: "Signing in…"
: setup
? 'Create account'
: 'Sign in'}
? "Create account"
: "Sign in"}
<ArrowRight aria-hidden="true" />
</Button>
</form>
<p className="auth-footnote">
{setup
? 'This workspace has one owner. Connections can be added later.'
: 'A private workspace. Access is limited to its owner.'}
? "This workspace has one owner. Connections can be added later."
: "A private workspace. Access is limited to its owner."}
</p>
</AuthFrame>
)
);
}
+90 -122
View File
@@ -1,93 +1,83 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute } from 'node:path'
import { eq, lte } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
import type { SafeOwner } from './auth-client'
import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute } from "node:path";
import { eq, lte } from "drizzle-orm";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { authSessions, authThrottle, workspaceOwner } from "../storage/schema";
import type { SafeOwner } from "./auth-client";
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
const derive = (password: string, salt: string) =>
new Promise<Buffer>((resolve, reject) =>
scrypt(password, salt, 64, scryptOptions, (error, key) =>
error ? reject(error) : resolve(key),
),
)
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }
const throttleWindow = 15 * 60_000
);
const scryptOptions = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const throttleWindow = 15 * 60_000;
export class AuthError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
this.name = 'AuthError'
super(message);
this.name = "AuthError";
}
}
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const equal = (left: string, right: string) =>
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)))
timingSafeEqual(Buffer.from(hash(left)), Buffer.from(hash(right)));
function safeOwner(owner: typeof workspaceOwner.$inferSelect): SafeOwner {
return {
id: owner.id,
email: owner.email,
name: owner.name,
onboardingCompletedAt: owner.onboardingCompletedAt,
}
};
}
export function readAuthState(database = getDatabase()) {
return {
needsSetup: !database
.select({ id: workspaceOwner.id })
.from(workspaceOwner)
.get(),
}
needsSetup: !database.select({ id: workspaceOwner.id }).from(workspaceOwner).get(),
};
}
/** Bootstrap secret is read only by the server or operator CLI, never sent to clients. */
export function getSetupToken() {
const configured = process.env.WORKSPACE_SETUP_TOKEN
const configured = process.env.WORKSPACE_SETUP_TOKEN;
if (configured) {
if (configured.length < 32)
throw new AuthError(
503,
'The setup token must contain at least 32 characters.',
)
return configured
throw new AuthError(503, "The setup token must contain at least 32 characters.");
return configured;
}
const dbPath = process.env.TWITTER_LITE_DB_PATH
const dbPath = process.env.TWITTER_LITE_DB_PATH;
if (!dbPath || !isAbsolute(dbPath))
throw new AuthError(503, 'Configure a workspace database or setup token.')
const path = `${dbPath}.setup-token`
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
throw new AuthError(503, "Configure a workspace database or setup token.");
const path = `${dbPath}.setup-token`;
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
try {
writeFileSync(path, randomBytes(32).toString('base64url'), {
writeFileSync(path, randomBytes(32).toString("base64url"), {
mode: 0o600,
flag: 'wx',
})
flag: "wx",
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const token = readFileSync(path, 'utf8').trim()
if (token.length < 32)
throw new AuthError(503, 'The workspace setup token is invalid.')
return token
const token = readFileSync(path, "utf8").trim();
if (token.length < 32) throw new AuthError(503, "The workspace setup token is invalid.");
return token;
}
function consumeAttempt(database: AppDatabase) {
database.transaction(
(tx) => {
const now = Date.now()
const current = tx.select().from(authThrottle).get()
const now = Date.now();
const current = tx.select().from(authThrottle).get();
if (current && current.resetAt > now) {
if (current.attempts >= 10)
throw new AuthError(
429,
'Too many attempts. Please try again in 15 minutes.',
)
throw new AuthError(429, "Too many attempts. Please try again in 15 minutes.");
tx.update(authThrottle)
.set({ attempts: current.attempts + 1 })
.where(eq(authThrottle.id, 1))
.run()
.run();
} else {
tx.insert(authThrottle)
.values({ id: 1, attempts: 1, resetAt: now + throttleWindow })
@@ -95,21 +85,18 @@ function consumeAttempt(database: AppDatabase) {
target: authThrottle.id,
set: { attempts: 1, resetAt: now + throttleWindow },
})
.run()
.run();
}
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
function session(
database: Pick<AppDatabase, 'insert' | 'delete'>,
database: Pick<AppDatabase, "insert" | "delete">,
owner: typeof workspaceOwner.$inferSelect,
) {
const sessionToken = randomBytes(32).toString('base64url')
database
.delete(authSessions)
.where(lte(authSessions.expiresAt, Date.now()))
.run()
const sessionToken = randomBytes(32).toString("base64url");
database.delete(authSessions).where(lte(authSessions.expiresAt, Date.now())).run();
database
.insert(authSessions)
.values({
@@ -117,125 +104,106 @@ function session(
ownerId: owner.id,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
})
.run()
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run()
return { sessionToken, owner: safeOwner(owner) }
.run();
database.delete(authThrottle).where(eq(authThrottle.id, 1)).run();
return { sessionToken, owner: safeOwner(owner) };
}
function cleanName(name: string) {
const cleaned = name.trim()
const cleaned = name.trim();
if (!cleaned || cleaned.length > 80)
throw new AuthError(400, 'Enter a name of up to 80 characters.')
return cleaned
throw new AuthError(400, "Enter a name of up to 80 characters.");
return cleaned;
}
export async function createOwner(
input: { email: string; password: string; name: string; setupToken: string },
database = getDatabase(),
) {
if (!readAuthState(database).needsSetup)
throw new AuthError(
409,
'This workspace is already set up. Please sign in.',
)
consumeAttempt(database)
throw new AuthError(409, "This workspace is already set up. Please sign in.");
consumeAttempt(database);
if (!equal(input.setupToken, getSetupToken()))
throw new AuthError(401, 'Invalid setup credentials.')
const email = input.email.trim().toLowerCase()
throw new AuthError(401, "Invalid setup credentials.");
const email = input.email.trim().toLowerCase();
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
throw new AuthError(400, 'Enter a valid email address.')
throw new AuthError(400, "Enter a valid email address.");
if (input.password.length < 15 || input.password.length > 128)
throw new AuthError(400, 'Use a password between 15 and 128 characters.')
const name = cleanName(input.name)
const salt = randomBytes(16).toString('hex')
const derived = (await derive(input.password, salt)) as Buffer
throw new AuthError(400, "Use a password between 15 and 128 characters.");
const name = cleanName(input.name);
const salt = randomBytes(16).toString("hex");
const derived = (await derive(input.password, salt)) as Buffer;
return database.transaction(
(tx) => {
if (tx.select().from(workspaceOwner).get())
throw new AuthError(
409,
'This workspace is already set up. Please sign in.',
)
throw new AuthError(409, "This workspace is already set up. Please sign in.");
const owner = {
id: 1,
email,
name,
passwordHash: `${salt}:${derived.toString('hex')}`,
passwordHash: `${salt}:${derived.toString("hex")}`,
onboardingCompletedAt: null,
createdAt: Date.now(),
}
tx.insert(workspaceOwner).values(owner).run()
return session(tx, owner)
};
tx.insert(workspaceOwner).values(owner).run();
return session(tx, owner);
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
export async function signIn(
input: { email: string; password: string },
database = getDatabase(),
) {
consumeAttempt(database)
const owner = database.select().from(workspaceOwner).get()
export async function signIn(input: { email: string; password: string }, database = getDatabase()) {
consumeAttempt(database);
const owner = database.select().from(workspaceOwner).get();
if (input.password.length > 128 || input.email.length > 254)
throw new AuthError(401, 'Invalid email or password.')
const [salt = '', expected = ''] = owner?.passwordHash.split(':') ?? [
'0'.repeat(32),
'0'.repeat(128),
]
const actual = (await derive(input.password, salt)) as Buffer
throw new AuthError(401, "Invalid email or password.");
const [salt = "", expected = ""] = owner?.passwordHash.split(":") ?? [
"0".repeat(32),
"0".repeat(128),
];
const actual = (await derive(input.password, salt)) as Buffer;
if (
!owner ||
!equal(input.email.trim().toLowerCase(), owner.email) ||
!timingSafeEqual(actual, Buffer.from(expected, 'hex'))
!timingSafeEqual(actual, Buffer.from(expected, "hex"))
)
throw new AuthError(401, 'Invalid email or password.')
return session(database, owner)
throw new AuthError(401, "Invalid email or password.");
return session(database, owner);
}
export function getSession(
token: string | undefined,
database = getDatabase(),
): SafeOwner | null {
if (!token || token.length > 128) return null
export function getSession(token: string | undefined, database = getDatabase()): SafeOwner | null {
if (!token || token.length > 128) return null;
const found = database
.select()
.from(authSessions)
.where(eq(authSessions.tokenHash, hash(token)))
.get()
if (!found) return null
.get();
if (!found) return null;
if (found.expiresAt <= Date.now()) {
database
.delete(authSessions)
.where(eq(authSessions.tokenHash, found.tokenHash))
.run()
return null
database.delete(authSessions).where(eq(authSessions.tokenHash, found.tokenHash)).run();
return null;
}
const owner = database
.select()
.from(workspaceOwner)
.where(eq(workspaceOwner.id, found.ownerId))
.get()
return owner ? safeOwner(owner) : null
.get();
return owner ? safeOwner(owner) : null;
}
export function signOut(token: string | undefined, database = getDatabase()) {
if (token)
database
.delete(authSessions)
.where(eq(authSessions.tokenHash, hash(token)))
.run()
.run();
}
export function completeOnboarding(
token: string,
input: { name: string },
database = getDatabase(),
): SafeOwner {
const owner = getSession(token, database)
if (!owner) throw new AuthError(401, 'Please sign in.')
const owner = getSession(token, database);
if (!owner) throw new AuthError(401, "Please sign in.");
const update = {
name: cleanName(input.name),
onboardingCompletedAt: owner.onboardingCompletedAt ?? Date.now(),
}
database
.update(workspaceOwner)
.set(update)
.where(eq(workspaceOwner.id, owner.id))
.run()
return { ...owner, ...update }
};
database.update(workspaceOwner).set(update).where(eq(workspaceOwner.id, owner.id)).run();
return { ...owner, ...update };
}
+102 -119
View File
@@ -1,10 +1,10 @@
// @vitest-environment node
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openDatabase } from '../storage/database.server'
import { authSessions, authThrottle, workspaceOwner } from '../storage/schema'
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { openDatabase } from "../storage/database.server";
import { authSessions, authThrottle, workspaceOwner } from "../storage/schema";
import {
completeOnboarding,
createOwner,
@@ -14,137 +14,120 @@ import {
SESSION_MAX_AGE_SECONDS,
signIn,
signOut,
} from './auth.server'
} from "./auth.server";
let database: ReturnType<typeof openDatabase>
let database: ReturnType<typeof openDatabase>;
const credentials = {
email: '[email protected]',
password: 'correct horse battery staple',
name: 'Owner',
setupToken: 's'.repeat(32),
}
email: "[email protected]",
password: "correct horse battery staple",
name: "Owner",
setupToken: "s".repeat(32),
};
beforeEach(() => {
database = openDatabase(':memory:')
vi.stubEnv('WORKSPACE_SETUP_TOKEN', credentials.setupToken)
})
database = openDatabase(":memory:");
vi.stubEnv("WORKSPACE_SETUP_TOKEN", credentials.setupToken);
});
afterEach(() => {
database.$client.close()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
describe('owner authentication', () => {
it('requires a private setup token and stores only derived credentials and session tokens', async () => {
expect(readAuthState(database)).toEqual({ needsSetup: true })
database.$client.close();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("owner authentication", () => {
it("requires a private setup token and stores only derived credentials and session tokens", async () => {
expect(readAuthState(database)).toEqual({ needsSetup: true });
await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database),
).rejects.toMatchObject({ status: 401 })
expect(readAuthState(database).needsSetup).toBe(true)
const result = await createOwner(credentials, database)
expect(readAuthState(database)).toEqual({ needsSetup: false })
expect(result.owner.email).toBe(credentials.email)
expect(result.owner).not.toHaveProperty('passwordHash')
expect(
database.select().from(workspaceOwner).get()?.passwordHash,
).not.toContain(credentials.password)
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(
result.sessionToken,
)
expect(getSession(result.sessionToken, database)).toEqual(result.owner)
createOwner({ ...credentials, setupToken: "wrong" }, database),
).rejects.toMatchObject({ status: 401 });
expect(readAuthState(database).needsSetup).toBe(true);
const result = await createOwner(credentials, database);
expect(readAuthState(database)).toEqual({ needsSetup: false });
expect(result.owner.email).toBe(credentials.email);
expect(result.owner).not.toHaveProperty("passwordHash");
expect(database.select().from(workspaceOwner).get()?.passwordHash).not.toContain(
credentials.password,
);
expect(database.select().from(authSessions).get()?.tokenHash).not.toEqual(result.sessionToken);
expect(getSession(result.sessionToken, database)).toEqual(result.owner);
await expect(createOwner(credentials, database)).rejects.toMatchObject({
status: 409,
})
})
it('only allows one owner even when setup requests race', async () => {
});
});
it("only allows one owner even when setup requests race", async () => {
const results = await Promise.allSettled([
createOwner(credentials, database),
createOwner(credentials, database),
])
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1)
expect(database.select().from(workspaceOwner).all()).toHaveLength(1)
})
it('checks the email and password and revokes logged-out sessions', async () => {
await createOwner(credentials, database)
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
expect(database.select().from(workspaceOwner).all()).toHaveLength(1);
});
it("checks the email and password and revokes logged-out sessions", async () => {
await createOwner(credentials, database);
await expect(signIn({ ...credentials, password: "wrong" }, database)).rejects.toMatchObject({
status: 401,
message: "Invalid email or password.",
});
await expect(
signIn({ ...credentials, password: 'wrong' }, database),
signIn({ ...credentials, email: "[email protected]" }, database),
).rejects.toMatchObject({
status: 401,
message: 'Invalid email or password.',
})
await expect(
signIn({ ...credentials, email: '[email protected]' }, database),
).rejects.toMatchObject({
status: 401,
message: 'Invalid email or password.',
})
const result = await signIn(
{ ...credentials, email: '[email protected]' },
database,
)
expect(database.select().from(authThrottle).all()).toHaveLength(0)
expect(getSession('invented', database)).toBeNull()
expect(getSession(result.sessionToken, database)).not.toBeNull()
signOut(result.sessionToken, database)
expect(getSession(result.sessionToken, database)).toBeNull()
})
it('expires sessions and stores onboarding completion', async () => {
const result = await createOwner(credentials, database)
expect(result.owner.onboardingCompletedAt).toBeNull()
const owner = completeOnboarding(
result.sessionToken,
{ name: 'Yuta' },
database,
)
expect(owner.name).toBe('Yuta')
expect(owner.onboardingCompletedAt).toBeTypeOf('number')
expect(getSession(result.sessionToken, database)).toEqual(owner)
expect(() =>
completeOnboarding('invalid', { name: 'Other' }, database),
).toThrow('Please sign in.')
vi.spyOn(Date, 'now').mockReturnValue(
Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1,
)
expect(getSession(result.sessionToken, database)).toBeNull()
expect(database.select().from(authSessions).all()).toHaveLength(0)
})
it('bounds setup and login attempts persistently and permits retry after cooldown', async () => {
message: "Invalid email or password.",
});
const result = await signIn({ ...credentials, email: "[email protected]" }, database);
expect(database.select().from(authThrottle).all()).toHaveLength(0);
expect(getSession("invented", database)).toBeNull();
expect(getSession(result.sessionToken, database)).not.toBeNull();
signOut(result.sessionToken, database);
expect(getSession(result.sessionToken, database)).toBeNull();
});
it("expires sessions and stores onboarding completion", async () => {
const result = await createOwner(credentials, database);
expect(result.owner.onboardingCompletedAt).toBeNull();
const owner = completeOnboarding(result.sessionToken, { name: "Yuta" }, database);
expect(owner.name).toBe("Yuta");
expect(owner.onboardingCompletedAt).toBeTypeOf("number");
expect(getSession(result.sessionToken, database)).toEqual(owner);
expect(() => completeOnboarding("invalid", { name: "Other" }, database)).toThrow(
"Please sign in.",
);
vi.spyOn(Date, "now").mockReturnValue(Date.now() + SESSION_MAX_AGE_SECONDS * 1000 + 1);
expect(getSession(result.sessionToken, database)).toBeNull();
expect(database.select().from(authSessions).all()).toHaveLength(0);
});
it("bounds setup and login attempts persistently and permits retry after cooldown", async () => {
for (let index = 0; index < 10; index++)
await expect(
createOwner({ ...credentials, setupToken: 'wrong' }, database),
).rejects.toMatchObject({ status: 401 })
createOwner({ ...credentials, setupToken: "wrong" }, database),
).rejects.toMatchObject({ status: 401 });
await expect(signIn(credentials, database)).rejects.toMatchObject({
status: 429,
})
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 15 * 60_000 + 1)
await expect(createOwner(credentials, database)).resolves.toHaveProperty(
'sessionToken',
)
})
it('rejects short passwords and invalid identity fields', async () => {
});
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 15 * 60_000 + 1);
await expect(createOwner(credentials, database)).resolves.toHaveProperty("sessionToken");
});
it("rejects short passwords and invalid identity fields", async () => {
await expect(
createOwner({ ...credentials, password: 'short' }, database),
).rejects.toMatchObject({ status: 400 })
await expect(
createOwner({ ...credentials, email: 'invalid' }, database),
).rejects.toMatchObject({ status: 400 })
await expect(
createOwner({ ...credentials, name: ' ' }, database),
).rejects.toMatchObject({ status: 400 })
})
it('creates a stable local setup token with private file permissions', () => {
const directory = mkdtempSync(join(tmpdir(), 'workspace-setup-'))
createOwner({ ...credentials, password: "short" }, database),
).rejects.toMatchObject({ status: 400 });
await expect(createOwner({ ...credentials, email: "invalid" }, database)).rejects.toMatchObject(
{ status: 400 },
);
await expect(createOwner({ ...credentials, name: " " }, database)).rejects.toMatchObject({
status: 400,
});
});
it("creates a stable local setup token with private file permissions", () => {
const directory = mkdtempSync(join(tmpdir(), "workspace-setup-"));
try {
vi.stubEnv('WORKSPACE_SETUP_TOKEN', '')
const path = join(directory, 'workspace.sqlite')
vi.stubEnv('TWITTER_LITE_DB_PATH', path)
const token = getSetupToken()
expect(token.length).toBeGreaterThanOrEqual(32)
expect(getSetupToken()).toBe(token)
expect(readFileSync(`${path}.setup-token`, 'utf8')).toBe(token)
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600)
vi.stubEnv("WORKSPACE_SETUP_TOKEN", "");
const path = join(directory, "workspace.sqlite");
vi.stubEnv("TWITTER_LITE_DB_PATH", path);
const token = getSetupToken();
expect(token.length).toBeGreaterThanOrEqual(32);
expect(getSetupToken()).toBe(token);
expect(readFileSync(`${path}.setup-token`, "utf8")).toBe(token);
expect(statSync(`${path}.setup-token`).mode & 0o777).toBe(0o600);
} finally {
rmSync(directory, { recursive: true, force: true })
rmSync(directory, { recursive: true, force: true });
}
})
})
});
});
+25 -24
View File
@@ -1,36 +1,37 @@
import { getSession, readAuthState } from './auth.server'
import { sessionToken } from './http.server'
import { getSession, readAuthState } from "./auth.server";
import { sessionToken } from "./http.server";
import { safeReturnTo } from "./return-to";
const publicPaths = new Set(['/login', '/setup', '/api/auth'])
const publicPaths = new Set(["/login", "/setup", "/api/auth"]);
export function checkSessionAccess(request: Request): Response | null {
const url = new URL(request.url)
if (publicPaths.has(url.pathname)) return null
const owner = getSession(sessionToken(request))
if (owner?.onboardingCompletedAt) return null
if (owner && url.pathname === '/onboarding') return null
const url = new URL(request.url);
if (publicPaths.has(url.pathname)) return null;
const owner = getSession(sessionToken(request));
if (owner?.onboardingCompletedAt) return null;
if (owner && url.pathname === "/onboarding") return null;
const document =
request.method === 'GET' &&
request.headers.get('accept')?.includes('text/html') &&
!url.pathname.startsWith('/api/') &&
!url.pathname.startsWith('/_serverFn/')
request.method === "GET" &&
request.headers.get("accept")?.includes("text/html") &&
!url.pathname.startsWith("/api/") &&
!url.pathname.startsWith("/_serverFn/");
if (document) {
const location = owner
? '/onboarding'
: readAuthState().needsSetup
? '/setup'
: '/login'
const location = owner ? "/onboarding" : readAuthState().needsSetup ? "/setup" : "/login";
return new Response(null, {
status: 303,
headers: { location, 'cache-control': 'no-store' },
})
headers: {
location:
location === "/login" && (url.pathname !== "/" || !!url.search)
? `/login?${new URLSearchParams({ returnTo: safeReturnTo(url.pathname + url.search) })}`
: location,
"cache-control": "no-store",
},
});
}
return Response.json(
{
error: owner
? 'Complete onboarding to continue.'
: 'Sign in to continue.',
error: owner ? "Complete onboarding to continue." : "Sign in to continue.",
},
{ status: owner ? 403 : 401, headers: { 'cache-control': 'no-store' } },
)
{ status: owner ? 403 : 401, headers: { "cache-control": "no-store" } },
);
}
+48 -60
View File
@@ -7,97 +7,85 @@ import {
SESSION_MAX_AGE_SECONDS,
signIn,
signOut,
} from './auth.server'
} from "./auth.server";
const SESSION_COOKIE = 'workspace_session'
const SESSION_COOKIE = "workspace_session";
export function sessionToken(request: Request) {
return request.headers
.get('cookie')
?.split(';')
.get("cookie")
?.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${SESSION_COOKIE}=`))
?.slice(SESSION_COOKIE.length + 1)
?.slice(SESSION_COOKIE.length + 1);
}
export function authState(request: Request) {
return { ...readAuthState(), owner: getSession(sessionToken(request)) }
return { ...readAuthState(), owner: getSession(sessionToken(request)) };
}
function cookie(token: string, clear = false) {
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith('https:')
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? '; Secure' : ''}`
const secure = process.env.TWITTER_LITE_ORIGIN?.startsWith("https:");
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_MAX_AGE_SECONDS}${secure ? "; Secure" : ""}`;
}
export async function authEndpoint(request: Request) {
const headers = new Headers({
'cache-control': 'no-store',
vary: 'Cookie',
'content-type': 'application/json',
})
"cache-control": "no-store",
vary: "Cookie",
"content-type": "application/json",
});
try {
if (request.method === 'GET')
return Response.json(authState(request), { headers })
if (request.method !== 'POST')
return new Response(null, { status: 405, headers })
if (!request.headers.get('content-type')?.startsWith('application/json'))
throw new AuthError(400, 'Use a JSON request.')
const body = await request.text()
if (body.length > 8192) throw new AuthError(413, 'Request is too large.')
let data: Record<string, unknown>
if (request.method === "GET") return Response.json(authState(request), { headers });
if (request.method !== "POST") return new Response(null, { status: 405, headers });
if (!request.headers.get("content-type")?.startsWith("application/json"))
throw new AuthError(400, "Use a JSON request.");
const body = await request.text();
if (body.length > 8192) throw new AuthError(413, "Request is too large.");
let data: Record<string, unknown>;
try {
const parsed = JSON.parse(body)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error('invalid body')
data = parsed
const parsed = JSON.parse(body);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error("invalid body");
data = parsed;
} catch {
throw new AuthError(400, 'Check the form and try again.')
throw new AuthError(400, "Check the form and try again.");
}
const text = (key: string) =>
typeof data[key] === 'string' ? data[key] : ''
const oldToken = sessionToken(request)
const text = (key: string) => (typeof data[key] === "string" ? data[key] : "");
const oldToken = sessionToken(request);
switch (data.action) {
case 'setup':
case 'login': {
case "setup":
case "login": {
const result =
data.action === 'setup'
data.action === "setup"
? await createOwner({
email: text('email'),
password: text('password'),
name: text('name'),
setupToken: text('setupToken'),
email: text("email"),
password: text("password"),
name: text("name"),
setupToken: text("setupToken"),
})
: await signIn({ email: text('email'), password: text('password') })
signOut(oldToken)
headers.set('set-cookie', cookie(result.sessionToken))
return Response.json(
{ needsSetup: false, owner: result.owner },
{ headers },
)
: await signIn({ email: text("email"), password: text("password") });
signOut(oldToken);
headers.set("set-cookie", cookie(result.sessionToken));
return Response.json({ needsSetup: false, owner: result.owner }, { headers });
}
case 'onboard': {
const owner = completeOnboarding(oldToken ?? '', { name: text('name') })
return Response.json({ needsSetup: false, owner }, { headers })
case "onboard": {
const owner = completeOnboarding(oldToken ?? "", { name: text("name") });
return Response.json({ needsSetup: false, owner }, { headers });
}
case 'logout':
signOut(oldToken)
headers.set('set-cookie', cookie('', true))
return Response.json(
{ needsSetup: readAuthState().needsSetup, owner: null },
{ headers },
)
case "logout":
signOut(oldToken);
headers.set("set-cookie", cookie("", true));
return Response.json({ needsSetup: readAuthState().needsSetup, owner: null }, { headers });
default:
throw new AuthError(400, 'Unknown action.')
throw new AuthError(400, "Unknown action.");
}
} catch (error) {
if (error instanceof AuthError)
return Response.json(
{ error: error.message },
{ status: error.status, headers },
)
return Response.json({ error: error.message }, { status: error.status, headers });
return Response.json(
{ error: 'Unable to complete the request. Please try again.' },
{ error: "Unable to complete the request. Please try again." },
{ status: 500, headers },
)
);
}
}
+33 -46
View File
@@ -1,55 +1,47 @@
import { useRouteContext } from '@tanstack/react-router'
import { ArrowRight, BookOpen, House, MessagesSquare } from 'lucide-react'
import { type FormEvent, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { authRequest } from './auth-client'
import { AuthFrame } from './auth-page'
import { useRouteContext } from "@tanstack/react-router";
import { ArrowRight, BookOpen, House, MessagesSquare } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { authRequest } from "./auth-client";
import { AuthFrame } from "./auth-page";
export function OnboardingPage() {
const { auth } = useRouteContext({ from: '__root__' })
const [name, setName] = useState(auth.owner?.name ?? '')
const [step, setStep] = useState(1)
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const { auth } = useRouteContext({ from: "__root__" });
const [name, setName] = useState(auth.owner?.name ?? "");
const [step, setStep] = useState(1);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function finish() {
setPending(true)
setError('')
setPending(true);
setError("");
try {
await authRequest({ action: 'onboard', name: name.trim() })
window.location.assign('/')
await authRequest({ action: "onboard", name: name.trim() });
window.location.assign("/");
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to save. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to save. Please try again.");
setPending(false);
}
}
async function signOut() {
setPending(true)
setError('')
setPending(true);
setError("");
try {
await authRequest({ action: 'logout' })
window.location.assign('/login')
await authRequest({ action: "logout" });
window.location.assign("/login");
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: 'Unable to sign out. Please try again.',
)
setPending(false)
setError(cause instanceof Error ? cause.message : "Unable to sign out. Please try again.");
setPending(false);
}
}
function next(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!name.trim()) return
setStep(2)
event.preventDefault();
if (!name.trim()) return;
setStep(2);
}
return (
@@ -116,20 +108,15 @@ export function OnboardingPage() {
</li>
</ul>
<p className="auth-prototype">
Home, contacts, notes, reading, and vitals currently use sample
data. You can add connections from your profile menu whenever you’re
ready.
Home, contacts, notes, reading, and vitals currently use sample data. You can add
connections from your profile menu whenever you’re ready.
</p>
<div className="auth-actions">
<Button
variant="ghost"
onClick={() => setStep(1)}
disabled={pending}
>
<Button variant="ghost" onClick={() => setStep(1)} disabled={pending}>
Back
</Button>
<Button size="lg" onClick={finish} disabled={pending}>
{pending ? 'Saving…' : 'Open workspace'}
{pending ? "Saving…" : "Open workspace"}
<ArrowRight aria-hidden="true" />
</Button>
</div>
@@ -147,5 +134,5 @@ export function OnboardingPage() {
</Button>
</footer>
</AuthFrame>
)
);
}
+19
View File
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import { safeReturnTo } from "./return-to";
it("keeps the selected view through login", () => {
expect(safeReturnTo("/inbox?q=agents&article=browser#read")).toBe(
"/inbox?q=agents&article=browser#read",
);
});
it.each([
"https://outside.invalid",
"//outside.invalid",
"/\\outside.invalid",
"/api/auth",
"/login",
"/inbox\n",
undefined,
])("rejects an unsafe destination: %s", (value) => {
expect(safeReturnTo(value)).toBe("/");
});
+15
View File
@@ -0,0 +1,15 @@
const paths = new Set(["/", "/deck", "/support", "/journal", "/inbox", "/vitals"]);
export function safeReturnTo(value: unknown): string {
if (
typeof value !== "string" ||
!value.startsWith("/") ||
value.startsWith("//") ||
/[\\\r\n]/.test(value)
)
return "/";
const url = new URL(value, "https://workspace.invalid");
return url.origin === "https://workspace.invalid" && paths.has(url.pathname)
? `${url.pathname}${url.search}${url.hash}`
: "/";
}
+9 -9
View File
@@ -1,14 +1,14 @@
import { createIsomorphicFn } from '@tanstack/react-start'
import type { AuthState } from './auth-client'
import { createIsomorphicFn } from "@tanstack/react-start";
import type { AuthState } from "./auth-client";
export const loadAuthState = createIsomorphicFn()
.server(async (): Promise<AuthState> => {
const { getRequest } = await import('@tanstack/react-start/server')
const { authState } = await import('./http.server')
return authState(getRequest())
const { getRequest } = await import("@tanstack/react-start/server");
const { authState } = await import("./http.server");
return authState(getRequest());
})
.client(async (): Promise<AuthState> => {
const response = await fetch('/api/auth', { cache: 'no-store' })
if (!response.ok) throw new Error('Unable to check your session.')
return response.json()
})
const response = await fetch("/api/auth", { cache: "no-store" });
if (!response.ok) throw new Error("Unable to check your session.");
return response.json();
});
@@ -1,60 +1,50 @@
import { useQuery } from '@tanstack/react-query'
import { useRouteContext } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { authRequest } from '#/features/auth/auth-client'
import { ConnectionManager } from './connection-manager'
import { loadConnections } from './server-functions'
import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { useState } from "react";
import { Dialog } from "#/components/dialog";
import { Button } from "#/components/ui/button";
import { authRequest } from "#/features/auth/auth-client";
import { ConnectionManager } from "./connection-manager";
import { loadConnections } from "./server-functions";
export function ConnectionManagerDialog({
onClose,
hasTemporaryDecks,
}: {
onClose: () => void
hasTemporaryDecks?: boolean
onClose: () => void;
hasTemporaryDecks?: boolean;
}) {
const { auth } = useRouteContext({ from: '__root__' })
const [signingOut, setSigningOut] = useState(false)
const [error, setError] = useState('')
const { auth } = useRouteContext({ from: "__root__" });
const [signingOut, setSigningOut] = useState(false);
const [error, setError] = useState("");
async function logout() {
setSigningOut(true)
setError('')
setSigningOut(true);
setError("");
try {
await authRequest({ action: 'logout' })
window.location.assign('/login')
await authRequest({ action: "logout" });
window.location.assign("/login");
} catch {
setError('Unable to sign out. Please try again.')
setSigningOut(false)
setError("Unable to sign out. Please try again.");
setSigningOut(false);
}
}
const fetchConnections = useServerFn(loadConnections)
const fetchConnections = useServerFn(loadConnections);
const connections = useQuery({
queryKey: ['connections'],
queryKey: ["connections"],
queryFn: () => fetchConnections(),
retry: false,
})
});
return (
<Dialog
title="Connected accounts"
onClose={onClose}
className="sm:max-w-2xl"
>
<Dialog title="Connected accounts" onClose={onClose} className="sm:max-w-2xl">
<div className="mb-5 flex items-center justify-between gap-4 border-b pb-4">
<div className="min-w-0">
<p className="truncate font-medium">{auth.owner?.name}</p>
<p className="truncate text-sm text-muted-foreground">
{auth.owner?.email}
</p>
<p className="truncate text-sm text-muted-foreground">{auth.owner?.email}</p>
</div>
<Button
variant="outline"
disabled={signingOut}
onClick={() => void logout()}
>
{signingOut ? 'Signing out…' : 'Sign out'}
<Button variant="outline" disabled={signingOut} onClick={() => void logout()}>
{signingOut ? "Signing out…" : "Sign out"}
</Button>
</div>
{error && <p role="alert">{error}</p>}
@@ -69,9 +59,7 @@ export function ConnectionManagerDialog({
</div>
) : (
<>
{connections.data.relayError && (
<p role="alert">{connections.data.relayError}</p>
)}
{connections.data.relayError && <p role="alert">{connections.data.relayError}</p>}
<ConnectionManager
connections={connections.data.connections}
onChanged={() => connections.refetch()}
@@ -80,5 +68,5 @@ export function ConnectionManagerDialog({
</>
)}
</Dialog>
)
);
}
@@ -1,46 +1,44 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { ConnectionManager } from './connection-manager'
import type { Connection } from './model'
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { ConnectionManager } from "./connection-manager";
import type { Connection } from "./model";
const actions = vi.hoisted(() => ({
load: vi.fn(),
authorize: vi.fn(),
disconnect: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('../mastodon/server-functions', () => ({
load: vi.fn<typeof import("../mastodon/server-functions").loadMastodonInstances>(),
authorize: vi.fn<typeof import("../mastodon/server-functions").startMastodonOAuth>(),
disconnect: vi.fn<typeof import("../mastodon/server-functions").disconnectMastodonAccount>(),
}));
vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
vi.mock("../mastodon/server-functions", () => ({
loadMastodonInstances: actions.load,
startMastodonOAuth: actions.authorize,
disconnectMastodonAccount: actions.disconnect,
}))
}));
const account: Connection = {
id: 'mastodon-first',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: '@first',
status: 'connected',
}
id: "mastodon-first",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "1",
displayName: "@first",
status: "connected",
};
beforeEach(() => {
vi.resetAllMocks()
vi.resetAllMocks();
actions.load.mockResolvedValue({
origins: ['https://mastodon.invalid', 'https://second.invalid'],
})
})
origins: ["https://mastodon.invalid", "https://second.invalid"],
});
});
function show(
connections: Connection[] = [account],
onChanged = vi.fn(),
onChanged = vi.fn<() => Promise<void> | void>(),
hasTemporaryDecks = false,
) {
return render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ConnectionManager
connections={connections}
@@ -48,92 +46,77 @@ function show(
hasTemporaryDecks={hasTemporaryDecks}
/>
</QueryClientProvider>,
)
);
}
it('shows per-account status and warns about temporary decks without blocking actions', async () => {
it("shows per-account status and warns about temporary decks without blocking actions", async () => {
show(
[
account,
{ ...account, id: 'expired', displayName: '@expired', status: 'expired' },
],
[account, { ...account, id: "expired", displayName: "@expired", status: "expired" }],
vi.fn(),
true,
)
expect(screen.getByText('Connected')).toBeVisible()
expect(screen.getByText('Reconnect required')).toBeVisible()
expect(screen.getByText(/Temporary decks will be lost/)).toBeVisible()
expect(
await screen.findByRole('button', { name: 'Authorize with Mastodon' }),
).toBeEnabled()
})
);
expect(screen.getByText("Connected")).toBeVisible();
expect(screen.getByText("Reconnect required")).toBeVisible();
expect(screen.getByText(/Temporary decks will be lost/)).toBeVisible();
expect(await screen.findByRole("button", { name: "Authorize with Mastodon" })).toBeEnabled();
});
it('uses the selected instance and reports a safe error when authorization fails', async () => {
actions.authorize.mockRejectedValue(
new Error('private server credential detail'),
)
show()
fireEvent.change(await screen.findByLabelText('Mastodon server'), {
target: { value: 'https://second.invalid' },
})
fireEvent.click(
screen.getByRole('button', { name: 'Authorize with Mastodon' }),
)
it("uses the selected instance and reports a safe error when authorization fails", async () => {
actions.authorize.mockRejectedValue(new Error("private server credential detail"));
show();
fireEvent.change(await screen.findByLabelText("Mastodon server"), {
target: { value: "https://second.invalid" },
});
fireEvent.click(screen.getByRole("button", { name: "Authorize with Mastodon" }));
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: 'https://second.invalid' },
data: { origin: "https://second.invalid" },
}),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'Could not start Mastodon authorization',
)
expect(screen.queryByText(/private server/)).toBeNull()
expect(
screen.getByRole('button', { name: 'Authorize with Mastodon' }),
).toBeEnabled()
})
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not start Mastodon authorization",
);
expect(screen.queryByText(/private server/)).toBeNull();
expect(screen.getByRole("button", { name: "Authorize with Mastodon" })).toBeEnabled();
});
it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => {
actions.authorize.mockRejectedValue(new Error('offline'))
show()
await screen.findByLabelText('Mastodon server')
fireEvent.click(screen.getByRole('button', { name: 'Reconnect @first' }))
it("binds reconnect to the selected account rather than whichever instance is selected for addition", async () => {
actions.authorize.mockRejectedValue(new Error("offline"));
show();
await screen.findByLabelText("Mastodon server");
fireEvent.click(screen.getByRole("button", { name: "Reconnect @first" }));
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: account.origin, connectionId: account.id },
}),
)
expect(await screen.findByRole('alert')).toBeVisible()
})
);
expect(await screen.findByRole("alert")).toBeVisible();
});
it('refreshes the account list after successful disconnect', async () => {
const onChanged = vi.fn().mockResolvedValue(undefined)
actions.disconnect.mockResolvedValue({ disconnected: true })
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' }))
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce())
it("refreshes the account list after successful disconnect", async () => {
const onChanged = vi.fn<() => Promise<void> | void>().mockResolvedValue(undefined);
actions.disconnect.mockResolvedValue({ disconnected: true });
show([account], onChanged);
fireEvent.click(screen.getByRole("button", { name: "Disconnect @first" }));
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
expect(actions.disconnect).toHaveBeenCalledWith({
data: { connectionId: account.id },
})
})
});
});
it('keeps account controls available after a failed disconnect', async () => {
actions.disconnect.mockRejectedValue(new Error('private token'))
const onChanged = vi.fn()
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Could not disconnect the account',
)
expect(onChanged).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: 'Disconnect @first' }),
).toBeEnabled()
})
it("keeps account controls available after a failed disconnect", async () => {
actions.disconnect.mockRejectedValue(new Error("private token"));
const onChanged = vi.fn<() => Promise<void> | void>();
show([account], onChanged);
fireEvent.click(screen.getByRole("button", { name: "Disconnect @first" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not disconnect the account");
expect(onChanged).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Disconnect @first" })).toBeEnabled();
});
it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => {
show([{ ...account, platform: 'twitter', id: 'twitter-first' }])
await screen.findByLabelText('Mastodon server')
expect(screen.queryByRole('button', { name: 'Reconnect @first' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Disconnect @first' })).toBeNull()
})
it("does not offer Mastodon reconnect or revoke for Twitter relay accounts", async () => {
show([{ ...account, platform: "twitter", id: "twitter-first" }]);
await screen.findByLabelText("Mastodon server");
expect(screen.queryByRole("button", { name: "Reconnect @first" })).toBeNull();
expect(screen.queryByRole("button", { name: "Disconnect @first" })).toBeNull();
});
+51 -69
View File
@@ -1,88 +1,80 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { useId, useState } from 'react'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { useQuery } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start";
import { useId, useState } from "react";
import { Badge } from "#/components/ui/badge";
import { Button } from "#/components/ui/button";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import {
disconnectMastodonAccount,
loadMastodonInstances,
startMastodonOAuth,
} from '../mastodon/server-functions'
import type { Connection } from './model'
import './connection-manager.css'
} from "../mastodon/server-functions";
import type { Connection } from "./model";
const statusNames = {
connected: 'Connected',
disconnected: 'Disconnected',
expired: 'Reconnect required',
}
connected: "Connected",
disconnected: "Disconnected",
expired: "Reconnect required",
};
export function ConnectionManager({
connections,
onChanged,
hasTemporaryDecks = false,
}: {
connections: Connection[]
onChanged: () => unknown
hasTemporaryDecks?: boolean
connections: Connection[];
onChanged: () => unknown;
hasTemporaryDecks?: boolean;
}) {
const fetchInstances = useServerFn(loadMastodonInstances)
const authorize = useServerFn(startMastodonOAuth)
const disconnect = useServerFn(disconnectMastodonAccount)
const fetchInstances = useServerFn(loadMastodonInstances);
const authorize = useServerFn(startMastodonOAuth);
const disconnect = useServerFn(disconnectMastodonAccount);
const instances = useQuery({
queryKey: ['mastodon-instances'],
queryKey: ["mastodon-instances"],
queryFn: () => fetchInstances(),
retry: false,
})
const [selected, setSelected] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const selectId = useId()
const origin = selected || instances.data?.origins[0] || ''
});
const [selected, setSelected] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const selectId = useId();
const origin = selected || instances.data?.origins[0] || "";
async function connect(origin: string, connectionId?: string) {
setBusy(true)
setError(undefined)
setBusy(true);
setError(undefined);
try {
const result = await authorize({
data: { origin, ...(connectionId ? { connectionId } : {}) },
})
window.location.assign(result.authorizationUrl)
});
window.location.assign(result.authorizationUrl);
} catch {
setError(
'Could not start Mastodon authorization. Check the server and try again.',
)
setError("Could not start Mastodon authorization. Check the server and try again.");
} finally {
setBusy(false)
setBusy(false);
}
}
async function remove(connectionId: string) {
setBusy(true)
setError(undefined)
setBusy(true);
setError(undefined);
try {
await disconnect({ data: { connectionId } })
await disconnect({ data: { connectionId } });
try {
await onChanged()
await onChanged();
} catch {
setError(
'Account disconnected, but the list could not be refreshed. Reload the page.',
)
setError("Account disconnected, but the list could not be refreshed. Reload the page.");
}
} catch {
setError('Could not disconnect the account. Try again later.')
setError("Could not disconnect the account. Try again later.");
} finally {
setBusy(false)
setBusy(false);
}
}
return (
<section
className="connection-manager"
aria-label="Connected account management"
>
<section className="connection-manager" aria-label="Connected account management">
<p className="connection-manager-description">
Connected accounts are available in columns on all your devices.
</p>
@@ -96,31 +88,25 @@ export function ConnectionManager({
<div className="connection-manager-account">
<strong>{connection.displayName}</strong>
<span>
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
{connection.platform === "twitter" ? "Twitter" : "Mastodon"} ·{" "}
{new URL(connection.origin).hostname}
</span>
<Badge
variant={
connection.status === 'connected' ? 'secondary' : 'outline'
}
>
<Badge variant={connection.status === "connected" ? "secondary" : "outline"}>
{statusNames[connection.status]}
</Badge>
</div>
{connection.platform === 'mastodon' && (
{connection.platform === "mastodon" && (
<div className="connection-manager-actions">
<Button
variant="outline"
type="button"
disabled={busy}
aria-label={`Reconnect ${connection.displayName}`}
onClick={() =>
void connect(connection.origin, connection.id)
}
onClick={() => void connect(connection.origin, connection.id)}
>
Reconnect
</Button>
{connection.status !== 'disconnected' && (
{connection.status !== "disconnected" && (
<Button
variant="destructive"
type="button"
@@ -140,8 +126,8 @@ export function ConnectionManager({
<form
className="connection-manager-add"
onSubmit={(event) => {
event.preventDefault()
if (origin) void connect(origin)
event.preventDefault();
if (origin) void connect(origin);
}}
>
<h3>Add a Mastodon account</h3>
@@ -150,11 +136,7 @@ export function ConnectionManager({
) : instances.isError ? (
<p role="alert">
Could not load servers.
<Button
variant="outline"
type="button"
onClick={() => void instances.refetch()}
>
<Button variant="outline" type="button" onClick={() => void instances.refetch()}>
Retry
</Button>
</p>
@@ -183,12 +165,12 @@ export function ConnectionManager({
)}
{hasTemporaryDecks && (
<p className="connection-manager-note">
Temporary decks will be lost when you leave to authorize. Save any
decks you want to keep first.
Temporary decks will be lost when you leave to authorize. Save any decks you want to
keep first.
</p>
)}
{busy && <p role="status">Working…</p>}
</form>
</section>
)
);
}
+42 -53
View File
@@ -1,11 +1,6 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto'
import { readFileSync } from 'node:fs'
import { z } from 'zod'
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { z } from "zod";
const envelopeSchema = z
.object({
@@ -15,83 +10,77 @@ const envelopeSchema = z
tag: z.string(),
ciphertext: z.string(),
})
.strict()
.strict();
function decodeBase64(value: string): Buffer {
const result = Buffer.from(value, 'base64')
if (result.toString('base64') !== value) throw new Error('Invalid encoding')
return result
const result = Buffer.from(value, "base64");
if (result.toString("base64") !== value) throw new Error("Invalid encoding");
return result;
}
function loadKey() {
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE
if (!path) throw new Error('TWITTER_LITE_CREDENTIAL_KEY_FILE is required.')
let encoded: string
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE;
if (!path) throw new Error("TWITTER_LITE_CREDENTIAL_KEY_FILE is required.");
let encoded: string;
try {
encoded = readFileSync(path, 'utf8').trim()
encoded = readFileSync(path, "utf8").trim();
} catch {
throw new Error('Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.')
throw new Error("Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.");
}
let key: Buffer
let key: Buffer;
try {
key = decodeBase64(encoded)
if (key.length !== 32) throw new Error('Invalid key length')
key = decodeBase64(encoded);
if (key.length !== 32) throw new Error("Invalid key length");
} catch {
throw new Error(
'TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.',
)
"TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.",
);
}
return {
key,
keyId: createHash('sha256').update(key).digest('hex').slice(0, 32),
}
keyId: createHash("sha256").update(key).digest("hex").slice(0, 32),
};
}
function associatedData(context: string, keyId: string) {
if (!context) throw new Error('A credential record and purpose are required.')
return Buffer.from(
JSON.stringify(['twitter-lite-credential', 1, keyId, context]),
)
if (!context) throw new Error("A credential record and purpose are required.");
return Buffer.from(JSON.stringify(["twitter-lite-credential", 1, keyId, context]));
}
/** Context must identify both record and purpose; persist the returned opaque JSON. */
export function encryptCredential(plaintext: string, context: string): string {
const { key, keyId } = loadKey()
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
cipher.setAAD(associatedData(context, keyId))
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
])
const { key, keyId } = loadKey();
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
cipher.setAAD(associatedData(context, keyId));
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return JSON.stringify({
version: 1,
keyId,
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
})
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ciphertext: ciphertext.toString("base64"),
});
}
export function decryptCredential(serialized: string, context: string): string {
const { key, keyId } = loadKey()
const { key, keyId } = loadKey();
try {
const envelope = envelopeSchema.parse(JSON.parse(serialized))
if (envelope.keyId !== keyId) throw new Error('Different key')
const iv = decodeBase64(envelope.iv)
const tag = decodeBase64(envelope.tag)
if (iv.length !== 12 || tag.length !== 16)
throw new Error('Invalid envelope')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAAD(associatedData(context, keyId))
decipher.setAuthTag(tag)
const envelope = envelopeSchema.parse(JSON.parse(serialized));
if (envelope.keyId !== keyId) throw new Error("Different key");
const iv = decodeBase64(envelope.iv);
const tag = decodeBase64(envelope.tag);
if (iv.length !== 12 || tag.length !== 16) throw new Error("Invalid envelope");
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAAD(associatedData(context, keyId));
decipher.setAuthTag(tag);
return Buffer.concat([
decipher.update(decodeBase64(envelope.ciphertext)),
decipher.final(),
]).toString('utf8')
]).toString("utf8");
} catch {
throw new Error(
'Credential could not be decrypted. Check the stored credential and encryption key.',
)
"Credential could not be decrypted. Check the stored credential and encryption key.",
);
}
}
+97 -104
View File
@@ -1,126 +1,119 @@
// @vitest-environment node
import { randomBytes } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { decryptCredential, encryptCredential } from './credentials.server'
import { randomBytes } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { decryptCredential, encryptCredential } from "./credentials.server";
let directory: string
let keyPath: string
const context = 'connection:first:access-token'
const secret = 'sensitive-token-秘密'
let directory: string;
let keyPath: string;
const context = "connection:first:access-token";
const secret = "sensitive-token-秘密";
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-credentials-'))
keyPath = join(directory, 'key')
writeFileSync(keyPath, `${randomBytes(32).toString('base64')}\n`, {
directory = mkdtempSync(join(tmpdir(), "twitter-lite-credentials-"));
keyPath = join(directory, "key");
writeFileSync(keyPath, `${randomBytes(32).toString("base64")}\n`, {
mode: 0o600,
})
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', keyPath)
})
});
vi.stubEnv("TWITTER_LITE_CREDENTIAL_KEY_FILE", keyPath);
});
afterEach(() => {
vi.unstubAllEnvs()
rmSync(directory, { recursive: true, force: true })
})
vi.unstubAllEnvs();
rmSync(directory, { recursive: true, force: true });
});
it('round-trips credentials with distinct randomized ciphertext and no plaintext', () => {
const first = encryptCredential(secret, context)
const second = encryptCredential(secret, context)
expect(first).not.toBe(second)
expect(first).not.toContain(secret)
expect(first).not.toContain(Buffer.from(secret).toString('base64'))
it("round-trips credentials with distinct randomized ciphertext and no plaintext", () => {
const first = encryptCredential(secret, context);
const second = encryptCredential(secret, context);
expect(first).not.toBe(second);
expect(first).not.toContain(secret);
expect(first).not.toContain(Buffer.from(secret).toString("base64"));
expect(JSON.parse(first)).toMatchObject({
version: 1,
keyId: expect.stringMatching(/^[a-f0-9]{32}$/),
})
expect(decryptCredential(first, context)).toBe(secret)
expect(decryptCredential(second, context)).toBe(secret)
})
});
expect(decryptCredential(first, context)).toBe(secret);
expect(decryptCredential(second, context)).toBe(secret);
});
it.each([
'connection:second:access-token',
'oauth-app:first:client-secret',
])('rejects ciphertext moved to another record or purpose: %s', (otherContext) => {
const stored = encryptCredential(secret, context)
expect(() => decryptCredential(stored, otherContext)).toThrow(
'Credential could not be decrypted.',
)
})
it.each(["connection:second:access-token", "oauth-app:first:client-secret"])(
"rejects ciphertext moved to another record or purpose: %s",
(otherContext) => {
const stored = encryptCredential(secret, context);
expect(() => decryptCredential(stored, otherContext)).toThrow(
"Credential could not be decrypted.",
);
},
);
it.each([
'iv',
'tag',
'ciphertext',
'keyId',
'version',
])('rejects tampering with %s without disclosing the secret', (field) => {
const envelope = JSON.parse(encryptCredential(secret, context))
envelope[field] = 'tampered'
it.each(["iv", "tag", "ciphertext", "keyId", "version"])(
"rejects tampering with %s without disclosing the secret",
(field) => {
const envelope = JSON.parse(encryptCredential(secret, context));
envelope[field] = "tampered";
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
"Credential could not be decrypted. Check the stored credential and encryption key.",
);
},
);
it("rejects a validly encoded altered authentication tag", () => {
const envelope = JSON.parse(encryptCredential(secret, context));
const tag = Buffer.from(envelope.tag, "base64");
tag[0] = (tag[0] ?? 0) ^ 1;
envelope.tag = tag.toString("base64");
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted. Check the stored credential and encryption key.',
)
})
"Credential could not be decrypted.",
);
});
it('rejects a validly encoded altered authentication tag', () => {
const envelope = JSON.parse(encryptCredential(secret, context))
const tag = Buffer.from(envelope.tag, 'base64')
tag[0] = (tag[0] ?? 0) ^ 1
envelope.tag = tag.toString('base64')
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted.',
)
})
it("requires the original key after restart or restore", () => {
const originalKey = readFileSync(keyPath);
const stored = encryptCredential(secret, context);
writeFileSync(keyPath, randomBytes(32).toString("base64"));
expect(() => decryptCredential(stored, context)).toThrow("Credential could not be decrypted.");
writeFileSync(keyPath, originalKey);
expect(decryptCredential(stored, context)).toBe(secret);
});
it('requires the original key after restart or restore', () => {
const originalKey = readFileSync(keyPath)
const stored = encryptCredential(secret, context)
writeFileSync(keyPath, randomBytes(32).toString('base64'))
expect(() => decryptCredential(stored, context)).toThrow(
'Credential could not be decrypted.',
)
writeFileSync(keyPath, originalKey)
expect(decryptCredential(stored, context)).toBe(secret)
})
it.each(["not-json", "{}", '{"version":2}'])(
"rejects malformed or unsupported stored credentials: %s",
(serialized) => {
expect(() => decryptCredential(serialized, context)).toThrow(
"Credential could not be decrypted.",
);
},
);
it.each([
'not-json',
'{}',
'{"version":2}',
])('rejects malformed or unsupported stored credentials: %s', (serialized) => {
expect(() => decryptCredential(serialized, context)).toThrow(
'Credential could not be decrypted.',
)
})
it('requires an explicitly configured credential key', () => {
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', '')
it("requires an explicitly configured credential key", () => {
vi.stubEnv("TWITTER_LITE_CREDENTIAL_KEY_FILE", "");
expect(() => encryptCredential(secret, context)).toThrow(
'TWITTER_LITE_CREDENTIAL_KEY_FILE is required.',
)
})
"TWITTER_LITE_CREDENTIAL_KEY_FILE is required.",
);
});
it('reports an unreadable key without including the path or plaintext', () => {
rmSync(keyPath)
it("reports an unreadable key without including the path or plaintext", () => {
rmSync(keyPath);
expect(() => encryptCredential(secret, context)).toThrow(
'Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.',
)
})
"Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.",
);
});
it.each([
'',
'this is not base64',
Buffer.alloc(16).toString('base64'),
])('rejects invalid key material', (encoded) => {
writeFileSync(keyPath, encoded)
expect(() => encryptCredential(secret, context)).toThrow(
'must contain 32 random bytes encoded as base64.',
)
})
it.each(["", "this is not base64", Buffer.alloc(16).toString("base64")])(
"rejects invalid key material",
(encoded) => {
writeFileSync(keyPath, encoded);
expect(() => encryptCredential(secret, context)).toThrow(
"must contain 32 random bytes encoded as base64.",
);
},
);
it('refuses encryption without record binding', () => {
expect(() => encryptCredential(secret, '')).toThrow(
'A credential record and purpose are required.',
)
})
it("refuses encryption without record binding", () => {
expect(() => encryptCredential(secret, "")).toThrow(
"A credential record and purpose are required.",
);
});
+5 -5
View File
@@ -1,12 +1,12 @@
import { z } from 'zod'
import { z } from "zod";
const connectionSchema = z.object({
id: z.string().min(1),
platform: z.enum(['twitter', 'mastodon']),
platform: z.enum(["twitter", "mastodon"]),
origin: z.string().url(),
accountId: z.string().nullable(),
displayName: z.string(),
status: z.enum(['connected', 'disconnected', 'expired']),
})
status: z.enum(["connected", "disconnected", "expired"]),
});
export type Connection = z.infer<typeof connectionSchema>
export type Connection = z.infer<typeof connectionSchema>;
+40 -49
View File
@@ -1,73 +1,68 @@
import { randomUUID } from 'node:crypto'
import { asc, eq } from 'drizzle-orm'
import { ProfileUnavailableError } from '../profiles/errors'
import { fetchProfileNames } from '../profiles/profile-service.server'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections } from '../storage/schema'
import type { Connection } from './model'
import { randomUUID } from "node:crypto";
import { asc, eq } from "drizzle-orm";
import { ProfileUnavailableError } from "../profiles/errors";
import { fetchProfileNames } from "../profiles/profile-service.server";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { connections } from "../storage/schema";
import type { Connection } from "./model";
function relayOrigin(): string {
const configured = process.env.TWITTER_RELAY_BASE_URL
if (!configured)
throw new ProfileUnavailableError('Set TWITTER_RELAY_BASE_URL.')
return new URL(configured).origin
const configured = process.env.TWITTER_RELAY_BASE_URL;
if (!configured) throw new ProfileUnavailableError("Set TWITTER_RELAY_BASE_URL.");
return new URL(configured).origin;
}
async function syncTwitterConnections(
database: AppDatabase,
fetchProfiles: typeof fetchProfileNames,
) {
const origin = relayOrigin()
const origin = relayOrigin();
const profiles = new Set(
await fetchProfiles(origin).catch(() => {
throw new ProfileUnavailableError(
'Could not verify connection profiles. Check the relay connection and try again.',
)
"Could not verify connection profiles. Check the relay connection and try again.",
);
}),
)
);
database.transaction((tx) => {
const existing = tx
.select()
.from(connections)
.where(eq(connections.platform, 'twitter'))
.all()
const now = Date.now()
const existing = tx.select().from(connections).where(eq(connections.platform, "twitter")).all();
const now = Date.now();
for (const connection of existing) {
const status =
connection.origin === origin &&
connection.relayProfile !== null &&
profiles.has(connection.relayProfile)
? 'connected'
: 'disconnected'
? "connected"
: "disconnected";
if (connection.status !== status) {
tx.update(connections)
.set({ status, updatedAt: now })
.where(eq(connections.id, connection.id))
.run()
.run();
}
}
const known = new Set(
existing
.filter((connection) => connection.origin === origin)
.map((connection) => connection.relayProfile),
)
);
for (const profile of profiles) {
if (known.has(profile)) continue
if (known.has(profile)) continue;
tx.insert(connections)
.values({
id: randomUUID(),
platform: 'twitter',
platform: "twitter",
origin,
relayProfile: profile,
displayName: profile,
status: 'connected',
status: "connected",
createdAt: now,
updatedAt: now,
})
.run()
.run();
}
})
return origin
});
return origin;
}
const publicFields = {
@@ -77,25 +72,25 @@ const publicFields = {
accountId: connections.accountId,
displayName: connections.displayName,
status: connections.status,
}
};
export async function listConnections(
database = getDatabase(),
fetchProfiles = fetchProfileNames,
): Promise<{ connections: Connection[]; relayError?: string }> {
let relayError: string | undefined
let relayError: string | undefined;
try {
await syncTwitterConnections(database, fetchProfiles)
await syncTwitterConnections(database, fetchProfiles);
} catch (error) {
if (!(error instanceof ProfileUnavailableError)) throw error
relayError = error.message
if (!(error instanceof ProfileUnavailableError)) throw error;
relayError = error.message;
}
const saved = database
.select(publicFields)
.from(connections)
.orderBy(asc(connections.createdAt), asc(connections.id))
.all()
return { connections: saved, ...(relayError ? { relayError } : {}) }
.all();
return { connections: saved, ...(relayError ? { relayError } : {}) };
}
export async function requireTwitterConnection(
@@ -103,21 +98,17 @@ export async function requireTwitterConnection(
database = getDatabase(),
fetchProfiles = fetchProfileNames,
): Promise<string> {
const origin = await syncTwitterConnections(database, fetchProfiles)
const connection = database
.select()
.from(connections)
.where(eq(connections.id, id))
.get()
const origin = await syncTwitterConnections(database, fetchProfiles);
const connection = database.select().from(connections).where(eq(connections.id, id)).get();
if (
connection?.platform !== 'twitter' ||
connection?.platform !== "twitter" ||
connection.origin !== origin ||
connection.status !== 'connected' ||
connection.status !== "connected" ||
!connection.relayProfile
) {
throw new ProfileUnavailableError(
'This connection is unavailable. Check the connected account for this column.',
)
"This connection is unavailable. Check the connected account for this column.",
);
}
return connection.relayProfile
return connection.relayProfile;
}
+100 -123
View File
@@ -1,157 +1,134 @@
// @vitest-environment node
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { listConnections, requireTwitterConnection } from './repository.server'
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connectionCredentials, connections } from "../storage/schema";
import { listConnections, requireTwitterConnection } from "./repository.server";
let database: AppDatabase
let database: AppDatabase;
beforeEach(() => {
database = openDatabase(':memory:')
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid/')
})
database = openDatabase(":memory:");
vi.stubEnv("TWITTER_RELAY_BASE_URL", "https://relay.invalid/");
});
afterEach(() => {
database.$client.close()
vi.unstubAllEnvs()
})
database.$client.close();
vi.unstubAllEnvs();
});
it('retains stable distinct IDs for multiple relay profiles across repeated discovery', async () => {
const fetchProfiles = vi.fn().mockResolvedValue(['first', 'second', 'first'])
const { connections: first } = await listConnections(database, fetchProfiles)
const { connections: second } = await listConnections(database, fetchProfiles)
expect(first).toHaveLength(2)
expect(new Set(first.map((connection) => connection.id)).size).toBe(2)
expect(second).toEqual(first)
expect(
await requireTwitterConnection(first[0]?.id ?? '', database, fetchProfiles),
).toBe(first[0]?.displayName)
expect(fetchProfiles).toHaveBeenCalledWith('https://relay.invalid')
})
it("retains stable distinct IDs for multiple relay profiles across repeated discovery", async () => {
const fetchProfiles = vi
.fn<() => Promise<string[]>>()
.mockResolvedValue(["first", "second", "first"]);
const { connections: first } = await listConnections(database, fetchProfiles);
const { connections: second } = await listConnections(database, fetchProfiles);
expect(first).toHaveLength(2);
expect(new Set(first.map((connection) => connection.id)).size).toBe(2);
expect(second).toEqual(first);
expect(await requireTwitterConnection(first[0]?.id ?? "", database, fetchProfiles)).toBe(
first[0]?.displayName,
);
expect(fetchProfiles).toHaveBeenCalledWith("https://relay.invalid");
});
it('preserves unavailable bindings and reconnects the original ID when a profile returns', async () => {
const { connections: original } = await listConnections(
database,
async () => ['first'],
)
const id = original[0]?.id ?? ''
expect((await listConnections(database, async () => [])).connections).toEqual(
[expect.objectContaining({ id, status: 'disconnected' })],
)
await expect(
requireTwitterConnection(id, database, async () => []),
).rejects.toThrow('This connection is unavailable')
expect(
(await listConnections(database, async () => ['first'])).connections,
).toEqual(original)
})
it("preserves unavailable bindings and reconnects the original ID when a profile returns", async () => {
const { connections: original } = await listConnections(database, async () => ["first"]);
const id = original[0]?.id ?? "";
expect((await listConnections(database, async () => [])).connections).toEqual([
expect.objectContaining({ id, status: "disconnected" }),
]);
await expect(requireTwitterConnection(id, database, async () => [])).rejects.toThrow(
"This connection is unavailable",
);
expect((await listConnections(database, async () => ["first"])).connections).toEqual(original);
});
it('does not reuse an old relay binding for the same profile at another origin', async () => {
const { connections: original } = await listConnections(
database,
async () => ['first'],
)
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://another-relay.invalid')
const { connections: current } = await listConnections(database, async () => [
'first',
])
expect(current).toHaveLength(2)
it("does not reuse an old relay binding for the same profile at another origin", async () => {
const { connections: original } = await listConnections(database, async () => ["first"]);
vi.stubEnv("TWITTER_RELAY_BASE_URL", "https://another-relay.invalid");
const { connections: current } = await listConnections(database, async () => ["first"]);
expect(current).toHaveLength(2);
expect(current).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: original[0]?.id, status: 'disconnected' }),
expect.objectContaining({ id: original[0]?.id, status: "disconnected" }),
expect.objectContaining({
origin: 'https://another-relay.invalid',
status: 'connected',
origin: "https://another-relay.invalid",
status: "connected",
}),
]),
)
);
await expect(
requireTwitterConnection(original[0]?.id ?? '', database, async () => [
'first',
]),
).rejects.toThrow('This connection is unavailable')
})
requireTwitterConnection(original[0]?.id ?? "", database, async () => ["first"]),
).rejects.toThrow("This connection is unavailable");
});
it('does not convert a relay outage into persisted account removal', async () => {
const original = await listConnections(database, async () => ['first'])
it("does not convert a relay outage into persisted account removal", async () => {
const original = await listConnections(database, async () => ["first"]);
const unavailable = vi
.fn()
.mockRejectedValue(new Error('private network detail'))
const failed = await listConnections(database, unavailable)
expect(failed.connections).toEqual(original.connections)
expect(failed.relayError).toContain(
'Check the relay connection and try again.',
)
expect(database.select().from(connections).get()?.status).toBe('connected')
expect(await listConnections(database, async () => ['first'])).toEqual(
original,
)
})
.fn<() => Promise<string[]>>()
.mockRejectedValue(new Error("private network detail"));
const failed = await listConnections(database, unavailable);
expect(failed.connections).toEqual(original.connections);
expect(failed.relayError).toContain("Check the relay connection and try again.");
expect(database.select().from(connections).get()?.status).toBe("connected");
expect(await listConnections(database, async () => ["first"])).toEqual(original);
});
it('returns public metadata for all platforms without joining or exposing credentials', async () => {
it("returns public metadata for all platforms without joining or exposing credentials", async () => {
database
.insert(connections)
.values({
id: 'mastodon-account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '42',
displayName: '@owner',
status: 'connected',
id: "mastodon-account",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "42",
displayName: "@owner",
status: "connected",
createdAt: 1,
updatedAt: 1,
})
.run()
.run();
database
.insert(connectionCredentials)
.values({
connectionId: 'mastodon-account',
encryptedToken: 'stored-secret-envelope',
connectionId: "mastodon-account",
encryptedToken: "stored-secret-envelope",
updatedAt: 1,
})
.run()
const { connections: discovered } = await listConnections(
database,
async () => ['first'],
)
.run();
const { connections: discovered } = await listConnections(database, async () => ["first"]);
expect(discovered[0]).toEqual({
id: 'mastodon-account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '42',
displayName: '@owner',
status: 'connected',
})
id: "mastodon-account",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "42",
displayName: "@owner",
status: "connected",
});
expect(Object.keys(discovered[1] ?? {}).sort()).toEqual([
'accountId',
'displayName',
'id',
'origin',
'platform',
'status',
])
expect(JSON.stringify(discovered)).not.toContain('stored-secret-envelope')
"accountId",
"displayName",
"id",
"origin",
"platform",
"status",
]);
expect(JSON.stringify(discovered)).not.toContain("stored-secret-envelope");
const outage = await listConnections(database, async () => {
throw new Error('offline')
})
expect(outage.connections).toEqual(discovered)
expect(outage.relayError).toBeDefined()
throw new Error("offline");
});
expect(outage.connections).toEqual(discovered);
expect(outage.relayError).toBeDefined();
await expect(
requireTwitterConnection('mastodon-account', database, async () => [
'first',
]),
).rejects.toThrow('This connection is unavailable')
requireTwitterConnection("mastodon-account", database, async () => ["first"]),
).rejects.toThrow("This connection is unavailable");
expect(
database
.select()
.from(connections)
.where(eq(connections.id, 'mastodon-account'))
.get()?.status,
).toBe('connected')
})
database.select().from(connections).where(eq(connections.id, "mastodon-account")).get()?.status,
).toBe("connected");
});
it('rejects an unknown connection without treating its ID as a relay profile', async () => {
await expect(
requireTwitterConnection('first', database, async () => ['first']),
).rejects.toThrow('This connection is unavailable')
})
it("rejects an unknown connection without treating its ID as a relay profile", async () => {
await expect(requireTwitterConnection("first", database, async () => ["first"])).rejects.toThrow(
"This connection is unavailable",
);
});

Some files were not shown because too many files have changed in this diff Show More