feat(task-board): refine running state indicator
This commit is contained in:
@@ -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。
|
||||
@@ -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,门禁已满足,进入服务重启与生效确认。
|
||||
@@ -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 周期移动,减少动态效果时静止。
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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('<img src=x onerror=alert(1)>'), '标题应作为纯文本保留');
|
||||
assert.strictEqual(walk(firstCard).some((node) => node.tagName === 'IMG'), false, '恶意标题不得解析成 DOM');
|
||||
|
||||
386
scripts/task-board-runtime-animation-browser.js
Normal file
386
scripts/task-board-runtime-animation-browser.js
Normal file
@@ -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 `<!doctype html>
|
||||
<html lang="zh-CN" data-theme="wasteland">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="stylesheet" href="/task-board.css">
|
||||
<style>html,body,#root{width:100%;height:100%;margin:0;overflow:hidden}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script src="/task-board.js"></script>
|
||||
<script>
|
||||
window.runtimeBoard = window.CcwebTaskBoard.mount(document.querySelector('#root'), {
|
||||
document,
|
||||
autoLoad: false,
|
||||
initialData: {
|
||||
statusDefinitions: [
|
||||
{ id: 'in_progress', label: '处理中', prompt: '任务正在实际推进时选择此列。', color: '#c49a5a', order: 10, enabled: true, system: true }
|
||||
],
|
||||
definitionsVersion: 1,
|
||||
tasks: [
|
||||
{
|
||||
sessionId: 'runtime-running',
|
||||
title: '正在执行的对话任务',
|
||||
isRunning: true,
|
||||
runtimeState: '运行中',
|
||||
taskTracking: { enabled: true, statusId: 'in_progress', summary: '卡片边缘展示活动轨迹', version: 2 }
|
||||
},
|
||||
{
|
||||
sessionId: 'runtime-idle',
|
||||
title: '未运行的对话任务',
|
||||
isRunning: false,
|
||||
runtimeState: '未运行',
|
||||
taskTracking: { enabled: true, statusId: 'in_progress', summary: '保持静态卡片样式', version: 1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
10
暗金荒野运行卡片动画 TO DO list.csv
Normal file
10
暗金荒野运行卡片动画 TO DO list.csv
Normal file
@@ -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 门禁已通过
|
||||
|
Reference in New Issue
Block a user