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:
Gabriel Brown committed 2026-10-30 14:58:46 -04:00
1 parent e21a4c24f0
commit 4e39be860c
9 files changed
+235 -42

No files matched your search

+30 -2
View File
@@ -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` |
+19 -11
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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 === "<" ? "&lt;" : "&amp;")));
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 === "<" ? "&lt;" : "&amp;")),
};
// 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>