feat(skills): pql-board panel from template — support/make-panel generator

The interactive panel is no longer hand-composed per render: support/
panel-template.html carries the widget markup (CDS tokens, pick-up/detail
sendPrompt contract) and support/make-panel fills it with live pql data —
actionable WIP + unblocked leaf work from the active phase AND the
maintenance initiative T-1037 (the unblocked head T-1174 lives there, not
under the phase epic — a phase-only query missed it), sorted
status→priority→recency with overflow noted. SKILL.md §Interactive panel
now instructs: run the script, paste stdout as widget_code verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 22:39:45 +02:00
co-authored by Claude Fable 5
parent 486592ffa5
commit 22e3579d43
3 changed files with 125 additions and 14 deletions
+12 -14
View File
@@ -63,25 +63,23 @@ Real one-click interactivity lives in the in-chat widget — render it when the
user asks for "the panel", "pick-up panel", or wants to act on tickets rather
than read them.
1. Pull live data (small, native projections — never embed the whole store):
1. Generate the widget code with the bundled script (template + live pql data
— do NOT hand-compose the HTML):
```bash
pql ticket list --status in_progress --fields id,type,priority,title
pql ticket list --under T-750 --unblocked --leaf --fields id,type,priority,title
.claude/skills/pql-board/support/make-panel # stdout = widget_code
```
Panel contents: WIP + the unblocked ready work of the active phase (cap at
~10 rows; if more, take highest priority first and say so under the panel).
Flags: `--phase T-NNN` (default T-750, the active phase epic), `--cap N`
(default 10). Contents: actionable WIP + unblocked leaf work from the
active phase AND the maintenance initiative (T-1037), sorted
status→priority→recency, overflow noted in the panel. Template:
`support/panel-template.html` (edit that file to change the panel's look;
the pick-up/detail sendPrompt contract lives there).
2. Render with `mcp__visualize__show_widget` (load its `read_me` first,
`interactive` module). Panel shape — compact bordered rows, CDS tokens:
- Each row: mono ticket id (link to the artifact deep-link `#T-NNN`),
title, status/priority pill, and two buttons:
- **Pick up ↗** → `sendPrompt('pick up the following ticket: T-NNN — <title>')`
- **Detail ↗** → `sendPrompt('show me T-NNN in detail')`
- Footer buttons: **Refresh board ↗** → `sendPrompt('/pql-board')`,
**Next batch ↗** → `sendPrompt('/whats-next')`.
- Widget rules: no emoji, sentence case, `` suffix on sendPrompt buttons.
2. Render with `mcp__visualize__show_widget`: load its `read_me` first
(`interactive` module) if not yet loaded this conversation, then pass the
script's stdout verbatim as `widget_code`.
3. **On receiving a "pick up the following ticket:" prompt** (from the panel
or typed): treat it as single-ticket batch activation — the /whats-next
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Fill panel-template.html with live pql data → widget_code for show_widget.
Emits the interactive in-chat pql panel (see ../SKILL.md §Interactive panel):
WIP + unblocked leaf work of the active phase, capped, with one-click
Pick up ↗ / Detail ↗ sendPrompt actions. Claude runs this and pastes stdout
verbatim as the show_widget widget_code.
Usage: .claude/skills/pql-board/support/make-panel [--phase T-NNN] [--cap N]
(defaults: active phase T-750, cap 10; output on stdout)
"""
import argparse
import json
import subprocess
from pathlib import Path
ARTIFACT_URL = "https://claude.ai/code/artifact/0064e59d-84a9-4a31-a830-f7d677df9d92"
FIELDS = "id,type,priority,status,title,updated_at"
PRIORITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
STATUS_RANK = {"in_progress": 0, "review": 1, "ready": 2, "backlog": 3}
ACTIONABLE_TYPES = ("story", "task", "bug")
MAINTENANCE = "T-1037"
def pql(*args):
out = subprocess.run(
["pql", *args], capture_output=True, text=True, check=True
).stdout
return json.loads(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--phase", default="T-750")
ap.add_argument("--cap", type=int, default=10)
args = ap.parse_args()
wip = pql("ticket", "list", "--status", "in_progress", "--fields", FIELDS)
phase = pql(
"ticket", "list", "--under", args.phase, "--unblocked", "--leaf",
"--fields", FIELDS,
)
maint = pql(
"ticket", "list", "--under", MAINTENANCE, "--unblocked", "--leaf",
"--fields", FIELDS,
)
seen, rows = set(), []
for t in wip + phase + maint:
if t["id"] in seen or t["type"] not in ACTIONABLE_TYPES:
continue
if t["status"] in ("done", "cancelled"):
continue
seen.add(t["id"])
rows.append(t)
rows.sort(key=lambda t: t.get("updated_at") or "", reverse=True)
rows.sort(
key=lambda t: (
STATUS_RANK.get(t["status"], 3),
PRIORITY_RANK.get(t.get("priority"), 2),
)
)
overflow = max(0, len(rows) - args.cap)
rows = rows[: args.cap]
tpl = (Path(__file__).parent / "panel-template.html").read_text()
data = json.dumps(
{"rows": rows, "url": ARTIFACT_URL, "overflow": overflow},
ensure_ascii=False,
separators=(",", ":"),
).replace("</", "<\\/")
print(tpl.replace("__URL__", ARTIFACT_URL).replace("__DATA__", data))
if __name__ == "__main__":
main()
@@ -0,0 +1,35 @@
<h2 class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Interactive pql panel: work-in-progress and unblocked ready tickets with one-click pick-up and detail actions.</h2>
<div id="pp-rows" style="border:0.5px solid var(--border);border-radius:var(--radius);overflow:hidden;"></div>
<div id="pp-overflow" style="font-size:12px;color:var(--text-muted);margin-top:6px;"></div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button onclick="sendPrompt('/pql-board')" style="font-size:13px;"><i class="ti ti-refresh" style="font-size:15px;vertical-align:-2px" aria-hidden="true"></i> Refresh board ↗</button>
<button onclick="sendPrompt('/whats-next')" style="font-size:13px;">Next batch ↗</button>
<span style="flex:1"></span>
<a href="__URL__" style="font-size:13px;align-self:center;"><i class="ti ti-external-link" style="font-size:15px;vertical-align:-2px" aria-hidden="true"></i> Open board</a>
</div>
<script type="application/json" id="pp-data">__DATA__</script>
<script>
const D=JSON.parse(document.getElementById("pp-data").textContent);
const PR={critical:["var(--bg-danger)","var(--text-danger)"],high:["var(--bg-warning)","var(--text-warning)"],
medium:["var(--surface-1)","var(--text-secondary)"],low:["var(--surface-1)","var(--text-muted)"]};
const ST={in_progress:["var(--bg-accent)","var(--text-accent)"],ready:["var(--bg-success)","var(--text-success)"],
backlog:["var(--surface-1)","var(--text-muted)"],review:["var(--bg-warning)","var(--text-warning)"]};
document.getElementById("pp-rows").innerHTML=D.rows.map((t,i)=>{
const p=PR[t.priority]||PR.medium, s=ST[t.status]||ST.backlog;
return `<div style="display:flex;align-items:center;gap:10px;padding:9px 12px;${i?"border-top:0.5px solid var(--border);":""}">
<a href="${D.url}#${t.id}" style="font:500 13px var(--font-mono);">${t.id}</a>
<span style="font-size:14px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${t.title.replace(/"/g,"&quot;")}">${t.title}</span>
<span style="font-size:11px;padding:2px 7px;border-radius:999px;background:${s[0]};color:${s[1]};">${t.status.replace("_"," ")}</span>
<span style="font-size:11px;padding:2px 7px;border-radius:999px;background:${p[0]};color:${p[1]};">${t.priority}</span>
<button data-act="pick" data-i="${i}" style="font-size:12px;padding:4px 8px;">Pick up ↗</button>
<button data-act="detail" data-i="${i}" style="font-size:12px;padding:4px 8px;">Detail ↗</button>
</div>`;}).join("");
if(D.overflow>0) document.getElementById("pp-overflow").textContent=
`…and ${D.overflow} more unblocked — open the board for the full list.`;
document.querySelectorAll("#pp-rows button").forEach(b=>b.onclick=()=>{
const t=D.rows[+b.dataset.i];
sendPrompt(b.dataset.act==="pick"
? `pick up the following ticket: ${t.id}${t.title}`
: `show me ${t.id} in detail`);
});
</script>