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

24 KiB
Raw Blame History

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):

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 追加一条(文件尾,既有用例一字不动):

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>:

<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(本任务创建,后续任务追加)第一条:

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();
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('复制失败,请手动复制地址栏链接')
  }
}
  1. 模板:meta 段(预计时长 那个 <p>,:80-90 区域)之后、description <p> 之前插:
<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:

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 追加:
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)
})
  1. 模板:<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:

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):

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:

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 "惯例)。