diff --git a/packages/viewer/e2e/template.e2e.mjs b/packages/viewer/e2e/template.e2e.mjs index 06cc740..5cdb153 100644 --- a/packages/viewer/e2e/template.e2e.mjs +++ b/packages/viewer/e2e/template.e2e.mjs @@ -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/ returns hints + example content", full.hints.includes("WHAT:") && full.content.length > 10 && full.type === "datatable"); + ok("GET /template/ returns the note + example content", full.hints.includes("swap the rows") && full.content.length > 10 && full.type === "datatable"); ok("GET /template/ 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. diff --git a/packages/viewer/src/client/style.css b/packages/viewer/src/client/style.css index 075b876..ba1e135 100644 --- a/packages/viewer/src/client/style.css +++ b/packages/viewer/src/client/style.css @@ -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; } diff --git a/packages/viewer/src/client/viewer.ts b/packages/viewer/src/client/viewer.ts index 0fbecae..512e255 100644 --- a/packages/viewer/src/client/viewer.ts +++ b/packages/viewer/src/client/viewer.ts @@ -755,12 +755,11 @@ async function openSharePopover(btn: HTMLButtonElement): Promise { 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 ` → 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 ` → read note → author data → push. let templatesPopover: HTMLElement | null = null; function closeTemplatesPopover(): void { templatesPopover?.remove(); @@ -788,20 +787,22 @@ async function openTemplatesPopover(btn: HTMLButtonElement): Promise { const save = document.createElement("div"); save.className = "tpl-save"; save.innerHTML = - `
Save ${current} as a template
` + - '' + - '' + - '
'; + '
Save this board’s design — its layout and styling, not the data — so you or an agent can rebuild it later with new data.
' + + '' + + '' + + '
' + + ''; (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" }, @@ -809,9 +810,21 @@ async function openTemplatesPopover(btn: HTMLButtonElement): Promise { }); 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}`; @@ -864,12 +877,12 @@ async function renderTemplateList(wrap: HTMLElement): Promise { 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 () => { @@ -890,7 +903,7 @@ async function renderTemplateList(wrap: HTMLElement): Promise { 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"; }