/* Reusable pieces: cards, buttons, form controls, pills, tables, dialogs, notifications. */

/* ── cards ─────────────────────────────────────────────────────────────────── */
.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-l);
  box-shadow: var(--shadow);
}
.card + .card { margin-top: 1rem; }
.card__head {
  display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
  padding: .875rem 1.125rem;
  border-bottom: 1px solid var(--line);
}
.card__head h2, .card__head h3 { margin-right: auto; }

/* A count pill belongs to the title it counts, so it hugs it and the actions take the free
   space. The title's own `margin-right: auto` fought the actions' `margin-left: auto` for
   that space and the two split it, parking the pill in the middle of the row with an equal
   gap on each side. It never showed while a head was full - every other card in the app is,
   which is why every other pill already sits against its title - and appeared the moment one
   head was wide enough to have space to split. The title keeps its auto margin only where no
   pill follows it, so a head of title-then-actions still pushes the actions right. */
.card__head h2:has(+ .pill),
.card__head h3:has(+ .pill) { margin-right: 0; }

/* A segmented control is a bordered box around its buttons, so at the same button height it
   stands 6px taller than the plain buttons beside it and lifts the whole row off its
   baseline. In an actions row it gives that padding back. */
.card__actions > .seg { padding: 0; }
.card__actions > .seg > button { min-height: 26px; }

/* A card that is also a <details>. Only the head changes: it becomes the summary, so it
   has to drop the browser's own disclosure marker (three vendor spellings, all still
   needed) and re-declare display, because `display: list-item` on a summary overrides the
   flex the head relies on to push its pill to the right. Everything else — border,
   radius, padding, the body — is the plain card. */
.card--collapsible > summary.card__head {
  cursor: pointer;
  list-style: none;
  display: flex;
  user-select: none;
}
.card--collapsible > summary.card__head::-webkit-details-marker { display: none; }
.card--collapsible > summary.card__head::marker { content: ""; }
/* The chevron sits last, so the head reads left-to-right as title, state, control. Sized
   to the 24px WCAG 2.5.8 target rather than to the text around it: the whole head is the
   click target, but the chevron is the only part that *looks* like one, so a reader
   scanning for the control has to be able to find it. */
.card--collapsible > summary.card__head::after {
  content: "\25B8";
  color: var(--fg-muted);
  font-size: 1.25rem;
  line-height: 1;
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  border-radius: var(--radius);
  transition: transform .15s ease, color .15s ease, background-color .15s ease;
}
.card--collapsible[open] > summary.card__head::after { transform: rotate(90deg); }
.card--collapsible > summary.card__head:hover::after,
.card--collapsible > summary.card__head:focus-visible::after {
  color: var(--fg);
  background: var(--surface-sunk);
}
/* Closed, the head is the whole card, so its bottom border would double the card's own. */
.card--collapsible:not([open]) > summary.card__head { border-bottom: 0; }
@media (prefers-reduced-motion: reduce) {
  .card--collapsible > summary.card__head::after { transition: none; }
}

.card__body { padding: 1.125rem; }
.card__body--flush { padding: 0; }
.card__foot {
  padding: .75rem 1.125rem;
  border-top: 1px solid var(--line);
  background: var(--surface-sunk);
  border-radius: 0 0 var(--radius-l) var(--radius-l);
}

/* ── buttons ───────────────────────────────────────────────────────────────── */
button, .btn {
  display: inline-flex; align-items: center; justify-content: center; gap: .4375rem;
  padding: .4375rem .8125rem;
  min-height: 34px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-s);
  background: var(--surface);
  color: var(--fg);
  font: 500 .8125rem/1 var(--sans);
  cursor: pointer;
  text-decoration: none;
  transition: background-color .12s ease, border-color .12s ease, opacity .12s ease;
}
button:hover:not(:disabled), .btn:hover { background: var(--surface-sunk); border-color: var(--fg-faint); }
button:disabled { opacity: .45; cursor: not-allowed; }
button[data-variant="primary"] {
  background: var(--accent); border-color: var(--accent); color: var(--accent-fg); font-weight: 600;
}
button[data-variant="primary"]:hover:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 86%, black); border-color: transparent;
}
button[data-variant="danger"] { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, transparent); }
button[data-variant="ghost"] { border-color: transparent; background: transparent; color: var(--fg-muted); }
button[data-variant="ghost"]:hover:not(:disabled) { background: var(--surface-sunk); color: var(--fg); }
button[data-size="s"] { min-height: 28px; padding: .25rem .5rem; font-size: .75rem; }
.icon-button { width: 34px; padding: 0; flex: none; }
.icon-button svg { flex: none; }

.tooltip {
  position: fixed;
  z-index: 70;
  max-width: min(20rem, calc(100vw - 2rem));
  padding: .375rem .5rem;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-s);
  background: var(--fg);
  color: var(--surface);
  box-shadow: var(--shadow);
  font-size: .75rem;
  line-height: 1.3;
  pointer-events: none;
}

/* ── forms ─────────────────────────────────────────────────────────────────── */
label { display: block; font-size: .8125rem; font-weight: 550; color: var(--fg-muted); margin-bottom: .3125rem; }

input, select, textarea {
  width: 100%;
  padding: .5rem .625rem;
  min-height: 36px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-s);
  background: var(--surface);
  color: var(--fg);
  font: 400 .875rem/1.35 var(--sans);
}
input::placeholder { color: var(--fg-faint); }
input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
input[aria-invalid="true"] { border-color: var(--bad); }
/* 24px is the WCAG 2.5.8 (AA) minimum target size, and selecting rows is the
   primary action on the controller list — at 16px it was a miss-prone target on a
   trackpad and unusable on touch.
   Sized rather than padded on purpose: a checkbox renders as a native widget
   (appearance: auto), and browsers drop padding on those, so padding out the hit
   area silently does nothing while looking like it worked. The box itself has to
   grow. */
input[type="checkbox"] { width: 24px; height: 24px; min-height: 0; accent-color: var(--accent); }
select {
  appearance: none; padding-right: 2rem;
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
                    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: right 1rem center, right .6875rem center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}
fieldset { margin: 0; padding: 0; border: 0; }

.field--inline { display: flex; align-items: center; gap: .5rem; }
.field--inline label { margin: 0; }
.hint { margin-top: .3125rem; font-size: .75rem; color: var(--fg-faint); }
.hint--bad { color: var(--bad); }
.field-error { margin-top: .3125rem; font-size: .75rem; color: var(--bad); font-weight: 550; }
.field-error:empty { display: none; }

/* ── pills, stats, key/value ───────────────────────────────────────────────── */
.pill {
  display: inline-flex; align-items: center; gap: .375rem;
  padding: .1875rem .5rem;
  border-radius: 999px;
  background: var(--idle-soft); color: var(--idle);
  font: 600 .6875rem/1.35 var(--sans); letter-spacing: .02em; white-space: nowrap;
}
.pill--ok     { background: var(--ok-soft);   color: var(--ok); }
.pill--warn   { background: var(--warn-soft); color: var(--warn); }
.pill--bad    { background: var(--bad-soft);  color: var(--bad); }
.pill--live   { background: var(--live-soft); color: var(--live); }
.pill--accent { background: var(--accent-soft); color: color-mix(in srgb, var(--accent) 78%, var(--fg)); }

/* A pill that acts: same shape, none of the boxed button chrome the base rule gives it,
   plus the affordance an actionable control owes the reader (pointer, hover, focus ring).
   Used by the project switcher in the top bar. */
button.pill {
  min-height: 0;
  border: 0;
  padding: .1875rem .5rem;
  font: 600 .6875rem/1.35 var(--sans);
  letter-spacing: .02em;
}
button.pill:hover:not(:disabled) {
  border-color: transparent;
  background: color-mix(in srgb, currentColor 18%, var(--surface));
}

.dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; flex: none; }
.pill--live .dot { animation: pulse 1.8s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .28; } }

.kv { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .375rem 1rem; font-size: .8125rem; }
/* A fixed label column stops the values stepping in and out as the longest label changes,
   which is what makes a capability readout scannable rather than merely present. */
.kv--wide { grid-template-columns: 10rem minmax(0, 1fr); }
.kv dt { color: var(--fg-muted); }
.kv dd { margin: 0; font-family: var(--mono); font-size: .78125rem; overflow-wrap: anywhere; }

/* One named group of a grouped detail record (GET /api/v1/controllers/{id}) — a heading
   plus its own kv list, so a reader can see which section a field belongs to without a
   payload-shaped wall of text. */
.detail-group__title {
  margin: 0 0 .5rem;
  color: var(--fg-muted);
  font-size: .6875rem;
  letter-spacing: .06em;
  text-transform: uppercase;
}
/* A group whose members are all omitted is still shown, rather than disappearing — the
   record has ten groups every controller carries, and a reader has to be able to tell
   "nothing here" from "this group did not load". */
.detail-group__empty { margin: 0; font-size: .8125rem; }

.stats { display: grid; gap: .625rem; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); }
.stat { padding: .75rem .875rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-sunk); }
.stat__label { color: var(--fg-muted); font-size: .6875rem; letter-spacing: .06em; text-transform: uppercase; }
.stat__value { margin-top: .25rem; font: 620 1.25rem/1.15 var(--sans); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }

/* ── empty states and notes ────────────────────────────────────────────────── */
.empty { padding: 2.5rem 1.25rem; text-align: center; color: var(--fg-muted); }
.empty h3 { color: var(--fg); margin-bottom: .375rem; }
.empty p { max-width: 44ch; margin-inline: auto; font-size: .875rem; }
.empty__actions { margin-top: 1rem; display: flex; gap: .5rem; justify-content: center; flex-wrap: wrap; }

.note {
  padding: .75rem .875rem;
  border-radius: var(--radius); border: 1px solid var(--line);
  background: var(--surface-sunk);
  font-size: .8125rem; color: var(--fg-muted);
}
.note--ok   { border-color: color-mix(in srgb, var(--ok) 40%, transparent);   background: var(--ok-soft);   color: var(--ok); }
.note--warn { border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); color: var(--warn); }
.note--bad  { border-color: color-mix(in srgb, var(--bad) 40%, transparent);  background: var(--bad-soft);  color: var(--bad); }
.note strong { font-weight: 650; }

/* ── collapsible raw payloads ──────────────────────────────────────────────── */
details.raw { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-sunk); }
details.raw > summary {
  padding: .5rem .75rem;
  cursor: pointer;
  font-size: .8125rem; font-weight: 550; color: var(--fg-muted);
  list-style: none; display: flex; align-items: center; gap: .375rem;
}
details.raw > summary::-webkit-details-marker { display: none; }
details.raw > summary::before { content: "▸"; font-size: .75em; transition: transform .15s ease; }
details.raw[open] > summary::before { transform: rotate(90deg); }

pre.json {
  margin: 0; padding: .75rem;
  max-height: 340px; overflow: auto;
  border-top: 1px solid var(--line);
  font-family: var(--mono); font-size: .75rem; line-height: 1.5;
  white-space: pre; tab-size: 2;
}

/* A payload inside a dialog is already inside a scrolling, bordered, padded body; repeating
   any of the three would put a frame inside a frame and a cap inside a cap. */
pre.json--dialog { max-height: none; border: 0; padding: 0; }

/* Copy button, floated over the panel's top-right. The wrapper is the positioning context
   so the button does not scroll away with the payload — a long controller state is
   thousands of lines, and a control you have to scroll back up to find is a control nobody
   uses. */
.json-copy { position: relative; }

.json-copy__button {
  position: absolute; top: .4rem; right: .9rem;
  padding: .15rem .5rem;
  font-size: .7rem; line-height: 1.6;
  color: var(--fg-muted);
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-s);
  cursor: pointer; opacity: .65; transition: opacity .12s ease, color .12s ease;
}

.json-copy:hover .json-copy__button,
.json-copy__button:focus-visible { opacity: 1; color: var(--fg); }

.json-copy__button:disabled { opacity: 1; color: var(--ok); cursor: default; }

/* JSON syntax colours. Reuses the status tokens so a payload reads in the same visual
   language as the rest of the tester, and both themes are covered without new values. */
.json__key    { color: var(--fg); font-weight: 600; }
/* A key whose value is null or empty is still worth finding, but should not compete with
   the fields that actually carry data. */
.json__key--empty { color: var(--fg-faint); font-weight: 500; }
.json__string { color: var(--accent); }
.json__number { color: var(--live); }
.json__null   { color: var(--fg-faint); font-style: italic; }
.json__punct  { color: var(--fg-faint); }
/* True and false get different colours, not one "keyword" colour: scanning a controller
   state is almost always looking for which flags are set. */
.json__bool--true  { color: var(--ok); font-weight: 600; }
.json__bool--false { color: var(--warn); }

/* ── table ─────────────────────────────────────────────────────────────────── */
.table-wrap { overflow: auto; max-height: 62dvh; border-radius: 0 0 var(--radius-l) var(--radius-l); }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: .8125rem; }
thead th {
  position: sticky; top: 0; z-index: 1;
  padding: .5rem .75rem;
  background: var(--surface-sunk);
  border-bottom: 1px solid var(--line);
  color: var(--fg-muted); font-weight: 600; text-align: left; white-space: nowrap;
}
thead th[aria-sort] { cursor: pointer; user-select: none; }
thead th[aria-sort]:hover { color: var(--fg); }
thead th[aria-sort="ascending"]::after  { content: " ↑"; color: var(--accent); }
thead th[aria-sort="descending"]::after { content: " ↓"; color: var(--accent); }
tbody td { padding: .5rem .75rem; border-bottom: 1px solid var(--line); vertical-align: middle; }
tbody tr { content-visibility: auto; contain-intrinsic-size: auto 37px; }
tbody tr:hover td { background: var(--surface-sunk); }
tbody tr[data-selected="true"] td { background: color-mix(in srgb, var(--accent) 8%, transparent); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
td.name { font-weight: 550; }

/* Column widths, declared once. Before this the name column was capped at 22ch while Area,
   Clock and Battery each held a full text column for values that are usually "—", so the
   two columns a reader actually scans — name and state — were the two that were squeezed.
   `table-layout: fixed` makes the browser honour these instead of re-deriving widths from
   the widest cell in 915 rows, which also removes a per-render layout pass. */
.table--controllers { table-layout: fixed; }
.table--controllers .col-pick    { width: 2.75rem; }
.table--controllers .num[data-sort="id"] { width: 5rem; }
.table--controllers .col-model   { width: 4.5rem; }
.table--controllers .col-clock   { width: 5rem; }
.table--controllers .col-battery { width: 5.5rem; }
/* Three buttons wide (watch, payloads, detail record) plus their gaps. Sized to the
   content it holds: at 8.5rem the third button overflowed the column, and because the row
   does not wrap, the whole table scrolled sideways to reach it. */
.table--controllers .col-actions { width: 12.5rem; }
/* The state cell holds a variable number of pills; letting them wrap inside a fixed column
   keeps every other column's width stable as states change under a live refresh. */
.table--controllers tbody td { overflow-wrap: anywhere; }

/* Below the rail breakpoint the secondary columns cost more than they carry — they are the
   ones most often empty — so they are dropped.
   The fixed layout goes with them. Nine declared widths summing past the viewport do not
   shrink gracefully: the browser honours them, the leftover is split between name, state
   and area, and at 430px that left three characters of name with the state pills painted
   over the cells beside them. Sizing to content and scrolling the wrapper keeps every cell
   readable; `.table-wrap` already scrolls, so the page itself never moves sideways. */
@media (max-width: 860px) {
  .table--controllers { table-layout: auto; min-width: 34rem; }
  .table--controllers .col-model,
  .table--controllers .col-clock,
  .table--controllers .col-battery { display: none; }
  .table--controllers tbody td { overflow-wrap: normal; }
}

details.raw--spaced { margin-top: 1rem; }

/* ── combobox ──────────────────────────────────────────────────────────────── */
.combo { position: relative; min-width: 0; }
.combo > input { width: 100%; }
.combo__list {
  position: absolute; z-index: 40;
  top: calc(100% + .25rem); left: 0; right: 0;
  /* The script narrows this to the room actually available and flips the anchor when the
     field sits too low; the value here is only the ceiling. */
  max-height: min(20rem, 42dvh); overflow: auto;
  padding: .25rem;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface);
  box-shadow: 0 10px 30px -12px rgb(0 0 0 / .45);
  scrollbar-width: thin;
}
.combo__list[hidden] { display: none; }
.combo__list--above { top: auto; bottom: calc(100% + .25rem); }
.combo__option {
  display: flex; align-items: baseline; gap: .5rem;
  padding: .375rem .5rem;
  border-radius: var(--radius-s);
  cursor: pointer;
}
.combo__option:hover,
.combo__option[aria-selected="true"] { background: var(--surface-sunk); }
.combo__option[aria-selected="true"] { outline: 1px solid var(--accent); outline-offset: -1px; }
.combo__value { flex: none; font-size: .75rem; color: var(--accent); }
.combo__label {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: .8125rem; color: var(--fg-muted);
}
.combo__empty { padding: .5rem; color: var(--fg-faint); font-size: .8125rem; }

/* ── chips ─────────────────────────────────────────────────────────────────── */
.chips { display: flex; flex-wrap: wrap; gap: .375rem; }
.chip {
  display: inline-flex; align-items: center; gap: .3125rem;
  padding: .1875rem .3125rem .1875rem .5rem;
  border: 1px solid var(--line-strong); border-radius: 999px;
  background: var(--surface);
  font-family: var(--mono); font-size: .6875rem;
}
.chip button { min-height: 0; padding: 0 .1875rem; border: 0; background: none; color: var(--fg-faint); font-size: .875rem; line-height: 1; }
.chip button:hover { background: none; color: var(--bad); }

/* ── dialog ────────────────────────────────────────────────────────────────── */
dialog {
  width: min(760px, calc(100vw - 2rem));
  max-height: min(80dvh, 720px);
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius-l);
  background: var(--surface);
  color: var(--fg);
  box-shadow: 0 24px 60px -20px rgb(0 0 0 / .45);
}
/* Do not rely on a browser's default dialog placement: a modal must sit over the active
   view, never continue in the document flow beneath a card. */
dialog:modal { position: fixed; inset: 0; margin: auto; }
dialog::backdrop { background: rgb(8 14 20 / .5); backdrop-filter: blur(2px); }
dialog .card__body { overflow: auto; max-height: 62dvh; }

/* ── notifications ─────────────────────────────────────────────────────────── */
.toasts {
  position: fixed; inset-block-end: 1rem; inset-inline-end: 1rem; z-index: 50;
  display: flex; flex-direction: column; gap: .5rem;
  width: min(380px, calc(100vw - 2rem));
}
.toast {
  padding: .625rem .75rem;
  border: 1px solid var(--line); border-left-width: 3px; border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--shadow);
  font-size: .8125rem;
  animation: rise .18s ease-out;
}
@keyframes rise { from { opacity: 0; transform: translateY(6px); } }
.toast--bad  { border-left-color: var(--bad); }
.toast--ok   { border-left-color: var(--ok); }
.toast--warn { border-left-color: var(--warn); }
.toast--info { border-left-color: var(--live); }
.toast__title { font-weight: 620; }
.toast__body  { margin-top: .1875rem; color: var(--fg-muted); overflow-wrap: anywhere; }
.toast__meta  { margin-top: .25rem; font-family: var(--mono); font-size: .6875rem; color: var(--fg-faint); }

.feed { max-height: 60dvh; overflow: auto; }
.feed__item { padding: .75rem 1.125rem; border-bottom: 1px solid var(--line); }
.feed__item:last-child { border-bottom: 0; }
.feed__top { display: flex; align-items: baseline; gap: .5rem; }
.feed__title { font-weight: 620; font-size: .875rem; }
.feed__time { margin-left: auto; font-family: var(--mono); font-size: .6875rem; color: var(--fg-faint); }
.feed__body { margin-top: .25rem; font-size: .8125rem; color: var(--fg-muted); overflow-wrap: anywhere; }
.feed__meta { margin-top: .375rem; font-family: var(--mono); font-size: .6875rem; color: var(--fg-faint); overflow-wrap: anywhere; }

/* A required marker that reads as one at a glance and still says so to a screen reader through
   the control's own aria-required, rather than relying on the asterisk to carry the meaning. */
.req { color: var(--bad); font-weight: 700; }
