@caddis/cli 0.4.65 → 0.4.67
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_created.py +191 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +11 -3
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +4 -0
- package/bundles/antigravity-plugin/scripts/caddis_own_cleanup.py +424 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +205 -70
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +3 -0
- package/bundles/antigravity-plugin/scripts/caddis_worktree_root.py +26 -3
- package/bundles/antigravity-plugin/scripts/roadmap_assets/LICENSE-Lucide.txt +43 -0
- package/bundles/antigravity-plugin/scripts/roadmap_assets/{OFL-JetBrainsMono.txt → OFL-Atkinson.txt} +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap_assets/fonts.css +3 -3
- package/bundles/antigravity-plugin/scripts/roadmap_assets/icons.svg +2 -0
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -5
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +58 -28
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +3 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +7 -1
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +7 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +3 -3
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -16,7 +16,8 @@ summary, now / next / later, gates and blockers, every milestone with its phase
|
|
|
16
16
|
The data is `status()` (roadmap.md and each plan's Tracker on origin), the plan headers (`### Phase <n> - <name>`,
|
|
17
17
|
`**Model:**`, `**Lane:**`, `**Risk:**`), the runner's local lock and last stop, and the local session-state folder
|
|
18
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
|
|
19
|
+
toggle, the hub's inbox reload, the phone menu, `aria-current`, the app bar crumbs and the search box): collapsing uses
|
|
20
|
+
`<details>`. Icons are Lucide line icons from one inline sprite (`roadmap_assets/icons.svg`). Every text
|
|
20
21
|
from the files is escaped: a plan, a relay or a hook message can hold any text.
|
|
21
22
|
"""
|
|
22
23
|
from __future__ import annotations
|
|
@@ -218,9 +219,12 @@ def build(root: Path, slug: str, roadmap_mod, now: datetime.datetime | None = No
|
|
|
218
219
|
# ---- rendering -------------------------------------------------------------------------------------------------
|
|
219
220
|
|
|
220
221
|
# One colour set for every page: light = slate + blue, dark = Dracula (owner, 17 Oct): https://draculatheme.com/contribute
|
|
222
|
+
# Dark uses the full official palette for text, borders, links, focus and status (owner, 21 Oct). Two tones are not
|
|
223
|
+
# Dracula's own: --muted (#a4acd4, a lighter comment: #6272a4 is only ~3:1 on the background) and the tinted chip
|
|
224
|
+
# backgrounds. A test checks every text/background pair the CSS uses against WCAG AA (CONTRAST_PAIRS).
|
|
221
225
|
# Dark applies when the OS asks for it, unless the reader picked light; the toggle sets data-theme on <html>.
|
|
222
|
-
_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:#
|
|
223
|
-
_PALETTE_DARK = '--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#
|
|
226
|
+
_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;--wait-h:#b91c1c;--nav:#f8fafc;--nav-fg:#0f172a;--on-accent:#ffffff;--focus:#2563eb;--field:#64748b;--shadow:0 1px 3px rgba(15,23,42,.06);--elev:0 8px 20px rgba(15,23,42,.10);--ico-sun:none;--ico-moon:inline-block;--title:var(--fg);--summary:var(--fg);--code:var(--fg);--c0:#2563eb;--c1:#7c3aed;--c2:#0d9488;--c3:#ea580c;--c4:#db2777;--c5:#475569;--sans:"Atkinson Hyperlegible Next","Segoe UI Variable","Segoe UI",system-ui,sans-serif;--mono:"Maple Mono","Cascadia Mono",Consolas,ui-monospace,monospace;--head:"Maple Mono","Cascadia Mono",Consolas,ui-monospace,monospace;color-scheme:light;'
|
|
227
|
+
_PALETTE_DARK = '--bg:#282a36;--panel:#21222c;--panel2:#343746;--fg:#f8f8f2;--muted:#a4acd4;--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:#3a2028;--wait-fg:#ff5555;--wait-h:#ff5555;--nav:#21222c;--nav-fg:#f8f8f2;--on-accent:#282a36;--focus:#ff79c6;--field:#6272a4;--shadow:none;--elev:0 8px 22px rgba(0,0,0,.45);--ico-sun:inline-block;--ico-moon:none;--title:#bd93f9;--summary:#ff79c6;--code:#f1fa8c;--c0:#8be9fd;--c1:#bd93f9;--c2:#50fa7b;--c3:#ffb86c;--c4:#ff79c6;--c5:#6272a4;color-scheme:dark;'
|
|
224
228
|
_THEME_CSS = (":root{" + _PALETTE_LIGHT + "}" + '@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){'
|
|
225
229
|
+ _PALETTE_DARK + "}}" + ':root[data-theme="dark"]{' + _PALETTE_DARK + "}")
|
|
226
230
|
|
|
@@ -228,12 +232,12 @@ _BASE_CSS = """
|
|
|
228
232
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);
|
|
229
233
|
font:14px/1.5 var(--sans)}code{font:12px var(--mono);color:var(--code)}
|
|
230
234
|
main{max-width:1360px;margin:0 auto;padding:20px 16px 48px;display:grid;gap:16px}main>*{min-width:0}
|
|
231
|
-
h1{font-size:24px;margin:0;color:var(--title)}h2{font-size:16px;margin:0}.sub{color:var(--muted);margin:4px 0 0}
|
|
235
|
+
h1,h2,h3,details.card>summary,.kpi b{font-family:var(--head)}h1{font-size:24px;margin:0;color:var(--title)}h2{font-size:16px;margin:0}.sub{color:var(--muted);margin:4px 0 0}
|
|
232
236
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
|
|
233
237
|
details.card>summary{cursor:pointer;font-weight:700;font-size:16px;list-style:none;color:var(--summary)}
|
|
234
238
|
details.card>summary::-webkit-details-marker{display:none}details.card>summary::before{content:"\\25B8 ";color:var(--muted)}
|
|
235
239
|
details.card[open]>summary::before{content:"\\25BE "}details.card+details.card{margin-top:8px}
|
|
236
|
-
.wait{border-color:var(--high)}.wait h2{color:var(--wait-
|
|
240
|
+
.wait{border-color:var(--high)}.wait h2{color:var(--wait-h)}.empty{color:var(--muted)}
|
|
237
241
|
table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:7px 9px;border-top:1px solid var(--line);
|
|
238
242
|
vertical-align:top}th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);border-top:0}
|
|
239
243
|
.scroll{overflow-x:auto}.pill{display:inline-block;font-size:11px;font-weight:700;text-transform:uppercase;
|
|
@@ -263,7 +267,8 @@ padding:3px 6px;margin:2px 0;white-space:normal;overflow-wrap:anywhere}.legend{d
|
|
|
263
267
|
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.kpi b{font-size:22px;display:block}
|
|
264
268
|
.bar{height:8px;border-radius:99px;background:var(--todo-bg);overflow:hidden;min-width:120px;margin:4px 0}
|
|
265
269
|
.bar i{display:block;height:100%;background:var(--done)}a{color:var(--accent)}td.num{white-space:nowrap}
|
|
266
|
-
.theme-btn{
|
|
270
|
+
.theme-btn{display:inline-grid;place-items:center;flex:none;width:36px;height:36px;padding:0;border-radius:10px;border:1px solid var(--line);background:var(--panel2);color:var(--fg);cursor:pointer}
|
|
271
|
+
.theme-btn:hover{border-color:var(--accent);color:var(--accent)}.theme-btn .ic{width:18px;height:18px;vertical-align:0}.theme-btn .t-sun{display:var(--ico-sun)}.theme-btn .t-moon{display:var(--ico-moon)}
|
|
267
272
|
.hdr{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
|
268
273
|
.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)}
|
|
269
274
|
.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)}
|
|
@@ -277,10 +282,17 @@ _CSS = _THEME_CSS + _BASE_CSS
|
|
|
277
282
|
# The one script on a page: the reader's theme choice; on the hub only, a reload every 60 s while the inbox shows
|
|
278
283
|
# (an open roadmap view is never reset); the phone menu closes when the view changes; and the nav item of the
|
|
279
284
|
# shown view gets aria-current. No network.
|
|
280
|
-
_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")}}
|
|
285
|
+
_THEME_SCRIPT = ('<script id="theme-toggle">(function(){var k="caddis-roadmap-theme",d=document.documentElement;d.classList.add("js");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")}}'
|
|
286
|
+
# The app bar crumbs follow the shown view (a roadmap view brings its own, its .view-crumbs).
|
|
287
|
+
'function bar(){var c=document.getElementById("ab-crumbs");if(!c||!document.getElementById("inbox"))return;var id=shown(),v=document.getElementById(id),p=v&&v.querySelector(".view-crumbs"),a=document.querySelector(\'.nav a.nav-i[href="#\'+id+\'"]\');if(p){c.innerHTML=p.innerHTML}else{c.innerHTML=\'<a href="#inbox">Roadmaps</a> \u203a <strong></strong>\';c.lastChild.textContent=a?a.getAttribute("data-label"):"Inbox"}}'
|
|
288
|
+
# Search: hide every [data-q] element that does not hold the text; open a matching milestone; count
|
|
289
|
+
# the [data-qn] hits of the shown view.
|
|
290
|
+
'function find(){var s=document.getElementById("q");if(!s)return;var v=s.value.trim().toLowerCase(),n=0,id=shown(),e=document.querySelectorAll("[data-q]");for(var i=0;i<e.length;i++){var x=e[i],hit=!v||(x.getAttribute("data-q")+" "+x.textContent).toLowerCase().indexOf(v)>=0;x.classList.toggle("q-miss",!hit);if(x.tagName==="DETAILS"){if(v&&hit){if(!x.hasAttribute("data-o"))x.setAttribute("data-o",x.open?"1":"0");x.open=true}else if(x.hasAttribute("data-o")){x.open=x.getAttribute("data-o")==="1";x.removeAttribute("data-o")}}var w=x.closest(".view");if(v&&hit&&x.hasAttribute("data-qn")&&(!w||w.id===id))n++}var o=document.getElementById("q-n");if(o)o.textContent=v?n+(n===1?" match":" matches"):""}'
|
|
291
|
+
'addEventListener("DOMContentLoaded",cur);addEventListener("DOMContentLoaded",function(){bar();var s=document.getElementById("q");if(s){s.addEventListener("input",find);s.addEventListener("keydown",function(ev){if(ev.key==="Escape"){s.value="";find()}})}});'
|
|
292
|
+
'addEventListener("keydown",function(ev){var s=document.getElementById("q"),f=document.activeElement;if(ev.key==="/"&&s&&f!==s&&!(f&&/^(INPUT|TEXTAREA|SELECT)$/.test(f.tagName))){ev.preventDefault();s.focus()}});'
|
|
293
|
+
'setInterval(function(){var s=document.getElementById("q");if(document.getElementById("inbox")&&document.visibilityState==="visible"&&shown()==="inbox"&&!(s&&s.value))location.reload()},60000);addEventListener("hashchange",function(){var b=document.getElementById("nav-cb");if(b)b.checked=false;cur();bar();find()})})();</script>')
|
|
281
294
|
# The shared copy is a static page: the inbox reload would only re-request it every minute, so it has no timer.
|
|
282
295
|
_SITE_THEME_SCRIPT = re.sub(r"setInterval\(function\(\)\{.*?\},60000\);", "", _THEME_SCRIPT)
|
|
283
|
-
_THEME_BUTTON = '<button type="button" class="theme-btn" onclick="caddisTheme()" aria-label="Switch between light and dark theme">◐ Theme</button>'
|
|
284
296
|
|
|
285
297
|
|
|
286
298
|
def _fonts_link(base: str) -> str:
|
|
@@ -288,27 +300,47 @@ def _fonts_link(base: str) -> str:
|
|
|
288
300
|
return f'<link rel="stylesheet" href="{_e(base)}_assets/fonts.css">'
|
|
289
301
|
|
|
290
302
|
_SHELL_CSS = """
|
|
291
|
-
.app{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:100vh}
|
|
292
|
-
.nav{background:var(--nav);color:var(--nav-fg);border-right:1px solid var(--line);padding:
|
|
293
|
-
.brand{display:block;font-weight:700;font-size:15px;color:var(--title);text-decoration:none;padding:4px 8px 12px}
|
|
303
|
+
.app{display:grid;grid-template-columns:240px minmax(0,1fr);grid-template-rows:auto 1fr;min-height:100vh}
|
|
304
|
+
.nav{background:var(--nav);color:var(--nav-fg);border-right:1px solid var(--line);padding:6px 10px 14px;position:sticky;top:56px;height:calc(100vh - 56px);overflow-y:auto}
|
|
294
305
|
.nav-h{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:14px 8px 4px}
|
|
295
|
-
.nav-repo{font-size:12px;font-weight:600;color:var(--muted);margin:10px 8px 2px}
|
|
296
|
-
.nav-i{display:flex;
|
|
297
|
-
.nav-i:hover,.nav-i:focus-visible{background:var(--panel2)
|
|
298
|
-
.nav-i .n{font-family:var(--mono);font-size:11px;color:var(--muted)}.nav-rm{padding-left:16px}
|
|
306
|
+
.nav-repo{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--muted);margin:10px 8px 2px}.nav-repo .ic{width:14px;height:14px}
|
|
307
|
+
.nav-i{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;color:inherit;text-decoration:none;font-size:13px;transition:background .15s}
|
|
308
|
+
.nav-i:hover,.nav-i:focus-visible{background:var(--panel2)}.nav-i .ic{color:var(--muted)}.nav-i[aria-current] .ic{color:var(--prog-fg)}
|
|
309
|
+
.nav-i .n{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted)}.nav-rm{padding-left:16px}.nav-rm .ic{width:14px;height:14px}
|
|
299
310
|
.nav-sub{display:grid;padding:2px 0 6px 28px}.nav-sub a{font-size:12px;color:var(--muted);text-decoration:none;padding:3px 0}
|
|
300
|
-
.main{min-width:0}.top{display:flex;align-items:center;gap:
|
|
301
|
-
.
|
|
311
|
+
.main{min-width:0}.top{grid-column:1/-1;display:flex;align-items:center;gap:14px;height:56px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:10;box-shadow:var(--shadow)}
|
|
312
|
+
.ab-brand{display:flex;align-items:center;gap:9px;flex:none;width:208px;font:700 15px var(--head);color:var(--title);text-decoration:none;white-space:nowrap}
|
|
313
|
+
.logo{display:inline-grid;place-items:center;flex:none;width:30px;height:30px;border-radius:9px;background:var(--accent);color:var(--on-accent)}.logo .ic{width:18px;height:18px;vertical-align:0}
|
|
314
|
+
.crumbs{flex:1;min-width:0;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.crumbs a{color:var(--accent);text-decoration:none}.crumbs strong{color:var(--fg)}
|
|
315
|
+
.search{position:relative;flex:0 1 300px;min-width:0}.search .ic{position:absolute;left:10px;top:50%;margin-top:-8px;color:var(--muted);pointer-events:none}
|
|
316
|
+
.search input{width:100%;height:36px;padding:0 10px 0 34px;border-radius:10px;border:1px solid var(--field);background:var(--panel2);color:var(--fg);font:13px var(--sans)}
|
|
317
|
+
.search input::placeholder{color:var(--muted);opacity:1}.q-n{font:11px var(--mono);color:var(--muted);white-space:nowrap}.q-n:empty{display:none}
|
|
318
|
+
.gen{display:flex;align-items:center;gap:5px;font:11px var(--mono);color:var(--muted);white-space:nowrap}.gen .ic{width:13px;height:13px}
|
|
319
|
+
.js .view-crumbs{display:none}
|
|
302
320
|
main [id]{scroll-margin-top:60px}main>.view{scroll-margin-top:200px}
|
|
303
321
|
.view>*+*{margin-top:16px}.view{display:none}.view:target,.view:has(:target){display:block}main:not(:has(.view:target,.view :target))>#inbox{display:block}
|
|
304
322
|
.nav-sub{display:none}.view-crumbs{font-size:13px;color:var(--muted);margin:0 0 8px}
|
|
305
323
|
.nav-cb{display:none}.nav-btn,.nav-close,.nav-scrim{display:none}
|
|
306
324
|
.nav-cb:focus-visible~.app .nav-btn{outline:2px solid var(--accent);outline-offset:2px}
|
|
307
|
-
@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-
|
|
325
|
+
@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;height:100vh;transform:translateX(-100%);transition:transform .2s}.nav-cb:checked~.app .nav{transform:none}.nav-btn{display:inline-grid;place-items:center;cursor:pointer;width:32px;height:32px;color:var(--fg)}.nav-btn .ic,.nav-close .ic{width:20px;height:20px;vertical-align:0}.nav-close{display:block;text-align:right;cursor:pointer;padding:6px 8px;color:var(--fg)}.nav-cb:checked~.app .nav-scrim{display:block;position:fixed;inset:0;z-index:15;background:rgba(0,0,0,.4)}
|
|
326
|
+
.top{flex-wrap:wrap;height:auto;gap:6px 8px;padding:8px 10px}.ab-brand{width:auto}.ab-name,.gen{display:none}.search{flex:1 1 120px}.crumbs{order:5;flex-basis:100%}main [id]{scroll-margin-top:100px}}
|
|
308
327
|
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
|
|
309
|
-
.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)}
|
|
328
|
+
.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)}
|
|
310
329
|
.dots{display:flex;flex-wrap:wrap;gap:4px}.dot{font:600 10.5px var(--mono);padding:1px 6px;border-radius:5px}
|
|
311
330
|
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;box-shadow:var(--shadow)}
|
|
331
|
+
.ic{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-3px}
|
|
332
|
+
.sprite{position:absolute;width:0;height:0;overflow:hidden}
|
|
333
|
+
h2 .ic,h3 .ic{color:var(--accent);margin-right:7px}.wait h2 .ic{color:var(--wait-h)}
|
|
334
|
+
.pill .ic,.kind .ic{width:12px;height:12px;margin-right:4px;vertical-align:-2px}.rcard .ic{margin-right:4px}.rcard strong .ic{color:var(--accent)}.tile .ic,.ltile .ic{width:13px;height:13px;align-self:center}
|
|
335
|
+
:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
|
|
336
|
+
.rcard,.kpi,.fnode{transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease}
|
|
337
|
+
.rcard:hover,.rcard:focus-visible,.fnode:hover,.fnode:focus-visible{transform:translateY(-2px);box-shadow:var(--elev);border-color:var(--accent)}
|
|
338
|
+
.rcard[data-st]{border-left:4px solid var(--line2)}.rcard[data-st=done]{border-left-color:var(--done)}.rcard[data-st=prog]{border-left-color:var(--prog)}.rcard[data-st=block]{border-left-color:var(--block)}.rcard[data-st=wait]{border-left-color:var(--high)}
|
|
339
|
+
.kpi{position:relative;padding-right:56px}.kpi-ic{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:9px;display:grid;place-items:center;background:var(--prog-bg);color:var(--prog-fg)}.kpi-ic .ic{width:18px;height:18px;vertical-align:0}
|
|
340
|
+
.kpi-ic.k-done{background:var(--done-bg);color:var(--done-fg)}.kpi-ic.k-block{background:var(--block-bg);color:var(--block-fg)}.kpi-ic.k-wait{background:var(--wait-bg);color:var(--wait-fg)}
|
|
341
|
+
.bar i{transform-origin:left;animation:fill .9s cubic-bezier(.2,.7,.2,1) both}@keyframes fill{from{transform:scaleX(0)}}
|
|
342
|
+
.q-miss,.nav-rm.q-miss+.nav-sub{display:none!important}
|
|
343
|
+
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}.rcard:hover,.rcard:focus-visible,.fnode:hover,.fnode:focus-visible{transform:none}}
|
|
312
344
|
"""
|
|
313
345
|
|
|
314
346
|
_LABEL = {"todo": "to do", "in-progress": "in progress", "done": "done", "moved": "moved", "blocked": "blocked"}
|
|
@@ -318,21 +350,83 @@ def _e(value) -> str:
|
|
|
318
350
|
return html.escape("" if value is None else str(value), quote=True)
|
|
319
351
|
|
|
320
352
|
|
|
353
|
+
@functools.lru_cache(maxsize=1)
|
|
354
|
+
def _sprite() -> str:
|
|
355
|
+
"""The Lucide sprite (`roadmap_assets/icons.svg`, made by build_roadmap_icons.py, with its licence comment), put
|
|
356
|
+
inline once per page: Chrome and Edge do not let a `file://` page `<use>` a symbol from another file. Empty when
|
|
357
|
+
the file is not shipped: the page then shows no icons, and nothing else changes."""
|
|
358
|
+
try:
|
|
359
|
+
return (ASSET_DIR / "icons.svg").read_text(encoding="utf-8").strip()
|
|
360
|
+
except OSError:
|
|
361
|
+
return ""
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _icon(name: str, label: str | None = None, cls: str = "") -> str:
|
|
365
|
+
"""One Lucide icon from the inline sprite, tinted by `currentColor`. Decorative by default (hidden from screen
|
|
366
|
+
readers); with `label` it is an image with that name, for an icon whose meaning no text beside it gives."""
|
|
367
|
+
attrs = f'role="img" aria-label="{_e(label)}"' if label else 'aria-hidden="true" focusable="false"'
|
|
368
|
+
return f'<svg class="ic{" " + cls if cls else ""}" {attrs}><use href="#ic-{name}"/></svg>'
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_STATUS_ICON = {"done": "circle-check", "todo": "circle", "in-progress": "circle-dot", "blocked": "octagon-alert",
|
|
372
|
+
"moved": "arrow-right-left", "not-needed": "circle-minus", "code-done/data-pending": "hourglass",
|
|
373
|
+
"running": "play", "owner-gate": "user-check"}
|
|
374
|
+
|
|
375
|
+
|
|
321
376
|
def _pill(status: str) -> str:
|
|
322
|
-
return f'<span class="pill s-{_e(status)}">{
|
|
377
|
+
return (f'<span class="pill s-{_e(status)}">{_icon(_STATUS_ICON.get(status, "circle"))}'
|
|
378
|
+
f'{_e(_LABEL.get(status, status))}</span>')
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _kind(kind: str) -> str:
|
|
382
|
+
"""A blocker type chip: owner gates get their own colour and icon."""
|
|
383
|
+
icon = "user-check" if kind == "owner-gate" else "shield-alert"
|
|
384
|
+
return f'<span class="kind k-{_e(kind)}">{_icon(icon)}{_e(kind)}</span>'
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _h2(icon: str, text: str) -> str:
|
|
388
|
+
return f"<h2>{_icon(icon)}{_e(text)}</h2>"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _kpi(icon: str, tone: str, value: str, label: str) -> str:
|
|
392
|
+
"""A KPI tile: `value` and `label` are already safe text."""
|
|
393
|
+
return f'<div class="kpi"><span class="{f"kpi-ic {tone}".strip()}">{_icon(icon)}</span><b>{value}</b>{label}</div>'
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _theme_button() -> str:
|
|
397
|
+
return ('<button type="button" class="theme-btn" onclick="caddisTheme()" aria-label="Switch between light and dark '
|
|
398
|
+
'theme" title="Switch between light and dark theme">' + _icon("moon", cls="t-moon") + _icon("sun", cls="t-sun")
|
|
399
|
+
+ "</button>")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _app_bar(base: str, crumbs: str, generated: str) -> str:
|
|
403
|
+
"""The top bar of every page: the phone menu button, the logo and name (a link to the inbox), the crumbs of the
|
|
404
|
+
shown view (the script keeps them current on the hub), the search box, the generated time and the theme button."""
|
|
405
|
+
home = f"{base}index.html#inbox" if base else "#inbox"
|
|
406
|
+
gen = f'<span class="gen">{_icon("clock")}generated {_e(generated)}</span>' if generated else ""
|
|
407
|
+
return ('<header class="top"><label for="nav-cb" class="nav-btn" aria-label="Open the menu">' + _icon("menu")
|
|
408
|
+
+ f'</label><a class="ab-brand" href="{_e(home)}"><span class="logo">{_icon("route")}</span>'
|
|
409
|
+
'<span class="ab-name">caddis Roadmaps</span></a>'
|
|
410
|
+
f'<div class="crumbs" id="ab-crumbs" role="navigation" aria-label="Breadcrumb">{crumbs}</div>'
|
|
411
|
+
f'<div class="search" role="search">{_icon("search")}<input type="search" id="q" '
|
|
412
|
+
'placeholder="Search roadmaps, milestones, phases" title="Press / to search" '
|
|
413
|
+
'aria-label="Search roadmaps, milestones and phases" '
|
|
414
|
+
'autocomplete="off" spellcheck="false"></div><span class="q-n" id="q-n" role="status" aria-live="polite"></span>'
|
|
415
|
+
f"{gen}{_theme_button()}</header>")
|
|
323
416
|
|
|
324
417
|
|
|
325
418
|
def _shell(*, title: str, base: str, nav: str, crumbs: str, body: str, css: str = "",
|
|
326
|
-
script: str | None = None) -> str:
|
|
327
|
-
"""The app frame around every page: head,
|
|
328
|
-
`nav`, `crumbs`, `body` and `css` are text the caller has already made safe; `title`
|
|
419
|
+
script: str | None = None, generated: str = "") -> str:
|
|
420
|
+
"""The app frame around every page: head, the icon sprite, the app bar, the left nav and the body.
|
|
421
|
+
`nav`, `crumbs`, `body` and `css` are text the caller has already made safe; `title` and `generated` are plain
|
|
422
|
+
text."""
|
|
329
423
|
return "".join([
|
|
330
424
|
'<!doctype html><html lang="en"><head><meta charset="utf-8">',
|
|
331
425
|
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
332
426
|
f"<title>{_e(title)}</title>{_fonts_link(base)}<style>{_CSS}{_SHELL_CSS}{css}</style>{script or _THEME_SCRIPT}</head>",
|
|
333
|
-
f'<body
|
|
334
|
-
|
|
335
|
-
f'<div class="
|
|
427
|
+
f'<body>{_sprite()}<input type="checkbox" id="nav-cb" class="nav-cb" aria-label="Toggle the navigation menu"><div class="app">',
|
|
428
|
+
_app_bar(base, crumbs, generated),
|
|
429
|
+
f'{nav}<label for="nav-cb" class="nav-scrim" aria-hidden="true"></label><div class="main">',
|
|
336
430
|
f"<main>{body}</main></div></div></body></html>\n"])
|
|
337
431
|
|
|
338
432
|
|
|
@@ -377,24 +471,26 @@ def _nav(rows: list[dict], base: str, site: bool = False) -> str:
|
|
|
377
471
|
"runners": sum(1 for r in rows if r.get("runner") == "running")}
|
|
378
472
|
hub = f"{base}index.html" if base else ""
|
|
379
473
|
out = ['<nav class="nav" aria-label="Roadmaps">'
|
|
380
|
-
'<label for="nav-cb" class="nav-close" aria-label="Close the menu"
|
|
381
|
-
|
|
474
|
+
f'<label for="nav-cb" class="nav-close" aria-label="Close the menu">{_icon("x")}</label>',
|
|
475
|
+
'<p class="nav-h">Needs you</p>']
|
|
382
476
|
for key, label in _NAV_ITEMS:
|
|
383
477
|
if site and key in _LOCAL_VIEWS:
|
|
384
478
|
continue
|
|
385
|
-
out.append(f'<a class="nav-i" href="{_e(hub)}#{key}"
|
|
479
|
+
out.append(f'<a class="nav-i" href="{_e(hub)}#{key}" data-label="{_e(label)}">{_icon(_NAV_ICON[key])}{_e(label)}'
|
|
480
|
+
f'<span class="n">{_e(counts[key])}</span></a>')
|
|
386
481
|
out.append('<p class="nav-h">Repos</p>')
|
|
387
482
|
for folder, group in order:
|
|
388
483
|
idle = 0 if site else sum(1 for s in _sessions_of(group) if not s.get("asking"))
|
|
389
484
|
idle_txt = f" · {idle} idle session(s)" if idle else ""
|
|
390
|
-
out.append(f'<p class="nav-repo">{_e(group[0].get("repo"))}{_e(idle_txt)}</p>')
|
|
485
|
+
out.append(f'<p class="nav-repo">{_icon("folder-git-2")}{_e(group[0].get("repo"))}{_e(idle_txt)}</p>')
|
|
391
486
|
for r in sorted(group, key=lambda r: str(r["slug"])):
|
|
392
487
|
rid = _rid(folder, r["slug"])
|
|
393
488
|
if rid is None:
|
|
394
489
|
continue
|
|
395
490
|
total = _int(r.get("phases_total"))
|
|
396
491
|
pct = int(round(100 * _int(r.get("phases_done")) / total)) if total else 0
|
|
397
|
-
out.append(f'<a class="nav-i nav-rm" href="{_e(hub)}#{rid}"
|
|
492
|
+
out.append(f'<a class="nav-i nav-rm" href="{_e(hub)}#{rid}" data-q="{_e(group[0].get("repo"))}" '
|
|
493
|
+
f'data-label="{_e(r["slug"])}">{_icon("map")}{_e(r["slug"])}<span class="n">{pct}%</span></a>'
|
|
398
494
|
f'<div class="nav-sub" data-rid="{rid}">'
|
|
399
495
|
+ "".join(f'<a href="{_e(hub)}#{rid}-{anchor}">{_e(label)}</a>' for anchor, label in _PAGE_ANCHORS
|
|
400
496
|
if not (site and anchor in _LOCAL_ANCHORS))
|
|
@@ -404,6 +500,7 @@ def _nav(rows: list[dict], base: str, site: bool = False) -> str:
|
|
|
404
500
|
|
|
405
501
|
|
|
406
502
|
_NAV_ITEMS = (("inbox", "Inbox"), ("gates", "Gates"), ("sessions", "Sessions"), ("runners", "Runners"))
|
|
503
|
+
_NAV_ICON = {"inbox": "inbox", "gates": "shield-alert", "sessions": "messages-square", "runners": "activity"}
|
|
407
504
|
_LOCAL_VIEWS = ("sessions", "runners") # views of this machine's live state: a shared copy has neither
|
|
408
505
|
_LOCAL_ANCHORS = ("runner",) # the section of a roadmap view that shows this machine's runner
|
|
409
506
|
|
|
@@ -429,7 +526,7 @@ def _view_css(rids: list[str], site: bool = False) -> str:
|
|
|
429
526
|
def _waiting_section(model: dict, site: bool = False) -> str:
|
|
430
527
|
"""Sessions that wait for input, then the open owner gates. A shared copy (`site`) has the owner gates only: its
|
|
431
528
|
sessions are not on the reader's machine, so a table of them would be empty for a wrong reason."""
|
|
432
|
-
out = ['<section id="waiting" class="card wait"
|
|
529
|
+
out = [f'<section id="waiting" class="card wait">{_h2("bell-ring", "Waiting for you")}']
|
|
433
530
|
sessions, gates = ([] if site else model["sessions"]), model["waiting"]
|
|
434
531
|
if not sessions and not gates:
|
|
435
532
|
out.append('<p class="empty">' + ("No owner gate is waiting." if site else "Nothing is waiting for you.")
|
|
@@ -461,7 +558,7 @@ def _swimlane(model: dict) -> str:
|
|
|
461
558
|
milestones, lanes = model["milestones"], model["lanes"]
|
|
462
559
|
cols = len(milestones)
|
|
463
560
|
# The section has the class `timeline` as well as the id: the hub prefixes every id, so the phone CSS selects by class.
|
|
464
|
-
out = ['<section class="card timeline" id="timeline"><div class="sec-h"
|
|
561
|
+
out = ['<section class="card timeline" id="timeline"><div class="sec-h">' + _h2("chart-gantt", "Timeline") + '<span class="note">plans as '
|
|
465
562
|
'lanes, milestones as columns in roadmap order, one tile per phase</span></div><div class="scroll">',
|
|
466
563
|
f'<div class="lanes" style="grid-template-columns:150px repeat({cols},minmax(110px,1fr));width:100%;'
|
|
467
564
|
f'min-width:{150 + cols * 110}px">',
|
|
@@ -485,7 +582,8 @@ def _swimlane(model: dict) -> str:
|
|
|
485
582
|
continue
|
|
486
583
|
hi = " hi" if "high" in ph.get("risk", "").lower() else ""
|
|
487
584
|
title = f"{ph['ref']} {ph['name']} | {ph['tracker'] or ph['state']}"
|
|
488
|
-
tiles.append(f'<div class="tile {_e(ph["tile"])}{hi}"
|
|
585
|
+
tiles.append(f'<div class="tile {_e(ph["tile"])}{hi}" data-q="{_e(ms["id"])} {_e(ph["ref"])}" title="{_e(title)}">'
|
|
586
|
+
f'{_tile_icon(ph["tile"])}<b>{_e(ph["num"])}</b>'
|
|
489
587
|
f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
|
|
490
588
|
out.append(f"<div>{''.join(tiles)}</div>")
|
|
491
589
|
if any(ms["blockers"] for ms in milestones): # the blockers get their own lane, so the column headers stay short
|
|
@@ -502,7 +600,8 @@ def _swimlane(model: dict) -> str:
|
|
|
502
600
|
out.append(f'<h3>{_e(ms["id"])} {_pill(ms["status"])}</h3>')
|
|
503
601
|
for ph in ms["phases"]:
|
|
504
602
|
hi = " hi" if "high" in ph.get("risk", "").lower() else ""
|
|
505
|
-
out.append(f'<div class="ltile {_e(ph["tile"])}{hi}"
|
|
603
|
+
out.append(f'<div class="ltile {_e(ph["tile"])}{hi}" data-q="{_e(ms["id"])} {_e(ph["ref"])}">{_tile_icon(ph["tile"])}'
|
|
604
|
+
f'<b>{_e(ph["plan"])} {_e(ph["num"])}</b>'
|
|
506
605
|
f'<span>{_e(ph["name"] or ph["ref"])}</span></div>')
|
|
507
606
|
out.append('</div><div class="legend"><span>colour: to do · <b>in progress</b> · done · striped = moved · '
|
|
508
607
|
'amber = blocked</span><span>red dot = Risk high (stops for review)</span></div></section>')
|
|
@@ -517,12 +616,28 @@ def _summary(model: dict) -> str:
|
|
|
517
616
|
nxt = model.get("next") or {}
|
|
518
617
|
nxt_txt = f"{nxt.get('milestone', '')} {nxt.get('plan', '')}#{nxt.get('phase', '')}" if nxt else "none ready"
|
|
519
618
|
return ('<section class="kpis" id="overview" aria-label="Summary">'
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
"</section>")
|
|
619
|
+
+ _kpi("circle-check-big", "k-done", f"{_e(done)} / {_e(len(phases))}", "phases done")
|
|
620
|
+
+ _kpi("milestone", "k-done", f'{_e(model["done"])} / {_e(model["total"])}', "milestones done")
|
|
621
|
+
+ _kpi("loader-circle", "", _e(len(building)), f'in progress: {_e(", ".join(building) or "none")}')
|
|
622
|
+
+ _kpi("triangle-alert", "k-block" if high else "", _e(high), "high-risk phases")
|
|
623
|
+
+ _kpi("skip-forward", "", _e(nxt_txt), "next batch for the runner")
|
|
624
|
+
+ "</section>")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _tile_icon(state: str) -> str:
|
|
628
|
+
"""The state of a timeline tile as a named icon: the tile's colour is not the only cue."""
|
|
629
|
+
return _icon(_STATUS_ICON.get(state, "circle"), _LABEL.get(state, state))
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _ms_tone(ms: dict) -> str:
|
|
633
|
+
"""The accent of a milestone card: done, wait (an owner gate), block, prog or todo."""
|
|
634
|
+
if ms["status"] in ("done", "not-needed"):
|
|
635
|
+
return "done"
|
|
636
|
+
if any(b.get("type") == "owner-gate" for b in ms["blockers"]):
|
|
637
|
+
return "wait"
|
|
638
|
+
if ms["blockers"] or ms["status"] == "blocked":
|
|
639
|
+
return "block"
|
|
640
|
+
return "prog" if ms["status"] == "in-progress" else "todo"
|
|
526
641
|
|
|
527
642
|
|
|
528
643
|
def _done_phases(ms: dict) -> int:
|
|
@@ -534,10 +649,10 @@ def _flow(model: dict) -> str:
|
|
|
534
649
|
nodes = []
|
|
535
650
|
for ms in model["milestones"]:
|
|
536
651
|
dep = f"after {', '.join(ms['depends'])}" if ms["depends"] else "starts first"
|
|
537
|
-
nodes.append(f'<a class="fnode s-{_e(ms["status"])}" href="#ms-{_e(ms["id"])}"><b>{_e(ms["id"])}</b> '
|
|
652
|
+
nodes.append(f'<a class="fnode s-{_e(ms["status"])}" data-q="{_e(ms["id"])}" href="#ms-{_e(ms["id"])}"><b>{_e(ms["id"])}</b> '
|
|
538
653
|
f'{_pill(ms["status"])}<span>{_e(dep)} · {_e(_done_phases(ms))}/{_e(len(ms["phases"]))} ph</span></a>')
|
|
539
654
|
chain = '<span class="arrow" aria-hidden="true">→</span>'.join(nodes)
|
|
540
|
-
return (f'<section id="flow"><div class="sec-h"
|
|
655
|
+
return (f'<section id="flow"><div class="sec-h">{_h2("workflow", "Flow")}<span class="note">milestones in roadmap order</span></div>'
|
|
541
656
|
f'<div class="card scroll"><div class="flow">{chain}</div></div></section>')
|
|
542
657
|
|
|
543
658
|
|
|
@@ -557,12 +672,13 @@ def _now_next_later(model: dict) -> str:
|
|
|
557
672
|
groups["Later"].append(ms)
|
|
558
673
|
cols = []
|
|
559
674
|
for title, items in groups.items():
|
|
560
|
-
body = "".join(f'<a class="rcard" href="#ms-{_e(m["id"])}"
|
|
675
|
+
body = "".join(f'<a class="rcard" href="#ms-{_e(m["id"])}" data-q="{_e(m["id"])}" data-st="{_ms_tone(m)}">'
|
|
676
|
+
f'<span><strong>{_e(m["id"])}</strong> {_pill(m["status"])}</span>'
|
|
561
677
|
f'{_bar(_done_phases(m), len(m["phases"]))}'
|
|
562
678
|
+ "".join(f'<span class="note">{_e(b["type"])}</span>' for b in m["blockers"])
|
|
563
679
|
+ "</a>" for m in items)
|
|
564
680
|
cols.append(f'<div><h3>{_e(title)}</h3>{body or "<p class=empty>none</p>"}</div>')
|
|
565
|
-
return ('<section id="board"><div class="sec-h"
|
|
681
|
+
return ('<section id="board"><div class="sec-h">' + _h2("square-kanban", "Now, next, later") +
|
|
566
682
|
'<span class="note">which milestones can start</span></div>'
|
|
567
683
|
f'<div class="cols">{"".join(cols)}</div></section>')
|
|
568
684
|
|
|
@@ -570,25 +686,26 @@ def _now_next_later(model: dict) -> str:
|
|
|
570
686
|
def _gates(model: dict) -> str:
|
|
571
687
|
rows = [(ms["id"], b) for ms in model["milestones"] for b in ms["blockers"]]
|
|
572
688
|
body = "".join(f"<tr><td><strong>{_e(mid)}</strong></td>"
|
|
573
|
-
f"<td
|
|
689
|
+
f"<td>{_kind(str(b['type']))}</td><td>{_e(b['reason'])}</td>"
|
|
574
690
|
f"<td><code>{_e(b['check'])}</code></td></tr>" for mid, b in rows)
|
|
575
|
-
return (f'<section class="card" id="gates"><div class="sec-h"
|
|
691
|
+
return (f'<section class="card" id="gates"><div class="sec-h">{_h2("shield-alert", "Gates and blockers")}'
|
|
576
692
|
f'<span class="note">{_e(len(rows))} open</span></div>'
|
|
577
693
|
+ (f'<div class="scroll"><table><tr><th>Milestone</th><th>Type</th><th>Reason</th><th>Check</th></tr>{body}'
|
|
578
694
|
"</table></div>" if rows else '<p class="empty">No blockers.</p>') + "</section>")
|
|
579
695
|
|
|
580
696
|
|
|
581
697
|
def _milestones(model: dict) -> str:
|
|
582
|
-
out = ['<section id="milestones"><div class="sec-h"
|
|
698
|
+
out = ['<section id="milestones"><div class="sec-h">' + _h2("milestone", "Milestones and phases") + '<span class="note">every phase with '
|
|
583
699
|
'model, lane, risk and Tracker status</span></div>']
|
|
584
700
|
for ms in model["milestones"]:
|
|
585
701
|
rows = "".join(
|
|
586
|
-
f"<tr
|
|
702
|
+
f"<tr data-q=\"{_e(ms['id'])} {_e(ph['ref'])}\" data-qn=\"1\"><td><code>{_e(ph['ref'])}</code></td><td>{_e(ph['name'])}</td>"
|
|
703
|
+
f"<td>{_e(_short_model(ph['model']))}</td>"
|
|
587
704
|
f"<td>{_e(ph['lane'].split(' ', 1)[0] if ph['lane'] else '-')}</td>"
|
|
588
705
|
f"<td>{_e(ph['risk'] or '-')}</td><td>{_pill(ph['tile'])} {_e(ph['tracker'])}</td></tr>"
|
|
589
706
|
for ph in ms["phases"])
|
|
590
707
|
target = f" · target {_e(ms['target'])}" if ms.get("target") else ""
|
|
591
|
-
out.append(f'<details class="card" id="ms-{_e(ms["id"])}"><summary>{_e(ms["id"])} '
|
|
708
|
+
out.append(f'<details class="card" id="ms-{_e(ms["id"])}" data-q="{_e(ms["id"])}" data-qn="1"><summary>{_e(ms["id"])} '
|
|
592
709
|
f'{_pill(ms["status"])} <span class="note">owner {_e(ms["owner"])}{target} · '
|
|
593
710
|
f'after {_e(", ".join(ms["depends"]) or "-")}</span></summary>'
|
|
594
711
|
'<div class="scroll"><table><tr><th>Phase</th><th>Name</th><th>Model</th><th>Lane</th><th>Risk</th>'
|
|
@@ -623,7 +740,7 @@ def _runner(model: dict) -> str:
|
|
|
623
740
|
if nxt:
|
|
624
741
|
parts.append(f"<p>Next batch: <code>{_e(nxt.get('milestone'))} {_e(nxt.get('plan'))}#{_e(nxt.get('phase'))}"
|
|
625
742
|
"</code></p>")
|
|
626
|
-
return ('<section class="card" id="runner"><div class="sec-h"
|
|
743
|
+
return ('<section class="card" id="runner"><div class="sec-h">' + _h2("cpu", "Runner") +
|
|
627
744
|
'<span class="note">roadmap-run state on this machine</span></div>' + "".join(parts) + "</section>")
|
|
628
745
|
|
|
629
746
|
|
|
@@ -645,7 +762,7 @@ def _prefix_ids(body: str, rid: str) -> str:
|
|
|
645
762
|
"""Every `id="x"` and `href="#x"` in a roadmap body becomes `<rid>-x`, so two roadmaps in one file never share an
|
|
646
763
|
id. Safe on rendered HTML: every value from the files is escaped, so a `"` in it is `"`."""
|
|
647
764
|
body = re.sub(r' id="([A-Za-z][\w-]*)"', lambda m: f' id="{rid}-{m.group(1)}"', body)
|
|
648
|
-
return re.sub(r' href="#([A-Za-z][\w-]*)"', lambda m: f' href="#{rid}-{m.group(1)}"', body)
|
|
765
|
+
return re.sub(r' href="#(?!ic-)([A-Za-z][\w-]*)"', lambda m: f' href="#{rid}-{m.group(1)}"', body)
|
|
649
766
|
|
|
650
767
|
|
|
651
768
|
def _stub(rid: str, slug: str) -> str:
|
|
@@ -661,7 +778,7 @@ def render_html(model: dict) -> str:
|
|
|
661
778
|
one local font link, no network. Every value from the files is escaped."""
|
|
662
779
|
slug = model["roadmap"]
|
|
663
780
|
return _shell(title=f"Roadmap {slug}", base="../", nav=_nav([], "../"), crumbs=_crumbs("", slug),
|
|
664
|
-
body=render_body(model))
|
|
781
|
+
body=render_body(model), generated=model.get("generated", ""))
|
|
665
782
|
|
|
666
783
|
|
|
667
784
|
def render_body(model: dict, site: bool = False) -> str:
|
|
@@ -841,7 +958,8 @@ def _write_atomic(path: Path, text: str) -> None:
|
|
|
841
958
|
|
|
842
959
|
|
|
843
960
|
ASSET_DIR = Path(__file__).resolve().parent / "roadmap_assets"
|
|
844
|
-
ASSET_FILES = ("fonts.css", "OFL-MapleMono.txt", "OFL-
|
|
961
|
+
ASSET_FILES = ("fonts.css", "OFL-MapleMono.txt", "OFL-Atkinson.txt", "LICENSE-Lucide.txt")
|
|
962
|
+
# icons.svg is shipped beside them but not copied: the sprite goes inline into each page (`_sprite`).
|
|
845
963
|
|
|
846
964
|
|
|
847
965
|
def install_assets(root_dir: Path) -> int:
|
|
@@ -997,6 +1115,18 @@ def _int(value) -> int:
|
|
|
997
1115
|
return 0
|
|
998
1116
|
|
|
999
1117
|
|
|
1118
|
+
def _row_tone(r: dict) -> str:
|
|
1119
|
+
"""The accent of a roadmap card on the inbox: wait (an owner gate), block, done, prog or todo."""
|
|
1120
|
+
total, done = _int(r.get("phases_total")), _int(r.get("phases_done"))
|
|
1121
|
+
if r.get("owner_gates"):
|
|
1122
|
+
return "wait"
|
|
1123
|
+
if _int(r.get("blocked")):
|
|
1124
|
+
return "block"
|
|
1125
|
+
if total and done >= total:
|
|
1126
|
+
return "done"
|
|
1127
|
+
return "prog" if r.get("in_progress") or done else "todo"
|
|
1128
|
+
|
|
1129
|
+
|
|
1000
1130
|
def _roadmap_link(r: dict, label=None) -> str:
|
|
1001
1131
|
"""The roadmap's name linked to its view; plain text for a row that has no view."""
|
|
1002
1132
|
rid = _rid(r.get("folder"), r.get("slug"))
|
|
@@ -1028,7 +1158,7 @@ def _gates_view(rows: list[dict]) -> str:
|
|
|
1028
1158
|
ms_text = f'<a href="#{rid}-ms-{ms_text}">{ms_text}</a>'
|
|
1029
1159
|
kind = str(b.get("type") or "owner-gate")
|
|
1030
1160
|
body.append(f'<tr><td><strong>{_e(r.get("repo"))}</strong></td><td>{_roadmap_link(r)}</td><td>{ms_text}</td>'
|
|
1031
|
-
f'<td
|
|
1161
|
+
f'<td>{_kind(kind)}</td><td>{_e(b.get("reason"))}</td>'
|
|
1032
1162
|
f'<td><code>{_e(b.get("check") or "-")}</code></td></tr>')
|
|
1033
1163
|
if not body:
|
|
1034
1164
|
return '<p class="empty">No open gates or blockers.</p>'
|
|
@@ -1047,7 +1177,7 @@ def _sessions_view(rows: list[dict]) -> str:
|
|
|
1047
1177
|
body.append(f'<tr><td><code>{_e(s.get("session"))}</code></td>'
|
|
1048
1178
|
f'<td>{"asking" if s.get("asking") else "idle"}</td><td>{_e(s.get("message"))}</td>'
|
|
1049
1179
|
f'<td>{_e(s.get("since"))}{_e(stale)}</td></tr>')
|
|
1050
|
-
out.append(f'<div class="card"><h3>{_e(group[0].get("repo"))}</h3>'
|
|
1180
|
+
out.append(f'<div class="card"><h3>{_icon("folder-git-2")}{_e(group[0].get("repo"))}</h3>'
|
|
1051
1181
|
+ _table(["Session", "Asking or idle", "Message", "Since"], body) + "</div>")
|
|
1052
1182
|
return "".join(out) or '<p class="empty">No session is waiting.</p>'
|
|
1053
1183
|
|
|
@@ -1060,9 +1190,10 @@ def _runners_view(rows: list[dict]) -> str:
|
|
|
1060
1190
|
batch = lock.get("batch") if isinstance(lock, dict) else None
|
|
1061
1191
|
stop_text = " · ".join(str(v) for v in ((stop or {}).get("reason"), (stop or {}).get("utc")) if v) if isinstance(stop, dict) else ""
|
|
1062
1192
|
runner = str(r.get("runner") or "")
|
|
1063
|
-
pill = ("s-in-progress" if runner == "running" else "s-blocked"
|
|
1193
|
+
pill, icon = (("s-in-progress", "play") if runner == "running" else ("s-blocked", "octagon-alert") if runner
|
|
1194
|
+
else ("s-todo", "circle"))
|
|
1064
1195
|
body.append(f'<tr><td><strong>{_e(r.get("repo"))}</strong></td><td>{_roadmap_link(r)}</td>'
|
|
1065
|
-
f'<td><span class="pill {pill}">{_e(runner or "idle")}</span></td><td>{_e(batch or "-")}</td>'
|
|
1196
|
+
f'<td><span class="pill {pill}">{_icon(icon)}{_e(runner or "idle")}</span></td><td>{_e(batch or "-")}</td>'
|
|
1066
1197
|
f'<td>{_e(stop_text or "-")}</td><td>{_e(r.get("next") or "-")}</td></tr>')
|
|
1067
1198
|
if not body:
|
|
1068
1199
|
return '<p class="empty">No roadmap pages yet.</p>'
|
|
@@ -1078,7 +1209,7 @@ def render_view(kind: str, rows: list[dict]) -> str:
|
|
|
1078
1209
|
"""`<section class="view" id="gates|sessions|runners">`: a cross-repo table over the page summaries. These sections
|
|
1079
1210
|
sit outside any roadmap body, so their ids are not prefixed; links into a roadmap use its prefixed ids."""
|
|
1080
1211
|
title, note, fn = _VIEWS[kind]
|
|
1081
|
-
return f'<section class="view" id="{kind}"
|
|
1212
|
+
return f'<section class="view" id="{kind}">{_h2(_NAV_ICON[kind], title)}<p class="sub">{_e(note)}</p>{fn(rows)}</section>'
|
|
1082
1213
|
|
|
1083
1214
|
|
|
1084
1215
|
_SITE_NOTE = "Shared copy — built by CI. Live sessions and runners show only on the owner's machine."
|
|
@@ -1104,15 +1235,15 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
|
1104
1235
|
blocked = sum(_int(r.get("blocked")) for r in rows)
|
|
1105
1236
|
out = [f'<p class="site-banner" role="note">{_e(banner or _SITE_NOTE)}</p>' if site else "",
|
|
1106
1237
|
'<section class="view" id="inbox"><section class="kpis">',
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
"" if site else
|
|
1238
|
+
_kpi("bell-ring", "k-wait" if count else "", _e(count), "Needs you"),
|
|
1239
|
+
_kpi("circle-check-big", "k-done", f"{_e(phases_done)} / {_e(phases_total)}",
|
|
1240
|
+
"Phases done" + _bar(phases_done, phases_total)),
|
|
1241
|
+
_kpi("octagon-alert", "k-block" if blocked else "", _e(blocked), "Blocked phases"),
|
|
1242
|
+
"" if site else _kpi("activity", "", _e(live), "Runners live"), "</section>",
|
|
1112
1243
|
f'<p class="sub">{_e(len(order))} repos · {_e(len(rows))} roadmaps · {_e(count)} item(s) need you · '
|
|
1113
1244
|
f'generated {_e(now.strftime("%Y-%m-%d %H:%M UTC"))}'
|
|
1114
1245
|
+ ("" if site else " · the inbox reloads every minute") + "</p>",
|
|
1115
|
-
'<section id="needs" class="card wait"
|
|
1246
|
+
f'<section id="needs" class="card wait">{_h2("bell-ring", "Needs you")}']
|
|
1116
1247
|
if not need:
|
|
1117
1248
|
out.append('<p class="empty">Nothing is waiting for you.</p>')
|
|
1118
1249
|
else:
|
|
@@ -1126,7 +1257,7 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
|
1126
1257
|
out.append(f"<tr><td><strong>{_e(repo)}</strong></td><td>owner gate {_e(r.get('slug'))} "
|
|
1127
1258
|
f"{_e(g.get('milestone'))}</td><td>{_e(g.get('reason'))}</td></tr>")
|
|
1128
1259
|
out.append("</table></div>")
|
|
1129
|
-
out.append("</section
|
|
1260
|
+
out.append("</section>" + _h2("map", "Roadmaps"))
|
|
1130
1261
|
if not rows:
|
|
1131
1262
|
out.append('<p class="card empty">No roadmap page has been rendered on this machine yet. Run '
|
|
1132
1263
|
"<code>caddis_roadmap.py render --all</code> in a repo that has <code>.caddis/roadmaps/</code>.</p>")
|
|
@@ -1140,9 +1271,11 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
|
1140
1271
|
if rid is None:
|
|
1141
1272
|
errors.append(f"{r.get('folder')}/{r.get('slug')}: the folder or slug name breaks the naming rule")
|
|
1142
1273
|
continue
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
f'{
|
|
1274
|
+
ids = " ".join(str(m.get("id")) for m in r.get("milestones") or [])
|
|
1275
|
+
out.append(f'<a class="rcard" href="#{rid}" data-q="{_e(r["slug"])} {_e(r.get("repo"))} {_e(ids)}" data-qn="1" '
|
|
1276
|
+
f'data-st="{_row_tone(r)}">'
|
|
1277
|
+
f'<div><strong>{_icon("map")}{_e(r["slug"])}</strong> <span class="empty">{_e(r.get("repo"))} · '
|
|
1278
|
+
f'{_icon("git-branch")}{_e(r.get("branch"))} · {_e(r.get("origin_commit"))}</span></div>'
|
|
1146
1279
|
f'<div>{_bar(_int(r.get("phases_done")), _int(r.get("phases_total")))} '
|
|
1147
1280
|
f'{_e(r.get("milestones_done"))}/{_e(r.get("milestones_total"))} milestones</div>'
|
|
1148
1281
|
f'<div class="dots">{dots}</div>'
|
|
@@ -1168,7 +1301,9 @@ def render_hub(rows: list[dict], errors: list[str], now: datetime.datetime,
|
|
|
1168
1301
|
for kind in _VIEWS:
|
|
1169
1302
|
if not (site and kind in _LOCAL_VIEWS):
|
|
1170
1303
|
out.append(render_view(kind, rows))
|
|
1171
|
-
return _shell(title="Roadmaps", base="", nav=_nav(rows, "", site),
|
|
1304
|
+
return _shell(title="Roadmaps", base="", nav=_nav(rows, "", site), body="".join(out),
|
|
1305
|
+
crumbs='<a href="#inbox">Roadmaps</a> › <strong>Inbox</strong>',
|
|
1306
|
+
generated=now.strftime("%Y-%m-%d %H:%M UTC"),
|
|
1172
1307
|
css=_view_css(rids, site) + (_SITE_CSS if site else ""),
|
|
1173
1308
|
script=_SITE_THEME_SCRIPT if site else None)
|
|
1174
1309
|
|
|
@@ -130,6 +130,7 @@ except ModuleNotFoundError as exc:
|
|
|
130
130
|
sys.path.insert(0, str(_HERE.parent / "claude-harness" / "scripts"))
|
|
131
131
|
import caddis_exit
|
|
132
132
|
|
|
133
|
+
import caddis_created # noqa: E402
|
|
133
134
|
import caddis_notify # noqa: E402
|
|
134
135
|
import caddis_redact # noqa: E402
|
|
135
136
|
import caddis_review # noqa: E402
|
|
@@ -1577,6 +1578,7 @@ def ensure_worktree(repo: Path, slug: str, default: str, root: Path | None = Non
|
|
|
1577
1578
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1578
1579
|
if _git_out(repo, "worktree", "add", "--quiet", "--detach", str(path), f"origin/{default}") is None:
|
|
1579
1580
|
raise _Stop(f"FAILED cannot make the roadmap worktree {path}", caddis_exit.BLOCKED)
|
|
1581
|
+
caddis_created.record(path, "worktree", f"roadmap {slug} runner worktree", repo=repo, by="roadmap-run")
|
|
1580
1582
|
_write_record(_state_file(repo, slug, "worktree"), {"path": str(path), "utc": _iso(datetime.now(timezone.utc))})
|
|
1581
1583
|
return path
|
|
1582
1584
|
|
|
@@ -1606,6 +1608,7 @@ def remove_worktree_if_clean(repo: Path, slug: str, root: Path | None = None) ->
|
|
|
1606
1608
|
if caddis_worktree_safety.safe_remove_worktree(repo, path) is not None:
|
|
1607
1609
|
return f"worktree kept (remove failed): {path}"
|
|
1608
1610
|
_state_file(repo, slug, "worktree").unlink(missing_ok=True)
|
|
1611
|
+
caddis_created.record(path, "worktree", "", repo=repo, status="removed", by="roadmap-run")
|
|
1609
1612
|
return f"worktree removed: {path}"
|
|
1610
1613
|
|
|
1611
1614
|
|