feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch

This commit is contained in:
2026-09-01 03:52:03 -04:00
parent 7b7a834a1a
commit 725af9fac1
32 changed files with 4356 additions and 107 deletions
+148 -1
View File
@@ -495,6 +495,126 @@ function markLastRetryable() {
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap);
// Phase 59: "Save as doc" stays the meta row's rightmost action —
// when the Retry button lands on the SAME bubble, re-append the save
// button after it (the auto margins split the free space between the
// right-aligned buttons; DOM order decides the right edge).
const saveDocBtn = lastBrainWrap.querySelector(".save-as-doc-btn");
if (saveDocBtn && saveDocBtn.parentElement)
saveDocBtn.parentElement.appendChild(saveDocBtn);
}
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
* "Save as doc" action of EVERY completed brain bubble (deflected
* included — same scope as Tune; a stopped partial is a note, not an
* answer, so m.stopped records never get it — the restore call site
* gates on it). Gate: admin (the whoami gate Tune uses) AND a
* configured docs repo (docsRepoConfigured — /api/config, settled in
* the boot IIFE before any bubble renders). `markdown` is the RAW
* persisted answer text — m.text on the restore path, the
* done/fallback raw text on the live path — NEVER the rendered HTML.
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the
* row's right edge (the TODO's "bottom right"); markLastRetryable
* keeps it rightmost when the last bubble also carries the Retry
* button.
*
* Click: default title (the LAST user question, whitespace-collapsed,
* ≤120 chars — the phase-50 auto-title convention) + default in-repo
* path (docs/<slug>.md) → POST /api/doc-drafts {title, path, body} →
* 201 → /doc-edit.html?draft=<token> (the edit screen, task 06, owns
* the rest). Failure → the neutral one-line banner (phase-55
* convention), the conversation unblocked, no navigation. */
const SAVE_AS_DOC_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></svg>';
const DOC_TITLE_MAX = 120; // the phase-50 auto-title cap (owner-locked)
/* The default doc title: the LAST user question's text,
* whitespace-collapsed, truncated to 120 chars — the phase-50
* auto-title convention (server-side: " ".join(text.split())[:120])
* applied to the last question. Defensive "Note" when the
* conversation has no user record (the UI cannot produce one).
* " ".join(split()) == replace(/\s+/g, " ").trim() for non-empty
* input; the trim keeps the leading/trailing-whitespace edge identical. */
function defaultDocTitle() {
let question = "";
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "user") {
question = conversation[i].text;
break;
}
}
return question.replace(/\s+/g, " ").trim().slice(0, DOC_TITLE_MAX) || "Note";
}
/* The default in-repo path slug (phase 59 locked assumption):
* lowercase, runs of non-alphanumerics → "-", trimmed, ≤60 chars,
* empty → "note". The 60-cut can land mid dash-run — the trailing
* trim again keeps the path from ending in a dangling "-". */
function docSlug(title) {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/g, "");
return slug || "note";
}
/* The bottom-right "Save as doc" button — the appendTuneButton
* pattern: reuses the .msg-meta row when it exists (role=list → the
* button joins as a listitem so ARIA stays valid), otherwise creates
* a plain meta row; one button per bubble. */
function appendSaveAsDocButton(wrap, markdown) {
if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
btn.addEventListener("click", () => saveAsDoc(btn, markdown));
meta.appendChild(btn);
}
/* Create the draft from the bubble's RAW markdown and hand off to the
* edit screen. Double-click guard: one save at a time (the button is
* disabled until the outcome — released in the finally, never stale,
* PLAN §7.4). */
async function saveAsDoc(btn, markdown) {
if (btn.disabled) return; // one save at a time (double-click guard)
btn.disabled = true;
try {
const title = defaultDocTitle();
const path = `docs/${docSlug(title)}.md`;
const res = await fetch("/api/doc-drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, path, body: markdown }),
});
if (!res.ok) {
// Neutral one-line copy (phase-55 convention) — the detail may
// be a guard-rail 422 or a server hiccup; neither is actionable
// here, and the conversation stays unblocked (no navigation).
showErrorBanner("Couldn't save the answer as a doc — try again.");
return;
}
const draft = await res.json();
// 201: the draft's uuid4 token IS the edit screen's credential.
location.assign("/doc-edit.html?draft=" + draft.token);
} catch {
showErrorBanner("Couldn't save the answer as a doc — is the app reachable?");
} finally {
btn.disabled = false; // released on EVERY outcome — never stale
}
}
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
@@ -1091,6 +1211,10 @@ function renderStoredMessage(m) {
}
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
// Phase 59: the RAW persisted markdown (m.text — HTML is never
// persisted). A stopped partial (m.stopped) is a note, not an answer
// — no button (the live stop path adds none either).
if (!m.stopped) appendSaveAsDocButton(wrap, m.text);
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
}
@@ -1538,6 +1662,16 @@ const signInLink = document.querySelector("#sign-in-link");
const signOutBtn = document.querySelector("#sign-out-btn");
let isAdmin = false;
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push gate
* — GET /api/config's ``docs_repo_configured`` (settings.docs_configured
* server-side), surfaced by brand.js as window.BOR_DOCS_REPO_CONFIGURED
* (the way app_name is: a window global, false until the boot fetch
* proves otherwise). Captured ONCE in the boot IIFE after the fetch
* settles, so the "Save as doc" buttons render exactly once: present
* for a configured admin, absent for everyone else — and while
* BOR_DOCS_REPO is empty the feature is inert (D3). */
let docsRepoConfigured = false;
function applyAuthState() {
if (signInLink) signInLink.hidden = isAdmin;
if (signOutBtn) signOutBtn.hidden = !isAdmin;
@@ -1812,11 +1946,15 @@ async function runTurn(text, { reask = false } = {}) {
appendMaybeTry(wrap, ev.suggestions);
}
appendSources(wrap, ev.sources);
appendTuneButton(wrap); // every completed brain bubble is tunable
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
appendTuneButton(wrap); // every completed brain bubble is tunable
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores below,
// so a reload (the restore path) offers the identical draft.
appendSaveAsDocButton(wrap, finalText || acc || "…");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
@@ -1851,6 +1989,7 @@ async function runTurn(text, { reask = false } = {}) {
const fallback = EMPTY_ANSWER_FALLBACK;
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
rememberBrainTurn(fallback, {}); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
@@ -1965,6 +2104,14 @@ window.addEventListener("pagehide", () => {
(async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
// Phase 59: /api/config is settled BEFORE any bubble renders —
// brand.js's single boot fetch (window.BOR_CONFIG_PROMISE, never
// rejecting) has set window.BOR_DOCS_REPO_CONFIGURED (false until
// proven), so a restored conversation of a configured admin gets the
// "Save as doc" button exactly once: no flash, no re-render, no
// second fetch (the brand fetch IS the config fetch).
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
// Phase 55 (task 03): no Share-reveal step — the pill is static,
// always-visible markup (visible to every visitor, phase 51 contract).
+105 -73
View File
@@ -9,7 +9,15 @@
* Contract (phase 39 locked decisions — A11 no CDN, runtime fetch):
* • window.BOR_BRAND = "Brain of Reese" synchronously — the default
* name renders immediately, no blank flash;
* • fetch("/api/config", { cache: "no-store" }) — on success with a
* • Phase 59: window.BOR_DOCS_REPO_CONFIGURED = false synchronously
* (inert until proven) and window.BOR_CONFIG_PROMISE — the SAME
* fetch's promise, exposed at parse time so the chat page's boot
* (app.js) can await it BEFORE rendering any bubble; the "Save as
* doc" gating flag is then final, and a restored conversation of a
* configured admin never misses (or flashes) the button. The
* promise NEVER rejects — the error arm warns and resolves null;
* • fetch("/api/config", { cache: "no-store" }) — on success the
* docs flag is set from cfg.docs_repo_configured, and on a
* non-empty app_name, window.BOR_BRAND is updated and the name is
* applied to the DOM:
* 1. document.title — global replace of the literal;
@@ -36,6 +44,12 @@
reading window.BOR_BRAND at evaluation time always find a value. */
window.BOR_BRAND = "Brain of Reese";
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push flag —
surfaced the way app_name is (a window global, inert until the boot
fetch proves otherwise). false = the "Save as doc" action is hidden
for everyone (BOR_DOCS_REPO empty — the feature is off). */
window.BOR_DOCS_REPO_CONFIGURED = false;
/* The literal the DOM passes replace — the default name. The page
scripts' own `window.BOR_BRAND || "Brain of Reese"` fallbacks stay in
sync with it. */
@@ -50,83 +64,101 @@ function escapeHTML(s) {
}[c]));
}
function applyBrand() {
fetch("/api/config", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((cfg) => {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
if (!name) return; // empty / missing: the default stands
window.BOR_BRAND = name;
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 2. The header brand on every page: a name starting "Brain of "
// keeps the bold split (the current look), anything else
// renders plain — the name is always escaped.
for (const el of document.querySelectorAll(".brand-text")) {
if (name.startsWith("Brain of ")) {
const rest = name.slice("Brain of ".length);
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
} else {
el.textContent = name;
}
}
// 3. Prose: a TreeWalker over the body's text nodes replaces the
// literal (the empty-state h1, any other copy). Text nodes
// inside <script>/<style> are rejected — the page source must
// never be rewritten.
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const tag = node.parentElement ? node.parentElement.tagName : "";
return tag === "SCRIPT" || tag === "STYLE"
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
},
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
}
}
// 4. Attributes: the #messages aria-label, the composer input
// label, the meta descriptions — aria-label / placeholder /
// meta content only, each replaced in place.
for (const el of document.querySelectorAll(
"[aria-label], [placeholder], meta[content]",
)) {
for (const attr of ["aria-label", "placeholder"]) {
const v = el.getAttribute(attr);
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
}
}
}
})
.catch((err) => {
/* The /api/config fetch — started at TOP LEVEL (parse time) so
window.BOR_CONFIG_PROMISE exists before the page's module scripts
evaluate (app.js's boot awaits it, above). Phase 59: the flag lands
here, the moment the answer arrives — before any DOM pass. The
promise NEVER rejects: the error arm warns (the loadHealth house
style — the page never breaks) and resolves to null, so the default
name + false flag stand. */
const BOR_CONFIG_PROMISE = fetch("/api/config", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then(
(cfg) => {
window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;
return cfg;
},
(err) => {
// Fetch failure (or a non-JSON body): the default name stays —
// the page never breaks (the loadHealth house style).
console.warn("brand: /api/config did not answer — keeping the default name.", err);
});
return null;
},
);
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
function applyBrand() {
BOR_CONFIG_PROMISE.then((cfg) => {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
if (!name) return; // empty / missing: the default stands
window.BOR_BRAND = name;
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 2. The header brand on every page: a name starting "Brain of "
// keeps the bold split (the current look), anything else
// renders plain — the name is always escaped.
for (const el of document.querySelectorAll(".brand-text")) {
if (name.startsWith("Brain of ")) {
const rest = name.slice("Brain of ".length);
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
} else {
el.textContent = name;
}
}
// 3. Prose: a TreeWalker over the body's text nodes replaces the
// literal (the empty-state h1, any other copy). Text nodes
// inside <script>/<style> are rejected — the page source must
// never be rewritten.
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const tag = node.parentElement ? node.parentElement.tagName : "";
return tag === "SCRIPT" || tag === "STYLE"
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
},
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
}
}
// 4. Attributes: the #messages aria-label, the composer input
// label, the meta descriptions — aria-label / placeholder /
// meta content only, each replaced in place.
for (const el of document.querySelectorAll(
"[aria-label], [placeholder], meta[content]",
)) {
for (const attr of ["aria-label", "placeholder"]) {
const v = el.getAttribute(attr);
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
}
}
}
});
}
/* The top level only sets the global (synchronously, at parse time);
the DOM passes run once the document is ready. */
/* The DOM passes run once the document is ready AND the config is
settled (applyBrand awaits the parse-time promise) — the fetch may
resolve before or after DOMContentLoaded; both orderings apply the
brand exactly once. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", applyBrand);
} else {
+287
View File
@@ -0,0 +1,287 @@
/* Brain of Reese — doc edit screen (phase 59, task 06).
*
* The standalone, admin-gated flow page
* ``/doc-edit.html?draft=<token>``: load the draft the chat page's
* "Save as doc" action (task 05) just created, edit the three fields
* (title, in-repo path, markdown body), and push — the commit lands on
* the .env-configured docs branch of the .env-configured repo, and the
* owner opens the PR themselves (D3: no PR tooling anywhere).
*
* The page is static; the API is the authority. The whoami gate is the
* ``sources-gate`` pattern (phases 16/35/50): anonymous visitors see
* the sign-in gate and NO /api/doc-drafts call is made (the draft
* endpoints are admin-only regardless — no draft data can leak through
* the page).
*
* Boot (admin): read ``?draft=<token>`` — missing → the error banner
* "No draft specified."; a malformed (non-uuid) token is treated as
* unknown → "Draft not found." with NO fetch (the shared.js malformed-
* token precedent) — then ``GET /api/doc-drafts/<token>``: 404 →
* "Draft not found.", any other non-2xx → the server's detail line, a
* network failure → the fixed one-line copy. On 200 the three fields
* are filled with VALUES (``.value`` only — never as markup; the body
* is user-derived markdown).
*
* Push (the §7.4 never-stale lifecycle, on #push-doc-btn):
* 1. client-side sanity FIRST — non-empty title/body, no ".." in the
* path (the server re-runs its guard-rails and is the authority;
* the browser's native ``required`` is the first line, these the
* second);
* 2. the button disables + relabels "Pushing…" and the live region
* says "Pushing…";
* 3. ``PUT /api/doc-drafts/<token>`` with all three fields — the
* push endpoint (task 04) commits the ROW's title/path/body, so
* the current edits must land on the row first (an unsaved edit
* would otherwise push the stale text);
* 4. ``POST /api/doc-drafts/<token>/push``:
* • 200 → the live region: `Pushed to <branch> — commit <sha7>.`
* (the full sha comes from the API, the first seven chars are
* shown — a re-push after further edits is a NEW commit on the
* same branch, the D3 ASSUMPTION); the button re-enables with
* its idle label;
* • non-2xx → the #push-error banner with the API's detail — for
* a git failure (502) that is git's stderr, trimmed to its
* first meaningful lines; the fields are PRESERVED (the fix is
* an edit, not a re-type) and the button re-enables;
* • network failure → the fixed one-line copy, same recovery.
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
* runs (single-evaluation design: no direct <script> tag; esbuild
* inlines it into the page bundle in the image build). The slim flow
* page carries no nav / auth pair / steering panel, so initSharedHeader
* would settle nothing — the import exists for the CACHED whoami
* (fetchIsAdmin) the gate runs on and for the Containerfile stage-1
* contract (every page module imports ./header.js).
*/
import { fetchIsAdmin } from "./header.js";
/* ---------- page elements (doc-edit.html, task 06) ---------- */
const gateEl = document.querySelector("#doc-edit-gate");
const contentEl = document.querySelector("#doc-edit-content");
const formEl = document.querySelector("#doc-edit-form");
const titleInput = document.querySelector("#draft-title");
const pathInput = document.querySelector("#draft-path");
const bodyInput = document.querySelector("#draft-body");
const pushBtn = document.querySelector("#push-doc-btn");
const statusEl = document.querySelector("#push-status");
const errorEl = document.querySelector("#push-error");
/* The button's idle label (restored in the finally — never stale). */
const IDLE_LABEL = "Push to docs branch";
/* The URL credential's shape — a uuid4 token (task 05 navigated with
it). A non-uuid value is unknown, full stop: "Draft not found." with
no fetch (the shared.js malformed-token precedent — a 422
validation line would be framework noise, not a house message). */
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Set by boot once whoami says admin and the token is present — the
push handler refuses to run without it (the form is unusable in
that state anyway: "No draft specified." is on the banner). */
let draftToken = null;
/* ---------- feedback channels (§7.4 never stale) ---------- */
/* The polite live region (role="status"): the push lifecycle line —
textContent only (the branch/sha are server data). */
function setStatus(message) {
if (statusEl) statusEl.textContent = message;
}
/* The error banner (role="alert"): shown with a message, hidden on a
fresh attempt. */
function showError(message) {
if (errorEl) {
errorEl.textContent = message;
errorEl.hidden = false;
}
}
function clearError() {
if (errorEl) errorEl.hidden = true;
}
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as
git-sources.js — 422 shape-aware. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* Git's stderr, trimmed to its first meaningful lines (task 06):
blank lines and the "hint:" chatter are dropped, at most three
lines are kept — the banner stays one compact line, and a
single-line detail (409 unconfigured, 422 guard-rail) passes
through untouched. */
function trimGitDetail(detail) {
const lines = String(detail)
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("hint:"));
return lines.slice(0, 3).join(" ") || "The push failed.";
}
/* ---------- load (GET /api/doc-drafts/<token>) ----------
* The 200 body fills the three fields — VALUES only (input.value /
* textarea.value), never as markup: the body is user-derived markdown
* and the title/path may contain anything but markup. */
async function loadDraft(token) {
let r;
try {
r = await fetch(`/api/doc-drafts/${token}`);
} catch {
showError("Could not reach the server — is the app running?");
return;
}
if (r.status === 404) {
showError("Draft not found.");
return;
}
if (!r.ok) {
showError(await apiDetail(r, `The server could not load the draft (${r.status}).`));
return;
}
let draft;
try {
draft = await r.json();
} catch {
showError("The server sent an unreadable draft — try again.");
return;
}
if (titleInput) titleInput.value = draft.title;
if (pathInput) pathInput.value = draft.path;
if (bodyInput) bodyInput.value = draft.body;
}
/* ---------- push (PUT the edits, then POST /push) ----------
* The push endpoint commits the ROW's title/path/body, so the current
* field values are PUT first (all three — a partial PUT would keep a
* stale field) and the push runs only once that lands. Every failure
* path lands the error banner (the server's detail line — git's
* stderr, trimmed, for 502s) and re-enables the button in the
* finally: never stale, success OR failure. */
function wirePush() {
if (!formEl || !pushBtn) return;
formEl.addEventListener("submit", async (e) => {
e.preventDefault();
if (!draftToken) {
showError("No draft specified.");
return;
}
// Client-side sanity (the server is the authority — it re-runs the
// guard-rails): non-empty title/body, no ".." in the path. The
// browser's native `required` is the first line, these the second
// (whitespace-only values included).
const title = titleInput.value.trim();
const path = pathInput.value.trim();
const body = bodyInput.value.trim();
if (!title) {
showError("Enter a title for the doc.");
titleInput.focus();
return;
}
if (!body) {
showError("The doc body must not be empty.");
bodyInput.focus();
return;
}
if (path.includes("..")) {
showError("The path must not contain '..'.");
pathInput.focus();
return;
}
clearError(); // a new attempt starts clean
setStatus("Pushing…");
pushBtn.disabled = true; // §7.4: one push per click
pushBtn.textContent = "Pushing…";
try {
const put = await fetch(`/api/doc-drafts/${draftToken}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, path, body }),
});
if (!put.ok) {
// 404 (the row vanished) / 422 (a field no longer passes the
// guard-rails) — the server line, the edits preserved, and
// any stale success line cleared (one claim at a time).
setStatus("");
showError(await apiDetail(put, "Could not save the doc edits — try again."));
return;
}
const r = await fetch(`/api/doc-drafts/${draftToken}/push`, {
method: "POST",
});
if (!r.ok) {
// 409 (repo unconfigured), 422 (the stored path), 502 (git's
// stderr) — the detail is the actionable line, trimmed to its
// first meaningful lines; the fields are preserved and the
// stale success line (if any) is cleared.
setStatus("");
showError(trimGitDetail(await apiDetail(r, "The push failed — try again.")));
return;
}
const pushed = await r.json();
// The full sha comes from the API; the first seven chars are the
// display value (a re-push after further edits is a NEW commit —
// the D3 ASSUMPTION — so the button re-enables for it).
setStatus(
`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`,
);
} catch {
setStatus("");
showError("Could not reach the server — is the app running?");
} finally {
pushBtn.disabled = false; // never stale — success OR failure
pushBtn.textContent = IDLE_LABEL;
}
});
}
wirePush();
/* ---------- boot ----------
* The admin gate FIRST (the sources-gate pattern — one cached
* whoami): anonymous visitors get the gate and NO /api/doc-drafts
* call (the endpoints are admin-only regardless — no draft data
* leaks through the page). The admin gets the form, then the draft
* load from ?draft=<token>. */
(async () => {
const admin = await fetchIsAdmin();
if (!admin) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
return;
}
if (gateEl) gateEl.hidden = true;
if (contentEl) contentEl.hidden = false;
// The URL credential (task 05's navigation: the 201 token).
const token = new URLSearchParams(window.location.search).get("draft");
if (!token) {
showError("No draft specified.");
return;
}
if (!UUID_RE.test(token)) {
// A non-uuid token is unknown — no fetch (the shared.js
// malformed-token precedent: a 422 validation line is framework
// noise, not a house message).
showError("Draft not found.");
return;
}
draftToken = token;
await loadDraft(token);
if (titleInput) titleInput.focus(); // land the caret in the first field
})();
+191
View File
@@ -734,6 +734,40 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.retry-btn svg { width: 14px; height: 14px; display: block; }
.retry-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the "Save as doc"
button — the bottom-right action of every completed brain bubble's
meta row (the JS gates: admin + a configured docs repo; this rule
only styles). The exact visual family of .tune-btn / .retry-btn
(same pill size/spacing, the global :focus-visible ring, >=44px via
min-height) so the meta actions read as one set — the brand hover
pair like Tune (a docs action), the file glyph rides currentColor.
margin-inline-start: auto pushes it to the row's RIGHT edge (the
TODO's "bottom right"; markLastRetryable keeps it rightmost when
the last bubble also carries the Retry button). Contrast:
ink-soft on --bg ~8.6:1, hover brand-ink on --brand-soft — AA,
same as the family. */
.save-as-doc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
margin-inline-start: auto;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.save-as-doc-btn svg { width: 14px; height: 14px; display: block; }
.save-as-doc-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.save-as-doc-btn:disabled { opacity: 0.6; cursor: wait; } /* draft POST in flight */
/* Phase 48: the "Stopped" note in a stopped brain bubble's meta row:
ink-soft on the surface bubble ≈6.9:1, the 10px filled-square glyph
centered with the row (the Tune button shares the row), and
@@ -2699,6 +2733,163 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.doc-modal-backdrop { transition: none; }
}
/* ---------- Doc edit screen (phase 59, task 06) ----------
/doc-edit.html: the admin-gated edit screen for a doc draft (title,
in-repo path, markdown body) — a FLOW page, not one of the app's
pages, so the header is SLIM (brand + "← Back to chat" only). The
46rem base column is HARD-CODED: a form column, not a reading
column — it does not ride --chat-column, so phase 58's wide-desktop
doubling never stretches the form. The .sources-gate gate is reused
verbatim (phases 16/35/50). Every pair reuses the Phase-08 AA
palette; touch targets >=44px; :focus-visible via the global 3px
outline rule. No CDN, system fonts. */
.doc-edit-shell {
width: 100%;
max-width: 46rem; /* the 46rem base column (hard-coded — see above) */
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* The slim header's back link — the .doc-back ghost language
(document.html): >=44px target, --line border, ink-soft (5.1:1 on
the --surface bar) rising to ink on hover; pushed to the bar's right
edge by the header-inner flex (margin-left: auto — the nav's
own pattern). */
.doc-edit-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
margin-left: auto;
padding: 0.4rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font-weight: 600;
text-decoration: none;
}
.doc-edit-back:hover { color: var(--ink); border-color: var(--ink-soft); }
.doc-edit-back svg { width: 16px; height: 16px; display: block; }
/* The edit form — the tuning form's card as a vertical stack: labeled
title input, mono path input, the mono markdown textarea (min-height
20rem — the body is the star), and the actions row (the brand Push
button + the back link). Inset fields (bg fill on the surface card). */
#doc-edit-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem 1.5rem 1.75rem;
}
#doc-edit-form label {
font-weight: 600;
font-size: 0.95rem;
}
#draft-title,
#draft-path {
width: 100%;
font: inherit;
font-size: 1rem;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.55rem 0.75rem;
min-height: 44px;
}
/* The in-repo path is machine data — mono (the git-sources URL-input
convention). */
#draft-path {
font-family: var(--mono);
font-size: 0.92rem;
}
/* The markdown body: mono, tall (min-height 20rem), vertical resize.
ink on bg = 16.7:1. */
#draft-body {
width: 100%;
font-family: var(--mono);
font-size: 0.92rem;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.75rem 0.9rem;
min-height: 20rem;
resize: vertical;
}
/* Actions row: the primary Push button (brand, dark ink on brand
5.2:1 — never white on brand) + the back link; wraps at narrow
widths. */
.doc-edit-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
#push-doc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font: inherit;
font-weight: 700;
cursor: pointer;
padding-inline: 1.25rem;
}
#push-doc-btn:hover:not(:disabled) { background: #7d88f5; }
#push-doc-btn:disabled { opacity: 0.6; cursor: wait; }
/* The success status line (role=status): the ok family (ok-ink on
ok-bg 10.6:1) when a push outcome has landed — min-height holds the
line's space so the layout never jumps when the text lands. Empty
(before the first push, or after a failure cleared the stale line)
it is the dashed placeholder (the #archive-upload-result language). */
.doc-edit-status {
margin: 0;
min-height: 1.5rem;
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.9rem;
font-weight: 600;
}
.doc-edit-status:empty {
background: transparent;
border-style: dashed;
color: var(--ink-soft);
}
/* The error banner (role=alert): the err family (err-ink on err-bg
9.3:1, err-line border) — git's stderr may carry long paths, so
long words break instead of overflowing the card. */
.doc-edit-error {
margin: 0;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
overflow-wrap: anywhere;
}
/* ---------- Footer ---------- */
.app-footer {
border-top: 1px solid var(--line);