diff --git a/.planning/2026-08-13-wasteland-runtime-card-animation/findings.md b/.planning/2026-08-13-wasteland-runtime-card-animation/findings.md new file mode 100644 index 0000000..fadc9e7 --- /dev/null +++ b/.planning/2026-08-13-wasteland-runtime-card-animation/findings.md @@ -0,0 +1,13 @@ +# 暗金荒野任务卡运行态动画发现 + +- 截图主题为 `wasteland / 暗金荒野`。 +- 服务端 `taskBoardTaskWithRuntime()` 已同时返回 `isRunning` 和中文 `runtimeState`。 +- 前端 `normalizeTask()` 当前只保留 `runtimeState` 文案,导致 CSS 无法基于可靠布尔状态选择卡片。 +- 任务卡 DOM 已有 `data-status-id`;新增独立 `data-runtime-state` 不会混淆任务列。 +- 任务卡已有移动/进出场动画;运行态动画不得覆盖 `transform`,避免和卡片移动及 hover 冲突。 +- `task-board.css` 已有全局 `prefers-reduced-motion` 门禁,可再给运行卡片明确静态落点。 +- Firefox 实测确认 `data-runtime-state="running"` 只来自服务端布尔字段,任务列仍为 `in_progress`;运行态动画不会改变列位置。 +- 真实截图:[/tmp/cc-web-wasteland-runtime-animation-1786605817208/animated-desktop-first.png](/tmp/cc-web-wasteland-runtime-animation-1786605817208/animated-desktop-first.png)、[/tmp/cc-web-wasteland-runtime-animation-1786605817208/animated-desktop-second.png](/tmp/cc-web-wasteland-runtime-animation-1786605817208/animated-desktop-second.png)、[/tmp/cc-web-wasteland-runtime-animation-1786605817208/reduced-motion-desktop-first.png](/tmp/cc-web-wasteland-runtime-animation-1786605817208/reduced-motion-desktop-first.png)。完整数据在 `evidence.json`。 +- 用户确认只保留左侧竖条/活动轨迹;运行卡片本体必须与同列未运行卡片保持相同背景、边框、阴影且没有动画。 +- 收敛版 Firefox computed style:running/idle 背景均为 `rgba(14, 16, 14, 0.94)`、边框均为 `rgba(196, 154, 90, 0.28)`、阴影均为 `none`、卡片动画均为 `none`;左侧竖条约 3.52px 宽、0.74 opacity。 +- 收敛版证据:`/tmp/cc-web-wasteland-runtime-animation-1786607214412/`;1024px 与 500px 的页面 scrollWidth 均等于 viewport width。 diff --git a/.planning/2026-08-13-wasteland-runtime-card-animation/progress.md b/.planning/2026-08-13-wasteland-runtime-card-animation/progress.md new file mode 100644 index 0000000..b6f90e5 --- /dev/null +++ b/.planning/2026-08-13-wasteland-runtime-card-animation/progress.md @@ -0,0 +1,15 @@ +# 暗金荒野任务卡运行态动画进度 + +- 2026-08-13:读取主题技能、主题架构和验收清单,确认只修改运行态层级。 +- 2026-08-13:通过 codebase-memory 索引和本地精确检索定位运行态服务端 DTO、任务卡 DOM、样式和前端单测。 +- 2026-08-13:设计确定为卡片静态强化边框 + 低频呼吸光 + 左侧活动轨迹;不移动内容,不依赖状态文字。 +- 2026-08-13:独立计划审查通过;新增 DOM、主题动画与 reduced-motion 契约后,前端专项测试按预期退出 1。 +- 2026-08-13:实现 `isRunning` 前端保留、卡片 `data-runtime-state`、暗金荒野运行卡片呼吸边框/左侧活动轨迹/强化徽标,以及 reduced-motion 静态降级。 +- 2026-08-13:语法检查、前端专项、service 专项、完整 regression 和 `git diff --check` 全部退出 0。 +- 2026-08-13:新增真实 Firefox 动画验收脚本 `scripts/task-board-runtime-animation-browser.js`,正常动效、减少动态效果和 500px 窄屏均通过;截图与几何数据写入 `/tmp/cc-web-wasteland-runtime-animation-1786605817208/`。 +- 2026-08-13:重启门禁未通过,另一个 cc-web 对话仍在运行;未重启服务,等待门禁满足后完成上线与临时清单清理。 +- 2026-08-13:收到视觉收敛反馈;按“先删重”原则准备移除卡片本体渐变、呼吸边框和外发光,保留左侧竖条/移动点并把徽标圆点改为静态。 +- 2026-08-13:返工红灯准确命中旧整卡动画;现已删除整卡运行态规则及 `runtime-card` 关键帧,缩小左侧轨迹光晕,并把徽标圆点改为静态。 +- 2026-08-13:真实浏览器契约改为对比 running/idle 卡片本体 computed style 必须相同,仅左侧 `::before` 竖条和 `::after` 移动点表达运行态。 +- 2026-08-13:语法检查、service 专项、完整 regression、`git diff --check` 和收敛版 Firefox 验收均退出 0;目视确认整卡不再泛光。 +- 2026-08-13:重启门禁复查仅当前对话为 running,门禁已满足,进入服务重启与生效确认。 diff --git a/.planning/2026-08-13-wasteland-runtime-card-animation/task_plan.md b/.planning/2026-08-13-wasteland-runtime-card-animation/task_plan.md new file mode 100644 index 0000000..9c2feaf --- /dev/null +++ b/.planning/2026-08-13-wasteland-runtime-card-animation/task_plan.md @@ -0,0 +1,36 @@ +# 暗金荒野任务卡运行态动画 + +## 目标 + +仅在 `wasteland / 暗金荒野` 主题下,让任务看板中真实运行的卡片具备清晰、克制的卡片级动画;运行态继续与动态任务列完全独立。 + +## 阶段 + +- [x] 1. 锁定运行态数据与暗金荒野样式边界 +- [x] 2. 写出运行态卡片标记和动画契约红灯 +- [x] 3. 实现布尔运行态标记和主题专属卡片动画 +- [x] 4. 运行专项与完整回归 +- [x] 5. 用真实浏览器检查运行/未运行和减少动态效果 +- [x] 6. 检查首次重启门禁 +- [x] 7. 按反馈移除卡片本体泛光并保留左侧轨迹 +- [x] 8. 重新运行专项、回归与真实浏览器验收 +- [ ] 9. 再次检查重启门禁并让修改生效(进行中) + +## 设计约束 + +- 只使用服务端明确下发的 `isRunning` 布尔值,不匹配“运行中”等文案。 +- 卡片写入 `data-runtime-state="running|idle"`,任务列 `data-status-id` 保持不变。 +- 运行中卡片本体与未运行卡片保持相同静态背景、边框和阴影,只保留左侧竖条与活动轨迹;内容和几何位置不抖动。 +- 未运行卡片保持静态;其他主题不应用该动画。 +- `prefers-reduced-motion: reduce` 下退化为静态高亮。 +- 不增加图片资产,不改变前端布局、任务状态或服务端业务逻辑。 + +## 错误记录 + +- 2026-08-13:此前 `pm2 restart ccweb --update-env` 在执行时被用户新消息中断,重启未完成。 +- 2026-08-13:前端专项红灯退出 1,准确失败在缺少暗金荒野真实运行卡片选择器。 +- 2026-08-13:Firefox 真实验收通过。正常动效下运行卡片动画名为 `ccweb-task-board-wasteland-runtime-card`(2.8s),边缘轨迹动画名为 `ccweb-task-board-wasteland-runtime-tracer`;6 次采样中轨迹移动超过 135px,卡片 x/y/宽高最大误差小于 0.5px。 +- 2026-08-13:未运行卡片 animation 为 `none`;`prefers-reduced-motion` 下运行卡片、轨迹点和徽标圆点均为 `none`,但保留静态金色边框;1024px 和 500px 视口页面 scrollWidth 均等于 viewport width。 +- 2026-08-13:重启门禁检查发现除当前对话外,`94dba3ea-62cb-45df-bdc5-41623832b2c6` 仍为 running;按仓库规则暂不执行 `pm2 restart ccweb --update-env`。临时 TODO CSV 因最后一步未完成而保留。 +- 2026-08-13:用户确认左侧竖条可以保留,但整张卡片呼吸背景与泛光过于花哨;返工为仅左侧轨迹表达运行态。 +- 2026-08-13:收敛版 Firefox 复验通过。运行/未运行卡片的背景、边框、阴影完全一致且卡片动画均为 `none`;左侧轨迹仍以 2.8s 周期移动,减少动态效果时静止。 diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 09b5b03..b256951 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/public/task-board.css b/public/task-board.css index 51f8000..ca7cc92 100644 --- a/public/task-board.css +++ b/public/task-board.css @@ -482,6 +482,61 @@ background: var(--bg-tertiary); } +/* 暗金荒野:运行态只来自 data-runtime-state,不从显示文案反推。 */ +html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::before { + content: ''; + position: absolute; + top: 0.62rem; + bottom: 0.62rem; + left: -0.12rem; + width: 0.22rem; + border-radius: 999px; + background: linear-gradient( + 180deg, + var(--wasteland-gold-bright), + var(--wasteland-green), + var(--wasteland-gold-bright) + ); + box-shadow: 0 0 0.28rem color-mix(in srgb, var(--wasteland-gold-bright) 32%, transparent); + opacity: 0.74; + pointer-events: none; +} + +html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::after { + content: ''; + position: absolute; + top: 0.72rem; + left: -0.17rem; + width: 0.28rem; + height: 0.28rem; + border-radius: 50%; + background: var(--wasteland-gold-bright); + box-shadow: + 0 0 0.25rem color-mix(in srgb, var(--wasteland-gold-bright) 72%, transparent), + 0 0 0.45rem color-mix(in srgb, var(--wasteland-green) 46%, transparent); + pointer-events: none; + animation: ccweb-task-board-wasteland-runtime-tracer 2.8s ease-in-out infinite; +} + +html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running'] .ccweb-task-board__runtime-state { + gap: 0.34rem; + border-style: solid; + border-color: color-mix(in srgb, var(--wasteland-green) 74%, var(--wasteland-gold)); + color: var(--wasteland-gold-bright); + background: color-mix(in srgb, var(--wasteland-green) 16%, var(--bg-tertiary)); + font-weight: 760; +} + +html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running'] .ccweb-task-board__runtime-state::before { + content: ''; + flex: 0 0 auto; + width: 0.38rem; + height: 0.38rem; + border-radius: 50%; + background: var(--wasteland-green); + box-shadow: 0 0 0.22rem color-mix(in srgb, var(--wasteland-green) 48%, transparent); +} + .ccweb-task-board__card-footer { align-items: flex-end; padding-top: 0.12rem; @@ -1001,6 +1056,19 @@ to { opacity: 1; } } +@keyframes ccweb-task-board-wasteland-runtime-tracer { + 0%, 100% { + top: 0.72rem; + opacity: 0.55; + transform: scale(0.72); + } + 50% { + top: calc(100% - 1.04rem); + opacity: 1; + transform: scale(1); + } +} + @media (max-width: 1180px) { .ccweb-task-board__toolbar { grid-template-columns: 1fr; @@ -1104,6 +1172,13 @@ } @media (prefers-reduced-motion: reduce) { + html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::after { + top: 50%; + opacity: 1; + transform: translateY(-50%); + animation: none; + } + .ccweb-task-board, .ccweb-task-board *, .ccweb-task-board *::before, diff --git a/public/task-board.js b/public/task-board.js index d8a686d..c4a2f1d 100644 --- a/public/task-board.js +++ b/public/task-board.js @@ -110,6 +110,7 @@ summary: text(tracking.summary || input.summary), reason: text(tracking.reason || input.reason), source: text(tracking.source || input.source), + isRunning: input.isRunning === true, runtimeState, runtimeDetail: text(input.runtimeDetail || runtime.detail || input.lastRuntimeMessage), agent: labelFromValue(input.agent || input.agentName || session.agent || runtime.agent), @@ -692,6 +693,7 @@ task.statusId, task.summary, task.reason, + task.isRunning, task.runtimeState, task.runtimeDetail, task.agent, @@ -709,6 +711,7 @@ card.replaceChildren(); card.dataset.sessionId = task.sessionId; card.dataset.statusId = task.statusId; + card.dataset.runtimeState = task.isRunning ? 'running' : 'idle'; card.setAttribute('role', 'listitem'); setStatusColor(card, definition.color); diff --git a/scripts/regression.js b/scripts/regression.js index a1beec1..950b6b6 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -771,6 +771,26 @@ function assertTaskBoardIntegrationContract() { saveSessionSource.includes('preserveNewerTaskTrackingForSessionSave(session, targetPath)'), 'Ordinary session persistence must preserve a newer task tracking version written by the classifier' ); + assert( + taskBoardFrontendSource.includes("card.dataset.runtimeState = task.isRunning ? 'running' : 'idle'") + && taskBoardFrontendSource.includes('isRunning: input.isRunning === true'), + 'Task cards should expose the server runtime boolean without matching localized status text' + ); + assert( + taskBoardStyleSource.includes("html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::before") + && taskBoardStyleSource.includes("html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::after") + && taskBoardStyleSource.includes('@keyframes ccweb-task-board-wasteland-runtime-tracer'), + 'Wasteland should keep only an isolated left-edge runtime indicator and tracer' + ); + assert( + !/html\[data-theme='wasteland'\] \.ccweb-task-board__card\[data-runtime-state='running'\]\s*\{/.test(taskBoardStyleSource) + && !taskBoardStyleSource.includes('@keyframes ccweb-task-board-wasteland-runtime-card'), + 'Wasteland should not animate or glow the entire running card' + ); + assert( + /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.ccweb-task-board__card\[data-runtime-state='running'\]::after\s*\{[^}]*animation:\s*none;/.test(taskBoardStyleSource), + 'Wasteland edge tracer should retain a static reduced-motion treatment' + ); } function assertFrontendGenerationControlsContract() { diff --git a/scripts/task-board-frontend-unit.js b/scripts/task-board-frontend-unit.js index 79c1729..0639f12 100644 --- a/scripts/task-board-frontend-unit.js +++ b/scripts/task-board-frontend-unit.js @@ -466,6 +466,24 @@ async function main() { assert(/grid-auto-columns:/.test(css), '看板列数和宽度应动态生成'); assert(css.includes(':focus-visible'), 'CSS 应提供键盘焦点样式'); assert(css.includes('@media (prefers-reduced-motion: reduce)'), 'CSS 应尊重减少动态效果偏好'); + assert( + css.includes("html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::before") + && css.includes("html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::after"), + '暗金荒野运行卡片应保留左侧竖条和活动轨迹', + ); + assert( + !/html\[data-theme='wasteland'\] \.ccweb-task-board__card\[data-runtime-state='running'\]\s*\{/.test(css) + && !css.includes('@keyframes ccweb-task-board-wasteland-runtime-card'), + '暗金荒野不得再给整张运行卡片添加背景、边框或呼吸泛光', + ); + assert( + css.includes('@keyframes ccweb-task-board-wasteland-runtime-tracer'), + '暗金荒野运行卡片应提供独立的边缘活动轨迹', + ); + assert( + /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.ccweb-task-board__card\[data-runtime-state='running'\]::after\s*\{[^}]*animation:\s*none;/.test(css), + '减少动态效果时左侧活动点必须停止移动', + ); assert(css.includes('.ccweb-task-tracking'), 'CSS 应覆盖可复用任务跟踪开关'); assert(!source.includes('refs.board.replaceChildren'), '数据更新不得清空并重建整个看板'); assert(source.includes('getBoundingClientRect'), '卡片移动应记录更新前后的几何位置'); @@ -650,6 +668,10 @@ async function main() { assert.strictEqual(byClass(firstCard, 'ccweb-task-board__business-state').length, 0, '卡片不得渲染业务状态胶囊'); assert.strictEqual(byAction(firstCard, 'set-status')[0].value, 'queue-x', '状态选择器应作为业务状态的唯一卡片入口'); assert(firstCard.textContent.includes('运行:运行中'), '卡片应独立显示运行态'); + assert.strictEqual(firstTaskState.isRunning, true, '前端公开任务状态应保留服务端明确下发的运行布尔值'); + assert.strictEqual(firstCard.dataset.runtimeState, 'running', '运行卡片应暴露稳定的 data-runtime-state 契约'); + const idleCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-three'); + assert.strictEqual(idleCard.dataset.runtimeState, 'idle', '未运行卡片应保持静态 idle 契约'); assert.strictEqual(firstCard.textContent.includes('Agent Alpha'), false, '任务卡不得显示 Agent 运行器名称'); assert(firstCard.textContent.includes(''), '标题应作为纯文本保留'); assert.strictEqual(walk(firstCard).some((node) => node.tagName === 'IMG'), false, '恶意标题不得解析成 DOM'); diff --git a/scripts/task-board-runtime-animation-browser.js b/scripts/task-board-runtime-animation-browser.js new file mode 100644 index 0000000..ef04c82 --- /dev/null +++ b/scripts/task-board-runtime-animation-browser.js @@ -0,0 +1,386 @@ +'use strict'; + +const assert = require('node:assert'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const REPO_DIR = path.resolve(__dirname, '..'); +const PUBLIC_DIR = path.join(REPO_DIR, 'public'); +const GECKODRIVER = '/snap/firefox/current/usr/lib/firefox/geckodriver'; +const FIREFOX = '/snap/firefox/current/usr/lib/firefox/firefox'; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForPort(port, timeoutMs = 10_000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const ready = await new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + }); + if (ready) return; + await sleep(50); + } + throw new Error(`等待端口 ${port} 超时`); +} + +async function waitFor(predicate, message, timeoutMs = 10_000) { + const started = Date.now(); + let lastError = null; + while (Date.now() - started < timeoutMs) { + try { + const value = await predicate(); + if (value) return value; + } catch (error) { + lastError = error; + } + await sleep(80); + } + throw new Error(`${message}${lastError ? `: ${lastError.message}` : ''}`); +} + +async function stopChild(child) { + if (!child || child.exitCode !== null) return; + child.kill('SIGTERM'); + await Promise.race([ + new Promise((resolve) => child.once('exit', resolve)), + sleep(1_000), + ]); + if (child.exitCode === null) child.kill('SIGKILL'); +} + +class FirefoxDriver { + constructor(port) { + this.port = port; + this.sessionId = ''; + } + + async request(method, endpoint, body) { + const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, { + method, + headers: body === undefined ? {} : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await response.json(); + if (!response.ok || payload?.value?.error) { + throw new Error(`WebDriver ${method} ${endpoint}: ${JSON.stringify(payload.value || payload)}`); + } + return payload.value; + } + + async start(options = {}) { + const reducedMotion = options.reducedMotion === true; + const value = await this.request('POST', '/session', { + capabilities: { + alwaysMatch: { + browserName: 'firefox', + acceptInsecureCerts: true, + 'moz:firefoxOptions': { + binary: FIREFOX, + args: ['-headless'], + prefs: { + 'browser.shell.checkDefaultBrowser': false, + 'browser.startup.homepage_override.mstone': 'ignore', + 'ui.prefersReducedMotion': reducedMotion ? 1 : 0, + 'media.prefers-reduced-motion': reducedMotion ? 1 : 0, + }, + }, + }, + }, + }); + this.sessionId = value.sessionId; + return value.capabilities; + } + + execute(script, args = []) { + return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args }); + } + + navigate(url) { + return this.request('POST', `/session/${this.sessionId}/url`, { url }); + } + + async setWindow(width, height) { + await this.request('POST', `/session/${this.sessionId}/window/rect`, { width, height }); + return this.request('GET', `/session/${this.sessionId}/window/rect`); + } + + async screenshot(targetPath) { + const base64 = await this.request('GET', `/session/${this.sessionId}/screenshot`); + fs.writeFileSync(targetPath, Buffer.from(base64, 'base64')); + return targetPath; + } + + async close() { + if (!this.sessionId) return; + try { + await this.request('DELETE', `/session/${this.sessionId}`); + } finally { + this.sessionId = ''; + } + } +} + +function fixtureHtml() { + return ` + + + + + + + + + +
+ + + +`; +} + +function startStaticServer(port) { + const allowed = new Map([ + ['/style.css', ['style.css', 'text/css; charset=utf-8']], + ['/task-board.css', ['task-board.css', 'text/css; charset=utf-8']], + ['/task-board.js', ['task-board.js', 'text/javascript; charset=utf-8']], + ]); + const server = http.createServer((request, response) => { + const pathname = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).pathname; + if (pathname === '/') { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + response.end(fixtureHtml()); + return; + } + const asset = allowed.get(pathname); + if (!asset) { + response.writeHead(404); + response.end('Not found'); + return; + } + response.writeHead(200, { 'content-type': asset[1], 'cache-control': 'no-store' }); + response.end(fs.readFileSync(path.join(PUBLIC_DIR, asset[0]))); + }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +async function audit(driver) { + return driver.execute(` + const running = document.querySelector('[data-session-id="runtime-running"]'); + const idle = document.querySelector('[data-session-id="runtime-idle"]'); + const rect = (node) => { + const value = node.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + const style = (node, pseudo) => { + const value = getComputedStyle(node, pseudo || null); + return { + content: value.content, + animationName: value.animationName, + animationDuration: value.animationDuration, + borderColor: value.borderColor, + backgroundColor: value.backgroundColor, + boxShadow: value.boxShadow, + left: value.left, + top: value.top, + width: value.width, + height: value.height, + opacity: value.opacity, + }; + }; + return { + theme: document.documentElement.dataset.theme, + reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches, + viewport: { width: innerWidth, height: innerHeight, scrollWidth: document.documentElement.scrollWidth }, + running: { + runtimeState: running?.dataset.runtimeState || '', + statusId: running?.dataset.statusId || '', + rect: rect(running), + style: style(running), + rail: style(running, '::before'), + tracer: style(running, '::after'), + badge: style(running.querySelector('.ccweb-task-board__runtime-state')), + badgeDot: style(running.querySelector('.ccweb-task-board__runtime-state'), '::before'), + }, + idle: { + runtimeState: idle?.dataset.runtimeState || '', + statusId: idle?.dataset.statusId || '', + rect: rect(idle), + style: style(idle), + tracer: style(idle, '::after'), + }, + }; + `); +} + +function rectDelta(left, right) { + return Math.max(...['x', 'y', 'width', 'height'].map((key) => Math.abs(left[key] - right[key]))); +} + +async function runBrowserCase(driver, url, evidenceRoot, options = {}) { + const reducedMotion = options.reducedMotion === true; + await driver.start({ reducedMotion }); + const desktop = await driver.setWindow(1024, 760); + await driver.navigate(url); + await waitFor(() => driver.execute(` + return document.querySelector('[data-session-id="runtime-running"]')?.dataset.runtimeState === 'running' + && document.querySelector('[data-session-id="runtime-idle"]')?.dataset.runtimeState === 'idle'; + `), '运行态卡片未完成真实挂载'); + + const first = await audit(driver); + const prefix = reducedMotion ? 'reduced-motion' : 'animated'; + const screenshots = { + first: await driver.screenshot(path.join(evidenceRoot, `${prefix}-desktop-first.png`)), + }; + const samples = [first]; + if (!reducedMotion) { + for (let index = 0; index < 6; index += 1) { + await sleep(240); + samples.push(await audit(driver)); + } + screenshots.second = await driver.screenshot(path.join(evidenceRoot, `${prefix}-desktop-second.png`)); + } + + assert.equal(first.theme, 'wasteland'); + assert.equal(first.reducedMotion, reducedMotion); + assert.equal(first.running.runtimeState, 'running'); + assert.equal(first.idle.runtimeState, 'idle'); + assert.equal(first.running.statusId, 'in_progress'); + assert.equal(first.idle.statusId, 'in_progress'); + assert.equal(first.running.style.animationName, 'none'); + assert.equal(first.idle.style.animationName, 'none'); + assert.equal(first.running.style.backgroundColor, first.idle.style.backgroundColor); + assert.equal(first.running.style.borderColor, first.idle.style.borderColor); + assert.equal(first.running.style.boxShadow, first.idle.style.boxShadow); + assert.notEqual(first.running.rail.content, 'none'); + assert(Number.parseFloat(first.running.rail.width) > 0, '左侧运行指示条没有可见宽度'); + assert(Number.parseFloat(first.running.rail.opacity) > 0, '左侧运行指示条不可见'); + assert.equal(first.running.rail.animationName, 'none'); + assert.equal(first.running.badgeDot.animationName, 'none'); + + if (reducedMotion) { + assert.equal(first.running.tracer.animationName, 'none'); + } else { + assert.match(first.running.tracer.animationName, /ccweb-task-board-wasteland-runtime-tracer/); + const tracerPositions = samples.map((sample) => Number.parseFloat(sample.running.tracer.top)).filter(Number.isFinite); + assert(Math.max(...tracerPositions) - Math.min(...tracerPositions) > 8, '左侧活动轨迹没有形成可见位移'); + assert(samples.every((sample) => rectDelta(first.running.rect, sample.running.rect) < 0.5), '运行动画导致卡片几何抖动'); + } + + const narrow = await driver.setWindow(500, 760); + await sleep(120); + const narrowAudit = await audit(driver); + assert.equal(narrow.width, 500); + assert(narrowAudit.viewport.scrollWidth <= narrowAudit.viewport.width, '窄屏产生页面级横向溢出'); + screenshots.narrow = await driver.screenshot(path.join(evidenceRoot, `${prefix}-500x760.png`)); + return { desktop, first, samples, narrow, narrowAudit, screenshots }; +} + +async function main() { + assert(fs.existsSync(GECKODRIVER), `缺少 geckodriver: ${GECKODRIVER}`); + assert(fs.existsSync(FIREFOX), `缺少 Firefox: ${FIREFOX}`); + + const evidenceRoot = path.join(os.tmpdir(), `cc-web-wasteland-runtime-animation-${Date.now()}`); + fs.mkdirSync(evidenceRoot, { recursive: true }); + const [serverPort, driverPort] = await Promise.all([freePort(), freePort()]); + const staticServer = await startStaticServer(serverPort); + const geckodriver = spawn(GECKODRIVER, ['--port', String(driverPort), '-b', FIREFOX], { + cwd: REPO_DIR, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let driverStderr = ''; + geckodriver.stderr.on('data', (chunk) => { driverStderr += chunk.toString(); }); + + const evidence = { evidenceRoot, animated: null, reducedMotion: null }; + try { + await waitForPort(driverPort); + const animatedDriver = new FirefoxDriver(driverPort); + try { + evidence.animated = await runBrowserCase( + animatedDriver, + `http://127.0.0.1:${serverPort}/`, + evidenceRoot, + { reducedMotion: false }, + ); + } finally { + await animatedDriver.close(); + } + + const reducedDriver = new FirefoxDriver(driverPort); + try { + evidence.reducedMotion = await runBrowserCase( + reducedDriver, + `http://127.0.0.1:${serverPort}/`, + evidenceRoot, + { reducedMotion: true }, + ); + } finally { + await reducedDriver.close(); + } + + fs.writeFileSync(path.join(evidenceRoot, 'evidence.json'), JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify({ ok: true, evidenceRoot, evidence }, null, 2)); + } catch (error) { + error.message += `\ngeckodriver stderr:\n${driverStderr.slice(-4_000)}`; + throw error; + } finally { + await new Promise((resolve) => staticServer.close(resolve)); + await stopChild(geckodriver); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/暗金荒野运行卡片动画 TO DO list.csv b/暗金荒野运行卡片动画 TO DO list.csv new file mode 100644 index 0000000..6d12f6b --- /dev/null +++ b/暗金荒野运行卡片动画 TO DO list.csv @@ -0,0 +1,10 @@ +id,item,status,done_at,notes +1,锁定运行态数据与暗金荒野样式边界,DONE,2026-08-13T15:10:00+08:00,已定位 isRunning DTO 与任务卡 DOM/CSS +2,写出运行态卡片标记和动画契约红灯,DONE,2026-08-13T15:14:34+08:00,专项测试退出 1 命中缺少主题运行卡片选择器 +3,实现布尔运行态标记和主题专属卡片动画,DONE,2026-08-13T15:20:02+08:00,新增 data-runtime-state 与 wasteland 专属动画 +4,运行专项与完整回归,DONE,2026-08-13T15:20:02+08:00,语法 专项 regression diff-check 全部退出 0 +5,用真实浏览器检查运行/未运行和减少动态效果,DONE,2026-08-13T15:25:00+08:00,Firefox 正常动效/减少动态效果/500px 均通过 +6,检查首次重启门禁,DONE,2026-08-13T15:30:00+08:00,另有对话 94dba3ea…b2c6 正在运行 未重启 +7,按反馈移除卡片本体泛光并保留左侧轨迹,DONE,2026-08-13T15:45:00+08:00,已删除整卡背景 边框 阴影动画并保留左侧轨迹 +8,重新运行专项、回归与真实浏览器验收,DONE,2026-08-13T15:48:27+08:00,专项 regression diff-check Firefox 均退出 0 +9,再次检查重启门禁并让修改生效,IN_PROGRESS,,仅当前对话为 running 门禁已通过