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);
|
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 {
|
.ccweb-task-board__card-footer {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding-top: 0.12rem;
|
padding-top: 0.12rem;
|
||||||
@@ -1001,6 +1056,19 @@
|
|||||||
to { opacity: 1; }
|
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) {
|
@media (max-width: 1180px) {
|
||||||
.ccweb-task-board__toolbar {
|
.ccweb-task-board__toolbar {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1104,6 +1172,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@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 *,
|
.ccweb-task-board *,
|
||||||
.ccweb-task-board *::before,
|
.ccweb-task-board *::before,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
summary: text(tracking.summary || input.summary),
|
summary: text(tracking.summary || input.summary),
|
||||||
reason: text(tracking.reason || input.reason),
|
reason: text(tracking.reason || input.reason),
|
||||||
source: text(tracking.source || input.source),
|
source: text(tracking.source || input.source),
|
||||||
|
isRunning: input.isRunning === true,
|
||||||
runtimeState,
|
runtimeState,
|
||||||
runtimeDetail: text(input.runtimeDetail || runtime.detail || input.lastRuntimeMessage),
|
runtimeDetail: text(input.runtimeDetail || runtime.detail || input.lastRuntimeMessage),
|
||||||
agent: labelFromValue(input.agent || input.agentName || session.agent || runtime.agent),
|
agent: labelFromValue(input.agent || input.agentName || session.agent || runtime.agent),
|
||||||
@@ -692,6 +693,7 @@
|
|||||||
task.statusId,
|
task.statusId,
|
||||||
task.summary,
|
task.summary,
|
||||||
task.reason,
|
task.reason,
|
||||||
|
task.isRunning,
|
||||||
task.runtimeState,
|
task.runtimeState,
|
||||||
task.runtimeDetail,
|
task.runtimeDetail,
|
||||||
task.agent,
|
task.agent,
|
||||||
@@ -709,6 +711,7 @@
|
|||||||
card.replaceChildren();
|
card.replaceChildren();
|
||||||
card.dataset.sessionId = task.sessionId;
|
card.dataset.sessionId = task.sessionId;
|
||||||
card.dataset.statusId = task.statusId;
|
card.dataset.statusId = task.statusId;
|
||||||
|
card.dataset.runtimeState = task.isRunning ? 'running' : 'idle';
|
||||||
card.setAttribute('role', 'listitem');
|
card.setAttribute('role', 'listitem');
|
||||||
setStatusColor(card, definition.color);
|
setStatusColor(card, definition.color);
|
||||||
|
|
||||||
|
|||||||
@@ -771,6 +771,26 @@ function assertTaskBoardIntegrationContract() {
|
|||||||
saveSessionSource.includes('preserveNewerTaskTrackingForSessionSave(session, targetPath)'),
|
saveSessionSource.includes('preserveNewerTaskTrackingForSessionSave(session, targetPath)'),
|
||||||
'Ordinary session persistence must preserve a newer task tracking version written by the classifier'
|
'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() {
|
function assertFrontendGenerationControlsContract() {
|
||||||
|
|||||||
@@ -466,6 +466,24 @@ async function main() {
|
|||||||
assert(/grid-auto-columns:/.test(css), '看板列数和宽度应动态生成');
|
assert(/grid-auto-columns:/.test(css), '看板列数和宽度应动态生成');
|
||||||
assert(css.includes(':focus-visible'), 'CSS 应提供键盘焦点样式');
|
assert(css.includes(':focus-visible'), 'CSS 应提供键盘焦点样式');
|
||||||
assert(css.includes('@media (prefers-reduced-motion: reduce)'), '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(css.includes('.ccweb-task-tracking'), 'CSS 应覆盖可复用任务跟踪开关');
|
||||||
assert(!source.includes('refs.board.replaceChildren'), '数据更新不得清空并重建整个看板');
|
assert(!source.includes('refs.board.replaceChildren'), '数据更新不得清空并重建整个看板');
|
||||||
assert(source.includes('getBoundingClientRect'), '卡片移动应记录更新前后的几何位置');
|
assert(source.includes('getBoundingClientRect'), '卡片移动应记录更新前后的几何位置');
|
||||||
@@ -650,6 +668,10 @@ async function main() {
|
|||||||
assert.strictEqual(byClass(firstCard, 'ccweb-task-board__business-state').length, 0, '卡片不得渲染业务状态胶囊');
|
assert.strictEqual(byClass(firstCard, 'ccweb-task-board__business-state').length, 0, '卡片不得渲染业务状态胶囊');
|
||||||
assert.strictEqual(byAction(firstCard, 'set-status')[0].value, 'queue-x', '状态选择器应作为业务状态的唯一卡片入口');
|
assert.strictEqual(byAction(firstCard, 'set-status')[0].value, 'queue-x', '状态选择器应作为业务状态的唯一卡片入口');
|
||||||
assert(firstCard.textContent.includes('运行:运行中'), '卡片应独立显示运行态');
|
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.strictEqual(firstCard.textContent.includes('Agent Alpha'), false, '任务卡不得显示 Agent 运行器名称');
|
||||||
assert(firstCard.textContent.includes('<img src=x onerror=alert(1)>'), '标题应作为纯文本保留');
|
assert(firstCard.textContent.includes('<img src=x onerror=alert(1)>'), '标题应作为纯文本保留');
|
||||||
assert.strictEqual(walk(firstCard).some((node) => node.tagName === 'IMG'), false, '恶意标题不得解析成 DOM');
|
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