:root {
  --bg: #f4f6fb;
  --card: #ffffff;
  --ink: #1d2433;
  --muted: #6b7488;
  --line: #e4e8f0;
  --brand: #2f57e6;
  --brand-dark: #1f3fbf;
  --ok: #18926b;
  --warn: #b8860b;
  --danger: #d33a4b;
  --shadow: 0 1px 2px rgba(20,30,60,.06), 0 6px 18px rgba(20,30,60,.06);
  --radius: 12px;
}
* { box-sizing: border-box; }
body {
  margin: 0; background: var(--bg); color: var(--ink);
  font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}
a { color: var(--brand); text-decoration: none; }
a:hover { text-decoration: underline; }

/* Top bar */
.topbar {
  display: flex; align-items: center; gap: 24px;
  background: #0f1b3d; color: #fff; padding: 0 22px; height: 58px;
  position: sticky; top: 0; z-index: 20;
}
.brand a { color: #fff; font-weight: 700; font-size: 18px; display: inline-flex; align-items: center; gap: 9px; }
/* The gap is meant for logo-to-wordmark only. A flex container wraps every run of
   text in its own anonymous item, so leaving the wordmark as bare text would space
   out "Agile", "AP" and "Helpdesk" equally — it must stay wrapped in .brand-name to
   read as one word. Only .brand-ap is tinted, not the whole wordmark. */
.brand-name { white-space: nowrap; }
.brand-ap { color: #7fa0ff; }
.brand-logo { display: block; width: 26px; height: 26px; }
.mainnav { display: flex; gap: 4px; flex: 1; }
.mainnav a {
  color: #c7d0e6; padding: 8px 12px; border-radius: 8px; font-weight: 500;
}
.mainnav a:hover { background: rgba(255,255,255,.08); color: #fff; text-decoration: none; }
.usermenu { display: flex; align-items: center; gap: 14px; }
.who { color: #fff; font-weight: 600; text-align: right; line-height: 1.1; }
.who small { display: block; color: #9fb0d6; font-weight: 400; font-size: 11px; }

.wrap { max-width: 1180px; margin: 26px auto; padding: 0 22px; }
.wrap-narrow { max-width: 440px; margin: 8vh auto; padding: 0 18px; }
/* The filtered ticket lists, which are wider than anything else in the app: a
   thirteen-column table (fourteen on "Assigned to me", which adds My role) above a filter
   row that runs to a search box and seven filters. At 1180 those wrapped onto a second
   line, where flex stretched the strays to half the page each and the row stopped reading
   as one set of controls.
   1440 held the filters but no longer the table: the Assigned to column was the one that
   pushed it past comfortable, and Module started arriving as "Invoice / Capture / OCR",
   three lines for one name. 1600 (1556 of content) is where thirteen columns read as a
   table again rather than as a stack of fragments.
   Only the list pages opt in — see the wrap_mod block in base.html; forms and detail
   pages read better narrow. */
.wrap-wide { max-width: 1600px; }
/* A single-column form page inside the 1180px wrap (1136px of content). Capping only
   the card leaves it hugging the left with a wide gap on the right; capping the whole
   column — heading included — centres it. 960px fills most of the wrap while keeping
   ~88px of breathing room each side, so it reads as deliberate rather than empty.
   Raise toward 1136 to fill edge to edge, lower for a narrower form. */
.form-page { max-width: 960px; margin: 0 auto; }
.foot { text-align: center; color: var(--muted); padding: 30px; font-size: 13px; }

/* Cards & layout */
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--shadow); padding: 22px; margin-bottom: 20px; }
/* overflow-x scrolls rather than crushes: the ticket table runs to nine columns with
   Category, which no longer fits a narrow screen. overflow-y stays hidden so the
   rounded corners still clip; leaving it visible would compute back to auto. */
.tablecard { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--shadow); overflow-x: auto; overflow-y: hidden; }
.page-head { display: flex; align-items: center; justify-content: space-between;
  gap: 16px; margin-bottom: 18px; flex-wrap: wrap; }
.page-head h1 { font-size: 22px; margin: 0; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 20px; }
.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 16px; }
@media (max-width: 900px){ .grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; } }
.queues { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; }

/* Buttons */
.btn { display: inline-block; border: 1px solid var(--brand); background: var(--brand);
  color: #fff; padding: 9px 16px; border-radius: 9px; font-weight: 600; cursor: pointer;
  font-size: 14px; }
.btn:hover { background: var(--brand-dark); text-decoration: none; }
.btn-ghost { background: transparent; border-color: rgba(255,255,255,.4); color: #fff; }
.btn-ghost:hover { background: rgba(255,255,255,.12); }
.btn-light { background: #fff; color: var(--ink); border-color: var(--line); }
.btn-light:hover { background: #f3f5fb; }
.btn-danger { background: var(--danger); border-color: var(--danger); }
.btn-sm { padding: 6px 11px; font-size: 13px; }
/* A submission in flight. The spinner is appended rather than replacing the label, so the
   button keeps its width and nothing on the page shifts while waiting. */
.btn[aria-busy="true"], .btn:disabled { opacity: .72; cursor: progress; }
.btn[aria-busy="true"]:hover { background: var(--brand); }
.btn-light[aria-busy="true"]:hover { background: #fff; }
.btn-danger[aria-busy="true"]:hover { background: var(--danger); }
.btn[aria-busy="true"]::after {
  content: ""; display: inline-block; width: 11px; height: 11px; margin-left: 8px;
  vertical-align: -1px; border: 2px solid currentColor; border-right-color: transparent;
  border-radius: 50%; animation: btn-spin .6s linear infinite;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
/* The whole form reads as busy, not just the button that was clicked. */
form[data-busy] { cursor: progress; }
@media (prefers-reduced-motion: reduce) {
  .btn[aria-busy="true"]::after { animation: none; }
}

/* Forms */
label { display: block; font-weight: 600; margin: 12px 0 5px; font-size: 14px; }
input, select, textarea {
  width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px;
  font: inherit; background: #fff; color: var(--ink);
}
input:focus, select:focus, textarea:focus { outline: 2px solid #b9c6ff; border-color: var(--brand); }
textarea { min-height: 120px; resize: vertical; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 700px){ .form-row { grid-template-columns: 1fr; } }
/* A native date picker sizes its internals differently from a plain text input, so the
   two fields in a row end up a pixel or two out of line (visible on "Date it happened"
   next to "Invoice No."). One min-height derived from the shared box model — 1.5em of
   text + 20px padding + 2px border — lifts the short control to match without
   resizing the others. */
.form-row input, .form-row select { min-height: calc(1.5em + 22px); }
.help { color: var(--muted); font-size: 13px; margin-top: 4px; }
.req { color: var(--danger); font-size: 12px; font-weight: 600; }
.inline { display: inline; }

/* Mandatory fields carry a red asterisk on their label. Driven off the control's own
   :required state rather than typed into each template, so a label can never claim
   the wrong thing, every form gets it for free, and a conditionally required field
   (the new-ticket attachment) gains and loses the mark as the JS toggles it. Screen
   readers already get this from the required attribute itself, so the asterisk is
   presentational only — which is why it lives here and not in the markup. */
label:has(+ input:required)::after,
label:has(+ select:required)::after,
label:has(+ textarea:required)::after {
  content: " *"; color: var(--danger); font-weight: 700;
}

/* Filter operator switch — see the filter_label macro in _macros.html. The label and the
   = / ≠ chip share one row, so the operator takes no width from the value control below
   it; .filt-head carries the margins the plain <label> it replaces would have had, which
   keeps a filter with a switch the same height as one without (the date fields, and the
   blank labels that align the buttons in a .toolbar). */
.filt-head { display: flex; align-items: center; gap: 7px; margin: 12px 0 5px; }
.filt-head > label { margin: 0; }
/* The chip follows its own label rather than being pushed out to the right of the column.
   Right-aligned it lined up neatly down the grid but sat 16px from the *next* column's
   label and 180px from its own, so on "Organisation | Module" the switch read as if it
   belonged to Module. Beside the word there is nothing to misread it against. */
/* The tick box is moved out of sight rather than display:none'd, so it stays in the tab
   order and keeps announcing its checked state to a screen reader; the chip is the box's
   own <label>, so a click anywhere on it flips the operator. Stretching the input over the
   whole chip means the pointer never lands on dead space inside it. */
.op-switch { flex: none; position: relative; display: inline-flex; align-items: center;
  justify-content: center; min-width: 27px; height: 21px; padding: 0 6px; margin: 0;
  border: 1px solid var(--line); border-radius: 6px; background: #fff;
  color: var(--muted); font-size: 12.5px; font-weight: 700; line-height: 1;
  cursor: pointer; user-select: none; }
.op-switch input { position: absolute; inset: 0; width: 100%; height: 100%;
  margin: 0; padding: 0; border: 0; opacity: 0; cursor: pointer; }
.op-switch::after { content: "="; }
.op-switch:hover { border-color: #cdd6ea; background: #f3f5fb; color: var(--ink); }
/* Not-equal is the state that has to be visible from across the page: "≠ Billing" returns
   very nearly the opposite rows to "= Billing", and a tick nobody noticed would read as a
   broken report rather than a chosen one. */
.op-switch:has(input:checked) { border-color: #f3c2c8; background: #fdeaec; color: #a52232; }
.op-switch:has(input:checked)::after { content: "≠"; }
.op-switch:has(input:focus-visible) { outline: 2px solid var(--brand); outline-offset: 1px; }

/* Multi-select filter — see the filter_multi macro in _macros.html. The closed control has
   to be the same box as the <select> next to it, or a row of filters stops lining up. */
.ms { position: relative; }
.ms > summary {
  display: flex; align-items: center; gap: 8px; cursor: pointer;
  padding: 10px 12px; min-height: calc(1.5em + 22px);
  border: 1px solid var(--line); border-radius: 9px; background: #fff;
  font: inherit; color: var(--ink); list-style: none;
}
.ms > summary::-webkit-details-marker { display: none; }   /* Safari's own triangle */
.ms > summary:hover { border-color: #cdd6ea; }
.ms > summary:focus-visible { outline: 2px solid #b9c6ff; border-color: var(--brand); }
.ms-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
/* The caret, drawn to match the one the browser puts on a <select>. */
.ms > summary::after {
  content: ""; flex: none; width: 9px; height: 9px; margin-right: 1px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: translateY(-2px) rotate(45deg);
}
.ms[open] > summary::after { transform: translateY(2px) rotate(225deg); }
.ms[open] > summary { border-color: var(--brand); }
/* Floating, so opening a filter cannot push the results down the page. max-height keeps a
   long list (every organisation) inside the viewport and scrolls the rest. */
.ms-menu {
  position: absolute; z-index: 30; top: calc(100% + 4px); left: 0; min-width: 100%;
  max-height: 260px; overflow-y: auto; padding: 6px;
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  box-shadow: 0 10px 28px rgba(20,30,60,.16);
}
.ms-menu label { display: flex; align-items: center; gap: 8px; margin: 0;
  padding: 7px 9px; border-radius: 7px; font-weight: 500; font-size: 14px;
  white-space: nowrap; cursor: pointer; }
.ms-menu label:hover { background: #f3f5fb; }
.ms-menu input { width: auto; min-height: 0; flex: none; margin: 0; }

/* Applied-filters bar — what the list is actually showing, above the results. Sits in the
   gap between the filter panel and the table, so it reads as a caption on the results
   rather than another control. */
.filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  margin: -6px 2px 14px; }
.filterbar-lbl { font-size: 12px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); }
.fchip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 4px 4px 10px;
  border: 1px solid var(--line); border-radius: 999px; background: #fff;
  font-size: 13px; line-height: 1.4; }
.fchip-k { color: var(--muted); }
.fchip-op { font-weight: 700; color: var(--muted); }
.fchip-v { font-weight: 600; }
/* The values of one filter are an OR, and saying so is the whole reason they share a chip. */
.fchip-or { color: var(--muted); font-size: 12px; font-style: italic; margin-left: 2px; }
/* A negated chip is tinted like the ≠ switch that produced it, so the bar and the panel
   agree at a glance about which filters are inverted. */
.fchip-ne { border-color: #f3c2c8; background: #fdeaec; }
.fchip-ne .fchip-op, .fchip-ne .fchip-k { color: #a52232; }
.fchip-x { display: inline-flex; align-items: center; justify-content: center;
  width: 19px; height: 19px; border-radius: 50%; color: var(--muted);
  font-size: 11px; text-decoration: none; }
.fchip-x:hover { background: #eef1f8; color: var(--danger); text-decoration: none; }
.fchip-ne .fchip-x:hover { background: #f8d3d8; }
.filterbar-clear { font-size: 13px; font-weight: 600; margin-left: 2px; }

/* Tables */
table.grid { width: 100%; border-collapse: collapse; }
table.grid th { text-align: left; font-size: 12px; text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); padding: 12px 14px; border-bottom: 1px solid var(--line); }
table.grid td { padding: 12px 14px; border-bottom: 1px solid var(--line); }
table.grid tbody tr { cursor: pointer; }
table.grid tbody tr:hover { background: #f7f9ff; }
/* Thirteen columns need room. Below this the card scrolls sideways instead of squeezing
   Title and Module down to a word per line — raised with the Assigned to column, which is
   a name and will not wrap, so without the extra floor it took its width out of the
   columns that will. Only the ticket table — the shorter admin tables (team, orgs,
   catalogues) still fit and must not gain a scrollbar. */
table.grid.tickets { min-width: 1180px; }
/* Thirteen columns of 14px side padding would spend 364px on gaps alone, which came out of
   Title. Buying 4px back per side across the row is enough to keep the whole table inside
   the page without a scrollbar. */
table.grid.tickets th, table.grid.tickets td { padding-left: 10px; padding-right: 10px; }
/* The running number. Narrow (1% collapses to its content under auto table layout) and
   quiet, because it is scaffolding for talking about the list — "the 14th row" — not a
   value of the ticket, and it must not read as a second ticket number. Tabular figures so
   the column stays a straight edge as it passes 9, 99 and 999. */
.num-col { width: 1%; white-space: nowrap; color: var(--muted); text-align: right;
  font-variant-numeric: tabular-nums; font-size: 12px; }
/* Title is what the row is read by, so it gets a floor as well as a ceiling: without the
   floor, auto table layout hands its width to the columns that refuse to wrap and a
   four-word title stacks into four lines. The ceiling is raised on the wide list pages:
   the spare width there has to land somewhere, and Title is the column that earns it. */
.title-cell { font-weight: 600; min-width: 190px; max-width: 300px; }
.wrap-wide .title-cell { max-width: 380px; }
/* A name and an age stay on one line — a person split across two lines reads as two
   people. The dates are deliberately left to wrap: holding "13 Aug 2026" on one line costs
   ~80px across the two date columns, which is the difference between the table fitting the
   page and Last modified falling off the right edge. Rows are two lines tall from the
   title anyway, so the wrap costs no height. */
.who-cell, .age-cell { white-space: nowrap; }
/* Assigned to is the one who-cell that may hold more than one person, so it is the one
   allowed to break — between names only, never inside one. See who_names in _macros.html:
   the names carry the nowrap the cell gives up. Two classes beats the one above it. */
.who-cell.who-multi { white-space: normal; }
.who-cell .who-name { white-space: nowrap; }
.empty { text-align: center; color: var(--muted); padding: 28px; }

/* Linked rows. Each cell of a ticket row holds an anchor to the ticket (see rowcell in
   _macros.html), so the padding moves off the cell and onto the anchor: left on the cell
   it would ring each link with a dead strip where a click lands on nothing. The colour is
   inherited rather than the link blue every other anchor takes, because the row is a table
   of values that happens to be clickable, not eleven links to read. Only .lnk-cell, so the
   "No tickets found" row keeps its own padding. */
table.grid.tickets td.lnk-cell { padding: 0; }
.rowlink { display: block; padding: 12px 10px; color: inherit; text-decoration: none; }
.rowlink:hover { color: inherit; text-decoration: none; }
/* :focus-visible, not :focus — a mouse click on a row should not leave a ring behind, but
   tabbing to it must show where the keyboard is. Inset, or the outline is clipped by the
   card that scrolls sideways. */
.rowlink:focus-visible { outline: 2px solid var(--brand); outline-offset: -3px; border-radius: 3px; }

/* Sortable headings. Same trick as the rows: the whole heading is the link, so the padding
   sits on the anchor and the entire cell is the click target. The arrow is a glyph beside
   the label rather than a separate button — a button inside a link cannot be clicked
   without ambiguity, and the whole heading means one thing anyway ("sort by this"). */
table.grid.tickets th.sortable { padding: 0; }
th.sortable > a { display: flex; align-items: center; gap: 5px; padding: 12px 10px;
  color: inherit; text-decoration: none; }
th.sortable > a:hover { color: var(--brand); background: #f3f6fd; text-decoration: none; }
th.sortable > a:focus-visible { outline: 2px solid var(--brand); outline-offset: -3px; }
/* Dimmed rather than hidden until hovered: an arrow nobody can see is an affordance
   nobody uses. The active column's arrow is full strength and points the way it runs. */
.sarrow { font-size: 9px; line-height: 1; opacity: .35; }
th.sortable > a:hover .sarrow { opacity: .75; }
th.sorted > a { color: var(--brand); }
th.sorted .sarrow { opacity: 1; }

/* Badges */
.badge { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: 12px;
  font-weight: 700; border: 1px solid transparent; white-space: nowrap; }
.sev-critical { background: #fde7ea; color: #b21f33; }
.sev-high { background: #fdeede; color: #a9620a; }
.sev-medium { background: #fff8db; color: #8a6d00; }
.sev-low { background: #e8f3ee; color: #18794e; }
.st-open { background: #eef1fb; color: #38469c; }
.st-in_progress { background: #e6f0ff; color: #1f5fbf; }
.st-done { background: #e8f3ee; color: #18794e; }
.role-pill { background: #eef1fb; color: #38469c; border-radius: 6px; padding: 2px 7px; font-size: 12px; font-weight: 600; }

/* KPI tiles */
.kpi { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--shadow); padding: 16px 18px; }
.kpi .n { font-size: 30px; font-weight: 800; }
.kpi .l { color: var(--muted); font-size: 13px; }
/* The lead/support split under "Assigned to me": a footnote to the count above it, not
   a second figure competing with it. */
.kpi .sub { color: var(--muted); font-size: 11px; margin-top: 4px; opacity: .85; }
.kpi.alert .n { color: var(--danger); }

/* Flash */
.flashes { margin-bottom: 16px; }
.flash { padding: 11px 14px; border-radius: 9px; margin-bottom: 8px; font-weight: 500; border: 1px solid; }
.flash-success { background: #e8f6ef; border-color: #bde6cf; color: #176a47; }
.flash-danger { background: #fdeaec; border-color: #f3c2c8; color: #a52232; }
.flash-warning { background: #fff6e0; border-color: #f0dca8; color: #8a6400; }
.flash-info { background: #eaf1fd; border-color: #c3d6f5; color: #28508f; }

/* Auth */
.auth-card { background: var(--card); border: 1px solid var(--line); border-radius: 16px;
  box-shadow: var(--shadow); padding: 30px; }
.auth-card h1 { font-size: 21px; margin: 0 0 4px; }
.auth-brand { display: flex; flex-direction: column; align-items: center; gap: 10px;
  font-weight: 800; font-size: 22px; margin-bottom: 18px; color: #0f1b3d; }
.auth-brand span { color: var(--brand); }
.auth-logo { display: block; width: 44px; height: 44px; }
.auth-links { margin-top: 16px; font-size: 14px; display: flex; justify-content: space-between; }

/* Status tracker — a numbered stepper along the happy path. Eleven stages read as a
   sequence only if they stay on one line, so the labels are short (workflow.
   TRACKER_LABELS) and the connector is drawn behind the markers rather than as a
   separate element. Below 760px the whole thing turns vertical, where a long list of
   stages is easier to follow than a squeezed row. */
.tracker-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px;
  margin: 0 0 16px; }
.tracker-head .step-of { font-size: 11.5px; font-weight: 700; color: var(--muted);
  text-transform: uppercase; letter-spacing: .05em; border: 1px solid var(--line);
  border-radius: 999px; padding: 3px 10px; background: #fafbfe; }
.tracker-head .now { font-size: 15px; }
.tracker-head .muted { font-size: 13px; }
.steps { display: flex; list-style: none; margin: 0; padding: 0; }
.steps .step { flex: 1 1 0; min-width: 0; text-align: center; position: relative;
  font-size: 11.5px; line-height: 1.3; color: var(--muted); padding: 0 2px; }
/* Connector: from the previous marker's centre to this one's, tucked behind them. */
.steps .step + .step:before { content: ""; position: absolute; top: 11px; left: -50%;
  right: 50%; height: 2px; background: var(--line); }
.steps .step.done:before, .steps .step.current:before { background: var(--ok); }
.steps .marker { position: relative; z-index: 1; display: flex; align-items: center;
  justify-content: center; width: 24px; height: 24px; margin: 0 auto 7px;
  border: 2px solid var(--line); border-radius: 50%; background: #fff;
  font-size: 11px; font-weight: 700; color: var(--muted); }
.steps .step.done { color: var(--ink); }
.steps .step.done .marker { background: var(--ok); border-color: var(--ok); color: #fff; }
.steps .step.current { color: var(--ink); font-weight: 700; }
.steps .step.current .marker { background: var(--brand); border-color: var(--brand);
  color: #fff; box-shadow: 0 0 0 4px rgba(47,87,230,.16); }
@media (max-width: 760px) {
  .steps { flex-direction: column; }
  .steps .step { display: flex; align-items: center; gap: 10px; text-align: left;
    font-size: 13.5px; padding: 4px 0; }
  .steps .step + .step:before { top: -6px; bottom: auto; left: 11px; right: auto;
    width: 2px; height: 14px; }
  .steps .marker { margin: 0; flex: none; }
}

/* Detail layout */
.detail { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; }
@media (max-width: 900px){ .detail { grid-template-columns: 1fr; } }
.meta-list { list-style: none; padding: 0; margin: 0; }
.meta-list li { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--line); gap: 12px; }
.meta-list li span:first-child { color: var(--muted); }

/* Comments / history timeline */
.comment { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin-bottom: 10px; background:#fff; }
.comment.internal { background: #fff8e8; border-color: #f0dca8; }
.comment .who { color: var(--ink); font-weight: 700; }
.comment .meta { color: var(--muted); font-size: 12px; }
/* Author line and the pencil that edits your own message, on one row. */
.comment-head { display: flex; align-items: flex-start; justify-content: space-between;
  gap: 10px; }
.comment-head .who { flex: 1; min-width: 0; }
/* Quiet until wanted: legible enough to find, faint enough that a conversation is not
   a column of buttons. Never hidden outright — on a touch screen there is no hover to
   reveal it, and a control that only appears on hover is a control that does not exist
   on a phone. */
.icon-btn { flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; padding: 0; border: 1px solid transparent; border-radius: 7px;
  background: transparent; color: var(--muted); cursor: pointer; opacity: .65;
  transition: opacity .12s ease, background .12s ease, color .12s ease; }
.comment:hover .icon-btn { opacity: 1; }
.icon-btn:hover { background: #eef1f8; border-color: var(--line); color: var(--brand); }
.comment.internal .icon-btn:hover { background: #f7ecd2; }
.icon-btn:focus-visible { opacity: 1; outline: 2px solid var(--brand); outline-offset: 1px; }

/* An @-mention inside a note. Built off .role-pill's palette because it is the same
   idea — a person's name lifted out of the sentence around it — and a conversation
   that already carries role pills should not gain a second way of saying "person".
   No underline and no link: the name is a fact about the note, not somewhere to go. */
.mention { background: #eef1fb; color: #38469c; border-radius: 5px; padding: 1px 5px;
  font-weight: 600; }
/* Your own name. On a long internal note the one thing you opened it to find is where
   somebody said *you*, so it gets the only colour in the message. */
.mention-me { background: #fde9b5; color: #7a5a12; }

.timeline { list-style: none; padding-left: 0; margin: 0; }
.timeline li { padding: 8px 0 8px 16px; border-left: 2px solid var(--line); position: relative; }
.timeline li:before { content:""; position:absolute; left:-6px; top:14px; width:10px; height:10px;
  border-radius:50%; background: var(--brand); }

/* Charts */
.chart-card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--shadow); padding: 16px 18px; }
.chart-card h3 { margin: 0 0 12px; font-size: 15px; }
.chart-box { position: relative; height: 240px; }
.sla-row { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--line); }
.sla-pill { font-weight:700; border-radius:6px; padding:2px 10px; }
.sla-green { background:#e8f3ee; color:#18794e; }
.sla-amber { background:#fff6e0; color:#8a6400; }
.sla-red { background:#fdeaec; color:#a52232; }
.sla-na { background:#eef1f6; color:#6b7488; }

.toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:end; }
.toolbar > div { flex: 1; min-width: 140px; }
/* One file per line, so the remove controls down the left edge line up with each other.
   Side by side they could not: each X sat wherever its own filename happened to end. */
.attach-list { display:flex; flex-direction:column; align-items:stretch; gap:6px; }
.attach { border:1px solid var(--line); border-radius:9px; padding:8px 10px; font-size:13px; background:#fff; }
.attach:hover { background:#f3f5fb; text-decoration:none; }
/* The remove control, then the file. One box so the two always travel together. */
.attach-row { display:flex; align-items:center; gap:6px; }
/* The link takes the rest of the row, so a long filename wraps inside its own box rather
   than pushing anything sideways. .attach itself is left alone — the report page and the
   submission form use it too. */
.attach-list .attach { flex:1; min-width:0; overflow-wrap:anywhere; }
.attach-remove { display:inline-flex; margin:0; }
.attach-remove .icon-btn { width:24px; height:24px; }
/* Visible from the keyboard and on touch; on a pointer it fades up with its row, the
   way the comment controls do. */
.attach-row .icon-btn, .attach-preview .icon-btn { opacity:.5; }
.attach-row:hover .icon-btn, .attach-row:focus-within .icon-btn,
.attach-preview:hover .icon-btn, .attach-preview:focus-within .icon-btn { opacity:1; }
.attach-remove .icon-btn:hover { background:#fdecea; border-color:var(--danger);
  color:var(--danger); }

/* An upload control that sends as soon as files are chosen (data-autoupload in
   static/js/admin.js). Everything here hangs off .au-live, which the script adds only
   once it has taken over — so with the script off this is the plain form it always was:
   a file input, a hint that never appears, and the Attach button doing the work. */
.dropzone { border:1px dashed transparent; border-radius:10px; margin:0 -6px;
  padding:6px; transition:border-color .12s ease, background .12s ease; }
/* The button is what the form has without JavaScript. With it, choosing the files is the
   whole gesture and a second click to confirm is a step that existed only for the form's
   sake — so it goes, along with the space that was holding it. */
.au-live > .btn, .au-live > .spacer { display:none; }
/* Dragging over it. Dashed, because the border is an invitation rather than a boundary —
   and the same dash the zone already carries, simply lit up. */
.au-over { border-color:var(--brand); background:#f4f6ff; }
/* In flight. Not `disabled` on the input — a disabled file input is not submitted, and
   the files would go nowhere. */
.au-sending { opacity:.65; pointer-events:none; }
.au-note { margin-top:4px; font-weight:500; color:var(--brand); }

/* Screenshots shown in place on the ticket, so the reader sees the evidence without
   opening anything. The tiles grow to fill the card (a lone screenshot gets the full
   width) and the image is capped in height so several attachments cannot push the
   conversation off the page — click still opens the untouched file in the lightbox.
   The dark plate behind it keeps a transparent PNG or a pale screenshot readable. */
.attach-previews { display:grid; gap:12px; margin-bottom:12px;
  grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); }
.attach-preview { margin:0; border:1px solid var(--line); border-radius:9px;
  overflow:hidden; background:#fff; }
.attach-preview > a { display:block; background:#0f1420; line-height:0; }
.attach-preview img { width:100%; max-height:340px; object-fit:contain; }
.attach-preview figcaption { padding:7px 10px; font-size:12.5px; border-top:1px solid var(--line);
  display:flex; align-items:center; gap:6px; }
/* The name is what gets clipped when the tile is narrow, never the ✕ beside it. */
.attach-preview figcaption .attach-name { flex:1; min-width:0;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* Existing attachments on the edit form: a Remove tick beside each file. */
/* A pasted Teams or Outlook deep link is long enough to push a card sideways, so links in
   an autolinked description break mid-URL rather than overflowing. */
.autolinked a { overflow-wrap: anywhere; }

/* A file held for the next submit: the same chip, without the affordances of a link —
   there is nothing to open yet, since it is not an attachment until the ticket exists. */
.attach.staged, .attach.staged:hover { background:#f7f9ff; color:var(--ink); cursor:default; }
.attach-manage { list-style:none; margin:0 0 4px; padding:0; }
.attach-manage li { display:flex; align-items:center; gap:10px; margin:6px 0; }
.attach-manage li > label { display:flex; align-items:center; gap:5px; margin:0;
  font-weight:500; font-size:13px; color:var(--danger); white-space:nowrap; }
.attach-manage li > label input { width:auto; min-height:0; }
.checkrow { display:flex; flex-wrap:wrap; gap:8px; }
.checkrow label { display:flex; align-items:center; gap:6px; font-weight:500; margin:0;
  border:1px solid var(--line); padding:7px 11px; border-radius:8px; background:#fff; }
.checkrow input { width:auto; }
/* Assignment picker: one bordered cell per developer holding both of the Team Lead's
   answers about them, so the tick and the owner mark stay visibly attached to a name.
   Styled alongside .checkrow rather than on it — the border belongs to the cell here,
   not to each label inside it. */
.dev-picks { display:flex; flex-wrap:wrap; gap:8px; }
/* Also used on its own as a single radio cell (the Ticket owner card), so the weight and
   spacing sit on .dev-pick itself rather than only on the labels nested inside it. */
.dev-pick { display:flex; align-items:center; gap:6px; border:1px solid var(--line);
  padding:7px 11px; border-radius:8px; background:#fff; font-weight:500; margin:0; }
.dev-pick label { display:flex; align-items:center; gap:6px; font-weight:500; margin:0; }
.dev-pick input { width:auto; }
.dev-pick .dev-main { border-left:1px solid var(--line); padding-left:10px;
  color:var(--muted); font-size:12px; font-weight:600; white-space:nowrap; }
.locked-set:disabled .dev-pick { background:#f4f5f8; cursor:not-allowed; }
/* Each name on its own line: with a pill after it a comma-separated run reads as one
   long sentence, and the point of the pills is to be scanned rather than read. */
.dev-roles { display:flex; flex-direction:column; align-items:flex-end; gap:4px; }
.dev-role { white-space:nowrap; }
.spacer { height: 10px; }
/* A form section that is inert for now (e.g. assignment once work has started): a
   plain <fieldset disabled> so the browser blocks the controls, greyed to show why. */
.locked-set { border:0; margin:0; padding:0; min-width:0; }
.locked-set:disabled { opacity:.5; }
.locked-set:disabled .checkrow label { background:#f4f5f8; cursor:not-allowed; }
.locked-set:disabled .btn { cursor:not-allowed; }

/* Pagination */
.pager { display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 12px; margin: 14px 2px 4px; }
.pager-size { display: flex; align-items: center; gap: 8px; margin: 0; }
.pager-size label { margin: 0; font-weight: 600; font-size: 13px; color: var(--muted); }
.pager-size select { width: auto; padding: 6px 10px; }
.pager-showing { color: var(--muted); font-size: 13px; white-space: nowrap; }
.pager-nav { display: flex; gap: 4px; flex-wrap: wrap; }
.pg { display: inline-block; min-width: 34px; text-align: center; padding: 6px 10px;
  border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink);
  font-size: 13px; font-weight: 600; }
a.pg:hover { background: #f3f5fb; text-decoration: none; border-color: #cdd6ea; }
.pg.cur { background: var(--brand); border-color: var(--brand); color: #fff; }
.pg.disabled { color: var(--muted); opacity: .5; }
.pg.gap { border: none; background: transparent; min-width: 18px; padding: 6px 2px; }

/* Help / guide pages */
.guide { display: grid; grid-template-columns: 232px 1fr; gap: 26px; align-items: start; }
@media (max-width: 900px){ .guide { grid-template-columns: 1fr; } }
.guide-toc { position: sticky; top: 78px; }
@media (max-width: 900px){ .guide-toc { position: static; } }
.guide-toc .card { padding: 14px 16px; margin-bottom: 0; }
.guide-toc h3 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); }
.guide-toc ol { list-style: none; margin: 0; padding: 0; }
.guide-toc li { margin: 0; }
.guide-toc a { display: block; padding: 5px 2px; font-size: 13.5px; color: var(--ink);
  border-radius: 6px; line-height: 1.35; }
.guide-toc a:hover { color: var(--brand); background: #f3f5fb; text-decoration: none; }

.guide-content section { scroll-margin-top: 74px; }
.guide-content section.card h2 { margin: 0 0 12px; font-size: 18px; }
.guide-content h3 { font-size: 15px; margin: 18px 0 8px; }
.guide-content h3:first-child { margin-top: 0; }
.guide-content p { margin: 0 0 12px; }
.guide-content p:last-child { margin-bottom: 0; }
.guide-content ol, .guide-content ul { margin: 0 0 12px; padding-left: 22px; }
.guide-content li { margin: 5px 0; }
.guide-content section:last-child { margin-bottom: 0; }
.guide .tablecard { margin: 6px 0 14px; }
.guide table.grid tbody tr { cursor: default; }
.guide table.grid tbody tr:hover { background: transparent; }
.guide table.grid td:first-child { font-weight: 600; white-space: nowrap; }
.guide table.grid td.wrap-cell { font-weight: 400; white-space: normal; }

/* Callout / note */
.callout { border-left: 3px solid var(--brand); background: #eff3fe;
  border-radius: 0 8px 8px 0; padding: 10px 14px; margin: 4px 0 14px; font-size: 14px; }
.callout p { margin: 0; }
.callout strong { color: var(--brand-dark); }
.callout.tip { border-left-color: var(--ok); background: #edf7f1; }
.callout.tip strong { color: var(--ok); }
.callout.warn { border-left-color: var(--warn); background: #fdf6e6; }
.callout.warn strong { color: var(--warn); }

/* Possible-duplicate matches on the new-ticket form */
.dup-list { margin: 8px 0 0; padding-left: 18px; }
.dup-list li { margin: 6px 0; }
.dup-list .muted { display: block; font-size: 13px; }

/* Row actions + edit modal */
.row-actions { display: inline-flex; gap: 6px; align-items: center; justify-content: flex-end; }
.row-actions form { display: inline; margin: 0; }
dialog.modal { border: 1px solid var(--line); border-radius: var(--radius);
  padding: 0; width: min(520px, calc(100vw - 32px)); box-shadow: 0 18px 50px rgba(20,30,60,.25);
  color: var(--ink); }
dialog.modal::backdrop { background: rgba(20,30,60,.45); }
dialog.modal .modal-head { padding: 16px 20px; border-bottom: 1px solid var(--line); }
dialog.modal .modal-head h3 { margin: 0; font-size: 17px; }
dialog.modal .modal-body { padding: 18px 20px; }
dialog.modal .modal-body label { display: block; margin: 12px 0 5px; font-size: 13px; font-weight: 600; }
dialog.modal .modal-body label:first-child { margin-top: 0; }
dialog.modal .modal-foot { padding: 14px 20px; border-top: 1px solid var(--line);
  display: flex; gap: 10px; justify-content: flex-end; background: #fafbfe;
  border-radius: 0 0 var(--radius) var(--radius); }
/* Attachment preview: a wide dialog so a screenshot is legible without leaving the
   ticket. The dark body keeps a transparent PNG or a pale screenshot readable. */
dialog.modal.lightbox { width: min(1040px, calc(100vw - 32px)); }
dialog.modal .modal-body.preview-body { padding: 0; background: #0f1420; min-height: 120px; }
dialog.modal .preview-body img,
dialog.modal .preview-body video { display: block; margin: 0 auto;
  max-width: 100%; max-height: 72vh; }
dialog.modal .preview-name { flex: 1; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }

.share-copy { display: flex; gap: 8px; align-items: stretch; }
.share-copy input { flex: 1; }
.share-copy .btn { white-space: nowrap; }

/* Formatted Description / Expected result — the editor on the ticket form, and the
   rendered result on the ticket page. See services/text.py and static/js/richtext.js. */

/* Nothing but the textarea shows until the script says otherwise: a bar whose buttons do
   nothing, over a box nobody can type into, is worse than the plain field it replaced. */
.rt { margin-top: 5px; }
.rt-bar, .rt-wysiwyg, .rt-linkbar { display: none; }
.rt.rt-live > textarea { display: none; }
.rt.rt-live .rt-bar { display: flex; }
.rt.rt-live .rt-wysiwyg { display: block; }
.rt.rt-live .rt-linkbar:not([hidden]) { display: flex; }

/* The bar sits on top of the box and shares its border, so the two read as one control
   rather than a row of buttons that happens to be above a field. */
.rt-bar {
  align-items: center; gap: 4px; flex-wrap: wrap;
  padding: 5px 6px; background: #fafbfe;
  border: 1px solid var(--line); border-bottom: 0;
  border-radius: 9px 9px 0 0;
}
.rt-btn {
  border: 1px solid transparent; background: transparent; color: var(--ink);
  border-radius: 7px; padding: 4px 9px; font: inherit; font-size: 13px;
  line-height: 1.4; cursor: pointer; min-width: 30px;
}
.rt-btn:hover:not(:disabled) { background: #eceffa; }
/* Indent and outdent, wherever the caret is not in a list. Dimmed rather than
   withdrawn: a bar whose buttons come and go is a bar that moves under the cursor. */
.rt-btn:disabled { opacity: .35; cursor: default; }
.rt-btn:focus-visible { outline: 2px solid #b9c6ff; }
/* Lit for whatever the caret is inside, so the bar reports the text as well as changes it. */
.rt-btn[aria-pressed="true"] { background: var(--brand); border-color: var(--brand); color: #fff; }
.rt-mono { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; }
.rt-sep { width: 1px; align-self: stretch; margin: 2px 4px; background: var(--line); }

.rt-linkbar {
  gap: 8px; align-items: center; padding: 8px 10px; background: #eef1f9;
  border: 1px solid var(--line); border-bottom: 0;
}
.rt-linkbar input { flex: 1; }
.rt-linkbar input[aria-invalid="true"] { border-color: var(--danger); }
.rt-linkbar .btn { white-space: nowrap; }

/* Same box as the textarea it stands in for, so switching the script off moves nothing. */
.rt-wysiwyg {
  min-height: 120px; padding: 10px 12px; background: #fff; color: var(--ink);
  border: 1px solid var(--line); border-radius: 0 0 9px 9px;
  overflow-wrap: anywhere;
}
.rt-wysiwyg:focus { outline: 2px solid #b9c6ff; border-color: var(--brand); }
/* contenteditable has no placeholder of its own, and it is rarely truly :empty — a browser
   leaves a <br> behind — so the class is driven from the serialised value instead. */
.rt.rt-empty .rt-wysiwyg::before {
  content: attr(data-placeholder); color: var(--muted); pointer-events: none;
}
/* Required, but the browser is no longer the one asking: the textarea it would have
   reported on is not on screen, so the submit handler does it and this says so. */
.rt.rt-missing .rt-wysiwyg { border-color: var(--danger); outline: 2px solid #f3c6cc; }
.rt.rt-missing .rt-help::after {
  content: " This field is required."; color: var(--danger); font-weight: 600;
}
.rt-help { margin-top: 6px; }

/* Rendered output, shared by the ticket pages and by the editor itself, so what a reporter
   is looking at while writing is what everyone else will see.

   pre-wrap is kept from when these fields were plain text: the renderer emits no whitespace
   of its own between tags, so it costs nothing, and it means a run of spaces in a pasted
   log line still lines up. */
.richtext { white-space: pre-wrap; }
.richtext > :first-child { margin-top: 0; }
.richtext > :last-child { margin-bottom: 0; }
.richtext p { margin: 0 0 10px; }
/* No white-space override on the lists: the renderer consumed the newlines between items
   into <li> elements and emits nothing between the tags, so there is no stray whitespace
   for pre-wrap to render — and leaving it on means a run of spaces inside a bullet
   survives, exactly as it does in a paragraph. */
.richtext li { margin: 2px 0; }
/* One indent step, and every level of every list uses it — including the ::before marker
   below, which is pulled back by exactly that much to sit in the space it makes. Change it
   here and the numbers stay lined up with the text they belong to. */
.richtext { --rt-indent: 26px; }
.richtext ul, .richtext ol { margin: 0 0 10px; padding-left: var(--rt-indent); }
/* A nested list sits tight under the item it belongs to rather than opening a paragraph
   gap inside it. */
.richtext li > ul, .richtext li > ol { margin: 2px 0; }

/* How a numbered list is numbered, by how deep it is:

       1.      2.      3.        the steps
       1.1     1.2               the sub-steps, carrying their parent's number
       a.      b.                below that, letters, starting again inside each parent
       i.      ii.               and deeper still, roman

   Done with CSS counters rather than written into the ticket. What is stored stays the
   plain "1." somebody typed at every level (see services/text.py), so the source is
   readable, an export reads sensibly, and this scheme can change without rewriting a
   single ticket. counters() — plural — is what joins a level to its parent's number and
   gives 1.1 for nothing.

   The same rules style the editor, which also carries .richtext, so a sub-point is
   numbered while it is being typed rather than only after the ticket is saved. */
.richtext ol { list-style: none; counter-reset: rt-num; }
.richtext ol > li { counter-increment: rt-num; position: relative; }
.richtext ol > li::before {
  content: counters(rt-num, ".") ".";
  position: absolute; left: calc(-1 * var(--rt-indent)); width: calc(var(--rt-indent) - 4px);
  text-align: right; font-variant-numeric: tabular-nums; color: var(--muted);
}
/* Second level: 1.1, and no trailing dot — counters() has already put one in the middle. */
.richtext ol ol > li::before { content: counters(rt-num, "."); }
/* Third and below: a number that keeps the whole ancestry is unreadable by 1.1.1, so each
   level starts again in its own alphabet. Nested selectors, so these override the two
   above by specificity rather than by order. */
.richtext ol ol ol > li::before { content: counter(rt-num, lower-alpha) "."; }
.richtext ol ol ol ol > li::before { content: counter(rt-num, lower-roman) "."; }
/* Deeper than the renderer will nest (_MAX_LIST_DEPTH), so this is only ever reached by a
   list built in the editor before a save flattens it back. Bullets rather than a fifth
   scheme nobody has a name for. */
.richtext ol ol ol ol ol > li::before { content: "\2022"; }
.richtext code {
  background: #eef1f9; border-radius: 4px; padding: 1px 5px;
  font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px;
}
.richtext strong { font-weight: 700; }
/* Struck-through text is still text somebody wrote and may still need to read, so it is
   dimmed rather than hidden — enough to read as withdrawn at a glance without becoming
   unreadable. */
.richtext del { text-decoration: line-through; text-decoration-thickness: 1px; opacity: .7; }

/* Mandatory fields whose control is not the label's next sibling.

   The asterisk is driven off the control's own :required state (see "Mandatory fields"
   above), which reaches the label through the sibling combinator. Two cases sit one level
   deeper than that rule can see:

   .rt          the formatted boxes keep their textarea inside a wrapper, so the label's
                next sibling is the wrapper. .rt-required is the same field once the editor
                has taken the requirement off the textarea and onto its own submit handler
                (see richtext.js) — still required, just no longer the browser's to ask.
   .sev-gate    the priority justification, which the script marks required only while the
                Severity beside it is one that demands one. */
label:has(+ .rt textarea:required)::after,
label:has(+ .rt.rt-required)::after,
label:has(+ .sev-gate textarea:required)::after {
  content: " *"; color: var(--danger); font-weight: 700;
}

/* A note somebody wrote on a status change. It used to be .muted — the style that means
   "skip this" — which on a trail row is exactly backwards: the status says what happened
   and the note is the only part saying why. Given its own left rule so it reads as a
   quotation rather than as small print under a heading. */
.trail-note {
  margin-top: 5px; padding: 4px 0 4px 10px; border-left: 2px solid var(--brand);
  color: var(--ink); font-size: 13.5px;
}

/* The standing record of every time a ticket was sent back, on the Quality panel. The
   band at the top of the page says why it is on somebody's desk now and goes when it
   leaves; this stays, because the next person to test it needs what was wrong last time
   whatever status the ticket has reached since. */
.panel-sub { margin: 18px 0 6px; font-size: 13px; font-weight: 600;
  text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.reopen-log { list-style: none; margin: 0 0 4px; padding: 0;
  display: flex; flex-direction: column; gap: 8px; }
.reopen-log li { padding: 8px 12px; border-left: 3px solid #c2870c;
  background: #fdf6e6; border-radius: 0 8px 8px 0; }
.reopen-why { margin: 0 0 2px; color: var(--ink); }
.reopen-log .muted { font-size: 12px; }

/* Why the ticket is back, at the top of the page rather than buried in History. Amber
   rather than red: it is not a failure to fix, it is work returning, and the page already
   spends red on refusals. */
.reopen-note {
  margin: 0 0 16px; padding: 12px 14px; border: 1px solid #e6c789;
  border-left: 4px solid #c2870c; border-radius: var(--radius); background: #fdf6e6;
}
.reopen-head {
  margin: 0 0 3px; font-weight: 700; font-size: 12px; letter-spacing: .06em;
  text-transform: uppercase; color: #8a5e06;
}
.reopen-note p + p { margin: 0; color: var(--ink); }

/* A field that is optional most of the time and required under one condition, saying
   which of the two it currently is. Two of them: the Status panel's note, required when
   the move is Reopened, and the QA remarks, required when the result is Failed.

   Driven by the field's own `required`, which the data-gate handler sets, so the words and
   the rule cannot drift apart — there is only one thing to be wrong. `:has(:required)`
   rather than `:has(input:required)`, because one of the two is a textarea.

   The label says the field is wanted; it does not say anybody has done anything wrong. So
   it is the ordinary label colour, and the box is left alone — choosing "Reopened" and
   being shown a red box before you have had a chance to type is the form telling you off
   for an answer it has not asked for yet.

   The red arrives at one moment and not before: a submit the browser turned back. That is
   `.req-tried`, which static/js/admin.js adds on the `invalid` event — the event fires on
   each field a submit was stopped for, and on nothing else. Deliberately not
   `:user-invalid`, which would also fire on blur after somebody typed and cleared the box,
   and deliberately not on `:required` alone, which would fire the instant the field became
   required and tell somebody off for an answer nobody had asked for yet.

   The browser's own "please fill out this field" lands at the same moment and is the
   reminder, so nothing here restates it. With the script off nothing goes red and that
   native message is still the whole of it, which is a quiet degradation rather than a
   broken one. */
.req-gate .when-required { display: none; font-weight: 600; color: var(--muted); }
.req-gate:has(:required) .when-optional { display: none; }
.req-gate:has(:required) .when-required { display: inline; }
.req-gate.req-tried :invalid {
  border-color: var(--danger);
  box-shadow: 0 0 0 3px rgba(190, 60, 45, .12);
}
.req-gate.req-tried:has(:invalid) .when-required { color: var(--danger); }

/* A block that only applies at certain severities — the priority justification, and the
   workaround toggle beside it in Triage. Shown and hidden by the data-gate handler in
   admin.js. The rule is set off from the fields around it because it appears and
   disappears as the Severity dropdown moves, and something that comes and goes under the
   reader's cursor should look like it belongs to what caused it. */
.sev-gate {
  margin: 12px 0 4px; padding: 12px 14px 4px;
  border: 1px solid #f0dca8; border-radius: 10px; background: #fff8e8;
}
.sev-gate > label:first-child { margin-top: 0; }
.sev-gate .role-pill { margin-left: 4px; }

/* An image inside a description — pasted straight into the box, and stored as one
   attachment the text points at (see services/text.py). Capped so a 3000px screenshot
   cannot push the ticket layout sideways, and given the cursor that says it opens. */
.richtext .rt-img { display: inline-block; max-width: 100%; }
.richtext .rt-img img {
  max-width: 100%; max-height: 420px; height: auto; display: block;
  border: 1px solid var(--line); border-radius: 8px; background: #fff; cursor: zoom-in;
}
.rt-wysiwyg .rt-img img { cursor: default; }
/* What the paste is doing, and what went wrong if it did. */
.rt-note { color: var(--muted); font-size: 13px; margin: 6px 0 0; }

/* ---------------------------------------------------------------- @ mentions ---
   The picker on the internal-note box (see static/js/mentions.js). Modelled on
   .ms-menu, the filter dropdown, so the two floating lists in this app look like one
   thing — and anchored to the box rather than to the caret, which keeps the whole
   position calculation to "underneath", with nothing to get wrong on a wrapped line or
   a scrolled textarea. */
.mention-wrap { position: relative; }
.mention-menu {
  position: absolute; z-index: 40; top: calc(100% - 2px); left: 0; right: 0;
  max-height: 244px; overflow-y: auto; margin: 0; padding: 6px; list-style: none;
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  box-shadow: 0 10px 28px rgba(20,30,60,.16);
}
.mention-menu li {
  display: flex; align-items: baseline; gap: 8px; padding: 7px 9px;
  border-radius: 7px; font-size: 14px; cursor: pointer;
}
/* Hover and keyboard selection are the same highlight on purpose: the arrow keys and
   the pointer are two ways of pointing at one row, and showing two would make the box
   look like it had two cursors in it. */
.mention-menu li:hover, .mention-menu li.active { background: #f3f5fb; }
.mention-name { font-weight: 600; }
.mention-role { color: var(--muted); font-size: 12px; margin-left: auto; }

/* The required asterisk again, for a note box that now sits inside a wrapper — the
   same case as .rt above, for the same reason. */
label:has(+ .mention-wrap textarea:required)::after {
  content: " *"; color: var(--danger); font-weight: 700;
}

/* -------------------------------------------------------------- work record ---
   The four internal stages on a staff ticket — Intake, Diagnosis, Quality, Release —
   as one card with a tab strip. They began as four full-height cards in the sidebar,
   which turned the page into a column to scroll past rather than read, and put the
   substantial forms in the narrow rail while the wide one held text.

   A quiet left edge marks it as AgileAP's own record of the work rather than part of
   the ticket the customer filed. Reusing .card's box otherwise: it is a card, not a
   second kind of thing. */
.work-card { border-left: 3px solid #c9d3ee; padding-top: 14px; }
.work-head { margin-bottom: 10px; }
.work-head h3 { margin: 0; }
.work-head .help { margin-top: 2px; }

/* The strip sits on the card's own edge — full-bleed to the padding, with the rule
   running the whole width so the active tab reads as a folder in front of it rather
   than a button floating above a line. */
.tabstrip {
  display: flex; flex-wrap: wrap; gap: 2px;
  margin: 0 -18px 16px; padding: 0 18px;
  border-bottom: 1px solid var(--line);
}
.tab {
  position: relative; display: inline-flex; align-items: center; gap: 7px;
  padding: 9px 14px; border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 14px; font-weight: 600; color: var(--muted);
  border-radius: 8px 8px 0 0;
  /* The underline is drawn as a box-shadow so it sits *on* the rule above rather than
     adding a pixel to the tab and shifting the row when the active one changes. */
  box-shadow: inset 0 -2px 0 transparent;
}
.tab:hover { color: var(--ink); background: #f3f5fb; }
.tab.active { color: var(--brand); box-shadow: inset 0 -2px 0 var(--brand); }
.tab:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
/* "Something is written in here" — so the strip answers how far the ticket has been
   written up without opening all four. */
.tab-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); flex: none; }

/* Once the script is running each panel is the whole card body; the heading it used to
   carry is now its tab, so it starts at the top with nothing above it. */
.tabpanel > :first-child { margin-top: 0; }
/* Without the script the panels are stacked, and each needs telling apart from the
   next. .tabs-live is added by tabs.js, so this only applies when it is not. */
.work-card:not(.tabs-live) .tabpanel + .tabpanel {
  margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--line);
}

/* The Intake block on the staff raise form. A second section inside one form — the
   customer's report above it, AgileAP's own record of it below — so it needs a rule
   between the two rather than running straight on from the last field. */
.intake-head { margin: 24px 0 2px; padding-top: 18px; border-top: 1px solid var(--line); }

/* A checkbox grid — impacted modules. Same shape as .dev-picks, which does the same
   job for resources, but sized for a longer list of shorter labels. */
.pick-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 2px 12px; margin: 4px 0 2px;
}
.pick-grid .pick { display: flex; align-items: center; gap: 7px; margin: 0;
  padding: 4px 0; font-weight: 500; font-size: 14px; }
.pick-grid .pick input { width: auto; min-height: 0; flex: none; margin: 0; }

/* ---- related-ticket picker (static/js/ticketlink.js) ---- */
.linkpick { position: relative; display: grid;
  grid-template-columns: minmax(120px, auto) minmax(0, 1fr); gap: 8px; align-items: start; }
.linkpick > .link-menu, .linkpick > .link-chosen { grid-column: 1 / -1; }

/* Anchored to the picker rather than to the caret, for the same reason .mention-menu
   is: "underneath" has nothing to get wrong. */
.link-menu {
  position: absolute; z-index: 40; top: calc(100% - 2px); left: 0; right: 0;
  max-height: 260px; overflow-y: auto; margin: 0; padding: 6px; list-style: none;
  background: var(--card); border: 1px solid var(--line); border-radius: 10px;
  box-shadow: 0 10px 28px rgba(20,30,60,.16);
}
.link-menu li {
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px;
  align-items: baseline; padding: 7px 9px; border-radius: 7px; font-size: 14px;
  cursor: pointer;
}
.link-menu li:hover, .link-menu li.active { background: #f3f5fb; }

.link-chosen { list-style: none; margin: 2px 0 0; padding: 0;
  display: flex; flex-direction: column; gap: 6px; }
.link-chosen li {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: #fff;
  font-size: 14px;
}
/* Monospaced and never wrapped: a ticket number is an identifier, and it is what the
   eye is actually scanning this list for. */
.link-no { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600;
  font-size: 13px; white-space: nowrap; color: var(--brand); }
.link-title { flex: 1 1 200px; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
.link-org { color: var(--muted); font-size: 12px; white-space: nowrap; }
.link-open { font-size: 13px; white-space: nowrap; }
.link-chosen .icon-btn { opacity: 1; font-size: 17px; line-height: 1; }

/* A status move the ticket's own recorded results have closed — printed where the
   option would have been. Amber rather than red: nothing has gone wrong, the ticket is
   simply saying it is not ready, and the same palette as .sev-gate because it is the
   same idea (a block that appears and disappears with the data around it). */
.gate-note {
  margin: 12px 0 0; padding: 11px 14px;
  border: 1px solid #f0dca8; border-radius: 10px; background: #fff8e8;
  display: flex; flex-direction: column; gap: 9px;
}
.gate-note p { margin: 0; font-size: 14px; line-height: 1.5; }
.gate-note form { margin: 0; }
.gate-note .btn { width: 100%; }
/* One reason can close more than one move, so its overrides stack under it rather
   than each carrying its own copy of the sentence. */
.gate-ways { display: flex; flex-direction: column; gap: 6px; }

/* The four values the ticket works out for itself, across the top of the Release panel.
   A row rather than a list: they are read together as the state of the release, and a
   stack of four label/value lines would push the fields somebody came to edit below the
   fold. Wraps to two columns before it ever squeezes. */
.derived-row {
  list-style: none; margin: 0 0 4px; padding: 12px 14px;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px 16px;
  background: #f7f9fd; border: 1px solid var(--line); border-radius: 10px;
}
.derived-row li { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.derived-row span { color: var(--muted); font-size: 12px; }
.derived-row strong { font-size: 14px; }

/* "Keep me signed in" on the sign-in form. A checkbox and its words on one line, at the
   weight of the labels above it rather than shouting — it is an option, not a field. */
.auth-card .remember { display: flex; align-items: center; gap: 8px;
  margin: 14px 0 0; font-weight: 500; font-size: 14px; }
.auth-card .remember input { flex: none; min-height: 0; margin: 0; }
