diff --git a/contrib/board/README.md b/contrib/board/README.md
new file mode 100644
index 0000000..948755c
--- /dev/null
+++ b/contrib/board/README.md
@@ -0,0 +1,35 @@
+# git-issue-board
+
+One self-contained HTML page over a repository's git-issue notes. git-issue
+has no web view; this is a read-only one for a laptop or a phone.
+
+```sh
+git fetch origin 'refs/notes/issue-*:refs/notes/issue-*'
+contrib/board/git-issue-board.py # writes git-issue-board.html in the cwd
+contrib/board/git-issue-board.py --repo ~/src/other -o /tmp/board/index.html
+```
+
+Python 3.8 or later, standard library only; it shells out to `git issue
+--porcelain list`, `export` and `show`, so `git-issue` must be on `PATH`.
+
+What the page shows:
+
+- Issues grouped by status in working order: in progress, review, blocked,
+ open, deferred, closed. Priority first within a group.
+- A segmented filter (Active hides closed), an assignee dropdown and a search
+ over id, title, body and comments. The state lives in the URL hash, so a
+ filtered view is a link.
+- A dependency graph, as inline SVG, for the issues that take part in an edge:
+ solid for `blocks` and `parent_of`, dashed for `relates_to`. Boxes link to
+ their cards.
+- Each card expands to the description, comments, author, dates and the
+ `git issue show` line for it.
+- Light and dark follow the OS; the layout holds at 390 px.
+
+The page is a snapshot. Its footer says when it was generated and from which
+commit; fetch and re-run to refresh. To read it on a phone, serve the output
+directory with any static server (`python3 -m http.server 8080` in that
+directory) and open it over the LAN or a tailnet.
+
+Known edge: the renderer runs one `export` per issue because `export` with no
+ids prints nothing in 1.0.0-rc6 (#3); about 0.1 s per issue.
diff --git a/contrib/board/git-issue-board.py b/contrib/board/git-issue-board.py
new file mode 100755
index 0000000..25a492a
--- /dev/null
+++ b/contrib/board/git-issue-board.py
@@ -0,0 +1,365 @@
+#!/usr/bin/env python3
+"""Render a repository's git-issue board as one self-contained HTML page.
+
+git-issue keeps issues as git notes and has no web view. This reads them
+through the tool's porcelain (`list`: twelve tab-separated columns per
+docs/rfc/draft-ndn-porcelain-output-00.md) plus one `export` and one `show`
+per issue for the description and comments, and writes a page that works
+offline, on a phone, and in light or dark mode: issues grouped by status in
+working order, a status/assignee/search filter kept in the URL hash so a view
+is a link, a dependency graph as inline SVG for the issues that have edges,
+and each card expanding to its body and comments.
+
+ contrib/board/git-issue-board.py # the repository you are in
+ contrib/board/git-issue-board.py --repo PATH -o board.html
+
+Python 3.8+, standard library only. The page is a snapshot: its footer says
+when it was generated and from which commit; fetch the notes and re-run to
+refresh. Serve the output directory with any static server for a phone.
+"""
+
+import argparse
+import datetime as dt
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+
+ANSI = re.compile(r"\x1b\[[0-9;]*m")
+COLS = ["repo", "id", "status", "priority", "assignee", "role", "updated",
+ "blocks", "depends_on", "parent_of", "relates_to", "title"]
+# Display order: what is moving first, what is finished last.
+STATUS_ORDER = ["in_progress", "review", "blocked", "open", "deferred", "closed"]
+PRIO_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
+
+
+def gi(clone, *args):
+ out = subprocess.run(["git", "-C", clone, "issue", *args], capture_output=True,
+ text=True, env={**os.environ, "GIT_ISSUE_NO_HEADER": "1"})
+ return ANSI.sub("", out.stdout)
+
+
+def read_issues(clone):
+ issues = {}
+ for line in gi(clone, "--porcelain", "list").splitlines():
+ parts = line.split("\t")
+ if len(parts) != len(COLS):
+ continue
+ rec = dict(zip(COLS, parts))
+ for k in ("blocks", "depends_on", "parent_of", "relates_to"):
+ rec[k] = [x for x in rec[k].split(",") if x]
+ issues[rec["id"]] = rec
+ # One export per id: in 1.0.0-rc6 `export` with no ids prints nothing (#3).
+ # show carries the comments, export the clean fields.
+ for iid, rec in issues.items():
+ fields = {}
+ for line in gi(clone, "export", iid).splitlines():
+ if line.startswith("---"):
+ break
+ m = re.match(r"^([a-z_]+): ?(.*)$", line)
+ if m:
+ fields[m.group(1)] = m.group(2)
+ elif line.startswith(" ") and fields:
+ last = list(fields)[-1]
+ fields[last] += "\n" + line.strip()
+ rec["description"] = fields.get("description", "")
+ rec["author"] = fields.get("author", "")
+ rec["created"] = fields.get("created", "")
+ show = gi(clone, "show", iid)
+ rec["comments"] = show.split("Comments:\n---------\n", 1)[1].strip() if "Comments:\n" in show else ""
+ return issues
+
+
+def head_info(clone):
+ sha = subprocess.run(["git", "-C", clone, "rev-parse", "--short", "HEAD"],
+ capture_output=True, text=True).stdout.strip()
+ remote = subprocess.run(["git", "-C", clone, "remote", "get-url", "origin"],
+ capture_output=True, text=True).stdout.strip()
+ remote = re.sub(r"^(https://github\.com/|git@github\.com:)|\.git$", "", remote) or os.path.basename(clone)
+ return sha, remote
+
+
+# --- dependency graph as inline SVG --------------------------------------------
+# Only issues that take part in an edge are drawn; a 40-node graph with five
+# edges is a list with a picture attached, not a picture. Layers come from the
+# longest path over blocks/parent_of edges; relates_to is drawn dashed and does
+# not affect layering.
+
+def layout(issues):
+ hard, soft = [], []
+ for rec in issues.values():
+ for t in rec["blocks"]:
+ if t in issues:
+ hard.append((rec["id"], t))
+ for t in rec["parent_of"]:
+ if t in issues:
+ hard.append((rec["id"], t))
+ for t in rec["relates_to"]:
+ if t in issues and (t, rec["id"]) not in soft:
+ soft.append((rec["id"], t))
+ nodes = sorted({n for e in hard + soft for n in e})
+ if not nodes:
+ return None
+ layer = {n: 0 for n in nodes}
+ for _ in range(len(nodes)):
+ changed = False
+ for a, b in hard:
+ if layer[b] < layer[a] + 1:
+ layer[b] = layer[a] + 1
+ changed = True
+ if not changed:
+ break
+ # Components side by side: relates_to pairs with no hard edges would
+ # otherwise all sit in layer 0 as one tall column.
+ cols = {}
+ for n in nodes:
+ cols.setdefault(layer[n], []).append(n)
+ W, H, GX, GY, PAD = 230, 44, 60, 16, 12
+ pos = {}
+ for l, ns in cols.items():
+ for i, n in enumerate(sorted(ns, key=lambda x: issues[x]["title"])):
+ pos[n] = (PAD + l * (W + GX), PAD + i * (H + GY))
+ width = PAD * 2 + (max(cols) + 1) * W + max(cols) * GX
+ height = PAD * 2 + max(len(v) for v in cols.values()) * (H + GY) - GY
+ out = [f'")
+ return "\n".join(out)
+
+
+# --- page ----------------------------------------------------------------------
+
+CSS = """
+:root{color-scheme:light;
+ --ink:#141a24;--muted:#5b6675;--faint:#8a94a3;--line:#d3dae3;
+ --bg:#f5f7fa;--card:#ffffff;--card2:#fcfdff;
+ --ok:#15803d;--ok-bg:#e9f6ec;--ok-line:#8fd0a3;
+ --active:#1d4ed8;--active-bg:#eaf0fe;--active-line:#9db8f6;
+ --defer:#475569;--defer-bg:#eef1f5;
+ --block:#b91c1c;--block-bg:#fdecec;
+ --warn:#b45309;--warn-bg:#fdf3e1;}
+@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;
+ --ink:#e6e9ee;--muted:#9aa4b2;--faint:#6b7686;--line:#2b333f;
+ --bg:#0e1218;--card:#161b22;--card2:#1a2029;
+ --ok:#56d98a;--ok-bg:#11291b;--ok-line:#255f3c;
+ --active:#7ea2ff;--active-bg:#16233f;--active-line:#2f4b8f;
+ --defer:#9aa4b2;--defer-bg:#1c222b;
+ --block:#f87171;--block-bg:#3a1515;
+ --warn:#fbbf24;--warn-bg:#3a2a0a;}}
+:root[data-theme="dark"]{color-scheme:dark;
+ --ink:#e6e9ee;--muted:#9aa4b2;--faint:#6b7686;--line:#2b333f;
+ --bg:#0e1218;--card:#161b22;--card2:#1a2029;
+ --ok:#56d98a;--ok-bg:#11291b;--ok-line:#255f3c;
+ --active:#7ea2ff;--active-bg:#16233f;--active-line:#2f4b8f;
+ --defer:#9aa4b2;--defer-bg:#1c222b;
+ --block:#f87171;--block-bg:#3a1515;
+ --warn:#fbbf24;--warn-bg:#3a2a0a;}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);
+ font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+.mono,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}
+header{background:var(--card);border-bottom:1px solid var(--line);padding:10px 16px;
+ display:flex;gap:14px;align-items:center;flex-wrap:wrap;position:sticky;top:0;z-index:2}
+h1{font-size:15px;margin:0;font-weight:600;white-space:nowrap}
+.scope{font-size:11px;color:var(--faint);border:1px solid var(--line);border-radius:99px;padding:2px 9px}
+.counts{color:var(--muted);font-size:12px}
+.controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-left:auto}
+input[type=search]{padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card2);
+ color:var(--ink);min-width:170px;font-size:13px}
+.seg{display:inline-flex;gap:1px;padding:2px;background:var(--card2);border:1px solid var(--line);border-radius:8px;flex-wrap:wrap}
+.seg button{appearance:none;border:0;background:transparent;color:var(--muted);font:600 11px/1 inherit;
+ font-family:inherit;padding:5px 8px;border-radius:6px;cursor:pointer}
+.seg button[aria-pressed="true"]{background:var(--active-bg);color:var(--active)}
+main{padding:16px;max-width:1100px;margin:0 auto}
+section.group,section.graphbox{margin-bottom:22px}
+section.group h2,section.graphbox h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);margin:0 0 8px;
+ display:flex;gap:8px;align-items:center}
+section.group h2 .n{color:var(--muted);font-weight:500}
+.issue{background:var(--card);border:1px solid var(--line);border-radius:9px;margin-bottom:8px;padding:0}
+.issue summary{list-style:none;cursor:pointer;padding:10px 12px;display:grid;
+ grid-template-columns:auto 1fr auto;gap:6px 10px;align-items:baseline}
+.issue summary::-webkit-details-marker{display:none}
+.issue .id{color:var(--faint);font-size:12px}
+.issue .title{font-weight:500}
+.issue .meta{grid-column:2/4;display:flex;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
+.badge{font-size:11px;padding:1px 7px;border-radius:99px;border:1px solid var(--line);white-space:nowrap}
+.p-high,.p-critical{color:var(--block);border-color:var(--block);background:var(--block-bg)}
+.p-medium{color:var(--warn);border-color:var(--warn);background:var(--warn-bg)}
+.p-low{color:var(--defer);background:var(--defer-bg)}
+.st{font-size:11px;padding:1px 7px;border-radius:99px;white-space:nowrap}
+.st-in_progress{background:var(--active-bg);color:var(--active)}
+.st-review{background:var(--active-bg);color:var(--active)}
+.st-blocked{background:var(--block-bg);color:var(--block)}
+.st-open{background:var(--card2);color:var(--muted);border:1px solid var(--line)}
+.st-deferred{background:var(--defer-bg);color:var(--defer)}
+.st-closed{background:var(--ok-bg);color:var(--ok)}
+.body{padding:0 12px 12px;border-top:1px solid var(--line);color:var(--ink)}
+.body p{margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere}
+.body .kv{font-size:12px;color:var(--muted);display:flex;gap:14px;flex-wrap:wrap;margin-top:8px}
+.body a{color:var(--active);text-decoration:none}
+.comments{margin-top:8px;padding:8px 10px;background:var(--card2);border-radius:7px;font-size:13px;white-space:pre-wrap}
+.issue:target{outline:2px solid var(--active);outline-offset:1px}
+.issue.hide{display:none}
+#graph{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:12px;overflow:auto;margin-bottom:22px}
+#graph svg{display:block;max-width:none}
+#graph .edge{fill:none;stroke:var(--block);stroke-width:1.6;color:var(--block)}
+#graph .edge.soft{stroke:var(--muted);stroke-dasharray:5 4;color:var(--muted)}
+#graph .node rect{fill:var(--card2);stroke:var(--line);stroke-width:1.2}
+#graph .node.st-in_progress rect{stroke:var(--active-line);fill:var(--active-bg)}
+#graph .node.st-blocked rect{stroke:var(--block);fill:var(--block-bg)}
+#graph .node.st-closed rect{stroke:var(--ok-line);fill:var(--ok-bg)}
+#graph text{fill:var(--ink);font-size:12px}
+#graph .nid{fill:var(--faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
+footer{color:var(--faint);font-size:11px;padding:12px 16px 28px;text-align:center}
+footer code{font-size:11px}
+@media (max-width:600px){main{padding:10px}.controls{margin-left:0}}
+"""
+
+JS = """
+const Q=document.getElementById('q'),SEG=document.getElementById('seg'),A=document.getElementById('asg');
+let status='active',asg='';
+function apply(){
+ const q=Q.value.trim().toLowerCase();
+ let shown=0;
+ document.querySelectorAll('.issue').forEach(el=>{
+ const st=el.dataset.status;
+ const okS=status==='all'||(status==='active'?st!=='closed':st===status);
+ const okA=!asg||el.dataset.assignee===asg;
+ const okQ=!q||el.dataset.text.includes(q);
+ const ok=okS&&okA&&okQ; el.classList.toggle('hide',!ok); if(ok)shown++;
+ });
+ document.querySelectorAll('section.group').forEach(g=>{
+ const n=g.querySelectorAll('.issue:not(.hide)').length;
+ g.style.display=n?'':'none'; g.querySelector('.n').textContent=n;
+ });
+ document.getElementById('shown').textContent=shown;
+ SEG.querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.v===status));
+ const h=new URLSearchParams(); if(status!=='active')h.set('status',status); if(q)h.set('q',q); if(asg)h.set('asg',asg);
+ history.replaceState(null,'',h.toString()?'#'+h:location.pathname);
+}
+SEG.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;status=b.dataset.v;apply();});
+Q.addEventListener('input',apply); A.addEventListener('change',()=>{asg=A.value;apply();});
+(function(){const h=new URLSearchParams(location.hash.slice(1));
+ if(h.get('status'))status=h.get('status'); if(h.get('q'))Q.value=h.get('q'); if(h.get('asg')){asg=h.get('asg');A.value=asg;}
+ if(location.hash.startsWith('#i-')){status='all';}
+ apply();})();
+"""
+
+
+def short_name(s):
+ # "Norman Nunley, Jr" -> "Norman"; keeps the full name in the title attribute.
+ return s.split(",")[0].split()[0] if s else "—"
+
+
+def render(issues, graph, sha, remote, clone):
+ now = dt.datetime.now(dt.timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %Z")
+ counts = {s: sum(1 for r in issues.values() if r["status"] == s) for s in STATUS_ORDER}
+ assignees = sorted({r["assignee"] for r in issues.values() if r["assignee"]})
+ parts = [f"
git-issue · {html.escape(remote)}",
+ f"0 shown · {len(issues)} total · "
+ + " · ".join(f"{counts[s]} {s.replace('_', ' ')}" for s in STATUS_ORDER if counts[s]) + "",
+ "
"
+ + "".join(f"" for v, l in
+ [("active", "Active"), ("in_progress", "In progress"), ("blocked", "Blocked"),
+ ("open", "Open"), ("deferred", "Deferred"), ("closed", "Closed"), ("all", "All")])
+ + "
"]
+ if graph:
+ parts.append("
Dependencies solid = blocks · dashed = relates to · click a box
"
+ f"
{graph}
")
+ def link_ids(ids):
+ return ", ".join(f"#{i}" for i in ids)
+ for st in STATUS_ORDER:
+ rows = [r for r in issues.values() if r["status"] == st]
+ if not rows:
+ continue
+ rows.sort(key=lambda r: (PRIO_RANK.get(r["priority"], 9), r["title"].lower()))
+ parts.append(f"
{st.replace('_', ' ')} {len(rows)}
")
+ for r in rows:
+ text = html.escape(" ".join([r["id"], r["title"], r["description"], r["comments"], r["assignee"]]).lower(), quote=True)
+ upd = r["updated"][:10]
+ deps = []
+ if r["blocks"]: deps.append("blocks " + link_ids(r["blocks"]))
+ if r["depends_on"]: deps.append("depends on " + link_ids(r["depends_on"]))
+ if r["parent_of"]: deps.append("parent of " + link_ids(r["parent_of"]))
+ if r["relates_to"]: deps.append("relates to " + link_ids(r["relates_to"]))
+ parts.append(
+ f""
+ f"#{r['id']}{html.escape(r['title'])}"
+ f"{r['priority']}"
+ f"{st.replace('_', ' ')}"
+ f"{html.escape(short_name(r['assignee']))}"
+ f"updated {upd}" + (f"{' · '.join(deps)}" if deps else "") + ""
+ f"
{html.escape(r['description']) or 'no description'}
"
+ + (f"
{html.escape(r['comments'])}
" if r["comments"] else "")
+ + f"
author {html.escape(r['author'])}created {r['created'][:10]}"
+ f"git issue show {r['id']}
")
+ parts.append("")
+ parts.append(f""
+ f"")
+ return "".join(parts)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ ap.add_argument("--repo", default=".", help="path inside the repository (default: cwd)")
+ ap.add_argument("-o", "--out", default="git-issue-board.html")
+ a = ap.parse_args()
+ top = subprocess.run(["git", "-C", a.repo, "rev-parse", "--show-toplevel"], capture_output=True, text=True)
+ if top.returncode != 0:
+ sys.exit(f"git-issue-board: {a.repo} is not inside a git repository")
+ clone = top.stdout.strip()
+ issues = read_issues(clone)
+ if not issues:
+ sys.exit(f"git-issue-board: no issues in {clone} (git fetch origin 'refs/notes/issue-*:refs/notes/issue-*' first?)")
+ sha, remote = head_info(clone)
+ page = render(issues, layout(issues), sha, remote, clone)
+ if os.path.dirname(a.out):
+ os.makedirs(os.path.dirname(a.out), exist_ok=True)
+ with open(a.out, "w") as f:
+ f.write(page)
+ print(f"git-issue-board: {len(issues)} issues -> {a.out} ({len(page) // 1024} KB)")
+
+
+if __name__ == "__main__":
+ main()