Compare commits

..

No commits in common. "0e02cff6c6cae647cfad25756a6197d1de2c325e" and "fcc158dff69f7a4179f4a2606236290598849672" have entirely different histories.

13 changed files with 13 additions and 795 deletions

View File

@ -21,12 +21,6 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-03 / ppt 对齐网格锁 + 错位/单调质检(d1285247 陶瓷 deck 复盘,bump 0.37.0)
对 d1285247 产物(25 页陶瓷方案 PPTX)逐页几何量测 + PowerPoint COM 渲图目视复盘,三类缺陷:①跨页左基线漂移(0.6560.75in 七个值)+ 并排块顶差 212px 的"想对齐没对齐"(S8/S19/S23);②5 页同为"图标+标题+三行字"卡网格,零流程箭头/零分层图形,单调;③标题语义不兑现("五层架构"画成五条等宽横条、"矩阵"画成卡片格)。根因:executor 手写绝对坐标但 spec_lock 无网格常量可依;质检只查重叠/越界不查对齐;"节奏不雷同"只约束相邻页。修四层:**A spec_lock 新增 `layout_grid` 锁段**(margin_x/content_top/footer_y/gutter,strategist 派生、executor 每页吸附、checker 强制;design_spec_reference §V 同步);**B executor-base §3 网格对齐纪律**(并排卡片同 top 同高等 gutter、打破网格 ≥16px 干净打破、同行文字 ≥0.3em 禁贴字);**C svg_quality_checker 新增 check 14**——兄弟卡片近失对齐(精确几何,212px error;底对齐/中心对齐/绘图区内数据柱三类豁免,71 charts 模板回归误报清零)、layout_grid 偏离 215px error、行内 gap 不等 warning、无锁存量项目跨页左缘聚类漂移 warning、版式指纹单调门(≥3 页同指纹 warn、≥4 或过半 error;仅对 NN_ 编号 deck 页聚合,模板库静默);**D 策略纪律升级**——同一版式原型整本 ≤2 次 + 标题语义必须被图形兑现(SKILL.md 大纲纪律 + strategist visual-floor GATE)。顺手修 comparison_columns 模板胶囊 5px 错位。新增 tests/test_svg_alignment_check.py 21 项,全量 153 过。已知边界:页面平衡类(底部大空白/重心偏移,S18/S22)误报风险高未进 checker,只进阶段五验收 checklist 眼看;错位 error 会被导出边界自动质检门连带拦截,存量项目重导出若报新 error 属预期(真缺陷)。
### 2026-07-03 / 进度条自愈:回放层强制单调完成(d1285247 复盘,bump 0.36.2)
用户报 task d1285247(ppt生成3)进度条反常:后面步(质检/导出)打绿勾、前面步(摄取素材/配图)却卡红圈"…",顶部"4/6"。诊断脚本 `scripts/diag_progress_d1285247.py` 拉出 `task_progress` 调用序列定位**非渲染 bug**——`progress.js` 忠实回放了模型发的调用:模型每次推进是"标下一步 completed + 再下一步 in_progress"的跳步,**每次都漏给上一次留在 in_progress 的那步补 completed**(s1、s3 被漏),回放到最后就是 `s1=in_progress,s2=completed,s3=in_progress,s4/s5/s6=completed`。根因是模型用工具收尾不稳,纯提示拦不住(与门体系教训同构)。修在**回放层加确定性单调不变量**:`enforceMonotonicProgress`——checklist 线性推进,只要某步 completed,其之前所有步自动视为 completed;`applyProgressAction` 的 set_plan / update_step 两条出口都过一遍,漏发自愈。前端单测加 3 条(含复刻 d1285247 跳步序列 → 6/6)。已知边界:假设步骤线性顺序(现有所有 skill 成立);若将来出现真·并行/乱序 checklist 会被抹平。
### 2026-07-03 / ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复(139a59c5 重跑复盘,bump 0.36.1) ### 2026-07-03 / ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复(139a59c5 重跑复盘,bump 0.36.1)
0.36.0 上线后同 task 重跑(仍 deepseek-v4-flash):产物整体大幅好转,但仍有 4/25 页错位(P12 色带裁两行标题+正文跑出卡外 / P14·P18 文字骑卡片边框 / P21 手画饼图弧线劈叉)。轨迹显示**两道新门都触发了、都被模型 8 秒内用逃生口按过去**:质检+渲图验收 0 调用,`--allow-iconless` + `--allow-unreviewed` 连按直接导出——门有了,逃生口对弱模型等于"报错时该加的参数"。且 `--allow-iconless` 的"正当理由"是我们自己给的:wrapper docstring 老示例教它 `-s final`,而图标门检查的是 svg_final(data-icon 已展开)→ 误报零图标;`-s final` 还连锁出图片路径连环坑(见 F)。二轮修五处:**A 验收门分层**——"从没渲过/渲后又改/finalize 前渲的"为硬问题,**任何 CLI flag 不豁免**(渲图便宜且机器可验,没理由交付没人能看过的页);`--allow-unreviewed` 只豁免"渲过但没标 pass";运维兜底走 `ZCBOT_PPT_FORCE_EXPORT=1` 环境变量(不进 --help/SKILL)。**B 拔 `-s final` 雷**——图标门永远对 svg_output 源检测(误报根除);wrapper docstring 示例去掉 `-s final` 并注明勿用。**C 导出自动质检门**——svg_to_pptx 导出前内嵌复跑 quality checker 逐页硬错误(坏 XML/禁用特性/图片缺失/几何 error),error 拒绝导出、无豁免参数(fail-open 于 import 失败)——"忘跑/不跑质检"从此无效。**D** 验收门报错计数措辞修正。**E 几何质检加"文字骑卡片边缘"检测**(warning 带坐标:文字与可见矩形交叠面积占比 0.20.85 即骑边,P12/P14/P18 三类当场可命中;P21 饼图弧线错误静态无解,只能渲图过目)。**F 修 svg_final 嵌图失效 bug**——finalize 先 copytree 到 `.build/svg_final` 再就地嵌图,`../images/` 从 svg_final 解析必落空 → **所有 deck 的 svg_final 一直嵌不进外链图**(渲图验收 PNG 里图片也是空的);`_resolve_image_path` 加"rebase 回 svg_output 同相对路径"兜底,实测 data:URI 落位。本机全链路回归:未渲→硬拒(带 flag 也拒)/ pending→拒、flag 放 / pass→放行 / 质检 error→拒 / env 强制→放;71 charts 模板几何 0 error。已知边界:P21 类"图形画错但不重叠不越界"仍只有渲图过目能拦——"看没看"无法机器验证,治本要平台层 vision 验收(待做,同 0.35.1 备注)。 0.36.0 上线后同 task 重跑(仍 deepseek-v4-flash):产物整体大幅好转,但仍有 4/25 页错位(P12 色带裁两行标题+正文跑出卡外 / P14·P18 文字骑卡片边框 / P21 手画饼图弧线劈叉)。轨迹显示**两道新门都触发了、都被模型 8 秒内用逃生口按过去**:质检+渲图验收 0 调用,`--allow-iconless` + `--allow-unreviewed` 连按直接导出——门有了,逃生口对弱模型等于"报错时该加的参数"。且 `--allow-iconless` 的"正当理由"是我们自己给的:wrapper docstring 老示例教它 `-s final`,而图标门检查的是 svg_final(data-icon 已展开)→ 误报零图标;`-s final` 还连锁出图片路径连环坑(见 F)。二轮修五处:**A 验收门分层**——"从没渲过/渲后又改/finalize 前渲的"为硬问题,**任何 CLI flag 不豁免**(渲图便宜且机器可验,没理由交付没人能看过的页);`--allow-unreviewed` 只豁免"渲过但没标 pass";运维兜底走 `ZCBOT_PPT_FORCE_EXPORT=1` 环境变量(不进 --help/SKILL)。**B 拔 `-s final` 雷**——图标门永远对 svg_output 源检测(误报根除);wrapper docstring 示例去掉 `-s final` 并注明勿用。**C 导出自动质检门**——svg_to_pptx 导出前内嵌复跑 quality checker 逐页硬错误(坏 XML/禁用特性/图片缺失/几何 error),error 拒绝导出、无豁免参数(fail-open 于 import 失败)——"忘跑/不跑质检"从此无效。**D** 验收门报错计数措辞修正。**E 几何质检加"文字骑卡片边缘"检测**(warning 带坐标:文字与可见矩形交叠面积占比 0.20.85 即骑边,P12/P14/P18 三类当场可命中;P21 饼图弧线错误静态无解,只能渲图过目)。**F 修 svg_final 嵌图失效 bug**——finalize 先 copytree 到 `.build/svg_final` 再就地嵌图,`../images/` 从 svg_final 解析必落空 → **所有 deck 的 svg_final 一直嵌不进外链图**(渲图验收 PNG 里图片也是空的);`_resolve_image_path` 加"rebase 回 svg_output 同相对路径"兜底,实测 data:URI 落位。本机全链路回归:未渲→硬拒(带 flag 也拒)/ pending→拒、flag 放 / pass→放行 / 质检 error→拒 / env 强制→放;71 charts 模板几何 0 error。已知边界:P21 类"图形画错但不重叠不越界"仍只有渲图过目能拦——"看没看"无法机器验证,治本要平台层 vision 验收(待做,同 0.35.1 备注)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.37.0" __version__ = "0.36.1"

View File

@ -1,56 +0,0 @@
"""Dump the task_progress tool-call sequence for a task (by id prefix). ASCII-only."""
import json
import os
import sys
from pathlib import Path
env = Path(__file__).resolve().parent.parent / ".env"
for line in env.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("ZCBOT_DB_URL="):
os.environ["ZCBOT_DB_URL"] = line.split("=", 1)[1].strip()
from sqlalchemy import create_engine, text # noqa: E402
engine = create_engine(os.environ["ZCBOT_DB_URL"])
prefix = sys.argv[1] if len(sys.argv) > 1 else "d1285247"
with engine.connect() as conn:
row = conn.execute(
text("select task_id,name,status,run_status from tasks where task_id::text like :p"),
{"p": prefix + "%"},
).fetchone()
if not row:
print("[NO TASK]", prefix)
sys.exit(1)
tid = row[0]
print(f"[TASK] {tid} name={row[1]!r} status={row[2]} run={row[3]}")
msgs = conn.execute(
text("select idx,payload from messages where task_id=:t order by idx"),
{"t": tid},
).fetchall()
print(f"[MESSAGES] {len(msgs)}")
n = 0
for idx, p in msgs:
for tc in p.get("tool_calls") or []:
fn = tc.get("function") or {}
if fn.get("name") != "task_progress":
continue
n += 1
try:
args = json.loads(fn.get("arguments") or "{}")
except Exception as e:
print(f" [{idx}] PARSE-ERR: {e} raw={fn.get('arguments')!r}")
continue
act = args.get("action")
if act == "set_plan":
steps = args.get("steps") or []
print(f" [{idx}] set_plan ({len(steps)} steps):")
for st in steps:
print(f" {st.get('id')!r:8} {st.get('status'):11} {st.get('title')!r}")
elif act == "update_step":
st = args.get("step") or {}
print(f" [{idx}] update_step id={st.get('id')!r} status={st.get('status')!r} title={st.get('title')!r}")
else:
print(f" [{idx}] {act} {json.dumps(args, ensure_ascii=False)}")
print(f"[task_progress calls] {n}")

View File

@ -100,14 +100,14 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死): **逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景"); - **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表尤其 —— 5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页; - **节奏不雷同**:相邻内容页不同版式;narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;
- **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。**标题语义必须被图形兑现**:标题写"架构"就画层块堆叠(不是等宽横条列表)、写"矩阵"就画真象限(不是卡片网格)、写"流程/层级"就有方向/层次 —— "五层架构"画成五条一样的横条是典型名不副实。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。 - **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。
大纲连同 ah **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。 大纲连同 ah **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。
**确认后产出两份引擎契约**(按骨架填,**只填实际用到的行**): **确认后产出两份引擎契约**(按骨架填,**只填实际用到的行**):
- `<project_dir>/design_spec.md` —— 人读叙事(IXI 节,见 design_spec_reference.md) - `<project_dir>/design_spec.md` —— 人读叙事(IXI 节,见 design_spec_reference.md)
- `<project_dir>/spec_lock.md` —— 机读执行锁(canvas/**layout_grid**/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。`layout_grid`(margin_x/content_top/footer_y/gutter)是跨页对齐的锚 —— 手写绝对坐标没有锁定基线必漂,质检会硬卡偏离网格 215px 的"想对齐没对齐"。 - `<project_dir>/spec_lock.md` —— 机读执行锁(canvas/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。
> 公式策略 mixed/render-all 且有公式 → 写 `images/formula_manifest.json` 后渲染(ppt-master 的 latex_render 未搬;zcbot 可用现有公式渲染或转图后按 `images` 行登记)。 > 公式策略 mixed/render-all 且有公式 → 写 `images/formula_manifest.json` 后渲染(ppt-master 的 latex_render 未搬;zcbot 可用现有公式渲染或转图后按 `images` 行登记)。
@ -130,7 +130,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
**纪律(来自 SKILL 全局 + executor-base,务必遵守)**: **纪律(来自 SKILL 全局 + executor-base,务必遵守)**:
1. **逐页串行手写,不批量、不脚本生成**:每页由当前主 agent 在同一上下文里手写 SVG;**禁止写循环脚本批量产 SVG**(跨页视觉一致性靠逐页带上游上下文,生成器做不到),也不要 5 页一组。 1. **逐页串行手写,不批量、不脚本生成**:每页由当前主 agent 在同一上下文里手写 SVG;**禁止写循环脚本批量产 SVG**(跨页视觉一致性靠逐页带上游上下文,生成器做不到),也不要 5 页一组。
2. **每页前重读 `spec_lock.md`**:颜色/字体/图标/图片只能来自它;查本页 `page_rhythm`/`page_layouts`/`page_charts`;坐标吸附 `layout_grid`(左缘=margin_x、正文顶=content_top、并排卡片同 top 同高等 gutter,打破网格要 ≥16px 干净地打破,不许差几 px 的"差不多" —— 对齐纪律详见 executor-base §3)。抗上下文压缩漂移。 2. **每页前重读 `spec_lock.md`**:颜色/字体/图标/图片只能来自它;查本页 `page_rhythm`/`page_layouts`/`page_charts`。抗上下文压缩漂移。
3. **模板供结构不供皮**(非 mirror):继承几何/标签位置/编码逻辑,**重新上 visual_style + spec_lock.colors 的皮**;字号按 spec_lock 角色锁定值,不继承模板占位字号。 3. **模板供结构不供皮**(非 mirror):继承几何/标签位置/编码逻辑,**重新上 visual_style + spec_lock.colors 的皮**;字号按 spec_lock 角色锁定值,不继承模板占位字号。
4. **图标(锁了就必须用,非可选装饰)**:spec_lock 有 `icons.library` + 非空 `inventory` 时,**每个内容页必须放 13 个 inventory 内的图标**(KPI/列表/流程/对比/特性网格版式尤其要,常一卡一图标)——自由设计没有模板可继承图标,只能逐页手写 `<use data-icon>` 才有图标。封面/纯排版分节页/单数字·金句 breathing 页/尾页可不放。写法:`<use data-icon="<lib>/<name>" x= y= width= height= fill= [stroke-width=]>`,name 必须在 inventory 内、文件在 `templates/icons/<lib>/`。**质检会硬卡**:锁了 inventory 但全 deck 0 图标 → error 退非零(见阶段四)。 4. **图标(锁了就必须用,非可选装饰)**:spec_lock 有 `icons.library` + 非空 `inventory` 时,**每个内容页必须放 13 个 inventory 内的图标**(KPI/列表/流程/对比/特性网格版式尤其要,常一卡一图标)——自由设计没有模板可继承图标,只能逐页手写 `<use data-icon>` 才有图标。封面/纯排版分节页/单数字·金句 breathing 页/尾页可不放。写法:`<use data-icon="<lib>/<name>" x= y= width= height= fill= [stroke-width=]>`,name 必须在 inventory 内、文件在 `templates/icons/<lib>/`。**质检会硬卡**:锁了 inventory 但全 deck 0 图标 → error 退非零(见阶段四)。
5. **配图**:`<image href="../images/<file>">`,croppable 用 `preserveAspectRatio="xMidYMid slice"`,`| no-crop` 行用 `meet`;意图与版式见 image-layout-*。 5. **配图**:`<image href="../images/<file>">`,croppable 用 `preserveAspectRatio="xMidYMid slice"`,`| no-crop` 行用 `meet`;意图与版式见 image-layout-*。
@ -142,7 +142,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
``` ```
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir> .venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
``` ```
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **兄弟卡片错位 212px、偏离 layout_grid 网格**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门)** 等)必须改:回阶段三重写该页再跑**,不放过。 - **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。 - `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。 - ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
@ -161,7 +161,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
# (有问题的页:--fail <页名> --reason "…";只标部分页:--pass <页名>;看状态:--status # (有问题的页:--fail <页名> --reason "…";只标部分页:--pass <页名>;看状态:--status
``` ```
- **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。) - **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。)
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、**并排元素顶/底是否对齐、与上一页对比左缘/内容顶线是否一致**(跨页一致性只有连续翻看才看得出)、图标在不在(位置对不对)、节奏是否单调(连续几页同为卡片墙就该返工换形态)、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。 - `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在(位置对不对)、节奏是否单调、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。
- 🚧 **差评即阻断 + 返工回路**:任一页有排版/溢出/重叠/半成品问题(哪怕只是封面)→ **改那一页 svg_output 的 SVG → 重跑 finalize → `svg_preview.py <project_dir> --pages <N>` 重渲该页 → 复看 → 再标 pass**。机制会强制这个回路:标 pass 和导出 gate 都校验"渲图之后源文件没再改过"(sha1),改了不重渲重看,gate 过不去。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。 - 🚧 **差评即阻断 + 返工回路**:任一页有排版/溢出/重叠/半成品问题(哪怕只是封面)→ **改那一页 svg_output 的 SVG → 重跑 finalize → `svg_preview.py <project_dir> --pages <N>` 重渲该页 → 复看 → 再标 pass**。机制会强制这个回路:标 pass 和导出 gate 都校验"渲图之后源文件没再改过"(sha1),改了不重渲重看,gate 过不去。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。
- ❌ **禁止盲改**:修错位/补图标不许写脚本批量 regex 插元素、改完不看渲染结果(真实事故来源:质检提示缺图标后 regex 批量盲插,图标全压在文字上交付)。每处修改都要走上面的返工回路落到"复看"。 - ❌ **禁止盲改**:修错位/补图标不许写脚本批量 regex 插元素、改完不看渲染结果(真实事故来源:质检提示缺图标后 regex 批量盲插,图标全压在文字上交付)。每处修改都要走上面的返工回路落到"复看"。

View File

@ -177,11 +177,6 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha
- **Proximity**: group related elements with tight spacing; separate unrelated groups - **Proximity**: group related elements with tight spacing; separate unrelated groups
- **Spec adherence**: follow color, layout, canvas format, and typography in the spec - **Spec adherence**: follow color, layout, canvas format, and typography in the spec
- **Grid & alignment discipline (HARD — checker-enforced)**: hand-written absolute coordinates drift; the fix is snapping, not eyeballing.
- **Snap to `layout_grid`**: header title, content blocks, and footer left edges sit exactly at `spec_lock.layout_grid.margin_x`; body content starts at `content_top`; footer baseline at `footer_y`. Never write a "close enough" coordinate (63 when the grid says 60) — the quality checker errors on 215px deviations. Breaking the grid on purpose (full-bleed, asymmetric hero) means clearing it by ≥16px, not by a few px.
- **Sibling cards align exactly**: cards in one row share the same `y` and `height`; cards in one column share the same `x`; gaps in a row all equal `layout_grid.gutter`. Compute one set of constants per grid (`x = margin_x + i * (card_w + gutter)`) instead of placing each card by feel. Deliberate stagger (masonry) offsets by ≥16px.
- **Two-column layouts resolve the bottom edge**: either both columns end at the same y, or the shorter column is deliberately closed (vertical centering, a filler visual, a closing rule) — never one column dangling far above the other with dead whitespace.
- **No glued glyphs on one line**: adjacent inline elements (arrow + number, numeral + unit, badge + label) keep ≥0.3em horizontal gap. An arrow touching a digit ("→02") reads as a typo.
- **Template structure**: if templates exist, inherit the visual framework - **Template structure**: if templates exist, inherit the visual framework
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity - **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time). - **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).

View File

@ -650,8 +650,6 @@ The most common Strategist failure mode is missing the structural half — treat
> - Every **content page whose shape matches a catalog Pick clause** MUST get a visual treatment — a `page_charts` entry (chart / infographic template), a `page_layouts` structural template, or an explicit §VII custom-diagram plan (`no-template-match` with the figure described). Text-in-boxes is the fallback only for pages that genuinely carry no structurable shape, and you must be able to name why. > - Every **content page whose shape matches a catalog Pick clause** MUST get a visual treatment — a `page_charts` entry (chart / infographic template), a `page_layouts` structural template, or an explicit §VII custom-diagram plan (`no-template-match` with the figure described). Text-in-boxes is the fallback only for pages that genuinely carry no structurable shape, and you must be able to name why.
> - **`spec_lock.md` MUST NOT ship a content deck with `page_charts` empty AND `page_layouts` empty/free-design AND no §VII custom-diagram rows.** That combination means no visual was assigned anywhere — re-scan §IX and map each content page's shape to a figure (comparison→columns/quadrant, process/历程→timeline/process_flow, cycle/循环→concentric/segmented_wheel, share→donut/pie, trend→line/area, ranking→bar, architecture→layered_architecture, relations→hub_spoke/mind_map). > - **`spec_lock.md` MUST NOT ship a content deck with `page_charts` empty AND `page_layouts` empty/free-design AND no §VII custom-diagram rows.** That combination means no visual was assigned anywhere — re-scan §IX and map each content page's shape to a figure (comparison→columns/quadrant, process/历程→timeline/process_flow, cycle/循环→concentric/segmented_wheel, share→donut/pie, trend→line/area, ranking→bar, architecture→layered_architecture, relations→hub_spoke/mind_map).
> - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero `<path>`/`<polygon>`/`<polyline>`/`<image>` deck-wide. Leaving the visual plan empty here guarantees that failure later — assign the figures now, at the spec stage, where it is cheapest to change. > - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero `<path>`/`<polygon>`/`<polyline>`/`<image>` deck-wide. Leaving the visual plan empty here guarantees that failure later — assign the figures now, at the spec stage, where it is cheapest to change.
> - **Archetype cap (anti-monotony)**: the same layout archetype — especially the icon+title+text card grid and the full-width stacked row list — may carry at most **2 pages per deck**, adjacent or not. Five "2×3 icon grid" pages read as the same slide repeated even when the copy differs (real shipped failure). When §IX assigns a third page the same shape, rework it into a different visual form via the mapping above. Downstream: the checker warns at 3 same-fingerprint pages and errors at 4.
> - **Title semantics must be honored by the layout**: a page titled 架构/分层 gets stacked layers (NOT equal-width row bars); 矩阵 gets a real 2×2 quadrant (NOT a card grid); 流程 gets a directional flow; 层级 gets a pyramid/tree. Shipping a "五层架构" page drawn as five identical list rows is the canonical semantic mismatch — the title promises a figure the page never draws.
> **Reading is mandatory; the catalog is a starting point, not a copy target.** > **Reading is mandatory; the catalog is a starting point, not a copy target.**
> - Fully read `templates/charts/charts_index.json` **before drafting the Eight Confirmations** — the read happens up front, not when you sit down to write Section VII. The file contains `meta` + `charts.<key>.summary` only; each `summary` is a selection rule (`"Pick for … Skip if …"`), not a description. There is **no category, quickLookup, or keyword index** — selection is done by semantically matching each page's content shape against all 71 summaries in one pass. > - Fully read `templates/charts/charts_index.json` **before drafting the Eight Confirmations** — the read happens up front, not when you sit down to write Section VII. The file contains `meta` + `charts.<key>.summary` only; each `summary` is a selection rule (`"Pick for … Skip if …"`), not a description. There is **no category, quickLookup, or keyword index** — selection is done by semantically matching each page's content shape against all 71 summaries in one pass.
@ -810,7 +808,7 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck
1. Read reference template: `templates/design_spec_reference.md` 1. Read reference template: `templates/design_spec_reference.md`
2. Generate complete spec from scratch based on analysis 2. Generate complete spec from scratch based on analysis
3. Save to: `projects/<project_name>.../design_spec.md` 3. Save to: `projects/<project_name>.../design_spec.md`
4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **layout_grid** / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative. 4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/<project_name>.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative.
- **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`. - **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`.
- **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota. - **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota.
- **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.) - **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.)

View File

@ -243,12 +243,6 @@ class SVGQualityChecker:
self._deck_graphic_total = 0 # path+polyline+polygon+image across deck self._deck_graphic_total = 0 # path+polyline+polygon+image across deck
self._deck_text_total = 0 # <text> across deck (density signal) self._deck_text_total = 0 # <text> across deck (density signal)
self._pages_no_graphic: List[str] = [] # pages with zero graphic primitives self._pages_no_graphic: List[str] = [] # pages with zero graphic primitives
# Alignment / grid / monotony aggregation (check 14). Cross-page margin
# drift and repeated layout archetypes are only visible deck-wide, so
# per-page passes record into these and print_summary aggregates.
self._grid_locked = False # any spec_lock carried layout_grid
self._page_left_edges: Dict[str, float] = {} # page -> primary content left edge
self._page_fingerprints: Dict[str, tuple] = {} # page -> layout archetype fingerprint
def check_file(self, svg_file: str, expected_format: str = None) -> Dict: def check_file(self, svg_file: str, expected_format: str = None) -> Dict:
""" """
@ -344,12 +338,6 @@ class SVGQualityChecker:
if not self.template_mode: if not self.template_mode:
self._check_geometry(content, result) self._check_geometry(content, result)
# 14. Alignment lint: sibling-card near-miss misalignment,
# layout_grid lock enforcement, uneven row gaps, plus
# deck-level margin-drift / layout-monotony aggregation.
if not self.template_mode:
self._check_alignment(content, svg_path, result)
# Determine pass/fail # Determine pass/fail
result['passed'] = len(result['errors']) == 0 result['passed'] = len(result['errors']) == 0
@ -1091,10 +1079,6 @@ class SVGQualityChecker:
return 'viewBox issues' return 'viewBox issues'
elif 'foreignObject' in error_msg: elif 'foreignObject' in error_msg:
return 'foreignObject' return 'foreignObject'
elif error_msg.startswith('Alignment:'):
return 'Alignment/grid'
elif error_msg.startswith('Geometry:'):
return 'Geometry'
elif 'font' in error_msg.lower(): elif 'font' in error_msg.lower():
return 'Font issues' return 'Font issues'
else: else:
@ -1864,359 +1848,6 @@ class SVGQualityChecker:
f"Geometry: ... and {len(bucket) - len(shown)} more " f"Geometry: ... and {len(bucket) - len(shown)} more "
f"similar issue(s) on this page") f"similar issue(s) on this page")
# ── Alignment / grid / monotony lint (check 14) ──────────────────────
# The shipped failures no overlap check sees: sibling cards a few px out
# of line ("meant to align, didn't"), content blocks drifting off the
# deck's margin line page by page, and the same card/icon-grid archetype
# repeated until the deck reads monotone. Rect / icon coordinates are
# exact (no width estimation), so near-miss offsets are reliable: a
# 2-12px offset between row-mates is virtually never design intent —
# deliberate stagger clears 16px — which is why those land error-tier,
# unlike the estimated text boxes above.
_ALIGN_TOL = 2.0 # <= : aligned (authoring rounding slack)
_ALIGN_ERR = 12.0 # (tol, err] : hard misalignment → error
_ALIGN_INTENT = 16.0 # (err, intent): borderline → warning; >= intent: deliberate
_CARD_MIN_W = 60.0 # smaller rects are chips / accent bars, not cards
_CARD_MIN_H = 36.0
_CLUSTER_TOL = 14.0 # row/col clustering tolerance (fingerprints, gaps)
_PAGE_NUM_RE = re.compile(r'^(\d{1,3})[_\-]')
def _page_rhythm(self, svg_path: Path, lock) -> str:
"""Return this page's page_rhythm tag ('' when unknown)."""
rhythm = (lock or {}).get('page_rhythm') or {}
m = self._PAGE_NUM_RE.match(svg_path.name)
if not m:
return ''
return (rhythm.get(f'P{int(m.group(1)):02d}') or '').strip().lower()
def _cards_from_rects(self, rects, canvas_w, canvas_h):
"""Card-sized visible rects, excluding full-bleed backgrounds."""
cards = []
for r in rects:
w, h = r['x1'] - r['x0'], r['y1'] - r['y0']
if w < self._CARD_MIN_W or h < self._CARD_MIN_H:
continue
if w * h >= 0.85 * canvas_w * canvas_h:
continue
cards.append(r)
return cards
@staticmethod
def _similar_size(a, b, rel=0.2, floor=8.0):
return abs(a - b) <= max(floor, rel * max(a, b))
@staticmethod
def _cluster_values(values, tol):
"""Cluster sorted scalars; returns list of cluster-center floats."""
centers = []
for v in sorted(values):
if centers and v - centers[-1][-1] <= tol:
centers[-1].append(v)
else:
centers.append([v])
return [sum(c) / len(c) for c in centers]
def _layout_fingerprint(self, cards, icons):
"""Classify the page's dominant grid archetype, or None.
icon-grid: >=4 icons arranged in a >=2x2 grid (the icon+title+text
card pattern usually no visible card rect, so icons carry the
structure). card-grid: >=4 similar-sized card rects in a grid, or a
>=4-row single-column stack (full-width list rows). Pages without a
dominant grid (covers, chapters, diagrams, timelines) get None and
never count toward monotony.
"""
if len(icons) >= 4:
rows = self._cluster_values([i['y0'] for i in icons], self._CLUSTER_TOL)
cols = self._cluster_values([i['x0'] for i in icons], self._CLUSTER_TOL)
if len(rows) >= 2 and len(cols) >= 2 \
and len(rows) * len(cols) <= len(icons) + 2:
return ('icon-grid', len(rows), len(cols))
# Consider only the largest similar-size card family on the page.
if len(cards) >= 4:
fam = max(
([c for c in cards
if self._similar_size(c['x1'] - c['x0'], k['x1'] - k['x0'])
and self._similar_size(c['y1'] - c['y0'], k['y1'] - k['y0'])]
for k in cards),
key=len,
)
if len(fam) >= 4:
rows = self._cluster_values([c['y0'] for c in fam], self._CLUSTER_TOL)
cols = self._cluster_values([c['x0'] for c in fam], self._CLUSTER_TOL)
if len(rows) * len(cols) <= len(fam) + 2 \
and (min(len(rows), len(cols)) >= 2
or (len(cols) == 1 and len(rows) >= 4)):
return ('card-grid', len(rows), len(cols))
return None
def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> None:
"""Sibling alignment + layout_grid enforcement + deck aggregation."""
try:
root = ET.fromstring(content)
except ET.ParseError:
return
vb = re.search(r'viewBox="([^"]+)"', content)
if not vb:
return
parts = vb.group(1).split()
if len(parts) != 4:
return
canvas_w, canvas_h = float(parts[2]), float(parts[3])
texts, icons, rects = self._collect_geometry(root)
cards = self._cards_from_rects(rects, canvas_w, canvas_h)
# Chart plot areas (the mandatory §3.1 marker): rects inside them are
# data marks (bars / boxes) whose offsets encode values, not layout —
# exclude them from every alignment check.
plot_areas = [
tuple(map(float, m.groups()))
for m in re.finditer(
r'chart-plot-area:\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)',
content)
]
if plot_areas:
cards = [
c for c in cards
if not any(px0 <= (c['x0'] + c['x1']) / 2 <= px1
and py0 <= (c['y0'] + c['y1']) / 2 <= py1
for px0, py0, px1, py1 in plot_areas)
]
errors: List[str] = []
warnings: List[str] = []
def near_equal(u, v, tol=None):
return abs(u - v) <= (self._ALIGN_TOL if tol is None else tol)
def cross_match(u, v):
# Cross-axis dimension near-equality: true sibling cards share the
# dimension perpendicular to their run (heights in a row, widths in
# a column). Data bars / featured-emphasis cards differ more.
return abs(u - v) <= max(6.0, 0.04 * max(u, v))
# 1. Sibling near-miss misalignment (exact geometry → error tier).
# A pair only errs when NO alignment scheme fits: leading edges,
# centers, and trailing edges all disagree. Shared centers (tree
# nodes on an axis, symmetric emphasis growth) or shared trailing
# edges (baseline-anchored elements) mean the offset is a scheme,
# not an accident.
for i in range(len(cards)):
for j in range(i + 1, len(cards)):
a, b = cards[i], cards[j]
aw, ah = a['x1'] - a['x0'], a['y1'] - a['y0']
bw, bh = b['x1'] - b['x0'], b['y1'] - b['y0']
v_overlap = min(a['y1'], b['y1']) - max(a['y0'], b['y0'])
h_overlap = min(a['x1'], b['x1']) - max(a['x0'], b['x0'])
# Row-mates: share a horizontal band, horizontally disjoint.
if (cross_match(ah, bh) and v_overlap >= 0.6 * min(ah, bh)
and h_overlap <= self._ALIGN_TOL):
dy = abs(a['y0'] - b['y0'])
aligned_otherwise = (
near_equal(a['y1'], b['y1'])
or near_equal((a['y0'] + a['y1']) / 2,
(b['y0'] + b['y1']) / 2))
if dy <= self._ALIGN_TOL or aligned_otherwise:
if dy <= self._ALIGN_TOL and self._similar_size(aw, bw):
dh = abs(ah - bh)
if self._ALIGN_TOL < dh:
warnings.append(
f"row-mate cards at x={a['x0']:.0f} and "
f"x={b['x0']:.0f} share a top but differ "
f"{dh:.0f}px in height — equalize or make the "
f"difference deliberate (>=16px)")
elif dy <= self._ALIGN_ERR:
errors.append(
f"row-mate cards at x={a['x0']:.0f} and x={b['x0']:.0f} have "
f"tops {dy:.0f}px apart (y={a['y0']:.0f} vs {b['y0']:.0f}) — "
f"meant to align; snap to one y")
elif dy < self._ALIGN_INTENT:
warnings.append(
f"row-mate cards at x={a['x0']:.0f} and x={b['x0']:.0f} have "
f"tops {dy:.0f}px apart — deliberate stagger should clear "
f"{self._ALIGN_INTENT:.0f}px")
# Column-mates: share a vertical band, vertically disjoint.
elif (cross_match(aw, bw) and h_overlap >= 0.6 * min(aw, bw)
and v_overlap <= self._ALIGN_TOL):
dx = abs(a['x0'] - b['x0'])
aligned_otherwise = (
near_equal(a['x1'], b['x1'])
or near_equal((a['x0'] + a['x1']) / 2,
(b['x0'] + b['x1']) / 2))
if dx <= self._ALIGN_TOL or aligned_otherwise:
pass
elif dx <= self._ALIGN_ERR:
errors.append(
f"column-mate cards at y={a['y0']:.0f} and y={b['y0']:.0f} have "
f"left edges {dx:.0f}px apart (x={a['x0']:.0f} vs {b['x0']:.0f}) "
f"— meant to align; snap to one x")
elif dx < self._ALIGN_INTENT:
warnings.append(
f"column-mate cards at y={a['y0']:.0f} and y={b['y0']:.0f} have "
f"left edges {dx:.0f}px apart — deliberate indent should clear "
f"{self._ALIGN_INTENT:.0f}px")
# 2. Uneven gaps in a >=3-card row. Only flag near-equal-but-not-equal
# spreads; a 2+1 grouping (gap spread comparable to the gap itself)
# is design intent, not drift.
top_groups: Dict[float, list] = {}
for c in cards:
for k in top_groups:
if abs(c['y0'] - k) <= self._CLUSTER_TOL:
top_groups[k].append(c)
break
else:
top_groups[c['y0']] = [c]
for row in top_groups.values():
if len(row) < 3:
continue
row.sort(key=lambda r: r['x0'])
gaps = [row[k + 1]['x0'] - row[k]['x1'] for k in range(len(row) - 1)]
if any(g < -self._ALIGN_TOL for g in gaps):
continue # overlapping/nested — not a simple row
spread = max(gaps) - min(gaps)
if 4.0 < spread and spread < 0.35 * max(gaps):
warnings.append(
f"{len(row)}-card row at y={row[0]['y0']:.0f} has uneven gaps "
f"({', '.join(f'{g:.0f}' for g in gaps)}px) — equalize to one gutter")
# 3. layout_grid lock enforcement (spec-declared baselines → error on
# near-miss deviation; clean break >=16px is allowed by contract).
lock = self._get_spec_lock(svg_path)
grid = (lock or {}).get('layout_grid') or {}
margin_x = self._f(grid.get('margin_x'))
content_top = self._f(grid.get('content_top'))
footer_y = self._f(grid.get('footer_y'))
rhythm = self._page_rhythm(svg_path, lock)
structural = rhythm == 'anchor'
if margin_x is not None:
self._grid_locked = True
if margin_x is not None and not structural:
seen = set()
for el, x0 in ([(f"card at y={c['y0']:.0f}", c['x0']) for c in cards]
+ [(f"text \"{t['label']}\"", t['x0'])
for t in texts if t['exact_left']]):
dev = abs(x0 - margin_x)
key = round(x0)
if self._ALIGN_TOL < dev < self._ALIGN_INTENT and key not in seen:
seen.add(key)
errors.append(
f"{el} sits at x={x0:.0f}, {dev:.0f}px off the locked "
f"margin_x={margin_x:.0f} — snap to the grid or clear it by >=16px")
if content_top is not None and not structural:
seen = set()
for el, y0 in ([(f"card at x={c['x0']:.0f}", c['y0']) for c in cards]
+ [(f"icon {i['label']}", i['y0']) for i in icons]):
dev = abs(y0 - content_top)
key = round(y0)
if self._ALIGN_TOL < dev < self._ALIGN_INTENT and key not in seen:
seen.add(key)
errors.append(
f"{el} starts at y={y0:.0f}, {dev:.0f}px off the locked "
f"content_top={content_top:.0f} — snap to the grid or clear it "
f"by >=16px")
if footer_y is not None and not structural:
for t in texts:
dev = abs(t['baseline'] - footer_y)
if self._ALIGN_TOL < dev < self._ALIGN_INTENT:
errors.append(
f"text \"{t['label']}\" baseline y={t['baseline']:.0f} is "
f"{dev:.0f}px off the locked footer_y={footer_y:.0f}")
break
# 4. Deck aggregation: primary left edge (margin-drift fallback when no
# layout_grid is locked) + layout-archetype fingerprint (monotony).
# Only numbered deck pages (NN_*.svg) participate — a directory of
# standalone template/chart SVGs is not a deck, and aggregating
# across unrelated files produces meaningless drift/monotony noise.
if not structural and self._PAGE_NUM_RE.match(svg_path.name):
edge_candidates = ([c['x0'] for c in cards]
+ [t['x0'] for t in texts if t['exact_left']])
edge_candidates = [x for x in edge_candidates if 0 < x < 0.25 * canvas_w]
if len(texts) >= 4 and edge_candidates:
self._page_left_edges[svg_path.name] = min(edge_candidates)
fp = self._layout_fingerprint(cards, icons)
if fp:
self._page_fingerprints[svg_path.name] = fp
for bucket, dest in ((errors, result['errors']), (warnings, result['warnings'])):
shown = bucket[:self._GEOM_MAX_REPORTS]
dest.extend(f"Alignment: {m}" for m in shown)
if len(bucket) > len(shown):
dest.append(
f"Alignment: ... and {len(bucket) - len(shown)} more "
f"similar issue(s) on this page")
def _print_alignment_summary(self):
"""Deck-level margin-drift fallback + layout-monotony gate.
Margin drift: without a layout_grid lock there is no declared baseline,
so cluster each content page's primary left edge — several distinct
values within 16px of each other is the drift signature ("meant to be
one margin line"), warning-tier only (legacy decks must not hard-fail).
Monotony: >=3 content pages sharing one grid fingerprint warning;
>=4 or over half the deck error (the user-visible "every page is
the same card wall" pathology). Same-family (any dims) repetition is
an advisory nudge. Short decks (<6 pages) exempt, like the flat gate.
"""
pages = self._deck_page_count
# Margin-drift fallback (only when no layout_grid was declared).
if not self._grid_locked and len(self._page_left_edges) >= 4:
centers = self._cluster_values(
self._page_left_edges.values(), self._ALIGN_TOL)
drifting = [
(a, b) for i, a in enumerate(centers) for b in centers[i + 1:]
if self._ALIGN_TOL < b - a < self._ALIGN_INTENT
]
if drifting:
self.summary['warnings'] += 1
vals = sorted({round(v) for pair in drifting for v in pair})
pages_by_edge = ', '.join(
f"{name}@{edge:.0f}" for name, edge
in sorted(self._page_left_edges.items())
if any(abs(edge - v) <= self._ALIGN_TOL for v in vals))
print(f"\n[WARN] Margin drift: content left edges cluster at "
f"{vals}px across pages — meant to be one margin line. "
f"Lock layout_grid in spec_lock.md and snap pages to it.")
print(f" ({pages_by_edge})")
# Layout monotony gate.
if pages < 6 or not self._page_fingerprints:
return
by_fp: Dict[tuple, list] = defaultdict(list)
by_family: Dict[str, list] = defaultdict(list)
for name, fp in self._page_fingerprints.items():
by_fp[fp].append(name)
by_family[fp[0]].append(name)
fp, members = max(by_fp.items(), key=lambda kv: len(kv[1]))
n = len(members)
label = f"{fp[1]}x{fp[2]} {fp[0]}"
_fix = ("Rework all but 1-2 of them into a different visual form — "
"timeline / layered architecture / quadrant / process flow / "
"hub-spoke / chart (templates/charts/) — per the content->layout "
"mapping in strategist.md; also check spec_lock page_layouts.")
if n >= 4 or (n >= 3 and n > 0.5 * pages):
self.summary['errors'] += 1
print(f"\n[ERROR] Layout monotony: {n} pages share the same {label} "
f"archetype ({', '.join(sorted(members))}) — the deck reads as "
f"the same card wall repeated.")
print(" " + _fix)
elif n >= 3:
self.summary['warnings'] += 1
print(f"\n[WARN] Layout monotony: {n} pages share the same {label} "
f"archetype ({', '.join(sorted(members))}) — consider reworking "
f"at least {n - 2} of them.")
print(" " + _fix)
else:
fam, fam_members = max(by_family.items(), key=lambda kv: len(kv[1]))
if len(fam_members) >= 4:
self.summary['warnings'] += 1
print(f"\n[WARN] Layout monotony: {len(fam_members)} pages are all "
f"{fam} layouts ({', '.join(sorted(fam_members))}) — vary the "
f"visual form even if the grid dims differ.")
print(" " + _fix)
def _print_graphic_summary(self): def _print_graphic_summary(self):
"""Deck-level flat-deck gate. """Deck-level flat-deck gate.
@ -2290,9 +1921,6 @@ class SVGQualityChecker:
# Deck-level flat-deck gate (text-on-rectangles, no diagrams/figures). # Deck-level flat-deck gate (text-on-rectangles, no diagrams/figures).
self._print_graphic_summary() self._print_graphic_summary()
# Deck-level margin-drift fallback + layout-monotony gate.
self._print_alignment_summary()
# Fix suggestions # Fix suggestions
if self.summary['errors'] > 0 or self.summary['warnings'] > 0: if self.summary['errors'] > 0 or self.summary['warnings'] > 0:
print(f"\n[TIP] Common fixes:") print(f"\n[TIP] Common fixes:")

View File

@ -90,8 +90,8 @@
<text x="508" y="445" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#8B5CF6" font-weight="700"></tspan><tspan x="532" fill="#334155">Custom Workflows</tspan></text> <text x="508" y="445" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#8B5CF6" font-weight="700"></tspan><tspan x="532" fill="#334155">Custom Workflows</tspan></text>
<text x="508" y="480" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#CBD5E1" font-weight="700"></tspan><tspan x="532" fill="#CBD5E1">Dedicated Account Manager</tspan></text> <text x="508" y="480" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#CBD5E1" font-weight="700"></tspan><tspan x="532" fill="#CBD5E1">Dedicated Account Manager</tspan></text>
<text x="508" y="515" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#CBD5E1" font-weight="700"></tspan><tspan x="532" fill="#CBD5E1">SLA Guarantee</tspan></text> <text x="508" y="515" text-anchor="start" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="15"><tspan fill="#CBD5E1" font-weight="700"></tspan><tspan x="532" fill="#CBD5E1">SLA Guarantee</tspan></text>
<rect x="540" y="560" width="200" height="44" rx="22" fill="#8B5CF6"/> <rect x="540" y="555" width="200" height="44" rx="22" fill="#8B5CF6"/>
<text x="640" y="587" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" <text x="640" y="582" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="14" font-weight="700" fill="#FFFFFF"><tspan>Upgrade Now</tspan></text> font-size="14" font-weight="700" fill="#FFFFFF"><tspan>Upgrade Now</tspan></text>
</g> </g>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -165,7 +165,6 @@ Two views on the same font decisions — fill both, keep them consistent:
- **Header area**: [Height and content description] - **Header area**: [Height and content description]
- **Content area**: [Height and content description] - **Content area**: [Height and content description]
- **Footer area**: [Height and content description] - **Footer area**: [Height and content description]
- **Layout grid**: [margin_x / content_top / footer_y / gutter in canvas px — the deck-wide alignment constants. Copy these verbatim into `spec_lock.md ## layout_grid`; the Executor snaps every content page to them and the quality checker errors on near-miss deviations (215px off). Hand-written coordinates drift across pages without this anchor.]
### Layout Pattern Library (combine or break as content demands) ### Layout Pattern Library (combine or break as content demands)

View File

@ -12,22 +12,7 @@
> Strategist: fill viewBox and format for the chosen canvas. Common values: `0 0 1280 720` (PPT 16:9), `0 0 1024 768` (PPT 4:3), `0 0 1242 1660` (Xiaohongshu), `0 0 1080 1080` (WeChat Moments), `0 0 1080 1920` (Story). > Strategist: fill viewBox and format for the chosen canvas. Common values: `0 0 1280 720` (PPT 16:9), `0 0 1024 768` (PPT 4:3), `0 0 1242 1660` (Xiaohongshu), `0 0 1080 1080` (WeChat Moments), `0 0 1080 1920` (Story).
## layout_grid ## mode
- margin_x: 60
- content_top: 150
- footer_y: 688
- gutter: 24
> Deck-wide layout constants, in canvas px. Executor snaps every content page to these; `svg_quality_checker.py` hard-checks them (a content block whose left edge lands 215px off `margin_x` is an error — "meant to align, didn't"). This section exists because hand-written absolute coordinates drift across pages without a locked baseline — the #1 source of cross-page misalignment.
>
> - `margin_x` — left/right content margin. Header title, content blocks, and footer on content pages all share this left edge.
> - `content_top` — top edge of the body content zone on content pages (below the header band). Structural pages (cover / chapter / TOC / ending) are exempt.
> - `footer_y` — footer baseline. Omit if the deck has no footer.
> - `gutter` — gap between side-by-side sibling cards in a row/grid. One value deck-wide.
>
> Strategist derives values from canvas + visual_style (defaults above suit 1280×720; scale proportionally for other canvases — e.g. 4:3 `margin_x: 50`). A page may deliberately break the grid (full-bleed image, asymmetric breathing page) — breaking means clearing it by ≥16px or going full-bleed, never a few-px "almost".
>
> **Missing section** → legacy deck; checker falls back to cross-page clustering (warning-tier only). New decks MUST fill it.
- mode: pyramid - mode: pyramid
> Strategist: the deck's narrative skeleton, locked at confirmation `d` Layer 1. One of `pyramid` / `narrative` / `instructional` / `showcase` / `briefing` — see [`references/modes/_index.md`](../references/modes/_index.md). Executor reads only the locked mode's file. Deck-wide. Or the literal `custom` for a bespoke direction no preset captures (a special cadence, a multi-mode fusion, a particular posture) — user-requested or Strategist-recommended (user confirms, like every lock). Then add a sibling `- mode_behavior:` paragraph (how the argument advances, title voice, page rhythm, register) that the Executor follows in place of a preset file. One deck locks one value; don't default to `custom` when a preset fits. > Strategist: the deck's narrative skeleton, locked at confirmation `d` Layer 1. One of `pyramid` / `narrative` / `instructional` / `showcase` / `briefing` — see [`references/modes/_index.md`](../references/modes/_index.md). Executor reads only the locked mode's file. Deck-wide. Or the literal `custom` for a bespoke direction no preset captures (a special cadence, a multi-mode fusion, a particular posture) — user-requested or Strategist-recommended (user confirms, like every lock). Then add a sibling `- mode_behavior:` paragraph (how the argument advances, title voice, page rhythm, register) that the Executor follows in place of a preset file. One deck locks one value; don't default to `custom` when a preset fits.

View File

@ -3,7 +3,6 @@ import test from "node:test";
import { import {
applyProgressAction, applyProgressAction,
enforceMonotonicProgress,
progressActionsFromToolCalls, progressActionsFromToolCalls,
} from "../web/static/js/progress.js"; } from "../web/static/js/progress.js";
@ -50,70 +49,3 @@ test("tool calls can apply progress updates on top of previous task progress", (
{ id: "s2", title: "实现功能", status: "pending" }, { id: "s2", title: "实现功能", status: "pending" },
]); ]);
}); });
test("a completed step force-completes earlier dangling steps (monotonic heal)", () => {
const steps = [
{ id: "s1", title: "摄取素材", status: "in_progress" },
{ id: "s2", title: "策略", status: "completed" },
{ id: "s3", title: "配图", status: "pending" },
];
assert.deepEqual(enforceMonotonicProgress(steps), [
{ id: "s1", title: "摄取素材", status: "completed" },
{ id: "s2", title: "策略", status: "completed" },
{ id: "s3", title: "配图", status: "pending" },
]);
});
test("update_step marking a later step completed heals the earlier in_progress step", () => {
const initial = applyProgressAction([], {
action: "set_plan",
steps: [
{ id: "s1", title: "摄取素材", status: "in_progress" },
{ id: "s2", title: "策略", status: "pending" },
{ id: "s3", title: "配图", status: "pending" },
],
});
const updated = applyProgressAction(initial, {
action: "update_step",
step: { id: "s2", status: "completed" },
});
assert.deepEqual(updated, [
{ id: "s1", title: "摄取素材", status: "completed" },
{ id: "s2", title: "策略", status: "completed" },
{ id: "s3", title: "配图", status: "pending" },
]);
});
// Replay the exact d1285247 task_progress sequence that produced the reported
// "green check below, red dot above" bug; the heal must yield a clean 6/6.
test("replays the d1285247 skip-ahead sequence to a fully completed plan", () => {
const call = (args) => ({
function: { name: "task_progress", arguments: JSON.stringify(args) },
});
const seq = [
call({
action: "set_plan",
steps: [
{ id: "s1", title: "摄取素材", status: "in_progress" },
{ id: "s2", title: "策略", status: "pending" },
{ id: "s3", title: "配图", status: "pending" },
{ id: "s4", title: "执行", status: "pending" },
{ id: "s5", title: "质检", status: "pending" },
{ id: "s6", title: "导出", status: "pending" },
],
}),
call({ action: "update_step", step: { id: "s2", status: "completed" } }),
call({ action: "update_step", step: { id: "s3", status: "in_progress" } }),
call({ action: "update_step", step: { id: "s4", status: "completed" } }),
call({ action: "update_step", step: { id: "s5", status: "in_progress" } }),
call({ action: "update_step", step: { id: "s5", status: "completed" } }),
call({ action: "update_step", step: { id: "s6", status: "completed" } }),
];
const { steps } = progressActionsFromToolCalls(seq, []);
assert.deepEqual(steps.map(s => s.status), [
"completed", "completed", "completed", "completed", "completed", "completed",
]);
});

View File

@ -1,240 +0,0 @@
"""svg_quality_checker 对齐/网格/单调检查(check 14)的 focused tests。
合成 SVG 三类病灶:兄弟卡片差几 px 不齐内容块偏离 layout_grid
同一卡网格原型重复 对应 d1285247 陶瓷 deck 复盘出的真实缺陷
纯几何逻辑,不依赖渲染器
"""
import contextlib
import io
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
SCRIPTS = Path(__file__).parent.parent / "skills" / "ppt" / "scripts"
sys.path.insert(0, str(SCRIPTS))
from svg_quality_checker import SVGQualityChecker # noqa: E402
def _svg(body: str) -> str:
return ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" '
'width="1280" height="720">' + body + '</svg>')
def _card(x, y, w=200, h=120):
return (f'<rect x="{x}" y="{y}" width="{w}" height="{h}" '
f'fill="#FFFFFF" stroke="#333333"/>')
def _icon(x, y, name="home"):
return (f'<use data-icon="tabler-outline/{name}" x="{x}" y="{y}" '
f'width="32" height="32" fill="#C00000"/>')
def _text(x, y, s="标签"):
return f'<text x="{x}" y="{y}" font-size="16" fill="#333333">{s}</text>'
def _write_page(project: Path, name: str, body: str) -> Path:
out = project / "svg_output"
out.mkdir(parents=True, exist_ok=True)
p = out / name
p.write_text(_svg(body), encoding="utf-8")
return p
def _alignment_errors(result):
return [e for e in result["errors"] if e.startswith("Alignment:")]
def _alignment_warnings(result):
return [w for w in result["warnings"] if w.startswith("Alignment:")]
class SiblingAlignmentTests(unittest.TestCase):
def _check(self, body: str):
with TemporaryDirectory() as tmp:
page = _write_page(Path(tmp), "03_content.svg", body)
return SVGQualityChecker().check_file(str(page))
def test_row_mates_offset_6px_is_error(self):
r = self._check(_card(100, 200) + _card(340, 206))
errs = _alignment_errors(r)
self.assertTrue(any("row-mate" in e for e in errs), errs)
def test_row_mates_exact_align_passes(self):
r = self._check(_card(100, 200) + _card(340, 200))
self.assertEqual(_alignment_errors(r), [])
self.assertEqual(_alignment_warnings(r), [])
def test_deliberate_stagger_24px_passes(self):
r = self._check(_card(100, 200) + _card(340, 224))
self.assertEqual(_alignment_errors(r), [])
self.assertEqual(_alignment_warnings(r), [])
def test_column_mates_offset_5px_is_error(self):
r = self._check(_card(100, 200) + _card(105, 360))
errs = _alignment_errors(r)
self.assertTrue(any("column-mate" in e for e in errs), errs)
def test_row_mates_height_mismatch_warns(self):
r = self._check(_card(100, 200, h=120) + _card(340, 200, h=125))
warns = _alignment_warnings(r)
self.assertTrue(any("height" in w for w in warns), warns)
def test_center_aligned_pair_exempt(self):
# 树节点宽度不同但中心同轴(640):左缘差 10px 是居中方案,不是事故
r = self._check(_card(540, 100, w=200, h=90) + _card(550, 300, w=180, h=90))
self.assertEqual(_alignment_errors(r), [])
def test_baseline_anchored_pair_exempt(self):
# 底对齐、顶差 5px(数据柱形态):存在对齐方案,不报
r = self._check(_card(100, 200, h=120) + _card(340, 205, h=115))
self.assertEqual(_alignment_errors(r), [])
def test_plot_area_bars_excluded(self):
# 绘图区标记内的"柱子"错位不报(值编码,非版式)
body = ('<!-- chart-plot-area: 50,100,1200,600 -->'
+ _card(100, 200) + _card(340, 206))
r = self._check(body)
self.assertEqual(_alignment_errors(r), [])
def test_uneven_row_gaps_warn(self):
# gaps 30 / 36 / 30 —— 近等不等,该报;2+1 分组的大差距不报
body = (_card(60, 300) + _card(290, 300)
+ _card(526, 300) + _card(756, 300))
r = self._check(body)
warns = _alignment_warnings(r)
self.assertTrue(any("uneven gaps" in w for w in warns), warns)
def test_grouped_row_large_gap_spread_passes(self):
# gaps 30 / 30 / 120 —— 明显是分组设计,不报
body = (_card(60, 300) + _card(290, 300)
+ _card(520, 300) + _card(840, 300))
r = self._check(body)
self.assertFalse(
any("uneven gaps" in w for w in _alignment_warnings(r)))
class LayoutGridLockTests(unittest.TestCase):
def _check(self, body: str, lock: str, name="03_content.svg"):
with TemporaryDirectory() as tmp:
project = Path(tmp)
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
page = _write_page(project, name, body)
return SVGQualityChecker().check_file(str(page))
LOCK = "## layout_grid\n- margin_x: 60\n- content_top: 150\n"
def test_card_6px_off_margin_is_error(self):
r = self._check(_card(66, 150), self.LOCK)
errs = _alignment_errors(r)
self.assertTrue(any("margin_x" in e for e in errs), errs)
def test_card_on_grid_passes(self):
r = self._check(_card(60, 150), self.LOCK)
self.assertEqual(_alignment_errors(r), [])
def test_clean_break_over_16px_passes(self):
r = self._check(_card(100, 200), self.LOCK)
self.assertEqual(_alignment_errors(r), [])
def test_icon_8px_off_content_top_is_error(self):
r = self._check(_icon(60, 158), self.LOCK)
errs = _alignment_errors(r)
self.assertTrue(any("content_top" in e for e in errs), errs)
def test_anchor_rhythm_page_exempt(self):
lock = self.LOCK + "\n## page_rhythm\n- P01: anchor\n"
r = self._check(_card(66, 150), lock, name="01_cover.svg")
self.assertEqual(_alignment_errors(r), [])
class DeckAggregationTests(unittest.TestCase):
def _run_deck(self, pages: dict, lock: str = None):
"""pages: {filename: body}; 返回 (checker, print_summary 输出)。"""
with TemporaryDirectory() as tmp:
project = Path(tmp)
if lock is not None:
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
checker = SVGQualityChecker()
for name, body in sorted(pages.items()):
page = _write_page(project, name, body)
checker.check_file(str(page))
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
checker.print_summary()
return checker, buf.getvalue()
@staticmethod
def _content_page(margin: float) -> str:
return "".join(_text(margin, 100 + 40 * i, f"{i}") for i in range(4))
def test_margin_drift_across_pages_warns(self):
pages = {
"01_a.svg": self._content_page(60),
"02_b.svg": self._content_page(63),
"03_c.svg": self._content_page(66),
"04_d.svg": self._content_page(60),
}
_, out = self._run_deck(pages)
self.assertIn("Margin drift", out)
def test_consistent_margin_no_drift_warning(self):
pages = {f"{i:02d}_p.svg": self._content_page(60) for i in range(1, 5)}
_, out = self._run_deck(pages)
self.assertNotIn("Margin drift", out)
def test_locked_grid_disables_drift_fallback(self):
pages = {
"01_a.svg": self._content_page(60),
"02_b.svg": self._content_page(63),
"03_c.svg": self._content_page(66),
"04_d.svg": self._content_page(60),
}
# margin 值刻意与页面无关:fallback 该关闭,页级 error 才是出口
_, out = self._run_deck(pages, lock="## layout_grid\n- margin_x: 60\n")
self.assertNotIn("Margin drift", out)
@staticmethod
def _icon_grid_page() -> str:
body = ""
for r in range(2):
for c in range(3):
body += _icon(100 + 420 * c, 200 + 240 * r)
return body
@staticmethod
def _diagram_page(seed: int) -> str:
return (f'<path d="M 100 {300 + seed} L 500 200 L 900 400" '
f'stroke="#C00000" fill="none"/>' + _text(60, 100))
def test_four_same_icon_grids_is_error(self):
pages = {f"{i:02d}_g.svg": self._icon_grid_page() for i in range(1, 5)}
pages["05_d.svg"] = self._diagram_page(1)
pages["06_e.svg"] = self._diagram_page(2)
checker, out = self._run_deck(pages)
self.assertIn("[ERROR] Layout monotony", out)
self.assertGreaterEqual(checker.summary["errors"], 1)
def test_three_same_icon_grids_warns(self):
pages = {f"{i:02d}_g.svg": self._icon_grid_page() for i in range(1, 4)}
pages["04_d.svg"] = self._diagram_page(1)
pages["05_e.svg"] = self._diagram_page(2)
pages["06_f.svg"] = self._diagram_page(3)
pages["07_h.svg"] = self._diagram_page(4)
_, out = self._run_deck(pages)
self.assertIn("[WARN] Layout monotony", out)
def test_varied_deck_no_monotony(self):
pages = {"01_g.svg": self._icon_grid_page(),
"02_g.svg": self._icon_grid_page()}
for i in range(3, 8):
pages[f"{i:02d}_d.svg"] = self._diagram_page(i)
_, out = self._run_deck(pages)
self.assertNotIn("Layout monotony", out)
if __name__ == "__main__":
unittest.main()

View File

@ -18,30 +18,13 @@ export function normalizeProgressStep(step) {
return { id, title, status }; return { id, title, status };
} }
// The checklist is a linear progress bar: work advances top-to-bottom. Models
// don't always send a `completed` update before moving the in_progress marker
// on (observed: later steps marked done while earlier ones dangle at
// in_progress), which renders as "green check below, red dot above". Enforce
// monotonic completion — any step before the last completed one is completed
// too — so a missed update self-heals instead of stranding earlier steps.
export function enforceMonotonicProgress(steps) {
if (!Array.isArray(steps)) return [];
let lastCompleted = -1;
for (let i = 0; i < steps.length; i++) {
if (steps[i] && steps[i].status === "completed") lastCompleted = i;
}
if (lastCompleted <= 0) return steps.map(s => ({ ...s }));
return steps.map((s, i) => (i < lastCompleted ? { ...s, status: "completed" } : { ...s }));
}
export function applyProgressAction(progress, args) { export function applyProgressAction(progress, args) {
const current = cloneProgressSteps(progress); const current = cloneProgressSteps(progress);
if (!args || typeof args !== "object") return current; if (!args || typeof args !== "object") return current;
const action = args.action || ""; const action = args.action || "";
if (action === "clear") return []; if (action === "clear") return [];
if (action === "set_plan") { if (action === "set_plan") {
const planned = Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : []; return Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : [];
return enforceMonotonicProgress(planned);
} }
if (action === "update_step") { if (action === "update_step") {
const raw = args.step; const raw = args.step;
@ -65,7 +48,7 @@ export function applyProgressAction(progress, args) {
status: normalizeProgressStatus(raw.status), status: normalizeProgressStatus(raw.status),
}); });
} }
return enforceMonotonicProgress(next); return next;
} }
return current; return current;
} }