Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions packages/viewer/e2e/template.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,28 +54,30 @@ try {
await p.goto(`${BASE}/w/${WSID}/#p/spend`, { waitUntil: "domcontentloaded", timeout: 20000 });
await p.waitForFunction(() => document.querySelectorAll("#diagram .tc-datatable tbody tr").length >= 3, null, { timeout: 15000 });

// Save as template via the UI.
// Save as template via the UI (the note is a plain, optional free-text field — no jargon scaffold).
await p.locator("#templates").click();
await p.waitForSelector(".templates-popover .tpl-save", { timeout: 5000 });
ok("UI: note field is empty by default (no jargon scaffold)", (await p.locator(".tpl-note").inputValue()) === "");
await p.fill(".tpl-name", "Cloud spend table");
await p.fill(".tpl-hints", "WHAT: monthly spend\nDATA SLOTS: rows[]\nADAPT: swap rows\nKEEP: columns");
await p.fill(".tpl-note", "Monthly spend by service — swap the rows each month, keep the columns.");
await p.locator(".tpl-save-btn").click();
await p.waitForFunction(() => /Saved tpl_/.test(document.querySelector(".tpl-save-msg")?.textContent || ""), null, { timeout: 8000 });
ok("UI: save returns a template id", /Saved tpl_/.test(await p.locator(".tpl-save-msg").textContent()));
// Success surfaces a copyable id + a "give your agent this id" instruction.
await p.waitForFunction(() => { const el = document.querySelector(".tpl-result"); return el && !el.hidden && /^tpl_/.test(el.querySelector(".tpl-id")?.textContent || ""); }, null, { timeout: 8000 });
ok("UI: save shows the id + how to reuse it", /give your agent this id/i.test(await p.locator(".tpl-result").textContent()));
await p.waitForFunction(() => document.querySelectorAll(".templates-popover .tpl-row").length === 1, null, { timeout: 5000 });
ok("UI: saved template appears in the library list", (await p.locator(".templates-popover .tpl-row").count()) === 1);

// Inline hints expand.
await p.locator(".tpl-row .tpl-act", { hasText: "Hints" }).first().click();
await p.waitForFunction(() => /WHAT:/.test(document.querySelector(".tpl-hints-box")?.textContent || ""), null, { timeout: 5000 });
ok("UI: hints expand inline", /DATA SLOTS:/.test(await p.locator(".tpl-hints-box").first().textContent()));
// Inline note expand.
await p.locator(".tpl-row .tpl-act", { hasText: "Note" }).first().click();
await p.waitForFunction(() => /swap the rows/.test(document.querySelector(".tpl-hints-box")?.textContent || ""), null, { timeout: 5000 });
ok("UI: note expands inline", /keep the columns/.test(await p.locator(".tpl-hints-box").first().textContent()));

// Global HTTP API: list + get (hints + content).
const list = await (await fetch(`${BASE}/w/${WSID}/templates`)).json();
ok("GET /templates lists the template", Array.isArray(list) && list.length === 1 && list[0].type === "datatable");
const id = list[0].id;
const full = await (await fetch(`${BASE}/w/${WSID}/template/${id}`)).json();
ok("GET /template/<id> returns hints + example content", full.hints.includes("WHAT:") && full.content.length > 10 && full.type === "datatable");
ok("GET /template/<id> returns the note + example content", full.hints.includes("swap the rows") && full.content.length > 10 && full.type === "datatable");
ok("GET /template/<unknown> is 404", (await fetch(`${BASE}/w/${WSID}/template/tpl_missing`)).status === 404);

// Reuse: adapt the example (swap a row) and push it to a NEW scope — proves the loop.
Expand Down
18 changes: 12 additions & 6 deletions packages/viewer/src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,19 @@ header { display: flex; gap: 16px; align-items: center; padding: 8px 12px; borde
.share-pop-revoke { align-self: flex-start; }
/* Templates popover — save the current board + browse/manage the library. */
.templates-popover { position: fixed; z-index: 1000; width: min(380px, calc(100vw - 16px)); max-height: min(70vh, 640px); overflow: auto; display: flex; flex-direction: column; gap: 10px; padding: 12px; background: var(--surface); color: var(--fg); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.35); }
.tpl-save { display: flex; flex-direction: column; gap: 6px; }
.tpl-save-head { font-size: 12.5px; color: var(--muted); }
.tpl-save-head strong { color: var(--fg); }
.tpl-name, .tpl-hints { width: 100%; padding: 6px 8px; font: inherit; font-size: 12px; color: var(--fg); background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box; }
.tpl-hints { resize: vertical; font-family: var(--font-mono); line-height: 1.4; }
.tpl-save-row { display: flex; align-items: center; gap: 8px; }
.tpl-save { display: flex; flex-direction: column; gap: 8px; }
.tpl-intro { font-size: 12.5px; line-height: 1.45; color: var(--muted); }
.tpl-intro strong { color: var(--fg); }
.tpl-field { display: flex; flex-direction: column; gap: 3px; font-size: 11.5px; color: var(--muted); }
.tpl-opt { color: var(--muted); font-weight: 400; }
.tpl-name, .tpl-note { width: 100%; padding: 6px 8px; font: inherit; font-size: 12px; color: var(--fg); background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box; }
.tpl-note { resize: vertical; line-height: 1.4; }
.tpl-save-row { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.tpl-save-msg { flex: 1; min-width: 0; font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tpl-result { display: flex; flex-direction: column; gap: 6px; padding: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; }
.tpl-result-line { font-size: 12px; color: var(--fg); }
.tpl-id-row { display: flex; align-items: center; gap: 8px; }
.tpl-id { flex: 1; min-width: 0; padding: 3px 6px; font-family: var(--font-mono); font-size: 12px; color: var(--accent); background: var(--bg); border: 1px solid var(--border); border-radius: 5px; overflow: hidden; text-overflow: ellipsis; }
.tpl-divider { height: 1px; background: var(--border); }
.tpl-list-head { font-size: 12.5px; font-weight: 600; color: var(--muted); }
.tpl-list { display: flex; flex-direction: column; gap: 6px; }
Expand Down
55 changes: 34 additions & 21 deletions packages/viewer/src/client/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,12 +755,11 @@ async function openSharePopover(btn: HTMLButtonElement): Promise<void> {
setTimeout(() => document.addEventListener("click", onDocClickShare, true), 0);
}

// --- Templates (save a board as a reusable, AI-adaptable template; browse/manage the library) ------
// The hints textarea is prefilled with this scaffold so saved templates carry the structured,
// AI-facing guidance a FUTURE agent needs to adapt the template for new data (see the diagram-recipes
// skill). Reuse is agent-driven: `termchart template get <id>` → read hints → author data → push.
const TEMPLATE_HINTS_SCAFFOLD =
"WHAT: \nDATA SLOTS: \nADAPT: \nKEEP: ";
// --- Templates (save a board's design as a reusable template; browse/manage the library) -----------
// A template stores the board's structure/styling (not its data) under a `tpl_…` id. The optional
// "note" is free-text guidance a future agent reads to rebuild it with new data — kept plain on
// purpose (no jargon scaffold), so a human saving from the UI isn't asked to fill in a form they
// don't understand. Reuse is agent-driven: `termchart template get <id>` → read note → author data → push.
let templatesPopover: HTMLElement | null = null;
function closeTemplatesPopover(): void {
templatesPopover?.remove();
Expand Down Expand Up @@ -788,30 +787,44 @@ async function openTemplatesPopover(btn: HTMLButtonElement): Promise<void> {
const save = document.createElement("div");
save.className = "tpl-save";
save.innerHTML =
`<div class="tpl-save-head">Save <strong>${current}</strong> as a template</div>` +
'<input class="tpl-name" type="text" aria-label="Template name" />' +
'<textarea class="tpl-hints" rows="5" aria-label="AI hints — how to adapt this template" spellcheck="false"></textarea>' +
'<div class="tpl-save-row"><span class="tpl-save-msg"></span><button type="button" class="tc-btn tc-btn-primary tpl-save-btn">Save template</button></div>';
'<div class="tpl-intro">Save this board’s <strong>design</strong> — its layout and styling, not the data — so you or an agent can rebuild it later with new data.</div>' +
'<label class="tpl-field">Name<input class="tpl-name" type="text" /></label>' +
'<label class="tpl-field">Note for your agent <span class="tpl-opt">— optional</span>' +
'<textarea class="tpl-note" rows="2" spellcheck="false" placeholder="How to reuse it: what data to swap, what to keep. e.g. “Monthly revenue table — replace the rows each month, keep the columns.”"></textarea></label>' +
'<div class="tpl-save-row"><span class="tpl-save-msg"></span><button type="button" class="tc-btn tc-btn-primary tpl-save-btn">Save as template</button></div>' +
'<div class="tpl-result" hidden></div>';
(save.querySelector(".tpl-name") as HTMLInputElement).value = current;
(save.querySelector(".tpl-hints") as HTMLTextAreaElement).value = TEMPLATE_HINTS_SCAFFOLD;
const msg = save.querySelector(".tpl-save-msg") as HTMLElement;
const result = save.querySelector(".tpl-result") as HTMLElement;
const saveBtn = save.querySelector(".tpl-save-btn") as HTMLButtonElement;
saveBtn.onclick = async () => {
const sl = current.indexOf("/");
const project = current.slice(0, sl), agent = current.slice(sl + 1);
const name = (save.querySelector(".tpl-name") as HTMLInputElement).value.trim() || current;
const hints = (save.querySelector(".tpl-hints") as HTMLTextAreaElement).value;
saveBtn.disabled = true; msg.textContent = "Saving…";
const hints = (save.querySelector(".tpl-note") as HTMLTextAreaElement).value.trim();
saveBtn.disabled = true; msg.textContent = "Saving…"; result.hidden = true;
try {
const r = await fetch(`${apiBase}/template`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ project, agent, name, hints }),
});
if (!r.ok) { msg.textContent = `Save failed (HTTP ${r.status})`; saveBtn.disabled = false; return; }
const d = (await r.json()) as { id: string };
msg.textContent = `Saved ${d.id}`;
msg.textContent = "";
// Tell the human what to DO with it: copy the id, hand it to an agent.
await copyText(d.id);
msg.textContent = `Saved ${d.id} (id copied)`;
result.replaceChildren();
const line = document.createElement("div");
line.className = "tpl-result-line";
line.textContent = "✓ Saved. To reuse it, give your agent this id:";
const row = document.createElement("div");
row.className = "tpl-id-row";
const code = document.createElement("code"); code.className = "tpl-id"; code.textContent = d.id;
const copy = document.createElement("button"); copy.type = "button"; copy.className = "tpl-act"; copy.textContent = "Copied ✓";
copy.onclick = async () => { const ok = await copyText(d.id); copy.textContent = ok ? "Copied ✓" : "Copy id"; };
row.append(code, copy);
result.append(line, row);
result.hidden = false;
await renderTemplateList(listWrap);
} catch (e) {
msg.textContent = `Save failed: ${(e as Error).message}`;
Expand Down Expand Up @@ -864,12 +877,12 @@ async function renderTemplateList(wrap: HTMLElement): Promise<void> {
const acts = document.createElement("div");
acts.className = "tpl-row-acts";
const copyId = document.createElement("button");
copyId.type = "button"; copyId.className = "tpl-act"; copyId.textContent = "Copy ID";
copyId.title = `Copy ${t.id} (hand this to an agent: termchart template get ${t.id})`;
copyId.onclick = async () => { const ok = await copyText(t.id); copyId.textContent = ok ? "Copied ✓" : "Failed"; setTimeout(() => { copyId.textContent = "Copy ID"; }, 1200); };
copyId.type = "button"; copyId.className = "tpl-act"; copyId.textContent = "Copy id";
copyId.title = `Copy ${t.id} — give it to an agent to reuse this template`;
copyId.onclick = async () => { const ok = await copyText(t.id); copyId.textContent = ok ? "Copied ✓" : "Failed"; setTimeout(() => { copyId.textContent = "Copy id"; }, 1200); };
const hintsBtn = document.createElement("button");
hintsBtn.type = "button"; hintsBtn.className = "tpl-act"; hintsBtn.textContent = "Hints";
hintsBtn.title = "Show this template's adaptation hints";
hintsBtn.type = "button"; hintsBtn.className = "tpl-act"; hintsBtn.textContent = "Note";
hintsBtn.title = "Show the reuse note";
const del = document.createElement("button");
del.type = "button"; del.className = "tpl-act tpl-act-danger"; del.textContent = "Delete";
del.onclick = async () => {
Expand All @@ -890,7 +903,7 @@ async function renderTemplateList(wrap: HTMLElement): Promise<void> {
try {
const r = await fetch(`${apiBase}/template/${encodeURIComponent(t.id)}`);
const full = r.ok ? (await r.json()) as { hints?: string } : null;
hintsBox.textContent = (full?.hints || "(no hints provided)").trim() || "(no hints provided)";
hintsBox.textContent = (full?.hints || "").trim() || "(no note)";
} catch { hintsBox.textContent = "(failed to load hints)"; }
hintsBox.dataset.loaded = "1";
}
Expand Down
Loading