/* ─────────────────────────────────────────────────────────────────────────────
   Tokens

   Dark-first, because this panel sits alongside a terminal more often than not.
   Light is a full token swap rather than a set of overrides, so nothing has to be
   redefined further down the file.
   ───────────────────────────────────────────────────────────────────────────── */
:root {
  --bg: #0e1014;
  --panel: #16191f;
  --panel-2: #1c2027;
  --panel-3: #22262e;
  --line: #282d36;
  --line-strong: #363c47;
  --text: #e8eaee;
  --muted: #98a1b0;
  --accent: #5b93ff;
  --accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
  --ok: #3ecf8e;
  --warn: #e2b33c;
  --err: #f2666b;

  /* Native controls — checkbox, select popup, scrollbar, autofill — take their
     colours from this and from nothing else. Without it they render light against a
     dark panel. Swapped with the tokens below, not left to `prefers-color-scheme`,
     so an explicit palette and the controls in it can never disagree. */
  color-scheme: dark;

  /* Spacing scale — every margin and padding below comes from here. */
  --s0: 2px;
  --s1: 4px;
  --s2: 8px;
  --s3: 12px;
  --s4: 16px;
  --s5: 24px;
  --s6: 32px;
  --s7: 48px;

  /* Type scale. Six steps, and the whole file uses them: the half-pixel sizes this
     replaced (11.5, 12.5) were indistinguishable from their neighbours and only made
     it harder to tell which of two similar-looking labels was meant to be smaller. */
  --fs-1: 11px; /* table headers, pills, mono tags */
  --fs-2: 12px; /* hints, small buttons, secondary lines */
  --fs-3: 13px; /* labels, table cells */
  --fs-4: 14px; /* body */
  --fs-5: 20px; /* run counters */
  --fs-6: 22px; /* page title */

  --radius: 10px;
  --radius-sm: 6px;
  --shadow: 0 1px 2px rgb(0 0 0 / 0.28), 0 8px 24px rgb(0 0 0 / 0.18);
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
}

@media (prefers-color-scheme: light) {
  :root {
    color-scheme: light;
    --bg: #f5f6f8;
    --panel: #ffffff;
    --panel-2: #f4f6f9;
    --panel-3: #eaeef3;
    --line: #e2e6ec;
    --line-strong: #cfd6e0;
    --text: #171a1f;
    --muted: #616b7a;
    --accent: #2f6fe4;
    --shadow: 0 1px 2px rgb(16 24 40 / 0.06), 0 8px 24px rgb(16 24 40 / 0.06);
  }
}

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

/* Alpine sets this until it boots, so templated content never flashes unstyled.
   !important is required: it has to beat the display value the element would take. */
[x-cloak] {
  /* biome-ignore lint/complexity/noImportantStyles: must override the element's own display */
  display: none !important;
}

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

a {
  color: var(--accent);
  text-decoration: none;
}
a:hover {
  text-decoration: underline;
}

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

/* ───────────────────────────────────────────────────────────── header + nav */

header {
  border-bottom: 1px solid var(--line);
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  backdrop-filter: blur(8px);
  position: sticky;
  top: 0;
  z-index: 20;
}
/* Inner wrapper, so the chrome sits on the same column as `main`'s content. */
header .bar {
  max-width: 1080px;
  margin: 0 auto;
  padding: 0 var(--s5);
  display: flex;
  align-items: center;
  gap: var(--s5);
  height: 54px;
}
header .brand {
  font-weight: 650;
  letter-spacing: -0.01em;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
header .brand:hover {
  text-decoration: none;
  color: var(--accent);
}
/* The app's sections. `flex: none` is load-bearing: with `min-width: 0` these collapse
   to a 0px scroll container on a phone, which has no hit area either — the links are laid
   out, invisible and unreachable. The brand gives way instead (its full text is in the
   document title), and below 860px nav takes a row of its own. */
header nav {
  display: flex;
  align-items: center;
  gap: var(--s1);
  flex: none;
}
/* Password and Sign out are account actions, not sections — pushed right, behind a rule,
   and deliberately outside `nav` so they can never scroll out of reach. */
header .account {
  display: flex;
  align-items: center;
  gap: var(--s1);
  margin-left: auto;
  padding-left: var(--s3);
  border-left: 1px solid var(--line);
  flex: none;
  min-width: 0;
}
header nav a,
header .account a {
  color: var(--muted);
  font-weight: 500;
  padding: var(--s1) var(--s3);
  border-radius: var(--radius-sm);
  white-space: nowrap;
  transition:
    color 0.12s ease,
    background 0.12s ease;
}
header nav a:hover,
header .account a:hover {
  color: var(--text);
  background: var(--panel-2);
  text-decoration: none;
}
header nav a.active,
header .account a.active {
  color: var(--text);
  background: var(--panel-3);
}

/* Sign-out is a POST form so a prefetch cannot trigger it; `display: contents` lets the
   button sit in the flex row as if the form were not there. The button's own look is set
   in the buttons section below, which must come after the generic `button` rule. */
header .account form {
  display: contents;
}

/* A single-column page — login, password — inside the wide `main`. */
.narrow {
  max-width: 420px;
  margin: var(--s7) auto 0;
}

main {
  max-width: 1080px;
  margin: 0 auto;
  padding: var(--s5) var(--s5) var(--s7);
}

/* ──────────────────────────────────────────────────────────────── typography */

h1 {
  font-size: var(--fs-6);
  margin: 0 0 var(--s1);
  letter-spacing: -0.02em;
  font-weight: 640;
}
h2 {
  font-size: var(--fs-4);
  margin: 0;
  letter-spacing: 0.01em;
  font-weight: 620;
}
.sub {
  color: var(--muted);
  margin: 0 0 var(--s5);
  max-width: 68ch;
}
.hint {
  color: var(--muted);
  font-size: var(--fs-2);
  margin-top: var(--s1);
  font-weight: 400;
}
.hint.tight {
  margin: 0;
}
/* A hint that leads a card, above the fields it applies to. */
.hint.lead {
  margin: 0 0 var(--s4);
}
.muted {
  color: var(--muted);
}
.bad {
  color: var(--err);
  font-weight: 600;
}
.right {
  text-align: right;
}
.nowrap {
  white-space: nowrap;
}
.mono,
td.mono {
  font-family: var(--mono);
  font-size: var(--fs-2);
}
code {
  font-family: var(--mono);
  font-size: var(--fs-2);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 1px var(--s1);
}

/* A title row and its actions, sharing a baseline. */
.page-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--s4);
  margin-bottom: var(--s5);
  flex-wrap: wrap;
}
/* The wrapper owns the gap below, so neither child carries its own bottom margin — and
   with `align-items: flex-start` an h1 margin would push the row's actions out of line. */
.page-head h1 {
  margin-bottom: 0;
}
.page-head .sub {
  margin: var(--s1) 0 0;
}

/* The way back to a page's parent. A sibling above `.page-head`, never inside it: as the
   left column's first child it set the row's top edge, and `align-items: flex-start` then
   floated that page's primary action level with a 12.5px link instead of the h1. */
.back {
  display: inline-block;
  color: var(--muted);
  font-size: var(--fs-2);
  font-weight: 500;
  margin-bottom: var(--s1);
}
.back:hover {
  color: var(--text);
  text-decoration: none;
}

/* ─────────────────────────────────────────────────────────────────── cards */

.card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--s4) var(--s5) var(--s5);
  margin-bottom: var(--s4);
}
.card.subtle {
  background: transparent;
}
.card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s3);
  flex-wrap: wrap;
  border-bottom: 1px solid var(--line);
  padding-bottom: var(--s3);
  margin-bottom: var(--s4);
}
.card-head h2 {
  border: none;
  padding: 0;
  margin: 0;
}

/* A second (or third) headed section inside one card. Used where several groups of
   fields share a single Save — the card is the save unit, so they cannot be separate
   cards without the button belonging to only one of them. */
.card-section {
  margin-top: var(--s5);
}

/* A card's closing row: what the card leaves you with, and the action that follows from
   it. Rules off from the content above the way `.card-head` rules off from the content
   below, so a card can be read from either end. */
.card-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-top: var(--s4);
  padding-top: var(--s3);
  border-top: 1px solid var(--line);
}
.card-foot .hint {
  max-width: 62ch;
}
/* A card foot that is only buttons. `.card-foot` spreads its children so a closing note
   and its action sit at opposite edges; a row of controls has to stay packed left. */
.actions.card-foot {
  justify-content: flex-start;
}

.empty {
  color: var(--muted);
  margin: 0;
  padding: var(--s5) 0;
  text-align: center;
}

/* ──────────────────────────────────────────────────────────────────── forms */

label {
  display: block;
  font-weight: 550;
  margin-bottom: var(--s1);
  font-size: var(--fs-3);
}
/* An aside in a label inherits the label's weight otherwise, which bolds the least
   important text on the row. */
label .muted {
  font-weight: 400;
}
.field {
  margin-bottom: var(--s4);
}
.field:last-child {
  margin-bottom: 0;
}

input[type="text"],
input[type="password"],
input[type="number"],
input[type="search"],
select,
textarea {
  width: 100%;
  padding: var(--s2) var(--s3);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--text);
  font: inherit;
  transition:
    border-color 0.12s ease,
    background 0.12s ease;
}
input:hover:not(:disabled):not([readonly]),
select:hover:not(:disabled),
textarea:hover {
  border-color: var(--line-strong);
}
input:focus,
select:focus,
textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
input:disabled,
select:disabled,
textarea:disabled {
  cursor: not-allowed;
}
/* A field the environment owns, or one that exists only to be copied. `opacity` alone was
   a ~4% background shift in light mode — indistinguishable from an editable field until
   you typed into it. No fill and a dashed edge reads as text, which is what these are. */
input[type="text"]:disabled,
input[type="password"]:disabled,
input[type="number"]:disabled,
select:disabled,
textarea:disabled,
input[readonly] {
  background: none;
  border-style: dashed;
  border-color: var(--line-strong);
  color: var(--muted);
}
/* Read-only is not the same as empty: this field holds the string you paste into Entra, so
   it keeps full-contrast text and a text cursor. The dashed edge says "not editable". */
input[readonly] {
  color: var(--text);
  cursor: text;
}
input[type="checkbox"]:disabled {
  opacity: 0.5;
}
input.invalid {
  border-color: var(--err);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 16%, transparent);
}
textarea {
  font-family: var(--mono);
  font-size: var(--fs-2);
  min-height: 68px;
  resize: vertical;
}

/* A checkbox that reads as one line with its text. */
.inline-check {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  margin: 0;
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
}
.inline-check input[type="checkbox"] {
  width: auto;
  margin: 0;
  accent-color: var(--accent);
}

/* ────────────────────────────────────────────────────────────────── buttons */

button,
.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  padding: var(--s2) var(--s4);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--panel-2);
  color: var(--text);
  font: inherit;
  font-weight: 550;
  cursor: pointer;
  white-space: nowrap;
  transition:
    border-color 0.12s ease,
    background 0.12s ease,
    transform 0.06s ease;
}
button:hover:not(:disabled),
.btn:hover:not([aria-disabled="true"]) {
  border-color: var(--line-strong);
  background: var(--panel-3);
  text-decoration: none;
}
button:active:not(:disabled) {
  transform: translateY(1px);
}
button.primary {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
button.primary:hover:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 86%, #000);
  border-color: color-mix(in srgb, var(--accent) 86%, #000);
}
button.danger {
  color: var(--err);
  border-color: color-mix(in srgb, var(--err) 45%, transparent);
}
button.danger:hover:not(:disabled) {
  background: color-mix(in srgb, var(--err) 12%, transparent);
  border-color: var(--err);
}
button.small,
.btn.small {
  padding: var(--s1) var(--s3);
  font-size: var(--fs-2);
}
/* A square button whose whole label is an icon. The padding is the small button's, on
   both axes, so it lines up with a `.btn.small` beside it. */
button.icon,
.btn.icon {
  padding: var(--s2);
  color: var(--muted);
}
button.icon:hover:not(:disabled),
.btn.icon:hover:not([aria-disabled="true"]) {
  color: var(--text);
}
button:disabled,
button[disabled],
.btn[aria-disabled="true"] {
  opacity: 0.45;
  cursor: not-allowed;
}
/* Kept hoverable rather than `pointer-events: none`: these spans carry the reason they
   are unavailable in a `title`, and killing pointer events would kill the tooltip too. */
.actions {
  display: flex;
  gap: var(--s2);
  flex-wrap: wrap;
  align-items: center;
}
/* The signed-in name is the one part of the account block that may be cut short. Down
   here so it follows the generic `.hint` rule it overrides, as the sign-out button below
   follows the generic `button` one. */
header .account .hint {
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 18ch;
}

/* The sign-out button, styled to match the links beside it. Defined here rather than in
   the header section so it follows the generic `button` rule above. */
header .account button {
  color: var(--muted);
  font-weight: 500;
  padding: var(--s1) var(--s3);
  border: 0;
  background: none;
  box-shadow: none;
  white-space: nowrap;
}
header .account button:hover {
  color: var(--text);
  background: var(--panel-2);
  border: 0;
}

/* Page links under a table. A disabled edge renders as a span, so it is not focusable,
   and `.btn[aria-disabled]` above already covers how it looks and that it does nothing. */
.pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s3);
  margin-top: var(--s4);
  padding-top: var(--s4);
  border-top: 1px solid var(--line);
}
/* A form's save row where it sits outside the cards it saves. Indented to the card content
   edge so it lines up with the primary buttons above it, and carrying the gap below that a
   card would — as a bare row its bottom edge was the next card's top border. */
.actions.form-footer {
  margin-bottom: var(--s4);
  padding-left: calc(var(--s5) + 1px);
}

/* The save row for a page-length deferred form, pinned to the bottom of the viewport.
   `/services` commits twenty-one controls through this one button; unpinned it sat at
   y=2077 of a 2180px document, so the only thing on the page that saves anything was
   below nine cards of things to change. */
.actions.form-footer.sticky-save {
  position: sticky;
  bottom: var(--s3);
  z-index: 15;
  /* Flush with the card's outer edge, not its content edge. `.form-footer`'s indent is
     for a bare row sitting under the cards it saves, where lining the button up with the
     primary buttons *inside* them is what reads. This row has no panel of its own, so
     the only edge near it is the card's, and 25px off it read as a mistake. */
  padding-left: 0;
}
/* No panel around the row. The button is already a solid block and carries a shadow to
   lift it off whatever it is over; a bordered, blurred box around it is the "card around
   one button" the plain `.form-footer` exists to avoid. It also keeps the button on the
   same left edge as every other primary Save in the app. */
.sticky-save button {
  box-shadow: var(--shadow);
}
/* The flag is the one thing here that would be unreadable over scrolling content, so it
   gets the backing rather than the whole row. Held in the layout by `visibility` so
   appearing does not shift the button — the same bargain the field mapper's marker makes. */
.sticky-save .save-flag {
  visibility: hidden;
  padding: var(--s1) var(--s3);
  border: 1px solid var(--line);
  border-radius: 99px;
  background: var(--panel);
  box-shadow: var(--shadow);
}
.sticky-save.dirty .save-flag {
  visibility: visible;
}

.save-bar {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

/* ─────────────────────────────────────────────────────────────────── tables */

/* Wide tables scroll inside the card rather than the page.
   `position: relative` is load-bearing: it is the containing block for the absolutely
   positioned `.sr-only` header labels inside. Static, those resolved against the initial
   containing block, escaped this element's clip, and left every table page scrolling
   sideways into hundreds of pixels of blank space. */
.table-scroll {
  position: relative;
  overflow-x: auto;
  margin: 0 calc(var(--s5) * -1);
  padding: 0 var(--s5);
  /* A permanent scrollbar, not the platform's overlay one. macOS hides an overlay
     scrollbar until something scrolls, so a table whose last column sits 200px past
     the card edge looked complete — there was no mark anywhere saying otherwise.
     Only painted when the content actually overflows, so a table that fits shows
     nothing. */
  scrollbar-width: thin;
  scrollbar-color: var(--line-strong) transparent;
}
/* macOS forces overlay scrollbars: the rules above take no layout space and paint
   nothing until something scrolls, so a table hiding two columns looked complete. The
   fade does not depend on the OS's scrollbar policy — it is the same one `header nav`
   uses. `.overflowing` is set by `markOverflow()` in app.js, so a table that fits is
   never faded. */
.table-scroll.overflowing:not(.at-end) {
  mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
.table-scroll::-webkit-scrollbar {
  height: 8px;
}
.table-scroll::-webkit-scrollbar-track {
  background: transparent;
}
.table-scroll::-webkit-scrollbar-thumb {
  background: var(--line-strong);
  border-radius: 99px;
}
table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-3);
}
th,
td {
  text-align: left;
  padding: var(--s2) var(--s3);
  border-bottom: 1px solid var(--line);
  vertical-align: middle;
}
/* `.table-scroll` aligns the table box with the card's content edge, but the cell padding
   then insets the ink 12px — so a heading and the column under it did not share a left
   edge on any table page. The outer cells give up their outer padding instead. */
th:first-child,
td:first-child {
  padding-left: 0;
}
th:last-child,
td:last-child {
  padding-right: 0;
}
th {
  /* Deliberately not sticky: these tables live inside an `overflow-x` scroll
     container, where a sticky header resolves against that container and ends up
     painted over the first row. The tables are short enough not to need it. */
  background: var(--panel);
  color: var(--muted);
  font-weight: 600;
  font-size: var(--fs-1);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  /* A three-line "Last signed in" is taller than the rows it labels. These tables already
     scroll sideways inside their card, so a header never needs to wrap to fit. */
  white-space: nowrap;
}
tbody tr:last-child td {
  border-bottom: none;
}
tbody tr:hover {
  background: var(--panel-2);
}

/* A checkbox column: no header text, and no right padding — the colgroup sets its width.
   It keeps its left padding, unlike the text columns: flush against the cell edge, the
   mapped-row accent bar is drawn under the checkbox and the two merge into one mark.
   Qualified with `td`/`th` deliberately — a bare `.col-check` is (0,1,0) and loses to
   `td:first-child` at (0,1,1) wherever it sits in the file. */
.col-check {
  padding-right: 0;
}
td.col-check,
th.col-check {
  padding-left: var(--s2);
}

/* A table row's actions, pushed to the right-hand end of the cell. `text-align: right`
   does not move a flex child, and the Sync button has to be a <form> to POST, which
   would otherwise sit on a line of its own. */
.row-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--s2);
}

/* ─────────────────────────────────────────────────────────── row kebab menu */

/* The dashboard's per-row actions, folded into one button. Three of them side by side
   made that table wider than its card as soon as the "Last run" column filled in, and
   the horizontal scroll that followed hid the controls themselves. */
.row-menu {
  position: relative;
}
/* `position: fixed`, and placed by `openRowMenu()` in app.js rather than by CSS, because
   this lives inside `.table-scroll` — an `overflow-x: auto` box, which clips vertically
   too — so an absolutely positioned panel would be cut off just below its own button.
   Nothing between here and the viewport sets `transform`, `filter` or `contain`, so
   nothing re-parents the fixed containing block. */
.row-menu-panel {
  display: none;
  position: fixed;
  z-index: 40;
  min-width: 168px;
  padding: var(--s1);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--panel);
  box-shadow: var(--shadow);
}
.row-menu.open .row-menu-panel {
  display: block;
}
/* Menu rows, not buttons: a stack of bordered `.btn`s in a popover reads as a toolbar
   that happens to be vertical. The generic `button` rule above is (0,0,1) and this is
   (0,1,0), so the border, padding and background all come off here. */
.row-menu-item {
  display: flex;
  width: 100%;
  align-items: center;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--text);
  font: inherit;
  font-size: var(--fs-3);
  font-weight: 550;
  text-align: left;
  white-space: nowrap;
  cursor: pointer;
}
a.row-menu-item:hover,
button.row-menu-item:hover:not(:disabled) {
  background: var(--panel-2);
  text-decoration: none;
}
.row-menu-item:disabled,
.row-menu-item[aria-disabled="true"] {
  opacity: 0.45;
  cursor: not-allowed;
}
/* Kept hoverable rather than `pointer-events: none`, as with `.btn[aria-disabled]`: the
   reason it is unavailable is in a `title`, and killing pointer events kills that too. */

/* Eleven columns. Below this the cells crush — "full · dry run" wraps to three lines —
   so `.table-scroll` takes over instead, the same bargain `table.fields` makes. */
table.runs {
  min-width: 880px;
}
/* The "Notion pages" group header, spanning the four tallies under it. */
table.runs th.group {
  text-align: center;
}
/* The second header row's outer cells are not the table's outer columns, so the
   edge-flush rule above must not reach them. */
table.runs thead tr + tr th:first-child {
  padding-left: var(--s3);
}
table.runs thead tr + tr th:last-child {
  padding-right: var(--s3);
}

/*
 * The users table. Fixed widths and a wrapping User/Email pair: an Entra username is an
 * email address, and one long one pushed the Disable and Delete buttons past the card's
 * right edge, where nothing but a horizontal scroll nobody could see would reach them.
 */
table.users {
  table-layout: fixed;
  /* The sum of the widths below, and 982px is deliberate: it is the card's scrollport at
     the app's 1080px content cap, so on the widest layout the row rules end flush with the
     card-head rule instead of overhanging it. Percentages here left the actions column with
     63px for two buttons needing 112, so they hung past the last row rule entirely. */
  min-width: 982px;
}
/* Wider than it was: the `root` and `you` pills moved in here from Sign-in, which in
   exchange now holds one pill instead of three and gives the width back. The six widths
   still sum to the 982px below. */
table.users .c-user {
  width: 224px;
}
table.users .c-email {
  width: 216px;
}
table.users .c-signin {
  width: 126px;
}
table.users .c-status {
  width: 84px;
}
table.users .c-seen {
  width: 130px;
}
/* `Confirm delete` + gap + `Cancel` is 184px, and this is a column width rather than a
   content width: `td:last-child` gives up its right padding but keeps its 12px on the
   left, so the content box is 12px narrower than whatever is set here. */
table.users .c-actions {
  width: 202px;
}
td.wrap {
  overflow-wrap: anywhere;
}
/* One line, cut with an ellipsis, full value on the cell's `title`. For a column whose
   content is an email address: `overflow-wrap: anywhere` breaks it mid-token, so a long
   Entra username rendered as three lines split at arbitrary characters. */
td.clip {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/*
 * The two field-mapping tables. `fixed` is load-bearing: with auto layout the Notion
 * property column is sized to the em dash an unmapped row shows, so ticking one row —
 * which replaces that dash with a select and a text input — reflows every column on the
 * page. Fixed widths mean the layout is the same before and after, which is what you
 * want when nearly every row ends up mapped.
 *
 * min-width keeps the columns usable on a narrow screen; .table-scroll takes over from
 * there rather than letting the cells crush.
 */
table.fields {
  table-layout: fixed;
  min-width: 760px;
}
table.fields .c-check {
  width: 34px;
}
/* Wide from the start: this holds the target select beside the property-name input.
   The select inside it carries a whole "<property> · <type>" line, and at 40% that
   was clipping a realistically-named property to about fourteen characters — in the
   one control whose job is to stop you creating a duplicate column in Notion. */
table.fields .c-target {
  width: 46%;
}
table.fields .c-as {
  width: 132px;
}
/* The Notion type and its badges. Wraps to a second line for the widest case — a mapped
   relation — so the column can stay narrow instead of reserving room it rarely needs.
   This must be an element *inside* the cell, never the `td`: a flex `td` stops taking the
   row's height, and its bottom border then renders offset from every other column's. */
.as-cell {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s1) var(--s2);
}

/* Holds its space in the layout while hidden, so nothing beside it moves. */
.invisible {
  visibility: hidden;
}

/* Visually hidden but still read out — used for the checkbox column's header. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The dashboard's one-line answer to "did the last sync work". Sits above the table it
   summarises, inside the same fragment, so a finished run updates both together. */
.sync-health {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
  font-size: var(--fs-2);
  margin-bottom: var(--s3);
}

/* ────────────────────────────────────────────────────────── pills + banners */

.pill {
  display: inline-block;
  padding: var(--s0) var(--s2);
  border-radius: 99px;
  font-size: var(--fs-1);
  font-weight: 600;
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--muted);
  white-space: nowrap;
}
/* A pill that is a link. Every other pill on the page is inert, so without this there
   was nothing to distinguish the one you are meant to click. Declared before the kind
   classes so a coloured pill that happens to be a link keeps its own colour, and the
   hover below is declared after all of them so it wins whatever kind it is. */
a.pill {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.pill.ok {
  color: var(--ok);
  border-color: color-mix(in srgb, var(--ok) 40%, transparent);
  background: color-mix(in srgb, var(--ok) 10%, transparent);
}
.pill.err {
  color: var(--err);
  border-color: color-mix(in srgb, var(--err) 40%, transparent);
  background: color-mix(in srgb, var(--err) 10%, transparent);
}
.pill.warn {
  color: var(--warn);
  border-color: color-mix(in srgb, var(--warn) 40%, transparent);
  background: color-mix(in srgb, var(--warn) 10%, transparent);
}
.pill.new,
.pill.env {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
  background: var(--accent-soft);
}
/* A cancelled run. `.pill` is already muted, so the colour alone said nothing — the
   dashed edge is what separates "stopped on purpose" from "off". */
.pill.muted-pill {
  border-style: dashed;
  border-color: var(--line-strong);
  background: none;
}
a.pill:hover {
  background: color-mix(in srgb, currentcolor 14%, transparent);
  text-decoration: none;
}

.banner {
  padding: var(--s3) var(--s4);
  border-radius: var(--radius-sm);
  margin-bottom: var(--s4);
  border: 1px solid var(--line);
}
.banner.err {
  border-color: color-mix(in srgb, var(--err) 45%, transparent);
  background: color-mix(in srgb, var(--err) 10%, transparent);
}
.banner.warn {
  border-color: color-mix(in srgb, var(--warn) 45%, transparent);
  background: color-mix(in srgb, var(--warn) 10%, transparent);
}
/* A banner below other card content, rather than leading it. */
.banner.inset {
  margin-top: var(--s4);
  margin-bottom: 0;
}
.banner.info {
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  background: var(--accent-soft);
}

/* ────────────────────────────────────────────────────────────── login: root */

/* The login page's "Root admin login" disclosure. A <details> rather than an Alpine
   toggle, because this is the break-glass login and it has to work with no JavaScript at
   all. The default summary renders as a bare list-item, so it gets a control's
   affordances here. No margin-top: adjacent margins collapse against the preceding
   card's margin-bottom. */
.login-alt > summary {
  cursor: pointer;
  list-style: none;
  color: var(--muted);
  font-weight: 550;
  font-size: var(--fs-3);
  padding: var(--s1) 0;
}
.login-alt > summary::-webkit-details-marker {
  display: none;
}
/* Two glyphs rather than one rotated: rotating a triangle about the line box lifted it
   off the text baseline every time the disclosure opened. */
.login-alt > summary::before {
  content: "\25B8";
  display: inline-block;
  width: 1em;
}
.login-alt[open] > summary::before {
  content: "\25BE";
}
.login-alt > summary:hover,
.login-alt > summary:focus-visible {
  color: var(--text);
}
.login-alt[open] > summary {
  margin-bottom: var(--s3);
}
/* Closed, the summary is the card's only content, and the card's asymmetric padding then
   left it 8px off centre. */
.login-alt:not([open]) {
  padding-bottom: var(--s4);
}

/* ─────────────────────────────────────────────────────────────────── toasts */

.toasts {
  position: fixed;
  bottom: var(--s5);
  right: var(--s5);
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  max-width: min(420px, calc(100vw - var(--s6)));
}
/* The kind is carried by a 4px edge, not by a 1px border tint: side by side, a tinted
   hairline made an error and a confirmation look the same. This is the app's only
   channel for a failed save, so which one it is has to read without being examined. */
.toast {
  display: flex;
  align-items: flex-start;
  gap: var(--s3);
  padding: var(--s3) var(--s4);
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-strong);
  border-left: 4px solid var(--line-strong);
  background: var(--panel);
  box-shadow: var(--shadow);
  font-size: var(--fs-3);
}
.toast.ok {
  border-color: color-mix(in srgb, var(--ok) 50%, transparent);
  border-left-color: var(--ok);
}
.toast.err {
  border-color: color-mix(in srgb, var(--err) 50%, transparent);
  border-left-color: var(--err);
}
.toast.warn {
  border-color: color-mix(in srgb, var(--warn) 50%, transparent);
  border-left-color: var(--warn);
}
/* `margin-left: auto` so it sits at the toast's edge. Without it the × trailed the end
   of the message, and in a stack two equally wide toasts closed at different x. */
.toast-close {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  flex: none;
  margin-left: auto;
  border: none;
  background: none;
  color: var(--muted);
  padding: 0;
  font-size: 18px;
  line-height: 20px;
  cursor: pointer;
}
.toast-close:hover {
  color: var(--text);
  background: none;
}

/* ───────────────────────────────────────────────────────────────── progress */

.progress {
  height: 8px;
  border-radius: 99px;
  background: var(--panel-3);
  overflow: hidden;
  margin-bottom: var(--s2);
}
.progress-fill {
  height: 100%;
  background: var(--accent);
  border-radius: 99px;
  transition: width 0.4s ease;
}
/* Before the record count lands there is no percentage to show, so it sweeps. */
.progress.indeterminate .progress-fill {
  width: 35%;
  animation: sweep 1.3s ease-in-out infinite;
}
@keyframes sweep {
  0% {
    margin-left: -35%;
  }
  100% {
    margin-left: 100%;
  }
}
.progress-label {
  display: flex;
  justify-content: space-between;
  font-size: var(--fs-2);
  color: var(--muted);
}

.run-live-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--s4);
  margin-bottom: var(--s4);
}
/* The run tallies, from views/partials/_run-counters.ejs. One class for every place
   they appear; `.inset` adds the rule for when they sit below other card content. */
.run-counters {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
  gap: var(--s3);
}
.run-counters.inset {
  margin-top: var(--s4);
  padding-top: var(--s4);
  border-top: 1px solid var(--line);
}
.run-counters .counter-label {
  display: block;
  color: var(--muted);
  font-size: var(--fs-2);
  font-weight: 500;
}
.run-counters .counter-value {
  font-size: var(--fs-5);
  font-weight: 600;
  letter-spacing: -0.02em;
}

.running-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  border-radius: 99px;
  border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
  background: var(--accent-soft);
  font-size: var(--fs-2);
}
.running-chip .dot {
  width: 7px;
  height: 7px;
  border-radius: 99px;
  background: var(--accent);
  animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.25;
  }
}

@media (prefers-reduced-motion: reduce) {
  * {
    /* biome-ignore lint/complexity/noImportantStyles: a motion opt-out must beat every rule */
    animation: none !important;
    /* biome-ignore lint/complexity/noImportantStyles: a motion opt-out must beat every rule */
    transition: none !important;
  }
}

.tabs {
  display: inline-flex;
  gap: var(--s0);
  padding: var(--s0);
  border-radius: var(--radius-sm);
  background: var(--panel-2);
  border: 1px solid var(--line);
}
.tabs button {
  border: none;
  background: none;
  color: var(--muted);
  padding: var(--s1) var(--s3);
  font-size: var(--fs-2);
}
.tabs button:hover:not(.active) {
  background: none;
  color: var(--text);
}
.tabs button.active {
  background: var(--panel);
  color: var(--text);
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.12);
}

/* ─────────────────────────────────────────────────────────── field mapping */

.field-name {
  font-weight: 550;
}
/* The Notion target: a "create new or link to existing" select beside the property name.
   Lives in a table cell, so it must not wrap the two controls onto separate lines. */
.map-target {
  display: flex;
  gap: var(--s2);
  flex-wrap: nowrap;
}

/* Mapped rows get an accent edge, so they can be found by eye in ~150 rows without
   reading every checkbox. `.row-off` is set on the unmapped ones. */
tbody tr:not(.row-off) .col-check {
  box-shadow: inset 2px 0 var(--accent);
}
.map-target select {
  flex: 1 1 62%;
  min-width: 0;
}
/* Less room than the select beside it: this usually repeats the source field's own
   name, which the row already shows two columns to the left. */
.map-target input {
  flex: 1 1 38%;
  min-width: 0;
}
.type-tag {
  font-size: var(--fs-1);
  color: var(--muted);
  font-family: var(--mono);
}
.title-card {
  border-color: color-mix(in srgb, var(--accent) 30%, var(--line));
}

.filters {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}
.filters .search {
  width: auto;
  min-width: 200px;
}
/* Controls in the filter bar size to themselves; the global rule stretches them to 100%. */
.filters select {
  width: auto;
}
/* The runs table's range line, above the table it describes. */
.count-summary {
  margin: 0 0 var(--s3);
}
.count-line {
  margin-top: var(--s2);
  font-size: var(--fs-2);
  color: var(--muted);
}

/* ───────────────────────────────────────────────────────────── services page */

/* The database pickers stack as one condensed block, so they read as a single set-up
   step rather than ten independent settings — which is what they are: a lookup only
   becomes a relation if its target object already has a database. */
.db-rows {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  /* What the last `.field` contributes above `.actions` on the object cards below. */
  margin-bottom: var(--s4);
}
.db-row {
  display: grid;
  grid-template-columns: 6.5rem 1fr auto;
  align-items: center;
  gap: var(--s3);
}
.db-name {
  font-weight: 550;
}
/* The flex gap alone spaces these; the banner's own bottom margin would double it.
   Indented like `.db-scope` so it sits under the database it is complaining about
   rather than under the object's name. */
.db-rows > .banner {
  margin-bottom: 0;
  margin-left: calc(6.5rem + var(--s3));
}
.db-control {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
}

/* Placed explicitly rather than auto-flowed: two `.db-control`s share column 2 and swap
   by `x-show`, and auto-placement would put the second one in column 3 for the frame
   before Alpine boots. */
.db-row > .db-name {
  grid-column: 1;
}
.db-row > .db-control {
  grid-column: 2;
}
.db-row > .db-sched {
  grid-column: 3;
  justify-self: end;
}
/* Every control in a row is pinned to one height — the buttons included. A select, a
   text input and a `.btn.small` all resolve to different intrinsic heights, the two
   panels swap places, and the buttons sit on the same line as the fields, so any
   difference shows up either as the rows below jumping or as a row of controls with
   three different top edges. */
.db-control select,
.db-control input[type="text"],
.db-control .btn,
.db-control button {
  min-width: 0;
  height: 37px;
  padding-top: 0;
  padding-bottom: 0;
}
/* The picker stops growing well before it becomes an empty bar. The create panel's two
   controls share what is left instead — capping them the same way made the row wrap to
   two lines, since `flex-wrap` wraps before it shrinks. */
.db-pick {
  flex: 0 1 26rem;
}
.db-new {
  flex: 1 1 9rem;
}
/* Linked to from the dashboard; the header is sticky, so clear it. */
.db-row {
  scroll-margin-top: 5rem;
}

/* The record filter sits under the row it belongs to, its left edge on the control
   column's x so the textarea and the picker above it line up. The top margin is real
   space, not the negative pull it used to be: tucked up under the dropdown, the "SOQL
   WHERE clause" label read as part of the row above rather than as the heading of its
   own field. */
.db-scope {
  grid-column: 2;
  margin: var(--s1) 0 var(--s2) 0;
}
.db-scope .field {
  margin-bottom: var(--s2);
}
.db-scope textarea {
  min-height: 3.4rem;
}

/* The rows are a flex column, so the filter block has to be pulled back under its row
   rather than relying on the grid. `6.5rem + gap` is where the control column starts. */
.db-rows > .db-scope {
  margin-left: calc(6.5rem + var(--s3));
}

@media (max-width: 560px) {
  .db-row {
    grid-template-columns: 1fr;
    gap: var(--s2);
  }
  .db-row > .db-name,
  .db-row > .db-control,
  .db-row > .db-sched {
    grid-column: 1;
    justify-self: start;
  }
  /* No picker column to align to once the row stacks. */
  .db-rows > .db-scope,
  .db-rows > .banner {
    margin-left: 0;
  }
  /* The track is too narrow for the cap: let the picker shrink so its button stays on
     the same line, and let the create panel stack rather than clip its controls. */
  .db-pick {
    flex: 1 1 8rem;
  }
  .db-new {
    flex: 1 1 100%;
  }
}

/* ────────────────────────────────────────────────────────────────────── log */

/* One span per log line, so a failure is visible rather than found by reading. Left
   inline: the spans each carry their own trailing newline, and `display: block` would
   break the line a second time and double-space the whole log. */
.log-line {
  color: inherit;
}
.log-line.bad {
  color: var(--err);
}
.log-line.warn {
  color: var(--warn);
}

pre.log {
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: var(--s3);
  overflow: auto;
  font-family: var(--mono);
  font-size: var(--fs-2);
  line-height: 1.65;
  max-height: 60vh;
  white-space: pre-wrap;
  word-break: break-word;
  margin: 0;
}

/* Always laid out, so starting a request cannot change the button's width. */
.spinner {
  visibility: hidden;
  color: var(--muted);
  font-size: var(--fs-2);
}
.spinner.on {
  visibility: visible;
}

/* ───────────────────────────────────────────────────────────────── responsive */

/* The sections take a row of their own before the one-row header can overflow — brand,
   nav and the account block together need ~810px, so 860px is the last width where they
   still fit side by side. Below it the header is two rows rather than a page that scrolls
   sideways with its nav crushed to nothing. */
@media (max-width: 860px) {
  header .bar {
    height: auto;
    min-height: 54px;
    padding: var(--s2) var(--s3);
    flex-wrap: wrap;
    gap: var(--s2) var(--s3);
  }
  /* `flex-basis: 0` rather than `auto`: a line is filled with hypothetical sizes before
     anything shrinks, so an `auto` brand wide enough to overflow pushed the account block
     onto a third row — 113px of sticky chrome on a phone — instead of ellipsising. It also
     makes the brand the only shrinkable item, which is why the block below gives the
     account side back some room before the app's own name starts disappearing. */
  header .brand {
    flex: 1 1 0;
  }
  header nav {
    order: 2;
    flex: 1 0 100%;
    min-width: 0;
    overflow-x: auto;
    scrollbar-width: none;
    /* Negative margin so the first link's text starts on the brand's left edge, not 12px
       inside it. The fade marks the row as scrollable when it still overflows. */
    margin-left: calc(var(--s3) * -1);
    mask-image: linear-gradient(to right, #000 calc(100% - 16px), transparent);
  }
  header nav::-webkit-scrollbar {
    display: none;
  }
}

/* Where the header row runs out of width, the signed-in name goes before the product name
   does: the `root` pill and the two account links carry what actually matters, and the
   name is still on the `title` of the element that held it. */
@media (max-width: 560px) {
  header .account {
    gap: 0;
    padding-left: var(--s2);
  }
  header .account .hint {
    display: none;
  }
  header .account a,
  header .account button {
    padding-left: var(--s2);
    padding-right: var(--s2);
  }
}

@media (max-width: 720px) {
  main {
    padding: var(--s4) var(--s3) var(--s6);
  }
  .card {
    padding: var(--s3) var(--s4) var(--s4);
  }
  .table-scroll {
    margin: 0 calc(var(--s4) * -1);
    padding: 0 var(--s4);
  }
  /* Tracks the card's own padding at this width. */
  .actions.form-footer {
    padding-left: calc(var(--s4) + 1px);
  }
  .toasts {
    left: var(--s3);
    right: var(--s3);
    bottom: var(--s3);
    max-width: none;
  }
}
