Verify reading flows on desktop and mobile and fix login contrast

This commit is contained in:
2026-11-07 01:51:57 +08:00
parent 3948cb33da
commit af0cf1ba59
8 changed files with 185 additions and 10 deletions
+11 -1
View File
@@ -1,6 +1,6 @@
# Thesisly 研迹 working agreements
- 本项目使用 Next.js App Router、TypeScript、Tailwind CSS、shadcn/ui(Radix primitives)、Lucide 和 Sonner。用户要求本地开发,但最新指示为暂不执行本地 build;不启动会隐式编译的预览,直至确认适用授权。不配置前端 CI/CD,不部署。
- 本项目使用 Next.js App Router、TypeScript、Tailwind CSS、shadcn/ui(Radix primitives)、Lucide 和 Sonner。用户明确授权本地 dev,随后仅撤回本地 build:允许按需启动 pnpm dev,禁止 pnpm build。不配置前端 CI/CD,不部署。
- 仅通过 Foundry Core 共享后端管理身份。复用 Endoleap 的 PKCE、HttpOnly Cookie、闲置续期、退出撤销与同源校验;禁止把 token 发到客户端或 localStorage。
- 论文读取模式必须显式区分真实数据库样本与远程 API。不得把本地20条样本称为全库、最新或热门数据,不虚构摘要、代码仓库、会议决定或指标。
- 源数据只读;不复制整库,不从浏览器直连 SQLite 或持有数据库凭据。
@@ -8,3 +8,13 @@
- 项目级技能在 .agents/skills;beautiful-article 内含 Vignelli/Tufte authoring profiles,两者不是独立上游 skill。Beautiful Article 只在另行请求单篇 HTML 文章时进入其完整 harness。
- 英文品牌 Thesisly、中文名研迹;远程仓库为 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/Thesisly.git`。用户已在 FOUNDRY_PROJECTS 新增 thesisly 回调,线上加载及登录仍待验收。main 仅允许本次已授权的空初始化提交,其余代码走工作分支与人工 PR 合并。
- 论文库迁移至 PostgreSQL 由「调查云盘占用原因」任务负责;等待其迁移完成并核对实际 schema 后,在 Foundry Core 实现只读论文 API。不部署已撤回的 SQLite 方案,也不要求添加 FOUNDRY_RESEARCH_DATABASE。
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+9 -5
View File
@@ -4,7 +4,7 @@
## 本地开发
用户最新要求暂不执行本地 build,当前尚未启动开发预览。以下为后续启动命令:
用户允许本地 dev,暂不执行本地 build。开发预览已启动并完成桌面/手机浏览器检查:
```sh
pnpm install
@@ -17,8 +17,8 @@ pnpm dev
```sh
pnpm typecheck
pnpm test
# 明确允许本地生产构建后
pnpm build
# 在另一个终端已运行 pnpm dev 后,使用系统 Chrome
pnpm test:browser
```
## 页面
@@ -30,7 +30,7 @@ pnpm build
- `/about`:数据范围与主题说明。
- 空结果、404、加载中与数据服务错误均有对应状态。
默认Vignelli,顶部可切换Editorial/Tufte;仅主题偏好存于localStorage,绝不保存认证token。英文品牌已确定为 Thesisly;中文名为研迹,Gitea 仓库链接待用户提供,尚未注册域名或商标。
默认Vignelli,顶部可切换Editorial/Tufte;仅主题偏好存于localStorage,绝不保存认证token。英文品牌已确定为 Thesisly;中文名为研迹,Gitea 仓库为 Zayden-Jung/Thesisly,尚未注册域名或商标。
## 数据与后端
@@ -46,8 +46,12 @@ pnpm build
类型检查与13项测试已通过:除了Cookie/样本策略,测试直接调用生产认证代理,覆盖兑换、登录/邮件验证、续期、退出、错误、凭据隔离与请求限制。它们使用进程内模拟的Core响应,不代表真实OAuth成功,也不需要启动Next.js或编译。
生产构建、开发服务器、浏览器交互及真实OAuth尚未通过验收;此前构建/预览被自动审批阻止,随后用户要求暂不 build。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。
12项桌面/手机浏览器测试覆盖搜索、分页、空结果、文章摘要与引用复制、三种主题持久化、登录/注册表单和404恢复;截图用于人工核对布局。真实OAuth尚未验收,生产构建按用户要求未执行。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。
## 当前后端依赖
用户已配置 Thesisly 的 FOUNDRY_PROJECTS。论文库正在迁移至 PostgreSQL;本目标等待迁移完成后实现并接通 Core 的论文 API,不再采用 SQLite sidecar,也不需要 FOUNDRY_RESEARCH_DATABASE。当前本地20条导出仅用于视觉与交互开发,绝不代表全库已接通。
## Endoleap 参考
已核对 [Endoleap 主线 README](https://gitea.entroia.com/Zayden-Jung/Endoleap/src/branch/main/README.md)(blob `358824763e09811a85f8986526e93dda406fdb93`):沿用 App Router、同源身份代理、PKCE、HttpOnly Cookie、Core 控制的30天闲置/90天绝对会话期限与共享第三方回调。Thesisly 使用独立端口17322、项目键thesisly及服务端环境变量;不复制 Endoleap 的品牌、Canvas 功能或部署流程。浏览器表单测试不代表真实 Google/Slack 登录成功。
+5
View File
@@ -1,2 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3 -1
View File
@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start --hostname localhost --port 17322",
"typecheck": "tsc --noEmit",
"test": "node --test tests/*.test.mjs"
"test": "node --test tests/*.test.mjs",
"test:browser": "playwright test"
},
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
@@ -23,6 +24,7 @@
"tailwind-merge": "3.7.0"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@tailwindcss/postcss": "4.3.4",
"@types/node": "26.6.6",
"@types/react": "19.3.0",
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "@playwright/test";
// Use an already running, explicitly authorized dev server. This configuration
// never runs `next build`, starts CI, or accesses a personal Chrome profile.
export default defineConfig({
testDir: "./tests/e2e",
workers: 1,
retries: 0,
timeout: 30000,
expect: { timeout: 10000 },
reporter: "list",
use: {
baseURL: "http://localhost:17322",
channel: "chrome",
headless: true,
launchOptions: { args: ["--no-proxy-server"] },
permissions: ["clipboard-read", "clipboard-write"],
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{ name: "desktop", use: { viewport: { width: 1440, height: 1000 } } },
{ name: "mobile", use: { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } },
],
});
+31 -2
View File
@@ -28,7 +28,7 @@ importers:
version: 1.69.0(@types/react@19.3.0)(react@19.3.1)
next:
specifier: 16.4.5
version: 16.4.5(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
version: 16.4.5(@playwright/test@1.64.1)(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
react:
specifier: 19.3.1
version: 19.3.1
@@ -42,6 +42,9 @@ importers:
specifier: 3.7.0
version: 3.7.0
devDependencies:
'@playwright/test':
specifier: 1.64.1
version: 1.64.1
'@tailwindcss/postcss':
specifier: 4.3.4
version: 4.3.4
@@ -318,6 +321,11 @@ packages:
cpu: [x64]
os: [win32]
'@playwright/test@1.64.1':
resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==}
engines: {node: '>=20'}
hasBin: true
'@radix-ui/primitive@1.2.0':
resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==}
@@ -940,6 +948,16 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
playwright-core@1.64.1:
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
engines: {node: '>=20'}
hasBin: true
playwright@1.64.1:
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
engines: {node: '>=20'}
hasBin: true
postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1250,6 +1268,10 @@ snapshots:
'@next/swc-win32-x64-msvc@16.4.5':
optional: true
'@playwright/test@1.64.1':
dependencies:
playwright: 1.64.1
'@radix-ui/primitive@1.2.0': {}
'@radix-ui/react-arrow@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
@@ -1701,7 +1723,7 @@ snapshots:
nanoid@3.3.20: {}
next@16.4.5(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
next@16.4.5(@playwright/test@1.64.1)(@types/node@26.6.6)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
dependencies:
'@next/env': 16.4.5
'@swc/helpers': 0.5.23
@@ -1720,6 +1742,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.5
'@next/swc-win32-arm64-msvc': 16.4.5
'@next/swc-win32-x64-msvc': 16.4.5
'@playwright/test': 1.64.1
sharp: 0.35.5(@types/node@26.6.6)
transitivePeerDependencies:
- '@babel/core'
@@ -1728,6 +1751,12 @@ snapshots:
picocolors@1.1.1: {}
playwright-core@1.64.1: {}
playwright@1.64.1:
dependencies:
playwright-core: 1.64.1
postcss@8.5.23:
dependencies:
nanoid: 3.3.20
+1 -1
View File
@@ -13,7 +13,7 @@
html { scroll-behavior:smooth; scroll-padding-top:100px; }
body { margin:0; background:var(--paper); color:var(--ink); font-family:"Helvetica Neue",Arial,"PingFang SC",sans-serif; font-size:14px; -webkit-font-smoothing:antialiased; }
::selection { background:#dededb; color:#171717; }
a { color:inherit; text-decoration:none; }
@layer base { a { color:inherit; text-decoration:none; } }
button,select,input { font:inherit; }
button,select { cursor:pointer; }
button:disabled { cursor:wait; }
+100
View File
@@ -0,0 +1,100 @@
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
const snapshot = JSON.parse(readFileSync(new URL("../../src/data/papers.json", import.meta.url)));
const errors = new WeakMap();
test.beforeEach(async ({ page }) => {
errors.set(page, []);
page.on("pageerror", error => errors.get(page).push(error.message));
});
test.afterEach(async ({ page }) => {
expect(errors.get(page)).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
test("sample index has working pagination and returns to the same list", async ({ page }, info) => {
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toContainText("好研究");
await expect(page.getByRole("note")).toContainText("前 20 条记录");
await expect(page.locator(".paper-row")).toHaveCount(8);
await expect(page.getByRole("link", { name: "登录", exact: true })).toBeVisible();
const first = await page.locator(".paper-row h3").first().innerText();
await page.screenshot({ path: info.outputPath("home.png"), fullPage: true });
await page.getByRole("link", { name: "下一页" }).click();
await expect(page).toHaveURL(/page=2/);
await expect(page.locator(".paper-row h3").first()).not.toHaveText(first);
await page.getByRole("link", { name: "上一页" }).click();
await expect(page.locator(".paper-row h3").first()).toHaveText(first);
});
test("search, empty results, author scope and category filters work", async ({ page }) => {
await page.goto("/");
await page.getByRole("textbox", { name: "搜索论文", exact: true }).fill("no-paper-matches-this-query-92871");
await page.getByRole("button", { name: "搜索论文", exact: true }).click();
await expect(page.getByRole("heading", { name: "没有找到符合条件的论文" })).toBeVisible();
await page.getByRole("link", { name: "查看全部论文" }).click();
const author = snapshot.papers[0].authors[0];
await page.getByLabel("搜索范围").selectOption("author");
await page.getByRole("textbox", { name: "搜索论文", exact: true }).fill(author);
await page.getByRole("button", { name: "搜索论文", exact: true }).click();
await expect(page.locator(".paper-row").first()).toContainText(author);
await page.getByRole("link", { name: "清除筛选" }).click();
const topic = snapshot.papers[0].topics[0];
await page.getByRole("link", { name: topic, exact: true }).click();
await expect(page).toHaveURL(new RegExp("topic=" + encodeURIComponent(topic).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
for (const row of await page.locator(".paper-meta").all()) await expect(row).toContainText(topic);
});
test("article preserves original abstract and offers citation copying", async ({ page }, info) => {
const paper = snapshot.papers[0];
await page.goto(`/papers/${paper.id}`);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(paper.title);
await expect(page.locator("#abstract")).toContainText(paper.abstract.replace(/\n/g, " "));
await expect(page.getByRole("link", { name: new RegExp(paper.sources[0].externalId.replaceAll(".", "\\.")) })).toHaveAttribute("href", paper.sources[0].url);
await page.getByRole("button", { name: "复制引用" }).click();
await expect(page.getByText("已复制引用信息", { exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain(paper.title);
await expect(page.getByRole("link", { name: "登录", exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath("article.png"), fullPage: true });
await page.getByRole("link", { name: "返回论文索引" }).click();
await expect(page).toHaveURL("http://localhost:17322/");
});
test("all reading themes apply and persist through a reload", async ({ page }) => {
await page.goto("/");
const theme = page.getByRole("combobox", { name: "阅读主题" });
for (const value of ["editorial", "tufte", "vignelli"]) {
await theme.selectOption(value);
await expect(page.locator("html")).toHaveAttribute("data-theme", value);
await page.reload();
await expect(theme).toHaveValue(value);
const login = page.getByRole("link", { name: "登录", exact: true });
await expect(login).toBeVisible();
expect(await login.evaluate(el => {
const expected = getComputedStyle(document.body).backgroundColor;
return getComputedStyle(el).color === expected;
})).toBe(true);
await expect(page.locator("html")).toHaveAttribute("data-theme", value);
}
});
test("login and registration forms remain usable without sending credentials", async ({ page }, info) => {
await page.goto("/login");
await expect(page.getByRole("button", { name: "Google", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Slack", exact: true })).toBeVisible();
await page.getByRole("button", { name: "创建账户", exact: true }).click();
await expect(page.getByRole("heading", { name: "创建账户" })).toBeVisible();
await expect(page.getByLabel("密码", { exact: true })).toHaveAttribute("minlength", "12");
await page.getByRole("button", { name: "显示密码" }).click();
await expect(page.getByLabel("密码", { exact: true })).toHaveAttribute("type", "text");
await page.screenshot({ path: info.outputPath("register.png"), fullPage: true });
await page.getByRole("button", { name: "登录", exact: true }).last().click();
await expect(page.getByRole("heading", { name: "继续你的阅读。" })).toBeVisible();
});
test("missing paper shows recovery navigation instead of an empty article", async ({ page }) => {
await page.goto("/papers/999999999");
await expect(page.getByRole("heading", { name: "这篇论文不在当前索引里。" })).toBeVisible();
await page.getByRole("link", { name: "返回论文索引" }).click();
await expect(page.getByRole("heading", { level: 1 })).toContainText("好研究");
});