diff --git a/.trellis/tasks/07-17-gilded-wasteland-theme/research/extract_plan_progress_icons.py b/.trellis/tasks/07-17-gilded-wasteland-theme/research/extract_plan_progress_icons.py new file mode 100644 index 0000000..a8385d4 --- /dev/null +++ b/.trellis/tasks/07-17-gilded-wasteland-theme/research/extract_plan_progress_icons.py @@ -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() diff --git a/.trellis/tasks/07-17-gilded-wasteland-theme/research/plan-progress-icon-audit.md b/.trellis/tasks/07-17-gilded-wasteland-theme/research/plan-progress-icon-audit.md new file mode 100644 index 0000000..fbd17b9 --- /dev/null +++ b/.trellis/tasks/07-17-gilded-wasteland-theme/research/plan-progress-icon-audit.md @@ -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 主题与全量回归均通过。 diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index dce5ff0..f11d40e 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index 3e27d6e..e74a19d 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -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) { diff --git a/public/app.js b/public/app.js index 1fee40b..9037444 100644 --- a/public/app.js +++ b/public/app.js @@ -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); diff --git a/public/assets/themes/wasteland/icons/plan-progress/complete.png b/public/assets/themes/wasteland/icons/plan-progress/complete.png new file mode 100644 index 0000000..2d87521 Binary files /dev/null and b/public/assets/themes/wasteland/icons/plan-progress/complete.png differ diff --git a/public/assets/themes/wasteland/icons/plan-progress/manifest.json b/public/assets/themes/wasteland/icons/plan-progress/manifest.json new file mode 100644 index 0000000..316e0e7 --- /dev/null +++ b/public/assets/themes/wasteland/icons/plan-progress/manifest.json @@ -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 未完成任务的暗金分段环" + } + ] +} diff --git a/public/assets/themes/wasteland/icons/plan-progress/remaining.png b/public/assets/themes/wasteland/icons/plan-progress/remaining.png new file mode 100644 index 0000000..c719e51 Binary files /dev/null and b/public/assets/themes/wasteland/icons/plan-progress/remaining.png differ diff --git a/public/index.html b/public/index.html index 0bf7052..b5f7d35 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - +
@@ -183,6 +183,6 @@ - +