Make ticket pages open in T3 Code's file viewer, and show plans, mocks and progress inline in the thread
This commit is contained in:
1 parent
e21a4c24f0
commit
4e39be860c
9 files changed
+235
-42
No files matched your search
@@ -0,0 +1,125 @@
|
||||
// Thread views: compact, static HTML snapshots of a ticket for T3 Code's html_render, which
|
||||
// shows a page inline in the conversation. The live ticket page stays the full record; these
|
||||
// are what Gib reads without leaving the thread. `ticket-page view` prints one, and the skill
|
||||
// hands it to html_preview, then html_render.
|
||||
//
|
||||
// html_render's rules shape everything here: no background on html, body or the outer
|
||||
// element, colours only from T3's theme variables, no vh units, and a box with its own
|
||||
// background gets 16px padding and var(--radius) corners. No script except the one that
|
||||
// sizes each mock frame to its artboard.
|
||||
|
||||
import type { Criterion, Mock, Step, Ticket } from "./ticket-data";
|
||||
|
||||
export const VIEWS = ["plan", "progress", "mocks"] as const;
|
||||
/** html_render's limit on a page, in characters. */
|
||||
export const RENDER_LIMIT = 512_000;
|
||||
export type View = (typeof VIEWS)[number];
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
const short = (sha: string) => sha.slice(0, 8);
|
||||
|
||||
const CSS = `
|
||||
body{margin:0;font:14px/1.5 var(--font-sans);color:var(--foreground)}
|
||||
h1{font-size:16px;margin:0 0 2px}h2{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted-foreground);margin:20px 0 8px}
|
||||
p{margin:0 0 8px}code{font:12px var(--font-mono);background:var(--code-background);padding:1px 4px;border-radius:4px}
|
||||
.sub{color:var(--muted-foreground);font-size:12px}
|
||||
.ask{background:var(--warning-surface);color:var(--foreground);border-radius:var(--radius);padding:16px;margin-top:12px}
|
||||
.ask h2{margin-top:0;color:var(--warning)}.ask ol{margin:0;padding-left:20px}
|
||||
ul.rows{list-style:none;margin:0;padding:0}ul.rows li{display:flex;gap:10px;padding:6px 0;border-top:1px solid var(--border)}
|
||||
ul.rows li:first-child{border-top:0}
|
||||
.box{flex:none;width:16px;height:16px;margin-top:2px;border:1.5px solid var(--border);border-radius:4px;display:grid;place-items:center;font-size:11px;line-height:1}
|
||||
.box.done{background:var(--success);border-color:var(--success);color:var(--background)}.box.run{border-color:var(--primary);color:var(--primary)}
|
||||
.grow{flex:1;min-width:0}.tag{font-size:11px;color:var(--muted-foreground);border:1px solid var(--border);border-radius:999px;padding:0 6px;margin-left:6px;white-space:nowrap}
|
||||
.id{font-weight:600;margin-right:6px}.meta{font-size:12px;color:var(--muted-foreground)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}td,th{text-align:left;vertical-align:top;padding:6px 8px 6px 0;border-top:1px solid var(--border)}th{font-weight:600;color:var(--muted-foreground);font-size:12px}
|
||||
.bar{height:6px;border-radius:3px;background:var(--secondary);overflow:hidden;margin:4px 0 2px}.bar i{display:block;height:100%;background:var(--success)}
|
||||
.stats{display:flex;gap:24px;flex-wrap:wrap}.stats div{min-width:140px}
|
||||
.mock{margin-top:16px}.mock iframe{display:block;width:100%;height:420px;border:1px solid var(--border);border-radius:var(--radius);background:#fff}
|
||||
`;
|
||||
|
||||
// Collapses each frame before measuring it: an iframe reports its own viewport height as
|
||||
// the document's scrollHeight, so measuring at 420px never reads less than 420.
|
||||
const MOCK_SIZER = `<script>
|
||||
for (const f of document.querySelectorAll('iframe[data-fit]')) f.addEventListener('load', () => {
|
||||
try { f.style.height = '0px'; f.style.height = f.contentDocument.documentElement.scrollHeight + 2 + 'px'; }
|
||||
catch { f.style.height = ''; }
|
||||
});
|
||||
</script>`;
|
||||
|
||||
function page(title: string, body: string, extra = "") {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title><style>${CSS}</style></head><body>${body}${extra}</body></html>`;
|
||||
}
|
||||
|
||||
const box = (status: string) =>
|
||||
`<span class="box${status === "done" ? " done" : status === "running" ? " run" : ""}">${status === "done" ? "✓" : status === "running" ? "•" : ""}</span>`;
|
||||
|
||||
const head = (t: Ticket, what: string) =>
|
||||
`<h1>${esc(t.key)}: ${esc(t.title)}</h1><div class="sub">${esc(what)}${t.branch ? ` on <code>${esc(t.branch)}</code>` : ""}</div>`;
|
||||
|
||||
const stepRow = (s: Step) =>
|
||||
`<li>${box(s.status)}<div class="grow">${s.n}. ${esc(s.text)}${s.kind !== "code" ? `<span class="tag">${s.kind}</span>` : ""}${s.tdd ? `<span class="tag">tests first</span>` : ""}` +
|
||||
`${s.commits.length ? `<div class="meta">${s.commits.map(c => `<code>${esc(short(c))}</code>`).join(" ")}</div>` : ""}</div></li>`;
|
||||
|
||||
const criterionRow = (t: Ticket, c: Criterion) => {
|
||||
const proof = t.proof.filter(p => p.proves.includes(c.id)).length;
|
||||
const extra = [`steps ${c.steps.join(", ")}`, proof ? `${proof} proof` : "", c.evidence ? esc(c.evidence) : ""].filter(Boolean).join(" · ");
|
||||
return `<li>${box(c.status)}<div class="grow"><span class="id">${esc(c.id)}</span>${esc(c.text)}<div class="meta">${extra}</div></div></li>`;
|
||||
};
|
||||
|
||||
const pct = (a: number, b: number) => (b ? Math.round((100 * a) / b) : 0);
|
||||
|
||||
/** The plan as it stands for approval: questions first, because they are what blocks it. */
|
||||
function plan(t: Ticket): string {
|
||||
const pl = t.plan!;
|
||||
const parts = [head(t, pl.approvedAt ? "Plan, approved" : "Plan, awaiting approval")];
|
||||
if (pl.questions.length) parts.push(`<div class="ask"><h2>Needs your answer</h2><ol>${pl.questions.map(q => `<li>${q}</li>`).join("")}</ol></div>`);
|
||||
parts.push(`<h2>Approach</h2>${pl.approach}`);
|
||||
parts.push(`<h2>Acceptance criteria</h2><ul class="rows">${pl.criteria.map(c => criterionRow(t, c)).join("")}</ul>`);
|
||||
parts.push(`<h2>Steps</h2><ul class="rows">${pl.steps.map(stepRow).join("")}</ul>`);
|
||||
if (pl.decisions.length)
|
||||
parts.push(`<h2>Decisions</h2><table><tr><th>Question</th><th>Choice</th><th>Why</th></tr>${pl.decisions.map(([q, c, w]) => `<tr><td>${esc(q)}</td><td>${esc(c)}</td><td>${esc(w)}</td></tr>`).join("")}</table>`);
|
||||
if (pl.risks.length)
|
||||
parts.push(`<h2>Risks</h2><table><tr><th>Risk</th><th>Handling</th><th>Covers</th></tr>${pl.risks.map(r => `<tr><td>${esc(r.risk)}${r.fromTicket ? `<span class="tag">ticket</span>` : ""}</td><td>${esc(r.handling)}</td><td>${esc((r.criteria ?? []).join(", "))}</td></tr>`).join("")}</table>`);
|
||||
if (pl.tests.length)
|
||||
parts.push(`<h2>Test plan</h2><table><tr><th>Kind</th><th>Test</th><th>Covers</th></tr>${pl.tests.map(x => `<tr><td>${esc(x.kind)}</td><td>${esc(x.text)}${x.fromTicket ? `<span class="tag">ticket</span>` : ""}</td><td>${esc((x.criteria ?? []).join(", "))}</td></tr>`).join("")}</table>`);
|
||||
if (pl.mocks.length) parts.push(`<p class="sub">${pl.mocks.length} mock artboard${pl.mocks.length > 1 ? "s" : ""}: ${pl.mocks.map(m => esc(m.title)).join(", ")}.</p>`);
|
||||
return page(`${t.key} plan`, parts.join(""));
|
||||
}
|
||||
|
||||
/** The to-do list: steps and criteria with their marks, then what review still holds open. */
|
||||
function progressView(t: Ticket): string {
|
||||
const pl = t.plan!;
|
||||
const steps = pl.steps.filter(s => s.status === "done").length;
|
||||
const crit = pl.criteria.filter(c => c.status === "done").length;
|
||||
const open = t.review.findings.filter(f => f.status === "open");
|
||||
const stat = (label: string, a: number, b: number) => `<div><div class="meta">${label}</div><div class="bar"><i style="width:${pct(a, b)}%"></i></div><b>${a}</b> of ${b}</div>`;
|
||||
const parts = [head(t, "Progress"), `<div class="stats" style="margin-top:12px">${stat("Steps", steps, pl.steps.length)}${stat("Criteria", crit, pl.criteria.length)}<div><div class="meta">Proof</div><b>${t.proof.length}</b> captured</div></div>`];
|
||||
parts.push(`<h2>Steps</h2><ul class="rows">${pl.steps.map(stepRow).join("")}</ul>`);
|
||||
parts.push(`<h2>Acceptance criteria</h2><ul class="rows">${pl.criteria.map(c => criterionRow(t, c)).join("")}</ul>`);
|
||||
if (open.length) parts.push(`<h2>Open review findings</h2><ul class="rows">${open.map(f => `<li>${box("todo")}<div class="grow"><span class="id">${esc(f.id)}</span>${esc(f.text)}${f.where ? `<div class="meta"><code>${esc(f.where)}</code></div>` : ""}</div></li>`).join("")}</ul>`);
|
||||
const v = t.review.preMr;
|
||||
if (v) parts.push(`<h2>pre-mr-review</h2><p><b>${esc(v.verdict)}</b>${v.head ? ` at <code>${esc(short(v.head))}</code>` : ""}, run ${v.runs}</p>`);
|
||||
return page(`${t.key} progress`, parts.join(""));
|
||||
}
|
||||
|
||||
/** Each artboard in its own frame, sized to its content, under its title, criteria and note. */
|
||||
function mocks(t: Ticket, only?: string): string {
|
||||
const all = t.plan!.mocks;
|
||||
const list: Mock[] = only ? [all.find(m => m.id === only) ?? fail(`no mock ${only}. Mocks: ${all.map(m => m.id).join(", ") || "none"}`)] : all;
|
||||
if (!list.length) fail(`${t.key} has no mocks`);
|
||||
const body = list.map(m =>
|
||||
`<div class="mock"><b>${esc(m.title)}</b> <span class="sub">${esc(m.id)}${m.criteria.length ? ` · ${esc(m.criteria.join(", "))}` : ""}</span>` +
|
||||
`${m.note ? `<div class="meta">${esc(m.note)}</div>` : ""}<iframe data-fit sandbox="allow-same-origin" srcdoc="${esc(m.html)}" title="${esc(m.title)}"></iframe></div>`).join("");
|
||||
return page(`${t.key} mocks`, head(t, "Mocks") + body, MOCK_SIZER);
|
||||
}
|
||||
|
||||
function fail(msg: string): never { throw new Error(msg); }
|
||||
|
||||
export function renderView(t: Ticket, view: View, mock?: string): string {
|
||||
if (!t.plan) fail(`${t.key} has no plan yet, so there is nothing to show`);
|
||||
const html = view === "plan" ? plan(t) : view === "progress" ? progressView(t) : mocks(t, mock);
|
||||
if (html.length > RENDER_LIMIT)
|
||||
fail(`the ${view} view is ${Math.round(html.length / 1000)} KB, over html_render's ${RENDER_LIMIT / 1000} KB.` +
|
||||
(view === "mocks" && !mock ? ` Show one artboard at a time with --mock: ${t.plan.mocks.map(m => m.id).join(", ")}.` : " Link the ticket page instead."));
|
||||
return html;
|
||||
}
|
||||
Reference in new issue
Block a user