Blueforce-Linux/web/static/app.css
emirhanmamak-blueforce 337db15d5b feat(web): add the central fleet panel the operator asked for first
The fleet has ~700 devices and no way to see them: the device console manages one
machine from its own keyboard, and the question "which devices are installed, and
which wave is each in" had no surface at all. This adds it as the first screen.

Python standard library only -- http.server, html.escape, string.Template. No
framework and no third-party import, because the fleet installs offline from a
committed package pool that contains no web framework, so fastapi/uvicorn/flask
would have made the panel uninstallable on the devices it manages. That was
decided with the user over the FastAPI they first picked, and a test asserts it:
the module imports only stdlib plus its own siblings.

What it shows: the dark header with the target and the privilege level; a summary
of counts by state with one loud figure; the device list with a state badge per
row, monospace ids and hostnames so columns line up, non-ready devices sorted
first, and a filter bar; the selected device's detail with its four-channel
verification, since READY is refused until every channel is verified (docs/27).
Nothing is colour-only: every badge carries its Turkish state word.

Data has no live source yet, so a missing inventory file falls back to synthetic
records that the page labels as demo in two places -- the panel never presents
invented devices as real ones.

Two defects found by reviewing a real render, both fixed here with a test that
fails when the bug returns (proven by mutation: each assertion goes red against
the reintroduced bug, green after):
- --state-provisioned resolved to var(--info), and --info is --accent, so a
  KURULDU badge and the selected row were the same blue. The state now resolves
  to its own colour, and the test resolves var() aliases before comparing --
  comparing the raw declarations does not detect this, which is how the first
  version of the test passed against the bug.
- the wave cell printed the bare code and then a label that already contains it,
  so one device read "P2 P2 — PILOT-2". Each wave is named once, now.

Also fixes two entries in docs/27 that had become untrue: it claimed a clone
carries the packages and the installer downloads nothing, which stopped being the
case when the pool moved to Git LFS and bf-bootstrap began cloning without
smudge. The real flow -- light clone, payload from a local mirror or a targeted
`git lfs pull` -- is documented instead.

Tests: tests/test_web_static.py, 112 tests, collected by tests/run-all.sh
(12/12 PASS overall).
2026-09-16 22:39:50 +03:00

660 lines
14 KiB
CSS

/*
* Blueforce Field OS — central panel stylesheet.
* ---------------------------------------------------------------------------
* Colour and metrics come from web/static/tokens.css and from nowhere else.
* This file declares no custom property and no literal colour: one palette
* change in tokens.css moves both operator surfaces. A colour here is a state
* or the action accent, never decoration (docs/DESIGN.md, Rules 1 and 2).
*
* Layout follows docs/DESIGN.md FIRST VIEWPORT: dark header band, one large
* figure in the summary strip, then the fleet list beside the selected device's
* detail region.
*/
*, *::before, *::after { box-sizing: border-box; }
html { background: var(--bg-app); }
body {
margin: 0;
background: var(--bg-app);
color: var(--text);
font-family: var(--font-ui);
font-size: var(--size-md);
line-height: var(--leading-normal);
}
a { color: var(--accent); }
a:hover { color: var(--accent-hover); }
.mono { font-family: var(--font-data); font-variant-numeric: tabular-nums; }
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.skip-link {
position: absolute;
left: var(--sp-2);
top: calc(-1 * var(--sp-7));
z-index: 2;
padding: var(--sp-2) var(--sp-3);
background: var(--bg-panel);
color: var(--accent);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
transition: top .1s linear;
}
.skip-link:focus { top: var(--sp-2); }
/* -- Header band: the one dark region ---------------------------------- */
.app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-5);
min-height: var(--header-h);
padding: var(--sp-2) var(--sp-4);
background: var(--bg-header);
color: var(--text-on-dark);
}
.app-header__identity {
display: flex;
flex-direction: column;
}
.app-header__product {
margin: 0;
font-size: var(--size-lg);
font-weight: 600;
line-height: var(--leading-tight);
letter-spacing: .01em;
}
.app-header__surface {
font-size: var(--size-xs);
color: var(--text-on-dark);
opacity: .75;
}
.app-header__target,
.app-header__privilege {
display: flex;
flex-direction: column;
gap: var(--sp-1);
margin: 0;
font-size: var(--size-sm);
}
.app-header__label {
font-size: var(--size-xs);
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-on-dark);
opacity: .7;
}
.app-header__value { color: var(--text-on-dark); }
.app-header__reason {
max-width: 34ch;
font-size: var(--size-xs);
color: var(--text-on-dark);
opacity: .8;
}
.app-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
}
.app-header__privilege {
max-width: 40ch;
text-align: right;
}
.gated-reason {
max-width: 30ch;
font-size: var(--size-xs);
color: var(--text-on-dark);
opacity: .8;
}
.app-header__nav {
display: flex;
gap: var(--sp-3);
margin-right: auto;
}
.nav-link {
color: var(--text-on-dark);
font-size: var(--size-sm);
text-decoration: none;
padding-bottom: var(--sp-1);
border-bottom: 2px solid transparent;
}
.nav-link[aria-current="page"] { border-bottom-color: var(--text-on-dark); }
/* -- Buttons and inputs: one height ------------------------------------ */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--control-h);
padding: 0 var(--sp-3);
font-family: inherit;
font-size: var(--size-sm);
font-weight: 600;
text-decoration: none;
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
cursor: pointer;
}
.button--primary {
color: var(--text-on-accent);
background: var(--accent);
border-color: var(--accent);
}
.button--primary:hover {
color: var(--text-on-accent);
background: var(--accent-hover);
}
.button--gated:disabled {
color: var(--text-faint);
background: var(--bg-sunken);
border-color: var(--border);
cursor: not-allowed;
}
input[type="search"],
select {
height: var(--control-h);
padding: 0 var(--sp-2);
font-family: inherit;
font-size: var(--size-sm);
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
}
/* -- Page frame -------------------------------------------------------- */
.app-main {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-4);
max-width: 1600px;
margin: 0 auto;
}
.app-footer {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
font-size: var(--size-xs);
color: var(--text-muted);
border-top: 1px solid var(--border);
}
.section-title {
margin: 0 0 var(--sp-2);
font-size: var(--size-xl);
line-height: var(--leading-tight);
}
.subsection-title {
margin: 0 0 var(--sp-2);
font-size: var(--size-lg);
line-height: var(--leading-tight);
}
.section-note {
margin: 0 0 var(--sp-2);
font-size: var(--size-sm);
color: var(--text-muted);
}
.cell-note {
font-size: var(--size-xs);
color: var(--text-muted);
}
/* -- Data-source banner: what the operator is looking at --------------- */
.data-banner {
display: flex;
align-items: baseline;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
font-size: var(--size-sm);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
}
.data-banner--info { background: var(--info-soft); border-color: var(--info); }
.data-banner--warn { background: var(--warn-soft); border-color: var(--warn); }
.data-banner--danger { background: var(--danger-soft); border-color: var(--danger); }
.data-banner__word {
font-size: var(--size-xs);
font-weight: 700;
letter-spacing: .06em;
}
/* -- Summary strip: the one loud figure -------------------------------- */
.summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-4) var(--sp-6);
padding: var(--sp-4);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.summary__lead {
display: grid;
gap: var(--sp-1);
padding-right: var(--sp-5);
border-right: 1px solid var(--border);
}
.summary__lead-figure {
font-size: var(--size-2xl);
font-weight: 700;
line-height: var(--leading-tight);
color: var(--state-ready);
}
.summary__lead-label { font-size: var(--size-sm); font-weight: 600; }
.summary__lead-note { font-size: var(--size-xs); color: var(--text-muted); }
.summary__grid {
display: flex;
flex-wrap: wrap;
gap: var(--sp-5);
margin: 0;
padding: 0;
list-style: none;
}
.summary__cell { display: grid; gap: var(--sp-1); }
.summary__cell-value {
font-size: var(--size-lg);
font-weight: 600;
line-height: var(--leading-tight);
}
.summary__cell-word {
font-size: var(--size-xs);
font-weight: 700;
letter-spacing: .04em;
}
.summary__cell-note { font-size: var(--size-xs); color: var(--text-muted); }
/* -- Two-column body: list beside detail ------------------------------- */
.split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 400px);
gap: var(--sp-4);
align-items: start;
}
@media (max-width: 1100px) {
.split { grid-template-columns: minmax(0, 1fr); }
}
.split__list,
.split__detail {
padding: var(--sp-4);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
/* -- Filter bar -------------------------------------------------------- */
.filter-bar {
display: grid;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
padding: var(--sp-3);
background: var(--bg-sunken);
border-radius: var(--radius);
}
.filter-bar__form {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-3);
margin: 0;
}
.filter-field {
display: grid;
gap: var(--sp-1);
margin: 0;
font-size: var(--size-xs);
color: var(--text-muted);
}
.filter-field--check {
grid-auto-flow: column;
align-items: center;
gap: var(--sp-2);
height: var(--control-h);
}
.filter-field--actions {
grid-auto-flow: column;
align-items: center;
gap: var(--sp-2);
font-size: var(--size-sm);
}
.filter-bar__summary {
margin: 0;
font-size: var(--size-xs);
color: var(--text-muted);
}
.wave-strip {
margin: 0 0 var(--sp-3);
font-size: var(--size-xs);
color: var(--text-muted);
}
.wave-strip__item { white-space: nowrap; }
/* -- Fleet table ------------------------------------------------------- */
.fleet-table {
width: 100%;
border-collapse: collapse;
font-size: var(--size-sm);
}
.fleet-table th {
padding: var(--sp-2);
text-align: left;
font-size: var(--size-xs);
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border-strong);
}
.fleet-table td {
height: var(--row-h);
padding: var(--sp-2);
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.fleet-row:hover td { background: var(--bg-sunken); }
.fleet-row--selected td { background: var(--accent-soft); }
.row-select { text-decoration: none; }
.row-select:hover { text-decoration: underline; }
/* -- Badges: a word plus a state colour -------------------------------- */
.badge {
display: inline-flex;
align-items: center;
padding: var(--sp-1) var(--sp-2);
font-family: var(--font-data);
font-size: var(--size-xs);
font-weight: 700;
letter-spacing: .04em;
border: 1px solid;
border-radius: var(--radius);
}
.badge--state-provisioned {
color: var(--state-provisioned);
background: var(--info-soft);
border-color: var(--state-provisioned);
}
.badge--state-enrolled {
color: var(--state-enrolled);
background: var(--warn-soft);
border-color: var(--state-enrolled);
}
.badge--state-ready {
color: var(--state-ready);
background: var(--ok-soft);
border-color: var(--state-ready);
}
.badge--state-failed {
color: var(--state-failed);
background: var(--danger-soft);
border-color: var(--state-failed);
}
.badge--state-absent {
color: var(--state-absent);
background: var(--idle-soft);
border-color: var(--state-absent);
}
.badge--privilege-read-only {
color: var(--text-on-dark);
background: transparent;
border-color: var(--text-on-dark);
}
.badge--privilege-full {
color: var(--warn);
background: var(--warn-soft);
border-color: var(--warn);
}
/* -- Detail region ----------------------------------------------------- */
.detail__state {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
margin: 0 0 var(--sp-3);
}
.detail__meaning { font-size: var(--size-sm); color: var(--text-muted); }
.selection-note,
.reason {
margin: 0 0 var(--sp-3);
padding: var(--sp-2) var(--sp-3);
font-size: var(--size-sm);
border: 1px solid;
border-radius: var(--radius);
}
.selection-note {
background: var(--idle-soft);
border-color: var(--state-absent);
}
.reason {
background: var(--danger-soft);
border-color: var(--state-failed);
color: var(--text);
}
.facts {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: var(--sp-2) var(--sp-3);
margin: 0 0 var(--sp-4);
font-size: var(--size-sm);
}
.facts dt {
font-size: var(--size-xs);
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text-muted);
align-self: center;
}
.facts dd { margin: 0; }
.channels {
display: grid;
gap: var(--sp-2);
margin: 0 0 var(--sp-2);
padding: 0;
list-style: none;
}
.channel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-1) var(--sp-2);
padding: var(--sp-2);
background: var(--bg-sunken);
border-radius: var(--radius);
}
.channel__name { font-weight: 600; font-size: var(--size-sm); }
.channel__status {
justify-self: end;
font-size: var(--size-xs);
font-weight: 700;
}
.channel__status--ok { color: var(--ok); }
.channel__status--warn { color: var(--warn); }
.channel__status--danger { color: var(--danger); }
.channel__status--idle { color: var(--idle); }
.channel__meta { font-size: var(--size-xs); color: var(--text-faint); }
.channel__hint { font-size: var(--size-xs); color: var(--text-muted); }
.notes__list {
display: grid;
gap: var(--sp-1);
margin: 0 0 var(--sp-3);
padding-left: var(--sp-4);
font-size: var(--size-xs);
color: var(--text-muted);
}
/* -- Warnings and invalid records -------------------------------------- */
.warnings,
.invalid {
padding: var(--sp-3);
margin-bottom: var(--sp-3);
border: 1px solid;
border-radius: var(--radius);
}
.warnings { background: var(--info-soft); border-color: var(--info); }
.invalid { background: var(--danger-soft); border-color: var(--state-failed); }
.warnings__list,
.invalid__list {
display: grid;
gap: var(--sp-1);
margin: 0;
padding-left: var(--sp-4);
font-size: var(--size-sm);
}
.empty-note {
margin: var(--sp-3) 0 0;
padding: var(--sp-3);
font-size: var(--size-sm);
background: var(--bg-sunken);
border-radius: var(--radius);
}
/* -- About/durum view -------------------------------------------------- */
.about { display: grid; gap: var(--sp-4); }
.card {
padding: var(--sp-4);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.card--danger {
background: var(--danger-soft);
border-color: var(--state-failed);
}
.about-table {
width: 100%;
border-collapse: collapse;
font-size: var(--size-sm);
}
.about-table th {
padding: var(--sp-2);
text-align: left;
font-size: var(--size-xs);
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border-strong);
}
.about-table td {
padding: var(--sp-2);
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.rules {
display: grid;
gap: var(--sp-2);
margin: 0;
padding-left: var(--sp-4);
font-size: var(--size-sm);
}