feat(kb): edit + re-embed document summaries from the viewer (admin)

This commit is contained in:
2026-08-31 23:52:22 -04:00
parent d94f3d5a52
commit 140b97ebf3
8 changed files with 1540 additions and 9 deletions
+157
View File
@@ -51,6 +51,15 @@
* doc.summary renders as a labeled .doc-summary section above the
* original content on BOTH surfaces (page + modal) through this one
* core; the summary text is a text node (XSS contract unchanged).
*
* Phase 57 (task 02, D4): the panel gains an ADMIN-ONLY edit
* affordance (docAdminReady() gate on the module-cached whoami): an
* Edit button in the panel's header row opens an inline editor
* (prefilled textarea + Save/Cancel + a role=status live region), and
* Save PATCHes /api/documents/summary. The section is built for
* everyone exactly as phase 36 — the public viewer stays
* byte-for-byte identical (no button, no wiring, no admin-only
* network call) until the admin gate resolves true.
*/
import { fetchIsAdmin, initSharedHeader } from "./header.js";
@@ -125,6 +134,14 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
body.textContent = doc.summary; // text node — XSS contract unchanged
section.append(title, body);
contentEl.appendChild(section);
// Phase 57 (task 02, D4): the section above is the phase-36 shape
// for EVERYONE — only an authenticated admin (docAdminReady(), the
// module-cached whoami promise) then gains the header row + Edit
// button + editor wiring. Anonymous / fetch failure: the panel is
// exactly what phase 36 built (byte-for-byte unchanged).
void docAdminReady().then((admin) => {
if (admin) wireSummaryEdit(section, doc);
});
}
if (doc.format === "md" || doc.format === "markdown") {
const wrap = document.createElement("div");
@@ -139,6 +156,146 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
}
}
/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ----------
* The .doc-summary panel is the ONE place the stored summary is edited
* (page + modal through this core). Only an admin (docAdminReady) ever
* gets the affordance; the public viewer is byte-for-byte unchanged. */
/* The admin gate (D4 — the viewer stays public): the module-cached
* /api/whoami promise (header.js's fetchIsAdmin — the SAME single
* request per page the shared header already makes on every surface,
* so this adds no request of its own). A non-admin or any fetch
* failure resolves false → the anonymous viewer. */
async function docAdminReady() {
try {
return (await fetchIsAdmin()) === true;
} catch {
return false;
}
}
/* The edit affordance on one rendered .doc-summary section. The bare
* h2 becomes a header row (label left, Edit button right). Edit swaps
* the .doc-summary-text node for the inline editor — a prefilled
* textarea (value, never innerHTML — XSS contract), Save / Cancel,
* and a role=status live region. Save PATCHes /api/documents/summary
* with { source, path, summary } — the pair comes from the doc object
* (the same values the modal core carries, document-modal.js). Success
* re-renders the text node via textContent and announces "Summary
* updated."; an empty save that clears announces "Summary cleared."
* and removes the panel a short beat later — the confirmation stays
* readable, and the renderer only draws the panel for non-empty
* summaries. Cancel restores the text node. A failure keeps the
* editor open with the user's text and shows neutral retry copy
* (phase-55 convention). */
function wireSummaryEdit(section, doc) {
const title = section.querySelector(".doc-summary-title");
const body = section.querySelector(".doc-summary-text");
if (!title || !body) return;
/* Header row: label left, Edit button right (admin-only — the
* anonymous section keeps its bare h2). */
const head = document.createElement("div");
head.className = "doc-summary-head";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "doc-summary-edit";
editBtn.textContent = "Edit";
head.append(title, editBtn);
section.replaceChildren(head, body);
/* Editor parts (built once; the textarea is rebuilt on every open so
* it always starts from the CURRENT stored summary). */
let editor = null;
const actions = document.createElement("div");
actions.className = "doc-summary-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "doc-summary-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "doc-summary-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
const status = document.createElement("p");
status.className = "doc-summary-status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
/* Back to the display state: the text node re-rendered from the
* doc object (the CURRENT stored summary), the live region (the
* announced message), the Edit button available again. If the
* summary is gone (a clear landed while the editor was open — e.g.
* Cancel right after a successful empty save) the panel is gone
* too: the renderer only draws it for non-empty summaries. */
function closeEditor(message) {
status.textContent = message;
editBtn.hidden = false;
if (typeof doc.summary !== "string" || doc.summary.trim() === "") {
section.remove();
return;
}
body.textContent = doc.summary; // text node — the CURRENT stored summary
section.replaceChildren(head, body, status);
editBtn.focus();
}
async function saveSummary() {
const value = editor.value;
saveBtn.disabled = true; // one PATCH at a time (never stale)
status.textContent = "";
try {
const res = await fetch("/api/documents/summary", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: doc.source, path: doc.path, summary: value }),
});
if (!res.ok) {
// Neutral retry copy (phase-55 convention) — the user's text
// stays in the editor (the editor stays open on failure).
status.textContent = "Couldn't update the summary — try again.";
return;
}
const data = await res.json();
if (data.summary === null) {
// Cleared (D4): the panel disappears — the renderer only draws
// it for non-empty summaries. The live-region confirmation
// stays visible for a short beat before the panel leaves the
// DOM (a screen reader must be able to read it; the removal
// is a no-op if the surface re-rendered or closed meanwhile).
doc.summary = null;
status.textContent = "Summary cleared.";
setTimeout(() => section.remove(), 2000);
return;
}
doc.summary = data.summary;
closeEditor("Summary updated.");
} catch {
// Network failure: same neutral shape, the reachable? copy.
status.textContent = "Couldn't update the summary — is the app reachable?";
} finally {
saveBtn.disabled = false;
}
}
function openEditor() {
editor = document.createElement("textarea");
editor.className = "doc-summary-editor";
editor.value = typeof doc.summary === "string" ? doc.summary : ""; // value, never innerHTML
status.textContent = "";
editBtn.hidden = true;
section.replaceChildren(head, editor, actions, status);
editor.focus();
}
editBtn.addEventListener("click", openEditor);
saveBtn.addEventListener("click", () => {
void saveSummary();
});
cancelBtn.addEventListener("click", () => closeEditor(""));
}
/* ---------- /document.html page (phases 10/13/19) ----------
* Phase 26: viewer-page-specific — see the import-safety note in the
* header. The guard is #doc-title: it exists only on this page, so the
+91
View File
@@ -2382,6 +2382,97 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
color: var(--ink); /* on --surface ≈14.5:1 */
}
/* Summary edit affordance (phase 57, task 02 — D4, admin-only): the
header row (label + Edit button), the inline editor (prefilled
textarea + Save/Cancel + a role=status live region). House
dark-tech palette (phase-08 tokens), system fonts, no CDN;
:focus-visible via the global 3px outline rule. Anonymous visitors
never see any of it — the button and editor are wired only for
admins (docAdminReady in document.js), so the public panel is
byte-for-byte the phase-36 shape. */
.doc-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem; /* the old .doc-summary-title bottom margin */
}
.doc-summary-head .doc-summary-title { margin: 0; }
.doc-summary-edit {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0.15rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.02em;
cursor: pointer;
}
.doc-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.doc-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
.doc-summary-editor {
display: block;
width: 100%;
min-height: 8rem;
padding: 0.6rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg); /* inset against the --surface panel */
color: var(--ink); /* 16.7:1 on --bg (AA) */
font: inherit;
line-height: 1.5;
resize: vertical;
}
.doc-summary-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.doc-summary-save {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0.35rem 0.95rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg); /* --bg on --brand = 5.2:1 (AA) */
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.doc-summary-save:hover { background: #f55a72; } /* the house hover lightening */
.doc-summary-save:disabled { opacity: 0.6; cursor: default; } /* one PATCH at a time */
.doc-summary-cancel {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0.35rem 0.95rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.doc-summary-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.doc-summary-status {
margin: 0.6rem 0 0;
font-size: 0.85rem;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
}
.doc-summary-status:empty { margin-top: 0; }
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
.doc-raw {
width: 100%;