From 4e39be860c2e5dffb6367cb6babe5ce8507df832 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 30 Oct 2026 14:58:46 -0400 Subject: [PATCH] Make ticket pages open in T3 Code's file viewer, and show plans, mocks and progress inline in the thread --- user/agents/skills/ticket/SITE.md | 32 ++++- user/agents/skills/ticket/SKILL.md | 30 +++-- .../skills/ticket/scripts/thread-view.ts | 125 ++++++++++++++++++ .../skills/ticket/scripts/ticket-page.test.ts | 14 +- .../skills/ticket/scripts/ticket-page.ts | 55 +++++--- .../skills/ticket/templates/site/epic.html | 8 +- .../skills/ticket/templates/site/site.css | 2 +- .../skills/ticket/templates/site/site.js | 3 +- .../skills/ticket/templates/site/ticket.html | 8 +- 9 files changed, 235 insertions(+), 42 deletions(-) create mode 100644 user/agents/skills/ticket/scripts/thread-view.ts diff --git a/user/agents/skills/ticket/SITE.md b/user/agents/skills/ticket/SITE.md index 09c52ca..f7cee97 100644 --- a/user/agents/skills/ticket/SITE.md +++ b/user/agents/skills/ticket/SITE.md @@ -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 / index.html, epic.data.js # the epic index, in build order / @@ -16,6 +15,35 @@ sees. It is a personal working file, exempt from the prose bar like the rest of tickets// # 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 ` 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 ` 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 ` 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 --issue `. Prints the ticket folder, then any populated Jira fields the page does not show. | +| Issue fetched to a scratch path | `init --issue `. Prints the ticket folder, then any populated Jira fields the page does not show. Then `open ` and link the path. | | Attachments downloaded, a video transcribed, frames extracted | `scan ` | | Ticket branch resolved, ticket classified | `meta --branch --classified bounded` | | Research notes worth keeping: what a video says, what a spreadsheet holds, a check against prod | `note "" --file notes.html` | diff --git a/user/agents/skills/ticket/SKILL.md b/user/agents/skills/ticket/SKILL.md index 47cd267..511debd 100644 --- a/user/agents/skills/ticket/SKILL.md +++ b/user/agents/skills/ticket/SKILL.md @@ -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. diff --git a/user/agents/skills/ticket/scripts/thread-view.ts b/user/agents/skills/ticket/scripts/thread-view.ts new file mode 100644 index 0000000..84bbf4b --- /dev/null +++ b/user/agents/skills/ticket/scripts/thread-view.ts @@ -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)}${body}${extra}`; +} + +const box = (status: string) => + `${status === "done" ? "✓" : status === "running" ? "•" : ""}`; + +const head = (t: Ticket, what: string) => + `

${esc(t.key)}: ${esc(t.title)}

${esc(what)}${t.branch ? ` on ${esc(t.branch)}` : ""}
`; + +const stepRow = (s: Step) => + `
  • ${box(s.status)}
    ${s.n}. ${esc(s.text)}${s.kind !== "code" ? `${s.kind}` : ""}${s.tdd ? `tests first` : ""}` + + `${s.commits.length ? `
    ${s.commits.map(c => `${esc(short(c))}`).join(" ")}
    ` : ""}
  • `; + +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 `
  • ${box(c.status)}
    ${esc(c.id)}${esc(c.text)}
    ${extra}
  • `; +}; + +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(`

    Needs your answer

      ${pl.questions.map(q => `
    1. ${q}
    2. `).join("")}
    `); + parts.push(`

    Approach

    ${pl.approach}`); + parts.push(`

    Acceptance criteria

      ${pl.criteria.map(c => criterionRow(t, c)).join("")}
    `); + parts.push(`

    Steps

      ${pl.steps.map(stepRow).join("")}
    `); + if (pl.decisions.length) + parts.push(`

    Decisions

    ${pl.decisions.map(([q, c, w]) => ``).join("")}
    QuestionChoiceWhy
    ${esc(q)}${esc(c)}${esc(w)}
    `); + if (pl.risks.length) + parts.push(`

    Risks

    ${pl.risks.map(r => ``).join("")}
    RiskHandlingCovers
    ${esc(r.risk)}${r.fromTicket ? `ticket` : ""}${esc(r.handling)}${esc((r.criteria ?? []).join(", "))}
    `); + if (pl.tests.length) + parts.push(`

    Test plan

    ${pl.tests.map(x => ``).join("")}
    KindTestCovers
    ${esc(x.kind)}${esc(x.text)}${x.fromTicket ? `ticket` : ""}${esc((x.criteria ?? []).join(", "))}
    `); + if (pl.mocks.length) parts.push(`

    ${pl.mocks.length} mock artboard${pl.mocks.length > 1 ? "s" : ""}: ${pl.mocks.map(m => esc(m.title)).join(", ")}.

    `); + 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) => `
    ${label}
    ${a} of ${b}
    `; + const parts = [head(t, "Progress"), `
    ${stat("Steps", steps, pl.steps.length)}${stat("Criteria", crit, pl.criteria.length)}
    Proof
    ${t.proof.length} captured
    `]; + parts.push(`

    Steps

      ${pl.steps.map(stepRow).join("")}
    `); + parts.push(`

    Acceptance criteria

      ${pl.criteria.map(c => criterionRow(t, c)).join("")}
    `); + if (open.length) parts.push(`

    Open review findings

      ${open.map(f => `
    • ${box("todo")}
      ${esc(f.id)}${esc(f.text)}${f.where ? `
      ${esc(f.where)}
      ` : ""}
    • `).join("")}
    `); + const v = t.review.preMr; + if (v) parts.push(`

    pre-mr-review

    ${esc(v.verdict)}${v.head ? ` at ${esc(short(v.head))}` : ""}, run ${v.runs}

    `); + 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 => + `
    ${esc(m.title)} ${esc(m.id)}${m.criteria.length ? ` · ${esc(m.criteria.join(", "))}` : ""}` + + `${m.note ? `
    ${esc(m.note)}
    ` : ""}
    `).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; +} diff --git a/user/agents/skills/ticket/scripts/ticket-page.test.ts b/user/agents/skills/ticket/scripts/ticket-page.test.ts index e271a7b..280073b 100644 --- a/user/agents/skills/ticket/scripts/ticket-page.test.ts +++ b/user/agents/skills/ticket/scripts/ticket-page.test.ts @@ -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(/^/); + expect(out.stdout).toContain("abc1234"); + 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"); diff --git a/user/agents/skills/ticket/scripts/ticket-page.ts b/user/agents/skills/ticket/scripts/ticket-page.ts index 9670e50..fa494d1 100644 --- a/user/agents/skills/ticket/scripts/ticket-page.ts +++ b/user/agents/skills/ticket/scripts/ticket-page.ts @@ -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 = { + 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 print where the ticket stands", a => { command("path", "path print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html"))); -command("open", "open open the page in the browser", a => { - spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" }); +command("open", "open 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 <${VIEWS.join("|")}> [--mock ] 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 print the ticket and plan as markdown, for an agent's reading order", a => { diff --git a/user/agents/skills/ticket/templates/site/epic.html b/user/agents/skills/ticket/templates/site/epic.html index 82603e2..ec4d169 100644 --- a/user/agents/skills/ticket/templates/site/epic.html +++ b/user/agents/skills/ticket/templates/site/epic.html @@ -4,10 +4,14 @@ {{TITLE}} - +

    Loading epic.data.js…

    - + diff --git a/user/agents/skills/ticket/templates/site/site.css b/user/agents/skills/ticket/templates/site/site.css index 25d99a0..6dbea5b 100644 --- a/user/agents/skills/ticket/templates/site/site.css +++ b/user/agents/skills/ticket/templates/site/site.css @@ -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; diff --git a/user/agents/skills/ticket/templates/site/site.js b/user/agents/skills/ticket/templates/site/site.js index c1b1507..8fc1499 100644 --- a/user/agents/skills/ticket/templates/site/site.js +++ b/user/agents/skills/ticket/templates/site/site.js @@ -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 . This script loads that file with a // +