Twelve scripts retired, three domains registered. `reach` now covers nine. generate: `generate-brands` and `generate-corporations` were the second and third copies of the same 24-line build-if-missing-then-exec bash `tooling/atlas` carried, so they collapsed into `core.process.cargo_binary` rather than being ported. `import_economics` shelled out to the first of those, so it now calls that helper — `generated_brands.toml` comes back byte-identical, and the stamp registry swaps the retired wrapper for `core/process.py`. pr: `watchlist-diff` derives its watched set from `generator_sources.py` instead of restating it, so it cannot drift from the stamp check. dev: the environment scripts split decision from performing, per D-263's guarded-exec rule. `godot_plan()` and `worktree_plan()` decide what would happen; `install_godot()`, `install_rust()` and `setup_worktree()` do it. `tooling/test_environment.py` pins the version pin, both override precedences, the already-current skip, the platform refusal and both worktree refusals — none of them performed. `make setup` now installs reach first, since the targets that install rust and godot are reach verbs. Two live bugs found while porting: - The clerk read its decision index from `decisions/README.md`, a path that stopped existing when the DQR tree moved to `governance/`. Every clerk agent has been grepping blind; its prompt pointed at the same dead directory. - The conformance exec-check matched any `x.system()` regardless of receiver, so `platform.system()` read as `os.system()`. Narrowed and re-proved against a real mutant. `process.run` gains `input=`, `timeout=` and a `ProcessTimeout` subclass so a killed run stays distinguishable from a verdict. The pre-push hook no longer merges the clerk's stderr into its stdout — under streaming the last merged line is a JSONL event, which would read as an unrecognised verdict and block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
366 lines
18 KiB
Python
Executable File
366 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Render the pql ticket board as a self-contained interactive HTML snapshot.
|
|
|
|
Companion to clide's two-column board view, for sessions running in the Claude
|
|
desktop app: the output is deployed as a claude.ai Artifact (stable URL across
|
|
redeploys), giving always-visible pql board + ticket-detail reference without
|
|
clide. The page is fully client-side over embedded JSON — artifacts are
|
|
network-sandboxed, so this is a *snapshot*; regenerate + redeploy to refresh.
|
|
|
|
Data comes from pql's native JSON output only (no output parsing):
|
|
- `pql ticket list --full` → every ticket, whole rows
|
|
- `pql ticket show <ids> --with-blockers` → dep edges, batched, for
|
|
non-terminal tickets only (native list output carries no dep edges — if
|
|
pql ever grows `list --with-blockers`, drop the batching here; candidate
|
|
upstream feature request per the no-ad-hoc-wrappers rule)
|
|
- `pql plan status` → header counts
|
|
|
|
Usage: reach pr board-html [--output OUTPUT.html]
|
|
(default output: /tmp/pql-board.html)
|
|
|
|
The emitted file is an Artifact-ready fragment: no doctype/html/head/body
|
|
wrapper (the Artifact pipeline adds those); it starts with <title> + <style>.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
from tooling.core import config, console, process
|
|
|
|
NON_TERMINAL = ["in_progress", "review", "ready", "backlog"]
|
|
SHOW_CHUNK = 40
|
|
|
|
|
|
def pql(*args):
|
|
"""Query the planning store. Goes through the guarded exec (D-263).
|
|
|
|
Was a bare subprocess.run with check=True, which raised
|
|
CalledProcessError — a traceback at someone who wanted to know that pql
|
|
was not installed.
|
|
"""
|
|
out = process.run(
|
|
["pql", *args],
|
|
cwd=config.repo_root(),
|
|
missing_fix="install pql, or check PATH in a non-interactive shell",
|
|
fix=f"run `pql {' '.join(args)}` directly to see what it reports",
|
|
).stdout
|
|
return json.loads(out)
|
|
|
|
|
|
def fetch():
|
|
tickets = pql("ticket", "list", "--full")
|
|
plan = pql("plan", "status")
|
|
|
|
ids = [t["id"] for t in tickets if t.get("status") in NON_TERMINAL]
|
|
blockers = {}
|
|
for i in range(0, len(ids), SHOW_CHUNK):
|
|
chunk = ids[i : i + SHOW_CHUNK]
|
|
shown = pql("ticket", "show", ",".join(chunk), "--with-blockers")
|
|
if isinstance(shown, dict):
|
|
shown = [shown]
|
|
for row in shown:
|
|
edges = row.get("blockers") or row.get("blocked_by") or []
|
|
blockers[row["id"]] = [
|
|
e["id"] if isinstance(e, dict) else e for e in edges
|
|
]
|
|
return tickets, blockers, plan
|
|
|
|
|
|
PAGE = """<title>Settled Reach — pql board</title>
|
|
<style>
|
|
:root {
|
|
--bg: #131110; --panel: #1b1815; --panel-2: #211d19; --line: #2a2521;
|
|
--text: #e8e2d9; --dim: #8a8178; --faint: #5c554e;
|
|
--accent: #e8963c; --accent-dim: #8a5a26;
|
|
--t-initiative: #b07cc6; --t-epic: #e8963c; --t-story: #7fb069;
|
|
--t-task: #6fa8dc; --t-bug: #d9534f;
|
|
--s-progress: #e8963c; --s-review: #c9a227; --s-ready: #5fb3a1;
|
|
--s-backlog: #8a8178; --s-done: #5e7d5e; --s-cancelled: #5c554e;
|
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { background: var(--bg); color: var(--text); font-family: var(--sans);
|
|
margin: 0; height: 100vh; display: flex; flex-direction: column;
|
|
overflow: hidden; }
|
|
a { color: var(--accent); text-decoration: none; }
|
|
|
|
header { display: flex; align-items: baseline; gap: 16px; padding: 10px 16px;
|
|
border-bottom: 1px solid var(--line); background: var(--panel);
|
|
flex: 0 0 auto; flex-wrap: wrap; }
|
|
header h1 { font: 600 14px/1 var(--mono); letter-spacing: .06em;
|
|
text-transform: uppercase; color: var(--accent); margin: 0; }
|
|
header .counts { font: 11px/1 var(--mono); color: var(--dim); }
|
|
header .stamp { margin-left: auto; font: 11px/1 var(--mono); color: var(--faint); }
|
|
|
|
main { flex: 1 1 auto; display: flex; min-height: 0; }
|
|
|
|
/* ---- left rail ---- */
|
|
#rail { flex: 0 0 372px; border-right: 1px solid var(--line); display: flex;
|
|
flex-direction: column; min-height: 0; background: var(--bg); }
|
|
#tools { padding: 10px 12px 8px; display: flex; flex-direction: column; gap: 8px;
|
|
border-bottom: 1px solid var(--line); }
|
|
#filter { background: var(--panel-2); border: 1px solid var(--line); color: var(--text);
|
|
font: 12px var(--mono); padding: 6px 9px; border-radius: 3px; width: 100%; }
|
|
#filter:focus { outline: 1px solid var(--accent-dim); }
|
|
#chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.chip { font: 10px/1 var(--mono); letter-spacing: .05em; text-transform: uppercase;
|
|
padding: 4px 8px; border-radius: 999px; border: 1px solid var(--line);
|
|
color: var(--dim); cursor: pointer; user-select: none; }
|
|
.chip.on { color: var(--text); border-color: currentcolor; }
|
|
.chip.on.c-initiative { color: var(--t-initiative); }
|
|
.chip.on.c-epic { color: var(--t-epic); }
|
|
.chip.on.c-story { color: var(--t-story); }
|
|
.chip.on.c-task { color: var(--t-task); }
|
|
.chip.on.c-bug { color: var(--t-bug); }
|
|
|
|
#list { overflow-y: auto; flex: 1 1 auto; min-height: 0; }
|
|
.ghead { position: sticky; top: 0; z-index: 2; display: flex; gap: 8px;
|
|
align-items: baseline; padding: 8px 12px 6px; background: var(--bg);
|
|
border-bottom: 1px solid var(--line); cursor: pointer; user-select: none; }
|
|
.ghead .arrow { color: var(--faint); font: 10px var(--mono); width: 10px; }
|
|
.ghead .gname { font: 600 10px/1 var(--mono); letter-spacing: .12em;
|
|
text-transform: uppercase; }
|
|
.ghead .gcount { font: 10px/1 var(--mono); color: var(--faint); }
|
|
.card { padding: 8px 12px 9px 14px; border-bottom: 1px solid var(--line);
|
|
border-left: 2px solid transparent; cursor: pointer; }
|
|
.card:hover { background: var(--panel); }
|
|
.card.sel { background: var(--panel-2); border-left-color: var(--accent); }
|
|
.card .chain { font: 10px/1.4 var(--mono); color: var(--faint); }
|
|
.card .idrow { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
|
|
.dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
|
|
.card .tid { font: 600 11px/1 var(--mono); color: var(--dim); }
|
|
.card .title { font: 12.5px/1.35 var(--sans); margin-top: 3px; }
|
|
.card.done .title, .card.cancelled .title { color: var(--dim); }
|
|
.card.cancelled .title { text-decoration: line-through; }
|
|
.prio { font: 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase;
|
|
padding: 2px 5px; border-radius: 2px; margin-left: auto; }
|
|
.prio.critical { color: var(--t-bug); border: 1px solid var(--t-bug); }
|
|
.prio.high { color: var(--accent); border: 1px solid var(--accent-dim); }
|
|
|
|
/* ---- detail pane ---- */
|
|
#detail { flex: 1 1 auto; overflow-y: auto; min-width: 0; padding: 22px 30px 60px; }
|
|
#empty { color: var(--faint); font: 13px var(--mono); padding: 40px; }
|
|
.d-chain { font: 11px/1.5 var(--mono); color: var(--dim); }
|
|
.d-chain a { color: var(--dim); } .d-chain a:hover { color: var(--accent); }
|
|
#d-idrow { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
|
|
#d-id { font: 700 15px var(--mono); color: var(--accent); }
|
|
#d-prio { font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
|
#d-copy { margin-left: auto; font: 10px/1 var(--mono); letter-spacing: .06em;
|
|
text-transform: uppercase; background: none; border: 1px solid var(--line);
|
|
border-radius: 3px; color: var(--dim); padding: 5px 9px; cursor: pointer; }
|
|
#d-copy:hover { color: var(--accent); border-color: var(--accent-dim); }
|
|
#d-title { font: 600 21px/1.3 var(--sans); text-wrap: balance; margin: 2px 0 14px;
|
|
max-width: 46ch; }
|
|
#d-status { display: flex; border: 1px solid var(--line); border-radius: 3px;
|
|
overflow: hidden; width: max-content; max-width: 100%; margin-bottom: 16px; }
|
|
#d-status span { font: 9.5px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase;
|
|
padding: 6px 13px; color: var(--faint); border-right: 1px solid var(--line); }
|
|
#d-status span:last-child { border-right: 0; }
|
|
#d-status span.on { background: var(--panel-2); color: var(--accent);
|
|
box-shadow: inset 0 -2px 0 var(--accent); }
|
|
#d-meta { display: grid; grid-template-columns: max-content 1fr; gap: 5px 18px;
|
|
font: 12px/1.5 var(--mono); margin-bottom: 18px; max-width: 70ch; }
|
|
#d-meta dt { color: var(--faint); text-transform: uppercase; font-size: 10px;
|
|
letter-spacing: .08em; padding-top: 2px; }
|
|
#d-meta dd { margin: 0; color: var(--text); overflow-wrap: anywhere; }
|
|
.tchip { display: inline-block; font: 11px/1 var(--mono); padding: 3px 7px;
|
|
border: 1px solid var(--line); border-radius: 3px; margin: 0 5px 5px 0;
|
|
color: var(--text); cursor: pointer; }
|
|
.tchip:hover { border-color: var(--accent-dim); }
|
|
.tchip .st { margin-left: 6px; }
|
|
.tchip.open .st { color: var(--accent); }
|
|
.tchip.closed .st { color: var(--s-done); }
|
|
#d-desc { font: 13.5px/1.65 var(--sans); max-width: 72ch; color: var(--text); }
|
|
#d-desc p { margin: 0 0 12px; }
|
|
#d-desc p + p { border-top: 1px dashed var(--line); padding-top: 12px; }
|
|
.seclabel { font: 600 10px/1 var(--mono); letter-spacing: .12em;
|
|
text-transform: uppercase; color: var(--faint); margin: 18px 0 8px; }
|
|
::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-thumb { background: var(--line); }
|
|
@media (max-width: 900px) {
|
|
main { flex-direction: column; overflow-y: auto; }
|
|
#rail { flex: 0 0 auto; max-height: 45vh; border-right: 0;
|
|
border-bottom: 1px solid var(--line); }
|
|
}
|
|
</style>
|
|
|
|
<header>
|
|
<h1>Settled Reach · pql</h1>
|
|
<span class="counts" id="counts"></span>
|
|
<span class="stamp">snapshot __STAMP__</span>
|
|
</header>
|
|
<main>
|
|
<div id="rail">
|
|
<div id="tools">
|
|
<input id="filter" type="search" placeholder="Filter tickets… (id or title)" />
|
|
<div id="chips"></div>
|
|
</div>
|
|
<div id="list"></div>
|
|
</div>
|
|
<div id="detail"><div id="empty">Select a ticket.</div></div>
|
|
</main>
|
|
|
|
<script type="application/json" id="data">__DATA__</script>
|
|
<script>
|
|
const RAW = JSON.parse(document.getElementById("data").textContent);
|
|
const T = RAW.tickets, BLK = RAW.blockers, PLAN = RAW.plan;
|
|
const byId = Object.fromEntries(T.map(t => [t.id, t]));
|
|
const children = {}; const blocks = {};
|
|
for (const t of T) if (t.parent_id) (children[t.parent_id] ||= []).push(t.id);
|
|
for (const [id, bs] of Object.entries(BLK)) for (const b of bs) (blocks[b] ||= []).push(id);
|
|
const TYPES = ["initiative","epic","story","task","bug"];
|
|
const GROUPS = [
|
|
["in_progress","IN PROGRESS",true],["review","REVIEW",true],["ready","READY",true],
|
|
["backlog","BACKLOG",true],["done","DONE",false],["cancelled","CANCELLED",false]];
|
|
const STATUSES = ["backlog","ready","in_progress","review","done","cancelled"];
|
|
const SCOLOR = {in_progress:"var(--s-progress)",review:"var(--s-review)",ready:"var(--s-ready)",
|
|
backlog:"var(--s-backlog)",done:"var(--s-done)",cancelled:"var(--s-cancelled)"};
|
|
const typeOn = Object.fromEntries(TYPES.map(t=>[t,true]));
|
|
const openGroups = Object.fromEntries(GROUPS.map(([k,,d])=>[k,d]));
|
|
let query = "", selected = null;
|
|
|
|
const counts = PLAN.tickets?.by_status || {};
|
|
document.getElementById("counts").textContent =
|
|
`${PLAN.tickets?.total ?? T.length} tickets · ${counts.in_progress||0} wip · ` +
|
|
`${counts.backlog||0} backlog · ${counts.done||0} done`;
|
|
|
|
function chain(t){ const parts=[]; let p=t.parent_id;
|
|
while(p && byId[p]){ parts.unshift(byId[p].id); p=byId[p].parent_id; }
|
|
return parts; }
|
|
function esc(s){ const d=document.createElement("span"); d.textContent=s??""; return d.innerHTML; }
|
|
function terminal(s){ return s==="done"||s==="cancelled"; }
|
|
|
|
const chipsEl = document.getElementById("chips");
|
|
for (const ty of TYPES){
|
|
const c=document.createElement("span");
|
|
c.className=`chip on c-${ty}`; c.textContent=ty; c.tabIndex=0;
|
|
const flip=()=>{ typeOn[ty]=!typeOn[ty]; c.classList.toggle("on",typeOn[ty]); renderList(); };
|
|
c.onclick=flip; c.onkeydown=e=>{ if(e.key==="Enter"||e.key===" "){e.preventDefault();flip();} };
|
|
chipsEl.appendChild(c);
|
|
}
|
|
document.getElementById("filter").addEventListener("input", e=>{
|
|
query=e.target.value.trim().toLowerCase(); renderList(); });
|
|
|
|
function visible(){
|
|
return T.filter(t => typeOn[t.type] &&
|
|
(!query || t.id.toLowerCase().includes(query) ||
|
|
(t.title||"").toLowerCase().includes(query)));
|
|
}
|
|
|
|
function renderList(){
|
|
const listEl=document.getElementById("list"); listEl.innerHTML="";
|
|
const vis=visible();
|
|
for (const [key,label] of GROUPS.map(g=>[g[0],g[1]])){
|
|
const rows=vis.filter(t=>t.status===key);
|
|
if(!rows.length) continue;
|
|
const gh=document.createElement("div"); gh.className="ghead";
|
|
gh.innerHTML=`<span class="arrow">${openGroups[key]?"▾":"▸"}</span>`+
|
|
`<span class="gname" style="color:${SCOLOR[key]}">${label}</span>`+
|
|
`<span class="gcount">· ${rows.length}</span>`;
|
|
gh.onclick=()=>{ openGroups[key]=!openGroups[key]; renderList(); };
|
|
listEl.appendChild(gh);
|
|
if(!openGroups[key]) continue;
|
|
for (const t of rows){
|
|
const c=document.createElement("div");
|
|
c.className=`card ${t.status}${t.id===selected?" sel":""}`;
|
|
c.dataset.id=t.id;
|
|
const ch=chain(t);
|
|
c.innerHTML=(ch.length?`<div class="chain">${ch.map(esc).join(" → ")}</div>`:"")+
|
|
`<div class="idrow"><span class="dot" style="background:var(--t-${t.type})"></span>`+
|
|
`<span class="tid">${esc(t.id)}</span>`+
|
|
(t.priority==="critical"||t.priority==="high"
|
|
?`<span class="prio ${t.priority}">${t.priority}</span>`:"")+
|
|
`</div><div class="title">${esc(t.title)}</div>`;
|
|
c.onclick=()=>select(t.id);
|
|
listEl.appendChild(c);
|
|
}
|
|
}
|
|
}
|
|
|
|
function tchip(id){
|
|
const t=byId[id]; if(!t) return `<span class="tchip">${esc(id)}</span>`;
|
|
const cls=terminal(t.status)?"closed":"open";
|
|
return `<span class="tchip ${cls}" data-id="${esc(id)}" title="${esc(t.title)}">`+
|
|
`${esc(id)}<span class="st">${terminal(t.status)?"✓":esc(t.status)}</span></span>`;
|
|
}
|
|
|
|
function select(id, push=true){
|
|
selected=id; if(push) location.hash=id;
|
|
renderList();
|
|
const t=byId[id]; const d=document.getElementById("detail");
|
|
if(!t){ d.innerHTML=`<div id="empty">Unknown ticket ${esc(id)}.</div>`; return; }
|
|
const ch=chain(t);
|
|
const kids=(children[t.id]||[]);
|
|
const bl=BLK[t.id]||[]; const bs=blocks[t.id]||[];
|
|
const meta=[["type",esc(t.type)],["team",esc(t.team||"—")],
|
|
["priority",esc(t.priority||"—")],["decision",esc(t.decision_ref||"—")],
|
|
["created",esc((t.created_at||"").slice(0,16))],
|
|
["updated",esc((t.updated_at||"").slice(0,16))],
|
|
["record",esc(t.record_id||"")]];
|
|
d.innerHTML=
|
|
(ch.length?`<div class="d-chain">${ch.map(p=>`<a href="#${esc(p)}">${esc(p)}</a>`).join(" → ")}</div>`:"")+
|
|
`<div id="d-idrow"><span class="dot" style="background:var(--t-${t.type});width:9px;height:9px"></span>`+
|
|
`<span id="d-id">${esc(t.id)}</span>`+
|
|
`<span id="d-prio" style="color:${t.priority==="critical"?"var(--t-bug)":t.priority==="high"?"var(--accent)":"var(--dim)"}">${esc(t.priority||"")}</span>`+
|
|
`<button id="d-copy" title="Copy a 'viewing …' line to paste to Claude">copy ref</button></div>`+
|
|
`<div id="d-title">${esc(t.title)}</div>`+
|
|
`<div id="d-status">${STATUSES.map(s=>`<span class="${s===t.status?"on":""}">${s.replace("_"," ")}</span>`).join("")}</div>`+
|
|
`<dl id="d-meta">${meta.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>`+
|
|
(bl.length?`<div class="seclabel">Blocked by</div><div>${bl.map(tchip).join("")}</div>`:"")+
|
|
(bs.length?`<div class="seclabel">Blocks</div><div>${bs.map(tchip).join("")}</div>`:"")+
|
|
(kids.length?`<div class="seclabel">Children · ${kids.length}</div><div>${kids.map(tchip).join("")}</div>`:"")+
|
|
`<div class="seclabel">Description</div>`+
|
|
`<div id="d-desc">${(t.description||"—").split(/\\n\\n+/).map(p=>`<p>${esc(p).replace(/\\n/g,"<br>")}</p>`).join("")}</div>`;
|
|
d.scrollTop=0;
|
|
d.querySelectorAll(".tchip[data-id]").forEach(el=>el.onclick=()=>select(el.dataset.id));
|
|
const cp=document.getElementById("d-copy");
|
|
cp.onclick=()=>{
|
|
const txt=`viewing ${t.id} — ${t.title}`;
|
|
const done=()=>{ cp.textContent="copied"; setTimeout(()=>{cp.textContent="copy ref";},1200); };
|
|
if(navigator.clipboard&&navigator.clipboard.writeText)
|
|
navigator.clipboard.writeText(txt).then(done,()=>window.prompt("Copy:",txt));
|
|
else window.prompt("Copy:",txt);
|
|
};
|
|
const card=document.querySelector(`.card[data-id="${CSS.escape(id)}"]`);
|
|
if(card) card.scrollIntoView({block:"nearest"});
|
|
}
|
|
|
|
window.addEventListener("hashchange",()=>{ const h=location.hash.slice(1);
|
|
if(h && h!==selected) select(h,false); });
|
|
document.addEventListener("keydown",e=>{
|
|
if(e.target.id==="filter") return;
|
|
if(e.key!=="ArrowDown"&&e.key!=="ArrowUp") return;
|
|
const vis=visible().filter(t=>openGroups[t.status]);
|
|
const order=[]; for(const [k] of GROUPS) order.push(...vis.filter(t=>t.status===k));
|
|
if(!order.length) return;
|
|
const i=order.findIndex(t=>t.id===selected);
|
|
const n=e.key==="ArrowDown"?Math.min(order.length-1,i+1):Math.max(0,i<0?0:i-1);
|
|
select(order[n].id); e.preventDefault();
|
|
});
|
|
|
|
renderList();
|
|
const initial=location.hash.slice(1);
|
|
if(initial&&byId[initial]) select(initial,false);
|
|
</script>
|
|
"""
|
|
|
|
|
|
def run(out_path=None) -> None:
|
|
"""Render the board to `out_path`, defaulting to the repo's scratch dir."""
|
|
out_path = str(out_path) if out_path else str(config.path(".cache", "pql-board.html"))
|
|
tickets, blockers, plan = fetch()
|
|
stamp = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
data = json.dumps(
|
|
{"tickets": tickets, "blockers": blockers, "plan": plan},
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
).replace("</", "<\\/")
|
|
page = PAGE.replace("__STAMP__", stamp).replace("__DATA__", data)
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
|
f.write(page)
|
|
console.verdict(
|
|
f"wrote {out_path}: {len(tickets)} tickets, "
|
|
f"{sum(len(v) for v in blockers.values())} dep edges, "
|
|
f"{len(page) // 1024} KiB"
|
|
)
|