Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc052c354 | ||
|
|
9daf6e71dc | ||
|
|
5406855e1c | ||
|
|
8eb299f46d | ||
|
|
079aaa1664 | ||
|
|
67ee53c305 | ||
|
|
70cacc2c2d | ||
|
|
1794255ebb | ||
|
|
24939e6f00 | ||
|
|
f5274195b9 | ||
|
|
8679a850e5 |
@@ -1,5 +1,6 @@
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
/.*/skills/
|
||||
.env*
|
||||
!.env.example
|
||||
.output/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-07-26",
|
||||
"macrostructure": "Workbench",
|
||||
"theme": "Garden",
|
||||
"enrichment": "none",
|
||||
"brief": "Personal read-only research client with a soft, deliberate workspace"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"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",
|
||||
"framework": "React 19 + TanStack Start + Vite 8",
|
||||
"preserve": ["routes", "information architecture", "read-only behavior"]
|
||||
}
|
||||
@@ -8,13 +8,16 @@ deliberate post detail link, or manually open a valid
|
||||
## 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
|
||||
|
||||
It intentionally has no home feed, recommendations, trends, notifications,
|
||||
history, account directory, or write actions.
|
||||
@@ -26,7 +29,9 @@ they do not add related-post recommendations or an account-discovery surface.
|
||||
|
||||
- `/` shows the empty user input.
|
||||
- `/:handle` shows a user timeline.
|
||||
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` shows search results.
|
||||
- `/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.
|
||||
@@ -42,9 +47,14 @@ nix develop -c pnpm install --frozen-lockfile
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set
|
||||
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same
|
||||
values in the process environment before `start` or `test:live`:
|
||||
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`:
|
||||
|
||||
```bash
|
||||
export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
|
||||
@@ -59,6 +69,7 @@ sent to the browser.
|
||||
```bash
|
||||
nix develop -c pnpm dev
|
||||
nix develop -c pnpm dev:tailscale
|
||||
nix develop -c pnpm generate:e2e-openapi
|
||||
nix develop -c pnpm knip
|
||||
nix develop -c pnpm test
|
||||
nix develop -c pnpm test:e2e
|
||||
@@ -68,8 +79,41 @@ nix develop -c pnpm start
|
||||
```
|
||||
|
||||
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
|
||||
the dev shell. `test:live` is an explicit, opt-in smoke test that performs
|
||||
exactly one read-only Top search using the configured relay.
|
||||
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.
|
||||
|
||||
## WebMCP prototype
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
[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.
|
||||
|
||||
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
|
||||
|
||||
@@ -175,10 +175,14 @@ None of these functions exposes the Bird client, arbitrary method names, arbitra
|
||||
|
||||
### Search Flow
|
||||
|
||||
1. Trim and validate the raw query.
|
||||
2. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
|
||||
3. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
|
||||
4. Return normalized tweets and the next cursor.
|
||||
1. Keep the raw query and structured author, period, language, content, and
|
||||
exclusion controls in typed URL search parameters.
|
||||
2. Compile selected controls into a deterministic advanced search expression
|
||||
using `from:`, `since:`, `until:`, `lang:`, and `filter:` operators.
|
||||
3. Trim and validate the compiled query, including its 512-character limit.
|
||||
4. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
|
||||
5. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
|
||||
6. Return normalized tweets and the next cursor.
|
||||
|
||||
Advanced X search grammar remains available because the raw query is otherwise passed unchanged.
|
||||
|
||||
@@ -207,7 +211,11 @@ The selected layout is **Focused Tabs**:
|
||||
- no content on first load;
|
||||
- results directly below the submitted controls.
|
||||
|
||||
The User tab contains one target input and a Display button. The Search tab contains a query input, a Popular/Latest segmented control, a Following only checkbox, and a Search button.
|
||||
The User tab contains one target input and a Display button. The Search tab
|
||||
contains a query input; buttons that open inline author, period, language,
|
||||
content-type, and exclusion controls; removable active-condition chips; a
|
||||
collapsible compiled-query preview; a Popular/Latest segmented control; a
|
||||
Following only checkbox; and a Search button.
|
||||
|
||||
Submitting a user target normalizes it to a handle and navigates to `/:handle`.
|
||||
Submitting a search updates the route's typed search parameters. Changing tabs
|
||||
@@ -344,7 +352,9 @@ The work is complete when:
|
||||
target or query, following a post detail link, or manually opening a valid
|
||||
status URL.
|
||||
3. A valid handle or profile URL loads that user's posts.
|
||||
4. Search supports Top, Latest, and the follows filter.
|
||||
4. Search supports Top, Latest, the follows filter, and button-driven author,
|
||||
period, language, content-type, reply, and repost conditions while preserving
|
||||
raw advanced search syntax.
|
||||
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
|
||||
6. Relay credentials and Bird remain server-only.
|
||||
7. No excluded discovery or mutation feature is reachable.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# WebMCP prototype
|
||||
|
||||
## Scope and design
|
||||
|
||||
Expose the existing intentional reader to agents through three React-owned
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
## Tool contracts
|
||||
|
||||
### `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 |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
|
||||
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.
|
||||
|
||||
### `get_loaded_posts`
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### `load_more_posts`
|
||||
|
||||
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`.
|
||||
|
||||
## Results and errors
|
||||
|
||||
Successful tool results contain JSON in an MCP text content block:
|
||||
|
||||
- `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.
|
||||
|
||||
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
|
||||
|
||||
```sh
|
||||
nix develop -c pnpm test
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
@@ -0,0 +1,12 @@
|
||||
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([])
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
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([])
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
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([])
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
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([])
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
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([])
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
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<Fixtures>({
|
||||
a11y: async ({ page }, use) => {
|
||||
const makeAxeBuilder = () =>
|
||||
new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.disableRules(['meta-viewport'])
|
||||
|
||||
await use(makeAxeBuilder)
|
||||
},
|
||||
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'
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TweetLegacy } from '../generated/twitter-openapi/client.ts'
|
||||
|
||||
interface TweetLegacyFixtureOptions {
|
||||
conversationId?: string
|
||||
inReplyTo?: string
|
||||
replyCount?: number
|
||||
}
|
||||
|
||||
export const tweetLegacyFixture = (
|
||||
id: string,
|
||||
text: string,
|
||||
{
|
||||
conversationId = id,
|
||||
inReplyTo,
|
||||
replyCount = 1,
|
||||
}: TweetLegacyFixtureOptions = {},
|
||||
): TweetLegacy => ({
|
||||
bookmark_count: 0,
|
||||
bookmarked: false,
|
||||
conversation_id_str: conversationId,
|
||||
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
||||
display_text_range: [0, text.length],
|
||||
favorite_count: 3,
|
||||
favorited: false,
|
||||
full_text: text,
|
||||
id_str: id,
|
||||
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
|
||||
is_quote_status: false,
|
||||
lang: 'ja',
|
||||
quote_count: 0,
|
||||
reply_count: replyCount,
|
||||
retweet_count: 2,
|
||||
retweeted: false,
|
||||
user_id_str: '42',
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
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.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()
|
||||
})
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,254 @@
|
||||
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<string | null>
|
||||
}
|
||||
|
||||
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,
|
||||
args: ['--enable-blink-features=WebMCP,WebMCPTesting'],
|
||||
},
|
||||
})
|
||||
|
||||
async function toolNames(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
(
|
||||
navigator as Navigator & { modelContextTesting: NativeTesting }
|
||||
).modelContextTesting
|
||||
.listTools()
|
||||
.map((tool) => tool.name)
|
||||
.sort(),
|
||||
)
|
||||
}
|
||||
|
||||
async function executeTool(
|
||||
page: Page,
|
||||
name: string,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<ToolResult> {
|
||||
const result = await page.evaluate(
|
||||
({ name, input }) =>
|
||||
(
|
||||
navigator as Navigator & { modelContextTesting: NativeTesting }
|
||||
).modelContextTesting.executeTool(name, JSON.stringify(input)),
|
||||
{ name, input },
|
||||
)
|
||||
expect(result, `${name} returned a result`).not.toBeNull()
|
||||
if (result === null) throw new Error(`${name} returned no result`)
|
||||
return JSON.parse(result)
|
||||
}
|
||||
|
||||
async function executeFeedTool(
|
||||
page: Page,
|
||||
name: string,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<FeedResult> {
|
||||
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 ?? '')
|
||||
}
|
||||
|
||||
let pageErrors: string[]
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { tweetLegacyFixture } from './fixtures/twitter.ts'
|
||||
|
||||
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT
|
||||
const port = Number(portValue)
|
||||
@@ -11,10 +12,12 @@ const failedRetryQueries = new Set()
|
||||
const rootByTarget = new Map([
|
||||
['1001', '1000'],
|
||||
['2001', '2000'],
|
||||
['3001', '3000'],
|
||||
])
|
||||
const targetByRoot = new Map([
|
||||
['1000', '1001'],
|
||||
['2000', '2001'],
|
||||
['3000', '3001'],
|
||||
])
|
||||
const failedThreadRoots = new Set()
|
||||
|
||||
@@ -25,18 +28,14 @@ const tweetResult = (
|
||||
id,
|
||||
text,
|
||||
username = 'yuta',
|
||||
{ conversationId = id, inReplyTo } = {},
|
||||
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
|
||||
) => ({
|
||||
rest_id: id,
|
||||
legacy: {
|
||||
full_text: text,
|
||||
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
||||
reply_count: 1,
|
||||
retweet_count: 2,
|
||||
favorite_count: 3,
|
||||
conversation_id_str: conversationId,
|
||||
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
|
||||
},
|
||||
legacy: tweetLegacyFixture(id, text, {
|
||||
conversationId,
|
||||
inReplyTo,
|
||||
replyCount,
|
||||
}),
|
||||
core: {
|
||||
user_results: {
|
||||
result: {
|
||||
@@ -97,6 +96,11 @@ const timeline = (entries) => ({ instructions: [{ entries }] })
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
|
||||
if (request.method === 'GET' && url.pathname === '/profiles') {
|
||||
send(response, { profiles: ['e2e', 'alternate'] })
|
||||
return
|
||||
}
|
||||
|
||||
const operation = [
|
||||
'UserByScreenName',
|
||||
'UserTweets',
|
||||
@@ -118,7 +122,8 @@ const server = createServer(async (request, response) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (request.headers['x-profile-name'] !== 'e2e') {
|
||||
const profileName = request.headers['x-profile-name']
|
||||
if (profileName !== 'e2e' && profileName !== 'alternate') {
|
||||
fail(response, 403, 'invalid profile')
|
||||
return
|
||||
}
|
||||
@@ -164,10 +169,12 @@ const server = createServer(async (request, response) => {
|
||||
return
|
||||
}
|
||||
const secondPage = variables.cursor === 'user-next'
|
||||
const firstPageText =
|
||||
profileName === 'alternate' ? 'alternate profile page 1' : 'user page 1'
|
||||
const entries = secondPage
|
||||
? [tweet('1010', 'user page 2')]
|
||||
: [
|
||||
tweet('1001', 'user page 1', 'yuta', {
|
||||
tweet('1001', firstPageText, 'yuta', {
|
||||
conversationId: '1000',
|
||||
}),
|
||||
cursor('user-next'),
|
||||
@@ -288,10 +295,16 @@ const server = createServer(async (request, response) => {
|
||||
|
||||
const targetRoot = rootByTarget.get(focalTweetId)
|
||||
if (targetRoot && variables.cursor === undefined) {
|
||||
const text = focalTweetId === '1001' ? 'user page 1' : 'retry focal'
|
||||
const noReplies = focalTweetId === '3001'
|
||||
const text = noReplies
|
||||
? 'no replies'
|
||||
: focalTweetId === '1001'
|
||||
? 'user page 1'
|
||||
: 'retry focal'
|
||||
const result = tweetResult(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
replyCount: noReplies ? 0 : 1,
|
||||
})
|
||||
send(response, {
|
||||
data: {
|
||||
@@ -303,6 +316,7 @@ const server = createServer(async (request, response) => {
|
||||
tweet(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
replyCount: noReplies ? 0 : 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -323,6 +337,10 @@ const server = createServer(async (request, response) => {
|
||||
fail(response, 400, 'invalid thread cursor')
|
||||
return
|
||||
}
|
||||
if (variables.cursor && focalTweetId === '3000') {
|
||||
fail(response, 500, 'zero-reply thread must not request a continuation')
|
||||
return
|
||||
}
|
||||
if (
|
||||
variables.cursor &&
|
||||
focalTweetId === '2000' &&
|
||||
@@ -340,25 +358,37 @@ const server = createServer(async (request, response) => {
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
]
|
||||
: [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(
|
||||
targetId,
|
||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||
'focus',
|
||||
{
|
||||
: focalTweetId === '3000'
|
||||
? [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(targetId, 'no replies', 'focus', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: focalTweetId,
|
||||
},
|
||||
),
|
||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
replyCount: 0,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
: [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(
|
||||
targetId,
|
||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||
'focus',
|
||||
{
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: focalTweetId,
|
||||
},
|
||||
),
|
||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
send(response, {
|
||||
data: {
|
||||
tweetResult: {
|
||||
@@ -0,0 +1,49 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,78 @@
|
||||
{
|
||||
"nodes": {
|
||||
"agent-skills-nix": {
|
||||
"inputs": {
|
||||
"home-manager": "home-manager",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783839654,
|
||||
"narHash": "sha256-rn5DkeQ9PBaJTZl9juBtPnVawyN4B83tyz1VpiinQFo=",
|
||||
"owner": "Kyure-A",
|
||||
"repo": "agent-skills-nix",
|
||||
"rev": "8326df98cf494dc515db99affa9ea9d2bd6666b6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Kyure-A",
|
||||
"repo": "agent-skills-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"hallmark": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1780605762,
|
||||
"narHash": "sha256-+yPIG1XdI6hhyOH48rd20+YlFrA9Gr416tPt1OoxfwQ=",
|
||||
"owner": "Nutlope",
|
||||
"repo": "hallmark",
|
||||
"rev": "aeb42fb354ff4efa36ab475773a082315a3af2ce",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Nutlope",
|
||||
"repo": "hallmark",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"agent-skills-nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783222121,
|
||||
"narHash": "sha256-E/ElL373TO8lQ2aMvYyzN+k4xkVaUGoRqoa8AtM71tI=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "a1645f40777620c4bd2b6d854b290c2fc354a266",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1783224372,
|
||||
"narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d407951447dcd00442e97087bf374aad70c04cea",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
@@ -18,7 +90,9 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
"agent-skills-nix": "agent-skills-nix",
|
||||
"hallmark": "hallmark",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
description = "Intentional X reader";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
inputs.agent-skills-nix.url = "github:Kyure-A/agent-skills-nix";
|
||||
inputs.hallmark = {
|
||||
url = "github:Nutlope/hallmark";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }:
|
||||
outputs = inputs@{ self, nixpkgs, ... }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
@@ -17,7 +22,7 @@
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pkgs.pnpm_11;
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE=";
|
||||
hash = "sha256-rUszJwsou2NXbVwyPQQ0bk+pRFzRQIzrWs+JXBX/Q9Y=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
@@ -55,10 +60,34 @@
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let pkgs = import nixpkgs { inherit system; };
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
agentLib = inputs.agent-skills-nix.lib.agent-skills;
|
||||
sources.hallmark = {
|
||||
path = inputs.hallmark;
|
||||
subdir = "skills";
|
||||
};
|
||||
catalog = agentLib.discoverCatalog sources;
|
||||
allowlist = agentLib.allowlistFor {
|
||||
inherit catalog sources;
|
||||
enableAll = true;
|
||||
};
|
||||
selection = agentLib.selectSkills {
|
||||
inherit catalog allowlist sources;
|
||||
skills = { };
|
||||
};
|
||||
bundle = agentLib.mkBundle { inherit pkgs selection; };
|
||||
localTargets = builtins.mapAttrs (_: target:
|
||||
target // {
|
||||
enable = true;
|
||||
}) agentLib.defaultLocalTargets;
|
||||
in {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [ nodejs_22 pnpm chromium ];
|
||||
shellHook = agentLib.mkShellHook {
|
||||
inherit pkgs bundle;
|
||||
targets = localTargets;
|
||||
};
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE =
|
||||
"${pkgs.chromium}/bin/chromium";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
export default {
|
||||
entry: [
|
||||
'playwright.config.ts',
|
||||
'tests/e2e/**/*.spec.ts',
|
||||
'tests/e2e/mock-relay.mjs',
|
||||
],
|
||||
entry: ['playwright.config.ts', 'e2e/**/*.test.ts', 'e2e/mock-relay.mjs'],
|
||||
playwright: false,
|
||||
} satisfies KnipConfig
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineConfig, defineTransformer } from 'orval'
|
||||
|
||||
const selectedTag = 'twitter-lite-e2e'
|
||||
const selectedOperationIds = new Set([
|
||||
'getUserByScreenName',
|
||||
'getUserTweets',
|
||||
'getTweetDetail',
|
||||
'getSearchTimeline',
|
||||
'getListLatestTweetsTimeline',
|
||||
])
|
||||
|
||||
const selectE2eOperations = defineTransformer((document) => {
|
||||
const foundOperationIds = new Set<string>()
|
||||
|
||||
for (const pathItem of Object.values(document.paths ?? {})) {
|
||||
if (!pathItem || typeof pathItem !== 'object') continue
|
||||
|
||||
for (const candidate of Object.values(pathItem)) {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate) ||
|
||||
!('operationId' in candidate)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const operation = candidate as {
|
||||
operationId?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
if (
|
||||
operation.operationId &&
|
||||
selectedOperationIds.has(operation.operationId)
|
||||
) {
|
||||
foundOperationIds.add(operation.operationId)
|
||||
operation.tags = [...(operation.tags ?? []), selectedTag]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missingOperationIds = [...selectedOperationIds].filter(
|
||||
(operationId) => !foundOperationIds.has(operationId),
|
||||
)
|
||||
if (missingOperationIds.length > 0) {
|
||||
throw new Error(
|
||||
`twitter-openapi is missing selected operations: ${missingOperationIds.join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
return document
|
||||
})
|
||||
|
||||
export default defineConfig({
|
||||
twitterOpenApi: {
|
||||
input: {
|
||||
target:
|
||||
'https://raw.githubusercontent.com/fa0311/twitter-openapi/590dae5c9f8575abc91d3774946bfe6f23960aba/dist/docs/openapi-3.0.yaml',
|
||||
filters: {
|
||||
mode: 'include',
|
||||
tags: [selectedTag],
|
||||
},
|
||||
override: {
|
||||
transformer: selectE2eOperations,
|
||||
},
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
client: 'fetch',
|
||||
mode: 'single',
|
||||
target: './e2e/generated/twitter-openapi/client.ts',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -6,12 +6,18 @@
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"knip": {
|
||||
"ignore": [
|
||||
"e2e/generated/**"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 127.0.0.1 --port 3000",
|
||||
"dev:tailscale": "vite dev --host 0.0.0.0 --port 3000",
|
||||
"generate:e2e-openapi": "orval --config orval.config.ts --formatter biome --fail-on-warnings",
|
||||
"generate-routes": "node scripts/generate-routes.mjs",
|
||||
"check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts",
|
||||
"build": "vite build",
|
||||
@@ -34,9 +40,11 @@
|
||||
"nitro": "3.0.260610-beta",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"usewebmcp": "5.1.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.11.1",
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
@@ -48,6 +56,8 @@
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"jsdom": "29.1.1",
|
||||
"knip": "6.27.0",
|
||||
"orval": "8.24.0",
|
||||
"tsx": "4.23.12",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.4",
|
||||
"vitest": "4.1.10"
|
||||
|
||||
@@ -11,7 +11,7 @@ if (!chromiumExecutable) {
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
use: {
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
|
||||
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} pnpm exec tsx e2e/mock-relay.mjs`,
|
||||
port: relayPort,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ProfileSwitcher } from '#/features/profiles/profile-switcher'
|
||||
|
||||
export function AppShell({
|
||||
active,
|
||||
@@ -10,11 +11,9 @@ export function AppShell({
|
||||
return (
|
||||
<main className="app">
|
||||
<header className="app-header">
|
||||
<p className="brand">TWITTER LITE</p>
|
||||
<p className="brand">Twitter Lite</p>
|
||||
<ProfileSwitcher />
|
||||
<nav aria-label="閲覧方法" className="tabs">
|
||||
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||
ユーザー
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'search' ? 'page' : undefined}
|
||||
search={{ q: '', product: 'Latest', following: false }}
|
||||
@@ -22,6 +21,9 @@ export function AppShell({
|
||||
>
|
||||
検索
|
||||
</Link>
|
||||
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||
ユーザー
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'list' ? 'page' : undefined}
|
||||
to="/i/lists"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { TwitterClient } from '@yuta/bird'
|
||||
import type { BirdReader } from './post-service'
|
||||
|
||||
let client: TwitterClient | undefined
|
||||
const clients = new Map<string | undefined, TwitterClient>()
|
||||
|
||||
export function getBirdReader(): BirdReader {
|
||||
client ??= new TwitterClient({
|
||||
export function getBirdReader(profileName?: string): BirdReader {
|
||||
const existing = clients.get(profileName)
|
||||
if (existing) return existing
|
||||
|
||||
const client = new TwitterClient({
|
||||
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
|
||||
profileName: process.env.BIRD_PROFILE_NAME,
|
||||
profileName,
|
||||
timeoutMs: 20_000,
|
||||
})
|
||||
clients.set(profileName, client)
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -4,6 +4,19 @@ 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()
|
||||
@@ -58,40 +71,82 @@ describe('ListForm', () => {
|
||||
|
||||
describe('SearchForm', () => {
|
||||
it('uses a labelled grouping for ranking controls', () => {
|
||||
render(
|
||||
<SearchForm
|
||||
initialQuery=""
|
||||
initialProduct="Latest"
|
||||
initialFollowing={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
render(<SearchForm initialValues={searchValues} onSubmit={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the selected ranking and follows filter', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<SearchForm
|
||||
initialQuery=""
|
||||
initialProduct="Latest"
|
||||
initialFollowing={false}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('検索語'), {
|
||||
target: { value: 'AI lang:ja' },
|
||||
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(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
|
||||
|
||||
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(
|
||||
<SearchForm
|
||||
initialValues={{ ...searchValues, q: 'AI', from: 'yuta', lang: 'ja' }}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '投稿者 yuta を解除' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '検索' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
...searchValues,
|
||||
q: 'AI',
|
||||
lang: 'ja',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,10 @@ describe('PostCard', () => {
|
||||
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()
|
||||
@@ -80,12 +84,39 @@ describe('PostCard', () => {
|
||||
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('decodes HTML entities in the post body before linking URLs', () => {
|
||||
render(
|
||||
<PostCard
|
||||
post={{
|
||||
...richPost,
|
||||
text: 'A > B & C 'D' https://example.com/?a=1&b=2',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<PostCard post={richPost} />)
|
||||
|
||||
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
|
||||
expect(detail).toHaveAttribute('href', '/yuta/status/123')
|
||||
expect(detail.parentElement).toHaveClass('post-actions')
|
||||
expect(
|
||||
screen.getByRole('link', { name: '詳細・スレッド' }),
|
||||
).toHaveAttribute('href', '/yuta/status/123')
|
||||
screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
|
||||
).toBe(detail.parentElement)
|
||||
})
|
||||
|
||||
it('marks the focal post without linking to its current page', () => {
|
||||
@@ -103,4 +134,26 @@ describe('PostCard', () => {
|
||||
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(
|
||||
<PostCard
|
||||
post={{
|
||||
...richPost,
|
||||
media: [
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://media.invalid/video-poster.jpg',
|
||||
previewUrl: 'https://media.invalid/video-preview.jpg',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('video')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('img', { name: '動画のプレビュー' }),
|
||||
).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,19 +3,39 @@ import type { Post } from '../types'
|
||||
import { PostMedia } from './post-media'
|
||||
|
||||
const URL = /(https?:\/\/[^\s]+)/g
|
||||
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
''': "'",
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
}
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Tokyo',
|
||||
})
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
|
||||
if (!decimal && !hexadecimal) {
|
||||
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
|
||||
}
|
||||
|
||||
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
|
||||
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
|
||||
})
|
||||
}
|
||||
|
||||
function linkedText(text: string): ReactNode[] {
|
||||
const decoded = decodeHtmlEntities(text)
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
for (const match of text.matchAll(URL)) {
|
||||
for (const match of decoded.matchAll(URL)) {
|
||||
const href = match[0]
|
||||
const start = match.index
|
||||
if (start > cursor) parts.push(text.slice(cursor, start))
|
||||
if (start > cursor) parts.push(decoded.slice(cursor, start))
|
||||
parts.push(
|
||||
<a
|
||||
href={href}
|
||||
@@ -28,7 +48,7 @@ function linkedText(text: string): ReactNode[] {
|
||||
)
|
||||
cursor = start + href.length
|
||||
}
|
||||
if (cursor < text.length) parts.push(text.slice(cursor))
|
||||
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -90,16 +110,18 @@ export function PostCard({
|
||||
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
||||
{post.likeCount ?? 0}
|
||||
</span>
|
||||
{!current ? (
|
||||
<a
|
||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||
>
|
||||
詳細・スレッド
|
||||
<span className="post-actions">
|
||||
{!current ? (
|
||||
<a
|
||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||
>
|
||||
詳細・スレッド
|
||||
</a>
|
||||
) : null}
|
||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
) : null}
|
||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} 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 }) {
|
||||
@@ -21,6 +22,7 @@ function FocalPost({ post }: { post: Post }) {
|
||||
|
||||
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||||
const query = usePostFeed(request)
|
||||
useFeedTools(request, query)
|
||||
const sentinel = useRef<HTMLDivElement>(null)
|
||||
const pages = query.data?.pages ?? []
|
||||
const focalPost =
|
||||
@@ -44,7 +46,7 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting && !requested) {
|
||||
requested = true
|
||||
void query.fetchNextPage()
|
||||
void query.fetchNextPage({ cancelRefetch: false })
|
||||
}
|
||||
},
|
||||
{ rootMargin: '600px 0px' },
|
||||
|
||||
@@ -7,30 +7,34 @@ export function PostMedia({ media }: { media: Media[] }) {
|
||||
|
||||
return (
|
||||
<div className="media-grid">
|
||||
{media.map((item) =>
|
||||
item.type === 'photo' ? (
|
||||
<img
|
||||
alt="投稿画像"
|
||||
className="media"
|
||||
height={item.height}
|
||||
key={item.url}
|
||||
loading="lazy"
|
||||
src={item.url}
|
||||
width={item.width}
|
||||
/>
|
||||
) : (
|
||||
{media.map((item) => {
|
||||
if (item.type === 'photo' || !item.videoUrl) {
|
||||
return (
|
||||
<img
|
||||
alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
|
||||
className="media"
|
||||
height={item.height}
|
||||
key={item.url}
|
||||
loading="lazy"
|
||||
src={item.previewUrl ?? item.url}
|
||||
width={item.width}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
||||
<video
|
||||
className="media"
|
||||
controls
|
||||
key={item.videoUrl ?? item.url}
|
||||
key={item.videoUrl}
|
||||
loop={item.type === 'animated_gif'}
|
||||
poster={item.previewUrl ?? item.url}
|
||||
preload="metadata"
|
||||
src={item.videoUrl ?? item.url}
|
||||
src={item.videoUrl}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,57 @@
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { buildSearchQuery } from '../inputs'
|
||||
import { type AdvancedSearchQuery, buildFilteredSearchQuery } from '../inputs'
|
||||
|
||||
type SearchValues = {
|
||||
q: string
|
||||
export type SearchValues = AdvancedSearchQuery & {
|
||||
product: SearchProduct
|
||||
following: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialQuery: string
|
||||
initialProduct: SearchProduct
|
||||
initialFollowing: boolean
|
||||
initialValues: SearchValues
|
||||
onSubmit: (values: SearchValues) => void
|
||||
}
|
||||
|
||||
export function SearchForm({
|
||||
initialQuery,
|
||||
initialProduct,
|
||||
initialFollowing,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const [q, setQuery] = useState(initialQuery)
|
||||
const [product, setProduct] = useState(initialProduct)
|
||||
const [following, setFollowing] = useState(initialFollowing)
|
||||
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<FilterName>()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const update = <Key extends keyof SearchValues>(
|
||||
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 {
|
||||
const query = buildSearchQuery(q, false)
|
||||
buildFilteredSearchQuery(normalized, normalized.following)
|
||||
setError('')
|
||||
onSubmit({ q: query, product, following })
|
||||
onSubmit(normalized)
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : '入力内容を確認してください。',
|
||||
@@ -39,33 +59,337 @@ export function SearchForm({
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<form className="intent-form" onSubmit={submit}>
|
||||
<label className="field">
|
||||
<span>検索語</span>
|
||||
<form className="intent-form search-form" onSubmit={submit}>
|
||||
<div className="field search-query">
|
||||
<label htmlFor="search-query">検索語</label>
|
||||
<input
|
||||
aria-describedby="search-query-help"
|
||||
autoComplete="off"
|
||||
aria-describedby={error ? 'query-error' : undefined}
|
||||
aria-invalid={Boolean(error)}
|
||||
id="search-query"
|
||||
name="q"
|
||||
value={q}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder="検索語や高度な検索演算子"
|
||||
value={values.q}
|
||||
onChange={(event) => update('q', event.currentTarget.value)}
|
||||
placeholder="キーワードや高度な検索式"
|
||||
/>
|
||||
{error ? (
|
||||
<span className="field-error" id="query-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
<span className="field-help" id="search-query-help">
|
||||
演算子を直接入力することもできます。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<fieldset className="search-filter-buttons">
|
||||
<legend>条件を追加</legend>
|
||||
{filterButtons.map((filter) => (
|
||||
<button
|
||||
aria-controls={`search-filter-${filter.name}`}
|
||||
aria-expanded={openFilter === filter.name}
|
||||
className="filter-button"
|
||||
data-active={filter.active || undefined}
|
||||
key={filter.name}
|
||||
onClick={() => toggleFilter(filter.name)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
{filter.active ? <span aria-hidden="true">●</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{openFilter ? (
|
||||
<fieldset
|
||||
className="search-filter-panel"
|
||||
id={`search-filter-${openFilter}`}
|
||||
>
|
||||
<legend>
|
||||
{filterButtons.find((item) => item.name === openFilter)?.label}
|
||||
の条件
|
||||
</legend>
|
||||
{openFilter === 'author' ? (
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-author">投稿者</label>
|
||||
<input
|
||||
aria-describedby="search-author-help"
|
||||
autoComplete="off"
|
||||
id="search-author"
|
||||
name="from"
|
||||
onChange={(event) => update('from', event.currentTarget.value)}
|
||||
placeholder="@handle"
|
||||
value={values.from}
|
||||
/>
|
||||
<span className="field-help" id="search-author-help">
|
||||
@は省略できます。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'period' ? (
|
||||
<>
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>すぐ選ぶ</legend>
|
||||
<button onClick={() => setRecentPeriod(1)} type="button">
|
||||
今日
|
||||
</button>
|
||||
<button onClick={() => setRecentPeriod(7)} type="button">
|
||||
過去7日
|
||||
</button>
|
||||
<button onClick={() => setRecentPeriod(30)} type="button">
|
||||
過去30日
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
since: '',
|
||||
until: '',
|
||||
}))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
すべて
|
||||
</button>
|
||||
</fieldset>
|
||||
<div className="date-fields">
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-since">開始日</label>
|
||||
<input
|
||||
id="search-since"
|
||||
name="since"
|
||||
onChange={(event) =>
|
||||
update('since', event.currentTarget.value)
|
||||
}
|
||||
type="date"
|
||||
value={values.since}
|
||||
/>
|
||||
<span className="field-help" />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-until">
|
||||
終了日(この日は含まない)
|
||||
</label>
|
||||
<input
|
||||
id="search-until"
|
||||
name="until"
|
||||
onChange={(event) =>
|
||||
update('until', event.currentTarget.value)
|
||||
}
|
||||
type="date"
|
||||
value={values.until}
|
||||
/>
|
||||
<span className="field-help" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'language' ? (
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>言語</legend>
|
||||
{(
|
||||
Object.keys(languageLabels) as Array<
|
||||
keyof typeof languageLabels
|
||||
>
|
||||
).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={values.lang === value}
|
||||
name="lang"
|
||||
onChange={() => update('lang', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
{languageLabels[value]}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'content' ? (
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>投稿の種類</legend>
|
||||
{(
|
||||
Object.keys(contentLabels) as Array<keyof typeof contentLabels>
|
||||
).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={values.content === value}
|
||||
name="content"
|
||||
onChange={() => update('content', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
{contentLabels[value]}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'exclude' ? (
|
||||
<fieldset className="search-exclusions">
|
||||
<legend>検索結果から除外</legend>
|
||||
<label className="check">
|
||||
<input
|
||||
checked={values.excludeReplies}
|
||||
onChange={(event) =>
|
||||
update('excludeReplies', event.currentTarget.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
返信
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
checked={values.excludeReposts}
|
||||
onChange={(event) =>
|
||||
update('excludeReposts', event.currentTarget.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
リポスト
|
||||
</label>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<ul aria-label="選択中の条件" className="active-filters">
|
||||
{values.from ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`投稿者 ${values.from} を解除`}
|
||||
onClick={() => update('from', '')}
|
||||
type="button"
|
||||
>
|
||||
{values.from.startsWith('@') ? values.from : `@${values.from}`}{' '}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</label>
|
||||
<fieldset className="segmented">
|
||||
{values.since || values.until ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="期間を解除"
|
||||
onClick={() =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
since: '',
|
||||
until: '',
|
||||
}))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{values.since || '指定なし'} → {values.until || '現在'}{' '}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.lang !== 'all' ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`言語 ${languageLabels[values.lang]} を解除`}
|
||||
onClick={() => update('lang', 'all')}
|
||||
type="button"
|
||||
>
|
||||
{languageLabels[values.lang]} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.content !== 'all' ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`投稿の種類 ${contentLabels[values.content]} を解除`}
|
||||
onClick={() => update('content', 'all')}
|
||||
type="button"
|
||||
>
|
||||
{contentLabels[values.content]} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.excludeReplies ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="返信の除外を解除"
|
||||
onClick={() => update('excludeReplies', false)}
|
||||
type="button"
|
||||
>
|
||||
返信を除外 <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.excludeReposts ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="リポストの除外を解除"
|
||||
onClick={() => update('excludeReposts', false)}
|
||||
type="button"
|
||||
>
|
||||
リポストを除外 <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<details className="search-query-preview">
|
||||
<summary>検索式を確認</summary>
|
||||
<code>{preview}</code>
|
||||
</details>
|
||||
|
||||
{error ? (
|
||||
<p className="field-error search-error" id="query-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="segmented search-ranking">
|
||||
<legend>並び順</legend>
|
||||
{(['Top', 'Latest'] as const).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={product === value}
|
||||
checked={values.product === value}
|
||||
name="product"
|
||||
onChange={() => setProduct(value)}
|
||||
onChange={() => update('product', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
@@ -73,15 +397,17 @@ export function SearchForm({
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<label className="check">
|
||||
<label className="check search-following">
|
||||
<input
|
||||
checked={following}
|
||||
onChange={(event) => setFollowing(event.currentTarget.checked)}
|
||||
checked={values.following}
|
||||
onChange={(event) => update('following', event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
<button type="submit">検索</button>
|
||||
<button className="search-submit" type="submit">
|
||||
検索
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildAdvancedSearchQuery,
|
||||
buildFilteredSearchQuery,
|
||||
buildSearchQuery,
|
||||
normalizeListTarget,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
postSearchRouteSchema,
|
||||
threadPageInputSchema,
|
||||
} from './inputs'
|
||||
|
||||
@@ -108,3 +111,134 @@ 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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}$/
|
||||
@@ -11,6 +12,23 @@ const tweetIdSchema = z
|
||||
|
||||
export class InputError extends Error {}
|
||||
|
||||
const searchLanguageSchema = z.enum(['all', 'ja', 'en'])
|
||||
const searchContentSchema = z.enum(['all', 'images', 'videos', 'links'])
|
||||
|
||||
type SearchLanguage = z.infer<typeof searchLanguageSchema>
|
||||
type SearchContent = z.infer<typeof searchContentSchema>
|
||||
|
||||
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,
|
||||
@@ -45,10 +63,87 @@ export const searchPageInputSchema = z.object({
|
||||
|
||||
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)) {
|
||||
@@ -129,8 +224,10 @@ export function buildSearchQuery(raw: string, following: boolean): string {
|
||||
if (!query) {
|
||||
throw new InputError('検索語を入力してください。')
|
||||
}
|
||||
if (query.length > 512) {
|
||||
const finalQuery =
|
||||
following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
|
||||
if (finalQuery.length > 512) {
|
||||
throw new InputError('検索語は 512 文字以内で入力してください。')
|
||||
}
|
||||
return following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
|
||||
return finalQuery
|
||||
}
|
||||
|
||||
@@ -168,6 +168,64 @@ describe('loadThreadPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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({
|
||||
|
||||
@@ -215,14 +215,22 @@ export async function loadThreadPage(
|
||||
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: (result.tweets ?? []).map(publicPost),
|
||||
tweets: tweets.map(publicPost),
|
||||
focalPost,
|
||||
conversationId,
|
||||
nextCursor: result.nextCursor,
|
||||
nextCursor,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,8 +28,11 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
|
||||
}
|
||||
|
||||
async function reader() {
|
||||
const { getBirdReader } = await import('./bird-client.server')
|
||||
return getBirdReader()
|
||||
const [{ getBirdReader }, { getSelectedProfileName }] = await Promise.all([
|
||||
import('./bird-client.server'),
|
||||
import('../profiles/profile-service.server'),
|
||||
])
|
||||
return getBirdReader(getSelectedProfileName())
|
||||
}
|
||||
|
||||
export const loadUserPosts = createServerFn({ method: 'GET' })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement, type ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createPostFeedOptions,
|
||||
getPostFeedData,
|
||||
PostLoadError,
|
||||
usePostFeed,
|
||||
} from './use-post-feed'
|
||||
@@ -229,6 +230,97 @@ 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' },
|
||||
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<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePostFeed', () => {
|
||||
beforeEach(() => {
|
||||
useServerFn.mockReset()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { type QueryClient, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import {
|
||||
@@ -120,23 +120,41 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
}
|
||||
}
|
||||
|
||||
export function usePostFeed(request: FeedRequest | undefined) {
|
||||
export 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<typeof createPostFeedOptions>,
|
||||
) {
|
||||
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: '',
|
||||
} satisfies FeedRequest
|
||||
|
||||
return useInfiniteQuery({
|
||||
...createPostFeedOptions(request ?? disabled, {
|
||||
loadUser,
|
||||
loadList,
|
||||
search,
|
||||
thread,
|
||||
}),
|
||||
...options(request ?? disabled),
|
||||
enabled: request !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
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<PostPage | ThreadPage>,
|
||||
) {
|
||||
const focal =
|
||||
request.kind === 'thread' ? focalPostFromPages(pages) : undefined
|
||||
return focal
|
||||
? [focal, ...flattenConversationPages(pages, focal.id)]
|
||||
: flattenPostPages(pages)
|
||||
}
|
||||
|
||||
export function feedResult(
|
||||
request: FeedRequest,
|
||||
pages: Array<PostPage | ThreadPage>,
|
||||
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<unknown>) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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'
|
||||
|
||||
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<typeof usePostFeed>,
|
||||
) {
|
||||
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)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
deleteCookie,
|
||||
getCookie,
|
||||
setCookie,
|
||||
} from '@tanstack/react-start/server'
|
||||
import { z } from 'zod'
|
||||
import type { ProfileSettings } from './types'
|
||||
|
||||
const PROFILE_COOKIE = 'twitter-lite-profile'
|
||||
const profilesSchema = z.object({
|
||||
profiles: z.array(z.string().min(1)),
|
||||
})
|
||||
|
||||
function relayBaseUrl() {
|
||||
const relayBaseUrl = process.env.TWITTER_RELAY_BASE_URL
|
||||
if (!relayBaseUrl) {
|
||||
throw new Error('TWITTER_RELAY_BASE_URL is required')
|
||||
}
|
||||
return relayBaseUrl
|
||||
}
|
||||
|
||||
export async function fetchProfileNames(
|
||||
baseUrl = relayBaseUrl(),
|
||||
request: typeof fetch = fetch,
|
||||
): Promise<string[]> {
|
||||
const response = await request(new URL('/profiles', baseUrl), {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Profiles request failed with HTTP ${response.status}`)
|
||||
}
|
||||
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<ProfileSettings> {
|
||||
const profiles = await fetchProfileNames()
|
||||
const selectedProfile = getSelectedProfileName()
|
||||
return {
|
||||
profiles,
|
||||
...(selectedProfile && profiles.includes(selectedProfile)
|
||||
? { selectedProfile }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveProfileSelection(
|
||||
profileName: string | null,
|
||||
): Promise<void> {
|
||||
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',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fetchProfileNames } from './profile-service.server'
|
||||
|
||||
describe('fetchProfileNames', () => {
|
||||
it('loads profile names from the relay endpoint', async () => {
|
||||
const request = vi.fn(async () =>
|
||||
Response.json({ profiles: ['main', 'work'] }),
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchProfileNames('https://relay.invalid/api', request),
|
||||
).resolves.toEqual(['main', 'work'])
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
new URL('https://relay.invalid/profiles'),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed relay responses', async () => {
|
||||
const request = vi.fn(async () => Response.json({ profiles: [42] }))
|
||||
|
||||
await expect(
|
||||
fetchProfileNames('https://relay.invalid', request),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects unsuccessful relay responses', async () => {
|
||||
const request = vi.fn(async () => new Response(null, { status: 503 }))
|
||||
|
||||
await expect(
|
||||
fetchProfileNames('https://relay.invalid', request),
|
||||
).rejects.toThrow('Profiles request failed with HTTP 503')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProfileSwitcher />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
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 (
|
||||
<div className="profile-switcher" data-state={state}>
|
||||
<label htmlFor="relay-profile">プロファイル</label>
|
||||
<select
|
||||
aria-invalid={state === 'error' ? 'true' : undefined}
|
||||
disabled={profiles.isPending || profiles.isError || selection.isPending}
|
||||
id="relay-profile"
|
||||
onChange={(event) =>
|
||||
selection.mutate(
|
||||
event.target.value.length ? event.target.value : null,
|
||||
)
|
||||
}
|
||||
value={profiles.data?.selectedProfile ?? ''}
|
||||
>
|
||||
<option value="">
|
||||
{profiles.isPending
|
||||
? '読み込み中'
|
||||
: profiles.isError
|
||||
? '取得失敗'
|
||||
: '自動'}
|
||||
</option>
|
||||
{profiles.data?.profiles.map((profile) => (
|
||||
<option key={profile} value={profile}>
|
||||
{profile}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="profile-switcher-status"
|
||||
role={selection.isError ? 'alert' : 'status'}
|
||||
>
|
||||
{selection.isError
|
||||
? '切り替えに失敗しました。'
|
||||
: selection.isSuccess
|
||||
? '切り替えました。'
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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()
|
||||
},
|
||||
)
|
||||
|
||||
export const selectProfile = createServerFn({ method: 'POST' })
|
||||
.validator(profileSelectionSchema)
|
||||
.handler(async ({ data }): Promise<ProfileSelection> => {
|
||||
const { saveProfileSelection } = await import('./profile-service.server')
|
||||
await saveProfileSelection(data.profileName)
|
||||
return data
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ProfileSettings = {
|
||||
profiles: string[]
|
||||
selectedProfile?: string
|
||||
}
|
||||
|
||||
export type ProfileSelection = {
|
||||
profileName: string | null
|
||||
}
|
||||
@@ -23,6 +23,10 @@ vi.mock('../features/posts/use-list-choices', async (importOriginal) => {
|
||||
return { ...original, useListChoices }
|
||||
})
|
||||
|
||||
vi.mock('../features/profiles/profile-switcher', () => ({
|
||||
ProfileSwitcher: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./__root', async () => {
|
||||
const { createRootRouteWithContext, Outlet } = await import(
|
||||
'@tanstack/react-router'
|
||||
@@ -153,13 +157,42 @@ describe('post feed route wiring', () => {
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'search',
|
||||
query: 'AI lang:ja',
|
||||
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')
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
} from '@tanstack/react-router'
|
||||
import { SearchTool } from '#/features/posts/webmcp-tools'
|
||||
import appCss from '../styles.css?url'
|
||||
|
||||
type RouterContext = { queryClient: QueryClient }
|
||||
@@ -16,6 +18,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{ name: 'referrer', content: 'no-referrer' },
|
||||
{ title: 'Twitter Lite' },
|
||||
],
|
||||
links: [
|
||||
@@ -27,8 +30,18 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
component: RootContent,
|
||||
})
|
||||
|
||||
function RootContent() {
|
||||
return (
|
||||
<>
|
||||
<SearchTool />
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ja">
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ListPage({ listId = '' }: { listId?: string }) {
|
||||
|
||||
return (
|
||||
<AppShell active="list">
|
||||
<h1>どのリストを読みますか?</h1>
|
||||
<h1 className="reader-title-long">どのリストを読みますか?</h1>
|
||||
<p className="intro">
|
||||
自分のリストを選ぶか、リストURLを入力してください。
|
||||
</p>
|
||||
|
||||
@@ -1,34 +1,70 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||
import { SearchForm } from '#/features/posts/components/search-form'
|
||||
import { postSearchRouteSchema } from '#/features/posts/inputs'
|
||||
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 (
|
||||
<AppShell active="search">
|
||||
<h1>何を確認しますか?</h1>
|
||||
<p className="intro">入力した条件だけを検索します。</p>
|
||||
<SearchForm
|
||||
initialFollowing={values.following}
|
||||
initialProduct={values.product}
|
||||
initialQuery={values.q}
|
||||
key={`${values.q}:${values.product}:${values.following}`}
|
||||
initialValues={formValues}
|
||||
key={formKey}
|
||||
onSubmit={(search) => navigate({ search })}
|
||||
/>
|
||||
<PostFeed
|
||||
request={
|
||||
values.q
|
||||
query
|
||||
? {
|
||||
kind: 'search',
|
||||
query: values.q,
|
||||
query,
|
||||
product: values.product,
|
||||
following: values.following,
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
/* 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 */
|
||||
@import "../tokens.css";
|
||||
|
||||
:root {
|
||||
--canvas: #dfe7e9;
|
||||
--surface: #f8faf9;
|
||||
--paper: #fff;
|
||||
--ink: #1e3238;
|
||||
--secondary: #506970;
|
||||
--accent: #255f6e;
|
||||
--accent-hover: #194d59;
|
||||
--border: #c1cfd2;
|
||||
--control-border: #748b91;
|
||||
--error: #8a3034;
|
||||
--canvas: var(--color-canvas);
|
||||
--surface: var(--color-paper-2);
|
||||
--paper: var(--color-paper);
|
||||
--ink: var(--color-ink);
|
||||
--secondary: var(--color-muted);
|
||||
--accent: var(--color-accent);
|
||||
--accent-hover: var(--color-accent-hover);
|
||||
--border: var(--color-rule);
|
||||
--control-border: var(--color-control-rule);
|
||||
--error: var(--color-error);
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
color-scheme: light;
|
||||
font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: var(--font-body);
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
@@ -22,12 +29,14 @@
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
overflow-x: clip;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -40,9 +49,19 @@ a {
|
||||
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: 3px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
outline: var(--rule-active) solid var(--color-focus);
|
||||
outline-offset: var(--space-2xs);
|
||||
}
|
||||
|
||||
.app {
|
||||
@@ -53,7 +72,7 @@ a {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.15rem;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 1.2rem 3rem rgb(30 50 56 / 10%);
|
||||
box-shadow: 0 var(--space-lg) var(--space-2xl) var(--color-shadow);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@@ -163,7 +182,7 @@ a {
|
||||
|
||||
.list-choices a[aria-current="page"] {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--paper));
|
||||
background: color-mix(in oklch, var(--accent) 7%, var(--paper));
|
||||
box-shadow: inset 3px 0 var(--accent);
|
||||
}
|
||||
|
||||
@@ -191,7 +210,7 @@ a {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.9rem;
|
||||
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
|
||||
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -352,7 +371,7 @@ a {
|
||||
|
||||
.current-post {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
@@ -401,7 +420,7 @@ a {
|
||||
margin-top: 0.8rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
|
||||
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
|
||||
}
|
||||
|
||||
.media-grid > :only-child {
|
||||
@@ -412,7 +431,7 @@ a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 32rem;
|
||||
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
|
||||
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
|
||||
}
|
||||
|
||||
img.media {
|
||||
@@ -503,7 +522,7 @@ video.media {
|
||||
transparent 100%
|
||||
);
|
||||
content: "";
|
||||
animation: rail 1.1s ease-in-out infinite;
|
||||
animation: rail 1.1s var(--ease-in-out) infinite;
|
||||
}
|
||||
|
||||
@keyframes rail {
|
||||
@@ -585,7 +604,7 @@ video.media {
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
@@ -603,3 +622,838 @@ video.media {
|
||||
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(3, 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);
|
||||
}
|
||||
|
||||
.reader > h1 {
|
||||
max-width: 18ch;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ink);
|
||||
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;
|
||||
}
|
||||
|
||||
.intro {
|
||||
max-width: 46ch;
|
||||
margin: var(--space-sm) 0 var(--space-xl);
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import { expect, type Locator, test } from '@playwright/test'
|
||||
|
||||
let consoleErrors: string[]
|
||||
let pageErrors: string[]
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
consoleErrors = []
|
||||
pageErrors = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
expect(consoleErrors, 'browser console errors').toEqual([])
|
||||
expect(pageErrors, 'uncaught browser errors').toEqual([])
|
||||
})
|
||||
|
||||
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
|
||||
const mutationControlNames = /投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
|
||||
|
||||
async function submitSearch(
|
||||
page: import('@playwright/test').Page,
|
||||
query: string,
|
||||
) {
|
||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
||||
await expect(page.locator('article')).toHaveCount(0)
|
||||
await page.getByLabel('検索語').fill(query)
|
||||
await page.getByRole('button', { name: '検索' }).click()
|
||||
}
|
||||
|
||||
async function openReader(page: import('@playwright/test').Page, path: string) {
|
||||
await page.goto(path)
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async function tabTo(page: import('@playwright/test').Page, target: Locator) {
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(target).toBeFocused()
|
||||
}
|
||||
|
||||
test('requires intent and infinitely loads a user timeline', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await expect(page.locator('article')).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
|
||||
0,
|
||||
)
|
||||
|
||||
await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta')
|
||||
await page.getByRole('button', { name: '表示' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/yuta$/)
|
||||
await expect(page.getByText('user page 1')).toBeVisible()
|
||||
await expect(page.getByText('user page 2')).toBeVisible()
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(page).toHaveScreenshot('mist-user.png', {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('selects an authenticated list and infinitely loads its timeline', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/i/lists')
|
||||
await expect(page.locator('article')).toHaveCount(0)
|
||||
|
||||
await page.getByRole('link', { name: /My E2E List.*10 メンバー/ }).click()
|
||||
|
||||
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens a card detail and infinitely loads its conversation', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/yuta')
|
||||
const sourceCard = page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: 'user page 1' })
|
||||
await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/yuta\/status\/1001$/)
|
||||
await expect(page.getByRole('heading', { name: '会話' })).toBeVisible()
|
||||
await expect(page.getByText('表示中の投稿')).toBeVisible()
|
||||
await expect(page.getByText('user page 1')).toHaveCount(1)
|
||||
await expect(page.getByText('thread root')).toBeVisible()
|
||||
await expect(page.getByText('thread page 1')).toBeVisible()
|
||||
await expect(page.getByText('thread page 2')).toBeVisible()
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(page).toHaveScreenshot('mist-thread.png', {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves a reply root and retries only the failed continuation', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/yuta/status/2001')
|
||||
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
await expect(page.getByText('thread root')).toBeVisible()
|
||||
await expect(page.getByText('thread page 1')).toBeVisible()
|
||||
const alert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
await expect(alert).toHaveCount(1)
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
|
||||
await alert.getByRole('button', { name: '再試行' }).click()
|
||||
|
||||
await expect(page.getByText('thread page 2')).toBeVisible()
|
||||
await expect(alert).toHaveCount(0)
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('searches Top posts from followed accounts', async ({ page }) => {
|
||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
||||
await expect(page.locator('article')).toHaveCount(0)
|
||||
await page.getByLabel('検索語').fill('AI lang:ja')
|
||||
await page.getByLabel('人気順').check()
|
||||
await page.getByLabel('フォロー中のみ').check()
|
||||
await expect(page.getByLabel('フォロー中のみ')).toBeChecked()
|
||||
await page.getByRole('button', { name: '検索' }).click()
|
||||
|
||||
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
||||
test('retains the first page through a pagination error and retries explicitly', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await submitSearch(page, `retry-${testInfo.project.name}`)
|
||||
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
const laterPageAlert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
await expect(laterPageAlert).toHaveCount(1)
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
|
||||
|
||||
await laterPageAlert.getByRole('button', { name: '再試行' }).click()
|
||||
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
await expect(laterPageAlert).toHaveCount(0)
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
||||
|
||||
await tabTo(page, page.getByRole('link', { name: 'ユーザー' }))
|
||||
await tabTo(page, page.getByRole('link', { name: '検索' }))
|
||||
await tabTo(page, page.getByRole('link', { name: 'リスト' }))
|
||||
await tabTo(page, page.getByLabel('検索語'))
|
||||
const ranking = page.getByLabel('最新順')
|
||||
await expect(ranking).toBeChecked()
|
||||
await tabTo(page, ranking)
|
||||
await tabTo(page, page.getByLabel('フォロー中のみ'))
|
||||
await tabTo(page, page.getByRole('button', { name: '検索' }))
|
||||
|
||||
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
|
||||
0,
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('button', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
page.getByRole('link', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('removes loading rail animation for reduced motion', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await submitSearch(page, `slow-${testInfo.project.name}`)
|
||||
|
||||
const rail = page.locator('.loading-rail')
|
||||
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,58 @@
|
||||
/* Hallmark · macrostructure: Workbench · tone: soft · anchor hue: leaf-green */
|
||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5 */
|
||||
: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;
|
||||
--font-body:
|
||||
"Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", ui-sans-serif, sans-serif;
|
||||
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
|
||||
--space-3xs: 0.125rem;
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2.5rem;
|
||||
--space-2xl: 4rem;
|
||||
--space-3xl: 6rem;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--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);
|
||||
|
||||
--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;
|
||||
|
||||
--rule-hairline: 1px;
|
||||
--rule-active: 2px;
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.625rem;
|
||||
--radius-lg: 1rem;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "e2e/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
@@ -6,6 +6,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
exclude: [...configDefaults.exclude, 'tests/e2e/**'],
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
},
|
||||
})
|
||||
|
||||