Author SHA1 Message Date
yuta 2bc052c354 feat: prototype WebMCP reader tools 2026-09-24 14:48:56 +09:00
yuta 9daf6e71dc fix(nix): refresh pnpm dependency hash 2026-09-23 21:46:32 +09:00
yuta 5406855e1c test(e2e): generate relay fixture types from OpenAPI 2026-08-12 00:24:55 +09:00
yuta 8eb299f46d Add button-driven search filters 2026-08-10 04:17:54 +09:00
yuta 079aaa1664 Add relay profile switching 2026-07-31 21:53:43 +09:00
yuta 67ee53c305 Put search first in navigation 2026-07-26 16:05:19 +09:00
yuta 70cacc2c2d Fix reader media and thread behavior (#4)
Reviewed-on: #4
2026-07-26 15:32:28 +09:00
yuta 1794255ebb Improve reader design (#3)
Reviewed-on: #3
2026-07-26 14:52:08 +09:00
yuta 24939e6f00 Add Hallmark agent skill 2026-07-26 14:08:51 +09:00
yuta f5274195b9 Add Axe accessibility E2E tests (#2)
Reviewed-on: #2
2026-07-26 13:55:56 +09:00
yuta 8679a850e5 Refactor E2E tests with page objects (#1)
Reviewed-on: #1
2026-07-26 13:43:31 +09:00
71 changed files with 8116 additions and 431 deletions
+1
View File
@@ -1,5 +1,6 @@
.superpowers/
.worktrees/
/.*/skills/
.env*
!.env.example
.output/
+9
View File
@@ -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"
}
]
+9
View File
@@ -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"]
}
+50 -6
View File
@@ -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.
+121
View File
@@ -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).
+12
View File
@@ -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([])
})
+24
View File
@@ -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([])
})
+43
View File
@@ -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([])
})
+12
View File
@@ -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([])
})
+12
View File
@@ -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([])
})
+34
View File
@@ -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'
+35
View File
@@ -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',
})
File diff suppressed because it is too large Load Diff
+240
View File
@@ -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()
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+254
View File
@@ -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()
}
})
+60 -30
View File
@@ -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: {
+49
View File
@@ -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()
}
}
+22
View File
@@ -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()
}
}
+11
View File
@@ -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()
}
}
+35
View File
@@ -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()
}
}
+17
View File
@@ -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}`)
}
}
+15
View File
@@ -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()
}
}
Generated
+75 -1
View File
@@ -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"
}
}
},
+32 -3
View File
@@ -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 -5
View File
@@ -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
+75
View File
@@ -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',
},
},
})
+10
View File
@@ -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"
+2 -2
View File
@@ -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,
+1170 -32
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -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"
+8 -4
View File
@@ -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
}
+72 -17
View File
@@ -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 &gt; B &amp; C &#39;D&#39; https://example.com/?a=1&amp;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')
})
})
+34 -12
View File
@@ -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> = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
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>
+3 -1
View File
@@ -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' },
+20 -16
View File
@@ -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>
)
}
+363 -37
View File
@@ -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>
)
}
+134
View File
@@ -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,
})
})
})
+99 -2
View File
@@ -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
}
+58
View File
@@ -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({
+10 -2
View File
@@ -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) {
+5 -2
View File
@@ -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' })
+92
View File
@@ -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()
+26 -8
View File
@@ -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,
})
}
+241
View File
@@ -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,
})
})
})
+146
View File
@@ -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,
}
}
}
+155
View File
@@ -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>
)
}
+22
View File
@@ -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
})
+8
View File
@@ -0,0 +1,8 @@
export type ProfileSettings = {
profiles: string[]
selectedProfile?: string
}
export type ProfileSelection = {
profileName: string | null
}
+34 -1
View File
@@ -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')
+13
View File
@@ -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">
+1 -1
View File
@@ -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>
+44 -8
View File
@@ -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,
}
+875 -21
View File
@@ -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;
}
}
-206
View File
@@ -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()
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

+58
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -6,6 +6,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
exclude: [...configDefaults.exclude, 'tests/e2e/**'],
exclude: [...configDefaults.exclude, 'e2e/**'],
},
})