yksa-web-kit/yksa_web/static/yksa/css/kit.css
2026-08-23 15:26:38 +08:00

679 lines
23 KiB
CSS

/* =============================================================================
YKSA UI kit.
Requires Bootstrap >= 5.3: the state pills below are built on the
--bs-*-bg-subtle / --bs-*-text-emphasis / --bs-*-border-subtle families, and
text-body-secondary is a 5.3 utility. On 5.2.3 every rule here still parses
and the page renders wrong -- muted text is not muted, tinted backgrounds are
transparent. The bundled bootstrap.min.css is the version this is written for;
do not point a service at its own older copy.
Two rules govern the whole kit:
1. Typography carries meaning through weight, colour and size only. No
small-caps, no thin/semibold, no letter-spacing, no .display-*. Weights
are 400 and 700.
2. Colour is never the only signal. Every state renders an icon and a word
alongside its hue.
Anything Bootstrap already provides is used as-is. The classes below exist
only where it has no equivalent.
============================================================================= */
:root {
--navbar-height: 44px;
/* One layer scale for the whole estate, because the alternative is each
surface picking a number and a sticky bar eventually landing on top of an
open menu -- which is exactly what ops's console header did to the navbar's
timezone dropdown. Bootstrap's own scale starts at modal-backdrop (1040),
so everything here stays below that.
1020 in-page sticky bars (ops's console header)
1030 page chrome: navbar, action bar, cookie banner
1035 anything transient the user just opened -- above all of it */
--yksa-z-sticky: 1020;
--yksa-z-chrome: 1030;
--yksa-z-floating: 1035;
/* One hover treatment for the whole estate: the surface's ground goes one
step darker and nothing else moves. Every hoverable thing -- table rows,
list rows, step rows, catalog cards, disclosures -- uses this token, so a
page cannot end up with three ways of saying "you are pointing at this".
Borders, shadows and colour shifts are all reserved for *state*, which is
a property of the record; hover is a property of the pointer. */
--yksa-hover-bg: var(--bs-tertiary-bg);
}
/* Bootstrap's own hoverable components, pointed at the same token. */
.table {
--bs-table-hover-bg: var(--yksa-hover-bg);
}
.list-group {
--bs-list-group-action-hover-bg: var(--yksa-hover-bg);
}
/* Bootstrap declares --bs-dropdown-zindex on .dropdown-menu, not on :root, so it
has to be overridden here -- a value set on :root is shadowed and does nothing.
At Bootstrap's default of 1000 an open menu loses to every bar above. */
.dropdown-menu {
--bs-dropdown-zindex: var(--yksa-z-floating);
}
.custom-navbar {
height: var(--navbar-height);
padding-top: 0;
padding-bottom: 0;
z-index: var(--yksa-z-chrome);
border: none;
background-color: var(--bs-body-bg) !important;
}
.navbar {
z-index: var(--yksa-z-chrome);
}
.navbar-brand {
margin-right: 1em;
}
/* Nav links fill the bar's height and butt against one another, so the active
item reads as a selected tab rather than a highlighted word. */
.nav-full-height {
display: flex;
align-items: center;
height: 100%;
}
/* The top padding is deliberately larger than the bottom: it is what sits the link
text on the same optical line as the wordmark in the brand block. Do not
symmetrise it -- the alignment is the reason it is here. */
.nav-full-height.nav-link {
color: inherit;
padding-left: 1rem !important;
padding-right: 1rem !important;
padding-top: 12px;
background-color: var(--bs-body-bg);
margin-right: -1px;
}
/* The locale item's only content is a flag image. Because the link is a flex
container, an image child gives it no text line box to take its height from, so
the item came out ~9px shorter than every neighbour. Reserve the height one line
of body text would have occupied and letterbox the flag inside it: same box as
its neighbours, flag unscaled and undistorted.
Scoped to .nav-link so the brand block's wordmark is untouched. */
.nav-full-height.nav-link > img {
min-height: calc(1em * var(--bs-body-line-height));
object-fit: contain;
}
/* An open dropdown is as much "the item you are on" as an active one. Without
this the toggle dropped back to the resting colours the moment its own menu
appeared, which reads as the click having missed. */
.nav-full-height.nav-link:hover,
.nav-full-height.nav-link.active,
.nav-full-height.nav-link.show {
color: #fff !important;
background-color: var(--bs-primary);
}
/* Muted text inside a highlighted nav item takes the item's colour.
Bootstrap's text utilities set `color` with `!important`, so a muted span --
a countdown, a hint -- kept its resting grey when the item turned solid primary
underneath it: grey on blue, the one combination the theme has no contrast for.
`inherit` needs `!important` here for the same reason the utility does.
Deliberately only the muted utility, not every descendant. A badge and a state
pill are separate surfaces carrying their own background, and flattening them
to the parent's colour would erase what they are for. */
.nav-full-height.nav-link:hover .text-body-secondary,
.nav-full-height.nav-link.active .text-body-secondary,
.nav-full-height.nav-link.show .text-body-secondary,
/* Same problem, same fix, one row down: a dropdown item with a muted hint or
sub-line under its label -- the outcome menu on the pass console, the duty
menu in the header -- highlights on hover, focus and keyboard selection, and
the muted line stayed grey against it. Any menu that explains its options has
this shape, so the rule belongs on `.dropdown-item` rather than on each one. */
.dropdown-item:hover .text-body-secondary,
.dropdown-item:focus .text-body-secondary,
.dropdown-item:active .text-body-secondary,
.dropdown-item.active .text-body-secondary {
color: inherit !important;
}
/* `inline-block`, not `inline-flex`, and this is the whole of the table-alignment
fix. An inline-flex box's baseline is its last flex item's baseline, and its
padding and border hang *below* that -- so in a table cell the pill sits low,
and no amount of `vertical-align` corrects it without also fighting the line
height it inherits. An inline-block shares the row's baseline with the text
beside it and straddles it symmetrically, which is why Bootstrap's own `.badge`
has always looked right in a table. The icon is centred by bootstrap-icons'
`vertical-align: -.125em`, the same way it is in running text.
Nothing here sets `line-height`: the pill takes the surface's, so it matches
the row it sits in rather than being a second type size. */
.yksa-state {
display: inline-block;
font-size: .75rem;
font-weight: 700;
padding: .05rem .4rem;
white-space: nowrap;
border: 1px solid transparent;
}
.yksa-state > .bi,
.yksa-chip > .bi {
margin-right: .25rem;
}
.yksa-state-ok,
.yksa-state-complete {
color: var(--bs-success-text-emphasis);
background: var(--bs-success-bg-subtle);
border-color: var(--bs-success-border-subtle);
}
.yksa-state-warning,
.yksa-state-forced {
color: var(--bs-warning-text-emphasis);
background: var(--bs-warning-bg-subtle);
border-color: var(--bs-warning-border-subtle);
}
.yksa-state-failed {
color: var(--bs-danger-text-emphasis);
background: var(--bs-danger-bg-subtle);
border-color: var(--bs-danger-border-subtle);
}
.yksa-state-running,
.yksa-state-in-progress {
color: var(--bs-primary-text-emphasis);
background: var(--bs-primary-bg-subtle);
border-color: var(--bs-primary-border-subtle);
}
.yksa-state-skipped,
.yksa-state-unknown,
.yksa-state-not-started {
color: var(--bs-secondary-color);
background: var(--bs-secondary-bg-subtle);
border-color: var(--bs-border-color);
}
/* Neutral metadata chip. Not a coloured badge, because it carries no status.
Same box model as .yksa-state, for the same reason. */
.yksa-chip {
display: inline-block;
font-size: .75rem;
padding: 0 .35rem;
white-space: nowrap;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
}
/* A borderless icon button for dense rows: a table row's remove control, an
overflow `...` toggle, a field's clear. Bordered buttons in those places draw a
box around every row and the boxes become the pattern the eye follows.
This exists because the alternative was being spelled out by hand, differently,
in five templates -- `btn btn-sm btn-link p-0 text-body-secondary`, sometimes
with `border-0`, sometimes with `px-1`, once with an `fs-5` on the glyph. It is
a button, not a link: `.btn-link` is reserved for navigation, and a control
styled as a link but wired to a POST invites a middle-click that silently
does nothing. */
.yksa-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
min-height: 1.75rem;
padding: 0 .25rem;
color: var(--bs-secondary-color);
background: none;
border: none;
line-height: 1;
}
.yksa-btn-icon:hover,
.yksa-btn-icon:focus-visible {
color: var(--bs-body-color);
background: var(--yksa-hover-bg);
}
.yksa-btn-icon.is-danger:hover,
.yksa-btn-icon.is-danger:focus-visible {
color: var(--bs-danger);
}
.yksa-label {
font-size: .75rem;
font-weight: 700;
color: var(--bs-secondary-color);
}
.yksa-hint {
font-size: .75rem;
font-weight: 400;
color: var(--bs-secondary-color);
}
/* Separator between items in a run of inline metadata: a hairline rule, not a
character. A middot at .75rem is hard to tell from a decimal point or a full
stop, it is announced by screen readers, and it lands in the clipboard when the
line is copied. This is the inline-text counterpart to Bootstrap's `.vr`, which
is built for flex rows and has no margins of its own. */
.yksa-sep {
display: inline-block;
width: 1px;
height: 0.9em;
margin-inline: 0.5rem;
vertical-align: -0.1em;
background-color: var(--bs-border-color);
}
/* Inside an already-tight run (a card header, a table cell), the default margin
is too generous. */
.yksa-sep-tight {
margin-inline: 0.3rem;
}
/* A run of metadata separated by rules. Use this rather than dropping bare
`.yksa-sep`s between text nodes.
The margins above are symmetric, but the *source* around them is not: a
template that puts the separator at the start of a line contributes a collapsed
space on its left and none on its right, so the rule sits visibly off-centre
between the two items it divides. Nobody sees that while writing the template
and everybody sees it on the page.
A flex container fixes it at the root: whitespace-only text nodes never become
flex items, so indentation stops existing, and the gap is the only spacing.
Each item goes in its own element -- an unwrapped text node *would* become an
anonymous flex item, and then the whole run is one item and the gap does
nothing. That is the reason for the spans. */
.yksa-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 .5rem;
}
.yksa-meta-tight {
gap: 0 .3rem;
}
/* Inside the run the gap does the spacing; the rule's own margins would double it. */
.yksa-meta > .yksa-sep {
margin-inline: 0;
}
/* Tabular numerals in the body face. Monospace is reserved for verbatim machine
text -- hex dumps, TLE lines, command mnemonics. */
.yksa-num {
font-variant-numeric: tabular-nums;
}
.yksa-readout {
font-variant-numeric: tabular-nums;
font-weight: 700;
line-height: 1;
}
.yksa-utc {
font-variant-numeric: tabular-nums;
}
.yksa-utc .yksa-local {
color: var(--bs-secondary-color);
font-size: .8em;
}
/* A container that supplies its own padding must not also inherit the trailing
margin of whatever it ends with. Bootstrap does this for `p` in a few places
and not at all for `dl`, `ul` or `table`, so the fix was being applied by hand
-- an `mb-0` on the last element of every card body -- which every element
added later then has to remember. It is one rule and it belongs here.
Scoped to direct children: anything deeper is inside something else's box, and
that box's spacing is its own business. */
.card-body > :last-child,
.alert > :last-child,
.list-group-item > :last-child,
.modal-body > :last-child,
.offcanvas-body > :last-child {
margin-bottom: 0;
}
/* A definition list ends with a `dd`, which carries a bottom margin of its own.
`mb-0` on the `dl` does not reach it, so the gap survives the very utility
applied to remove it -- which is how several fact lists ended up looking
bottom-heavy inside an otherwise even card. */
dl:last-child > dd:last-child {
margin-bottom: 0;
}
/* Alerts here paint in solid colour, not Bootstrap's default tinted panel: a
utility text colour computed against the page ground is unreadable on a
saturated one, and an outline button drawn in `--bs-secondary` all but
vanishes -- hence the overrides below.
Muted text inside an alert is the alert's own colour, softened. All three of
these resolve to --bs-secondary-color, which is computed against the page. */
.alert .text-body-secondary,
.alert .yksa-label,
.alert .yksa-hint {
color: currentColor !important;
opacity: .8;
}
/* Outline buttons take their colour from the alert rather than from the palette,
and invert to the alert's ground on hover. The semantic distinction between
`-secondary` and `-danger` is lost here, which is correct: the alert already
says which of those it is, and a red outline on a red ground says nothing.
Every value comes from `--bs-alert-color` / `--bs-alert-bg`, which the alert
variant declares and the button inherits, so one rule covers all six variants
in both text directions. Not `currentColor`: inside `:hover` that resolves to
the *hover* colour, so a hover background of `currentColor` paints the button
the colour it is about to be and the hover state disappears. */
.alert .btn-outline-primary,
.alert .btn-outline-secondary,
.alert .btn-outline-success,
.alert .btn-outline-danger,
.alert .btn-outline-warning {
--bs-btn-color: var(--bs-alert-color);
--bs-btn-border-color: var(--bs-alert-color);
--bs-btn-hover-color: var(--bs-alert-bg);
--bs-btn-hover-bg: var(--bs-alert-color);
--bs-btn-hover-border-color: var(--bs-alert-color);
--bs-btn-active-color: var(--bs-alert-bg);
--bs-btn-active-bg: var(--bs-alert-color);
--bs-btn-active-border-color: var(--bs-alert-color);
--bs-btn-disabled-color: var(--bs-alert-color);
--bs-btn-disabled-border-color: var(--bs-alert-color);
--bs-btn-focus-shadow-rgb: 255, 255, 255;
}
/* A filled button on a solid alert is the alert's own colour inverted -- the same
pair, the other way round. `btn-light` was doing this by hand and only happened
to be right on the four dark variants. */
.alert .btn-contrast {
--bs-btn-color: var(--bs-alert-bg);
--bs-btn-bg: var(--bs-alert-color);
--bs-btn-border-color: var(--bs-alert-color);
--bs-btn-hover-color: var(--bs-alert-bg);
--bs-btn-hover-bg: var(--bs-alert-color);
--bs-btn-hover-border-color: var(--bs-alert-color);
--bs-btn-active-color: var(--bs-alert-bg);
--bs-btn-active-bg: var(--bs-alert-color);
--bs-btn-active-border-color: var(--bs-alert-color);
--bs-btn-focus-shadow-rgb: 255, 255, 255;
opacity: 1;
}
.alert .btn-contrast:hover {
opacity: .9;
}
/* A record in a list is a row with a left status marker, not a card. */
.yksa-row {
display: flex;
align-items: center;
gap: .75rem;
padding: .5rem;
border-left: 4px solid var(--bs-border-color);
}
.yksa-row:hover {
background: var(--yksa-hover-bg);
}
.yksa-row.is-in-progress { border-left-color: var(--bs-primary); }
.yksa-row.is-complete { border-left-color: var(--bs-success); }
.yksa-row.is-pending { border-left-color: var(--bs-warning); }
.yksa-row.is-failed { border-left-color: var(--bs-danger); }
/* Withdrawn/skipped: still a record, but nothing here is work. The border
stays the resting grey and the row's text steps back, which is the same
thing `skipped` says in the state vocabulary. */
.yksa-row.is-skipped { color: var(--bs-secondary-color); }
/* A table row that *is* a record: the row is the link to it. Sanctioned where a
card's equivalent is not -- a row is one target, while a card is a container
of several. `.stretched-link` goes on the cell that names the record; this
class is only here to give that anchor something to stretch against.
Sibling links in the row are lifted above it, the same lift `.card-footer`
needed and for the same reason: the stretched anchor covers the whole row, so
without this a press on the station's own link silently opens the record
instead. A row with no sibling links needs no lift and pays nothing for it. */
.yksa-row-link {
position: relative;
}
.yksa-row-link a:not(.stretched-link) {
position: relative;
z-index: 2;
}
/* The one emphasised element allowed on a surface: the active shift, the pass in
contact, the source currently polling. Two on a page means one is wrong.
It is a heavier border in the accent colour, not a tint. A row already carries
a coloured left border and usually a state pill; adding a filled ground makes
three ways of saying one thing, and the strongest of the three drowns the row's
own content. Emphasis is the *weight* of the existing signal, not a new one. */
.yksa-row.is-active {
border-left-color: var(--bs-success);
border-left-width: 6px;
padding-left: calc(.5rem - 2px);
}
/* Catalog card: a card summarising one record. The card is a container, not a
control: its title is the link and nothing else in it moves under the pointer.
Two earlier versions made the whole card hot -- one recoloured the border and
title, one painted the hover ground -- and both put a card-sized reaction
behind a card-sized target that also held its own footer buttons, so the page
read as a grid of giant buttons and a press near a footer control was a coin
toss. Hover is reserved for rows, which are one target each.
`position: relative` stays so a card can still host absolutely-positioned
corner chrome; it no longer backs a .stretched-link. */
.yksa-card-link {
position: relative;
}
/* The title is a link and says so in the link colour, the way odms has always
drawn it. `.text-decoration-none` on the anchor suppresses the underline at
rest so a grid of forty titles is not forty underlines; hover brings it back,
which is this card's one hover affordance. Both rules have to out-specify that
utility, hence the element-qualified selectors. */
.yksa-card-link .card-title a,
.yksa-card-link h2 a,
.yksa-card-link h3 a {
color: var(--bs-link-color);
}
.yksa-card-link .card-title a:hover,
.yksa-card-link h2 a:hover,
.yksa-card-link h3 a:hover,
.yksa-card-link .card-title a:focus-visible,
.yksa-card-link h2 a:focus-visible,
.yksa-card-link h3 a:focus-visible {
color: var(--bs-link-hover-color);
text-decoration: underline;
}
/* Row chrome that appears under the pointer: the retract on one entry of a long
append-only stream, where forty always-visible trash icons would be the loudest
thing on the page. It keeps its box at rest, so revealing it never reflows the
row. Focus counts as pointing -- without that clause the control exists only
for mouse users. */
.yksa-reveal {
opacity: 0;
}
.yksa-reveal-host:hover .yksa-reveal,
.yksa-reveal:focus-visible,
.yksa-reveal-host:focus-within .yksa-reveal {
opacity: 1;
}
/* The fact grid inside a catalog card: two columns of `.yksa-label` + figure.
It was `.meta-col` in odms.css, defined against `.sat-row-card`, which is why
ops built its own with a `dl.row` instead and got a different rhythm and a
stray bottom margin. One card, one grid. */
.yksa-card-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .25rem .75rem;
}
/* Empty state: a sentence plus, where there is one, the action that resolves
it -- beside the sentence, not under it. Never a blank card. */
.yksa-empty {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .75rem;
padding: 1.25rem;
color: var(--bs-secondary-color);
}
/* Reading column for forms and reference pages that are worse for being wide. */
.yksa-narrow {
max-width: 920px;
}
/* Save bar pinned to the viewport bottom, plus a spacer so it never covers the
last row of the form it belongs to. */
.yksa-actionbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: var(--yksa-z-chrome);
}
.yksa-actionbar-spacer {
height: 5rem;
}
.form-error-bar ul {
margin-bottom: 0;
}
.cookie-banner {
position: sticky;
bottom: 0;
left: 0;
right: 0;
z-index: var(--yksa-z-chrome);
background-color: #1f2937;
color: #f8fafc;
}
.cookie-banner a {
color: #bfdbfe;
}
@media (max-width: 991px) {
/* Collapsed, the nav is a stacked list; the full-height tab treatment would
put a border around every row. */
.nav-full-height.nav-link {
padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);
margin-right: 0;
border: none !important;
}
/* The bar grows here (the brand block keeps its height and the padding is
added around it), and --navbar-height has to grow with it: it is what
every in-page sticky bar offsets itself by, and a stale 44px slid ops's
pass console header up under the navbar on tablet widths.
61px is the same arithmetic that produces 44px above -- 34px logo + the
brand block's .3125rem padding either side -- plus .5rem of bar padding
either side and the 1px bottom border. min-height, not height, so a taller
brand overflows the offset rather than being clipped by it. */
:root {
--navbar-height: 61px;
}
.custom-navbar {
height: auto;
min-height: var(--navbar-height);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
}
/* A filter chip is a toggle, so it looks like one at both ends of its state: an
outlined chip at rest, a filled one when it is on. It used to be `badge
bg-secondary` at rest, which made an unselected filter a solid grey block --
as loud as the selected one and easy to read as already applied. The class is
self-contained now; callers need no `badge bg-*` alongside it. */
.filter-chip {
display: inline-block;
font-size: .75rem;
line-height: 1.5;
padding: .05rem .5rem;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
cursor: pointer;
user-select: none;
transition: background-color 0.12s ease;
}
.filter-chip:hover {
background-color: var(--yksa-hover-bg);
}
.filter-chip.active {
color: #fff;
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
.filter-chip.active:hover {
background-color: var(--bs-primary);
}
/* Long-form text rendered from the database (policies, descriptions) and the
table cells that hold it. */
.text-content {
overflow: auto;
hyphens: auto;
}
.text-content img {
max-width: 100%;
height: auto !important;
}
.text-content p:last-child,
.text-content ul:last-child,
.text-content ol:last-child,
td p:last-child,
td ul:last-child {
margin-bottom: 0 !important;
}
/* Django's messages framework emits `error`; Bootstrap only ships `danger`. */
.alert-error {
--bs-alert-color: #fff;
--bs-alert-bg: #c42526;
--bs-alert-border-color: #c42526;
}
/* A quiet border for grouped buttons that sit inside another bordered surface --
a catalog card's footer -- where the button group's own outline would read as a
second box around the first. `--bs-border-color` rather than a black alpha: the
alpha approximated exactly this colour on white and disappeared on anything else. */
.btn-outline-bordered {
border-color: var(--bs-border-color);
}