docs: P9 batch-1 plan (U1-U7 anchors) + implementer brief — feat/p9-ux-titles on crearte, five-leg acceptance

This commit is contained in:
2026-10-01 12:03:09 +08:00
parent 1fca6f1c53
commit 67f7a802d1
@@ -0,0 +1,86 @@
# P9 UX 第一批(浏览器反馈包)· 实现计划
Spec:`docs/specs/2026-10-01-p9-ux-browser-feedback-design.md`(§1–§6 全量为审查依据,不只本计划)。
仓库:仅 crearte(分支 `feat/p9-ux-titles`,BASE `7d5f338`)。零 server/deploy 改动。版本 0.22.0。
## 环境与惯例(全部已验证)
- 测试/run 命令在 `/root/.openclaw/workspace/coder/crearte-monorepo/crearte/src`(package.json 在此)。vitest:happy-dom 文件首行 `// @vitest-environment happy-dom`。mock 惯例见 `app/views/AuthorView.test.ts`(`vi.hoisted` + `vi.mock('@/data')` + 复用真实 `resolveUserSlug`)。
- e2e 主套件 baseURL `localhost:4173`(preview),helpers:`src/e2e/helpers.ts`(`openGame(page, slug)` 已存在)。跑 e2e 前先 `npm run build`(preview 吃 dist)。
- 路由表在 `app/router/index.ts`:`routes: RouteRecordRaw[]`(export 供测试)、`beforeEach(resolveNavigation)`、`scrollBehavior`。文件顶部 import 区加 afterEach 接线。
- 网格页(保持 cards 骨架):CatalogView.vue、LandingView.vue、AuthorView.vue。lines 转换页(六文件、八个实例,AdminView 有 3 处):GameView.vue:37、DocsView.vue:29、AdminView.vue:148/172/219、AdminUsersView.vue:113、AdminAuditView.vue:58、SubmitListView.vue:93。
## 任务 U1 — `app/lib/pageTitle.ts`(纯 builder + setter)
```ts
export const SITE = 'crearte 创艺'
export const DEFAULT_DESCRIPTION =
'crearte 创艺——互动小说与浏览器小游戏托管社区,收录可直接游玩的作品目录。'
export function clip(s: string, max: number): string // Array.from 码点截断,超出加 '…'
export function joinTitle(...parts: Array<string | '' | null | undefined>): string
// 过滤空段后 ' · ' 连接;joinTitle() === SITE
export function gameTitle(name: string): string // joinTitle(name, SITE 去前缀逻辑见下)
```
**命名格式硬约束(spec D-A/D-B)**:所有页面标题 = `段1 · 段2 · crearte 创艺`;home = 恰 `crearte 创艺`。实现建议:`sectionTitleOf(name: unknown): string` 返回中文段名(19 个 name 全表见 spec §3.2;未名中→`''`),`setPageTitle(joinTitle(section))` 自动把 SITE 收尾。builder 清单:
- `gameTitle(name)` → `${name} · ${SITE}`
- `gameNotFoundTitle()` → `未找到的作品 · ${SITE}`
- `catalogTitle(q)` → q 非空:`搜索「${clip(q, 40)}」 · 作品 · ${SITE}`;空:`作品 · ${SITE}`
- `docsTitle(docTitle?)` → 有:`${docTitle} · 文档 · ${SITE}`;无:`文档 · ${SITE}`
- `authorTitle(display)` → `${display} · 创作者 · ${SITE}`
- `setPageTitle(t)`:`document.title = t`
- `setPageDescription(d)`:`document.querySelector('meta[name="description"]')` 缺则 create+append head;`content = clip(d || DEFAULT_DESCRIPTION, 120)`
新 `app/lib/pageTitle.test.ts`(happy-dom):joinTitle 空段/全空、clip 中英混+emoji 码点、每 builder 一钉、setter 写 title、meta 自动建+回落 DEFAULT、截断 120。
## 任务 U2 — router afterEach
`app/router/index.ts`:`import { joinTitle, setPageTitle, sectionTitleOf } from '@/lib/pageTitle'`;在 `beforeEach` 之后挂 `router.afterEach((to) => setPageTitle(joinTitle(sectionTitleOf(to.name))))`。
扩 `app/router/index.test.ts`(现文件用 `createMemoryHistory` + `makeRouter()`):push `/`→`document.title==='crearte 创艺'`;`/games`→`作品 · crearte 创艺`;`/account`→`我的账号 · crearte 创艺`;`/no-such-page`→`页面不存在 · crearte 创艺`。**注意**测试 router 是 makeRouter 重建,afterEach 挂在导出的 `router` 单例上——测试若用 `createRouter({routes})` 新实例则钩子不在。**解法**:把 afterEach 注册抽成导出函数 `attachTitleHook(r: Router)` 在 index.ts 底部对 `router` 调用,测试对 makeRouter 实例同样调用后再断言。
## 任务 U3 — 四数据页精化
- **GameView.vue** script 加:`watch(game, (g) => { if (notFound.value) { setPageTitle(gameNotFoundTitle()); setPageDescription('') } else if (g) { setPageTitle(gameTitle(g.name)); setPageDescription(g.description ?? '') } })`。loading/error(非 notFound)期不动(保持基线)。
- **CatalogView.vue**:`watch(() => state.value.q, (q) => setPageTitle(catalogTitle(q)), { immediate: true })`。
- **DocsView.vue**:`watch(doc, (d) => setPageTitle(docsTitle(d?.title)))`。
- **AuthorView.vue**:`watch(games, (list) => { const first = (list ?? [])[0]; setPageTitle(authorTitle(first ? authorDisplayName(first) : props.user)) })`(import `authorDisplayName` from `@/lib/labels`)。
测试:
- 扩 `GameView.test.ts`:现有 `minimalGame` 挂载 flush 后断言 `document.title` 为 `最小作品 · crearte 创艺`;给带 description 的 fixture 断言 meta content;notFound 腿(mock getGame reject NotFoundError)断言 `未找到的作品`。**每个 it 前重置 `document.title=''` 与 meta**(beforeEach 清)。
- 新建 `CatalogView.test.ts`、`DocsView.test.ts`:mock 惯例照 AuthorView.test.ts;Catalog mount+push `/games?q=2048`(makeRouter 需该路由)断言标题含 `搜索「2048」`;Docs mock listDocs/getDoc 断言 `文档` 基线与 doc.title 精化。
- 扩 `AuthorView.test.ts`:列表 [{author:{name:'笔锋'}}]→`笔锋 · 创作者 · crearte 创艺`;空列表→`@fixture · 创作者 · …`(props.user 兜底)。
## 任务 U4 — StatePanel variant + 六页 lines
`StatePanel.vue`:props 加 `variant?: 'cards' | 'lines'`(默认 cards)。cards 骨架 `v-for="n in 6"`(原 3);lines 分支:三个 `border-2 border-ink bg-surface shadow-hard p-4` 块内 `h-4 w-2/3` + `h-3 w-full` + `h-3 w-1/2`(bg-[#EFE9DA]、animate-skeleton),无 aspect-video。error/slot 段零改动。
六页八个实例全部加 `variant="lines"`(行号见头部清单;AdminView 三处都要)。
新 `StatePanel.test.ts`:默认 6 个 aspect-video 节点;lines 时 0 aspect-video、3 面板;error 腿 `role="alert"` 原样。
## 任务 U5 — index.html
head `theme-color` 行后加:`<meta name="description" content="DEFAULT_DESCRIPTION 同文(手抄一致,测试钉)" />`。
## 任务 U6 — e2e
`src/e2e/landing.spec.ts` **首条测试一字不动**(兼容回归钉)。新增一条:
```ts
test('路由级标题:作品页与目录搜索态', async ({ page }) => {
await page.goto('http://localhost:4173/games/fixture/2048')
await expect(page).toHaveTitle(/^2048 · crearte 创艺$/)
await page.goto('http://localhost:4173/games?q=2048')
await expect(page).toHaveTitle(/搜索「2048」 · 作品 · crearte 创艺$/)
})
```
game 页 fixture「2048」名称核对:`public/data/index.json`(e2e 用静态数据)里该条目 name 若不为 `2048`,以实际 name 为准改正则(实施时第一步先 `grep '"name"' src/public/data/games/fixture__2048.json | head -1` 取证)。
## 任务 U7 — CHANGELOG + 提交
`docs/CHANGELOG.md` 顶插 `## [0.22.0] - 2026-10-01`(Added 双语:路由级 document.title 两段式、作品页 meta description、StatePanel variant 骨架对齐;注明 P9 UX 第一批)。commit 拆两枚:`feat(ux): router-level document titles and game-page description` 与 `fix(ux): align loading skeletons with actual grids (StatePanel variants)`。分支 `feat/p9-ux-titles`,**不 merge master、不 push**(控制者验收后合)。
## 验收腿(控制者跑)
vitest 全绿(基线 512+新增 ~20)、`npx vue-tsc --noEmit`、`npm run build`、主 e2e(71 存量含 landing 首条不动 + 新增 1)、noauth 4。