Author SHA1 Message Date
yuta 88730dbec9 Refactor E2E tests with page objects 2026-07-26 13:41:57 +09:00
44 changed files with 94 additions and 1165 deletions
-1
View File
@@ -1,6 +1,5 @@
.superpowers/
.worktrees/
/.*/skills/
.env*
!.env.example
.output/
-9
View File
@@ -1,9 +0,0 @@
[
{
"date": "2026-07-26",
"macrostructure": "Workbench",
"theme": "Garden",
"enrichment": "none",
"brief": "Personal read-only research client with a soft, deliberate workspace"
}
]
-9
View File
@@ -1,9 +0,0 @@
{
"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"]
}
+2 -7
View File
@@ -42,9 +42,6 @@ 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`:
@@ -71,10 +68,8 @@ nix develop -c pnpm start
```
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
the dev shell. Browser tests are organized under `e2e/`: page object models
and fixtures are shared by integration flows and Axe checks for WCAG 2.0/2.1
A and AA. `test:live` is an explicit, opt-in smoke test that performs exactly
one read-only Top search using the configured relay.
the dev shell. `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
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('home page has no accessibility violations', async ({
a11y,
homePage,
}) => {
await homePage.goTo()
await expect(homePage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-24
View File
@@ -1,24 +0,0 @@
import { expect, test } from '../fixtures'
test('list picker has no accessibility violations', async ({
a11y,
listsPage,
}) => {
await listsPage.goTo()
await expect(listsPage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
test('list timeline has no accessibility violations', async ({
a11y,
listsPage,
}) => {
await listsPage.goTo()
await listsPage.openList(/My E2E List.*10 メンバー/)
await expect(listsPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-24
View File
@@ -1,24 +0,0 @@
import { expect, test } from '../fixtures'
test('empty search page has no accessibility violations', async ({
a11y,
searchPage,
}) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
test('search results have no accessibility violations', async ({
a11y,
searchPage,
}) => {
await searchPage.goTo()
await searchPage.search('AI lang:ja')
await expect(searchPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('conversation has no accessibility violations', async ({
a11y,
threadPage,
}) => {
await threadPage.goTo('yuta', '1001')
await expect(threadPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
-12
View File
@@ -1,12 +0,0 @@
import { expect, test } from '../fixtures'
test('user timeline has no accessibility violations', async ({
a11y,
userPage,
}) => {
await userPage.goTo('yuta')
await expect(userPage.endOfFeedLocator).toBeVisible()
const result = await a11y().analyze()
expect(result.violations).toEqual([])
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Generated
+4 -78
View File
@@ -1,84 +1,12 @@
{
"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=",
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "NixOS",
"repo": "nixpkgs",
"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",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github"
},
"original": {
@@ -90,9 +18,7 @@
},
"root": {
"inputs": {
"agent-skills-nix": "agent-skills-nix",
"hallmark": "hallmark",
"nixpkgs": "nixpkgs_2"
"nixpkgs": "nixpkgs"
}
}
},
+3 -32
View File
@@ -2,13 +2,8 @@
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 = inputs@{ self, nixpkgs, ... }:
outputs = { self, nixpkgs, ... }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
@@ -22,7 +17,7 @@
inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_11;
fetcherVersion = 4;
hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A=";
hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE=";
};
nativeBuildInputs = with pkgs; [
@@ -60,34 +55,10 @@
});
devShells = forAllSystems (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;
let pkgs = import nixpkgs { inherit system; };
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";
+5 -1
View File
@@ -1,6 +1,10 @@
import type { KnipConfig } from 'knip'
export default {
entry: ['playwright.config.ts', 'e2e/**/*.test.ts', 'e2e/mock-relay.mjs'],
entry: [
'playwright.config.ts',
'tests/e2e/**/*.spec.ts',
'tests/e2e/mock-relay.mjs',
],
playwright: false,
} satisfies KnipConfig
-1
View File
@@ -37,7 +37,6 @@
"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",
+2 -2
View File
@@ -11,7 +11,7 @@ if (!chromiumExecutable) {
}
export default defineConfig({
testDir: './e2e',
testDir: './tests/e2e',
fullyParallel: false,
workers: 1,
use: {
@@ -22,7 +22,7 @@ export default defineConfig({
},
webServer: [
{
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node e2e/mock-relay.mjs`,
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
port: relayPort,
reuseExistingServer: false,
timeout: 120_000,
-19
View File
@@ -36,9 +36,6 @@ 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
@@ -102,11 +99,6 @@ 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'}
@@ -1082,10 +1074,6 @@ 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==}
@@ -1994,11 +1982,6 @@ 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
@@ -2860,8 +2843,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@babel/core': 7.29.7
+1 -1
View File
@@ -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,10 +55,6 @@ 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()
@@ -84,39 +80,12 @@ 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: '元の投稿を開く' }).parentElement,
).toBe(detail.parentElement)
screen.getByRole('link', { name: '詳細・スレッド' }),
).toHaveAttribute('href', '/yuta/status/123')
})
it('marks the focal post without linking to its current page', () => {
@@ -134,26 +103,4 @@ 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')
})
})
+12 -34
View File
@@ -3,39 +3,19 @@ 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 decoded.matchAll(URL)) {
for (const match of text.matchAll(URL)) {
const href = match[0]
const start = match.index
if (start > cursor) parts.push(decoded.slice(cursor, start))
if (start > cursor) parts.push(text.slice(cursor, start))
parts.push(
<a
href={href}
@@ -48,7 +28,7 @@ function linkedText(text: string): ReactNode[] {
)
cursor = start + href.length
}
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
if (cursor < text.length) parts.push(text.slice(cursor))
return parts
}
@@ -110,18 +90,16 @@ export function PostCard({
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
{post.likeCount ?? 0}
</span>
<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">
元の投稿を開く
{!current ? (
<a
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
>
詳細・スレッド
</a>
</span>
) : null}
<a href={original} rel="noreferrer noopener" target="_blank">
元の投稿を開く
</a>
</footer>
) : null}
</article>
+16 -20
View File
@@ -7,34 +7,30 @@ export function PostMedia({ media }: { media: Media[] }) {
return (
<div className="media-grid">
{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 (
{media.map((item) =>
item.type === 'photo' ? (
<img
alt="投稿画像"
className="media"
height={item.height}
key={item.url}
loading="lazy"
src={item.url}
width={item.width}
/>
) : (
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
<video
className="media"
controls
key={item.videoUrl}
key={item.videoUrl ?? item.url}
loop={item.type === 'animated_gif'}
poster={item.previewUrl ?? item.url}
preload="metadata"
src={item.videoUrl}
src={item.videoUrl ?? item.url}
/>
)
})}
),
)}
</div>
)
}
-58
View File
@@ -168,64 +168,6 @@ 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({
+2 -10
View File
@@ -215,22 +215,14 @@ 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: tweets.map(publicPost),
tweets: (result.tweets ?? []).map(publicPost),
focalPost,
conversationId,
nextCursor,
nextCursor: result.nextCursor,
},
}
} catch (error) {
-1
View File
@@ -16,7 +16,6 @@ export const Route = createRootRouteWithContext<RouterContext>()({
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{ name: 'referrer', content: 'no-referrer' },
{ title: 'Twitter Lite' },
],
links: [
+1 -1
View File
@@ -31,7 +31,7 @@ export function ListPage({ listId = '' }: { listId?: string }) {
return (
<AppShell active="list">
<h1 className="reader-title-long">どのリストを読みますか?</h1>
<h1>どのリストを読みますか?</h1>
<p className="intro">
自分のリストを選ぶか、リストURLを入力してください。
</p>
+21 -611
View File
@@ -1,25 +1,18 @@
/* 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: 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);
--canvas: #dfe7e9;
--surface: #f8faf9;
--paper: #fff;
--ink: #1e3238;
--secondary: #506970;
--accent: #255f6e;
--accent-hover: #194d59;
--border: #c1cfd2;
--control-border: #748b91;
--error: #8a3034;
color: var(--ink);
background: var(--canvas);
color-scheme: light;
font-family: var(--font-body);
font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
font-synthesis: none;
}
@@ -29,14 +22,12 @@
html {
min-width: 320px;
overflow-x: clip;
background: var(--canvas);
}
body {
min-height: 100vh;
margin: 0;
overflow-x: clip;
}
button,
@@ -49,19 +40,9 @@ 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: var(--rule-active) solid var(--color-focus);
outline-offset: var(--space-2xs);
outline: 3px solid var(--accent);
outline-offset: 3px;
}
.app {
@@ -72,7 +53,7 @@ a[aria-disabled="true"] {
border: 1px solid var(--border);
border-radius: 1.15rem;
background: var(--surface);
box-shadow: 0 var(--space-lg) var(--space-2xl) var(--color-shadow);
box-shadow: 0 1.2rem 3rem rgb(30 50 56 / 10%);
}
.app-header {
@@ -182,7 +163,7 @@ a[aria-disabled="true"] {
.list-choices a[aria-current="page"] {
border-color: var(--accent);
background: color-mix(in oklch, var(--accent) 7%, var(--paper));
background: color-mix(in srgb, var(--accent) 7%, var(--paper));
box-shadow: inset 3px 0 var(--accent);
}
@@ -210,7 +191,7 @@ a[aria-disabled="true"] {
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.9rem;
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
}
.field {
@@ -371,7 +352,7 @@ a[aria-disabled="true"] {
.current-post {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 25%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent);
}
.post-header {
@@ -420,7 +401,7 @@ a[aria-disabled="true"] {
margin-top: 0.8rem;
overflow: hidden;
border-radius: 0.75rem;
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
}
.media-grid > :only-child {
@@ -431,7 +412,7 @@ a[aria-disabled="true"] {
display: block;
width: 100%;
max-height: 32rem;
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
}
img.media {
@@ -522,7 +503,7 @@ video.media {
transparent 100%
);
content: "";
animation: rail 1.1s var(--ease-in-out) infinite;
animation: rail 1.1s ease-in-out infinite;
}
@keyframes rail {
@@ -604,7 +585,7 @@ video.media {
}
.media-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-columns: 1fr;
}
.post-footer {
@@ -622,574 +603,3 @@ 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;
}
}
-10
View File
@@ -1,4 +1,3 @@
import AxeBuilder from '@axe-core/playwright'
import { test as base } from '@playwright/test'
import { HomePage } from './models/HomePage'
import { ListsPage } from './models/ListsPage'
@@ -7,7 +6,6 @@ import { ThreadPage } from './models/ThreadPage'
import { UserPage } from './models/UserPage'
type Fixtures = {
a11y: () => AxeBuilder
homePage: HomePage
listsPage: ListsPage
searchPage: SearchPage
@@ -16,14 +14,6 @@ type Fixtures = {
}
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)),
+20 -45
View File
@@ -11,12 +11,10 @@ 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()
@@ -27,13 +25,13 @@ const tweetResult = (
id,
text,
username = 'yuta',
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
{ conversationId = id, inReplyTo } = {},
) => ({
rest_id: id,
legacy: {
full_text: text,
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
reply_count: replyCount,
reply_count: 1,
retweet_count: 2,
favorite_count: 3,
conversation_id_str: conversationId,
@@ -290,16 +288,10 @@ const server = createServer(async (request, response) => {
const targetRoot = rootByTarget.get(focalTweetId)
if (targetRoot && variables.cursor === undefined) {
const noReplies = focalTweetId === '3001'
const text = noReplies
? 'no replies'
: focalTweetId === '1001'
? 'user page 1'
: 'retry focal'
const text = focalTweetId === '1001' ? 'user page 1' : 'retry focal'
const result = tweetResult(focalTweetId, text, 'focus', {
conversationId: targetRoot,
inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
})
send(response, {
data: {
@@ -311,7 +303,6 @@ const server = createServer(async (request, response) => {
tweet(focalTweetId, text, 'focus', {
conversationId: targetRoot,
inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
}),
],
},
@@ -332,10 +323,6 @@ 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' &&
@@ -353,37 +340,25 @@ const server = createServer(async (request, response) => {
inReplyTo: targetId,
}),
]
: focalTweetId === '3000'
? [
tweet(focalTweetId, 'thread root', 'root', {
conversationId: focalTweetId,
}),
tweet(targetId, 'no replies', 'focus', {
: [
tweet(focalTweetId, 'thread root', 'root', {
conversationId: focalTweetId,
}),
tweet(
targetId,
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
'focus',
{
conversationId: focalTweetId,
inReplyTo: focalTweetId,
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),
]
},
),
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
conversationId: focalTweetId,
inReplyTo: targetId,
}),
cursor(expectedCursor),
]
send(response, {
data: {
tweetResult: {
@@ -1,4 +1,4 @@
import { expect, test } from '../fixtures'
import { expect, test } from './fixtures'
let consoleErrors: string[]
let pageErrors: string[]
@@ -24,10 +24,6 @@ test('requires intent and infinitely loads a user timeline', async ({
}) => {
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)
@@ -100,18 +96,6 @@ test('resolves a reply root and retries only the failed continuation', async ({
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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

-57
View File
@@ -1,57 +0,0 @@
/* 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 -1
View File
@@ -1,5 +1,5 @@
{
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "e2e/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.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, 'e2e/**'],
exclude: [...configDefaults.exclude, 'tests/e2e/**'],
},
})