@caddis/cli 0.4.58 → 0.4.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,11 @@
3
3
  `caddis_roadmap.py render --roadmap <slug> [--out <file>]` writes it (default `~/.caddis/roadmap-pages/<repo>/<slug>.html`,
4
4
  outside every checkout). Plan: `.caddis/plans/roadmap-page.md` (owner decisions, 17 Oct).
5
5
 
6
- The hub: each default render also writes `<slug>.json` (one summary row) beside the page, and rebuilds
7
- `~/.caddis/roadmap-pages/index.html` from every summary on this machine, one section per repo. The hub links to
8
- the pages by relative path, so it works as a local file and behind a static web server (Phase 4).
6
+ The hub: each default render writes `<slug>.body.html` (the page body) and `<slug>.json` (one summary row), and
7
+ rebuilds `~/.caddis/roadmap-pages/index.html` from every summary on this machine. `index.html` is the whole app: the
8
+ inbox, the gates, sessions and runners views, and one view per roadmap (its body). CSS shows one view at a time from
9
+ the URL's `#view`, so there is one nav and it is never stale. `<slug>.html` is a one-line redirect to its view. It
10
+ works as a local file and behind a static web server.
9
11
 
10
12
  Sections, in order: Waiting for you (sessions that wait for input, and open owner-gate blockers), Plan on a page (a
11
13
  swimlane: plans as lanes, milestones as columns, one tile per phase; open by default), then collapsed sections:
@@ -13,7 +15,8 @@ summary, now / next / later, gates and blockers, every milestone with its phase
13
15
 
14
16
  The data is `status()` (roadmap.md and each plan's Tracker on origin), the plan headers (`### Phase <n> - <name>`,
15
17
  `**Model:**`, `**Lane:**`, `**Risk:**`), the runner's local lock and last stop, and the local session-state folder
16
- (`<sid>.waiting` markers from the Notification hook). No script, no network: collapsing uses `<details>`. Every text
18
+ (`<sid>.waiting` markers from the Notification hook). No network, and one script only (id theme-toggle: the light/dark
19
+ toggle, the hub's inbox reload, the phone menu and `aria-current`): collapsing uses `<details>`. Every text
17
20
  from the files is escaped: a plan, a relay or a hook message can hold any text.
18
21
  """
19
22
  from __future__ import annotations
@@ -28,7 +31,6 @@ import re
28
31
  import subprocess
29
32
  import sys
30
33
  import time
31
- import urllib.parse
32
34
  from pathlib import Path
33
35
 
34
36
  PHASE_HEADER = re.compile(r"^###\s+Phase\s+(\d+)[a-z]?\s*[-–—:]\s*(.+?)\s*(?:[✅⏳\U0001F532].*)?$",
@@ -213,21 +215,20 @@ def build(root: Path, slug: str, roadmap_mod, now: datetime.datetime | None = No
213
215
 
214
216
  # ---- rendering -------------------------------------------------------------------------------------------------
215
217
 
216
- _CSS = """
217
- :root{--bg:#f4f6f9;--panel:#fff;--panel2:#f8fafc;--fg:#0f172a;--muted:#475569;--line:#e2e8f0;--line2:#cbd5e1;
218
- --accent:#2563eb;--todo-bg:#f1f5f9;--todo-fg:#334155;--prog:#2563eb;--prog-bg:#dbeafe;--prog-fg:#1d4ed8;
219
- --done:#16a34a;--done-bg:#dcfce7;--done-fg:#15803d;--block:#d97706;--block-bg:#fef3c7;--block-fg:#92400e;
220
- --high:#dc2626;--wait-bg:#fee2e2;--wait-fg:#b91c1c}
221
- @media (prefers-color-scheme:dark){:root{--bg:#0b0f17;--panel:#111827;--panel2:#161f30;--fg:#f1f5f9;--muted:#94a3b8;
222
- --line:#1e293b;--line2:#334155;--accent:#60a5fa;--todo-bg:#1e293b;--todo-fg:#cbd5e1;--prog:#60a5fa;--prog-bg:#172554;
223
- --prog-fg:#93c5fd;--done:#22c55e;--done-bg:#052e16;--done-fg:#86efac;--block:#f59e0b;--block-bg:#451a03;
224
- --block-fg:#fcd34d;--high:#f87171;--wait-bg:#450a0a;--wait-fg:#fca5a5}}
218
+ # One colour set for every page: light = slate + blue, dark = Dracula (owner, 17 Oct): https://draculatheme.com/contribute
219
+ # Dark applies when the OS asks for it, unless the reader picked light; the toggle sets data-theme on <html>.
220
+ _PALETTE_LIGHT = '--bg:#f4f6f9;--panel:#fff;--panel2:#f8fafc;--fg:#0f172a;--muted:#475569;--line:#e2e8f0;--line2:#cbd5e1;--accent:#2563eb;--todo-bg:#f1f5f9;--todo-fg:#334155;--prog:#2563eb;--prog-bg:#dbeafe;--prog-fg:#1d4ed8;--done:#16a34a;--done-bg:#dcfce7;--done-fg:#15803d;--block:#d97706;--block-bg:#fef3c7;--block-fg:#92400e;--high:#dc2626;--wait-bg:#fee2e2;--wait-fg:#b91c1c;--nav:#f8fafc;--nav-fg:#0f172a;--on-accent:#fff;--shadow:0 1px 3px rgba(15,23,42,.06);--title:var(--fg);--summary:var(--fg);--code:var(--fg);--sans:"Maple Mono","Segoe UI Variable","Segoe UI",system-ui,sans-serif;--mono:"JetBrains Mono","Cascadia Mono",Consolas,ui-monospace,monospace;color-scheme:light;'
221
+ _PALETTE_DARK = '--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#6272a4;--line:#44475a;--line2:#6272a4;--accent:#8be9fd;--todo-bg:#44475a;--todo-fg:#f8f8f2;--prog:#bd93f9;--prog-bg:#3c3352;--prog-fg:#bd93f9;--done:#50fa7b;--done-bg:#2b4a36;--done-fg:#50fa7b;--block:#ffb86c;--block-bg:#4a3a2b;--block-fg:#ffb86c;--high:#ff5555;--wait-bg:#4a2b33;--wait-fg:#ff5555;--nav:#21222c;--nav-fg:#f8f8f2;--on-accent:#282a36;--shadow:none;--title:#bd93f9;--summary:#ff79c6;--code:#f1fa8c;color-scheme:dark;'
222
+ _THEME_CSS = (":root{" + _PALETTE_LIGHT + "}" + '@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){'
223
+ + _PALETTE_DARK + "}}" + ':root[data-theme="dark"]{' + _PALETTE_DARK + "}")
224
+
225
+ _BASE_CSS = """
225
226
  *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);
226
- font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}code{font:12px ui-monospace,Consolas,monospace}
227
+ font:14px/1.5 var(--sans)}code{font:12px var(--mono);color:var(--code)}
227
228
  main{max-width:1360px;margin:0 auto;padding:20px 16px 48px;display:grid;gap:16px}main>*{min-width:0}
228
- h1{font-size:24px;margin:0}h2{font-size:16px;margin:0}.sub{color:var(--muted);margin:4px 0 0}
229
+ h1{font-size:24px;margin:0;color:var(--title)}h2{font-size:16px;margin:0}.sub{color:var(--muted);margin:4px 0 0}
229
230
  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
230
- details.card>summary{cursor:pointer;font-weight:700;font-size:16px;list-style:none}
231
+ details.card>summary{cursor:pointer;font-weight:700;font-size:16px;list-style:none;color:var(--summary)}
231
232
  details.card>summary::-webkit-details-marker{display:none}details.card>summary::before{content:"\\25B8 ";color:var(--muted)}
232
233
  details.card[open]>summary::before{content:"\\25BE "}details.card>summary span{color:var(--muted);font-weight:400;font-size:13px}
233
234
  .wait{border-color:var(--high)}.wait h2{color:var(--wait-fg)}.empty{color:var(--muted)}
@@ -242,7 +243,7 @@ padding:1px 8px;border-radius:999px;white-space:nowrap}
242
243
  padding:6px}.lanes .hd{background:var(--panel2);border-top:0;font-weight:700}.lanes .ln{position:sticky;left:0;z-index:1;
243
244
  background:var(--panel);border-left:0;border-right:1px solid var(--line2);font-weight:600;min-width:170px}
244
245
  .tile{display:flex;gap:6px;align-items:center;font-size:12px;padding:3px 6px;margin:0 0 4px;border-radius:5px;
245
- border:1px solid var(--line2);background:var(--panel);max-width:100%}.tile b{font:600 11px ui-monospace,Consolas,monospace}
246
+ border:1px solid var(--line2);background:var(--panel);max-width:100%}.tile b{font:600 11px var(--mono)}
246
247
  .tile span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile.in-progress{background:var(--prog-bg);
247
248
  border-color:var(--prog);color:var(--prog-fg)}.tile.done{background:var(--done-bg);border-color:var(--done);
248
249
  color:var(--done-fg)}.tile.moved{background:repeating-linear-gradient(135deg,var(--done-bg) 0 5px,var(--panel) 5px 10px);
@@ -252,6 +253,47 @@ border-color:var(--done)}.tile.blocked{background:var(--block-bg);border-color:v
252
253
  padding:3px 6px;margin:2px 0;white-space:normal;overflow-wrap:anywhere}.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:var(--muted);margin-top:8px}
253
254
  .cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}@media (max-width:820px){.cols{grid-template-columns:1fr}}
254
255
  .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.kpi b{font-size:22px;display:block}
256
+ .bar{height:8px;border-radius:99px;background:var(--todo-bg);overflow:hidden;min-width:120px;margin:4px 0}
257
+ .bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
258
+ .theme-btn{font:600 12px var(--sans);padding:6px 11px;border-radius:999px;border:1px solid var(--line);background:var(--panel2);color:var(--fg);cursor:pointer}
259
+ .hdr{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
260
+ """
261
+
262
+ _CSS = _THEME_CSS + _BASE_CSS
263
+
264
+ # The one script on a page: the reader's theme choice; on the hub only, a reload every 60 s while the inbox shows
265
+ # (an open roadmap view is never reset); the phone menu closes when the view changes; and the nav item of the
266
+ # shown view gets aria-current. No network.
267
+ _THEME_SCRIPT = ('<script id="theme-toggle">(function(){var k="caddis-roadmap-theme",d=document.documentElement;try{var t=localStorage.getItem(k);if(t==="light"||t==="dark")d.dataset.theme=t}catch(e){}window.caddisTheme=function(){var c=d.dataset.theme||(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),n=c==="dark"?"light":"dark";d.dataset.theme=n;try{localStorage.setItem(k,n)}catch(e){}};function shown(){var h=location.hash.slice(1),e=h&&document.getElementById(h),v=e&&e.closest(".view");return v?v.id:"inbox"}function cur(){var a=document.querySelectorAll(".nav a.nav-i");for(var i=0;i<a.length;i++){if(a[i].getAttribute("href")==="#"+shown())a[i].setAttribute("aria-current","page");else a[i].removeAttribute("aria-current")}}addEventListener("DOMContentLoaded",cur);setInterval(function(){if(document.getElementById("inbox")&&document.visibilityState==="visible"&&shown()==="inbox")location.reload()},60000);addEventListener("hashchange",function(){var b=document.getElementById("nav-cb");if(b)b.checked=false;cur()})})();</script>')
268
+ _THEME_BUTTON = '<button type="button" class="theme-btn" onclick="caddisTheme()" aria-label="Switch between light and dark theme">◐ Theme</button>'
269
+
270
+
271
+ def _fonts_link(base: str) -> str:
272
+ """The one allowed <link>: bundled fonts. If it does not load, the font stacks fall back to system fonts."""
273
+ return f'<link rel="stylesheet" href="{_e(base)}_assets/fonts.css">'
274
+
275
+ _SHELL_CSS = """
276
+ .app{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:100vh}
277
+ .nav{background:var(--nav);color:var(--nav-fg);border-right:1px solid var(--line);padding:14px 10px;position:sticky;top:0;height:100vh;overflow-y:auto}
278
+ .brand{display:block;font-weight:700;font-size:15px;color:var(--title);text-decoration:none;padding:4px 8px 12px}
279
+ .nav-h{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:14px 8px 4px}
280
+ .nav-repo{font-size:12px;font-weight:600;color:var(--muted);margin:10px 8px 2px}
281
+ .nav-i{display:flex;justify-content:space-between;gap:8px;padding:6px 8px;border-radius:7px;color:inherit;text-decoration:none;font-size:13px}
282
+ .nav-i:hover,.nav-i:focus-visible{background:var(--panel2);outline:none}
283
+ .nav-i .n{font-family:var(--mono);font-size:11px;color:var(--muted)}.nav-rm{padding-left:16px}
284
+ .nav-sub{display:grid;padding:2px 0 6px 28px}.nav-sub a{font-size:12px;color:var(--muted);text-decoration:none;padding:3px 0}
285
+ .main{min-width:0}.top{display:flex;align-items:center;gap:12px;padding:10px 20px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:5}
286
+ .crumbs{flex:1;min-width:0;font-size:13px;color:var(--muted)}.crumbs a{color:var(--accent);text-decoration:none}
287
+ main [id]{scroll-margin-top:60px}main>.view{scroll-margin-top:200px}
288
+ .view{display:none}.view:target,.view:has(:target){display:block}main:not(:has(.view:target,.view :target))>#inbox{display:block}
289
+ .nav-sub{display:none}.view-crumbs{font-size:13px;color:var(--muted);margin:0 0 8px}
290
+ .nav-cb{display:none}.nav-btn,.nav-close,.nav-scrim{display:none}
291
+ .nav-cb:focus-visible~.app .nav-btn{outline:2px solid var(--accent);outline-offset:2px}
292
+ @media (max-width:900px){.app{grid-template-columns:1fr}.nav-cb{display:block;position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}.nav{position:fixed;left:0;top:0;z-index:20;width:260px;transform:translateX(-100%);transition:transform .2s}.nav-cb:checked~.app .nav{transform:none}.nav-btn{display:inline-block;cursor:pointer;font-size:18px;padding:0 6px}.nav-close{display:block;text-align:right;cursor:pointer;font-size:18px;padding:2px 8px}.nav-cb:checked~.app .nav-scrim{display:block;position:fixed;inset:0;z-index:15;background:rgba(0,0,0,.4)}}
293
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
294
+ .rcard{display:grid;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;color:inherit;text-decoration:none;box-shadow:var(--shadow)}.rcard:hover,.rcard:focus-visible{border-color:var(--accent);outline:none}
295
+ .dots{display:flex;flex-wrap:wrap;gap:4px}.dot{font:600 10.5px var(--mono);padding:1px 6px;border-radius:5px}
296
+ .kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;box-shadow:var(--shadow)}
255
297
  """
256
298
 
257
299
  _LABEL = {"todo": "to do", "in-progress": "in progress", "done": "done", "moved": "moved", "blocked": "blocked"}
@@ -265,8 +307,100 @@ def _pill(status: str) -> str:
265
307
  return f'<span class="pill s-{_e(status)}">{_e(_LABEL.get(status, status))}</span>'
266
308
 
267
309
 
310
+ def _shell(*, title: str, base: str, nav: str, crumbs: str, body: str, css: str = "") -> str:
311
+ """The app frame around every page: head, left nav, top bar (crumbs and the theme button) and the body.
312
+ `nav`, `crumbs`, `body` and `css` are text the caller has already made safe; `title` is plain text."""
313
+ return "".join([
314
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">',
315
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
316
+ f"<title>{_e(title)}</title>{_fonts_link(base)}<style>{_CSS}{_SHELL_CSS}{css}</style>{_THEME_SCRIPT}</head>",
317
+ f'<body><input type="checkbox" id="nav-cb" class="nav-cb" aria-label="Toggle the navigation menu"><div class="app">{nav}<label for="nav-cb" class="nav-scrim" aria-hidden="true"></label><div class="main">',
318
+ '<header class="top"><label for="nav-cb" class="nav-btn" aria-label="Open the menu">☰</label>',
319
+ f'<div class="crumbs">{crumbs}</div>{_THEME_BUTTON}</header>',
320
+ f"<main>{body}</main></div></div></body></html>\n"])
321
+
322
+
323
+ def _crumbs(folder: str, slug: str) -> str:
324
+ """`Roadmaps › <repo> › <slug>` for a roadmap page; the repo is the folder name without its `-<6hex>` tag."""
325
+ repo = re.sub(r"-[0-9a-f]{6}$", "", folder or "")
326
+ mid = f"{_e(repo)} › " if repo else ""
327
+ return f'<a href="../index.html">Roadmaps</a> › {mid}<strong>{_e(slug)}</strong>'
328
+
329
+
330
+ def _sessions_of(group: list[dict]) -> list[dict]:
331
+ """The newest summary of a checkout has its newest session marks."""
332
+ return max(group, key=lambda r: str(r.get("generated_utc", ""))).get("sessions") or []
333
+
334
+
335
+ def _group_rows(rows: list[dict]) -> tuple[list[tuple[str, list[dict]]], list[tuple[str, list, list]], int]:
336
+ """(order, need, count): the summaries per repo folder in repo-name order; per repo the asking sessions and the
337
+ owner gates (repo, asking, gates), only for repos that have any; and how many items need the owner."""
338
+ by_repo: dict[str, list[dict]] = {}
339
+ for r in rows:
340
+ by_repo.setdefault(r["folder"], []).append(r)
341
+ order = sorted(by_repo.items(), key=lambda kv: (str(kv[1][0].get("repo", "")).lower(), kv[0]))
342
+ need = []
343
+ for _folder, group in order:
344
+ asking = [s for s in _sessions_of(group) if s.get("asking")]
345
+ gates = [(r, g) for r in group for g in r.get("owner_gates") or []]
346
+ if asking or gates:
347
+ need.append((group[0].get("repo"), asking, gates))
348
+ count = sum(len(a) + len(g) for _, a, g in need)
349
+ return order, need, count
350
+
351
+
352
+ def _nav(rows: list[dict], base: str) -> str:
353
+ """The left nav: Needs you (inbox, gates, sessions, runners), then every repo with its roadmaps. Every link opens a
354
+ view of `index.html` (`#inbox`, `#<rid>`, ...); on the hub itself (`base` "") the link is the bare `#view`. The
355
+ active item and its sub-links are shown by `_view_css`, so the nav needs no per-page copy."""
356
+ order, _need, count = _group_rows(rows)
357
+ counts = {"inbox": count, "gates": sum(len(r.get("blockers") or r.get("owner_gates") or []) for r in rows),
358
+ "sessions": sum(len(_sessions_of(group)) for _f, group in order),
359
+ "runners": sum(1 for r in rows if r.get("runner") == "running")}
360
+ hub = f"{base}index.html" if base else ""
361
+ out = ['<nav class="nav" aria-label="Roadmaps">'
362
+ '<label for="nav-cb" class="nav-close" aria-label="Close the menu">✕</label>',
363
+ f'<a class="brand" href="{_e(hub or "#inbox")}">caddis · Roadmaps</a>', '<p class="nav-h">Needs you</p>']
364
+ for key, label in (("inbox", "Inbox"), ("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")):
365
+ out.append(f'<a class="nav-i" href="{_e(hub)}#{key}">{_e(label)}<span class="n">{_e(counts[key])}</span></a>')
366
+ out.append('<p class="nav-h">Repos</p>')
367
+ for folder, group in order:
368
+ idle = sum(1 for s in _sessions_of(group) if not s.get("asking"))
369
+ idle_txt = f" · {idle} idle session(s)" if idle else ""
370
+ out.append(f'<p class="nav-repo">{_e(group[0].get("repo"))}{_e(idle_txt)}</p>')
371
+ for r in sorted(group, key=lambda r: str(r["slug"])):
372
+ rid = _rid(folder, r["slug"])
373
+ if rid is None:
374
+ continue
375
+ total = _int(r.get("phases_total"))
376
+ pct = int(round(100 * _int(r.get("phases_done")) / total)) if total else 0
377
+ out.append(f'<a class="nav-i nav-rm" href="{_e(hub)}#{rid}">{_e(r["slug"])}<span class="n">{pct}%</span></a>'
378
+ f'<div class="nav-sub" data-rid="{rid}">'
379
+ + "".join(f'<a href="{_e(hub)}#{rid}-{anchor}">{_e(label)}</a>' for anchor, label in _PAGE_ANCHORS)
380
+ + "</div>")
381
+ out.append("</nav>")
382
+ return "".join(out)
383
+
384
+
385
+ _ON = "{background:var(--prog-bg);color:var(--prog-fg);font-weight:600}"
386
+
387
+
388
+ def _view_css(rids: list[str]) -> str:
389
+ """Marks the nav item of the view that shows, and opens its sub-links. One rule per roadmap: CSS cannot take
390
+ the target's id into a selector. Every rid comes from `_rid` (hex and slug characters only), so it is safe
391
+ in a selector."""
392
+ shown = ".view:target,.view :target"
393
+ out = [f'body:not(:has({shown})) .nav a[href="#inbox"]{_ON}']
394
+ for key in ("inbox", "gates", "sessions", "runners"):
395
+ out.append(f'body:has(#{key}:target,#{key} :target) .nav a[href="#{key}"]{_ON}')
396
+ for rid in rids:
397
+ on = f"body:has(#{rid}:target,#{rid} :target)"
398
+ out.append(f'{on} .nav a[href="#{rid}"]{_ON}{on} .nav-sub[data-rid="{rid}"]{{display:grid}}')
399
+ return "".join(out)
400
+
401
+
268
402
  def _waiting_section(model: dict) -> str:
269
- out = ['<section class="card wait"><h2>Waiting for you</h2>']
403
+ out = ['<section id="waiting" class="card wait"><h2>Waiting for you</h2>']
270
404
  sessions, gates = model["sessions"], model["waiting"]
271
405
  if not sessions and not gates:
272
406
  out.append('<p class="empty">Nothing is waiting for you.</p></section>')
@@ -296,7 +430,7 @@ def _waiting_section(model: dict) -> str:
296
430
  def _swimlane(model: dict) -> str:
297
431
  milestones, lanes = model["milestones"], model["lanes"]
298
432
  cols = len(milestones)
299
- out = ['<details class="card" open><summary>Plan on a page <span>plans as lanes, milestones as columns in '
433
+ out = ['<details class="card" id="timeline" open><summary>Plan on a page <span>plans as lanes, milestones as columns in '
300
434
  'roadmap order, one tile per phase</span></summary><div class="scroll">',
301
435
  f'<div class="lanes" style="grid-template-columns:170px repeat({cols},190px)">',
302
436
  '<div class="hd ln">Plan / milestone</div>']
@@ -332,7 +466,7 @@ def _summary(model: dict) -> str:
332
466
  high = sum(1 for ph in phases if "high" in ph.get("risk", "").lower())
333
467
  nxt = model.get("next") or {}
334
468
  nxt_txt = f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "none ready"
335
- return ('<details class="card"><summary>Summary <span>progress, current work, next batch</span></summary>'
469
+ return ('<details class="card" id="overview"><summary>Summary <span>progress, current work, next batch</span></summary>'
336
470
  '<div class="kpis">'
337
471
  f'<div class="kpi"><b>{_e(done)} / {_e(len(phases))}</b>phases done</div>'
338
472
  f'<div class="kpi"><b>{_e(model["done"])} / {_e(model["total"])}</b>milestones done</div>'
@@ -370,13 +504,13 @@ def _gates(model: dict) -> str:
370
504
  rows = [(ms["id"], b) for ms in model["milestones"] for b in ms["blockers"]]
371
505
  body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td><td>{_e(b['type'])}</td><td>{_e(b['reason'])}</td>"
372
506
  f"<td><code>{_e(b['check'])}</code></td></tr>" for mid, b in rows)
373
- return (f'<details class="card"><summary>Gates and blockers <span>{_e(len(rows))} open</span></summary>'
507
+ return (f'<details class="card" id="gates"><summary>Gates and blockers <span>{_e(len(rows))} open</span></summary>'
374
508
  + (f'<div class="scroll"><table><tr><th>Milestone</th><th>Type</th><th>Reason</th><th>Check</th></tr>{body}'
375
509
  "</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</details>")
376
510
 
377
511
 
378
512
  def _milestones(model: dict) -> str:
379
- out = ['<details class="card"><summary>Milestones and phases <span>every phase with model, lane, risk and '
513
+ out = ['<details class="card" id="milestones"><summary>Milestones and phases <span>every phase with model, lane, risk and '
380
514
  'Tracker status</span></summary>']
381
515
  for ms in model["milestones"]:
382
516
  rows = "".join(
@@ -420,28 +554,62 @@ def _runner(model: dict) -> str:
420
554
  if nxt:
421
555
  parts.append(f"<p>Next batch: <code>{_e(nxt.get('milestone'))} {_e(nxt.get('plan'))}#{_e(nxt.get('phase'))}"
422
556
  "</code></p>")
423
- return ('<details class="card"><summary>Runner <span>roadmap-run state on this machine</span></summary>'
557
+ return ('<details class="card" id="runner"><summary>Runner <span>roadmap-run state on this machine</span></summary>'
424
558
  + "".join(parts) + "</details>")
425
559
 
426
560
 
561
+ # In page order (Phase 4 moves the overview above the timeline and updates this list).
562
+ _PAGE_ANCHORS = [("waiting", "Waiting"), ("timeline", "Timeline"), ("overview", "Overview"), ("gates", "Gates"),
563
+ ("milestones", "Milestones"), ("runner", "Runner")]
564
+
565
+ def _rid(folder, slug) -> str | None:
566
+ """The view id of a roadmap in `index.html`: `rm-<the folder's 6 hex>-<slug>` (the 6 hex keep two clones with
567
+ one repo name apart), or None when the folder has no `-<6hex>` tag or the slug breaks the
568
+ slug rule (such a row gets no view: its name is not safe in an id or a selector)."""
569
+ m = re.fullmatch(r".+-([0-9a-f]{6})", str(folder or ""))
570
+ if not m or not isinstance(slug, str) or not _SLUG_RE.fullmatch(slug):
571
+ return None
572
+ return f"rm-{m.group(1)}-{slug}"
573
+
574
+
575
+ def _prefix_ids(body: str, rid: str) -> str:
576
+ """Every `id="x"` and `href="#x"` in a roadmap body becomes `<rid>-x`, so two roadmaps in one file never share an
577
+ id. Safe on rendered HTML: every value from the files is escaped, so a `"` in it is `&quot;`."""
578
+ body = re.sub(r' id="([A-Za-z][\w-]*)"', lambda m: f' id="{rid}-{m.group(1)}"', body)
579
+ return re.sub(r' href="#([A-Za-z][\w-]*)"', lambda m: f' href="#{rid}-{m.group(1)}"', body)
580
+
581
+
582
+ def _stub(rid: str, slug: str) -> str:
583
+ """`<slug>.html`: one line that opens the roadmap's view in the hub, so an old link or bookmark still works."""
584
+ target = f"../index.html#{rid}"
585
+ return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
586
+ f'<meta http-equiv="refresh" content="0;url={_e(target)}"><title>Roadmap {_e(slug)}</title></head>'
587
+ f'<body><p><a href="{_e(target)}">Open roadmap {_e(slug)}</a></p></body></html>\n')
588
+
589
+
427
590
  def render_html(model: dict) -> str:
428
- """Self-contained HTML: no script, no network. Every value from the files is escaped."""
591
+ """One self-contained roadmap page (an `--out` copy): the app shell with an empty repo tree, one theme script,
592
+ one local font link, no network. Every value from the files is escaped."""
593
+ slug = model["roadmap"]
594
+ return _shell(title=f"Roadmap {slug}", base="../", nav=_nav([], "../"), crumbs=_crumbs("", slug),
595
+ body=render_body(model))
596
+
597
+
598
+ def render_body(model: dict) -> str:
599
+ """Everything inside `<main>`: the header and the sections. `write_page` keeps it as `<slug>.body.html`; the
600
+ hub puts it, with its ids prefixed, in `index.html` as the roadmap's view."""
429
601
  title = f"Roadmap {model['roadmap']}"
430
602
  phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
431
603
  done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
432
604
  commit = f" · origin <code>{_e(model.get('origin_commit'))}</code>" if model.get("origin_commit") else ""
433
605
  return "".join([
434
- '<!doctype html><html lang="en"><head><meta charset="utf-8">',
435
- '<meta name="viewport" content="width=device-width,initial-scale=1">',
436
- f"<title>{_e(title)}</title><style>{_CSS}</style></head><body><main>",
437
- f'<header><h1>{_e(title)}</h1><p class="sub">{_e(model["done"])} of {_e(model["total"])} milestones · '
438
- f'{_e(done)} of {_e(len(phases))} phases done · branch <code>{_e(model["branch"])}</code>{commit} · '
439
- f"generated {_e(model['generated'])}</p></header>",
606
+ f'<header class="hdr"><div><h1>{_e(title)}</h1><p class="sub">{_e(model["done"])} of {_e(model["total"])} '
607
+ f'milestones · {_e(done)} of {_e(len(phases))} phases done · branch <code>{_e(model["branch"])}</code>'
608
+ f"{commit} · generated {_e(model['generated'])}</p></div></header>",
440
609
  _waiting_section(model), _swimlane(model), _summary(model), _now_next_later(model), _gates(model),
441
610
  _milestones(model), _runner(model),
442
611
  '<p class="empty">Built from roadmap.md, the plan Trackers and headers on origin, the runner state and the '
443
- "session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>",
444
- "</main></body></html>\n"])
612
+ "session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>"])
445
613
 
446
614
 
447
615
  def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
@@ -454,20 +622,26 @@ def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
454
622
 
455
623
 
456
624
  def write_page(root: Path, slug: str, out: Path | None, roadmap_mod) -> Path | None:
457
- """The page. At the default path it also writes `<slug>.json`, the one-row summary the hub is built from; an
458
- `--out` page is a one-off copy and stays out of the hub. None: a newer caddis wrote this page, so it is kept."""
625
+ """At the default path: `<slug>.body.html` (the page body, which the hub shows as a view of `index.html`), a
626
+ one-line `<slug>.html` that opens that view, and `<slug>.json` (the one-row summary the hub is built from). An
627
+ `--out` page is a one-off self-contained copy and stays out of the hub. None: a newer caddis wrote this page,
628
+ so it is kept."""
459
629
  path = Path(out) if out else default_page_path(root, slug)
460
630
  if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
461
631
  return None
462
632
  model = build(root, slug, roadmap_mod)
463
633
  path.parent.mkdir(parents=True, exist_ok=True)
464
- _write_atomic(path, render_html(model))
465
- if out is None:
466
- try: # the summary feeds the hub only: the page is written, so a failure here never fails the render
467
- _write_atomic(path.with_suffix(".json"),
468
- json.dumps(page_summary(model, main_checkout(root), path), indent=1))
469
- except Exception as exc:
470
- print(f"[ROADMAP] hub summary not written: {exc}", file=sys.stderr)
634
+ if out is not None:
635
+ _write_atomic(path, render_html(model))
636
+ return path
637
+ _write_atomic(path.with_suffix(".body.html"), render_body(model))
638
+ rid = _rid(path.parent.name, model["roadmap"])
639
+ _write_atomic(path, _stub(rid, model["roadmap"]) if rid else render_html(model))
640
+ try: # the summary feeds the hub only: the page is written, so a failure here never fails the render
641
+ _write_atomic(path.with_suffix(".json"),
642
+ json.dumps(page_summary(model, main_checkout(root), path), indent=1))
643
+ except Exception as exc:
644
+ print(f"[ROADMAP] hub summary not written: {exc}", file=sys.stderr)
471
645
  return path
472
646
 
473
647
 
@@ -556,6 +730,33 @@ def _write_atomic(path: Path, text: str) -> None:
556
730
  pass
557
731
 
558
732
 
733
+ ASSET_DIR = Path(__file__).resolve().parent / "roadmap_assets"
734
+ ASSET_FILES = ("fonts.css", "OFL-MapleMono.txt", "OFL-JetBrainsMono.txt")
735
+
736
+
737
+ def install_assets(root_dir: Path) -> int:
738
+ """Copy the bundled fonts (and their licences) to `<root_dir>/_assets/` when missing or changed. Text is compared
739
+ as text, not bytes: `_write_atomic` writes CRLF on Windows, so a byte compare would rewrite every time. A source
740
+ file that is not shipped is skipped. Returns the number of files written."""
741
+ written = 0
742
+ target_dir = root_dir / "_assets"
743
+ for name in ASSET_FILES:
744
+ src = ASSET_DIR / name
745
+ if not src.exists():
746
+ continue
747
+ text = src.read_text(encoding="utf-8")
748
+ dst = target_dir / name
749
+ try:
750
+ if dst.read_text(encoding="utf-8") == text:
751
+ continue
752
+ except FileNotFoundError:
753
+ pass
754
+ target_dir.mkdir(parents=True, exist_ok=True)
755
+ _write_atomic(dst, text)
756
+ written += 1
757
+ return written
758
+
759
+
559
760
  def pages_root(home: Path | None = None) -> Path:
560
761
  return Path(home or Path.home()) / ".caddis" / "roadmap-pages"
561
762
 
@@ -570,7 +771,7 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
570
771
  else f"stopped: {stop.get('reason')}" if stop.get("reason") else "")
571
772
  nxt = model.get("next") or {}
572
773
  return {
573
- "version": 1, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
774
+ "version": 2, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
574
775
  "branch": model["branch"], "origin_commit": model.get("origin_commit", ""),
575
776
  "generated_utc": model["generated"],
576
777
  "milestones_done": model["done"], "milestones_total": model["total"],
@@ -583,19 +784,32 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
583
784
  for s in model["sessions"]],
584
785
  "runner": runner,
585
786
  "next": f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "",
787
+ "milestones": [{"id": ms["id"], "status": ms["status"]} for ms in model["milestones"]],
788
+ "blockers": [{"milestone": ms["id"], "type": b["type"], "reason": b["reason"], "check": b["check"]}
789
+ for ms in model["milestones"] for b in ms["blockers"]
790
+ if ms["status"] not in ("done", "not-needed")],
791
+ "runner_detail": {"lock": r.get("lock"), "stale_lock": bool(r.get("stale_lock")),
792
+ "last_stop": r.get("last_stop"), "errors": r.get("errors", [])},
586
793
  }
587
794
 
588
795
 
796
+ # The roadmap slug rule, copied from caddis_roadmap.SLUG_RE_TEXT (a test keeps the two equal): this module stays
797
+ # importable on its own, without the roadmap module and its git helpers. A slug never holds a dot, so `<slug>.body.html` can never be another
798
+ # roadmap's `<slug>.html`; files whose name breaks the rule are never pruned or shown as a view.
799
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
800
+
801
+
589
802
  def prune_folder(folder: Path, keep: set[str]) -> int:
590
- """Remove the page and summary of each roadmap that is no longer on origin. Only `<slug>.json` files and their
591
- `<slug>.html` pages are touched. Returns how many roadmaps were removed."""
803
+ """Remove the page, body and summary of each roadmap that is no longer on origin. Only `<slug>.json` files and
804
+ their `<slug>.html` and `<slug>.body.html` files are touched. Returns how many roadmaps were removed."""
592
805
  removed = 0
593
806
  if not folder.is_dir():
594
807
  return removed
595
808
  for js in folder.glob("*.json"):
596
- if js.stem in keep or _newer_owner(js)[1]: # a live newer caddis may know a roadmap this one cannot list
809
+ # a live newer caddis may know a roadmap this one cannot list
810
+ if js.stem in keep or not _SLUG_RE.fullmatch(js.stem) or _newer_owner(js)[1]:
597
811
  continue
598
- for f in (js, js.with_suffix(".html")):
812
+ for f in (js, js.with_suffix(".html"), js.with_suffix(".body.html")):
599
813
  try:
600
814
  f.unlink()
601
815
  except FileNotFoundError:
@@ -619,15 +833,39 @@ def read_summaries(root_dir: Path) -> tuple[list[dict], list[str]]:
619
833
  data["page"] = f"{js.parent.name}/{js.with_suffix('.html').name}" # the link comes from the disk, not the file
620
834
  data["folder"] = js.parent.name
621
835
  data.setdefault("repo", js.parent.name.rsplit("-", 1)[0])
622
- for key in ("sessions", "owner_gates"): # a hand-edited summary must not stop the hub for every repo
836
+ for key in ("sessions", "owner_gates", "milestones", "blockers"): # a hand-edited summary must not stop the hub for every repo
623
837
  items = data.get(key)
624
838
  data[key] = [i for i in items if isinstance(i, dict)] if isinstance(items, list) else []
625
839
  if not isinstance(data.get("in_progress"), list):
626
840
  data["in_progress"] = []
841
+ if not isinstance(data.get("runner_detail"), dict):
842
+ data["runner_detail"] = {}
627
843
  rows.append(data)
628
844
  return rows, errors
629
845
 
630
846
 
847
+ def read_bodies(root_dir: Path, rows: list[dict]) -> dict[str, str]:
848
+ """{rid: body} for every row with a view id and a `<slug>.body.html`, read only. The path comes from
849
+ `row["page"]` (set from the disk by `read_summaries`), never from the summary's own text, and only a file
850
+ directly in a `<root_dir>/<folder>/` is read. One row that fails never stops the others."""
851
+ root = Path(root_dir).resolve()
852
+ bodies: dict[str, str] = {}
853
+ for row in rows:
854
+ rel, slug = row.get("page"), row.get("slug")
855
+ rid = _rid(row.get("folder"), slug)
856
+ if rid is None or not isinstance(rel, str) or slug != Path(rel).stem:
857
+ continue
858
+ try:
859
+ page = (root / rel).resolve()
860
+ body_file = page.with_suffix(".body.html")
861
+ if page.parent.parent != root or page.suffix != ".html" or not body_file.is_file():
862
+ continue
863
+ bodies[rid] = body_file.read_text(encoding="utf-8")
864
+ except Exception as exc: # noqa: BLE001 - one bad row never stops the others (docstring)
865
+ print(f"[ROADMAP] {Path(rel).with_suffix('').as_posix()}: body not read: {exc}", file=sys.stderr)
866
+ return bodies
867
+
868
+
631
869
  def _age_h(utc_text: str, now: datetime.datetime) -> float | None:
632
870
  try:
633
871
  when = datetime.datetime.strptime(utc_text, "%Y-%m-%d %H:%M UTC").replace(tzinfo=datetime.timezone.utc)
@@ -642,19 +880,6 @@ def _bar(done: int, total: int) -> str:
642
880
  f'<span class="empty">{_e(done)}/{_e(total)} phases · {pct}%</span>')
643
881
 
644
882
 
645
- # The hub's dark theme is Dracula (owner, 17 Oct): https://draculatheme.com/contribute - background #282a36,
646
- # current line #44475a, foreground #f8f8f2, comment #6272a4, cyan, green, orange, pink, purple, red, yellow.
647
- _HUB_CSS = _CSS + """
648
- @media (prefers-color-scheme:dark){:root{--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#6272a4;
649
- --line:#44475a;--line2:#6272a4;--accent:#8be9fd;--todo-bg:#44475a;--todo-fg:#f8f8f2;--prog:#bd93f9;--prog-bg:#3c3352;
650
- --prog-fg:#bd93f9;--done:#50fa7b;--done-bg:#2b4a36;--done-fg:#50fa7b;--block:#ffb86c;--block-bg:#4a3a2b;
651
- --block-fg:#ffb86c;--high:#ff5555;--wait-bg:#4a2b33;--wait-fg:#ff5555}
652
- h1{color:#bd93f9}.card summary{color:#ff79c6}code{color:#f1fa8c}}
653
- .bar{height:8px;border-radius:99px;background:var(--todo-bg);overflow:hidden;min-width:120px;margin:4px 0}
654
- .bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
655
- """
656
-
657
-
658
883
  def _int(value) -> int:
659
884
  try:
660
885
  return int(value)
@@ -662,30 +887,27 @@ def _int(value) -> int:
662
887
  return 0
663
888
 
664
889
 
665
- def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> str:
666
- """Self-contained HTML, no script. It reloads itself every 60 s, so an open tab follows the renders."""
667
- by_repo: dict[str, list[dict]] = {}
668
- for r in rows:
669
- by_repo.setdefault(r["folder"], []).append(r)
670
-
671
- def sessions_of(group): # the newest summary of a checkout has its newest session marks
672
- return max(group, key=lambda r: str(r.get("generated_utc", ""))).get("sessions") or []
673
-
674
- order = sorted(by_repo.items(), key=lambda kv: (str(kv[1][0].get("repo", "")).lower(), kv[0]))
675
- need = []
676
- for _folder, group in order:
677
- asking = [s for s in sessions_of(group) if s.get("asking")]
678
- gates = [(r, g) for r in group for g in r.get("owner_gates") or []]
679
- if asking or gates:
680
- need.append((group[0].get("repo"), asking, gates))
681
- count = sum(len(a) + len(g) for _, a, g in need)
682
- out = ['<!doctype html><html lang="en"><head><meta charset="utf-8">',
683
- '<meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="60">',
684
- f"<title>Roadmaps</title><style>{_HUB_CSS}</style></head><body><main>",
685
- f'<header><h1>Roadmaps</h1><p class="sub">{_e(len(by_repo))} repos · {_e(len(rows))} roadmaps · '
686
- f'{_e(count)} item(s) need you · generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))} · '
687
- "reloads every minute</p></header>",
688
- '<section class="card wait"><h2>Needs you</h2>']
890
+ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
891
+ bodies: dict[str, str] | None = None) -> str:
892
+ """The whole app in one file: the inbox and overview view, then one view per roadmap (its body from `bodies`,
893
+ ids prefixed). CSS shows one view at a time, so the nav is the same for every view and is never stale. While the
894
+ inbox shows, the page reloads itself every 60 s (the script), so an open tab follows the renders."""
895
+ bodies = bodies or {}
896
+ errors = list(errors)
897
+ order, need, count = _group_rows(rows)
898
+ phases_done = sum(_int(r.get("phases_done")) for r in rows)
899
+ phases_total = sum(_int(r.get("phases_total")) for r in rows)
900
+ live = sum(1 for r in rows if r.get("runner") == "running")
901
+ blocked = sum(_int(r.get("blocked")) for r in rows)
902
+ out = ['<section class="view" id="inbox"><section class="kpis">',
903
+ f'<div class="kpi"><b>{_e(count)}</b>Needs you</div>',
904
+ f'<div class="kpi"><b>{_e(phases_done)} / {_e(phases_total)}</b>Phases done'
905
+ f'{_bar(phases_done, phases_total)}</div>',
906
+ f'<div class="kpi"><b>{_e(blocked)}</b>Blocked phases</div>',
907
+ f'<div class="kpi"><b>{_e(live)}</b>Runners live</div></section>',
908
+ f'<p class="sub">{_e(len(order))} repos · {_e(len(rows))} roadmaps · {_e(count)} item(s) need you · '
909
+ f'generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))} · the inbox reloads every minute</p>',
910
+ '<section id="needs" class="card wait"><h2>Needs you</h2>']
689
911
  if not need:
690
912
  out.append('<p class="empty">Nothing is waiting for you.</p>')
691
913
  else:
@@ -699,35 +921,51 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> s
699
921
  out.append(f"<tr><td><strong>{_e(repo)}</strong></td><td>owner gate {_e(r.get('slug'))} "
700
922
  f"{_e(g.get('milestone'))}</td><td>{_e(g.get('reason'))}</td></tr>")
701
923
  out.append("</table></div>")
702
- out.append("</section>")
924
+ out.append("</section><h2>Roadmaps</h2>")
703
925
  if not rows:
704
926
  out.append('<p class="card empty">No roadmap page has been rendered on this machine yet. Run '
705
927
  "<code>caddis_roadmap.py render --all</code> in a repo that has <code>.caddis/roadmaps/</code>.</p>")
706
- for _folder, group in order:
707
- first = group[0]
708
- idle = sum(1 for s in sessions_of(group) if not s.get("asking"))
709
- idle_txt = f" · {idle} idle session(s)" if idle else ""
710
- out.append(f'<details class="card" open><summary>{_e(first.get("repo"))} <span>{_e(len(group))} roadmap(s)'
711
- f'{_e(idle_txt)} · <code>{_e(first.get("checkout", ""))}</code></span></summary>'
712
- '<div class="scroll"><table><tr><th>Roadmap</th><th>Progress</th><th>Milestones</th>'
713
- "<th>In progress</th><th>Blocked</th><th>Runner</th><th>Next</th><th>Updated</th></tr>")
714
- for r in sorted(group, key=lambda r: str(r["slug"])):
715
- age = _age_h(str(r.get("generated_utc", "")), now)
716
- old = " (not refreshed)" if age is not None and age > HUB_STALE_H else ""
717
- href = _e(urllib.parse.quote(r["page"]))
718
- out.append(f'<tr><td><a href="{href}"><strong>{_e(r["slug"])}</strong></a></td>'
719
- f'<td>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))}</td>'
720
- f'<td class="num">{_e(r.get("milestones_done"))} / {_e(r.get("milestones_total"))}</td>'
721
- f'<td>{_e(", ".join(map(str, r.get("in_progress") or [])) or "-")}</td>'
722
- f'<td class="num">{_e(_int(r.get("blocked")))}</td><td>{_e(r.get("runner") or "-")}</td>'
723
- f'<td><code>{_e(r.get("next") or "-")}</code></td>'
724
- f'<td class="num">{_e(r.get("generated_utc"))}{_e(old)}</td></tr>')
725
- out.append("</table></div></details>")
928
+ out.append('<div class="cards">')
929
+ for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
930
+ age = _age_h(str(r.get("generated_utc", "")), now)
931
+ old = " (not refreshed)" if age is not None and age > HUB_STALE_H else ""
932
+ dots = "".join(f'<span class="dot s-{_e(m.get("status"))}">{_e(m.get("id"))}</span>'
933
+ for m in r.get("milestones") or [])
934
+ rid = _rid(r.get("folder"), r.get("slug"))
935
+ if rid is None:
936
+ errors.append(f"{r.get('folder')}/{r.get('slug')}: the folder or slug name breaks the naming rule")
937
+ continue
938
+ out.append(f'<a class="rcard" href="#{rid}">'
939
+ f'<div><strong>{_e(r["slug"])}</strong> <span class="empty">{_e(r.get("repo"))} · '
940
+ f'{_e(r.get("branch"))} · {_e(r.get("origin_commit"))}</span></div>'
941
+ f'<div>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))} '
942
+ f'{_e(r.get("milestones_done"))}/{_e(r.get("milestones_total"))} milestones</div>'
943
+ f'<div class="dots">{dots}</div>'
944
+ f'<div class="empty">next: {_e(r.get("next") or "-")} · runner: {_e(r.get("runner") or "idle")}'
945
+ f'{_e(old)}</div></a>')
946
+ out.append("</div>")
726
947
  for err in errors:
727
948
  out.append(f'<p class="empty">Could not read {_e(err)}.</p>')
728
949
  out.append('<p class="empty">Built from the page summaries in this folder. Every roadmap page render rebuilds it.'
729
- "</p></main></body></html>\n")
730
- return "".join(out)
950
+ "</p></section>")
951
+ rids = []
952
+ for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
953
+ rid = _rid(r.get("folder"), r.get("slug"))
954
+ if rid is None:
955
+ continue
956
+ rids.append(rid)
957
+ body = bodies.get(rid)
958
+ inner = (_prefix_ids(body, rid) if body is not None else
959
+ f'<p class="card empty">No page body yet for {_e(r["slug"])}: run <code>caddis_roadmap.py render</code> '
960
+ "in its repo.</p>")
961
+ out.append(f'<section class="view" id="{rid}"><p class="view-crumbs"><a href="#inbox">Roadmaps</a> › '
962
+ f'{_e(r.get("repo"))} › <strong>{_e(r["slug"])}</strong></p>{inner}</section>')
963
+ for key, label in (("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")): # Phase 6 fills these
964
+ out.append(f'<section class="view" id="{key}"><h2>{label}</h2><p class="card empty">The cross-repo {key} '
965
+ 'view comes in a later update. For now, see <a href="#needs">Needs you</a> on the inbox and each '
966
+ "roadmap's own page.</p></section>")
967
+ return _shell(title="Roadmaps", base="", nav=_nav(rows, ""), crumbs="Roadmaps", body="".join(out),
968
+ css=_view_css(rids))
731
969
 
732
970
 
733
971
  def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path | None:
@@ -743,8 +981,17 @@ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) ->
743
981
  theirs, live = "", False
744
982
  if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
745
983
  return None
984
+ try:
985
+ install_assets(root_dir)
986
+ except Exception as exc: # a missing font file must never stop the hub
987
+ print(f"[ROADMAP] assets not installed: {exc}", file=sys.stderr)
746
988
  rows, errors = read_summaries(root_dir)
989
+ try: # a hub without the roadmap bodies is better than no hub
990
+ bodies = read_bodies(root_dir, rows)
991
+ except Exception as exc:
992
+ print(f"[ROADMAP] page bodies not read: {exc}", file=sys.stderr)
993
+ bodies = {}
747
994
  path = root_dir / "index.html"
748
- _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc)))
995
+ _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc), bodies))
749
996
  _write_atomic(stamp, generator_version() + "\n")
750
997
  return path