/* Palette, defined once for every page.

   One theme, light. There was a dark one and a toggle between them; the
   redesign dropped both. Every colour in the app still flows through these
   tokens and nothing else names a colour, so a second theme comes back as
   one more block that redefines the same names - `:root[data-theme="dark"]`
   - and no rule anywhere else has to change. Keep it that way: a literal
   colour in a component is the thing that would make that untrue.

   Every text/background pair the pages can produce was measured against
   WCAG AA on all three content grounds - ground, panel and band - rather
   than assumed: 4.5:1 for text, 3:1 for the status chips and bars. The
   tightest is --ink-dim on --panel-hi at 4.74. */

:root {
  color-scheme: light;

  /* The content area: a warm off-white ground, white panels on it, and a
     band a shade darker for alternate rows and hover. */
  --ground: #faf8f5;
  --panel: #ffffff;
  --panel-hi: #f1ede7;
  --rule: #ded5c7;
  --rule-hi: #b9ae9b;

  /* Dark slate, not black. */
  --ink: #39424e;
  --ink-dim: #616977;

  --ok: #23784b;
  --late: #c33328;
  --reboot: #8a6119;
  --unknown: #55637a;

  /* Chip fills. A solid block carrying text, so each has to clear 4.5:1
     against that text AND 3:1 against the ground behind it, or it stops
     being a status anybody can read at a glance. White ink on darker fills:
     a bright fill with dark ink washes out on a near-white ground.

     Measured, and three of the four originally asked-for values did not
     pass: #C26A08 gave 3.92 and #5E8F1E gave 3.87 against white, so both
     are a shade darker here. The unknown chip failed hardest - #DCD8D3 with
     #6E6E6E text is 3.59 against its own text and 1.40 against the panel,
     a block you cannot see. The same two colours the other way round pass,
     and grey still reads as quiet. Against the band the lowest is
     chip-reboot at 3.87. */
  --chip-ok: #56821b;
  --chip-late: #c4342a;
  --chip-reboot: #b46104;
  --chip-unknown: #6e6e6e;
  --chip-ink: #ffffff;
  --late-wash: #f9e9e3;
  --ok-wash: #e9f0e7;

  --accent: #3b5f80;

  /* Floating things - the row menu, the phone sheet. A shadow tuned for a
     light ground; a dark one reads as dirt rather than depth. */
  --shadow-float: 0 10px 24px rgba(57, 66, 78, 0.16);

  /* Interaction. The focus ring is the accent: 5.7:1 or better against every
     light ground (ground, panel, band, the washes) and distinct from the
     ink, so a ring is never mistaken for a border. Hover and press are two
     steps darker than the band, each still 7:1 under the ink. */
  --focus: #3b5f80;
  --hover: #e8e2d9;
  --press: #ddd5c8;
  --late-press: #f1d5cb;

  /* The sidebar is the frame, not the page, and is dark whatever the
     content's palette. Its own tokens, so a dark content theme later does
     not have to remember that the frame was never part of it.
     Measured on --side and on --side-hi (the current item): ink 13.9 and
     11.5, dim 6.7 and 5.6, and the orange edge 6.9 and 5.7 - a 3:1 floor
     for a mark that is not text. */
  --side: #1d1d1d;
  --side-hi: #2c2c2c;
  --side-hover: #262626;
  --side-rule: #333333;
  --side-ink: #ece9e4;
  --side-dim: #a9a39b;
  --side-mark: #f28c28;
  /* Status on the frame's dark ground - the overview's header on a phone.
     The content chips clear 3:1 on --side but not on --side-hi, which is the
     ring's track and what a gap between segments shows, so the dark ground
     gets its own. Measured on --side / --side-hi: ok 6.8 / 5.6, late
     5.0 / 4.2, unknown 6.0 / 5.0. */
  --side-ok: #7fb34a;
  --side-late: #e0675d;
  --side-unknown: #9a9a9a;
  --side-width: 212px;

  /* Captured job output: a terminal's dark ground, whatever the content
     palette. Measured: ink 12.5:1, dim 6.2:1. */
  --out: #1f2226;
  --out-ink: #e6e3de;
  --out-dim: #a6a19a;

  --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;

  /* --- the scales ------------------------------------------------------

     Three ladders, so a new page picks a rung rather than a number. The
     values are the ones already in use across the app: this named what was
     there rather than retuning everything, so nothing moved when the pages
     were brought onto it.

     Type. `display` is the dashboard headline and the only fluid one -
     everything else is fixed, because a 13px label that grows with the
     viewport is a label that disagrees with the one beside it. */
  --t-display: clamp(44px, 15vw, 60px);
  --t-title: 19px;      /* page title */
  --t-head: 15.5px;     /* card title, a row's name */
  --t-lead: 14.5px;     /* a row you are meant to read, not scan */
  --t-body: 13.5px;     /* prose */
  --t-ui: 13px;         /* buttons, form fields, table cells */
  --t-dense: 12.5px;    /* secondary line under a value */
  --t-small: 12px;      /* monospace cells, captions */
  --t-micro: 11.5px;    /* pills, legends, the smallest thing allowed */

  /* Space. A 4px grid; the gaps between are not on it because they were
     tuned by eye against real content and moving them changes every page. */
  --s-1: 4px;
  --s-2: 8px;
  --s-3: 12px;
  --s-4: 16px;
  --s-5: 22px;
  --s-6: 28px;
  --s-7: 44px;

  /* Radii. Four, and the pill. A fifth value means two things that should
     match no longer do. */
  --r-sm: 6px;          /* buttons, fields, notices */
  --r-md: 8px;          /* cards, panels */
  --r-lg: 10px;         /* list rows, table-cards */
  --r-xl: 12px;         /* the sheet, anything floating */
  --r-pill: 99px;

  /* The one touch dimension, so nothing has to remember the number. */
  --tap: 44px;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}

/* --- one interface, narrower ---------------------------------------------

   Everything below is layout, shared by every page because every page links
   this file. There is no mobile build and no second template: the same DOM,
   laid out differently.

   Two separate questions, deliberately two separate media features:

     (max-width: N)    what shape the page should be
     (pointer: coarse) how big a thing has to be to hit with a thumb

   They are not the same question. A touchscreen laptop at 1400px needs the
   targets without the single-column layout, and a narrow window on a desktop
   needs the layout without the targets. Conflating them gets both wrong. */

/* --- tables become cards -------------------------------------------------

   A table with eight columns cannot be made to work at 390px; it can only be
   made to scroll, which is the thing we are trying to avoid. So below 860px -
   where the sidebar gives way to the bottom bar, one breakpoint for the app -
   the same rows are laid out as cards - same DOM, same builder, same patching
   path, no second render. Each cell carries its own heading in `data-label`,
   set where the cell is created, so the header row can simply go away.

   `td:empty` disappears: a card with a blank "Last error" line reads worse
   than one without it, and the table could not make that choice. */
@media (max-width: 860px) {
  table[data-cards],
  table[data-cards] > tbody { display: block; width: 100%; }
  /* The rows are grid boxes now, not table cells - collapsed borders would
     have them share edges with neighbours that are no longer beside them. */
  table[data-cards] { border-collapse: separate; }
  table[data-cards] > colgroup,
  table[data-cards] > thead { display: none; }

  /* The same surface as .card, deliberately: a host at 390px on /hosts and
     the same host on the overview are the same object, and were two
     different-looking blocks for as long as each page drew its own. Radius,
     border and background all come from the card, so changing the card
     changes both. */
  table[data-cards] > tbody > tr {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: center;
    gap: 0 10px;
    margin-bottom: 10px;
    padding: 12px 14px;
    background: var(--panel);
    border: 1px solid var(--rule);
    border-radius: var(--r-md);
  }
  /* And the state reads the same way it does on a card: a bar down the
     leading edge, never a colour on its own - the state is in words in the
     row as well. */
  table[data-cards] > tbody > tr.outdated { border-left: 4px solid var(--late); }
  table[data-cards] > tbody > tr.reboot   { border-left: 4px solid var(--reboot); }
  table[data-cards] > tbody > tr.ok       { border-left: 4px solid var(--ok); }
  table[data-cards] > tbody > tr.unknown  { border-left: 4px solid var(--unknown); }

  table[data-cards] td {
    display: block;
    grid-column: 1 / -1;
    border: 0;
    padding: 4px 0;
    text-align: left;
    white-space: normal;
    overflow: visible;
    overflow-wrap: anywhere;
  }

  /* A control in a stacked cell takes the card's width rather than its own
     intrinsic one - the OS family picker's longest option is "RHEL / Rocky /
     Alma (dnf)", which is wider than a 390px card and would push the row
     sideways. `min-width: 0` because a select's default minimum content size
     ignores the grid track otherwise. */
  table[data-cards] td select,
  table[data-cards] td input:not([type="checkbox"]) {
    max-width: 100%;
    min-width: 0;
  }

  /* The checkbox and the name share the top line; everything else stacks. */
  table[data-cards] td.pick {
    grid-column: 1; grid-row: 1;
    width: auto; padding: 0;
    display: flex; align-items: center; justify-content: center;
  }
  table[data-cards] td.cardtitle {
    grid-column: 2; grid-row: 1;
    font-size: 15.5px; font-weight: 600; padding-bottom: 6px;
  }

  /* Term and value, not a wall of unlabelled strings. */
  table[data-cards] td[data-label] {
    display: grid;
    grid-template-columns: 94px minmax(0, 1fr);
    gap: 10px;
    align-items: baseline;
    font-size: 13px;
  }
  table[data-cards] td[data-label]::before {
    content: attr(data-label);
    color: var(--ink-dim);
    font-size: 12px;
  }
  /* Everything real belongs in the value column, stacked. Without this the
     second child of a cell - the "saw Windows" note under a dropdown - lands
     back under the heading, in the wrong column. */
  table[data-cards] td[data-label] > * {
    grid-column: 2;
    justify-self: start;
    max-width: 100%;
  }
  /* A card with a blank "Last error" line reads worse than one without it.
     This has to come after the rules above, which are equally specific. */
  table[data-cards] td:empty { display: none; }

  /* Address and error are nowrap-and-truncate in a table because a column is
     a fixed width. A card is not, so they simply say what they say - which
     also retires a title= nobody can hover on a phone. */
  table[data-cards] td.addr, table[data-cards] td.err {
    white-space: normal; overflow: visible; text-overflow: clip;
  }
  table[data-cards] td.err { color: var(--late); }
  table[data-cards] td.num { font-size: var(--t-ui); }
  table[data-cards] > tbody > tr.suspended { border-style: dashed; }
  table[data-cards] > tbody > tr.suspended td:not(.actions) { color: var(--ink-dim); }
  table[data-cards] td.cardtitle span { display: inline-block; padding: 4px 0; }
  table[data-cards] td.cardtitle input[type="text"] { width: 100% !important; }

  table[data-cards] td.actions {
    grid-column: 1 / -1;
    padding-top: 10px; margin-top: 6px;
    border-top: 1px solid var(--rule);
  }
  table[data-cards] td.actions .actionwrap {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 8px;
    justify-content: stretch;
  }
  table[data-cards] td.actions .actionwrap button {
    width: 100%; font-size: 13px; padding: 10px 8px;
  }
}


/* ==========================================================================
   Components
   ==========================================================================

   The visual language, defined once. Every page links this file, so a card on
   /patches is the same object as a card on the dashboard - not a similar one
   maintained separately.

   This replaces an earlier arrangement where each page carried its own copy
   of the same rules inline. With one page that was reasonable; by eight it
   meant 548 duplicated declarations, a `.pill` that was 11px on six pages and
   11.5px on a seventh, and a button that could only be restyled everywhere by
   being edited everywhere.

   A page's own <style> block is for what is genuinely that page's: column
   widths, how dense its table is, the shape of one control it alone has. If a
   page needs a card, a button, a pill or a row, it uses these. */

/* --- page frame --------------------------------------------------------- */

.wrap {
  max-width: 1240px;
  margin: 0 auto;
  padding: var(--s-6) var(--s-5) 64px;
}
/* Tables need the room; a settings form reads badly at full width. Both are
   the same frame with a different ceiling, not a different frame. */
.wrap.wide { max-width: 1400px; }
.wrap.narrow { max-width: 820px; }

/* --- the page header bar -----------------------------------------------

   Title with its one-line count under it on the left, the page's own
   actions on the right, and - where one sidebar entry covers several pages
   - the tabs between them along the bottom. Navigation is not in here any
   more: it is the sidebar's. */
header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3) var(--s-4);
  padding-bottom: var(--s-3);
  border-bottom: 1px solid var(--rule);
}
.pagetitle {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; gap: 2px;
}
.pageactions {
  flex: 0 0 auto; margin-left: auto;
  display: flex; flex-wrap: wrap; gap: var(--s-2); justify-content: flex-end;
}
/* Log out arrives here in each page's markup, because each page's script
   binds it, and session.js moves it to the foot of the sidebar. Hidden
   while it is still here so it never flashes in the bar first. */
.pageactions #logout { display: none; }
.pageactions:not(:has(> :not(#logout))) { display: none; }

/* Sibling pages behind one sidebar entry. Links, not a tablist: each is a
   page of its own with its own URL, and a tablist would promise arrow-key
   switching between panels that are not on this page. */
.subnav {
  flex: 1 0 100%;
  display: flex; flex-wrap: wrap; gap: var(--s-1) var(--s-5);
  margin-bottom: calc(-1 * var(--s-3) - 1px);
}
.subtab {
  display: inline-flex; align-items: center; min-height: 36px;
  font-size: var(--t-ui); color: var(--ink-dim); text-decoration: none;
  border-bottom: 2px solid transparent;
}
.subtab:hover { color: var(--ink); }
.subtab[aria-current="page"] { color: var(--ink); font-weight: 600;
  border-bottom-color: var(--ink); }
.subtab:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

h1 {
  font-size: var(--t-title);
  font-weight: 600;
  margin: 0;
  letter-spacing: -0.01em;
}
/* A section heading inside a page. /discover and /settings each carried
   their own at 15px while /history used this at 14px - one heading, two
   sizes, which is the drift this file exists to stop. */
h2 {
  font-size: var(--t-head);
  font-weight: 600;
  margin: var(--s-6) 0 var(--s-1);
  letter-spacing: -0.01em;
}
/* Inside a <section> the heading starts the section, so it does not also
   need the space above it. */
section > h2 { margin-top: 0; }
.meta { font-size: var(--t-ui); color: var(--ink-dim); }
.meta:empty::before { content: "\00a0"; }
.spacer { flex: 1; }

/* A text link inside a page's content ("see all held snapshots"). */
.navlink {
  font-size: var(--t-ui);
  color: var(--ink-dim);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  padding-bottom: 1px;
}
.navlink:hover { color: var(--ink); border-bottom-color: var(--rule); }
.navlink:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }

/* --- the sidebar --------------------------------------------------------

   Rendered by session.js on every signed-in page from one model. Fixed to
   the left edge at full height; the content moves over by its width. Dark
   whatever the content's palette, because it is the frame, not the page.

   It is a column of three parts and only the middle one scrolls: on a short
   window the groups scroll while the wordmark, the account and the way out
   stay put - the same rule as the phone sheet, for the same reason. */
.sidebar {
  position: fixed; top: 0; bottom: 0; left: 0; z-index: 20;
  width: var(--side-width);
  display: flex; flex-direction: column; min-height: 0;
  background: var(--side); color: var(--side-ink);
  font-size: var(--t-ui);
}
.sidetop { flex: 0 0 auto; padding: var(--s-5) var(--s-5) var(--s-4); }
.wordmark {
  color: var(--side-ink); text-decoration: none;
  font-size: 17px; font-weight: 650; letter-spacing: -0.01em;
}
.wordmark:focus-visible { outline: 2px solid var(--side-ink); outline-offset: 3px; }
.sidescroll {
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto; overscroll-behavior: contain;
  padding-bottom: var(--s-3);
}
.sidegroup { margin-top: var(--s-4); }
.sidegroup:first-child { margin-top: 0; }
.sidegrouptitle {
  padding: var(--s-1) var(--s-5);
  font-size: var(--t-micro); font-weight: 600; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--side-dim);
}
/* The current page: a 3px orange edge and a lighter ground. The edge is an
   inset shadow rather than a border so the label does not move by 3px when
   it becomes current. */
.sideitem {
  display: flex; align-items: center; min-height: 36px;
  padding: 0 var(--s-5);
  color: var(--side-ink); text-decoration: none;
}
.sideitem:hover { background: var(--side-hover); }
.sideitem[aria-current="page"] {
  background: var(--side-hi);
  box-shadow: inset 3px 0 0 var(--side-mark);
  font-weight: 600;
}
.sideitem:focus-visible,
.sidesmall:focus-visible { outline: 2px solid var(--side-ink); outline-offset: -2px; }

.sidefoot {
  flex: 0 0 auto;
  display: flex; flex-direction: column; gap: 2px;
  padding: var(--s-3) var(--s-5) var(--s-4);
  border-top: 1px solid var(--side-rule);
}
.sidewho { display: flex; flex-direction: column; margin-bottom: var(--s-1); }
.sidename { color: var(--side-ink); font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.siderole, .sideversion { color: var(--side-dim); font-size: var(--t-small); }
.sideversion { margin-top: var(--s-2); font-family: var(--mono); }
/* Change password and Log out: one a link, one the page's own button, and
   they must look like one list. */
.sidesmall {
  display: flex; align-items: center; min-height: 30px; width: 100%;
  padding: 0; margin: 0;
  background: transparent; border: 0; border-radius: 0;
  color: var(--side-dim); text-decoration: none; text-align: left;
  font: inherit; font-size: var(--t-dense); cursor: pointer;
}
.sidesmall:hover, .sidesmall[aria-current="page"] {
  color: var(--side-ink); background: transparent; }

/* Above the phone breakpoint the content moves over by the sidebar's
   width. Below it the sidebar is gone and the bottom bar is the
   navigation - one or the other, never both: two navigations on one screen
   disagree with each other eventually. */
@media (min-width: 861px) {
  body.hasnav { padding-left: var(--side-width); }
}
@media (max-width: 860px) {
  .sidebar { display: none; }
}

/* Bounded for the same reason the phone sheet is, before it becomes the same
   bug. It hangs below its trigger rather than upward from the bottom, so it
   runs off the *bottom* edge - and at 1024x560 it already ends 10px from it,
   with ten items. One more administrative page and it would have been the
   second instance of this, found the same way.

   The cap is the space between the trigger and the bottom of the window,
   less a margin. `position: absolute` means the page could in principle be
   scrolled to reach an overhang, but only if the page is tall enough to
   scroll at all - which is not something a menu should depend on. */
.menupanel {
  position: absolute; right: 0; top: calc(100% + 6px); z-index: 40;
  min-width: 220px; padding: 6px;
  max-height: calc(100vh - 100px);
  max-height: calc(100dvh - 100px);
  overflow-y: auto; overscroll-behavior: contain;
  background: var(--panel); border: 1px solid var(--rule-hi);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-float);
}
.menupanel[hidden] { display: none; }

.menuitem {
  display: block; width: 100%; text-align: left;
  padding: 7px 10px; border-radius: var(--r-sm);
  font: inherit; font-size: var(--t-ui); color: var(--ink);
  text-decoration: none; background: transparent; border: 0; cursor: pointer;
}
.menuitem:hover { background: var(--panel-hi); border-color: transparent; }
.menuitem:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.menuitem[aria-current="page"] {
  color: var(--ink); font-weight: 600; background: var(--panel-hi);
}

/* The label over a set of related items. Not a heading anybody can focus:
   the arrow keys move between destinations, not past captions. */
.menugroup {
  padding: 9px 10px 4px;
  font-size: var(--t-micro); font-weight: 600; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--ink-dim);
}
.menugroup:first-child { padding-top: 4px; }
/* A rule above every group but the first, which is what separates them. */
.menuitem + .menugroup {
  margin-top: 5px; border-top: 1px solid var(--rule); padding-top: 9px;
}

/* A section heading inside a page: quieter than h2, and it names a group of
   rows rather than starting a new subject. */
.sectionhead {
  margin: var(--s-5) 0 var(--s-2);
  font-size: var(--t-dense);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--ink-dim);
}

/* --- focus, hover, pressed ---------------------------------------------

   Every interactive element has a focus ring that clears 3:1 against what is
   around it, every row and button answers a hover, and every button shows
   that it is being pressed. The ring is a floor at zero specificity, so a
   component that draws its own (inset, on a dark ground) still can; what it
   may not do is draw none. tests/responsive/focus.js tabs through every page
   and measures each ring against the colour beside it.

   Colour, not movement: a pressed button darkens, it does not shift, so
   nothing here needs to wait for the reduced-motion check.

   Anything at all: Chromium makes a scrolling container focusable without a
   tabindex (the history chart), and a list of selectors misses exactly that. */
:where(:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
/* On the dark frame the ring is the frame's ink. */
:where(.sidebar, .ringtip) :focus-visible { outline-color: var(--side-ink); }

/* --- buttons ------------------------------------------------------------

   One shape. The variants say what a button is for, never how big it is:
   size comes from where it sits (a table row, a card, a toolbar), and that
   is what `.row` and `.cardbtn` are for. */

button, .btn {
  font: inherit;
  font-size: var(--t-ui);
  background: var(--panel-hi);
  color: var(--ink);
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  padding: 6px 14px;
  cursor: pointer;
}
button:hover:not([disabled]), .btn:hover { border-color: var(--rule-hi); background: var(--hover); }
button:active:not([disabled]), .btn:active {
  background: var(--press); border-color: var(--rule-hi);
  box-shadow: inset 0 1px 2px rgba(57, 66, 78, 0.18);
}
button:focus-visible, .btn:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
button[disabled], .btn[disabled] { opacity: 0.5; cursor: default; }
button[disabled]:hover { border-color: var(--rule); }

/* The action a screen exists for. One per view, or it stops meaning that. */
button.primary, .btn.primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--ground);
  font-weight: 550;
}
button.primary:hover:not([disabled]), .btn.primary:hover {
  border-color: var(--accent); background: var(--accent); filter: brightness(1.08);
}
button.primary:active:not([disabled]), .btn.primary:active {
  background: var(--accent); filter: brightness(0.86);
}

/* Outlined, never filled: a red block reads as an error message, and this is
   a button somebody has to choose to press. */
button.danger, .btn.danger {
  border-color: var(--late);
  color: var(--late);
  background: transparent;
}
button.danger:hover { border-color: var(--late); background: var(--late-wash); }
button.danger:active:not([disabled]) { background: var(--late-press); }

/* Present but not competing. */
button.secondary, .btn.secondary { background: transparent; color: var(--ink-dim); }
button.secondary:hover:not([disabled]) { color: var(--ink); background: var(--hover); }

/* In a table row or a dense list: smaller, and transparent so a row of them
   does not read as a wall. */
button.row {
  font-size: var(--t-micro);
  padding: 3px 8px;
  background: transparent;
}
button.row:hover:not([disabled]) { background: var(--hover); }

/* On a card, where a finger is the pointing device. */
.cardbtn {
  flex: 1 1 0;
  min-height: var(--tap);
  padding: 0 var(--s-3);
  font-size: var(--t-lead);
  font-weight: 550;
  border-radius: var(--r-md);
}

/* --- surfaces -----------------------------------------------------------

   `.card` is the object: a bordered, rounded block on the page background.
   `.panel` is a card that has been opened for a task - a form, a
   confirmation - and says so with the accent border. */

.card {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--rule);
  border-radius: var(--r-md);
}
.card:hover { border-color: var(--rule-hi); }

/* The state of the thing a card is about, as a bar down its leading edge.
   Colour only - the same fact is always in words on the card as well, so a
   card is never a colour somebody has to decode. */
.card.outdated { border-left: 4px solid var(--late); }
.card.reboot   { border-left: 4px solid var(--reboot); }
.card.ok       { border-left: 4px solid var(--ok); }
.card.unknown  { border-left: 4px solid var(--unknown); }

.panel {
  margin-top: var(--s-4);
  padding: var(--s-4) 18px;
  background: var(--panel);
  border: 1px solid var(--accent);
  border-radius: var(--r-md);
}
.panel h3 { margin: 0 0 var(--s-3); font-size: 14px; font-weight: 600; }

/* A question that must be answered before anything happens. */
.confirm {
  margin-top: 14px;
  padding: 13px 15px;
  background: var(--late-wash);
  border: 1px solid var(--late);
  border-radius: var(--r-sm);
}
.confirm p { margin: 0 0 11px; font-size: var(--t-body); }
.confirm .names { font-family: var(--mono); font-size: var(--t-small); margin-bottom: 11px; }
.confirm-actions { display: flex; gap: var(--s-2); }

/* --- list row -----------------------------------------------------------

   One line that stands for a set of things and taps through to them: "7 hosts
   up to date". A card is about one thing; this is about several. */

.listrow {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 52px;
  padding: 0 var(--s-3);
  background: var(--panel);
  border: 1px solid var(--rule);
  border-radius: var(--r-lg);
  color: var(--ink);
  text-decoration: none;
  font-size: var(--t-lead);
}
.listrow:hover { border-color: var(--rule-hi); }
.listrow:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.listrow .rowtext { flex: 1; }
.listrow .rowchev { color: var(--ink-dim); font-size: 17px; line-height: 1; }
.listrow .dot {
  width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto;
  background: var(--unknown);
}
.listrow.outdated .dot { background: var(--late); }
.listrow.reboot .dot   { background: var(--reboot); }
.listrow.ok .dot       { background: var(--ok); }
.listrow.unknown .dot  { background: var(--unknown); }

/* --- status pill --------------------------------------------------------

   The one way a state is named. Outlined rather than filled: a filled pill
   at 11px cannot hold 4.5:1 against its own text in both themes without a
   different foreground per state, and six of those is how palettes drift. */

.pill, .pstate {
  font-size: var(--t-micro);
  padding: 1px 8px;
  border-radius: var(--r-pill);
  border: 1px solid var(--rule);
  color: var(--ink-dim);
  white-space: nowrap;
  /* Centred because a pill is often the only thing in a grid cell, and a
     left-aligned word in a centred column is the kind of thing that reads as
     a mistake without anyone being able to say why. */
  text-align: center;
}
.pill.outdated, .pstate.outdated { border-color: var(--late); color: var(--late); }
.pill.reboot, .pstate.reboot     { border-color: var(--reboot); color: var(--reboot); }
.pill.ok, .pstate.ok             { border-color: var(--ok); color: var(--ok); }
.pill.unknown, .pstate.unknown   { border-color: var(--unknown); color: var(--unknown); }
.pill.suspended  { border-color: var(--ink-dim); color: var(--ink-dim); }
.pill.installing { border-color: var(--accent); color: var(--accent); }
.pill.new        { border-color: var(--ok); color: var(--ok); }
/* Worth a second look, not wrong - the same amber `.notice.warn` uses. */
.pill.warn       { border-color: var(--reboot); color: var(--reboot); }
/* Patch decisions use the same component: a decision is a status too. */
.pill.declined, .pstate.declined { border-color: var(--late); color: var(--late); }
.pill.approved, .pstate.approved { border-color: var(--ok); color: var(--ok); }
.pill.undecided, .pstate.undecided { border-color: var(--rule-hi); color: var(--ink-dim); }

/* Roles are not statuses, but they are the same object with a different set
   of modifiers, and making them a second component would be a second thing
   to keep in step. */
.rolepill {
  font-size: var(--t-micro); padding: 1px 8px; border-radius: var(--r-pill);
  border: 1px solid var(--accent); color: var(--accent); white-space: nowrap;
}
.rolepill.admin    { border-color: var(--late); color: var(--late); }
.rolepill.operator { border-color: var(--reboot); color: var(--reboot); }
.rolepill.approver { border-color: var(--ok); color: var(--ok); }
.rolepill.viewer   { border-color: var(--ink-dim); color: var(--ink-dim); }
/* No role: signed in and given nothing. Dashed, like anything else "not
   yet" - a colour and an edge, never a fade. */
.rolepill.none     { border-color: var(--ink-dim); border-style: dashed; color: var(--ink-dim); }

/* --- notices, empties, toolbars ----------------------------------------- */

.notice {
  margin-top: var(--s-3);
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-sm);
  font-size: var(--t-ui);
  border: 1px solid var(--rule);
  color: var(--ink-dim);
}
.notice.error { background: var(--late-wash); border-color: var(--late); color: var(--late); }
.notice.good  { background: var(--ok-wash); border-color: var(--ok); color: var(--ok); }
.notice.warn  { background: var(--late-wash); border-color: var(--reboot); color: var(--reboot); }
.notice[hidden] { display: none; }

.empty {
  color: var(--ink-dim);
  padding: var(--s-7) 0;
  text-align: center;
  font-size: var(--t-body);
}

.toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 18px;
  padding-bottom: var(--s-3);
  border-bottom: 1px solid var(--rule);
}
.toolbar label { font-size: var(--t-ui); color: var(--ink-dim); }

/* Explanatory prose. Measured, because a 1400px line of 12px text is not
   readable however correct it is. */
.why, .rolehelp {
  font-size: var(--t-dense);
  color: var(--ink-dim);
  line-height: 1.6;
  margin: var(--s-1) 0 var(--s-3);
  max-width: 78ch;
}
.why b, .rolehelp b { color: var(--ink); font-weight: 600; }

/* --- form fields -------------------------------------------------------- */

input[type="text"], input[type="password"], input[type="email"],
input[type="number"], select, textarea {
  font: inherit;
  font-size: var(--t-ui);
  background: var(--ground);
  color: var(--ink);
  border: 1px solid var(--rule);
  border-radius: var(--r-sm);
  padding: 6px 10px;
}
input:focus-visible, select:focus-visible, textarea:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; }
/* The hit area, drawn around the box rather than as it. A checkbox is 15px
   (22px on a touchscreen) and a 22px target is not a target. */
label.tap {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: var(--tap); min-height: var(--tap); cursor: pointer;
}
/* In a table cell the hit area is pulled back into the row, so a 44px target
   does not make every row 44px tall. Outside one it simply takes its space. */
td.pick > label.tap, th.pick > label.tap { margin: -11px; }

/* --- tables -------------------------------------------------------------

   The shared shape. Column widths and which cells are monospace belong to
   the page; that a table looks like this does not.

   table-layout: fixed with a colgroup so the header and the body share one
   explicit column model. With auto layout the widths are derived from
   content, so a nowrap header and a wrapping cell disagree about how wide a
   column is and the whole table grows past the viewport. */

table {
  width: 100%;
  table-layout: fixed;
  border-collapse: collapse;
  margin-top: 2px;
}
th {
  text-align: left;
  font-size: var(--t-micro);
  font-weight: 500;
  color: var(--ink-dim);
  padding: 9px 9px 7px;
  border-bottom: 1px solid var(--rule);
  overflow: hidden;
  text-overflow: ellipsis;
}
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--ink); }
/* Coloured, not faded: at 0.5 opacity the arrow was 2.0:1 and the only
   thing on the page saying which way the table is sorted. */
th .arrow { color: var(--ink-dim); font-size: 10px; margin-left: 3px; }
td {
  padding: 6px 9px;
  font-size: var(--t-ui);
  border-bottom: 1px solid var(--rule);
  vertical-align: middle;
  overflow-wrap: anywhere;
}
tbody tr:hover { background: var(--panel-hi); }
td.mono, td.addr { font-family: var(--mono); font-size: var(--t-small); }
/* Alignment belongs to the column, not to the body. Scoping these to `td`
   was why a Pending header sat left above right-aligned numbers. */
th.num, td.num { text-align: right; font-family: var(--mono); font-size: var(--t-small); }
th.actions, td.actions { text-align: right; }
td.actions { white-space: normal; }
td.actions .actionwrap {
  display: flex; flex-wrap: wrap; gap: var(--s-1); justify-content: flex-end;
}
td.dim, .dim { color: var(--ink-dim); }
th.pick, td.pick { width: 32px; padding-right: 0; }
/* An address is meaningless truncated mid-octet: one line and a tooltip
   rather than a wrap - and on a card, where there is room, neither. */
td.addr { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
td.err {
  color: var(--late); font-family: var(--mono); font-size: var(--t-micro);
  line-height: 1.45; overflow-wrap: anywhere;
}

/* The header row does the sorting on a wide screen; this does it on a narrow
   one, where there is no header row to click. Same sortBy(), one visible at
   a time, CSS decides which. */
.sortbar { display: none; }
.sortbar label { font-size: var(--t-ui); color: var(--ink-dim); }
.sortbar select { flex: 1 1 auto; min-width: 0; }

@media (max-width: 860px) {
  .sortbar { display: flex; align-items: center; gap: 10px; margin: var(--s-3) 0 var(--s-3); }
}

/* --- bottom navigation --------------------------------------------------

   Rendered by session.js on every signed-in page, from the same model the
   sidebar is drawn from. Below 860px it replaces the sidebar: Overview,
   Devices, Patches, and More for everything else. Fixed, because the answer
   to "where else can I go" should not require scrolling back to the top of
   a long table. */

.bottomnav { display: none; }
.sheet { display: none; }

@media (max-width: 860px) {
  body.hasnav .wrap {
    padding-bottom: calc(86px + env(safe-area-inset-bottom, 0px));
  }
  .bottomnav {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
    display: grid; grid-template-columns: repeat(4, 1fr);
    background: var(--panel); border-top: 1px solid var(--rule);
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }
  .navitem {
    display: flex; flex-direction: column; align-items: center;
    justify-content: center; gap: 3px;
    min-height: 56px; padding: 6px 2px;
    background: transparent; border: 0; cursor: pointer;
    color: var(--ink-dim); text-decoration: none;
    font: inherit; font-size: var(--t-micro);
  }
  .navitem:hover { border: 0; }
  .navicon { font-size: 15px; line-height: 1; }
  /* "true" on More: you are on a page that lives behind it. */
  .navitem[aria-current] { color: var(--ink); }
  .navitem[aria-current] .navicon { color: var(--accent); }
  .navitem:focus-visible { outline: 2px solid var(--focus); outline-offset: -3px; }

  /* Anchored to the bottom, and capped so it can never grow off the top.
     It had `bottom` with no `top` and no max-height, so it grew upward
     without limit: at 390x844 the first item sat at -4px and at 390x667 the
     whole of `Hypervisors` was 181px above the screen. The menu reached that
     length one page at a time, which is why nobody saw the moment it broke.

     The cap subtracts, in order: the bottom nav it sits above, both safe-area
     insets - phone system bars overlay the page and eat usable height, and
     the top one is the half that matters here - and a margin so the sheet
     never looks glued to the status bar.

     `dvh` after `vh`: a browser that has it uses the *dynamic* viewport,
     which is what is actually visible while the address bar is showing. A
     browser that does not falls back to the static one, which is close
     enough and still bounded. */
  .sheet {
    position: fixed; left: var(--s-2); right: var(--s-2); z-index: 31;
    bottom: calc(60px + env(safe-area-inset-bottom, 0px));
    max-height: calc(100vh - 60px - env(safe-area-inset-bottom, 0px)
                     - env(safe-area-inset-top, 0px) - 24px);
    max-height: calc(100dvh - 60px - env(safe-area-inset-bottom, 0px)
                     - env(safe-area-inset-top, 0px) - 24px);
    /* A column so the head stays put and only the list scrolls. */
    display: flex; flex-direction: column; min-height: 0;
    background: var(--panel); border: 1px solid var(--rule-hi);
    border-radius: var(--r-xl); padding: 6px;
    box-shadow: var(--shadow-float);
  }
  .sheet[hidden] { display: none; }

  /* Stays while the list moves, so Close is never the thing you have to
     scroll to find. */
  .sheethead {
    flex: 0 0 auto; display: flex; align-items: center; gap: var(--s-2);
    padding: 4px 6px 8px; border-bottom: 1px solid var(--rule);
  }
  .sheethead .sheettitle {
    flex: 1 1 auto; font-size: var(--t-dense); color: var(--ink-dim);
  }
  /* 44 in both directions, stated here rather than left to the
     coarse-pointer block: that block sizes `button`, and a class beats an
     element selector whatever the order, so any min-height set here would
     have silently undercut the floor. The sheet is a phone control - this is
     the one place a small target is least forgivable. */
  .sheetclose {
    flex: 0 0 auto; min-height: 44px; min-width: 44px; padding: 0 var(--s-3);
    background: transparent; border: 1px solid var(--rule);
    border-radius: var(--r-md); color: var(--ink);
    font: inherit; font-size: 14px; cursor: pointer;
  }
  .sheetclose:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }

  /* The list is what scrolls - the page behind a fixed sheet cannot move it,
     which is why there was no way to reach what had gone off the top.
     `overscroll-behavior: contain` keeps a flick at the end of the list from
     scrolling the page underneath instead. */
  .sheetscroll {
    flex: 1 1 auto; min-height: 0;
    overflow-y: auto; -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
  }
  .sheetlist { display: flex; flex-direction: column; }
  .sheetitem {
    display: flex; align-items: center; min-height: 48px; padding: 0 var(--s-3);
    background: transparent; border: 0; border-radius: var(--r-md);
    color: var(--ink); text-decoration: none;
    font: inherit; font-size: 15px; text-align: left; cursor: pointer;
  }
  .sheetitem + .sheetitem { border-top: 1px solid var(--rule); border-radius: 0; }
  /* Same grouping as the dropdown, same order. A caption is not an item, so
     it does not take the separator above it. */
  .sheet .menugroup { padding: 12px 12px 5px; }
  .sheetitem + .menugroup { border-top: 1px solid var(--rule); margin-top: 4px; }
  .sheet .sheetitem[aria-current="page"] { font-weight: 600; }
  .menugroup + .sheetitem { border-top: 0; }
  /* The header's tabs are the only way to a sibling page on a phone - the
     sheet lists entries, not every page behind them - so they get the full
     target whatever the pointer. */
  .subtab { min-height: 44px; min-width: 44px; justify-content: center; }
  .sheetversion {
    padding: 10px 12px 4px; font-family: var(--mono);
    font-size: var(--t-small); color: var(--ink-dim);
  }
  .sheetitem:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
}

/* --- narrow: the shared shape ------------------------------------------ */

@media (max-width: 700px) {
  /* The header bar keeps its shape: title and count, the page's own
     actions beside them, tabs beneath. Only the type steps down. */
  header h1 { font-size: 17px; }

  .toolbar { gap: var(--s-2); }
  .toolbar .spacer { display: none; }
  .toolbar > button, .toolbar > select { flex: 1 1 auto; }
  /* Its own line: a sentence does not belong in a row of controls at 390px. */
  .toolbar #selection { flex: 1 0 100%; order: 9; }
  .confirm-actions { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-2); }
}

@media (max-width: 560px) {
  .wrap, .wrap.wide, .wrap.narrow { padding: var(--s-5) var(--s-3) 56px; }
  /* At this width a toolbar's buttons share the row evenly, and the count of
     what is selected takes a line of its own - it is a sentence, and a
     sentence does not belong in a row of controls. */
  .toolbar button { flex: 1 1 auto; }
  .toolbar #selection { flex: 1 0 100%; order: 9; }
  /* A wrapping row of label-and-field pairs breaks wherever it runs out of
     room, which put "Actor" at the end of the line holding the Action field.
     Each label takes its own line so a label is always above its own
     control, never beside somebody else's. */
  .toolbar label { flex: 1 0 100%; margin-top: var(--s-1); }
  .toolbar input, .toolbar select { flex: 1 1 100%; min-width: 0; }
  .cabar { gap: 6px 14px; }
  /* Label above field: 150px of label leaves nothing for the field. */
  .grid2 { grid-template-columns: minmax(0, 1fr); gap: var(--s-1); }
  .grid2 label { margin-top: 10px; }
}

@media (prefers-reduced-motion: no-preference) {
  button, .btn, .card, .listrow, input, select {
    transition: background-color 120ms ease, border-color 120ms ease;
  }
}

/* --- loading, empty, filtered, failed ----------------------------------------

   static/pagestate.js. A skeleton is grey blocks at the shape of what is
   coming; the shimmer is motion and sits in the no-preference block below,
   so reduced motion gets still grey blocks. A state is a short sentence and
   the action that resolves it. */
.skelwrap { display: flex; flex-direction: column; gap: 0; }
/* --rule, not --panel-hi: the band is a shade off the ground and a block in
   it disappears on the ground the content sits on. */
.skel {
  display: block; height: 12px; border-radius: var(--r-sm);
  background: var(--rule);
}
.skelrow {
  display: flex; align-items: center; gap: var(--s-4); padding: 0 var(--s-3);
  border-bottom: 1px solid var(--rule);
}
.skelrow .skel { flex: 1 1 0; max-width: 220px; }
.skelrow .skel:first-child { flex: 0 0 auto; width: 18px !important; height: 18px; }
.skelcard {
  display: flex; flex-direction: column; justify-content: center; gap: 10px;
  margin-bottom: 10px; padding: 12px 14px;
  background: var(--panel); border: 1px solid var(--rule); border-radius: var(--r-md);
}
.skelring { display: flex; align-items: center; gap: var(--s-5); }
.skeldisc { flex: 0 0 auto; border-radius: 50%; }
.skellines { flex: 1 1 160px; display: flex; flex-direction: column; gap: 14px; }
.skelbar { display: grid; grid-template-columns: 30% 1fr; align-items: center; gap: var(--s-3); }
.skelbar .skeltrack { height: 10px; border-radius: var(--r-pill); }
.skelwrap > .skel { margin: 5px 0; }
/* A heading's text, before it is known. */
.skel.skelname { display: inline-block; width: 180px; height: 0.9em; vertical-align: middle; }
.skelwrap.skelchips { flex-direction: row; flex-wrap: nowrap; overflow: hidden; gap: 6px; }
.skelwrap.skelchips > .skelchip { flex: 0 0 auto; }
.skelwrap.skelchips > .skelchip { height: 30px; margin: 0; border-radius: var(--r-pill); }

/* A page that draws skeletons (`<html data-shaping>`) keeps what is below
   its header invisible until the first one is in place. Scripts at the end
   of <body> run after the browser has painted, so without this the empty
   containers were painted first and every skeleton then moved the page.
   Hidden content does not shift. If no script ever runs, it is shown anyway
   after 1.5s - this is not motion, so it is outside the reduced-motion block. */
@keyframes pp-reveal { to { visibility: visible; } }
html[data-shaping]:not([data-shaped]) .wrap > :not(header) {
  visibility: hidden;
  animation: pp-reveal 0s 1.5s forwards;
}
.pghost { display: flow-root; }

.pgstate {
  margin-top: var(--s-4); padding: var(--s-4) var(--s-5);
  background: var(--panel); border: 1px dashed var(--rule-hi); border-radius: var(--r-md);
}
.pgstate .pgtitle { margin: 0; font-weight: 600; font-size: var(--t-body); }
.pgstate .pgdetail { margin: 6px 0 0; color: var(--ink-dim); line-height: 1.55;
                    max-width: 68ch; }
.pgstate .pgactions { display: flex; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-3); }
/* Nothing waiting is the outcome patching is for, and looks like it. */
.pgstate.good { border-style: solid; border-color: var(--ok); background: var(--ok-wash, var(--panel)); }
.pgstate.good .pgtitle { color: var(--ok); }
.pgstate.filtered { border-style: solid; border-color: var(--accent); }
.pgstate.error { border-style: solid; border-color: var(--late); background: var(--late-wash); }
.pgstate.error .pgtitle { color: var(--late); }
.pgstate.error .pgdetail { font-family: var(--mono); font-size: var(--t-small); color: var(--ink);
                          overflow-wrap: anywhere; }

@keyframes pp-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
@media (prefers-reduced-motion: no-preference) {
  .skel {
    background: linear-gradient(90deg, var(--rule) 30%, var(--panel-hi) 50%, var(--rule) 70%);
    background-size: 300% 100%;
    animation: pp-shimmer 1.6s ease-in-out infinite;
  }
}

/* --- the command palette ------------------------------------------------------

   static/palette.js. A centred dialog above 860px and a full-screen sheet
   below it, from the same markup. The highlighted result is marked by a bar
   in the focus colour as well as a tint, so it is findable without colour
   vision and clears 3:1 like any focus indicator. */
.headsearch {
  flex: 0 0 auto; display: inline-flex; align-items: center; gap: 8px;
  min-width: 210px; background: var(--panel); color: var(--ink-dim); text-align: left;
}
.headsearch .hsicon { font-size: 16px; line-height: 1; }
.headsearch .hslabel { flex: 1 1 auto; }
.headsearch .hskey, kbd {
  font-family: var(--mono); font-size: var(--t-micro); color: var(--ink-dim);
  border: 1px solid var(--rule-hi); border-bottom-width: 2px; border-radius: var(--r-sm);
  padding: 0 5px; background: var(--panel-hi);
}
@media (max-width: 860px) {
  /* Just the glyph, and still a target - this rule outranks the coarse
     pointer's button floor, so it states the 44px itself. Out of the flow,
     in a corner the header keeps free: in the row, it was what made a long
     subtitle wrap the header and move the page down once the data came. */
  header { position: relative; padding-right: 52px; }
  /* The title takes the row, and actions - where a page has any - the next
     one. Beside each other, whether they fitted depended on how long the
     subtitle turned out to be, so the header wrapped when the data arrived. */
  .pagetitle { flex-basis: 100%; }
  /* The actions' row is below the corner, so it may use it. */
  header > .pageactions { margin-right: -52px; }
  .headsearch {
    position: absolute; top: 0; right: 0;
    min-width: 44px; min-height: 44px; justify-content: center;
  }
  .headsearch .hslabel, .headsearch .hskey { display: none; }
}
html.palopen { overflow: hidden; }
.palback {
  position: fixed; inset: 0; z-index: 70;
  display: flex; justify-content: center; align-items: flex-start;
  padding: 12vh 16px 16px; background: rgba(29, 29, 29, 0.35);
}
.palback[hidden] { display: none; }
.palette {
  width: min(640px, 100%); max-height: 72vh;
  display: flex; flex-direction: column; overflow: hidden;
  background: var(--panel); border: 1px solid var(--rule); border-radius: var(--r-md);
  box-shadow: var(--shadow-float);
}
.paltop { display: flex; align-items: center; gap: var(--s-2); padding: 8px 10px;
          border-bottom: 1px solid var(--rule); }
.palinput {
  flex: 1 1 auto; min-width: 0; border: 0; background: transparent; color: var(--ink);
  font: inherit; font-size: 16px; padding: 8px 6px;
}
.palclose { flex: 0 0 auto; font-size: var(--t-dense); padding: 4px 10px; }
.palstatus { margin: 0; padding: 8px 14px 0; font-size: var(--t-small); color: var(--ink-dim); }
.palstatus:empty { display: none; }
.palstatus.error { color: var(--late); }
.pallist { list-style: none; margin: 0; padding: 4px 0 8px; overflow-y: auto;
           overscroll-behavior: contain; flex: 1 1 auto; }
.palhead {
  padding: 10px 14px 4px; font-size: var(--t-micro); font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-dim);
}
.palitem {
  display: flex; align-items: center; gap: 10px; min-height: 40px;
  padding: 6px 14px; cursor: pointer; color: var(--ink);
}
.palitem:hover { background: var(--panel-hi); }
.palitem.on { background: var(--hover); box-shadow: inset 3px 0 0 var(--focus); }
.palbody { flex: 1 1 auto; min-width: 0; }
.pallabel { display: block; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pallabel mark { background: transparent; color: inherit; font-weight: 750; text-decoration: underline; }
.palsub { display: block; font-size: var(--t-small); color: var(--ink-dim);
          overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palhint { flex: 0 0 auto; font-size: var(--t-micro); color: var(--ink-dim); }
.palitem:not(.on) .palhint { visibility: hidden; }
.palmore { padding: 2px 14px 6px; font-size: var(--t-micro); color: var(--ink-dim); }
.palfoot { margin: 0; padding: 8px 14px; border-top: 1px solid var(--rule);
           font-size: var(--t-micro); color: var(--ink-dim); background: var(--panel-hi); }
.shortcuts h2 { flex: 1 1 auto; margin: 0 4px; font-size: var(--t-body); }
.keys { display: grid; grid-template-columns: auto 1fr; gap: 10px 18px; align-items: baseline;
        margin: 0; padding: 14px 16px; overflow-y: auto; }
.keys dt { white-space: nowrap; color: var(--ink-dim); }
.keys dd { margin: 0; }
@media (max-width: 860px) {
  /* A sheet: the whole screen, its own scroll, the way out at the top. */
  .palback { padding: 0; align-items: stretch; }
  .palette {
    width: 100%; max-height: none; height: 100vh; height: 100dvh;
    border: 0; border-radius: 0;
    padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);
  }
  .palitem { min-height: 48px; }
}
/* The key hints are for a keyboard. */
@media (pointer: coarse) { .palfoot { display: none; } }

/* --- toasts -------------------------------------------------------------------

   static/toast.js. The frame's dark, so a toast reads as the app speaking
   rather than as part of the page; bottom right, and above the bottom bar on
   a phone (--toast-bottom; the device page lifts it over its pinned
   actions). Never an error that needs a decision - those are inline. */
.toasts {
  position: fixed; right: 16px; bottom: var(--toast-bottom, 16px); z-index: 60;
  display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
  max-width: min(440px, calc(100vw - 32px)); pointer-events: none;
}
.toast {
  pointer-events: auto;
  display: flex; align-items: center; gap: var(--s-2);
  padding: 8px 8px 8px 14px; border-radius: var(--r-md);
  background: var(--side); color: var(--side-ink); box-shadow: var(--shadow-float);
  font-size: var(--t-dense); line-height: 1.4;
}
.toast .toastmsg { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.toast button {
  flex: 0 0 auto; background: transparent; color: var(--side-ink);
  border: 1px solid var(--side-rule); padding: 4px 10px; font-size: var(--t-dense);
}
.toast button:hover:not([disabled]) { background: var(--side-hi); border-color: var(--side-dim); }
.toast button:active:not([disabled]) { background: var(--side-hover); box-shadow: none; }
.toast .toastundo { color: var(--side-mark); font-weight: 600; }
.toast .toastclose { border-color: transparent; font-size: 16px; line-height: 1; padding: 4px 8px; }
:where(.toast) :focus-visible { outline-color: var(--side-ink); }
@media (max-width: 860px) {
  .toasts {
    left: 12px; right: 12px; max-width: none; align-items: stretch;
    bottom: var(--toast-bottom, calc(68px + env(safe-area-inset-bottom, 0px)));
  }
}
@keyframes pp-toast-in { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: no-preference) {
  .toast { animation: pp-toast-in 160ms var(--ease-out, ease-out); }
}

/* --- motion ---------------------------------------------------------------

   Charts draw on, states fade, counts tick - and only on first render and
   on actual change. Every rule that moves anything is inside
   `(prefers-reduced-motion: no-preference)`, so somebody who asked for no
   motion gets the static rendering that existed before, by construction
   rather than by an override somebody has to remember to extend.

   Nothing here is driven by a poll. A CSS animation runs when an element is
   created; a transition runs when a value changes; and the patcher
   (static/dompatch.js) creates nothing and writes nothing on a poll that
   brought no change. The number tick is static/motion.js, on the same terms.
   tests/responsive/still.js leaves pages open for two minutes and fails on
   anything that moves.

   What is not motion stays outside the block: a highlighted segment is a
   thicker stroke in both modes; only the lift is movement. */
:root {
  --motion-state: 200ms;     /* a status colour changing */
  --motion-draw: 600ms;      /* a chart drawing on, or a share changing */
  --motion-stagger: 80ms;    /* between one ring segment and the next */
  --motion-hover: 160ms;     /* a segment lifting */
  --ease-out: cubic-bezier(0.2, 0.7, 0.2, 1);
}

/* A ring's segments may lift outward, so the svg must not clip them. */
.ringwrap svg { overflow: visible; }
.ringseg { cursor: pointer; }
.ringseg.hl { stroke-width: 7.4; }
/* The legend row matching a hovered segment, and the reverse. */
.legend > .hl { background: var(--panel-hi); }

/* A segment's label, count and share, beside it. Fixed and on <body>, so a
   card's edge cannot clip it. Pointer-only: the legend says the same in
   words, and is the control. */
.ringtip {
  position: fixed; z-index: 40; pointer-events: none;
  display: flex; flex-direction: column; gap: 1px;
  padding: 6px 9px; border-radius: var(--r-sm);
  background: var(--side); color: var(--side-ink);
  box-shadow: var(--shadow-float); font-size: var(--t-small); line-height: 1.35;
  white-space: nowrap;
}
.ringtip[hidden] { display: none; }
.ringtip .tl { font-weight: 600; }
.ringtip .tn { font-variant-numeric: tabular-nums; color: var(--side-dim); }

@keyframes pp-ring-draw { from { stroke-dasharray: 0 100; } }
@keyframes pp-bar-grow { from { width: 0; } }

@media (prefers-reduced-motion: no-preference) {
  /* A ring draws on from zero, clockwise, a segment at a time, when it is
     made; after that a changed share transitions and an unchanged one has
     nothing written to it. */
  .ringseg {
    animation: pp-ring-draw var(--motion-draw) var(--ease-out) backwards;
    animation-delay: calc(var(--i, 0) * var(--motion-stagger));
    transition: stroke-dasharray var(--motion-draw) var(--ease-out),
                stroke-dashoffset var(--motion-draw) var(--ease-out),
                stroke-width var(--motion-hover) ease,
                transform var(--motion-hover) var(--ease-out);
  }
  .ringseg.hl { transform: translate(var(--lx, 0), var(--ly, 0)); }

  /* A bar grows from its baseline when it is made; a changed count moves it. */
  .bar .fill {
    animation: pp-bar-grow var(--motion-draw) var(--ease-out) backwards;
    transition: width var(--motion-draw) var(--ease-out);
  }

  /* A state change fades rather than swaps. */
  .statedot, .statepill { transition: background-color var(--motion-state) ease; }
  table[data-cards] > tbody > tr { transition: border-left-color var(--motion-state) ease; }
}

/* --- the filter strip ----------------------------------------------------

   "You are looking at a filtered board", said out loud. A filter whose effect
   you cannot see is a trap, and five pages each carried their own copy of
   this saying it. */

.filterbar {
  margin-top: 14px; padding: var(--s-2) var(--s-3);
  display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
  font-size: var(--t-dense);
  border: 1px solid var(--accent); border-radius: var(--r-sm);
  color: var(--ink-dim);
}
.filterbar .chip {
  font-family: var(--mono); font-size: var(--t-micro); padding: 2px var(--s-2);
  border-radius: var(--r-pill); border: 1px solid var(--rule); color: var(--ink);
}
.filterbar .chip.outdated { border-color: var(--late); color: var(--late); }
.filterbar .chip.reboot   { border-color: var(--reboot); color: var(--reboot); }
.filterbar .chip.ok       { border-color: var(--ok); color: var(--ok); }
.filterbar .chip.unknown  { border-color: var(--unknown); color: var(--unknown); }
.filterbar button { font-size: var(--t-small); padding: 3px 10px; }

/* The certificate authority's state, in one line. */
.cabar {
  margin-top: 14px; padding: 9px 13px;
  font-size: var(--t-dense); font-family: var(--mono); color: var(--ink-dim);
  border: 1px solid var(--rule); border-radius: var(--r-sm);
  display: flex; gap: 18px; flex-wrap: wrap; align-items: center;
}
.cabar .warn { color: var(--reboot); }
.cabar .good { color: var(--ok); }

/* Who is signed in, and what that lets them do. */
.who {
  margin-top: var(--s-4); padding: 10px 13px; font-size: var(--t-dense);
  border: 1px solid var(--rule); border-radius: var(--r-sm); color: var(--ink-dim);
  display: flex; gap: var(--s-4); flex-wrap: wrap; align-items: center;
}

/* A security advisory marker, wherever one is shown. */
.sec { color: var(--late); font-size: var(--t-micro); }

/* --- forms ---------------------------------------------------------------

   `.grid2` is the label-beside-field form used by every admin panel. The
   picker is checkboxes rather than a multi-select on purpose: access is the
   thing you most want to read off the screen without opening anything. */

.grid2 {
  display: grid; grid-template-columns: 150px minmax(0, 1fr);
  gap: 10px 14px; align-items: center;
}
.grid2 label { font-size: var(--t-ui); color: var(--ink-dim); }
/* Text fields and selects fill their column. Not checkboxes or radios: a
   checkbox stretched to the column's width sat hundreds of pixels from its
   own label, which is how the host list on /schedules became unreadable. */
.grid2 input:not([type="checkbox"]):not([type="radio"]),
.grid2 select { width: 100%; }

.picker {
  max-height: 190px; overflow-y: auto; padding: 6px 10px;
  background: var(--ground); border: 1px solid var(--rule);
  border-radius: var(--r-sm);
}
label.groupcheck {
  /* A list reads from the left edge. label.tap centres, which is right for
     a lone checkbox and wrong for a column of named ones. */
  display: flex; align-items: center; justify-content: flex-start;
  gap: var(--s-2);
  font-size: var(--t-ui); padding: 3px 0; cursor: pointer; min-width: 0;
}
label.groupcheck span { overflow: hidden; text-overflow: ellipsis; }

/* Quieter than secondary: present, and not asking to be noticed. */
button.quiet { background: transparent; color: var(--ink-dim); padding: 5px 10px; }
button.quiet:hover { color: var(--ink); }

/* A boxed group of settings, on the pages that are made of them. */
section.box {
  margin-top: var(--s-5); background: var(--panel);
  border: 1px solid var(--rule); border-radius: var(--r-md);
  padding: 18px 20px 20px;
}
section.box > h2 { margin-top: 0; }
section.box > .hint { font-size: var(--t-ui); color: var(--ink-dim); margin: 3px 0 0; }

/* --- the signed-out card -------------------------------------------------

   /login and /changepw are the same page with a different question, and were
   two copies of the same eleven rules.

   `.signcard` is a sized, padded box that happens to use the card's surface.
   It is not `.card` itself: `.card` is the surface and nothing else, and when
   this page called its box `.card` the two names collided - deduplicating
   against the shared component silently took the sign-in box's width and
   padding with it. A component and a layout want different names. */

body.signedout {
  min-height: 100vh;
  display: flex; align-items: center; justify-content: center;
  padding: var(--s-5);
}
.signcard {
  width: 100%; max-width: 340px;
  background: var(--panel); border: 1px solid var(--rule);
  border-radius: var(--r-md); padding: var(--s-5) var(--s-5) 26px;
}

.cardhead {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-3);
}
.signform { margin: var(--s-5) 0 0; }
.signform label {
  display: block; font-size: var(--t-ui); color: var(--ink-dim); margin-bottom: 5px;
}
.signform input { font-size: 14px; width: 100%; padding: var(--s-2) 11px; }
.field + .field { margin-top: 14px; }
/* Full width and set apart, but the same button - the shape is the
   button's and the placement is the form's. */
.signform button { width: 100%; margin-top: 20px; }
.sub { font-size: var(--t-ui); color: var(--ink-dim); margin: var(--s-1) 0 0; }
.error {
  margin: 18px 0 0; padding: 9px var(--s-3);
  background: var(--late-wash); border: 1px solid var(--late);
  border-radius: var(--r-sm); color: var(--late); font-size: var(--t-ui);
}
/* Its opposite number, and the same box. */
.good {
  margin: var(--s-4) 0 0; padding: 9px 11px;
  background: var(--ok-wash); border: 1px solid var(--ok);
  border-radius: var(--r-sm); color: var(--ok); font-size: var(--t-ui);
}
.note {
  margin: 20px 0 0; padding-top: 15px; border-top: 1px solid var(--rule);
  font-family: var(--mono); font-size: var(--t-micro); color: var(--ink-dim);
}

/* Windows sign-in, when the server offers it: a choice above the form, never
   instead of it. The button is the same full-width button as the form's; the
   rule between them says what the form is for, so nobody reads the second
   option as a fallback for the first going wrong. */
.signalt { margin: var(--s-5) 0 0; }
.signalt .btn { display: flex; justify-content: center; width: 100%; text-decoration: none; }
.signalt .hint { margin: var(--s-2) 0 0; font-size: var(--t-micro); color: var(--ink-dim); }
.signor {
  display: flex; align-items: center; gap: var(--s-3);
  margin: var(--s-5) 0 0; font-size: var(--t-micro); color: var(--ink-dim);
}
.signor::before, .signor::after { content: ""; flex: 1; border-top: 1px solid var(--rule); }
.signedout .notice { margin: 18px 0 0; }

/* --- de-emphasis ---------------------------------------------------------

   A row that is disabled, suspended, already monitored or otherwise not the
   point still has to be readable. Opacity is the obvious way to say "less
   important" and the wrong one: it multiplies the text against whatever is
   behind it, so 0.6 turns dim ink from 4.99:1 into 2.70:1 and a disabled
   user's name stops meeting AA. Every page had its own copy of that mistake.

   So de-emphasis is a colour and an edge, never a fade. The text lands on
   the dim ink, which is measured; the dashed border does the rest of the
   work of saying "this one is not live". */

.muted { color: var(--ink-dim); }
.muted td:not(.actions), .muted .dim { color: var(--ink-dim); }
tr.muted, .card.muted { border-style: dashed; }
tr.suspended td:not(.actions) { color: var(--ink-dim); }

/* Kept as its own name because the markup says `off` in four places and the
   word is right there in the data - an account is disabled, not muted. */
.off { color: var(--ink-dim); }
.off td:not(.actions) { color: var(--ink-dim); }
tr.off { border-style: dashed; }

/* --- table density -------------------------------------------------------

   The table is shared; how much of it fits on a line is the page's call, and
   in practice that call is made per column rather than per table - an audit
   log wants a tight monospace timestamp beside ordinary-sized prose. So a
   page tunes its own columns with `td.<name>` rules, from the type scale
   above, and never touches the base `td`. `tests/dom/shared.js` enforces the
   second half of that. */

/* ==========================================================================
   Touch sizing
   ==========================================================================

   Last in the file on purpose. These rules exist to override whatever a
   component says about its size, so they have to come after the components -
   appending the component layer above them once left every button back at
   its 6px padding and every checkbox at 15px on a phone.

   Two media features, two questions:

     (max-width: N)     how much room there is, which decides the shape
     (pointer: coarse)  how big a thing has to be to hit with a thumb

   They are not the same question. A touchscreen laptop at 1400px needs the
   targets without the single-column layout, and a narrow window on a desktop
   needs the layout without the targets. */


/* --- shared per-host components -------------------------------------------
   Used by the dashboard's expanded tile and by /hosts. They were scoped to
   `.detail` in index.html while only one page had them; the moment a second
   page did, that scoping was what would have silently unstyled them. */

/* The WinRM diagnosis report (static/hostdiag.js). */
.diag { margin-top: 16px; }
.diag-summary {
  margin: 0 0 10px; padding: 10px 12px; font-size: var(--t-ui); line-height: 1.55;
  border: 1px solid var(--reboot); border-radius: 6px; color: var(--reboot);
}
.diag-line { font-size: var(--t-dense); color: var(--ink-dim); margin-bottom: 5px; }
.diag-line.warn { color: var(--reboot); }
.diag-addr {
  margin-top: 11px; font-family: var(--mono); font-size: var(--t-small); color: var(--ink);
}
.diag-ports { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 6px; }
.pchip {
  font-family: var(--mono); font-size: var(--t-micro); padding: 2px 8px;
  border-radius: 99px; border: 1px solid var(--rule); color: var(--ink-dim);
}
.pchip.p-open    { border-color: var(--ok); color: var(--ok); }
.pchip.p-refused { border-color: var(--reboot); color: var(--reboot); }
.pchip.p-timeout { border-color: var(--late); color: var(--late); }
.diag-wait { font-size: var(--t-dense); color: var(--ink-dim); margin: 14px 0 0; }
.diag-why {
  font-family: var(--mono); font-size: var(--t-micro); color: var(--ink-dim);
  margin-top: 5px; overflow-wrap: anywhere;
}

/* Whether an install will have a snapshot behind it, said before the button
   rather than discovered in a job log afterwards. */
.snapyes { margin: 8px 0 0; font-size: var(--t-dense); color: var(--ok); }
.snapno {
  margin: 8px 0 0; font-size: var(--t-dense); color: var(--reboot);
  line-height: 1.5;
}


/* "set by hand" beside a value a person set, and the complaint when the
   server refuses one (static/familypicker.js). */
.byhand {
  font-family: var(--mono); font-size: var(--t-micro);
  color: var(--ink-dim); margin-left: 9px;
}
.byhand.err { color: var(--late); }

/* The snapshot-policy control (static/snapshotexclusion.js), on the
   dashboard's setup panel and on /hosts. */
.snapex { margin-top: 6px; }
.snapexrow {
  display: flex; gap: var(--s-2); align-items: center; flex-wrap: wrap;
  margin-top: 8px;
}
.snapexrow input[type="text"] {
  font: inherit; font-size: var(--t-ui); flex: 1 1 190px; min-width: 0;
  background: var(--ground); color: var(--ink);
  border: 1px solid var(--rule); border-radius: 6px; padding: 4px 8px;
}
.snapexrow select { min-width: 0; max-width: 100%; }

/* The server's sentence about a snapshot - why one is held, why one will not
   be taken, what a failed freeze means. Three pages draw it now; it lived in
   snapshots.html while only one did. */
.snapwhy {
  margin: 8px 0 0; font-size: var(--t-dense); color: var(--ink-dim);
  line-height: 1.55;
}
.snapwhy.bad { color: var(--reboot); }

@media (max-width: 700px) {
  /* One per line rather than three squeezed onto one - the reason field is
     the one that suffers, and an unreadable reason is the whole point lost. */
  .snapexrow > * { flex: 1 1 100%; }
}

/* What the declines on a job were worth. Amber: not a failure - the install
   did what it was asked - but a qualification on what it means. */
.declinenote {
  margin-top: 12px; padding: 11px 13px;
  background: var(--late-wash); border: 1px solid var(--reboot);
  border-radius: var(--r-sm);
}
.declinehead {
  margin: 0; font-size: var(--t-dense); color: var(--reboot); line-height: 1.5;
  overflow-wrap: anywhere;
}
.declinewhy {
  margin: 8px 0 0; font-size: var(--t-dense); color: var(--ink-dim);
  line-height: 1.55;
}
.declinecases {
  margin: 8px 0 0; padding-left: 18px;
  font-size: var(--t-dense); color: var(--ink-dim); line-height: 1.55;
}
.declinecases li { margin-bottom: 4px; }

/* A record whose snapshot the hypervisor no longer has. Dim ink and a dashed
   edge rather than opacity - opacity on text takes it under AA, and this row
   still has to be readable. It must not look live: a stale record sitting in
   the list looking real is how the page stops being trusted. */
.snaprow.gonerow {
  border-style: dashed;
  border-color: var(--rule);
  background: var(--ground);
}
.snaprow.gonerow .snapname { color: var(--ink-dim); }

/* The dangerous direction: recorded deleted, still present. Nothing is
   coming back for it on its own. */
.snaprow.orphanrow {
  border-color: var(--late);
  border-left: 4px solid var(--late);
}

/* The per-host lines in a bulk confirmation. Each one is the server's own
   sentence about that host, so they have to be readable rather than a count. */
.planlist {
  margin: 8px 0 0; padding-left: 18px;
  font-size: var(--t-dense); color: var(--ink-dim); line-height: 1.55;
}
.planlist li { margin-bottom: 4px; overflow-wrap: anywhere; }

/* --- filter chips ---------------------------------------------------------

   A choice that narrows a list, with how many it leaves. The overview's filter
   bar, the devices list's states and the patches list all draw these; a chip
   that is a link marks itself with aria-current, a toggle with aria-pressed. */
.fchips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.fchip {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: var(--t-dense); padding: 4px 11px; border-radius: var(--r-pill);
  background: var(--panel); border: 1px solid var(--rule); color: var(--ink);
  cursor: pointer; white-space: nowrap; text-decoration: none;
}
.fchip .n { color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.fchip[aria-pressed="true"], .fchip[aria-current="true"] {
  background: var(--ink); border-color: var(--ink); color: var(--panel);
}
.fchip[aria-pressed="true"] .n, .fchip[aria-current="true"] .n { color: var(--panel-hi); }
.fchip:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* The state's colour as a dot, never as the chip's fill: the words carry it. */
.fchip .statedot { width: 8px; height: 8px; }
@media (max-width: 860px) {
  /* One row that scrolls sideways: the strip scrolls, the page does not. */
  .fchips { flex-wrap: nowrap; overflow-x: auto; overscroll-behavior-x: contain;
            scrollbar-width: none; padding-bottom: 2px; }
  .fchips::-webkit-scrollbar { display: none; }
  .fchip { flex: 0 0 auto; min-height: 44px; }
}

/* A list page's filter line: chips at the start, any other control after. */
.listbar {
  display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-4); align-items: center;
  justify-content: space-between; margin-top: var(--s-4);
}
.listbar > .fchips { min-width: 0; max-width: 100%; }

/* A host's state as a dot. Never on its own: the row or card says it in words
   too, for whoever cannot tell the colours apart. */
.statedot {
  display: inline-block; flex: 0 0 auto; width: 11px; height: 11px;
  border-radius: 50%; background: var(--chip-unknown); vertical-align: middle;
}
.statedot.outdated { background: var(--chip-late); }
.statedot.reboot { background: var(--chip-reboot); }
.statedot.ok { background: var(--chip-ok); }
.statedot.unknown { background: var(--chip-unknown); }

/* A short verdict with a tone - the rollback column, a patch's decision. The
   tone is the server's (ok / warn / bad); the words are always there. */
.pill.ok { color: var(--ok); border-color: var(--ok); }
.pill.warn { color: var(--reboot); border-color: var(--reboot); }
.pill.bad { color: var(--late); border-color: var(--late); }

/* Select-all and the count it changes stay together when a toolbar wraps. */
.selectall { display: inline-flex; align-items: center; gap: var(--s-2); }
@media (max-width: 860px) {
  /* The pair takes a line of its own, the count beside its box - the narrow
     toolbar's "#selection on its own line" is for a count standing alone. */
  .toolbar .selectall { flex: 1 1 100%; min-width: 0; }
  .toolbar .selectall #selection { flex: 0 1 auto; order: 0; }
  .toolbar .selectall label { flex: 0 0 auto; margin-top: 0; }
  /* A card's title cell makes its spans inline blocks; a subtitle is a line. */
  table[data-cards] td.cardtitle .rowsub { display: block; padding: 0;
    font-weight: 400; font-size: var(--t-small); }
}

/* --- density ----------------------------------------------------------------

   Comfortable or compact, per user (session.js, PATCH /api/me/prefs), as
   <html data-density>. Compact takes padding out of rows and cards and
   shrinks a row's buttons on a precise pointer; it never hides a column or
   a line, and on a touchscreen a target stays 44px whatever the density. */
.densitytoggle { display: inline-flex; }
.densitytoggle button {
  font-size: var(--t-dense); padding: 4px 11px; background: var(--panel); border-radius: 0;
}
.densitytoggle button + button { margin-left: -1px; }
.densitytoggle button:first-child { border-radius: var(--r-sm) 0 0 var(--r-sm); }
.densitytoggle button:last-child { border-radius: 0 var(--r-sm) var(--r-sm) 0; }
.densitytoggle button[aria-pressed="true"] {
  background: var(--ink); border-color: var(--ink); color: var(--panel);
}
/* The chosen one still answers a pointer - lighter, not a different colour. */
.densitytoggle button[aria-pressed="true"]:hover {
  background: var(--ink); border-color: var(--ink); filter: brightness(1.3);
}
/* Above its neighbours while focused, or the next button's border covers
   half the ring. */
.densitytoggle button:focus-visible { position: relative; z-index: 1; }

html[data-density="compact"] td { padding-top: 2px; padding-bottom: 2px; }
html[data-density="compact"] th { padding-top: 5px; padding-bottom: 4px; }
html[data-density="compact"] td .rowsub { line-height: 1.3; }
@media (max-width: 860px) {
  html[data-density="compact"] table[data-cards] > tbody > tr {
    padding: 7px 12px; margin-bottom: 6px;
  }
  html[data-density="compact"] table[data-cards] td { padding: 1px 0; }
  html[data-density="compact"] table[data-cards] td.actions,
  html[data-density="compact"] table[data-cards] td.act { padding-top: 6px; margin-top: 3px; }
}
@media not all and (pointer: coarse) {
  html[data-density="compact"] button.row { padding: 1px 7px; }
}

/* A row answers the pointer as a card does. */
table[data-cards] > tbody > tr:hover { border-color: var(--rule-hi); }
.fchip:hover { border-color: var(--rule-hi); background: var(--hover); }
.fchip[aria-pressed="true"]:hover, .fchip[aria-current="true"]:hover {
  background: var(--ink); border-color: var(--ink); filter: brightness(1.3);
}
.fchip:active { background: var(--press); }

/* A list row is a link to its own page; the row's buttons act in place. */
tr.rowlink { cursor: pointer; }
td .rowsub {
  display: block; font-size: var(--t-small); color: var(--ink-dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The state in words: on a card it is the dot's row; in a table the dot's
   column header and the row colour say it, and the words go to a reader. */
.statetext { position: absolute; width: 1px; height: 1px; overflow: hidden;
             clip: rect(0 0 0 0); white-space: nowrap; }
@media (max-width: 860px) {
  table[data-cards] .statetext { position: static; width: auto; height: auto;
                                 overflow: visible; clip: auto; margin-left: 6px; }
  table[data-cards] td.act { grid-column: 1 / -1; padding-top: 8px; }
  table[data-cards] td.act:empty { display: none; }
  table[data-cards] td.act button { width: 100%; }
  table[data-cards] td.rowsel { grid-column: 1; grid-row: 1; padding: 0; }
}

/* The row overflow. A dropdown above 700px, the shared .sheet below it -
   see static/rowmenu.js. Both are built from one list, so the two pages
   cannot drift on what a host can have done to it. */
/* Fixed, and on <body>: anchored inside the row it belongs to it was clipped
   by `.hostrow { overflow: hidden }` - the rule that rounds the status chip -
   and lost every item past the second. static/rowmenu.js positions it. */
.rowmenu { position: fixed; right: auto; min-width: 230px; max-width: 320px; }
/* An item can carry two lines: what it does, and - when the server says it
   cannot be done - why not. A title= would be a hover, and the sheet exists
   for a device that has none. */
.rowmenu .menuitem, .rowsheet .sheetitem {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
}
.miwhy {
  font-size: var(--t-small); line-height: 1.45; color: var(--ink-dim);
  white-space: normal;
}
/* Dim ink and no pointer, never opacity: fading a row takes its text from
   4.99:1 to 2.70:1, and the faded line here is the explanation. */
.rowmenu .menuitem.off, .rowsheet .sheetitem.off { cursor: default; }
.rowmenu .menuitem.off .mitext,
.rowsheet .sheetitem.off .mitext { color: var(--ink-dim); }
.rowmenu .menuitem.off:hover,
.rowsheet .sheetitem.off:hover { background: transparent; }
.rowmenu .menuitem.danger .mitext,
.rowsheet .sheetitem.danger .mitext { color: var(--late); }
/* The sheet's own items are taller than a navigation entry once they carry a
   reason, so the fixed 48px floor becomes a minimum rather than a height. */
.rowsheet .sheetitem { padding-top: 8px; padding-bottom: 8px; }

@media (pointer: coarse) {
  button,
  a.navlink,
  .subtab,
  .sideitem,
  .sidesmall,
  input[type="submit"],
  [role="button"] {
    min-height: 44px;
  }

  button {
    padding-top: 10px;
    padding-bottom: 10px;
    /* 44 in both directions. A two-letter label made a 39px-wide target. */
    min-width: 44px;
  }

  /* 44px in both directions. "Hosts" is a short word and was a 39px-wide
     target; the floor is a square, not a height. */
  a.navlink {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    padding: 0 2px;
  }

  /* A link drawn as a button - an empty state's "Run discovery" - is a
     target like any button. `min-height` does nothing to an inline box. */
  a.btn { display: inline-flex; align-items: center; justify-content: center;
          min-height: 44px; min-width: 44px; }

  /* A short tab name ("Users") is a narrow target; the floor is a square. */
  .subtab { min-width: 44px; justify-content: center; }

  /* 16px is not a style choice: iOS Safari zooms the viewport when a field
     smaller than that takes focus, and the page never zooms back. */
  input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
  select, textarea {
    font-size: 16px;
    min-height: 44px;
  }
  textarea { min-height: 88px; }

  /* A 15px checkbox is a 15px target. The box grows, and the cell around it
     is given room to be hit without hitting it exactly. */
  input[type="checkbox"] {
    width: 22px; height: 22px;
  }
  /* `min-height` does nothing to a non-replaced inline element, and a bare
     <label> is one - so this rule read as a 44px floor and delivered a 26px
     target on any label short enough to stay on one line. It was only ever
     right by accident, on the pages whose labels happened to wrap.
     `label.tap` had the display change; this did not. */
  label:has(> input[type="checkbox"]) {
    display: inline-flex; align-items: center;
    min-height: 44px; min-width: 44px;
  }
  td.pick, th.pick {
    min-height: 44px; min-width: 44px;
  }
  /* A checkbox with its label beside it rather than around it: tapping the
     words works already, so the pair only needs to be tall enough. */
  input[type="checkbox"] + label {
    display: inline-flex; align-items: center; min-height: 44px;
  }

  /* Hover is deliberately NOT suppressed here. On a touchscreen the hover
     state lands on tap and reads as a press, which is useful. What matters is
     that nothing is only *revealed* by hovering - a title= on a disabled
     button, or on a port chip, is unreachable with a finger, so those say
     what they have to say on the page instead. */
}

@media (max-width: 560px) {
  header { gap: 6px 12px; }
  header h1 { font-size: 17px; }
}
