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 `
+
+