style(web): filled buttons, bolder icons, table/row alignment fixes, brand icon

- Two filled button kinds, no neutral/outline third state: every action is
  either "normal" (#4382c2) or destructive (#cb4b4c); border is the same
  neutral gray as every table/card border, not the fill colour.
- Icons: bolder stroke site-wide (2 -> 2.5) and +1px, except inside buttons
  (2.25, no size bump) where a bold icon next to now-unbolded button text
  was too heavy.
- tbody row hover: flat gray -> a transparent wash of the link/accent blue.
- The Actions <td> in Services/Templates was itself a flex container; some
  engines size that box's content slightly differently from a plain-text
  sibling cell, which showed up as the row's collapsed bottom border sitting
  1px higher under Actions than its neighbours. The flex row now lives on an
  inner <div class="actions-row"> instead, with its <form> children pinned
  to content width (a bare <form> is display:flex by the site's own rule,
  which was letting one form claim the whole row and push the next to wrap).
- Navbar brand gets an icon (a server rack -- this dashboard orchestrates
  hosts/VMs, not generic settings).
This commit is contained in:
2026-07-25 19:35:36 +02:00
parent a1fb620ca5
commit 3ffcf4314b
3 changed files with 164 additions and 118 deletions
+141 -97
View File
@@ -91,10 +91,16 @@ dd code, dd .mono { overflow-wrap: anywhere; }
}
.navbar-brand {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--text);
font-weight: 600;
font-size: 1.05rem;
}
/* Gear: automation/pipeline, fits an orchestrator's brand mark better than a
* subject-specific glyph (server/box/etc. already belong to their own card). */
.navbar-brand .icon { width: 1.3em; height: 1.3em; }
.navbar-brand:hover { color: var(--accent-hover); text-decoration: none; }
.navbar-links {
@@ -120,63 +126,16 @@ dd code, dd .mono { overflow-wrap: anywhere; }
#action-slot:empty { display: none; }
#action-slot:not(:empty) { margin-bottom: 1.25rem; }
/* ── status header (A3) ──────────────────────────────────────────────── */
/* ── resource gauges inside cards (Stage F) ──────────────────────────── */
.status-header {
display: grid;
/* Six tiles (VMs, Runners, CPU, RAM, Disk, Cache) -- a smaller min track
* lets all six sit on one row on a wide screen, wrapping gracefully below. */
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
/* The Host card's CPU / RAM / disk readouts. These lived in the retired
* status-header tiles; the `tile-*` level class names survive as what the
* gauge fill colour keys off. Stage G dropped the level word + check-icon
* strip that used to follow each gauge: the state is stated as a number
* ("42%", "20.0 / 62 GB") right above the bar, and the level word moved into
* the gauge's aria-label -- so no reading of this card depends on colour. */
.stat-tile {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: var(--radius);
padding: 0.9rem 1rem;
}
.stat-tile > .icon { flex-shrink: 0; margin-top: 0.15rem; color: var(--text-dim); }
.stat-tile-body { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; flex: 1; }
.stat-tile-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-dim);
}
.stat-tile-value { font-size: 1.15rem; font-weight: 600; color: var(--text); }
.stat-tile-sub { font-size: 0.8rem; font-weight: 400; color: var(--warn); }
.stat-tile-status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* Health must never rely on color alone -- the border accent + icon +
* label all move together, so a color-blind viewer still reads the state
* from the text/icon. */
.tile-ok { border-left-color: var(--success); }
.tile-ok .stat-tile-status { color: var(--success); }
.tile-warn { border-left-color: var(--warn); }
.tile-warn .stat-tile-status { color: var(--warn); }
.tile-bad { border-left-color: var(--danger); }
.tile-bad .stat-tile-status { color: var(--danger); }
.tile-unknown { border-left-color: var(--border); }
.tile-unknown .stat-tile-status { color: var(--text-dim); }
.resource-value { display: block; font-variant-numeric: tabular-nums; }
.gauge {
width: 100%;
@@ -184,24 +143,30 @@ dd code, dd .mono { overflow-wrap: anywhere; }
background: var(--panel-2);
border-radius: 999px;
overflow: hidden;
margin: 0.15rem 0;
margin: 0.25rem 0;
}
.gauge-fill { height: 100%; background: var(--success); }
.tile-warn .gauge-fill { background: var(--warn); }
.tile-bad .gauge-fill { background: var(--danger); }
.tile-bad .gauge-fill { background: var(--danger); }
/* Sub-line under a gauge (Host: the load average under CPU), same role as the
* dim "GB free" beside the disk percentage. */
.resource-sub {
display: block;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.dashboard-grid {
display: grid;
/* FIXED four-column track, NOT auto-fit. A `.card--wide` spans `1 / -1`,
* i.e. occupies every column on its row -- with `auto-fit` that means no
* track is ever globally empty, so auto-fit can't collapse the compact
* row's spare tracks and the four info cards stayed narrow with dead
* columns on the right. A fixed count makes the four compact cards fill
* their row exactly, and the wide tables still span all four via
* `grid-column: 1 / -1`. minmax(0,1fr) lets a card shrink below its
* content width so a wide table scrolls inside its own .table-wrap
* instead of forcing the page to scroll. Column count steps down with
* width (below). */
* track is ever globally empty, so auto-fit could not collapse a compact
* row's spare tracks. Since Stage F every card is `.card--wide` (the four
* compact info cards became the one Platform card), so the track count only
* matters if a compact card is ever added back. minmax(0,1fr) lets a card
* shrink below its content width so a wide table scrolls inside its own
* .table-wrap instead of forcing the page to scroll. */
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1.25rem;
/* stretch: every card in a row shares the row's height, so the grid reads
@@ -285,6 +250,19 @@ dd code, dd .mono { overflow-wrap: anywhere; }
* the bar's right end. Compact and case-normal so it reads as a control rather
* than part of the uppercase heading text, and short enough not to grow the
* bar's height. */
/* Right-hand end of a card header bar: a single near-static fact that would
* otherwise cost a whole <dl> row (Host: uptime). `text-transform` must be
* reset -- the h2 uppercases, and "26H 03M" is not a duration anyone reads. */
.head-meta {
margin-left: auto;
font-size: 0.8rem;
font-weight: 400;
text-transform: none;
letter-spacing: normal;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
}
.head-select {
margin-left: auto;
padding: 0.1rem 0.35rem;
@@ -295,7 +273,10 @@ dd code, dd .mono { overflow-wrap: anywhere; }
color: var(--text);
}
/* Per-template expandable backup list (Templates panel). */
/* Per-template expandable backup list (Templates panel). Each row is its own
* bordered line (not just a wrapping flex list run into the next) so a
* multi-backup template reads as a small table, not a paragraph the delete
* button happened to float in. */
.backups-details > summary {
cursor: pointer;
color: var(--link);
@@ -306,14 +287,19 @@ dd code, dd .mono { overflow-wrap: anywhere; }
.backups-list { list-style: none; margin: 0; padding: 0; }
.backups-list li {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
padding: 0.2rem 0;
font-size: 0.82rem;
align-items: center;
gap: 0.75rem;
padding: 0.45rem 0;
font-size: 0.85rem;
border-top: 1px solid var(--border);
}
.backups-list li:first-child { border-top: none; }
/* Name + size/date as one shrinkable unit, so the row wraps as a block
* instead of the delete button drifting away from a name that wrapped. */
.backup-info { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem; min-width: 0; flex: 1 1 auto; }
.backup-info code { overflow-wrap: anywhere; }
/* Per-backup delete button pinned to the right end of its row. */
.backup-del { margin-left: auto; align-self: center; }
.backup-del { margin-left: auto; flex: none; }
.card h2 {
display: flex;
@@ -339,7 +325,9 @@ dd code, dd .mono { overflow-wrap: anywhere; }
dl { display: grid; grid-template-columns: auto 1fr; gap: 0 0.9rem; }
dt, dd { padding: 0.45rem 0; border-bottom: 1px solid var(--border); }
dt { color: var(--text-dim); font-weight: 500; }
dd { margin: 0; }
/* Long unbreakable values (a cache root, a kernel string, a CPU model)
* wrap inside the card rather than widening its grid track. */
dd { margin: 0; overflow-wrap: anywhere; }
/* ── tables ───────────────────────────────────────────────────────────── */
@@ -347,7 +335,13 @@ dd { margin: 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
/* vertical-align:middle is explicit, not left to the UA default (baseline) --
* keeps every cell in a row anchored the same way regardless of its content
* (a row of buttons vs. one line of text). The Actions-column border step
* this was chasing turned out to be .actions-row's flex box sitting directly
* on the <td> (see the comment there); this rule stays for alignment, not as
* that fix. */
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
th {
color: var(--text-dim);
@@ -358,7 +352,7 @@ th {
}
tbody tr { transition: background-color 120ms ease; }
tbody tr:hover { background: var(--panel-2); }
tbody tr:hover { background: rgba(65, 131, 196, 0.12); }
tr.row-orphan td { color: var(--warn); }
tr.row-alert td { color: var(--danger); }
@@ -396,8 +390,37 @@ tr.row-alert td { color: var(--danger); }
.badge-info { background: rgba(65, 131, 196, 0.15); color: var(--accent); border: 1px solid rgba(65, 131, 196, 0.4); }
.badge-muted { background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); }
/* A badge sitting right before a dim sub-state ("loaded" + "kernel module",
* "active" + "running") only had the markup's one collapsed whitespace
* character between them -- reads as glued together. */
.badge + .text-dim { margin-left: 0.4rem; }
/* VMware/Runners (Stage H): "loaded"/"not loaded", "active"/"down" are
* different lengths, so the pill in each <dd> row was a different width from
* its neighbours. Levelled to fit the longest word in either card, same
* technique as the Executions Status column. */
#vmware-card .badge,
#runner-card .badge {
min-width: 5rem;
justify-content: flex-start;
}
/* Executions card: the Status column mixes run outcomes (success/failure/
* queued/cancelled...) and VM-only states (orphan/running) of very different
* word lengths -- a ragged column of differently-sized pills. Both row kinds
* put their badge in the 8th <td> (Run, Repo, Workflow, Branch, Commit,
* Trigger, Actor, Status, ...), so one rule levels every pill in the column
* to "cancelled"'s width (the longest word) with room to spare, and
* `justify-content` keeps icon+text hugging the left edge of the now-wider
* pill rather than centering. */
#executions-card td:nth-child(8) { width: 5rem; }
#executions-card td:nth-child(8) .badge { width: 100%; justify-content: flex-start; }
/* ── buttons ──────────────────────────────────────────────────────────── */
/* Two filled button kinds, no neutral/outline third state: every action is
* either "normal" (blue) or destructive (red) -- the confirm gate is still
* the real safeguard on a destructive action, this is just legibility. */
.btn {
display: inline-flex;
align-items: center;
@@ -406,26 +429,31 @@ tr.row-alert td { color: var(--danger); }
border-radius: var(--radius);
padding: 0.45rem 0.9rem;
font-size: 0.875rem;
font-weight: 600;
font-weight: 400;
font-family: inherit;
cursor: pointer;
background: var(--panel-2);
color: var(--text);
background: #4382c2;
color: #ffffff;
/* Same neutral gray as every table/card border, not the fill colour --
* a solid-colour border read as an outline duplicating the fill. */
border-color: var(--border);
transition: background-color 120ms ease, border-color 120ms ease, transform 80ms ease;
}
.btn:hover { background: var(--border); }
/* Hover tint: +25 per channel, same lighten-by magnitude as --accent-hover.
* Border stays the neutral gray -- only the fill shifts on hover. */
.btn:hover { background: #5c9bdb; }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); color: #ffffff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* A plain-weight button label leaves its icon looking thin/washed-out next
* to a solid fill -- bumped stroke-width and a couple px of extra size make
* it read as clearly as the bold text used to. */
.btn .icon { stroke-width: 2.25; width: 1.15em; height: 1.15em; }
/* Destructive actions: a NEUTRAL button at rest (same panel surface + border
* as .btn) that only reads as red through its text/icon -- no red border or
* fill glaring on the page. Danger becomes fully visible on hover (red border
* + soft red wash). The real safeguard is the two-step confirm gate, not the
* colour. */
.btn-danger { background: var(--panel-2); color: var(--danger); border-color: var(--border); }
.btn-danger:hover { background: rgba(248, 81, 73, 0.14); color: var(--danger); border-color: var(--danger); }
.btn-primary { background: #4382c2; color: #ffffff; border-color: var(--border); }
.btn-primary:hover { background: #5c9bdb; }
.btn-danger { background: #cb4b4c; color: #ffffff; border-color: var(--border); }
.btn-danger:hover { background: #e46465; }
/* Compact variant for buttons that live inside a table row (VM remove,
* per-service start/stop/restart) so a row of them stays low-profile. */
@@ -458,8 +486,17 @@ input[type="text"], input[type="number"], select {
/* Multiple one-button action forms side-by-side in one table cell (Stage D
* services panel: Start/Stop/Restart). Overrides the default form's
* margin-top so the row stays compact inside a <td>. */
.service-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.service-actions form { margin-top: 0; }
/* Wraps the row of action buttons INSIDE the td rather than making the td
* itself a flex box -- a table-cell that is also a flex container computes
* its content-box height slightly differently from a plain-text sibling
* cell in some engines, which showed up as the row's collapsed bottom
* border sitting 1px higher under this column than its neighbours. */
.actions-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
/* Each <form> is itself display:flex (global form rule) -- as a flex item of
* .actions-row that can size to fill the row instead of its own content,
* pushing the next form onto a new line. flex:none pins it to content width
* so multiple forms actually sit side by side. */
.actions-row form { margin-top: 0; flex: none; }
/* Muted inline text (e.g. a relative-time suffix next to a file name). */
.text-dim { color: var(--text-dim); }
@@ -600,18 +637,26 @@ input[type="text"], input[type="number"], select {
* references render. currentColor lets every icon pick up whatever text
* color its container already has -- no per-icon color rules needed. */
.icon {
width: 1em;
height: 1em;
/* calc(...+1px) rather than a bigger em multiplier: a flat 1px bump reads
* the same at every context size (a 0.75rem badge icon and a 15px body
* icon both just gain one pixel), where a fixed em delta would swing by
* context. .btn .icon overrides this with a plain em value -- no +1px --
* so buttons are unaffected. */
width: calc(1em + 1px);
height: calc(1em + 1px);
vertical-align: -0.15em;
fill: none;
stroke: currentColor;
/* Bolder than the sprite's own stroke-width="2" attribute -- CSS wins over
* the SVG presentation attribute. Button icons dial this back down, see
* .btn .icon below. */
stroke-width: 2.5;
flex-shrink: 0;
}
.card h2 .icon,
.stat-tile > .icon {
width: 1.1em;
height: 1.1em;
.card h2 .icon {
width: calc(1.1em + 1px);
height: calc(1.1em + 1px);
}
/* ── accessibility ────────────────────────────────────────────────────── */
@@ -637,5 +682,4 @@ input[type="text"], input[type="number"], select {
.navbar-inner { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
form { flex-direction: column; align-items: flex-start; }
dl { grid-template-columns: 1fr; }
.status-header { grid-template-columns: 1fr; }
}
+1 -1
View File
@@ -13,7 +13,7 @@
{% set logged_in_user = request.session.get("user") %}
<nav class="navbar">
<div class="navbar-inner">
<a class="navbar-brand" href="/">ci-orchestrator</a>
<a class="navbar-brand" href="/"><svg class="icon" aria-hidden="true"><use href="#i-server"></use></svg>ci-orchestrator</a>
<div class="navbar-links">
<a href="/audit">Audit log</a>
{% if gitea_url %}
@@ -25,26 +25,28 @@
{% endif %}
</td>
<td>{{ state.sub_state }}</td>
<td class="service-actions">
{% if not state.active %}
<form method="post" action="/services/{{ state.service.id }}/start"
hx-post="/services/{{ state.service.id }}/start" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-primary btn-sm" aria-label="Start {{ state.service.label }}">Start</button>
</form>
{% endif %}
{% if state.active and state.service.allow_stop %}
<form method="post" action="/services/{{ state.service.id }}/stop"
hx-post="/services/{{ state.service.id }}/stop" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger btn-sm" aria-label="Stop {{ state.service.label }}">Stop</button>
</form>
{% endif %}
<form method="post" action="/services/{{ state.service.id }}/restart"
hx-post="/services/{{ state.service.id }}/restart" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm" aria-label="Restart {{ state.service.label }}">Restart</button>
</form>
<td>
<div class="actions-row">
{% if not state.active %}
<form method="post" action="/services/{{ state.service.id }}/start"
hx-post="/services/{{ state.service.id }}/start" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-primary btn-sm" aria-label="Start {{ state.service.label }}">Start</button>
</form>
{% endif %}
{% if state.active and state.service.allow_stop %}
<form method="post" action="/services/{{ state.service.id }}/stop"
hx-post="/services/{{ state.service.id }}/stop" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger btn-sm" aria-label="Stop {{ state.service.label }}">Stop</button>
</form>
{% endif %}
<form method="post" action="/services/{{ state.service.id }}/restart"
hx-post="/services/{{ state.service.id }}/restart" hx-target="#action-slot" hx-swap="innerHTML show:top">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm" aria-label="Restart {{ state.service.label }}">Restart</button>
</form>
</div>
</td>
</tr>
{% endfor %}