feat(rag): steering notes — tune how Brain answers, stored in Postgres and injected into every system prompt
This commit is contained in:
+212
-1
@@ -28,6 +28,16 @@
|
||||
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
|
||||
* state.
|
||||
*
|
||||
* Steering notes (phase 15) let the owner tune how Brain answers: a
|
||||
* "Tune" button under every completed brain bubble (deflected included)
|
||||
* opens an inline form → POST /api/steering → the note is stored in
|
||||
* Postgres and injected into the system prompt of every subsequent turn
|
||||
* (the <tuning> section). Notes are listed newest-first in the header
|
||||
* "Tuning" panel (#steering-panel), where each can be deleted. Note text
|
||||
* is always rendered with textContent (XSS-safe), save/delete are
|
||||
* announced through a polite live region (#steering-announcer), and the
|
||||
* panel + count badge update on every change.
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -90,6 +100,203 @@ export function documentUrl(source, path, back = "/") {
|
||||
return url;
|
||||
}
|
||||
|
||||
/* ---------- steering notes (phase 15) ----------
|
||||
*
|
||||
* The owner's tuning notes steer every future answer: they live in
|
||||
* Postgres (stateless API, A10) and the chat turn reads them into the
|
||||
* system prompt. UI contract: Tune button → inline form → save →
|
||||
* confirmation (or inline error, form kept); the header panel lists the
|
||||
* notes (newest first) with per-note delete.
|
||||
*/
|
||||
const steeringToggle = document.querySelector("#steering-toggle");
|
||||
const steeringCount = document.querySelector("#steering-count");
|
||||
const steeringPanel = document.querySelector("#steering-panel");
|
||||
const steeringList = document.querySelector("#steering-list");
|
||||
const steeringEmpty = document.querySelector("#steering-empty");
|
||||
const steeringAnnouncer = document.querySelector("#steering-announcer");
|
||||
|
||||
const TUNE_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>';
|
||||
|
||||
let tuneSeq = 0; // unique ids for one open tune form's inputs
|
||||
|
||||
function announceSteering(message) {
|
||||
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
|
||||
}
|
||||
|
||||
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
|
||||
sources' .msg-meta row when it exists (role=list → the button joins as
|
||||
a listitem so ARIA stays valid); otherwise creates a plain meta row. */
|
||||
function appendTuneButton(wrap) {
|
||||
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(".tune-btn")) return; // one per bubble
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tune-btn";
|
||||
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
|
||||
btn.innerHTML = TUNE_ICON + "<span>Tune</span>";
|
||||
btn.addEventListener("click", () => openTuneForm(wrap, btn));
|
||||
meta.appendChild(btn);
|
||||
}
|
||||
|
||||
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
|
||||
+ Save / Cancel. Success replaces the form with the .tune-saved status
|
||||
(role=status); failure keeps the form and shows an inline error
|
||||
(role=alert) — the note is never lost on a failed save. */
|
||||
function openTuneForm(wrap, toggleBtn) {
|
||||
document.querySelectorAll(".tune-form").forEach((f) => f.remove()); // one at a time
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
tuneSeq += 1;
|
||||
const inputId = `tune-input-${tuneSeq}`;
|
||||
const form = document.createElement("form");
|
||||
form.className = "tune-form";
|
||||
form.noValidate = true;
|
||||
form.innerHTML =
|
||||
`<label for="${inputId}">Tuning note — how should Brain answer from now on?</label>` +
|
||||
`<textarea id="${inputId}" name="note" rows="2" maxlength="2000"
|
||||
placeholder="e.g. be more concise — or: assume I'm on NixOS"></textarea>`;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tune-form-actions";
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "submit";
|
||||
saveBtn.className = "tune-save";
|
||||
saveBtn.textContent = "Save";
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "tune-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
actions.append(saveBtn, cancelBtn);
|
||||
form.appendChild(actions);
|
||||
const status = document.createElement("p");
|
||||
status.className = "tune-error";
|
||||
status.setAttribute("role", "alert");
|
||||
status.hidden = true;
|
||||
form.appendChild(status);
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
saveBtn.disabled = true;
|
||||
status.hidden = true;
|
||||
try {
|
||||
const r = await fetch("/api/steering", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: form.querySelector("textarea").value }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
let detail = "Could not save the note — try again.";
|
||||
try {
|
||||
const data = await r.json();
|
||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||
detail = String(data.detail[0].msg);
|
||||
} else if (typeof data.detail === "string" && data.detail) {
|
||||
detail = data.detail;
|
||||
}
|
||||
} catch { /* non-JSON error body */ }
|
||||
status.textContent = detail;
|
||||
status.hidden = false;
|
||||
saveBtn.disabled = false;
|
||||
return; // form kept on failure — the instruction survives
|
||||
}
|
||||
const saved = document.createElement("p");
|
||||
saved.className = "tune-saved";
|
||||
saved.setAttribute("role", "status");
|
||||
saved.textContent = "Saved — future answers will follow this.";
|
||||
form.replaceWith(saved);
|
||||
announceSteering("Tuning note saved. Future answers will follow it.");
|
||||
await loadSteering(); // panel + count badge update
|
||||
} catch {
|
||||
status.textContent = "Could not save the note — is the app reachable?";
|
||||
status.hidden = false;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
form.remove();
|
||||
toggleBtn.focus();
|
||||
});
|
||||
body.appendChild(form);
|
||||
form.querySelector("textarea").focus();
|
||||
}
|
||||
|
||||
/* Panel: newest-first list (textContent — XSS-safe), per-note delete,
|
||||
empty text, and the header count badge. */
|
||||
async function loadSteering() {
|
||||
let notes = [];
|
||||
try {
|
||||
const r = await fetch("/api/steering");
|
||||
if (r.ok) notes = (await r.json()).notes || [];
|
||||
} catch { /* API unreachable: keep the last rendered list */ }
|
||||
renderSteeringPanel(notes);
|
||||
return notes;
|
||||
}
|
||||
|
||||
function renderSteeringPanel(notes) {
|
||||
if (!steeringList) return;
|
||||
steeringList.textContent = "";
|
||||
for (const n of notes) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "steering-note";
|
||||
const text = document.createElement("span");
|
||||
text.className = "steering-note-text";
|
||||
text.textContent = n.note; // rendered as text, never as HTML
|
||||
li.appendChild(text);
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "steering-delete";
|
||||
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||||
del.innerHTML =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||||
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
|
||||
li.appendChild(del);
|
||||
steeringList.appendChild(li);
|
||||
}
|
||||
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
||||
if (steeringCount) steeringCount.textContent = String(notes.length);
|
||||
}
|
||||
|
||||
async function deleteSteeringNote(id, btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (r.status === 404) {
|
||||
announceSteering("That note was already removed.");
|
||||
await loadSteering();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announceSteering("Could not delete the note — try again.");
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await loadSteering();
|
||||
announceSteering("Tuning note deleted.");
|
||||
} catch {
|
||||
announceSteering("Could not delete the note — is the app reachable?");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setSteeringPanel(open) {
|
||||
if (!steeringPanel || !steeringToggle) return;
|
||||
steeringPanel.hidden = !open;
|
||||
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
if (steeringToggle && steeringPanel) {
|
||||
steeringToggle.addEventListener("click", () => {
|
||||
setSteeringPanel(steeringPanel.hidden);
|
||||
if (!steeringPanel.hidden) loadSteering(); // refresh when (re)opened
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
|
||||
* Inline SVG as string constants so the message renderer and the typing
|
||||
* indicator share exactly the same marks. currentColor lets the CSS theme
|
||||
@@ -426,6 +633,7 @@ function renderStoredMessage(m) {
|
||||
appendMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
appendSources(wrap, m.sources);
|
||||
appendTuneButton(wrap); // restored brain answers are tunable too
|
||||
}
|
||||
|
||||
/* On load: re-render the stored conversation (markdown, source chips,
|
||||
@@ -545,6 +753,7 @@ async function handleSend(e) {
|
||||
appendMaybeTry(wrap, ev.suggestions);
|
||||
}
|
||||
appendSources(wrap, ev.sources);
|
||||
appendTuneButton(wrap); // every completed brain bubble is tunable
|
||||
// Persistence save point 2: the answer lands only when the turn is
|
||||
// complete (raw text + the done metadata).
|
||||
rememberBrainTurn(acc, {
|
||||
@@ -558,7 +767,8 @@ async function handleSend(e) {
|
||||
});
|
||||
if (!aborted && !wrap) {
|
||||
const fallback = "Hmm — that came back empty. Ask me again?";
|
||||
addMessage("brain", fallback);
|
||||
const fwrap = addMessage("brain", fallback);
|
||||
appendTuneButton(fwrap);
|
||||
rememberBrainTurn(fallback, {}); // persist what the user actually saw
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -592,3 +802,4 @@ composer.addEventListener("submit", handleSend);
|
||||
restoreConversation(); // phase 14: the conversation comes back as left
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
loadSteering(); // phase 15: tuning notes (panel + count badge)
|
||||
|
||||
@@ -241,6 +241,48 @@ body::after {
|
||||
whole control below 640px. */
|
||||
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
|
||||
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
|
||||
(not removed) below 640px so the accessible name keeps the word.
|
||||
≥44px touch target at every width. */
|
||||
.steering-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.steering-toggle:hover, .steering-toggle[aria-expanded="true"] {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
.steering-toggle svg { width: 16px; height: 16px; display: block; }
|
||||
.steering-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
min-width: 1.35rem;
|
||||
text-align: center;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
.steering-toggle[aria-expanded="true"] .steering-count {
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||
}
|
||||
|
||||
/* ---------- Main frame ---------- */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
@@ -381,6 +423,170 @@ body::after {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Steering notes (phase 15) ---------- */
|
||||
/* "Tune" button in the meta row of every completed brain bubble: ghost
|
||||
pill, ≥44px, right-aligned after the source chips. ink-soft on surface
|
||||
≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1. */
|
||||
.tune-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 44px;
|
||||
margin-left: 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;
|
||||
}
|
||||
.tune-btn svg { width: 14px; height: 14px; display: block; }
|
||||
.tune-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
|
||||
/* Inline tuning form under the bubble: labeled textarea + Save/Cancel
|
||||
(both ≥44px). Save = brand button (dark ink 5.2:1), Cancel = ghost. */
|
||||
.tune-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--brand-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
.tune-form label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.tune-form textarea {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
background: #0d1120;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem 0.6rem;
|
||||
resize: vertical;
|
||||
min-height: 2.6rem;
|
||||
}
|
||||
.tune-form-actions { display: flex; gap: 0.5rem; }
|
||||
.tune-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1.1rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tune-save:hover:not(:disabled) { background: #7d88f5; }
|
||||
.tune-save:disabled { opacity: 0.6; cursor: wait; }
|
||||
.tune-cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tune-cancel:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
/* Save confirmation (role=status): ok pair ≈10.6:1. */
|
||||
.tune-saved {
|
||||
margin: 0.2rem 0 0 0.25rem;
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-ink);
|
||||
border: 1px solid rgb(110 231 168 / 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Inline save failure (role=alert): err pair ≈9.1:1 — form is kept. */
|
||||
.tune-error {
|
||||
margin: 0 0 0 0.25rem;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Header panel above the messages: notes newest-first, per-note delete,
|
||||
designed empty state. */
|
||||
.steering-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--brand-soft);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.9rem 1.1rem 1rem;
|
||||
}
|
||||
.steering-panel-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.15rem 0.6rem; }
|
||||
.steering-panel-title { margin: 0; font-size: 1rem; font-weight: 700; color: var(--ink); }
|
||||
.steering-panel-sub { margin: 0; font-size: 0.82rem; color: var(--ink-soft); }
|
||||
.steering-list {
|
||||
list-style: none;
|
||||
margin: 0.65rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.steering-note {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
background: #0d1120;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.35rem 0.4rem 0.35rem 0.8rem;
|
||||
}
|
||||
.steering-note-text {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
/* Notes may be multi-line instructions — keep line breaks as typed. */
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.steering-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
.steering-delete svg { width: 16px; height: 16px; display: block; }
|
||||
.steering-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.steering-delete:disabled { opacity: 0.5; cursor: wait; }
|
||||
.steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; }
|
||||
|
||||
/* typing indicator */
|
||||
.typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; }
|
||||
.typing span {
|
||||
@@ -799,6 +1005,20 @@ body::after {
|
||||
.new-chat-btn { padding: 0.4rem 0.55rem; }
|
||||
.new-chat-label { display: none; }
|
||||
.new-chat-btn svg { display: block; }
|
||||
.steering-toggle { padding: 0.4rem 0.55rem; }
|
||||
/* Visually hidden, NOT display:none — the accessible name keeps the
|
||||
word "Tuning" next to the count badge. */
|
||||
.steering-label {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
|
||||
.tune-btn { min-height: 44px; }
|
||||
.msg-body { max-width: 92%; }
|
||||
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
|
||||
.empty-state-title { font-size: 1.25rem; }
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
|
||||
<a href="/sources.html" class="nav-link">Sources</a>
|
||||
</nav>
|
||||
<!-- Phase 15: open the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — chat page only. -->
|
||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
||||
aria-expanded="false" aria-controls="steering-panel">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
||||
<span class="steering-label">Tuning</span>
|
||||
<span class="steering-count" id="steering-count">0</span>
|
||||
</button>
|
||||
<!-- Phase 14: reset the local (localStorage) conversation — chat page only. -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
@@ -36,6 +44,18 @@
|
||||
<span id="kb-banner-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Phase 15: tuning-notes panel (stored notes, newest first). -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
|
||||
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-state-glyph" aria-hidden="true">
|
||||
|
||||
Reference in New Issue
Block a user