Files
6krrt/plans/admin-design-standards.md
adlee-was-taken c4cfe9e67f feat(quota): runway panel over per-shape cards, and no middots anywhere in admin
The quota page, rebuilt to the design we settled on the canvas, plus the
suite-wide typographic rule that came out of reviewing it.

RUNWAY PANEL, full width, on top. Both accounts on ONE shared time axis,
because time is the only axis a renewing kWh subscription and a draining
prepaid pool genuinely share: they count down the same way and end
differently. neuralwatt's track runs solid to the day the plan is spent, then
HATCHED to the renewal marker -- and that hatching is the overage, 18 days of
it, shown instead of asserted.

That panel replaces the red alarm strip. "2.8x plan pace" stated a multiplier
and named neither the provider nor the plan it was 2.8x of; the track shows
what the multiplier costs. The alarm object still feeds the navbar bell and
the chip on other pages, so nothing lost a warning.

PER-SHAPE CARDS below it, one per account, each answering its own question:

  subscription    ring gauge (a cycle) + a pace track whose tick marks
                  elapsed against burned, "renews 2026-10-06 / in 24 days"
                  in two slots of one bordered row, and OVERAGE CREDITS as a
                  dollar figure -- which needed a backend fix, because the
                  credit block was gated on balance_url and NeuralWatt has
                  none: its allowance was being fetched for the burn
                  calculation and then dropped, so a subscription card could
                  never show what its overage draws on.

  prepaid credit  a flat one-way drain bar, balance of pool, runway in days,
                  "renews never / to refill top up".

PER-MODEL SPEND in an accordion inside the account that spent it, never a
shared ledger -- a blended provider-vs-provider table is what made the first
version unreadable. Ranked by spend where the provider reports it, by CALL
COUNT where it does not: ranking openrouter by cost put xiaomi/mimo-v2.5 on
top at $0.00002 (two priced calls out of 666) above a model with 905 calls
and no price at all. models_ranked_by tells the card which sentence to print,
and a dash says the call predates per-request cost capture, so it sits in the
account total but not the list. Open state survives the 30s poll, which would
otherwise shut an accordion while you were reading it.

NO MIDDOTS, suite-wide, and the rule is now in
plans/admin-design-standards.md so future work inherits it. A dot-joined run
compresses unrelated facts into one undifferentiated string --
"655 calls - 0.00005 kWh - $0.0000" -- and the eye cannot tell which number
answers which question. 27 sites across 8 pages, in three kinds:

  8 data pairings   -> layout (.stat-set spacing, labelled slots, ':' where
                       the relationship is genuinely hierarchical)
  8 footers         -> layout (.footer-bits, a flex row)
  8 titles          -> em dash, the one honest case: a browser tab has no
                       layout to lay anything out with
  3 in the new card -> written that way from the start

Caught in the browser: a bare `.legend` class collides with Tabler/Chart.js on
the quota page and rendered the bar legends one character per line. Namespaced
to .meter-legend.

Verified against the live database and in the browser at 1517px. Full suite
1901 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-11 20:24:13 -04:00

426 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Admin Portal Design Standards
Status: reference -- conventions the admin pages follow
This document captures the design system used by the Claude-built admin portal uplift (commits around `23b74a3`–`906c9e6`) so future edits to `admin/frontend/` stay visually consistent.
Intended readers: any agent or human touching `admin/frontend/*.html`, `admin.py`, or the admin API surface.
**Supplementary sources** (read these too before touching the admin UI):
- `.omo/notepads/admin-facelift/learnings.md` — the running log of every visual QA finding, root cause, and fix across the whole 10-wave facelift. This is the single richest record of what went wrong and how it was fixed.
- `plans/admin-facelift.md` — the original spec (survival guardrails S1–S5, device contracts).
- `plans/admin-visual-fixes-review.md` and `plans/router-admin-portal-implementation-review.md` — post-hoc audits that caught regressions the QA pass missed.
## Philosophy
- **Liquid-glass dashboard, not a marketing page.** Background is a subtle gradient wash; cards are translucent, blurred, and softly lit from the top. The goal is information density with low visual fatigue.
- **One visual language everywhere.** Use the same frosted-card treatment, pill buttons, and glass badges on every page so the portal feels like a single app.
- **Subtle cues over neon alerts.** Status colors are desaturated translucent tints. Warnings live in the navbar bell, not in a full card.
## Theme
- Always dark mode. Root is `<html lang="en" data-bs-theme="dark">`.
- **Base background** (body):
- Three overlapping `radial-gradient` blobs:
- amber (`rgba(245,158,11,.10)`) top-left
- blue (`rgba(59,130,246,.10)`) top-right
- purple (`rgba(139,92,246,.07)`) bottom
- Base color `#111827`.
- `background-attachment: fixed` so it stays put during scroll.
## Layout shell (every page)
1. Bootstrap 5 / Tabler page skeleton:
```html
<header class="navbar navbar-expand-md navbar-dark" data-bs-theme="dark">…</header>
<div class="page">
<div class="page-wrapper">
<div class="page-header d-print-none">…</div>
<div class="page-body">…</div>
<footer class="footer footer-transparent d-print-none">…</footer>
</div>
</div>
```
2. Scrolling context: **`<body>` scrolls, `<html>` does not.** This works around a Chrome/Linux phantom-margin bug caused by the HTML element also being the scrollbar container.
```css
html { overflow:hidden; height:100%; margin:0!important; padding:0!important }
body { overflow-y:auto; height:100%; margin:0!important; padding:0!important }
```
3. Container width: `container-xl` inside `.page-body`.
4. Cards grid: `.row.row-cards > .col-xl-6`, `.col-xl-4` or `.col-12`.
Use `.col-xl-4` when **three** peer cards belong on one row. At `.col-xl-6`
a third card wraps and produces two dead columns at once — the short card
stranded beside a tall one, plus the empty half-row under it. That is the
same defect as the "Card-level layout" note below, so the fix is a narrower
column, not an `align-items` value. Do not mix widths within one row:
`6 + 4 + 4` leaves a gap and reads as a mistake.
## Navbar
- Glass background: `rgba(31,41,55,.6)` with `backdrop-filter: blur(16px) saturate(160%)`.
- Bottom border: `1px solid rgba(255,255,255,.06)`.
- Explicit `position: relative; z-index: 1030;` on `header.navbar` so its dropdowns paint above later backdrop-filter stacking contexts (e.g. the quota chip).
- Container padding pinned to exactly `20px` left/right so the brand edge stays aligned across breakpoints:
```css
header.navbar > .container-fluid { padding-left:20px!important; padding-right:20px!important; }
```
- Brand wordmark: **6krrt LLM Router**, in `'Quicksand', var(--tblr-font-sans-serif)` at `font-weight: 700; letter-spacing: 0.01em`. The leading `6` is `.brand-six` — brand amber, `1.14em` — because the mascot is itself a 6. Keep the wordmark inside a single `.brand-word` span: the anchor is `inline-flex` with a `gap`, so a bare span around the `6` becomes its own flex item and that gap opens up between `6` and `krrt`.
- Logo SVG inline; force `filter:none!important` and size `64x64px` to defeat Tabler's autodark invert filter. The size lives in three places that must agree — the CSS rule, and the inline `width`/`height` attributes **and** `style="width:64px;height:64px"` on the `<svg>` (that inline style outranks the stylesheet, so changing only the CSS silently does nothing).
- **64px brand, 69px bar.** The mascot's face is ~40% of his height, so at 45px his eyes were ~4px. The extra 19px comes out of padding rather than out of the bar: `.navbar-brand` gives up its 8px top/bottom, and `header.navbar` drops 4px to 2px. Net bar height 69px, 1px *shorter* than the 45px version. Re-measure `header.navbar.offsetHeight` before touching either number.
### Logo
The navbar mascot is a **raster**.webp` asset, not inline SVG. In each admin
page the logo is an `<img>` element with the same class and sizing contract that
the inline SVG previously had:
```html
<img class="navbar-brand-image"
src="assets/6krrt-logo.webp"
width="64" height="64"
style="width:64px;height:64px"
alt="6krrt logo"
aria-hidden="true">
```
- The `.webp` file is generated from the new `assets/6krrt-logo.svg` Inkscape
source (the `assets/6krrt-logo-outlined.svg` variant was dropped).
- The CSS rule `.navbar-brand-autodark .navbar-brand-image{filter:none!important;
height:64px;width:64px}` still applies and prevents Tabler's autodark
`brightness(0) invert(1)` from turning it into a white silhouette.
- The three **speedlines** stay as a separate inline SVG (`.brand-speedlines`)
so they can animate and drop out on narrow screens without affecting the logo.
- The favicon (`<link rel="icon" href="data:image/svg+xml:…">`) still points at
the old inline SVG data-URI. Updating it requires re-encoding the `.webp`
artwork (or going back to inline SVG) in all four admin pages.
The `.webp` approach was chosen to avoid Chrome rendering issues with inline SVG
in the navbar context (anti-aliasing / sub-pixel edge artifacts).
### Speed lines
Three tapered amber lines trail the mascot inside the brand link
(`.brand-speedlines`), because he is a wheeled rover and should look like he is
going somewhere.
- Their own inline SVG, **not** part of the logo asset — `assets/*.svg` stays the
plain mascot, and the lines can drop out on narrow screens (`d-none d-md-flex`).
- The gradient fades *away* from him — transparent at the far left, amber at his
back. That direction is what reads as speed; reverse it and you get three
floating dashes.
- `gradientUnits="userSpaceOnUse"`. The default `objectBoundingBox` is undefined
on a horizontal line, whose bbox has zero height.
- `margin-right: -20px` on the wrapper. The logo's viewBox carries ~18px of empty
space left of his tail at 64px, so the pull spends most of itself cancelling
that before it buys any actual closeness. Target gap ≈ 4.5px.
- Motion is **hover-only**, and off under `prefers-reduced-motion`. A dashboard
that twitches at rest is a dashboard you stop looking at.
- Right cluster: warnings bell (dashboard + decisions only), SSE status badge, optional generated-at timestamp.
## Cards
```css
.card {
background: rgba(31,41,55,.55);
backdrop-filter: blur(18px) saturate(140%);
border: 1px solid rgba(255,255,255,.07);
box-shadow: 0 10px 30px -14px rgba(0,0,0,.55),
inset 0 1px 0 rgba(255,255,255,.04);
}
.card-header { border-bottom-color: rgba(255,255,255,.06); }
```
- Titles use `.card-title` with a leading inline SVG icon injected via `[data-icon]` + the inline `icon()` helper.
- Header actions align with flex utilities (`d-flex align-items-center justify-content-between`).
## Buttons
Override the solid Bootstrap semantic buttons to glass pills:
```css
.btn-success {
background: rgba(34,197,94,.16)!important;
border-color: rgba(74,222,128,.45)!important;
color: #4ade80!important;
}
.btn-success:hover {
background: rgba(34,197,94,.28)!important;
border-color: rgba(74,222,128,.7)!important;
color: #86efac!important;
}
.btn-danger { /* analogous red */ }
.btn { backdrop-filter: blur(6px); }
```
- Outlined secondary buttons are used for neutral actions (clear, view-all).
- Keep it small: `btn-sm` in headers and tables.
## Badges
All `.badge.bg-*` are glass tints:
| Class | Background | Text |
|---|---|---|
| `.bg-success` | `rgba(34,197,94,.20)` | `#4ade80` |
| `.bg-warning` | `rgba(245,158,11,.20)` | `#fbbf24` |
| `.bg-danger` | `rgba(239,68,68,.20)` | `#f87171` |
| `.bg-info` | `rgba(59,130,246,.20)` | `#93c5fd` |
| `.bg-secondary` | `rgba(148,163,184,.20)` | `#e2e8f0` |
| `.bg-purple` | `rgba(168,85,247,.20)` | `#d8b4fe` |
## Progress bars
Used for per-model usage, verdict mix, and category breakdown.
```css
.progress {
background: rgba(255,255,255,.08)!important;
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
box-shadow: 0 0 6px 0 currentColor;
filter: saturate(1.25);
}
```
## Color palette
- Brand amber: `#f59e0b` / `#fbbf24`
- Success green: `#4ade80` / `#2ecc71`
- Danger red: `#f87171` / `#e74c3c`
- Warning yellow: `#fbbf24` / `#f1c40f`
- Info blue: `#93c5fd` / `#3498db`
- Purple accent: `#d8b4fe` / `#9b59b6`
- Cyan accent: used for `local-vision` kind badges (`--tblr-cyan`)
- Muted text: `var(--tblr-secondary)` (#94a3b8 region)
- Surface: `rgba(31,41,55,…)` (`#1f2937`)
- Background: `#111827`
Functional color mapping:
- Tier 1 → success green
- Tier 2 → warning amber
- Tier 3 → danger red
- Tier unknown → secondary
## Typography
- Body / UI text: Tabler default (`--tblr-font-sans-serif`).
- Brand + page titles: `'Quicksand', var(--tblr-font-sans-serif)`, weight 700, `letter-spacing: 0.01em`.
- Numeric data: `font-variant-numeric: tabular-nums` so numbers don't jitter when updating.
- Small meta text: `.text-muted small`, `font-size` around `0.75–0.85rem`.
### No middot separators. Relate facts with layout.
**Never use `·` / `&middot;`.** Not in copy, not in a `<title>`, not in the
footer. And the em dash is not the fallback: use one only where there is
genuinely no layout and no rephrase available.
When two facts need relating, in order of preference:
1. **Layout.** A micro-label above its value; a label-left / value-right grid
row; two slots inside one bordered block; a caption under the thing it
describes. In HTML this is nearly always available, and it is the answer.
2. **`:` or a rephrase.** "renews in 24 days", not "renews `·` 24d".
3. **Em dash**, last resort. The one honest case is a `<title>`, because a
browser tab has no layout to lay anything out with.
The reason is not taste. A dot-joined run compresses unrelated facts into one
undifferentiated string:
```
655 calls · 0.00005 kWh · $0.0000 <- which number answers which question?
renews 2026-10-06 · 24d
```
The eye cannot tell which value belongs to which label, so it reads every
number at the same weight and none of them land. Laid out, each fact gets a
position and the position carries the meaning:
```html
<!-- the pairing is structural, so no glyph has to hold it together -->
<span class="lbl">balance</span>
<div class="num">$39.09 <span class="mut">of $50.00</span></div>
<dl class="facts"> <!-- label left, value right, one per line -->
<dt>Used this period</dt><dd>$10.25</dd>
<dt>Balance read</dt><dd>12 minutes ago</dd>
</dl>
```
**Not covered by this rule:** an em dash standing in for a missing value in a
data cell (`—` where a number would be). That is a placeholder glyph, not
punctuation joining clauses, and it stays.
## Icons
- **Do not** use the Tabler icons webfont ( avoided to prevent FOUT / network dependency).
- Use the inline `icon(name, size=18)` helper and `[data-icon]` placeholders. Each page ships a local `svgs` dictionary with only the icons it needs.
- Stroke-based SVGs, `width="18"`, `stroke-width="2"`, consistent with Feather/Tabler style.
## Charts
Dashboard uses Chart.js 4.4.7 with `chartjs-adapter-date-fns`.
### Line/area mini charts (History)
- One chart per metric; title rendered as plain text above the canvas.
- Common Chart.js options:
- `responsive: true`, `maintainAspectRatio: false`
- Legend hidden (`plugins.legend.display: false`)
- Grid: `rgba(255,255,255,0.04)`
- Y axis: `beginAtZero: true`, `maxTicksLimit: 6`
- X axis: `type: 'time'` with unit derived from range
- `pointRadius: 0`, `tension: 0.2`, `fill: false`
- Metric colors:
| Metric | Border | Fill bg |
|---|---|---|
| decisions | `#3498db` | `rgba(52,152,219,0.1)` |
| requests | `#9b59b6` | `rgba(155,89,182,0.1)` |
| cost | `#2ecc71` | `rgba(46,204,113,0.1)` |
| energy | `#e67e22` | `rgba(230,126,34,0.1)` |
| carbon | `#e74c3c` | `rgba(231,76,60,0.1)` |
### Verdict Mix / Category Breakdown bars
- Pure HTML/CSS, no Chart.js.
- Row layout: `.verdict-row { label | progress | count (share%) }`.
- Colors shared via `categoryColor(label)` so verdict bars and category dots line up.
## The quota chip
- Lives in `.page-header` right side, **not** in a dashboard card.
- Amber/blue gradient glass pill:
```css
background: linear-gradient(135deg, rgba(245,158,11,.12), rgba(59,130,246,.08));
border: 1px solid rgba(245,158,11,.22);
backdrop-filter: blur(14px) saturate(150%);
```
- Opens a modal with the full Quota Meter data.
## Modals
```css
.modal-content {
background: rgba(31,41,55,.75);
backdrop-filter: blur(24px) saturate(150%);
border: 1px solid rgba(255,255,255,.09);
box-shadow: 0 20px 50px -20px rgba(0,0,0,.6);
}
.modal-backdrop { background: #000; opacity: .6; }
```
## Warnings navbar bell
- Hidden if no warnings.
- Amber glass dot count; dropdown is a glass menu with dismissable small alerts.
- Dismissal is per-browser `localStorage`, not server ack.
## Warning-signal / API contract details (from learnings.md)
- **Never use `${icon(...)}` in static HTML.** The `icon()` helper only works in
JS-generated markup. In static card headers, use `<span data-icon="..."></span>`
and let `renderStaticIcons()` fill it on init. Using the literal template in
static markup renders the raw `${icon('x')}` text into the page — a real
regression that shipped once.
- **Tabler exposes Bootstrap components under the `tabler.*` global, NOT
`bootstrap.*`.** `tabler.min.js` does not expose a `bootstrap` global.
Always `new tabler.Toast(...)`, never `bootstrap.Toast(...)` (would throw
`ReferenceError`).
- **Keep a Chart.js `<script>` tag** even on pages that no longer use charts —
`tests/test_admin_frontend.py` asserts the string `"chart.js"` appears in
served page HTML. (Note: current pages load Chart.js only where actually
used; the test targets pages that historically included it — verify which
assertion applies to the page you touch.)
- **SSE status uses a Tabler `.badge bg-*` pill**, not a dot: classes are
`bg-warning` (connecting) → `bg-success` (live) → `bg-danger`
(reconnecting). Both `#sse-status` and `#sse-text` are updated together;
`#sse-text` carries the text for screen readers.
- **`var(--red)` / `var(--green)` / `var(--yellow)` / `var(--text-dim)` are
dead custom tokens** — replaced by Tabler's `--tblr-danger/success/warning/secondary`.
Never reintroduce the old names.
## Tables
- Use `.table.table-vcenter.card-table` or `.table-vcenter.table-hover.card-table`.
- Fixed column widths via utility classes where needed.
- Tier column uses `.tier-{1,2,3,?}` classes.
## Forms / controls
- Boolean knobs: `.form-check.form-switch` — **including** persisted-config
booleans. A bare `.form-check-input` in one panel and a switch in the other
reads as two different apps.
- Model availability: `.form-select.form-select-sm`.
### Settings rows (Controls page)
Runtime Knobs and Persisted Config share one row grammar, `.setting-row`:
```
[ key ................... ] [ meta ] [ control ]
minmax(0,1fr) auto 176px
```
- CSS grid, not `.row`/`.col-*`. A fixed control track is the point: every
switch, input and select lands on the same right edge, so the eye scans one
column. The Bootstrap-grid version put controls mid-row at three different
widths.
- `min-height: 38px` on the row — a switch and a text input differ by ~10px of
natural height, and alternating them looked ragged.
- `.settings-list` carries a negative inline margin equal to the row padding,
so keys align with the card body's content edge while the hover tint bleeds
to the card's inner edge.
- Dotted keys render as `<span class="scope">objective.</span>quality_tolerance`
— dim the scope, keep the leaf bright.
- **Do not add a column that restates the control's own value.** The meta track
is for facts the control cannot show: a unit (`kWh`), or a
`.badge.bg-warning-subtle.text-warning` reading `file: <value>` when a runtime
knob disagrees with `config.yaml`.
- Unsaved persisted-config edits are marked on the row (`.is-dirty`: amber left
rail + tint) and counted on the save button (`Save 2 changes`, disabled at
zero), rather than being invisible until a blind save-everything.
### Card-level layout
A card whose content is a single line of buttons goes **full width** with the
buttons in `.card-actions`, not into a `col-xl-6` beside a tall card — no
`align-items` value fixes the dead column that creates.
## Data patterns
- API base is `''` (relative to `/admin/`); SSE is `/events/decisions` (root level).
- Every page connects the same SSE stream for live status dot/bell updates.
- Escape everything inserted via `innerHTML` with `escapeHtml()`.
- Use `apiFetch()` wrapper that returns `null` on failure; render empty states accordingly.
- Use Bootstrap native toast (`tabler.Toast`) for user feedback, bottom-right, 4s delay.
## Hard-won rules
1. Backdrop-filter stacking contexts need explicit `position: relative; z-index: 1030;` on the navbar or dropdowns sink below later glass elements.
2. Always assign the return value of `new Chart(...)` to a variable, or polling will recreate charts and throw "Canvas is already in use".
3. Keep `<body>` the scroll context, not `<html>`, or Chrome/Linux can add a phantom left margin equal to scrollbar width.
4. Defeat Tabler's `filter: brightness(0) invert(1)` on `.navbar-brand-image` with `filter:none!important` and explicit 45px sizing.
5. Use `!important` on badge/button glass overrides because Bootstrap's attribute-selectored dark rules have higher specificity.
6. Chart.js 4 does **not** stack values within a single dataset. To build a stacked bar (verdict mix), emit **one dataset per segment**.
7. `renderStaticIcons()` must be called first in `init()` — static `[data-icon]` placeholders render undefined otherwise.
8. History range button selectors must match where the buttons actually live. A selector assuming they sit in a `.history-rows` ancestor broke once because the buttons were moved into `.card-title`. Prefer `button[data-range]` globally.
9. Verify rendered DOM with screenshots, not just grep — static-vs-JS template interpolation is easy to get wrong (see rule 6/7 in learnings).
10. After a change to `/admin` pages, run `python -m pytest` (733 tests) AND a Playwright load + one `REFRESH_MS` (30s) idle wait, confirming zero console errors. A fresh-load screenshot alone has missed canvas-reuse and selector bugs twice in this project's history.
11. **No paragraphs of explanatory prose in the UI.** A control gets a short label and at most one terse helper line. Longer rationale belongs in `docs/` or `CLAUDE.md`, not on the page — the portal is for operating the system, not documenting it. The gaming-mode block on `controls.html` and the old category-coverage sentence on `profiles.html` are what this rule exists to stop repeating, not templates to copy.
12. **Mutually exclusive settings must look mutually exclusive.** Use a radio group or segmented control, never independent-looking checkboxes/toggles side by side. The exclusivity has to be obvious before anyone reads a word (`classifier.mode`'s three options; `cloud_primary` vs `cloud_primary_auto`).
13. **Cards are for summaries, not records.** A ~340px card cannot hold a name, an icon, a stat, a source badge, a read-only hint, and up to three action buttons — `profiles.html` proved this over three failed rounds of "polish", where each fix just relocated the collision. For record-shaped pages (a set of named things, each with fields and per-item actions), use a **list plus a detail pane**: the list carries one line per record, the pane gets full width, and nothing competes for the same row. See `renderProfileList()` / `renderProfileDetail()` in `admin/frontend/profiles.html` for the reference implementation, including the inline editor.
14. **Every major config knob needs an admin control.** A setting that can only be changed by hand-editing `config.local.yaml` and restarting is a gap, not a design decision — `classifier.cloud_fallback` sat that way while the portal's own gaming-mode text told operators they needed it. Deliberate exemptions exist (`objective.credit_attenuation.enabled` is config-file-only because `cfg` binds at import), but they should be stated as exemptions rather than left looking like oversights.
15. **Watch for mojibake when an agent edits these files.** A triple-encoded em-dash (`\xc3\x83\xc2\xa2...`) shipped into `profiles.html` comments via an agent edit and survived a merge; a later agent then burned a whole turn failing to remove it with `printf`/`awk`/`iconv`. Keep punctuation in `admin/frontend/*` ASCII (`--`, straight quotes), and scan before committing: `grep -P '[\xc2\xc3][\x80-\xbf][\xc2\xc3\x80-\xbf]+'`.
## File inventory
| File | Purpose |
|---|---|
| `admin/frontend/index.html` | Dashboard (quota chip, per-model usage, verdict mix, category breakdown, history mini-charts, recent decisions) |
| `admin/frontend/models.html` | Model availability table with override dropdown |
| `admin/frontend/decisions.html` | Full decision log with filter + search |
| `admin/frontend/controls.html` | Operational triggers, runtime knobs, persisted config editor |
| `admin.py` | FastAPI sub-router mounted at `/admin` |
| `admin_schema.sql` | RBAC/audit tables (not yet wired in UI) |