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

22 KiB
Raw Permalink Blame History

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:
    <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.
    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:
    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.

The navbar mascot is a raster.webpasset, 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:

<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

.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:

.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.

.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:

<!-- 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:
    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

.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)