@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +14 -4
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +356 -109
- package/bundles/antigravity-plugin/scripts/roadmap_assets/OFL-JetBrainsMono.txt +93 -0
- package/bundles/antigravity-plugin/scripts/roadmap_assets/OFL-MapleMono.txt +93 -0
- package/bundles/antigravity-plugin/scripts/roadmap_assets/fonts.css +5 -0
- package/bundles/antigravity-plugin/scripts/secret_filter.py +6 -2
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
7
|
-
`~/.caddis/roadmap-pages/index.html` from every summary on this machine
|
|
8
|
-
the
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
--accent:#2563eb;--todo-bg:#f1f5f9;--todo-fg:#334155;--prog:#2563eb;--prog-bg:#dbeafe;--prog-fg:#1d4ed8;
|
|
219
|
-
--done:#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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
|
|
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 `"`."""
|
|
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
|
-
"""
|
|
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
|
-
'
|
|
435
|
-
'
|
|
436
|
-
f"
|
|
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
|
-
"""
|
|
458
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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":
|
|
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
|
|
591
|
-
`<slug>.html`
|
|
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
|
-
|
|
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
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
for
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
for
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
'<
|
|
684
|
-
f"
|
|
685
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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></
|
|
730
|
-
|
|
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
|