/* ---------------------------------------------------------------------------
   Theme tokens.

   Light is defined on bare :root so it is always the fallback. Dark is
   declared twice on purpose: once under prefers-color-scheme for people who
   never touch the toggle, and once under [data-theme="dark"] so an explicit
   choice wins over the OS in both directions. The media block is guarded with
   :not([data-theme="light"]) so choosing light on a dark OS actually sticks.
--------------------------------------------------------------------------- */
:root {
  --bg: #f2f4f7;
  --panel: #ffffff;
  --panel-2: #f7f9fb;
  --ink: #16191d;
  --muted: #6b7280;
  --line: #dfe3e8;
  --line-soft: #eceff3;
  --hover: #f1f6fd;
  --sidebar: #ffffff;
  --sidebar-ink: #4b5563;
  --sidebar-ink-strong: #16191d;
  --sidebar-line: #e3e7ec;
  --accent: #006fff;
  --accent-ink: #ffffff;
  --accent-soft: #e8f2ff;
  --ok: #1c7a4a;      --ok-bg: #e6f4ec;   --ok-line: #bcdfcb;
  --warn: #9a6212;    --warn-bg: #fdf2e0; --warn-line: #f0dcb4;
  --bad: #a32020;     --bad-bg: #fbeaea;  --bad-line: #f0c4c4;
  /* --accent is the fill; --accent-text is the same hue darkened enough to
     clear 4.5:1 against --accent-soft, which #006fff missed at 4.0:1. Any
     accent-coloured *text* uses this one. */
  --accent-text: #0058cc;
  --shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
  /* Three heights, so a drawer, a toast and a modal do not each invent one.
     They were three hard-coded rgba() values that never changed with the
     theme. */
  --shadow-1: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
  --shadow-2: 0 6px 24px rgba(16, 24, 40, .12);
  --shadow-3: 0 18px 48px rgba(16, 24, 40, .22);
  --radius: 6px;
  --speed: 120ms;

  /* Spacing and type scales. Six near-identical micro-label sizes and a
     scattering of .3–.75rem gaps is what happens without them. */
  --space-1: .25rem;
  --space-2: .45rem;
  --space-3: .7rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --text-micro: .72rem;
  --text-small: .85rem;
  --text-body: 1rem;
  --text-h2: 1.15rem;
  --text-h1: 1.5rem;
  /* Anything a finger has to hit. 44px is the floor Apple and Google agree on;
     the pointer-coarse block below applies it. */
  --touch: 44px;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #101317;
    --panel: #181c22;
    --panel-2: #1d222a;
    --ink: #e7eaee;
    --muted: #949daa;
    --line: #2a2f38;
    --line-soft: #22262e;
    --hover: #1f2530;
    --sidebar: #0c0f13;
    --sidebar-ink: #98a2b0;
    --sidebar-ink-strong: #ffffff;
    --sidebar-line: #1c2027;
    --accent: #3f9bff;
    --accent-ink: #04121f;
    --accent-soft: #12253c;
    --ok: #5fce97;     --ok-bg: #14261d;   --ok-line: #26533b;
    --warn: #e0b062;   --warn-bg: #2a2114; --warn-line: #5b451f;
    --bad: #f08b8b;    --bad-bg: #2a1717;  --bad-line: #5b2a2a;
    --accent-text: #7dbcff;
    --shadow: 0 1px 2px rgba(0, 0, 0, .4);
    --shadow-1: 0 1px 2px rgba(0, 0, 0, .4);
    --shadow-2: 0 6px 24px rgba(0, 0, 0, .5);
    --shadow-3: 0 18px 48px rgba(0, 0, 0, .62);
  }
}

:root[data-theme="dark"] {
  --bg: #101317;
  --panel: #181c22;
  --panel-2: #1d222a;
  --ink: #e7eaee;
  --muted: #949daa;
  --line: #2a2f38;
  --line-soft: #22262e;
  --hover: #1f2530;
  --sidebar: #0c0f13;
  --sidebar-ink: #98a2b0;
  --sidebar-ink-strong: #ffffff;
  --sidebar-line: #1c2027;
  --accent: #3f9bff;
  --accent-ink: #04121f;
  --accent-soft: #12253c;
  --ok: #5fce97;     --ok-bg: #14261d;   --ok-line: #26533b;
  --warn: #e0b062;   --warn-bg: #2a2114; --warn-line: #5b451f;
  --bad: #f08b8b;    --bad-bg: #2a1717;  --bad-line: #5b2a2a;
  --accent-text: #7dbcff;
  --shadow: 0 1px 2px rgba(0, 0, 0, .4);
  --shadow-1: 0 1px 2px rgba(0, 0, 0, .4);
  --shadow-2: 0 6px 24px rgba(0, 0, 0, .5);
  --shadow-3: 0 18px 48px rgba(0, 0, 0, .62);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  -webkit-font-smoothing: antialiased;
}

a { color: var(--accent); }

/* --- app shell ----------------------------------------------------------
   A fixed left rail with the content beside it, in the manner of the UniFi
   console this tool sits next to: sections grouped under quiet headings, the
   current one marked, and the account parked at the foot. Ubiquiti's own marks
   are deliberately not reproduced — the site's initial stands in for a logo.

   The rail is fixed rather than sticky so long tables scroll under it, and the
   content is inset by its width instead of being a flex sibling, which keeps
   the existing centred <main> working untouched.
------------------------------------------------------------------------- */
body.app-shell { --rail: 232px; }
body.app-shell .content { margin-left: var(--rail); }

.sidebar {
  position: fixed; inset: 0 auto 0 0; width: var(--rail); z-index: 40;
  display: flex; flex-direction: column;
  background: var(--sidebar); border-right: 1px solid var(--sidebar-line);
}

.sidebar-brand {
  display: flex; align-items: center; gap: .6rem;
  padding: .95rem 1rem; border-bottom: 1px solid var(--sidebar-line);
  color: var(--sidebar-ink-strong); font-weight: 600; letter-spacing: -.01em;
}
.brand-mark {
  display: grid; place-items: center; width: 26px; height: 26px; flex: 0 0 26px;
  border-radius: var(--radius); background: var(--accent); color: #fff;
  font-size: .85rem; font-weight: 700;
}
/* min-width: 0 so the two lines below can ellipsize instead of forcing the
   rail wider than its 232px. */
.brand-text { display: grid; min-width: 0; line-height: 1.25; }
.brand-name,
.brand-site { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The building, quieter than the product above it — it answers a different
   question and should not compete with the navigation for attention. */
.brand-site {
  font-size: .72rem; font-weight: 500; letter-spacing: 0;
  color: var(--sidebar-ink);
}

.sidebar-nav { flex: 1; overflow-y: auto; padding: .6rem .5rem; }
.nav-heading { margin: 1rem .55rem .3rem; }
.nav-item {
  display: flex; align-items: center; gap: .6rem;
  padding: .45rem .55rem; margin-bottom: 1px; border-radius: var(--radius);
  color: var(--sidebar-ink); text-decoration: none; font-size: .9rem;
  transition: background var(--speed), color var(--speed);
}
.nav-item:hover { background: var(--hover); color: var(--sidebar-ink-strong); }
.nav-item .nav-icon { flex: 0 0 16px; opacity: .8; }
.nav-item.is-current {
  background: var(--accent-soft); color: var(--accent-text); font-weight: 600;
}
.nav-item.is-current .nav-icon { opacity: 1; }
.nav-item .badge {
  margin-left: auto; background: var(--bad); color: #fff;
  border-radius: 999px; padding: 0 .4rem; font-size: .7rem; font-weight: 600;
}

.sidebar-foot { border-top: 1px solid var(--sidebar-line); padding: .55rem .5rem; }
.nav-user {
  display: flex; align-items: center; gap: .55rem; padding: .4rem .55rem;
  border-radius: var(--radius); text-decoration: none; color: var(--sidebar-ink-strong);
  transition: background var(--speed);
}
.nav-user:hover { background: var(--hover); }
.avatar {
  display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px;
  border-radius: 50%; background: var(--accent-soft); color: var(--accent-text);
  font-size: .8rem; font-weight: 700;
}
.nav-user-text { min-width: 0; line-height: 1.25; }
.nav-user-name {
  display: block; font-size: .85rem; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.nav-user-role { display: block; font-size: .72rem; color: var(--muted); }

.sidebar-actions {
  display: flex; align-items: center; gap: .4rem; padding: .15rem .55rem 0;
}
.sign-out {
  margin-left: auto; font-size: .8rem; color: var(--muted); text-decoration: none;
}
.sign-out:hover { color: var(--bad); }

/* Theme toggle. Hidden until theme.js marks the document as script-capable,
   so a no-JS visitor is not offered a control that cannot work. */
.theme-toggle { display: none; }
html.has-js .theme-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; padding: 0;
  background: transparent; border: 1px solid var(--sidebar-line);
  color: var(--muted); cursor: pointer; border-radius: var(--radius); font-size: .85rem;
  transition: background var(--speed), color var(--speed);
}
html.has-js .theme-toggle:hover { background: var(--hover); color: var(--ink); }
.theme-toggle .icon-dark { display: none; }
:root[data-theme="dark"] .theme-toggle .icon-dark { display: inline; }
:root[data-theme="dark"] .theme-toggle .icon-light { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle .icon-dark { display: inline; }
  :root:not([data-theme="light"]) .theme-toggle .icon-light { display: none; }
}

/* Under a laptop width the rail becomes a strip of icons; below that it folds
   into a scrolling row at the top, because a 232px rail on a phone is most of
   the screen. */
@media (max-width: 1100px) {
  body.app-shell { --rail: 60px; }
  .brand-text, .nav-item span:not(.badge), .nav-user-text, .sign-out { display: none; }
  .nav-heading { text-align: center; margin-inline: 0; letter-spacing: 0; }
  .nav-item, .nav-user { justify-content: center; }
  .sidebar-actions { justify-content: center; padding-inline: 0; }
}
@media (max-width: 720px) {
  body.app-shell { --rail: 0px; }
  .sidebar {
    position: static; width: auto; flex-direction: row; align-items: center;
    gap: .5rem; overflow-x: auto;
    border-right: 0; border-bottom: 1px solid var(--sidebar-line);
  }
  .sidebar-brand, .sidebar-foot { border: 0; padding: .5rem; }
  .nav-heading { display: none; }

  /* On a phone the strip was six unlabelled 16px glyphs, which is a guess per
     tap. It gets the words back — but a scrolling row of ten labelled items is
     640px of content in a 390px viewport, so Audit and everything below it sat
     off the right-hand edge with nothing to say they were there. A wrapping
     grid puts every destination on screen at once, which is what a tool used
     while standing at a door needs. */
  .sidebar-nav {
    display: grid; flex: 1; padding: .35rem; overflow: visible;
    grid-template-columns: repeat(auto-fit, minmax(4.4rem, 1fr));
    gap: .15rem;
  }
  .nav-item {
    position: relative;
    display: flex; flex-direction: column; gap: .2rem;
    align-items: center; justify-content: center;
    min-height: var(--touch); margin-bottom: 0;
    padding: .3rem .15rem; text-align: center;
  }
  .nav-item span:not(.badge) {
    display: block; font-size: .62rem; line-height: 1.1; letter-spacing: 0;
    /* Long labels shorten rather than pushing their neighbour over. */
    max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .nav-item .badge { position: absolute; top: .1rem; right: .35rem; margin-left: 0; }

  /* Brand and account on one line, the destinations on their own full-width
     grid below. Sharing a row with them cost the grid two of its columns and
     an extra row of height. */
  .sidebar { flex-wrap: wrap; align-items: center; row-gap: 0; }
  .sidebar-brand { order: 0; }
  .sidebar-foot { order: 1; margin-left: auto; display: flex; align-items: center; gap: .35rem; }
  .sidebar-nav { order: 2; flex: 1 0 100%; }
}


/* --- layout ------------------------------------------------------------ */
/* Left-aligned by default, centred only once the screen is wide enough that
   centring cannot put the content under the drawer.

   The drawer overlays rather than compressing (see below), so the content never
   moves when it opens — but a centred column on a mid-sized screen would sit
   partly beneath it. Centring is safe when the spare space on each side already
   exceeds the widest drawer:

       content area   = viewport - 232px rail
       spare per side = (content area - 1180px) / 2
       safe when        spare per side >= 560px
                     -> viewport >= 1180 + 2*560 + 232 = 2532px

   Hence the 2532px threshold: a 2560-wide ultrawide and anything larger centres,
   everything below stays left. */
main { max-width: 1180px; margin: 1.5rem auto 1.5rem 0; padding: 0 1.5rem 3rem; }

@media (min-width: 2532px) {
  main { margin-inline: auto; }
}
/* h1 and h2 sat .4rem apart, which is not enough for the second to read as
   subordinate to the first — the panels on a long page all looked like peers
   of the page title. */
h1 { font-size: var(--text-h1); margin: 0 0 .3rem; letter-spacing: -.015em; }
h2 { font-size: var(--text-h2); margin: 0 0 .6rem; letter-spacing: -.005em; }
p.lede { color: var(--muted); margin-top: 0; max-width: 68ch; }

/* Title on the left, the page's one action on the right. It wraps rather than
   pushing the page wide: at 390px an h1 and a button do not share a line. */
.page-head {
  display: flex; flex-wrap: wrap; align-items: baseline;
  justify-content: space-between; gap: .5rem 1rem;
}
.page-head h1 { margin-bottom: .35rem; }
.page-head .button { white-space: nowrap; }

.panel {
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 1rem; margin-bottom: 1rem; box-shadow: var(--shadow);
}
.panel-head {
  display: flex; align-items: baseline; gap: .75rem; flex-wrap: wrap;
  margin-bottom: .6rem;
}
.panel-head h2 { margin: 0; }
.panel-head form { margin-left: auto; }

/* --- tables ------------------------------------------------------------ */
table { width: 100%; border-collapse: collapse; }
th, td {
  text-align: left; padding: .5rem .6rem;
  border-bottom: 1px solid var(--line-soft); vertical-align: top;
}
th { border-bottom-color: var(--line); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:last-child td, table tr:last-child td { border-bottom: 0; }
/* Row highlight. Scoped away from header rows, and off for tables that are
   really a layout grid rather than a list. */
table:not(.plain) tr:hover td { background: var(--hover); }

/* --- tables on a phone ---------------------------------------------------
   A table is sized by its min-content width, which `width: 100%` does not
   cap, so a wide one used to drag the whole page sideways — and with it the
   position: fixed drawer, whose close button went off the screen edge.

   Nothing here breaks words. The users list stopped needing that once its
   address moved under the name (see .name-sub); the six-column tables cannot
   fit at any word boundary, so they scroll inside themselves instead. With
   the existing `width: 100%` the block still fills its container, so a table
   that already fits looks exactly as it did. */
@media (max-width: 720px) {
  table { display: block; overflow-x: auto; }
  /* .6rem of side padding per cell is 19px a row of five gives away, which is
     what tipped the orphan queue and the session list past the viewport and
     pushed their buttons off it. */
  th, td { padding: .5rem .35rem; }
  td .row { gap: .3rem; }
  td button.small, td .button.small { padding-inline: .45rem; }

  /* Opt-in, and only for the reference tables — the audit log and the run
     history — whose columns are all data and whose rows carry no action.
     `display: block` alone made them shrink to the phone and squeeze instead:
     the audit log came out with every timestamp broken across three lines and
     the page fourteen thousand pixels tall. Sizing the row group to its
     content is what actually makes it scroll.

     It is deliberately not applied to every table. A table whose last column
     is a button — the orphan queue's Pull in / Ignore, the session list's
     Revoke — would put the only thing worth tapping off the right-hand edge,
     which is worse than a cell that wraps. */
  .table-scroll > thead,
  .table-scroll > tbody { display: table; width: max-content; min-width: 100%; }
  .table-scroll td.small, .table-scroll th.small,
  .table-scroll td.mono, .table-scroll .num { white-space: nowrap; }
  /* …except the details column, which is the one long free-text field. */
  .table-scroll td.mono.small { white-space: normal; min-width: 12rem; }

  /* An empty state is a message, not data: it must not stretch the scroll box
     and must be readable without scrolling sideways to find it. */
  td > .empty {
    box-sizing: border-box; white-space: normal;
    width: min(100%, calc(100vw - 5.5rem));
  }
}
table tr { transition: background var(--speed); }

/* --- forms ------------------------------------------------------------- */
input, select, textarea {
  font: inherit; padding: .45rem .5rem; border: 1px solid var(--line);
  border-radius: var(--radius); background: var(--panel-2); color: var(--ink); max-width: 100%;
  transition: border-color var(--speed), box-shadow var(--speed);
}
input:focus-visible, select:focus-visible, textarea:focus-visible {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
input[type="checkbox"] { accent-color: var(--accent); }

/* Fields fill the box they are given. Every one of them said so with an inline
   style="width:100%" — 36 copies of one rule, and the only reason the Content
   Security Policy had to allow inline styles at all. Controls with an
   intrinsic size are exempt; a class-based rule still overrides this, since
   this is a bare element selector. */
input:not([type="checkbox"]):not([type="radio"]):not([type="time"]):not([type="file"]):not([type="submit"]),
select,
textarea { width: 100%; }
label { display: block; font-size: .85rem; color: var(--muted); margin: .5rem 0 .15rem; }

/* Buttons are tinted rather than filled: a solid accent block against a white
   panel was a harder step than the action deserved. The fill is the accent at
   low opacity, so it works over either theme's panel, and the label is the
   accent mixed toward the ink so it stays legible on that tint. Hover deepens
   the same colour instead of switching to another one. */
button, .button {
  font: inherit; padding: .45rem .8rem; border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: color-mix(in srgb, var(--accent) 72%, var(--ink));
  cursor: pointer; text-decoration: none; display: inline-block;
  transition: background var(--speed), border-color var(--speed), color var(--speed);
}
button:hover, .button:hover {
  background: color-mix(in srgb, var(--accent) 20%, transparent);
  border-color: color-mix(in srgb, var(--accent) 55%, transparent);
  color: var(--accent);
}
button:active, .button:active { background: color-mix(in srgb, var(--accent) 28%, transparent); }
button:focus-visible, .button:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}

/* Quieter still: no fill until it is pointed at. */
button.secondary, .button.secondary {
  background: transparent; border-color: var(--line); color: var(--muted);
}
button.secondary:hover, .button.secondary:hover {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  color: var(--accent);
}

button.danger, .button.danger {
  background: color-mix(in srgb, var(--bad) 12%, transparent);
  border-color: color-mix(in srgb, var(--bad) 32%, transparent);
  color: color-mix(in srgb, var(--bad) 72%, var(--ink));
}
button.danger:hover, .button.danger:hover {
  background: color-mix(in srgb, var(--bad) 22%, transparent);
  border-color: var(--bad); color: var(--bad);
}

/* Browsers without color-mix keep a solid, legible button rather than an
   invisible one. */
@supports not (background: color-mix(in srgb, red 10%, transparent)) {
  button, .button { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
  button.secondary, .button.secondary { background: var(--panel); color: var(--accent); }
  button.danger, .button.danger { background: var(--bad); color: #fff; border-color: var(--bad); }
}

/* One filled button per view: the thing the page exists to do. Everything else
   stays tinted, which is what made a page of eight equally weighted buttons
   read as eight equally likely choices. */
button.primary, .button.primary {
  background: var(--accent); border-color: var(--accent); color: var(--accent-ink);
  font-weight: 600;
}
button.primary:hover, .button.primary:hover {
  background: color-mix(in srgb, var(--accent) 85%, var(--ink));
  border-color: color-mix(in srgb, var(--accent) 85%, var(--ink));
  color: var(--accent-ink);
}

/* Busy and unavailable.

   There was no rule for either, so "Sync now" looked exactly the same during a
   forty-second run as it did before it started. aria-disabled rather than
   disabled is what the submit handler sets — a disabled button leaves the tab
   order, throwing a keyboard user back to the top of the page mid-submit. */
button[disabled], .button[disabled],
button[aria-disabled="true"], .button[aria-disabled="true"] {
  opacity: .55; cursor: not-allowed; pointer-events: none;
}
button[aria-disabled="true"] { cursor: progress; }

button.is-busy, .button.is-busy { position: relative; color: transparent; }
button.is-busy::after, .button.is-busy::after {
  content: ""; position: absolute; inset: 0; margin: auto;
  width: 1em; height: 1em; border-radius: 50%;
  border: 2px solid currentColor; border-top-color: transparent;
  color: var(--accent);
  animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  button.is-busy::after, .button.is-busy::after { animation-duration: 2.4s; }
}

button.small, .button.small { padding: .2rem .5rem; font-size: .85rem; }
/* A block button: it leads somewhere rather than acting on what is beside it,
   so it takes the full width instead of sitting in a row of small controls. */
.button-wide {
  display: block; width: 100%; text-align: center;
  padding: .6rem .8rem; margin-bottom: 1rem;
}

.row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end; }
.row > div { flex: 1 1 180px; }
/* A control's label is not prose: at flex:0 the Add user button shrank to its
   min-content and came out as "Add / user". */
.row .button, .row button { white-space: nowrap; }
.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }

/* An inline edit form that has to sit inside a table cell. */

/* --- flashes ----------------------------------------------------------- */
.flash { padding: .6rem .8rem; border-radius: var(--radius); margin-bottom: 1rem; }
.flash.ok  { background: var(--ok-bg);  border: 1px solid var(--ok-line);  color: var(--ok); }
.flash.err { background: var(--bad-bg); border: 1px solid var(--bad-line); color: var(--bad); }

/* --- tags -------------------------------------------------------------- */
.tag {
  display: inline-block; font-size: .75rem; padding: .1rem .45rem; border-radius: 999px;
  background: var(--panel-2); border: 1px solid var(--line); color: var(--muted);
  margin-right: .25rem;
  /* A pill is one token. Without this, "3 doors" wrapped inside its own border
     on a phone and came out as a two-line circle. */
  white-space: nowrap;
}
.tag.ok   { background: var(--ok-bg);   color: var(--ok);   border-color: var(--ok-line); }
.tag.warn { background: var(--warn-bg); color: var(--warn); border-color: var(--warn-line); }
.tag.bad  { background: var(--bad-bg);  color: var(--bad);  border-color: var(--bad-line); }
/* --- inline status / rank icons ----------------------------------------
   The user list dropped its Status column; these sit in front of the name
   instead. Sized under the nav icons so a table row does not grow. */
/* Two fixed slots then the name, so every name in the column starts at the
   same place whether or not its row carries a rank. An absent rank keeps its
   slot rather than letting the name slide left. */
.name-cell { display: grid; grid-template-columns: 14px 14px 1fr; align-items: center; gap: .4rem; }
/* The address has a column of its own wherever there is room for one. Below
   the phone breakpoint there is not — four columns left neither the name nor
   the address enough width and both broke mid-word — so there it moves under
   the name instead and the column goes away. Reserving that vertical slot on
   a desktop would waste the width the column is there to use. */
.name-sub { display: none; color: var(--muted); font-size: .78rem; line-height: 1.35; }

/* Kept next to the rule above deliberately: both are `display` on the same
   element at the same specificity, so the one that wins is whichever comes
   last in the file. Split across two sections, the phone override sat 100
   lines earlier and quietly lost, leaving the address showing nowhere at all
   on a phone. */
@media (max-width: 720px) {
  .col-email { display: none; }
  .name-sub { display: block; }
  /* Columns worth a third of a phone screen only when there is a screen to
     spare: a 32-character UniFi hash, a timestamp beside a summary. The row's
     action must stay reachable without scrolling sideways to find it. */
  .col-wide { display: none; }
}
.ico { display: inline-flex; flex: 0 0 auto; line-height: 0; }
.ico .ico-svg { width: 14px; height: 14px; }
.ico.ok     { color: var(--ok); }
.ico.warn   { color: var(--warn); }
.ico.bad    { color: var(--bad); }
.ico.accent { color: var(--accent); }
/* The placeholder that holds the rank column open when there is no rank to
   show. It had no rule at all, so it collapsed to nothing and the names it was
   meant to keep aligned were not. */
.ico-empty { width: 14px; height: 14px; }


/* One small-caps label. There were five rules describing three slightly
   different versions of the same thing, which is why the areas page, the
   tables and the drawers never quite matched. Each consumer keeps its own
   spacing; only the type is shared. */
.micro-label,
.nav-heading,
.section-label,
.picker-heading,
.tree-head,
th {
  font-size: var(--text-micro); font-weight: 600; text-transform: uppercase;
  letter-spacing: .07em; color: var(--muted);
}

.muted { color: var(--muted); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
.small { font-size: .85rem; }
.stack > * + * { margin-top: .75rem; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

fieldset { border: 1px solid var(--line); border-radius: var(--radius); margin: 0 0 1rem; padding: .75rem; }
legend { font-size: .85rem; color: var(--muted); padding: 0 .35rem; }
.auth { max-width: 420px; margin: 3rem auto; }
.notice { border-left: 3px solid var(--warn); padding-left: .75rem; color: var(--muted); }

/* "Nothing here" is a state, not a missing row. The copy for these was already
   good; it was being rendered as a faint <td> that read like a rendering
   failure. */
.empty {
  margin: 0; padding: var(--space-5) var(--space-4);
  text-align: center; color: var(--muted); font-size: var(--text-small);
  border: 1px dashed var(--line); border-radius: var(--radius);
  background: var(--panel-2);
}
td.empty, th.empty { border: 0; background: transparent; }

/* Panels open with a heading that must not push the panel's own padding down.
   Twenty-six templates said style="margin-top:0" to arrange that. */
/* A panel's first child never pushes the panel's own padding down. Twenty-six
   templates said style="margin-top:0" to arrange that, one heading at a time. */
.panel > *:first-child, .modal > *:first-child { margin-top: 0; }
.panel-title { margin-top: 0; font-size: var(--text-h2); }

/* A button in a .row sits at its own width; .row > div otherwise makes every
   child a 180px flex base, which stretched every Save and Cancel across the
   form. Fifteen templates carried style="flex:0" for this. */
.row > .row-action { flex: 0 0 auto; }
/* The search takes the width the sort control does not want: it is the one
   thing on that row somebody types into, and a 180px box does not hold a
   badge number and its name. Sized on the flex item, because the global
   input width beats anything set on the input itself. */
.row > .cred-search { flex: 3 1 20rem; }

/* The last inline styles in the application, named rather than repeated. Every
   one of these was the only thing keeping 'unsafe-inline' in the Content
   Security Policy. */
.row--tight { gap: var(--space-2); }
.row--spaced { margin-top: var(--space-3); }
.form--spaced { margin-bottom: var(--space-4); }
.field--name { width: 320px; max-width: 100%; }
.pre-wrap { white-space: pre-wrap; }
.qr-code { width: 220px; height: 220px; background: #fff; }

/* The read-only week: the drawn bars when there are scripts to place them,
   the same windows spelled out when there are not. */
.sched-day-text { color: var(--muted); }
html.has-js .sched-day-text { display: none; }
html:not(.has-js) [data-mini-track] { display: none; }

/* htmx toggles .htmx-request on the element that asked; the spinner it names
   fades in for as long as the request is out. Search felt broken on a slow
   connection because nothing at all changed until the rows did. */
.htmx-indicator { opacity: 0; transition: opacity var(--speed); }
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { opacity: 1; }
.field-spinner {
  position: absolute; right: .6rem; top: 50%; margin-top: -.5em;
  width: 1em; height: 1em; border-radius: 50%;
  border: 2px solid var(--accent); border-top-color: transparent;
  animation: spin 700ms linear infinite;
  pointer-events: none;
}
.row > div:has(> .field-spinner) { position: relative; }

/* --- checkbox grids (zone door pickers, admin allocation) -------------- */
.checks {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: .15rem; margin-bottom: .75rem;
}
.check {
  display: flex; align-items: center; gap: .45rem; margin: 0;
  padding: .3rem .45rem; border-radius: var(--radius); font-size: .9rem; color: var(--ink);
  cursor: pointer; transition: background var(--speed);
}
.check:hover { background: var(--hover); }
.check input { margin: 0; }

/* A space and the doors inside it, on the administrator allocation page. */
.zone-group { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: .75rem; }
.zone-group > summary {
  padding: .5rem .7rem; cursor: pointer; font-weight: 600;
  display: flex; align-items: center; gap: .5rem;
}
.zone-group > summary:hover { background: var(--hover); }
.zone-group > summary::marker { color: var(--muted); }
.zone-group .checks { padding: 0 .7rem .7rem; margin-bottom: 0; }
.zone-group .bulk { font-weight: 400; font-size: .8rem; margin-left: auto; }
.bulk-toggle {
  display: inline-flex; align-items: center; gap: .25rem;
  font-weight: 500; color: var(--muted); line-height: 1;
}
.bulk-toggle input { margin: 0; }
.bulk-toggle.compact { font-size: .78rem; }
.door-branch { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; }
.tree-check { flex: 0 0 auto; }

/* --- superadmin preview ------------------------------------------------ */
.preview-banner {
  display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
  background: var(--warn-bg); border: 1px solid var(--warn-line); color: var(--warn);
}
.preview-banner form { margin: 0; margin-left: auto; }
.preview-banner button {
  padding: .2rem .6rem; font-size: .85rem;
  background: var(--warn); border-color: var(--warn); color: var(--panel);
}

/* --- selection drawer ---------------------------------------------------
   A right-hand drawer, the way the UniFi console surfaces the thing you have
   just picked. It is fixed, so opening it never reflows the tree: earlier this
   sat in the flow and appearing shoved every row down the page, throwing the
   door you had just clicked out from under the cursor.

   The content is inset by the drawer's width while it is open rather than
   being covered. That is a horizontal shift only — rows keep their vertical
   position, so the row you clicked stays exactly where it was.

   The drawer carries the hidden attribute when nothing is ticked; a display
   value here would beat the user-agent [hidden] rule, so that is restated. The
   transition rides on a separate class so hiding stays instant and correct.
------------------------------------------------------------------------- */
/* The drawer grows with the screen instead of staying a fixed sliver: at 330px
   a form with a paired button row had nowhere to go. It never takes more than
   a third of a wide display, and never more than the viewport on a small one. */
body { --drawer: clamp(340px, 26vw, 520px); }
/* A drawer holding a whole record needs more room than one holding an action. */
body.drawer-wide { --drawer: clamp(340px, 34vw, 680px); }
@media (max-width: 900px) {
  body, body.drawer-wide { --drawer: min(420px, 92vw); }
}

/* Content sits in a centred column rather than stretching to the full width —
   a 520px-wide label/field pair reads badly, and the eye loses the left edge. */
.drawer > *, .selection-bar > * {
  width: 100%; max-width: 30rem; margin-inline: auto;
}

.selection-bar,
.drawer {
  position: fixed; top: 0; right: 0; bottom: 0; left: auto; z-index: 30;
  width: var(--drawer);
  display: flex; flex-direction: column; align-items: stretch; gap: .9rem;
  padding: 1rem 1.1rem 1.3rem;
  overflow-y: auto;
  background: var(--panel); border-left: 1px solid var(--line);
  box-shadow: var(--shadow-2);
}
.selection-bar[hidden], .drawer[hidden] { display: none; }
.selection-bar [data-selection-count] { font-variant-numeric: tabular-nums; }

html.has-js .selection-bar,
html.has-js .drawer {
  transform: translateX(100%);
  transition: transform var(--speed) ease-out;
}
html.has-js .selection-bar.is-visible,
html.has-js .drawer.is-visible { transform: none; }

/* Inside a drawer the record's own heading carries the name, so the page-level
   h1 would be a second title. */
.drawer h1 { font-size: 1.1rem; margin-bottom: .2rem; }
/* The drawer's own title is the h2 above; the body's lead reads as a heading
   without claiming to be the page's. */
.drawer-lead { font-size: 1.1rem; margin: 0 0 .2rem; }
/* minmax(0, 1fr), not 1fr: a track's automatic minimum is min-content, so a
   panel holding a wide table inflated the track and pushed the panel (373px)
   out of a 358px drawer. Since the drawer is position: fixed, that horizontal
   overflow is what carried its close button off a phone screen. */
.drawer .grid2 { grid-template-columns: minmax(0, 1fr); }
.drawer .panel { box-shadow: none; padding: .8rem; }
.drawer table { font-size: .85rem; }
.drawer th, .drawer td { padding: .35rem .4rem; }

/* Rows that open a drawer. The Open link they replace stays for anyone with
   no scripts, which is also the only way this page works without htmx. */
html.has-js .row-open { cursor: pointer; }
html.has-js .row-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
html.has-js .no-js-only { display: none; }
.row-open.is-open td { background: var(--accent-soft); }

/* The drawer floats over the content rather than making room in it, the way
   the UniFi console does. Insetting the content reflowed the page every time a
   drawer opened, which moved the row that had just been clicked. Overlaying
   costs some width on a narrow screen and costs nothing at all on a wide one,
   where the content is centred clear of it. */

.drawer-head {
  display: flex; align-items: center; gap: .5rem;
  padding-bottom: .7rem; border-bottom: 1px solid var(--line-soft);
}
.drawer-title { margin: 0; font-size: .95rem; font-weight: 600; }
.drawer-close {
  margin-left: auto; width: 26px; height: 26px; padding: 0; flex: 0 0 26px;
  display: grid; place-items: center;
  background: transparent; border: 1px solid var(--line); border-radius: var(--radius);
  color: var(--muted); font-size: 1.05rem; line-height: 1; cursor: pointer;
}
.drawer-close:hover { background: var(--hover); color: var(--ink); }

/* On a phone the drawer covers the page, and nothing said so: the content
   behind it stayed at full contrast and looked reachable. The scrim also gives
   a place to tap to dismiss — app.js wires that to the same close. */
@media (max-width: 720px) {
  body.drawer-open::before {
    content: ""; position: fixed; inset: 0; z-index: 29;
    background: rgba(8, 11, 15, .45);
  }
}

.drawer-field label { margin-top: 0; }
.drawer-field .combobox { display: block; width: 100%; min-width: 0; }
.drawer-hint { margin: .35rem 0 0; }

.drawer-actions { display: flex; flex-direction: column; gap: .45rem; }
.drawer-actions button { width: 100%; }
.drawer-actions button[hidden] { display: none; }

/* --- the identity block --------------------------------------------------
   Name, address and status read as a record rather than a form until somebody
   asks to change them. The values are inputs the whole time so nothing shifts
   position when it unlocks — only the chrome arrives. */
.identity-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .2rem; }
.identity-head h2 { margin: 0; }
.identity-actions { margin-left: auto; display: flex; gap: .4rem; flex: 0 0 auto; }
.identity-actions button[hidden] { display: none; }
.identity input { width: 100%; }
.identity.is-locked input {
  border-color: transparent; background: transparent; padding-left: 0; cursor: default;
}
.identity.is-locked input:focus-visible { box-shadow: none; border-color: transparent; }
.identity-status { margin: .7rem 0 0; font-size: .85rem; color: var(--muted); }

/* --- the lookup verdict --------------------------------------------------
   The answer to "who is this?" is one line, and the form underneath is the
   consequence of it. It used to be a panel with its own heading, which in the
   drawer pushed the form it introduces below the fold — so the verdict was
   read and the thing to do about it was not. */
.lookup-verdict {
  display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap;
  margin: 1rem 0 .5rem; font-size: .9rem; color: var(--muted);
}
.lookup-hit { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
.lookup-hit-name { font-weight: 600; color: var(--ink); }

/* --- the door tree -------------------------------------------------------
   Area, then zone label, then doors. Indentation carries the nesting rather
   than rules or bullets: three shallow levels read better as a stepped list
   than as a box drawing. */
.tree-area + .tree-area { margin-top: .9rem; padding-top: .9rem; border-top: 1px solid var(--line-soft); }
.tree-area-name { margin: 0 0 .3rem; font-weight: 600; }
.tree-zone { margin: .45rem 0 .15rem; font-size: .85rem; color: var(--muted); }
.tree-doors { list-style: none; margin: 0; padding: 0; }
.tree-doors.is-nested { padding-left: .9rem; }
.tree-doors li { padding: .12rem 0; }
.tree-doors li::before { content: "—"; color: var(--muted); margin-right: .45rem; }

/* --- the week ------------------------------------------------------------ */
/* A layout grid, not a list: the shared th/td rule draws a rule under every
   cell, which turned each weekday label into an underlined heading. */
.week { width: 100%; }
.week th, .week td { border-bottom: 0; }
.week th {
  text-align: left; font-weight: 500; color: var(--muted);
  width: 8.5rem; padding: .22rem .4rem .22rem 0; text-transform: none; font-size: .85rem;
}
.week td { padding: .22rem 0; }
.week .mono { margin-right: .5rem; }

/* --- a person's access: doors against hours ------------------------------
   Two readings of one fact, side by side. The doors are the narrow column
   because a door is a name, and the hours are the wide one because they are a
   picture. Below the breakpoint they stack rather than shrink: a 24-hour track
   in a third of a phone is not a picture of anything.
------------------------------------------------------------------------- */
.access-split { display: grid; grid-template-columns: 30% minmax(0, 1fr); gap: 1.2rem; }
@media (max-width: 900px) { .access-split { grid-template-columns: minmax(0, 1fr); } }
/* Grid children default to min-width:auto, which lets a long door name push
   the 30% column wider than 30%. */
.access-doors, .access-hours { min-width: 0; }
.split-title {
  margin: 0 0 .5rem; font-size: .8rem; font-weight: 600; letter-spacing: .04em;
  text-transform: uppercase; color: var(--muted);
}
.zone-group + .zone-group { margin-top: .5rem; }
/* A group is the whole set of labels a door carries, so several tags sit on
   the heading line together. */
.zone-group .tree-zone { display: flex; flex-wrap: wrap; gap: .25rem; }

.lane-key { display: flex; flex-wrap: wrap; gap: .3rem; margin: 0 0 .6rem; }
.week-lanes { display: flex; flex-direction: column; gap: .3rem; }
/* Past the day-name and lane-name columns, so an hour on the scale is over
   that hour on the tracks below it. */
.week-lanes .track-scale {
  margin-left: calc(2.4rem + .5rem + 5.5rem + .4rem); margin-right: 0;
}
.lane-day {
  display: grid; grid-template-columns: 2.4rem minmax(0, 1fr);
  gap: .5rem; align-items: start;
}
.lane-day-name {
  text-transform: capitalize; font-size: .75rem; color: var(--muted);
  padding-top: .15rem;
}
/* One lane per zone and schedule, stacked: overlaying them would hide whichever
   was drawn first, and which grouping is open when is the whole question. */
.lane-stack { display: flex; flex-direction: column; gap: .15rem; }
/* Two lanes of one zone are the same colour, so each says which schedule it
   is. The name is repeated on every day rather than written once above the
   week: the stack is read a day at a time. */
.lane-row {
  display: grid; grid-template-columns: 5.5rem minmax(0, 1fr);
  gap: .4rem; align-items: center;
}
.lane-name {
  font-size: .7rem; color: var(--muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lane-row .sched-day-text { grid-column: 2; }
/* A phone has no 5.5rem to spare beside a 24-hour track. The name keeps its
   own line there instead of taking a column off the picture. */
@media (max-width: 720px) {
  .lane-row { grid-template-columns: minmax(0, 1fr); gap: .1rem; }
  .lane-row .sched-day-text { grid-column: 1; }
  .week-lanes .track-scale { margin-left: calc(2.4rem + .5rem); }
}
.lane-shut { margin: 0; }
/* The mini track already draws the hour grid and its own height; the lane only
   colours what sits on it, from the same hue the zone's tag uses. */
.track.lane .mini-block {
  background: hsl(var(--hue, 214) calc(62% * var(--sat, 1)) 62%);
  border-color: hsl(var(--hue, 214) calc(62% * var(--sat, 1)) 45%);
  color: hsl(var(--hue, 214) calc(62% * var(--sat, 1)) 18%);
}

/* The policy picker (users/[id]). A grid rather than a list: they are read
   across, as a set of things this person could be on, and each one carries the
   colour its hours are drawn in below. Not a table — a chip is a control, not
   a cell.
------------------------------------------------------------------------- */
.policy-grid {
  display: grid; gap: .5rem; margin-bottom: .8rem;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
}
.policy-chip {
  display: flex; align-items: flex-start; gap: .5rem;
  padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 8px;
  background: var(--panel); cursor: pointer;
}
.policy-chip:hover { border-color: var(--line-strong, var(--line)); }
/* Held before this edit began: the one state the server rendered. */
.policy-chip.is-held { background: var(--raised, var(--panel)); }
/* Ticked but not saved, and unticked but still saved — both are proposals, and
   the dashed edge is what says "this is not the record yet". */
.policy-chip.is-preview { border-style: dashed; border-color: hsl(38 80% 50%); }
.policy-chip.is-dropped { border-style: dashed; opacity: .6; }
.policy-chip-body { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
.policy-name { font-size: .85rem; font-weight: 600; }
.policy-meta { font-size: .7rem; color: var(--muted); }
.policy-dot {
  flex: none; width: .7rem; height: .7rem; margin-top: .25rem; border-radius: 50%;
  background: hsl(var(--hue, 214) calc(62% * var(--sat, 1)) 55%);
  border: 1px solid hsl(var(--hue, 214) calc(62% * var(--sat, 1)) 40%);
}
/* Inside a name on the zone-schedule list, so the two pages agree on colour. */
.acc-name .policy-dot { display: inline-block; margin: 0 .35rem 0 0; vertical-align: baseline; }
.policy-unsaved { margin: 0 0 .6rem; font-size: .8rem; color: hsl(38 80% 32%); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .policy-unsaved { color: hsl(38 80% 66%); }
}
:root[data-theme="dark"] .policy-unsaved { color: hsl(38 80% 66%); }

/* The doors those policies open, as Area -> the zones a door carries -> the
   door. The indent is what makes it a tree; the marks are what say which level
   a line is on, since the three read as one column otherwise. */
.door-list, .door-tree, .door-zones, .door-leaves {
  list-style: none; margin: 0; padding: 0;
}
.door-list, .door-tree { margin-bottom: .6rem; --indent: 1.1rem; }
.door-zones, .door-leaves { padding-left: var(--indent); }
.door-area + .door-area { margin-top: .5rem; }
.door-branch { display: flex; align-items: center; gap: .35rem; padding: .15rem 0; }
.door-area > .door-branch { font-size: .85rem; font-weight: 600; }
.door-zone > .door-branch { font-size: .78rem; color: var(--muted); }
.door-zone > .door-branch em { font-style: italic; }
.zone-group .door-tree { padding: .35rem .7rem .7rem; margin-bottom: 0; }
.zone-group .door-zone > .door-branch { padding-left: var(--indent); }
.zone-group .door-leaves { padding-left: calc(var(--indent) * 2); }
.zone-group .door-check { grid-template-columns: auto minmax(0, 1fr); }
.zone-group .door-name { overflow: visible; text-overflow: clip; white-space: normal; }
.branch-icon { flex: none; opacity: .75; width: 13px; height: 13px; }
.door-item { display: flex; align-items: center; gap: .4rem; padding: .15rem 0; }
.door-dots { display: flex; gap: .2rem; flex: none; }
.door-name { font-size: .85rem; }
/* A door or a lane on the page only because something is ticked. */
.door-item.is-preview, .lane-row.is-preview { opacity: .75; }
.lane-row.is-preview .lane-name::after { content: " (preview)"; }
/* A display of grid or flex outranks the user-agent [hidden] rule, so each of
   these restates it. Without that an unticked policy's lanes and doors stay on
   the page: the server marks them hidden and the layout puts them back. */
.lane-row[hidden], .door-item[hidden],
.door-area[hidden], .door-zone[hidden] { display: none; }

.zone-checks { display: flex; flex-wrap: wrap; gap: .25rem .9rem; }

.cred-line { margin: .35rem 0; display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
.cred-line:first-of-type { margin-top: 0; }
/* Same reason as the lanes above: the flex display would put a searched-out
   badge back on the page. */
.cred-line[hidden] { display: none; }

/* The badge search in a panel head, next to Add a badge: both pushed to the
   right of the heading, the search first because it is what the panel is
   mostly used for. The field is sized as a flex item — a width on the input
   loses to the global `input { width: 100% }`. */
.panel-head .cred-search-head { display: flex; align-items: baseline; gap: .4rem; margin-left: auto; }
.panel-head .cred-search-head input { flex: 1 1 11rem; min-width: 0; }
.panel-head .cred-search-head + .panel-head-action { margin-left: 0; }

/* The row a badge is on, and what can be done to it. The actions sit at the
   end and stay out of sight until the row is pointed at or tabbed into: two
   destructive buttons per badge, permanently on, would be the loudest thing on
   a page that is mostly about access. Faded rather than removed, so they keep
   their place in the layout and nothing jumps when the pointer arrives. */
.cred-line { padding: .3rem .4rem; margin-inline: -.4rem; border-radius: var(--radius); }
.cred-line:hover, .cred-line:focus-within { background: var(--hover); }
.cred-actions {
  display: flex; gap: .35rem; margin-left: auto;
  opacity: 0; transition: opacity var(--speed);
}
.cred-line:hover .cred-actions, .cred-line:focus-within .cred-actions { opacity: 1; }
/* No pointer to hover with, and on a touch screen nothing else would reveal
   them. The same goes for anyone who has asked for less movement. */
@media (hover: none), (prefers-reduced-motion: reduce) {
  .cred-actions { opacity: 1; transition: none; }
}

/* Removing a badge is two acts, and the dialog exists to make somebody say
   which. They are laid out as two blocks with their own button rather than a
   pair of radio buttons and one Confirm: what each does is the thing being
   chosen between, so it has to be readable at the moment of choosing. */
.choice-list { list-style: none; margin: 0; padding: 0; }
.choice {
  display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
  padding: .7rem 0; border-top: 1px solid var(--line);
}
.choice-body { flex: 1 1 16rem; min-width: 0; }
.choice h3 { margin: 0 0 .2rem; font-size: .92rem; }
.choice p { margin: 0; }
.choice-body p + p { margin-top: .35rem; }
.choice form { margin: 0; }
.choice > form, .choice > .button { flex: 0 0 auto; }

/* The last tick before something goes. Aligned to the top because the
   sentence beside it runs to two lines and a box floating in the middle of
   them reads as decoration. */
.check--confirm { align-items: flex-start; padding-left: 0; }
.check--confirm input { margin-top: .2rem; flex: 0 0 auto; }
.check--confirm span { font-size: .85rem; }

.reasons { margin: 0; padding: 0; border: 0; }
.reasons legend { padding: 0; font-size: .8rem; color: var(--muted); }

html:not(.has-js) [data-cred-act-open] { display: none; }
html:not(.has-js) [data-cred-act] {
  display: block; position: static;
  width: auto; max-width: none; margin: 0 0 1rem; box-shadow: var(--shadow);
}
html:not(.has-js) [data-cred-act] .modal-x { display: none; }

/* Narrow screens get a drawer over the content — there is no room to inset.
   Its width is set once, in the 900px block near the top: a second --drawer at
   the same breakpoint made the width depend on rule order, not the viewport. */

/* The template ships the drawer hidden because JS un-hides it as soon as a
   door is ticked. With no JS nothing ever would, and the assign controls are
   the point of the page — so show it in flow, as a plain panel above the
   list. */
html:not(.has-js) .drawer { display: none; }
html:not(.has-js) .selection-bar[hidden] { display: flex; }
html:not(.has-js) .selection-bar {
  position: static; width: auto; margin: 0 0 1rem;
  border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--shadow);
}

/* --- combobox ----------------------------------------------------------
   A plain text input with a shortcut list under it. Free text always wins,
   so nothing here may look like a closed set.
------------------------------------------------------------------------- */
.combobox { position: relative; display: inline-block; min-width: 15rem; }
.combobox [data-combobox-input] { width: 100%; }

.combobox-list {
  position: absolute; top: calc(100% + .25rem); left: 0; right: 0; z-index: 30;
  margin: 0; padding: .2rem; list-style: none;
  max-height: 15rem; overflow-y: auto; overscroll-behavior: contain;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow);
}
.combobox-list[hidden] { display: none; }
.combobox-list li {
  padding: .35rem .5rem; border-radius: var(--radius); font-size: .9rem;
  color: var(--ink); cursor: pointer;
  transition: background var(--speed), color var(--speed);
}
.combobox-list li[hidden] { display: none; }
.combobox-list li:hover { background: var(--hover); }
.combobox-list li.is-active { background: var(--accent-soft); color: var(--accent-text); }

/* The "will be created" hint. Not an option, so it must not look clickable. */
.combobox-list li.combobox-empty {
  color: var(--muted); font-size: .85rem; cursor: default;
}
.combobox-list li.combobox-empty:hover { background: transparent; }

/* ---------------------------------------------------------------------------
   Space management tree.

   A tree without connector lines: depth is carried by indentation and by the
   disclosure triangle alone. Rows are a grid rather than a table so columns
   line up across every branch, which a table-per-branch could not do.
--------------------------------------------------------------------------- */
.tree-panel { padding: .35rem .5rem .5rem; --indent: 1.15rem; }

.tree-head,
.tree-leaf {
  display: grid;
  /* The name gets what it needs and no more — as a fraction it stretched with
     the window and left a gulf before the labels. The labels take the slack
     instead, which is the column that actually benefits from width. */
  /* The UniFi state moved in front of the name, so that column is gone. The
     action column sizes to its content now that the button carries a label. */
  grid-template-columns: 1.75rem minmax(10rem, max-content) minmax(0, 1fr) auto;
  gap: .5rem;
  align-items: center;
}
.tree-head {
  padding: .35rem .6rem .35rem calc(.6rem + var(--indent));
  border-bottom: 1px solid var(--line);
}

.tree-node { border-bottom: 1px solid var(--line-soft); }
.tree-node:last-child { border-bottom: 0; }

.tree-branch {
  display: flex; align-items: center; gap: .5rem;
  padding: .5rem .6rem; cursor: pointer;
  font-weight: 600;
  border-radius: var(--radius);
  transition: background var(--speed);
}
.tree-branch:hover { background: var(--hover); }

/* A 324px row minus two count pills and an Edit button leaves 129px for the
   name, and "Baxter Design" needs 132 — so every two-word area broke across
   two lines while the counts sat beside it. The name gets the line; the counts
   are metadata and drop below it. */
@media (max-width: 720px) {
  /* The name keeps its own width rather than shrinking to a word — that is
     what broke "Baxter Design" in half — and the row wraps instead. Source
     order stands: name, then its counts, then the way to edit it. */
  .tree-branch { flex-wrap: wrap; row-gap: .25rem; }
  .tree-branch .branch-name { flex: 0 0 auto; min-width: 0; }
  .tree-branch .icon-text-btn { margin-left: auto; }
}
.tree-branch::marker { color: var(--muted); }
.tree-branch .branch-name em { color: var(--muted); font-weight: 500; }

/* The leaf indent is what makes it read as a tree — one step in from the
   branch, with the disclosure triangle's own width accounted for. */
.tree-leaf {
  padding: .35rem .6rem .35rem calc(.6rem + var(--indent));
  border-radius: var(--radius);
  transition: background var(--speed);
}
.tree-leaf:hover { background: var(--hover); }
.tree-leaf .col-actions form { margin: 0; }

/* Two icon columns replacing a word each: whether UniFi knows this door, and
   the unlock. Both carry a title and an aria-label, since an icon alone says
   nothing to a screen reader. */
.state-icon { display: inline-grid; place-items: center; color: var(--muted); }
.state-icon.is-linked { color: var(--ok); }
.icon-btn {
  display: inline-grid; place-items: center;
  width: 26px; height: 26px; padding: 0;
  background: transparent; border: 1px solid var(--line); border-radius: var(--radius);
  color: var(--muted); cursor: pointer;
}
.icon-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--hover); }
.tree-empty { padding: .35rem .6rem .5rem 2rem; margin: 0; }

/* A row whose checkbox is ticked, so the selection stays legible while the
   sticky bar is being used. */
.tree-leaf:has(input[data-door-select]:checked) { background: var(--accent-soft); }

/* --- picking a row ------------------------------------------------------
   Clicking a door reveals the checkboxes of its zone and no other, so a
   selection can never span two zones.

   Everything here is behind html.has-js: with scripts off no row is clickable
   and every checkbox has to stay visible, which is the whole no-JS fallback.
   It is scoped to .tree-leaf[data-door] as well, because the superadmin
   allocation page has the same checkboxes on rows that carry no data-door and
   never gets a reveal — its boxes must stay visible either way.

   visibility, not display: the select column is a fixed grid track, so the
   rows keep their alignment as the selection moves, and a hidden box drops
   out of the tab order instead of being a focusable invisible target.
------------------------------------------------------------------------- */
/* Only the parts that actually select show a pointer, so the cursor tells the
   truth about where a click lands. */
html.has-js .tree-leaf[data-door] { cursor: default; }
html.has-js .tree-leaf[data-door] .col-select,
html.has-js .tree-leaf[data-door] .col-name { cursor: pointer; }
html.has-js .tree-leaf[data-door]:focus-visible {
  outline: 2px solid var(--accent); outline-offset: -2px;
}

/* Checkboxes appear for the zone in play and stay there until the selection
   moves elsewhere. visibility, not display — the select column is a fixed grid
   track, so rows keep their alignment as the selection changes, and a hidden
   box leaves the tab order instead of being an invisible focus target. */
html.has-js .tree-leaf[data-door] [data-door-select] { visibility: hidden; }
html.has-js .tree-leaf[data-door].is-selectable [data-door-select] { visibility: visible; }

/* Rows in another zone while a selection exists. Still readable and still
   clickable — clicking one moves the selection there — just quieter, so the
   zone in play is obvious. */
html.has-js .tree-leaf[data-door].is-inert { color: var(--muted); }

html.has-js .tree-leaf[data-door].is-selected {
  background: var(--accent-soft);
  box-shadow: inset 2px 0 0 var(--accent);
}

/* Select-all for a zone: the same checkbox as the rows, sitting in the same
   grid track directly above them. Part-selected shows as indeterminate.
   Hidden until that zone has a selection, so an untouched page is plain. */
.zone-toggle { margin: 0; cursor: pointer; accent-color: var(--accent); }
.zone-toggle[hidden] { display: none; }

@media (max-width: 720px) {
  .tree-head { display: none; }
  /* Name and its actions share the first line; the zone labels take the
     second. Stacking all three put every door on three lines and made a
     six-door area a page and a half of scrolling. */
  .tree-leaf {
    grid-template-columns: 1.75rem minmax(0, 1fr) auto;
    row-gap: .15rem;
  }
  .tree-leaf .col-name { grid-column: 2; min-width: 0; }
  .tree-leaf .col-actions { grid-column: 3; grid-row: 1; justify-self: end; }
  .tree-leaf .col-zones { grid-column: 2 / -1; }
}

/* --- palette -------------------------------------------------------------
   One rule set driven by a hue, so a colour costs one declaration rather than
   a block — and so each theme can pick its own lightness from the same hue. A
   hex stored in the database could not do that: what reads well on white is
   unreadable on near-black.

   Sixteen of them, listed here in hue order for reading; the order they are
   *handed out* in is app.services.zones.PALETTE, which keeps the original
   eight first so nothing already labelled changes colour. Sixteen is the
   width, not a ceiling on zone schedules: past it the wheel is crowded enough
   that two dots stop being tellable apart, and the name beside the dot is
   what separates them. Every name here must exist in PALETTE and vice versa —
   test_route_wiring pins that.
------------------------------------------------------------------------- */
.tag--red     { --hue: 8; }
.tag--orange  { --hue: 24; }
.tag--amber   { --hue: 38; }
.tag--yellow  { --hue: 62; }
.tag--lime    { --hue: 88; }
.tag--moss    { --hue: 116; }
.tag--green   { --hue: 145; }
.tag--jade    { --hue: 160; }
.tag--teal    { --hue: 176; }
.tag--cyan    { --hue: 194; }
.tag--blue    { --hue: 214; }
.tag--indigo  { --hue: 241; }
.tag--violet  { --hue: 268; }
.tag--magenta { --hue: 292; }
.tag--pink    { --hue: 320; }
.tag--rose    { --hue: 344; }

/* Grey is not in the palette an owner can pick from: it is what a door with no
   label is drawn in. Same hue machinery, saturation turned nearly off, so it
   reads as "none of the above" in both themes instead of as a ninth colour. */
.tag--grey   { --hue: 214; --sat: .12; }

/* Gold is the one tag that is not a label: it says "administers this area".
   It rides the same hue machinery so both themes stay legible, and carries
   weight as well as colour — colour alone must not be the only signal. */
.tag--gold   { --hue: 44; }
.tag.tag--gold { font-weight: 600; }

.tag[class*="tag--"] {
  background: hsl(var(--hue) calc(88% * var(--sat, 1)) 95%);
  color: hsl(var(--hue) calc(62% * var(--sat, 1)) 30%);
  border-color: hsl(var(--hue) calc(62% * var(--sat, 1)) 84%);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .tag[class*="tag--"] {
    background: hsl(var(--hue) calc(42% * var(--sat, 1)) 16%);
    color: hsl(var(--hue) calc(70% * var(--sat, 1)) 74%);
    border-color: hsl(var(--hue) calc(38% * var(--sat, 1)) 30%);
  }
}
:root[data-theme="dark"] .tag[class*="tag--"] {
  background: hsl(var(--hue) calc(42% * var(--sat, 1)) 16%);
  color: hsl(var(--hue) calc(70% * var(--sat, 1)) 74%);
  border-color: hsl(var(--hue) calc(38% * var(--sat, 1)) 30%);
}

/* --- inline label editing ------------------------------------------------
   Tag pills that can be removed one at a time, and a "+ label" that opens a
   suggestion box on the row itself. Removal asks first, because a mis-aimed
   click on a small x is easy.
------------------------------------------------------------------------- */
/* One line by default: a door with a dozen labels must not make its row four
   rows tall. The expander lifts the cap for that row only. */
.col-zones { display: flex; align-items: flex-start; gap: .25rem; min-width: 0; }
.labels {
  display: flex; flex-wrap: wrap; align-items: center; gap: .25rem;
  /* Not 1 1 auto: growing to fill the column pushed "+ label" to the far
     right, marooned from the labels it adds to. It takes its content's width
     and the controls follow immediately after. */
  flex: 0 1 auto; min-width: 0;
  max-height: 1.55rem; overflow: hidden;
}
.labels.is-expanded { max-height: none; }
.labels .tag { display: inline-flex; align-items: center; gap: .2rem; margin: 0; }

.labels-more {
  flex: 0 0 auto; align-self: flex-start;
  padding: .05rem .35rem; border: 1px solid var(--line); border-radius: 999px;
  background: var(--panel-2); color: var(--muted);
  font: inherit; font-size: .72rem; line-height: 1.4; cursor: pointer;
}
.labels-more:hover { border-color: var(--accent); color: var(--accent); }
.labels-more[hidden] { display: none; }

.tag-x {
  display: inline-grid; place-items: center;
  width: 14px; height: 14px; padding: 0; margin-left: .1rem;
  background: transparent; border: 0; border-radius: 50%;
  color: inherit; opacity: .55; cursor: pointer;
  font-size: .8rem; line-height: 1;
}
.tag-x:hover { opacity: 1; background: rgba(128, 128, 128, .25); }

.tag--add {
  background: transparent; border-style: dashed; border-color: var(--line);
  color: var(--muted); cursor: pointer; font: inherit; font-size: .75rem;
  padding: .1rem .45rem;
}
.tag--add:hover { border-color: var(--accent); color: var(--accent); }

.label-add { display: inline-flex; align-items: center; }
.label-form { display: inline-flex; align-items: center; gap: .25rem; position: relative; }
.label-form[hidden] { display: none; }
.label-form input { width: 9rem; padding: .15rem .35rem; font-size: .8rem; }
/* Without JS the "+" cannot open anything, so show the box and hide the "+". */
html:not(.has-js) .label-form[hidden] { display: inline-flex; }
html:not(.has-js) .tag--add { display: none; }

/* The drawer's bulk editor. */
.subzone-list { display: flex; flex-wrap: wrap; gap: .3rem; margin: .35rem 0 .5rem; }
.subzone-list .check { margin: 0; padding: .15rem .3rem; }
.subzone-list .check[hidden] { display: none; }
html:not(.has-js) .subzone-list .check[hidden] { display: flex; }
[data-subzone-editor][hidden] { display: none; }
.subzone-empty { margin: 0 0 .5rem; color: var(--muted); font-size: .85rem; }

/* --- zones of one area ---------------------------------------------------
   Opened from the count in an area's header. Stacks above the selection
   drawer rather than sharing the slot, so closing it reveals whatever was
   already open underneath.
------------------------------------------------------------------------- */
#area-zones { z-index: 31; }

/* A count that is also a button. It must not look like a form control in the
   middle of a heading, so it borrows the tag's shape. */
.tag-button {
  font: inherit; font-size: .75rem; cursor: pointer;
  padding: .1rem .45rem; line-height: 1.4;
}
.tag-button:hover { border-color: var(--accent); color: var(--accent); }

.zone-list { display: flex; flex-direction: column; gap: .3rem; }
.zone-item { display: flex; align-items: center; gap: .35rem; }
.zone-pick {
  flex: 1 1 auto; display: flex; align-items: center; gap: .5rem;
  padding: .4rem .5rem; text-align: left;
  background: var(--panel-2); border: 1px solid var(--line-soft);
  border-radius: var(--radius); color: var(--ink); font: inherit; cursor: pointer;
}
.zone-pick:hover:not(:disabled) { border-color: var(--accent); background: var(--hover); }
/* A zone with no doors selects nothing, so it offers nothing to click — but it
   stays listed, because it is the one that can be deleted. */
.zone-pick:disabled { cursor: default; opacity: .65; }
.zone-pick .tag { margin: 0; }
.zone-item form { margin: 0; }

/* --- area edit drawer -----------------------------------------------------
   The panel holds four different decisions — what the area is called, the
   zones in it, the doors in it, and removing it. They were one undifferentiated
   stack. Now each is a section: an eyebrow with its own count, a hairline above
   it, and one rhythm inside. The area's name is the only boxed input in the
   panel; every other field edits in place, so nothing competes with it.
-------------------------------------------------------------------------- */
.drawer-section { padding: .95rem 0; border-top: 1px solid var(--line-soft); }
.drawer-section:first-of-type { padding-top: .2rem; border-top: 0; }
.drawer-section:last-of-type { padding-bottom: .4rem; }

.section-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: .5rem; margin-bottom: .5rem;
}
.section-count {
  font-size: .72rem; color: var(--muted); font-variant-numeric: tabular-nums;
}
.section-empty { margin: .1rem 0 0; }

/* The one boxed input in the panel spans its section, so its edges line up with
   the hairlines and the rows below rather than stopping short of them. */
.zone-panel .drawer-field > input { width: 100%; }

/* Four columns, so colour, name, count and controls line up down the list
   however long any one name is. */
.zone-manage-row {
  /* Five items sit in this row, not four: the rename form is display:contents
     so it contributes its own four, and the delete form is the fifth. Four
     columns wrapped it onto a second row and doubled the row height. */
  display: grid; grid-template-columns: 18px minmax(0, 1fr) 4.2rem 26px 26px;
  align-items: center; gap: .4rem;
  padding: .15rem .3rem; border-radius: var(--radius);
}
.zone-manage-row > form:not(.zone-rename-form) { display: inline-grid; place-items: center; }
.zone-manage-row:hover { background: var(--hover); }
.zone-rename-form { display: contents; }
.zone-rename-form .subzone-icon { display: inline-grid; place-items: center; }
/* Edit in place: no box until it is being used, so a list of zones reads as a
   list rather than as a column of form fields. */
.zone-rename-form input[name="name"] {
  min-width: 0; width: 100%; padding: .22rem .35rem;
  background: transparent; border: 1px solid transparent;
  border-radius: 4px; color: var(--ink); font: inherit;
}
.zone-manage-row:hover .zone-rename-form input[name="name"] { border-color: var(--line); }
.zone-rename-form input[name="name"]:focus {
  background: var(--panel); border-color: var(--accent); outline: none;
}
.zone-count {
  font-size: .75rem; color: var(--muted); text-align: right;
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* The row's own controls stay quiet until the row is under the pointer — they
   are per-row actions, not a column of buttons demanding attention. */
.row-action { opacity: .45; transition: opacity var(--speed); }
.zone-manage-row:hover .row-action,
.zone-manage-row:focus-within .row-action { opacity: 1; }
.row-action:focus-visible { opacity: 1; }
.danger-action:hover { border-color: var(--bad); color: var(--bad); background: var(--bad-bg); }
/* A zone still in use keeps the slot its delete would occupy, so rows do not
   jog sideways depending on which happen to be empty. */
.icon-btn.is-inert {
  display: inline-grid; place-items: center; width: 26px; height: 26px;
  border: 1px solid transparent; opacity: 0; cursor: default;
}

/* A door reads as a door: its mark, its name, then the zones it carries,
   right-aligned so the names stay in one column. */
.door-check {
  display: grid; grid-template-columns: auto 16px minmax(0, 1fr) auto;
  align-items: center; gap: .45rem;
}
.door-icon { display: inline-grid; place-items: center; color: var(--muted); }
.door-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.door-tags {
  display: flex; gap: .25rem; justify-content: flex-end; flex-wrap: wrap;
  min-width: 0; width: fit-content; max-width: 100%;
}
.door-tags .tag { margin: 0; }

/* Saving a section is a small confirmation of an edit, not the purpose of the
   panel — so it sits at the end of its own section rather than spanning it. */
.button-row.button-row--end { justify-content: flex-end; margin-top: .55rem; }
/* Declared with both classes so it outranks .button-row > *, which stretches
   children and is declared further down the sheet. */
.button-row.button-row--end > * { flex: 0 0 auto; }

/* --- toasts --------------------------------------------------------------
   Results of an action, bottom right, above everything including the drawers.
   They sit outside the flow so a redirect never shifts the page to make room
   for them, which is what the old flash at the top of <main> did.
------------------------------------------------------------------------- */
.toasts {
  position: fixed; right: 1rem; bottom: 1rem; z-index: 60;
  display: flex; flex-direction: column-reverse; gap: .5rem;
  max-width: min(28rem, calc(100vw - 2rem));
  pointer-events: none;
}
.toast {
  display: flex; align-items: flex-start; gap: .6rem;
  padding: .6rem .7rem; border-radius: var(--radius);
  box-shadow: var(--shadow-2);
  pointer-events: auto;
  font-size: .9rem;
  /* A message can contain something with no spaces in it — a card token, a
     path, an address. Without this it runs straight out of the box, taking the
     dismiss button off the edge of the screen with it. */
  min-width: 0; overflow-wrap: anywhere;
}
/* The dismiss button keeps its corner however long the message is. */
.toast-text { min-width: 0; flex: 1 1 auto; }
.toast.ok  { background: var(--ok-bg);  border: 1px solid var(--ok-line);  color: var(--ok); }
.toast.err { background: var(--bad-bg); border: 1px solid var(--bad-line); color: var(--bad); }
.toast-x {
  margin-left: auto; flex: 0 0 auto;
  width: 18px; height: 18px; padding: 0; display: grid; place-items: center;
  background: transparent; border: 0; border-radius: 50%;
  color: inherit; opacity: .6; cursor: pointer; font-size: .95rem; line-height: 1;
}
.toast-x:hover { opacity: 1; background: rgba(128, 128, 128, .2); }

html.has-js .toast {
  opacity: 0; transform: translateY(.4rem);
  transition: opacity var(--speed), transform var(--speed);
}
html.has-js .toast.is-visible { opacity: 1; transform: none; }
html.has-js .toast.is-leaving { opacity: 0; transform: translateY(.4rem); }
/* Without JS nothing adds is-visible, and a permanently invisible message is
   worse than one that never fades. */
html:not(.has-js) .toast { opacity: 1; transform: none; }
html:not(.has-js) .toast-x { display: none; }

/* One frame with movement switched off, for state that is being put back
   rather than changed. */
.no-transition, .no-transition * { transition: none !important; }

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; }
}


/* Panels inside the area drawer — one per area, plus one for a new one. */
.zone-panel[hidden] { display: none; }
.zone-panel .drawer-field { margin-bottom: .8rem; }
.zone-panel .zone-list { max-height: 16rem; overflow-y: auto; margin: .4rem 0 .8rem; }
.zone-panel .check { padding: .2rem .3rem; border-radius: 4px; }
.zone-panel .check:hover { background: var(--hover); }

/* The area's own action, at the far end of its header — away from the counts,
   which describe the area rather than acting on it. */
.branch-action { margin-left: auto; flex: 0 0 auto; }

/* --- drawer panel actions -------------------------------------------------
   Save and Cancel share one row and, together, occupy exactly the width of the
   destructive action below them — so the row reads as one decision and Delete
   reads as another. The rule and the gap are there so Delete can never be hit
   while reaching for Save.
------------------------------------------------------------------------- */
.button-row { display: flex; gap: .5rem; margin-top: .7rem; }
.button-row > * { flex: 1 1 0; text-align: center; }
.button-block { display: block; width: 100%; text-align: center; }

.drawer-danger { padding-top: .8rem; border-top: 1px solid var(--line-soft); }
/* Quiet by default and only filled on hover: it is the last thing in the panel,
   so it needs to be findable, not loud. */
.drawer-danger .button.danger { background: transparent; color: var(--bad); border-color: var(--bad-line); }
.drawer-danger .button.danger:hover { background: var(--bad-bg); border-color: var(--bad); }

/* Edit opens a panel of settings — a different kind of act from the icon-only
   unlock, so it carries its label. */
.icon-text-btn {
  display: inline-flex; align-items: center; gap: .3rem;
  padding: .18rem .5rem; font: inherit; font-size: .78rem;
  background: transparent; border: 1px solid var(--line); border-radius: var(--radius);
  color: var(--muted); cursor: pointer;
  transition: background var(--speed), color var(--speed), border-color var(--speed);
}
.icon-text-btn:hover,
.tree-branch:hover .icon-text-btn { border-color: var(--accent); color: var(--accent); }
.icon-text-btn .nav-icon { width: 13px; height: 13px; }

/* --- door picker, inside an area's drawer panel --------------------------
   Two lists in one form: the area's own doors, ticked, and the unallocated
   ones, not. What is ticked on save is what the area holds. Headings rather
   than two separate forms, so the swap reads as one decision.
------------------------------------------------------------------------- */
.door-picker { margin-top: .5rem; }
.picker-heading { margin: .9rem 0 .3rem; }
.picker-heading:first-child { margin-top: 0; }
.door-picker .check { padding: .25rem .35rem; border-radius: 4px; }
.door-picker .check:hover { background: var(--hover); }
.door-picker .check .tag { margin: 0; }

/* Inside the picker, the areas this administrator holds. One open at a time,
   and the open one is the area the zone schedule is for — so it is bordered
   like a choice rather than like a list. The radio in the head is the choice
   itself; the disclosure only decides what is on screen. */
.area-acc { border: 1px solid var(--line); border-radius: 6px; margin-bottom: .35rem; }
.area-acc[open] { border-color: var(--accent); }
.area-acc-head {
  display: flex; align-items: center; gap: .45rem;
  padding: .35rem .5rem; cursor: pointer; list-style: none;
}
.area-acc-head::-webkit-details-marker { display: none; }
.area-acc-head::marker { content: ""; }
.area-acc-head:hover { background: var(--hover); }
.area-acc-head:focus-within { outline: 2px solid var(--accent); outline-offset: -2px; }
.area-acc-name {
  font-weight: 600; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.area-acc-count { margin-left: auto; flex: none; }
.area-acc-body { padding: .25rem .5rem .5rem; border-top: 1px solid var(--line); }
.area-acc-use {
  display: flex; align-items: center; gap: .4rem;
  padding: .3rem 0 .35rem; color: var(--muted); font-size: .82rem;
}
.area-acc-body .door-tree { margin-bottom: 0; }
/* The zones a door carries are the branch above it here, so the row itself
   does not repeat them and gives the width back to the week beside it. */
.area-acc-body .door-check { grid-template-columns: auto 16px minmax(0, 1fr); }

/* The marks in front of a door's name: whether UniFi knows it, then that it is
   a door at all. Read left to right they answer "linked?" then "what is it?"
   before the name itself. */
.door-marks { display: inline-flex; align-items: center; gap: .25rem; margin-right: .45rem; }
.door-marks .state-icon .nav-icon,
.door-icon .nav-icon { width: 13px; height: 13px; }
.tree-leaf .col-name { display: flex; align-items: center; }

/* The area's own mark, matching the one the sidebar uses for areas. */
.branch-name { display: inline-flex; align-items: center; gap: .4rem; }
.branch-name .nav-icon { width: 14px; height: 14px; color: var(--muted); flex: 0 0 14px; }

/* --- confirmation overlay -------------------------------------------------
   A native <dialog>, so the backdrop, Escape and the focus trap come from the
   browser rather than from script. It asks on top of the page instead of
   replacing it, which is the point: the answer belongs in the same context as
   the question.
------------------------------------------------------------------------- */
.modal {
  width: min(34rem, calc(100vw - 2rem));
  padding: 1.1rem 1.2rem 1.3rem;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--panel); color: var(--ink);
  box-shadow: var(--shadow-3);
}
.modal::backdrop { background: rgba(8, 11, 15, .5); }
.modal .lede { margin-top: 0; }
.modal table { margin-bottom: 1rem; }

.modal-head { display: flex; align-items: flex-start; gap: .5rem; }
.modal-head h2 { margin: 0 0 .6rem; font-size: 1.05rem; }
.modal-head form { margin: 0 0 0 auto; }
.modal-x {
  width: 26px; height: 26px; padding: 0; display: grid; place-items: center;
  background: transparent; border: 1px solid var(--line); border-radius: var(--radius);
  color: var(--muted); font-size: 1.05rem; line-height: 1; cursor: pointer;
}
.modal-x:hover { background: var(--hover); color: var(--ink); }

.modal-actions { margin-top: 0; }
.modal-actions form { margin: 0; flex: 1 1 0; }

html.has-js .modal[open] { animation: modal-in var(--speed) ease-out; }
@keyframes modal-in {
  from { opacity: 0; transform: translateY(.5rem); }
  to   { opacity: 1; transform: none; }
}

/* --- view switch ----------------------------------------------------------
   Two readings of the same areas. A segmented control rather than a dropdown:
   there are exactly two, and both should be visible and one click away.
------------------------------------------------------------------------- */
.view-bar { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
.view-bar .button { margin-left: auto; }
.view-switch {
  display: inline-flex; padding: 2px; gap: 2px;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
}
.view-tab {
  padding: .3rem .7rem; border-radius: var(--radius); font-size: .85rem;
  color: var(--muted); text-decoration: none;
  transition: background var(--speed), color var(--speed);
}
.view-tab:hover { color: var(--ink); }
.view-tab.is-current {
  background: var(--panel); color: var(--accent); font-weight: 600;
  box-shadow: var(--shadow);
}

/* --- zone-grouped rows ----------------------------------------------------
   The transpose of the door view: a zone per row, its doors in the column the
   labels occupy the other way round. Each expands to list them one per line.
------------------------------------------------------------------------- */
.zone-row { border-bottom: 1px solid var(--line-soft); }
.zone-row:last-child { border-bottom: 0; }
.zone-row > summary { cursor: pointer; list-style: none; }
.zone-row > summary::-webkit-details-marker { display: none; }
.zone-row > summary::marker { content: ""; }
.zone-row > summary:hover { background: var(--hover); }
.zone-row[open] > summary { background: var(--accent-soft); }
.zone-row .col-name .tag { margin: 0; }

.tree-subleaf {
  display: flex; align-items: center;
  /* One step in from the zone above it, exactly as that zone is one step in
     from its area. Before this the zones carried an empty select column and
     their own doors sat to the left of them. */
  padding: .3rem .6rem .3rem calc(.6rem + var(--indent) * 2);
  font-size: .9rem; color: var(--ink);
  border-top: 1px solid var(--line-soft);
}
.tree-subleaf:hover { background: var(--hover); }

/* Once a zone is open its doors are listed in full below, so repeating them as
   labels in the summary says the same thing twice. */
.zone-row[open] > summary .col-zones .tag { display: none; }
.zone-row[open] > summary .col-zones::after {
  content: "listed below"; color: var(--muted); font-size: .8rem;
}

.subleaf-name { flex: 1 1 auto; min-width: 0; }
.tree-subleaf form { margin: 0 0 0 auto; flex: 0 0 auto; }

/* A zone's mark carries its colour, so nothing is lost by dropping the pill.
   Same hue machinery as the labels, applied to the glyph instead of a fill. */
.subzone-icon { display: inline-grid; place-items: center; color: hsl(var(--hue) calc(62% * var(--sat, 1)) 34%); }
.subzone-icon.is-none { color: var(--muted); }
.subzone-icon .nav-icon { width: 14px; height: 14px; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .subzone-icon { color: hsl(var(--hue) calc(70% * var(--sat, 1)) 68%); }
}
:root[data-theme="dark"] .subzone-icon { color: hsl(var(--hue) calc(70% * var(--sat, 1)) 68%); }

/* The zone reading has no checkboxes, so its rows drop the select column and
   start at the row's own indent. */
.zone-head, .zone-summary {
  grid-template-columns: minmax(9rem, max-content) minmax(0, 1fr) auto;
}
@media (max-width: 720px) {
  /* A narrower step: three levels at 1.15rem each eats a phone's width. */
  .tree-panel { --indent: .7rem; }
  .zone-head { display: none; }
  .zone-summary { grid-template-columns: minmax(0, 1fr) auto; row-gap: .15rem; }
  .zone-summary .col-zones { grid-column: 1 / -1; }
  .zone-summary .col-actions { grid-column: 2; grid-row: 1; justify-self: end; }
}

/* The zone reading's rows carry a checkbox so the selection machinery works,
   but ticking doors is the other reading's job — so it never appears. */
.tree-subleaf [data-door-select] { display: none; }
html.has-js .tree-subleaf[data-door] { cursor: pointer; }
html.has-js .tree-subleaf[data-door]:focus-visible {
  outline: 2px solid var(--accent); outline-offset: -2px;
}
html.has-js .tree-subleaf[data-door].is-selected {
  background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent);
}

/* --- schedule week --------------------------------------------------------
   One row per weekday, each holding any number of time windows. The shape
   mirrors what UniFi stores, so what is edited here is what is pushed.
------------------------------------------------------------------------- */
/* Named apart from the .week *table* on the user summary. Both were ".week",
   the flex rule came later, and it silently turned that table into a column
   with its 8.5rem day cells discarded. */
.week-editor { display: flex; flex-direction: column; gap: var(--space-1); }
.day-row {
  display: flex; flex-direction: column; gap: .3rem;
  padding: .4rem .5rem; border-radius: var(--radius);
}
/* Name, the ranges it holds, then the way to add another — a day's whole
   state on one line, above the track that shapes it. */
.day-head { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
.day-times { display: inline-flex; align-items: center; gap: .25rem; flex-wrap: wrap; }
.time-pill {
  font: inherit; font-size: .72rem; font-variant-numeric: tabular-nums;
  cursor: pointer; padding: .1rem .45rem;
}
.time-pill:hover { border-color: var(--accent); color: var(--accent); }
.day-row:hover { background: var(--hover); }
.day-name {
  text-transform: capitalize; font-size: .9rem; font-weight: 600;
  color: var(--ink); min-width: 5.5rem;
}
.windows { display: flex; flex-wrap: wrap; gap: .35rem; }
.window {
  display: inline-flex; align-items: center; gap: .25rem;
  padding: .15rem .3rem; border: 1px solid var(--line-soft); border-radius: var(--radius);
  background: var(--panel-2);
}
.window input[type="time"] { padding: .15rem .3rem; font-size: .85rem; }
.window-dash { color: var(--muted); }
.window-x {
  width: 18px; height: 18px; padding: 0; display: grid; place-items: center;
  background: transparent; border: 0; border-radius: 50%;
  color: var(--muted); cursor: pointer; font-size: .9rem; line-height: 1;
}
.window-x:hover { background: rgba(128, 128, 128, .22); color: var(--ink); }
.window-add { align-self: start; }
/* Without scripts the button cannot do anything, and the two rendered windows
   are the whole offer. */
/* The add button carries .tag .tag--add, not .window-add — matching on the
   data attribute is the only way to reach both buttons, and the class
   selector that used to be here reached neither. */
html:not(.has-js) [data-window-add],
html:not(.has-js) [data-window-remove] { display: none; }

@media (max-width: 720px) {
  .day-name { min-width: 0; }
}

/* --- schedule timeline ----------------------------------------------------
   A 0-24h track per weekday with a block per window, draggable to move and
   resizable from either edge. The <input type="time"> pairs underneath remain
   the value the form posts; the track writes into them. Without scripts the
   track never appears and the inputs are the interface.
------------------------------------------------------------------------- */
.track { display: none; }
html.has-js .day-row.has-track .track {
  display: block; position: relative;
  height: 34px; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--panel-2);
  /* An hour every 1/24, marked heavier every six so the eye can find 06, 12
     and 18 without counting. */
  background-image:
    repeating-linear-gradient(to right,
      var(--line-soft) 0 1px, transparent 1px calc(100% / 24)),
    repeating-linear-gradient(to right,
      var(--line) 0 1px, transparent 1px calc(100% / 4));
  touch-action: none;
}
html.has-js .day-row.has-track .windows { display: none; }

.window-block {
  position: absolute; top: 3px; bottom: 3px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 4px; cursor: grab;
  background: color-mix(in srgb, var(--accent) 78%, transparent);
  color: #fff; font-size: .7rem; overflow: hidden;
  touch-action: none; user-select: none;
}
.window-block:active { cursor: grabbing; }
.window-block:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; }
.block-label { pointer-events: none; white-space: nowrap; padding: 0 .9rem; }

/* A window narrower than its own label used to show the middle of it —
   "12:00-14:00" came out as "2:00-14:0", which is a time that does not exist.
   The block asks its own width rather than the viewport's, because the same
   editor is drawn full width on the schedules page and at half of a pane in
   the zone schedule workbench. What is dropped here is still listed as text
   beside the day name, which is where a narrow window is legible anyway. */
.window-block { container-type: inline-size; }
@container (max-width: 92px) { .block-label { display: none; } }

/* Wide enough to grab with a thumb, which is what a phone needs. */
.grip {
  position: absolute; top: 0; bottom: 0; width: 12px;
  cursor: ew-resize; background: rgba(255, 255, 255, .28);
}
.grip-start { left: 0; border-radius: 4px 0 0 4px; }
.grip-end { right: 0; border-radius: 0 4px 4px 0; }
.grip:hover { background: rgba(255, 255, 255, .5); }

.block-x {
  position: absolute; top: 1px; right: 13px;
  width: 14px; height: 14px; padding: 0; display: none; place-items: center;
  background: rgba(0, 0, 0, .3); border: 0; border-radius: 50%;
  color: #fff; font-size: .7rem; line-height: 1; cursor: pointer;
}
.window-block:hover .block-x, .window-block:focus-within .block-x { display: grid; }

/* The hour scale, once, above the week. */
.track-scale {
  display: grid; grid-template-columns: repeat(24, 1fr);
  font-size: .65rem; color: var(--muted);
  margin: 0 .5rem .1rem;
}
.track-scale span { text-align: left; }
.track-scale span:nth-child(odd) { visibility: hidden; }

@media (max-width: 720px) {
  .track-scale span { visibility: hidden; }
  .track-scale span:nth-child(6n + 1) { visibility: visible; }
}

/* --- new schedule, behind a disclosure ------------------------------------
   The form is long; the list is what the page is for. <details> so it opens
   with no scripts.
------------------------------------------------------------------------- */
.new-schedule { padding: .6rem .8rem; }
.new-schedule[open] { padding-bottom: 1rem; }
.new-schedule-toggle { list-style: none; cursor: pointer; display: inline-block; }
.new-schedule-toggle::-webkit-details-marker { display: none; }
.new-schedule-toggle::marker { content: ""; }
.new-schedule[open] .new-schedule-toggle { margin-bottom: .8rem; }
.new-schedule[open] .new-schedule-toggle .button::before { content: "\00d7 "; }
.new-schedule[open] .new-schedule-toggle .button { font-size: 0; }
.new-schedule[open] .new-schedule-toggle .button::before {
  content: "\00d7  Close"; font-size: .95rem;
}

/* --- a schedule at rest ---------------------------------------------------
   The same track the editor draws, read-only and compact: no grips, nothing
   draggable, and the days that hold nothing are not drawn at all.
------------------------------------------------------------------------- */
.sched { padding: .6rem 0; border-bottom: 1px solid var(--line-soft); }
.sched:last-child { border-bottom: 0; }
.sched-head { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; }
.sched-name { font-weight: 600; }
/* Editing and deleting sit at the far end of the head, away from the name and
   the sync tag: they act on the schedule, they are not part of reading it.
   Quiet until the row is under the pointer, like the area rows. */
.sched-actions {
  display: flex; align-items: center; gap: .35rem; margin-left: auto;
  opacity: .45; transition: opacity var(--speed);
}
.sched-actions form { margin: 0; }
.sched:hover .sched-actions,
.sched:focus-within .sched-actions { opacity: 1; }
/* The editor is a form, not a dialog: the week it draws is the thing being
   read on the same page, so it opens under it rather than over it. */
.sched-edit { margin-top: .6rem; padding-top: .6rem; border-top: 1px dashed var(--line); }
.sched-edit[hidden] { display: none; }

.sched-week { display: flex; flex-direction: column; gap: .2rem; }
.sched-day {
  display: grid; grid-template-columns: 2.4rem minmax(0, 1fr);
  gap: .5rem; align-items: center;
}
.sched-day-name {
  text-transform: capitalize; font-size: .75rem; color: var(--muted);
}
.sched-day-times { display: inline-flex; gap: .2rem; flex-wrap: wrap; }
.time-pill.is-static { cursor: default; pointer-events: none; }

html.has-js .track.mini, .track.mini {
  /* Tall enough to hold a time inside a bar, which is where it belongs — at
     the end of the row it was detached from the thing it described. */
  display: block; position: relative; height: 20px;
  border: 1px solid var(--line); border-radius: 3px; background: var(--panel-2);
  background-image: repeating-linear-gradient(to right,
    var(--line-soft) 0 1px, transparent 1px calc(100% / 4));
}
.mini-block {
  position: absolute; top: 1px; bottom: 1px; border-radius: 2px;
  display: flex; align-items: center; justify-content: center;
  background: color-mix(in srgb, var(--accent) 78%, transparent);
  color: #fff; font-size: .62rem; font-variant-numeric: tabular-nums;
  white-space: nowrap; overflow: hidden;
}

/* A track is 230px wide on a phone, so a four-hour window is 38px and
   "06:00–10:00" came out as "6:00–10:0" — a time that does not exist. Below
   720px the bars are the picture and the hours are listed under them, which is
   the same text the no-script page shows. */
@media (max-width: 720px) {
  .mini-block { font-size: 0; }
  html.has-js .sched-day-text { display: block; }
  .sched-day { grid-template-columns: 2.4rem minmax(0, 1fr); row-gap: .1rem; }
  .sched-day-text {
    grid-column: 2; font-size: var(--text-micro);
    font-variant-numeric: tabular-nums;
  }
}



/* A rejected submit, shown against the form that caused it — so the answer is
   beside the question and the form still holds what was typed. */

/* --- held unlocked --------------------------------------------------------
   A door standing open during a schedule is worth seeing without opening
   anything, so it is marked on the row. Amber rather than the accent: it is a
   state to notice, not an action to take, and it is not pushed to UniFi yet.
------------------------------------------------------------------------- */
.hold-tag {
  display: inline-flex; align-items: center; gap: .2rem; margin-left: .35rem;
  background: var(--warn-bg); border-color: var(--warn-line); color: var(--warn);
}
.hold-tag .nav-icon { width: 12px; height: 12px; }

.drawer-warn {
  margin: .4rem 0 .6rem; padding: .4rem .55rem; border-radius: var(--radius);
  background: var(--warn-bg); border: 1px solid var(--warn-line); color: var(--warn);
}
[data-hold-editor] select { width: 100%; }

/* The control wears the caveat's colours. Holding a door open is the one thing
   in this drawer that changes how the building behaves rather than how it is
   filed, and it is the one thing the app cannot yet push to UniFi — so it
   reads as a warning from the outside, before it is opened. */
.drawer-field--warn {
  padding: .6rem .65rem; border-radius: var(--radius);
  background: var(--warn-bg); border: 1px solid var(--warn-line);
}
.drawer-field--warn > button { margin-top: .55rem; }

.hold-switch { font-weight: 600; color: var(--warn); }

/* Said out loud, because a ticked box on its own does not say what will
   happen to the door. */
.hold-state {
  margin: .5rem 0 0; padding: .35rem .5rem; border-radius: var(--radius);
  background: var(--warn-line); color: var(--warn); font-weight: 600;
}
.hold-state[hidden] { display: none; }

[data-hold-editor] .sched-week { margin-top: .45rem; }

/* The Schedules page's week editor, in a drawer a third as wide: the day names
   are abbreviated in the markup, the ruler thins to every sixth hour, and the
   track loses a few pixels so all seven days are one glance rather than one
   scroll. Nothing else about it changes — it is the same editor, and it posts
   the same fields. */
.week-editor--drawer .day-row { padding: .2rem .3rem; gap: .2rem; }
.week-editor--drawer .day-name { min-width: 2.4rem; font-size: .8rem; }
.week-editor--drawer .track-scale { margin: 0 .3rem .1rem; font-size: .6rem; }
.week-editor--drawer .track-scale span { visibility: hidden; }
.week-editor--drawer .track-scale span:nth-child(6n + 1) { visibility: visible; }
html.has-js .week-editor--drawer .day-row.has-track .track { height: 26px; }

/* A stored UniFi id that no longer resolves. Amber, not grey: a door that was
   never linked is a normal state, one whose link has broken is a problem. */
.state-icon.is-broken { color: var(--warn); }

/* Anything a finger has to hit, on any device that has fingers. Padding rather
   than width, so the mark itself keeps the size it was drawn at. */
@media (pointer: coarse) {
  /* The hit area is an invisible overlay rather than a bigger button: a 44px
     .tag-x would rewrap every tag row. The mark keeps the size it was drawn
     at; only the region that answers a tap grows. */
  .tag-x, .block-x, .window-x { position: relative; }
  .tag-x::after, .block-x::after, .window-x::after {
    content: ""; position: absolute; top: 50%; left: 50%;
    width: var(--touch); height: var(--touch);
    transform: translate(-50%, -50%);
  }
  .drawer-close, .modal-x, .toast-close {
    min-width: var(--touch); min-height: var(--touch);
  }
  .grip { width: 22px; }

  /* A 13px checkbox is the browser default and is not tappable. Measured with
     headless Chrome at 390px, which is where this application is actually
     used — jsdom has no box model and reports nothing. */
  input[type="checkbox"] { width: 24px; height: 24px; }
  .col-select { min-height: var(--touch); display: flex; align-items: center; }

  /* The pill-shaped buttons come out around 22px tall from their text size. */
  .tag--add, .tag-button, .time-pill { padding-block: .5rem; }

  /* An inline "Open" in a table cell is about 34x18. */
  td a, .tree-leaf a { display: inline-block; padding: .6rem .2rem; }
  /* Hover cannot happen here, so a control revealed by hover can never be
     reached at all. This was the only way to remove a schedule window. */
  .block-x { display: grid; }
  .check, .nav-item { min-height: var(--touch); }
}

/* The phone door list packs its select column to 1.75rem, which crops a 24px
   checkbox to 21. Widened only where both conditions hold, so a touch laptop
   keeps the wide grid. */
@media (pointer: coarse) and (max-width: 720px) {
  .tree-leaf { grid-template-columns: 2.6rem 1fr; }
}

/* --------------------------------------------------------------------------
   tabs — the schedules page and its policies tab
   -------------------------------------------------------------------------- */

.tabs {
  display: flex;
  gap: .25rem;
  margin: 0 0 1rem;
  border-bottom: 1px solid var(--line);
}

.tabs .tab {
  padding: .45rem .9rem;
  color: var(--muted);
  text-decoration: none;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  transition: color var(--speed), border-color var(--speed);
}

.tabs .tab:hover { color: var(--text); }

.tabs .tab.is-current {
  color: var(--accent-text);
  border-bottom-color: var(--accent);
  font-weight: 500;
}

/* --------------------------------------------------------------------------
   policies
   -------------------------------------------------------------------------- */

/* The accordion: headers that read down like the table they replaced, and one
   panel open at a time. The columns are declared once and shared by the header
   strip and every summary, so they stay in line without a table. */
.acc { --acc-cols: minmax(0, 2fr) 4.5rem minmax(0, 1.4fr) 5rem; }

.acc-head, .acc-summary {
  display: grid;
  grid-template-columns: var(--acc-cols);
  align-items: center;
  gap: .6rem;
  padding: .55rem .2rem;
}

.acc-head {
  color: var(--muted);
  font-size: var(--text-small);
  text-transform: uppercase;
  letter-spacing: .04em;
  border-bottom: 1px solid var(--line);
}

.acc-item { border-bottom: 1px solid var(--line); }

/* The whole header is the control, so the arrow the browser puts in front of
   it would sit where the name goes. The open state is shown by the panel. */
.acc-summary { list-style: none; cursor: pointer; }
.acc-summary::-webkit-details-marker { display: none; }
.acc-summary::marker { content: ""; }
.acc-summary:hover { background: var(--hover); }
.acc-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

.acc-name { font-weight: 500; }
.acc-item[open] > .acc-summary .acc-name { color: var(--accent-text); }

/* The one panel with nothing to summarise: it has no name yet, so it says what
   it is for across the three columns the facts would have filled. */
.acc-item--new .acc-name { color: var(--muted); font-weight: 600; }
/* The area the zone schedule belongs to, inside the name rather than beside
   it: the summary is a four-column grid and a fifth child would push every
   heading out of its column. */
.acc-area { margin-left: .4rem; font-weight: 400; }
.acc-item--new .acc-hint { grid-column: 2 / -1; }

.acc-body { padding: .2rem .2rem 1.1rem; }

/* Narrow enough and four columns cannot be told apart, so each fact carries its
   own word and they stack under the name. */
@media (max-width: 40rem) {
  .acc-head { display: none; }
  .acc-summary { grid-template-columns: 1fr; gap: .15rem; }
  .acc-cell::before {
    content: attr(data-label) ": ";
    color: var(--muted);
    font-size: var(--text-small);
  }
  .acc-item--new .acc-hint { grid-column: auto; }
}

/* .check is a flex row, so a label long enough to wrap squeezes the box beside
   it: the confirm sentence shrank the phone's 24px tap target to 14px. The
   door names are short, which is why only this one fell under it.

   Scoped to the workbench rather than .policy-edit: the form that makes a zone
   schedule is not inside a details, and its door list is the same list. */
.workbench .check input[type="checkbox"] { flex-shrink: 0; }

.danger-check { color: var(--bad); }

/* --------------------------------------------------------------------------
   the zone schedule workbench — doors on one side, hours on the other

   Both at once needs about 46rem inside the panel, which a phone does not
   have and a sidebar-narrowed desktop may not either. So the breakpoint asks
   the workbench how wide *it* is rather than the viewport: the same editor
   sits in a table cell, inside a panel, inside a page whose sidebar the
   person can collapse, and a media query would answer for the wrong box.

   Under that width the panes stack to one at a time and the arrows appear.
   Which one shows is decided here, not in script — script only moves the
   number, so the two can never disagree about what is visible.
   -------------------------------------------------------------------------- */

.workbench { container-type: inline-size; container-name: wb; }

.wb-head { display: flex; align-items: center; gap: .5rem; margin-bottom: .8rem; }
.wb-head label { color: var(--muted); font-size: var(--text-small); }
.wb-head input { flex: 0 1 20rem; }

.wb-panes { display: grid; gap: 1.2rem; }

.wb-pane { min-width: 0; }
.wb-title {
  display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap;
  margin: 0 0 .5rem; font-size: .95rem;
}
.wb-count { font-weight: 400; }

/* Long door lists scroll rather than pushing the save button off the screen —
   the button belongs under the slider, where it can be reached without
   reading to the end of the area. */
.wb-scroll { max-height: 20rem; overflow-y: auto; }

.wb-field { margin: .7rem 0; }
.wb-field label { display: block; color: var(--muted); font-size: var(--text-small); }
.wb-field input, .wb-field select { max-width: 20rem; }
.wb-hint { margin: .3rem 0 0; }

.wb-actions {
  display: flex; align-items: center; justify-content: flex-end;
  gap: .8rem; flex-wrap: wrap;
  margin-top: 1rem; padding-top: .8rem; border-top: 1px solid var(--line-soft);
}
.wb-actions .danger-check { margin-right: auto; }
/* The button says which area the ticks are going into, so that a save that
   moves a zone schedule to another area cannot happen quietly. */
.wb-area { font-weight: 400; opacity: .85; }
.wb-area:empty { display: none; }

/* --- the slider chrome ---------------------------------------------------
   Only ever seen in the stacked reading. Hidden outright without scripts,
   where both panes are shown and there is nothing to step between.
------------------------------------------------------------------------- */
.wb-nav { display: none; }
html.has-js .workbench[data-wb-at] .wb-nav {
  display: flex; align-items: center; justify-content: space-between;
  gap: .5rem; margin-bottom: .8rem;
}

.wb-step {
  font-size: var(--text-small); color: var(--muted); text-align: center;
  flex: 1 1 auto;
}

.wb-arrow {
  flex: 0 0 auto; width: 2.2rem; min-height: 2.2rem;
  display: inline-grid; place-items: center;
  font-size: 1.2rem; line-height: 1; padding: 0;
}
.wb-arrow[disabled] { opacity: .35; }

@container wb (width < 46rem) {
  html.has-js .workbench[data-wb-at="0"] [data-wb-pane]:not(:first-of-type),
  html.has-js .workbench[data-wb-at="1"] [data-wb-pane]:first-of-type {
    display: none;
  }
}

/* The select is the choice a scriptless browser gets; with scripts the labels
   are the choice and this only carries the value. */
html.has-js .wb-noscript-only { display: none; }

.sched-picker { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .2rem; }
.sched-pick { cursor: pointer; }
.sched-pick[aria-pressed="true"] {
  background: var(--accent); color: #fff; border-color: var(--accent);
}
/* Custom is a state, not a label to press: it says the bars below match none
   of the ones above. Dashed, so it does not read as something clickable. */
.sched-custom { cursor: default; border-style: dashed; }
.sched-custom[hidden] { display: none; }

@container wb (min-width: 46rem) {
  /* Doors are names in a list and need no more room than a name; the week is
     twenty-four hours wide and can always use what is left. */
  .wb-panes { grid-template-columns: minmax(0, 17rem) minmax(0, 1fr); }
  html.has-js .workbench[data-wb-at] .wb-nav { display: none; }
  /* Side by side, the door list runs the height of the week next to it
     instead of stopping at its own 20rem with the week carrying on below. */
  .wb-pane:has(> .wb-scroll) { display: flex; flex-direction: column; }
  .wb-pane > .wb-scroll { flex: 1 1 0; min-height: 20rem; max-height: none; }
}

/* --- footer ---------------------------------------------------------------
   Pushed to the bottom of the viewport on a short page, and sitting after the
   content on a long one. Not `position: fixed`: the toasts already own the
   bottom-right corner at z-index 60 and would sit on top of it, and a bar
   permanently across the foot of a phone costs more room than a version
   string is worth.

   The mechanism is the flex column on .content below. <main> takes the free
   space, so the footer lands wherever the content stops or the viewport does,
   whichever is lower. Both children need `width: 100%` because their
   `margin-right: auto` is an auto margin in the cross axis, and an auto cross
   margin cancels the stretch a flex item would otherwise get — without it
   <main> would collapse to fit-content and the whole page would narrow.
------------------------------------------------------------------------- */
.content {
  display: flex; flex-direction: column;
  min-height: 100vh;
  min-height: 100dvh;   /* excludes a phone's retracting URL bar */
}
.content > main { flex: 1 1 auto; width: 100%; }

/* Aligned with <main> rather than centred in the column, so the two do not
   disagree about where the page's left edge is. */
.app-footer {
  width: 100%; max-width: 1180px; margin: 2rem auto 0 0;
  padding: 1rem 1.5rem 1.5rem;
  border-top: 1px solid var(--line);
  display: flex; flex-direction: column; gap: .2rem;
  font-size: .74rem; color: var(--muted);
}
@media (min-width: 2532px) { .app-footer { margin-inline: auto; } }

.footer-line {
  margin: 0; display: flex; flex-wrap: wrap; gap: .45rem; align-items: baseline;
}
.footer-sep { opacity: .45; }
.app-footer .mono { letter-spacing: .01em; }
.app-footer a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.app-footer a:hover { color: var(--accent); }

/* Below 720px the rail stops being fixed and becomes a row in the flow, so
   .content is no longer the whole page and 100vh would overshoot by the
   height of that row. Hand the job to body, which does contain both. */
@media (max-width: 720px) {
  body { display: flex; flex-direction: column; min-height: 100vh; min-height: 100dvh; }
  body.app-shell .sidebar { flex: 0 0 auto; }
  .content { flex: 1 1 auto; min-height: 0; }
}

/* --- issuing a badge ------------------------------------------------------
   The dialog on a person's page. Two steps: pick the picture that matches the
   badge in your hand, then write down what is on it. The pictures exist
   because the alternative was a dropdown reading "wiegand26", which is a word
   from the reader's data sheet and not from anybody's building.

   With no scripts the <dialog> is laid out in the flow as an ordinary panel
   with both steps showing, and the button that would have opened it goes away.
------------------------------------------------------------------------- */
.panel-head-action { margin-left: auto; }
.modal--wide { width: min(46rem, calc(100vw - 2rem)); }

.card-kinds {
  display: grid; gap: .6rem; margin-bottom: .75rem;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}
/* Three columns: the radio, the picture, the words. With scripts the radio
   leaves the flow and its column collapses — the tile itself is the control by
   then, and a radio button beside a picture of the thing it selects is one
   mark too many. */
.card-kind {
  display: grid; gap: .15rem .7rem; align-items: center; cursor: pointer;
  grid-template-columns: auto auto 1fr; position: relative;
  padding: .6rem .7rem; border: 1px solid var(--line); border-radius: 8px;
  background: var(--panel);
}
.card-kind:hover { background: var(--hover); }
.card-kind input { grid-column: 1; grid-row: 1 / span 2; margin: 0; align-self: center; }
.card-kind .badge-art { grid-column: 2; grid-row: 1 / span 2; width: 86px; height: auto; }
.card-kind-name { grid-column: 3; font-size: .88rem; font-weight: 600; align-self: end; }
.card-kind-hint { grid-column: 3; font-size: .74rem; color: var(--muted); align-self: start; }
/* The radio is the control; the tile is what is looked at. It keeps its own
   focus ring by staying in the layout at zero size rather than display:none,
   which would take it out of the tab order. */
html.has-js .card-kind input { position: absolute; width: 1px; height: 1px; opacity: 0; }
html.has-js .card-kind:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
.card-kind:has(input:checked) { border-color: var(--accent); background: var(--raised, var(--panel)); }

/* A badge, drawn. The number on it is the example, and it is the whole reason
   the picture is there — everything else is just enough card to read it as
   one. */
.badge-art { display: block; }
.badge-art .badge-body { fill: var(--panel-2); stroke: var(--line); }
.badge-art .badge-hole { fill: none; stroke: var(--line); }
.badge-art .badge-chip { fill: none; stroke: var(--line); }
.badge-art .badge-chip-line { fill: none; stroke: var(--line); }
.badge-art .badge-code {
  fill: var(--ink); font-family: var(--mono, ui-monospace, monospace); font-size: 15px;
  letter-spacing: .5px;
}
.card-kind:has(input:checked) .badge-art .badge-body { stroke: var(--accent); }

/* What step one answered, kept on screen while step two is typed. */
.cred-chosen {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  margin: 0 0 .75rem; font-size: .85rem; font-weight: 600;
}
.cred-chosen .muted { font-weight: 400; }
.cred-chosen button { margin-left: auto; flex: 0 0 auto; }

/* Badges nobody is carrying. One form per row, so handing one over is a plain
   POST and needs no script to say which. */
.spare-list { list-style: none; margin: .5rem 0 0; padding: 0; max-height: 18rem; overflow: auto; }
.spare-item {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  padding: .45rem .1rem; border-bottom: 1px solid var(--line);
}
.spare-item form { margin: 0 0 0 auto; }
.spare-name { font-size: .85rem; font-weight: 600; }

html:not(.has-js) [data-cred-open] { display: none; }
html:not(.has-js) [data-cred-dialog] {
  display: block; position: static;
  width: auto; max-width: none; margin: 0 0 1rem; box-shadow: var(--shadow);
}
html:not(.has-js) [data-cred-dialog] .modal-x { display: none; }

/* -------------------------------------------------------------------------
   The Credentials list acts on the row (owner decision 2026-09-04). The badge
   has no page of its own, so the cell that holds a fact also holds the button
   that changes it: handing a badge over and taking it back live in Held by,
   flagging and removing stand where the Open link used to.

   The small buttons are quiet until the row is pointed at or tabbed into —
   the same idea as .cred-actions on a person's page — because a table of two
   hundred rows with six buttons on each reads as a control panel rather than
   a list. Quiet, never absent: they keep their space, so nothing reflows on
   hover, and :focus-within is what makes them reachable by keyboard.
---------------------------------------------------------------------------- */
.held-cell { min-width: 12rem; }
.held-one { display: inline-flex; align-items: center; gap: .15rem; margin-right: .4rem; }
.held-form { display: inline; margin: 0; }
.held-x {
  width: 18px; height: 18px; padding: 0; display: inline-grid; place-items: center;
  background: transparent; border: 1px solid transparent; border-radius: var(--radius);
  color: var(--muted); font-size: .95rem; line-height: 1; cursor: pointer;
  opacity: 0; transition: opacity var(--speed) ease;
}
.held-add {
  font-size: .78rem; color: var(--muted); text-decoration: none;
  border: 1px dashed var(--line); border-radius: var(--radius);
  padding: .05rem .35rem; white-space: nowrap;
  opacity: 0; transition: opacity var(--speed) ease;
}
tr:hover .held-x, tr:focus-within .held-x,
tr:hover .held-add, tr:focus-within .held-add { opacity: 1; }
.held-x:hover, .held-x:focus { opacity: 1; border-color: var(--line); background: var(--hover); color: var(--bad); }
.held-add:hover, .held-add:focus { opacity: 1; border-style: solid; color: var(--ink); background: var(--hover); }

.cred-row-actions { white-space: nowrap; text-align: right; }
.cred-row-actions .button { opacity: .55; transition: opacity var(--speed) ease; }
tr:hover .cred-row-actions .button,
tr:focus-within .cred-row-actions .button { opacity: 1; }

/* The two shells at the bottom of the list ship with no `open` and no rule
   that reveals them, so a browser with no scripts never sees them — nothing
   there could fill them, and both buttons on the row are links to a page that
   asks the same question. That is the opposite of [data-cred-dialog] above,
   which is a complete form and is meant to lay out in the flow. */

/* Who to hand a badge to. A list, not a table: one fact and one button per
   row, and it has to read the same in a 24rem drawer as on a full page. */
.attach-new { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.attach-list { list-style: none; margin: .25rem 0 0; padding: 0; }
.attach-item {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  padding: .45rem .1rem; border-bottom: 1px solid var(--line);
}
.attach-item form { margin: 0 0 0 auto; }
.attach-item .tag { margin-left: auto; }
.attach-who { display: flex; flex-direction: column; gap: .05rem; min-width: 0; }
.attach-name { font-size: .9rem; font-weight: 600; }
.attach-sub { line-height: 1.3; }

/* Badges waiting on an area to agree, on the Areas page. Confirming is the
   area's act, so it is listed with the areas rather than with the badge. */
.flag-queue { list-style: none; margin: .5rem 0 0; padding: 0; }
.flag-item { padding: .6rem 0; border-bottom: 1px solid var(--line); }
.flag-item:last-child { border-bottom: 0; }
.flag-item .button-row { margin-top: .4rem; }
.flag-item form { margin: 0; }
.flag-what { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; }
