diff --git a/.env.example b/.env.example index 5f5d809..378df15 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1 @@ TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 -BIRD_PROFILE_NAME= diff --git a/.hallmark/log.json b/.hallmark/log.json index 11ab754..b6bbf2e 100644 --- a/.hallmark/log.json +++ b/.hallmark/log.json @@ -1,4 +1,12 @@ [ + { + "date": "2026-09-24", + "macrostructure": "Workbench", + "theme": "custom", + "theme_axes": "dark navy / system sans / blue", + "enrichment": "none", + "brief": "Full-height TweetDeck-style research columns with compact sidebar and modal editors" + }, { "date": "2026-07-26", "macrostructure": "Workbench", diff --git a/.hallmark/preflight.json b/.hallmark/preflight.json index b575cc4..9d107d9 100644 --- a/.hallmark/preflight.json +++ b/.hallmark/preflight.json @@ -1,9 +1,10 @@ { - "date": "2026-07-26", - "font_stack": "Inter / Noto Sans JP / system sans declared in src/styles.css", - "palette": "CSS custom properties with muted teal hex values", - "motion": "motion-cut; no motion library", - "spacing": "no named scale", + "date": "2026-09-24", + "font_stack": "System sans; tokens.css", + "palette": "Dark navy and blue OKLCH tokens", + "motion": "motion-cut", + "spacing": "named token scale", "framework": "React 19 + TanStack Start + Vite 8", - "preserve": ["routes", "information architecture", "read-only behavior"] + "preserve": ["deck data model", "profile binding", "WebMCP contracts"], + "override": "User requested TweetDeck-like full UI redesign instead of Garden reader" } diff --git a/README.md b/README.md index f178a09..08fb7b7 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,32 @@ # Twitter Lite -Twitter Lite is an intentional, read-only X reader. It shows content only after -you enter a user handle, profile URL, search query, or list URL, follow a -deliberate post detail link, or manually open a valid -`/:handle/status/:tweetId` URL. +Twitter Lite is a read-only research deck for X. Create multiple named decks, +and arrange up to six columns per deck for searches, user timelines, and lists. +Each column is bound to an explicit relay profile, so different accounts can +be used side by side. ## Scope -- User timelines and raw X search syntax -- Button-driven search filters for author, period, language, content type, replies, and reposts -- Popular (`Top`) and chronological (`Latest`) search -- Optional `filter:follows` search -- Authenticated account list selection and list timelines from a deliberate URL or ID -- Relay profile switching from the profiles exposed by the relay -- Infinite cursor pagination with explicit retry -- Deliberate post detail pages with the visible conversation -- Infinite conversation loading with explicit continuation retry -- Read-only cards for text, media, quotes, articles, and quiet engagement counts -- Experimental WebMCP tools for search, loaded-post reading, and continuation -- Deck WebMCP tools to inspect or create/replace up to six columns at once -- Research deck with named Twitter search columns, independent pagination, - editing, ordering, and browser-local condition persistence +- Multiple deck profiles with creation, selection, renaming, and deletion +- Search, user timeline, and list columns with independent relay profiles +- Native X search syntax, Top/Latest ranking, and optional `filter:follows` +- List discovery for the profile selected in the column editor +- Column editing, ordering, deletion/undo, manual refresh, and cursor pagination +- Browser-local persistence of deck definitions and the active deck +- Read-only cards with original-post links, text, media, and quotes +- Experimental WebMCP tools to manage decks and read or paginate their columns -It intentionally has no home feed, recommendations, trends, notifications, -history, account directory, or write actions. +Both `/` and `/deck` open the deck workspace. The separate reader, search, +list, user-profile, and conversation routes have been removed. Original-post +links open X; conversations are not rendered inside the app. -Post detail pages show only the selected post and its visible conversation; -they do not add related-post recommendations or an account-discovery surface. - -## Routes - -- `/` shows the empty user input. -- `/deck` opens the research deck (up to six search columns). -- `/:handle` shows a user timeline. -- `/search` shows search results. Its typed query parameters preserve the query, - author, period, language, content type, exclusions, ranking, and follows filter - so filtered searches can be reloaded or shared. -- `/i/lists` shows the authenticated account's lists. -- `/i/lists/:listId` shows a list timeline. -- `/:handle/status/:tweetId` shows one selected post and its visible conversation. +Built-in AI planning, summaries, and the Mastodon/Bluesky/Threads/Nostr +connectors are not implemented yet. Twitter is the only supported platform. ## Requirements and setup Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc` -routes the `@yuta` scope to the public Gitea Packages registry, where Bird -`0.10.0` provides the required Top and Latest search interface. +routes the `@yuta` scope to the public Gitea Packages registry. ```bash nix develop -c pnpm install --frozen-lockfile @@ -52,21 +34,19 @@ cp .env.example .env.local ``` The Nix development shell installs Hallmark's agent skill for the supported -local agent targets. - -Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development. -`BIRD_PROFILE_NAME` optionally selects the initial relay profile; the header -selector can switch between the profiles returned by the relay's `/profiles` -endpoint. Export the same values in the process environment before `start` or -`test:live`: +local agent targets. Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite +development, or export it before running the production server: ```bash export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 -export BIRD_PROFILE_NAME= ``` -Both values are read by server-only code at runtime; neither value nor Bird is -sent to the browser. +The app discovers available account profiles through the relay's `/profiles` +endpoint. Select a profile for each column. There is no global account selector, +profile cookie, or application-level `BIRD_PROFILE_NAME` default. Requests +validate that the bound profile still exists before fetching posts or lists. +Bird and relay credentials stay on the server; saved deck definitions include +the selected profile names. ## Commands @@ -82,68 +62,64 @@ nix develop -c pnpm build nix develop -c pnpm start ``` -`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by -the dev shell. Browser tests are organized under `e2e/`: page object models -and fixtures are shared by integration flows and Axe checks for WCAG 2.0/2.1 -A and AA. The tests keep a standalone HTTP relay so TanStack Start server -functions and SSR requests exercise the same network boundary as production. -The API client and fixture types are generated with Orval from a pinned -[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba); -run `generate:e2e-openapi` only when updating that pin or the selected -operations. Generated files are committed, so normal E2E runs do not require -network access. E2E fixtures use the generated response types but stay -deterministic. Runtime requests continue through Bird because its transport -differs from the upstream OpenAPI client. Relay-only endpoints, request -transport differences, cursors, and stateful failure scenarios remain in the -handwritten mock server. -`test:live` is an explicit, opt-in smoke test that performs exactly one -read-only Top search using the configured relay. +`test:e2e` uses the system Chromium supplied by the Nix dev shell. Playwright +integration and Axe accessibility tests live under `e2e/`. A standalone mock +HTTP relay exercises the production server-function boundary without contacting +X or a personal relay. Orval generates API fixture types from a pinned +[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba). +Generated files are committed; run `generate:e2e-openapi` only when changing +the pin or selected operations. Runtime requests use Bird. -## Research deck +`test:live` is an explicit, opt-in smoke test that performs one read-only Top +search. For this standalone probe only, `BIRD_PROFILE_NAME` selects the account +and `TWITTER_LITE_LIVE_QUERY` overrides the search text. -Open **デッキ** in the navigation, name the investigation, and add a column -for each perspective. Each column has a title, native Twitter search query, -Top/Latest ordering, and an optional follows filter. Update or paginate a -column independently, edit its conditions, move it left/right, or undo a -removal. All columns use the currently selected relay profile. +## Deck workspace -One deck's conditions are stored in this browser's localStorage. Reloading -fetches the first page again; collected posts are not archived or synced -between devices. Other open tabs are not synchronized. A WebMCP-capable agent -can use `get_deck` and `set_deck` on `/deck` to create or edit the whole deck. -Built-in AI planning, summaries, and the Mastodon/Bluesky/Threads/Nostr connectors -are not implemented yet. Only Twitter can currently be selected. See -[the platform boundary and next steps](docs/research-decks.md). +The interface uses a full-height, dark TweetDeck-style workbench: a deck sidebar, +compact column headers, and independently scrolling post columns. On narrow +screens, the sidebar becomes a compact top bar. Add/edit forms open in native +dialogs; Escape closes them and returns focus. Column menus contain editing, +ordering, and deletion; refresh stays available directly in each header. -## WebMCP prototype +Create a deck for an investigation, then add columns for each perspective. +Choose a relay profile and a source: search, user timeline, or list. Profile +changes affect only the edited column. Matching source conditions and profiles +share the query cache; different profiles never share posts or cursors. -In a WebMCP-enabled browser, `search_posts` searches and updates the page, -`get_loaded_posts` reads a bounded slice of the active feed, and -`load_more_posts` appends a continuation and returns the new posts. The tools -share the reader's query cache and active relay profile. They do not publish -posts or change profiles. +The workspace saves multiple decks and the active selection in localStorage. +Only the active deck is rendered and fetched. Posts and cursors are not saved; +reloading fetches first pages. Devices and tabs do not synchronize edits. + +Old single-deck data is not migrated automatically because it has no explicit +column profile binding. Invalid or older saved data remains untouched while +the app shows an empty workspace and an explanation. Saving a new edit replaces +that saved data. See [the deck model and persistence contract](docs/research-decks.md). + +## WebMCP + +A WebMCP-enabled browser exposes `list_decks`, `get_deck`, `set_deck`, +`select_deck`, `delete_deck`, `get_column_posts`, and `load_more_column` on +both deck routes. Start with `list_decks` to discover deck IDs and available +relay profiles. `set_deck` creates a new deck when `deckId` is omitted; supply +an existing ID to replace that deck's complete ordered columns and activate it. Enable `chrome://flags/#enable-webmcp-testing`, restart Chrome, and open the -app on `http://localhost:3000` or `http://127.0.0.1:3000`. Use the +local app. Use the [Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp#imitate_agent_chat_with_the_inspector_extension) -to list and invoke tools. Try `search_posts` with -`{"q":"TypeScript","lang":"ja","product":"Latest"}`. - -This prototype uses native `document.modelContext` through `usewebmcp`; it -does not initialize a polyfill or provide an external MCP transport. Browsers -without the API keep the regular reader UI. Public deployment requires the -appropriate browser support/origin trial and a secure context. See -[tool contracts and verification](docs/webmcp-prototype.md) for details. +to invoke tools. Registration uses native `document.modelContext` through +`usewebmcp`; there is no polyfill or external MCP transport. Unsupported +browsers retain the manual deck UI. See [tool contracts and verification](docs/webmcp-prototype.md). Development binds to `127.0.0.1` by default. `dev:tailscale` binds to -`0.0.0.0`, so it exposes the app on LAN interfaces as well as Tailscale. Use it -only on a trusted network and obtain the Tailscale address with -`tailscale ip -4`. +`0.0.0.0`, including LAN interfaces. Native WebMCP needs a secure context; +use a Tailscale Serve HTTPS origin for remote access, and allow its exact +hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process +environment. HTTP and HTTPS origins have separate localStorage. ## NixOS service -The flake provides both a production package and a NixOS module. Import the -module and configure the relay: +The flake provides a production package and a NixOS module: ```nix { @@ -159,7 +135,6 @@ module and configure the relay: services.twitter-lite = { enable = true; relayBaseUrl = "http://127.0.0.1:6900"; - # profileName = "default"; }; } ]; @@ -170,13 +145,10 @@ module and configure the relay: The service listens on `127.0.0.1:3000` by default. Set `services.twitter-lite.host` or `services.twitter-lite.port` to change the -listener. The package can also be built directly with `nix build`. +listener. Profiles are selected in column definitions, not service options. +The package can also be built directly with `nix build`. ## Reliability Bird uses X's internal GraphQL operations through the configured relay. Query IDs and response shapes can change without notice. - -Conversation pages use Bird's current per-page chronological ordering. Keeping -X's original ranked branch order is deferred until Bird exposes that order -without expanding the relay surface. diff --git a/docs/research-decks.md b/docs/research-decks.md index 6d2b4d0..be61bac 100644 --- a/docs/research-decks.md +++ b/docs/research-decks.md @@ -1,134 +1,140 @@ # Research decks +## Workspace interface + +The deck occupies the viewport with a dark sidebar and horizontally arranged, +independently scrolling columns. A compact toolbar names the active deck. +The sidebar switches decks, adds columns, and jumps to a column; on mobile, +it becomes a compact top bar. Column header menus expose editing, ordering, +and deletion. Creation and editing use native modal dialogs with Escape and +focus restoration. Tokens use a navy/blue palette and a system sans font. +This replaces the original spacious Garden reader layout. Layout inspiration: +[Twitter's TweetDeck design notes](https://blog.x.com/en_us/a/2012/designing-the-new-tweetdeck). + ## Product direction -A research topic should become a TweetDeck-style workspace: columns represent -questions or perspectives, collect posts across social platforms, and support -AI summaries with references back to the evidence. The intended platforms are -Twitter, Mastodon, Bluesky, Threads, and Nostr. +A research topic becomes a TweetDeck-style workspace: columns represent +questions or perspectives and will eventually collect posts across Twitter, +Mastodon, Bluesky, Threads, and Nostr for AI summaries with source references. +The current implementation supports Twitter only, with manually or +WebMCP-authored decks. Built-in planning, summaries, and other connectors +remain future work. -The first increment is a manually composed deck backed by Twitter. It makes -the deck definition, column lifecycle, and post normalization concrete before -adding AI or more connectors. +## Workspace and column model -## Implemented boundary +Both `/` and `/deck` render the same deck-only application. Separate reader, +search, list, user-profile, and conversation pages have been removed. +Original-post links open X. -- `src/features/decks/model.ts` validates a versioned deck definition: name, - ordered columns with stable IDs, and provider-specific search conditions. - At most six columns are loaded, bounding concurrent first-page requests. -- `src/features/platforms/types.ts` defines the display/evidence record without - importing Bird: stable key, platform, native identity, original URL, text, - author, optional publication time, media, and quoted post. -- `src/features/platforms/twitter.ts` maps existing reader posts into that - record. Keys use `twitter:` rather than the author's mutable handle. - Raw provider responses and credentials do not enter the common record. -- `ResearchColumn` uses the existing Twitter search hook and server functions. - Equal search conditions share the reader's query cache. Distinct conditions - have independent loading/error/pagination state. Profile changes invalidate - queries through the existing profile switcher. -- `ResearchPostCard` only consumes the common record. Provider-specific detail - routes remain in the existing Twitter reader. Original-post links, media, - and one level of quotes are shown in the deck; engagement metrics and - Twitter article previews are not normalized yet. +`src/features/decks/model.ts` validates a version-2 workspace containing +`activeDeckId` and one or more named decks. Each deck has a stable ID and up to +six ordered columns. Only the active deck mounts its columns. The UI supports +creating, selecting, renaming, and deleting deck profiles; the last deck cannot +be deleted. Deleting the active deck selects the first remaining deck. -The current source is a single Twitter search per column. Its `platform` -discriminator is the extension point for another real connector, not a claim -that five adapters already work. Twitter's raw syntax, Top/Latest, and follows -filter are not requirements imposed on the other platforms. Unsupported -source definitions fail validation instead of silently dropping conditions. +Each column has a stable ID, title, required `profileName`, and one source: -The existing reader and its active-feed WebMCP tools remain separate from the -deck. Mounting multiple reader `PostFeed` components would register conflicting -active-feed tools; deck columns therefore use the search hook directly. -On `/deck`, `get_deck` and `set_deck` expose the definition once local storage -has loaded. They unregister when leaving the route. +| Source kind | Conditions | +| --- | --- | +| `search` | Native Twitter `query`, `product` (`Top`/`Latest`), and `following` | +| `user` | `target`: handle or X/Twitter profile URL, normalized to a handle | +| `list` | `target`: numeric ID or X/Twitter list URL, normalized to an ID | + +All sources currently require `platform: "twitter"`. Unsupported definitions +fail validation. Column IDs must be unique within a deck, and deck IDs within +the workspace. Manual edits and agent tools use the same final schema. + +`profileName` is the relay account binding, distinct from a named deck profile. +The editor discovers names through `/profiles` and lists through the selected +profile. A column's profile can be changed independently. Every feed and list +request carries an explicit profile name; the server confirms it still exists. +Deleted profiles and unavailable discovery produce errors instead of falling +back to another account. There is no browser-wide profile selection. + +The complete source and profile participate in query-cache identity. Equal +conditions on the same profile share loaded pages; distinct profiles retain +separate results and cursors. Columns have independent refresh, pagination, +and error state. Pagination and refresh are manual, with no polling. + +## Platform boundary + +`src/features/platforms/types.ts` defines the display/evidence record without +Bird imports: stable key, platform, native identity, original URL, text, +author, optional publication time, media, and quoted post. The Twitter mapper +uses `twitter:` keys rather than mutable author handles. Raw responses +and credentials do not enter this record. Cards consume the normalized record; +engagement metrics and Twitter article previews are not normalized yet. + +The source union is the extension point for future connectors. Twitter search +syntax and ranking controls are provider-specific. When implementing another +connector, add its real schema and server operation, normalize stable identity, +and bind connection details into cache identity. Multiple sources in one +column should wait until a second connector exercises that need; each source +must retain its own opaque continuation and error state. + +## Persistence + +The workspace is stored under `twitter-lite-research-deck` in localStorage, +including all deck definitions and the active selection. It stores conditions +and relay profile names, not credentials, posts, summaries, or cursors. +Reloading fetches first pages of the selected deck. SSR and the first browser +render show a loading state until storage has been read. + +There is no automatic migration of the former single-deck format, which did +not pin profiles to columns. Invalid or older saved data remains untouched +while the UI presents an empty workspace and an error. An explicit saved edit +replaces it. Storage failures are visible: changes still apply in the current +tab, but persistence failures mean they will be lost on reload. Tabs and +devices do not synchronize; the last write to an origin's localStorage wins. +HTTP and HTTPS origins maintain separate workspaces. ## Deck WebMCP tools -`get_deck({})` returns the current definition (including column IDs) and any -storage error. `set_deck` replaces the complete ordered definition and closes -unsaved editor forms. Read before editing, keep IDs of retained columns, and -include every column you want to keep. Omit IDs for new columns. An empty array -clears the deck. The same deck schema validates the entire input before changes. +Both deck routes expose workspace management and active-column reading. +`list_decks` discovers definitions and available relay profiles; `get_deck` +reads a specific or active deck. `set_deck` creates or replaces and activates a +deck; `select_deck` and `delete_deck` operate by ID. `get_column_posts` and +`load_more_column` read or paginate columns in the active deck. See the +[full tool contracts](webmcp-prototype.md). + +For example, after discovering a relay profile named `main`, create a deck: ```json { "title": "WebMCPの反応", "columns": [ - { "title": "日本語", "source": { "query": "WebMCP lang:ja" } }, - { "title": "海外の話題", "source": { "query": "WebMCP lang:en", "product": "Top" } } + { + "title": "日本語", + "profileName": "main", + "source": { "kind": "search", "query": "WebMCP lang:ja" } + }, + { + "title": "開発者", + "profileName": "main", + "source": { "kind": "user", "target": "@example" } + } ] } ``` -`source.platform` defaults to `twitter`, `product` to `Latest`, and `following` -to false. `set_deck` returns the applied definition and `persisted: true` before -post loading completes. It does not claim that searches succeeded. A storage -failure returns `isError: true` and explains that the in-memory change was -applied but will be lost on reload. Invalid input changes neither UI nor storage. +Omitting `deckId` creates a deck. To edit, read first and include its `deckId` +and every column to retain; keep existing column IDs. Omitted columns are +removed and omitted column IDs are generated. Post loading is asynchronous, +so a successful save does not mean the upstream requests succeeded. -Native WebMCP needs a supported browser and a secure context. Use the HTTPS -Tailscale Serve origin rather than an HTTP tailnet IP. For Vite, allow that exact -hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the dev process's -environment. HTTP and HTTPS origins have separate browser-local deck storage. +## Future AI work -## Persistence and fetching - -One deck is saved under `twitter-lite-research-deck` in localStorage. It contains -only conditions and names, not posts, summaries, credentials, or cursors. -SSR and the initial browser render show a loading state before reading it. -Invalid saved data is retained until the user explicitly saves an edit. Storage -read/write failures are surfaced in the UI. - -The selected relay profile is browser-wide, not pinned to a column. Reopening -a deck uses that current profile and fetches first pages. Pagination is manual; -there is no polling or background collection. Tabs do not synchronize deck -edits; the last edit written to localStorage wins. LocalStorage is an initial -single-browser workspace, not the eventual research archive. - -## Adding the second platform - -1. Add a real provider-specific source schema and a validated server-side - search operation. Keep authentication on the server. Extend the source - union and put dispatch at the feed boundary, outside the post card. -2. Implement normalization with stable provider identity. For Mastodon, do not - treat an instance-local numeric ID as globally unique; for Bluesky prefer - the canonical record identity; for Nostr use event identity. The human - original-post URL and the deduplication identity are separate fields. -3. Add a connection reference when per-column accounts, Mastodon instances, - or Nostr relay sets are introduced. Include it in query-cache identity. -4. Support multiple sources within a column only when a second connector can - exercise it. Each source needs its own continuation and error state. Do not - merge opaque upstream cursors into one cross-platform cursor or claim a - globally complete chronological feed from separately ranked searches. - -Search capability is connection-dependent. Mastodon documents that status -search depends on the instance's search backend and authentication: -[Mastodon search API](https://docs.joinmastodon.org/methods/search/). -Nostr's full-text search is an optional relay capability: -[NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md). -Validate actual connection capabilities when those connectors are added. -Threads authorization and Bluesky endpoint behavior must likewise be verified -when implementing those adapters, not inferred from Twitter's contract. - -## AI increment - -The next product slice should turn a topic into validated column definitions, -then let the user refine them. AI-generated conditions use the same schema as -manual edits. Summaries should reference a persisted collection snapshot -(post keys, original URLs, retrieval time, source/query and profile context), -so a later refresh does not change the evidence behind an earlier claim. -LocalStorage of conditions alone does not provide this evidence store. - -ACP or Codex app-server can connect the planner to the application, but they -are not part of the platform data model or this implementation. +A planner can generate definitions through the existing schema and tools. +Summaries will need persisted collection snapshots: post identity and URL, +retrieval time, source conditions, and profile context. Saved conditions alone +do not preserve the evidence behind a summary. ACP or Codex app-server may +connect a future planner, but neither is part of this implementation. ## Verification -Unit tests cover normalization, source/definition validation, ordering and -storage errors. Playwright tests use the existing standalone mock relay through -the real server functions to verify independent pagination, retry, editing, -ordering, reload, deletion/undo, and invalid saved data. They also check viewport -widths 320/375/414/768 and run Axe on the populated deck. - -No live SNS search is required by these tests. +Unit tests cover normalization, source/workspace validation, ordering, +profile-specific caching and pagination, profile discovery failures, and +storage behavior. Playwright uses a standalone mock relay through real server +functions to exercise deck switching, column/profile editing, list selection, +pagination, persistence, and native WebMCP. Accessibility checks run on the +workspace. Automated tests do not need a live SNS search. diff --git a/docs/webmcp-prototype.md b/docs/webmcp-prototype.md index 98c3698..c3e2ccc 100644 --- a/docs/webmcp-prototype.md +++ b/docs/webmcp-prototype.md @@ -1,98 +1,88 @@ # WebMCP prototype -## Scope and design +## Scope and registration -Expose the existing intentional reader to agents through three React-owned -reader tools. The deck additionally exposes `get_deck` and `set_deck` on `/deck`; -see [deck tool contracts](research-decks.md#deck-webmcp-tools). -Search remains available across routes. Feed tools are registered only -while a valid timeline, search, or conversation is open. Returning home or to -an empty search/list page removes the feed tools. Unsupported browsers do not -register tools or fetch posts automatically. +The deck workspace exposes seven React-owned tools on `/` and `/deck`. +`usewebmcp` owns native browser registration and cleanup. There is no polyfill +or external MCP transport. Unsupported browsers retain the manual UI. Tools +are enabled after local storage loads; relay-profile discovery may still be +pending, which `list_decks` reports as `profiles: null`. -`usewebmcp` 5.1.0 owns browser registration and cleanup. No polyfill is -initialized. The root search tool navigates through TanStack Router and awaits -the same query options as the visible feed. Completed data is reused through -`ensureInfiniteQueryData`; an invalidated or in-progress query is awaited -through `fetchInfiniteQuery`, including refreshes after a profile change. -Reading the feed uses its current React Query result. Continuation and the -scroll observer use `fetchNextPage({ cancelRefetch: false })` to join an -existing request. A feed change during continuation returns an error instead -of reporting old results as belonging to the new feed. +The former `search_posts`, `get_loaded_posts`, and `load_more_posts` tools and +standalone reader routes have been removed. Agents manage named decks and +address columns explicitly, including their bound relay profiles. -## Tool contracts +## Workspace tools -### `search_posts` - -Search X, show the criteria and results in the UI, and return the first slice -after retrieval. Searches use the currently selected relay profile. - -Inputs match the existing search controls: - -| Field | Default | Meaning | +| Tool | Input | Behavior | | --- | --- | --- | -| `q` | empty | Search text, including raw X search syntax | -| `from` | empty | Author handle, optionally prefixed with `@` | -| `since` | empty | Inclusive `YYYY-MM-DD` date using X search semantics | -| `until` | empty | Exclusive `YYYY-MM-DD` date, later than `since` | -| `lang` | `all` | `all`, `ja`, or `en` | -| `content` | `all` | `all`, `images`, `videos`, or `links` | -| `excludeReplies`, `excludeReposts` | `false` | Exclusions | -| `product` | `Latest` | `Latest` or `Top` | -| `following` | `false` | Restrict to followed accounts | +| `list_decks` | `{}` | Return all deck definitions, `activeDeckId`, available `profiles`, and `storageError` | +| `get_deck` | Optional `deckId` | Read a saved deck; omitted ID selects the active deck | +| `set_deck` | Optional `deckId`, required `title` and `columns` | Create when ID is omitted; otherwise replace an existing deck, then activate it | +| `select_deck` | `deckId` | Activate a saved deck and persist the selection | +| `delete_deck` | `deckId` | Permanently remove the definition; cannot delete the last deck | -Supply `q` or `from`. Unknown properties and invalid inputs fail before -navigation. Existing query construction validates dates, handles, and the -512-character compiled-query limit. A concurrent search fails with an -actionable message. Navigating elsewhere during a search prevents a stale -success response. +`set_deck` accepts at most six columns. Each requires `title`, `profileName` +from `list_decks`, and a discriminated `source`. Its `kind` is `search`, `user`, +or `list`; `platform` defaults to `twitter`. Searches require `query`, with +`product` defaulting to `Latest` and `following` to false. User and list sources +require `target` (handle/profile URL or list ID/URL). Unknown source fields, +invalid targets, duplicate column IDs, and unknown profiles fail before saving. -### `get_loaded_posts` +Read before editing. Include every column to keep; omitted columns are removed. +Preserve IDs for retained columns and omit IDs for new ones. An empty columns +array clears a deck. A supplied deck ID must already exist. Successful mutation +closes unsaved editor forms. `set_deck` returns the applied definition, +`persisted: true`, and `posts: "loading-asynchronously"`; searches can fail +independently after the save succeeds. -Return a slice of the current feed without a network request. Accepts -`offset` (default 0, nonnegative integer) and `limit` (default 20, integer -1–50). Conversation results place the selected post first, once. +Deleting the active deck selects the first remaining one. Deletion has no +workspace-tool undo. Storage failures return `isError: true` explaining that +the mutation applied in memory but could not be persisted. Saving replaces +invalid or legacy saved data; there is no automatic legacy migration. -The response includes `status` (`loading`, `ready`, or `error`), `loading`, -and `error` information alongside the common result. This is a snapshot of -all loaded posts, not just posts inside the viewport. +## Column tools -### `load_more_posts` +`get_column_posts` accepts `columnId`, `offset` (default 0, nonnegative integer), +and `limit` (default 20, integer 1–50). It reads already loaded posts without a +network request. Only columns mounted in the active deck are available; select +the deck and allow it to render first. -Accepts `{}`. Wait for one continuation, append it to the UI, and return up to -20 newly appended posts. An in-progress scroll request is shared. Initial -loading or a feed refresh must finish first. A failed continuation can be retried explicitly. -At the end of the feed the result is an empty `posts` array and `hasMore: false`. +`load_more_column` accepts `columnId`. It loads or retries one continuation +using that column's bound profile. Wait for its initial load or refresh before +calling. Concurrent pagination joins the existing request. If the deck or +column changes during the request, the tool reports an error instead of +returning results under the new identity. At the end, it returns no appended +posts and `hasMore: false`. + +Both return: + +- `column`: ID, title, bound profile, and source definition +- `status`: `loading`, `ready`, or `error`, plus `loading` and `error` details +- `posts`: normalized records with original URLs, identity, text, author, + and available media/quotes +- `loadedCount`, `offset`, and `nextOffset` for slicing deduplicated cached posts +- `hasMore`: whether the current feed has an upstream continuation + +Continuation returns up to 20 newly appended posts. Use `nextOffset` with +`get_column_posts` to read additional already loaded records, and +`load_more_column` for an upstream page. These are cache snapshots; manual UI +refreshes and pagination can change the available records. ## Results and errors -Successful tool results contain JSON in an MCP text content block: +Tools return JSON in an MCP text content block. Execution failures set +`isError: true` with `code`, `message`, and `retryable`. Schema failures use +`invalid-input`; other tool failures use `tool-error`. Column snapshots report +underlying relay failures through their `error` field. An empty successful +query is not an error. -- `request`: active feed kind and normalized criteria. -- `posts`: `id`, `author` (username/name), `text`, `textTruncated`, optional - `createdAt`, and the original X `url`. -- `loadedCount`: number of deduplicated, loaded posts. -- `offset`: start of this slice within the loaded feed. -- `nextOffset`: next unread offset within the already loaded feed, or `null`. -- `hasMore`: whether the last loaded page has an upstream continuation. +`list_decks`, `get_deck`, and `get_column_posts` carry `readOnlyHint: true`. +The other tools change local UI or storage. `delete_deck` carries +`destructiveHint: true`; tools returning external posts mark them untrusted. +Annotations are metadata, not authorization controls. No tool writes to X. -Use `get_loaded_posts` with `nextOffset` for loaded posts outside a returned -slice; use `load_more_posts` for an upstream continuation. Scrolling can load -more posts independently, so these fields describe a snapshot. Post text is -capped at 2,000 characters per post and explicitly marked when truncated; -the source URL remains available. Media, quote bodies, and article previews -are not included in this initial text-oriented tool response. - -Failures use `isError: true` and JSON containing `code`, `message`, and -`retryable`. Existing relay error details are preserved. A successful empty -search is not an error. - -All tools mark returned external content with `untrustedContentHint: true`. -Only `get_loaded_posts` has `readOnlyHint: true`: search and continuation -change local UI state. No tool performs X write actions. Annotations are -metadata, not authorization controls. - -## Verification and limits +## Verification and browser setup ```sh nix develop -c pnpm test @@ -100,24 +90,19 @@ nix develop -c pnpm typecheck nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts ``` -The E2E file enables native Chromium WebMCP/testing flags and uses -`navigator.modelContextTesting` to invoke actual registered tools. The -standalone mock relay supplies deterministic data through the production -server-function boundary. Tests do not contact X or a personal relay. +The E2E tests enable native Chromium WebMCP/testing flags and use +`navigator.modelContextTesting` to invoke actual registered tools. A mock +relay supplies deterministic responses through real server functions. -For interactive testing, enable Chrome's WebMCP testing flag, restart, run -`nix develop -c pnpm dev`, and open the local site with Model Context Tool -Inspector. Registration uses `document.modelContext`; availability is checked -at mount, so reload after changing browser support. No production origin-trial -token or external MCP-client bridge is configured by this prototype. +For interactive testing, enable `chrome://flags/#enable-webmcp-testing`, +restart Chrome, and use Model Context Tool Inspector on the app. Registration +uses `document.modelContext`; reload after changing browser support. Native +WebMCP requires a secure context: local loopback works for development; remote +Tailscale access should use an HTTPS Serve origin. Allow the exact hostname +through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process environment. +HTTP and HTTPS have separate browser-local workspaces. -The hook does not forward the browser's execution AbortSignal to application -callbacks. Browser cancellation therefore does not guarantee cancellation of -the shared read request. Tools do detect navigation changes before returning -their asynchronous results. Agent task-selection quality still needs manual -evaluation with the consuming agent; deterministic browser tests verify the -tool contracts and UI behavior. - -Design references: [Chrome best practices](https://developer.chrome.com/docs/ai/webmcp/best-practices), -[workflow design](https://developer.chrome.com/docs/ai/webmcp/build-tools), -and [usewebmcp](https://github.com/WebMCP-org/npm-packages/tree/main/packages/usewebmcp). +No production origin-trial token or external MCP-client bridge is configured. +Browser cancellation does not guarantee cancellation of a shared feed request. +Agent task-selection quality still needs evaluation with the consuming agent; +automated browser tests verify contracts and UI behavior. diff --git a/e2e/a11y/homePage.test.ts b/e2e/a11y/homePage.test.ts deleted file mode 100644 index 5ae7ee6..0000000 --- a/e2e/a11y/homePage.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from '../fixtures' - -test('home page has no accessibility violations', async ({ - a11y, - homePage, -}) => { - await homePage.goTo() - await expect(homePage.articlesLocator).toHaveCount(0) - - const result = await a11y().analyze() - expect(result.violations).toEqual([]) -}) diff --git a/e2e/a11y/listsPage.test.ts b/e2e/a11y/listsPage.test.ts deleted file mode 100644 index 889fd04..0000000 --- a/e2e/a11y/listsPage.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, test } from '../fixtures' - -test('list picker has no accessibility violations', async ({ - a11y, - listsPage, -}) => { - await listsPage.goTo() - await expect(listsPage.articlesLocator).toHaveCount(0) - - const result = await a11y().analyze() - expect(result.violations).toEqual([]) -}) - -test('list timeline has no accessibility violations', async ({ - a11y, - listsPage, -}) => { - await listsPage.goTo() - await listsPage.openList(/My E2E List.*10 メンバー/) - await expect(listsPage.endOfFeedLocator).toBeVisible() - - const result = await a11y().analyze() - expect(result.violations).toEqual([]) -}) diff --git a/e2e/a11y/searchPage.test.ts b/e2e/a11y/searchPage.test.ts deleted file mode 100644 index 890a999..0000000 --- a/e2e/a11y/searchPage.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test } from '../fixtures' - -test('empty search page has no accessibility violations', async ({ - a11y, - searchPage, -}) => { - await searchPage.goTo() - await expect(searchPage.articlesLocator).toHaveCount(0) - - const result = await a11y().analyze() - expect(result.violations).toEqual([]) -}) - -test('search results have no accessibility violations', async ({ - a11y, - searchPage, -}) => { - await searchPage.goTo() - await searchPage.search('AI lang:ja') - await expect(searchPage.endOfFeedLocator).toBeVisible() - - const result = await a11y().analyze() - expect(result.violations).toEqual([]) -}) - -test('open filters and active condition chips have no accessibility violations', async ({ - a11y, - page, - searchPage, -}) => { - await searchPage.goTo() - await searchPage.queryInputLocator.fill('TypeScript') - await page.getByRole('button', { name: '投稿者' }).click() - await page.getByLabel('投稿者').fill('@tan_stack') - await page.getByRole('button', { name: '言語' }).click() - await page.getByLabel('日本語').check() - await expect( - page.getByRole('button', { name: '投稿者 @tan_stack を解除' }), - ).toBeVisible() - - const result = await a11y().analyze() - expect(result.violations).toEqual([]) -}) diff --git a/e2e/a11y/threadPage.test.ts b/e2e/a11y/threadPage.test.ts deleted file mode 100644 index 1990055..0000000 --- a/e2e/a11y/threadPage.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from '../fixtures' - -test('conversation has no accessibility violations', async ({ - a11y, - threadPage, -}) => { - await threadPage.goTo('yuta', '1001') - await expect(threadPage.endOfFeedLocator).toBeVisible() - - const result = await a11y().analyze() - expect(result.violations).toEqual([]) -}) diff --git a/e2e/a11y/userPage.test.ts b/e2e/a11y/userPage.test.ts deleted file mode 100644 index 9c12605..0000000 --- a/e2e/a11y/userPage.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from '../fixtures' - -test('user timeline has no accessibility violations', async ({ - a11y, - userPage, -}) => { - await userPage.goTo('yuta') - await expect(userPage.endOfFeedLocator).toBeVisible() - - const result = await a11y().analyze() - expect(result.violations).toEqual([]) -}) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 301675e..e2151fa 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,34 +1,14 @@ import AxeBuilder from '@axe-core/playwright' import { test as base } from '@playwright/test' -import { HomePage } from './models/HomePage' -import { ListsPage } from './models/ListsPage' -import { SearchPage } from './models/SearchPage' -import { ThreadPage } from './models/ThreadPage' -import { UserPage } from './models/UserPage' -type Fixtures = { - a11y: () => AxeBuilder - homePage: HomePage - listsPage: ListsPage - searchPage: SearchPage - threadPage: ThreadPage - userPage: UserPage -} - -export const test = base.extend({ +export const test = base.extend<{ a11y: () => AxeBuilder }>({ a11y: async ({ page }, use) => { - const makeAxeBuilder = () => + await use(() => new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .disableRules(['meta-viewport']) - - await use(makeAxeBuilder) + .disableRules(['meta-viewport']), + ) }, - homePage: ({ page }, use) => use(new HomePage(page)), - listsPage: ({ page }, use) => use(new ListsPage(page)), - searchPage: ({ page }, use) => use(new SearchPage(page)), - threadPage: ({ page }, use) => use(new ThreadPage(page)), - userPage: ({ page }, use) => use(new UserPage(page)), }) export { expect } from '@playwright/test' diff --git a/e2e/integrations/deck.test.ts b/e2e/integrations/deck.test.ts index 8881872..3e0326c 100644 --- a/e2e/integrations/deck.test.ts +++ b/e2e/integrations/deck.test.ts @@ -5,13 +5,28 @@ async function addColumn( page: Page, title: string, query: string, - top = false, + profile = 'e2e', + kind = 'search', ) { await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() const editor = page.getByRole('form', { name: 'カラムを追加', exact: true }) await editor.getByLabel('カラム名', { exact: true }).fill(title) - await editor.getByLabel('Twitterの検索条件').fill(query) - if (top) await editor.getByLabel('表示順').selectOption('Top') + await editor + .getByRole('combobox', { name: '接続プロファイル', exact: true }) + .selectOption(profile) + await editor + .getByRole('combobox', { name: 'カラムの種類', exact: true }) + .selectOption(kind) + await editor + .getByLabel( + kind === 'search' + ? 'Twitterの検索条件' + : kind === 'user' + ? 'ユーザー' + : 'リスト', + { exact: true }, + ) + .fill(query) await editor.getByRole('button', { name: '追加して検索' }).click() await expect( page @@ -22,63 +37,114 @@ async function addColumn( } test.beforeEach(async ({ page }) => { - await page.goto('/deck') + await page.goto('/') await page.waitForLoadState('networkidle') }) -test('creates independent columns, edits, reorders, and restores saved conditions', async ({ +test('binds identical searches to independent profiles through paging, editing and reload', async ({ page, }) => { - const errors: string[] = [] - page.on('pageerror', (error) => errors.push(error.message)) - await page.getByRole('button', { name: '名前を変更' }).click() - await page.getByLabel('調査テーマ・デッキ名').fill('WebMCPの反応') - await page.getByRole('button', { name: '名前を保存' }).click() - await addColumn(page, '日本語の反応', 'WebMCP lang:ja') - await addColumn(page, '話題の投稿', 'WebMCP', true) - const first = page.getByRole('region', { name: '日本語の反応', exact: true }) - const second = page.getByRole('region', { name: '話題の投稿', exact: true }) + 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: '続きを読み込む' }).click() await expect(first.locator('article')).toHaveCount(2) await expect(second.locator('article')).toHaveCount(1) - await second.getByRole('button', { name: '話題の投稿を左へ' }).click() - await expect(page.locator('.deck-column h2')).toHaveText([ - '話題の投稿', - '日本語の反応', - ]) + await second.getByRole('button', { name: '続きを読み込む' }).click() + await expect( + second.getByText('alternate · Latest · all page 2'), + ).toBeVisible() + await second.locator('summary[aria-label="別の接続の操作"]').click() await second.getByRole('button', { name: '編集', exact: true }).click() await page - .getByRole('form', { name: 'カラムを編集' }) - .getByLabel('カラム名', { exact: true }) - .fill('公式の投稿') - await page.getByLabel('Twitterの検索条件').fill('from:OpenAI') + .getByRole('combobox', { name: '接続プロファイル', exact: true }) + .selectOption('e2e') await page.getByRole('button', { name: '変更を保存' }).click() - await page.reload() - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - 'WebMCPの反応', + await expect( + second.getByText('Latest · all page 1', { exact: true }), + ).toBeVisible() + await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount( + 0, ) - await expect(page.locator('.deck-column h2')).toHaveText([ - '公式の投稿', - '日本語の反応', - ]) - const official = page.getByRole('region', { name: '公式の投稿', exact: true }) - await expect(official.locator('.deck-query')).toHaveText('from:OpenAI') - await expect(official.locator('article')).toHaveCount(1) - await official.getByRole('button', { name: '公式の投稿を削除' }).click() - await expect(page.locator('.deck-column')).toHaveCount(1) - await page.getByRole('button', { name: '元に戻す' }).click() - await expect(page.locator('.deck-column h2')).toHaveText([ - '公式の投稿', - '日本語の反応', - ]) - expect(errors).toEqual([]) + await page.reload() + await expect( + second.getByText('Latest · all page 1', { exact: true }), + ).toBeVisible() + await expect(first.locator('article')).toHaveCount(1) }) -test('keeps other columns usable when pagination fails and retries only that column', async ({ +test('creates, switches, renames and deletes persisted deck profiles', async ({ + page, +}) => { + await page.getByRole('button', { name: '名前を変更' }).click() + await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査') + await page.getByRole('button', { name: '名前を保存' }).click() + await addColumn(page, '最初の観点', 'first-deck') + await page.getByRole('button', { name: 'デッキを作成', exact: true }).click() + await page.getByLabel('新しいデッキ名').fill('別の調査') + await page.getByRole('button', { name: '作成', exact: true }).click() + await expect(page.locator('.deck-column')).toHaveCount(0) + await addColumn(page, '別の観点', 'second-deck', 'alternate') + await page + .getByLabel('デッキプロファイル', { exact: true }) + .selectOption({ label: '最初の調査' }) + await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点']) + await page.reload() + await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') + await page + .getByLabel('デッキプロファイル', { exact: true }) + .selectOption({ label: '別の調査' }) + await expect(page.locator('.deck-column h2')).toHaveText(['別の観点']) + await page.getByRole('button', { name: 'デッキを削除', exact: true }).click() + await page.getByRole('button', { name: '削除する', exact: true }).click() + await page.reload() + await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') + await expect( + page.getByLabel('デッキプロファイル', { exact: true }).locator('option'), + ).toHaveCount(1) +}) + +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: '続きを読み込む' }).click() + await expect(list.getByText('list page 2')).toBeVisible() + await list.locator('summary[aria-label="リストの投稿の操作"]').click() + await list.getByRole('button', { name: 'リストの投稿を左へ' }).click() + await expect(page.locator('.deck-column h2')).toHaveText([ + 'リストの投稿', + 'アカウント', + ]) + await list.locator('summary[aria-label="リストの投稿の操作"]').click() + await list.getByRole('button', { name: 'リストの投稿を削除' }).click() + await page.getByRole('button', { name: '元に戻す' }).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 ({ page, }, testInfo) => { await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`) - await addColumn(page, '別の検索', 'independent-deck', true) + 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: '続きを読み込む' }).click() @@ -88,15 +154,15 @@ test('keeps other columns usable when pagination fails and retries only that col await expect(other.locator('article')).toHaveCount(2) await failed.getByRole('button', { name: '再試行' }).click() await expect(failed.locator('article')).toHaveCount(2) - await expect(failed.getByRole('alert')).toHaveCount(0) }) -test('keeps columns within narrow viewports and accessible', 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', true) + 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( @@ -104,16 +170,36 @@ test('keeps columns within narrow viewports and accessible', async ({ () => 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, height: 900, }) - await expect( - page - .getByRole('region', { name: '日本語の反応', exact: true }) - .locator('article'), - ).toHaveCount(1) + if (testInfo.project.name === 'desktop') { + await page + .getByRole('navigation', { name: 'カラム一覧' }) + .getByRole('button', { name: '公式アカウント' }) + .click() + await expect( + page + .getByRole('region', { name: '公式アカウント', exact: true }) + .locator('.deck-column-results'), + ).toBeFocused() + } + expect((await a11y().analyze()).violations).toEqual([]) + await page.screenshot({ + path: testInfo.outputPath('deck-populated.png'), + fullPage: true, + }) + await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() expect((await a11y().analyze()).violations).toEqual([]) await page.screenshot({ path: testInfo.outputPath('deck.png'), @@ -121,9 +207,22 @@ test('keeps columns within narrow viewports and accessible', async ({ }) }) -test('does not overwrite an unreadable saved deck on reload', async ({ +test('closes the column editor with Escape and restores focus without applying edits', async ({ page, }) => { + const opener = page.getByRole('button', { name: 'カラムを追加', exact: true }) + await opener.click() + await expect(page.getByRole('dialog')).toBeVisible() + await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点') + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog')).toHaveCount(0) + await expect(opener).toBeFocused() + await expect(page.locator('.deck-column')).toHaveCount(0) + await opener.click() + await expect(page.getByLabel('カラム名', { exact: true })).toHaveValue('') +}) + +test('does not overwrite unreadable storage on reload', async ({ page }) => { await page.evaluate(() => localStorage.setItem('twitter-lite-research-deck', '{broken'), ) @@ -136,58 +235,33 @@ test('does not overwrite an unreadable saved deck on reload', async ({ ).toBe('{broken') }) -test('keeps the six-column limit when undo and add overlap', async ({ +test('selects a list from the column profile catalog and refreshes it on profile change', async ({ page, }) => { - await page.evaluate(() => - localStorage.setItem( - 'twitter-lite-research-deck', - JSON.stringify({ - version: 1, - title: '上限の確認', - columns: Array.from({ length: 6 }, (_, index) => ({ - id: `column-${index}`, - title: `観点${index}`, - source: { - platform: 'twitter', - query: `limit-${index}`, - product: 'Latest', - following: false, - }, - })), - }), - ), - ) - await page.reload() - await page.waitForLoadState('networkidle') - await expect( - page.getByRole('button', { name: 'カラムを追加', exact: true }), - ).toBeDisabled() - await page.getByRole('button', { name: '観点0を削除' }).click() await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() - await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled() - await page.getByLabel('カラム名', { exact: true }).fill('追加の観点') - await page.getByLabel('Twitterの検索条件').fill('new-limit') - await page.getByRole('button', { name: '追加して検索' }).click() - await expect(page.locator('.deck-column')).toHaveCount(6) - await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled() - await page.reload() - await expect(page.locator('.deck-column')).toHaveCount(6) -}) - -test('creates columns without the secure-context-only randomUUID API', async ({ - page, -}) => { - await page.addInitScript(() => - Object.defineProperty(crypto, 'randomUUID', { value: undefined }), + await page.getByLabel('カラム名', { exact: true }).fill('購読リスト') + await page + .getByRole('combobox', { name: 'カラムの種類', exact: true }) + .selectOption('list') + await expect( + page.getByRole('button', { name: 'My E2E List', exact: true }), + ).toBeVisible() + await page + .getByRole('combobox', { name: '接続プロファイル', exact: true }) + .selectOption('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('リスト', { exact: true })).toHaveValue( + '1234567890', ) - await page.reload() - await page.waitForLoadState('networkidle') - await addColumn(page, 'HTTP接続からの調査', 'http-deck') - await page.reload() + await page.getByRole('button', { name: '追加して検索' }).click() await expect( page - .getByRole('region', { name: 'HTTP接続からの調査', exact: true }) - .locator('article'), - ).toHaveCount(1) + .getByRole('region', { name: '購読リスト', exact: true }) + .getByText('list page 1', { exact: true }), + ).toBeVisible() }) diff --git a/e2e/integrations/reader.test.ts b/e2e/integrations/reader.test.ts deleted file mode 100644 index cd22005..0000000 --- a/e2e/integrations/reader.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { expect, test } from '../fixtures' - -let consoleErrors: string[] -let pageErrors: string[] - -test.beforeEach(async ({ page }) => { - consoleErrors = [] - pageErrors = [] - page.on('console', (message) => { - if (message.type() === 'error') consoleErrors.push(message.text()) - }) - page.on('pageerror', (error) => pageErrors.push(error.message)) -}) - -test.afterEach(() => { - expect(consoleErrors, 'browser console errors').toEqual([]) - expect(pageErrors, 'uncaught browser errors').toEqual([]) -}) - -test('requires intent and infinitely loads a user timeline', async ({ - homePage, - page, - userPage, -}) => { - await homePage.goTo() - await expect(page).toHaveURL(/\/$/) - await expect(page.locator('meta[name="referrer"]')).toHaveAttribute( - 'content', - 'no-referrer', - ) - await expect(homePage.articlesLocator).toHaveCount(0) - await expect(homePage.ambientLinksLocator).toHaveCount(0) - - await homePage.displayUser('@yuta') - - await expect(page).toHaveURL(/\/yuta$/) - await expect(page.getByText('user page 1')).toBeVisible() - await expect(page.getByText('user page 2')).toBeVisible() - await expect(userPage.endOfFeedLocator).toBeVisible() - await expect(page).toHaveScreenshot('mist-user.png', { - animations: 'disabled', - fullPage: true, - }) -}) - -test('switches the relay profile and reloads visible content', async ({ - homePage, - page, -}) => { - await homePage.goTo() - - const profile = page.getByLabel('プロファイル') - await expect(profile).toHaveValue('e2e') - await homePage.displayUser('@yuta') - await expect(page.getByText('user page 1')).toBeVisible() - - await profile.selectOption('alternate') - await expect(page.getByRole('status')).toHaveText('切り替えました。') - - await expect(page.getByText('alternate profile page 1')).toBeVisible() -}) - -test('keeps the profile selector within narrow viewports', async ({ - homePage, - page, -}) => { - for (const width of [320, 375, 414, 768]) { - await page.setViewportSize({ width, height: 900 }) - await homePage.goTo() - await expect(page.getByLabel('プロファイル')).toBeVisible() - expect( - await page.evaluate( - () => document.documentElement.scrollWidth > window.innerWidth, - ), - ).toBe(false) - } -}) - -test('selects an authenticated list and infinitely loads its timeline', async ({ - listsPage, - page, -}) => { - await listsPage.goTo() - await expect(listsPage.articlesLocator).toHaveCount(0) - - await listsPage.openList(/My E2E List.*10 メンバー/) - - await expect(page).toHaveURL(/\/i\/lists\/1234567890$/) - await expect(page.getByText('list page 1')).toBeVisible() - await expect(page.getByText('list page 2')).toBeVisible() - await expect(listsPage.endOfFeedLocator).toBeVisible() -}) - -test('opens a card detail and infinitely loads its conversation', async ({ - page, - threadPage, - userPage, -}) => { - await userPage.goTo('yuta') - await userPage.openPostDetails('user page 1') - - await expect(page).toHaveURL(/\/yuta\/status\/1001$/) - await expect(threadPage.headingLocator).toBeVisible() - await expect(threadPage.selectedPostLabelLocator).toBeVisible() - await expect(page.getByText('user page 1')).toHaveCount(1) - await expect(page.getByText('thread root')).toBeVisible() - await expect(page.getByText('thread page 1')).toBeVisible() - await expect(page.getByText('thread page 2')).toBeVisible() - await expect(threadPage.endOfFeedLocator).toBeVisible() - await expect(page).toHaveScreenshot('mist-thread.png', { - animations: 'disabled', - fullPage: true, - }) -}) - -test('resolves a reply root and retries only the failed continuation', async ({ - page, - threadPage, -}) => { - await threadPage.goTo('yuta', '2001') - - await expect(page.getByText('retry focal')).toHaveCount(1) - await expect(page.getByText('thread root')).toBeVisible() - await expect(page.getByText('thread page 1')).toBeVisible() - await expect(threadPage.continuationErrorLocator).toHaveCount(1) - await expect(page.getByText('retry focal')).toHaveCount(1) - - await threadPage.retryContinuation() - - await expect(page.getByText('thread page 2')).toBeVisible() - await expect(threadPage.continuationErrorLocator).toHaveCount(0) - await expect(threadPage.endOfFeedLocator).toBeVisible() - await expect(threadPage.mutationButtonsLocator).toHaveCount(0) -}) - -test('ends a thread without loading a continuation when there are no replies', async ({ - page, - threadPage, -}) => { - await threadPage.goTo('yuta', '3001') - - await expect(page.getByText('no replies')).toHaveCount(1) - await expect(page.getByText('thread root')).toBeVisible() - await expect(threadPage.endOfFeedLocator).toBeVisible() - await expect(threadPage.continuationErrorLocator).toHaveCount(0) -}) - -test('searches Top posts from followed accounts', async ({ - page, - searchPage, -}) => { - await searchPage.goTo() - await expect(searchPage.articlesLocator).toHaveCount(0) - await searchPage.searchTopFromFollowing('AI lang:ja') - await expect(searchPage.followingOnlyLocator).toBeChecked() - - await expect(page).toHaveURL(/following=true/) - await expect(page.getByText('Top · follows page 1')).toBeVisible() - await expect(page.getByText('Top · follows page 2')).toBeVisible() - await expect(searchPage.endOfFeedLocator).toBeVisible() -}) - -test('retains the first page through a pagination error and retries explicitly', async ({ - page, - searchPage, -}, testInfo) => { - await searchPage.goTo() - await expect(searchPage.articlesLocator).toHaveCount(0) - await searchPage.search(`retry-${testInfo.project.name}`) - - await expect(page.getByText('Latest · all page 1')).toBeVisible() - await expect(searchPage.continuationErrorLocator).toHaveCount(1) - await expect(page.getByText('Latest · all page 1')).toBeVisible() - await expect(page.getByText('Latest · all page 2')).toHaveCount(0) - - await searchPage.retryContinuation() - - await expect(page.getByText('Latest · all page 2')).toBeVisible() - await expect(searchPage.continuationErrorLocator).toHaveCount(0) - await expect(searchPage.endOfFeedLocator).toBeVisible() -}) - -test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({ - searchPage, -}) => { - await searchPage.goTo() - - await searchPage.tabTo( - searchPage.page.getByRole('combobox', { name: 'プロファイル' }), - ) - await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'デッキ' })) - await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' })) - await searchPage.tabTo( - searchPage.page.getByRole('link', { name: 'ユーザー' }), - ) - await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' })) - await searchPage.tabTo(searchPage.queryInputLocator) - await searchPage.tabTo( - searchPage.page.getByRole('button', { name: '投稿者' }), - ) - await searchPage.tabTo(searchPage.page.getByRole('button', { name: '期間' })) - await searchPage.tabTo(searchPage.page.getByRole('button', { name: '言語' })) - await searchPage.tabTo( - searchPage.page.getByRole('button', { name: '投稿の種類' }), - ) - await searchPage.tabTo(searchPage.page.getByRole('button', { name: '除外' })) - await searchPage.tabTo( - searchPage.page.getByText('検索式を確認', { exact: true }), - ) - await expect(searchPage.latestRankingLocator).toBeChecked() - await searchPage.tabTo(searchPage.latestRankingLocator) - await searchPage.tabTo(searchPage.followingOnlyLocator) - await searchPage.tabTo(searchPage.searchButtonLocator) - - await expect(searchPage.ambientLinksLocator).toHaveCount(0) - await expect(searchPage.mutationButtonsLocator).toHaveCount(0) - await expect(searchPage.mutationLinksLocator).toHaveCount(0) -}) - -test('removes loading rail animation for reduced motion', async ({ - page, - searchPage, -}, testInfo) => { - await page.emulateMedia({ reducedMotion: 'reduce' }) - await searchPage.goTo() - await expect(searchPage.articlesLocator).toHaveCount(0) - await searchPage.search(`slow-${testInfo.project.name}`) - - const rail = searchPage.loadingRailLocator - await expect(rail).toBeVisible() - await expect - .poll(() => - rail.evaluate((element) => - getComputedStyle(element, '::after').getPropertyValue('animation-name'), - ), - ) - .toBe('none') - - await expect(page.getByText('Latest · all page 2')).toBeVisible() - await expect(searchPage.endOfFeedLocator).toBeVisible() -}) diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png deleted file mode 100644 index 2e9fb54..0000000 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-thread-desktop-linux.png and /dev/null differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png deleted file mode 100644 index bd2f60a..0000000 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-thread-mobile-linux.png and /dev/null differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png deleted file mode 100644 index 3166b9c..0000000 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-user-desktop-linux.png and /dev/null differ diff --git a/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png b/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png deleted file mode 100644 index 905e917..0000000 Binary files a/e2e/integrations/reader.test.ts-snapshots/mist-user-mobile-linux.png and /dev/null differ diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts index 089028f..81cc6e7 100644 --- a/e2e/integrations/webmcp.test.ts +++ b/e2e/integrations/webmcp.test.ts @@ -1,26 +1,15 @@ import type { Page } from '@playwright/test' import { expect, test } from '../fixtures' -import { SearchPage } from '../models/SearchPage' type NativeTesting = { listTools(): { name: string }[] executeTool(name: string, input: string): Promise } - type ToolResult = { content: { type: string; text: string }[] isError?: boolean } -type FeedResult = { - request: { kind: string; query: string; product: string; following: boolean } - posts: { id: string; text: string; url: string }[] - loadedCount: number - offset: number - nextOffset: number | null - hasMore: boolean -} - test.use({ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, @@ -38,7 +27,6 @@ async function toolNames(page: Page) { .sort(), ) } - async function executeTool( page: Page, name: string, @@ -55,290 +43,139 @@ async function executeTool( if (result === null) throw new Error(`${name} returned no result`) return JSON.parse(result) } - -async function executeFeedTool( - page: Page, - name: string, - input: Record = {}, -): Promise { - const result = await executeTool(page, name, input) - expect(result.isError, JSON.stringify(result.content)).not.toBe(true) - expect(result.content[0]?.type).toBe('text') - return JSON.parse(result.content[0]?.text ?? '') -} +const column = (title: string, profileName = 'e2e') => ({ + title, + profileName, + source: { kind: 'search', query: 'WebMCP' }, +}) let pageErrors: string[] - -test.beforeEach(async ({ page }, testInfo) => { +test.beforeEach(async ({ page }) => { pageErrors = [] page.on('pageerror', (error) => pageErrors.push(error.message)) - if (testInfo.title === 'joins pagination already started by scrolling') return - // Keep pagination under the tool's control without replacing WebMCP itself. - await page.addInitScript(() => { - window.IntersectionObserver = class { - readonly root = null - readonly rootMargin = '0px' - readonly scrollMargin = '0px' - readonly thresholds = [0] - observe() {} - unobserve() {} - disconnect() {} - takeRecords() { - return [] - } - } - }) -}) - -test.afterEach(() => { - expect(pageErrors, 'uncaught browser errors').toEqual([]) -}) - -test('searches through native WebMCP and keeps results synchronized with the UI', async ({ - homePage, - page, - searchPage, -}) => { - await homePage.goTo() - await expect.poll(() => toolNames(page)).toEqual(['search_posts']) - - const first = await executeFeedTool(page, 'search_posts', { - q: 'AI', - product: 'Top', - following: true, - }) - - expect(first.request).toMatchObject({ - kind: 'search', - query: 'AI filter:follows', - product: 'Top', - following: true, - }) - expect(first.posts.map((post) => post.text)).toEqual(['Top · follows page 1']) - expect(first).toMatchObject({ loadedCount: 1, offset: 0, hasMore: true }) - await expect(page).toHaveURL(/\/search\?/) - await expect(searchPage.queryInputLocator).toHaveValue('AI') - await expect(searchPage.topRankingLocator).toBeChecked() - await expect(searchPage.followingOnlyLocator).toBeChecked() - await expect( - page.getByText(first.posts[0]?.text ?? '', { exact: true }), - ).toBeVisible() - - await expect - .poll(() => toolNames(page)) - .toEqual(['get_loaded_posts', 'load_more_posts', 'search_posts']) - const more = await executeFeedTool(page, 'load_more_posts') - expect(more.posts.map((post) => post.text)).toEqual(['Top · follows page 2']) - expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) - await expect(searchPage.articlesLocator).toHaveCount(2) - await expect(searchPage.endOfFeedLocator).toBeVisible() - - const slice = await executeFeedTool(page, 'get_loaded_posts', { - offset: 1, - limit: 1, - }) - expect(slice.posts).toEqual(more.posts) - expect(slice).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) - - const end = await executeFeedTool(page, 'load_more_posts') - expect(end).toMatchObject({ posts: [], loadedCount: 2, hasMore: false }) - await expect(searchPage.articlesLocator).toHaveCount(2) - - // SPA navigation must unregister the old feed's tools in the same document. - await page.getByRole('link', { name: 'ユーザー', exact: true }).click() - await expect(page).toHaveURL(/\/$/) - await expect.poll(() => toolNames(page)).toEqual(['search_posts']) -}) - -test('rejects invalid search conditions without navigating or dropping the active feed', async ({ - homePage, - page, - searchPage, -}) => { - await homePage.goTo() - await expect.poll(() => toolNames(page)).toContain('search_posts') - const first = await executeFeedTool(page, 'search_posts', { q: 'existing' }) - const previousURL = page.url() - const result = await executeTool(page, 'search_posts', { - q: 'invalid dates', - since: '2026-09-24', - until: '2026-09-23', - }) - - expect(result.isError).toBe(true) - expect(result.content[0]?.type).toBe('text') - expect(() => JSON.parse(result.content[0]?.text ?? '')).not.toThrow() - await expect(page).toHaveURL(previousURL) - await expect(searchPage.queryInputLocator).toHaveValue('existing') - const current = await executeFeedTool(page, 'get_loaded_posts') - expect(current.posts).toEqual(first.posts) -}) - -test('retries a failed continuation without losing the first page', async ({ - homePage, - page, - searchPage, -}, testInfo) => { - await homePage.goTo() - await expect.poll(() => toolNames(page)).toContain('search_posts') - const first = await executeFeedTool(page, 'search_posts', { - q: `retry-webmcp-${testInfo.project.name}`, - }) - const failed = await executeTool(page, 'load_more_posts') - expect(failed.isError, JSON.stringify(failed.content)).toBe(true) - await expect(searchPage.continuationErrorLocator).toBeVisible() - const retained = await executeFeedTool(page, 'get_loaded_posts') - expect(retained.posts).toEqual(first.posts) - - const recovered = await executeFeedTool(page, 'load_more_posts') - expect(recovered.posts.map((post) => post.text)).toEqual([ - 'Latest · all page 2', - ]) - expect(recovered).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) - await expect(searchPage.continuationErrorLocator).toHaveCount(0) - await expect(searchPage.articlesLocator).toHaveCount(2) - await expect(searchPage.endOfFeedLocator).toBeVisible() -}) - -test('joins pagination already started by scrolling', async ({ - homePage, - page, - searchPage, -}, testInfo) => { - await homePage.goTo() - await expect.poll(() => toolNames(page)).toContain('search_posts') - await executeFeedTool(page, 'search_posts', { - q: `slow-webmcp-${testInfo.project.name}`, - }) - await expect(searchPage.loadingRailLocator).toBeVisible() - - const more = await executeFeedTool(page, 'load_more_posts') - expect(more.posts.map((post) => post.text)).toEqual(['Latest · all page 2']) - expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) - await expect(searchPage.articlesLocator).toHaveCount(2) - await expect(searchPage.endOfFeedLocator).toBeVisible() -}) - -test('keeps the normal search form functional without native WebMCP', async ({ - playwright, - baseURL, -}, testInfo) => { - const browser = await playwright.chromium.launch({ - executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, - args: ['--disable-blink-features=WebMCP,WebMCPTesting'], - }) - const page = await browser.newPage({ baseURL }) - const searchPage = new SearchPage(page) - try { - page.on('pageerror', (error) => pageErrors.push(error.message)) - await searchPage.goTo() - expect(await page.evaluate(() => Boolean(document.modelContext))).toBe( - false, - ) - await searchPage.search('ordinary search') - await expect(page.getByText('Latest · all page 1')).toBeVisible() - await expect(page.getByText('Latest · all page 2')).toBeVisible() - } catch (error) { - await testInfo.attach('unsupported-browser-state', { - body: JSON.stringify({ url: page.url(), html: await page.content() }), - contentType: 'application/json', - }) - await testInfo.attach('unsupported-browser-screen', { - body: await page.screenshot(), - contentType: 'image/png', - }) - throw error - } finally { - await browser.close() - } -}) - -test('creates a deck through native WebMCP, edits it, and restores its saved definition', async ({ - page, -}) => { await page.goto('/deck') - await page.waitForLoadState('networkidle') await expect .poll(() => toolNames(page)) - .toEqual(['get_deck', 'search_posts', 'set_deck']) - const result = await executeTool(page, 'set_deck', { + .toEqual([ + 'delete_deck', + 'get_column_posts', + 'get_deck', + 'list_decks', + 'load_more_column', + 'select_deck', + 'set_deck', + ]) +}) +test.afterEach(() => expect(pageErrors, 'uncaught browser errors').toEqual([])) + +test('creates, replaces, switches and deletes persisted decks through native WebMCP', async ({ + page, +}) => { + const inventory = await executeTool(page, 'list_decks') + expect(JSON.parse(inventory.content[0]?.text ?? '').profiles).toEqual([ + 'e2e', + 'alternate', + ]) + const created = await executeTool(page, 'set_deck', { title: 'WebMCPの反応', columns: [ - { id: 'new', title: '日本語', source: { query: 'WebMCP lang:ja' } }, - { title: '話題', source: { query: 'WebMCP', product: 'Top' } }, + { ...column('日本語'), id: 'new' }, + column('別の接続', 'alternate'), ], }) - expect(result.isError).not.toBe(true) - await expect(page.locator('.deck-column h2')).toHaveText(['日本語', '話題']) - await expect(page.getByText('Latest · all page 1')).toBeVisible() - await expect(page.getByText('Top · all page 1')).toBeVisible() - const before = await executeTool(page, 'get_deck') - const { deck } = JSON.parse(before.content[0]?.text ?? '') - expect(deck.columns[0].id).toBe('new') - // Tool-provided IDs must not collide with the UI's new-column editing state. + expect(created.isError).not.toBe(true) + const { deck } = JSON.parse(created.content[0]?.text ?? '') + await expect(page.locator('.deck-column h2')).toHaveText([ + '日本語', + '別の接続', + ]) + await expect( + page.getByText('alternate · Latest · all page 1', { exact: true }), + ).toBeVisible() + await page + .getByRole('region', { name: '日本語', exact: true }) + .locator('summary[aria-label="日本語の操作"]') + .click() await page .getByRole('region', { name: '日本語', exact: true }) .getByRole('button', { name: '編集', exact: true }) .click() - await expect(page.getByLabel('Twitterの検索条件')).toHaveValue( - 'WebMCP lang:ja', - ) - const edited = await executeTool(page, 'set_deck', { + await expect(page.getByLabel('Twitterの検索条件')).toHaveValue('WebMCP') + const replaced = await executeTool(page, 'set_deck', { + deckId: deck.id, title: '更新した調査', columns: [deck.columns[1]], }) - expect(edited.isError).not.toBe(true) + expect(replaced.isError).not.toBe(true) await expect(page.getByRole('form', { name: 'カラムを編集' })).toHaveCount(0) - await expect(page.locator('.deck-column h2')).toHaveText(['話題']) + await expect(page.locator('.deck-column h2')).toHaveText(['別の接続']) + const another = await executeTool(page, 'set_deck', { + title: '別の調査', + columns: [], + }) + const anotherId = JSON.parse(another.content[0]?.text ?? '').deck.id + expect( + (await executeTool(page, 'select_deck', { deckId: deck.id })).isError, + ).not.toBe(true) await page.reload() - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - '更新した調査', - ) await expect.poll(() => toolNames(page)).toContain('get_deck') - const restored = await executeTool(page, 'get_deck') - expect(JSON.parse(restored.content[0]?.text ?? '').deck.columns[0].id).toBe( - deck.columns[1].id, + const restored = JSON.parse( + (await executeTool(page, 'get_deck')).content[0]?.text ?? '', + ).deck + expect(restored).toEqual({ + ...deck, + title: '更新した調査', + columns: [deck.columns[1]], + }) + expect( + (await executeTool(page, 'delete_deck', { deckId: anotherId })).isError, + ).not.toBe(true) + const after = JSON.parse( + (await executeTool(page, 'list_decks')).content[0]?.text ?? '', ) - await page.getByRole('link', { name: 'ユーザー', exact: true }).click() - await expect.poll(() => toolNames(page)).toEqual(['search_posts']) + expect( + after.decks.some((item: { id: string }) => item.id === anotherId), + ).toBe(false) + expect(after.activeDeckId).toBe(deck.id) }) -test('rejects an invalid deck without changing the visible or saved columns', async ({ +test('rejects duplicate IDs, unavailable profiles and invalid sources without mutations', async ({ page, }) => { - await page.goto('/deck') - await page.waitForLoadState('networkidle') - await expect.poll(() => toolNames(page)).toContain('set_deck') await executeTool(page, 'set_deck', { title: '残す調査', - columns: [{ title: '残す観点', source: { query: 'AI' } }], + columns: [column('残す観点')], }) const saved = await page.evaluate(() => localStorage.getItem('twitter-lite-research-deck'), ) - const column = { id: 'duplicate', title: '不正', source: { query: 'AI' } } - const result = await executeTool(page, 'set_deck', { - title: '変更しない', - columns: [column, column], - }) - expect(result.isError).toBe(true) - await expect(page.locator('.deck-column h2')).toHaveText(['残す観点']) - expect( - await page.evaluate(() => - localStorage.getItem('twitter-lite-research-deck'), - ), - ).toBe(saved) + const duplicate = { ...column('不正'), id: 'duplicate' } + for (const columns of [ + [duplicate, duplicate], + [column('不明', 'missing')], + [{ ...column('不正'), source: { kind: 'list', target: 'not-a-list' } }], + ]) { + expect( + (await executeTool(page, 'set_deck', { title: '変更しない', columns })) + .isError, + ).toBe(true) + await expect(page.locator('.deck-column h2')).toHaveText(['残す観点']) + expect( + await page.evaluate(() => + localStorage.getItem('twitter-lite-research-deck'), + ), + ).toBe(saved) + } }) -test('reports a deck persistence failure instead of claiming it was saved', async ({ +test('reports persistence failure while keeping the applied deck visible', async ({ page, }) => { - await page.goto('/deck') - await page.waitForLoadState('networkidle') - await expect.poll(() => toolNames(page)).toContain('set_deck') await page.evaluate(() => { - Storage.prototype.setItem = () => { - throw new Error('QuotaExceededError') + const setItem = Storage.prototype.setItem + Storage.prototype.setItem = function (key, value) { + if (this === localStorage) throw new Error('QuotaExceededError') + setItem.call(this, key, value) } }) const result = await executeTool(page, 'set_deck', { @@ -354,3 +191,85 @@ test('reports a deck persistence failure instead of claiming it was saved', asyn ) await expect(page.getByRole('alert')).toContainText('保存できません') }) + +test('keeps manual deck creation functional when WebMCP is disabled', async ({ + playwright, + baseURL, +}) => { + const browser = await playwright.chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, + args: ['--disable-blink-features=WebMCP,WebMCPTesting'], + }) + try { + const page = await browser.newPage({ baseURL }) + await page.goto('/') + await page + .getByRole('button', { name: 'カラムを追加', exact: true }) + .click() + await page.getByLabel('カラム名', { exact: true }).fill('手動で調査') + await page + .getByRole('combobox', { name: '接続プロファイル', exact: true }) + .selectOption('e2e') + await page.getByLabel('Twitterの検索条件').fill('ordinary-search') + await page.getByRole('button', { name: '追加して検索' }).click() + await expect( + page.getByText('Latest · all page 1', { exact: true }), + ).toBeVisible() + } finally { + await browser.close() + } +}) + +test('reads and pages the bound column profile without mixing sibling feeds', async ({ + page, +}) => { + const result = await executeTool(page, 'set_deck', { + title: '接続を比較', + columns: [column('通常'), column('別の接続', 'alternate')], + }) + const { deck } = JSON.parse(result.content[0]?.text ?? '') + await expect( + page.getByText('alternate · Latest · all page 1', { exact: true }), + ).toBeVisible() + const boundId = deck.columns[1].id + const loaded = JSON.parse( + (await executeTool(page, 'get_column_posts', { columnId: boundId })) + .content[0]?.text ?? '', + ) + expect(loaded.column.profileName).toBe('alternate') + expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([ + 'alternate · Latest · all page 1', + ]) + const moreResult = await executeTool(page, 'load_more_column', { + columnId: boundId, + }) + expect(moreResult.isError).not.toBe(true) + const more = JSON.parse(moreResult.content[0]?.text ?? '') + expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) + expect(more.posts.map((post: { text: string }) => post.text)).toEqual([ + 'alternate · Latest · all page 2', + ]) + await expect( + page.getByRole('region', { name: '通常', exact: true }).locator('article'), + ).toHaveCount(1) + await expect( + page + .getByRole('region', { name: '別の接続', exact: true }) + .locator('article'), + ).toHaveCount(2) + const slice = JSON.parse( + ( + await executeTool(page, 'get_column_posts', { + columnId: boundId, + offset: 1, + limit: 1, + }) + ).content[0]?.text ?? '', + ) + expect(slice.posts).toEqual(more.posts) + await executeTool(page, 'set_deck', { title: '別デッキ', columns: [] }) + expect( + (await executeTool(page, 'get_column_posts', { columnId: boundId })) + .isError, + ).toBe(true) +}) diff --git a/e2e/mock-relay.mjs b/e2e/mock-relay.mjs index e46c349..65f5383 100644 --- a/e2e/mock-relay.mjs +++ b/e2e/mock-relay.mjs @@ -218,7 +218,10 @@ const server = createServer(async (request, response) => { itemContent: { list: { id_str: '1234567890', - name: 'My E2E List', + name: + profileName === 'alternate' + ? 'Alternate List' + : 'My E2E List', description: 'Accounts selected for testing', member_count: 10, mode: 'Public', @@ -456,7 +459,7 @@ const server = createServer(async (request, response) => { } const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all' - const label = `${variables.product} · ${follows}` + const label = `${profileName === 'alternate' ? 'alternate · ' : ''}${variables.product} · ${follows}` const id = encodeURIComponent(rawQuery) const entries = secondPage ? [tweet(`s-${id}-2`, `${label} page 2`)] diff --git a/e2e/models/Base.ts b/e2e/models/Base.ts deleted file mode 100644 index 7332770..0000000 --- a/e2e/models/Base.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { expect, type Locator, type Page } from '@playwright/test' - -export class Base { - readonly ambientLinksLocator: Locator - readonly articlesLocator: Locator - readonly continuationErrorLocator: Locator - readonly endOfFeedLocator: Locator - readonly loadingRailLocator: Locator - readonly mutationButtonsLocator: Locator - readonly mutationLinksLocator: Locator - - constructor(readonly page: Page) { - const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/ - const mutationControlNames = - /投稿する|返信|いいね|再投稿|フォロー|ブックマーク/ - - this.ambientLinksLocator = page.getByRole('link', { - name: ambientLinkNames, - }) - this.articlesLocator = page.locator('article') - this.continuationErrorLocator = page - .getByRole('alert') - .filter({ hasText: '続きの投稿を取得できませんでした。' }) - this.endOfFeedLocator = page.getByText('これ以上の投稿はありません。') - this.loadingRailLocator = page.locator('.loading-rail') - this.mutationButtonsLocator = page.getByRole('button', { - name: mutationControlNames, - }) - this.mutationLinksLocator = page.getByRole('link', { - name: mutationControlNames, - }) - } - - protected async navigateTo(path: string) { - await this.page.goto(path) - await this.page.waitForLoadState('networkidle') - } - - async tabTo(target: Locator) { - await this.page.keyboard.press('Tab') - await expect(target).toBeFocused() - } - - async retryContinuation() { - await this.continuationErrorLocator - .getByRole('button', { name: '再試行' }) - .click() - } -} diff --git a/e2e/models/HomePage.ts b/e2e/models/HomePage.ts deleted file mode 100644 index 20839ae..0000000 --- a/e2e/models/HomePage.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Locator, Page } from '@playwright/test' -import { Base } from './Base' - -export class HomePage extends Base { - readonly handleInputLocator: Locator - readonly submitButtonLocator: Locator - - constructor(page: Page) { - super(page) - this.handleInputLocator = page.getByLabel('ハンドルまたはプロフィール URL') - this.submitButtonLocator = page.getByRole('button', { name: '表示' }) - } - - async goTo() { - await this.navigateTo('/') - } - - async displayUser(handle: string) { - await this.handleInputLocator.fill(handle) - await this.submitButtonLocator.click() - } -} diff --git a/e2e/models/ListsPage.ts b/e2e/models/ListsPage.ts deleted file mode 100644 index 2053c24..0000000 --- a/e2e/models/ListsPage.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Base } from './Base' - -export class ListsPage extends Base { - async goTo() { - await this.navigateTo('/i/lists') - } - - async openList(name: RegExp | string) { - await this.page.getByRole('link', { name }).click() - } -} diff --git a/e2e/models/SearchPage.ts b/e2e/models/SearchPage.ts deleted file mode 100644 index 7e3e540..0000000 --- a/e2e/models/SearchPage.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Locator, Page } from '@playwright/test' -import { Base } from './Base' - -export class SearchPage extends Base { - readonly followingOnlyLocator: Locator - readonly latestRankingLocator: Locator - readonly queryInputLocator: Locator - readonly searchButtonLocator: Locator - readonly topRankingLocator: Locator - - constructor(page: Page) { - super(page) - this.followingOnlyLocator = page.getByLabel('フォロー中のみ') - this.latestRankingLocator = page.getByLabel('最新順') - this.queryInputLocator = page.getByLabel('検索語') - this.searchButtonLocator = page.getByRole('button', { name: '検索' }) - this.topRankingLocator = page.getByLabel('人気順') - } - - async goTo() { - await this.navigateTo('/search?q=&product=Latest&following=false') - } - - async search(query: string) { - await this.queryInputLocator.fill(query) - await this.searchButtonLocator.click() - } - - async searchTopFromFollowing(query: string) { - await this.queryInputLocator.fill(query) - await this.topRankingLocator.check() - await this.followingOnlyLocator.check() - await this.searchButtonLocator.click() - } -} diff --git a/e2e/models/ThreadPage.ts b/e2e/models/ThreadPage.ts deleted file mode 100644 index d4e7668..0000000 --- a/e2e/models/ThreadPage.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Locator, Page } from '@playwright/test' -import { Base } from './Base' - -export class ThreadPage extends Base { - readonly headingLocator: Locator - readonly selectedPostLabelLocator: Locator - - constructor(page: Page) { - super(page) - this.headingLocator = page.getByRole('heading', { name: '会話' }) - this.selectedPostLabelLocator = page.getByText('表示中の投稿') - } - - async goTo(handle: string, tweetId: string) { - await this.navigateTo(`/${handle}/status/${tweetId}`) - } -} diff --git a/e2e/models/UserPage.ts b/e2e/models/UserPage.ts deleted file mode 100644 index f792b61..0000000 --- a/e2e/models/UserPage.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Base } from './Base' - -export class UserPage extends Base { - async goTo(handle: string) { - await this.navigateTo(`/${handle}`) - } - - async openPostDetails(postText: string) { - await this.page - .getByRole('article') - .filter({ hasText: postText }) - .getByRole('link', { name: '詳細・スレッド' }) - .click() - } -} diff --git a/flake.nix b/flake.nix index cc93e38..b215fc7 100644 --- a/flake.nix +++ b/flake.nix @@ -113,13 +113,6 @@ description = "Base URL of the Twitter relay."; }; - profileName = lib.mkOption { - type = lib.types.nullOr lib.types.nonEmptyStr; - default = null; - example = "default"; - description = "Relay profile to use when the relay has multiple profiles."; - }; - host = lib.mkOption { type = lib.types.nonEmptyStr; default = "127.0.0.1"; @@ -142,8 +135,6 @@ HOST = cfg.host; PORT = toString cfg.port; TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl; - } // lib.optionalAttrs (cfg.profileName != null) { - BIRD_PROFILE_NAME = cfg.profileName; }; serviceConfig = { DynamicUser = true; diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 8586d02..4286a65 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,43 +1,21 @@ -import { Link } from '@tanstack/react-router' -import { ProfileSwitcher } from '#/features/profiles/profile-switcher' +import { Icon } from './icon' export function AppShell({ - active, + sidebar, children, }: { - active?: 'user' | 'search' | 'list' | 'deck' + sidebar: React.ReactNode children: React.ReactNode }) { return ( -
-
-

Twitter Lite

- - -
+
+
{children}
) diff --git a/src/components/dialog.tsx b/src/components/dialog.tsx new file mode 100644 index 0000000..1fb9e45 --- /dev/null +++ b/src/components/dialog.tsx @@ -0,0 +1,51 @@ +import { useEffect, useId, useRef } from 'react' +import { Icon } from './icon' + +export function Dialog({ + title, + onClose, + children, +}: { + title: string + onClose: () => void + children: React.ReactNode +}) { + const dialog = useRef(null) + const headingId = useId() + useEffect(() => { + const element = dialog.current + const opener = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null + element?.showModal() + return () => { + element?.close() + if (opener?.isConnected) opener.focus({ preventScroll: true }) + } + }, []) + return ( + { + event.preventDefault() + onClose() + }} + > +
+

{title}

+ +
+ {children} +
+ ) +} diff --git a/src/components/icon.tsx b/src/components/icon.tsx new file mode 100644 index 0000000..31b133d --- /dev/null +++ b/src/components/icon.tsx @@ -0,0 +1,31 @@ +const paths = { + columns: 'M3 4h7v16H3z M14 4h7v16h-7z', + 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 ( + + ) +} diff --git a/src/features/decks/column-editor.tsx b/src/features/decks/column-editor.tsx index 33015d6..391a321 100644 --- a/src/features/decks/column-editor.tsx +++ b/src/features/decks/column-editor.tsx @@ -1,17 +1,66 @@ import { useId, useState } from 'react' +import { useListChoices } from '#/features/posts/use-list-choices' import { columnSchema, type DeckColumn } from './model' +function ListChoices({ + profileName, + onSelect, +}: { + profileName: string + onSelect: (id: string) => void +}) { + const lists = useListChoices(profileName) + if (lists.isPending) return

リストを取得しています…

+ if (lists.isError) + return ( +

+ リストを取得できませんでした。 + +

+ ) + return ( +
+ {lists.data.length === 0 ? ( +

リストはありません。URLまたはIDでも指定できます。

+ ) : ( + lists.data.map((list) => ( + + )) + )} +
+ ) +} + export function ColumnEditor({ column, + profiles, onSave, onCancel, }: { column?: DeckColumn + profiles: string[] onSave: (column: DeckColumn) => void onCancel: () => void }) { const formId = useId() const [error, setError] = useState() + const [kind, setKind] = useState( + column?.source.kind ?? 'search', + ) + const [profileName, setProfileName] = useState( + column?.profileName ?? profiles[0] ?? '', + ) + const [target, setTarget] = useState( + column && column.source.kind !== 'search' ? column.source.target : '', + ) return (
{ event.preventDefault() const data = new FormData(event.currentTarget) + const source = + kind === 'search' + ? { + platform: 'twitter', + kind, + query: data.get('query'), + product: data.get('product'), + following: data.get('following') === 'on', + } + : { platform: 'twitter', kind, target } const parsed = columnSchema.safeParse({ - // getRandomValues also works on HTTP tailnet origins. id: column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'), title: data.get('title'), - source: { - platform: 'twitter', - query: data.get('query'), - product: data.get('product'), - following: data.get('following') === 'on', - }, + profileName, + source, }) - if (!parsed.success) { + if (!parsed.success || !profiles.includes(profileName)) { setError( - 'カラム名と検索条件を確認してください。検索語はフォロー条件を含め512文字以内です。', + parsed.success + ? '接続プロファイルを選択してください。' + : parsed.error.issues[0]?.message, ) return } onSave(parsed.data) }} > -

{column ? 'カラムを編集' : '調べる観点を追加'}

- -
-

- from:、lang:、since: など、Twitterの検索構文を使えます。 + {kind === 'search' ? ( +

+ + +

+ from:、lang:、since: など、Twitterの検索構文を使えます。 +

+ +
+ ) : ( +
+ + {kind === 'list' && profiles.includes(profileName) ? ( + + ) : null} +
+ )} +

+ このカラムは選択した接続プロファイルで取得します。

- {error ? (

{error}

) : null}
- +
+ +

{column.title}

- - - +
+ + + +
+ + + + +
+
+
+ + {column.profileName} + + + {column.source.kind === 'search' + ? `${column.source.product === 'Latest' ? '最新' : '話題'}${column.source.following ? ' · フォロー中' : ''}` + : column.source.kind === 'user' + ? 'ユーザー投稿' + : 'リスト'} + +
+

+ {column.source.kind === 'search' + ? column.source.query + : column.source.target} +

{query.isFetching ? '投稿を取得しています…' diff --git a/src/features/decks/deck-page.tsx b/src/features/decks/deck-page.tsx index a564b43..87c98df 100644 --- a/src/features/decks/deck-page.tsx +++ b/src/features/decks/deck-page.tsx @@ -1,33 +1,63 @@ -import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useServerFn } from '@tanstack/react-start' +import { useRef, useState } from 'react' import { AppShell } from '#/components/app-shell' +import { Dialog } from '#/components/dialog' +import { Icon } from '#/components/icon' +import { loadProfiles } from '#/features/profiles/server-functions' import { ColumnEditor } from './column-editor' +import { type ColumnRegistry, useColumnTools } from './column-tools' import { ResearchColumn } from './deck-column' +import { DeckSwitcher } from './deck-switcher' import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model' import { useDeck } from './use-deck' import { useDeckTools } from './webmcp-tools' import './decks.css' export function DeckPage() { - const { deck, ready, storageError, save } = useDeck() + const registry = useRef(new Map()).current + const { + deck, + workspace, + ready, + storageError, + save, + create, + select, + remove, + getWorkspace, + } = useDeck() + const fetchProfiles = useServerFn(loadProfiles) + const profiles = useQuery({ + queryKey: ['profiles'], + queryFn: () => fetchProfiles(), + retry: false, + }) const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null) const [renaming, setRenaming] = useState(false) + useColumnTools(registry, ready) const [removed, setRemoved] = useState<{ column: DeckColumn index: number }>() useDeckTools({ - deck, ready, storageError, save, - onApplied: () => { - setEditing(null) - setRenaming(false) - setRemoved(undefined) - }, + select, + remove, + getWorkspace, + profiles: profiles.data?.profiles, + onApplied: clearEditors, }) + function clearEditors() { + setEditing(null) + setRenaming(false) + setRemoved(undefined) + } + function saveColumn(column: DeckColumn) { save({ ...deck, @@ -42,83 +72,148 @@ export function DeckPage() { } return ( - + + { + select(id) + clearEditors() + }} + onCreate={(title) => { + create(title) + clearEditors() + }} + onDelete={() => { + remove(deck.id) + clearEditors() + }} + /> + +

+

このブラウザに保存

+ + } + >

{deck.title}

-

- 観点ごとに検索を並べて、投稿を読み比べる。 -

+ + {deck.columns.length} / {MAX_COLUMNS} カラム +
-
-

- {!ready - ? 'デッキを読み込んでいます…' - : storageError - ? 'デッキの保存状態を確認してください。' - : `このブラウザにデッキの条件を保存 · ${deck.columns.length} / ${MAX_COLUMNS} カラム`} -

+ {!ready ? ( +

+ デッキを読み込んでいます… +

+ ) : null} + {profiles.isError ? ( +

+ 接続プロファイルを取得できませんでした。 + +

+ ) : null} + {profiles.isPending ? ( +

接続プロファイルを取得しています…

+ ) : null} {storageError ? (

{storageError}

) : null} {renaming ? ( - { - event.preventDefault() - const title = String( - new FormData(event.currentTarget).get('title') ?? '', - ).trim() - if (!title) return - save({ ...deck, title }) - setRenaming(false) - }} - > - - - + setRenaming(false)}> +
{ + event.preventDefault() + const title = String( + new FormData(event.currentTarget).get('title') ?? '', + ).trim() + if (!title) return + save({ ...deck, title }) + setRenaming(false) + }} + > + + +
+
) : null} {editing !== null ? ( - column.id === editing.id) - } - onSave={saveColumn} - onCancel={() => setEditing(null)} - /> + setEditing(null)} + > + column.id === editing.id) + } + onSave={saveColumn} + onCancel={() => setEditing(null)} + /> + ) : null} {removed ? (
@@ -139,9 +234,10 @@ export function DeckPage() { ) : null} {ready && deck.columns.length === 0 && editing === null ? (
-

最初の観点を追加しましょう。

+ +

調査のカラムを並べましょう

- 「日本語での反応」「発表元の投稿」など、調べたい切り口に名前と検索条件を付けてください。 + 検索、ユーザー投稿、リスト。読みたい情報を選んで、ここに追加できます。

+ +
+ {creating ? ( + setCreating(false)}> +
{ + event.preventDefault() + const title = String( + new FormData(event.currentTarget).get('title') ?? '', + ).trim() + if (!title) return + onCreate(title) + setCreating(false) + }} + > + + + +
+
+ ) : null} + {deleting ? ( + setDeleting(false)}> +
+

このデッキとカラムの設定を削除します。

+ + +
+
+ ) : null} +
+ ) +} diff --git a/src/features/decks/decks.css b/src/features/decks/decks.css index 3791ea0..b410960 100644 --- a/src/features/decks/decks.css +++ b/src/features/decks/decks.css @@ -1,61 +1,68 @@ -/* Hallmark · existing Garden tokens · research deck: horizontal columns - * User-requested application layout; inherit the reader's typography and color. - * pre-emit critique: P4 H4 E4 S5 R5 V4 - */ -.app-deck .reader { - min-width: 0; -} -.app-deck button, -.app-deck input, -.app-deck select { - font: inherit; -} +/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */ +/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */ .app-deck button { - min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + min-height: 36px; padding: var(--space-xs) var(--space-sm); border: var(--rule-hairline) solid var(--control-border); border-radius: var(--radius-sm); color: var(--ink); - background: var(--paper); + background: var(--surface); cursor: pointer; white-space: nowrap; - transition: background-color var(--dur-short) var(--ease-out); } .app-deck button:hover:not(:disabled) { - background: var(--surface); -} -.app-deck button:active:not(:disabled) { background: var(--color-paper-3); } +.app-deck button:active:not(:disabled) { + color: var(--accent); +} .app-deck button:disabled { - opacity: 0.5; + opacity: 0.45; cursor: default; } -.app-deck :focus-visible { - outline: var(--rule-active) solid var(--color-focus); - outline-offset: 3px; +.app-deck .icon-button { + width: 36px; + height: 36px; + padding: var(--space-xs); + border-color: transparent; + background: transparent; } .app-deck .deck-primary { color: var(--color-accent-ink); background: var(--accent); border-color: var(--accent); + font-weight: 650; } .app-deck .deck-primary:hover:not(:disabled), .app-deck .deck-primary:active:not(:disabled) { background: var(--accent-hover); + color: var(--color-accent-ink); } .deck-heading { display: flex; - align-items: start; + align-items: center; justify-content: space-between; - flex-wrap: wrap; - gap: var(--space-md); + flex-shrink: 0; + gap: var(--space-sm); + min-height: 58px; + padding: var(--space-xs) var(--space-md); } .deck-heading > div { min-width: 0; } .deck-heading h1 { - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.deck-heading p { + margin: var(--space-2xs) 0 0; + color: var(--secondary); + font-size: var(--text-xs); } .deck-actions { display: flex; @@ -67,131 +74,82 @@ .deck-save-status, .deck-column-status { color: var(--secondary); - font-size: var(--text-sm); - line-height: 1.6; + font-size: var(--text-xs); + line-height: 1.5; } .deck-save-status { - margin: var(--space-md) 0; -} -.deck-editor { - margin-block: var(--space-lg); - padding-block: var(--space-lg); - border-block: var(--rule-hairline) solid var(--border); -} -.deck-editor h2, -.deck-empty h2 { - font-size: var(--text-md); - margin: 0 0 var(--space-md); - overflow-wrap: anywhere; -} -.deck-editor-fields { - display: grid; - gap: var(--space-md); -} -.deck-editor label, -.deck-rename label { - display: grid; - gap: var(--space-xs); - font-size: var(--text-sm); - min-width: 0; -} -.deck-editor input:not([type="checkbox"]), -.deck-editor select, -.deck-rename input { - min-width: 0; - width: 100%; - min-height: 44px; - padding: var(--space-xs) var(--space-sm); - border: var(--rule-hairline) solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--paper); - color: var(--ink); -} -.deck-editor .deck-checkbox { - display: flex; - align-items: center; - min-height: 44px; - margin-bottom: var(--space-sm); -} -.deck-checkbox input { - accent-color: var(--accent); - width: 1.15rem; - height: 1.15rem; -} -.deck-rename { - display: flex; - flex-wrap: wrap; - align-items: end; - gap: var(--space-sm); - margin-block: var(--space-md); -} -.deck-rename label { - flex: 1 1 15rem; -} -.deck-error { - color: var(--error); - line-height: 1.6; -} -.deck-undo { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-sm); - margin-block: var(--space-md); - overflow-wrap: anywhere; -} -.deck-empty { - max-width: 40rem; - padding-block: var(--space-2xl); -} -.deck-empty p { - line-height: 1.8; - margin-bottom: var(--space-lg); + margin: 0; + padding: 0 var(--space-md) var(--space-xs); } .deck-board { display: grid; grid-auto-flow: column; - grid-auto-columns: min(100%, 23rem); - gap: var(--space-md); + grid-auto-columns: 328px; + justify-content: start; + flex: 1; + min-height: 0; + gap: var(--space-xs); overflow-x: auto; - padding: var(--space-2xs) var(--space-2xs) var(--space-md); + overflow-y: hidden; + padding: 0 var(--space-xs) var(--space-xs); scroll-snap-type: x proximity; + scrollbar-color: var(--control-border) var(--canvas); } .deck-column { min-width: 0; + min-height: 0; display: flex; flex-direction: column; - height: max(30rem, calc(100svh - 20rem)); - border: var(--rule-hairline) solid var(--border); background: var(--paper); scroll-snap-align: start; } .deck-column-header { - padding: var(--space-md); + position: relative; + flex-shrink: 0; + padding: var(--space-xs) var(--space-sm); border-bottom: var(--rule-hairline) solid var(--border); + background: var(--surface); +} +.deck-column-title { + display: flex; + align-items: center; + gap: var(--space-xs); +} +.deck-column-title > svg { + color: var(--accent); +} +.deck-column h2 { + flex: 1; + min-width: 0; + margin: 0; + font-size: var(--text-base); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .deck-column-label { display: flex; + align-items: center; flex-wrap: wrap; - justify-content: space-between; gap: var(--space-xs); color: var(--secondary); font-size: var(--text-xs); } -.deck-column h2 { - margin: var(--space-sm) 0; - font-size: var(--text-md); +.deck-column-label span { + min-width: 0; overflow-wrap: anywhere; } .deck-query { - margin: 0 0 var(--space-md); - font-size: var(--text-sm); - overflow-wrap: anywhere; - max-height: 4.5em; - overflow-y: auto; + margin: var(--space-2xs) 0; + font-size: var(--text-xs); + color: var(--secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .deck-column-status { - margin-bottom: 0; + margin: var(--space-2xs) 0 0; font-variant-numeric: tabular-nums; } .deck-column-results { @@ -199,27 +157,70 @@ overflow-y: auto; flex: 1; overscroll-behavior-y: contain; + scrollbar-width: thin; + scrollbar-color: var(--control-border) var(--paper); +} +.column-menu { + position: relative; +} +.column-menu summary { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + list-style: none; + cursor: pointer; +} +.column-menu summary::-webkit-details-marker { + display: none; +} +.column-menu summary:hover, +.column-menu[open] summary { + background: var(--color-paper-3); +} +.column-menu-panel { + position: absolute; + top: 100%; + right: 0; + z-index: 5; + display: flex; + flex-direction: column; + align-items: stretch; + min-width: 158px; + padding: var(--space-2xs); + border: var(--rule-hairline) solid var(--control-border); + background: var(--surface); + box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow); +} +.column-menu-panel button { + justify-content: flex-start; + border: 0; } .deck-message { padding: var(--space-md); color: var(--secondary); - line-height: 1.7; + font-size: var(--text-sm); + line-height: 1.6; } .research-post { - padding: var(--space-md); + padding: var(--space-sm); border-bottom: var(--rule-hairline) solid var(--border); overflow-wrap: anywhere; } .research-post header { display: flex; align-items: center; - gap: var(--space-sm); + gap: var(--space-xs); } .research-post header > div { min-width: 0; } .research-post header img { - border-radius: 50%; + flex-shrink: 0; + border-radius: var(--radius-sm); + object-fit: cover; } .research-post strong { font-size: var(--text-sm); @@ -227,20 +228,22 @@ .research-post-text, .research-post blockquote p { white-space: pre-wrap; - line-height: 1.8; + line-height: 1.55; font-size: var(--text-sm); + margin: var(--space-xs) 0; } .research-post blockquote { - margin: var(--space-md) 0; - padding-left: var(--space-md); - border-left: var(--rule-hairline) solid var(--border); + margin: var(--space-sm) 0; + padding: var(--space-sm); + border: var(--rule-hairline) solid var(--border); + border-radius: var(--radius-sm); } .research-post footer { display: flex; flex-wrap: wrap; justify-content: space-between; - gap: var(--space-sm); - margin-top: var(--space-md); + gap: var(--space-xs); + margin-top: var(--space-sm); color: var(--secondary); font-size: var(--text-xs); } @@ -253,22 +256,258 @@ width: 100%; max-height: 20rem; object-fit: contain; - margin-block: var(--space-sm); + margin-block: var(--space-xs); + border-radius: var(--radius-sm); } -@media (min-width: 52rem) { - .app.app-deck { - width: 100%; - grid-template-columns: 11rem minmax(0, 1fr); +.deck-dialog { + width: min(480px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + padding: var(--space-lg); + overflow-y: auto; + border: var(--rule-hairline) solid var(--control-border); + border-radius: var(--radius-md); + background: var(--paper); + color: var(--ink); + box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow); +} +.deck-dialog::backdrop { + background: var(--color-overlay); +} +.dialog-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + margin-bottom: var(--space-md); +} +.dialog-heading h2, +.deck-editor h2, +.deck-empty h2 { + margin: 0; + font-size: var(--text-md); + overflow-wrap: anywhere; +} +.deck-editor { + margin: 0; + padding: 0; +} +.deck-editor-fields, +.deck-source-fields { + display: grid; + gap: var(--space-md); +} +.deck-source-fields { + margin-block: var(--space-md); +} +.deck-editor label, +.deck-rename label { + display: grid; + gap: var(--space-xs); + font-size: var(--text-sm); + min-width: 0; +} +.deck-editor input:not([type="checkbox"]), +.deck-editor select, +.deck-rename input, +.deck-workspaces select { + min-width: 0; + width: 100%; + min-height: 40px; + padding: var(--space-xs); + border: var(--rule-hairline) solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--canvas); + color: var(--ink); +} +.deck-editor .deck-checkbox { + display: flex; + align-items: center; + min-height: 40px; + margin-bottom: var(--space-sm); +} +.deck-checkbox input { + accent-color: var(--accent); + width: 1.15rem; + height: 1.15rem; +} +.deck-rename { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-sm); + margin: 0; +} +.deck-rename label { + flex: 1 1 100%; +} +.deck-error { + color: var(--error); + font-size: var(--text-sm); + line-height: 1.6; + overflow-wrap: anywhere; +} +.reader > .deck-error { + margin: 0; + padding: var(--space-xs) var(--space-md); +} +.deck-undo { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-md); + overflow-wrap: anywhere; + font-size: var(--text-sm); +} +.deck-undo p { + margin: 0; +} +.deck-empty { + margin: auto; + max-width: 32rem; + padding: var(--space-lg); +} +.deck-empty p { + color: var(--secondary); + line-height: 1.7; + margin-bottom: var(--space-lg); +} +.deck-workspaces { + min-width: 0; +} +.deck-workspaces > .deck-actions { + align-items: stretch; +} +.deck-workspaces > .deck-actions > label { + width: 100%; + color: var(--secondary); + font-size: var(--text-xs); +} +.deck-workspaces > .deck-actions > select { + flex: 1 1 100%; +} +.deck-workspaces > .deck-actions > button { + flex: 1; +} +.deck-list-choices { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} +.app-deck .sidebar-add { + width: 100%; +} +@media (max-width: 767px) { + .deck-workspaces { + flex: 1; } - .app-deck .reader { - padding: var(--space-xl) var(--space-lg); + .deck-workspaces > .deck-actions { + flex-wrap: nowrap; + gap: var(--space-2xs); } - .deck-editor-fields { - grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(7rem, 0.5fr); + .deck-workspaces > .deck-actions > label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + } + .deck-workspaces > .deck-actions > select { + flex: 1; + width: 0; + } + .deck-workspaces > .deck-actions > button { + flex: none; + } + .app-deck .sidebar-add { + width: 36px; + flex-shrink: 0; + padding: var(--space-xs); + } + .sidebar-add span { + display: none; + } + .deck-heading { + min-height: 52px; + padding-inline: var(--space-sm); + } + .deck-heading .deck-actions { + flex-wrap: nowrap; + } + .deck-board { + grid-auto-columns: min(328px, 100%); } } -@media (prefers-reduced-motion: reduce) { - .app-deck button { - transition: none; +@media (pointer: coarse) { + .app-deck .icon-button, + .column-menu summary { + min-width: 40px; + min-height: 40px; + } +} +.deck-count { + color: var(--secondary); + font-size: var(--text-xs); +} +.research-post header time { + display: block; + color: var(--secondary); + font-size: var(--text-xs); +} +.deck-column-status { + text-align: right; +} +@media (max-width: 767px) { + .workspace-create span, + .workspace-delete span { + display: none; + } + .app-deck .workspace-create, + .app-deck .workspace-delete { + width: 36px; + padding: var(--space-xs); + } +} +.sidebar-columns { + display: flex; + flex-direction: column; + gap: var(--space-2xs); + min-height: 0; + overflow-y: auto; +} +.app-deck .sidebar-columns button { + flex-shrink: 0; + justify-content: flex-start; + width: 100%; + background: transparent; + border-color: transparent; +} +.sidebar-columns span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.research-post header > div { + flex: 1; +} +.research-post header time { + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} +.research-post footer a { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); +} +.research-post footer svg { + width: 14px; + height: 14px; +} +.column-menu-panel .danger { + color: var(--error); +} +@media (max-width: 767px) { + .sidebar-columns { + display: none; } } diff --git a/src/features/decks/model.test.ts b/src/features/decks/model.test.ts index 09c42e5..499b5e9 100644 --- a/src/features/decks/model.test.ts +++ b/src/features/decks/model.test.ts @@ -1,26 +1,67 @@ import { describe, expect, it } from 'vitest' -import { type Deck, deckSchema, moveColumn } from './model' +import { type Deck, deckSchema, moveColumn, workspaceSchema } from './model' const deck: Deck = { - version: 1, + id: 'research', title: '調査', columns: ['a', 'b'].map((id) => ({ id, title: id, + profileName: 'main', source: { platform: 'twitter', + kind: 'search', query: 'AI lang:ja', product: 'Latest', following: false, }, })), } - describe('deck definitions', () => { - it('preserves native Twitter search conditions when serialized', () => { + it('preserves native search and the bound relay profile when serialized', () => { expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck) }) - it('rejects duplicate column identities and unsupported platforms', () => { + it('normalizes user and list targets and rejects invalid targets', () => { + const column = deck.columns[0] + expect( + deckSchema.parse({ + ...deck, + columns: [ + { + ...column, + source: { platform: 'twitter', kind: 'user', target: '@alice' }, + }, + ], + }).columns[0]?.source, + ).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' }) + expect( + deckSchema.parse({ + ...deck, + columns: [ + { + ...column, + source: { + platform: 'twitter', + kind: 'list', + target: 'https://x.com/i/lists/123', + }, + }, + ], + }).columns[0]?.source, + ).toEqual({ platform: 'twitter', kind: 'list', target: '123' }) + expect( + deckSchema.safeParse({ + ...deck, + columns: [ + { + ...column, + source: { platform: 'twitter', kind: 'list', target: 'invalid' }, + }, + ], + }).success, + ).toBe(false) + }) + it('rejects duplicate identities and missing profiles', () => { expect( deckSchema.safeParse({ ...deck, @@ -30,13 +71,25 @@ describe('deck definitions', () => { expect( deckSchema.safeParse({ ...deck, - columns: [ - { ...deck.columns[0], source: { platform: 'nostr', query: 'AI' } }, - ], + columns: [{ ...deck.columns[0], profileName: '' }], + }).success, + ).toBe(false) + expect( + workspaceSchema.safeParse({ + version: 2, + activeDeckId: 'missing', + decks: [deck], + }).success, + ).toBe(false) + expect( + workspaceSchema.safeParse({ + version: 2, + activeDeckId: deck.id, + decks: [deck, deck], }).success, ).toBe(false) }) - it('rejects queries that exceed the upstream limit after adding follows', () => { + it('rejects queries exceeding the upstream limit after adding follows', () => { expect( deckSchema.safeParse({ ...deck, @@ -45,6 +98,7 @@ describe('deck definitions', () => { ...deck.columns[0], source: { platform: 'twitter', + kind: 'search', query: 'a'.repeat(512), product: 'Latest', following: true, @@ -54,7 +108,7 @@ describe('deck definitions', () => { }).success, ).toBe(false) }) - it('moves a column without changing its query or the previous deck', () => { + it('moves a column without changing its conditions or the previous deck', () => { expect(moveColumn(deck, 'b', -1).columns).toEqual([ deck.columns[1], deck.columns[0], diff --git a/src/features/decks/model.ts b/src/features/decks/model.ts index 9bc062d..ed4849f 100644 --- a/src/features/decks/model.ts +++ b/src/features/decks/model.ts @@ -1,13 +1,16 @@ import { z } from 'zod' -import { buildSearchQuery } from '#/features/posts/inputs' +import { + buildSearchQuery, + normalizeListTarget, + normalizeUserTarget, +} from '#/features/posts/inputs' export const MAX_COLUMNS = 6 -// Queries stay native to the provider. Add a discriminated-union member when -// the next connector is implemented instead of inventing a shared search DSL. -const columnSourceSchema = z +const searchSourceSchema = z .object({ platform: z.literal('twitter'), + kind: z.literal('search'), query: z.string().trim().min(1).max(512), product: z.enum(['Latest', 'Top']), following: z.boolean(), @@ -27,17 +30,57 @@ const columnSourceSchema = z } }) +function targetSchema(normalize: (target: string) => string) { + return z + .string() + .trim() + .min(1) + .max(256) + .transform((target, ctx) => { + try { + return normalize(target) + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: + error instanceof Error ? error.message : '対象を確認してください。', + }) + return z.NEVER + } + }) +} + +const userSourceSchema = z + .object({ + platform: z.literal('twitter'), + kind: z.literal('user'), + target: targetSchema(normalizeUserTarget), + }) + .strict() +const listSourceSchema = z + .object({ + platform: z.literal('twitter'), + kind: z.literal('list'), + target: targetSchema(normalizeListTarget), + }) + .strict() + export const columnSchema = z .object({ id: z.string().min(1).max(128), title: z.string().trim().min(1).max(80), - source: columnSourceSchema, + profileName: z.string().min(1), + source: z.discriminatedUnion('kind', [ + searchSourceSchema, + userSourceSchema, + listSourceSchema, + ]), }) .strict() export const deckSchema = z .object({ - version: z.literal(1), + id: z.string().min(1).max(128), title: z.string().trim().min(1).max(120), columns: z.array(columnSchema).max(MAX_COLUMNS), }) @@ -49,10 +92,41 @@ export const deckSchema = z 'カラム ID が重複しています。', ) +export const workspaceSchema = z + .object({ + version: z.literal(2), + activeDeckId: deckSchema.shape.id, + decks: z.array(deckSchema).min(1), + }) + .strict() + .refine( + (workspace) => + new Set(workspace.decks.map((deck) => deck.id)).size === + workspace.decks.length, + 'デッキ ID が重複しています。', + ) + .refine( + (workspace) => + workspace.decks.some((deck) => deck.id === workspace.activeDeckId), + '選択したデッキが見つかりません。', + ) + export type Deck = z.infer export type DeckColumn = z.infer - -export const EMPTY_DECK: Deck = { version: 1, title: '新しい調査', columns: [] } +export type Workspace = z.infer +export const EMPTY_DECK: Deck = { + id: 'initial', + title: '新しい調査', + columns: [], +} +export const EMPTY_WORKSPACE: Workspace = { + version: 2, + activeDeckId: EMPTY_DECK.id, + decks: [EMPTY_DECK], +} +export function newId() { + return crypto.getRandomValues(new Uint32Array(4)).join('-') +} export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck { const index = deck.columns.findIndex((column) => column.id === id) diff --git a/src/features/decks/research-post-card.tsx b/src/features/decks/research-post-card.tsx index 77eac90..9843bde 100644 --- a/src/features/decks/research-post-card.tsx +++ b/src/features/decks/research-post-card.tsx @@ -1,9 +1,12 @@ +import { Icon } from '#/components/icon' import { PostText } from '#/components/post-text' import type { ResearchPost } from '#/features/platforms/types' const dateFormatter = new Intl.DateTimeFormat('ja-JP', { - dateStyle: 'short', - timeStyle: 'short', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', timeZone: 'Asia/Tokyo', }) @@ -15,15 +18,22 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) { ) : null}
{post.author.name} - {post.author.handle} + + @{post.author.handle.replace(/^@/, '')} +
+ {post.createdAt ? ( + + ) : null}

@@ -64,13 +74,8 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) { ) : null}

diff --git a/src/features/decks/use-deck.test.ts b/src/features/decks/use-deck.test.ts index 635c759..c311e43 100644 --- a/src/features/decks/use-deck.test.ts +++ b/src/features/decks/use-deck.test.ts @@ -1,20 +1,20 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { EMPTY_DECK } from './model' +import { EMPTY_DECK, EMPTY_WORKSPACE } from './model' import { DECK_STORAGE_KEY, useDeck } from './use-deck' beforeEach(() => { localStorage.clear() vi.restoreAllMocks() }) - -describe('saved deck', () => { +describe('saved decks', () => { it('restores saved conditions without overwriting them during mount', () => { - const saved = { ...EMPTY_DECK, title: 'WebMCP調査' } + const deck = { ...EMPTY_DECK, title: 'WebMCP調査' } + const saved = { ...EMPTY_WORKSPACE, decks: [deck] } localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(saved)) const { result } = renderHook(useDeck) expect(result.current.ready).toBe(true) - expect(result.current.deck).toEqual(saved) + expect(result.current.deck).toEqual(deck) expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual( saved, ) @@ -25,21 +25,42 @@ describe('saved deck', () => { expect(result.current.storageError).toContain('読み込めません') expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken') }) - it('persists edits and restores them on remount', () => { - const first = renderHook(useDeck) - act(() => - first.result.current.save({ ...EMPTY_DECK, title: '保存した調査' }), - ) - first.unmount() - expect(renderHook(useDeck).result.current.deck.title).toBe('保存した調査') - }) - it('reports failed writes instead of claiming that changes were saved', () => { + it('reports failed writes while keeping changes usable in memory', () => { vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError') }) const { result } = renderHook(useDeck) - act(() => result.current.save({ ...EMPTY_DECK, title: '未保存' })) + let persisted = true + act(() => { + persisted = result.current.save({ ...EMPTY_DECK, title: '未保存' }) + }) + expect(persisted).toBe(false) expect(result.current.deck.title).toBe('未保存') expect(result.current.storageError).toContain('保存できません') }) + it('keeps decks isolated through immediate create, edit, switch and reload', () => { + const { result, unmount } = renderHook(useDeck) + const firstId = result.current.deck.id + let secondId = '' + act(() => { + result.current.create('第二の調査') + secondId = result.current.getWorkspace().activeDeckId + result.current.save({ + id: secondId, + title: '変更した第二の調査', + columns: [], + }) + result.current.select(firstId) + }) + expect(result.current.deck.title).toBe('新しい調査') + expect(result.current.workspace.decks).toHaveLength(2) + unmount() + const restored = renderHook(useDeck) + act(() => restored.result.current.select(secondId)) + expect(restored.result.current.deck.title).toBe('変更した第二の調査') + act(() => restored.result.current.remove(secondId)) + expect(restored.result.current.deck.id).toBe(firstId) + expect(() => restored.result.current.remove(firstId)).toThrow('最後') + expect(() => restored.result.current.select('missing')).toThrow() + }) }) diff --git a/src/features/decks/use-deck.ts b/src/features/decks/use-deck.ts index 79ad55a..5884048 100644 --- a/src/features/decks/use-deck.ts +++ b/src/features/decks/use-deck.ts @@ -1,17 +1,30 @@ -import { useEffect, useState } from 'react' -import { type Deck, deckSchema, EMPTY_DECK } from './model' +import { useEffect, useRef, useState } from 'react' +import { + type Deck, + deckSchema, + EMPTY_DECK, + EMPTY_WORKSPACE, + newId, + type Workspace, + workspaceSchema, +} from './model' export const DECK_STORAGE_KEY = 'twitter-lite-research-deck' export function useDeck() { - const [deck, setDeck] = useState(EMPTY_DECK) + const [workspace, setWorkspace] = useState(EMPTY_WORKSPACE) + const current = useRef(workspace) const [ready, setReady] = useState(false) const [storageError, setStorageError] = useState() useEffect(() => { try { const saved = localStorage.getItem(DECK_STORAGE_KEY) - if (saved !== null) setDeck(deckSchema.parse(JSON.parse(saved))) + if (saved !== null) { + const restored = workspaceSchema.parse(JSON.parse(saved)) + current.current = restored + setWorkspace(restored) + } } catch { setStorageError( '保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。', @@ -20,9 +33,10 @@ export function useDeck() { setReady(true) }, []) - function save(next: Deck) { - const validated = deckSchema.parse(next) - setDeck(validated) + function persist(next: Workspace) { + const validated = workspaceSchema.parse(next) + current.current = validated + setWorkspace(validated) try { localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated)) setStorageError(undefined) @@ -35,5 +49,56 @@ export function useDeck() { } } - return { deck, ready, storageError, save } + function save(next: Deck) { + const validated = deckSchema.parse(next) + const previous = current.current + const exists = previous.decks.some((deck) => deck.id === validated.id) + return persist({ + ...previous, + activeDeckId: validated.id, + decks: exists + ? previous.decks.map((deck) => + deck.id === validated.id ? validated : deck, + ) + : [...previous.decks, validated], + }) + } + + function create(title: string) { + return save({ id: newId(), title, columns: [] }) + } + function select(id: string) { + if (!current.current.decks.some((deck) => deck.id === id)) + throw new Error('デッキが見つかりません。') + return persist({ ...current.current, activeDeckId: id }) + } + function remove(id: string) { + const previous = current.current + if (!previous.decks.some((deck) => deck.id === id)) + throw new Error('デッキが見つかりません。') + const decks = previous.decks.filter((deck) => deck.id !== id) + const first = decks[0] + if (!first) throw new Error('最後のデッキは削除できません。') + return persist({ + ...previous, + decks, + activeDeckId: + previous.activeDeckId === id ? first.id : previous.activeDeckId, + }) + } + + const deck = + workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ?? + EMPTY_DECK + return { + deck, + workspace, + ready, + storageError, + save, + create, + select, + remove, + getWorkspace: () => current.current, + } } diff --git a/src/features/decks/use-webmcp-supported.ts b/src/features/decks/use-webmcp-supported.ts new file mode 100644 index 0000000..910408b --- /dev/null +++ b/src/features/decks/use-webmcp-supported.ts @@ -0,0 +1,8 @@ +import { useEffect, useState } from 'react' +export function useWebMCPSupported() { + const [supported, setSupported] = useState(false) + useEffect(() => { + setSupported(Boolean(document.modelContext)) + }, []) + return supported +} diff --git a/src/features/decks/webmcp-contracts.test.ts b/src/features/decks/webmcp-contracts.test.ts index 5cff60e..31245df 100644 --- a/src/features/decks/webmcp-contracts.test.ts +++ b/src/features/decks/webmcp-contracts.test.ts @@ -1,60 +1,108 @@ import { describe, expect, it } from 'vitest' import { prepareDeck } from './webmcp-contracts' +const column = { + title: '観点', + profileName: 'main', + source: { kind: 'search', query: 'AI' }, +} describe('deck tool input', () => { - it('fills Twitter defaults and assigns independent IDs to new columns', () => { - const deck = prepareDeck({ - title: '調査', - columns: [ - { title: '日本語', source: { query: 'WebMCP lang:ja' } }, - { title: '海外', source: { query: 'WebMCP lang:en' } }, - ], - }) + it('fills Twitter defaults and assigns independent deck and column IDs', () => { + const deck = prepareDeck({ title: '調査', columns: [column, column] }, [ + 'main', + ]) expect(deck.columns[0]?.source).toEqual({ platform: 'twitter', - query: 'WebMCP lang:ja', + kind: 'search', + query: 'AI', product: 'Latest', following: false, }) - expect(new Set(deck.columns.map((column) => column.id)).size).toBe(2) - }) - it('keeps existing IDs and rejects duplicates before applying a deck', () => { - const column = { id: 'existing', title: '観点', source: { query: 'AI' } } expect( - prepareDeck({ title: '調査', columns: [column] }).columns[0]?.id, - ).toBe('existing') + new Set([deck.id, ...deck.columns.map((column) => column.id)]).size, + ).toBe(3) + }) + it('keeps supplied identities and rejects duplicates', () => { + const identified = { ...column, id: 'existing' } + expect( + prepareDeck( + { deckId: 'research', title: '調査', columns: [identified] }, + ['main'], + ), + ).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] }) expect(() => - prepareDeck({ title: '調査', columns: [column, column] }), + prepareDeck({ title: '調査', columns: [identified, identified] }, [ + 'main', + ]), ).toThrow() }) + it('rejects unavailable profiles before applying a definition', () => { + expect(() => + prepareDeck({ title: '調査', columns: [column] }, ['work']), + ).toThrow('Unknown relay profile') + }) + it('accepts user and list columns bound to separate profiles', () => { + const deck = prepareDeck( + { + title: '調査', + columns: [ + { ...column, source: { kind: 'user', target: '@alice' } }, + { + ...column, + profileName: 'work', + source: { kind: 'list', target: '123' }, + }, + ], + }, + ['main', 'work'], + ) + expect(deck.columns.map((column) => column.profileName)).toEqual([ + 'main', + 'work', + ]) + expect(deck.columns.map((column) => column.source.kind)).toEqual([ + 'user', + 'list', + ]) + }) it('rejects unsupported platforms, overlong queries and more than six columns', () => { expect(() => - prepareDeck({ - title: '調査', - columns: [ - { title: '観点', source: { platform: 'nostr', query: 'AI' } }, - ], - }), + prepareDeck( + { + title: '調査', + columns: [ + { + ...column, + source: { kind: 'search', platform: 'nostr', query: 'AI' }, + }, + ], + }, + ['main'], + ), ).toThrow() expect(() => - prepareDeck({ - title: '調査', - columns: [ - { - title: '観点', - source: { query: 'a'.repeat(512), following: true }, - }, - ], - }), + prepareDeck( + { + title: '調査', + columns: [ + { + ...column, + source: { + kind: 'search', + query: 'a'.repeat(512), + following: true, + }, + }, + ], + }, + ['main'], + ), ).toThrow() expect(() => - prepareDeck({ - title: '調査', - columns: Array.from({ length: 7 }, () => ({ - title: '観点', - source: { query: 'AI' }, - })), - }), + prepareDeck( + { title: '調査', columns: Array.from({ length: 7 }, () => column) }, + ['main'], + ), ).toThrow() }) }) diff --git a/src/features/decks/webmcp-contracts.ts b/src/features/decks/webmcp-contracts.ts index 0da7c20..1c65cce 100644 --- a/src/features/decks/webmcp-contracts.ts +++ b/src/features/decks/webmcp-contracts.ts @@ -1,43 +1,102 @@ import { z } from 'zod' -import { columnSchema, deckSchema, MAX_COLUMNS } from './model' +import { InputError } from '#/features/posts/inputs' +import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model' -const source = columnSchema.shape.source -const toolColumn = columnSchema.omit({ id: true }).extend({ +const toolColumn = columnSchema.omit({ id: true, source: true }).extend({ id: columnSchema.shape.id .optional() .describe( - 'Keep an existing ID from get_deck when editing; omit for a new column.', + 'Keep an existing column ID when editing; omit for a new column.', ), - source: z - .object({ - ...source.shape, - platform: source.shape.platform.default('twitter'), - product: source.shape.product.default('Latest'), - following: source.shape.following.default(false), - }) - .strict(), + source: z.discriminatedUnion('kind', [ + z + .object({ + platform: z.literal('twitter').default('twitter'), + kind: z.literal('search'), + query: z.string().trim().min(1).max(512), + product: z.enum(['Latest', 'Top']).default('Latest'), + following: z.boolean().default(false), + }) + .strict(), + z + .object({ + platform: z.literal('twitter').default('twitter'), + kind: z.literal('user'), + target: z.string().min(1).max(256), + }) + .strict(), + z + .object({ + platform: z.literal('twitter').default('twitter'), + kind: z.literal('list'), + target: z.string().min(1).max(256), + }) + .strict(), + ]), }) - +export const emptyToolInput = z.object({}).strict() +export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict() +export const getDeckInput = z + .object({ deckId: deckSchema.shape.id.optional() }) + .strict() export const setDeckInput = z .object({ - title: deckSchema.shape.title.describe('The research topic or deck title.'), + deckId: deckSchema.shape.id + .optional() + .describe( + 'Omit to create a deck; supply an existing deck ID to replace and activate it.', + ), + title: deckSchema.shape.title, columns: z .array(toolColumn) .max(MAX_COLUMNS) .describe( - 'Complete ordered column list. Omitted existing columns are removed. Empty list clears the deck.', + 'Complete ordered columns, each bound to a relay profile from list_decks. Omitted existing columns are removed.', ), }) .strict() -export function prepareDeck(input: unknown) { +export function prepareDeck(input: unknown, profiles: readonly string[]) { const parsed = setDeckInput.parse(input) - return deckSchema.parse({ - version: 1, + const deck = deckSchema.parse({ + id: parsed.deckId ?? newId(), title: parsed.title, columns: parsed.columns.map((column) => ({ ...column, - id: column.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'), + id: column.id ?? newId(), })), }) + for (const column of deck.columns) { + if (!profiles.includes(column.profileName)) + throw new InputError(`Unknown relay profile: ${column.profileName}`) + } + return deck +} + +export async function toolResult(run: () => unknown | Promise) { + try { + return { + content: [ + { type: 'text' as const, text: JSON.stringify(await run()) }, + ] as const, + } + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + code: + error instanceof z.ZodError || error instanceof InputError + ? 'invalid-input' + : 'tool-error', + message: + error instanceof Error ? error.message : 'Tool execution failed.', + retryable: false, + }), + }, + ] as const, + } + } } diff --git a/src/features/decks/webmcp-tools.ts b/src/features/decks/webmcp-tools.ts index 3533f6b..cdb7bd9 100644 --- a/src/features/decks/webmcp-tools.ts +++ b/src/features/decks/webmcp-tools.ts @@ -1,63 +1,137 @@ -import { useRef } from 'react' import { useWebMCP } from 'usewebmcp' -import { emptyToolInput, toolResult } from '#/features/posts/webmcp-contracts' -import { useWebMCPSupported } from '#/features/posts/webmcp-tools' -import type { Deck } from './model' -import { prepareDeck, setDeckInput } from './webmcp-contracts' +import type { Deck, Workspace } from './model' +import { useWebMCPSupported } from './use-webmcp-supported' +import { + deckIdInput, + emptyToolInput, + getDeckInput, + prepareDeck, + setDeckInput, + toolResult, +} from './webmcp-contracts' export function useDeckTools({ - deck, ready, storageError, save, + select, + remove, + getWorkspace, + profiles, onApplied, }: { - deck: Deck ready: boolean storageError?: string save: (deck: Deck) => boolean + select: (id: string) => boolean + remove: (id: string) => boolean + getWorkspace: () => Workspace + profiles: string[] | undefined onApplied: () => void }) { const supported = useWebMCPSupported() - const current = useRef({ deck, storageError }) - current.current = { deck, storageError } - + const enabled = supported && ready + function applied(persisted: boolean) { + onApplied() + if (!persisted) + throw new Error( + 'The deck was applied in this tab but could not be saved. Reloading will lose these changes.', + ) + } useWebMCP({ - name: 'get_deck', + name: 'list_decks', description: - 'Read the current research deck: title and ordered columns with IDs and native Twitter search conditions. No post fetch. Use this before editing with set_deck. Available on /deck only.', + 'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.', inputSchema: emptyToolInput, - enabled: supported && ready, + enabled, annotations: { readOnlyHint: true, untrustedContentHint: true }, execute: (input) => toolResult(() => { emptyToolInput.parse(input) + const workspace = getWorkspace() return { - deck: current.current.deck, - storageError: current.current.storageError ?? null, + decks: workspace.decks, + activeDeckId: workspace.activeDeckId, + profiles: profiles ?? null, + storageError: storageError ?? null, } }), }) - + useWebMCP({ + name: 'get_deck', + description: + 'Read one saved deck with column IDs, bound relay profiles and source definitions. Omit deckId for the active deck. No post fetch.', + inputSchema: getDeckInput, + enabled, + annotations: { readOnlyHint: true, untrustedContentHint: true }, + execute: (input) => + toolResult(() => { + const { deckId } = getDeckInput.parse(input) + const workspace = getWorkspace() + const deck = workspace.decks.find( + (deck) => deck.id === (deckId ?? workspace.activeDeckId), + ) + if (!deck) throw new Error('Deck not found.') + return { deck, storageError: storageError ?? null } + }), + }) useWebMCP({ name: 'set_deck', description: - 'Create or replace the entire visible research deck and save its conditions in this browser. Supply a title and up to six ordered Twitter search columns. Keep existing column IDs to edit them; omitted columns are removed. Closes unsaved editor forms. Only Twitter is connected. Returns the applied definition immediately; posts load asynchronously and may fail independently. Available on /deck only.', + 'Create a saved deck when deckId is omitted, or replace an existing deck by ID, then activate it. Up to six ordered columns: search, user timeline or list, each with an explicit relay profile from list_decks. Omitted columns are removed. Closes unsaved forms. Posts load asynchronously and can fail independently.', inputSchema: setDeckInput, - enabled: supported && ready, + enabled, annotations: { readOnlyHint: false, untrustedContentHint: true }, execute: (input) => toolResult(() => { - // Validate the whole definition before modifying either storage or UI. - const next = prepareDeck(input) - const persisted = save(next) - onApplied() - const error = persisted - ? undefined - : 'The deck was applied in this tab but could not be saved. Reloading will lose these changes.' - current.current = { deck: next, storageError: error } - if (error) throw new Error(error) + const parsed = setDeckInput.parse(input) + if ( + parsed.deckId && + !getWorkspace().decks.some((deck) => deck.id === parsed.deckId) + ) + throw new Error('Deck not found.') + if (!profiles) throw new Error('Relay profiles are not available yet.') + const next = prepareDeck(parsed, profiles) + applied(save(next)) return { deck: next, persisted: true, posts: 'loading-asynchronously' } }), }) + useWebMCP({ + name: 'select_deck', + description: + 'Activate a saved deck by ID and save the selection in this browser. Closes unsaved forms; its columns load asynchronously.', + inputSchema: deckIdInput, + enabled, + annotations: { readOnlyHint: false }, + execute: (input) => + toolResult(() => { + const { deckId } = deckIdInput.parse(input) + applied(select(deckId)) + const workspace = getWorkspace() + return { + decks: workspace.decks, + activeDeckId: workspace.activeDeckId, + persisted: true, + } + }), + }) + useWebMCP({ + name: 'delete_deck', + description: + 'Permanently delete one saved deck and its column definitions from this browser. The last remaining deck cannot be deleted. Deleting the active deck selects the first remaining deck.', + inputSchema: deckIdInput, + enabled, + annotations: { readOnlyHint: false, destructiveHint: true }, + execute: (input) => + toolResult(() => { + const { deckId } = deckIdInput.parse(input) + applied(remove(deckId)) + const workspace = getWorkspace() + return { + decks: workspace.decks, + activeDeckId: workspace.activeDeckId, + persisted: true, + } + }), + }) } diff --git a/src/features/posts/bird-client.server.ts b/src/features/posts/bird-client.server.ts index 4419bbd..c134248 100644 --- a/src/features/posts/bird-client.server.ts +++ b/src/features/posts/bird-client.server.ts @@ -1,9 +1,9 @@ import { TwitterClient } from '@yuta/bird' import type { BirdReader } from './post-service' -const clients = new Map() +const clients = new Map() -export function getBirdReader(profileName?: string): BirdReader { +export function getBirdReader(profileName: string): BirdReader { const existing = clients.get(profileName) if (existing) return existing diff --git a/src/features/posts/components/forms.test.tsx b/src/features/posts/components/forms.test.tsx deleted file mode 100644 index a31405d..0000000 --- a/src/features/posts/components/forms.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { ListForm } from './list-form' -import { SearchForm } from './search-form' -import { UserForm } from './user-form' - -const searchValues = { - q: '', - from: '', - since: '', - until: '', - lang: 'all' as const, - content: 'all' as const, - excludeReplies: false, - excludeReposts: false, - product: 'Latest' as const, - following: false, -} - -describe('UserForm', () => { - it('submits only after the user enters a target', () => { - const onSubmit = vi.fn() - render() - - fireEvent.change(screen.getByLabelText('ハンドルまたはプロフィール URL'), { - target: { value: '@yuta' }, - }) - fireEvent.click(screen.getByRole('button', { name: '表示' })) - - expect(onSubmit).toHaveBeenCalledWith('yuta') - }) - - it('explains invalid input beside the field', () => { - const onSubmit = vi.fn() - render() - - fireEvent.click(screen.getByRole('button', { name: '表示' })) - - expect(screen.getByRole('alert')).toHaveTextContent( - 'ハンドルまたはプロフィール URL を入力してください。', - ) - expect(onSubmit).not.toHaveBeenCalled() - }) -}) - -describe('ListForm', () => { - it('submits the list ID from a pasted list URL', () => { - const onSubmit = vi.fn() - render() - - fireEvent.change(screen.getByLabelText('リスト URL または ID'), { - target: { value: 'https://x.com/i/lists/1234567890' }, - }) - fireEvent.click(screen.getByRole('button', { name: '表示' })) - - expect(onSubmit).toHaveBeenCalledWith('1234567890') - }) - - it('explains invalid input beside the field', () => { - const onSubmit = vi.fn() - render() - - fireEvent.click(screen.getByRole('button', { name: '表示' })) - - expect(screen.getByRole('alert')).toHaveTextContent( - 'リストの URL または ID を入力してください。', - ) - expect(onSubmit).not.toHaveBeenCalled() - }) -}) - -describe('SearchForm', () => { - it('uses a labelled grouping for ranking controls', () => { - render() - - expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument() - }) - - it('submits the selected ranking and follows filter', () => { - const onSubmit = vi.fn() - render() - - fireEvent.change(screen.getByLabelText('検索語'), { - target: { value: ' AI lang:ja ' }, - }) - fireEvent.click(screen.getByLabelText('人気順')) - fireEvent.click(screen.getByLabelText('フォロー中のみ')) - expect(screen.getByText('AI lang:ja filter:follows')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '検索' })) - - expect(onSubmit).toHaveBeenCalledWith({ - q: 'AI lang:ja', - from: '', - since: '', - until: '', - lang: 'all', - content: 'all', - excludeReplies: false, - excludeReposts: false, - product: 'Top', - following: true, - }) - }) - - it('submits conditions selected with the filter buttons', () => { - const onSubmit = vi.fn() - render() - - fireEvent.change(screen.getByLabelText('検索語'), { - target: { value: 'TypeScript' }, - }) - fireEvent.click(screen.getByRole('button', { name: '投稿者' })) - fireEvent.change(screen.getByLabelText('投稿者'), { - target: { value: '@tan_stack' }, - }) - fireEvent.click(screen.getByRole('button', { name: '言語' })) - fireEvent.click(screen.getByLabelText('日本語')) - fireEvent.click(screen.getByRole('button', { name: '投稿の種類' })) - fireEvent.click(screen.getByLabelText('画像')) - fireEvent.click(screen.getByRole('button', { name: '除外' })) - fireEvent.click(screen.getByLabelText('返信')) - fireEvent.click(screen.getByRole('button', { name: '検索' })) - - expect(onSubmit).toHaveBeenCalledWith({ - ...searchValues, - q: 'TypeScript', - from: '@tan_stack', - lang: 'ja', - content: 'images', - excludeReplies: true, - }) - }) - - it('removes only the selected condition from its active chip', () => { - const onSubmit = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: '投稿者 yuta を解除' })) - fireEvent.click(screen.getByRole('button', { name: '検索' })) - - expect(onSubmit).toHaveBeenCalledWith({ - ...searchValues, - q: 'AI', - lang: 'ja', - }) - }) -}) diff --git a/src/features/posts/components/list-form.tsx b/src/features/posts/components/list-form.tsx deleted file mode 100644 index 10fd95f..0000000 --- a/src/features/posts/components/list-form.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { type FormEvent, useState } from 'react' -import { normalizeListTarget } from '../inputs' - -type Props = { - initialTarget: string - onSubmit: (target: string) => void -} - -export function ListForm({ initialTarget, onSubmit }: Props) { - const [target, setTarget] = useState(initialTarget) - const [error, setError] = useState('') - const submit = (event: FormEvent) => { - event.preventDefault() - try { - const listId = normalizeListTarget(target) - setError('') - onSubmit(listId) - } catch (cause) { - setError( - cause instanceof Error ? cause.message : '入力内容を確認してください。', - ) - } - } - - return ( -
- - -
- ) -} diff --git a/src/features/posts/components/list-picker.tsx b/src/features/posts/components/list-picker.tsx deleted file mode 100644 index 49e882d..0000000 --- a/src/features/posts/components/list-picker.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { ListChoicesLoadError, useListChoices } from '../use-list-choices' - -export function ListPicker({ selectedId }: { selectedId?: string }) { - const query = useListChoices() - - if (query.isPending) { - return ( -
-

自分のリスト

-

- リストを取得しています… -

-
- ) - } - - if (query.isError) { - const error = - query.error instanceof ListChoicesLoadError - ? query.error.detail - : { message: 'リストを取得できませんでした。', retryable: true } - return ( -
-

自分のリスト

-
-

{error.message}

- {error.retryable ? ( - - ) : null} -
-
- ) - } - - return ( -
-

自分のリスト

- {query.data.length === 0 ? ( -

リストはありません。

- ) : ( -
    - {query.data.map((list) => ( -
  • - - {list.name} - - {list.isPrivate ? '非公開 · ' : ''} - {list.memberCount?.toLocaleString('ja-JP') ?? 0} メンバー - - {list.description ?

    {list.description}

    : null} - -
  • - ))} -
- )} -
- ) -} diff --git a/src/features/posts/components/post-card.test.tsx b/src/features/posts/components/post-card.test.tsx deleted file mode 100644 index d789a9f..0000000 --- a/src/features/posts/components/post-card.test.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import type { Post } from '../types' -import { PostCard } from './post-card' - -const richPost: Post = { - id: '123', - text: '詳細 https://content.invalid/article', - author: { - username: 'yuta', - name: 'Yuta', - profileImageUrl: 'https://media.invalid/avatar.jpg', - }, - createdAt: '2026-07-13T00:00:00.000Z', - replyCount: 3, - retweetCount: 4, - likeCount: 5, - media: [ - { - type: 'photo', - url: 'https://media.invalid/photo.jpg', - width: 1200, - height: 800, - }, - { - type: 'video', - url: 'https://media.invalid/video-poster.jpg', - videoUrl: 'https://media.invalid/video.mp4', - }, - { - type: 'animated_gif', - url: 'https://media.invalid/gif-poster.jpg', - videoUrl: 'https://media.invalid/animation.mp4', - }, - ], - article: { title: 'Article title', previewText: 'Preview' }, - quotedTweet: { - id: '122', - text: 'quoted', - author: { username: 'other', name: 'Other' }, - }, -} - -describe('PostCard', () => { - it('renders rich read-only content without mutation controls', () => { - const { container } = render() - - expect(screen.getByText('Yuta')).toBeInTheDocument() - expect(screen.getByText('@yuta')).toBeInTheDocument() - expect(container.querySelector('time')).toHaveAttribute( - 'datetime', - richPost.createdAt, - ) - expect(screen.getByRole('img', { name: '投稿画像' })).toBeInTheDocument() - expect(container.querySelectorAll('video')).toHaveLength(2) - expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls') - expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop') - expect(container.querySelectorAll('video')[0]).toHaveAttribute( - 'src', - 'https://media.invalid/video.mp4', - ) - expect(screen.getByText('Article title')).toBeInTheDocument() - expect(screen.getByText('Preview')).toBeInTheDocument() - expect(screen.getByText('quoted')).toBeInTheDocument() - expect(screen.getByText(/返信 3.*再投稿 4.*いいね 5/)).toBeInTheDocument() - expect(screen.queryByRole('button')).not.toBeInTheDocument() - }) - - it('uses deliberate safe external links without linking the author', () => { - render() - - const original = screen.getByRole('link', { name: '元の投稿を開く' }) - expect(original).toHaveAttribute('href', 'https://x.com/yuta/status/123') - expect(original).toHaveAttribute('target', '_blank') - expect(original).toHaveAttribute('rel', 'noreferrer noopener') - - const textLink = screen.getByRole('link', { - name: 'https://content.invalid/article', - }) - expect(textLink).toHaveAttribute('target', '_blank') - expect(textLink).toHaveAttribute('rel', 'noreferrer noopener') - expect(screen.queryByRole('link', { name: 'Yuta' })).not.toBeInTheDocument() - expect(screen.getByText('Yuta').closest('a')).toBeNull() - expect(screen.getByText('@yuta').closest('a')).toBeNull() - }) - - it('decodes HTML entities in the post body before linking URLs', () => { - render( - , - ) - - expect( - screen.getByText((_, element) => - Boolean( - element?.classList.contains('post-text') && - element.textContent === - "A > B & C 'D' https://example.com/?a=1&b=2", - ), - ), - ).toBeInTheDocument() - expect( - screen.getByRole('link', { name: 'https://example.com/?a=1&b=2' }), - ).toHaveAttribute('href', 'https://example.com/?a=1&b=2') - }) - - it('links deliberately to the internal detail page', () => { - render() - - const detail = screen.getByRole('link', { name: '詳細・スレッド' }) - expect(detail).toHaveAttribute('href', '/yuta/status/123') - expect(detail.parentElement).toHaveClass('post-actions') - expect( - screen.getByRole('link', { name: '元の投稿を開く' }).parentElement, - ).toBe(detail.parentElement) - }) - - it('marks the focal post without linking to its current page', () => { - render() - - expect(screen.queryByRole('link', { name: '詳細・スレッド' })).toBeNull() - expect(screen.getByRole('article')).toHaveClass('current-post') - }) - - it('collapses a malformed optional timestamp without losing the post', () => { - const { container } = render( - , - ) - - expect(screen.getByText('Yuta')).toBeInTheDocument() - expect(container.querySelector('time')).not.toBeInTheDocument() - }) - - it('does not try to play a poster image when Bird has no video URL', () => { - const { container } = render( - , - ) - - expect(container.querySelector('video')).not.toBeInTheDocument() - expect( - screen.getByRole('img', { name: '動画のプレビュー' }), - ).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg') - }) -}) diff --git a/src/features/posts/components/post-card.tsx b/src/features/posts/components/post-card.tsx deleted file mode 100644 index a46d723..0000000 --- a/src/features/posts/components/post-card.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { PostText } from '#/components/post-text' -import type { Post } from '../types' -import { PostMedia } from './post-media' - -const dateFormatter = new Intl.DateTimeFormat('ja-JP', { - dateStyle: 'medium', - timeStyle: 'short', - timeZone: 'Asia/Tokyo', -}) - -function formattedTime(createdAt?: string): string | undefined { - if (!createdAt) return undefined - const date = new Date(createdAt) - return Number.isNaN(date.getTime()) ? undefined : dateFormatter.format(date) -} - -export function PostCard({ - post, - quoted = false, - current = false, -}: { - post: Post - quoted?: boolean - current?: boolean -}) { - const time = formattedTime(post.createdAt) - const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}` - - return ( -
-
- {post.author.profileImageUrl ? ( - - ) : null} -
- {post.author.name} - @{post.author.username} -
- {time && post.createdAt ? ( - - ) : null} -
-

- -

- {post.media ? : null} - {post.article ? ( -
- {post.article.title} - {post.article.previewText ?

{post.article.previewText}

: null} -
- ) : null} - {post.quotedTweet && !quoted ? ( - - ) : null} - {!quoted ? ( - - ) : null} -
- ) -} diff --git a/src/features/posts/components/post-feed.test.tsx b/src/features/posts/components/post-feed.test.tsx deleted file mode 100644 index 9ac5cbf..0000000 --- a/src/features/posts/components/post-feed.test.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Post } from '../types' -import { PostLoadError } from '../use-post-feed' -import { PostFeed } from './post-feed' - -const usePostFeed = vi.hoisted(() => vi.fn()) - -vi.mock('../use-post-feed', async (importOriginal) => { - const original = await importOriginal() - return { ...original, usePostFeed } -}) - -const observe = vi.fn() -const unobserve = vi.fn() -const disconnect = vi.fn() -let observerCallback: IntersectionObserverCallback = () => {} -let observerOptions: IntersectionObserverInit | undefined - -class FakeIntersectionObserver { - readonly root = null - readonly rootMargin: string - readonly thresholds: readonly number[] = [] - - constructor( - callback: IntersectionObserverCallback, - options?: IntersectionObserverInit, - ) { - observerCallback = callback - observerOptions = options - this.rootMargin = options?.rootMargin ?? '0px' - } - - disconnect = disconnect - observe = observe - takeRecords = () => [] - unobserve = unobserve -} - -const post = (id: string): Post => ({ - id, - text: `post-${id}`, - author: { username: `user-${id}`, name: `User ${id}` }, -}) - -const queryResult = () => ({ - data: undefined, - error: null, - fetchNextPage: vi.fn().mockResolvedValue(undefined), - hasNextPage: false, - isError: false, - isFetchNextPageError: false, - isFetchingNextPage: false, - isPending: false, - refetch: vi.fn().mockResolvedValue(undefined), -}) - -describe('PostFeed', () => { - beforeEach(() => { - usePostFeed.mockReset() - usePostFeed.mockReturnValue(queryResult() as never) - observe.mockReset() - unobserve.mockReset() - disconnect.mockReset() - observerCallback = () => {} - observerOptions = undefined - vi.stubGlobal( - 'IntersectionObserver', - FakeIntersectionObserver as unknown as typeof IntersectionObserver, - ) - }) - - afterEach(() => vi.unstubAllGlobals()) - - it('renders no feed state before a deliberate request', () => { - render() - - expect(usePostFeed).toHaveBeenCalledWith(undefined) - expect(screen.queryByRole('article')).not.toBeInTheDocument() - expect(screen.queryByRole('status')).not.toBeInTheDocument() - expect(screen.queryByRole('alert')).not.toBeInTheDocument() - }) - - it('announces the initial loading state', () => { - usePostFeed.mockReturnValue({ - ...queryResult(), - isPending: true, - } as never) - - render() - - expect(screen.getByRole('status')).toHaveTextContent( - '投稿を取得しています…', - ) - }) - - it('renders a quiet empty state', () => { - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { pages: [{ tweets: [] }], pageParams: [undefined] }, - } as never) - - render() - - expect(screen.getByText('条件に一致する投稿はありません。')).toBeVisible() - expect(screen.queryByRole('alert')).not.toBeInTheDocument() - }) - - it('continues from an empty page when a next cursor exists', () => { - const fetchNextPage = vi.fn(() => new Promise(() => undefined)) - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [{ tweets: [], nextCursor: 'next' }], - pageParams: [undefined], - }, - fetchNextPage, - hasNextPage: true, - } as never) - - render() - act(() => { - observerCallback( - [{ isIntersecting: true } as IntersectionObserverEntry], - {} as IntersectionObserver, - ) - }) - - expect( - screen.queryByText('条件に一致する投稿はありません。'), - ).not.toBeInTheDocument() - expect(fetchNextPage).toHaveBeenCalledOnce() - }) - - it('deduplicates every page while preserving a quiet end state', () => { - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [ - { tweets: [post('1'), post('2')], nextCursor: 'next' }, - { tweets: [post('2'), post('3')] }, - ], - pageParams: [undefined, 'next'], - }, - } as never) - - const { container } = render( - , - ) - - expect(screen.getAllByRole('article')).toHaveLength(3) - expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument() - expect(screen.getByText('これ以上の投稿はありません。')).toBeVisible() - expect(screen.queryByRole('alert')).not.toBeInTheDocument() - }) - - it('renders the focal post once above its deduplicated conversation', () => { - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [ - { - focalPost: post('2'), - conversationId: '1', - nextCursor: 'next', - tweets: [post('1'), post('2')], - }, - { - conversationId: '1', - tweets: [post('2'), post('3')], - }, - ], - pageParams: [undefined, { cursor: 'next', conversationId: '1' }], - }, - } as never) - - const { container } = render( - , - ) - - expect(screen.getByText('表示中の投稿')).toBeVisible() - expect(screen.getAllByText('post-2')).toHaveLength(1) - expect(screen.getAllByRole('article')).toHaveLength(3) - expect(container.querySelector('.current-post')).toBeInTheDocument() - }) - - it('keeps the focal post visible when there are no other posts', () => { - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [ - { - focalPost: post('2'), - conversationId: '1', - tweets: [post('2')], - }, - ], - pageParams: [undefined], - }, - } as never) - - render() - - expect(screen.getByText('post-2')).toBeVisible() - expect(screen.getByText('会話にほかの投稿はありません。')).toBeVisible() - }) - - it('keeps the focal post and conversation across a later-page error', () => { - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [ - { - focalPost: post('2'), - conversationId: '1', - nextCursor: 'next', - tweets: [post('1'), post('2')], - }, - ], - pageParams: [undefined], - }, - error: new PostLoadError({ - code: 'upstream', - message: 'X から投稿を取得できませんでした。', - retryable: true, - }), - hasNextPage: true, - isError: true, - isFetchNextPageError: true, - } as never) - - render() - - expect(screen.getByText('post-2')).toBeVisible() - expect(screen.getByText('post-1')).toBeVisible() - expect(screen.getByRole('alert')).toHaveTextContent( - '続きの投稿を取得できませんでした。', - ) - }) - - it('retries an initial retryable error with refetch', () => { - const refetch = vi.fn().mockResolvedValue(undefined) - usePostFeed.mockReturnValue({ - ...queryResult(), - error: new PostLoadError({ - code: 'upstream', - message: '最初の取得に失敗しました。', - retryable: true, - }), - isError: true, - refetch, - } as never) - - render() - fireEvent.click(screen.getByRole('button', { name: '再試行' })) - - expect(screen.getByRole('alert')).toHaveTextContent( - '最初の取得に失敗しました。', - ) - expect(refetch).toHaveBeenCalledOnce() - }) - - it('does not offer retry for a non-retryable initial error', () => { - usePostFeed.mockReturnValue({ - ...queryResult(), - error: new PostLoadError({ - code: 'invalid-input', - message: '入力を確認してください。', - retryable: false, - }), - isError: true, - } as never) - - render() - - expect(screen.getByRole('alert')).toHaveTextContent( - '入力を確認してください。', - ) - expect(screen.queryByRole('button')).not.toBeInTheDocument() - }) - - it('keeps loaded posts while retrying a later page', () => { - const fetchNextPage = vi.fn().mockResolvedValue(undefined) - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [{ tweets: [post('kept')], nextCursor: 'next' }], - pageParams: [undefined], - }, - error: new PostLoadError({ - code: 'timeout', - message: '取得がタイムアウトしました。', - retryable: true, - }), - fetchNextPage, - hasNextPage: true, - isError: true, - isFetchNextPageError: true, - } as never) - - render() - fireEvent.click(screen.getByRole('button', { name: '再試行' })) - - expect(screen.getByText('post-kept')).toBeVisible() - expect(screen.getByRole('alert')).toHaveTextContent( - '続きの投稿を取得できませんでした。', - ) - expect(observe).not.toHaveBeenCalled() - expect(fetchNextPage).toHaveBeenCalledOnce() - }) - - it('retries a later page after an empty page with fetchNextPage', () => { - const fetchNextPage = vi.fn().mockResolvedValue(undefined) - const refetch = vi.fn().mockResolvedValue(undefined) - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [{ tweets: [], nextCursor: 'next' }], - pageParams: [undefined], - }, - error: new PostLoadError({ - code: 'timeout', - message: '取得がタイムアウトしました。', - retryable: true, - }), - fetchNextPage, - hasNextPage: true, - isError: true, - isFetchNextPageError: true, - refetch, - } as never) - - render() - fireEvent.click(screen.getByRole('button', { name: '再試行' })) - - expect(screen.getByRole('alert')).toHaveTextContent( - '続きの投稿を取得できませんでした。', - ) - expect(fetchNextPage).toHaveBeenCalledOnce() - expect(refetch).not.toHaveBeenCalled() - }) - - it('announces next-page loading', () => { - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [{ tweets: [post('1')], nextCursor: 'next' }], - pageParams: [undefined], - }, - hasNextPage: true, - isFetchingNextPage: true, - } as never) - - render() - - expect(screen.getByRole('status')).toHaveTextContent( - '次の投稿を取得しています…', - ) - }) - - it('does not auto-fetch while a next page is already loading', () => { - const fetchNextPage = vi.fn().mockResolvedValue(undefined) - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [{ tweets: [post('1')], nextCursor: 'next' }], - pageParams: [undefined], - }, - fetchNextPage, - hasNextPage: true, - isFetchingNextPage: true, - } as never) - - render() - act(() => { - observerCallback( - [{ isIntersecting: true } as IntersectionObserverEntry], - {} as IntersectionObserver, - ) - }) - - expect(observe).not.toHaveBeenCalled() - expect(fetchNextPage).not.toHaveBeenCalled() - }) - - it('auto-fetches once near the sentinel and disconnects on cleanup', () => { - const fetchNextPage = vi.fn(() => new Promise(() => undefined)) - usePostFeed.mockReturnValue({ - ...queryResult(), - data: { - pages: [{ tweets: [post('1')], nextCursor: 'next' }], - pageParams: [undefined], - }, - fetchNextPage, - hasNextPage: true, - } as never) - - const { unmount } = render( - , - ) - act(() => { - const entries = [{ isIntersecting: true } as IntersectionObserverEntry] - observerCallback(entries, {} as IntersectionObserver) - observerCallback(entries, {} as IntersectionObserver) - }) - - expect(observerOptions).toEqual({ rootMargin: '600px 0px' }) - expect(observe).toHaveBeenCalledOnce() - expect(fetchNextPage).toHaveBeenCalledOnce() - - unmount() - expect(disconnect).toHaveBeenCalledOnce() - }) -}) diff --git a/src/features/posts/components/post-feed.tsx b/src/features/posts/components/post-feed.tsx deleted file mode 100644 index 2d4ecad..0000000 --- a/src/features/posts/components/post-feed.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect, useRef } from 'react' -import { - flattenConversationPages, - flattenPostPages, - focalPostFromPages, -} from '../page' -import type { Post } from '../types' -import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed' -import { useFeedTools } from '../webmcp-tools' -import { PostCard } from './post-card' - -function FocalPost({ post }: { post: Post }) { - return ( -
-

- 表示中の投稿 -

- -
- ) -} - -export function PostFeed({ request }: { request: FeedRequest | undefined }) { - const query = usePostFeed(request) - useFeedTools(request, query) - const sentinel = useRef(null) - const pages = query.data?.pages ?? [] - const focalPost = - request?.kind === 'thread' ? focalPostFromPages(pages) : undefined - const posts = focalPost - ? flattenConversationPages(pages, focalPost.id) - : flattenPostPages(pages) - - useEffect(() => { - if ( - !sentinel.current || - !query.hasNextPage || - query.isFetchNextPageError || - query.isFetchingNextPage - ) { - return - } - - let requested = false - const observer = new IntersectionObserver( - ([entry]) => { - if (entry?.isIntersecting && !requested) { - requested = true - void query.fetchNextPage({ cancelRefetch: false }) - } - }, - { rootMargin: '600px 0px' }, - ) - observer.observe(sentinel.current) - return () => observer.disconnect() - }, [ - query.fetchNextPage, - query.hasNextPage, - query.isFetchNextPageError, - query.isFetchingNextPage, - ]) - - if (!request) return null - if (query.isPending) { - return ( -

- 投稿を取得しています… -

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

{error.message}

- {error.retryable ? ( - - ) : null} -
- ) - } - if (posts.length === 0 && !query.hasNextPage) { - return ( - <> - {focalPost ? : null} -

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

- - ) - } - - return ( - <> - {focalPost ? : null} -
- {posts.map((post) => ( - - ))} -
- - ) -} diff --git a/src/features/posts/components/post-media.tsx b/src/features/posts/components/post-media.tsx deleted file mode 100644 index 954ed52..0000000 --- a/src/features/posts/components/post-media.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { Post } from '../types' - -type Media = NonNullable[number] - -export function PostMedia({ media }: { media: Media[] }) { - if (media.length === 0) return null - - return ( -
- {media.map((item) => { - if (item.type === 'photo' || !item.videoUrl) { - return ( - {item.type - ) - } - - return ( - // biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs. -
- ) -} diff --git a/src/features/posts/components/search-form.tsx b/src/features/posts/components/search-form.tsx deleted file mode 100644 index a5257b7..0000000 --- a/src/features/posts/components/search-form.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import type { SearchProduct } from '@yuta/bird' -import { type FormEvent, useState } from 'react' -import { type AdvancedSearchQuery, buildFilteredSearchQuery } from '../inputs' - -export type SearchValues = AdvancedSearchQuery & { - product: SearchProduct - following: boolean -} - -type Props = { - initialValues: SearchValues - onSubmit: (values: SearchValues) => void -} - -type FilterName = 'author' | 'period' | 'language' | 'content' | 'exclude' - -const languageLabels = { all: 'すべて', ja: '日本語', en: '英語' } as const -const contentLabels = { - all: 'すべて', - images: '画像', - videos: '動画', - links: 'リンク', -} as const - -function localDate(daysAgo: number): string { - const date = new Date() - date.setDate(date.getDate() - daysAgo) - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -export function SearchForm({ initialValues, onSubmit }: Props) { - const [values, setValues] = useState(initialValues) - const [openFilter, setOpenFilter] = useState() - const [error, setError] = useState('') - - const update = ( - key: Key, - value: SearchValues[Key], - ) => setValues((current) => ({ ...current, [key]: value })) - - const submit = (event: FormEvent) => { - event.preventDefault() - const normalized = { - ...values, - q: values.q.trim(), - from: values.from.trim(), - } - try { - buildFilteredSearchQuery(normalized, normalized.following) - setError('') - onSubmit(normalized) - } catch (cause) { - setError( - cause instanceof Error ? cause.message : '入力内容を確認してください。', - ) - } - } - - const toggleFilter = (filter: FilterName) => { - setOpenFilter((current) => (current === filter ? undefined : filter)) - } - - const setRecentPeriod = (days: number) => { - setValues((current) => ({ - ...current, - since: localDate(days - 1), - until: '', - })) - } - - let preview = '検索語または投稿者を入力すると表示されます。' - try { - preview = buildFilteredSearchQuery(values, values.following) - } catch { - // The submit error gives the actionable message; the preview stays quiet. - } - - const filterButtons: Array<{ - name: FilterName - label: string - active: boolean - }> = [ - { name: 'author', label: '投稿者', active: Boolean(values.from) }, - { - name: 'period', - label: '期間', - active: Boolean(values.since || values.until), - }, - { name: 'language', label: '言語', active: values.lang !== 'all' }, - { - name: 'content', - label: '投稿の種類', - active: values.content !== 'all', - }, - { - name: 'exclude', - label: '除外', - active: values.excludeReplies || values.excludeReposts, - }, - ] - - return ( -
-
- - update('q', event.currentTarget.value)} - placeholder="キーワードや高度な検索式" - /> - - 演算子を直接入力することもできます。 - -
- -
- 条件を追加 - {filterButtons.map((filter) => ( - - ))} -
- - {openFilter ? ( -
- - {filterButtons.find((item) => item.name === openFilter)?.label} - の条件 - - {openFilter === 'author' ? ( -
- - update('from', event.currentTarget.value)} - placeholder="@handle" - value={values.from} - /> - - @は省略できます。 - -
- ) : null} - - {openFilter === 'period' ? ( - <> -
- すぐ選ぶ - - - - -
-
-
- - - update('since', event.currentTarget.value) - } - type="date" - value={values.since} - /> - -
-
- - - update('until', event.currentTarget.value) - } - type="date" - value={values.until} - /> - -
-
- - ) : null} - - {openFilter === 'language' ? ( -
- 言語 - {( - Object.keys(languageLabels) as Array< - keyof typeof languageLabels - > - ).map((value) => ( - - ))} -
- ) : null} - - {openFilter === 'content' ? ( -
- 投稿の種類 - {( - Object.keys(contentLabels) as Array - ).map((value) => ( - - ))} -
- ) : null} - - {openFilter === 'exclude' ? ( -
- 検索結果から除外 - - -
- ) : null} -
- ) : null} - -
    - {values.from ? ( -
  • - -
  • - ) : null} - {values.since || values.until ? ( -
  • - -
  • - ) : null} - {values.lang !== 'all' ? ( -
  • - -
  • - ) : null} - {values.content !== 'all' ? ( -
  • - -
  • - ) : null} - {values.excludeReplies ? ( -
  • - -
  • - ) : null} - {values.excludeReposts ? ( -
  • - -
  • - ) : null} -
- -
- 検索式を確認 - {preview} -
- - {error ? ( - - ) : null} - -
- 並び順 - {(['Top', 'Latest'] as const).map((value) => ( - - ))} -
- - -
- ) -} diff --git a/src/features/posts/components/user-form.tsx b/src/features/posts/components/user-form.tsx deleted file mode 100644 index e5c8608..0000000 --- a/src/features/posts/components/user-form.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { type FormEvent, useState } from 'react' -import { normalizeUserTarget } from '../inputs' - -type Props = { - initialTarget: string - onSubmit: (target: string) => void -} - -export function UserForm({ initialTarget, onSubmit }: Props) { - const [target, setTarget] = useState(initialTarget) - const [error, setError] = useState('') - const submit = (event: FormEvent) => { - event.preventDefault() - try { - const handle = normalizeUserTarget(target) - setError('') - onSubmit(handle) - } catch (cause) { - setError( - cause instanceof Error ? cause.message : '入力内容を確認してください。', - ) - } - } - - return ( -
- - -
- ) -} diff --git a/src/features/posts/inputs.test.ts b/src/features/posts/inputs.test.ts index 65ca50d..157bcd1 100644 --- a/src/features/posts/inputs.test.ts +++ b/src/features/posts/inputs.test.ts @@ -1,52 +1,28 @@ import { describe, expect, it } from 'vitest' import { - buildAdvancedSearchQuery, - buildFilteredSearchQuery, buildSearchQuery, + listChoicesInputSchema, + listPageInputSchema, normalizeListTarget, - normalizeTweetId, normalizeUserTarget, - postSearchRouteSchema, - threadPageInputSchema, + searchPageInputSchema, + userPageInputSchema, } from './inputs' -describe('threadPageInputSchema', () => { - it('accepts initial and complete continuation requests', () => { - expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({ - tweetId: '123', - }) - expect( - threadPageInputSchema.parse({ - tweetId: '123', - conversationId: '100', - cursor: 'thread-next', - }), - ).toEqual({ - tweetId: '123', - conversationId: '100', - cursor: 'thread-next', - }) - }) - +describe('explicit relay profile binding', () => { it.each([ - { tweetId: '' }, - { tweetId: 'abc' }, - { tweetId: '123', cursor: 'thread-next' }, - { tweetId: '123', conversationId: '100' }, - { tweetId: '123', conversationId: 'root', cursor: 'thread-next' }, - ])('rejects an invalid thread request %#', (input) => { - expect(() => threadPageInputSchema.parse(input)).toThrow() - }) -}) - -describe('normalizeTweetId', () => { - it('keeps a decimal post ID', () => { - expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890') - }) - - it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => { - expect(() => normalizeTweetId(input)).toThrow( - '投稿 ID を確認してください。', + [ + searchPageInputSchema, + { query: 'AI', product: 'Latest', following: false }, + ], + [listPageInputSchema, { target: '123' }], + [userPageInputSchema, { target: 'yuta' }], + [listChoicesInputSchema, {}], + ])('rejects missing or empty bindings for loader %#', (schema, input) => { + expect(schema.safeParse(input).success).toBe(false) + expect(schema.safeParse({ ...input, profileName: ' ' }).success).toBe(false) + expect(schema.safeParse({ ...input, profileName: 'work' }).success).toBe( + true, ) }) }) @@ -111,134 +87,3 @@ describe('buildSearchQuery', () => { expect(() => buildSearchQuery(' ', false)).toThrow() }) }) - -describe('buildAdvancedSearchQuery', () => { - it('builds a query from button-friendly filters', () => { - expect( - buildAdvancedSearchQuery({ - q: 'TypeScript', - from: '@tan_stack', - since: '2026-08-04', - until: '2026-08-11', - lang: 'ja', - content: 'images', - excludeReplies: true, - excludeReposts: true, - }), - ).toBe( - 'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets', - ) - }) - - it('allows an author search without keywords', () => { - expect( - buildAdvancedSearchQuery({ - q: '', - from: 'tan_stack', - since: '', - until: '', - lang: 'all', - content: 'all', - excludeReplies: false, - excludeReposts: false, - }), - ).toBe('from:tan_stack') - }) - - it('rejects filters that have no keyword or author', () => { - expect(() => - buildAdvancedSearchQuery({ - q: '', - from: '', - since: '2026-08-04', - until: '', - lang: 'ja', - content: 'all', - excludeReplies: false, - excludeReposts: false, - }), - ).toThrow('検索語または投稿者を入力してください。') - }) - - it('rejects an invalid author and reversed dates', () => { - const base = { - q: 'TypeScript', - from: '', - since: '', - until: '', - lang: 'all' as const, - content: 'all' as const, - excludeReplies: false, - excludeReposts: false, - } - - expect(() => - buildAdvancedSearchQuery({ ...base, from: 'not-valid!' }), - ).toThrow('投稿者のハンドルを確認してください。') - expect(() => - buildAdvancedSearchQuery({ - ...base, - since: '2026-08-11', - until: '2026-08-10', - }), - ).toThrow('終了日は開始日より後の日付を選んでください。') - }) - - it('rejects nonexistent dates and a compiled query over 512 characters', () => { - const base = { - q: 'TypeScript', - from: '', - since: '', - until: '', - lang: 'all' as const, - content: 'all' as const, - excludeReplies: false, - excludeReposts: false, - } - - expect(() => - buildAdvancedSearchQuery({ ...base, since: '2026-02-30' }), - ).toThrow('開始日を確認してください。') - expect(() => - buildAdvancedSearchQuery({ ...base, q: 'a'.repeat(510), lang: 'ja' }), - ).toThrow('検索語は 512 文字以内で入力してください。') - }) -}) - -describe('buildFilteredSearchQuery', () => { - it('includes the follows filter in the final query and enforces its length', () => { - const input = { - q: 'TypeScript', - from: '', - since: '', - until: '', - lang: 'ja' as const, - content: 'all' as const, - excludeReplies: false, - excludeReposts: false, - } - - expect(buildFilteredSearchQuery(input, true)).toBe( - 'TypeScript lang:ja filter:follows', - ) - expect(() => - buildFilteredSearchQuery({ ...input, q: 'a'.repeat(498) }, true), - ).toThrow('検索語は 512 文字以内で入力してください。') - }) -}) - -describe('postSearchRouteSchema', () => { - it('keeps omitted filter parameters optional', () => { - expect( - postSearchRouteSchema.parse({ - q: 'AI', - product: 'Latest', - following: false, - }), - ).toEqual({ - q: 'AI', - product: 'Latest', - following: false, - }) - }) -}) diff --git a/src/features/posts/inputs.ts b/src/features/posts/inputs.ts index 475fcd9..49b5c50 100644 --- a/src/features/posts/inputs.ts +++ b/src/features/posts/inputs.ts @@ -1,157 +1,36 @@ import { z } from 'zod' const HANDLE = /^[A-Za-z0-9_]{1,15}$/ -const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i -const TWEET_ID = /^\d{1,32}$/ const LIST_ID = /^\d{1,32}$/ -const tweetIdSchema = z - .string() - .trim() - .regex(TWEET_ID, '投稿 ID を確認してください。') +const profileNameSchema = z.string().trim().min(1).max(256) export class InputError extends Error {} -const searchLanguageSchema = z.enum(['all', 'ja', 'en']) -const searchContentSchema = z.enum(['all', 'images', 'videos', 'links']) - -type SearchLanguage = z.infer -type SearchContent = z.infer - -export type AdvancedSearchQuery = { - q: string - from: string - since: string - until: string - lang: SearchLanguage - content: SearchContent - excludeReplies: boolean - excludeReposts: boolean -} - -export const threadPageInputSchema = z.union([ - z.object({ - tweetId: tweetIdSchema, - conversationId: z.undefined().optional(), - cursor: z.undefined().optional(), - }), - z.object({ - tweetId: tweetIdSchema, - conversationId: tweetIdSchema, - cursor: z.string().min(1), - }), -]) - export const userPageInputSchema = z.object({ + profileName: profileNameSchema, target: z.string().trim().min(1).max(256), cursor: z.string().min(1).optional(), }) export const listPageInputSchema = z.object({ + profileName: profileNameSchema, target: z.string().trim().min(1).max(256), cursor: z.string().min(1).optional(), }) -export const listChoicesInputSchema = z.object({}) +export const listChoicesInputSchema = z.object({ + profileName: profileNameSchema, +}) export const searchPageInputSchema = z.object({ + profileName: profileNameSchema, query: z.string().trim().min(1).max(512), product: z.enum(['Top', 'Latest']), following: z.boolean(), cursor: z.string().min(1).optional(), }) -export const postSearchRouteSchema = z.object({ - q: z.string().catch(''), - from: z - .string() - .refine((value) => !value || HANDLE.test(value.replace(/^@/, ''))) - .optional() - .catch(undefined), - since: z - .string() - .refine((value) => !value || isValidDate(value)) - .optional() - .catch(undefined), - until: z - .string() - .refine((value) => !value || isValidDate(value)) - .optional() - .catch(undefined), - lang: searchLanguageSchema.optional().catch(undefined), - content: searchContentSchema.optional().catch(undefined), - excludeReplies: z.boolean().optional().catch(undefined), - excludeReposts: z.boolean().optional().catch(undefined), - product: z.enum(['Top', 'Latest']).catch('Latest'), - following: z.boolean().catch(false), -}) - -function isValidDate(value: string): boolean { - if (!ISO_DATE.test(value)) return false - const [year, month, day] = value.split('-').map(Number) - const date = new Date(Date.UTC(year ?? 0, (month ?? 0) - 1, day)) - return ( - date.getUTCFullYear() === year && - date.getUTCMonth() + 1 === month && - date.getUTCDate() === day - ) -} - -function requireDate(value: string, label: string): string { - if (!isValidDate(value)) { - throw new InputError(`${label}を確認してください。`) - } - return value -} - -function normalizeSearchAuthor(raw: string): string { - const value = raw.trim().replace(/^@/, '') - if (!HANDLE.test(value)) { - throw new InputError('投稿者のハンドルを確認してください。') - } - return value -} - -export function buildAdvancedSearchQuery(input: AdvancedSearchQuery): string { - const query = input.q.trim() - const author = input.from.trim() - if (!query && !author) { - throw new InputError('検索語または投稿者を入力してください。') - } - - const parts = [query] - if (author) parts.push(`from:${normalizeSearchAuthor(author)}`) - - const since = input.since ? requireDate(input.since, '開始日') : '' - const until = input.until ? requireDate(input.until, '終了日') : '' - if (since && until && since >= until) { - throw new InputError('終了日は開始日より後の日付を選んでください。') - } - if (since) parts.push(`since:${since}`) - if (until) parts.push(`until:${until}`) - if (input.lang !== 'all') parts.push(`lang:${input.lang}`) - if (input.content !== 'all') parts.push(`filter:${input.content}`) - if (input.excludeReplies) parts.push('-filter:replies') - if (input.excludeReposts) parts.push('-filter:retweets') - - return buildSearchQuery(parts.filter(Boolean).join(' '), false) -} - -export function buildFilteredSearchQuery( - input: AdvancedSearchQuery, - following: boolean, -): string { - return buildSearchQuery(buildAdvancedSearchQuery(input), following) -} - -export function normalizeTweetId(raw: string): string { - const value = raw.trim() - if (!TWEET_ID.test(value)) { - throw new InputError('投稿 ID を確認してください。') - } - return value -} - function requireHandle(value: string): string { if (!HANDLE.test(value)) { throw new InputError('ハンドルは英数字とアンダースコアで入力してください。') diff --git a/src/features/posts/page.test.ts b/src/features/posts/page.test.ts index f394298..fa842ed 100644 --- a/src/features/posts/page.test.ts +++ b/src/features/posts/page.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - flattenConversationPages, - flattenPostPages, - focalPostFromPages, -} from './page' -import type { Post, PostPage, ThreadPage } from './types' +import { flattenPostPages } from './page' +import type { Post, PostPage } from './types' const post = (id: string): Post => ({ id, @@ -22,29 +18,3 @@ describe('flattenPostPages', () => { expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3']) }) }) - -describe('thread page helpers', () => { - const focal = post('2') - const pages: ThreadPage[] = [ - { - focalPost: focal, - conversationId: '1', - nextCursor: 'next', - tweets: [post('1'), focal], - }, - { - conversationId: '1', - tweets: [focal, post('3')], - }, - ] - - it('takes the focal post from the first page', () => { - expect(focalPostFromPages(pages)).toEqual(focal) - }) - - it('deduplicates the conversation and removes the focal post', () => { - expect( - flattenConversationPages(pages, focal.id).map(({ id }) => id), - ).toEqual(['1', '3']) - }) -}) diff --git a/src/features/posts/page.ts b/src/features/posts/page.ts index ddeeb1f..d737758 100644 --- a/src/features/posts/page.ts +++ b/src/features/posts/page.ts @@ -1,4 +1,4 @@ -import type { Post, PostPage, ThreadPage } from './types' +import type { Post, PostPage } from './types' export function flattenPostPages(pages: PostPage[]): Post[] { const seen = new Set() @@ -10,17 +10,3 @@ export function flattenPostPages(pages: PostPage[]): Post[] { }), ) } - -export function focalPostFromPages( - pages: Array, -): Post | undefined { - const first = pages[0] - return first && 'focalPost' in first ? first.focalPost : undefined -} - -export function flattenConversationPages( - pages: Array, - focalPostId: string, -): Post[] { - return flattenPostPages(pages).filter(({ id }) => id !== focalPostId) -} diff --git a/src/features/posts/post-service.test.ts b/src/features/posts/post-service.test.ts index 49948d7..6069a99 100644 --- a/src/features/posts/post-service.test.ts +++ b/src/features/posts/post-service.test.ts @@ -3,7 +3,6 @@ import { type BirdReader, loadListChoices, loadListPage, - loadThreadPage, loadUserPage, searchPage, } from './post-service' @@ -24,27 +23,6 @@ const reader = (): BirdReader => ({ }, ], }), - getTweet: vi.fn().mockResolvedValue({ - success: true, - tweet: { - id: '123', - text: 'focal', - conversationId: '100', - author: { username: 'focus', name: 'Focus' }, - }, - }), - getThreadPaged: vi.fn().mockResolvedValue({ - success: true, - tweets: [ - { - id: '100', - text: 'root', - conversationId: '100', - author: { username: 'root', name: 'Root' }, - }, - ], - nextCursor: 'thread-next', - }), getUserIdByUsername: vi .fn() .mockResolvedValue({ success: true, userId: '42' }), @@ -114,231 +92,6 @@ describe('loadListChoices', () => { }) }) -describe('loadThreadPage', () => { - it('resolves the focal post and fetches one root conversation page', async () => { - const client = reader() - const result = await loadThreadPage(client, { tweetId: '123' }) - - expect(client.getTweet).toHaveBeenCalledWith('123') - expect(client.getThreadPaged).toHaveBeenCalledWith('100', { - maxPages: 1, - pageDelayMs: 0, - }) - expect(result).toEqual({ - ok: true, - page: { - focalPost: { - id: '123', - text: 'focal', - conversationId: '100', - author: { username: 'focus', name: 'Focus' }, - }, - tweets: [ - { - id: '100', - text: 'root', - conversationId: '100', - author: { username: 'root', name: 'Root' }, - }, - ], - conversationId: '100', - nextCursor: 'thread-next', - }, - }) - }) - - it('uses the carried root for exactly one continuation read', async () => { - const client = reader() - const result = await loadThreadPage(client, { - tweetId: '123', - conversationId: '100', - cursor: 'thread-next', - }) - - expect(client.getTweet).not.toHaveBeenCalled() - expect(client.getThreadPaged).toHaveBeenCalledOnce() - expect(client.getThreadPaged).toHaveBeenCalledWith('100', { - cursor: 'thread-next', - maxPages: 1, - pageDelayMs: 0, - }) - expect(result).toMatchObject({ - ok: true, - page: { conversationId: '100', focalPost: undefined }, - }) - }) - - it('does not expose a continuation when the focal post has no replies', async () => { - const client = reader() - vi.mocked(client.getTweet).mockResolvedValue({ - success: true, - tweet: { - id: '123', - text: 'focal', - conversationId: '100', - replyCount: 0, - author: { username: 'focus', name: 'Focus' }, - }, - }) - - const result = await loadThreadPage(client, { tweetId: '123' }) - - expect(result).toMatchObject({ - ok: true, - page: { nextCursor: undefined }, - }) - }) - - it.each([ - { - name: 'an empty final page', - tweets: [], - returnedCursor: 'thread-final', - }, - { - name: 'the same cursor again', - tweets: [ - { - id: '101', - text: 'last reply', - author: { username: 'reply', name: 'Reply' }, - }, - ], - returnedCursor: 'thread-next', - }, - ])('stops after $name', async ({ tweets, returnedCursor }) => { - const client = reader() - vi.mocked(client.getThreadPaged).mockResolvedValue({ - success: true, - tweets, - nextCursor: returnedCursor, - }) - - const result = await loadThreadPage(client, { - tweetId: '123', - conversationId: '100', - cursor: 'thread-next', - }) - - expect(result).toMatchObject({ - ok: true, - page: { nextCursor: undefined }, - }) - }) - - it('removes raw data from focal, conversation, and quotes', async () => { - const client = reader() - vi.mocked(client.getTweet).mockResolvedValue({ - success: true, - tweet: { - id: '123', - text: 'focal', - conversationId: '100', - author: { username: 'focus', name: 'Focus' }, - quotedTweet: { - id: '90', - text: 'quote', - author: { username: 'quote', name: 'Quote' }, - _raw: { rest_id: 'private-quote' }, - }, - _raw: { rest_id: 'private-focal' }, - }, - }) - vi.mocked(client.getThreadPaged).mockResolvedValue({ - success: true, - tweets: [ - { - id: '100', - text: 'root', - conversationId: '100', - author: { username: 'root', name: 'Root' }, - _raw: { rest_id: 'private-thread' }, - }, - ], - }) - - const result = await loadThreadPage(client, { tweetId: '123' }) - - expect(JSON.stringify(result)).not.toContain('private-') - }) - - it('returns a safe non-retryable missing-post error', async () => { - const client = reader() - vi.mocked(client.getTweet).mockResolvedValue({ - success: false, - error: 'Tweet not found: private relay detail', - }) - - expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({ - ok: false, - error: { - code: 'post-not-found', - message: '投稿が見つかりませんでした。', - retryable: false, - }, - }) - expect(client.getThreadPaged).not.toHaveBeenCalled() - }) - - it('classifies malformed Bird conversation metadata as an upstream failure', async () => { - const client = reader() - const privateMetadata = 'FINAL_PRIVATE_CONVERSATION_MARKER_71c9' - vi.mocked(client.getTweet).mockResolvedValue({ - success: true, - tweet: { - id: '123', - text: 'focal', - conversationId: privateMetadata, - author: { username: 'focus', name: 'Focus' }, - }, - }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - try { - const result = await loadThreadPage(client, { tweetId: '123' }) - - expect(result).toEqual({ - ok: false, - error: { - code: 'upstream', - message: 'X から投稿を取得できませんでした。', - retryable: true, - }, - }) - expect(client.getTweet).toHaveBeenCalledWith('123') - expect(client.getThreadPaged).not.toHaveBeenCalled() - expect(errorSpy).toHaveBeenCalledTimes(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Unexpected Bird failure:', - expect.objectContaining({ - message: '投稿 ID を確認してください。', - }), - ) - expect(JSON.stringify(result)).not.toContain(privateMetadata) - expect(errorSpy.mock.calls.flat().join(' ')).not.toContain( - privateMetadata, - ) - } finally { - errorSpy.mockRestore() - } - }) - - it('rejects an invalid ID without a Bird call', async () => { - const client = reader() - - expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({ - ok: false, - error: { - code: 'invalid-input', - message: '投稿 ID を確認してください。', - retryable: false, - }, - }) - expect(client.getTweet).not.toHaveBeenCalled() - expect(client.getThreadPaged).not.toHaveBeenCalled() - }) -}) - describe('loadUserPage', () => { it('resolves a handle and fetches exactly one page', async () => { const client = reader() diff --git a/src/features/posts/post-service.ts b/src/features/posts/post-service.ts index 9a0205f..c0bc877 100644 --- a/src/features/posts/post-service.ts +++ b/src/features/posts/post-service.ts @@ -1,14 +1,8 @@ -import type { - GetTweetResult, - SearchProduct, - SearchResult, - TweetData, -} from '@yuta/bird' +import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird' import { buildSearchQuery, InputError, normalizeListTarget, - normalizeTweetId, normalizeUserTarget, } from './inputs' import type { @@ -19,8 +13,6 @@ import type { LoadResult, Post, SearchPageInput, - ThreadLoadResult, - ThreadPageInput, UserPageInput, } from './types' @@ -45,15 +37,6 @@ type BirdListsResult = export interface BirdReader { getLists(count?: number): Promise - getTweet(tweetId: string): Promise - getThreadPaged( - tweetId: string, - options: { - cursor?: string - maxPages: number - pageDelayMs: number - }, - ): Promise getUserIdByUsername(username: string): Promise getUserTweetsPaged( userId: string, @@ -87,12 +70,11 @@ const failure = ( retryable: boolean, ): LoadFailure => ({ ok: false, error: { code, message, retryable } }) -type FailureSubject = 'user' | 'post' | 'list' +type FailureSubject = 'user' | 'list' const notFoundFailure: Record LoadFailure> = { user: () => failure('user-not-found', 'ユーザーが見つかりませんでした。', false), - post: () => failure('post-not-found', '投稿が見つかりませんでした。', false), list: () => failure('list-not-found', 'リストが見つかりませんでした。', false), } @@ -100,7 +82,6 @@ const notFoundFailure: Record LoadFailure> = { const unavailableFailure: Record LoadFailure> = { user: () => failure('user-unavailable', 'このユーザーの投稿は取得できません。', false), - post: () => failure('post-unavailable', 'この投稿は取得できません。', false), list: () => failure('list-unavailable', 'このリストは取得できません。', false), } @@ -181,69 +162,9 @@ function resultPage( : upstreamFailure(result.error, subject) } -export async function loadThreadPage( - reader: BirdReader, - input: ThreadPageInput, -): Promise { - try { - const tweetId = normalizeTweetId(input.tweetId) - let focalPost: Post | undefined - let conversationId: string - - if (input.cursor && input.conversationId) { - conversationId = normalizeTweetId(input.conversationId) - } else { - const focal = await reader.getTweet(tweetId) - if (!focal.success || !focal.tweet) { - return upstreamFailure(focal.error ?? 'Tweet not found', 'post') - } - focalPost = publicPost(focal.tweet) - try { - conversationId = normalizeTweetId( - focal.tweet.conversationId ?? focal.tweet.id, - ) - } catch (error) { - return upstreamFailure(error, 'post') - } - } - - const result = await reader.getThreadPaged(conversationId, { - ...(input.cursor ? { cursor: input.cursor } : {}), - maxPages: 1, - pageDelayMs: 0, - }) - if (!result.success) { - return upstreamFailure(result.error, 'post') - } - const tweets = result.tweets ?? [] - const nextCursor = - result.nextCursor && - tweets.length > 0 && - result.nextCursor !== input.cursor && - focalPost?.replyCount !== 0 - ? result.nextCursor - : undefined - - return { - ok: true, - page: { - tweets: tweets.map(publicPost), - focalPost, - conversationId, - nextCursor, - }, - } - } catch (error) { - if (error instanceof InputError) { - return failure('invalid-input', error.message, false) - } - return upstreamFailure(error, 'post') - } -} - export async function loadUserPage( reader: BirdReader, - input: UserPageInput, + input: Omit, ): Promise { try { const handle = normalizeUserTarget(input.target) @@ -271,7 +192,7 @@ export async function loadUserPage( export async function loadListPage( reader: BirdReader, - input: ListPageInput, + input: Omit, ): Promise { try { const listId = normalizeListTarget(input.target) @@ -295,7 +216,7 @@ export async function loadListPage( export async function searchPage( reader: BirdReader, - input: SearchPageInput, + input: Omit, ): Promise { try { return resultPage( diff --git a/src/features/posts/server-functions.ts b/src/features/posts/server-functions.ts index 1d6aca2..4be603d 100644 --- a/src/features/posts/server-functions.ts +++ b/src/features/posts/server-functions.ts @@ -1,21 +1,30 @@ import { createServerFn } from '@tanstack/react-start' +import { ProfileUnavailableError } from '../profiles/errors' import { listChoicesInputSchema, listPageInputSchema, searchPageInputSchema, - threadPageInputSchema, userPageInputSchema, } from './inputs' import { loadListChoices, loadListPage, - loadThreadPage, loadUserPage, searchPage, } from './post-service' import type { LoadFailure } from './types' const configFailure = (diagnostic: unknown): LoadFailure => { + if (diagnostic instanceof ProfileUnavailableError) { + return { + ok: false, + error: { + code: 'profile-unavailable', + message: diagnostic.message, + retryable: true, + }, + } + } console.error('Bird client configuration failed:', diagnostic) return { ok: false, @@ -27,19 +36,20 @@ const configFailure = (diagnostic: unknown): LoadFailure => { } } -async function reader() { - const [{ getBirdReader }, { getSelectedProfileName }] = await Promise.all([ +async function reader(profileName: string) { + const [{ getBirdReader }, { requireProfileName }] = await Promise.all([ import('./bird-client.server'), import('../profiles/profile-service.server'), ]) - return getBirdReader(getSelectedProfileName()) + await requireProfileName(profileName) + return getBirdReader(profileName) } export const loadUserPosts = createServerFn({ method: 'GET' }) .validator(userPageInputSchema) .handler(async ({ data }) => { try { - return await loadUserPage(await reader(), data) + return await loadUserPage(await reader(data.profileName), data) } catch (error) { return configFailure(error) } @@ -49,7 +59,7 @@ export const loadListPosts = createServerFn({ method: 'GET' }) .validator(listPageInputSchema) .handler(async ({ data }) => { try { - return await loadListPage(await reader(), data) + return await loadListPage(await reader(data.profileName), data) } catch (error) { return configFailure(error) } @@ -57,9 +67,9 @@ export const loadListPosts = createServerFn({ method: 'GET' }) export const loadLists = createServerFn({ method: 'GET' }) .validator(listChoicesInputSchema) - .handler(async () => { + .handler(async ({ data }) => { try { - return await loadListChoices(await reader()) + return await loadListChoices(await reader(data.profileName)) } catch (error) { return configFailure(error) } @@ -69,17 +79,7 @@ export const searchPosts = createServerFn({ method: 'GET' }) .validator(searchPageInputSchema) .handler(async ({ data }) => { try { - return await searchPage(await reader(), data) - } catch (error) { - return configFailure(error) - } - }) - -export const loadThreadPosts = createServerFn({ method: 'GET' }) - .validator(threadPageInputSchema) - .handler(async ({ data }) => { - try { - return await loadThreadPage(await reader(), data) + return await searchPage(await reader(data.profileName), data) } catch (error) { return configFailure(error) } diff --git a/src/features/posts/types.ts b/src/features/posts/types.ts index 40a27ff..90c176d 100644 --- a/src/features/posts/types.ts +++ b/src/features/posts/types.ts @@ -9,19 +9,13 @@ export type PostPage = { nextCursor?: string } -export type ThreadPage = PostPage & { - focalPost?: Post - conversationId: string -} - type LoadErrorCode = | 'invalid-input' | 'list-not-found' | 'list-unavailable' - | 'post-not-found' - | 'post-unavailable' | 'user-not-found' | 'user-unavailable' + | 'profile-unavailable' | 'relay-config' | 'timeout' | 'upstream' @@ -38,8 +32,6 @@ export type LoadResult = | { ok: true; page: TPage } | LoadFailure -export type ThreadLoadResult = LoadResult - export type ListChoice = { id: string name: string @@ -50,29 +42,20 @@ export type ListChoice = { export type ListChoicesResult = { ok: true; lists: ListChoice[] } | LoadFailure -export type ThreadPageInput = - | { - tweetId: string - conversationId?: never - cursor?: never - } - | { - tweetId: string - conversationId: string - cursor: string - } - export type UserPageInput = { + profileName: string target: string cursor?: string } export type ListPageInput = { + profileName: string target: string cursor?: string } export type SearchPageInput = { + profileName: string query: string product: SearchProduct following: boolean diff --git a/src/features/posts/use-list-choices.test.ts b/src/features/posts/use-list-choices.test.ts index 8f9cb46..0f4c642 100644 --- a/src/features/posts/use-list-choices.test.ts +++ b/src/features/posts/use-list-choices.test.ts @@ -10,13 +10,13 @@ describe('createListChoicesOptions', () => { ok: true, lists: [{ id: '123', name: 'My List' }], }) - const options = createListChoicesOptions(loader) + const options = createListChoicesOptions('main', loader) await expect(options.queryFn()).resolves.toEqual([ { id: '123', name: 'My List' }, ]) - expect(loader).toHaveBeenCalledWith({ data: {} }) - expect(options.queryKey).toEqual(['list-choices']) + expect(loader).toHaveBeenCalledWith({ data: { profileName: 'main' } }) + expect(options.queryKey).toEqual(['list-choices', 'main']) expect(options.retry).toBe(false) expect(options.refetchOnWindowFocus).toBe(false) expect(options.refetchOnReconnect).toBe(false) @@ -29,6 +29,7 @@ describe('createListChoicesOptions', () => { retryable: true, } const options = createListChoicesOptions( + 'main', vi.fn().mockResolvedValue({ ok: false, error }), ) diff --git a/src/features/posts/use-list-choices.ts b/src/features/posts/use-list-choices.ts index c4bba9a..bc7441f 100644 --- a/src/features/posts/use-list-choices.ts +++ b/src/features/posts/use-list-choices.ts @@ -4,7 +4,7 @@ import { loadLists } from './server-functions' import type { ListChoice, ListChoicesResult, LoadError } from './types' type ListChoicesLoader = (options: { - data: Record + data: { profileName: string } }) => Promise export class ListChoicesLoadError extends Error { @@ -13,11 +13,14 @@ export class ListChoicesLoadError extends Error { } } -export function createListChoicesOptions(loader: ListChoicesLoader) { +export function createListChoicesOptions( + profileName: string, + loader: ListChoicesLoader, +) { return { - queryKey: ['list-choices'] as const, + queryKey: ['list-choices', profileName] as const, queryFn: async (): Promise => { - const result = await loader({ data: {} }) + const result = await loader({ data: { profileName } }) if (!result.ok) throw new ListChoicesLoadError(result.error) return result.lists }, @@ -27,7 +30,10 @@ export function createListChoicesOptions(loader: ListChoicesLoader) { } } -export function useListChoices() { +export function useListChoices(profileName: string) { const loader = useServerFn(loadLists) - return useQuery(createListChoicesOptions(loader)) + return useQuery({ + ...createListChoicesOptions(profileName, loader), + enabled: Boolean(profileName), + }) } diff --git a/src/features/posts/use-post-feed.test.ts b/src/features/posts/use-post-feed.test.ts index 7957ac0..0aacb17 100644 --- a/src/features/posts/use-post-feed.test.ts +++ b/src/features/posts/use-post-feed.test.ts @@ -4,7 +4,6 @@ import { createElement, type ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPostFeedOptions, - getPostFeedData, PostLoadError, usePostFeed, } from './use-post-feed' @@ -23,7 +22,6 @@ const loaders = (overrides: Partial = {}): FeedLoaders => ({ loadUser: vi.fn(), loadList: vi.fn(), search: vi.fn(), - thread: vi.fn(), ...overrides, }) @@ -34,7 +32,7 @@ describe('createPostFeedOptions', () => { page: { tweets: [], nextCursor: 'next' }, }) const options = createPostFeedOptions( - { kind: 'user', target: '@yuta' }, + { profileName: 'main', kind: 'user', target: '@yuta' }, loaders({ loadUser }), ) @@ -42,7 +40,7 @@ describe('createPostFeedOptions', () => { expect(loadUser).toHaveBeenCalledOnce() expect(loadUser).toHaveBeenCalledWith({ - data: { target: '@yuta', cursor: 'cursor' }, + data: { profileName: 'main', target: '@yuta', cursor: 'cursor' }, }) }) @@ -52,7 +50,7 @@ describe('createPostFeedOptions', () => { page: { tweets: [], nextCursor: 'next' }, }) const options = createPostFeedOptions( - { kind: 'list', target: '1234567890' }, + { profileName: 'main', kind: 'list', target: '1234567890' }, loaders({ loadList }), ) @@ -60,17 +58,17 @@ describe('createPostFeedOptions', () => { expect(loadList).toHaveBeenCalledOnce() expect(loadList).toHaveBeenCalledWith({ - data: { target: '1234567890', cursor: 'cursor' }, + data: { profileName: 'main', target: '1234567890', cursor: 'cursor' }, }) expect(options.queryKey).toEqual([ 'posts', - { kind: 'list', target: '1234567890' }, + { profileName: 'main', kind: 'list', target: '1234567890' }, ]) }) it('does not retry requests automatically', () => { const options = createPostFeedOptions( - { kind: 'user', target: 'yuta' }, + { profileName: 'main', kind: 'user', target: 'yuta' }, loaders(), ) @@ -79,7 +77,7 @@ describe('createPostFeedOptions', () => { it('does not refetch automatically when the window regains focus', () => { const options = createPostFeedOptions( - { kind: 'user', target: 'yuta' }, + { profileName: 'main', kind: 'user', target: 'yuta' }, loaders(), ) @@ -88,7 +86,7 @@ describe('createPostFeedOptions', () => { it('does not refetch automatically when the connection returns', () => { const options = createPostFeedOptions( - { kind: 'user', target: 'yuta' }, + { profileName: 'main', kind: 'user', target: 'yuta' }, loaders(), ) @@ -97,7 +95,7 @@ describe('createPostFeedOptions', () => { it('takes the next cursor only from the resolved page', () => { const options = createPostFeedOptions( - { kind: 'user', target: 'yuta' }, + { profileName: 'main', kind: 'user', target: 'yuta' }, loaders(), ) @@ -110,6 +108,7 @@ describe('createPostFeedOptions', () => { it('includes every search control in the query key', () => { const options = createPostFeedOptions( { + profileName: 'main', kind: 'search', query: 'AI', product: 'Top', @@ -120,7 +119,13 @@ describe('createPostFeedOptions', () => { expect(options.queryKey).toEqual([ 'posts', - { kind: 'search', query: 'AI', product: 'Top', following: true }, + { + profileName: 'main', + kind: 'search', + query: 'AI', + product: 'Top', + following: true, + }, ]) }) @@ -131,6 +136,7 @@ describe('createPostFeedOptions', () => { }) const options = createPostFeedOptions( { + profileName: 'main', kind: 'search', query: 'AI lang:ja', product: 'Top', @@ -144,6 +150,7 @@ describe('createPostFeedOptions', () => { expect(search).toHaveBeenCalledOnce() expect(search).toHaveBeenCalledWith({ data: { + profileName: 'main', query: 'AI lang:ja', product: 'Top', following: true, @@ -152,61 +159,6 @@ describe('createPostFeedOptions', () => { }) }) - it('loads the initial thread and carries its root with the next cursor', async () => { - const thread = vi.fn().mockResolvedValue({ - ok: true, - page: { - tweets: [], - focalPost: { - id: '123', - text: 'focal', - author: { username: 'focus', name: 'Focus' }, - }, - conversationId: '100', - nextCursor: 'thread-next', - }, - }) - const options = createPostFeedOptions( - { kind: 'thread', tweetId: '123' }, - loaders({ thread }), - ) - - const page = await options.queryFn({ pageParam: undefined } as never) - - expect(thread).toHaveBeenCalledWith({ data: { tweetId: '123' } }) - expect(options.queryKey).toEqual([ - 'posts', - { kind: 'thread', tweetId: '123' }, - ]) - expect(options.getNextPageParam(page)).toEqual({ - cursor: 'thread-next', - conversationId: '100', - }) - }) - - it('forwards a thread continuation page parameter intact', async () => { - const thread = vi.fn().mockResolvedValue({ - ok: true, - page: { tweets: [], conversationId: '100' }, - }) - const options = createPostFeedOptions( - { kind: 'thread', tweetId: '123' }, - loaders({ thread }), - ) - - await options.queryFn({ - pageParam: { cursor: 'thread-next', conversationId: '100' }, - } as never) - - expect(thread).toHaveBeenCalledWith({ - data: { - tweetId: '123', - cursor: 'thread-next', - conversationId: '100', - }, - }) - }) - it('preserves a typed load failure for the feed', async () => { const error = { code: 'upstream' as const, @@ -214,7 +166,7 @@ describe('createPostFeedOptions', () => { retryable: true, } const options = createPostFeedOptions( - { kind: 'user', target: 'yuta' }, + { profileName: 'main', kind: 'user', target: 'yuta' }, loaders({ loadUser: vi.fn().mockResolvedValue({ ok: false, error }), }), @@ -230,93 +182,33 @@ describe('createPostFeedOptions', () => { }) }) -describe('getPostFeedData', () => { - it('reuses the visible feed without fetching a second copy', async () => { - const loadUser = vi - .fn() - .mockResolvedValue({ ok: true, page: { tweets: [] } }) - const options = createPostFeedOptions( - { kind: 'user', target: 'yuta' }, +describe('profile-bound feed cache', () => { + it('keeps cached posts and pagination isolated between bound profiles', async () => { + const loadUser = vi.fn(async ({ data }) => ({ + ok: true as const, + page: { tweets: [], nextCursor: `${data.profileName}-next` }, + })) + const main = createPostFeedOptions( + { kind: 'user', target: 'yuta', profileName: 'main' }, + loaders({ loadUser }), + ) + const work = createPostFeedOptions( + { kind: 'user', target: 'yuta', profileName: 'work' }, loaders({ loadUser }), ) const client = new QueryClient() - const visible = await client.fetchInfiniteQuery(options) - - expect(await getPostFeedData(client, options)).toEqual(visible) - expect(loadUser).toHaveBeenCalledOnce() - client.clear() - }) - - it('waits for the active profile refresh instead of returning old cached posts', async () => { - let release = () => {} - const gate = new Promise((resolve) => { - release = resolve + await client.ensureInfiniteQueryData(main) + expect( + (await client.ensureInfiniteQueryData(work)).pages[0]?.nextCursor, + ).toBe('work-next') + expect( + (await client.ensureInfiniteQueryData(main)).pages[0]?.nextCursor, + ).toBe('main-next') + expect(loadUser).toHaveBeenCalledTimes(2) + await work.queryFn({ pageParam: 'work-next' }) + expect(loadUser).toHaveBeenLastCalledWith({ + data: { target: 'yuta', profileName: 'work', cursor: 'work-next' }, }) - const freshPage = { - tweets: [ - { - id: 'new-profile', - text: 'fresh', - author: { username: 'new', name: 'New' }, - }, - ], - } - const loadUser = vi.fn(async () => { - await gate - return { ok: true as const, page: freshPage } - }) - const options = createPostFeedOptions( - { kind: 'user', target: 'yuta' }, - loaders({ loadUser }), - ) - const client = new QueryClient() - client.setQueryData(options.queryKey, { - pages: [{ tweets: [] }], - pageParams: [undefined], - }) - await client.invalidateQueries({ - queryKey: options.queryKey, - refetchType: 'none', - }) - const refresh = client.fetchInfiniteQuery(options) - const resolved = vi.fn() - const tool = getPostFeedData(client, options).then(resolved) - await Promise.resolve() - - expect(resolved).not.toHaveBeenCalled() - release() - await Promise.all([refresh, tool]) - expect(resolved).toHaveBeenCalledWith({ - pages: [freshPage], - pageParams: [undefined], - }) - expect(loadUser).toHaveBeenCalledOnce() - client.clear() - }) - - it('refreshes invalidated cached data before returning it', async () => { - const loadUser = vi.fn().mockResolvedValue({ - ok: true, - page: { tweets: [], nextCursor: 'fresh' }, - }) - const options = createPostFeedOptions( - { kind: 'user', target: 'yuta' }, - loaders({ loadUser }), - ) - const client = new QueryClient() - client.setQueryData(options.queryKey, { - pages: [{ tweets: [] }], - pageParams: [undefined], - }) - await client.invalidateQueries({ - queryKey: options.queryKey, - refetchType: 'none', - }) - - expect((await getPostFeedData(client, options)).pages[0]?.nextCursor).toBe( - 'fresh', - ) - expect(loadUser).toHaveBeenCalledOnce() client.clear() }) }) diff --git a/src/features/posts/use-post-feed.ts b/src/features/posts/use-post-feed.ts index bdfb98f..b0636fd 100644 --- a/src/features/posts/use-post-feed.ts +++ b/src/features/posts/use-post-feed.ts @@ -1,25 +1,17 @@ -import { type QueryClient, useInfiniteQuery } from '@tanstack/react-query' +import { useInfiniteQuery } from '@tanstack/react-query' import { useServerFn } from '@tanstack/react-start' import type { SearchProduct } from '@yuta/bird' -import { - loadListPosts, - loadThreadPosts, - loadUserPosts, - searchPosts, -} from './server-functions' +import { loadListPosts, loadUserPosts, searchPosts } from './server-functions' import type { ListPageInput, LoadError, LoadResult, PostPage, SearchPageInput, - ThreadLoadResult, - ThreadPage, - ThreadPageInput, UserPageInput, } from './types' -export type FeedRequest = +export type FeedRequest = { profileName: string } & ( | { kind: 'user'; target: string } | { kind: 'list'; target: string } | { @@ -28,20 +20,12 @@ export type FeedRequest = product: SearchProduct following: boolean } - | { kind: 'thread'; tweetId: string } - -type ThreadPageParam = { - cursor: string - conversationId: string -} - -type FeedPageParam = string | ThreadPageParam | undefined +) type Loaders = { loadUser: (options: { data: UserPageInput }) => Promise loadList: (options: { data: ListPageInput }) => Promise search: (options: { data: SearchPageInput }) => Promise - thread: (options: { data: ThreadPageInput }) => Promise } export class PostLoadError extends Error { @@ -58,17 +42,18 @@ function unwrap(result: LoadResult): TPage { export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) { return { queryKey: ['posts', request] as const, - initialPageParam: undefined as FeedPageParam, + initialPageParam: undefined as string | undefined, retry: false as const, refetchOnWindowFocus: false as const, refetchOnReconnect: false as const, - queryFn: async ({ pageParam }: { pageParam: FeedPageParam }) => { + queryFn: async ({ pageParam }: { pageParam: string | undefined }) => { if (request.kind === 'user') { return unwrap( await loaders.loadUser({ data: { + profileName: request.profileName, target: request.target, - cursor: typeof pageParam === 'string' ? pageParam : undefined, + cursor: pageParam, }, }), ) @@ -77,80 +62,47 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) { return unwrap( await loaders.loadList({ data: { + profileName: request.profileName, target: request.target, - cursor: typeof pageParam === 'string' ? pageParam : undefined, - }, - }), - ) - } - if (request.kind === 'search') { - return unwrap( - await loaders.search({ - data: { - query: request.query, - product: request.product, - following: request.following, - cursor: typeof pageParam === 'string' ? pageParam : undefined, + cursor: pageParam, }, }), ) } return unwrap( - await loaders.thread({ - data: - typeof pageParam === 'object' - ? { - tweetId: request.tweetId, - cursor: pageParam.cursor, - conversationId: pageParam.conversationId, - } - : { tweetId: request.tweetId }, + await loaders.search({ + data: { + profileName: request.profileName, + query: request.query, + product: request.product, + following: request.following, + cursor: pageParam, + }, }), ) }, - getNextPageParam: (page: PostPage | ThreadPage) => { - if (!page.nextCursor) return undefined - return request.kind === 'thread' && 'conversationId' in page - ? { - cursor: page.nextCursor, - conversationId: page.conversationId, - } - : page.nextCursor - }, + getNextPageParam: (page: PostPage) => page.nextCursor, } } -export function usePostFeedOptions() { +function usePostFeedOptions() { const loadUser = useServerFn(loadUserPosts) const loadList = useServerFn(loadListPosts) const search = useServerFn(searchPosts) - const thread = useServerFn(loadThreadPosts) return (request: FeedRequest) => createPostFeedOptions(request, { loadUser, loadList, search, - thread, }) } -export function getPostFeedData( - client: QueryClient, - options: ReturnType, -) { - const state = client.getQueryState(options.queryKey) - // Profile changes invalidate and refresh an existing feed. Wait for that - // refresh instead of returning the previous profile's cached posts. - return state?.fetchStatus === 'fetching' || state?.isInvalidated - ? client.fetchInfiniteQuery(options) - : client.ensureInfiniteQueryData(options) -} - export function usePostFeed(request: FeedRequest | undefined) { const options = usePostFeedOptions() const disabled = { kind: 'user', target: '', + profileName: '', } satisfies FeedRequest return useInfiniteQuery({ diff --git a/src/features/posts/webmcp-contracts.test.ts b/src/features/posts/webmcp-contracts.test.ts deleted file mode 100644 index 65c25bb..0000000 --- a/src/features/posts/webmcp-contracts.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Post, ThreadPage } from './types' -import { type FeedRequest, PostLoadError } from './use-post-feed' -import { - feedResult, - prepareSearch, - readToolInput, - toolResult, -} from './webmcp-contracts' - -const post = (id: string): Post => ({ - id, - text: `Post ${id}`, - author: { username: 'reader', name: 'Reader' }, -}) - -const request = { kind: 'user', target: 'reader' } satisfies FeedRequest - -describe('WebMCP search input', () => { - it('defaults to chronological search without adding filters', () => { - expect(prepareSearch({ q: ' hello ' })).toMatchObject({ - search: { q: 'hello', product: 'Latest', following: false }, - request: { - kind: 'search', - query: 'hello', - product: 'Latest', - following: false, - }, - }) - }) - - it('compiles the same author, date, content, and exclusion filters as the UI', () => { - expect( - prepareSearch({ - q: 'release', - from: '@reader', - since: '2026-09-01', - until: '2026-09-24', - lang: 'ja', - content: 'links', - excludeReplies: true, - excludeReposts: true, - product: 'Top', - following: true, - }).request, - ).toEqual({ - kind: 'search', - query: - 'release from:reader since:2026-09-01 until:2026-09-24 lang:ja filter:links -filter:replies -filter:retweets filter:follows', - product: 'Top', - following: true, - }) - }) - - it('accepts an author as the deliberate search intent', () => { - expect(prepareSearch({ from: 'reader' }).request.query).toBe('from:reader') - }) - - it.each([ - ['an impossible date', { q: 'hello', since: '2026-02-30' }], - ['an invalid date format', { q: 'hello', until: 'yesterday' }], - [ - 'an inverted period', - { q: 'hello', since: '2026-09-24', until: '2026-09-01' }, - ], - [ - 'an empty period', - { q: 'hello', since: '2026-09-24', until: '2026-09-24' }, - ], - ['missing search intent', {}], - ['whitespace-only intent', { q: ' ', from: ' ' }], - ['an unrecognized property', { q: 'hello', profile: 'other' }], - ])('rejects %s', (_description, input) => { - expect(() => prepareSearch(input)).toThrow() - }) -}) - -describe('WebMCP read input', () => { - it('defaults to the first 20 loaded posts', () => { - expect(readToolInput.parse({})).toEqual({ offset: 0, limit: 20 }) - }) - - it('accepts the maximum batch size', () => { - expect(readToolInput.parse({ offset: 10, limit: 50 })).toEqual({ - offset: 10, - limit: 50, - }) - }) - - it.each([ - { limit: 0 }, - { limit: 51 }, - { limit: 1.5 }, - { limit: '20' }, - { offset: -1 }, - { offset: 0.5 }, - { cursor: 'upstream-cursor' }, - ])('rejects invalid pagination %j', (input) => { - expect(readToolInput.safeParse(input).success).toBe(false) - }) -}) - -describe('WebMCP feed output', () => { - it('bounds post text and indicates when text was truncated', () => { - const result = feedResult(request, [ - { - tweets: [ - { ...post('1'), text: 'a'.repeat(2001) }, - { ...post('2'), text: 'b'.repeat(2000) }, - ], - }, - ]) - - expect(result.posts).toEqual([ - { - id: '1', - author: { username: 'reader', name: 'Reader' }, - text: 'a'.repeat(2000), - textTruncated: true, - createdAt: undefined, - url: 'https://x.com/reader/status/1', - }, - { - id: '2', - author: { username: 'reader', name: 'Reader' }, - text: 'b'.repeat(2000), - textTruncated: false, - createdAt: undefined, - url: 'https://x.com/reader/status/2', - }, - ]) - }) - - it('deduplicates loaded pages before applying the requested offset and limit', () => { - const result = feedResult( - request, - [ - { tweets: [post('1'), post('2')], nextCursor: 'next' }, - { tweets: [post('2'), post('3'), post('4')] }, - ], - 1, - 2, - ) - - expect(result.posts.map(({ id }) => id)).toEqual(['2', '3']) - expect(result).toMatchObject({ - loadedCount: 4, - offset: 1, - nextOffset: 3, - hasMore: false, - }) - }) - - it('includes the focal post once before the deduplicated conversation', () => { - const focal = post('2') - const pages: ThreadPage[] = [ - { - focalPost: focal, - conversationId: '1', - tweets: [post('1'), focal], - nextCursor: 'next', - }, - { conversationId: '1', tweets: [focal, post('3'), post('1')] }, - ] - - const result = feedResult({ kind: 'thread', tweetId: '2' }, pages) - - expect(result.posts.map(({ id }) => id)).toEqual(['2', '1', '3']) - expect(result.loadedCount).toBe(3) - }) - - it('reports upstream continuation separately from remaining loaded posts', () => { - const result = feedResult(request, [ - { tweets: [post('1')], nextCursor: 'more-on-server' }, - ]) - - expect(result).toMatchObject({ - loadedCount: 1, - nextOffset: null, - hasMore: true, - }) - }) - - it('returns an empty batch when the offset is beyond the loaded posts', () => { - expect( - feedResult(request, [{ tweets: [post('1')] }], 10, 20), - ).toMatchObject({ - posts: [], - loadedCount: 1, - offset: 10, - nextOffset: null, - hasMore: false, - }) - }) -}) - -describe('WebMCP execution result', () => { - it.each([ - true, - false, - ])('preserves a load failure with retryable=%s', async (retryable) => { - const detail = { - code: 'upstream' as const, - message: 'Relay unavailable', - retryable, - } - - const result = await toolResult(() => - Promise.reject(new PostLoadError(detail)), - ) - - expect(result).toEqual({ - isError: true, - content: [{ type: 'text', text: JSON.stringify(detail) }], - }) - }) - - it('returns invalid-input for schema validation failures', async () => { - const result = await toolResult(() => readToolInput.parse({ limit: 100 })) - - expect(result).toMatchObject({ isError: true }) - expect(JSON.parse(result.content[0].text)).toMatchObject({ - code: 'invalid-input', - retryable: false, - }) - }) - - it('keeps successful empty results distinguishable from failures', async () => { - const result = await toolResult(() => feedResult(request, [{ tweets: [] }])) - - expect(result).not.toHaveProperty('isError') - expect(JSON.parse(result.content[0].text)).toEqual({ - request, - posts: [], - loadedCount: 0, - offset: 0, - nextOffset: null, - hasMore: false, - }) - }) -}) diff --git a/src/features/posts/webmcp-contracts.ts b/src/features/posts/webmcp-contracts.ts deleted file mode 100644 index 5064f42..0000000 --- a/src/features/posts/webmcp-contracts.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { z } from 'zod' -import { buildFilteredSearchQuery, InputError } from './inputs' -import { - flattenConversationPages, - flattenPostPages, - focalPostFromPages, -} from './page' -import type { Post, PostPage, ThreadPage } from './types' -import { type FeedRequest, PostLoadError } from './use-post-feed' - -export const searchToolInput = z - .object({ - q: z - .string() - .trim() - .max(512) - .default('') - .describe( - 'Search text; raw X search operators are supported. Provide q or from.', - ), - from: z - .string() - .trim() - .max(16) - .default('') - .describe('Author handle, with or without @.'), - since: z - .string() - .default('') - .describe( - 'Inclusive start date, YYYY-MM-DD, using X search date semantics.', - ), - until: z - .string() - .default('') - .describe('Exclusive end date, YYYY-MM-DD; must be after since.'), - lang: z.enum(['all', 'ja', 'en']).default('all'), - content: z.enum(['all', 'images', 'videos', 'links']).default('all'), - excludeReplies: z.boolean().default(false), - excludeReposts: z.boolean().default(false), - product: z - .enum(['Latest', 'Top']) - .default('Latest') - .describe('Latest is chronological; Top uses X ranking.'), - following: z - .boolean() - .default(false) - .describe('Restrict to accounts followed by the active relay profile.'), - }) - .strict() - -export const readToolInput = z - .object({ - offset: z - .number() - .int() - .min(0) - .default(0) - .describe('Offset into the currently loaded posts, starting at zero.'), - limit: z.number().int().min(1).max(50).default(20), - }) - .strict() - -export const emptyToolInput = z.object({}).strict() - -export function prepareSearch(input: unknown) { - const search = searchToolInput.parse(input) - const request = { - kind: 'search', - query: buildFilteredSearchQuery(search, search.following), - product: search.product, - following: search.following, - } satisfies FeedRequest - return { search, request } -} - -function summarizePost(post: Post) { - return { - id: post.id, - author: { username: post.author.username, name: post.author.name }, - text: post.text.slice(0, 2000), - textTruncated: post.text.length > 2000, - createdAt: post.createdAt, - url: `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`, - } -} - -export function loadedPosts( - request: FeedRequest, - pages: Array, -) { - const focal = - request.kind === 'thread' ? focalPostFromPages(pages) : undefined - return focal - ? [focal, ...flattenConversationPages(pages, focal.id)] - : flattenPostPages(pages) -} - -export function feedResult( - request: FeedRequest, - pages: Array, - offset = 0, - limit = 20, -) { - const posts = loadedPosts(request, pages) - const selected = posts.slice(offset, offset + limit) - const nextOffset = offset + selected.length - return { - request, - posts: selected.map(summarizePost), - loadedCount: posts.length, - offset, - nextOffset: nextOffset < posts.length ? nextOffset : null, - hasMore: Boolean(pages.at(-1)?.nextCursor), - } -} - -// Keep the tool's failure distinguishable from a successful empty result. -export async function toolResult(run: () => unknown | Promise) { - try { - return { - content: [ - { type: 'text' as const, text: JSON.stringify(await run()) }, - ] as const, - } - } catch (error) { - const detail = - error instanceof PostLoadError - ? error.detail - : { - code: - error instanceof z.ZodError || error instanceof InputError - ? 'invalid-input' - : 'tool-error', - message: - error instanceof Error ? error.message : 'Tool execution failed.', - retryable: false, - } - return { - isError: true, - content: [ - { type: 'text' as const, text: JSON.stringify(detail) }, - ] as const, - } - } -} diff --git a/src/features/posts/webmcp-tools.tsx b/src/features/posts/webmcp-tools.tsx deleted file mode 100644 index 3a0b786..0000000 --- a/src/features/posts/webmcp-tools.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useRouter } from '@tanstack/react-router' -import { useEffect, useRef, useState } from 'react' -import { useWebMCP } from 'usewebmcp' -import { - type FeedRequest, - getPostFeedData, - PostLoadError, - type usePostFeed, - usePostFeedOptions, -} from './use-post-feed' -import { - emptyToolInput, - feedResult, - loadedPosts, - prepareSearch, - readToolInput, - searchToolInput, - toolResult, -} from './webmcp-contracts' - -export function useWebMCPSupported() { - const [supported, setSupported] = useState(false) - useEffect(() => { - setSupported(Boolean(document.modelContext)) - }, []) - return supported -} - -export function SearchTool() { - const supported = useWebMCPSupported() - const router = useRouter() - const queryClient = useQueryClient() - const options = usePostFeedOptions() - const searching = useRef(false) - - useWebMCP({ - name: 'search_posts', - description: - 'Search X posts and return the first result slice. Updates the visible search page and its filters. Uses the active relay profile. Returns post text, authors, dates, source URLs and pagination information.', - inputSchema: searchToolInput, - enabled: supported, - annotations: { - readOnlyHint: false, - untrustedContentHint: true, - }, - execute: (input) => - toolResult(async () => { - const { search, request } = prepareSearch(input) - if (searching.current) - throw new Error('A search is already running. Wait for it to finish.') - searching.current = true - try { - await router.navigate({ to: '/search', search }) - const href = router.buildLocation({ to: '/search', search }).href - if (router.state.location.href !== href) - throw new Error( - 'The page changed. Search again from the current page.', - ) - // Join the UI request (or use its completed data), rather than fetch twice. - const result = await getPostFeedData(queryClient, options(request)) - if (router.state.location.href !== href) - throw new Error( - 'The page changed while searching. Read the current feed or search again.', - ) - return feedResult(request, result.pages) - } finally { - searching.current = false - } - }), - }) - return null -} - -export function useFeedTools( - request: FeedRequest | undefined, - query: ReturnType, -) { - const supported = useWebMCPSupported() - const generation = useRef(0) - const requestKey = JSON.stringify(request) - useEffect(() => { - // Executions belong to the feed that was visible when they started. - void requestKey - generation.current += 1 - return () => { - generation.current += 1 - } - }, [requestKey]) - - useWebMCP({ - name: 'get_loaded_posts', - description: - 'Read a slice of posts already loaded in the current timeline, search or conversation without a network request. Includes the selected conversation post. Returns active criteria, loading status, source URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation.', - inputSchema: readToolInput, - enabled: supported && Boolean(request), - annotations: { - readOnlyHint: true, - untrustedContentHint: true, - }, - execute: (input) => - toolResult(() => { - const { offset, limit } = readToolInput.parse(input) - if (!request) - throw new Error('Open a timeline, search or conversation first.') - return { - ...feedResult(request, query.data?.pages ?? [], offset, limit), - status: query.isPending - ? 'loading' - : query.isError - ? 'error' - : 'ready', - loading: query.isFetching, - error: - query.error instanceof PostLoadError ? query.error.detail : null, - } - }), - }) - - useWebMCP({ - name: 'load_more_posts', - description: - 'Load one continuation page into the current feed and return only newly appended posts. Joins an in-progress scroll request. Returns nextOffset if additional loaded posts remain outside this response. At the end returns an empty posts array with hasMore false. Can retry a failed continuation.', - inputSchema: emptyToolInput, - enabled: supported && Boolean(request), - annotations: { - readOnlyHint: false, - untrustedContentHint: true, - }, - execute: (input) => - toolResult(async () => { - emptyToolInput.parse(input) - if (!request || query.isPending) - throw new Error( - 'Wait for the initial feed to load before requesting more posts.', - ) - if (query.isError && !query.isFetchNextPageError) throw query.error - if (query.isFetching && !query.isFetchingNextPage) - throw new Error( - 'The feed is refreshing. Wait before requesting more posts.', - ) - const pages = query.data?.pages ?? [] - const offset = loadedPosts(request, pages).length - if (!query.hasNextPage) return feedResult(request, pages, offset) - const started = generation.current - const result = await query.fetchNextPage({ cancelRefetch: false }) - if (generation.current !== started) - throw new Error( - 'The feed changed while loading. Read the current feed before requesting more posts.', - ) - if (result.isError) throw result.error - return feedResult(request, result.data?.pages ?? [], offset) - }), - }) -} diff --git a/src/features/profiles/errors.ts b/src/features/profiles/errors.ts new file mode 100644 index 0000000..72a62b9 --- /dev/null +++ b/src/features/profiles/errors.ts @@ -0,0 +1 @@ +export class ProfileUnavailableError extends Error {} diff --git a/src/features/profiles/profile-service.server.ts b/src/features/profiles/profile-service.server.ts index 795ba07..03934b4 100644 --- a/src/features/profiles/profile-service.server.ts +++ b/src/features/profiles/profile-service.server.ts @@ -1,12 +1,6 @@ -import { - deleteCookie, - getCookie, - setCookie, -} from '@tanstack/react-start/server' import { z } from 'zod' -import type { ProfileSettings } from './types' +import { ProfileUnavailableError } from './errors' -const PROFILE_COOKIE = 'twitter-lite-profile' const profilesSchema = z.object({ profiles: z.array(z.string().min(1)), }) @@ -32,38 +26,15 @@ export async function fetchProfileNames( return profilesSchema.parse(await response.json()).profiles } -export function getSelectedProfileName(): string | undefined { - return getCookie(PROFILE_COOKIE) || process.env.BIRD_PROFILE_NAME -} - -export async function loadProfileSettings(): Promise { - const profiles = await fetchProfileNames() - const selectedProfile = getSelectedProfileName() - return { - profiles, - ...(selectedProfile && profiles.includes(selectedProfile) - ? { selectedProfile } - : {}), - } -} - -export async function saveProfileSelection( - profileName: string | null, -): Promise { - if (profileName === null) { - deleteCookie(PROFILE_COOKIE, { path: '/' }) - return - } - - const profiles = await fetchProfileNames() - if (!profiles.includes(profileName)) { - throw new Error(`Unknown relay profile: ${profileName}`) - } - - setCookie(PROFILE_COOKIE, profileName, { - httpOnly: true, - maxAge: 60 * 60 * 24 * 365, - path: '/', - sameSite: 'lax', +export async function requireProfileName(profileName: string): Promise { + const profiles = await fetchProfileNames().catch(() => { + throw new ProfileUnavailableError( + 'プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。', + ) }) + if (!profiles.includes(profileName)) { + throw new ProfileUnavailableError( + 'このプロファイルは利用できません。カラムのプロファイルを選び直してください。', + ) + } } diff --git a/src/features/profiles/profile-service.test.ts b/src/features/profiles/profile-service.test.ts index 9b03f8b..a2e6a2a 100644 --- a/src/features/profiles/profile-service.test.ts +++ b/src/features/profiles/profile-service.test.ts @@ -1,7 +1,40 @@ // @vitest-environment node -import { describe, expect, it, vi } from 'vitest' -import { fetchProfileNames } from './profile-service.server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProfileUnavailableError } from './errors' +import { fetchProfileNames, requireProfileName } from './profile-service.server' + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('requireProfileName', () => { + it('accepts a discovered profile and rejects a stale column binding', async () => { + vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid') + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ profiles: ['main', 'work'] })), + ) + + await expect(requireProfileName('work')).resolves.toBeUndefined() + await expect(requireProfileName('removed')).rejects.toBeInstanceOf( + ProfileUnavailableError, + ) + }) + + it('reports discovery failures without silently selecting another profile', async () => { + vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid') + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 503 })), + ) + + await expect(requireProfileName('work')).rejects.toThrow( + 'プロファイルを確認できませんでした。', + ) + }) +}) describe('fetchProfileNames', () => { it('loads profile names from the relay endpoint', async () => { diff --git a/src/features/profiles/profile-switcher.test.tsx b/src/features/profiles/profile-switcher.test.tsx deleted file mode 100644 index f531bdf..0000000 --- a/src/features/profiles/profile-switcher.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { ProfileSwitcher } from './profile-switcher' - -const serverFns = vi.hoisted(() => ({ - loadProfiles: vi.fn(), - selectProfile: vi.fn(), -})) - -vi.mock('./server-functions', () => serverFns) -vi.mock('@tanstack/react-start', () => ({ - useServerFn: (serverFn: unknown) => serverFn, -})) - -function renderSwitcher() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - render( - - - , - ) -} - -describe('ProfileSwitcher', () => { - it('shows relay profiles and switches the active profile', async () => { - serverFns.loadProfiles.mockResolvedValue({ - profiles: ['main', 'work'], - selectedProfile: 'main', - }) - serverFns.selectProfile.mockResolvedValue({ profileName: 'work' }) - renderSwitcher() - - const select = await screen.findByLabelText('プロファイル') - await waitFor(() => expect(select).toHaveValue('main')) - - fireEvent.change(select, { target: { value: 'work' } }) - - await waitFor(() => - expect(serverFns.selectProfile).toHaveBeenCalledWith({ - data: { profileName: 'work' }, - }), - ) - expect(await screen.findByRole('status')).toHaveTextContent( - '切り替えました。', - ) - }) - - it('reports a relay profiles failure without offering stale choices', async () => { - serverFns.loadProfiles.mockRejectedValue(new Error('relay unavailable')) - renderSwitcher() - - const select = await screen.findByLabelText('プロファイル') - await waitFor(() => expect(select).toBeDisabled()) - expect(select).toHaveTextContent('取得失敗') - expect(select).toHaveAttribute('aria-invalid', 'true') - }) -}) diff --git a/src/features/profiles/profile-switcher.tsx b/src/features/profiles/profile-switcher.tsx deleted file mode 100644 index 5fa7703..0000000 --- a/src/features/profiles/profile-switcher.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useServerFn } from '@tanstack/react-start' -import { loadProfiles, selectProfile } from './server-functions' - -export function ProfileSwitcher() { - const queryClient = useQueryClient() - const load = useServerFn(loadProfiles) - const select = useServerFn(selectProfile) - const profiles = useQuery({ - queryKey: ['profiles'], - queryFn: () => load(), - retry: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }) - const selection = useMutation({ - mutationFn: (profileName: string | null) => - select({ data: { profileName } }), - onSuccess: () => queryClient.invalidateQueries(), - }) - - const state = profiles.isError - ? 'error' - : selection.isError - ? 'error' - : selection.isSuccess - ? 'success' - : profiles.isPending || selection.isPending - ? 'loading' - : 'default' - - return ( -
- - - - {selection.isError - ? '切り替えに失敗しました。' - : selection.isSuccess - ? '切り替えました。' - : ''} - -
- ) -} diff --git a/src/features/profiles/server-functions.ts b/src/features/profiles/server-functions.ts index 8635d15..3498d58 100644 --- a/src/features/profiles/server-functions.ts +++ b/src/features/profiles/server-functions.ts @@ -1,22 +1,8 @@ import { createServerFn } from '@tanstack/react-start' -import { z } from 'zod' -import type { ProfileSelection } from './types' - -const profileSelectionSchema = z.object({ - profileName: z.string().min(1).nullable(), -}) export const loadProfiles = createServerFn({ method: 'GET' }).handler( async () => { - const { loadProfileSettings } = await import('./profile-service.server') - return loadProfileSettings() + const { fetchProfileNames } = await import('./profile-service.server') + return { profiles: await fetchProfileNames() } }, ) - -export const selectProfile = createServerFn({ method: 'POST' }) - .validator(profileSelectionSchema) - .handler(async ({ data }): Promise => { - const { saveProfileSelection } = await import('./profile-service.server') - await saveProfileSelection(data.profileName) - return data - }) diff --git a/src/features/profiles/types.ts b/src/features/profiles/types.ts deleted file mode 100644 index a135edc..0000000 --- a/src/features/profiles/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type ProfileSettings = { - profiles: string[] - selectedProfile?: string -} - -export type ProfileSelection = { - profileName: string | null -} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 136fa22..3db8a3b 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,127 +9,48 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as SearchRouteImport } from './routes/search' import { Route as DeckRouteImport } from './routes/deck' -import { Route as HandleRouteImport } from './routes/$handle' import { Route as IndexRouteImport } from './routes/index' -import { Route as IListsRouteImport } from './routes/i.lists' -import { Route as IListsListIdRouteImport } from './routes/i.lists_.$listId' -import { Route as HandleStatusTweetIdRouteImport } from './routes/$handle_.status.$tweetId' -const SearchRoute = SearchRouteImport.update({ - id: '/search', - path: '/search', - getParentRoute: () => rootRouteImport, -} as any) const DeckRoute = DeckRouteImport.update({ id: '/deck', path: '/deck', getParentRoute: () => rootRouteImport, } as any) -const HandleRoute = HandleRouteImport.update({ - id: '/$handle', - path: '/$handle', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) -const IListsRoute = IListsRouteImport.update({ - id: '/i/lists', - path: '/i/lists', - getParentRoute: () => rootRouteImport, -} as any) -const IListsListIdRoute = IListsListIdRouteImport.update({ - id: '/i/lists_/$listId', - path: '/i/lists/$listId', - getParentRoute: () => rootRouteImport, -} as any) -const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({ - id: '/$handle_/status/$tweetId', - path: '/$handle/status/$tweetId', - getParentRoute: () => rootRouteImport, -} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/$handle': typeof HandleRoute '/deck': typeof DeckRoute - '/search': typeof SearchRoute - '/i/lists': typeof IListsRoute - '/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute - '/i/lists/$listId': typeof IListsListIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/$handle': typeof HandleRoute '/deck': typeof DeckRoute - '/search': typeof SearchRoute - '/i/lists': typeof IListsRoute - '/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute - '/i/lists/$listId': typeof IListsListIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/$handle': typeof HandleRoute '/deck': typeof DeckRoute - '/search': typeof SearchRoute - '/i/lists': typeof IListsRoute - '/$handle_/status/$tweetId': typeof HandleStatusTweetIdRoute - '/i/lists_/$listId': typeof IListsListIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/$handle' - | '/deck' - | '/search' - | '/i/lists' - | '/$handle/status/$tweetId' - | '/i/lists/$listId' + fullPaths: '/' | '/deck' fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/$handle' - | '/deck' - | '/search' - | '/i/lists' - | '/$handle/status/$tweetId' - | '/i/lists/$listId' - id: - | '__root__' - | '/' - | '/$handle' - | '/deck' - | '/search' - | '/i/lists' - | '/$handle_/status/$tweetId' - | '/i/lists_/$listId' + to: '/' | '/deck' + id: '__root__' | '/' | '/deck' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - HandleRoute: typeof HandleRoute DeckRoute: typeof DeckRoute - SearchRoute: typeof SearchRoute - IListsRoute: typeof IListsRoute - HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute - IListsListIdRoute: typeof IListsListIdRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/search': { - id: '/search' - path: '/search' - fullPath: '/search' - preLoaderRoute: typeof SearchRouteImport - parentRoute: typeof rootRouteImport - } '/deck': { id: '/deck' path: '/deck' @@ -137,13 +58,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DeckRouteImport parentRoute: typeof rootRouteImport } - '/$handle': { - id: '/$handle' - path: '/$handle' - fullPath: '/$handle' - preLoaderRoute: typeof HandleRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -151,38 +65,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/i/lists': { - id: '/i/lists' - path: '/i/lists' - fullPath: '/i/lists' - preLoaderRoute: typeof IListsRouteImport - parentRoute: typeof rootRouteImport - } - '/i/lists_/$listId': { - id: '/i/lists_/$listId' - path: '/i/lists/$listId' - fullPath: '/i/lists/$listId' - preLoaderRoute: typeof IListsListIdRouteImport - parentRoute: typeof rootRouteImport - } - '/$handle_/status/$tweetId': { - id: '/$handle_/status/$tweetId' - path: '/$handle/status/$tweetId' - fullPath: '/$handle/status/$tweetId' - preLoaderRoute: typeof HandleStatusTweetIdRouteImport - parentRoute: typeof rootRouteImport - } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - HandleRoute: HandleRoute, DeckRoute: DeckRoute, - SearchRoute: SearchRoute, - IListsRoute: IListsRoute, - HandleStatusTweetIdRoute: HandleStatusTweetIdRoute, - IListsListIdRoute: IListsListIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/$handle.tsx b/src/routes/$handle.tsx deleted file mode 100644 index 4c1ccea..0000000 --- a/src/routes/$handle.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { UserPage } from './index' - -export const Route = createFileRoute('/$handle')({ - component: UserRoute, -}) - -function UserRoute() { - const { handle } = Route.useParams() - return -} diff --git a/src/routes/$handle_.status.$tweetId.tsx b/src/routes/$handle_.status.$tweetId.tsx deleted file mode 100644 index e468278..0000000 --- a/src/routes/$handle_.status.$tweetId.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { AppShell } from '#/components/app-shell' -import { PostFeed } from '#/features/posts/components/post-feed' -import { - InputError, - normalizeTweetId, - normalizeUserTarget, -} from '#/features/posts/inputs' - -export const Route = createFileRoute('/$handle_/status/$tweetId')({ - component: StatusRoute, -}) - -function StatusRoute() { - const { handle: rawHandle, tweetId: rawTweetId } = Route.useParams() - let tweetId: string | undefined - let error: string | undefined - - try { - normalizeUserTarget(rawHandle) - tweetId = normalizeTweetId(rawTweetId) - } catch (cause) { - error = - cause instanceof InputError ? cause.message : 'URL を確認してください。' - } - - return ( - -

会話

-

選んだ投稿と、その会話だけを表示します。

- {error ? ( -

- {error} -

- ) : null} - -
- ) -} diff --git a/src/routes/-feed-wiring.test.tsx b/src/routes/-feed-wiring.test.tsx deleted file mode 100644 index 4766312..0000000 --- a/src/routes/-feed-wiring.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { QueryClient } from '@tanstack/react-query' -import { - createMemoryHistory, - createRouter, - RouterProvider, -} from '@tanstack/react-router' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { routeTree } from '../routeTree.gen' - -const usePostFeed = vi.hoisted(() => vi.fn()) -const useListChoices = vi.hoisted(() => vi.fn()) - -vi.mock('../features/posts/use-post-feed', async (importOriginal) => { - const original = - await importOriginal() - return { ...original, usePostFeed } -}) - -vi.mock('../features/posts/use-list-choices', async (importOriginal) => { - const original = - await importOriginal() - return { ...original, useListChoices } -}) - -vi.mock('../features/profiles/profile-switcher', () => ({ - ProfileSwitcher: () => null, -})) - -vi.mock('./__root', async () => { - const { createRootRouteWithContext, Outlet } = await import( - '@tanstack/react-router' - ) - return { - Route: createRootRouteWithContext<{ queryClient: QueryClient }>()({ - component: Outlet, - }), - } -}) - -async function renderRoute(path: string) { - const router = createRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: [path] }), - context: { queryClient: new QueryClient() }, - }) - await router.load() - render() -} - -describe('post feed route wiring', () => { - beforeEach(() => { - vi.stubGlobal('scrollTo', vi.fn()) - usePostFeed.mockReset() - useListChoices.mockReset() - usePostFeed.mockReturnValue({ - data: undefined, - error: null, - fetchNextPage: vi.fn(), - hasNextPage: false, - isError: false, - isFetchNextPageError: false, - isFetchingNextPage: false, - isPending: false, - refetch: vi.fn(), - } as never) - useListChoices.mockReturnValue({ - data: [{ id: '1234567890', name: 'My List', memberCount: 10 }], - error: null, - isError: false, - isPending: false, - refetch: vi.fn(), - } as never) - }) - - afterEach(() => vi.unstubAllGlobals()) - - it('keeps an empty user URL disabled', async () => { - await renderRoute('/') - - expect( - await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }), - ).toBeVisible() - expect(usePostFeed).toHaveBeenCalledWith(undefined) - }) - - it('turns a manually entered user URL into a deliberate request', async () => { - await renderRoute('/yuta') - - await waitFor(() => - expect(usePostFeed).toHaveBeenCalledWith({ - kind: 'user', - target: 'yuta', - }), - ) - }) - - it('keeps an invalid user path local and idle', async () => { - await renderRoute('/not-a-handle') - - expect(await screen.findByRole('alert')).toHaveTextContent( - 'ハンドルは英数字とアンダースコアで入力してください。', - ) - expect(usePostFeed).toHaveBeenCalledWith(undefined) - }) - - it('keeps an empty list URL disabled', async () => { - await renderRoute('/i/lists') - - expect( - await screen.findByRole('heading', { name: 'どのリストを読みますか?' }), - ).toBeVisible() - expect(usePostFeed).toHaveBeenCalledWith(undefined) - }) - - it('turns a manually entered list URL into a deliberate request', async () => { - await renderRoute('/i/lists/1234567890') - - await waitFor(() => - expect(usePostFeed).toHaveBeenCalledWith({ - kind: 'list', - target: '1234567890', - }), - ) - expect( - await screen.findByRole('link', { name: 'リスト', current: 'page' }), - ).toBeVisible() - }) - - it('keeps an invalid list path local and idle', async () => { - await renderRoute('/i/lists/not-a-list') - - expect(await screen.findByRole('alert')).toHaveTextContent( - 'リスト ID は数字で入力してください。', - ) - expect(usePostFeed).toHaveBeenCalledWith(undefined) - }) - - it('turns a selected authenticated list into a deliberate request', async () => { - await renderRoute('/i/lists') - - fireEvent.click( - await screen.findByRole('link', { name: /My List.*10 メンバー/ }), - ) - - await waitFor(() => - expect(usePostFeed).toHaveBeenCalledWith({ - kind: 'list', - target: '1234567890', - }), - ) - }) - - it('forwards every manually entered search control', async () => { - await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true') - - await waitFor(() => - expect(usePostFeed).toHaveBeenCalledWith({ - kind: 'search', - query: 'AI lang:ja filter:follows', - product: 'Top', - following: true, - }), - ) - }) - - it('compiles button-selected search conditions from the URL', async () => { - await renderRoute( - '/search?q=TypeScript&from=%40tan_stack&since=2026-08-04&until=2026-08-11&lang=ja&content=images&excludeReplies=true&excludeReposts=true&product=Latest&following=false', - ) - - await waitFor(() => - expect(usePostFeed).toHaveBeenCalledWith({ - kind: 'search', - query: - 'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets', - product: 'Latest', - following: false, - }), - ) - expect( - screen.getByRole('button', { name: '投稿者 @tan_stack を解除' }), - ).toBeVisible() - }) - - it('keeps contradictory date parameters local and idle', async () => { - await renderRoute( - '/search?q=TypeScript&since=2026-08-11&until=2026-08-10&product=Latest&following=false', - ) - - fireEvent.click(screen.getByRole('button', { name: '期間' })) - expect(await screen.findByLabelText('開始日')).toHaveValue('2026-08-11') - expect(usePostFeed).toHaveBeenCalledWith(undefined) - }) - - it('turns a decimal status path into a deliberate thread request', async () => { - await renderRoute('/yuta/status/123') - - expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible() - await waitFor(() => - expect(usePostFeed).toHaveBeenCalledWith({ - kind: 'thread', - tweetId: '123', - }), - ) - expect( - screen.queryByRole('link', { name: 'ユーザー', current: 'page' }), - ).toBeNull() - expect( - screen.queryByRole('link', { name: '検索', current: 'page' }), - ).toBeNull() - }) - - it('keeps an invalid tweet path local and idle', async () => { - await renderRoute('/yuta/status/not-a-tweet') - - expect(await screen.findByRole('alert')).toHaveTextContent( - '投稿 ID を確認してください。', - ) - expect(usePostFeed).toHaveBeenCalledWith(undefined) - }) - - it('keeps an invalid status handle local and idle', async () => { - await renderRoute('/not-a-handle/status/123') - - expect(await screen.findByRole('alert')).toHaveTextContent( - 'ハンドルは英数字とアンダースコアで入力してください。', - ) - expect(usePostFeed).toHaveBeenCalledWith(undefined) - }) -}) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index ca57629..1d3620d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -5,7 +5,6 @@ import { Outlet, Scripts, } from '@tanstack/react-router' -import { SearchTool } from '#/features/posts/webmcp-tools' import appCss from '../styles.css?url' type RouterContext = { queryClient: QueryClient } @@ -34,12 +33,7 @@ export const Route = createRootRouteWithContext()({ }) function RootContent() { - return ( - <> - - - - ) + return } function RootDocument({ children }: { children: React.ReactNode }) { diff --git a/src/routes/i.lists.tsx b/src/routes/i.lists.tsx deleted file mode 100644 index 48260ef..0000000 --- a/src/routes/i.lists.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' -import { AppShell } from '#/components/app-shell' -import { ListForm } from '#/features/posts/components/list-form' -import { ListPicker } from '#/features/posts/components/list-picker' -import { PostFeed } from '#/features/posts/components/post-feed' -import { InputError, normalizeListTarget } from '#/features/posts/inputs' - -export const Route = createFileRoute('/i/lists')({ - component: ListIndexRoute, -}) - -function ListIndexRoute() { - return -} - -export function ListPage({ listId = '' }: { listId?: string }) { - const navigate = useNavigate() - let normalizedListId: string | undefined - let error: string | undefined - - if (listId) { - try { - normalizedListId = normalizeListTarget(listId) - } catch (cause) { - error = - cause instanceof InputError - ? cause.message - : 'リスト ID を確認してください。' - } - } - - return ( - -

どのリストを読みますか?

-

- 自分のリストを選ぶか、リストURLを入力してください。 -

- - - navigate({ - to: '/i/lists/$listId', - params: { listId: nextListId }, - }) - } - /> - {error ? ( -

- {error} -

- ) : null} - -
- ) -} diff --git a/src/routes/i.lists_.$listId.tsx b/src/routes/i.lists_.$listId.tsx deleted file mode 100644 index 0f61b99..0000000 --- a/src/routes/i.lists_.$listId.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { ListPage } from './i.lists' - -export const Route = createFileRoute('/i/lists_/$listId')({ - component: ListRoute, -}) - -function ListRoute() { - const { listId } = Route.useParams() - return -} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 192e9a8..300496f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,56 +1,4 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' -import { AppShell } from '#/components/app-shell' -import { PostFeed } from '#/features/posts/components/post-feed' -import { UserForm } from '#/features/posts/components/user-form' -import { InputError, normalizeUserTarget } from '#/features/posts/inputs' +import { createFileRoute } from '@tanstack/react-router' +import { DeckPage } from '#/features/decks/deck-page' -export const Route = createFileRoute('/')({ - component: UserIndexRoute, -}) - -function UserIndexRoute() { - return -} - -export function UserPage({ handle = '' }: { handle?: string }) { - const navigate = useNavigate() - let normalizedHandle: string | undefined - let error: string | undefined - - if (handle) { - try { - normalizedHandle = normalizeUserTarget(handle) - } catch (cause) { - error = - cause instanceof InputError - ? cause.message - : 'ハンドルを確認してください。' - } - } - - return ( - -

誰の投稿を見ますか?

-

履歴やおすすめは表示しません。

- - navigate({ to: '/$handle', params: { handle: nextHandle } }) - } - /> - {error ? ( -

- {error} -

- ) : null} - -
- ) -} +export const Route = createFileRoute('/')({ component: DeckPage }) diff --git a/src/routes/search.tsx b/src/routes/search.tsx deleted file mode 100644 index 2bfb788..0000000 --- a/src/routes/search.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { AppShell } from '#/components/app-shell' -import { PostFeed } from '#/features/posts/components/post-feed' -import { - SearchForm, - type SearchValues, -} from '#/features/posts/components/search-form' -import { - buildFilteredSearchQuery, - postSearchRouteSchema, -} from '#/features/posts/inputs' - -export const Route = createFileRoute('/search')({ - validateSearch: (search) => postSearchRouteSchema.parse(search), - component: SearchRoute, -}) - -function compileQuery(values: SearchValues): string | undefined { - if (!values.q && !values.from) return undefined - try { - return buildFilteredSearchQuery(values, values.following) - } catch { - return undefined - } -} - -function SearchRoute() { - const values = Route.useSearch() - const navigate = Route.useNavigate() - const formValues = { - ...values, - from: values.from ?? '', - since: values.since ?? '', - until: values.until ?? '', - lang: values.lang ?? 'all', - content: values.content ?? 'all', - excludeReplies: values.excludeReplies ?? false, - excludeReposts: values.excludeReposts ?? false, - } satisfies SearchValues - const query = compileQuery(formValues) - const formKey = [ - formValues.q, - formValues.from, - formValues.since, - formValues.until, - formValues.lang, - formValues.content, - formValues.excludeReplies, - formValues.excludeReposts, - formValues.product, - formValues.following, - ].join(':') - return ( - -

何を確認しますか?

-

入力した条件だけを検索します。

- navigate({ search })} - /> - -
- ) -} diff --git a/src/styles.css b/src/styles.css index cd3bccf..041b839 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,8 +1,4 @@ -/* Hallmark · genre: editorial · macrostructure: Workbench · theme: Garden · enrichment: none - * tone: soft · anchor: leaf-green · nav: N3 · footer: none (app shell) - * contrast: pass (40–41) · slop: pass (42–49) · mobile: pass (34, 49–57) - */ -/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5 */ +/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */ @import "../tokens.css"; :root { @@ -18,1442 +14,125 @@ --error: var(--color-error); color: var(--ink); background: var(--canvas); - color-scheme: light; + color-scheme: dark; font-family: var(--font-body); + font-size: 14px; font-synthesis: none; } - * { box-sizing: border-box; } - -html { +html, +body { min-width: 320px; overflow-x: clip; background: var(--canvas); } - body { - min-height: 100vh; margin: 0; - overflow-x: clip; } - button, -input { +input, +select { font: inherit; } - a { color: var(--accent); text-underline-offset: 0.18em; } - a:active { color: var(--accent-hover); } - -a[aria-disabled="true"] { - cursor: not-allowed; - opacity: 0.55; - pointer-events: none; -} - :focus-visible { outline: var(--rule-active) solid var(--color-focus); - outline-offset: var(--space-2xs); + outline-offset: 2px; } - .app { - width: min(46rem, calc(100% - 2rem)); - min-height: calc(100vh - 2rem); - margin: 1rem auto; - overflow: clip; - border: 1px solid var(--border); - border-radius: 1.15rem; - background: var(--surface); - box-shadow: 0 var(--space-lg) var(--space-2xl) var(--color-shadow); + width: 100%; + height: 100dvh; + min-height: 0; + background: var(--canvas); } - -.app-header { - display: flex; - align-items: end; - justify-content: space-between; - gap: 1rem; - padding: 1.2rem 1.4rem 0; - border-bottom: 1px solid var(--border); -} - -.brand { - margin: 0 0 0.85rem; - color: var(--secondary); - font-size: 0.68rem; - font-weight: 750; - letter-spacing: 0.2em; - line-height: 1; -} - -.tabs { - display: flex; - gap: 1.1rem; -} - -.tabs a { - display: inline-flex; - min-height: 2.75rem; - align-items: center; - padding: 0.85rem 0.2rem 0.75rem; - border-radius: 0; - color: var(--secondary); - font-size: 0.9rem; - font-weight: 650; - text-decoration: none; -} - -.tabs a[aria-current="page"] { - border-bottom: 2px solid var(--accent); - color: var(--accent); - font-weight: 750; -} - -.reader { - padding: 2rem 1.4rem 3rem; -} - -.reader > h1 { - max-width: 22ch; - margin: 0; - font-size: clamp(1.45rem, 4vw, 2rem); - font-weight: 700; - letter-spacing: -0.03em; - line-height: 1.25; -} - -.intro { - margin: 0.45rem 0 1.5rem; - color: var(--secondary); - line-height: 1.7; -} - -.list-picker { - margin-bottom: 1rem; - padding: 1rem; - border: 1px solid var(--border); - border-radius: 0.9rem; - background: var(--paper); -} - -.list-picker h2 { - margin: 0 0 0.75rem; - font-size: 0.9rem; - letter-spacing: 0.01em; -} - -.list-picker-state { - margin: 0; - color: var(--secondary); - font-size: 0.84rem; -} - -.list-picker-state p { - margin-top: 0; -} - -.list-choices { +.app-deck { display: grid; - max-height: 22rem; - gap: 0.55rem; - margin: 0; - padding: 0; + grid-template-columns: 188px minmax(0, 1fr); +} +.app-sidebar { + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-lg); + padding: var(--space-md) var(--space-sm); + border-right: var(--rule-hairline) solid var(--border); overflow-y: auto; - list-style: none; - scrollbar-gutter: stable; } - -.list-choices a { - display: grid; - gap: 0.2rem; - padding: 0.75rem; - border: 1px solid var(--border); - border-radius: 0.65rem; +.brand { + display: flex; + align-items: center; + gap: var(--space-xs); + margin: 0; color: var(--ink); + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 750; + letter-spacing: -0.035em; text-decoration: none; + white-space: nowrap; } - -.list-choices a[aria-current="page"] { - border-color: var(--accent); - background: color-mix(in oklch, var(--accent) 7%, var(--paper)); - box-shadow: inset 3px 0 var(--accent); +svg { + flex-shrink: 0; } - -.list-choices span { - font-weight: 750; -} - -.list-choices small { - color: var(--secondary); - font-size: 0.74rem; -} - -.list-choices p { - margin: 0.2rem 0 0; - color: var(--secondary); - font-size: 0.78rem; - line-height: 1.5; -} - -.intent-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.85rem; - align-items: end; - padding: 1rem; - border: 1px solid var(--border); - border-radius: 0.9rem; - background: color-mix(in oklch, var(--canvas) 45%, var(--surface)); -} - -.field { - display: grid; - gap: 0.4rem; - min-width: 0; - color: var(--secondary); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.02em; -} - -.field input { - width: 100%; - min-width: 0; - min-height: 2.75rem; - padding: 0.7rem 0.85rem; - border: 1px solid var(--control-border); - border-radius: 0.55rem; - background: var(--paper); - color: var(--ink); -} - -.field input::placeholder { - color: var(--secondary); - opacity: 1; - font-weight: 400; - letter-spacing: 0; -} - -.field input[aria-invalid="true"] { - border-color: var(--error); -} - -.field-error { - color: var(--error); - font-size: 0.76rem; - font-weight: 650; - letter-spacing: 0; -} - -.intent-form button, -.list-picker-state button, -.state button { - min-height: 2.75rem; - padding: 0.65rem 1rem; - border: 1px solid var(--accent); - border-radius: 0.55rem; - background: var(--accent); - color: var(--paper); - cursor: pointer; - font-weight: 750; -} - -.segmented { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - min-width: 0; - margin: 0; - padding: 0; - border: 0; -} - -.segmented legend { - width: 100%; - margin-bottom: 0.4rem; - color: var(--secondary); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.02em; -} - -.segmented label { - position: relative; - display: inline-flex; - min-height: 2.75rem; - align-items: center; - padding: 0.55rem 0.75rem; - border: 1px solid var(--control-border); - border-radius: 0.55rem; - background: var(--surface); - cursor: pointer; - font-size: 0.84rem; - font-weight: 650; -} - -.segmented input { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; -} - -.segmented label:has(input:checked) { - border-color: var(--accent); - background: var(--accent); - color: var(--paper); -} - -.segmented label:has(input:focus-visible) { - outline: 3px solid var(--accent); - outline-offset: 3px; -} - -.check { - display: flex; - min-height: 2.75rem; - align-items: center; - gap: 0.5rem; - color: var(--ink); - font-size: 0.84rem; - font-weight: 650; -} - -.check input { - width: 1.1rem; - height: 1.1rem; - margin: 0; - accent-color: var(--accent); -} - -.intent-form:has(.segmented) { - grid-template-columns: minmax(0, 1fr) auto auto; -} - -.intent-form:has(.segmented) .field { - grid-column: 1 / -1; -} - -.focal-post { - display: grid; - gap: 0.5rem; - margin-top: 1.4rem; -} - -.focal-label { - margin: 0; +.brand svg { color: var(--accent); - font-size: 0.72rem; - font-weight: 750; - letter-spacing: 0.08em; + flex-shrink: 0; } - -.feed { - display: grid; - gap: 0.85rem; - margin-top: 1.4rem; -} - -.post { - min-width: 0; - padding: 1rem; - border: 1px solid var(--border); - border-radius: 0.9rem; - background: var(--paper); -} - -.current-post { - border-color: var(--accent); - box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 25%, transparent); -} - -.post-header { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 0.65rem; - align-items: center; -} - -.post-header > div { - min-width: 0; - overflow-wrap: anywhere; -} - -.avatar { - border-radius: 50%; -} - -.handle, -.post-header time { - display: block; - color: var(--secondary); - font-size: 0.76rem; - font-variant-numeric: tabular-nums; -} - -.post-header time { - text-align: right; -} - -.post-text { - margin: 0.9rem 0; - overflow-wrap: anywhere; - line-height: 1.75; - white-space: pre-wrap; -} - -.post a { - color: var(--accent); -} - -.media-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.4rem; - margin-top: 0.8rem; - overflow: hidden; - border-radius: 0.75rem; - background: color-mix(in oklch, var(--canvas) 45%, var(--surface)); -} - -.media-grid > :only-child { - grid-column: 1 / -1; -} - -.media { - display: block; - width: 100%; - max-height: 32rem; - background: color-mix(in oklch, var(--canvas) 45%, var(--surface)); -} - -img.media { - height: auto; - object-fit: cover; -} - -.media-grid > img:only-child { - object-fit: contain; -} - -video.media { - aspect-ratio: 16 / 9; - object-fit: contain; -} - -.article-card, -.quote { - margin-top: 0.8rem; - padding: 0.85rem; - overflow-wrap: anywhere; - border: 1px solid var(--border); - border-radius: 0.75rem; - background: var(--surface); -} - -.article-card p { +.sidebar-note { + margin-top: auto; margin-bottom: 0; color: var(--secondary); + font-size: var(--text-xs); line-height: 1.6; } - -.post-footer { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 0.75rem 1rem; - margin-top: 1rem; - color: var(--secondary); - font-size: 0.74rem; -} - -.post-footer a { - display: inline-flex; - min-height: 1.5rem; - align-items: center; - font-weight: 650; -} - -.counts { - font-variant-numeric: tabular-nums; -} - -.state { - margin: 1.4rem 0 0; - padding: 1rem; - color: var(--secondary); - text-align: center; -} - -.state p { - margin-top: 0; -} - -[role="alert"].state { - color: var(--error); -} - -.loading-rail { - position: relative; - padding: 0.8rem; - color: var(--secondary); - font-size: 0.78rem; - text-align: center; -} - -.loading-rail::after { - position: absolute; - right: 0; - bottom: 0; - left: 0; - height: 3px; - background: linear-gradient( - 90deg, - transparent 0%, - var(--accent) 45%, - transparent 100% - ); - content: ""; - animation: rail 1.1s var(--ease-in-out) infinite; -} - -@keyframes rail { - 0%, - 100% { - opacity: 0.25; - transform: scaleX(0.35); - } - - 50% { - opacity: 1; - transform: scaleX(1); - } -} - -@media (hover: hover) and (pointer: fine) { - a:hover { - color: var(--accent-hover); - } - - .intent-form button:hover, - .list-picker-state button:hover, - .state button:hover { - border-color: var(--accent-hover); - background: var(--accent-hover); - } - - .segmented label:hover { - border-color: var(--accent); - } - - .segmented label:has(input:checked):hover { - border-color: var(--accent-hover); - background: var(--accent-hover); - } - - .list-choices a:hover { - border-color: var(--accent); - color: var(--ink); - } -} - -@media (max-width: 42rem) { - .app { - width: 100%; - min-height: 100vh; - margin: 0; - border-right: 0; - border-left: 0; - border-radius: 0; - } - - .app-header, - .reader { - padding-right: 1rem; - padding-left: 1rem; - } - - .tabs { - gap: 0.7rem; - } - - .intent-form, - .intent-form:has(.segmented) { - grid-template-columns: 1fr; - } - - .intent-form button { - width: 100%; - } - - .post-header { - grid-template-columns: auto minmax(0, 1fr); - } - - .post-header time { - grid-column: 2; - text-align: left; - } - - .media-grid { - grid-template-columns: minmax(0, 1fr); - } - - .post-footer { - align-items: flex-start; - flex-direction: column; - } -} - -.quote .post-header time { - display: none; -} - -@media (prefers-reduced-motion: reduce) { - .loading-rail::after { - animation: none; - } -} - -/* Workbench layout: the routes are tools on a quiet side rail. */ -.app { - display: grid; - grid-template-rows: auto minmax(0, 1fr); - width: 100%; - min-height: 100svh; - margin: 0; - overflow: visible; - border: 0; - border-radius: 0; - background: var(--paper); - box-shadow: none; -} - -.app-header { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(8rem, 9.5rem); - align-items: start; - gap: var(--space-lg) var(--space-md); - padding: var(--space-lg) var(--space-md) 0; - border-bottom: var(--rule-hairline) solid var(--border); - background: var(--paper); - color: var(--ink); -} - -.brand { - margin: 0; - color: var(--ink); - font-family: var(--font-display); - font-size: var(--text-md); - font-style: normal; - font-weight: 700; - letter-spacing: -0.02em; - line-height: 1.2; -} - -.reader > .reader-title-long { - font-size: var(--text-display-long); - white-space: nowrap; -} - -.tabs { - display: grid; - grid-column: 1 / -1; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0; -} - -.tabs a { - min-height: 2.75rem; - justify-content: center; - padding: var(--space-sm) var(--space-xs); - border-bottom: var(--rule-active) solid transparent; - color: var(--secondary); - font-size: var(--text-sm); - font-weight: 700; - white-space: nowrap; - transition: - color var(--dur-short) var(--ease-out), - background-color var(--dur-short) var(--ease-out); -} - -.tabs a[aria-current="page"] { - border-bottom-color: var(--accent); - color: var(--accent); - font-weight: 700; -} - -/* Hallmark · component: select · genre: editorial · theme: Garden - * states: default · hover · focus · active · disabled · loading · error · success - * contrast: pass - */ -.profile-switcher { - position: relative; - display: grid; - width: 100%; - min-width: 0; - gap: var(--space-2xs); -} - -.profile-switcher label { - color: var(--secondary); - font-size: var(--text-xs); - font-weight: 700; - line-height: 1; - white-space: nowrap; -} - -.profile-switcher select { - width: 100%; - min-height: 2.75rem; - padding: var(--space-xs) 1.8rem var(--space-xs) var(--space-sm); - border: var(--rule-hairline) solid var(--control-border); - border-radius: var(--radius-sm); - outline: var(--rule-active) solid transparent; - outline-offset: var(--space-3xs); - background: var(--paper); - color: var(--ink); - cursor: pointer; - font: inherit; - font-size: var(--text-sm); - text-overflow: ellipsis; -} - -.profile-switcher select:active { - background: var(--color-paper-3); -} - -.profile-switcher select:disabled { - cursor: not-allowed; - opacity: 0.55; -} - -.profile-switcher[data-state="error"] select { - border-color: var(--error); -} - -.profile-switcher[data-state="success"] select { - border-color: var(--color-success); -} - -.profile-switcher-status { - position: absolute; - inset-block-start: 100%; - inset-inline-end: 0; - color: var(--error); - font-size: var(--text-xs); - white-space: nowrap; -} - -.profile-switcher[data-state="success"] .profile-switcher-status { - color: var(--color-success); -} - .reader { - width: 100%; min-width: 0; - padding: var(--space-xl) var(--space-md) var(--space-2xl); + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; } - -.reader > h1 { - max-width: 18ch; - min-width: 0; +h1 { margin: 0; - overflow-wrap: anywhere; - color: var(--ink); + min-width: 0; font-family: var(--font-display); font-size: var(--text-display-s); - font-style: normal; font-weight: 700; - letter-spacing: -0.035em; - line-height: 1.2; + line-height: 1.3; + overflow-wrap: anywhere; } - -.intro { - max-width: 46ch; - margin: var(--space-sm) 0 var(--space-xl); +.handle { + display: block; color: var(--secondary); - font-size: var(--text-base); - line-height: 1.7; -} - -.intent-form { - gap: var(--space-md); - align-items: end; - padding: var(--space-lg); - border: 0; - border-block: var(--rule-hairline) solid var(--border); - border-radius: 0; - background: var(--surface); - color: var(--ink); -} - -.field { - gap: 0; - grid-template-rows: auto auto minmax(1lh, auto); - color: var(--secondary); - font-size: var(--text-sm); - font-weight: 700; - letter-spacing: 0; -} - -.field > label:first-child, -.field > span:first-child { - margin-bottom: var(--space-xs); -} - -.field input { - min-height: 2.75rem; - padding: var(--space-sm) var(--space-md); - border: var(--rule-hairline) solid var(--control-border); - border-radius: var(--radius-sm); - outline: var(--rule-active) solid transparent; - outline-offset: var(--space-3xs); - background: var(--paper); - color: var(--ink); - transition: background-color var(--dur-short) var(--ease-out); -} - -.field input::placeholder { - color: var(--secondary); -} - -.field input:focus-visible { - border-color: var(--control-border); - outline-color: var(--color-focus); -} - -.field input[aria-invalid="true"] { - border-color: var(--error); -} - -.field input:disabled { - border-color: var(--border); - background: var(--color-paper-3); - cursor: not-allowed; - opacity: 0.55; -} - -.field-error { - min-height: 1lh; - margin-top: var(--space-2xs); - color: var(--error); - font-size: var(--text-sm); -} - -.intent-form button, -.list-picker-state button, -.state button { - min-height: 2.75rem; - padding: var(--space-sm) var(--space-lg); - border: var(--rule-hairline) solid var(--accent); - border-radius: var(--radius-sm); - background: var(--paper); - color: var(--accent); - cursor: pointer; - font-weight: 700; - white-space: nowrap; - transition: - background-color var(--dur-short) var(--ease-out), - color var(--dur-short) var(--ease-out), - transform var(--dur-instant) var(--ease-out); -} - -.intent-form button:active, -.list-picker-state button:active, -.state button:active { - transform: translateY(var(--rule-hairline)); -} - -button:disabled, -button[aria-disabled="true"] { - cursor: not-allowed; - opacity: 0.55; -} - -button[aria-busy="true"] { - cursor: progress; - opacity: 0.7; -} - -button[data-state="error"] { - border-color: var(--error); - color: var(--error); -} - -button[data-state="success"] { - border-color: var(--color-success); - color: var(--color-success); -} - -.segmented { - gap: var(--space-xs); -} - -.segmented legend { - margin-bottom: var(--space-xs); - color: var(--secondary); - font-size: var(--text-sm); - letter-spacing: 0; -} - -.segmented label { - min-height: 2.75rem; - padding: var(--space-sm) var(--space-md); - border: var(--rule-hairline) solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--paper); - color: var(--ink); - font-size: var(--text-sm); - transition: - background-color var(--dur-short) var(--ease-out), - color var(--dur-short) var(--ease-out); -} - -.segmented label:has(input:checked) { - border-color: var(--accent); - background: var(--accent); - color: var(--color-accent-ink); -} - -.segmented label:has(input:focus-visible) { - outline: var(--rule-active) solid var(--color-focus); - outline-offset: var(--space-3xs); -} - -.segmented label:active { - background: var(--color-paper-3); -} - -.segmented label:has(input:disabled) { - cursor: not-allowed; - opacity: 0.55; -} - -.check { - min-height: 2.75rem; - gap: var(--space-xs); - color: var(--ink); - font-size: var(--text-sm); - white-space: nowrap; -} - -/* Hallmark · component: search filter builder · genre: editorial · theme: Garden - * states: default · hover · focus · active · disabled · loading · error · success - * contrast: pass (40–41) - */ -.search-query, -.search-filter-buttons, -.search-filter-panel, -.active-filters, -.search-query-preview, -.search-error { - grid-column: 1 / -1; -} - -.field-help { - min-height: 1lh; - margin-top: var(--space-2xs); - color: var(--secondary); - font-size: var(--text-sm); - font-weight: 400; -} - -.search-filter-buttons, -.active-filters { - display: flex; - flex-wrap: wrap; - gap: var(--space-xs); -} - -.search-filter-buttons { - min-width: 0; - margin: 0; - padding: 0; - border: 0; -} - -.search-filter-buttons legend, -.search-filter-panel > legend { - width: 100%; - margin-bottom: var(--space-xs); - color: var(--secondary); - font-size: var(--text-sm); - font-weight: 700; -} - -.search-filter-buttons .filter-button, -.active-filters button, -.search-filter-options > button { - min-height: 2.75rem; - padding: var(--space-xs) var(--space-sm); - border-color: var(--control-border); - background: var(--paper); - color: var(--ink); - font-size: var(--text-sm); -} - -.search-filter-buttons .filter-button { - display: inline-flex; - align-items: center; - gap: var(--space-xs); -} - -.search-filter-buttons .filter-button span { - color: var(--accent); font-size: var(--text-xs); } - -.search-filter-buttons .filter-button[aria-expanded="true"], -.search-filter-buttons .filter-button[data-active="true"], -.active-filters button { - border-color: var(--accent); -} - -.search-filter-panel { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--space-md); - min-width: 0; - margin: 0; - padding: var(--space-md); - border-block: var(--rule-hairline) solid var(--border); - background: var(--color-paper-3); - color: var(--ink); -} - -.search-filter-panel .field input, -.search-filter-panel .segmented label, -.search-filter-panel button { - background: var(--paper); -} - -.compact-field { - width: 100%; -} - -.date-fields { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--space-md); - min-width: 0; -} - -.search-filter-options, -.search-exclusions { - display: flex; - flex-wrap: wrap; - gap: var(--space-xs); - min-width: 0; - margin: 0; - padding: 0; - border: 0; -} - -.search-filter-options legend, -.search-exclusions legend { - width: 100%; - margin-bottom: var(--space-xs); - color: var(--secondary); - font-size: var(--text-sm); - font-weight: 700; -} - -.active-filters { - min-height: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.active-filters:empty { - display: none; -} - -.active-filters button { - color: var(--accent); -} - -.search-query-preview { - min-width: 0; - color: var(--secondary); - font-size: var(--text-sm); -} - -.search-query-preview summary { - min-height: 2.75rem; - width: fit-content; - padding-block: var(--space-sm); - cursor: pointer; - color: var(--accent); - font-weight: 700; - white-space: nowrap; -} - -.search-query-preview code { - display: block; - max-width: 100%; - padding: var(--space-sm) var(--space-md); - overflow-wrap: anywhere; - border-left: var(--rule-active) solid var(--border); - background: var(--paper); - color: var(--ink); - font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: 1.6; -} - -.search-error { - margin: 0; -} - -.list-picker { - margin-bottom: var(--space-xl); - padding: var(--space-lg); - border: 0; - border-block: var(--rule-hairline) solid var(--border); - border-radius: 0; - background: var(--surface); - color: var(--ink); -} - -.list-picker h2 { - margin: 0 0 var(--space-md); - font-family: var(--font-display); - font-size: var(--text-md); - font-style: normal; - letter-spacing: -0.02em; -} - -.list-picker-state { - color: var(--secondary); - font-size: var(--text-sm); -} - -.list-choices { - gap: 0; -} - -.list-choices a { - gap: var(--space-2xs); - padding: var(--space-md) var(--space-sm); - border: 0; - border-bottom: var(--rule-hairline) solid var(--border); - border-radius: 0; - background: transparent; - color: var(--ink); -} - -.list-choices li:first-child a { - border-top: var(--rule-hairline) solid var(--border); -} - -.list-choices a[aria-current="page"] { - border-color: var(--border); - background: var(--color-paper-3); - box-shadow: inset var(--rule-active) 0 var(--accent); -} - -.list-choices small, -.list-choices p { - color: var(--secondary); - font-size: var(--text-sm); -} - -.focal-post, -.feed { - gap: 0; - margin-top: var(--space-xl); -} - -.focal-label { - margin: 0 0 var(--space-sm); - color: var(--accent); - font-size: var(--text-sm); - letter-spacing: 0; -} - -.feed { - border-top: var(--rule-hairline) solid var(--border); -} - -.post { - min-width: 0; - padding: var(--space-lg) 0; - border: 0; - border-bottom: var(--rule-hairline) solid var(--border); - border-radius: 0; - background: transparent; - color: var(--ink); -} - -.current-post { - padding-inline: var(--space-lg); - border: var(--rule-hairline) solid var(--accent); - background: var(--paper); - box-shadow: none; -} - -.post-header { - gap: var(--space-sm); -} - -.avatar { - border-radius: 50%; -} - -.handle, -.post-header time { - color: var(--secondary); - font-size: var(--text-sm); -} - -.post-text { - margin: var(--space-md) 0; - font-size: var(--text-base); - line-height: 1.8; -} - -.media-grid { - gap: var(--space-xs); - margin-top: var(--space-md); - border-radius: var(--radius-md); - background: var(--surface); -} - -.media { - background: var(--surface); -} - -.article-card, -.quote { - margin-top: var(--space-md); - padding: var(--space-md) 0 var(--space-md) var(--space-lg); - border: 0; - border-left: var(--rule-active) solid var(--border); - border-radius: 0; - background: transparent; - color: var(--ink); -} - -.article-card p { - color: var(--secondary); -} - -.post-footer { - gap: var(--space-sm) var(--space-lg); - margin-top: var(--space-lg); - color: var(--secondary); - font-size: var(--text-sm); -} - -.post-footer a { - min-height: 2.75rem; - font-weight: 700; - white-space: nowrap; -} - -.post-actions { - display: flex; - width: 100%; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--space-sm) var(--space-lg); - margin-inline-start: auto; -} - -.state { - margin: var(--space-xl) 0 0; - padding: var(--space-lg); - color: var(--secondary); -} - -.loading-rail { - padding: var(--space-md); - color: var(--secondary); - font-size: var(--text-sm); -} - -.loading-rail::after { - height: var(--rule-active); - background: var(--accent); -} - -@media (hover: hover) and (pointer: fine) { - .tabs a:hover { - background: var(--surface); - color: var(--accent-hover); +@media (max-width: 767px) { + .app-deck { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); } - - .profile-switcher select:not(:disabled):hover { - background: var(--color-paper-2); - } - - .field input:hover { - background: var(--surface); - } - - .intent-form button:hover, - .list-picker-state button:hover, - .state button:hover { - border-color: var(--accent-hover); - background: var(--accent); - color: var(--color-accent-ink); - } - - .segmented label:hover { - border-color: var(--control-border); - background: var(--surface); - } - - .segmented label:has(input:checked):hover { - border-color: var(--accent-hover); - background: var(--accent-hover); - color: var(--color-accent-ink); - } - - .search-filter-buttons .filter-button:hover, - .active-filters button:hover, - .search-filter-options > button:hover { - border-color: var(--accent); - background: var(--surface); - color: var(--ink); - } - - .active-filters button:hover { - color: var(--accent-hover); - } - - .list-choices a:hover { - border-color: var(--border); - background: var(--color-paper-3); - color: var(--ink); - } -} - -@media (min-width: 40rem) { - .intent-form { - grid-template-columns: minmax(0, 1fr) auto; - } - - .intent-form:has(.segmented) { - grid-template-columns: minmax(0, 1fr) auto auto; - } - - .intent-form:has(.segmented) .field { - grid-column: 1 / -1; - } - - .search-form .compact-field { - grid-column: auto; - } - - .date-fields { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .intent-form:not(:has(.segmented)) > button { - align-self: start; - margin-top: calc(1lh + var(--space-xs)); - } - - .media-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .post-header { - grid-template-columns: auto minmax(0, 1fr) auto; - } - - .post-header time { - grid-column: auto; - text-align: right; - } - - .post-footer { - align-items: center; + .app-sidebar { flex-direction: row; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs); + overflow: visible; + border-right: 0; + border-bottom: var(--rule-hairline) solid var(--border); } - - .post-actions { - width: auto; + .brand { + font-size: var(--text-sm); } -} - -@media (min-width: 52rem) { - body { - padding-block: var(--space-lg); - padding-inline: var(--space-xl); - } - - .app { - grid-template-rows: auto 1fr; - grid-template-columns: 13.5rem minmax(0, 1fr); - width: min(74rem, 100%); - min-height: calc(100svh - (var(--space-lg) * 2)); - margin: 0 auto; - border: var(--rule-hairline) solid var(--border); - border-radius: var(--radius-lg); - box-shadow: 0 var(--space-lg) var(--space-2xl) var(--color-shadow); - } - - .app-header { - position: sticky; - top: var(--space-lg); - grid-template-columns: minmax(0, 1fr); - grid-row: 1; - grid-column: 1; - align-self: start; - gap: var(--space-xl); - padding: var(--space-xl) var(--space-lg); - border-right: var(--rule-hairline) solid var(--border); - border-bottom: 0; - border-radius: var(--radius-lg) 0 0 0; - } - - .tabs { - grid-column: 1; - grid-template-columns: minmax(0, 1fr); - } - - .tabs a { - justify-content: flex-start; - padding-inline: var(--space-md); - border-bottom: 0; - border-left: var(--rule-active) solid transparent; - } - - .tabs a[aria-current="page"] { - border-bottom: 0; - border-left-color: var(--accent); - background: var(--surface); - } - - .reader { - grid-row: 1 / span 2; - grid-column: 2; - max-width: none; - padding: var(--space-2xl) clamp(var(--space-xl), 6vw, var(--space-2xl)) - var(--space-3xl); - } -} - -@media (prefers-reduced-motion: reduce) { - .tabs a, - .field input, - .intent-form button, - .list-picker-state button, - .state button, - .segmented label { - transition-duration: var(--dur-instant); - transition-property: opacity; + .brand span, + .sidebar-note { + display: none; } } diff --git a/tokens.css b/tokens.css index 21683a6..5c172e5 100644 --- a/tokens.css +++ b/tokens.css @@ -1,29 +1,28 @@ -/* Hallmark · macrostructure: Workbench · tone: soft · anchor hue: leaf-green */ -/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5 */ +/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */ +/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */ :root { - --color-canvas: oklch(93% 0.018 125); - --color-paper: oklch(98% 0.01 100); - --color-paper-2: oklch(95% 0.018 115); - --color-paper-3: oklch(90% 0.026 125); - --color-ink: oklch(23% 0.025 145); - --color-ink-2: oklch(32% 0.03 145); - --color-muted: oklch(42% 0.026 145); - --color-accent: oklch(38% 0.09 145); - --color-accent-hover: oklch(31% 0.085 145); - --color-accent-ink: oklch(98% 0.01 100); - --color-rule: oklch(80% 0.025 125); - --color-control-rule: oklch(55% 0.035 135); - --color-focus: oklch(45% 0.15 65); - --color-error: oklch(42% 0.13 25); - --color-success: oklch(38% 0.1 145); - --color-shadow: oklch(23% 0.025 145 / 10%); - - --font-display: - "YuMincho", "Hiragino Mincho ProN", "Noto Serif JP", ui-serif, serif; + --color-canvas: oklch(19% 0.021 250); + --color-paper: oklch(24% 0.025 250); + --color-paper-2: oklch(28% 0.027 250); + --color-paper-3: oklch(33% 0.031 250); + --color-ink: oklch(95% 0.005 250); + --color-ink-2: oklch(85% 0.015 250); + --color-muted: oklch(72% 0.025 250); + --color-accent: oklch(72% 0.15 240); + --color-accent-hover: oklch(79% 0.13 240); + --color-accent-ink: oklch(18% 0.03 250); + --color-rule: oklch(34% 0.023 250); + --color-control-rule: oklch(47% 0.029 250); + --color-focus: oklch(79% 0.13 240); + --color-error: oklch(78% 0.13 20); + --color-success: oklch(78% 0.13 155); + --color-shadow: oklch(10% 0.015 250 / 45%); + --color-overlay: oklch(10% 0.015 250 / 70%); --font-body: - "Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", ui-sans-serif, sans-serif; + -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", + "Noto Sans JP", sans-serif; + --font-display: var(--font-body); --font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace; - --space-3xs: 0.125rem; --space-2xs: 0.25rem; --space-xs: 0.5rem; @@ -33,26 +32,23 @@ --space-xl: 2.5rem; --space-2xl: 4rem; --space-3xl: 6rem; - - --text-xs: 0.75rem; - --text-sm: 0.875rem; + --text-xs: 0.8571rem; + --text-sm: 1rem; --text-base: 1rem; - --text-md: 1.25rem; - --text-lg: 1.5625rem; - --text-xl: 1.9531rem; - --text-display-s: clamp(1.75rem, 7.4vw + 0.25rem, 2.75rem); - --text-display-long: clamp(1.5rem, 5.5vw + 0.4rem, 2.75rem); - + --text-md: 1.125rem; + --text-lg: 1.25rem; + --text-xl: 1.5rem; + --text-display-s: 1.125rem; + --text-display-long: 1.125rem; --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in: cubic-bezier(0.7, 0, 0.84, 0); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); --dur-instant: 100ms; - --dur-short: 200ms; - --dur-medium: 420ms; - + --dur-short: 150ms; + --dur-medium: 300ms; --rule-hairline: 1px; --rule-active: 2px; - --radius-sm: 0.375rem; - --radius-md: 0.625rem; - --radius-lg: 1rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; }