Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01d808381b | ||
|
|
c43278f785 | ||
|
|
1794255ebb | ||
|
|
24939e6f00 | ||
|
|
f5274195b9 | ||
|
|
8679a850e5 |
@@ -1,5 +1,6 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
/.*/skills/
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
.output/
|
.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
|
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
|
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
|
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same
|
||||||
values in the process environment before `start` or `test:live`:
|
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
|
`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
|
the dev shell. Browser tests are organized under `e2e/`: page object models
|
||||||
exactly one read-only Top search using the configured relay.
|
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
|
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.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([])
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright'
|
||||||
import { test as base } from '@playwright/test'
|
import { test as base } from '@playwright/test'
|
||||||
import { HomePage } from './models/HomePage'
|
import { HomePage } from './models/HomePage'
|
||||||
import { ListsPage } from './models/ListsPage'
|
import { ListsPage } from './models/ListsPage'
|
||||||
@@ -6,6 +7,7 @@ import { ThreadPage } from './models/ThreadPage'
|
|||||||
import { UserPage } from './models/UserPage'
|
import { UserPage } from './models/UserPage'
|
||||||
|
|
||||||
type Fixtures = {
|
type Fixtures = {
|
||||||
|
a11y: () => AxeBuilder
|
||||||
homePage: HomePage
|
homePage: HomePage
|
||||||
listsPage: ListsPage
|
listsPage: ListsPage
|
||||||
searchPage: SearchPage
|
searchPage: SearchPage
|
||||||
@@ -14,6 +16,14 @@ type Fixtures = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<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)),
|
homePage: ({ page }, use) => use(new HomePage(page)),
|
||||||
listsPage: ({ page }, use) => use(new ListsPage(page)),
|
listsPage: ({ page }, use) => use(new ListsPage(page)),
|
||||||
searchPage: ({ page }, use) => use(new SearchPage(page)),
|
searchPage: ({ page }, use) => use(new SearchPage(page)),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from './fixtures'
|
import { expect, test } from '../fixtures'
|
||||||
|
|
||||||
let consoleErrors: string[]
|
let consoleErrors: string[]
|
||||||
let pageErrors: string[]
|
let pageErrors: string[]
|
||||||
@@ -24,6 +24,10 @@ test('requires intent and infinitely loads a user timeline', async ({
|
|||||||
}) => {
|
}) => {
|
||||||
await homePage.goTo()
|
await homePage.goTo()
|
||||||
await expect(page).toHaveURL(/\/$/)
|
await expect(page).toHaveURL(/\/$/)
|
||||||
|
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute(
|
||||||
|
'content',
|
||||||
|
'no-referrer',
|
||||||
|
)
|
||||||
await expect(homePage.articlesLocator).toHaveCount(0)
|
await expect(homePage.articlesLocator).toHaveCount(0)
|
||||||
await expect(homePage.ambientLinksLocator).toHaveCount(0)
|
await expect(homePage.ambientLinksLocator).toHaveCount(0)
|
||||||
|
|
||||||
@@ -96,6 +100,18 @@ test('resolves a reply root and retries only the failed continuation', async ({
|
|||||||
await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
|
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 ({
|
test('searches Top posts from followed accounts', async ({
|
||||||
page,
|
page,
|
||||||
searchPage,
|
searchPage,
|
||||||
|
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([
|
const rootByTarget = new Map([
|
||||||
['1001', '1000'],
|
['1001', '1000'],
|
||||||
['2001', '2000'],
|
['2001', '2000'],
|
||||||
|
['3001', '3000'],
|
||||||
])
|
])
|
||||||
const targetByRoot = new Map([
|
const targetByRoot = new Map([
|
||||||
['1000', '1001'],
|
['1000', '1001'],
|
||||||
['2000', '2001'],
|
['2000', '2001'],
|
||||||
|
['3000', '3001'],
|
||||||
])
|
])
|
||||||
const failedThreadRoots = new Set()
|
const failedThreadRoots = new Set()
|
||||||
|
|
||||||
@@ -25,13 +27,13 @@ const tweetResult = (
|
|||||||
id,
|
id,
|
||||||
text,
|
text,
|
||||||
username = 'yuta',
|
username = 'yuta',
|
||||||
{ conversationId = id, inReplyTo } = {},
|
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
|
||||||
) => ({
|
) => ({
|
||||||
rest_id: id,
|
rest_id: id,
|
||||||
legacy: {
|
legacy: {
|
||||||
full_text: text,
|
full_text: text,
|
||||||
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
||||||
reply_count: 1,
|
reply_count: replyCount,
|
||||||
retweet_count: 2,
|
retweet_count: 2,
|
||||||
favorite_count: 3,
|
favorite_count: 3,
|
||||||
conversation_id_str: conversationId,
|
conversation_id_str: conversationId,
|
||||||
@@ -288,10 +290,16 @@ const server = createServer(async (request, response) => {
|
|||||||
|
|
||||||
const targetRoot = rootByTarget.get(focalTweetId)
|
const targetRoot = rootByTarget.get(focalTweetId)
|
||||||
if (targetRoot && variables.cursor === undefined) {
|
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', {
|
const result = tweetResult(focalTweetId, text, 'focus', {
|
||||||
conversationId: targetRoot,
|
conversationId: targetRoot,
|
||||||
inReplyTo: targetRoot,
|
inReplyTo: targetRoot,
|
||||||
|
replyCount: noReplies ? 0 : 1,
|
||||||
})
|
})
|
||||||
send(response, {
|
send(response, {
|
||||||
data: {
|
data: {
|
||||||
@@ -303,6 +311,7 @@ const server = createServer(async (request, response) => {
|
|||||||
tweet(focalTweetId, text, 'focus', {
|
tweet(focalTweetId, text, 'focus', {
|
||||||
conversationId: targetRoot,
|
conversationId: targetRoot,
|
||||||
inReplyTo: targetRoot,
|
inReplyTo: targetRoot,
|
||||||
|
replyCount: noReplies ? 0 : 1,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -323,6 +332,10 @@ const server = createServer(async (request, response) => {
|
|||||||
fail(response, 400, 'invalid thread cursor')
|
fail(response, 400, 'invalid thread cursor')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (variables.cursor && focalTweetId === '3000') {
|
||||||
|
fail(response, 500, 'zero-reply thread must not request a continuation')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
variables.cursor &&
|
variables.cursor &&
|
||||||
focalTweetId === '2000' &&
|
focalTweetId === '2000' &&
|
||||||
@@ -340,25 +353,37 @@ const server = createServer(async (request, response) => {
|
|||||||
inReplyTo: targetId,
|
inReplyTo: targetId,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: [
|
: focalTweetId === '3000'
|
||||||
tweet(focalTweetId, 'thread root', 'root', {
|
? [
|
||||||
conversationId: focalTweetId,
|
tweet(focalTweetId, 'thread root', 'root', {
|
||||||
}),
|
conversationId: focalTweetId,
|
||||||
tweet(
|
}),
|
||||||
targetId,
|
tweet(targetId, 'no replies', 'focus', {
|
||||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
|
||||||
'focus',
|
|
||||||
{
|
|
||||||
conversationId: focalTweetId,
|
conversationId: focalTweetId,
|
||||||
inReplyTo: focalTweetId,
|
inReplyTo: focalTweetId,
|
||||||
},
|
replyCount: 0,
|
||||||
),
|
}),
|
||||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
cursor(expectedCursor),
|
||||||
conversationId: focalTweetId,
|
]
|
||||||
inReplyTo: targetId,
|
: [
|
||||||
}),
|
tweet(focalTweetId, 'thread root', 'root', {
|
||||||
cursor(expectedCursor),
|
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, {
|
send(response, {
|
||||||
data: {
|
data: {
|
||||||
tweetResult: {
|
tweetResult: {
|
||||||
@@ -1,12 +1,84 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783776592,
|
"lastModified": 1783224372,
|
||||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
"narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"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"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -18,7 +90,9 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs"
|
"agent-skills-nix": "agent-skills-nix",
|
||||||
|
"hallmark": "hallmark",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
description = "Intentional X reader";
|
description = "Intentional X reader";
|
||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
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
|
let
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||||
@@ -17,7 +22,7 @@
|
|||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
pnpm = pkgs.pnpm_11;
|
pnpm = pkgs.pnpm_11;
|
||||||
fetcherVersion = 4;
|
fetcherVersion = 4;
|
||||||
hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE=";
|
hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
@@ -55,10 +60,34 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
devShells = forAllSystems (system:
|
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 {
|
in {
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
packages = with pkgs; [ nodejs_22 pnpm chromium ];
|
packages = with pkgs; [ nodejs_22 pnpm chromium ];
|
||||||
|
shellHook = agentLib.mkShellHook {
|
||||||
|
inherit pkgs bundle;
|
||||||
|
targets = localTargets;
|
||||||
|
};
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE =
|
PLAYWRIGHT_CHROMIUM_EXECUTABLE =
|
||||||
"${pkgs.chromium}/bin/chromium";
|
"${pkgs.chromium}/bin/chromium";
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { KnipConfig } from 'knip'
|
import type { KnipConfig } from 'knip'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
entry: [
|
entry: ['playwright.config.ts', 'e2e/**/*.test.ts', 'e2e/mock-relay.mjs'],
|
||||||
'playwright.config.ts',
|
|
||||||
'tests/e2e/**/*.spec.ts',
|
|
||||||
'tests/e2e/mock-relay.mjs',
|
|
||||||
],
|
|
||||||
playwright: false,
|
playwright: false,
|
||||||
} satisfies KnipConfig
|
} satisfies KnipConfig
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "4.11.1",
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@playwright/test": "1.61.1",
|
"@playwright/test": "1.61.1",
|
||||||
"@testing-library/dom": "10.4.1",
|
"@testing-library/dom": "10.4.1",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if (!chromiumExecutable) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './e2e',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
use: {
|
use: {
|
||||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
webServer: [
|
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,
|
port: relayPort,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ importers:
|
|||||||
specifier: 4.4.3
|
specifier: 4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@axe-core/playwright':
|
||||||
|
specifier: 4.11.1
|
||||||
|
version: 4.11.1([email protected])
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.3
|
specifier: 2.5.3
|
||||||
version: 2.5.3
|
version: 2.5.3
|
||||||
@@ -99,6 +102,11 @@ packages:
|
|||||||
'@asamuzakjp/[email protected]':
|
'@asamuzakjp/[email protected]':
|
||||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
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]':
|
'@babel/[email protected]':
|
||||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1074,6 +1082,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
||||||
|
|
||||||
@@ -1982,6 +1994,11 @@ snapshots:
|
|||||||
|
|
||||||
'@asamuzakjp/[email protected]': {}
|
'@asamuzakjp/[email protected]': {}
|
||||||
|
|
||||||
|
'@axe-core/[email protected]([email protected])':
|
||||||
|
dependencies:
|
||||||
|
axe-core: 4.11.4
|
||||||
|
playwright-core: 1.61.1
|
||||||
|
|
||||||
'@babel/[email protected]':
|
'@babel/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
@@ -2843,6 +2860,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function AppShell({
|
|||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<p className="brand">TWITTER LITE</p>
|
<p className="brand">Twitter Lite</p>
|
||||||
<nav aria-label="閲覧方法" className="tabs">
|
<nav aria-label="閲覧方法" className="tabs">
|
||||||
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||||
ユーザー
|
ユーザー
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ describe('PostCard', () => {
|
|||||||
expect(container.querySelectorAll('video')).toHaveLength(2)
|
expect(container.querySelectorAll('video')).toHaveLength(2)
|
||||||
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
|
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
|
||||||
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
|
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('Article title')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Preview')).toBeInTheDocument()
|
expect(screen.getByText('Preview')).toBeInTheDocument()
|
||||||
expect(screen.getByText('quoted')).toBeInTheDocument()
|
expect(screen.getByText('quoted')).toBeInTheDocument()
|
||||||
@@ -80,12 +84,39 @@ describe('PostCard', () => {
|
|||||||
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('decodes HTML entities in the post body before linking URLs', () => {
|
||||||
|
render(
|
||||||
|
<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', () => {
|
it('links deliberately to the internal detail page', () => {
|
||||||
render(<PostCard post={richPost} />)
|
render(<PostCard post={richPost} />)
|
||||||
|
|
||||||
|
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
|
||||||
|
expect(detail).toHaveAttribute('href', '/yuta/status/123')
|
||||||
|
expect(detail.parentElement).toHaveClass('post-actions')
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('link', { name: '詳細・スレッド' }),
|
screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
|
||||||
).toHaveAttribute('href', '/yuta/status/123')
|
).toBe(detail.parentElement)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks the focal post without linking to its current page', () => {
|
it('marks the focal post without linking to its current page', () => {
|
||||||
@@ -103,4 +134,26 @@ describe('PostCard', () => {
|
|||||||
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
||||||
expect(container.querySelector('time')).not.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'
|
import { PostMedia } from './post-media'
|
||||||
|
|
||||||
const URL = /(https?:\/\/[^\s]+)/g
|
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', {
|
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
timeZone: 'Asia/Tokyo',
|
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[] {
|
function linkedText(text: string): ReactNode[] {
|
||||||
|
const decoded = decodeHtmlEntities(text)
|
||||||
const parts: ReactNode[] = []
|
const parts: ReactNode[] = []
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
for (const match of text.matchAll(URL)) {
|
for (const match of decoded.matchAll(URL)) {
|
||||||
const href = match[0]
|
const href = match[0]
|
||||||
const start = match.index
|
const start = match.index
|
||||||
if (start > cursor) parts.push(text.slice(cursor, start))
|
if (start > cursor) parts.push(decoded.slice(cursor, start))
|
||||||
parts.push(
|
parts.push(
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@@ -28,7 +48,7 @@ function linkedText(text: string): ReactNode[] {
|
|||||||
)
|
)
|
||||||
cursor = start + href.length
|
cursor = start + href.length
|
||||||
}
|
}
|
||||||
if (cursor < text.length) parts.push(text.slice(cursor))
|
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,16 +110,18 @@ export function PostCard({
|
|||||||
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
||||||
{post.likeCount ?? 0}
|
{post.likeCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
{!current ? (
|
<span className="post-actions">
|
||||||
<a
|
{!current ? (
|
||||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
<a
|
||||||
>
|
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||||
詳細・スレッド
|
>
|
||||||
|
詳細・スレッド
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||||
|
元の投稿を開く
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
</span>
|
||||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
|
||||||
元の投稿を開く
|
|
||||||
</a>
|
|
||||||
</footer>
|
</footer>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -7,30 +7,34 @@ export function PostMedia({ media }: { media: Media[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="media-grid">
|
<div className="media-grid">
|
||||||
{media.map((item) =>
|
{media.map((item) => {
|
||||||
item.type === 'photo' ? (
|
if (item.type === 'photo' || !item.videoUrl) {
|
||||||
<img
|
return (
|
||||||
alt="投稿画像"
|
<img
|
||||||
className="media"
|
alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
|
||||||
height={item.height}
|
className="media"
|
||||||
key={item.url}
|
height={item.height}
|
||||||
loading="lazy"
|
key={item.url}
|
||||||
src={item.url}
|
loading="lazy"
|
||||||
width={item.width}
|
src={item.previewUrl ?? item.url}
|
||||||
/>
|
width={item.width}
|
||||||
) : (
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
||||||
<video
|
<video
|
||||||
className="media"
|
className="media"
|
||||||
controls
|
controls
|
||||||
key={item.videoUrl ?? item.url}
|
key={item.videoUrl}
|
||||||
loop={item.type === 'animated_gif'}
|
loop={item.type === 'animated_gif'}
|
||||||
poster={item.previewUrl ?? item.url}
|
poster={item.previewUrl ?? item.url}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
src={item.videoUrl ?? item.url}
|
src={item.videoUrl}
|
||||||
/>
|
/>
|
||||||
),
|
)
|
||||||
)}
|
})}
|
||||||
</div>
|
</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 () => {
|
it('removes raw data from focal, conversation, and quotes', async () => {
|
||||||
const client = reader()
|
const client = reader()
|
||||||
vi.mocked(client.getTweet).mockResolvedValue({
|
vi.mocked(client.getTweet).mockResolvedValue({
|
||||||
|
|||||||
@@ -215,14 +215,22 @@ export async function loadThreadPage(
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return upstreamFailure(result.error, 'post')
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
page: {
|
page: {
|
||||||
tweets: (result.tweets ?? []).map(publicPost),
|
tweets: tweets.map(publicPost),
|
||||||
focalPost,
|
focalPost,
|
||||||
conversationId,
|
conversationId,
|
||||||
nextCursor: result.nextCursor,
|
nextCursor,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
name: 'viewport',
|
name: 'viewport',
|
||||||
content: 'width=device-width, initial-scale=1',
|
content: 'width=device-width, initial-scale=1',
|
||||||
},
|
},
|
||||||
|
{ name: 'referrer', content: 'no-referrer' },
|
||||||
{ title: 'Twitter Lite' },
|
{ title: 'Twitter Lite' },
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function ListPage({ listId = '' }: { listId?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell active="list">
|
<AppShell active="list">
|
||||||
<h1>どのリストを読みますか?</h1>
|
<h1 className="reader-title-long">どのリストを読みますか?</h1>
|
||||||
<p className="intro">
|
<p className="intro">
|
||||||
自分のリストを選ぶか、リストURLを入力してください。
|
自分のリストを選ぶか、リストURLを入力してください。
|
||||||
</p>
|
</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 {
|
:root {
|
||||||
--canvas: #dfe7e9;
|
--canvas: var(--color-canvas);
|
||||||
--surface: #f8faf9;
|
--surface: var(--color-paper-2);
|
||||||
--paper: #fff;
|
--paper: var(--color-paper);
|
||||||
--ink: #1e3238;
|
--ink: var(--color-ink);
|
||||||
--secondary: #506970;
|
--secondary: var(--color-muted);
|
||||||
--accent: #255f6e;
|
--accent: var(--color-accent);
|
||||||
--accent-hover: #194d59;
|
--accent-hover: var(--color-accent-hover);
|
||||||
--border: #c1cfd2;
|
--border: var(--color-rule);
|
||||||
--control-border: #748b91;
|
--control-border: var(--color-control-rule);
|
||||||
--error: #8a3034;
|
--error: var(--color-error);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
font-family: var(--font-body);
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,12 +29,14 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
overflow-x: clip;
|
||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -40,9 +49,19 @@ a {
|
|||||||
text-underline-offset: 0.18em;
|
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 {
|
:focus-visible {
|
||||||
outline: 3px solid var(--accent);
|
outline: var(--rule-active) solid var(--color-focus);
|
||||||
outline-offset: 3px;
|
outline-offset: var(--space-2xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
@@ -53,7 +72,7 @@ a {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 1.15rem;
|
border-radius: 1.15rem;
|
||||||
background: var(--surface);
|
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 {
|
.app-header {
|
||||||
@@ -163,7 +182,7 @@ a {
|
|||||||
|
|
||||||
.list-choices a[aria-current="page"] {
|
.list-choices a[aria-current="page"] {
|
||||||
border-color: var(--accent);
|
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);
|
box-shadow: inset 3px 0 var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +210,7 @@ a {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.9rem;
|
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 {
|
.field {
|
||||||
@@ -352,7 +371,7 @@ a {
|
|||||||
|
|
||||||
.current-post {
|
.current-post {
|
||||||
border-color: var(--accent);
|
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 {
|
.post-header {
|
||||||
@@ -401,7 +420,7 @@ a {
|
|||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 0.75rem;
|
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 {
|
.media-grid > :only-child {
|
||||||
@@ -412,7 +431,7 @@ a {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 32rem;
|
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 {
|
img.media {
|
||||||
@@ -503,7 +522,7 @@ video.media {
|
|||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
content: "";
|
content: "";
|
||||||
animation: rail 1.1s ease-in-out infinite;
|
animation: rail 1.1s var(--ease-in-out) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rail {
|
@keyframes rail {
|
||||||
@@ -585,7 +604,7 @@ video.media {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-grid {
|
.media-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-footer {
|
.post-footer {
|
||||||
@@ -603,3 +622,574 @@ video.media {
|
|||||||
animation: none;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
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": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./tests/setup.ts'],
|
setupFiles: ['./tests/setup.ts'],
|
||||||
exclude: [...configDefaults.exclude, 'tests/e2e/**'],
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||