/* The portal's design system: dark, dense, unfussy.
 *
 * Dark is the default now rather than an alternative. The two surfaces are one
 * product and an operator moves between them all day: a white console next to
 * a near-black kiosk read as two different pieces of software, and the palette
 * here is the kiosk's, at operator density.
 *
 * Which is the only thing they share. The portal is a desktop tool, so its type
 * starts at 15px rather than 28px, its targets are pointer-sized, and it has
 * hover states the kiosk cannot use. Importing the kiosk's stylesheet would
 * bring a 96px touch minimum into a data table. So the values are restated
 * here, deliberately, and the comment is the link between them.
 *
 * Light is still written out in full rather than derived by inversion.
 * Inverting produces grey text on near-black that fails contrast at the small
 * sizes a dense table uses, and turns six status colours into one murk.
 *
 * Tokens only in this block. Every value used below refers to one of them, so
 * changing a colour is one edit rather than a search.
 */

:root,
[data-theme="dark"] {
  color-scheme: dark;

  --bg: #07060b;
  --surface: #121019;
  --surface-sunk: #0d0b14;
  --surface-raised: #1c1826;
  --border: #2a2437;
  --border-strong: #3b3350;
  --text: #f5f3fa;
  --muted: #a79fbc;

  --primary: #8b5cf6;
  --primary-hover: #a78bfa;
  --primary-text: #ffffff;
  --accent: #ec4899;

  /* Status colours are the kiosk's, which were already chosen against a
     near-black ground and hold 4.5:1 on this surface. */
  --ok: #34d399;
  --warn: #fbbf24;
  --err: #f87171;
  --ok-bg: #0c2a20;
  --warn-bg: #2b2110;
  --err-bg: #2c1517;

  --focus: #8b5cf6;
  --shadow: 0 1px 2px rgb(0 0 0 / 40%), 0 8px 24px rgb(0 0 0 / 28%);

  /* The ring, as tokens rather than literals, because anything that clips its
     own overflow has to know how far the ring reaches in order to leave room
     for it, and a ring that grew while the reservation did not would be
     clipped again in silence. */
  --ring-width: 2px;
  --ring-offset: 2px;
  --ring-reach: calc(var(--ring-width) + var(--ring-offset));

  /* One radius scale, so a panel and a control stay in step when one changes.
     These were literals repeated by hand in two stylesheets. */
  --radius-panel: 10px;
  --radius-control: 7px;
  --radius-small: 4px;

  /* One stacking scale, in the order things actually sit.
   *
   * These were four literals scattered through the sheet (1, 20, 100) and the
   * next person to stick something down picks a bigger number than whatever
   * they can see, which is how a sheet ends up with a 9999. Naming the layers
   * makes the question "above what?" answerable without grepping.
   *
   * A sticky table header only has to clear its own rows. The form action bar
   * has to clear that header. The sidebar has to clear both, because on a
   * narrow window it sits over the content rather than beside it. The skip
   * link clears everything by definition: it is the first thing focused and
   * has to be visible over whatever it was hiding behind. */
  --z-sticky: 1;
  --z-raised: 10;
  --z-nav: 20;
  --z-skip: 100;

  /* A primary wash for a selected chip. The solid `--primary` behind 13px text
     is a button, not a selection, and reads louder than the thing it marks. */
  --primary-soft: rgb(139 92 246 / 18%);

  /* -- depth ------------------------------------------------------------
   *
   * The same three ideas as the kiosk, at console scale: a very dim wash so a
   * full-width dark page is not a flat rectangle, a hairline of light along
   * the top of a raised surface, and one gradient reserved for the primary
   * action on a view.
   */
  --ambient:
    radial-gradient(ellipse 60% 50% at 0% -10%, rgb(139 92 246 / 14%), transparent 60%),
    radial-gradient(ellipse 50% 40% at 100% 0%, rgb(236 72 153 / 9%), transparent 62%);
  --sheen: linear-gradient(180deg, rgb(255 255 255 / 5%), rgb(255 255 255 / 0%) 40%);
  /* Two brand gradients, and which one you want depends on one question: is
   * there text on it?
   *
   * `--primary-text` is white, and white on the vivid pair measures 4.23:1 at
   * the purple end and 3.53:1 at the pink, so a normal-size label on it fails
   * AA. That was live on every primary button in the portal: "Add a kiosk" and
   * "Add a style" are 15px at weight 550, which is not large text, so 4.5:1 is
   * the bar and neither end cleared it.
   *
   * So the plain name is the one that is safe to put a label on: 5.70:1 and
   * 4.60:1, and the ramp between them is monotonic so the ends bound it. It is
   * a notch deeper than the vivid pair and still obviously the same brand.
   * `-vivid` is for a shape with no text in it, which is the active-nav bar,
   * and it keeps the full-strength colour where nothing has to be read off it.
   * The brand mark used to be in this set when it was an empty square. It is
   * the product artwork now, which already carries its own colour, so it is
   * no longer a gradient at all.
   *
   * Named this way round on purpose. Reaching for `--brand-gradient` is what
   * somebody adding a button will do without thinking about contrast, so that
   * name has to be the accessible one and the bright one has to be the
   * deliberate choice. */
  --brand-gradient: linear-gradient(135deg, #7c3aed, #db2777);
  --brand-gradient-vivid: linear-gradient(135deg, #8b5cf6, #ec4899);
  --glow-primary: 0 0 0 1px rgb(139 92 246 / 40%), 0 8px 28px rgb(139 92 246 / 28%);

  /* -- motion -----------------------------------------------------------
   *
   * One rhythm, so a page that fades and a row that highlights feel like the
   * same product. Exits are shorter than entrances, because a slow exit is the
   * only delay a person actually reads as the software being slow.
   */
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-spring: cubic-bezier(0.16, 1.02, 0.28, 1);
  --dur-micro: 140ms;
  --dur-enter: 240ms;
  --dur-exit: 160ms;
}

[data-theme="light"] {
  color-scheme: light;

  --bg: #F8FAFC;
  --surface: #FFFFFF;
  --surface-sunk: #F1F5F9;
  --surface-raised: #FFFFFF;
  --border: #E2E8F0;
  --border-strong: #CBD5E1;
  --text: #0F172A;
  --muted: #475569;
  /* Darkened from the dark-mode primary: #8b5cf6 on white is 3.1:1, which
     fails for the small text a button label actually is. */
  --primary: #6d28d9;
  --primary-hover: #5b21b6;
  --primary-text: #FFFFFF;
  --accent: #be185d;
  --ok: #047857;
  --warn: #B45309;
  --err: #B91C1C;
  --ok-bg: #ECFDF5;
  --warn-bg: #FFFBEB;
  --err-bg: #FEF2F2;
  --focus: #6d28d9;
  --shadow: 0 1px 2px rgb(15 23 42 / 6%), 0 1px 3px rgb(15 23 42 / 10%);

  --ring-width: 2px;
  --ring-offset: 2px;
  --ring-reach: calc(var(--ring-width) + var(--ring-offset));

  --radius-panel: 10px;
  --radius-control: 7px;
  --radius-small: 4px;

  /* Lighter than the dark theme's, because the same 18% of a darker purple over
     white is a bruise rather than a tint. */
  --primary-soft: rgb(109 40 217 / 11%);

  --ambient:
    radial-gradient(ellipse 60% 50% at 0% -10%, rgb(109 40 217 / 6%), transparent 60%),
    radial-gradient(ellipse 50% 40% at 100% 0%, rgb(190 24 93 / 4%), transparent 62%);
  --sheen: linear-gradient(180deg, rgb(255 255 255 / 70%), rgb(255 255 255 / 0%) 40%);
  /* The light theme already darkened its primary for this reason, so the safe
     pair here was correct from the start: 7.10:1 and 6.04:1. */
  --brand-gradient: linear-gradient(135deg, #6d28d9, #be185d);
  --brand-gradient-vivid: linear-gradient(135deg, #7c3aed, #db2777);
  --glow-primary: 0 0 0 1px rgb(109 40 217 / 30%), 0 8px 28px rgb(109 40 217 / 18%);

  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-spring: cubic-bezier(0.16, 1.02, 0.28, 1);
  --dur-micro: 140ms;
  --dur-enter: 240ms;
  --dur-exit: 160ms;
}

/* No `prefers-color-scheme` block. Dark is the product's appearance rather
 * than a response to the desktop's setting, because the console sits next to a
 * kiosk that has no light mode at all. An operator who wants light asks for it
 * and gets `data-theme="light"`, which the toggle in `portal.js` sets.
 */

/* --- type -----------------------------------------------------------------
 *
 * Self-hosted, with no Google Fonts CDN. Using the CDN would loosen the CSP,
 * add a render-blocking third-party dependency, and send an operator's address
 * to a third party on every page load.
 *
 * The files are the kiosk's. Three faces were declared here under names of
 * their own, none of the three files had ever been added, and every portal
 * page therefore 404ed on its fonts and silently rendered in the system UI
 * face. A stylesheet asserting a typeface it does not ship is worse than one
 * that asks for a system face honestly, because nothing fails loudly: the
 * pages render, they just render as nothing in particular.
 *
 * Pointing at `../kiosk/fonts/` rather than copying them in. They are the same
 * bytes, the two surfaces are one deployment served from one static root, and
 * a second copy is a second thing to forget when a subset changes.
 *
 * Armenian is split out by unicode-range because in the entire Google Fonts
 * catalogue only Noto Sans Armenian and Noto Serif Armenian are usable text
 * faces. Anything chosen for its personality renders Armenian as tofu, or
 * substitutes a system face, which looks worse than tofu because it looks
 * deliberate. The split means the Armenian file downloads only when Armenian
 * is actually on screen, which for an operator in Yerevan is the audit log and
 * not the dashboard.
 */

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../kiosk/fonts/inter-latin.260c81a4759b.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F,
    U+2070-209F, U+20A0-20BF, U+2116, U+2190-21BB;
}

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../kiosk/fonts/inter-latin-ext.1ad231aac0a8.woff2") format("woff2");
  unicode-range: U+0100-02AF, U+1E00-1EFF, U+2020, U+20A0-20AB, U+2113, U+A720-A7FF;
}

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../kiosk/fonts/inter-cyrillic.de93fdc85e3d.woff2") format("woff2");
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../kiosk/fonts/inter-cyrillic-ext.251d36f2b5a6.woff2") format("woff2");
  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}

@font-face {
  font-family: "Noto Sans Armenian";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../kiosk/fonts/noto-sans-armenian-armenian.f062c0821106.woff2") format("woff2");
  unicode-range: U+0530-058F, U+FB13-FB17;
}

/* No @font-face for the monospace. A token, an identifier and a recovery code
 * need figures that cannot be confused and columns that line up, and every
 * desktop this portal is opened on already has a face that does both. Shipping
 * a fourth file to supply what the machine has is bytes for nothing, and it is
 * the one role where a system face is not a compromise. */
:root {
  --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas,
    "Liberation Mono", monospace;
}

* { box-sizing: border-box; }

/* `[hidden]` is a normal rule with normal specificity, so any later class that
   sets `display` outbids it and the "hidden" thing is simply on the page. The
   kiosk learned this with a screen drawn on top of the current one; here it is
   a row of credential fields for a transport nobody chose. Stated once, with
   the importance, and then never thought about again. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background-color: var(--bg);
  /* Fixed, so the wash stays put behind a page that scrolls rather than
     sliding away and leaving the bottom of a long table on flat black. */
  background-image: var(--ambient);
  background-attachment: fixed;
  color: var(--text);
  font-family: "Inter", "Noto Sans Armenian", system-ui, -apple-system, sans-serif;
  font-size: 15px;
  line-height: 1.5;
  font-variant-numeric: tabular-nums;
}

h1, h2, h3, h4 { margin: 0 0 8px; line-height: 1.25; font-weight: 650; }
h1 { font-size: 24px; }
h2 { font-size: 19px; }
h3 { font-size: 16px; }
p { margin: 0 0 12px; }

/* Identifiers, tokens and amounts. Tabular figures so a column of numbers
 * lines up, and monospace so a session token can be compared by eye against
 * one in a log. */
code, .mono, .token, .amount, td.num, th.num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}
td.num, th.num { text-align: right; }

a { color: var(--primary); text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:hover { color: var(--primary-hover); }

/* One visible focus treatment everywhere. Removing outlines without replacing
 * them makes the whole portal unusable by keyboard, and a back office is
 * exactly where somebody works all day without touching a mouse. */
:focus-visible {
  outline: var(--ring-width) solid var(--focus);
  outline-offset: var(--ring-offset);
  border-radius: var(--radius-small);
}

/* Inside a scroller, the ring turns inward.
 *
 * `.table-wrap` is `overflow-x: auto`, and setting overflow on one axis
 * computes the other from `visible` to `auto`, so it is a clipping box on all
 * four sides whether or not it ever scrolls. A ring at a positive offset on a
 * control in the first column or the top row is therefore drawn outside the
 * box and thrown away, and `jobs.html` puts a checkbox exactly there.
 *
 * A negative offset instead of room reserved with padding, which is what the
 * kiosk's scrolling surfaces do. Padding is right there and wrong here: the
 * wrap draws the border, the radius and the ground for the table, so insetting
 * the table would leave a band of surface inside a rounded frame and the
 * sticky header would stop reaching the edges. Turned inward the ring is still
 * the full 2px, still `--focus`, and cannot be clipped by anything.
 */
.table-wrap :focus-visible {
  outline-offset: calc(-1 * var(--ring-offset));
}

.skip {
  position: absolute;
  left: -9999px;
  top: 8px;
  background: var(--surface);
  color: var(--text);
  padding: 8px 14px;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  z-index: var(--z-skip);
}
.skip:focus { left: 8px; }

/* --- chrome ----------------------------------------------------------------
 *
 * A sidebar rather than the row of nine links that was here. The row had
 * `overflow-x: auto` on it, which is the tell: nine sections did not fit
 * across a laptop, so the last two scrolled out of sight and nothing on screen
 * said they existed. Down the side they are all visible at once, each gets a
 * full-width target, and they can be grouped, which a single row cannot do.
 *
 * It collapses to a horizontal bar under 1024px. That is the same scrolling
 * row again, and it is the right trade there: a narrow window has width to
 * spare and no height, which is the opposite of the problem on a desktop.
 * -------------------------------------------------------------------------- */

.shell {
  display: grid;
  grid-template-columns: 232px minmax(0, 1fr);
  min-height: 100vh;
  min-height: 100dvh;
}

.sidebar {
  display: flex;
  flex-direction: column;
  gap: 18px;
  padding: 16px 12px;
  background: var(--surface);
  border-right: 1px solid var(--border);
  /* Sticks for the height of the viewport while the page scrolls past it. */
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100dvh;
  overflow-y: auto;
  z-index: var(--z-nav);
}

.brand {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 12px;
  padding: 6px 8px;
  font-weight: 700;
  font-size: 20px;
  color: var(--text);
  text-decoration: none;
  white-space: nowrap;
}

.brand-logo {
  width: 48px;
  height: 48px;
  flex: 0 0 48px;
  object-fit: contain;
  display: block;
}

.nav {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1;
}

.nav-group {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* The label is what makes a sidebar better than a row rather than just taller:
   nine destinations become three groups of three, which is a shape somebody
   can learn. */
.nav-label {
  padding: 12px 10px 4px;
  font-size: 11px;
  font-weight: 650;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.nav a {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-radius: 8px;
  color: var(--muted);
  text-decoration: none;
  font-weight: 500;
  transition:
    background-color var(--dur-micro) var(--ease-out),
    color var(--dur-micro) var(--ease-out);
}

.nav a:hover { background: var(--surface-sunk); color: var(--text); }

.nav a svg {
  width: 18px;
  height: 18px;
  flex: 0 0 18px;
  opacity: 0.8;
}

.nav a[aria-current="page"] {
  background: var(--surface-raised);
  background-image: var(--sheen);
  color: var(--text);
  font-weight: 600;
}

.nav a[aria-current="page"] svg { opacity: 1; color: var(--primary); }

/* A bar as well as the weight and the fill, so the current section is never
   indicated by colour alone. */
.nav a[aria-current="page"]::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: 3px;
  height: 18px;
  margin-top: -9px;
  border-radius: 999px;
  /* Vivid: a 3px bar, and it is an indicator rather than a surface. */
  background: var(--brand-gradient-vivid);
}

.sidebar-foot {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}

.who {
  display: block;
  padding: 8px 10px;
  border-radius: 8px;
  color: var(--text);
  text-decoration: none;
  font-weight: 500;
  transition: background-color var(--dur-micro) var(--ease-out);
}

.who:hover { background: var(--surface-sunk); }

/* On its own line under the name. Inline, the two ran together into "admin
   Your account", which reads as one odd label rather than a name and what
   clicking it does. */
.who .small { display: block; color: var(--muted); font-weight: 400; }

.inline { display: inline; margin: 0; }

.link-button {
  background: none;
  border: 0;
  padding: 0;
  color: var(--primary);
  cursor: pointer;
  font: inherit;
  text-decoration: underline;
}

/* Signing out is spatially separated from the sections, because it is the one
   item in the sidebar that ends what you are doing. */
.sidebar-foot .link-button {
  padding: 8px 10px;
  border-radius: 8px;
  text-align: left;
  text-decoration: none;
  color: var(--muted);
  width: 100%;
  transition: background-color var(--dur-micro) var(--ease-out);
}

.sidebar-foot .link-button:hover { background: var(--surface-sunk); color: var(--text); }

/* `min-width: 0` so a wide table inside a grid track does not push the track
   wider than the column it was given. A grid item's automatic minimum is its
   content, which is the whole reason a single unbreakable row can shove a
   sidebar off the screen. */
.shell-main {
  min-width: 0;
  display: flex;
  flex-direction: column;
}

/* An auto margin on a flex item switches off `stretch`, and then the item is
 * shrink-to-fit with a min-content floor.
 *
 * `.page` centres itself with `margin: 0 auto`, which is right on a wide
 * window. Inside this flex column it also meant the item was no longer
 * stretched to the container's 390px: an auto margin on the cross axis
 * disables `align-items: stretch`, so the width became
 * `max(min-content, available)`, and min-content was the 690px of the widest
 * table on the page. The whole document then scrolled sideways and the header,
 * the filters and the pager were all 690 wide in a 390 viewport.
 *
 * `width: 100%` restores the stretch without giving up the centring: the
 * margins still do their job once `max-width` is the smaller of the two on a
 * wide window. `.table-wrap` then finally gets asked to be narrow and scrolls
 * its own table, which is what it was for.
 *
 * Every `main` here, not just `.page`: `.page-narrow` and `.page-auth` carry
 * the same auto margin and would fail the same way the moment one of them held
 * something wide. */
.shell-main > main {
  width: 100%;
  min-width: 0;
}

@media (max-width: 1023px) {
  .shell { grid-template-columns: minmax(0, 1fr); }

  /* The nav wraps here, it does not scroll.
   *
   * It used to be `overflow-x: auto`, which at 390px put the sidebar's content
   * at 1194px and left seven of the nine sections off screen behind a
   * horizontal scroll with no affordance pointing at it. An operator on a
   * phone could reach Overview and Kiosks and had no way of knowing Settings
   * existed. It also made the bar a clipping box on all four sides, so the
   * focus ring of every nav item was being eaten, which is the gotcha this
   * project has already paid for twice.
   *
   * Wrapping costs vertical space on a narrow window and shows every section,
   * which is the right trade for nine items that are the whole navigation. */
  .sidebar {
    position: sticky;
    top: 0;
    height: auto;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px 12px;
    border-right: 0;
    border-bottom: 1px solid var(--border);
  }

  .nav { flex-direction: row; flex-wrap: wrap; gap: 2px; }
  .nav-group { flex-direction: row; flex-wrap: wrap; }
  .nav-label { display: none; }
  .nav a { white-space: nowrap; }

  .nav a[aria-current="page"]::before {
    left: 10px;
    right: 10px;
    top: auto;
    bottom: 2px;
    width: auto;
    height: 2px;
    margin: 0;
  }

  .sidebar-foot {
    flex-direction: row;
    align-items: center;
    gap: 8px;
    padding-top: 0;
    border-top: 0;
    margin-left: auto;
  }
}

/* --- layout -------------------------------------------------------------- */

.page { max-width: 1360px; margin: 0 auto; padding: 22px 24px 64px; }
.page-narrow { max-width: 680px; margin: 0 auto; padding: 40px 20px 64px; }
/* Narrower again, for the pages that are one card and nothing else: sign in,
   the code, re-authentication. A 680px column around a two-field form leaves
   the fields stretched across a width that makes them look like a search box
   rather than a credential. */
.page-auth { max-width: 420px; margin: 0 auto; padding: 24px 20px; }

/* A page arrives rather than appearing. Short, and transform plus opacity
   only, so it costs one composited frame and never reflows what it is
   revealing. */
main {
  animation: page-in var(--dur-enter) var(--ease-out) both;
}

@keyframes page-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
}

/* A signed-out page has no sidebar to sit beside, so it centres in the window
   instead of at the top of a column. */
.signed-out {
  display: grid;
  place-items: center;
  min-height: 100vh;
  min-height: 100dvh;
}

.page-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 18px;
  flex-wrap: wrap;
}
.page-head .muted { margin: 0; max-width: 62ch; }

.grid { display: grid; gap: 14px; }
.grid-2 { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.grid-3 { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
.grid-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }

.card {
  /* Named so a child can undo it. The sticky form action bar has to reach the
     card's edges, which means knowing the inset it is cancelling; a literal 16
     repeated in the other sheet is the coupling that breaks when this changes. */
  --card-pad: 16px;
  background-color: var(--surface);
  /* A hairline of light along the top edge. The single cheapest thing that
     stops a card on a near-black page reading as a hole cut in it. */
  background-image: var(--sheen);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: var(--card-pad);
  box-shadow: var(--shadow);
}
.card h2, .card h3 { margin-top: 0; }
.card-sunk { background-color: var(--surface-sunk); background-image: none; box-shadow: none; }

/* The number is the reason the card exists, so it gets the size. Tabular
   figures inherited from `body`, so a row of four stats does not shuffle
   sideways as the dashboard refreshes them. */
.stat .stat-value { font-size: 32px; font-weight: 700; line-height: 1.1; letter-spacing: -0.02em; }
.stat .stat-label { color: var(--muted); font-size: 13px; }
.stat .stat-note { color: var(--muted); font-size: 12px; margin-top: 4px; }

.muted { color: var(--muted); }
.small { font-size: 13px; }

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

/* `position: relative` so the scroller is a containing block.
 *
 * Without it an absolutely positioned descendant is laid out against whatever
 * is positioned further up, which means it is not clipped by this scroller and
 * its width lands on the document instead. The `.visually-hidden` labels in
 * these table headers are `position: absolute`, and their static position is
 * wherever their column happens to be, so on the styles page at 390px they sat
 * at x=710 outside the scroller and the whole document scrolled 320px. The
 * table itself was being clipped and scrolled correctly the entire time, which
 * is what made it confusing: every visible thing was in the right place and
 * the page still moved. */
.table-wrap {
  position: relative;
  overflow-x: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-panel);
  background: var(--surface);
}
table { width: 100%; border-collapse: collapse; font-size: 14px; }
thead th {
  text-align: left;
  padding: 9px 12px;
  background: var(--surface-sunk);
  border-bottom: 1px solid var(--border);
  font-weight: 600;
  font-size: 12px;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--muted);
  position: sticky;
  /* Zero, not 52px. That number was the height of the topbar this header used
     to slide under; the nav is down the side now, so an offset leaves a gap
     the rows scroll through. */
  top: 0;
  z-index: var(--z-sticky);
}
tbody td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
tbody tr:last-child td { border-bottom: 0; }

/* A When cell that asked to stay absolute. Relative wording is shorter and
   wraps; the clock time is what the column is for, so it does not wrap either. */
.stamp-absolute,
.stamp-absolute time {
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* Colour only, and no transition. A table here runs to a few hundred rows, and
   a transition on the row under the pointer is a repaint per row crossed while
   somebody drags down a list looking for something. */
tbody tr:hover { background: var(--surface-sunk); }

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

/* Every status carries a word. Colour is the fast signal, the word is the
 * actual information, and roughly one man in twelve cannot rely on the
 * former. */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px 9px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  border: 1px solid transparent;
  white-space: nowrap;
}
.pill-ok { background: var(--ok-bg); color: var(--ok); border-color: currentcolor; }
.pill-warn { background: var(--warn-bg); color: var(--warn); border-color: currentcolor; }
.pill-err { background: var(--err-bg); color: var(--err); border-color: currentcolor; }
.pill-idle { background: var(--surface-sunk); color: var(--muted); border-color: var(--border-strong); }

.messages { max-width: 1360px; margin: 14px auto 0; padding: 0 20px; }
.message {
  padding: 10px 14px;
  border-radius: 8px;
  border: 1px solid;
  margin-bottom: 8px;
  font-weight: 500;
}
.message-success { background: var(--ok-bg); color: var(--ok); border-color: currentcolor; }
.message-info { background: var(--surface); color: var(--text); border-color: var(--border-strong); }
.message-warning { background: var(--warn-bg); color: var(--warn); border-color: currentcolor; }
.message-error { background: var(--err-bg); color: var(--err); border-color: currentcolor; }

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

.field { margin-bottom: 16px; }
.field > label { display: block; font-weight: 600; margin-bottom: 4px; }
.field .help {
  color: var(--muted);
  font-size: 13px;
  margin-top: 4px;
  max-width: 64ch;
}
.field .errors { color: var(--err); font-size: 13px; margin-top: 4px; font-weight: 500; }

input[type="text"], input[type="email"], input[type="password"], input[type="number"],
input[type="date"], input[type="url"], input[type="search"], input[type="tel"],
select, textarea {
  width: 100%;
  padding: 8px 10px;
  font: inherit;
  color: var(--text);
  /* Sunk rather than level with the card it sits on. On a dark surface a field
     the same colour as its container is invisible until it has a value in it,
     and an operator cannot see where to click. */
  background: var(--surface-sunk);
  border: 1px solid var(--border-strong);
  border-radius: 7px;
  transition:
    border-color var(--dur-micro) var(--ease-out),
    background-color var(--dur-micro) var(--ease-out);
}

/* The border moves with the ring, so a focused field is legible as focused
   from the field itself rather than only from the outline around it. */
input:focus-visible, select:focus-visible, textarea:focus-visible {
  border-color: var(--primary);
  background: var(--surface);
}
textarea { min-height: 110px; resize: vertical; font-family: inherit; }
input:disabled, select:disabled, textarea:disabled {
  background: var(--surface-sunk);
  color: var(--muted);
  cursor: not-allowed;
}
/* `select` as well, and `.field-invalid` as the belt to it.
 *
 * This rule existed from the start and had never fired: Django does not set
 * `aria-invalid`, so a rejected field had the same border as an accepted one
 * and the message below it was the whole signal. `with_validity` in
 * `portal_forms.py` now sets the attribute, and the class on the wrapper
 * catches any widget that renders something other than these three tags. */
input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"],
.field-invalid input,
.field-invalid select,
.field-invalid textarea {
  border-color: var(--err);
}

fieldset { border: 1px solid var(--border); border-radius: 9px; padding: 14px; margin: 0 0 18px; }
legend { font-weight: 650; padding: 0 6px; }

.actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 18px; }

.button {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 7px 14px;
  border-radius: 7px;
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-weight: 550;
  cursor: pointer;
  text-decoration: none;
  transition:
    background-color var(--dur-micro) var(--ease-out),
    border-color var(--dur-micro) var(--ease-out),
    transform var(--dur-micro) var(--ease-out);
}
.button:hover { background: var(--surface-sunk); color: var(--text); }

/* The press, which is the whole of the feedback a button owes a pointer. One
   pixel, because a button that moves further than that on a dense page reads
   as the layout shifting rather than as a control responding. */
.button:active { transform: translateY(1px); }

/* The gradient is on the primary action and on nothing else. It is how a page
   says which of five controls is the one you came for, and a second gradient
   anywhere on the same screen spends that for nothing. */
.button-primary {
  background-image: var(--brand-gradient);
  border-color: transparent;
  color: var(--primary-text);
  box-shadow: var(--glow-primary);
}

/* Brightened by lifting the whole thing rather than by swapping the gradient
   for a second one: two gradients that differ slightly is a flicker on hover,
   and `filter` animates on the compositor. */
.button-primary:hover {
  background-image: var(--brand-gradient);
  color: var(--primary-text);
  filter: brightness(1.12);
}

.button-danger { border-color: var(--err); color: var(--err); }
.button-danger:hover { background: var(--err-bg); color: var(--err); }
.button-disabled, .button[aria-disabled="true"] {
  opacity: 0.5;
  pointer-events: none;
  /* A disabled primary keeps its shape and loses its claim on the eye. */
  box-shadow: none;
  filter: none;
}

/* --- pager and empty states --------------------------------------------- */

.pager { display: flex; gap: 10px; align-items: center; margin-top: 14px; }
.pager-count { color: var(--muted); font-size: 13px; }

.empty {
  text-align: center;
  padding: 44px 20px;
  color: var(--muted);
  background: var(--surface);
  border: 1px dashed var(--border-strong);
  border-radius: 10px;
}
.empty p:first-child { color: var(--text); font-weight: 600; }

/* --- kiosk grid --------------------------------------------------------- */

/* A wall display, so a card is read at a glance and from further away than a
 * table is: the name, the state as a word in a pill, then the three numbers that
 * say whether the unit is working, then what it is running in the smallest type
 * on the page.
 *
 * What this replaced was a seven row definition list of label and value pairs,
 * in which "Sessions today 105" and "Version 5.0" were the same size and the
 * same weight, so there was nothing to glance at. The three that matter are now
 * numbers over their labels, which is the same treatment the overview's figures
 * get and readable across a stockroom.
 *
 * 272px rather than 246px as the smallest column: the stats are three across, so
 * a narrower card wraps "Waiting to upload" onto three lines and the row of
 * numbers stops lining up. */
.kiosk-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(272px, 1fr)); }
.kiosk-card {
  background: var(--surface);
  background-image: var(--sheen);
  border: 1px solid var(--border);
  border-left: 3px solid var(--border-strong);
  border-radius: 10px;
  padding: 13px 14px;
  box-shadow: var(--shadow);
}
.kiosk-card[data-status="online"] { border-left-color: var(--ok); }
.kiosk-card[data-status="degraded"] { border-left-color: var(--warn); }
.kiosk-card[data-status="offline"] { border-left-color: var(--err); }
.kiosk-card[data-status="disabled"],
.kiosk-card[data-status="maintenance"] { border-left-color: var(--muted); }

/* The name takes the room and the pill keeps its own width, so a long name wraps
   rather than pushing the state off the card. */
.kiosk-card__head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) max-content;
  align-items: baseline;
  gap: 4px 10px;
}
.kiosk-card h3 { font-size: 15px; margin: 0; }
.kiosk-card__place { margin: 1px 0 0; color: var(--muted); font-size: 12.5px; }

/* Only ever the machine's own complaint, so it is amber and it is quoted from a
 * device standing in public: `portal.js` rebuilds these as text nodes. Hidden by
 * `:empty` rather than by a condition, because the stream fills and empties this
 * list without the page being rebuilt, and a rule that only the server can apply
 * would leave an empty amber box on the card after a unit recovered. */
.kiosk-card__reasons {
  display: grid;
  gap: 2px;
  margin: 9px 0 0;
  padding: 7px 10px;
  list-style: none;
  background: var(--surface-sunk);
  border-left: 2px solid var(--warn);
  border-radius: 0 6px 6px 0;
  font-size: 12.5px;
}
.kiosk-card__reasons:empty { display: none; }

/* Two counts, not three: how long ago the unit spoke is words rather than a
   number, and at this size "18 minutes ago" is three lines in a track that its
   neighbours fill with one digit. It reads on the line under the state instead,
   which is where it belongs anyway, the state being derived from it. */
.kiosk-card__stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
  margin: 11px 0 0;
}
.kiosk-card__stats dt { color: var(--muted); font-size: 11.5px; line-height: 1.3; }
.kiosk-card__stats dd { margin: 2px 0 0; font-size: 19px; font-weight: 650; line-height: 1.15; }

.kiosk-card__foot {
  margin: 11px 0 0;
  color: var(--muted);
  font-size: 12px;
  line-height: 1.45;
}

/* Shown once, on screen, and never again. Made large and selectable because
 * somebody is reading it off this screen and typing it into a kiosk across
 * the room. */
.pairing-code {
  font-family: var(--font-mono);
  font-size: 44px;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-align: center;
  padding: 22px;
  background: var(--surface-sunk);
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  user-select: all;
}

/* The email preview is operator-authored HTML, so it renders inside a
 * sandboxed iframe and never in this document. Injecting it here would be a
 * stored cross-site scripting hole straight into the admin origin. */
.mail-preview {
  display: block;
  width: 100%;
  min-height: 640px;
  border: 1px solid var(--border-strong);
  border-radius: 9px;
  background: #FFFFFF;
}

/* Phone must shrink with the card. A fixed 390 px iframe was wider than the
 * preview column on a typical window and pushed the page sideways. */
.mail-preview--phone {
  width: min(390px, 100%);
  height: 620px;
  min-height: 620px;
  margin-inline: auto;
  border-radius: 28px;
}

.preview-switch {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.mail-test__row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}

.mail-test__row input[type="email"] {
  flex: 1 1 16em;
  min-width: 0;
}

.diff { display: grid; grid-template-columns: minmax(120px, auto) 1fr 1fr; gap: 1px 12px; font-size: 13px; }
.diff .was { color: var(--err); }
.diff .now { color: var(--ok); }

/* Text only a screen reader needs. Every list table in the portal carries a
 * caption saying what it holds, because "table with seven columns" on its own
 * is not an answer to what am I looking at. The caption must not be visible:
 * a sighted operator already has the heading above it.
 *
 * Added here because it was being used by the table markup before it was
 * defined anywhere, which showed every caption on the page. `clip-path` rather
 * than `display: none`, since a hidden element is not announced at all. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The before and after columns of one audit entry. `.diff` above is a grid,
 * and an entry reads better as a real table with a caption and a heading per
 * column, so the two colours have to work outside that grid as well. Colour is
 * not carrying the meaning here: each row also says in words whether the value
 * was replaced, filled in or emptied. */
td.was { color: var(--err); }
td.now { color: var(--ok); }

@media (max-width: 860px) {
  /* Nothing here for the nav. It is a sidebar now, and its own narrow-window
     rules are up with the rest of it rather than a thousand lines away. The
     `.topbar` rule that was here matched nothing. */
  thead th { position: static; }
}

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

@media print {
  /* `.sidebar`, not `.topbar`: the nav moved and the old selector matched
     nothing, so a printed page carried the whole navigation down its left. */
  .sidebar, .pager, .actions, .skip { display: none; }
  .shell { display: block; }
  .card, .table-wrap { border-color: #999; box-shadow: none; }
  /* Paper is white whatever the screen was, and the ambient wash on a dark
     theme prints as a grey smear across every page. */
  body { background: #fff; background-image: none; color: #000; }
  main { animation: none; }
}

/* --- authentication ------------------------------------------------------ */

/* Recovery codes, shown once and then never again. Two columns so ten of them
 * fit above the fold on a laptop, monospace so an O and a 0 can be told apart,
 * and selectable as a unit because the sensible thing to do with this list is
 * print it or paste it into a password manager. */
.codes {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 8px 16px;
  list-style: none;
  margin: 0 0 16px;
  padding: 16px;
  background: var(--surface-sunk);
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  font-family: var(--font-mono);
  font-size: 17px;
  letter-spacing: 0.06em;
  user-select: all;
}

/* The authenticator key, for somebody typing it into a phone with a thumb.
 * Larger than body text and loosely spaced for the same reason the pairing
 * code is, but not as large: this one is thirty-odd characters and has to fit
 * a narrow column without wrapping mid-group. */
.secret-key {
  font-family: var(--font-mono);
  font-size: 20px;
  font-weight: 600;
  letter-spacing: 0.08em;
  word-spacing: 0.2em;
  padding: 14px 16px;
  background: var(--surface-sunk);
  border: 1px solid var(--border-strong);
  border-radius: 9px;
  user-select: all;
}
