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
@@ -7,7 +7,6 @@ sees. It is a personal working file, exempt from the prose bar like the rest of
|
|||||||
|
|
||||||
```
|
```
|
||||||
.claude/docs/epics/
|
.claude/docs/epics/
|
||||||
_site/site.css, site.js # shared renderer, refreshed from templates/site/ on every write
|
|
||||||
<EPIC-KEY>/
|
<EPIC-KEY>/
|
||||||
index.html, epic.data.js # the epic index, in build order
|
index.html, epic.data.js # the epic index, in build order
|
||||||
<TICKET-KEY>/
|
<TICKET-KEY>/
|
||||||
@@ -16,6 +15,35 @@ sees. It is a personal working file, exempt from the prose bar like the rest of
|
|||||||
tickets/<TICKET-KEY>/ # same shape, for a ticket with no epic, and no index
|
tickets/<TICKET-KEY>/ # same shape, for a ticket with no epic, and no index
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each `index.html` carries the renderer from `templates/site/` inline, rewritten on every
|
||||||
|
write, and loads only its own data file beside it. That is what lets T3 Code's file viewer
|
||||||
|
show it: an HTML file there can load files from its own folder and below, nothing above.
|
||||||
|
|
||||||
|
## In T3 Code
|
||||||
|
|
||||||
|
Gib follows the work in a T3 Code thread, so the page and the plan come to him there.
|
||||||
|
|
||||||
|
- **The live page.** `ticket-page open <KEY>` refreshes the page and prints its absolute path.
|
||||||
|
Link that path in your reply, and it opens in T3's file viewer beside the thread, still
|
||||||
|
reloading as you write. Outside T3 (no `T3CODE_THREAD_ID`) it also opens the browser.
|
||||||
|
Never start an http server for the page.
|
||||||
|
- **Inline snapshots.** `ticket-page view <KEY> <view>` prints a self-contained page styled
|
||||||
|
with T3's theme. Pass its output, verbatim, to `html_preview`, check the screenshot, then
|
||||||
|
to `html_render` with the `contentHeight` it reported (capped at 2000) as the height. Do not
|
||||||
|
restate what the snapshot shows in your reply. Snapshots do not update, so render one at
|
||||||
|
the moment it is for, not on every write.
|
||||||
|
|
||||||
|
| Moment | View |
|
||||||
|
|---|---|
|
||||||
|
| Plan ready for approval (Phase 1, last step) | `plan`, then `mocks --mock <id>` once per artboard |
|
||||||
|
| Hand-off for pre-mr-review, and on resume | `progress` |
|
||||||
|
|
||||||
|
- One artboard per render, so each can be expanded full screen on its own and options sit
|
||||||
|
one after another for comparison. `view` refuses a page over html_render's 512 KB and
|
||||||
|
names the artboards to render one at a time.
|
||||||
|
- Keep artboards light: CSS and real copy, not base64 screenshots. A screenshot pasted into an
|
||||||
|
artboard as a data URI is most of its size, and every byte goes through the conversation.
|
||||||
|
|
||||||
## Write through `ticket-page`, always
|
## Write through `ticket-page`, always
|
||||||
|
|
||||||
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
|
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
|
||||||
@@ -31,7 +59,7 @@ the repo.
|
|||||||
|
|
||||||
| Moment | Command |
|
| Moment | Command |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. |
|
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. Then `open <KEY>` and link the path. |
|
||||||
| Attachments downloaded, a video transcribed, frames extracted | `scan <KEY>` |
|
| Attachments downloaded, a video transcribed, frames extracted | `scan <KEY>` |
|
||||||
| Ticket branch resolved, ticket classified | `meta <KEY> --branch <name> --classified bounded` |
|
| Ticket branch resolved, ticket classified | `meta <KEY> --branch <name> --classified bounded` |
|
||||||
| Research notes worth keeping: what a video says, what a spreadsheet holds, a check against prod | `note <KEY> "<Title>" --file notes.html` |
|
| Research notes worth keeping: what a video says, what a spreadsheet holds, a check against prod | `note <KEY> "<Title>" --file notes.html` |
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ Jira has it, the plan with its mocks, live progress, proof beside the mocks, and
|
|||||||
Every epic gets an **epic index** listing its stories in build order. Both render from data
|
Every epic gets an **epic index** listing its stories in build order. Both render from data
|
||||||
files that only `ticket-page` writes, and both update themselves as it writes.
|
files that only `ticket-page` writes, and both update themselves as it writes.
|
||||||
|
|
||||||
|
Gib works in T3 Code. The page opens in T3's file viewer beside the thread when you link its
|
||||||
|
absolute path, so never start a server for it. At two moments, plan approval and hand-off,
|
||||||
|
the skill also shows a snapshot inline in the thread with `ticket-page view`, so he can read
|
||||||
|
it without leaving the conversation. SITE.md has the commands, under **In T3 Code**.
|
||||||
|
|
||||||
**Read [SITE.md](SITE.md) before the first write of every run.** It holds the command for each
|
**Read [SITE.md](SITE.md) before the first write of every run.** It holds the command for each
|
||||||
moment the phases below name, the plan's JSON shape, how to draw mocks, and how the epic order
|
moment the phases below name, the plan's JSON shape, how to draw mocks, and how the epic order
|
||||||
is seeded. Record each moment when it happens, not in a batch at the end, because Gib is
|
is seeded. Record each moment when it happens, not in a batch at the end, because Gib is
|
||||||
@@ -34,9 +39,8 @@ watching the page change.
|
|||||||
|
|
||||||
```
|
```
|
||||||
.claude/docs/epics/
|
.claude/docs/epics/
|
||||||
_site/ # the shared renderer, refreshed by ticket-page
|
|
||||||
<EPIC-KEY>/
|
<EPIC-KEY>/
|
||||||
index.html, epic.data.js # the epic index
|
index.html, epic.data.js # the epic index (each index.html carries its own renderer)
|
||||||
<TICKET-KEY>/
|
<TICKET-KEY>/
|
||||||
index.html, ticket.data.js # the ticket page
|
index.html, ticket.data.js # the ticket page
|
||||||
resources/
|
resources/
|
||||||
@@ -468,8 +472,8 @@ place from the start.
|
|||||||
epic (or `tickets/` without one), imports every rich-text field verbatim as sanitized
|
epic (or `tickets/` without one), imports every rich-text field verbatim as sanitized
|
||||||
HTML, writes the page, and prints the ticket folder. Copy the fetched JSON to
|
HTML, writes the page, and prints the ticket folder. Copy the fetched JSON to
|
||||||
`<target-dir>/resources/issue.raw.json`, which later re-imports and the attachment
|
`<target-dir>/resources/issue.raw.json`, which later re-imports and the attachment
|
||||||
script read. Record the branch from **Branch setup** with `ticket-page meta`, then open
|
script read. Record the branch from **Branch setup** with `ticket-page meta`, then run
|
||||||
the page with `ticket-page open <KEY>` so Gib can follow from here on.
|
`ticket-page open <KEY>` and link the path it prints, so Gib can follow from here on.
|
||||||
4. `init` ends by listing populated Jira fields the page does not show. Read each one and
|
4. `init` ends by listing populated Jira fields the page does not show. Read each one and
|
||||||
add any that bear on implementing or verifying the ticket as a note. This sweep exists
|
add any that bear on implementing or verifying the ticket as a note. This sweep exists
|
||||||
because real content sometimes hides in per-project custom fields with unpredictable
|
because real content sometimes hides in per-project custom fields with unpredictable
|
||||||
@@ -580,7 +584,9 @@ place from the start.
|
|||||||
otherwise.
|
otherwise.
|
||||||
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`,
|
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`,
|
||||||
as [SITE.md](SITE.md) describes under **Mocks**.
|
as [SITE.md](SITE.md) describes under **Mocks**.
|
||||||
13. Stop here. Tell the user the plan is on the ticket page, give its path from
|
13. Stop here. Show the plan in the thread: `ticket-page view <KEY> plan`, then each mock
|
||||||
|
artboard with `ticket-page view <KEY> mocks --mock <id>`, each through `html_preview`
|
||||||
|
and then `html_render` (SITE.md, **In T3 Code**). Then link the page path from
|
||||||
`ticket-page path <KEY>`, summarize the approach in a couple of sentences, and mention
|
`ticket-page path <KEY>`, summarize the approach in a couple of sentences, and mention
|
||||||
any open questions that need their input before you'd implement it. Do not start
|
any open questions that need their input before you'd implement it. Do not start
|
||||||
implementing in this same run — wait for them to review the plan and invoke
|
implementing in this same run — wait for them to review the plan and invoke
|
||||||
@@ -700,9 +706,10 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
|
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
|
||||||
review run from the developer's separate work Claude account, so the audit on
|
review run from the developer's separate work Claude account, so the audit on
|
||||||
record must come from there, not from this session. Instead:
|
record must come from there, not from this session. Instead:
|
||||||
1. Tell the user the branch is ready for its pre-mr-review and ask them to run
|
1. Show where the build stands with `ticket-page view <KEY> progress`, through
|
||||||
`/pre-mr-review` from their work account, then let you know when it has
|
`html_preview` and then `html_render`. Then tell the user the branch is ready for
|
||||||
finished. Stop and wait, this is a hard gate.
|
its pre-mr-review and ask them to run `/pre-mr-review` from their work account,
|
||||||
|
then let you know when it has finished. Stop and wait, this is a hard gate.
|
||||||
2. When they say it ran, read the audit and handoff files it wrote under
|
2. When they say it ran, read the audit and handoff files it wrote under
|
||||||
`.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review`
|
`.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review`
|
||||||
skill prints the exact paths). Check the `last_reviewed_head` in the audit
|
skill prints the exact paths). Check the `last_reviewed_head` in the audit
|
||||||
@@ -944,6 +951,7 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
|
|
||||||
If Phase 0 routes here because some steps are already done on the page: check
|
If Phase 0 routes here because some steps are already done on the page: check
|
||||||
`git log` on the current branch against the page's step commits to sanity-check they actually
|
`git log` on the current branch against the page's step commits to sanity-check they actually
|
||||||
match reality (a checked-off step should have a corresponding commit), then continue
|
match reality (a checked-off step should have a corresponding commit), then show
|
||||||
from the first open step. If the plan and the git history disagree, stop and ask
|
`ticket-page view <KEY> progress` in the thread and continue from the first open step.
|
||||||
the user rather than guessing which one is right.
|
If the plan and the git history disagree, stop and ask the user rather than guessing
|
||||||
|
which one is right.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -67,7 +67,10 @@ describe("the CLI, end to end", () => {
|
|||||||
expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0);
|
expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0);
|
||||||
for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true);
|
for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true);
|
||||||
expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true);
|
expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true);
|
||||||
expect(readFileSync(join(dir, "index.html"), "utf8")).toMatch(/href="..\/..\/_site\/site.css\?v=[0-9a-f]{10}"/);
|
// Self-contained: T3 Code's file viewer serves nothing from above the page's own folder.
|
||||||
|
const page = readFileSync(join(dir, "index.html"), "utf8");
|
||||||
|
expect(page).toContain(readFileSync(join(import.meta.dir, "../templates/site/site.js"), "utf8"));
|
||||||
|
expect(page).not.toMatch(/(src|href)="\.\./);
|
||||||
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
|
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +88,15 @@ describe("the CLI, end to end", () => {
|
|||||||
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } });
|
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("view prints a thread snapshot of the plan's progress, and refuses an unknown view", () => {
|
||||||
|
const out = cli("view", "KACP-1", "progress");
|
||||||
|
expect(out.status).toBe(0);
|
||||||
|
expect(out.stdout).toMatch(/^<!doctype html>/);
|
||||||
|
expect(out.stdout).toContain("<code>abc1234</code>");
|
||||||
|
expect(cli("view", "KACP-1", "mocks").status).not.toBe(0);
|
||||||
|
expect(cli("view", "KACP-1", "everything").status).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
test("text proof is embedded, because a file:// page cannot fetch it", () => {
|
test("text proof is embedded, because a file:// page cannot fetch it", () => {
|
||||||
mkdirSync(join(dir, "proof"), { recursive: true });
|
mkdirSync(join(dir, "proof"), { recursive: true });
|
||||||
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored");
|
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored");
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
|
// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
|
||||||
// ticket skill calls each command. Run `ticket-page help` for the command list.
|
// ticket skill calls each command. Run `ticket-page help` for the command list.
|
||||||
|
|
||||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join, relative, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
|
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
|
||||||
|
import { renderView, VIEWS } from "./thread-view";
|
||||||
import {
|
import {
|
||||||
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
|
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
|
||||||
storyStatus, type Ticket, writeData,
|
storyStatus, type Ticket, writeData,
|
||||||
@@ -49,24 +49,19 @@ function ticketDir(key: string): string {
|
|||||||
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
|
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
|
||||||
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
|
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
|
||||||
|
|
||||||
/** Copies the shared renderer into _site/ when the skill's copy changed, and writes the
|
/** Writes the page shell with the renderer's CSS and JS inlined, so the page carries
|
||||||
* page shell. Run on every write so an old ticket picks up the current renderer. */
|
* everything but its own data file. T3 Code's file viewer serves an HTML file's assets only
|
||||||
|
* from its own folder and below, so a shell that pointed at a shared ../../_site/ renderer
|
||||||
|
* rendered blank there. Run on every write so an old ticket picks up the current renderer. */
|
||||||
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
|
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
|
||||||
const site = join(docsRoot(), "_site");
|
const fill: Record<string, string> = {
|
||||||
mkdirSync(site, { recursive: true });
|
CSS: readFileSync(join(SITE_TEMPLATES, "site.css"), "utf8"),
|
||||||
const version = createHash("sha1");
|
JS: readFileSync(join(SITE_TEMPLATES, "site.js"), "utf8"),
|
||||||
for (const f of ["site.css", "site.js"]) {
|
TITLE: title.replace(/[<&]/g, c => (c === "<" ? "<" : "&")),
|
||||||
const from = join(SITE_TEMPLATES, f), to = join(site, f);
|
};
|
||||||
const body = readFileSync(from, "utf8");
|
// A replacer function, because the renderer's own source is full of `$` sequences that
|
||||||
version.update(body);
|
// a replacement string would read as patterns.
|
||||||
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
|
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE)\}\}/g, (_, k: string) => fill[k]);
|
||||||
}
|
|
||||||
// The shell names the renderer by content hash, so a browser that cached an older
|
|
||||||
// renderer loads the new one the next time the page opens.
|
|
||||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8")
|
|
||||||
.replaceAll("{{SITE}}", relative(pageDir, site) || ".")
|
|
||||||
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10))
|
|
||||||
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "<" : "&")));
|
|
||||||
const page = join(pageDir, "index.html");
|
const page = join(pageDir, "index.html");
|
||||||
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
|
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
|
||||||
}
|
}
|
||||||
@@ -342,8 +337,24 @@ command("show", "show <KEY> print where the ticket stands", a => {
|
|||||||
|
|
||||||
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
|
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
|
||||||
|
|
||||||
command("open", "open <KEY> open the page in the browser", a => {
|
command("open", "open <KEY> print the page path, and open it in the browser outside T3 Code", a => {
|
||||||
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
|
const { dir, t } = load(a.pos[0]);
|
||||||
|
// Rewrites the shells first, so a page last written before the renderer was inlined
|
||||||
|
// opens in T3 too.
|
||||||
|
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
|
||||||
|
const epic = t.epic && epicFile(t.epic.key);
|
||||||
|
if (epic && existsSync(epic)) { const e = readData(epic, "epic"); refreshSite(dirname(epic), "epic", `${e.key}: ${e.title}`); }
|
||||||
|
const page = join(dir, "index.html");
|
||||||
|
// Inside a T3 Code thread the agent links this path and the page opens in T3's file
|
||||||
|
// viewer, beside the conversation. A browser window would be one more thing to juggle.
|
||||||
|
if (!process.env.T3CODE_THREAD_ID) spawnSync("xdg-open", [page], { stdio: "ignore" });
|
||||||
|
console.log(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
command("view", `view <KEY> <${VIEWS.join("|")}> [--mock <id>] print a thread view for T3 Code's html_render`, a => {
|
||||||
|
const [key, name] = a.pos;
|
||||||
|
const view = VIEWS.find(v => v === name) ?? fail(`view takes ${VIEWS.join(", ")}`);
|
||||||
|
console.log(renderView(load(key).t, view, a.one("mock")));
|
||||||
});
|
});
|
||||||
|
|
||||||
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
|
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{TITLE}}</title>
|
<title>{{TITLE}}</title>
|
||||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
<style>
|
||||||
|
{{CSS}}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-page="epic" data-src="epic.data.js">
|
<body data-page="epic" data-src="epic.data.js">
|
||||||
<div id="app"><p class="loading">Loading epic.data.js…</p></div>
|
<div id="app"><p class="loading">Loading epic.data.js…</p></div>
|
||||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
<script>
|
||||||
|
{{JS}}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Shared stylesheet for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page. */
|
/* Shared stylesheet for ticket and epic pages. ticket-page inlines it into every page shell. */
|
||||||
:root {
|
:root {
|
||||||
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
|
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
|
||||||
--line: #e2e2dd; --accent: #2f5fd0; --accent-ink: #fff; --accent-soft: #e8eeff;
|
--line: #e2e2dd; --accent: #2f5fd0; --accent-ink: #fff; --accent-soft: #e8eeff;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Renderer for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page.
|
// Renderer for ticket and epic pages. ticket-page inlines it into every page shell, so a
|
||||||
|
// page needs nothing outside its own folder (T3 Code's file viewer allows nothing else).
|
||||||
// A page shell names its data file in <body data-src>. This script loads that file with a
|
// A page shell names its data file in <body data-src>. This script loads that file with a
|
||||||
// <script> tag (file:// pages cannot fetch), re-loads it every two seconds, and redraws
|
// <script> tag (file:// pages cannot fetch), re-loads it every two seconds, and redraws
|
||||||
// only when the data changed. The data shape is defined in scripts/ticket-data.ts.
|
// only when the data changed. The data shape is defined in scripts/ticket-data.ts.
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{TITLE}}</title>
|
<title>{{TITLE}}</title>
|
||||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
<style>
|
||||||
|
{{CSS}}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-page="ticket" data-src="ticket.data.js">
|
<body data-page="ticket" data-src="ticket.data.js">
|
||||||
<div id="app"><p class="loading">Loading ticket.data.js…</p></div>
|
<div id="app"><p class="loading">Loading ticket.data.js…</p></div>
|
||||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
<script>
|
||||||
|
{{JS}}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user