chore: rebuild release package
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从归档图标板的 16px 原生规格生成 Plan List 暗金主题进度图标。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
SOURCE = ROOT / '.trellis/tasks/07-17-gilded-wasteland-theme/references/source-assets/wasteland-icon-sheet.webp'
|
||||
OUTPUT_DIR = ROOT / 'public/assets/themes/wasteland/icons/plan-progress'
|
||||
MANIFEST_PATH = OUTPUT_DIR / 'manifest.json'
|
||||
EXPECTED_SOURCE_SHA256 = '482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e'
|
||||
CANVAS_SIZE = (20, 20)
|
||||
|
||||
# 卡片坐标来自归档图标板的几何审计;只读取每张卡片左侧的 16px 原生规格。
|
||||
SPECS = (
|
||||
{
|
||||
'name': 'complete',
|
||||
'semantic': '已完成',
|
||||
'source_card': 'R2C3 STATUS (ONLINE)',
|
||||
'card': (582, 201, 247, 138),
|
||||
'usage': 'Plan List 已完成任务的绿色宝石',
|
||||
},
|
||||
{
|
||||
'name': 'remaining',
|
||||
'semantic': '未完成',
|
||||
'source_card': 'R2C4 LOADING',
|
||||
'card': (846, 201, 246, 138),
|
||||
'usage': 'Plan List 未完成任务的暗金分段环',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open('rb') as handle:
|
||||
for chunk in iter(lambda: handle.read(64 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def source_slot(card: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
|
||||
"""返回卡片左侧 16px 规格的检测带,排除标题、尺寸文字和卡片边框。"""
|
||||
x, y, _width, height = card
|
||||
return x + 14, y + 30, x + 74, min(y + 105, y + height - 24)
|
||||
|
||||
|
||||
def icon_alpha(pixel: tuple[int, int, int]) -> int:
|
||||
"""分离暗金高光与状态绿,同时压掉 WebP 暗背景噪点。"""
|
||||
red, green, blue = pixel
|
||||
gold = red > 44 and green > 31 and red - blue > 15 and green - blue > 7
|
||||
pale = red > 118 and green > 86 and red - blue > 28
|
||||
status_green = green > 58 and green - red > 5 and green - blue > 10
|
||||
if not (gold or pale or status_green):
|
||||
return 0
|
||||
if status_green:
|
||||
score = (green - min(red, blue)) * 4 + green
|
||||
else:
|
||||
score = (red - blue) * 3 + (green - blue) * 2 + (red + green) // 4
|
||||
return max(0, min(255, score))
|
||||
|
||||
|
||||
def alpha_metrics(image: Image.Image) -> tuple[list[int], list[float]]:
|
||||
alpha = image.getchannel('A')
|
||||
bbox = alpha.getbbox()
|
||||
if bbox is None:
|
||||
raise RuntimeError('输出图标没有 alpha 主体')
|
||||
pixels = alpha.load()
|
||||
total = weighted_x = weighted_y = 0
|
||||
for y in range(image.height):
|
||||
for x in range(image.width):
|
||||
value = pixels[x, y]
|
||||
if not value:
|
||||
continue
|
||||
total += value
|
||||
weighted_x += x * value
|
||||
weighted_y += y * value
|
||||
return (
|
||||
[bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]],
|
||||
[round(weighted_x / total, 3), round(weighted_y / total, 3)],
|
||||
)
|
||||
|
||||
|
||||
def extract_icon(sheet: Image.Image, spec: dict[str, object]) -> dict[str, object]:
|
||||
slot = source_slot(spec['card'])
|
||||
region = sheet.crop(slot).convert('RGB')
|
||||
mask = Image.new('L', region.size, 0)
|
||||
source_pixels = region.load()
|
||||
mask_pixels = mask.load()
|
||||
points: list[tuple[int, int]] = []
|
||||
|
||||
for y in range(region.height):
|
||||
for x in range(region.width):
|
||||
alpha = icon_alpha(source_pixels[x, y])
|
||||
if alpha < 18:
|
||||
continue
|
||||
mask_pixels[x, y] = alpha
|
||||
points.append((x, y))
|
||||
|
||||
if not points:
|
||||
raise RuntimeError(f"未检测到图标主体:{spec['name']}")
|
||||
|
||||
left = max(0, min(x for x, _y in points) - 3)
|
||||
top = max(0, min(y for _x, y in points) - 3)
|
||||
right = min(region.width, max(x for x, _y in points) + 4)
|
||||
bottom = min(region.height, max(y for _x, y in points) + 4)
|
||||
detected = region.crop((left, top, right, bottom)).convert('RGBA')
|
||||
detected.putalpha(mask.crop((left, top, right, bottom)).filter(ImageFilter.GaussianBlur(0.12)))
|
||||
|
||||
target_width, target_height = CANVAS_SIZE
|
||||
scale = min((target_width - 2) / detected.width, (target_height - 2) / detected.height)
|
||||
resized = detected.resize(
|
||||
(max(1, round(detected.width * scale)), max(1, round(detected.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
output = Image.new('RGBA', CANVAS_SIZE, (0, 0, 0, 0))
|
||||
output.alpha_composite(resized, ((target_width - resized.width) // 2, (target_height - resized.height) // 2))
|
||||
|
||||
# 按 alpha 加权重心做最后 1px 级光学校正。
|
||||
_bbox, center = alpha_metrics(output)
|
||||
offset_x = round((target_width - 1) / 2 - center[0])
|
||||
offset_y = round((target_height - 1) / 2 - center[1])
|
||||
if offset_x or offset_y:
|
||||
centered = Image.new('RGBA', CANVAS_SIZE, (0, 0, 0, 0))
|
||||
centered.alpha_composite(output, (offset_x, offset_y))
|
||||
output = centered
|
||||
|
||||
output_path = OUTPUT_DIR / f"{spec['name']}.png"
|
||||
output.save(output_path, optimize=True)
|
||||
alpha_bbox, alpha_center = alpha_metrics(output)
|
||||
return {
|
||||
'name': spec['name'],
|
||||
'semantic': spec['semantic'],
|
||||
'source': SOURCE.name,
|
||||
'source_sha256': EXPECTED_SOURCE_SHA256,
|
||||
'source_card': spec['source_card'],
|
||||
'source_variant': '16px',
|
||||
'slot_box': [slot[0], slot[1], slot[2] - slot[0], slot[3] - slot[1]],
|
||||
'detected_box': [slot[0] + left, slot[1] + top, right - left, bottom - top],
|
||||
'output': f"icons/plan-progress/{spec['name']}.png",
|
||||
'size': list(CANVAS_SIZE),
|
||||
'alpha_bbox': alpha_bbox,
|
||||
'alpha_center': alpha_center,
|
||||
'sha256': file_sha256(output_path),
|
||||
'usage': spec['usage'],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
actual_sha256 = file_sha256(SOURCE)
|
||||
if actual_sha256 != EXPECTED_SOURCE_SHA256:
|
||||
raise RuntimeError(f'图标板 hash 不匹配:{actual_sha256}')
|
||||
sheet = Image.open(SOURCE).convert('RGB')
|
||||
if sheet.size != (1672, 941):
|
||||
raise RuntimeError(f'图标板尺寸不匹配:{sheet.size}')
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
assets = [extract_icon(sheet, spec) for spec in SPECS]
|
||||
manifest = {
|
||||
'source': SOURCE.name,
|
||||
'source_path': str(SOURCE.relative_to(ROOT)),
|
||||
'source_sha256': EXPECTED_SOURCE_SHA256,
|
||||
'source_size': list(sheet.size),
|
||||
'assets': assets,
|
||||
}
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
# Plan List 进度图标主题兼容审计
|
||||
|
||||
审计时间:2026-07-20
|
||||
|
||||
## 范围
|
||||
|
||||
- 扩展既有 `wasteland`(暗金荒野)主题,不新增或替换主题 ID。
|
||||
- 只修改 Plan List 标题中的 `.plan-progress-dot` 视觉层。
|
||||
- `washi`、`coolvibe`、`editorial`、`sage`、`ink`、`dawn`、`carbon`、`nocturne`、`cinder`、`gilded` 保持 14px 语义色圆点。
|
||||
- `wasteland` 使用独立的本地原色图标;不修改 Plan 数据、DOM 结构和其他工具块。
|
||||
|
||||
## 素材台账
|
||||
|
||||
| 角色 | 归档路径 | 尺寸/格式 | SHA-256 | Alpha | 用途 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 图标板 | `references/source-assets/wasteland-icon-sheet.webp` | 1672×941 RGB WebP | `482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e` | 无 | 仅作为可复现切片源 |
|
||||
|
||||
运行时不依赖会话附件或远程 URL,切片脚本只读取上述归档文件。
|
||||
|
||||
## 参考区域到实现映射
|
||||
|
||||
| 进度语义 | 图标板区域 | 目标资产 | 实际 DOM | CSS 责任 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 已完成 | R2C3 `STATUS (ONLINE)` 的 16px 规格 | `icons/plan-progress/complete.png` | `.plan-progress-dot.is-complete` | 暗金主题显示绿色宝石原色图标 |
|
||||
| 未完成 | R2C4 `LOADING` 的 16px 规格 | `icons/plan-progress/remaining.png` | `.plan-progress-dot.is-remaining` | 暗金主题显示暗金分段环原色图标 |
|
||||
|
||||
两枚图标输出到 20×20 透明画布,按 alpha 加权重心居中;桌面和窄屏均保持主体清晰,不再从 64px PNG 二次缩小。
|
||||
|
||||
## 验收清单
|
||||
|
||||
- 两枚 PNG 来自归档图标板的 16px 原生规格,manifest 记录源 hash、卡片、检测框、画布与用途。
|
||||
- PNG 为 RGBA,透明背景无黑边、棋盘格或文字残留。
|
||||
- 主题专属选择器全部限定 `html[data-theme='wasteland']`。
|
||||
- 其他 10 个主题仍使用 14×14、左边距 8px 的圆点。
|
||||
- Wasteland 完成/未完成图标可区分,标题与长计划不溢出。
|
||||
- `style.css` / `app.js` 缓存版本同步推进,资产与静态资源 HTTP 200。
|
||||
- `plan-list-progress`、`wasteland-theme`、`gilded-theme` 和全量回归通过。
|
||||
- Firefox 检查 11 个主题及 1440×900、500×844、390×844 视口。
|
||||
|
||||
## 实际验收记录
|
||||
|
||||
验收日期:2026-07-20
|
||||
|
||||
- Firefox 152 实际加载 `style.css?v=20260720-plan-progress-icons`。
|
||||
- `washi`、`coolvibe`、`editorial`、`sage`、`ink`、`dawn`、`carbon`、`nocturne`、`cinder`、`gilded`:完成/未完成均为 14×14,左边距 8px;未完成圆点命中 1px inset 描边与 `plan-progress-breathe 1.8s`,没有加载主题图片。
|
||||
- `wasteland`:桌面图标 20×20、移动端 16×16;完成与未完成分别加载 `complete.png`、`remaining.png`;`box-shadow: none`,没有方形描边。
|
||||
- 呼吸动画在 700ms 采样间隔内从 `scale(0.944)` / `opacity 0.820` 变化到 `scale(0.984)` / `opacity 0.948`。
|
||||
- Firefox `ui.prefersReducedMotion=1` 时:`animation-name: none`、`opacity: 1`、`transform: none`,图标仍正常显示且无描边。
|
||||
- 1672×941、1440×900、500×844、390×844 均检查短计划与 12 图标长计划;标题、长计划和页面横向溢出量均为 0。
|
||||
- 两枚 PNG、manifest、CSS、JS 均返回 HTTP 200;专项、暗金主题、旧 Warframe 主题与全量回归均通过。
|
||||
Binary file not shown.
@@ -200,6 +200,14 @@ function createCodexAppRuntime(deps = {}) {
|
||||
return entry.step || entry.text || entry.title || entry.name || entry.description || entry.task || entry.item || entry.content || '';
|
||||
}
|
||||
|
||||
function summarizeTodoListProgress(items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : [];
|
||||
return {
|
||||
completed: normalizedItems.filter((item) => item?.completed).length,
|
||||
total: normalizedItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTodoListFromPlanItem(item) {
|
||||
if (!isPlanLikeItem(item)) return null;
|
||||
const candidates = [
|
||||
@@ -232,6 +240,7 @@ function createCodexAppRuntime(deps = {}) {
|
||||
id: item.id || item.itemId || item.planId || 'codex-app-plan',
|
||||
type: 'todo_list',
|
||||
items,
|
||||
progress: summarizeTodoListProgress(items),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -542,6 +551,7 @@ function createCodexAppRuntime(deps = {}) {
|
||||
title: 'Plan List',
|
||||
subtitle: item.explanation || item.title || item.tool || '',
|
||||
status: todoListPlanStatus(item, todoList),
|
||||
progress: todoList.progress,
|
||||
};
|
||||
}
|
||||
switch (item.type) {
|
||||
|
||||
@@ -6488,6 +6488,80 @@
|
||||
return tool?.name || 'Tool';
|
||||
}
|
||||
|
||||
function normalizePlanProgress(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const source = value.progress && typeof value.progress === 'object' ? value.progress : value;
|
||||
const total = Number(source.total);
|
||||
const completed = Number(source.completed);
|
||||
if (!Number.isFinite(total) || !Number.isFinite(completed) || total <= 0) return null;
|
||||
const normalizedTotal = Math.max(0, Math.floor(total));
|
||||
const normalizedCompleted = Math.min(normalizedTotal, Math.max(0, Math.floor(completed)));
|
||||
return { completed: normalizedCompleted, total: normalizedTotal };
|
||||
}
|
||||
|
||||
function planProgressFromPayload(payload) {
|
||||
let value = payload;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const explicitProgress = normalizePlanProgress(value);
|
||||
if (explicitProgress) return explicitProgress;
|
||||
if (!Array.isArray(value?.items) || value.items.length === 0) return null;
|
||||
return {
|
||||
completed: value.items.filter((item) => item?.completed).length,
|
||||
total: value.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveToolPlanProgress(tool) {
|
||||
if (toolKind(tool) !== 'todo_list') return null;
|
||||
const candidates = [tool?.meta?.progress, tool?.input, tool?.result];
|
||||
for (const candidate of candidates) {
|
||||
const progress = planProgressFromPayload(candidate);
|
||||
if (progress) return progress;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createPlanProgressElement(tool) {
|
||||
const progress = resolveToolPlanProgress(tool);
|
||||
if (!progress) return null;
|
||||
|
||||
const meter = document.createElement('span');
|
||||
meter.className = 'plan-progress';
|
||||
meter.setAttribute('role', 'img');
|
||||
const progressLabel = `计划进度:已完成 ${progress.completed} 项,共 ${progress.total} 项`;
|
||||
meter.setAttribute('aria-label', progressLabel);
|
||||
meter.title = progressLabel;
|
||||
|
||||
const dots = document.createElement('span');
|
||||
dots.className = 'plan-progress-dots';
|
||||
dots.setAttribute('aria-hidden', 'true');
|
||||
const visibleDotCount = Math.min(progress.total, 12);
|
||||
const completedDotCount = progress.total <= visibleDotCount
|
||||
? progress.completed
|
||||
: Math.round((progress.completed / progress.total) * visibleDotCount);
|
||||
for (let index = 0; index < visibleDotCount; index += 1) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `plan-progress-dot ${index < completedDotCount ? 'is-complete' : 'is-remaining'}`;
|
||||
dots.appendChild(dot);
|
||||
}
|
||||
meter.appendChild(dots);
|
||||
|
||||
if (progress.total > visibleDotCount) {
|
||||
const count = document.createElement('span');
|
||||
count.className = 'plan-progress-count';
|
||||
count.setAttribute('aria-hidden', 'true');
|
||||
count.textContent = `${progress.completed}/${progress.total}`;
|
||||
meter.appendChild(count);
|
||||
}
|
||||
return meter;
|
||||
}
|
||||
|
||||
function toolSubtitle(tool) {
|
||||
if (toolKind(tool) === 'file_change') {
|
||||
return '';
|
||||
@@ -6554,7 +6628,12 @@
|
||||
main.className = 'tool-call-summary-main';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tool-call-label';
|
||||
label.textContent = toolTitle(tool);
|
||||
const labelText = document.createElement('span');
|
||||
labelText.className = 'tool-call-label-text';
|
||||
labelText.textContent = toolTitle(tool);
|
||||
label.appendChild(labelText);
|
||||
const planProgress = createPlanProgressElement(tool);
|
||||
if (planProgress) label.appendChild(planProgress);
|
||||
main.appendChild(label);
|
||||
|
||||
const subtitleText = toolSubtitle(tool);
|
||||
|
||||
BIN
public/assets/themes/wasteland/icons/plan-progress/complete.png
Normal file
BIN
public/assets/themes/wasteland/icons/plan-progress/complete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1022 B |
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"source": "wasteland-icon-sheet.webp",
|
||||
"source_path": ".trellis/tasks/07-17-gilded-wasteland-theme/references/source-assets/wasteland-icon-sheet.webp",
|
||||
"source_sha256": "482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e",
|
||||
"source_size": [
|
||||
1672,
|
||||
941
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"name": "complete",
|
||||
"semantic": "已完成",
|
||||
"source": "wasteland-icon-sheet.webp",
|
||||
"source_sha256": "482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e",
|
||||
"source_card": "R2C3 STATUS (ONLINE)",
|
||||
"source_variant": "16px",
|
||||
"slot_box": [
|
||||
596,
|
||||
231,
|
||||
60,
|
||||
75
|
||||
],
|
||||
"detected_box": [
|
||||
608,
|
||||
255,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"output": "icons/plan-progress/complete.png",
|
||||
"size": [
|
||||
20,
|
||||
20
|
||||
],
|
||||
"alpha_bbox": [
|
||||
2,
|
||||
1,
|
||||
17,
|
||||
18
|
||||
],
|
||||
"alpha_center": [
|
||||
9.885,
|
||||
9.097
|
||||
],
|
||||
"sha256": "2f75c0dfc105e7fd50b1c90d3ca3b2439306afd50c2a0b5ebdeb674cd592516a",
|
||||
"usage": "Plan List 已完成任务的绿色宝石"
|
||||
},
|
||||
{
|
||||
"name": "remaining",
|
||||
"semantic": "未完成",
|
||||
"source": "wasteland-icon-sheet.webp",
|
||||
"source_sha256": "482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e",
|
||||
"source_card": "R2C4 LOADING",
|
||||
"source_variant": "16px",
|
||||
"slot_box": [
|
||||
860,
|
||||
231,
|
||||
60,
|
||||
75
|
||||
],
|
||||
"detected_box": [
|
||||
870,
|
||||
253,
|
||||
34,
|
||||
35
|
||||
],
|
||||
"output": "icons/plan-progress/remaining.png",
|
||||
"size": [
|
||||
20,
|
||||
20
|
||||
],
|
||||
"alpha_bbox": [
|
||||
2,
|
||||
2,
|
||||
17,
|
||||
18
|
||||
],
|
||||
"alpha_center": [
|
||||
9.268,
|
||||
9.911
|
||||
],
|
||||
"sha256": "90decca2d4b8204032fa06c0bbde6e9f49afc11bf4fabe21b8307caf5769d672",
|
||||
"usage": "Plan List 未完成任务的暗金分段环"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
public/assets/themes/wasteland/icons/plan-progress/remaining.png
Normal file
BIN
public/assets/themes/wasteland/icons/plan-progress/remaining.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 726 B |
@@ -24,7 +24,7 @@
|
||||
document.documentElement.dataset.dividerTime = dividerTime;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=20260718-shared-locator-panel-2">
|
||||
<link rel="stylesheet" href="style.css?v=20260720-plan-progress-icons">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -183,6 +183,6 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
|
||||
<script src="app.js?v=20260718-shared-locator-panel-2"></script>
|
||||
<script src="app.js?v=20260720-plan-progress-icons"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
112
public/style.css
112
public/style.css
@@ -19,6 +19,8 @@
|
||||
--accent-hover: #a84530; /* 深朱 deep vermillion */
|
||||
--accent-light: #f5ddd4; /* 薄朱 light vermillion */
|
||||
--success: #5d8a54; /* 抹茶 matcha */
|
||||
--plan-progress-complete: var(--success);
|
||||
--plan-progress-remaining: var(--accent);
|
||||
--danger: #c0553a;
|
||||
--info: #5b7ea1; /* 縹色 blue-gray */
|
||||
--note-accent: #5b7ea1;
|
||||
@@ -3103,6 +3105,85 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tool-call-label-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tool-call.codex-todo-list .tool-call-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.plan-progress {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.plan-progress-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.plan-progress-dot {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.plan-progress-dot.is-complete {
|
||||
background: var(--plan-progress-complete);
|
||||
}
|
||||
.plan-progress-dot.is-remaining {
|
||||
background: var(--plan-progress-remaining);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 28%, transparent);
|
||||
transform-origin: center;
|
||||
animation: plan-progress-breathe 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes plan-progress-breathe {
|
||||
0%, 100% {
|
||||
opacity: 0.68;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.plan-progress-dot.is-remaining {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.plan-progress-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.tool-call.codex-todo-list .tool-call-label {
|
||||
gap: 5px;
|
||||
}
|
||||
.plan-progress {
|
||||
gap: 3px;
|
||||
}
|
||||
.plan-progress-dots {
|
||||
gap: 2px;
|
||||
}
|
||||
.plan-progress-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
.plan-progress-count {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
.tool-call-subtitle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -8692,6 +8773,29 @@ html[data-theme='wasteland'] .tool-call summary::before {
|
||||
background: url('assets/themes/wasteland/icons/ui/terminal.png') center / 20px 18px no-repeat;
|
||||
}
|
||||
|
||||
html[data-theme='wasteland'] .plan-progress-dot {
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 6px;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 20px 20px;
|
||||
box-shadow: none;
|
||||
filter: drop-shadow(0 0 3px rgba(223, 180, 108, 0.28));
|
||||
}
|
||||
|
||||
html[data-theme='wasteland'] .plan-progress-dot.is-complete {
|
||||
background-image: url('assets/themes/wasteland/icons/plan-progress/complete.png');
|
||||
}
|
||||
|
||||
html[data-theme='wasteland'] .plan-progress-dot.is-remaining {
|
||||
background-image: url('assets/themes/wasteland/icons/plan-progress/remaining.png');
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html[data-theme='wasteland'] :is(
|
||||
.tool-call-content,
|
||||
.tool-call-content.reasoning,
|
||||
@@ -8963,6 +9067,14 @@ html[data-theme='wasteland'] .mode-select {
|
||||
height: 24px;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
html[data-theme='wasteland'] .plan-progress-dot {
|
||||
flex-basis: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 3px;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗金荒野:暗场气泡用无黑底的亮度补偿磨砂,避免透明层视觉上仍像实心黑块。 */
|
||||
|
||||
@@ -618,8 +618,8 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260718-shared-locator-panel-2')
|
||||
&& indexSource.includes('app.js?v=20260718-shared-locator-panel-2'),
|
||||
indexSource.includes('style.css?v=20260720-plan-progress-icons')
|
||||
&& indexSource.includes('app.js?v=20260720-plan-progress-icons'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
}
|
||||
@@ -799,6 +799,141 @@ function assertMockCodexAppPromptUserNotTextTriggered() {
|
||||
assert(!source.includes('mcp-ccweb-prompt-user'), 'Regression should not depend on a mock ccweb_prompt_user tool call id');
|
||||
}
|
||||
|
||||
function assertPlanListProgressContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
|
||||
|
||||
const normalizeSource = extractFunctionSource(source, 'normalizePlanProgress');
|
||||
const payloadSource = extractFunctionSource(source, 'planProgressFromPayload');
|
||||
const resolveSource = extractFunctionSource(source, 'resolveToolPlanProgress');
|
||||
const progressApi = new Function(`
|
||||
const toolKind = (tool) => tool?.kind || tool?.meta?.kind || null;
|
||||
${normalizeSource}
|
||||
${payloadSource}
|
||||
${resolveSource}
|
||||
return { normalizePlanProgress, planProgressFromPayload, resolveToolPlanProgress };
|
||||
`)();
|
||||
|
||||
assert(
|
||||
JSON.stringify(progressApi.normalizePlanProgress({ completed: 3, total: 5 })) === JSON.stringify({ completed: 3, total: 5 }),
|
||||
'Plan progress should preserve valid completed and total counts'
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(progressApi.normalizePlanProgress({ completed: 9, total: 5 })) === JSON.stringify({ completed: 5, total: 5 }),
|
||||
'Plan progress should clamp completed counts to the total'
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(progressApi.planProgressFromPayload({ items: [
|
||||
{ completed: true },
|
||||
{ completed: false },
|
||||
{ completed: true },
|
||||
] })) === JSON.stringify({ completed: 2, total: 3 }),
|
||||
'Legacy todo payloads without explicit progress should derive counts from items'
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(progressApi.resolveToolPlanProgress({
|
||||
kind: 'todo_list',
|
||||
meta: { progress: { completed: 3, total: 5 } },
|
||||
})) === JSON.stringify({ completed: 3, total: 5 }),
|
||||
'Todo tool summaries should resolve progress from runtime metadata'
|
||||
);
|
||||
assert(progressApi.resolveToolPlanProgress({ kind: 'command_execution' }) === null, 'Non-plan tools should not render plan progress');
|
||||
|
||||
const sent = [];
|
||||
const runtime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => sent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
const entry = { ws: {}, toolCalls: [], fullText: '' };
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'plan/updated',
|
||||
params: {
|
||||
id: 'regression-plan-progress',
|
||||
status: 'inProgress',
|
||||
plan: [
|
||||
{ step: '完成数据契约', status: 'completed' },
|
||||
{ step: '完成 DOM', status: 'completed' },
|
||||
{ step: '补充回归', status: 'completed' },
|
||||
{ step: '实现样式', status: 'in_progress' },
|
||||
{ step: '浏览器验收', status: 'pending' },
|
||||
],
|
||||
},
|
||||
}, 'plan-progress-session');
|
||||
const update = sent.find((message) => message.type === 'tool_update' && message.toolUseId === 'regression-plan-progress');
|
||||
assert(update, 'Plan updates should emit a tool_update payload');
|
||||
assert(update.meta?.progress?.completed === 3 && update.meta?.progress?.total === 5, 'Plan metadata should expose 3/5 progress');
|
||||
assert(update.input?.progress?.completed === 3 && update.input?.progress?.total === 5, 'Normalized todo input should expose 3/5 progress');
|
||||
const result = JSON.parse(update.result);
|
||||
assert(result.progress?.completed === 3 && result.progress?.total === 5, 'Persisted todo result should expose 3/5 progress');
|
||||
|
||||
const summarySource = extractFunctionSource(source, 'applyToolSummary');
|
||||
const progressElementSource = extractFunctionSource(source, 'createPlanProgressElement');
|
||||
assert(summarySource.includes('createPlanProgressElement(tool)'), 'Tool summaries should append the plan progress element beside the title');
|
||||
assert(progressElementSource.includes("meter.setAttribute('role', 'img')"), 'Plan progress should expose an accessible image role');
|
||||
assert(progressElementSource.includes("meter.setAttribute('aria-label', progressLabel)"), 'Plan progress should announce completed and total counts');
|
||||
assert(progressElementSource.includes('Math.min(progress.total, 12)'), 'Long plans should cap visible dots to protect the header layout');
|
||||
assert(progressElementSource.includes("count.textContent = `${progress.completed}/${progress.total}`"), 'Compacted long plans should keep an exact numeric count');
|
||||
|
||||
assert(styleSource.includes('--plan-progress-complete: var(--success);'), 'Plan progress should inherit the active theme success color');
|
||||
assert(styleSource.includes('--plan-progress-remaining: var(--accent);'), 'Remaining plan progress should inherit the active theme accent color');
|
||||
assert(/\.plan-progress-dot\s*\{[^}]*width:\s*14px;[^}]*height:\s*14px;[^}]*margin-left:\s*8px;/.test(styleSource), 'Desktop plan progress dots should keep the requested 14px size and 8px left margin');
|
||||
assert(/\.plan-progress-dot\.is-complete\s*\{[^}]*background:\s*var\(--plan-progress-complete\);/.test(styleSource), 'Completed plan dots should use the semantic completion token');
|
||||
assert(/\.plan-progress-dot\.is-remaining\s*\{[^}]*background:\s*var\(--plan-progress-remaining\);[^}]*box-shadow:\s*inset 0 0 0 1px[^}]*animation:\s*plan-progress-breathe 1\.8s/.test(styleSource), 'Remaining plan dots should use the semantic color, 1px inner border and breathing animation');
|
||||
assert(/@keyframes plan-progress-breathe\s*\{[\s\S]*?transform:\s*scale\(0\.9\);[\s\S]*?transform:\s*scale\(1\);[\s\S]*?\}/.test(styleSource), 'Plan progress breathing should use a restrained scale cycle');
|
||||
assert(/@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.plan-progress-dot\.is-remaining\s*\{[^}]*animation:\s*none;[^}]*transform:\s*none;/.test(styleSource), 'Plan progress breathing should respect reduced-motion preferences');
|
||||
assert(/@media \(max-width:\s*560px\)[\s\S]*?\.plan-progress-dot\s*\{[^}]*width:\s*5px;[^}]*height:\s*5px;/.test(styleSource), 'Plan progress dots should stay compact on narrow screens');
|
||||
|
||||
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\s*\{[^}]*width:\s*20px;[^}]*height:\s*20px;[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/.test(styleSource), 'Wasteland plan progress should replace circles with 20px transparent icon canvases');
|
||||
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\.is-complete\s*\{[^}]*url\('assets\/themes\/wasteland\/icons\/plan-progress\/complete\.png'\);/.test(styleSource), 'Wasteland completed tasks should use the local green status jewel');
|
||||
assert(/html\[data-theme='wasteland'\] \.plan-progress-dot\.is-remaining\s*\{[^}]*url\('assets\/themes\/wasteland\/icons\/plan-progress\/remaining\.png'\);[^}]*box-shadow:\s*none;/.test(styleSource), 'Wasteland remaining tasks should use the local gold loading ring without a square inset border');
|
||||
assert(/@media \(max-width:\s*560px\)[\s\S]*?html\[data-theme='wasteland'\] \.plan-progress-dot\s*\{[^}]*width:\s*16px;[^}]*height:\s*16px;/.test(styleSource), 'Wasteland progress icons should remain legible and compact on narrow screens');
|
||||
const isolatedPlanIconRules = Array.from(styleSource.matchAll(/html\[data-theme='([^']+)'\] \.plan-progress-dot\.is-(?:complete|remaining)\s*\{[^}]*icons\/plan-progress\//g));
|
||||
assert(isolatedPlanIconRules.length === 2 && isolatedPlanIconRules.every((match) => match[1] === 'wasteland'), 'Plan progress image assets should stay isolated to the Wasteland theme');
|
||||
|
||||
const planAssetRoot = path.join(WASTELAND_THEME_ASSETS.root, 'icons', 'plan-progress');
|
||||
const planManifestPath = path.join(planAssetRoot, 'manifest.json');
|
||||
assert(fs.existsSync(planManifestPath), 'Wasteland plan progress should ship a reproducible asset manifest');
|
||||
const planManifest = JSON.parse(fs.readFileSync(planManifestPath, 'utf8'));
|
||||
assert(planManifest.source_sha256 === '482edda2dbc8932b3c209686fab89f50fa8600242b16379b39fa6657e722410e', 'Plan progress manifest should retain the reviewed source sheet hash');
|
||||
const expectedPlanAssets = {
|
||||
complete: {
|
||||
semantic: '已完成',
|
||||
card: 'R2C3 STATUS (ONLINE)',
|
||||
sha256: '2f75c0dfc105e7fd50b1c90d3ca3b2439306afd50c2a0b5ebdeb674cd592516a',
|
||||
},
|
||||
remaining: {
|
||||
semantic: '未完成',
|
||||
card: 'R2C4 LOADING',
|
||||
sha256: '90decca2d4b8204032fa06c0bbde6e9f49afc11bf4fabe21b8307caf5769d672',
|
||||
},
|
||||
};
|
||||
assert(Array.isArray(planManifest.assets) && planManifest.assets.length === 2, 'Plan progress manifest should list exactly two semantic assets');
|
||||
Object.entries(expectedPlanAssets).forEach(([name, expected]) => {
|
||||
const entry = planManifest.assets.find((asset) => asset.name === name);
|
||||
assert(entry?.semantic === expected.semantic && entry?.source_card === expected.card, `${name} progress icon should keep its reviewed semantic and source card`);
|
||||
assert(entry?.source_variant === '16px', `${name} progress icon should be cut from the native 16px source variant`);
|
||||
assert(entry?.size?.[0] === 20 && entry?.size?.[1] === 20, `${name} progress icon manifest should retain a 20x20 canvas`);
|
||||
assert(Array.isArray(entry?.alpha_bbox) && entry.alpha_bbox[2] >= 16 && entry.alpha_bbox[3] >= 16, `${name} progress icon should retain a visible alpha subject`);
|
||||
const assetPath = path.join(planAssetRoot, `${name}.png`);
|
||||
assert(fs.existsSync(assetPath), `Wasteland should ship ${name}.png`);
|
||||
const asset = fs.readFileSync(assetPath);
|
||||
assert(asset.subarray(0, 8).toString('hex') === '89504e470d0a1a0a', `${name}.png should remain a PNG`);
|
||||
assert(asset.readUInt32BE(16) === 20 && asset.readUInt32BE(20) === 20 && asset.readUInt8(25) === 6, `${name}.png should remain a 20x20 RGBA image`);
|
||||
const actualHash = crypto.createHash('sha256').update(asset).digest('hex');
|
||||
assert(actualHash === expected.sha256 && entry.sha256 === expected.sha256, `${name}.png should match the reviewed manifest hash`);
|
||||
});
|
||||
const extractorPath = path.join(REPO_DIR, '.trellis', 'tasks', '07-17-gilded-wasteland-theme', 'research', 'extract_plan_progress_icons.py');
|
||||
const extractorSource = fs.readFileSync(extractorPath, 'utf8');
|
||||
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
|
||||
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260720-plan-progress-icons'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260720-plan-progress-icons'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
}
|
||||
|
||||
function assertFrontendGildedThemeContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
@@ -911,8 +1046,8 @@ function assertFrontendGildedThemeContract() {
|
||||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||||
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
||||
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
||||
assert(indexSource.includes('style.css?v=20260718-shared-locator-panel-2'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20260720-plan-progress-icons'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260720-plan-progress-icons'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
}
|
||||
|
||||
function assertFrontendWastelandThemeContract() {
|
||||
@@ -1165,8 +1300,8 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260718-shared-locator-panel-2'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20260720-plan-progress-icons'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260720-plan-progress-icons'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
function assertFrontendCcwebPromptContract() {
|
||||
@@ -3303,6 +3438,7 @@ async function main() {
|
||||
}
|
||||
if (regressionTarget === 'wasteland-theme') {
|
||||
assertFrontendWastelandThemeContract();
|
||||
assertPlanListProgressContract();
|
||||
console.log('Wasteland theme regression checks passed.');
|
||||
return;
|
||||
}
|
||||
@@ -3311,6 +3447,11 @@ async function main() {
|
||||
console.log('Sidebar collapse regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'plan-list-progress') {
|
||||
assertPlanListProgressContract();
|
||||
console.log('Plan List progress regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'runtime-image-send') {
|
||||
await runRuntimeImageSendTarget();
|
||||
console.log('Runtime image send regression checks passed.');
|
||||
@@ -3342,6 +3483,7 @@ async function main() {
|
||||
assertFrontendMarkdownLinkContract();
|
||||
assertMockCodexAppPromptUserNotTextTriggered();
|
||||
assertFrontendMcpReloadContract();
|
||||
assertPlanListProgressContract();
|
||||
assertFrontendSubagentCardMetadataContract();
|
||||
assertCodexAppRuntimeSubAgentActivityContract();
|
||||
assertFrontendPrimaryCodexAppUiContract();
|
||||
|
||||
Reference in New Issue
Block a user