feat: /admin/triage web UI + auto-drain loop
Makes human triage sustainable. Before: command-line-only review,
auto-triage stopped after 100 candidates/run. Now:
1. Web UI at /admin/triage
- Lists all pending candidates with inline promote/reject/edit
- Edit content in-place before promoting (PUT /memory/{id})
- Change type via dropdown
- Keyboard shortcuts: Y=promote, N=reject, E=edit, S=scroll-next
- Cards fade out after action, queue count updates live
- Zero JS framework — vanilla fetch + event delegation
2. auto_triage.py drains queue
- Loops up to 20 batches (default) of 100 candidates each
- Tracks seen IDs so needs_human items don't reprocess
- Exits cleanly when queue empty
- Nightly cron naturally drains everything
3. Dashboard + wiki surface triage queue
- Dashboard /admin/dashboard: new "triage" section with pending
count + /admin/triage URL + warning/notice severity levels
- Wiki homepage: prominent callout "N candidates awaiting triage —
review now" linking to /admin/triage, styled with triage-warning
(>50) or triage-notice (>20) CSS
Pattern: human intervenes only when AI can't decide. The UI makes
that intervention fast (20 candidates in 60 seconds). Nightly
auto-triage drains the queue so the human queue stays bounded.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -209,75 +209,81 @@ def main():
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--dry-run", action="store_true", help="preview without executing")
|
||||
parser.add_argument("--max-batches", type=int, default=20,
|
||||
help="Max batches of 100 to process per run (default 20 = 2000 candidates)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Fetch candidates
|
||||
result = api_get(args.base_url, "/memory?status=candidate&limit=100")
|
||||
candidates = result.get("memories", [])
|
||||
print(f"candidates: {len(candidates)} model: {args.model} dry_run: {args.dry_run}")
|
||||
|
||||
if not candidates:
|
||||
print("queue empty, nothing to triage")
|
||||
return
|
||||
|
||||
# Cache active memories per project for dedup
|
||||
active_cache = {}
|
||||
# Track IDs we've already seen so needs_human items don't re-process
|
||||
# every batch (they stay in the candidate table until a human reviews).
|
||||
seen_ids: set[str] = set()
|
||||
active_cache: dict[str, list] = {}
|
||||
promoted = rejected = needs_human = errors = 0
|
||||
batch_num = 0
|
||||
|
||||
for i, cand in enumerate(candidates, 1):
|
||||
# Light rate-limit pacing: 0.5s between triage calls so a burst
|
||||
# doesn't overwhelm the claude CLI's backend. With ~60s per call
|
||||
# this is negligible overhead but avoids the "all-failed" pattern
|
||||
# we saw on large batches.
|
||||
if i > 1:
|
||||
time.sleep(0.5)
|
||||
while batch_num < args.max_batches:
|
||||
batch_num += 1
|
||||
|
||||
project = cand.get("project") or ""
|
||||
if project not in active_cache:
|
||||
active_cache[project] = fetch_active_memories_for_project(args.base_url, project)
|
||||
result = api_get(args.base_url, "/memory?status=candidate&limit=100")
|
||||
all_candidates = result.get("memories", [])
|
||||
# Filter out already-seen (needs_human from prior batch in same run)
|
||||
candidates = [c for c in all_candidates if c["id"] not in seen_ids]
|
||||
|
||||
verdict_obj = triage_one(cand, active_cache[project], args.model, DEFAULT_TIMEOUT_S)
|
||||
verdict = verdict_obj["verdict"]
|
||||
conf = verdict_obj["confidence"]
|
||||
reason = verdict_obj["reason"]
|
||||
conflicts_with = verdict_obj.get("conflicts_with", "")
|
||||
|
||||
mid = cand["id"]
|
||||
label = f"[{i:2d}/{len(candidates)}] {mid[:8]} [{cand['memory_type']}]"
|
||||
|
||||
if verdict == "promote" and conf >= AUTO_PROMOTE_MIN_CONFIDENCE:
|
||||
if args.dry_run:
|
||||
print(f" WOULD PROMOTE {label} conf={conf:.2f} {reason}")
|
||||
if not candidates:
|
||||
if batch_num == 1:
|
||||
print("queue empty, nothing to triage")
|
||||
else:
|
||||
try:
|
||||
api_post(args.base_url, f"/memory/{mid}/promote")
|
||||
print(f" PROMOTED {label} conf={conf:.2f} {reason}")
|
||||
active_cache[project].append(cand)
|
||||
except Exception:
|
||||
errors += 1
|
||||
promoted += 1
|
||||
elif verdict == "reject":
|
||||
if args.dry_run:
|
||||
print(f" WOULD REJECT {label} conf={conf:.2f} {reason}")
|
||||
else:
|
||||
try:
|
||||
api_post(args.base_url, f"/memory/{mid}/reject")
|
||||
print(f" REJECTED {label} conf={conf:.2f} {reason}")
|
||||
except Exception:
|
||||
errors += 1
|
||||
rejected += 1
|
||||
elif verdict == "contradicts":
|
||||
# Leave candidate in queue but flag the conflict in content
|
||||
# so the wiki/triage shows it. This is conservative: we
|
||||
# don't silently merge or reject when sources disagree.
|
||||
print(f" CONTRADICTS {label} vs {conflicts_with[:8] if conflicts_with else '?'} {reason}")
|
||||
contradicts_count = locals().get('contradicts_count', 0) + 1
|
||||
needs_human += 1
|
||||
else:
|
||||
print(f" NEEDS_HUMAN {label} conf={conf:.2f} {reason}")
|
||||
needs_human += 1
|
||||
print(f"\nQueue drained after batch {batch_num-1}.")
|
||||
break
|
||||
|
||||
print(f"\npromoted={promoted} rejected={rejected} needs_human={needs_human} errors={errors}")
|
||||
print(f"\n=== batch {batch_num}: {len(candidates)} candidates model: {args.model} dry_run: {args.dry_run} ===")
|
||||
|
||||
for i, cand in enumerate(candidates, 1):
|
||||
if i > 1:
|
||||
time.sleep(0.5)
|
||||
|
||||
seen_ids.add(cand["id"])
|
||||
project = cand.get("project") or ""
|
||||
if project not in active_cache:
|
||||
active_cache[project] = fetch_active_memories_for_project(args.base_url, project)
|
||||
|
||||
verdict_obj = triage_one(cand, active_cache[project], args.model, DEFAULT_TIMEOUT_S)
|
||||
verdict = verdict_obj["verdict"]
|
||||
conf = verdict_obj["confidence"]
|
||||
reason = verdict_obj["reason"]
|
||||
conflicts_with = verdict_obj.get("conflicts_with", "")
|
||||
|
||||
mid = cand["id"]
|
||||
label = f"[{i:2d}/{len(candidates)}] {mid[:8]} [{cand['memory_type']}]"
|
||||
|
||||
if verdict == "promote" and conf >= AUTO_PROMOTE_MIN_CONFIDENCE:
|
||||
if args.dry_run:
|
||||
print(f" WOULD PROMOTE {label} conf={conf:.2f} {reason}")
|
||||
else:
|
||||
try:
|
||||
api_post(args.base_url, f"/memory/{mid}/promote")
|
||||
print(f" PROMOTED {label} conf={conf:.2f} {reason}")
|
||||
active_cache[project].append(cand)
|
||||
except Exception:
|
||||
errors += 1
|
||||
promoted += 1
|
||||
elif verdict == "reject":
|
||||
if args.dry_run:
|
||||
print(f" WOULD REJECT {label} conf={conf:.2f} {reason}")
|
||||
else:
|
||||
try:
|
||||
api_post(args.base_url, f"/memory/{mid}/reject")
|
||||
print(f" REJECTED {label} conf={conf:.2f} {reason}")
|
||||
except Exception:
|
||||
errors += 1
|
||||
rejected += 1
|
||||
elif verdict == "contradicts":
|
||||
print(f" CONTRADICTS {label} vs {conflicts_with[:8] if conflicts_with else '?'} {reason}")
|
||||
needs_human += 1
|
||||
else:
|
||||
print(f" NEEDS_HUMAN {label} conf={conf:.2f} {reason}")
|
||||
needs_human += 1
|
||||
|
||||
print(f"\ntotal: promoted={promoted} rejected={rejected} needs_human={needs_human} errors={errors} batches={batch_num}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -119,6 +119,17 @@ def wiki_search(q: str = "") -> HTMLResponse:
|
||||
return HTMLResponse(content=render_search(q))
|
||||
|
||||
|
||||
@router.get("/admin/triage", response_class=HTMLResponse)
|
||||
def admin_triage(limit: int = 100) -> HTMLResponse:
|
||||
"""Human triage UI for candidate memories.
|
||||
|
||||
Lists pending candidates with inline promote/reject/edit buttons.
|
||||
Keyboard shortcuts: Y=promote, N=reject, E=edit content.
|
||||
"""
|
||||
from atocore.engineering.triage_ui import render_triage_page
|
||||
return HTMLResponse(content=render_triage_page(limit=limit))
|
||||
|
||||
|
||||
# --- Request/Response models ---
|
||||
|
||||
|
||||
@@ -1022,6 +1033,16 @@ def api_dashboard() -> dict:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Triage queue health
|
||||
triage: dict = {
|
||||
"pending": len(candidates),
|
||||
"review_url": "/admin/triage",
|
||||
}
|
||||
if len(candidates) > 50:
|
||||
triage["warning"] = f"High queue: {len(candidates)} candidates pending review."
|
||||
elif len(candidates) > 20:
|
||||
triage["notice"] = f"{len(candidates)} candidates awaiting triage."
|
||||
|
||||
return {
|
||||
"memories": {
|
||||
"active": len(active),
|
||||
@@ -1037,6 +1058,7 @@ def api_dashboard() -> dict:
|
||||
"interactions": interaction_stats,
|
||||
"extraction_pipeline": extract_state,
|
||||
"pipeline": pipeline,
|
||||
"triage": triage,
|
||||
}
|
||||
|
||||
|
||||
|
||||
231
src/atocore/engineering/triage_ui.py
Normal file
231
src/atocore/engineering/triage_ui.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Human triage UI for AtoCore candidate memories.
|
||||
|
||||
Renders a lightweight HTML page at /admin/triage with all pending
|
||||
candidate memories, each with inline Promote / Reject / Edit buttons.
|
||||
No framework, no JS build, no database — reads candidates from the
|
||||
AtoCore DB and posts back to the existing REST endpoints.
|
||||
|
||||
Design principle: the user should be able to triage 20 candidates in
|
||||
60 seconds from any browser. Keyboard shortcuts (y/n/e/s) make it
|
||||
feel like email triage (archive/delete).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
|
||||
from atocore.engineering.wiki import render_html
|
||||
from atocore.memory.service import get_memories
|
||||
|
||||
|
||||
VALID_TYPES = ["identity", "preference", "project", "episodic", "knowledge", "adaptation"]
|
||||
|
||||
|
||||
def _escape(s: str | None) -> str:
|
||||
return _html.escape(s or "", quote=True)
|
||||
|
||||
|
||||
def _render_candidate_card(cand) -> str:
|
||||
"""One candidate row with inline forms for promote/reject/edit."""
|
||||
mid = _escape(cand.id)
|
||||
content = _escape(cand.content)
|
||||
memory_type = _escape(cand.memory_type)
|
||||
project = _escape(cand.project or "")
|
||||
project_display = project or "(global)"
|
||||
confidence = f"{cand.confidence:.2f}"
|
||||
refs = cand.reference_count or 0
|
||||
created = _escape(str(cand.created_at or ""))
|
||||
|
||||
type_options = "".join(
|
||||
f'<option value="{t}"{" selected" if t == cand.memory_type else ""}>{t}</option>'
|
||||
for t in VALID_TYPES
|
||||
)
|
||||
|
||||
return f"""
|
||||
<div class="cand" id="cand-{mid}" data-id="{mid}">
|
||||
<div class="cand-head">
|
||||
<span class="cand-type">[{memory_type}]</span>
|
||||
<span class="cand-project">{project_display}</span>
|
||||
<span class="cand-meta">conf {confidence} · refs {refs} · {created[:16]}</span>
|
||||
</div>
|
||||
<div class="cand-body">
|
||||
<textarea class="cand-content" id="content-{mid}">{content}</textarea>
|
||||
</div>
|
||||
<div class="cand-actions">
|
||||
<button class="btn-promote" data-id="{mid}" title="Promote (Y)">✅ Promote</button>
|
||||
<button class="btn-reject" data-id="{mid}" title="Reject (N)">❌ Reject</button>
|
||||
<button class="btn-save-promote" data-id="{mid}" title="Edit + Promote (E)">✏️ Save&Promote</button>
|
||||
<label class="cand-type-label">Type:
|
||||
<select class="cand-type-select" id="type-{mid}">{type_options}</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="cand-status" id="status-{mid}"></div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
_TRIAGE_SCRIPT = """
|
||||
<script>
|
||||
async function apiCall(url, method, body) {
|
||||
try {
|
||||
const opts = { method };
|
||||
if (body) {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
return { ok: res.ok, status: res.status };
|
||||
} catch (e) { return { ok: false, status: 0, error: String(e) }; }
|
||||
}
|
||||
|
||||
function setStatus(id, msg, ok) {
|
||||
const el = document.getElementById('status-' + id);
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = 'cand-status ' + (ok ? 'ok' : 'err');
|
||||
}
|
||||
|
||||
function removeCard(id) {
|
||||
setTimeout(() => {
|
||||
const card = document.getElementById('cand-' + id);
|
||||
if (card) {
|
||||
card.style.opacity = '0';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
}
|
||||
updateCount();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const n = document.querySelectorAll('.cand').length;
|
||||
const el = document.getElementById('cand-count');
|
||||
if (el) el.textContent = n;
|
||||
const next = document.querySelector('.cand');
|
||||
if (next) next.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
async function promote(id) {
|
||||
setStatus(id, 'Promoting…', true);
|
||||
const r = await apiCall('/memory/' + encodeURIComponent(id) + '/promote', 'POST');
|
||||
if (r.ok) { setStatus(id, '✅ Promoted', true); removeCard(id); }
|
||||
else setStatus(id, '❌ Failed: ' + r.status, false);
|
||||
}
|
||||
|
||||
async function reject(id) {
|
||||
setStatus(id, 'Rejecting…', true);
|
||||
const r = await apiCall('/memory/' + encodeURIComponent(id) + '/reject', 'POST');
|
||||
if (r.ok) { setStatus(id, '❌ Rejected', true); removeCard(id); }
|
||||
else setStatus(id, '❌ Failed: ' + r.status, false);
|
||||
}
|
||||
|
||||
async function savePromote(id) {
|
||||
const content = document.getElementById('content-' + id).value.trim();
|
||||
const mtype = document.getElementById('type-' + id).value;
|
||||
if (!content) { setStatus(id, 'Content is empty', false); return; }
|
||||
setStatus(id, 'Saving…', true);
|
||||
const r1 = await apiCall('/memory/' + encodeURIComponent(id), 'PUT', {
|
||||
content: content, memory_type: mtype
|
||||
});
|
||||
if (!r1.ok) { setStatus(id, '❌ Save failed: ' + r1.status, false); return; }
|
||||
const r2 = await apiCall('/memory/' + encodeURIComponent(id) + '/promote', 'POST');
|
||||
if (r2.ok) { setStatus(id, '✅ Saved & Promoted', true); removeCard(id); }
|
||||
else setStatus(id, '❌ Promote failed: ' + r2.status, false);
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const id = e.target.dataset?.id;
|
||||
if (!id) return;
|
||||
if (e.target.classList.contains('btn-promote')) promote(id);
|
||||
else if (e.target.classList.contains('btn-reject')) reject(id);
|
||||
else if (e.target.classList.contains('btn-save-promote')) savePromote(id);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts on the currently-focused card
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Don't intercept if user is typing in textarea/select/input
|
||||
const t = e.target.tagName;
|
||||
if (t === 'TEXTAREA' || t === 'INPUT' || t === 'SELECT') return;
|
||||
const first = document.querySelector('.cand');
|
||||
if (!first) return;
|
||||
const id = first.dataset.id;
|
||||
if (e.key === 'y' || e.key === 'Y') { e.preventDefault(); promote(id); }
|
||||
else if (e.key === 'n' || e.key === 'N') { e.preventDefault(); reject(id); }
|
||||
else if (e.key === 'e' || e.key === 'E') {
|
||||
e.preventDefault();
|
||||
document.getElementById('content-' + id)?.focus();
|
||||
}
|
||||
else if (e.key === 's' || e.key === 'S') { e.preventDefault(); first.scrollIntoView({behavior:'smooth'}); }
|
||||
});
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
_TRIAGE_CSS = """
|
||||
<style>
|
||||
.triage-header { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:1rem; }
|
||||
.triage-header .count { font-size:1.4rem; font-weight:600; color:var(--accent); }
|
||||
.triage-help { background:var(--card); border-left:4px solid var(--accent); padding:0.8rem 1rem; margin-bottom:1.5rem; border-radius:4px; font-size:0.9rem; }
|
||||
.triage-help kbd { background:var(--hover); padding:2px 6px; border-radius:3px; font-family:monospace; font-size:0.85em; border:1px solid var(--border); }
|
||||
.cand { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:1rem; margin-bottom:1rem; transition:opacity 0.3s; }
|
||||
.cand-head { display:flex; gap:0.8rem; align-items:center; margin-bottom:0.6rem; font-size:0.9rem; }
|
||||
.cand-type { font-weight:600; color:var(--accent); font-family:monospace; }
|
||||
.cand-project { color:var(--text); opacity:0.8; font-family:monospace; }
|
||||
.cand-meta { color:var(--text); opacity:0.55; font-size:0.8rem; margin-left:auto; }
|
||||
.cand-content { width:100%; min-height:80px; font-family:inherit; font-size:0.95rem; padding:0.5rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; resize:vertical; box-sizing:border-box; }
|
||||
.cand-content:focus { outline:none; border-color:var(--accent); }
|
||||
.cand-actions { display:flex; gap:0.5rem; margin-top:0.8rem; align-items:center; flex-wrap:wrap; }
|
||||
.cand-actions button { padding:0.4rem 0.9rem; border:1px solid var(--border); background:var(--card); color:var(--text); border-radius:4px; cursor:pointer; font-size:0.88rem; }
|
||||
.cand-actions button:hover { background:var(--hover); }
|
||||
.btn-promote:hover { background:#059669; color:white; border-color:#059669; }
|
||||
.btn-reject:hover { background:#dc2626; color:white; border-color:#dc2626; }
|
||||
.btn-save-promote:hover { background:var(--accent); color:white; border-color:var(--accent); }
|
||||
.cand-type-label { font-size:0.85rem; margin-left:auto; opacity:0.7; }
|
||||
.cand-type-select { padding:0.25rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:monospace; }
|
||||
.cand-status { margin-top:0.5rem; font-size:0.85rem; min-height:1.2em; }
|
||||
.cand-status.ok { color:#059669; }
|
||||
.cand-status.err { color:#dc2626; }
|
||||
.empty { text-align:center; padding:3rem; opacity:0.6; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def render_triage_page(limit: int = 100) -> str:
|
||||
"""Render the full triage page with all pending candidates."""
|
||||
try:
|
||||
candidates = get_memories(status="candidate", limit=limit)
|
||||
except Exception as e:
|
||||
body = f"<p>Error loading candidates: {_escape(str(e))}</p>"
|
||||
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
|
||||
|
||||
if not candidates:
|
||||
body = _TRIAGE_CSS + """
|
||||
<div class="triage-header">
|
||||
<h1>Triage Queue</h1>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<p>🎉 No candidates to review.</p>
|
||||
<p>The auto-triage pipeline keeps this queue empty unless something needs your judgment.</p>
|
||||
</div>
|
||||
"""
|
||||
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
|
||||
|
||||
cards_html = "".join(_render_candidate_card(c) for c in candidates)
|
||||
|
||||
body = _TRIAGE_CSS + f"""
|
||||
<div class="triage-header">
|
||||
<h1>Triage Queue</h1>
|
||||
<span class="count"><span id="cand-count">{len(candidates)}</span> pending</span>
|
||||
</div>
|
||||
<div class="triage-help">
|
||||
Review candidate memories the auto-triage wasn't sure about. Edit the content
|
||||
if needed, then promote or reject. Shortcuts: <kbd>Y</kbd> promote · <kbd>N</kbd>
|
||||
reject · <kbd>E</kbd> edit · <kbd>S</kbd> scroll to next.
|
||||
</div>
|
||||
{cards_html}
|
||||
""" + _TRIAGE_SCRIPT
|
||||
|
||||
return render_html(
|
||||
"Triage — AtoCore",
|
||||
body,
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")],
|
||||
)
|
||||
@@ -119,9 +119,19 @@ def render_homepage() -> str:
|
||||
# Quick stats
|
||||
all_entities = get_entities(limit=500)
|
||||
all_memories = get_memories(active_only=True, limit=500)
|
||||
pending = get_memories(status="candidate", limit=500)
|
||||
lines.append('<h2>System</h2>')
|
||||
lines.append(f'<p>{len(all_entities)} entities · {len(all_memories)} active memories · {len(projects)} projects</p>')
|
||||
lines.append(f'<p><a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
|
||||
|
||||
# Triage queue prompt — surfaced prominently if non-empty
|
||||
if pending:
|
||||
tone = "triage-warning" if len(pending) > 50 else "triage-notice"
|
||||
lines.append(
|
||||
f'<p class="{tone}">🗂️ <strong>{len(pending)} candidates</strong> awaiting triage — '
|
||||
f'<a href="/admin/triage">review now →</a></p>'
|
||||
)
|
||||
|
||||
lines.append(f'<p><a href="/admin/triage">Triage Queue</a> · <a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
|
||||
|
||||
return render_html("AtoCore Wiki", "\n".join(lines))
|
||||
|
||||
@@ -289,6 +299,9 @@ _TEMPLATE = """<!DOCTYPE html>
|
||||
.card .stats { font-size: 0.8em; margin-top: 0.5rem; opacity: 0.5; }
|
||||
.card .client { font-size: 0.85em; opacity: 0.65; margin-bottom: 0.3rem; font-style: italic; }
|
||||
.card h3 .tag { font-size: 0.65em; vertical-align: middle; margin-left: 0.4rem; }
|
||||
.triage-notice { background: var(--card); border-left: 4px solid var(--accent); padding: 0.6rem 1rem; border-radius: 4px; margin: 0.8rem 0; }
|
||||
.triage-warning { background: #fef3c7; color: #78350f; border-left: 4px solid #d97706; padding: 0.6rem 1rem; border-radius: 4px; margin: 0.8rem 0; }
|
||||
@media (prefers-color-scheme: dark) { .triage-warning { background: #451a03; color: #fde68a; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user