# 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 ``. - **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
…
``` 2. Scrolling context: **`` scrolls, `` 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 `` (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 `` element with the same class and sizing contract that the inline SVG previously had: ```html ``` - 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 (``) 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 `·` / `·`.** Not in copy, not in a ``, 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) |