@caddis/cli 0.4.59 → 0.4.61

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,23 +215,22 @@ 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);--c0:#2563eb;--c1:#7c3aed;--c2:#0d9488;--c3:#ea580c;--c4:#db2777;--c5:#475569;--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;--c0:#8be9fd;--c1:#bd93f9;--c2:#50fa7b;--c3:#ffb86c;--c4:#ff79c6;--c5:#6272a4;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
- details.card[open]>summary::before{content:"\\25BE "}details.card>summary span{color:var(--muted);font-weight:400;font-size:13px}
233
+ details.card[open]>summary::before{content:"\\25BE "}details.card+details.card{margin-top:8px}
233
234
  .wait{border-color:var(--high)}.wait h2{color:var(--wait-fg)}.empty{color:var(--muted)}
234
235
  table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:7px 9px;border-top:1px solid var(--line);
235
236
  vertical-align:top}th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);border-top:0}
@@ -238,20 +239,72 @@ padding:1px 8px;border-radius:999px;white-space:nowrap}
238
239
  .s-todo{background:var(--todo-bg);color:var(--todo-fg)}.s-in-progress{background:var(--prog-bg);color:var(--prog-fg)}
239
240
  .s-done,.s-moved,.s-not-needed{background:var(--done-bg);color:var(--done-fg)}.s-blocked{background:var(--block-bg);color:var(--block-fg)}
240
241
  .s-code-done\\/data-pending{background:var(--block-bg);color:var(--block-fg)}
241
- .lanes{display:grid;width:max-content}.lanes>div{border-top:1px solid var(--line);border-left:1px dashed var(--line);
242
+ .lanes{display:grid}.lanes>div{border-top:1px solid var(--line);border-left:1px dashed var(--line);
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
- background:var(--panel);border-left:0;border-right:1px solid var(--line2);font-weight:600;min-width:170px}
244
- .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
- .tile span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile.in-progress{background:var(--prog-bg);
244
+ background:var(--panel);border-left:4px solid var(--lc,var(--line2));border-right:1px solid var(--line2);font-weight:600;overflow-wrap:anywhere}
245
+ .tile{display:flex;gap:5px;align-items:center;font-size:12px;padding:3px 5px;position:relative;margin:0 0 4px;border-radius:5px;
246
+ border:1px solid var(--line2);background:var(--panel);max-width:100%}.tile b{font:600 11px var(--mono)}
247
+ .tile span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal;overflow-wrap:anywhere}.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);
249
250
  border-color:var(--done)}.tile.blocked{background:var(--block-bg);border-color:var(--block);color:var(--block-fg)}
251
+ .tile.hi::after{position:absolute;top:3px;right:3px;margin:0}
252
+ .ltile{display:flex;gap:8px;align-items:baseline;font-size:13px;padding:5px 8px;margin:0 0 4px;border-radius:6px;border:1px solid var(--line2);background:var(--panel)}.ltile b{font:600 11px var(--mono);white-space:nowrap}
253
+ .ltile.in-progress{background:var(--prog-bg);border-color:var(--prog);color:var(--prog-fg)}.ltile.done{background:var(--done-bg);border-color:var(--done);color:var(--done-fg)}
254
+ .ltile.moved{background:repeating-linear-gradient(135deg,var(--done-bg) 0 5px,var(--panel) 5px 10px);border-color:var(--done)}.ltile.blocked{background:var(--block-bg);border-color:var(--block);color:var(--block-fg)}
255
+ .bcount{display:inline-block;font-size:11px;font-weight:700;color:var(--block-fg);background:var(--block-bg);border:1px dashed var(--block);border-radius:6px;padding:0 6px;margin-top:3px}
256
+ .lane-list{display:none}.lane-list h3{margin:10px 0 6px}@media (max-width:900px){.timeline .scroll{display:none}.lane-list{display:block}}
250
257
  .hi::after{content:"";width:7px;height:7px;border-radius:50%;background:var(--high);flex:none;margin-left:auto}
251
258
  .gate{font-size:11px;color:var(--block-fg);background:var(--block-bg);border:1px dashed var(--block);border-radius:6px;
252
259
  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
260
  .cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}@media (max-width:820px){.cols{grid-template-columns:1fr}}
254
261
  .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.kpi b{font-size:22px;display:block}
262
+ .bar{height:8px;border-radius:99px;background:var(--todo-bg);overflow:hidden;min-width:120px;margin:4px 0}
263
+ .bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
264
+ .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}
265
+ .hdr{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
266
+ .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}.chip{border:1px solid var(--line);border-left:4px solid var(--lc);border-radius:8px;padding:5px 10px;background:var(--panel2);font-size:12.5px}.chip span{color:var(--muted);font-family:var(--mono)}
267
+ .flow{display:flex;gap:6px;align-items:stretch;min-width:max-content}.fnode{display:grid;gap:3px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);text-decoration:none;color:inherit;min-width:120px}.fnode span{font:11px var(--mono);color:var(--muted)}.fnode span.pill{font:700 11px var(--sans);justify-self:start}.arrow{align-self:center;color:var(--muted)}
268
+ .kind{font-size:10.5px;font-weight:700;text-transform:uppercase;padding:2px 8px;border-radius:6px;background:var(--block-bg);color:var(--block-fg)}.k-owner-gate{background:var(--wait-bg);color:var(--wait-fg)}
269
+ .note{color:var(--muted);font-weight:400;font-size:13px}.sec-h{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline;margin-bottom:8px}h3{font-size:14px;margin:0 0 8px}
270
+ .cols .rcard{margin-bottom:8px}.cols .rcard .bar{margin:0}
271
+ """
272
+
273
+ _CSS = _THEME_CSS + _BASE_CSS
274
+
275
+ # The one script on a page: the reader's theme choice; on the hub only, a reload every 60 s while the inbox shows
276
+ # (an open roadmap view is never reset); the phone menu closes when the view changes; and the nav item of the
277
+ # shown view gets aria-current. No network.
278
+ _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>')
279
+ _THEME_BUTTON = '<button type="button" class="theme-btn" onclick="caddisTheme()" aria-label="Switch between light and dark theme">◐ Theme</button>'
280
+
281
+
282
+ def _fonts_link(base: str) -> str:
283
+ """The one allowed <link>: bundled fonts. If it does not load, the font stacks fall back to system fonts."""
284
+ return f'<link rel="stylesheet" href="{_e(base)}_assets/fonts.css">'
285
+
286
+ _SHELL_CSS = """
287
+ .app{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:100vh}
288
+ .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}
289
+ .brand{display:block;font-weight:700;font-size:15px;color:var(--title);text-decoration:none;padding:4px 8px 12px}
290
+ .nav-h{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:14px 8px 4px}
291
+ .nav-repo{font-size:12px;font-weight:600;color:var(--muted);margin:10px 8px 2px}
292
+ .nav-i{display:flex;justify-content:space-between;gap:8px;padding:6px 8px;border-radius:7px;color:inherit;text-decoration:none;font-size:13px}
293
+ .nav-i:hover,.nav-i:focus-visible{background:var(--panel2);outline:none}
294
+ .nav-i .n{font-family:var(--mono);font-size:11px;color:var(--muted)}.nav-rm{padding-left:16px}
295
+ .nav-sub{display:grid;padding:2px 0 6px 28px}.nav-sub a{font-size:12px;color:var(--muted);text-decoration:none;padding:3px 0}
296
+ .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}
297
+ .crumbs{flex:1;min-width:0;font-size:13px;color:var(--muted)}.crumbs a{color:var(--accent);text-decoration:none}
298
+ main [id]{scroll-margin-top:60px}main>.view{scroll-margin-top:200px}
299
+ .view>*+*{margin-top:16px}.view{display:none}.view:target,.view:has(:target){display:block}main:not(:has(.view:target,.view :target))>#inbox{display:block}
300
+ .nav-sub{display:none}.view-crumbs{font-size:13px;color:var(--muted);margin:0 0 8px}
301
+ .nav-cb{display:none}.nav-btn,.nav-close,.nav-scrim{display:none}
302
+ .nav-cb:focus-visible~.app .nav-btn{outline:2px solid var(--accent);outline-offset:2px}
303
+ @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)}}
304
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
305
+ .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}
306
+ .dots{display:flex;flex-wrap:wrap;gap:4px}.dot{font:600 10.5px var(--mono);padding:1px 6px;border-radius:5px}
307
+ .kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;box-shadow:var(--shadow)}
255
308
  """
256
309
 
257
310
  _LABEL = {"todo": "to do", "in-progress": "in progress", "done": "done", "moved": "moved", "blocked": "blocked"}
@@ -265,8 +318,100 @@ def _pill(status: str) -> str:
265
318
  return f'<span class="pill s-{_e(status)}">{_e(_LABEL.get(status, status))}</span>'
266
319
 
267
320
 
321
+ def _shell(*, title: str, base: str, nav: str, crumbs: str, body: str, css: str = "") -> str:
322
+ """The app frame around every page: head, left nav, top bar (crumbs and the theme button) and the body.
323
+ `nav`, `crumbs`, `body` and `css` are text the caller has already made safe; `title` is plain text."""
324
+ return "".join([
325
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">',
326
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
327
+ f"<title>{_e(title)}</title>{_fonts_link(base)}<style>{_CSS}{_SHELL_CSS}{css}</style>{_THEME_SCRIPT}</head>",
328
+ 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">',
329
+ '<header class="top"><label for="nav-cb" class="nav-btn" aria-label="Open the menu">☰</label>',
330
+ f'<div class="crumbs">{crumbs}</div>{_THEME_BUTTON}</header>',
331
+ f"<main>{body}</main></div></div></body></html>\n"])
332
+
333
+
334
+ def _crumbs(folder: str, slug: str) -> str:
335
+ """`Roadmaps › <repo> › <slug>` for a roadmap page; the repo is the folder name without its `-<6hex>` tag."""
336
+ repo = re.sub(r"-[0-9a-f]{6}$", "", folder or "")
337
+ mid = f"{_e(repo)} › " if repo else ""
338
+ return f'<a href="../index.html">Roadmaps</a> › {mid}<strong>{_e(slug)}</strong>'
339
+
340
+
341
+ def _sessions_of(group: list[dict]) -> list[dict]:
342
+ """The newest summary of a checkout has its newest session marks."""
343
+ return max(group, key=lambda r: str(r.get("generated_utc", ""))).get("sessions") or []
344
+
345
+
346
+ def _group_rows(rows: list[dict]) -> tuple[list[tuple[str, list[dict]]], list[tuple[str, list, list]], int]:
347
+ """(order, need, count): the summaries per repo folder in repo-name order; per repo the asking sessions and the
348
+ owner gates (repo, asking, gates), only for repos that have any; and how many items need the owner."""
349
+ by_repo: dict[str, list[dict]] = {}
350
+ for r in rows:
351
+ by_repo.setdefault(r["folder"], []).append(r)
352
+ order = sorted(by_repo.items(), key=lambda kv: (str(kv[1][0].get("repo", "")).lower(), kv[0]))
353
+ need = []
354
+ for _folder, group in order:
355
+ asking = [s for s in _sessions_of(group) if s.get("asking")]
356
+ gates = [(r, g) for r in group for g in r.get("owner_gates") or []]
357
+ if asking or gates:
358
+ need.append((group[0].get("repo"), asking, gates))
359
+ count = sum(len(a) + len(g) for _, a, g in need)
360
+ return order, need, count
361
+
362
+
363
+ def _nav(rows: list[dict], base: str) -> str:
364
+ """The left nav: Needs you (inbox, gates, sessions, runners), then every repo with its roadmaps. Every link opens a
365
+ view of `index.html` (`#inbox`, `#<rid>`, ...); on the hub itself (`base` "") the link is the bare `#view`. The
366
+ active item and its sub-links are shown by `_view_css`, so the nav needs no per-page copy."""
367
+ order, _need, count = _group_rows(rows)
368
+ counts = {"inbox": count, "gates": sum(len(r.get("blockers") or r.get("owner_gates") or []) for r in rows),
369
+ "sessions": sum(len(_sessions_of(group)) for _f, group in order),
370
+ "runners": sum(1 for r in rows if r.get("runner") == "running")}
371
+ hub = f"{base}index.html" if base else ""
372
+ out = ['<nav class="nav" aria-label="Roadmaps">'
373
+ '<label for="nav-cb" class="nav-close" aria-label="Close the menu">✕</label>',
374
+ f'<a class="brand" href="{_e(hub or "#inbox")}">caddis · Roadmaps</a>', '<p class="nav-h">Needs you</p>']
375
+ for key, label in (("inbox", "Inbox"), ("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")):
376
+ out.append(f'<a class="nav-i" href="{_e(hub)}#{key}">{_e(label)}<span class="n">{_e(counts[key])}</span></a>')
377
+ out.append('<p class="nav-h">Repos</p>')
378
+ for folder, group in order:
379
+ idle = sum(1 for s in _sessions_of(group) if not s.get("asking"))
380
+ idle_txt = f" · {idle} idle session(s)" if idle else ""
381
+ out.append(f'<p class="nav-repo">{_e(group[0].get("repo"))}{_e(idle_txt)}</p>')
382
+ for r in sorted(group, key=lambda r: str(r["slug"])):
383
+ rid = _rid(folder, r["slug"])
384
+ if rid is None:
385
+ continue
386
+ total = _int(r.get("phases_total"))
387
+ pct = int(round(100 * _int(r.get("phases_done")) / total)) if total else 0
388
+ out.append(f'<a class="nav-i nav-rm" href="{_e(hub)}#{rid}">{_e(r["slug"])}<span class="n">{pct}%</span></a>'
389
+ f'<div class="nav-sub" data-rid="{rid}">'
390
+ + "".join(f'<a href="{_e(hub)}#{rid}-{anchor}">{_e(label)}</a>' for anchor, label in _PAGE_ANCHORS)
391
+ + "</div>")
392
+ out.append("</nav>")
393
+ return "".join(out)
394
+
395
+
396
+ _ON = "{background:var(--prog-bg);color:var(--prog-fg);font-weight:600}"
397
+
398
+
399
+ def _view_css(rids: list[str]) -> str:
400
+ """Marks the nav item of the view that shows, and opens its sub-links. One rule per roadmap: CSS cannot take
401
+ the target's id into a selector. Every rid comes from `_rid` (hex and slug characters only), so it is safe
402
+ in a selector."""
403
+ shown = ".view:target,.view :target"
404
+ out = [f'body:not(:has({shown})) .nav a[href="#inbox"]{_ON}']
405
+ for key in ("inbox", "gates", "sessions", "runners"):
406
+ out.append(f'body:has(#{key}:target,#{key} :target) .nav a[href="#{key}"]{_ON}')
407
+ for rid in rids:
408
+ on = f"body:has(#{rid}:target,#{rid} :target)"
409
+ out.append(f'{on} .nav a[href="#{rid}"]{_ON}{on} .nav-sub[data-rid="{rid}"]{{display:grid}}')
410
+ return "".join(out)
411
+
412
+
268
413
  def _waiting_section(model: dict) -> str:
269
- out = ['<section class="card wait"><h2>Waiting for you</h2>']
414
+ out = ['<section id="waiting" class="card wait"><h2>Waiting for you</h2>']
270
415
  sessions, gates = model["sessions"], model["waiting"]
271
416
  if not sessions and not gates:
272
417
  out.append('<p class="empty">Nothing is waiting for you.</p></section>')
@@ -296,20 +441,24 @@ def _waiting_section(model: dict) -> str:
296
441
  def _swimlane(model: dict) -> str:
297
442
  milestones, lanes = model["milestones"], model["lanes"]
298
443
  cols = len(milestones)
299
- out = ['<details class="card" open><summary>Plan on a page <span>plans as lanes, milestones as columns in '
300
- 'roadmap order, one tile per phase</span></summary><div class="scroll">',
301
- f'<div class="lanes" style="grid-template-columns:170px repeat({cols},190px)">',
444
+ # The section has the class `timeline` as well as the id: the hub prefixes every id, so the phone CSS selects by class.
445
+ out = ['<section class="card timeline" id="timeline"><div class="sec-h"><h2>Timeline</h2><span class="note">plans as '
446
+ 'lanes, milestones as columns in roadmap order, one tile per phase</span></div><div class="scroll">',
447
+ f'<div class="lanes" style="grid-template-columns:150px repeat({cols},minmax(110px,1fr));width:100%;'
448
+ f'min-width:{150 + cols * 110}px">',
302
449
  '<div class="hd ln">Plan / milestone</div>']
303
450
  for ms in milestones:
304
451
  dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
305
- gates = "".join(f'<div class="gate">{_e(b["type"])}: {_e(b["reason"])}</div>' for b in ms["blockers"])
452
+ n = len(ms["blockers"])
453
+ count = f' <span class="bcount">{n} gate(s)</span>' if n else ""
306
454
  out.append(f'<div class="hd">{_e(ms["id"])} {_pill(ms["status"])}<div class="empty" style="font-weight:400;'
307
- f'font-size:11px">{_e(dep)} · {_e(ms["owner"])}</div>{gates}</div>')
308
- for lane in lanes or ["-"]:
455
+ f'font-size:11px">{_e(dep)}</div>{count}</div>')
456
+ plan_lanes = lanes or ["-"]
457
+ for i, lane in enumerate(plan_lanes):
309
458
  done = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane and ph["tile"] in ("done", "moved"))
310
459
  total = sum(1 for ms in milestones for ph in ms["phases"] if ph["plan"] == lane)
311
- out.append(f'<div class="ln">{_e(lane)}<div class="empty" style="font-weight:400;font-size:11px">'
312
- f'{done}/{total} done</div></div>')
460
+ out.append(f'<div class="ln" style="--lc:var(--c{i % 6})">{_e(lane)}<div class="empty" '
461
+ f'style="font-weight:400;font-size:11px">{done}/{total} done</div></div>')
313
462
  for ms in milestones:
314
463
  tiles = []
315
464
  for ph in ms["phases"]:
@@ -320,8 +469,24 @@ def _swimlane(model: dict) -> str:
320
469
  tiles.append(f'<div class="tile {_e(ph["tile"])}{hi}" title="{_e(title)}"><b>{_e(ph["num"])}</b>'
321
470
  f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
322
471
  out.append(f"<div>{''.join(tiles)}</div>")
323
- out.append('</div></div><div class="legend"><span>colour: to do · <b>in progress</b> · done · striped = moved · '
324
- 'amber = blocked</span><span>red dot = Risk high (stops for review)</span></div></details>')
472
+ if any(ms["blockers"] for ms in milestones): # the blockers get their own lane, so the column headers stay short
473
+ out.append('<div class="ln">Owner and outside</div>')
474
+ for ms in milestones:
475
+ cells = []
476
+ for b in ms["blockers"]:
477
+ reason = str(b["reason"])
478
+ short = reason if len(reason) <= 60 else reason[:60] + "…"
479
+ cells.append(f'<div class="gate" title="{_e(reason)}">{_e(b["type"])}: {_e(short)}</div>')
480
+ out.append(f"<div>{''.join(cells)}</div>")
481
+ out.append('</div></div><div class="lane-list">')
482
+ for ms in milestones: # the phone list: the grid is hidden on a narrow screen
483
+ out.append(f'<h3>{_e(ms["id"])} {_pill(ms["status"])}</h3>')
484
+ for ph in ms["phases"]:
485
+ hi = " hi" if "high" in ph.get("risk", "").lower() else ""
486
+ out.append(f'<div class="ltile {_e(ph["tile"])}{hi}"><b>{_e(ph["plan"])} {_e(ph["num"])}</b>'
487
+ f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
488
+ out.append('</div><div class="legend"><span>colour: to do · <b>in progress</b> · done · striped = moved · '
489
+ 'amber = blocked</span><span>red dot = Risk high (stops for review)</span></div></section>')
325
490
  return "".join(out)
326
491
 
327
492
 
@@ -332,14 +497,29 @@ def _summary(model: dict) -> str:
332
497
  high = sum(1 for ph in phases if "high" in ph.get("risk", "").lower())
333
498
  nxt = model.get("next") or {}
334
499
  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>'
336
- '<div class="kpis">'
500
+ return ('<section class="kpis" id="overview" aria-label="Summary">'
337
501
  f'<div class="kpi"><b>{_e(done)} / {_e(len(phases))}</b>phases done</div>'
338
502
  f'<div class="kpi"><b>{_e(model["done"])} / {_e(model["total"])}</b>milestones done</div>'
339
503
  f'<div class="kpi"><b>{_e(len(building))}</b>in progress: {_e(", ".join(building) or "none")}</div>'
340
504
  f'<div class="kpi"><b>{_e(high)}</b>high-risk phases</div>'
341
505
  f'<div class="kpi"><b>{_e(nxt_txt)}</b>next batch for the runner</div>'
342
- "</div></details>")
506
+ "</section>")
507
+
508
+
509
+ def _done_phases(ms: dict) -> int:
510
+ return sum(1 for ph in ms["phases"] if ph["tile"] in ("done", "moved"))
511
+
512
+
513
+ def _flow(model: dict) -> str:
514
+ """The milestones as a left-to-right chain in roadmap order; each node links to its milestone below."""
515
+ nodes = []
516
+ for ms in model["milestones"]:
517
+ dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
518
+ nodes.append(f'<a class="fnode s-{_e(ms["status"])}" href="#ms-{_e(ms["id"])}"><b>{_e(ms["id"])}</b> '
519
+ f'{_pill(ms["status"])}<span>{_e(dep)} · {_e(_done_phases(ms))}/{_e(len(ms["phases"]))} ph</span></a>')
520
+ chain = '<span class="arrow" aria-hidden="true">→</span>'.join(nodes)
521
+ return (f'<section id="flow"><div class="sec-h"><h2>Flow</h2><span class="note">milestones in roadmap order</span></div>'
522
+ f'<div class="card scroll"><div class="flow">{chain}</div></div></section>')
343
523
 
344
524
 
345
525
  def _now_next_later(model: dict) -> str:
@@ -358,26 +538,30 @@ def _now_next_later(model: dict) -> str:
358
538
  groups["Later"].append(ms)
359
539
  cols = []
360
540
  for title, items in groups.items():
361
- body = "".join(f'<p><strong>{_e(m["id"])}</strong> {_pill(m["status"])} '
362
- f'{_e(sum(1 for p in m["phases"] if p["tile"] in ("done", "moved")))}/{_e(len(m["phases"]))}'
363
- f' phases{"".join(" · " + _e(b["type"]) for b in m["blockers"])}</p>' for m in items)
364
- cols.append(f'<div><h2>{_e(title)}</h2>{body or "<p class=empty>none</p>"}</div>')
365
- return ('<details class="card"><summary>Now, next, later <span>which milestones can start</span></summary>'
366
- f'<div class="cols">{"".join(cols)}</div></details>')
541
+ body = "".join(f'<a class="rcard" href="#ms-{_e(m["id"])}"><span><strong>{_e(m["id"])}</strong> {_pill(m["status"])}</span>'
542
+ f'{_bar(_done_phases(m), len(m["phases"]))}'
543
+ + "".join(f'<span class="note">{_e(b["type"])}</span>' for b in m["blockers"])
544
+ + "</a>" for m in items)
545
+ cols.append(f'<div><h3>{_e(title)}</h3>{body or "<p class=empty>none</p>"}</div>')
546
+ return ('<section id="board"><div class="sec-h"><h2>Now, next, later</h2>'
547
+ '<span class="note">which milestones can start</span></div>'
548
+ f'<div class="cols">{"".join(cols)}</div></section>')
367
549
 
368
550
 
369
551
  def _gates(model: dict) -> str:
370
552
  rows = [(ms["id"], b) for ms in model["milestones"] for b in ms["blockers"]]
371
- body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td><td>{_e(b['type'])}</td><td>{_e(b['reason'])}</td>"
553
+ body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td>"
554
+ f"<td><span class=\"kind k-{_e(b['type'])}\">{_e(b['type'])}</span></td><td>{_e(b['reason'])}</td>"
372
555
  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>'
556
+ return (f'<section class="card" id="gates"><div class="sec-h"><h2>Gates and blockers</h2>'
557
+ f'<span class="note">{_e(len(rows))} open</span></div>'
374
558
  + (f'<div class="scroll"><table><tr><th>Milestone</th><th>Type</th><th>Reason</th><th>Check</th></tr>{body}'
375
- "</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</details>")
559
+ "</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</section>")
376
560
 
377
561
 
378
562
  def _milestones(model: dict) -> str:
379
- out = ['<details class="card"><summary>Milestones and phases <span>every phase with model, lane, risk and '
380
- 'Tracker status</span></summary>']
563
+ out = ['<section id="milestones"><div class="sec-h"><h2>Milestones and phases</h2><span class="note">every phase with '
564
+ 'model, lane, risk and Tracker status</span></div>']
381
565
  for ms in model["milestones"]:
382
566
  rows = "".join(
383
567
  f"<tr><td><code>{_e(ph['ref'])}</code></td><td>{_e(ph['name'])}</td><td>{_e(_short_model(ph['model']))}</td>"
@@ -385,13 +569,13 @@ def _milestones(model: dict) -> str:
385
569
  f"<td>{_e(ph['risk'] or '-')}</td><td>{_pill(ph['tile'])} {_e(ph['tracker'])}</td></tr>"
386
570
  for ph in ms["phases"])
387
571
  target = f" · target {_e(ms['target'])}" if ms.get("target") else ""
388
- out.append(f"<h2 style=\"margin-top:12px\">{_e(ms['id'])} {_pill(ms['status'])} "
389
- f"<span class=\"empty\" style=\"font-weight:400;font-size:13px\">owner {_e(ms['owner'])}{target} · "
390
- f"after {_e(', '.join(ms['depends']) or '-')}</span></h2>")
391
- out.append('<div class="scroll"><table><tr><th>Phase</th><th>Name</th><th>Model</th><th>Lane</th><th>Risk</th>'
572
+ out.append(f'<details class="card" id="ms-{_e(ms["id"])}"><summary>{_e(ms["id"])} '
573
+ f'{_pill(ms["status"])} <span class="note">owner {_e(ms["owner"])}{target} · '
574
+ f'after {_e(", ".join(ms["depends"]) or "-")}</span></summary>'
575
+ '<div class="scroll"><table><tr><th>Phase</th><th>Name</th><th>Model</th><th>Lane</th><th>Risk</th>'
392
576
  f'<th>Tracker</th></tr>{rows or "<tr><td colspan=6 class=empty>no build phases</td></tr>"}'
393
- "</table></div>")
394
- out.append("</details>")
577
+ "</table></div></details>")
578
+ out.append("</section>")
395
579
  return "".join(out)
396
580
 
397
581
 
@@ -420,28 +604,68 @@ def _runner(model: dict) -> str:
420
604
  if nxt:
421
605
  parts.append(f"<p>Next batch: <code>{_e(nxt.get('milestone'))} {_e(nxt.get('plan'))}#{_e(nxt.get('phase'))}"
422
606
  "</code></p>")
423
- return ('<details class="card"><summary>Runner <span>roadmap-run state on this machine</span></summary>'
424
- + "".join(parts) + "</details>")
607
+ return ('<section class="card" id="runner"><div class="sec-h"><h2>Runner</h2>'
608
+ '<span class="note">roadmap-run state on this machine</span></div>' + "".join(parts) + "</section>")
609
+
610
+
611
+ # The sections of a roadmap view, in page order: the nav sub-links. `render_body` writes them in this order.
612
+ _PAGE_ANCHORS = [("waiting", "Waiting"), ("overview", "Overview"), ("flow", "Flow"), ("board", "Board"),
613
+ ("timeline", "Timeline"), ("gates", "Gates"), ("milestones", "Milestones"), ("runner", "Runner")]
614
+
615
+ def _rid(folder, slug) -> str | None:
616
+ """The view id of a roadmap in `index.html`: `rm-<the folder's 6 hex>-<slug>` (the 6 hex keep two clones with
617
+ one repo name apart), or None when the folder has no `-<6hex>` tag or the slug breaks the
618
+ slug rule (such a row gets no view: its name is not safe in an id or a selector)."""
619
+ m = re.fullmatch(r".+-([0-9a-f]{6})", str(folder or ""))
620
+ if not m or not isinstance(slug, str) or not _SLUG_RE.fullmatch(slug):
621
+ return None
622
+ return f"rm-{m.group(1)}-{slug}"
623
+
624
+
625
+ def _prefix_ids(body: str, rid: str) -> str:
626
+ """Every `id="x"` and `href="#x"` in a roadmap body becomes `<rid>-x`, so two roadmaps in one file never share an
627
+ id. Safe on rendered HTML: every value from the files is escaped, so a `"` in it is `&quot;`."""
628
+ body = re.sub(r' id="([A-Za-z][\w-]*)"', lambda m: f' id="{rid}-{m.group(1)}"', body)
629
+ return re.sub(r' href="#([A-Za-z][\w-]*)"', lambda m: f' href="#{rid}-{m.group(1)}"', body)
630
+
631
+
632
+ def _stub(rid: str, slug: str) -> str:
633
+ """`<slug>.html`: one line that opens the roadmap's view in the hub, so an old link or bookmark still works."""
634
+ target = f"../index.html#{rid}"
635
+ return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
636
+ f'<meta http-equiv="refresh" content="0;url={_e(target)}"><title>Roadmap {_e(slug)}</title></head>'
637
+ f'<body><p><a href="{_e(target)}">Open roadmap {_e(slug)}</a></p></body></html>\n')
425
638
 
426
639
 
427
640
  def render_html(model: dict) -> str:
428
- """Self-contained HTML: no script, no network. Every value from the files is escaped."""
641
+ """One self-contained roadmap page (an `--out` copy): the app shell with an empty repo tree, one theme script,
642
+ one local font link, no network. Every value from the files is escaped."""
643
+ slug = model["roadmap"]
644
+ return _shell(title=f"Roadmap {slug}", base="../", nav=_nav([], "../"), crumbs=_crumbs("", slug),
645
+ body=render_body(model))
646
+
647
+
648
+ def render_body(model: dict) -> str:
649
+ """Everything inside `<main>`: the header and the sections. `write_page` keeps it as `<slug>.body.html`; the
650
+ hub puts it, with its ids prefixed, in `index.html` as the roadmap's view."""
429
651
  title = f"Roadmap {model['roadmap']}"
430
652
  phases = [ph for ms in model["milestones"] for ph in ms["phases"]]
431
653
  done = sum(1 for ph in phases if ph["tile"] in ("done", "moved"))
432
654
  commit = f" · origin <code>{_e(model.get('origin_commit'))}</code>" if model.get("origin_commit") else ""
655
+ chips = "".join(
656
+ f'<span class="chip" style="--lc:var(--c{i % 6})"><b>{_e(plan)}</b> <span>'
657
+ f'{_e(sum(1 for ph in phases if ph["plan"] == plan and ph["tile"] in ("done", "moved")))}/'
658
+ f'{_e(sum(1 for ph in phases if ph["plan"] == plan))} phases</span></span>'
659
+ for i, plan in enumerate(model["lanes"]))
433
660
  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>",
440
- _waiting_section(model), _swimlane(model), _summary(model), _now_next_later(model), _gates(model),
441
- _milestones(model), _runner(model),
661
+ f'<header class="hdr"><div><h1>{_e(title)}</h1><p class="sub">{_e(model["done"])} of {_e(model["total"])} '
662
+ f'milestones · {_e(done)} of {_e(len(phases))} phases done · branch <code>{_e(model["branch"])}</code>'
663
+ f'{commit} · generated {_e(model["generated"])}</p>'
664
+ + (f'<div class="chips">{chips}</div>' if chips else "") + "</div></header>",
665
+ _waiting_section(model), _summary(model), _flow(model), _now_next_later(model), _swimlane(model),
666
+ _gates(model), _milestones(model), _runner(model),
442
667
  '<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"])
668
+ "session-state folder. Re-rendered by the runner, /handoff, /pr-merge, /catchup and the waiting hooks.</p>"])
445
669
 
446
670
 
447
671
  def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
@@ -454,20 +678,26 @@ def default_page_path(root: Path, slug: str, home: Path | None = None) -> Path:
454
678
 
455
679
 
456
680
  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."""
681
+ """At the default path: `<slug>.body.html` (the page body, which the hub shows as a view of `index.html`), a
682
+ one-line `<slug>.html` that opens that view, and `<slug>.json` (the one-row summary the hub is built from). An
683
+ `--out` page is a one-off self-contained copy and stays out of the hub. None: a newer caddis wrote this page,
684
+ so it is kept."""
459
685
  path = Path(out) if out else default_page_path(root, slug)
460
686
  if out is None and _owned_by_newer(slug, path.with_suffix(".json")): # before build: a skip does no work
461
687
  return None
462
688
  model = build(root, slug, roadmap_mod)
463
689
  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)
690
+ if out is not None:
691
+ _write_atomic(path, render_html(model))
692
+ return path
693
+ _write_atomic(path.with_suffix(".body.html"), render_body(model))
694
+ rid = _rid(path.parent.name, model["roadmap"])
695
+ _write_atomic(path, _stub(rid, model["roadmap"]) if rid else render_html(model))
696
+ try: # the summary feeds the hub only: the page is written, so a failure here never fails the render
697
+ _write_atomic(path.with_suffix(".json"),
698
+ json.dumps(page_summary(model, main_checkout(root), path), indent=1))
699
+ except Exception as exc:
700
+ print(f"[ROADMAP] hub summary not written: {exc}", file=sys.stderr)
471
701
  return path
472
702
 
473
703
 
@@ -556,6 +786,33 @@ def _write_atomic(path: Path, text: str) -> None:
556
786
  pass
557
787
 
558
788
 
789
+ ASSET_DIR = Path(__file__).resolve().parent / "roadmap_assets"
790
+ ASSET_FILES = ("fonts.css", "OFL-MapleMono.txt", "OFL-JetBrainsMono.txt")
791
+
792
+
793
+ def install_assets(root_dir: Path) -> int:
794
+ """Copy the bundled fonts (and their licences) to `<root_dir>/_assets/` when missing or changed. Text is compared
795
+ as text, not bytes: `_write_atomic` writes CRLF on Windows, so a byte compare would rewrite every time. A source
796
+ file that is not shipped is skipped. Returns the number of files written."""
797
+ written = 0
798
+ target_dir = root_dir / "_assets"
799
+ for name in ASSET_FILES:
800
+ src = ASSET_DIR / name
801
+ if not src.exists():
802
+ continue
803
+ text = src.read_text(encoding="utf-8")
804
+ dst = target_dir / name
805
+ try:
806
+ if dst.read_text(encoding="utf-8") == text:
807
+ continue
808
+ except FileNotFoundError:
809
+ pass
810
+ target_dir.mkdir(parents=True, exist_ok=True)
811
+ _write_atomic(dst, text)
812
+ written += 1
813
+ return written
814
+
815
+
559
816
  def pages_root(home: Path | None = None) -> Path:
560
817
  return Path(home or Path.home()) / ".caddis" / "roadmap-pages"
561
818
 
@@ -570,7 +827,7 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
570
827
  else f"stopped: {stop.get('reason')}" if stop.get("reason") else "")
571
828
  nxt = model.get("next") or {}
572
829
  return {
573
- "version": 1, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
830
+ "version": 2, "generator": generator_version(), "repo": main.name, "checkout": str(main), "folder": path.parent.name, "slug": model["roadmap"],
574
831
  "branch": model["branch"], "origin_commit": model.get("origin_commit", ""),
575
832
  "generated_utc": model["generated"],
576
833
  "milestones_done": model["done"], "milestones_total": model["total"],
@@ -583,19 +840,32 @@ def page_summary(model: dict, main: Path, path: Path) -> dict:
583
840
  for s in model["sessions"]],
584
841
  "runner": runner,
585
842
  "next": f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "",
843
+ "milestones": [{"id": ms["id"], "status": ms["status"]} for ms in model["milestones"]],
844
+ "blockers": [{"milestone": ms["id"], "type": b["type"], "reason": b["reason"], "check": b["check"]}
845
+ for ms in model["milestones"] for b in ms["blockers"]
846
+ if ms["status"] not in ("done", "not-needed")],
847
+ "runner_detail": {"lock": r.get("lock"), "stale_lock": bool(r.get("stale_lock")),
848
+ "last_stop": r.get("last_stop"), "errors": r.get("errors", [])},
586
849
  }
587
850
 
588
851
 
852
+ # The roadmap slug rule, copied from caddis_roadmap.SLUG_RE_TEXT (a test keeps the two equal): this module stays
853
+ # 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
854
+ # roadmap's `<slug>.html`; files whose name breaks the rule are never pruned or shown as a view.
855
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
856
+
857
+
589
858
  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."""
859
+ """Remove the page, body and summary of each roadmap that is no longer on origin. Only `<slug>.json` files and
860
+ their `<slug>.html` and `<slug>.body.html` files are touched. Returns how many roadmaps were removed."""
592
861
  removed = 0
593
862
  if not folder.is_dir():
594
863
  return removed
595
864
  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
865
+ # a live newer caddis may know a roadmap this one cannot list
866
+ if js.stem in keep or not _SLUG_RE.fullmatch(js.stem) or _newer_owner(js)[1]:
597
867
  continue
598
- for f in (js, js.with_suffix(".html")):
868
+ for f in (js, js.with_suffix(".html"), js.with_suffix(".body.html")):
599
869
  try:
600
870
  f.unlink()
601
871
  except FileNotFoundError:
@@ -619,15 +889,39 @@ def read_summaries(root_dir: Path) -> tuple[list[dict], list[str]]:
619
889
  data["page"] = f"{js.parent.name}/{js.with_suffix('.html').name}" # the link comes from the disk, not the file
620
890
  data["folder"] = js.parent.name
621
891
  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
892
+ for key in ("sessions", "owner_gates", "milestones", "blockers"): # a hand-edited summary must not stop the hub for every repo
623
893
  items = data.get(key)
624
894
  data[key] = [i for i in items if isinstance(i, dict)] if isinstance(items, list) else []
625
895
  if not isinstance(data.get("in_progress"), list):
626
896
  data["in_progress"] = []
897
+ if not isinstance(data.get("runner_detail"), dict):
898
+ data["runner_detail"] = {}
627
899
  rows.append(data)
628
900
  return rows, errors
629
901
 
630
902
 
903
+ def read_bodies(root_dir: Path, rows: list[dict]) -> dict[str, str]:
904
+ """{rid: body} for every row with a view id and a `<slug>.body.html`, read only. The path comes from
905
+ `row["page"]` (set from the disk by `read_summaries`), never from the summary's own text, and only a file
906
+ directly in a `<root_dir>/<folder>/` is read. One row that fails never stops the others."""
907
+ root = Path(root_dir).resolve()
908
+ bodies: dict[str, str] = {}
909
+ for row in rows:
910
+ rel, slug = row.get("page"), row.get("slug")
911
+ rid = _rid(row.get("folder"), slug)
912
+ if rid is None or not isinstance(rel, str) or slug != Path(rel).stem:
913
+ continue
914
+ try:
915
+ page = (root / rel).resolve()
916
+ body_file = page.with_suffix(".body.html")
917
+ if page.parent.parent != root or page.suffix != ".html" or not body_file.is_file():
918
+ continue
919
+ bodies[rid] = body_file.read_text(encoding="utf-8")
920
+ except Exception as exc: # noqa: BLE001 - one bad row never stops the others (docstring)
921
+ print(f"[ROADMAP] {Path(rel).with_suffix('').as_posix()}: body not read: {exc}", file=sys.stderr)
922
+ return bodies
923
+
924
+
631
925
  def _age_h(utc_text: str, now: datetime.datetime) -> float | None:
632
926
  try:
633
927
  when = datetime.datetime.strptime(utc_text, "%Y-%m-%d %H:%M UTC").replace(tzinfo=datetime.timezone.utc)
@@ -642,19 +936,6 @@ def _bar(done: int, total: int) -> str:
642
936
  f'<span class="empty">{_e(done)}/{_e(total)} phases · {pct}%</span>')
643
937
 
644
938
 
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
939
  def _int(value) -> int:
659
940
  try:
660
941
  return int(value)
@@ -662,30 +943,27 @@ def _int(value) -> int:
662
943
  return 0
663
944
 
664
945
 
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>']
946
+ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
947
+ bodies: dict[str, str] | None = None) -> str:
948
+ """The whole app in one file: the inbox and overview view, then one view per roadmap (its body from `bodies`,
949
+ ids prefixed). CSS shows one view at a time, so the nav is the same for every view and is never stale. While the
950
+ inbox shows, the page reloads itself every 60 s (the script), so an open tab follows the renders."""
951
+ bodies = bodies or {}
952
+ errors = list(errors)
953
+ order, need, count = _group_rows(rows)
954
+ phases_done = sum(_int(r.get("phases_done")) for r in rows)
955
+ phases_total = sum(_int(r.get("phases_total")) for r in rows)
956
+ live = sum(1 for r in rows if r.get("runner") == "running")
957
+ blocked = sum(_int(r.get("blocked")) for r in rows)
958
+ out = ['<section class="view" id="inbox"><section class="kpis">',
959
+ f'<div class="kpi"><b>{_e(count)}</b>Needs you</div>',
960
+ f'<div class="kpi"><b>{_e(phases_done)} / {_e(phases_total)}</b>Phases done'
961
+ f'{_bar(phases_done, phases_total)}</div>',
962
+ f'<div class="kpi"><b>{_e(blocked)}</b>Blocked phases</div>',
963
+ f'<div class="kpi"><b>{_e(live)}</b>Runners live</div></section>',
964
+ f'<p class="sub">{_e(len(order))} repos · {_e(len(rows))} roadmaps · {_e(count)} item(s) need you · '
965
+ f'generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))} · the inbox reloads every minute</p>',
966
+ '<section id="needs" class="card wait"><h2>Needs you</h2>']
689
967
  if not need:
690
968
  out.append('<p class="empty">Nothing is waiting for you.</p>')
691
969
  else:
@@ -699,35 +977,51 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime) -> s
699
977
  out.append(f"<tr><td><strong>{_e(repo)}</strong></td><td>owner gate {_e(r.get('slug'))} "
700
978
  f"{_e(g.get('milestone'))}</td><td>{_e(g.get('reason'))}</td></tr>")
701
979
  out.append("</table></div>")
702
- out.append("</section>")
980
+ out.append("</section><h2>Roadmaps</h2>")
703
981
  if not rows:
704
982
  out.append('<p class="card empty">No roadmap page has been rendered on this machine yet. Run '
705
983
  "<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>")
984
+ out.append('<div class="cards">')
985
+ for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
986
+ age = _age_h(str(r.get("generated_utc", "")), now)
987
+ old = " (not refreshed)" if age is not None and age > HUB_STALE_H else ""
988
+ dots = "".join(f'<span class="dot s-{_e(m.get("status"))}">{_e(m.get("id"))}</span>'
989
+ for m in r.get("milestones") or [])
990
+ rid = _rid(r.get("folder"), r.get("slug"))
991
+ if rid is None:
992
+ errors.append(f"{r.get('folder')}/{r.get('slug')}: the folder or slug name breaks the naming rule")
993
+ continue
994
+ out.append(f'<a class="rcard" href="#{rid}">'
995
+ f'<div><strong>{_e(r["slug"])}</strong> <span class="empty">{_e(r.get("repo"))} · '
996
+ f'{_e(r.get("branch"))} · {_e(r.get("origin_commit"))}</span></div>'
997
+ f'<div>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))} '
998
+ f'{_e(r.get("milestones_done"))}/{_e(r.get("milestones_total"))} milestones</div>'
999
+ f'<div class="dots">{dots}</div>'
1000
+ f'<div class="empty">next: {_e(r.get("next") or "-")} · runner: {_e(r.get("runner") or "idle")}'
1001
+ f'{_e(old)}</div></a>')
1002
+ out.append("</div>")
726
1003
  for err in errors:
727
1004
  out.append(f'<p class="empty">Could not read {_e(err)}.</p>')
728
1005
  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)
1006
+ "</p></section>")
1007
+ rids = []
1008
+ for r in sorted(rows, key=lambda r: (str(r.get("repo", "")).lower(), str(r["folder"]), str(r["slug"]))):
1009
+ rid = _rid(r.get("folder"), r.get("slug"))
1010
+ if rid is None:
1011
+ continue
1012
+ rids.append(rid)
1013
+ body = bodies.get(rid)
1014
+ inner = (_prefix_ids(body, rid) if body is not None else
1015
+ f'<p class="card empty">No page body yet for {_e(r["slug"])}: run <code>caddis_roadmap.py render</code> '
1016
+ "in its repo.</p>")
1017
+ out.append(f'<section class="view" id="{rid}"><p class="view-crumbs"><a href="#inbox">Roadmaps</a> › '
1018
+ f'{_e(r.get("repo"))} › <strong>{_e(r["slug"])}</strong></p>{inner}</section>')
1019
+ for key, label in (("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners")): # Phase 6 fills these
1020
+ out.append(f'<section class="view" id="{key}"><h2>{label}</h2><p class="card empty">The cross-repo {key} '
1021
+ 'view comes in a later update. For now, see <a href="#needs">Needs you</a> on the inbox and each '
1022
+ "roadmap's own page.</p></section>")
1023
+ return _shell(title="Roadmaps", base="", nav=_nav(rows, ""), crumbs="Roadmaps", body="".join(out),
1024
+ css=_view_css(rids))
731
1025
 
732
1026
 
733
1027
  def write_hub(home: Path | None = None, now: datetime.datetime | None = None) -> Path | None:
@@ -743,8 +1037,17 @@ def write_hub(home: Path | None = None, now: datetime.datetime | None = None) ->
743
1037
  theirs, live = "", False
744
1038
  if _gate("hub", theirs if is_newer(theirs, generator_version()) else "", live):
745
1039
  return None
1040
+ try:
1041
+ install_assets(root_dir)
1042
+ except Exception as exc: # a missing font file must never stop the hub
1043
+ print(f"[ROADMAP] assets not installed: {exc}", file=sys.stderr)
746
1044
  rows, errors = read_summaries(root_dir)
1045
+ try: # a hub without the roadmap bodies is better than no hub
1046
+ bodies = read_bodies(root_dir, rows)
1047
+ except Exception as exc:
1048
+ print(f"[ROADMAP] page bodies not read: {exc}", file=sys.stderr)
1049
+ bodies = {}
747
1050
  path = root_dir / "index.html"
748
- _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc)))
1051
+ _write_atomic(path, render_hub(rows, errors, now or datetime.datetime.now(datetime.timezone.utc), bodies))
749
1052
  _write_atomic(stamp, generator_version() + "\n")
750
1053
  return path