feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch
This commit is contained in:
+148
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Edit a saved chat answer before it is committed to the docs repository (admin-only).">
|
||||
<title>Edit doc · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<!-- Phase 59 task 06: the SLIM header — this is a flow page (the
|
||||
login.html / shared.html minimal-flow-page lineage), not one of
|
||||
the app's pages: no nav, no auth pair, no hamburger. Brand +
|
||||
the "← Back to chat" link are the whole chrome. -->
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<a class="doc-edit-back" href="/">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Back to chat</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<div class="container doc-edit-shell">
|
||||
<!-- The 46rem base column (a FORM column — it hard-codes 46rem,
|
||||
it does not ride --chat-column, so phase 58's wide-desktop
|
||||
doubling never stretches the form). -->
|
||||
<div class="page-head">
|
||||
<h1>Edit doc</h1>
|
||||
<p class="page-sub">
|
||||
Review the saved answer, adjust anything, then push it to the
|
||||
docs branch — the commit lands in the configured docs repo;
|
||||
you open the PR yourself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase 59 task 06: the admin gate — the EXACT #sources-gate
|
||||
pattern (phase 16) and the same .sources-gate visual
|
||||
language (phases 35/50). The page is static; the API is the
|
||||
authority — the draft endpoints are admin-only regardless,
|
||||
so a non-admin visitor gets the gate and NO draft data
|
||||
(doc-edit.js makes no /api/doc-drafts call before whoami
|
||||
says admin). -->
|
||||
<section class="sources-gate" id="doc-edit-gate" aria-labelledby="doc-edit-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="doc-edit-gate-title">Sign in to edit docs</h2>
|
||||
<p class="sources-gate-sub">
|
||||
Saving a chat answer as documentation is admin-only. Chat —
|
||||
and any document an answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/doc-edit.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- SHIPS hidden (anonymous-safe; the gate is what anonymous
|
||||
visitors see). doc-edit.js reveals it once the cached whoami
|
||||
says admin, then loads the draft from ?draft=<token> (the
|
||||
uuid4 token task 05's button navigated with). -->
|
||||
<div id="doc-edit-content" hidden>
|
||||
<form id="doc-edit-form">
|
||||
<label for="draft-title">Title</label>
|
||||
<input
|
||||
id="draft-title"
|
||||
name="title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
|
||||
<label for="draft-path">In-repo path</label>
|
||||
<input
|
||||
id="draft-path"
|
||||
name="path"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
|
||||
<label for="draft-body">Body — markdown</label>
|
||||
<textarea id="draft-body" name="body" required></textarea>
|
||||
|
||||
<div class="doc-edit-actions">
|
||||
<button type="submit" id="push-doc-btn">Push to docs branch</button>
|
||||
<a class="doc-edit-back" href="/">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Back to chat</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- §7.4 never-stale: the polite live region carries the
|
||||
push lifecycle — "Pushing…" while the request is out,
|
||||
then `Pushed to <branch> — commit <sha7>.` on success.
|
||||
doc-edit.js owns the text (textContent only). -->
|
||||
<p class="doc-edit-status" id="push-status" role="status" aria-live="polite"></p>
|
||||
|
||||
<!-- The error banner (role=alert), hidden until a load or
|
||||
push failure: the server's detail (git's stderr,
|
||||
trimmed to its first meaningful lines) lands here and
|
||||
the fields are preserved — the fix is an edit, not a
|
||||
re-type. -->
|
||||
<div class="doc-edit-error" id="push-error" role="alert" hidden></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 39: the brand layer — classic script, first on the page:
|
||||
window.BOR_BRAND at parse time, refreshed from /api/config.
|
||||
Phase 59 task 06: the page module loads the shared header module
|
||||
through its own relative `import "./header.js"` — a hoisted
|
||||
import evaluated before this body runs (single-evaluation
|
||||
design: no direct header.js <script> tag; esbuild inlines it
|
||||
into the page bundle in the image build). On this slim flow
|
||||
page the import is the cached whoami (fetchIsAdmin) the admin
|
||||
gate runs on. -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script type="module" src="/assets/doc-edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user