Files
crearte-monorepo/docs/plans/2026-10-01-p10-ux-daily-interaction.md

377 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P10 UX 第一批(日常交互包)· 实现计划
spec:`docs/specs/2026-10-01-p10-ux-daily-interaction-design.md`(决策 D-A…D-L 以其为准;本计划与 spec 冲突时 spec 赢)。
仓库:**仅 crearte**(`crearte-monorepo/crearte`),分支 `feat/p10-ux-daily`。server/deploy 零改动。
npm 根:`crearte/src`(package.json 在此)。命令一律 `cd crearte/src` 后跑。
## 全局约束(逐字,进每个任务简报)
1. 零新依赖:不得改 `package.json` dependencies/devDependencies;version 字段恒 `0.1.0` 不动。
2. 禁触文件/行:`e2e/landing.spec.ts` 既有全部用例、`e2e/merge-repo.spec.ts`、`e2e/noauth.spec.ts`、`playwright.config.ts`、`vitest.config.ts`、`vite.config.ts`、`runtime/` 下除 `GameHost.vue` 外一切文件(D-H 唯一例外)、`scripts/`。
3. 样式语言:新粗野主义既有 tokens(`border-2 border-ink`、`shadow-hard*`、`bg-paper/surface/ink/highlight/accent/success`、`btn-ink/btn-surface/lift`、`font-mono text-[0.6875rem]`);注释中文、标识符英文(仓内惯例)。
4. TDD:先写/改 vitest 再实现;e2e 用例随所属任务同 commit。
5. 每任务独立 commit(message 用 `feat(ux):`/`fix(ux):` 前缀 + 任务号);**CHANGELOG 不由任务写**——控制者在全部任务后统一补 0.23.0 条目。
6. 验收命令(每任务收尾自跑并贴输出摘要):`npm test -- <相关测试文件>`(改动面)+ `npm run typecheck`。全量 `npm test`/`npm run build`/`npm run e2e` 由控制者在波次末跑。
7. 分支纪律:commit 前 `git branch --show-current` 必须是 `feat/p10-ux-daily`;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
8. vitest 基线 551 全绿不回退;e2e 主套件基线 72+1skip 不回退。
9. `data-testid` 命名精确使用 spec 给定值:`toast-host`、`toast`、`copy-link`、`tag-link`、`recent-strip`。
10. 文案精确逐字(spec §3):守卫 `表单尚未保存,确定离开吗?未保存的修改将丢失。`;复制成功 `链接已复制`;复制失败 `复制失败,请手动复制地址栏链接`;toast 关闭按钮 `aria-label="关闭提示"`;条带标题 `继续游玩 · RECENTLY PLAYED`;toast 参数 success/info 3000ms、error 5000ms、上限 3 条。
---
### Task 1: toast 基座(useToast + ToastHost + App.vue)
**新文件 `app/composables/useToast.ts`**(模块级单例 store,spec §3.1 + D-C/D-D):
```ts
import { ref, type Ref } from 'vue'
export type ToastKind = 'success' | 'error' | 'info'
export interface ToastItem { id: number; kind: ToastKind; text: string }
export const MAX_VISIBLE = 3
export const DURATIONS: Record<ToastKind, number> = { success: 3000, error: 5000, info: 3000 }
const toasts: Ref<ToastItem[]> = ref([])
let nextId = 1
const timers = new Map<number, ReturnType<typeof setTimeout>>()
function dismiss(id: number): void {
const t = timers.get(id)
if (t) { clearTimeout(t); timers.delete(id) }
toasts.value = toasts.value.filter((x) => x.id !== id)
}
function push(kind: ToastKind, text: string, duration: number = DURATIONS[kind]): number {
const id = nextId++
toasts.value = [...toasts.value.slice(-(MAX_VISIBLE - 1)), { id, kind, text }]
timers.set(id, setTimeout(() => dismiss(id), duration))
return id
}
export function useToast() {
return {
toasts,
push,
success: (text: string) => push('success', text),
error: (text: string) => push('error', text),
info: (text: string) => push('info', text),
dismiss
}
}
// 测试隔离用:清空全部 toast 与计时器(仅测试导入)
export function __resetToasts(): void {
for (const t of timers.values()) clearTimeout(t)
timers.clear()
toasts.value = []
nextId = 1
}
```
**新文件 `app/components/ToastHost.vue`**:不 Teleport(D-C 测试友好);容器 `data-testid="toast-host"` `role="status"` `aria-live="polite"` `class="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2"`;`<TransitionGroup name="toast">` 包每条;每条 `data-testid="toast"` + kind 配色(success `bg-success text-paper`、error `bg-accent text-paper`、info `bg-highlight text-ink`)+ `pointer-events-auto border-2 border-ink px-3 py-2 text-xs font-bold shadow-hard flex items-start justify-between gap-2`;关闭按钮 `aria-label="关闭提示"`(PhX from `@phosphor-icons/vue`,size 12 weight bold,`aria-hidden="true"`)。`main.css` 追加 `.toast-enter-active/.toast-leave-active`(transform+opacity 150ms)与 `.toast-enter-from/.toast-leave-to`,放进既有 `@media (prefers-reduced-motion: reduce)` 归零分支可及的范围(reduce 时 transition 时长归零即可,写法对齐 main.css:95 既有分支)。store 空时容器仍渲染(`role="status"` 常驻,屏幕阅读器语义稳定)但零子条。
**改 `app/App.vue`**:`<AppFooter />` 之后挂 `<ToastHost />`(import 补上)。
**测试 新 `app/composables/useToast.test.ts`**:`beforeEach(__resetToasts)` + `vi.useFakeTimers`;钉:push 返回递增 id;success/info 3000ms、error 5000ms 后自动消失(`vi.advanceTimersByTime`);连 push 4 条只剩最末 3 条(最旧被移除);`dismiss` 提前清掉且计时器不泄漏(dismiss 后 advance 不抛);自定义 duration 生效。
**测试 新 `app/components/ToastHost.test.ts`**:mount 后 store 空→容器在、零 `[data-testid=toast]`;push 三种 kind→各配色类命中;点关闭按钮→store 少一条;容器 `role="status"` `aria-live="polite"` 钉死。
**验收**:`npm test -- app/composables/useToast.test.ts app/components/ToastHost.test.ts` 全绿 + `npm run typecheck` 零错。
**commit**:`feat(ux): P10-T1 toast store + ToastHost (D-C/D-D)`
---
### Task 2: 脏表单守卫(SubmitFormView)
**改 `app/views/SubmitFormView.vue`**(spec §3.2 + D-A/D-B)。锚点:`suppressAadWatch` 声明在 :114 附近;`loadExisting` :186-227(try 前 `suppressAadWatch = true`,finally `= false`);`save()` 成功路径 `await router.push('/submit')` :295 附近;既有 `onBeforeUnmount` :64-67。
实现要点:
1. `import { onBeforeRouteLeave } from 'vue-router'`(该文件已从 vue-router import `useRoute, useRouter`,同行追加)。
2. 常量 `const GUARD_COPY = '表单尚未保存,确定离开吗?未保存的修改将丢失。'`(放 KIND_LABELS 附近)。
3. `const dirty = ref(false)`、`let suppressDirty = false`、`let suppressLeave = false`。
4. 深度 watch:`watch([form, kind, () => bundle.value?.upload_id, () => cover.value?.upload_id], () => { if (!suppressDirty) dirty.value = true }, { deep: true })`。放既有 AAD watch 之后。注意:`form` 是 `reactive` 对象,watch 数组元素里直接放 `form`(reactive 对象自身可深度观察);`kind` 是 ref。
5. `loadExisting`:进入 try 前 `suppressDirty = true`(与 `suppressAadWatch = true` 同点);finally 里 `suppressAadWatch = false` 后 `await nextTick()`(既有)再 `suppressDirty = false; dirty.value = false`。⚠️ 顺序:既有代码 finally 前已有 `await nextTick()` 在 try 内——实现时把 `suppressDirty = false; dirty.value = false` 放 finally 中 `suppressAadWatch = false` 之后、且需再 `await nextTick()` 一次让深度 watch 队列冲完再释放(防止回填触发的 watch 回调在释放后才跑而误置 dirty;深度 watch 默认 flush pre,nextTick 足以)。
6. `prefill()` **不**抑制(D-B:其触发前提本身是用户修改)——不动 prefill。
7. `save()` 成功分支:`await router.push('/submit')` 前插 `dirty.value = false; suppressLeave = true`。失败路径(catch)不动 dirty。
8. `onBeforeRouteLeave(() => { if (suppressLeave || !dirty.value) return true; return window.confirm(GUARD_COPY) })`。
9. beforeunload:`watch(dirty, (d) => { if (d) window.addEventListener('beforeunload', onBeforeUnload); else window.removeEventListener('beforeunload', onBeforeUnload) })` + `function onBeforeUnload(e: BeforeUnloadEvent): void { e.preventDefault(); e.returnValue = '' }` + 既有 `onBeforeUnmount` 里补 `window.removeEventListener('beforeunload', onBeforeUnload)`。
**测试 扩 `app/views/SubmitFormView.test.ts`**(先读既有文件沿用其 mock/router 惯例;该文件已有完整 contentClient mock 体系):
- 用户改 `form.name`(`w.vm` 或直接 input setValue)→ 触发 router 导航被 `window.confirm` mock(返 false)拒 → 停留原路由;confirm 改返 true → 放行。
- `loadExisting` 回填后(既有编辑模式用例路径)→ confirm spy 未被调(导航直通)。
- save-draft 成功流(既有用例已有该流)→ push('/submit') 前 confirm 未被调(suppressLeave)。
- dirty=true 时 `window.dispatchEvent(new Event('beforeunload', { cancelable: true }))` → `defaultPrevented` true;干净时 false。
- 上传成功(既有 bundle 上传用例路径)→ dirty。
**e2e 扩 `e2e/submit-flow.spec.ts` 追加一条**(文件尾,既有用例一字不动):
```ts
test('脏表单守卫:SPA 离开需确认,save 成功直通', async ({ page }) => {
await seedSession(page)
const state = { subs: [] as Sub[] }
installSubmissionApi(page, state)
await page.goto('http://localhost:4173/submit/new')
await page.getByLabel('展示名称').fill('My Game')
// 无监听 → Playwright 自动 dismiss → 导航被拒
await page.getByRole('link', { name: '作品', exact: true }).click()
await expect(page).toHaveURL('http://localhost:4173/submit/new')
// accept → 放行
page.once('dialog', (d) => void d.accept())
await page.getByRole('link', { name: '作品', exact: true }).click()
await expect(page).toHaveURL('http://localhost:4173/games')
// 回到表单填好 → save-draft 成功 push 不被守卫拦(suppressLeave 回归)
await page.goto('http://localhost:4173/submit/new')
await fillNewWorkForm(page) // 注意:fillNewWorkForm 自带 goto /submit/new,直接调用即可
await page.locator('[data-testid=save-draft]').click()
await expect(page).toHaveURL('http://localhost:4173/submit')
})
```
(实施时按文件内既有 helper 命名微调;`fillNewWorkForm` 已含 goto,上面第三段直接调它、不再重复 goto。)
**验收**:`npm test -- app/views/SubmitFormView.test.ts` + typecheck。
**commit**:`feat(ux): P10-T2 dirty-form leave guard on SubmitFormView (D-A/D-B)`
---
### Task 3: 标签可点(GameView + GameCard)
**改 `app/views/GameView.vue` :88-95**(spec §3.3 + D-G):tags `<span>` → `<RouterLink>`:
```html
<RouterLink
v-for="tag in game.tags"
:key="tag"
data-testid="tag-link"
:to="{ path: '/games', query: { tag } }"
class="border-[1.5px] border-ink bg-surface px-2 py-0.5 font-mono text-[0.6875rem] hover:bg-highlight"
>{{ tag }}</RouterLink>
```
**改 `app/components/GameCard.vue` :70-75**:`visibleTags` 的 `<span>` → `<RouterLink>`,同款样式 + `relative z-10`(卡面拉伸链接 `after:absolute inset-0` 在 :49,作者链接 :54-58 已用 `relative z-10` 同法)+ `data-testid="tag-link"`;`:to="{ path: '/games', query: { tag } }"`;`hiddenTags` 的 `+N` 角标(:76-79)**维持 span 不动**(D-G)。
**测试 扩 `app/views/GameView.test.ts`**:带 tags 的作品(既有夹具可扩)→ `[data-testid=tag-link]` 数量=tags 数、首个 href `/games?tag=<encodeURIComponent(t)>`(断言用 `router.resolve` 产物或字面量,中文 tag 注意编码)。
**测试 扩 `app/components/GameCard.test.ts`**:有 tags → `a[data-testid=tag-link]` 带 `relative z-10` 类且 href 以 `/games?tag=` 开头;tags>2 → 第三个是 `+N` span 非链接;tags=[] → 零 tag-link。
**e2e 新文件 `e2e/ux.spec.ts`**(本任务创建,后续任务追加)第一条:
```ts
test('作品页标签点击进入目录过滤', async ({ page }) => {
await page.goto('http://localhost:4173/games/fixture/2048')
const tag = page.locator('[data-testid=tag-link]').first()
const text = (await tag.textContent())!.trim()
await tag.click()
await expect(page).toHaveURL(`http://localhost:4173/games?tag=${encodeURIComponent(text)}`)
await expect(page.locator('[data-testid=game-card]').first()).toBeVisible()
})
```
(前置核实:`fixtures/generated/games/fixture__2048.json` 的 tags 非空;若为空换 `case-files` 等任一有 tag 的夹具,并把 spec 测试计划里的 slug 一并换——实施时以真实夹具为准,不改 spec 结构。)
**验收**:`npm test -- app/views/GameView.test.ts app/components/GameCard.test.ts` + typecheck。
**commit**:`feat(ux): P10-T3 clickable tags on game page and cards (D-G)`
---
### Task 4: 复制链接 + toast 接线(GameView)
**改 `app/views/GameView.vue`**(spec §3.4 + D-E/D-F)。依赖 Task 1 的 `useToast`。
1. import:`PhLinkSimple`(`@phosphor-icons/vue` 同行追加)、`useToast`。
2. setup:`const toast = useToast()`;
```ts
async function copyLink(): Promise<void> {
const url = location.origin + router.resolve({ name: 'game', params: { user: props.user, slug: props.slug } }).href
try {
await navigator.clipboard.writeText(url)
toast.success('链接已复制')
} catch {
toast.error('复制失败,请手动复制地址栏链接')
}
}
```
3. 模板:meta 段(`预计时长` 那个 `<p>`,:80-90 区域)之后、description `<p>` 之前插:
```html
<div>
<button
type="button"
data-testid="copy-link"
class="btn-surface lift inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold"
@click="copyLink"
><PhLinkSimple :size="14" weight="bold" aria-hidden="true" />复制链接</button>
</div>
```
(happy-dom/旧环境 `navigator.clipboard` 可能 undefined——`try` 需覆盖属性访问:实现为 `await navigator.clipboard?.writeText(url)` 且 undefined 时手动 throw,或整段放 try 内直接访问 `navigator.clipboard.writeText`(undefined 属性访问抛 TypeError 也被 catch)。取后者:整段 try/catch 已兜住。)
**测试 扩 `app/views/GameView.test.ts`**:
- mock `navigator.clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }`(happy-dom 下 `Object.defineProperty(navigator, 'clipboard', …)`;beforeEach 还原)+ `__resetToasts()`。
- 点 `[data-testid=copy-link]` → writeText 收到 `http://localhost/games/fixture/minimal`(happy-dom location.origin 以实际环境为准,断言用 `location.origin + '/games/fixture/minimal'` 拼接式)→ toast store 有 kind=success text=`链接已复制`。
- writeText reject → toast kind=error text=`复制失败,请手动复制地址栏链接`。
- notFound 分支 → `[data-testid=copy-link]` 零渲染。
**e2e 追加 `e2e/ux.spec.ts`**:
```ts
test('复制链接:clipboard 写入规范 URL + toast 确认', async ({ context, page }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto('http://localhost:4173/games/fixture/2048')
await page.locator('[data-testid=copy-link]').click()
await expect(page.locator('[data-testid=toast]').first()).toContainText('链接已复制')
const text = await page.evaluate(() => navigator.clipboard.readText())
expect(text).toBe('http://localhost:4173/games/fixture/2048')
})
```
**验收**:`npm test -- app/views/GameView.test.ts` + typecheck。
**commit**:`feat(ux): P10-T4 copy-link share button wired to toast (D-E/D-F)`
---
### Task 5: 页头下拉外点/Esc 关闭(AppHeader)
**改 `app/components/AppHeader.vue`**(spec §3.5 + D-K)。锚点:`detailsRef` :9-10、route watch :11-13、`<details>` :79、`<summary>` :80。
1. import 追加 `onBeforeUnmount, onMounted`(vue 同行)。
2. setup 追加:
```ts
const menuOpen = ref(false)
const summaryRef = ref<HTMLElement | null>(null)
function onDocPointerDown(e: PointerEvent): void {
if (menuOpen.value && detailsRef.value && !detailsRef.value.contains(e.target as Node)) {
detailsRef.value.open = false
}
}
function onDocKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape' && menuOpen.value && detailsRef.value) {
detailsRef.value.open = false
summaryRef.value?.focus()
}
}
onMounted(() => {
document.addEventListener('pointerdown', onDocPointerDown)
document.addEventListener('keydown', onDocKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown)
document.removeEventListener('keydown', onDocKeydown)
})
```
3. 模板:`<details … @toggle="menuOpen = ($event.target as HTMLDetailsElement).open">`;`<summary ref="summaryRef" …>`(其余属性类名一字不动)。既有 route.fullPath watch 保留(可在其中同步 `menuOpen.value = false`,或依赖 toggle 事件——实现取后者:程序性 `open = false` 也触发 toggle,无需重复)。
**测试 新 `app/components/AppHeader.test.ts`**(mock 惯例:`vi.mock('@/data')` 供 repo.listGames/listDocs、`vi.mock('@/auth')` 供 authEnabled=true + session.state.user 已登录;memory router 带 home/catalog 两路由):
- 设 `details.open = true`(dispatchEvent toggle 或 `element.open=true` 后 `await nextTick()` 让 @toggle 同步 menuOpen)→ document 上 `dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`(target 为 document.body 外点)→ open 变 false。
- 菜单内 pointerdown(target=details 子节点,bubbles)→ 不关。
- open 后 `document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))` → 关且 `document.activeElement` 为 summary。
- 卸载后监听移除(unmount → 再 dispatch 不抛)。
**e2e 追加 `e2e/ux.spec.ts`**:
```ts
test('页头菜单:Esc 与外点关闭', async ({ page }) => {
await seedSession(page) // 从 './helpers' import
await page.goto('http://localhost:4173/')
const summary = page.getByRole('button', { name: /tester/ }) // details summary 的可访问名;实施时按真实渲染调整选择器(可用 page.locator('details summary'))
await summary.click()
await expect(page.locator('details[open]')).toHaveCount(1)
await page.keyboard.press('Escape')
await expect(page.locator('details[open]')).toHaveCount(0)
await summary.click()
await expect(page.locator('details[open]')).toHaveCount(1)
await page.locator('main').click({ position: { x: 5, y: 5 } })
await expect(page.locator('details[open]')).toHaveCount(0)
})
```
**验收**:`npm test -- app/components/AppHeader.test.ts` + typecheck。
**commit**:`feat(ux): P10-T5 header dropdown closes on outside-click and Esc (D-K)`
---
### Task 6: 最近玩过(recent.ts + GameHost + LandingView)
**新文件 `app/lib/recent.ts`**(spec §3.6 + D-H/D-I):
```ts
const KEY = 'crearte.recent.v1'
const MAX_RECENT = 12
export interface RecentEntry { id: string; at: string }
export function listRecent(): string[] {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.filter((e): e is RecentEntry => Boolean(e) && typeof (e as RecentEntry).id === 'string')
.map((e) => e.id)
} catch {
return []
}
}
export function recordPlay(id: string): void {
if (!id) return
try {
const entries: RecentEntry[] = [{ id, at: new Date().toISOString() },
...listRecentRaw().filter((e) => e.id !== id)].slice(0, MAX_RECENT)
localStorage.setItem(KEY, JSON.stringify(entries))
} catch {
// 隐私模式/配额满:静默(spec §4)
}
}
// listRecentRaw:同 listRecent 但返回 RecentEntry[](内部复用解析逻辑,导出与否以测试便利定)
```
**改 `runtime/host/GameHost.vue`**(D-H,全局约束 2 的唯一 runtime 例外):
- import `recordPlay`:`import { recordPlay } from '../../app/lib/recent'`(与 GameView import GameHost 的跨层相对路径惯例对称)。
- setup 追加 `watch(() => frame.state.value.phase, (p) => { if (p === 'ready') recordPlay(props.game.id) })`(`watch` 已在 import 列表)。
**改 `app/views/LandingView.vue`**(spec §3.6 + D-J):
- import `listRecent`;`const RECENT_LIMIT = 6`(FEATURED_LIMIT 旁)。
- setup:`const recentIds = listRecent()`(setup 时读一次即可——路由重挂载天然刷新,D-J)。
- `const recent = computed(() => { const byId = new Map((games.value ?? []).map((g) => [g.id, g])); return recentIds.map((id) => byId.get(id)).filter((g): g is GameSummary => Boolean(g)).slice(0, RECENT_LIMIT) })`。
- 模板:hero `</section>` 与精选 `<section class="mt-8">` 之间插(**一字不差**按 spec §3.6 的条带模板,`data-testid="recent-strip"`、h2 样式逐字、`:heading-level="3"`)。
**测试 新 `app/lib/recent.test.ts`**:beforeEach `localStorage.clear()`;钉:空→[];recordPlay 前插(两次不同 id → 新者先);重复 id 去重冒泡到最前;13 次 → 截 12;损坏 JSON(手 setItem 'not-json')→ [];非数组 JSON → [];mock `localStorage.setItem` throw → recordPlay 不抛、listRecent 仍可用。
**测试 扩 `runtime/host/GameHost.test.ts`**(先读既有 mock 体系;`vi.mock('../../app/lib/recent')` 或按该文件相对别名):phase → 'ready' 触发 `recordPlay(game.id)`;'booting'/'error' 不触发;restart 后再次 ready 再次触发。若既有 GameHost.test 的 frame mock 不便驱动 phase,允许对该测试文件做最小扩展(不删既有用例)。
**测试 新/扩 `app/views/LandingView.test.ts`**(如不存在则新建,mock 惯例照抄 AuthorView.test.ts 的 vi.hoisted+vi.mock):games 夹具两款 + localStorage 预置 `[{"id":"b","at":"…"},{"id":"a","at":"…"},{"id":"gone","at":"…"}]` → `[data-testid=recent-strip]` 渲染 2 卡(gone 丢弃)且顺序 b,a;无记录 → 零渲染;记录全不可解析 → 零渲染。
**e2e 追加 `e2e/ux.spec.ts`**:
```ts
test('最近玩过:站内运行就绪后落地页出现继续游玩条带', async ({ page }) => {
await openGame(page, '2048') // helpers 既有;data-ready=1 即 phase ready
await openGame(page, 'a-dark-room')
await page.goto('http://localhost:4173/')
const strip = page.locator('[data-testid=recent-strip]')
await expect(strip).toBeVisible()
await expect(strip.getByText('继续游玩 · RECENTLY PLAYED')).toBeVisible()
const hrefs = await strip.locator('a[href^="/games/"]').evaluateAll((els) => els.map((el) => el.getAttribute('href')))
expect(hrefs[0]).toBe('/games/fixture/a-dark-room') // 最近玩的冒泡最前
expect(hrefs).toContain('/games/fixture/2048')
// 精选区计数不受影响(条带在 hero 与精选之间,选择器分开)
await expect(page.locator('main > section:nth-of-type(3) a[href^="/games/"]')).toHaveCount(6)
})
```
(⚠️ 最后一行的选择器以真实 DOM 结构为准——条带渲染后 section 序号会变;实施时改用精选区自己的稳定定位,如给精选 section 的 h2 文本定位后 xpath following,或直接用 `page.locator('section', { hasText: '精选 · SELECTED' })` 圈定后计数。目的只有一个:证明 landing.spec 精选 6 卡钉桩在有条带时仍成立。)
**验收**:`npm test -- app/lib/recent.test.ts runtime/host/GameHost.test.ts app/views/LandingView.test.ts` + typecheck。
**commit**:`feat(ux): P10-T6 recently-played strip (recent.ts + GameHost record + landing, D-H/D-I/D-J)`
---
### 控制者收尾(不派子代理)
1. 全量验收五腿:`npm test`(≥551+新增全绿)、`npm run typecheck`、`npm run build`、`npm run e2e`(72+1skip+新增全绿)、`npm run e2e:noauth`(4)。
2. CHANGELOG 0.23.0 双语条目(Added:toast/标签/复制链接/最近玩过条带;Fixed:脏表单守卫口径归 Added「交互护栏」、下拉关闭归 Fixed)+ commit。
3. 全分支审查包(`scripts/review-package $(git merge-base master HEAD) HEAD`)→ 派最终审查者(强模型)。
4. 审查 notes 逐条裁定后 `--no-ff` 合 master、删分支、push 三仓(crearte + wrapper 记账:ROADMAP P10 行 + 索引登记 + wrapper CHANGELOG 0.3.2)。
5. wrapper 记账 commit 引审查判定形("PASS with notes — note pinned by <sha>"惯例)。