diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a253d7..ce2ff135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- fix(progress-report): **the top bar is one group of section links (Stages, Build, Pages, Tips) instead of stage chips plus links; the page carries one `id=stamp`, and the header drops the build-plan.html note.** The sticky bar loses the `Stage P 0 … 6` chips, which repeated the stage list right below it, and gains a `Stages` link to that list; the `#stage-N` card anchors stay for links from other pages, and the header now states the current stage. Five elements shared `id=stamp` (invalid HTML); the "Last updated" span keeps it and the others get their own ids plus a `data-stamp` marker, which `build-plan-status.sh --refresh` now reads (with `id=stamp`, so pages written before this still compare) to leave an unchanged page alone. The filter now depends on both scripts: a project whose `bin/build-plan-status.sh` is locally edited keeps the old `id=stamp` filter after sync, so the page is rewritten on every refresh; such a project needs `sync-project.sh --only-bin build-plan-status.sh --upgrade-bin build-plan-status.sh` alongside this. "replaces the former build-plan.html" is gone from the header sub-line. Fixes #479 items 1–3 — MendixMau (review 2026-10-20) - fix(progress-report): **the build plan is shown once: the phase cards are the plan.** Expanding a card now shows its full rows (#, Kind, Step, Produces/Proves, Depends on, State, Scripts) and Claims, which only the duplicate "Full plan tables" block carried; that block is gone and views A (script folders) and B (module proof) are their own section. The `now` phase renders open with a selected style, opening another phase closes the previous one (accordion; `#phase-N` links still open their card), and a viewer's saved open/closed state survives the 30 s reload and wins over the default (#474) — field feedback from a requirements-driven project at Stage 5 — MendixMau (field feedback 2026-10-20) ## v2026.10.19 diff --git a/project-bin/build-plan-status.sh b/project-bin/build-plan-status.sh index c7f1ac32..b450ca84 100755 --- a/project-bin/build-plan-status.sh +++ b/project-bin/build-plan-status.sh @@ -739,8 +739,10 @@ if [ "$WRITE_HTML" -eq 1 ]; then if "$PY" "$GEN" "$ROOT" "${GEN_ARGS[@]}" >/dev/null 2>"$OUT.err.$$"; then # Two renders of the same state differ only in the stamp lines. A refresh that changed # nothing leaves the file alone, or every exec.sh run would dirty the working tree. + # Stamp lines carry a data-stamp attribute (one of them also id=stamp); 'id=stamp' is kept in + # the pattern so a page written before #479, with id=stamp on every such line, still compares. if [ "$REFRESH" -eq 1 ] && [ -f "$OUT" ] \ - && [ "$(grep -v 'id=stamp' "$OUT")" = "$(grep -v 'id=stamp' "$OUT_TMP")" ]; then + && [ "$(grep -vE 'id=stamp|data-stamp' "$OUT")" = "$(grep -vE 'id=stamp|data-stamp' "$OUT_TMP")" ]; then rm -f "${OUT_TMP:?}" else mv -f "$OUT_TMP" "$OUT" diff --git a/project-bin/progress-report.py b/project-bin/progress-report.py index 951675d7..5e27a503 100755 --- a/project-bin/progress-report.py +++ b/project-bin/progress-report.py @@ -1392,7 +1392,7 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, health = run_health(cs) return fired[:ADV["max_items"]], quiet + fired[ADV["max_items"]:], health, cs, hidden[0] -JS = "" +JS = "" # ----------------------------------------------------------------- render CSS = """ :root{--bg:#f6f7f9;--card:#fff;--ink:#1c2430;--mut:#667085;--line:#e3e7ee;--accent:#2f6fed;--ok:#1f9d63;--warn:#d98a0b;--bad:#c93c3c;--idle:#d9dde5;--main:#2f6fed;--sub:#8b5cf6;--soft:#eef2fb;--shadow:0 1px 2px rgba(20,30,50,.06)} @@ -1433,10 +1433,10 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, .stale{color:var(--warn)}.r-partial td:last-child{color:var(--warn)}.r-built td:last-child{color:var(--ok)} .stack{display:flex;height:16px;border-radius:5px;overflow:hidden;background:var(--idle)}.stack>i{display:block;height:100%} .note{font-size:12px;color:var(--mut);margin:4px 0} -html{scroll-behavior:smooth}[id^=stage-],[id^=agents-],[id^=phase-],#tips,#pages{scroll-margin-top:56px} +html{scroll-behavior:smooth}[id^=stage-],[id^=agents-],[id^=phase-],#stages,#build-detail,#tips,#pages{scroll-margin-top:56px} .snav{position:sticky;top:0;z-index:20;background:var(--bg);border-bottom:1px solid var(--line);padding:8px 16px;display:flex;gap:6px;align-items:center;flex-wrap:wrap} -.snav b{font-size:12px;color:var(--mut);margin-right:4px}.snav a{min-width:34px;text-align:center;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid var(--line);font-weight:650;font-size:13px;color:var(--ink)} -.snav a.passed{border-color:var(--ok)}.snav a.cur{background:var(--accent);color:#fff;border-color:var(--accent)}.snav a:hover{text-decoration:none;border-color:var(--accent)} +.snav a{min-width:34px;text-align:center;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid var(--line);font-weight:650;font-size:13px;color:var(--ink)} +.snav a:hover{text-decoration:none;border-color:var(--accent)} .hd h3 a{color:inherit}.updated{display:inline-block;margin-top:6px;padding:3px 10px;border-radius:8px;background:var(--soft);font-size:13px;font-weight:600} .layout{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px;align-items:start}.layout.solo{display:block}.content{min-width:0}.side{position:sticky;top:52px;max-height:calc(100vh - 64px);overflow:auto;padding-right:2px}.side h2{margin-top:0}.health{margin-top:8px}.health .hi{font-size:13px;margin-top:3px}.grp{margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--card)}.grp>summary{cursor:pointer;padding:9px 12px;font-size:14px}.grp .speed,.grp>.split,.grp>div,.grp .tip{padding:0 12px 10px}.tip{border-top:1px solid var(--line);padding-top:10px}.tip h3{font-size:13px;margin:0 0 4px}.tip p{margin:0 0 4px;font-size:12.5px}.p-info{background:#8793a6;color:#fff}@media(max-width:1000px){.layout{grid-template-columns:minmax(0,1fr)}.side{position:static;max-height:none}}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px}.p-green{background:#2e9e5b;color:#fff}.p-amber{background:#e0a100;color:#fff}.p-red{background:#d1383d;color:#fff}.pill{padding:1px 9px;margin-left:6px;border-radius:99px;font-size:12px}.speed{margin-top:8px;font-size:13px}.split{display:flex;gap:2px;margin:6px 0}.seg{background:var(--accent);color:#fff;font-size:11px;padding:3px 6px;border-radius:4px;white-space:nowrap;overflow:hidden}.seg:nth-child(2){opacity:.75}.seg:nth-child(3){background:var(--idle);color:var(--ink)}.seg:nth-child(4){opacity:.55}.muted{color:var(--mut);font-size:12px}\n.advl{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:10px;margin-top:8px} .advi{border-left:4px solid var(--warn)}.advi.hi{border-left-color:var(--bad)}.advi .rk{font-size:12px;color:var(--mut)}.advi h3{margin:2px 0 6px}.advi p{margin:4px 0;font-size:13px}.advi .why{color:var(--mut);font-size:12px} @@ -1452,7 +1452,7 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, .pages .tree{margin-top:6px;font-size:13px}.pg{display:grid;grid-template-columns:minmax(120px,220px) minmax(0,1fr);gap:2px 12px;padding:5px 0;border-bottom:1px solid var(--line)}.pg:last-child{border-bottom:0} .pg .dir{font-weight:600;overflow-wrap:anywhere}.pg ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:3px 14px}.pg li{overflow-wrap:anywhere}.pg details{margin:0}.pg details ul{margin-top:4px} footer{margin-top:36px;font-size:13px;color:var(--mut)}footer li{margin:3px 0} -@media(max-width:520px){code,pre,.stage dd,.stage dt{overflow-wrap:anywhere;min-width:0}.stage dl{grid-template-columns:minmax(0,1fr)}.stage dd{text-align:left}.hd .sub{white-space:normal;flex:1 1 100%}.snav{padding:6px 12px;flex-wrap:nowrap;overflow-x:auto;max-width:100%}.snav a{flex:0 0 auto}.snav b{flex:0 0 auto}.updated{max-width:100%;overflow-wrap:anywhere}.advl{grid-template-columns:minmax(0,1fr)}.pg{grid-template-columns:minmax(0,1fr)}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.rail{grid-template-columns:minmax(0,1fr)}main{padding:12px}h1{font-size:20px}.kpi .v{font-size:19px}} +@media(max-width:520px){code,pre,.stage dd,.stage dt{overflow-wrap:anywhere;min-width:0}.stage dl{grid-template-columns:minmax(0,1fr)}.stage dd{text-align:left}.hd .sub{white-space:normal;flex:1 1 100%}.snav{padding:6px 12px;flex-wrap:nowrap;overflow-x:auto;max-width:100%}.snav a{flex:0 0 auto}.updated{max-width:100%;overflow-wrap:anywhere}.advl{grid-template-columns:minmax(0,1fr)}.pg{grid-template-columns:minmax(0,1fr)}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.rail{grid-template-columns:minmax(0,1fr)}main{padding:12px}h1{font-size:20px}.kpi .v{font-size:19px}} """ KC = {"BRIEF": "#8b5cf6", "BUILD": "#2f6fed", "PROVE": "#1f9d63", "HARNESS": "#d98a0b", "RUN": "#c93c3c"} @@ -1516,13 +1516,16 @@ def money(c): return "n/a" if unp else "$%.0f" % c w("") w("") w("Progress report: %s" % (esc(info["name"]), CSS)) - w("
" % ("" if L else " solo")) + # One group of section links (issue #479). The stage list is one link away; the current stage is in the + # header line below, and the #stage-N card anchors stay for links from other pages. + w("
" % ("" if L else " solo")) last_ev = datetime.datetime.fromtimestamp(an["t1"]).strftime("%Y-%m-%d %H:%M") if L else "no session log" w("

Progress report: %s

" % esc(info["name"])) - w("Last updated %s %s session log last event %s%s
" % (gen, datetime.datetime.now().astimezone().strftime("%Z"), last_ev, + w("Last updated %s %s session log last event %s%s
" % (gen, datetime.datetime.now().astimezone().strftime("%Z"), last_ev, " · Live · refreshes every 30 s · Pause" if args.reload else "")) - w("
Stage P to 6 · entry mode %s · PROJECT.md · gate-check page · replaces the former build-plan.html
" % - (esc(info["entry_mode"]), href(os.path.join(proj, "PROJECT.md")), href(os.path.join(proj, "index.html")))) + nowstage = ("Stage %s: %s" % (info["current"], info["current"], esc(STAGE_NAME[info["current"]]))) if info["current"] in STAGE_NAME else "unknown" + w("
Current stage: %s · Stage P to 6 · entry mode %s · PROJECT.md · gate-check page
" % + (nowstage, esc(info["entry_mode"]), href(os.path.join(proj, "PROJECT.md")), href(os.path.join(proj, "index.html")))) how_parts = [] if L and DISCOVERY.get("mention") and not args.session_log: rows = "".join("%s%s%d%%" % (esc(u["id"]), datetime.datetime.fromtimestamp(u["t"]).strftime("%Y-%m-%d"), round(u["share"] * 100)) for u in sorted(DISCOVERY.get("used", []), key=lambda u: u["t"])) @@ -1585,7 +1588,7 @@ def money(c): return "n/a" if unp else "$%.0f" % c parts.append("
%d check(s) ran without needing action; %d more are inherent to how the toolkit and mxcli work (build time, required gates and reviews, waiting) and are never shown.
" % (len(quiet), n_inherent)) aside = "".join(parts) # stage rail - w("

Stage rail: Kickoff (P) to Verify and report (6)

Each card shows gate state, artifact progress%s, decisions and links.%s
" % (", wall time, tokens, sub-agents" if L else "", " Time and token splits per stage are approximate (see footer)." if L else "")) + w("

Stage rail: Kickoff (P) to Verify and report (6)

Each card shows gate state, artifact progress%s, decisions and links.%s
" % (", wall time, tokens, sub-agents" if L else "", " Time and token splits per stage are approximate (see footer)." if L else "")) wins = {s: (a, b) for s, a, b in an["wins"]} for s in ["pre"] + STAGES: sd = S[s] @@ -1746,10 +1749,10 @@ def chip(f, cls=""): w("
" + "".join("" % (k, v, pct(v, bp["total"]), KC.get(k, "#888")) for k, v in kinds.items()) + "
") w("
" + "".join("%s %d" % (KC.get(k, "#888"), k, v) for k, v in kinds.items()) + "
") lb = (facts["last_run"]["t"], facts["last_run"]["script"], facts["last_run"]["result"]) if facts else None - if lb: w("
%s. Last build step: %s (%s), %s at %s.
" % (esc(facts_line(facts)), esc(lb[1][:90]), esc(re.sub(r"[^\w .,/()+-]", "", lb[2])[:40].strip() or "no result"), esc(ago(lb[0])), esc(datetime.datetime.fromtimestamp(lb[0]).strftime("%Y-%m-%d %H:%M")))) - if facts and facts["score"]: w("
Latest model score %.1f (%s).
" % (facts["score"]["score"], esc(facts["score"]["script"][:40]))) + if lb: w("
%s. Last build step: %s (%s), %s at %s.
" % (esc(facts_line(facts)), esc(lb[1][:90]), esc(re.sub(r"[^\w .,/()+-]", "", lb[2])[:40].strip() or "no result"), esc(ago(lb[0])), esc(datetime.datetime.fromtimestamp(lb[0]).strftime("%Y-%m-%d %H:%M")))) + if facts and facts["score"]: w("
Latest model score %.1f (%s).
" % (facts["score"]["score"], esc(facts["score"]["script"][:40]))) if bp.get("note"): w("
%s.
" % esc(bp["note"])) - w("
Kinds are the plan's own Kind column; row state is the plan's State column read by prefix (built or done = built, partial or in progress = partial, anything else = not built). Plan read %s from %s.
" % (esc(bp.get("generated") or "unknown"), "architecture/build-plan.md" if bp.get("source") == "md" else "architecture/build-plan.json")) + w("
Kinds are the plan's own Kind column; row state is the plan's State column read by prefix (built or done = built, partial or in progress = partial, anything else = not built). Plan read %s from %s.
" % (esc(bp.get("generated") or "unknown"), "architecture/build-plan.md" if bp.get("source") == "md" else "architecture/build-plan.json")) w("
") for p in bp["phases"]: now = p is cur @@ -1787,7 +1790,7 @@ def chip(f, cls=""): direct = "progress-report.py <project> " + (" ".join("--session-log %s" % esc(x) for x in args.session_log) if args.session_log else "--logs-dir <dir>") w("
  • Inputs. Project folder: %s. Session log(s) (%s): %s. Rates file: %s. To regenerate with the same inputs: %s, or %s.
  • " % (esc(proj), how, logs_txt, rates_txt, regen, direct)) w("") - w("
    Rebuild: project-bin/build-plan-status.sh --html. Setting in PROJECT.md: Progress report: off, ask or auto (now %s). Generated %s.
    %s
    " % (esc(info["setting"]), gen, aside)) + w("
    Rebuild: project-bin/build-plan-status.sh --html. Setting in PROJECT.md: Progress report: off, ask or auto (now %s). Generated %s.
    %s
    " % (esc(info["setting"]), gen, aside)) w(JS) if args.reload: w("" % ("(function(){\nvar VK='pr-view:'+location.pathname,PK='pr-paused',IDLE=20000,EVERY=30000,last=%d,active=%d,lastInput=0,de=document.documentElement;\nfunction get(k){try{return sessionStorage.getItem(k)}catch(e){return null}}\nfunction put(k,v){try{if(v===null)sessionStorage.removeItem(k);else sessionStorage.setItem(k,v)}catch(e){}}\ntry{history.scrollRestoration='manual'}catch(e){}\nfunction restore(){try{var st=JSON.parse(get(VK)||'null');if(!st)return;\n var d=st.d||{};for(var id in d){var e=document.getElementById(id);if(e&&e.tagName==='DETAILS')e.open=!!d[id]}\n var sb=de.style.scrollBehavior;de.style.scrollBehavior='auto';window.scrollTo(0,st.y||0);de.style.scrollBehavior=sb;\n }catch(e){}}\nrestore();window.addEventListener('load',function(){restore();put(VK,null)});\nfunction save(){var d={};var all=document.querySelectorAll('details[id]');for(var i=0;i/dev/null 2>&1; then node - "$WORK/out14/r.html" <<'JSEOF' && ok "node: state saved and restored, no reload within 20 s of input, Pause stops it" || bad "autoreload behaviour (node)" const fs = require("fs"); diff --git a/tests/wave2/test-progress-report-polish.sh b/tests/wave2/test-progress-report-polish.sh index 59fe2091..4773e87c 100644 --- a/tests/wave2/test-progress-report-polish.sh +++ b/tests/wave2/test-progress-report-polish.sh @@ -16,6 +16,10 @@ # P6b an older progress-report.py without --phase-detail (a partial sync) still renders, with the short row tables. # P6c the page's own scripts, run in node against a DOM shim: the now phase is open on load, opening phase 1 # closes phase 2, a #phase-3 link on load wins over the default, and restored state wins over the default. +# P7 top bar and header (#479): one group of section links (Stages, Build, Pages), no stage chips; the bar stays +# sticky and phone-safe; every #stage-N card anchor stays; the header states the current stage; exactly one +# id=stamp, every other volatile line marked data-stamp, every id on the page unique; the autoreload's +# live controls sit on the id=stamp line; no "replaces the former build-plan.html" in the header. # P3 no session log: one calm line (not the orange box), the regenerate hint inside "How this was measured"; # with --no-log the page never claims it looked in folders or logs. # @@ -147,6 +151,48 @@ assert.deepStrictEqual(load("", saved).open(), ["phase-1"], "restored state wins JSEOF else ok "SKIP: node not available for the accordion behaviour test"; fi +echo "P7 top bar, unique stamp id, header wording (#479)" +P7="$WORK/p7"; mkproj "$P7" "**Stage 5 - Build**, in progress - gates passed: P, 0, 1, 2, 3, 4" +cp "$P1/architecture/build-plan.md" "$P7/architecture/" +mkdir -p "$P7/.claude/loop" # recent activity, so the page carries the autoreload script and its live controls +printf 'ts\tscript\tgate\tresult\telapsed_s\tmxbuild_errors\tlint_rc\tlint_total\trestored\tscore\n%s\t02-billing.mdl\tpass\tapplied\t40\t0\t0\t\t0\t90.0\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$P7/.claude/loop/metrics-ledger.tsv" +CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P7" --html >/dev/null 2>&1 +R7="$P7/architecture/progress-report.html" +[ "$(grep -c 'id=stamp' "$R7")" -le 1 ] && ok "grep -c 'id=stamp' is at most 1 ($(grep -c 'id=stamp' "$R7"))" || bad "id=stamp appears $(grep -c 'id=stamp' "$R7") times" +"$PY" - "$R7" "$ROOT/project-bin/progress-report.py" <<'PYEOF7' && ok "one nav group with Stages, sticky and phone-safe; stage anchors kept; current stage in the header; ids unique; volatile lines marked; autoreload controls on the stamp line" || bad "top bar / stamp / header (P7)" +import re, sys, collections +h = open(sys.argv[1], encoding="utf-8").read(); gen = open(sys.argv[2], encoding="utf-8").read() +nav = re.search(r"", h).group(1) +links = re.findall(r"]*>([^<]+)", nav) +assert [t for _, t in links] == ["Stages", "Build", "Pages"], links # no Tips without a session log +assert not re.search(r"href='#stage-", nav) and "" not in nav, nav # the chip group is gone +assert "id=stages" in h and h.index("id=stages") < h.index("id='stage-P'"), "Stages target sits above the stage cards" +for s_ in ["P", "0", "1", "2", "3", "4", "5", "6"]: assert "id='stage-%s'" % s_ in h, s_ +assert ".snav{position:sticky;top:0" in gen and ".snav{padding:6px 12px;flex-wrap:nowrap;overflow-x:auto" in gen +sm = re.search(r"([^{}]*)\{scroll-margin-top:56px\}", gen).group(1).split(",") +assert "#stages" in sm and "#build-detail" in sm, sm # nav targets clear the sticky bar +assert ".snav b{" not in gen, "no rule for the removed Stage label" +head = h[h.index("
    "):h.index("
    ")] +assert re.search(r"Current stage: Stage 5: [^<]+", head), "current stage in the header" +assert "replaces the former build-plan.html" not in head +ids = re.findall(r"""\bid=['"]?([\w:.-]+)""", h) +dup = [i for i, c in collections.Counter(ids).items() if c > 1]; assert not dup, dup +lines = h.split("\n") +st = [l for l in lines if "id=stamp" in l]; assert len(st) == 1 and "Last updated" in st[0], st +for marker in ("Last updated", "session log last event", "Last build step", "Latest model score", "Plan read", "Rebuild: project-bin"): + for l in lines: + if marker in l and "/dev/null 2>&1 +has "$P7u/architecture/progress-report.html" "Current stage: unknown" "an unreadable current stage reads plain 'unknown', no link to #stage-P" +# --refresh still leaves an unchanged page alone: the volatile lines are found by id=stamp or data-stamp +SUM7="$(cksum < "$R7")"; sleep 1 +CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P7" --refresh >/dev/null 2>&1 +[ "$(cksum < "$R7")" = "$SUM7" ] && ok "--refresh leaves the page byte-identical when only stamp lines would change" || bad "--refresh rewrote an unchanged page" + echo "P2 placeholder failure on a later stage is pending" P2="$WORK/p2"; mkproj "$P2" "**Stage P - Kickoff**, in progress - gates passed: none" cat > "$P2/index.html" <<'HTML'