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/
|
||||
_site/site.css, site.js # shared renderer, refreshed from templates/site/ on every write
|
||||
<EPIC-KEY>/
|
||||
index.html, epic.data.js # the epic index, in build order
|
||||
<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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
|
||||
@@ -31,7 +59,7 @@ the repo.
|
||||
|
||||
| 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>` |
|
||||
| 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` |
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
@@ -34,9 +39,8 @@ watching the page change.
|
||||
|
||||
```
|
||||
.claude/docs/epics/
|
||||
_site/ # the shared renderer, refreshed by ticket-page
|
||||
<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>/
|
||||
index.html, ticket.data.js # the ticket page
|
||||
resources/
|
||||
@@ -468,8 +472,8 @@ place from the start.
|
||||
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
|
||||
`<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
|
||||
the page with `ticket-page open <KEY>` so Gib can follow from here on.
|
||||
script read. Record the branch from **Branch setup** with `ticket-page meta`, then run
|
||||
`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
|
||||
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
|
||||
@@ -580,7 +584,9 @@ place from the start.
|
||||
otherwise.
|
||||
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**.
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
review run from the developer's separate work Claude account, so the audit on
|
||||
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
|
||||
`/pre-mr-review` from their work account, then let you know when it has
|
||||
finished. Stop and wait, this is a hard gate.
|
||||
1. Show where the build stands with `ticket-page view <KEY> progress`, through
|
||||
`html_preview` and then `html_render`. Then tell the user the branch is ready for
|
||||
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
|
||||
`.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
|
||||
@@ -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
|
||||
`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
|
||||
from the first open step. If the plan and the git history disagree, stop and ask
|
||||
the user rather than guessing which one is right.
|
||||
match reality (a checked-off step should have a corresponding commit), then show
|
||||
`ticket-page view <KEY> progress` in the thread and continue from the first open step.
|
||||
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);
|
||||
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(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"]]);
|
||||
});
|
||||
|
||||
@@ -85,6 +88,15 @@ describe("the CLI, end to end", () => {
|
||||
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", () => {
|
||||
mkdirSync(join(dir, "proof"), { recursive: true });
|
||||
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 skill calls each command. Run `ticket-page help` for the command list.
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
|
||||
import { renderView, VIEWS } from "./thread-view";
|
||||
import {
|
||||
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
|
||||
storyStatus, type Ticket, writeData,
|
||||
@@ -49,24 +49,19 @@ function ticketDir(key: string): string {
|
||||
const ticketFile = (dir: string) => join(dir, "ticket.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
|
||||
* page shell. Run on every write so an old ticket picks up the current renderer. */
|
||||
/** Writes the page shell with the renderer's CSS and JS inlined, so the page carries
|
||||
* 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) {
|
||||
const site = join(docsRoot(), "_site");
|
||||
mkdirSync(site, { recursive: true });
|
||||
const version = createHash("sha1");
|
||||
for (const f of ["site.css", "site.js"]) {
|
||||
const from = join(SITE_TEMPLATES, f), to = join(site, f);
|
||||
const body = readFileSync(from, "utf8");
|
||||
version.update(body);
|
||||
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
|
||||
}
|
||||
// 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 fill: Record<string, string> = {
|
||||
CSS: readFileSync(join(SITE_TEMPLATES, "site.css"), "utf8"),
|
||||
JS: readFileSync(join(SITE_TEMPLATES, "site.js"), "utf8"),
|
||||
TITLE: title.replace(/[<&]/g, c => (c === "<" ? "<" : "&")),
|
||||
};
|
||||
// A replacer function, because the renderer's own source is full of `$` sequences that
|
||||
// a replacement string would read as patterns.
|
||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8").replace(/\{\{(CSS|JS|TITLE)\}\}/g, (_, k: string) => fill[k]);
|
||||
const page = join(pageDir, "index.html");
|
||||
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("open", "open <KEY> open the page in the browser", a => {
|
||||
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
|
||||
command("open", "open <KEY> print the page path, and open it in the browser outside T3 Code", a => {
|
||||
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 => {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="epic" data-src="epic.data.js">
|
||||
<div id="app"><p class="loading">Loading epic.data.js…</p></div>
|
||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
||||
<script>
|
||||
{{JS}}
|
||||
</script>
|
||||
</body>
|
||||
</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 {
|
||||
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
|
||||
--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
|
||||
// <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.
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="ticket" data-src="ticket.data.js">
|
||||
<div id="app"><p class="loading">Loading ticket.data.js…</p></div>
|
||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
||||
<script>
|
||||
{{JS}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user