/* Wormhole console. Monospace for data, a slate ground, and one meaning per semantic colour.
 *
 * Four device states, four colours, used for nothing else; the violet accent is never a
 * status. "Never connected" is light blue, not red: an install that did not complete, not an outage.
 *
 * Themes: bare :root holds the light palette; the prefers-color-scheme block redefines tokens
 * unless data-theme="light" is set; [data-theme="dark"] redefines them again. Colours below are
 * set only through tokens.
 */

/* Self-hosted, vendored in console/vendor/ (pinned and checksummed, see vendor/README.md)
 * rather than loaded from a CDN, same reason xterm.js is vendored: this ships to bastions with
 * no outbound internet. Two weights because the terminal renders SGR bold, and a browser asked
 * for a bold weight it does not have synthesises one by skewing the regular face. */
@font-face {
  font-family: "JetBrains Mono";
  src: url("vendor/JetBrainsMono-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("vendor/JetBrainsMono-Bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

:root {
  --ground:    #f6f6fb;
  --surface:   #ffffff;
  --sunken:    #eeeef6;
  --ink:       #12162b;
  --muted:     #575d78;
  --faint:     #8489a6;
  --line:      #dcdcea;
  --line-firm: #c3c4da;

  --accent:      #5b43d6;
  --accent-ink:  #42309f;
  --accent-wash: #eeebff;
  /* The label on a filled --accent. See .btn-primary. */
  --accent-on:   #ffffff;

  --up:         #157f63;
  --up-wash:    #e0f5ee;
  --flap:       #8e6410;
  --flap-wash:  #f7eeda;
  --down:       #a63c2e;
  --down-wash:  #f8e7e3;
  /* Light blue rather than grey, and deliberately NOT --accent's violet: the accent is
   * reserved so it is never mistaken for a status, and never-connected is a status, so it gets
   * its own, paler blue. */
  --never:      #7ba7c9;
  --never-wash: #eaf2f7;

  --glass:      #a85e14;
  --glass-wash: #faeedf;

  /* JetBrains Mono first: ui-monospace is a CSS generic that always resolves to SOME face, so
   * anything behind it is never reached. It stays as the fallback for the width of a page load
   * before the self-hosted face is ready. */
  --mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas,
          "Liberation Mono", monospace;
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;

  --rail: 208px;
  --radius: 4px;

  /* The scrollbar thumb: a tint of the border, so it reads as part of the surface it sits on,
   * and the border itself under the pointer so it can still be found. Defined once: the mix
   * reads --line-firm on this same element, so each theme block's border gives its own thumb. */
  --scroll-thumb:       color-mix(in srgb, var(--line-firm) 60%, transparent);
  --scroll-thumb-hover: var(--line-firm);
  --term-scroll-thumb:  color-mix(in srgb, var(--term-faint) 60%, transparent);

  /* The terminal's own colours, named once. DECLARED HERE ONLY, and deliberately NOT redefined
   * in either dark block: the device terminal is dark on a light page, so these are the same
   * in both themes. That is the one case where a single definition is the intent rather than
   * the theme bug it usually is. */
  --term-ground: #08131c;
  --term-ink:    #cfd9e0;
  --term-faint:  #5d6f7c;
  --term-line:   #24313a;
  /* Amber, for the state that is genuinely uncertain (no answer). 8.54:1 against
   * --term-ground, which clears AA for body text on the one ground it is ever drawn on. */
  --term-warn:   #e0a35c;
  /* Red, for the states that are refusals. NOT var(--down), though it is that token's dark
   * value: --down is #a63c2e in the light theme, and on the strip's ground, dark in BOTH
   * themes, that measures 2.95:1, below AA. This is 6.10:1 on --term-ground. */
  --term-bad:    #e07460;
}

/* Every scrolling region: nav rail, table boxes, popover lists, the document itself. Thin, no
 * track, the thumb from the tokens above. A browser that honours scrollbar-color ignores the
 * ::-webkit-scrollbar rules, which say the same thing for the ones that do not. */
*, ::before, ::after {
  scrollbar-width: thin;
  scrollbar-color: var(--scroll-thumb) transparent;
}
:hover { scrollbar-color: var(--scroll-thumb-hover) transparent; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); }
/* The terminal is dark in both themes, so its thumb comes from the terminal's own tokens. */
.term-pane .xterm-viewport, .term-pane .xterm-viewport:hover {
  scrollbar-color: var(--term-scroll-thumb) transparent;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ground:    #0b111c;
    --surface:   #121a27;
    --sunken:    #0e1521;
    --ink:       #e4e7f4;
    --muted:     #9aa1bd;
    --faint:     #7b81a0;
    --line:      #222b3d;
    --line-firm: #334060;

    --accent:      #a492ff;
    --accent-ink:  #c4b8ff;
    --accent-wash: #1f1d3d;
    --accent-on:   #0b111c;

    --up:         #35d39a;
    --up-wash:    #0f2a22;
    --flap:       #d39f3d;
    --flap-wash:  #2b2211;
    --down:       #e07460;
    --down-wash:  #2e1a16;
    --never:      #8fb8d6;
    --never-wash: #16232c;

    --glass:      #e09a42;
    --glass-wash: #2e2113;
  }
}

/* .login-screen takes the dark tokens in every theme. The sign-in page is a night sky, and a
 * light card on it is a harsh white-on-black contrast; a light sky did not read as space. */
:root[data-theme="dark"], .shell.login-screen {
  --ground:    #0b111c;
  --surface:   #121a27;
  --sunken:    #0e1521;
  --ink:       #e4e7f4;
  --muted:     #9aa1bd;
  --faint:     #7b81a0;
  --line:      #222b3d;
  --line-firm: #334060;

  --accent:      #a492ff;
  --accent-ink:  #c4b8ff;
  --accent-wash: #1f1d3d;
  --accent-on:   #0b111c;

  --up:         #35d39a;
  --up-wash:    #0f2a22;
  --flap:       #d39f3d;
  --flap-wash:  #2b2211;
  --down:       #e07460;
  --down-wash:  #2e1a16;
  --never:      #8fb8d6;
  --never-wash: #16232c;

  --glass:      #e09a42;
  --glass-wash: #2e2113;
}

*, *::before, *::after { box-sizing: border-box; }

html { color-scheme: light dark; }

body {
  margin: 0;
  /* Explicit, from a token: the page composites over a ground painted in the viewer's
   * theme, and a transparent body silently borrows it. */
  background: var(--ground);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 15px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}

/* ---- shell ------------------------------------------------------------------ */

.shell {
  display: grid;
  grid-template-columns: var(--rail) 1fr;
  /* HEIGHT, not min-height. `min-height: 100vh` lets the grid grow with its content, the rail
   * stretches with it, and `.rail-foot` pins sign-out to the bottom of the document rather
   * than the screen. dvh before vh because on a phone vh includes browser chrome that is not
   * there, so 100vh overflows by the height of the URL bar. vh stays as the fallback. */
  height: 100vh;
  height: 100dvh;
  /* The main column scrolls, so the shell itself must not. Without this the grid's own
   * overflow puts the scrollbar back on the document and the rail travels with it again. */
  overflow: hidden;
}

.rail {
  border-right: 1px solid var(--line);
  background: var(--surface);
  padding: 20px 0;
  display: flex;
  flex-direction: column;
  gap: 24px;
  /* The rail is exactly the screen and never taller. */
  height: 100%;
  min-height: 0;
  /* AND NEVER WIDER. A grid item's automatic minimum size is its min-content, so without this
   * the rail grows to fit its widest unbreakable child and ignores its track, and `overflow:
   * hidden` on .shell then clips the overflow rather than revealing it, leaving sign-out
   * outside the viewport at narrow widths. Same class as the min-height: 0 above. */
  min-width: 0;
  overflow: hidden;
}

/* THE NAV SCROLLS, THE FOOT DOES NOT. Pinning the foot with margin-top:auto inside a
 * fixed-height rail hides it the moment the nav outgrows the screen (a short phone in
 * landscape), so the nav gets the overflow and the foot is outside it. min-height: 0 because
 * a flex child's default min-height is its content, which would make it refuse to shrink and
 * expand the rail instead. */
.nav { min-height: 0; min-width: 0; overflow-y: auto; }

.brand {
  font-family: var(--mono);
  font-size: 13px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--faint);
  padding: 0 20px;
}

.brand { display: flex; align-items: center; gap: 8px; }
/* The same --faint as the name beside it, so at rest it reads as part of the brand line. */
.docs-link { display: inline-flex; color: var(--faint); }
.docs-link[hidden] { display: none; }
.docs-link:hover, .docs-link:focus-visible { color: var(--accent); }

.nav { display: flex; flex-direction: column; }

.nav a {
  font-family: var(--mono);
  font-size: 13px;
  color: var(--muted);
  text-decoration: none;
  padding: 8px 20px;
  border-left: 2px solid transparent;
}
/* The key that reaches this entry, in front of the name. Fixed width so the names line up
 * whether the digit is there or not, and --faint so it reads as a hint beside the entry rather
 * than as part of its name. */
.nav a .nav-key {
  display: inline-block;
  width: 1.1em;
  color: var(--faint);
}
.nav a:hover .nav-key, .nav a[aria-current="page"] .nav-key { color: inherit; }
.nav a:hover { color: var(--ink); background: var(--sunken); }
.nav a[aria-current="page"] {
  color: var(--accent);
  border-left-color: var(--accent);
  background: var(--accent-wash);
}

.rail-foot {
  margin-top: auto;
  padding: 0 20px;
  font-family: var(--mono);
  font-size: 11px;
  color: var(--faint);
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.main { padding: 24px 28px 64px; min-width: 0; }

/* The head holds a status line and no title: the rail names the screen. Fleet and health carry
 * their status at the end of the search row instead and have no head at all. */
.page-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 20px;
}

/* THE TYPOGRAPHY RULE. MONO names or labels something a reader SCANS: an id, a section heading,
 * a table header, a notice's short title, a dialog's title, a number. SANS is prose a reader
 * READS. h1/h2, .dialog-title and .notice .t are the mono set; bodies stay sans by inheriting.
 * The second half, which a font-family declaration cannot enforce: every mono TITLE is a label,
 * not a sentence. A mono sentence directly above sans body text in a small box reads as an
 * accidental font mismatch; the sentence belongs in the body underneath. */
h1 {
  font-family: var(--mono);
  font-weight: 500;
  font-size: 20px;
  letter-spacing: -0.01em;
  margin: 0;
}

h2 {
  font-family: var(--mono);
  font-weight: 500;
  font-size: 14px;
  letter-spacing: 0.02em;
  margin: 28px 0 10px;
  color: var(--muted);
}

.updated {
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--faint);
}
/* A stale dashboard that looks live is the failure this whole subsystem exists to prevent,
 * so staleness is loud rather than absent. */
.updated.is-stale { color: var(--down); }

/* ---- tiles ------------------------------------------------------------------- */
/* The counts are the question and the filter is the answer, so they are one control. */

/* The tiles sit on the search line, inside .filters, rather than in a band of their own: that
 * band cost about 70px of every table screen, which is two rows of the table. They are chips of
 * one line, and .filters wraps them under the search box when the row runs out of room. */
.tiles {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.tile {
  appearance: none;
  text-align: left;
  font: inherit;
  cursor: pointer;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 4px 12px;
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
}
.tile:hover { border-color: var(--line-firm); }
/* The whole border, not the top edge alone: a chip has no top edge to read as a bar. */
.tile[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-wash); }

.tile .n {
  font-family: var(--mono);
  font-size: 14px;
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
}
.tile .k {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}
.tile.t-up    .n { color: var(--up); }
.tile.t-down  .n { color: var(--down); }
.tile.t-flap  .n { color: var(--flap); }
.tile.t-never .n { color: var(--never); }

/* ---- notices ---------------------------------------------------------------- */

.notice {
  border: 1px solid var(--line);
  border-left: 3px solid var(--accent);
  background: var(--surface);
  border-radius: 0 var(--radius) var(--radius) 0;
  padding: 12px 16px;
  margin-top: 14px;
  margin-bottom: 14px;
  font-size: 14px;
}
.notice.is-warn  { border-left-color: var(--flap); }
.notice.is-error { border-left-color: var(--down); }
.notice .t {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  display: block;
  margin-bottom: 4px;
}
.notice a { color: var(--accent); }

/* ---- filters ---------------------------------------------------------------- */

.filters {
  display: flex;
  gap: 10px;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 12px;
}

.input {
  font: inherit;
  font-family: var(--mono);
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line-firm);
  border-radius: var(--radius);
  padding: 6px 10px;
  /* A bare `min-width: 220px` is a hard floor, and a floor wider than the viewport pushes the
   * page sideways instead of shrinking. min() keeps the comfortable default where there is
   * room and gives it up when there is not. 100% rather than 100vw because the container is
   * what constrains it, and the page has padding. */
  min-width: min(220px, 100%);
  max-width: 100%;
}
/* The fleet's and health's search box is twice the width it had: what is typed here is an alias,
 * a name or a location, and 220px cut most of those in half. It gives the width up on a narrow
 * window. The row is picked by the tiles beside it, so the activity log's filters, which hold no
 * tiles, keep the narrow box. */
.filters:has(.tiles) > .input[type="search"] { flex: 0 1 28rem; min-width: min(28rem, 100%); }
/* The freshness and the count end the search row instead of taking a row above it. margin-left
 * rather than an order rule, so that when the row wraps the text still finishes the last line
 * and never lands between two controls. */
.filters > .updated { margin-left: auto; }
.input::placeholder { color: var(--faint); }

.chips { display: flex; gap: 6px; flex-wrap: wrap; }

/* The enrolment tag field: chips sit INSIDE the input's own box, on its right side, rather
 * than in a separate row beneath it, the same "this control IS the value" reasoning as the
 * fleet table's always-visible edit trigger. Only the input itself scrolls/grows; the chip row
 * does not wrap, so a long tag list degrades the same way the fleet table's does: the fourth
 * tag on becomes a count, not a taller box. */
/* .tag-input-wrap carries .input too, for the same border/background/radius every other field
 * on this form has -- only the layout (flex, so the chips can sit on the right) and the padding
 * (moved onto the inner input so a chip does not sit flush against the border) are its own. */
.tag-input-wrap.input {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 3px 6px;
}
.tag-input-wrap .tag-type-input {
  flex: 1 1 6rem;
  min-width: 4rem;
  border: 0;
  background: none;
  padding: 3px 2px;
  font: inherit;
  color: inherit;
  outline: none;
}
.tag-input-chips {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-shrink: 0;
  max-width: 60%;
  overflow: hidden;
}
/* Reuses .rchip/.rchip-x -- the same removable-chip look the fleet row's tag popover already
 * uses, so a tag reads the same whether it is being added here or edited there. */
.tag-input-chips .rchip { flex-shrink: 0; }
/* The "+N" counter sits beside .tag-input-chips rather than inside it, and does not shrink: the
 * chips row clips on purpose (a long tag name must not push the typing area out of the field),
 * and inside that clip the counter was being cut by the field border once three chips were
 * present. Everything the clip hides is recoverable from the counter, so the counter is the one
 * thing that cannot be hidden by it. */
.tag-input-more { display: flex; align-items: center; flex-shrink: 0; }
/* The enrolment tag field anchors its own .combo-list. .combo-wrap carries a margin-top
 * meant for the fleet row's popover, which would push this field's suggestions off the input,
 * so the positioning half is its own class and the list styling is shared. */
.combo-anchor { position: relative; }
.tag-overflow {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.chip {
  appearance: none;
  font: inherit;
  font-family: var(--mono);
  font-size: 11.5px;
  cursor: pointer;
  background: var(--accent-wash);
  color: var(--accent-ink);
  border: 1px solid var(--accent);
  border-radius: 999px;
  padding: 2px 8px 2px 10px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.chip .x { font-size: 13px; line-height: 1; }

.link-btn {
  appearance: none;
  background: none;
  border: 0;
  font: inherit;
  font-family: var(--mono);
  font-size: 12px;
  color: var(--accent);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
  padding: 2px;
}

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

.table-wrap {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  overflow-x: auto;
}

table { border-collapse: collapse; width: 100%; font-size: 14px; }

/* THE FLEET AND HEALTH TABLES FIT THE WINDOW AND COMPRESS. They used to be as wide as their
 * widest row and scroll sideways, which hid the actions column and the check button behind a
 * scrollbar on a 1024px laptop. `table-layout: fixed` means the header row decides the widths
 * and no cell can widen a column, so every column stays on screen and long values clip.
 *
 * The narrow columns take a fixed width and the text columns take none, which is how they
 * share what is left. Vertical scrolling is unchanged: .table-wrap still scrolls its rows. */
table.grid-table { table-layout: fixed; }
table.grid-table th, table.grid-table td { overflow: hidden; text-overflow: ellipsis; }
/* The actions cell is no longer "as narrow as its content": under a fixed layout the header
 * sets the width, and `width: 1%` there would starve it. */
table.grid-table td.actions { width: auto; }

/* The narrow columns. Same values on both tables, so a row on Fleet and the same row on Health
 * start at the same place and the eye does not have to re-find the device. */
/* Wide enough for the longest badge on one line: "flapping · 3/24h" and "never connected" in
 * uppercase mono. Below the narrow query this column gives the pixels back and the badge clips
 * to an ellipsis again, with the pill's title holding the whole of it. */
table.grid-table th.col-state { width: 192px; }
table.grid-table th.col-actions { width: 166px; }
/* Wide enough for the word "Check", not only for the button under it: a header clipped to one
 * letter names nothing. */
table.grid-table th.col-check { width: 62px; }
table.grid-table th.col-check, table.grid-table td.col-check { padding-left: 8px; padding-right: 8px; }
table.grid-table th.row-term { width: 26px; }

/* The text columns take no width at all. A fixed table hands what the sized columns leave to
 * the columns that ask for nothing, in equal parts, so these are equal shares of the remainder
 * at every window width. No arithmetic to keep in step: hiding a column below gives its pixels
 * to these by itself. (Shares in a ratio would be the nicer answer, but a column width written
 * as a percentage of a table that is itself a percentage is circular, and the browser drops it
 * and falls back to an equal share.) */
.fleet-table th.col-dev,
.fleet-table th.col-name,
.fleet-table th.col-location,
.health-table th.col-dev,
.health-table th.col-name,
.health-table th.col-os,
.health-table th.col-model { width: auto; }

/* Tags is one width at every window, not a share: a share of a narrow table leaves room for
 * "+N" and nothing else, and a tags column with no tag in it is a count where chips were. This
 * is the 205px the column measured before it was given a fixed layout, rounded; it holds two
 * chips and the edit control, and the rest fold as they always did. */
.fleet-table th.col-tags { width: 210px; }
/* Above this width an equal share is wider than the floor, so Tags joins the columns that
 * share what is left and a third chip can appear. */
@media (min-width: 1650px) { .fleet-table th.col-tags { width: auto; } }
/* 200px, so the strip inside it is 172px after the cell padding: seven days is 168 hours, and
 * an hour of downtime that draws less than a pixel draws a quiet week the device did not have. */
.fleet-table th.col-history { width: 200px; }
.health-table th.col-uptime { width: 70px; }
.health-table th.col-checked { width: 150px; }
.health-table th.col-num { width: 62px; }
/* Less padding than the wider fact columns: these three hold four characters at most, and the
 * 14px each side that suits "Ubuntu 22.04" would take half the column. */
.health-table td.col-num { padding-left: 8px; padding-right: 8px; }

/* What goes as the window narrows, widest step first. Nothing that names the device or its
 * state is ever hidden, and no action is ever lost: the icons fold into the menu that already
 * holds the same list.
 *
 * The 7-day strip was specified to go below 720px. The arithmetic does not reach: at an 800px
 * window the table box is 524px, and the fixed columns alone are more than that while the strip
 * is there. It goes at 1100px, the widest step where the table is still readable without it. */
@media (max-width: 1400px) {
  /* The action icons fold into their menu trigger here, not only below 780px: five icons in a
   * row cost 166px, and below this width that is a fifth of the table for controls the fold
   * offers in full. Same list either way, one trigger instead of five icons. */
  .row-actions-icons { display: none; }
  .row-actions-fold-trigger { display: inline-flex; align-items: center; justify-content: center; }
  table.grid-table th.col-actions { width: 44px; }
}
@media (max-width: 1100px) {
  /* CPU, memory and disk go together: three columns for three numbers, all three of which the
   * device page also carries. */
  .health-table th.col-num, .health-table td.col-num { display: none; }
  .fleet-table th.col-history, .fleet-table td.col-history { display: none; }
}
@media (max-width: 900px) {
  .health-table th.col-uptime, .health-table td.col-uptime { display: none; }
  /* Shorter without the year-round room for "a minute ago": at this width the table needs the
   * pixels more than the phrase needs to be unclipped. */
  .health-table th.col-checked { width: 110px; }
  table.grid-table th.col-state { width: 110px; }
  .fleet-table th.col-tags { width: 140px; }
  /* Location goes last and only here. Four text columns in 524px is about ten characters each,
   * which names nothing; with Location on the device page and in the search, Device and Name
   * get a width worth reading. */
  .fleet-table th.col-location, .fleet-table td.col-location { display: none; }
  /* Model goes for the same reason, and before OS: an operator reading a narrow window wants
   * to know what the device runs, not what it is. */
  .health-table th.col-model, .health-table td.col-model { display: none; }
}

th {
  text-align: left;
  font-family: var(--mono);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--faint);
  background: var(--sunken);
  border-bottom: 1px solid var(--line);
  padding: 9px 14px;
  white-space: nowrap;
  position: sticky;
  /* Not a bare 0: the shell status bar above it is sticky too, and --status-h is that bar's
   * own current height (zero when it is not shown), kept live by wireStatusHeight() in app.js.
   * Without this a table's header parks itself at the same y as a status bar and the two
   * draw on top of each other. */
  top: var(--status-h, 0px);
}

th .sort {
  appearance: none;
  background: none;
  border: 0;
  font: inherit;
  /* Buttons do not inherit text-transform from the UA stylesheet, so a sortable header
   * rendered "Device" beside a plain one rendering "TAGS". */
  text-transform: inherit;
  letter-spacing: inherit;
  color: inherit;
  cursor: pointer;
  padding: 0;
  display: inline-flex;
  gap: 5px;
  align-items: center;
}
/* THE SORTED HEADER IS NOT BRIGHTER. Both tables sort by State on arrival, so a colour rule
 * would make exactly one column title read as foreground, which reads as a different kind of
 * text rather than as a sort indicator. A caret after the sorted column's label carries the
 * column and the direction instead. Driven by aria-sort, which the views set on the th, so
 * the semantics and the mark have one source and cannot disagree. No mark on the Tags header,
 * which is a filter trigger rather than a sort. */
th[aria-sort] .sort::after {
  content: '\25B2';
  font-size: 9px;
  line-height: 1;
}
th[aria-sort="descending"] .sort::after { content: '\25BC'; }

td {
  padding: 8px 14px;
  border-bottom: 1px solid var(--line);
  font-variant-numeric: tabular-nums;
}
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: var(--sunken); }

td.id { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
td.id a { color: var(--ink); text-decoration: none; }
td.id a:hover { color: var(--accent); text-decoration: underline; }
td.actions { text-align: right; width: 1%; white-space: nowrap; }

/* ---- fleet table: device alias, tags, actions ---------------------------------
 * There is no "last seen" column: the state pill already says roughly how long, and the exact
 * figure is one tap away on the device page. */

td.id { position: relative; }
tbody tr { cursor: pointer; }
/* Real controls inside a clickable row keep their own cursor rather than inheriting the
 * row's, so a link or a chip does not look like plain text pretending to be clickable. */
tbody tr a, tbody tr button { cursor: pointer; }

/* The first cell of every row: the sign that a click on the row opens a terminal. Quiet until
 * the row is pointed at, because it is the same glyph in every row and the fleet is read for
 * what differs between rows. The column is as narrow as the glyph. */
td.row-term, th.row-term { width: 1px; padding-left: 10px; padding-right: 0; color: var(--faint); }
/* The health row's own check, right-most. Always visible: unlike a pencil it is the one action
 * the row offers, and a control that appears on hover is a control nobody finds. */
td.col-check, th.col-check { width: 1px; text-align: center; }
tr.is-checking .row-check svg { animation: wh-spin 1s linear infinite; }
@keyframes wh-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { tr.is-checking .row-check svg { animation: none; } }
.fact-checking { color: var(--muted); font-style: italic; }
td.row-term svg { display: block; }
tbody tr:hover td.row-term { color: var(--ink); }

/* .device-alias sits inside .id-copy, the button that copies the id (idCopyControl in
 * lib/terminal-tab.js), so a click on the alias or id copies and the rest of the row opens the
 * terminal. One line, alias then id then pencil, so a row with an alias and one without are the
 * same height and a fleet of both reads as one grid. The id keeps .row-sub, which is what tells
 * the two apart. Both tables use these classes, so the fleet and health move together. */
.device-alias { color: var(--ink); display: flex; align-items: baseline; gap: 8px; }
/* The name and location cells copy the same way (textCopyControl), so the two controls carry one
 * rule: no button appearance, and the accent colour under the pointer as the whole hint that the
 * text does something. There is no icon: four icons in a row, one per copyable cell, read as
 * four things to do. */
td.id .id-copy, td.meta .text-copy {
  appearance: none; background: none; border: 0; padding: 0; margin: 0;
  font: inherit; color: inherit; text-align: left;
  display: inline-flex; align-items: center;
  cursor: copy;
}
td.id .id-copy:hover, td.meta .text-copy:hover,
td.id .id-copy:focus-visible, td.meta .text-copy:focus-visible { color: var(--accent); }
/* The pencil sits to the right of the alias, on the one line the cell has. */
td.id .alias-line, td.meta .meta-line { display: flex; align-items: center; gap: 6px; }
td.id .alias-line[hidden], td.meta .meta-line[hidden] { display: none; }
/* Name and location: free text, the same editor as the alias, and an empty cell still holds the
 * pencil so a value can be given where there is none. One line, as td.id is, so a row with a
 * location is the height of one without; .table-wrap scrolls when the fleet is wide. */
td.meta { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
/* A long value is clipped at the cell edge, with the whole on the tooltip. Display only: the
 * copy control, the editor and the search hold the full string. There is no max-width, because
 * the column is the cap -- `table-layout: fixed` above -- and the window sets the column. */
.device-text {
  color: var(--ink); min-width: 1ch;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* An ellipsis needs a clear path from the cell to the text. Every box between the two is a flex
 * item, and a flex item is at least as wide as its content unless it is told otherwise, so
 * without these the text pushes past the cell edge and the cell cuts it off mid-character. */
table.grid-table td.id .alias-line,
table.grid-table td.meta .meta-line,
table.grid-table td.id .id-copy,
table.grid-table td.meta .text-copy,
table.grid-table .device-alias,
table.grid-table .device-alias > * { min-width: 0; }
/* The copy control is an inline box, and an inline box does not shrink to its cell by itself:
 * without this the button keeps the full width of the text it holds and reaches across the
 * columns beside it, where a click on the name lands on another row's cell. */
table.grid-table td.id .id-copy,
table.grid-table td.meta .text-copy { max-width: 100%; }
table.grid-table .device-alias > * {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
tbody tr:hover .device-alias { color: var(--accent); }
/* A pencil shows when the pointer is over its own cell, not anywhere in the row: a row of four
 * pencils lighting at once reads as four things to do. Hidden by opacity, not `display`, so the
 * cell width does not change. The keyboard reveal is `:has(:focus-visible)` on the cell, not
 * `:focus-within` on the row: the row itself is a Tab stop, so a click on it leaves it focused
 * and `:focus-within` held every pencil in the row lit until the next draw. Only under
 * `hover: hover`: on a touch device it stays visible. */
@media (hover: hover) {
  td.id .alias-pencil, td.meta .meta-pencil { opacity: 0; transition: opacity 90ms linear; }
  td.id:hover .alias-pencil,
  td.id:has(:focus-visible) .alias-pencil,
  td.meta:hover .meta-pencil,
  td.meta:has(:focus-visible) .meta-pencil { opacity: 1; }
}
@media (hover: hover) and (prefers-reduced-motion: reduce) {
  td.id .alias-pencil, td.meta .meta-pencil { transition: none; }
}
.row-sub { font-family: var(--mono); font-size: 11.5px; color: var(--faint); text-decoration: none; }

.alias-edit, .meta-edit {
  font: inherit; font-family: var(--mono); font-size: 13px; padding: 3px 6px;
  border: 1px solid var(--accent); border-radius: 3px; background: var(--surface); color: var(--ink);
  width: 168px; max-width: 100%;
}
td.id .field-hint, td.meta .field-hint { margin-top: 3px; max-width: 220px; white-space: normal; }
/* .alias-suggest is a <button> styled as a field-hint (the hostname suggestion), so it needs
 * a UA-button reset. */
.alias-suggest {
  appearance: none; background: none; border: 0; font: inherit; padding: 0; display: block;
  text-align: left; text-decoration: underline; cursor: pointer;
}
.alias-suggest:hover { color: var(--accent); }

/* The chips, the "+N" overflow trigger and the edit control are one row of controls, and they
 * are spaced as one: the same 4px between a chip and its neighbour, between the last chip and
 * "+N", and between "+N" and the edit button. Same value in both rules on purpose: one number,
 * applied at both levels, is what makes the three distances equal. */
/* One line, never two. The tags column has the width the table gives it, and a cell that
 * wrapped would make every row in the table taller to serve the one device with four tags.
 * What does not fit goes behind the "+N" control, which updateRow sizes by measuring. */
.tag-cell-row { display: flex; flex-wrap: nowrap; gap: 4px; align-items: center; min-width: 0; }
.tag-chip-row { display: inline-flex; flex-wrap: nowrap; gap: 4px; align-items: center; min-width: 0; overflow: hidden; }
.tag-cell-row > .chip-more, .tag-cell-row > .tag-edit-btn { flex: 0 0 auto; }
/* Between chips, never inside a tag name: "customer-a" broken across two lines reads as two
 * different tags. A chip is an identifier: it wraps as a unit or the column gets wider and the
 * table scrolls, which is what .table-wrap's overflow-x is there for. */
.tag-chip-row > * { white-space: nowrap; }
.chip-static {
  font-family: var(--mono);
  font-size: 11px;
  background: var(--sunken);
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 1px 8px;
}
.chip-more {
  appearance: none;
  font: inherit;
  font-family: var(--mono);
  font-size: 11px;
  cursor: pointer;
  background: var(--surface);
  color: var(--muted);
  border: 1px dashed var(--line-firm);
  border-radius: 999px;
  padding: 1px 7px;
}
.chip-more:hover { border-color: var(--accent); color: var(--accent); }

/* A tag is a control, on a row and in the rail above the search box. It keeps
 * .chip-static's quiet appearance UNCHANGED in its off state, deliberately: a tags column is
 * mostly tags that are not part of the current filter, and repainting all of them to announce
 * that they became clickable would cost more legibility than the affordance gains. What a
 * button needs and a span does not is only the appearance and cursor reset. */
.chip-filter {
  appearance: none;
  font: inherit;
  font-family: var(--mono);
  font-size: 11px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.chip-filter:hover { border-color: var(--accent); color: var(--accent-ink); }
.chip-filter:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* NOT COLOUR ALONE: the weight changes with the wash, and aria-pressed carries the same fact
 * to a screen reader, so "in the filter" survives both a monochrome display and no display. */
.chip-filter.is-on {
  background: var(--accent-wash);
  color: var(--accent-ink);
  border-color: var(--accent);
  font-weight: 700;
}
/* The count, inside the toggle so a wrap cannot separate a number from the tag it counts. */
.chip-filter .c {
  font-variant-numeric: tabular-nums;
  font-size: 10.5px;
  color: var(--faint);
  border-left: 1px solid var(--line);
  padding-left: 6px;
}
.chip-filter.is-on .c { color: var(--accent-ink); border-left-color: var(--accent); }
/* A tag no device in the current view carries. Dimmed rather than hidden -- see drawTagRail. */
.chip-filter.is-empty { opacity: .45; }

/* The rail itself: the fleet's whole tag vocabulary, visible without opening anything. */
.tag-rail {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  flex-wrap: wrap;
  padding: 9px 11px;
  margin-bottom: 10px;
  background: var(--sunken);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.tag-rail[hidden] { display: none; }
.tag-rail .rail-label {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
  padding-top: 3px;
  flex: 0 0 auto;
}
.rail-tags { display: flex; flex-wrap: wrap; gap: 6px; flex: 1 1 300px; }

/* The +N popover: the overflow tags only, since the first three are already visible inline
 * in the row and repeating them would be the row's own noise problem moved one click deep.
 *
 * display IS GATED ON :popover-open, and that is not a style preference. The UA stylesheet
 * hides a popover with `[popover]:not(:popover-open) { display: none }`, and author rules
 * outrank UA rules by ORIGIN before specificity is even considered -- an unconditional
 * `display: flex` here, at any specificity, wins the cascade and the popover renders open
 * all the time, from the moment it exists. */
.tag-popover {
  padding: 8px;
  border: 1px solid var(--line-firm);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.16);
  max-width: 16rem;
  /* Reset the UA's centring so anchor positioning below is what places it, same as .menu-panel. */
  margin: 0;
  inset: auto;
}
.tag-popover:popover-open { display: flex; flex-direction: column; gap: 4px; }
.tag-popover .chip-static { align-self: flex-start; }

/* The tag-EDIT trigger and its popover. Always visible, unlike .chip-more above,
 * because editing has to be reachable even at zero tags. */
.tag-edit-btn {
  appearance: none;
  background: none;
  border: 1px dashed var(--line-firm);
  border-radius: 999px;
  color: var(--faint);
  font-size: 11px;
  line-height: 1.5;
  padding: 0 6px;
  cursor: pointer;
}
.tag-edit-btn:hover, .tag-edit-btn:focus-visible { color: var(--accent); border-color: var(--accent); }

/* overflow: visible OVERRIDES THE UA STYLESHEET, not a preference. [popover]'s own default
 * stylesheet sets `overflow: auto`, so without this the combo-list dropdown below -- which is
 * absolutely positioned and deliberately extends past the popover's own content box -- gets
 * clipped to a sliver with its own scrollbar instead of appearing as a dropdown. */
.tag-edit-popover { width: 15rem; max-width: 90vw; overflow: visible; }
.tag-popover-current { display: flex; flex-wrap: wrap; gap: 5px; }

/* The removable chip inside the edit popover -- .chip-static's read-only cousin, with an ×
 * that costs one whole-list write, same as the device page's own tag chips. */
.rchip {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  font-family: var(--mono);
  font-size: 11px;
  background: var(--sunken);
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 1px 3px 1px 8px;
}
.rchip-x {
  appearance: none;
  background: none;
  border: 0;
  color: var(--faint);
  cursor: pointer;
  font-size: 12px;
  line-height: 1;
  padding: 2px;
}
.rchip-x:hover, .rchip-x:focus-visible { color: var(--down); }

.combo-wrap { position: relative; margin-top: 6px; }
.combo-input {
  width: 100%;
  font: inherit;
  font-family: var(--mono);
  font-size: 12.5px;
  padding: 5px 7px;
  border: 1px solid var(--line-firm);
  border-radius: 3px;
  background: var(--ground);
  color: var(--ink);
}
.combo-input:disabled { opacity: 0.6; }
.combo-list {
  position: absolute;
  top: calc(100% + 3px);
  left: 0;
  right: 0;
  background: var(--surface);
  border: 1px solid var(--line-firm);
  border-radius: 3px;
  box-shadow: 0 6px 16px rgb(0 0 0 / 0.16);
  max-height: 9rem;
  overflow-y: auto;
  z-index: 1;
}
.combo-opt {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  font-family: var(--mono);
  font-size: 12px;
  background: none;
  border: 0;
  padding: 5px 8px;
  cursor: pointer;
  color: var(--ink);
}
.combo-opt:hover, .combo-opt:focus-visible { background: var(--accent-wash); color: var(--accent-ink); }
.combo-opt.add-new { color: var(--up); border-top: 1px solid var(--line); }
.tag-edit-popover .field-hint { margin-top: 6px; }

/* The Tags column header's own filter, a checklist rather than a menu: checking a box must
 * not close the panel the way choosing a menu item does.
 *
 * display is gated on :popover-open for the same reason .tag-popover's is -- see the comment
 * there. */
.tag-filter-panel {
  padding: 6px;
  border: 1px solid var(--line-firm);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.16);
  max-height: 60vh;
  overflow-y: auto;
  min-width: 14rem;
  /* Reset the UA's centring so anchor positioning below is what places it, same as .menu-panel. */
  margin: 0;
  inset: auto;
}
.tag-filter-panel:popover-open { display: flex; flex-direction: column; gap: 2px; }
.tag-filter-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 6px;
  border-radius: 4px;
  font-weight: 400;
  text-transform: none;
  letter-spacing: normal;
  color: var(--ink);
  cursor: pointer;
}
.tag-filter-row:hover { background: var(--sunken); }
.tag-filter-name { font-family: var(--mono); font-size: 12.5px; flex: 1; }
.tag-filter-count {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}

.empty {
  padding: 40px 16px;
  text-align: center;
  color: var(--muted);
  font-size: 14px;
}

/* ---- pills ------------------------------------------------------------------ */
/* Never colour alone: a dot, a colour and a word. Some of these engineers are colour-blind
 * and all of them are reading a laptop screen in a plant. */

.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  padding: 2px 8px;
  border-radius: 3px;
  white-space: nowrap;
  border: 1px solid transparent;
}
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
/* In a fixed-width State column the pill can be wider than its cell. It clips inside its own
 * border with an ellipsis, so the badge still reads as a badge: the state word comes first and
 * the age after it, and the age is what an ellipsis takes. */
table.grid-table .pill { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
table.grid-table .pill::before { display: inline-block; margin-right: 6px; vertical-align: 1px; }

.pill.s-up    { color: var(--up);    background: var(--up-wash);    border-color: var(--up); }
.pill.s-flap  { color: var(--flap);  background: var(--flap-wash);  border-color: var(--flap); }
.pill.s-down  { color: var(--down);  background: var(--down-wash);  border-color: var(--down); }
.pill.s-never { color: var(--never); background: var(--never-wash); border-color: var(--never); }

/* ---- health bands -------------------------------------------------------------
 * Colour AUGMENTS a number here, it never replaces one -- every hband still renders
 * the figure the collector reported, so a colour-blind reader loses the same information a
 * sighted one keeps if they read the digits rather than the tint. */
.hband { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.hband-ok      { color: var(--up); }
.hband-warn    { color: var(--flap); }
.hband-crit    { color: var(--down); }
.hband-unknown { color: var(--faint); }

/* OS and model are text in a column group that is otherwise numbers, and they are long:
   "Debian GNU/Linux 12 (bookworm)" is wider than every other collected column put together. A
   row that grows to fit them pushes the numbers off the right of a laptop screen, so the cell
   truncates and the full string stays reachable in the title attribute. No colour of its own --
   this is a fact, not a judgement, and nothing here bands. */
.fact-text {
  display: inline-block;
  max-width: 18ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: bottom;
}
/* TWICE THE ALLOWANCE, FOR OS ONLY. At 18ch "Debian GNU/Linux 12 (bookworm)" loses its release
 * name, which is the part an operator reads. Here rather than in .fact-text because Model
 * shares that class and its strings are shorter. The cell still truncates and still carries
 * the full string as a title; only where the truncation starts changes. */
td[data-fact='os'] .fact-text { max-width: 36ch; }

/* ---- fact columns ---------------------------------------------------------------
 * The columns fed by /api/health: collected facts from a collector that ran up to an hour ago,
 * beside the live Device and State columns.
 */
/* `col-fact`, NOT `fact`: the device page's own facts grid owns a bare `.fact` (below, with a
 * border-right and its own padding), and a td carrying that class would inherit a vertical
 * rule between every one of these columns. Prefixed the way `col-tags` is, so the table's
 * column classes cannot collide with a component's. */
td.col-fact {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
/* No divider between State and the facts: every column but Device and State is a collected
 * fact, so a rule there would mark a boundary that does not separate two kinds of thing. */

/* Numbers right-aligned would read better in isolation and worse here: the group sits between
 * a left-aligned State pill and a left-aligned action row, and two alignment flips across one
 * row is more noise than the columns are worth. Fixed width keeps the digits from shifting the
 * action row sideways as values change under a poll. */
td.col-fact { padding-left: 14px; padding-right: 14px; }

/* The collector has never reached this device. Raw muted text spanning the five cells, not a
 * chip: a chip reads as a status the device has, and this is the absence of one. */
.fact-none { color: var(--faint); font-style: italic; }

/* The second clock: the collector cannot currently reach the device, while the facts above
 * stay true of when it last could. Smaller and below, so it reads as a qualifier on the age
 * rather than as a sixth number. */

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

.btn {
  appearance: none;
  font: inherit;
  font-family: var(--mono);
  font-size: 12.5px;
  cursor: pointer;
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-firm);
  border-radius: var(--radius);
  padding: 6px 12px;
}
a.btn { text-decoration: none; display: inline-block; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn:disabled { cursor: not-allowed; opacity: 0.5; }
.btn:disabled:hover { border-color: var(--line-firm); color: var(--ink); }

/* The label is a token rather than --surface. White on the dark theme's violet (#a492ff) is
 * 2.58:1, below AA for any text, so a light accent needs a dark label; the ground navy on it
 * is 7.32:1. Light stays white on #5b43d6 at 6.52:1. */
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-on); }
.btn-primary:hover { color: var(--accent-on); filter: brightness(1.1); }

.btn-destructive { background: var(--glass); border-color: var(--glass); color: var(--surface); }
.btn-destructive:hover { color: var(--surface); filter: brightness(1.08); }

/* ---- the ... menu ----------------------------------------------------------- */

.menu-trigger {
  appearance: none;
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius);
  color: var(--muted);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  padding: 2px 8px;
}
.menu-trigger:hover { color: var(--ink); border-color: var(--line-firm); background: var(--surface); }

/* ---- the fleet row's inline action icons, and the fold that replaces them below the
 * breakpoint the row action menu already had ----------------------------------------------- */

.row-actions-icons { display: inline-flex; align-items: center; gap: 2px; }
/* Hidden above the breakpoint by default; the @media block below is what shows it and hides
 * .row-actions-icons in its place -- exactly one of the two is ever visible, never both and
 * never neither, which is the "all-or-nothing" half of the fold. */
.row-actions-fold-trigger { display: none; }

.icon-btn {
  appearance: none; background: none; border: 1px solid transparent; border-radius: var(--radius);
  width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center;
  color: var(--muted); cursor: pointer; padding: 0; flex: none;
}
.icon-btn svg { width: 15px; height: 15px; display: block; }
.icon-btn:hover, .icon-btn:focus-visible { color: var(--ink); background: var(--sunken); border-color: var(--line-firm); }
.icon-btn:disabled, .icon-btn[aria-disabled="true"] { color: var(--line-firm); cursor: not-allowed; }
.icon-btn:disabled:hover, .icon-btn[aria-disabled="true"]:hover { background: none; border-color: transparent; color: var(--line-firm); }
.icon-btn.is-destructive { color: var(--glass); }
.icon-btn.is-destructive:hover, .icon-btn.is-destructive:focus-visible { background: var(--glass-wash); border-color: var(--glass); color: var(--glass); }

.menu-panel {
  /* The popover is in the top layer, so it needs its own painted surface -- it does not
   * inherit the page's. */
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-firm);
  border-radius: var(--radius);
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.16);
  padding: 4px;
  min-width: 190px;
  /* Reset the UA's centring so anchor positioning below is what places it. */
  margin: 0;
  inset: auto;
}

/* Attached to its trigger, flipping above when there is no room below. position-area does
 * the flip on its own -- no measurement, no scroll listener. */
@supports (anchor-name: --probe) {
  .menu-panel {
    position-area: bottom span-left;
    position-try-fallbacks: top span-left, bottom span-right, top span-right;
    margin-top: 4px;
  }
  /* Same attachment as .menu-panel, for the fleet table's own popovers: the +N tag overflow
   * and the Tags header's filter checklist. anchoredPopover() (lib/menu.js) wires the
   * anchor-name/position-anchor pair in JS; this is the declarative half that actually moves
   * the panel once that pair exists, and without it the panel would sit centred by the UA
   * default with the anchor silently doing nothing. */
  .tag-popover, .tag-filter-panel {
    position-area: bottom span-left;
    position-try-fallbacks: top span-left, bottom span-right, top span-right;
    margin-top: 4px;
  }
}

.menu-item {
  appearance: none;
  display: block;
  width: 100%;
  text-align: left;
  text-decoration: none;
  font: inherit;
  font-size: 13px;
  background: none;
  border: 0;
  border-radius: 3px;
  color: var(--ink);
  cursor: pointer;
  padding: 6px 10px;
}
.menu-item:hover { background: var(--accent-wash); color: var(--accent-ink); }
.menu-item.is-destructive { color: var(--glass); }
.menu-item.is-destructive:hover { background: var(--glass-wash); }

/* Disabled, not hidden. Hiding an action you may not take produces an engineer who believes
 * the console is broken; at 2am that costs more than the tidier menu saves. */
.menu-item[aria-disabled="true"] { color: var(--faint); cursor: not-allowed; }
.menu-item[aria-disabled="true"]:hover { background: none; color: var(--faint); }

.menu-why {
  display: block;
  font-family: var(--sans);
  font-size: 11.5px;
  line-height: 1.35;
  color: var(--faint);
  margin-top: 2px;
}

/* ---- dialog ----------------------------------------------------------------- */

.dialog {
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-firm);
  border-radius: var(--radius);
  padding: 22px 24px;
  max-width: 30rem;
  width: calc(100vw - 32px);
  box-shadow: 0 12px 40px rgb(0 0 0 / 0.24);
}
.dialog::backdrop { background: rgb(0 0 0 / 0.45); }

.dialog-title { font-family: var(--mono); font-weight: 500; font-size: 16px; margin: 0 0 10px; }
.dialog-body { margin: 0 0 16px; color: var(--muted); font-size: 14px; }
.dialog-label { display: block; font-size: 13px; margin-bottom: 6px; }
.dialog-label code { font-family: var(--mono); background: var(--sunken); padding: 1px 5px; border-radius: 3px; }
.dialog .input { width: 100%; }
/* The count a progress dialog reports, in the numeral font the fact columns use, because it is
   read as a figure that changes rather than as a sentence. */
.dialog-progress {
  margin: 0;
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-size: 14px;
  color: var(--ink);
}

.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
/* The web-UI picker's choices: full-width rows rather than a button strip, because a
   device can declare several and their names are the thing being read, not decoration on a
   confirm. Stacked, so a fourth choice grows the dialog downward instead of shrinking the
   labels. */
.dialog-choices { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; }
.dialog-choice { justify-content: flex-start; text-align: left; text-decoration: none; }

/* Form dialogs (add operator, change password) are their own <dialog>, built by
 * views/operators.js, and lib/dialog.js's code prompt opens as a second one on top. Two
 * top-layer entries stack on their own. Wider than the base .dialog because it holds a QR with
 * text beside it. */
.dialog-form { max-width: 36rem; position: relative; }
/* CSS-drawn X, not a glyph: '✕' (U+2715) renders as tofu in this font stack. Two rotated
 * bars remove the dependency on which code points the fonts happen to cover. */
.dialog-close {
  position: absolute; top: 14px; right: 14px;
  width: 28px; height: 28px; border-radius: 50%; border: none; background: transparent;
  cursor: pointer; padding: 0;
}
.dialog-close::before, .dialog-close::after {
  content: ''; position: absolute; top: 50%; left: 50%;
  width: 14px; height: 2px; background: var(--faint);
  transform-origin: center;
}
.dialog-close::before { transform: translate(-50%, -50%) rotate(45deg); }
.dialog-close::after  { transform: translate(-50%, -50%) rotate(-45deg); }
.dialog-close:hover { background: var(--sunken); }
.dialog-close:hover::before, .dialog-close:hover::after { background: var(--ink); }
/* A bare .field has gap:5px inside it (label to control to hint) but nothing between one
 * .field and the next; that gap belongs to the container of a field sequence, as it does in
 * .form-grid and .login-form. */
.dialog-form-body { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }

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

.toasts {
  position: fixed;
  right: 16px;
  bottom: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  z-index: 50;
  max-width: min(30rem, calc(100vw - 32px));
}

.toast {
  background: var(--surface);
  border: 1px solid var(--line-firm);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  padding: 10px 12px;
  font-size: 13.5px;
  box-shadow: 0 4px 14px rgb(0 0 0 / 0.14);
  display: flex;
  gap: 10px;
  align-items: flex-start;
}
.toast-success { border-left-color: var(--up); }
.toast-error   { border-left-color: var(--down); }
.toast-text { flex: 1; }
.toast-close {
  appearance: none; background: none; border: 0; cursor: pointer;
  color: var(--muted); font-size: 16px; line-height: 1; padding: 0 2px;
}

/* ---- sparkline -------------------------------------------------------------- */

/* Scales to its column. preserveAspectRatio="none" is set on the element: this is a
 * proportion strip, not a shape, so stretching it horizontally is exactly right. */
.sparkline { display: block; width: 100%; max-width: 560px; height: 22px; border-radius: 2px; overflow: hidden; }

/* The same strip in a table cell: shorter, and as wide as the column, whose width is set with
 * the others above. Below 1100px there is not even that much room, and the column is hidden
 * rather than allowed to draw a quiet week the device did not have. */
th.col-history, td.col-history { min-width: 0; }
td.col-history .sparkline { height: 14px; max-width: 100%; }
.spark-ground { fill: var(--sunken); }
.spark-up   { fill: var(--up); }
.spark-down { fill: var(--down); }
/* The same token as the fleet row's .pill.s-never, whatever that token is: a device's strip
 * and its own row must read as one signal, not two. Never --accent, which is reserved for the
 * interactive/brand accent. */
.spark-new  { fill: var(--never); }

/* ---- facts (device detail) -------------------------------------------------- */

/* Cell borders rather than a 1px grid gap over a coloured container: with auto-fit, the
 * last row is usually short, and the gap trick paints the leftover track in the line
 * colour -- a grey slab hanging off the end of the grid. Borders leave it as surface. */
.facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
}
.fact {
  padding: 12px 14px;
  border-right: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}
.fact .k {
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--faint);
  margin-bottom: 4px;
}
.fact .v { font-family: var(--mono); font-size: 14px; font-variant-numeric: tabular-nums; }
.fact .v.is-warn { color: var(--down); }
.fact .v.is-ok { color: var(--up); }

.cmd {
  display: flex;
  gap: 8px;
  align-items: center;
  background: var(--sunken);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 8px 10px;
  font-family: var(--mono);
  font-size: 12.5px;
  overflow-x: auto;
}
.cmd code { white-space: nowrap; }


/* THE WHOLE LINE IS THE CONTROL, on the enrolment page: button.cmd rather than a div with a
 * button beside it. appearance: none and the rest undo the UA's own button skin, which .cmd's
 * background/border override in most browsers already but not reliably everywhere. */
button.cmd {
  appearance: none;
  /* Sized to its content and capped at its container, so a long one-liner never widens the
   * page; .cmd's overflow-x: auto (with nowrap on the code) scrolls it internally past the
   * cap rather than wrapping mid-command. */
  width: fit-content;
  max-width: 100%;
  text-align: left;
  color: inherit;
  cursor: pointer;
}
button.cmd:hover, button.cmd:focus-visible { border-color: var(--accent); }
button.cmd code { flex: 1; overflow-x: auto; }
/* Subtle until it matters: full opacity only on hover or keyboard focus, so the icon reads as
 * an affordance rather than another piece of the command to parse. */
.copy-icon {
  margin-left: auto;
  flex: none;
  display: inline-flex;
  color: var(--faint);
  opacity: 0.55;
}
button.cmd:hover .copy-icon, button.cmd:focus-visible .copy-icon {
  opacity: 1;
  color: var(--accent);
}
button.cmd.is-primary { border-color: var(--accent); background: var(--accent-wash); }
button.cmd.is-primary .copy-icon { color: var(--accent-ink); }

/* ---- responsive ------------------------------------------------------------- */
/* Down to a phone, read-only: a field engineer standing next to a dead IPC, checking
 * whether it is the tunnel or the box, is a real scenario. */

@media (max-width: 780px) {
  /* Two rows: the bar, then the scrolling column. `auto 1fr` rather than one track, so the
   * bar takes what it needs and the main column takes the rest and scrolls inside it. */
  .shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
  .rail {
    border-right: 0;
    border-bottom: 1px solid var(--line);
    flex-direction: row;
    /* WRAPS, so the foot becomes a second line in the bar instead of being dropped. */
    flex-wrap: wrap;
    align-items: center;
    gap: 8px 12px;
    padding: 10px 14px;
    /* Not overflow-x: auto any more. A horizontally scrolling bar hid the foot by putting it
     * off the right edge, which is the same defect as hiding it, one axis over. */
    overflow: visible;
    height: auto;
    min-width: 0;
  }
  .nav { flex-direction: row; overflow-x: auto; overflow-y: visible; }
  .nav a { border-left: 0; border-bottom: 2px solid transparent; padding: 6px 10px; }
  .nav a[aria-current="page"] { border-left-color: transparent; border-bottom-color: var(--accent); }

  /* Never display: none. The foot carries sign-out, the operator's name and the break-glass
   * line, and a phone in the field is exactly where that line is read. It is a full-width
   * second row in the bar: nothing dropped, nothing off the right edge, sign-out reachable
   * without scrolling the page. */
  .rail-foot {
    display: flex;
    min-width: 0;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px 12px;
    margin-top: 0;
    padding: 0;
    flex-basis: 100%;
  }
  /* The break-glass command is the longest thing here and the least droppable. It wraps
   * inside its own line rather than widening the bar, which would put the page back on a
   * horizontal scroll -- the 208px defect again. */
  .rail-foot code { white-space: normal; word-break: break-all; }
  /* Every child of the foot, not only the code. The span wrapping "break-glass: <code>" is
   * itself a flex item, and its automatic minimum would put the floor back. */
  .rail-foot > * { min-width: 0; }
  .main { padding: 16px 14px 48px; }

  /* Tags stays visible at every width: the column is the only way to edit a device's tags,
   * so it is never on this narrow-width hide list. */

  /* THE FOLD, all-or-nothing: below this width the inline action icons swap for one trigger
   * opening the same actions in a menu, built from the identical itemsFor(d) list, so what an
   * operator can do never differs by width, only how it is reached. Rename and Edit tags do
   * not fold: neither shares the actions column's width budget. */
  .row-actions-icons { display: none; }
  .row-actions-fold-trigger { display: inline-flex; align-items: center; justify-content: center; }
}

/* ---- activity ---------------------------------------------------------------- */

td.when { font-family: var(--mono); font-size: 12.5px; color: var(--muted); white-space: nowrap; }

/* The console's own reads, shown but subordinate. They are part of the record -- reading
 * the audit trail is itself audited -- so hiding them by default would make this a log of
 * what someone wanted to see rather than of what happened. Dimming them lets a real
 * session stand out without pretending the reads did not occur. */
tr.is-read td { color: var(--faint); }
tr.is-read td.id { color: var(--muted); }

tr.is-refused td.id { color: var(--ink); }
tr.is-unparsed td { background: var(--flap-wash); }

.repeats {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--muted);
  background: var(--sunken);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0 5px;
  margin-left: 6px;
  cursor: help;
}

.notice ul { font-size: 13.5px; color: var(--muted); line-height: 1.5; }

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

.form-grid {
  display: grid;
  /* Device id / Token expiry / Tags, 25/25/50: tags is the field an
   * operator actually fills in with several values, so it gets the half rather than an equal
   * third the other two fields don't need. One column at 640px and below, by the media rule
   * after .form-grid-mint. */
  grid-template-columns: 1fr 1fr 2fr;
  gap: 14px;
  margin-bottom: 20px;
}
/* The mint button shares the tags field's row: a fourth column sized to the button keeps it
   beside Tags without moving any of the three fields. The narrow collapse below stacks all four. */
.form-grid-mint { grid-template-columns: 1fr 1fr 2fr auto; }
/* The mint cell carries the same label-then-control structure the fields do (an aria-hidden
   spacer where the label is, see enrolment.js), so the button's top lands on the input's top by
   construction. No height rule: .btn and .input already resolve to the same box. The min-width
   covers the wider of "Mint token" and "Mint key", so the two buttons are one width and, in an
   auto column, one right edge. */
.form-grid-mint .field-mint { align-self: start; }
.form-grid-mint .mint-row { margin-top: 0; }
.form-grid-mint .mint-row .btn { min-width: 7.5rem; }
@media (max-width: 640px) {
  .form-grid { grid-template-columns: 1fr; }
  .form-grid-mint { grid-template-columns: 1fr; }
}

/* One column: every action on the Operators page is a button, so there is no composed-command
 * side to split from. */
.op-col { min-width: 0; max-width: 62rem; }
/* The signed-in operator's row carries no wash: the "you" badge beside the name marks it in a
 * word rather than in a tint a reader has to learn. */
.you-badge {
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-ink);
  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
  border-radius: 3px;
  padding: 0 5px;
  margin-left: 6px;
}
.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.field .input { width: 100%; }

.field-label {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--faint);
}

.field-hint { font-size: 12.5px; color: var(--faint); line-height: 1.4; }
/* Reserves nothing, same reasoning as .notice-slot: an empty hint that still took a line's
 * height pushed everything below it down for no reason, which mattered once the name and tag
 * sections were compressed to two lines and a blank third line undid the compression. */
.field-hint:empty { display: none; }

/* Input and its buttons on one row. wrap rather than nowrap, so a narrow viewport drops the
 * buttons to a second line instead of clipping the input. */
.name-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 4px; }
.name-row .input { flex: 1 1 14rem; min-width: 0; max-width: 26rem; }
/* Validity is a sentence naming the rule, not a red border. "Invalid" tells an operator
 * nothing; "uppercase is not allowed" tells them what to change. */
.field-hint.is-ok  { color: var(--up); }
.field-hint.is-bad { color: var(--down); }

.input[aria-invalid="true"] { border-color: var(--down); }

textarea.input { line-height: 1.5; resize: vertical; }

.cmd { margin-bottom: 4px; }

/* A pressed toggle has to look pressed. aria-pressed was set and nothing rendered it, so
 * the enrolment method switch was announced correctly to a screen reader and invisible to
 * everyone else -- the inverse of the usual failure, and just as broken. */
.btn[aria-pressed="true"] {
  background: var(--accent-wash);
  border-color: var(--accent);
  color: var(--accent-ink);
}

select.input { cursor: pointer; }
.field-hint.is-warn { color: var(--flap); }

/* --- sign in ------------------------------------------------------------------------
 *
 * Narrow and centred, and deliberately not the shell layout: somebody who is not signed in
 * has no fleet, so a rail listing screens they cannot open would be six dead links and a
 * question about whether the console is broken.
 */
/* The colour as well as the tokens: text that sets no colour inherits it from body, which is
 * outside the shell and keeps the theme's own ink. */
.shell.login-screen { color-scheme: dark; color: var(--ink); }
/* The card sits in the middle of the screen, on the backdrop. */
.shell.login-screen .main {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* The card is centred, so the deep bottom padding of the other screens only makes the first
   * sign-in scroll on a laptop. */
  padding-block: 24px;
}
/* 80% opaque, so the sky shows through the card a little. No backdrop-filter: a blur over a
 * canvas that changes 20 times a second makes the browser compute the blur again for every
 * frame. The inputs keep an opaque --surface, so their text has its full contrast. */
.login-page {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 26rem;
  margin: 0 auto;
  padding: 2rem;
  background: color-mix(in srgb, var(--surface) 80%, transparent);
  border: 1px solid var(--line-firm);
  border-radius: 12px;
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
}
@media (max-width: 560px) { .login-page { padding: 1.25rem; } }
.login-page h1 { margin: 0 0 2rem; }
.login-backdrop {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  /* The colour of the sky before the first frame is drawn, so the page does not flash. */
  background: #04060c;
  --sp-top: #04060c; --sp-bottom: #0a1626; --sp-star: #dcebff; --sp-star-alpha: 0.75;
  --sp-dust: #7d879c; --sp-dust-alpha: 0.4; --sp-void: #010203;
  --sp-ring-1: #b3a5e6; --sp-ring-2: #8ccfd4;
}
/* The backdrop is positioned, so it paints over every block that is not. The update notice is
 * one; the status bar is sticky with its own z-index. */
.shell.login-screen .update-notice { position: relative; z-index: 1; }
.login-backdrop canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.login-form { display: flex; flex-direction: column; gap: 1.25rem; }
.login-actions { display: flex; gap: 0.75rem; align-items: center; }
.login-step { display: flex; flex-direction: column; gap: 1.25rem; }
.login-step[hidden] { display: none; }
/* Smaller than in the create dialog, so step 2 of the first sign-in fits a laptop screen. A
 * phone camera reads it at this size from the usual distance. */
.login-page .qr svg { width: 168px; height: 168px; }
/* Reserves nothing. An empty slot that occupied height would push the form up when a
 * refusal arrived, moving the button out from under the pointer of somebody retrying. */
.notice-slot:empty { display: none; }

.login-fallback {
  margin-top: 2rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--line);
  color: var(--muted);
}
.login-fallback .t {
  display: block;
  font-family: var(--mono);
  font-size: 0.78rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--faint);
  margin-bottom: 0.5rem;
}
.login-fallback p { margin: 0 0 0.75rem; max-width: 62ch; }
.login-fallback .cmd {
  font-family: var(--mono);
  font-size: 0.85rem;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 0.6rem 0.8rem;
  margin: 0;
  color: var(--ink);
  white-space: pre;
  overflow-x: auto;
}
/* The rail is hidden rather than absent, so the shell is still one static tree. See the
 * comment at the toggle in app.js for why that trade is worth making for one screen. */
.shell.no-rail .rail { display: none; }

/* AND THE COLUMN GOES WITH IT. `display: none` takes the rail out of the grid but leaves the
 * track it sat in, so without this .main-col becomes the first item and is laid out in the
 * 208px rail column. Hiding an element and removing its track are two changes. */
.shell.no-rail { grid-template-columns: 1fr; }

/* The terminal tab is the terminal and the action buttons and nothing else: sign-out and
 * break-glass leave this tab entirely, same mechanism as no-rail above. Losing sign-out here
 * is not losing it: the tab is opened from the fleet, which still has its own. */
.shell.terminal-only .rail { display: none; }
.shell.terminal-only { grid-template-columns: 1fr; }

/* --- terminal ------------------------------------------------------------------------ */
.term-banner {
  border: 1px solid var(--line);
  border-left: 3px solid var(--faint);
  border-radius: 4px;
  background: var(--surface);
  padding: 0.7rem 0.9rem;
  margin: 0 0 0.75rem;
}
/* Five states, five left borders and five words. Never colour alone: the word in
 * .term-status carries the state on its own, per convention 7. */
.term-banner.is-connecting  { border-left-color: var(--accent); }
.term-banner.is-live        { border-left-color: var(--up); }
.term-banner.is-closed-you  { border-left-color: var(--faint); }
.term-banner.is-closed-them { border-left-color: var(--flap); }
.term-banner.is-failed      { border-left-color: var(--down); }
.term-status {
  display: block;
  font-family: var(--mono);
  font-size: 0.78rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--faint);
  margin-bottom: 0.3rem;
}
.term-detail { margin: 0; color: var(--muted); max-width: 90ch; }

.term-pane {
  /* The box around the xterm emulator: border and height only, since xterm draws the cells.
   * 26rem applies outside the terminal-only tab; .shell.terminal-only .term-pane below
   * overrides it to fill the window. */
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--term-ground, #08131c);
  padding: 0.5rem;
  height: 26rem;
  overflow: hidden;
}
/* The emulator fills its host. Without this it lays out at its own idea of a size and the
 * border sits somewhere unrelated to the text. */
.term-pane .xterm { height: 100%; }
/* The remainder strip. vendor/xterm.css hardcodes .xterm-viewport to `background-color: #000`
 * and positions it to fill the whole .xterm container, while the canvas that draws text is only
 * rows*cellHeight tall, so any pane height that is not an exact multiple of the row height
 * shows the viewport's black below the last row. Overridden here, not in the vendored file. */
.term-pane .xterm-viewport { background-color: var(--term-ground, #08131c); }
/* A focus ring that is actually visible on a dark pane, because this element takes
 * keystrokes and "am I typing into the device or the page" must never be a guess. */
.term-pane:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Last in DOM order (the action row sits at the bottom of the window), so the margin is above
 * it, making room after the pane rather than below itself. */
.term-actions { display: flex; gap: 0.6rem; margin: 0.75rem 0 0; flex-wrap: wrap; }

/* --- full screen ---------------------------------------------------------------------------
 *
 * The pane is fixed to the viewport rather than handed to requestFullscreen, because the API
 * needs a user gesture per page and the ask is for a persisted default that applies on open.
 * position: relative on the pane is what the button is positioned against; it changes nothing
 * about the box xterm measures. */
.term-pane { position: relative; }

.term-full-btn {
  position: absolute;
  top: 8px; right: 8px;
  /* Above xterm own canvas layers, which stack up to 10 inside the pane. */
  z-index: 12;
  width: 30px; height: 30px;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--surface);
  cursor: pointer;
  /* Hidden until the mouse moves in the pane, and not display:none: a button that is laid out
   * can be faded, and one that is not cannot be Tab-reached either. visibility is what takes it
   * out of the pointer's way while it is invisible. */
  opacity: 0;
  visibility: hidden;
  transition: opacity 120ms linear;
}
.term-full-btn.is-awake, .term-full-btn:focus-visible { opacity: 1; visibility: visible; }
/* The two corner brackets, drawn rather than typed, so nothing depends on a glyph being in the
 * font. Out (expand) by default; in (contract) once full screen, which is the same pair of
 * corners mirrored. */
.term-full-btn::before, .term-full-btn::after {
  content: ''; position: absolute; width: 8px; height: 8px;
  border: 2px solid var(--muted);
}
.term-full-btn::before { top: 7px; left: 7px; border-right: 0; border-bottom: 0; }
.term-full-btn::after { bottom: 7px; right: 7px; border-left: 0; border-top: 0; }
/* The exit icon is the enter icon FLIPPED: the same two elbows, same shapes, rotated about the
 * button's centre by swapping which corner each one sits in, so both point inward. Flipping the
 * borders in place instead leaves the boxes in the outer corners and draws a cross. */
.term-full-btn.is-out::before {
  top: auto; left: auto; bottom: 7px; right: 7px;
  border: 2px solid var(--muted); border-right: 0; border-bottom: 0;
}
.term-full-btn.is-out::after {
  bottom: auto; right: auto; top: 7px; left: 7px;
  border: 2px solid var(--muted); border-left: 0; border-top: 0;
}
.term-full-btn:hover { border-color: var(--muted); }

body.term-fullscreen .term-pane {
  position: fixed;
  inset: 0;
  z-index: 40;
  height: auto;
  border: 0;
  border-radius: 0;
}

/* The pane fills the window. Scoped to .terminal-only, the class app.js sets on .shell for this
 * route: every other view stacks headings, tables and forms in block flow, which a flex column
 * would break. .main is the flex column (banner, pane, action row) and the pane takes what the
 * other two do not need; min-height: 0 on both, because a flex child's default min-height is
 * its content and would push the pane taller than the window. terminal.js's ResizeObserver
 * re-measures on every box-size change this rule causes. */
.shell.terminal-only .main {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  padding-bottom: 24px;
}
.shell.terminal-only .term-pane {
  flex: 1;
  min-height: 0;
  height: auto;
}

/* The table screens (fleet, health, activity; TABLE_SCREENS in app.js) scroll their rows inside
 * .table-wrap, so the heading, tiles, filters and the status bar above stay on screen. .main is
 * a flex column that takes the height .main-col has left, and .table-wrap is as tall as its
 * rows until the space the other children leave runs out, and scrolls from there: one filtered
 * row gets a box one row tall, not the rest of the window. The floor below applies only once
 * the table has enough rows to fill it, so it never opens an empty box under a short table;
 * where the heading, tiles and filters alone nearly fill the window (a phone in landscape),
 * .main-col keeps overflow-y: auto, the column scrolls, and the rows keep that floor rather
 * than shrinking to nothing. The tag popovers and the +N overflow are [popover] elements in
 * the top layer, so this scroll box does not clip them. */
.shell.table-screen .main {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  padding-bottom: 0;
}
.shell.table-screen .main > * { flex-shrink: 0; }
/* The box is the last thing in the column and ends at the window edge: no bottom padding, and
 * when its rows overflow it (at-edge, set by watchTableEdge in app.js) the bottom border and its
 * corners go, since a rounded edge against the window looks cut off. The top corners keep their
 * rounding, and a short table ends above the edge with the plain box. */
.shell.table-screen .main > .table-wrap {
  flex: 0 1 auto;
  min-height: 0;
  overflow-y: auto;
}
.shell.table-screen .main > .table-wrap.at-edge {
  border-bottom: 0;
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}
/* Eight rows are taller than 12rem on every table screen, so the floor is never above the rows. */
.shell.table-screen .main > .table-wrap:has(tbody tr:nth-child(8)) {
  min-height: min(12rem, 60dvh);
}
/* The count under activity's table, now in its head: nothing may sit under the box. */
.page-head .head-count {
  font-family: var(--mono);
  font-size: 11.5px;
  color: var(--faint);
  text-align: right;
}
/* Sticky at 0: the status bar is outside this scroll box, so --status-h does not apply. The
 * z-index keeps td.id, which is position: relative, from painting over the header as it passes. */
.shell.table-screen th { top: 0; z-index: 1; }

/* --- shell status -------------------------------------------------------------------
 *
 * The banner above the view, drawn by showStatus() in app.js. The class is the claim level
 * rather than the condition: .bar.is-unknown where the console reached no conclusion about the
 * bastion, .bar.is-unreachable only where it concludes the bastion cannot be reached. Which
 * condition it is lives in dataset.kind on the host, which is what recovery clears by.
 */
/* The column that scrolls. min-height: 0 as on the nav: without it this flex child does not
 * shrink below its content and pushes the shell taller than the viewport. */
.main-col {
  display: flex; flex-direction: column; min-width: 0; flex: 1;
  min-height: 0; overflow-y: auto;
}
/* One line above the status bar, in the page's own voice: no icon, no button, no colour wash.
 * It reports a fact the operator can act on by refreshing, and anything louder would compete
 * with the bar below it, which is for things that are wrong. */
.update-notice {
  padding: 1rem 2rem 0;
  color: var(--muted);
  font-size: 0.9rem;
}
.shell-status:empty { display: none; }
/* STICKY, AND WITH THE PAGE'S OWN BACKGROUND. Found by reproducing the report: the bar lived
 * inside .main-col, the same element that scrolls the page under it, so anyone already a
 * screenful down a device page when their session expired never saw it -- it rendered above
 * the fold they had scrolled past. Pinned to the top of the scrolling container it came from,
 * and opaque, so it occludes rather than lets a row of the table scroll visibly through it. */
.shell-status {
  padding: 1rem 2rem 0;
  position: sticky;
  top: 0;
  z-index: 6;
  background: var(--ground);
}
.shell-status .bar {
  border: 1px solid var(--line);
  border-left: 4px solid var(--faint);
  border-radius: 4px;
  background: var(--surface);
  padding: 0.85rem 1rem;
}
/* A warning bar gets a wash across the whole card, not just an accent rail; is-unreachable
 * below carries it. */
.shell-status .bar.is-unreachable { border-left-color: var(--down); background: var(--down-wash); }
.shell-status .t {
  display: block;
  font-family: var(--mono);
  font-size: 0.85rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  margin-bottom: 0.35rem;
}
.shell-status p { margin: 0 0 0.4rem; color: var(--muted); max-width: 92ch; }
.shell-status p:last-child { margin-bottom: 0; }
.shell-status code {
  font-family: var(--mono);
  font-size: 0.85em;
  background: var(--ground);
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0.1rem 0.3rem;
}
/* The line under a dialog field: the field's own hint, and under the code field "Each code
 * works once." */
.dialog-hint { color: var(--muted); font-size: 13px; margin: 0.4rem 0 0; max-width: 52ch; }
/* The same line, carrying a refusal instead of the standing note. Matches .field-hint.is-bad
   above, which is the colour this console already uses for "what you just sent was refused". */
.dialog-hint.is-bad { color: var(--down); }
.mint-row { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; flex-wrap: wrap; }
/* The mint result, under the form that minted it: the join line to copy, or the refusal.
 * Empty until a mint. */
.mint-out:empty { display: none; }
.mint-out { margin-top: 0.75rem; }

/* --- files ---------------------------------------------------------------------------- */
.files-quick { display: flex; flex-direction: column; gap: 0.5rem; margin: 0 0 1.75rem; }
/* One click each, and the reason is on the button. Five places worth looking beats a
 * directory tree for an audience that does not know the filesystem. */
.files-pick {
  display: flex; flex-direction: column; gap: 0.2rem; align-items: flex-start;
  text-align: left; width: 100%; cursor: pointer;
  background: var(--surface); border: 1px solid var(--line); border-radius: 4px;
  padding: 0.65rem 0.8rem; color: var(--ink); font: inherit;
}
.files-pick:hover { border-color: var(--accent); }
.files-pick code { font-family: var(--mono); font-size: 0.85rem; }
.files-pick span { color: var(--muted); font-size: 13.5px; }
.files-row { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; }
.files-row .input { flex: 1 1 18rem; min-width: 0; }
/* The two send buttons are the same box. They sit one under the other with only their
   words differing in length, and two buttons of different widths in that column read as two
   different kinds of control. The width is the wider label's, so neither wraps. */
.files-row .btn { min-width: 8.5rem; }
.files-out:empty { display: none; }
.files-out { margin-top: 1.25rem; display: flex; flex-direction: column; gap: 0.6rem; }
/* The console reached no conclusion about the bastion: a session it could not settle, or a wait
 * it ended itself. An accent border, apart from an unreachable console's red. */
.shell-status .bar.is-unknown { border-left-color: var(--accent); }
/* The device's default folder, above the listing: what it is, and the one button that changes
 * it. It wraps rather than pushing the button off a narrow screen. */
.files-default {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 0.4rem 0 0.2rem;
}

.files-listing-head { display: flex; align-items: baseline; gap: 0.75rem; margin: 0.35rem 0 0.5rem; }
.files-listing-head code { font-family: var(--mono); font-size: 0.85rem; }
/* The breadcrumb IS the parent navigation -- no separate "up" control, one ancestor
 * segment click each. Persists through a refusal (browse() builds it from the path before the
 * fetch resolves), which is what makes not_a_directory's own "use the breadcrumb to go back
 * up" true rather than a reference to a control that was never built. */
.files-crumb {
  display: flex; flex-wrap: wrap; align-items: center; gap: 1px;
  font-family: var(--mono); font-size: 0.85rem; margin: 0 0 0.5rem;
}
.files-crumb-seg {
  appearance: none; background: none; border: 0; font: inherit; color: var(--accent);
  cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px;
}
.files-crumb-seg:hover, .files-crumb-seg:focus-visible { background: var(--sunken); color: var(--accent-ink); }
.files-crumb-seg:last-child { color: var(--ink); cursor: default; }
.files-crumb-seg:last-child:hover { background: none; }
.files-crumb-sep { color: var(--faint); }
/* Four columns, filled down each column. grid-auto-flow: column with the row count set
 * by the view (ceil(entries / 4)) is what caps it at four: the flow makes as many columns as
 * the rows leave it. Below 1100px it goes back to one column, because four columns of a long
 * filename is not a file browser, it is a puzzle. */
.files-listing {
  display: grid; grid-auto-flow: column; gap: 2px;
  grid-auto-columns: minmax(0, 1fr);
}
@media (max-width: 1100px) {
  .files-listing { display: flex; flex-direction: column; }
}
/* One level, on request. Directories first, sizes on the right. The row is not one big
 * button: a file gets a Get action, a directory gets Open (its own name) and Get as .tar, so
 * the row is a container for two buttons rather than a single click target. */
.files-entry {
  display: flex; align-items: center; gap: 0.75rem; width: 100%;
  background: var(--surface); border: 1px solid var(--line); border-radius: 3px;
  padding: 0.4rem 0.65rem;
}
.files-entry:has(.files-entry-name:hover) { border-color: var(--accent); }
.files-entry-name {
  display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
  flex: 1 1 auto; min-width: 0; text-align: left; cursor: pointer; font: inherit;
  color: var(--ink); background: none; border: 0; padding: 0;
}
.files-entry code { font-family: var(--mono); font-size: 0.82rem; word-break: break-all; }
.files-entry span { color: var(--muted); font-size: 12.5px; white-space: nowrap; }
.files-entry .btn { flex-shrink: 0; }
/* --- operator roster ------------------------------------------------------------------ */
/* The access cell's .is-ok, .is-warn and .is-muted are operatorState()'s tone, from
 * lib/operators.js. The roster sits in a .table-wrap and takes the shared `table`, `th` and `td` rules
 * unmodified; .op-table is only a selector for the checkers. */
.op-access { font-weight: 600; }
.op-access.is-ok { color: var(--up, inherit); }
.op-access.is-warn { color: var(--flap, inherit); }
.op-access.is-muted { color: var(--muted); }

/* The Scope column. "Entire fleet" rather than a blank cell for an unscoped
 * operator -- the truthful answer to "what can this account reach" is not nothing. */
.op-scope-fleet { color: var(--muted); font-family: var(--mono); font-size: 12px; }
/* A signer entry still pinned to a withdrawn device scope. NOT styled as a scope chip,
 * deliberately -- this operator cannot sign in with that key at all, and the muted pill the
 * other two states use would read as a working restriction. --flap is the same token the warn
 * notice and the flapping tile use, so "needs an administrator" looks the same everywhere. */
.op-scope-stale { color: var(--flap); font-family: var(--mono); font-size: 12px; }
.op-scope-stale-detail { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
/* The edit control sits after whatever the cell already says, on its own line rather than
   beside it: the three things this cell renders are a chip row, a sentence and a two-line stale
   warning, and only the first would have shared a baseline with an icon. */
/* inline-flex, not inline-block: an inline-block wrapper carries a text baseline strut, which
   sits the pencil's box about 1px below its wrapper's middle. margin-block, not margin-top:
   `vertical-align: middle` centres the MARGIN box, so a top-only margin pushes the box 2px down,
   while the 4px still separates the pencil from the chips when it wraps under them. */
.op-scope-cell .op-tip-wrap { display: inline-flex; margin-block: 4px; vertical-align: middle; }
/* The pencil's path sits 0.69px high inside its own 16-unit viewBox, so a centred box still
   shows a high glyph. A whole pixel, not a translate of 0.69px: both land inside a quarter pixel
   of the chips, and a whole-pixel offset cannot blur the stroke. */
.op-scope-cell .icon-btn svg { position: relative; top: 1px; }
/* The Role cell's dropdown. Sized to its own content rather than filling the column, so a
   three-word column does not become the widest one in the roster, and the plain-text form beside
   it keeps the same line height so a roster of both does not look ragged. */
.op-role-select { width: auto; min-width: 0; padding: 3px 6px; font-size: 12.5px; }
.op-role-plain { display: inline-block; padding: 3px 0; }
.op-role-select[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
.op-factor.is-no, .op-factor.is-unknown { color: var(--muted); }

/* The Access-cell and Remove-icon tooltip: hover AND keyboard focus, same
 * mechanism either way. Not the native `title` attribute -- browser tooltips are single-line,
 * low-contrast, unstyleable and slow to appear, and the text this carries (a pending/half-
 * enrolled explainer, a disabled-remove reason) is exactly the multi-clause prose `title`
 * handles worst. */
.op-tip-wrap { position: relative; display: inline-flex; align-items: center; }
.op-tip {
  position: absolute;
  bottom: calc(100% + 7px);
  left: 0;
  background: var(--ink);
  color: var(--ground);
  font-family: var(--sans);
  font-size: 12px;
  line-height: 1.45;
  padding: 8px 10px;
  border-radius: 4px;
  width: max-content;
  max-width: 22rem;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.12s ease;
  z-index: 20;
}
.op-tip-wrap:hover .op-tip,
.op-tip-wrap:focus-visible .op-tip,
.op-tip-wrap:focus-within .op-tip { opacity: 1; visibility: visible; }
/* ANCHORED TO THE RIGHT IN THE LAST COLUMN. A `left: 0`, `width: max-content` tip in the
 * actions column extends past the right edge of .table-wrap, and `visibility: hidden` still
 * takes part in layout, so an unhovered tooltip would set the roster's scrollable width.
 * Right-anchoring in this column only: everywhere else `left: 0` opens into the table. */
td.actions .op-tip { left: auto; right: 0; }
/* Not truncated: a fingerprint exists to be read back over a second channel, and half of one
 * compares equal to nothing. It gets its own wrap rather than an ellipsis. */
.fp { font-size: 0.78rem; word-break: break-all; }

/* The bastion's own output, shown verbatim. Pre rather than a paragraph because it carries a
 * path and a filename, and re-wrapping either makes it unreadable at the moment somebody is
 * copying it. Scrolls in its own box so a long path cannot widen the page. */
.notice pre.said {
  margin: 0.4rem 0; padding: 0.5rem 0.6rem; overflow-x: auto;
  font-family: var(--mono); font-size: 0.78rem;
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  white-space: pre-wrap; word-break: break-all;
}

/* --- operator creation: the QR beside its own text ------------------------------------- */
/* Side by side, and the text is not a fallback tucked underneath. There is no independent QR
 * decoder available to verify our encoder against, so a phone that will not scan must never be
 * a dead end -- the secret in readable groups is the guarantee and the QR is the convenience. */
/* No own margin. .qr-box only ever sits inside .dialog-form-body, a flex column with its own
 * 16px gap between every field; a margin here would stack on top of that gap. The parent's gap
 * is the only spacing rule. */
.qr-box { display: flex; gap: 1.25rem; flex-wrap: wrap; align-items: flex-start; }
.qr { background: #fff; padding: 0.5rem; border: 1px solid var(--line); border-radius: var(--radius); }
.qr svg { display: block; width: 220px; height: 220px; }
.qr-text { min-width: 0; flex: 1; max-width: 44ch; }
.secret {
  display: block; font-family: var(--mono); font-size: 0.95rem; letter-spacing: 0.05em;
  padding: 0.5rem 0.6rem; margin: 0.35rem 0; word-break: break-all;
  background: var(--surface); border: 1px solid var(--line-firm); border-radius: var(--radius);
}

/* --- tag editing ------------------------------------------------------------------------ */
.tag-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; min-height: 1.6rem; }

/* The id beneath an alias. Not decoration: it is what the bastion, the audit trail and
 * `wormhole ssh` use, so it stays on screen where somebody reads an alias and then opens a
 * terminal. */
.page-sub { font-family: var(--mono); font-size: 0.8rem; color: var(--muted); margin-top: 0.15rem; }
/* Name and location under the heading: facts, read beside the id. */
.page-facts { font-size: 0.9rem; color: var(--ink); margin-top: 0.35rem; }
.page-fact-label { color: var(--muted); }




/* One waiting device, one line. The id takes what is left, the reason a fixed share, and
   the decisions and the time keep their own width. */
.pending-table { width: 100%; border-collapse: collapse; }
.pending-table td.id { font-family: var(--mono, ui-monospace, monospace); }
.pending-table td.origin .fp { font-family: var(--mono, ui-monospace, monospace); overflow-wrap: anywhere; }
.pending-table td.id.is-bad { color: var(--glass); }
.pending-table td.updated { white-space: nowrap; }
.pending-table td.actions { text-align: right; white-space: nowrap; }
.pending-table td.actions .btn { margin-left: 0.5rem; }
/* The row's own answer, on a line of its own under it. No top border, so it reads as part of
   the row above rather than as a row of its own with four empty columns; and an empty one takes
   no room at all, which is what keeps a list of ten waiting devices ten lines long. */
.pending-table td.pending-note { border-top: none; padding-top: 0; }
.pending-table td.pending-note:empty { padding: 0; }

/* The enrolment key list, a table. Sized like the operator roster rather than the fleet: five
   short facts and two icons, no history column and no growth to a thousand rows, so it needs no
   per-column minimum. */
.key-table { width: 100%; border-collapse: collapse; }
.key-table td.id { font-family: var(--mono, ui-monospace, monospace); }
/* The copy glyph sits before the name, so the cell is two things rather than text.
   INLINE, NOT A FLEX CELL. `display: flex` on the td itself lines the two up in one line of
   CSS and takes the cell out of the table's own layout, so its column no longer shares a width
   with the header above it. The glyph is already an inline-flex .icon-btn, the same size as
   Revoke, so it needs nothing but a gap and a baseline. */
.key-table td.id { white-space: nowrap; }
.key-table td.id .icon-btn { vertical-align: middle; margin-right: 6px; }
.key-table td.id .key-name { vertical-align: middle; }
/* The whole row copies, so it says so under the pointer. Only a row with a line to copy gets a
   tabindex, so the cursor is keyed on that rather than on the row class: a row with nothing to
   copy does nothing and must not look as though it would. The hover fill is `tbody tr:hover`
   above, which every table on this console already has. */
.key-table tr.key-row[tabindex] { cursor: pointer; }
.key-tags { display: table-cell; }
.key-tags .chip-static { margin-right: 4px; }
/* Dimmed, NOT HIDDEN. A revoked or expired key is the answer to "why is nothing joining", so it
   stays legible; the dimming says it admits nothing, and the Validity column says which. */
.key-table tr.is-inactive td { opacity: 0.6; }
.key-table td.actions { text-align: right; white-space: nowrap; }
