Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01d808381b | ||
|
|
c43278f785 | ||
|
|
1794255ebb | ||
|
|
24939e6f00 | ||
|
|
f5274195b9 | ||
|
|
8679a850e5 |
@@ -1,5 +1,6 @@
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
/.*/skills/
|
||||
.env*
|
||||
!.env.example
|
||||
.output/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-07-26",
|
||||
"macrostructure": "Workbench",
|
||||
"theme": "Garden",
|
||||
"enrichment": "none",
|
||||
"brief": "Personal read-only research client with a soft, deliberate workspace"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"date": "2026-07-26",
|
||||
"font_stack": "Inter / Noto Sans JP / system sans declared in src/styles.css",
|
||||
"palette": "CSS custom properties with muted teal hex values",
|
||||
"motion": "motion-cut; no motion library",
|
||||
"spacing": "no named scale",
|
||||
"framework": "React 19 + TanStack Start + Vite 8",
|
||||
"preserve": ["routes", "information architecture", "read-only behavior"]
|
||||
}
|
||||
@@ -42,6 +42,9 @@ nix develop -c pnpm install --frozen-lockfile
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
The Nix development shell installs Hallmark's agent skill for the supported
|
||||
local agent targets.
|
||||
|
||||
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set
|
||||
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same
|
||||
values in the process environment before `start` or `test:live`:
|
||||
@@ -68,8 +71,10 @@ 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. `test:live` is an explicit, opt-in smoke test that performs exactly
|
||||
one read-only Top search using the configured relay.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('home page has no accessibility violations', async ({
|
||||
a11y,
|
||||
homePage,
|
||||
}) => {
|
||||
await homePage.goTo()
|
||||
await expect(homePage.articlesLocator).toHaveCount(0)
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('list picker has no accessibility violations', async ({
|
||||
a11y,
|
||||
listsPage,
|
||||
}) => {
|
||||
await listsPage.goTo()
|
||||
await expect(listsPage.articlesLocator).toHaveCount(0)
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
|
||||
test('list timeline has no accessibility violations', async ({
|
||||
a11y,
|
||||
listsPage,
|
||||
}) => {
|
||||
await listsPage.goTo()
|
||||
await listsPage.openList(/My E2E List.*10 メンバー/)
|
||||
await expect(listsPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
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([])
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('conversation has no accessibility violations', async ({
|
||||
a11y,
|
||||
threadPage,
|
||||
}) => {
|
||||
await threadPage.goTo('yuta', '1001')
|
||||
await expect(threadPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('user timeline has no accessibility violations', async ({
|
||||
a11y,
|
||||
userPage,
|
||||
}) => {
|
||||
await userPage.goTo('yuta')
|
||||
await expect(userPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { HomePage } from './models/HomePage'
|
||||
import { ListsPage } from './models/ListsPage'
|
||||
import { SearchPage } from './models/SearchPage'
|
||||
import { ThreadPage } from './models/ThreadPage'
|
||||
import { UserPage } from './models/UserPage'
|
||||
|
||||
type Fixtures = {
|
||||
a11y: () => AxeBuilder
|
||||
homePage: HomePage
|
||||
listsPage: ListsPage
|
||||
searchPage: SearchPage
|
||||
threadPage: ThreadPage
|
||||
userPage: UserPage
|
||||
}
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
a11y: async ({ page }, use) => {
|
||||
const makeAxeBuilder = () =>
|
||||
new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.disableRules(['meta-viewport'])
|
||||
|
||||
await use(makeAxeBuilder)
|
||||
},
|
||||
homePage: ({ page }, use) => use(new HomePage(page)),
|
||||
listsPage: ({ page }, use) => use(new ListsPage(page)),
|
||||
searchPage: ({ page }, use) => use(new SearchPage(page)),
|
||||
threadPage: ({ page }, use) => use(new ThreadPage(page)),
|
||||
userPage: ({ page }, use) => use(new UserPage(page)),
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
@@ -0,0 +1,192 @@
|
||||
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('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('link', { name: 'ユーザー' }),
|
||||
)
|
||||
await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
|
||||
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
|
||||
await searchPage.tabTo(searchPage.queryInputLocator)
|
||||
await expect(searchPage.latestRankingLocator).toBeChecked()
|
||||
await searchPage.tabTo(searchPage.latestRankingLocator)
|
||||
await searchPage.tabTo(searchPage.followingOnlyLocator)
|
||||
await searchPage.tabTo(searchPage.searchButtonLocator)
|
||||
|
||||
await expect(searchPage.ambientLinksLocator).toHaveCount(0)
|
||||
await expect(searchPage.mutationButtonsLocator).toHaveCount(0)
|
||||
await expect(searchPage.mutationLinksLocator).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('removes loading rail animation for reduced motion', async ({
|
||||
page,
|
||||
searchPage,
|
||||
}, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await searchPage.goTo()
|
||||
await expect(searchPage.articlesLocator).toHaveCount(0)
|
||||
await searchPage.search(`slow-${testInfo.project.name}`)
|
||||
|
||||
const rail = searchPage.loadingRailLocator
|
||||
await expect(rail).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
rail.evaluate((element) =>
|
||||
getComputedStyle(element, '::after').getPropertyValue('animation-name'),
|
||||
),
|
||||
)
|
||||
.toBe('none')
|
||||
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 57 KiB |
@@ -11,10 +11,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,13 +27,13 @@ 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,
|
||||
reply_count: replyCount,
|
||||
retweet_count: 2,
|
||||
favorite_count: 3,
|
||||
conversation_id_str: conversationId,
|
||||
@@ -288,10 +290,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 +311,7 @@ const server = createServer(async (request, response) => {
|
||||
tweet(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
replyCount: noReplies ? 0 : 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -323,6 +332,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 +353,37 @@ const server = createServer(async (request, response) => {
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
]
|
||||
: [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(
|
||||
targetId,
|
||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||
'focus',
|
||||
{
|
||||
: focalTweetId === '3000'
|
||||
? [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(targetId, 'no replies', 'focus', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: focalTweetId,
|
||||
},
|
||||
),
|
||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
replyCount: 0,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
: [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(
|
||||
targetId,
|
||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||
'focus',
|
||||
{
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: focalTweetId,
|
||||
},
|
||||
),
|
||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
send(response, {
|
||||
data: {
|
||||
tweetResult: {
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
export class Base {
|
||||
readonly ambientLinksLocator: Locator
|
||||
readonly articlesLocator: Locator
|
||||
readonly continuationErrorLocator: Locator
|
||||
readonly endOfFeedLocator: Locator
|
||||
readonly loadingRailLocator: Locator
|
||||
readonly mutationButtonsLocator: Locator
|
||||
readonly mutationLinksLocator: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
|
||||
const mutationControlNames =
|
||||
/投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
|
||||
|
||||
this.ambientLinksLocator = page.getByRole('link', {
|
||||
name: ambientLinkNames,
|
||||
})
|
||||
this.articlesLocator = page.locator('article')
|
||||
this.continuationErrorLocator = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
this.endOfFeedLocator = page.getByText('これ以上の投稿はありません。')
|
||||
this.loadingRailLocator = page.locator('.loading-rail')
|
||||
this.mutationButtonsLocator = page.getByRole('button', {
|
||||
name: mutationControlNames,
|
||||
})
|
||||
this.mutationLinksLocator = page.getByRole('link', {
|
||||
name: mutationControlNames,
|
||||
})
|
||||
}
|
||||
|
||||
protected async navigateTo(path: string) {
|
||||
await this.page.goto(path)
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async tabTo(target: Locator) {
|
||||
await this.page.keyboard.press('Tab')
|
||||
await expect(target).toBeFocused()
|
||||
}
|
||||
|
||||
async retryContinuation() {
|
||||
await this.continuationErrorLocator
|
||||
.getByRole('button', { name: '再試行' })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class HomePage extends Base {
|
||||
readonly handleInputLocator: Locator
|
||||
readonly submitButtonLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.handleInputLocator = page.getByLabel('ハンドルまたはプロフィール URL')
|
||||
this.submitButtonLocator = page.getByRole('button', { name: '表示' })
|
||||
}
|
||||
|
||||
async goTo() {
|
||||
await this.navigateTo('/')
|
||||
}
|
||||
|
||||
async displayUser(handle: string) {
|
||||
await this.handleInputLocator.fill(handle)
|
||||
await this.submitButtonLocator.click()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Base } from './Base'
|
||||
|
||||
export class ListsPage extends Base {
|
||||
async goTo() {
|
||||
await this.navigateTo('/i/lists')
|
||||
}
|
||||
|
||||
async openList(name: RegExp | string) {
|
||||
await this.page.getByRole('link', { name }).click()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class SearchPage extends Base {
|
||||
readonly followingOnlyLocator: Locator
|
||||
readonly latestRankingLocator: Locator
|
||||
readonly queryInputLocator: Locator
|
||||
readonly searchButtonLocator: Locator
|
||||
readonly topRankingLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.followingOnlyLocator = page.getByLabel('フォロー中のみ')
|
||||
this.latestRankingLocator = page.getByLabel('最新順')
|
||||
this.queryInputLocator = page.getByLabel('検索語')
|
||||
this.searchButtonLocator = page.getByRole('button', { name: '検索' })
|
||||
this.topRankingLocator = page.getByLabel('人気順')
|
||||
}
|
||||
|
||||
async goTo() {
|
||||
await this.navigateTo('/search?q=&product=Latest&following=false')
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.queryInputLocator.fill(query)
|
||||
await this.searchButtonLocator.click()
|
||||
}
|
||||
|
||||
async searchTopFromFollowing(query: string) {
|
||||
await this.queryInputLocator.fill(query)
|
||||
await this.topRankingLocator.check()
|
||||
await this.followingOnlyLocator.check()
|
||||
await this.searchButtonLocator.click()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class ThreadPage extends Base {
|
||||
readonly headingLocator: Locator
|
||||
readonly selectedPostLabelLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.headingLocator = page.getByRole('heading', { name: '会話' })
|
||||
this.selectedPostLabelLocator = page.getByText('表示中の投稿')
|
||||
}
|
||||
|
||||
async goTo(handle: string, tweetId: string) {
|
||||
await this.navigateTo(`/${handle}/status/${tweetId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Base } from './Base'
|
||||
|
||||
export class UserPage extends Base {
|
||||
async goTo(handle: string) {
|
||||
await this.navigateTo(`/${handle}`)
|
||||
}
|
||||
|
||||
async openPostDetails(postText: string) {
|
||||
await this.page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: postText })
|
||||
.getByRole('link', { name: '詳細・スレッド' })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,84 @@
|
||||
{
|
||||
"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": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
"lastModified": 1783224372,
|
||||
"narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
||||
"rev": "d407951447dcd00442e97087bf374aad70c04cea",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1784796856,
|
||||
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -18,7 +90,9 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
"agent-skills-nix": "agent-skills-nix",
|
||||
"hallmark": "hallmark",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
description = "Intentional X reader";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
inputs.agent-skills-nix.url = "github:Kyure-A/agent-skills-nix";
|
||||
inputs.hallmark = {
|
||||
url = "github:Nutlope/hallmark";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, ... }:
|
||||
outputs = inputs@{ self, nixpkgs, ... }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
@@ -17,7 +22,7 @@
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pkgs.pnpm_11;
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE=";
|
||||
hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
@@ -55,10 +60,34 @@
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let pkgs = import nixpkgs { inherit system; };
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
agentLib = inputs.agent-skills-nix.lib.agent-skills;
|
||||
sources.hallmark = {
|
||||
path = inputs.hallmark;
|
||||
subdir = "skills";
|
||||
};
|
||||
catalog = agentLib.discoverCatalog sources;
|
||||
allowlist = agentLib.allowlistFor {
|
||||
inherit catalog sources;
|
||||
enableAll = true;
|
||||
};
|
||||
selection = agentLib.selectSkills {
|
||||
inherit catalog allowlist sources;
|
||||
skills = { };
|
||||
};
|
||||
bundle = agentLib.mkBundle { inherit pkgs selection; };
|
||||
localTargets = builtins.mapAttrs (_: target:
|
||||
target // {
|
||||
enable = true;
|
||||
}) agentLib.defaultLocalTargets;
|
||||
in {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [ nodejs_22 pnpm chromium ];
|
||||
shellHook = agentLib.mkShellHook {
|
||||
inherit pkgs bundle;
|
||||
targets = localTargets;
|
||||
};
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE =
|
||||
"${pkgs.chromium}/bin/chromium";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
export default {
|
||||
entry: [
|
||||
'playwright.config.ts',
|
||||
'tests/e2e/**/*.spec.ts',
|
||||
'tests/e2e/mock-relay.mjs',
|
||||
],
|
||||
entry: ['playwright.config.ts', 'e2e/**/*.test.ts', 'e2e/mock-relay.mjs'],
|
||||
playwright: false,
|
||||
} satisfies KnipConfig
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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} node e2e/mock-relay.mjs`,
|
||||
port: relayPort,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -36,6 +36,9 @@ importers:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@axe-core/playwright':
|
||||
specifier: 4.11.1
|
||||
version: 4.11.1([email protected])
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
version: 2.5.3
|
||||
@@ -99,6 +102,11 @@ packages:
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@axe-core/[email protected]':
|
||||
resolution: {integrity: sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==}
|
||||
peerDependencies:
|
||||
playwright-core: '>= 1.0.0'
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1074,6 +1082,10 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
||||
|
||||
@@ -1982,6 +1994,11 @@ snapshots:
|
||||
|
||||
'@asamuzakjp/[email protected]': {}
|
||||
|
||||
'@axe-core/[email protected]([email protected])':
|
||||
dependencies:
|
||||
axe-core: 4.11.4
|
||||
playwright-core: 1.61.1
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
@@ -2843,6 +2860,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
|
||||
@@ -10,7 +10,7 @@ export function AppShell({
|
||||
return (
|
||||
<main className="app">
|
||||
<header className="app-header">
|
||||
<p className="brand">TWITTER LITE</p>
|
||||
<p className="brand">Twitter Lite</p>
|
||||
<nav aria-label="閲覧方法" className="tabs">
|
||||
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||
ユーザー
|
||||
|
||||
@@ -55,6 +55,10 @@ describe('PostCard', () => {
|
||||
expect(container.querySelectorAll('video')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
|
||||
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
|
||||
expect(container.querySelectorAll('video')[0]).toHaveAttribute(
|
||||
'src',
|
||||
'https://media.invalid/video.mp4',
|
||||
)
|
||||
expect(screen.getByText('Article title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Preview')).toBeInTheDocument()
|
||||
expect(screen.getByText('quoted')).toBeInTheDocument()
|
||||
@@ -80,12 +84,39 @@ describe('PostCard', () => {
|
||||
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('decodes HTML entities in the post body before linking URLs', () => {
|
||||
render(
|
||||
<PostCard
|
||||
post={{
|
||||
...richPost,
|
||||
text: 'A > B & C 'D' https://example.com/?a=1&b=2',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText((_, element) =>
|
||||
Boolean(
|
||||
element?.classList.contains('post-text') &&
|
||||
element.textContent ===
|
||||
"A > B & C 'D' https://example.com/?a=1&b=2",
|
||||
),
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://example.com/?a=1&b=2' }),
|
||||
).toHaveAttribute('href', 'https://example.com/?a=1&b=2')
|
||||
})
|
||||
|
||||
it('links deliberately to the internal detail page', () => {
|
||||
render(<PostCard post={richPost} />)
|
||||
|
||||
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
|
||||
expect(detail).toHaveAttribute('href', '/yuta/status/123')
|
||||
expect(detail.parentElement).toHaveClass('post-actions')
|
||||
expect(
|
||||
screen.getByRole('link', { name: '詳細・スレッド' }),
|
||||
).toHaveAttribute('href', '/yuta/status/123')
|
||||
screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
|
||||
).toBe(detail.parentElement)
|
||||
})
|
||||
|
||||
it('marks the focal post without linking to its current page', () => {
|
||||
@@ -103,4 +134,26 @@ describe('PostCard', () => {
|
||||
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
||||
expect(container.querySelector('time')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not try to play a poster image when Bird has no video URL', () => {
|
||||
const { container } = render(
|
||||
<PostCard
|
||||
post={{
|
||||
...richPost,
|
||||
media: [
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://media.invalid/video-poster.jpg',
|
||||
previewUrl: 'https://media.invalid/video-preview.jpg',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('video')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('img', { name: '動画のプレビュー' }),
|
||||
).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,19 +3,39 @@ import type { Post } from '../types'
|
||||
import { PostMedia } from './post-media'
|
||||
|
||||
const URL = /(https?:\/\/[^\s]+)/g
|
||||
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
''': "'",
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
}
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Tokyo',
|
||||
})
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
|
||||
if (!decimal && !hexadecimal) {
|
||||
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
|
||||
}
|
||||
|
||||
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
|
||||
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
|
||||
})
|
||||
}
|
||||
|
||||
function linkedText(text: string): ReactNode[] {
|
||||
const decoded = decodeHtmlEntities(text)
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
for (const match of text.matchAll(URL)) {
|
||||
for (const match of decoded.matchAll(URL)) {
|
||||
const href = match[0]
|
||||
const start = match.index
|
||||
if (start > cursor) parts.push(text.slice(cursor, start))
|
||||
if (start > cursor) parts.push(decoded.slice(cursor, start))
|
||||
parts.push(
|
||||
<a
|
||||
href={href}
|
||||
@@ -28,7 +48,7 @@ function linkedText(text: string): ReactNode[] {
|
||||
)
|
||||
cursor = start + href.length
|
||||
}
|
||||
if (cursor < text.length) parts.push(text.slice(cursor))
|
||||
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -90,16 +110,18 @@ export function PostCard({
|
||||
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
||||
{post.likeCount ?? 0}
|
||||
</span>
|
||||
{!current ? (
|
||||
<a
|
||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||
>
|
||||
詳細・スレッド
|
||||
<span className="post-actions">
|
||||
{!current ? (
|
||||
<a
|
||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||
>
|
||||
詳細・スレッド
|
||||
</a>
|
||||
) : null}
|
||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
) : null}
|
||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,6 +168,64 @@ describe('loadThreadPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose a continuation when the focal post has no replies', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: '100',
|
||||
replyCount: 0,
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
page: { nextCursor: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'an empty final page',
|
||||
tweets: [],
|
||||
returnedCursor: 'thread-final',
|
||||
},
|
||||
{
|
||||
name: 'the same cursor again',
|
||||
tweets: [
|
||||
{
|
||||
id: '101',
|
||||
text: 'last reply',
|
||||
author: { username: 'reply', name: 'Reply' },
|
||||
},
|
||||
],
|
||||
returnedCursor: 'thread-next',
|
||||
},
|
||||
])('stops after $name', async ({ tweets, returnedCursor }) => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getThreadPaged).mockResolvedValue({
|
||||
success: true,
|
||||
tweets,
|
||||
nextCursor: returnedCursor,
|
||||
})
|
||||
|
||||
const result = await loadThreadPage(client, {
|
||||
tweetId: '123',
|
||||
conversationId: '100',
|
||||
cursor: 'thread-next',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
page: { nextCursor: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
it('removes raw data from focal, conversation, and quotes', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
|
||||
@@ -215,14 +215,22 @@ export async function loadThreadPage(
|
||||
if (!result.success) {
|
||||
return upstreamFailure(result.error, 'post')
|
||||
}
|
||||
const tweets = result.tweets ?? []
|
||||
const nextCursor =
|
||||
result.nextCursor &&
|
||||
tweets.length > 0 &&
|
||||
result.nextCursor !== input.cursor &&
|
||||
focalPost?.replyCount !== 0
|
||||
? result.nextCursor
|
||||
: undefined
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
page: {
|
||||
tweets: (result.tweets ?? []).map(publicPost),
|
||||
tweets: tweets.map(publicPost),
|
||||
focalPost,
|
||||
conversationId,
|
||||
nextCursor: result.nextCursor,
|
||||
nextCursor,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{ name: 'referrer', content: 'no-referrer' },
|
||||
{ title: 'Twitter Lite' },
|
||||
],
|
||||
links: [
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ListPage({ listId = '' }: { listId?: string }) {
|
||||
|
||||
return (
|
||||
<AppShell active="list">
|
||||
<h1>どのリストを読みますか?</h1>
|
||||
<h1 className="reader-title-long">どのリストを読みますか?</h1>
|
||||
<p className="intro">
|
||||
自分のリストを選ぶか、リストURLを入力してください。
|
||||
</p>
|
||||
|
||||
@@ -1,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,574 @@ 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;
|
||||
align-items: start;
|
||||
gap: var(--space-lg);
|
||||
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-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;
|
||||
}
|
||||
|
||||
.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 > 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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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-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-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.tabs a {
|
||||
justify-content: flex-start;
|
||||
padding-inline: var(--space-md);
|
||||
border-bottom: 0;
|
||||
border-left: var(--rule-active) solid transparent;
|
||||
}
|
||||
|
||||
.tabs a[aria-current="page"] {
|
||||
border-bottom: 0;
|
||||
border-left-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.reader {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 2;
|
||||
max-width: none;
|
||||
padding: var(--space-2xl) clamp(var(--space-xl), 6vw, var(--space-2xl))
|
||||
var(--space-3xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tabs a,
|
||||
.field input,
|
||||
.intent-form button,
|
||||
.list-picker-state button,
|
||||
.state button,
|
||||
.segmented label {
|
||||
transition-duration: var(--dur-instant);
|
||||
transition-property: opacity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import { expect, type Locator, test } from '@playwright/test'
|
||||
|
||||
let consoleErrors: string[]
|
||||
let pageErrors: string[]
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
consoleErrors = []
|
||||
pageErrors = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
expect(consoleErrors, 'browser console errors').toEqual([])
|
||||
expect(pageErrors, 'uncaught browser errors').toEqual([])
|
||||
})
|
||||
|
||||
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
|
||||
const mutationControlNames = /投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
|
||||
|
||||
async function submitSearch(
|
||||
page: import('@playwright/test').Page,
|
||||
query: string,
|
||||
) {
|
||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
||||
await expect(page.locator('article')).toHaveCount(0)
|
||||
await page.getByLabel('検索語').fill(query)
|
||||
await page.getByRole('button', { name: '検索' }).click()
|
||||
}
|
||||
|
||||
async function openReader(page: import('@playwright/test').Page, path: string) {
|
||||
await page.goto(path)
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async function tabTo(page: import('@playwright/test').Page, target: Locator) {
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(target).toBeFocused()
|
||||
}
|
||||
|
||||
test('requires intent and infinitely loads a user timeline', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await expect(page.locator('article')).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
|
||||
0,
|
||||
)
|
||||
|
||||
await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta')
|
||||
await page.getByRole('button', { name: '表示' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/yuta$/)
|
||||
await expect(page.getByText('user page 1')).toBeVisible()
|
||||
await expect(page.getByText('user page 2')).toBeVisible()
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(page).toHaveScreenshot('mist-user.png', {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('selects an authenticated list and infinitely loads its timeline', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/i/lists')
|
||||
await expect(page.locator('article')).toHaveCount(0)
|
||||
|
||||
await page.getByRole('link', { name: /My E2E List.*10 メンバー/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/i\/lists\/1234567890$/)
|
||||
await expect(page.getByText('list page 1')).toBeVisible()
|
||||
await expect(page.getByText('list page 2')).toBeVisible()
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens a card detail and infinitely loads its conversation', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/yuta')
|
||||
const sourceCard = page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: 'user page 1' })
|
||||
await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/yuta\/status\/1001$/)
|
||||
await expect(page.getByRole('heading', { name: '会話' })).toBeVisible()
|
||||
await expect(page.getByText('表示中の投稿')).toBeVisible()
|
||||
await expect(page.getByText('user page 1')).toHaveCount(1)
|
||||
await expect(page.getByText('thread root')).toBeVisible()
|
||||
await expect(page.getByText('thread page 1')).toBeVisible()
|
||||
await expect(page.getByText('thread page 2')).toBeVisible()
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(page).toHaveScreenshot('mist-thread.png', {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves a reply root and retries only the failed continuation', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/yuta/status/2001')
|
||||
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
await expect(page.getByText('thread root')).toBeVisible()
|
||||
await expect(page.getByText('thread page 1')).toBeVisible()
|
||||
const alert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
await expect(alert).toHaveCount(1)
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
|
||||
await alert.getByRole('button', { name: '再試行' }).click()
|
||||
|
||||
await expect(page.getByText('thread page 2')).toBeVisible()
|
||||
await expect(alert).toHaveCount(0)
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('searches Top posts from followed accounts', async ({ page }) => {
|
||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
||||
await expect(page.locator('article')).toHaveCount(0)
|
||||
await page.getByLabel('検索語').fill('AI lang:ja')
|
||||
await page.getByLabel('人気順').check()
|
||||
await page.getByLabel('フォロー中のみ').check()
|
||||
await expect(page.getByLabel('フォロー中のみ')).toBeChecked()
|
||||
await page.getByRole('button', { name: '検索' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/following=true/)
|
||||
await expect(page.getByText('Top · follows page 1')).toBeVisible()
|
||||
await expect(page.getByText('Top · follows page 2')).toBeVisible()
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
||||
test('retains the first page through a pagination error and retries explicitly', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await submitSearch(page, `retry-${testInfo.project.name}`)
|
||||
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
const laterPageAlert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
await expect(laterPageAlert).toHaveCount(1)
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
|
||||
|
||||
await laterPageAlert.getByRole('button', { name: '再試行' }).click()
|
||||
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
await expect(laterPageAlert).toHaveCount(0)
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
||||
|
||||
await tabTo(page, page.getByRole('link', { name: 'ユーザー' }))
|
||||
await tabTo(page, page.getByRole('link', { name: '検索' }))
|
||||
await tabTo(page, page.getByRole('link', { name: 'リスト' }))
|
||||
await tabTo(page, page.getByLabel('検索語'))
|
||||
const ranking = page.getByLabel('最新順')
|
||||
await expect(ranking).toBeChecked()
|
||||
await tabTo(page, ranking)
|
||||
await tabTo(page, page.getByLabel('フォロー中のみ'))
|
||||
await tabTo(page, page.getByRole('button', { name: '検索' }))
|
||||
|
||||
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
|
||||
0,
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('button', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
page.getByRole('link', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('removes loading rail animation for reduced motion', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await submitSearch(page, `slow-${testInfo.project.name}`)
|
||||
|
||||
const rail = page.locator('.loading-rail')
|
||||
await expect(rail).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
rail.evaluate((element) =>
|
||||
getComputedStyle(element, '::after').getPropertyValue('animation-name'),
|
||||
),
|
||||
)
|
||||
.toBe('none')
|
||||
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,57 @@
|
||||
/* 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;
|
||||
|
||||
--space-3xs: 0.125rem;
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2.5rem;
|
||||
--space-2xl: 4rem;
|
||||
--space-3xl: 6rem;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-md: 1.25rem;
|
||||
--text-lg: 1.5625rem;
|
||||
--text-xl: 1.9531rem;
|
||||
--text-display-s: clamp(1.75rem, 7.4vw + 0.25rem, 2.75rem);
|
||||
--text-display-long: clamp(1.5rem, 5.5vw + 0.4rem, 2.75rem);
|
||||
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--dur-instant: 100ms;
|
||||
--dur-short: 200ms;
|
||||
--dur-medium: 420ms;
|
||||
|
||||
--rule-hairline: 1px;
|
||||
--rule-active: 2px;
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.625rem;
|
||||
--radius-lg: 1rem;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "e2e/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
@@ -6,6 +6,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
exclude: [...configDefaults.exclude, 'tests/e2e/**'],
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
},
|
||||
})
|
||||
|
||||