/**
 * theme-components.css — Awesome Support theme layer, file 5 of 6 (iteration II)
 * =============================================================================
 *
 * OWNS (api-contract-II §4.1.1 row 5): the project's OWN classes, the ones
 * Bootstrap has no equivalent for — the brand lockup, the client shell frame
 * and its drawer, the status / priority chips, the ticket-thread message, the
 * empty state, the skeleton, the `data-label` table-to-card collapse, the
 * danger-zone card, the segmented control, the page head and the filter bar.
 *
 * It also carries the rules FOLDED IN from the two deleted legacy stylesheets
 * (`helpdeskz/css/helpdesk.css`, 115 lines, and `helpdeskz/css/staff.css`,
 * 5 lines — api-contract-II §4.1.1 "Legacy files"). Every surviving legacy
 * rule moved to the file that owns it; no rule was lost and no third override
 * layer is kept. See §12 at the bottom.
 *
 * MUST NOT CONTAIN: vendor-widget overrides (theme-vendor.css).
 *
 * Class prefix: `.ds-*`. These are ADDED to the markup; no Bootstrap or Shards
 * class is ever renamed or removed to make room for one (rule R2, DOM
 * contract §4.2).
 *
 * Reference keys, as in the other theme files:
 *   ref §N    → docs/input/design/component-reference.css section N
 *   proto §N  → docs/input/design/prototype/ (index.html section / prototype.css)
 *   spec §N   → docs/designs/design-spec-II.md section N
 *
 * Every `@container (max-width: 767px)` block of prototype.css appears here as
 * a plain `@media (max-width: 767px)` block with identical declarations
 * (§4.1.3 rule 4). Breakpoints: 767/768 and 1199/1200 only. There is no
 * `prefers-color-scheme` anywhere in the theme layer.
 */

/* ==========================================================================
 * 1. BRAND LOCKUP — glyph image + product name as LIVE TEXT
 *    spec §1.13 / §2.14, brand/README.md, proto §1/§4/§5.
 *
 * The pt_center wordmark IMAGE reads "AWESOME TRAINER" — the wrong product —
 * and is never shipped. The name is text so that it is correct by
 * construction, translatable, sharp at every density, and readable by screen
 * readers and by TC-8, which asserts the lockup's text reads exactly
 * `Awesome Support` on every themed route.
 * ========================================================================== */

.ds-brand {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
  min-width: 0;
  color: var(--color-text);
}

.ds-brand:hover,
.ds-brand:focus { color: var(--color-text); text-decoration: none; }

/* The glyph. `object-fit: contain` is what keeps an ADMIN-UPLOADED logo of any
 * aspect ratio from being stretched: the client navbar renders this slot from
 * `site_logo()`, which resolves to the admin-configurable logo and only falls
 * back to the shipped brand mark. ref §12 `.ds-logo-mark`. */
.ds-brand-mark {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
  display: block;
  object-fit: contain;
}

/* The wordmark. Uppercase + `.12em` letter-spacing is one of exactly four
 * places the design uses that treatment (spec §1.3). proto §1 `.p-brand-word`. */
.ds-brand-word {
  font-size: var(--text-base);
  font-weight: var(--font-weight-bold);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--color-text);
  white-space: nowrap;
  line-height: 1;
}

.ds-brand-word b {
  color: var(--color-primary);
  font-weight: var(--font-weight-bold);
}

/* ==========================================================================
 * 2. CLIENT SHELL FRAME — `client/template.php` (archetype A1)
 *    proto §1, spec §2.11.
 *
 * A flex column pinned to the viewport height with the footer as the last
 * item. This replaces the legacy technique (`html/body { height: 100% }` +
 * `body { padding-bottom: 6rem }` + `#footer { position: absolute; bottom: 0 }`)
 * from the deleted helpdesk.css, which reserved a fixed strip at the bottom of
 * every page and broke whenever the footer wrapped to two lines.
 * ========================================================================== */

.ds-page {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  background-color: var(--color-surface);
}

.ds-page-content {
  flex: 1;
  min-width: 0;
  padding-top: var(--space-6);
  padding-bottom: var(--space-6);
}

@media (max-width: 767px) {
  .ds-page-content {
    padding-top: var(--space-4);
    padding-bottom: var(--space-4);
  }
}

/* The client navbar. `.navbar` itself is themed in theme-bootstrap.css §8;
 * here it only gives up its own gutter, because the client portal aligns
 * everything to the Bootstrap `.container` that sits inside it.
 * proto §1 `.p-navbar`. */
/* The z-index is on the DRAWER's level, not the navbar's, and that is
 * load-bearing: `position: relative` + a z-index makes the navbar a stacking
 * context, and the off-canvas drawer is a DESCENDANT of it. A navbar pinned at
 * z-index 1 would cap its drawer at 1 too, and any later page element with
 * `position: relative; z-index: 1` (the hero's own children, per §11) would
 * then paint straight through the open drawer. Pinning the navbar to
 * `--z-drawer` keeps the whole lockup + drawer group above the page and above
 * the backdrop (`--z-backdrop`), which is the order §1.17 defines. */
.ds-navbar {
  padding: 0;
  position: relative;
  z-index: var(--z-drawer);
}

.ds-navbar > .container,
.ds-navbar > .container-fluid {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  flex-wrap: nowrap;
  min-width: 0;
}

@media (max-width: 767px) {
  .ds-navbar { min-height: 56px; }
}

/* The inline link row at >= 768px. `.navbar-nav` is `flex-direction: column`
 * in Bootstrap; the design's desktop navbar is a row. proto §1 `.p-navlinks`. */
.ds-navlinks {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
  min-width: 0;
}

.ds-navdrawer {
  display: flex;
  align-items: center;
  margin-left: auto;
  min-width: 0;
}

/* The client footer. `#footer` is the existing id and is kept (it is the
 * element every client view's spacing was authored against). Folds the legacy
 * `#footer` block of helpdesk.css (hex EFEFEF fill, hex 88837F ink, absolute
 * positioning). spec §2.11 "Footer". */
#footer {
  position: static;
  flex-shrink: 0;
  margin-top: auto;
  padding: var(--space-4) 0;
  background-color: var(--color-surface-alt);
  border-top: 1px solid var(--color-border);
  color: var(--color-text-muted);
  font-size: var(--text-sm);
}

#footer a { color: var(--color-text-muted); }
#footer a:hover { color: var(--color-primary); text-decoration: none; }

/* ==========================================================================
 * 3. HAMBURGER, DRAWER AND BACKDROP — the only new client-side behaviour of
 *    this iteration. ref §11 `.ds-hamburger` / §12 `.ds-sidebar-backdrop`,
 *    proto §1/§5, spec §2.24.
 *
 * Used by BOTH shells: the client navbar drawer and the staff sidebar drawer.
 * Behaviour (assets/helpdeskz/js/helpdesk.js and staff.js): the hamburger
 * toggles `aria-expanded`, the drawer opens, the backdrop appears, `Esc` and a
 * backdrop click close it, and focus returns to the hamburger on close.
 * ========================================================================== */

.ds-hamburger {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  margin-left: auto;
  padding: 0;
  background: none;
  border: none;
  border-radius: var(--radius-sm);
  color: var(--color-text);
  font-size: 20px;
  cursor: pointer;
  transition: color 150ms ease, background-color 150ms ease;
}

.ds-hamburger:hover {
  color: var(--color-primary);
  background-color: var(--color-surface-raised);
}

.ds-hamburger:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* The hamburger only exists where a sidebar/navbar collapses into a drawer.
 * ref §11, verbatim. */
@media (min-width: 768px) {
  .ds-hamburger { display: none; }
}

/* A control that belongs to the mobile band only (the staff top bar's brand
 * lockup). Paired with `.ds-hamburger` above so the two disappear together. */
.ds-only-mobile { display: none; }

@media (max-width: 767px) {
  .ds-only-mobile { display: flex; }
}

/* A label that is VISUALLY collapsed below 768px but stays in the
 * accessibility tree, so an icon+label control becomes icon-only on a phone
 * WITHOUT losing its accessible name. Accessible names are contracts
 * (api-contract-II §4.2.4), so `display: none` is not an option here; this is
 * the standard screen-reader clip, the same technique the 64px staff rail uses
 * for its nav labels (theme-shards.css §4).
 *
 * Used by the staff top bar, where the hamburger, the brand lockup, the
 * "New ticket" action and the user menu must all share a 375px row. */
@media (max-width: 767px) {
  .ds-text-collapse {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
}

/* Backdrop — ref §12 `.ds-sidebar-backdrop`, verbatim including the
 * `!important`. That `!important` is the ONE the reference itself uses
 * (spec §2.24) and it is load-bearing: without it a drawer left open while the
 * viewport is widened past 768px would strand a full-screen scrim over a
 * layout that no longer has a drawer to close. It counters the
 * `.ds-sidebar-backdrop-visible { display: block }` state class in this same
 * file, which the drawer script sets. */
.ds-sidebar-backdrop {
  display: none;
  position: fixed;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.5);   /* verbatim from component-reference.css §12 `.ds-sidebar-backdrop` */
  z-index: var(--z-backdrop);
  transition: opacity 250ms ease;
}

.ds-sidebar-backdrop-visible { display: block; }

@media (min-width: 768px) {
  .ds-sidebar-backdrop { display: none !important; }   /* counters `.ds-sidebar-backdrop-visible { display: block }` above — verbatim from component-reference.css §12 */
}

/* Body scroll lock while a drawer is open (spec §2.24). */
.ds-drawer-open { overflow: hidden; }

/* The client navigation drawer. Below 768px the link row becomes the
 * off-canvas panel; at >= 768px it is the inline row of §2 above.
 * proto §1 (`@container (max-width: 767px)` → `@media`), ref §12. */
@media (max-width: 767px) {
  .ds-navdrawer {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    width: 280px;
    max-width: 85vw;
    display: block;
    margin-left: 0;
    padding: var(--space-4);
    background-color: var(--color-surface-alt);
    border-right: 1px solid var(--color-border);
    box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5);   /* verbatim from prototype.css `.p-sidebar` mobile band */
    transform: translateX(-100%);
    /* a11y focus order (routed from review #65): when closed the drawer sits
     * fully off-screen (translateX(-100%) at left:0) but its 3 links stay in the
     * tab order and the a11y tree — a keyboard/SR user Tabs into three invisible
     * controls at 360/375. `visibility: hidden` removes them from both while
     * closed; the `0s linear 250ms` delay defers the hide until the slide-out
     * finishes, so the close animation is preserved. The resting default here is
     * hidden, which is what fixes the fresh-page-load case. */
    visibility: hidden;
    transition: transform 250ms ease, visibility 0s linear 250ms;
    z-index: var(--z-drawer);
    overflow-y: auto;
  }

  .ds-navdrawer.ds-navdrawer-open {
    transform: translateX(0);
    visibility: visible;
    transition: transform 250ms ease, visibility 0s;   /* show immediately on open */
  }

  .ds-navlinks {
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-1);
    width: 100%;
  }

  /* Tap target (sweep dimension 3): the drawer links compute to 35.5px (space-2
   * vertical padding + a text-sm line box). Flex + a 40px floor brings every
   * link to the mobile tap-target minimum without disturbing the desktop row,
   * which is untouched by this mobile-only block. */
  .ds-navlinks .nav-link {
    display: flex;
    align-items: center;
    min-height: 40px;
  }

  /* The "Sign in" secondary action is a `.btn-sm` (32px). Raise it to the 40px
   * floor inside the drawer only. (0,2,0) beats `.btn-sm` (0,1,0). */
  .ds-navdrawer .btn-sm { height: 40px; }

  /* A dropdown inside the drawer expands inline instead of floating out of
   * the 280px panel. */
  .ds-navdrawer .dropdown-menu {
    position: static;
    float: none;
    width: 100%;
    border: none;
    box-shadow: none;
    background-color: var(--color-surface);
    border-radius: var(--radius-sm);
    padding: var(--space-1) 0;
    margin: 0;
  }

  .ds-navdrawer .dropdown-item { padding-left: var(--space-6); }
}

/* ==========================================================================
 * 4. PAGE HEAD — proto §1/§5/§6, spec §2.18
 * ========================================================================== */

.ds-page-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  flex-wrap: wrap;
  margin-bottom: var(--space-5);
}

.ds-page-title {
  font-size: var(--text-2xl);
  font-weight: var(--font-weight-bold);
  color: var(--color-text);
  line-height: 1.2;
  margin: 0;
  min-width: 0;
  overflow-wrap: anywhere;   /* a ticket subject is user data and can be one unbroken 85-character string (spec §3.6) */
}

/* The title + sub-line pair when the page head carries both. `min-width: 0` so
 * a long ticket subject shrinks inside the flex row instead of pushing the
 * action button off the viewport (TC-6). */
.ds-page-head-main {
  flex: 1 1 auto;
  min-width: 0;
}

.ds-page-sub {
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  margin-top: var(--space-1);
}

/* Type steps down on mobile in exactly two places; the page title is one of
 * them (spec §1.3). proto `.p-page-title` mobile band. */
@media (max-width: 767px) {
  .ds-page-title { font-size: var(--text-xl); }
  .ds-page-head > .btn { width: 100%; }
}

/* ==========================================================================
 * 5. FILTER BAR AND FORM-ACTIONS ROW — proto §2/§5/§6, spec §2.19
 * ========================================================================== */

.ds-filterbar {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-bottom: var(--space-4);
}

.ds-filterbar .ds-filter-search {
  flex: 1;
  min-width: 200px;
}

@media (max-width: 767px) {
  .ds-filterbar { gap: var(--space-2); }
  .ds-filterbar .ds-segmented { width: 100%; overflow-x: auto; }
  .ds-filterbar .ds-filter-search { flex-basis: 100%; }
}

/* The action row. The PRIMARY ACTION IS LAST IN DOM ORDER, so that
 * `column-reverse` on mobile lands it at the bottom of the stack where the
 * thumb is (spec §1.11, proto §6 `.p-form-actions`). */
.ds-form-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-3);
  padding-top: var(--space-2);
  flex-wrap: wrap;
}

@media (max-width: 767px) {
  .ds-form-actions {
    flex-direction: column-reverse;
    align-items: stretch;
    flex-wrap: nowrap;
  }
  /* `white-space: normal` added by issue #72. theme-bootstrap.css §1 gives
   * `.btn` `white-space: nowrap`, which is right for a button that sizes to its
   * label — but a STACKED, full-width action button has a fixed width and a
   * label that may not fit it. Measured on `staff/profile.php` at 375px: the
   * 2FA action reads "Activate Two-Factor Authentication" and its `scrollWidth`
   * was 289px inside a 275px content box, i.e. 14px of the label rendered
   * outside the button. Wrapping is the only overflow decision available here —
   * the width is dictated by the card, and the label is an existing language
   * string that A15 forbids shortening. `min-height` keeps the 40px tap target
   * for the short labels that still fit on one line. */
  .ds-form-actions .btn {
    width: 100%;
    min-height: 40px;
    white-space: normal;
  }
}

/* ==========================================================================
 * 6. STATUS / PRIORITY CHIP — ref §6, proto §2/§5, spec §2.6
 *
 * The chip is the idiom for every HelpDeskZ ticket status, priority, role and
 * flag label. COLOUR IS NEVER THE SOLE CARRIER OF MEANING (§4.1.3 rule 7): a
 * chip always pairs a coloured dot with a text label that states the value.
 *
 * The Bootstrap `.badge-*` hosts are restyled into the same pill in
 * theme-bootstrap.css §6 — the chip classes below are an ADDITION a view may
 * carry alongside them, never a replacement (DOM contract §4.2.3).
 * ========================================================================== */

.ds-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  height: 28px;
  padding: 0 var(--space-3);
  border-radius: var(--radius-full);
  background-color: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  vertical-align: middle;
}

.ds-chip-dot {
  width: 8px;
  height: 8px;
  border-radius: var(--radius-full);
  flex-shrink: 0;
  background-color: var(--color-text-muted);
}

.ds-chip-label {
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  line-height: 1;
  white-space: nowrap;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-text-muted);
}

/* Semantic variants — dot and ink move together. ref §6, verbatim. */
.ds-chip-open    .ds-chip-dot { background-color: var(--color-success); }
.ds-chip-open    .ds-chip-label { color: var(--color-success); }
.ds-chip-pending .ds-chip-dot { background-color: var(--color-warning); }
.ds-chip-pending .ds-chip-label { color: var(--color-warning); }
.ds-chip-closed  .ds-chip-dot { background-color: var(--color-text-muted); }
.ds-chip-closed  .ds-chip-label { color: var(--color-text-muted); }
.ds-chip-urgent  .ds-chip-dot { background-color: var(--color-error); }
.ds-chip-urgent  .ds-chip-label { color: var(--color-error); }
.ds-chip-accent  .ds-chip-dot { background-color: var(--color-primary); }
.ds-chip-accent  .ds-chip-label { color: var(--color-primary); }

/* `.ds-chip-info` — the DOCUMENTED EXTENSION of spec §2.6. The application has
 * FIVE ticket statuses; the reference draws five chip variants of which none is
 * blue, so "Answered" (`.badge-dark`) would otherwise collide with "Closed" on
 * `--color-text-muted` and the two would be indistinguishable. It composes two
 * EXISTING tokens in the established dot+ink pattern — no new value is
 * introduced. Ink measures 11.29:1 on `--color-surface-raised`. */
.ds-chip-info    .ds-chip-dot { background-color: var(--color-info); }
.ds-chip-info    .ds-chip-label { color: var(--color-info-ink); }

/* ==========================================================================
 * 7. TABLE BLOCK AND THE `data-label` COLLAPSE — ref §4, proto §2, spec §2.4
 *
 * THE mobile strategy for every list view in the application: above 768px a
 * table, below it a stack of cards — from IDENTICAL HTML, driven by a
 * `data-label="<column header>"` attribute the PHP loop emits on every `<td>`.
 * That attribute is the ONE new attribute this redesign introduces, and it is
 * an addition, which the DOM contract always permits (§4.2.5).
 * ========================================================================== */

.ds-table-wrap {
  background-color: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  overflow: hidden;
}

.ds-table-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-4);
  border-bottom: 1px solid var(--color-border);
  flex-wrap: wrap;
  gap: var(--space-2);
}

/* In the tablet band the CONTAINER scrolls and the page never does — that is
 * what keeps TC-6 green on a wide staff table at 900px. ref §4. */
.ds-table-scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

@media (max-width: 767px) {
  .ds-table-collapse thead { display: none; }

  .ds-table-collapse tbody tr {
    display: block;
    padding: var(--space-3) var(--space-4);
    border-bottom: 1px solid var(--color-border);
  }

  .ds-table-collapse tbody td {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    padding: var(--space-1) 0;
    border: none;
  }

  .ds-table-collapse tbody td::before {
    content: attr(data-label);
    flex: 0 0 auto;
    font-size: var(--text-xs);
    font-weight: var(--font-weight-bold);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--color-text-muted);
  }

  /* The first cell becomes the card headline: no label, full width. */
  .ds-table-collapse tbody td:first-child {
    display: block;
    margin-bottom: var(--space-1);
  }

  .ds-table-collapse tbody td:first-child::before { display: none; }

  /* On a phone the row identity gets two lines instead of one ellipsised line
   * (spec §3.6): the card is the only place the subject appears, and the
   * horizontal room an ellipsis would need is exactly what a 375px card does
   * not have. */
  .ds-table-collapse .ds-cell-title {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    white-space: normal;
    overflow-wrap: anywhere;
  }

  .ds-pagination { flex-direction: column; align-items: stretch; gap: var(--space-3); }
  .ds-pagination-controls { justify-content: center; }
}

/* The first cell's identity line. Overflow decision (spec §3.6, frontend stack
 * profile "Responsive guardrails"): ONE line + ellipsis on desktop with the
 * full text in `title`, TWO lines on mobile — a ticket subject is the row's
 * only identifier, so it is never truncated to the point of being
 * unidentifiable. `min-width: 0` is what lets the ellipsis engage at all
 * inside a table cell. */
.ds-cell-title {
  font-size: var(--text-base);
  font-weight: var(--font-weight-medium);
  color: var(--color-text);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ds-cell-title > a { color: var(--color-text); }

.ds-cell-title > a:hover,
.ds-cell-title > a:focus { color: var(--color-primary); text-decoration: none; }

/* An empty list still renders its `<tr><td colspan=N>` carrier row (spec §2.4:
 * the controller is not touched), but a column header strip above a centred
 * "nothing here" panel reads as a broken table. The view adds this class to the
 * table when the result set is empty; it is presentational only — the `<thead>`
 * markup, and therefore the table's structure, is unchanged. */
.ds-table-empty thead { display: none; }
.ds-table-empty tbody tr:hover { background-color: transparent; }
.ds-table-empty tbody td { padding: 0; border: none; }

.ds-cell-meta {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  margin-top: 2px;
}

/* The pagination footer strip of a table block. ref §5. */
.ds-pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--color-border);
  flex-wrap: wrap;
  gap: var(--space-2);
}

.ds-pagination-info { font-size: var(--text-sm); color: var(--color-text-muted); }
.ds-pagination-controls { display: flex; align-items: center; gap: var(--space-2); }

/* CodeIgniter's own pager templates use `.pager` with `.current` for the
 * active page instead of Bootstrap's `.page-item.active` — both are DOM
 * contracts (§4.2.3). The framework pager views are restyled in Stage 5
 * (issue #84); this keeps `.current` on the `<li>` reading as the active plate,
 * while the active-page ember on the anchor comes from theme-bootstrap.css §11
 * (`.page-item.current .page-link`) and theme-vendor.css §6. ref §5
 * `.ds-page-btn-active`. */
.pager .current,
.pagination .current {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  min-height: 32px;
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--color-primary);
  border-radius: var(--radius-sm);
  background-color: var(--color-primary);
  color: var(--color-on-primary);
  font-size: var(--text-sm);
}

/* Simple pager (`pager/default_simple.php`): two Newer/Older buttons whose
 * anchors carry NO `.page-link` class (theme-vendor.css §6 gives `.pager li a`
 * the base pill treatment). `.ds-pager-simple` — an added class (always allowed)
 * — lays the pair out and, on mobile, makes them full-width side by side
 * (design-spec-II §2.5 / §3.3 view 6). */
.ds-pager-simple {
  display: flex;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}
.ds-pager-simple > li { display: flex; }
.ds-pager-simple > li > a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 32px;
  padding: var(--space-1) var(--space-4);
  border-radius: var(--radius-sm);
}

/* Disabled Newer/Older — marked by MORE THAN OPACITY (§4.1.3 rule 7): a dashed
 * border + a recessed fill + `not-allowed` + `pointer-events: none`, paired with
 * `aria-disabled="true"` in the markup. (0,2,2) so it outranks theme-vendor.css
 * §6's `.pager li a` (0,1,2) for the fill/ink; the anchor keeps its `#` href but
 * is inert. */
.pager li.disabled > a,
.ds-pager-simple > li.disabled > a {
  border: 1px dashed var(--color-border);
  background: var(--color-surface-inset);
  color: var(--color-text-muted);
  cursor: not-allowed;
  pointer-events: none;
}

@media (max-width: 767px) {
  /* The two buttons go full-width side by side; ≥ 40 px tap targets. */
  .ds-pager-simple > li { flex: 1 1 0; min-width: 0; }
  .ds-pager-simple > li > a { width: 100%; min-height: 40px; }
}

/* ==========================================================================
 * 8. SEGMENTED CONTROL — ref §10, proto §2/§5, spec §2.10
 *    The idiom for HelpDeskZ's ticket status filters and nav-pill rows.
 * ========================================================================== */

.ds-segmented {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-1);
  border-radius: var(--radius-md);
  background-color: var(--color-surface-raised);
}

.ds-segment {
  min-width: 56px;
  padding: var(--space-2) var(--space-4);
  border: none;
  border-radius: var(--radius-sm);
  background-color: transparent;
  color: var(--color-text-muted);
  font-family: var(--font-family);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  letter-spacing: 0.04em;
  white-space: nowrap;
  cursor: pointer;
  transition: background-color 150ms ease, color 150ms ease;
}

.ds-segment:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

.ds-segment-active {
  background-color: var(--color-primary);
  color: var(--color-on-primary);
}

.ds-segment:not(.ds-segment-active):hover { color: var(--color-text); }

/* --- 8b. THE BUTTON-HOSTED SEGMENT — the staff ticket queue's filter row ---
 *
 * spec §3.1 view 4 requires that row to render as this component WHILE KEEPING
 * its `.btn-primary` / `.btn-outline-primary` classes (a DOM contract, §4.2.3).
 * The segment treatment is therefore layered ON TOP of Bootstrap's button
 * variants rather than replacing them, which forces two deliberate deviations
 * from the bare `.ds-segment` above:
 *
 *   1. The radius stays the BUTTON radius (`--radius-md`, 8px), not the
 *      segment's `--radius-sm`. TC-4 (api-contract-II §4.4) pins
 *      `borderRadius === '8px'` on EVERY `.btn-primary`, and the active segment
 *      is one — the assertion contract outranks the component's nominal radius.
 *      Nothing is declared for it: `.btn` already computes 8px. This note
 *      exists so a later agent does not "finish the job" by adding
 *      `--radius-sm` here and silently reddening TC-4.
 *   2. The inactive segments are `.btn-outline-primary`, whose ember border and
 *      ember ink are neutralised INSIDE the track so four buttons read as one
 *      control. Beaten on specificity (0,3,0 and 0,6,0 against
 *      theme-bootstrap.css §1's 0,1,0 and 0,3,0), never with `!important` —
 *      those vendor/theme rules are not `!important` either.
 */

/* The link track never wraps: below 768px it scrolls inside itself
 * (`.ds-filterbar .ds-segmented` above already supplies `overflow-x: auto`),
 * which is what keeps a four-filter row off the page width at 375px (TC-6). */
.ds-segmented-links {
  flex-wrap: nowrap;
  max-width: 100%;
}

.ds-segment-link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  flex: 0 0 auto;          /* never squeeze — the TRACK scrolls instead */
  min-height: 40px;        /* >= 40px tap target in the mobile band */
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  letter-spacing: 0.04em;
  white-space: nowrap;
}

/* Inset ring: the segment sits inside the bordered track, so the standard
 * 2px-outside ring of theme-tokens.css would be clipped by it (spec §1.9). */
.ds-segment-link:focus-visible { outline-offset: -2px; }

/* Inactive segment — quiet ink, no outline, transparent on the track.
 * Overrides theme-bootstrap.css §1 `.btn-secondary, .btn-outline-primary`. */
.ds-segmented .btn-outline-primary.ds-segment-link {
  background-color: transparent;
  border-color: transparent;
  color: var(--color-text-muted);
}

/* Hover/focus of an inactive segment — ink only (spec §2.10). Overrides
 * theme-bootstrap.css §1 `.btn-outline-primary:hover:not(:disabled):not(.disabled),
 * .btn-outline-primary:focus`, which would otherwise repaint the ember wash. */
.ds-segmented .btn-outline-primary.ds-segment-link:hover:not(:disabled):not(.disabled),
.ds-segmented .btn-outline-primary.ds-segment-link:focus {
  background-color: transparent;
  border-color: transparent;
  color: var(--color-text);
  text-decoration: none;
}

/* The count badge — spec §2.10 "Count badges": the `--text-xs`/700 number after
 * the label at a `--space-2` gap, IN THE SEGMENT'S CURRENT INK. It is
 * deliberately unfilled: a pill would need a fill that works on both the raised
 * track and the ember active segment, and every such value would be a new
 * colour literal, which §4.1.3 rule 1 forbids outside theme-tokens.css. */
.ds-segment-count {
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

/* The active filter is marked by MORE THAN COLOUR (§4.1.3 rule 7): the bold
 * weight and the leading check glyph here, plus `aria-current="page"` in the
 * accessibility tree, which the view emits. Removing colour still leaves three
 * carriers. */
.ds-segment-link-active { font-weight: var(--font-weight-bold); }

.ds-segment-mark { font-size: var(--text-xs); }

/* ==========================================================================
 * 9. TICKET-THREAD MESSAGE — proto §3, spec §2.15
 *    Hosts: the existing `.card.mb-3` message blocks, the `#msg_<id>` anchors,
 *    `.user-avatar.rounded-circle`, and `.bg-staff` — the staff-message hook,
 *    which is a DOM contract (§4.2.3) and is KEPT (see §12 below).
 * ========================================================================== */

.ds-msg {
  display: flex;
  gap: var(--space-3);
  background-color: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-4);
}

/* A staff reply carries an ember left rail on a raised surface — but the
 * distinction never rests on colour: the author line always names the role
 * (spec §2.15). proto §3 `.p-msg-staff`. */
.ds-msg-staff {
  border-left: 3px solid var(--color-primary);
  background-color: var(--color-surface-raised);
}

.ds-avatar {
  width: 36px;
  height: 36px;
  border-radius: var(--radius-full);
  background-color: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-sm);
  font-weight: var(--font-weight-bold);
  color: var(--color-primary);
  flex-shrink: 0;
  object-fit: cover;
}

.ds-msg-body { flex: 1; min-width: 0; }

.ds-msg-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-bottom: var(--space-2);
}

.ds-msg-author { font-weight: var(--font-weight-medium); font-size: var(--text-sm); }
.ds-msg-time   { font-size: var(--text-xs); color: var(--color-text-muted); }

/* `overflow-wrap: anywhere` is load-bearing, not cosmetic: a ticket message is
 * arbitrary user content and routinely carries a pasted URL, a stack trace or a
 * token with no break opportunity. Without it that single unbroken string sets
 * the flex item's min-content width and widens the whole page (TC-6).
 * spec §2.15 / §3.6 "Note / message body". */
.ds-msg-text {
  font-size: var(--text-sm);
  color: var(--color-text);
  line-height: 1.6;
  min-width: 0;
  overflow-wrap: anywhere;
}

/* The author line: name -> role chip -> timestamp. The chip is what carries the
 * role as TEXT, so the ember rail on a staff message is never the only signal
 * (spec §2.15). */
.ds-msg-head .badge { flex: 0 0 auto; }

/* An avatar IMAGE has to be pinned in both axes or a non-square upload
 * stretches the circle; `object-fit: cover` on `.ds-avatar` then crops it. The
 * views drop Bootstrap's `.img-fluid` here for the same reason — its
 * `height: auto` would defeat the fixed 36px box. */
img.ds-avatar { width: 36px; height: 36px; }

@media (max-width: 767px) {
  .ds-avatar,
  img.ds-avatar { width: 28px; height: 28px; }
}

/* A row of attachment pills (the knowledgebase article's attachment block).
 * `flex-wrap` plus `min-width: 0` on the name keeps a long filename inside the
 * pill instead of widening the page. */
.ds-attach-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.ds-attach-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ds-attach-size { flex: 0 0 auto; }

a.ds-attach:hover {
  border-color: var(--color-primary);
  color: var(--color-text);
  text-decoration: none;
}

/* Attachment pill — the one place `--color-surface` is used ON a card, per the
 * reference. proto §3 `.p-attach`. */
.ds-attach {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  background-color: var(--color-surface);
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ==========================================================================
 * 10. EMPTY STATE, SKELETON, DANGER ZONE, PLATE
 * ========================================================================== */

/* Empty state — ref §9, spec §2.9. The "no tickets / no articles" surface. */
.ds-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  background-color: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-8) var(--space-6);
  text-align: center;
}

.ds-empty-icon { font-size: 32px; line-height: 1; color: var(--color-text-muted); }
.ds-empty-text { margin: 0; font-size: var(--text-sm); color: var(--color-text-muted); line-height: 1.5; }

/* "The action becomes full-width below 768px" (spec §2.9). The empty state's
 * action is a `.btn-sm`, which computes to a 32px box — under the 40px mobile
 * tap target — so the mobile band restores the height as well as the width.
 * Two classes (0,2,0) to outrank `.btn-sm` (0,1,0) on specificity rather than
 * on load order. Applies to every `.ds-empty` in the application, including the
 * knowledgebase and search-result empties. */
@media (max-width: 767px) {
  .ds-empty { padding: var(--space-6) var(--space-4); }
  .ds-empty .btn { width: 100%; min-height: 40px; }
}

/* Skeleton — ref §14, spec §2.13. NOTE: the application is server-rendered, so
 * no page renders a skeleton on first paint; inventing one would be a
 * behaviour change. It exists for the image-manager grid during upload. */
.ds-skeleton {
  height: 16px;
  border-radius: var(--radius-sm);
  background: linear-gradient(
    90deg,
    var(--color-surface-raised) 25%,
    var(--color-border) 50%,
    var(--color-surface-raised) 75%
  );
  background-size: 200% 100%;
  animation: dsShimmer 1.4s ease-in-out infinite;
}

@keyframes dsShimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

/* Danger-zone card — proto §6, spec §2.3. A `.card` whose border and fill move
 * to the error family, isolating destructive actions. */
.ds-danger-zone {
  border-color: var(--color-error);
  background-color: var(--color-error-bg);
}

.ds-danger-zone .card-title,
.ds-danger-zone .ds-card-title {
  color: var(--color-error);
  font-size: var(--text-lg);
}

/* Deliberately light plates (decision D-4, api-contract-II §4.4.3 item 3).
 * THREE surfaces are light BY DESIGN and must be excluded from TC-3 by a
 * class the theme itself defines, never by a coordinate: the 2FA QR-code
 * plate (a QR on `--color-surface` will not scan), the current-logo plate on
 * settings_general.php, and the image-manager thumbnail plates. The class is
 * defined here so the TC-3 carve-out has a stable, auditable name from Stage 1
 * onward; the owning stages attach it to their markup. */
.ds-plate {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-3);
  background-color: var(--color-on-primary);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
}

/* ==========================================================================
 * 11. CLIENT HERO — the knowledgebase header photograph. proto §1 `.p-hero`,
 *     spec §1.12 / archetype A1. Folds the legacy `.slider-block` /
 *     `.slider-content` block of helpdesk.css, which used
 *     `images/slider.jpg` with no scrim and a hex 1b1e21 ink.
 *
 *     Band-appropriate asset (spec §1.12): `background_mobile.png` below
 *     768px, `background_web.png` at >= 768px — never one desktop asset
 *     scaled down on a phone (requirements-II §7.4).
 * ========================================================================== */

.ds-hero,
.slider-block {
  position: relative;
  padding: var(--space-12) var(--space-6);
  text-align: center;
  background-image: url('../images/background_mobile.png');
  background-size: cover;
  background-position: center;
  border-bottom: 1px solid var(--color-border);
  color: var(--color-text);
}

/* The dark scrim. Without it, muted ink over a mid-tone photograph measures
 * ~3.2:1 and fails AA. ref §11 / proto `.p-hero::after`. */
.ds-hero::after,
.slider-block::after {
  content: '';
  position: absolute;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.62);   /* verbatim from prototype.css `.p-hero::after` */
  pointer-events: none;
}

.ds-hero > *,
.slider-block > * { position: relative; z-index: 1; }

/* RULING D-A2 (spec §1.12): ALL text over a photograph uses `--color-text`,
 * never `--color-text-muted` — the only place a requirement overrides a
 * prototype declaration, because muted over the .62 scrim measures ~3.2:1
 * while `--color-text` measures ~9.9:1. */
.ds-hero h1,
.ds-hero p,
.slider-block h1,
.slider-block p,
.slider-content h1,
.slider-content p { color: var(--color-text); }

.ds-hero h1,
.slider-content h1 {
  font-size: var(--text-3xl);
  font-weight: var(--font-weight-bold);
  margin-bottom: var(--space-3);
}

.slider-content { text-align: center; padding: 0; }

.ds-hero-search {
  display: flex;
  gap: var(--space-2);
  max-width: 560px;
  margin: 0 auto;
}

/* The input takes the free space and the button keeps its intrinsic width.
 * `min-width: 0` is what stops a flex child from refusing to shrink below its
 * content width — the classic source of a 360px overflow (frontend stack
 * profile, "Responsive guardrails"). */
.ds-hero-search .form-control { flex: 1 1 auto; min-width: 0; }
.ds-hero-search .btn { flex: 0 0 auto; }

/* The same search pair repeated above the search results (spec §3.1 view 5).
 * It is not a hero element, so it is left-aligned and takes the column width. */
.ds-search-repeat { margin-bottom: var(--space-5); }
.ds-search-repeat .ds-hero-search { max-width: none; margin: 0; }

/* The left-aligned counterpart of `.ds-form-actions` — a page-level action that
 * does not terminate a form (the error page's "back to knowledgebase" link).
 * Same mobile behaviour: full-width below 768px. */
.ds-page-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-top: var(--space-5);
}

@media (max-width: 767px) {
  .ds-page-actions { flex-direction: column; align-items: stretch; }
  .ds-page-actions .btn { width: 100%; }
}

/* A standalone field label outside a `.form-group` (the department picker's
 * group label). Same treatment as a `.form-group > label` (spec §2.2). */
.ds-field-label {
  display: block;
  margin-bottom: var(--space-2);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  color: var(--color-text);
}

@media (min-width: 768px) {
  .ds-hero,
  .slider-block { background-image: url('../images/background_web.png'); }
}

/* The hero is FULL-BLEED and sits directly under the navbar (archetype A1:
 * navbar -> hero, no band between them). `.ds-page-content` reserves a
 * top gutter for ordinary page content, so the hero — when it is the first
 * thing on the page — cancels it. Using the content band's own token here
 * keeps the two in step if that gutter ever changes. */
.ds-page-content > .slider-block:first-child,
.ds-page-content > .ds-hero:first-child { margin-top: calc(-1 * var(--space-6)); }

@media (max-width: 767px) {
  .ds-page-content > .slider-block:first-child,
  .ds-page-content > .ds-hero:first-child { margin-top: calc(-1 * var(--space-4)); }

  .ds-hero,
  .slider-block { padding: var(--space-8) var(--space-4); }
  .ds-hero h1,
  .slider-content h1 { font-size: var(--text-xl); }
  .ds-hero-search { flex-direction: column; }
  .ds-hero-search .btn { width: 100%; }
}

/* ==========================================================================
 * 12. FOLDED LEGACY RULES — from the DELETED
 *     app/assets/helpdeskz/css/helpdesk.css (115 lines) and
 *     app/assets/helpdeskz/css/staff.css (5 lines).
 *     api-contract-II §4.1.1 "Legacy files": every surviving rule moves to the
 *     file that owns it and no third override layer is kept.
 *
 *     Where each legacy rule went:
 *       html / body sizing, body ink+font, a:hover          → theme-tokens.css
 *       .form-control font                                   → theme-bootstrap.css §2
 *       .navbar, .navbar-light .navbar-nav .nav-link (+hover) → theme-bootstrap.css §8
 *       #footer                                              → §2 above
 *       .slider-block / .slider-content                      → §11 above
 *       everything below                                     → here
 *
 *     `.bg-staff` and `.pointer` were duplicated verbatim in BOTH legacy
 *     files; they are de-duplicated into the single definitions below.
 * ========================================================================== */

/* Client knowledgebase link states (client/home.php, search_result.php,
 * article.php). Legacy: hex 0077FF / hex 6BAFFF — neither is legible on the dark
 * surface. The active/inactive distinction is preserved: the current item is
 * ember, a not-yet-visited sibling is muted. */
.static_link,
.static_link:hover {
  color: var(--color-primary);
}

.inactive_link,
.inactive_link:hover {
  color: var(--color-text-muted);
}

/* Client page headings (client/*.php). Legacy: 42px/48px and 36px/35px at
 * weight 200. Retuned onto the type scale; no raw px font size survives
 * (spec §1.3). */
.heading {
  font-size: var(--text-3xl);
  font-weight: var(--font-weight-bold);
  line-height: 1.1;
  color: var(--color-text);
  padding-top: var(--space-2);
}

.sub_heading {
  font-size: var(--text-2xl);
  font-weight: var(--font-weight-bold);
  line-height: 1.2;
  color: var(--color-text);
  margin-top: var(--space-1);
}

@media (max-width: 767px) {
  .heading     { font-size: var(--text-xl); }
  .sub_heading { font-size: var(--text-lg); }
}

/* Knowledgebase category link + counters (client/home.php, article.php,
 * search_result.php, page_template.php). Legacy: hex 4C4C4C / hex 0077FF hover,
 * hex B3B3B3, hex C7C7C7, hex E5E5E5, hex 999. */
a.kb_category {
  font-weight: var(--font-weight-medium);
  font-size: var(--text-lg);
  color: var(--color-text);
}

a.kb_category:hover { color: var(--color-primary); text-decoration: none; }

.kb_total_articles  { color: var(--color-text-muted); font-size: var(--text-sm); }
.kb_article_icon    { color: var(--color-text-muted); }
.kb_article_icon_lg { color: var(--color-primary); font-size: var(--text-3xl); }
.article_description { color: var(--color-text-muted); font-size: var(--text-sm); }

/* Utility, verbatim from both legacy files. */
.pointer { cursor: pointer; }

/* `.bg-staff` marks a staff reply in the ticket thread and is a DOM CONTRACT
 * (§4.2.3) — restyled, never renamed. Legacy: hex FFF5E5, a cream plate that
 * TC-3 would flag. It now carries the staff-message treatment of §9: the
 * raised surface plus the 3px ember left rail. The role is still named in the
 * author line, so the distinction is never colour alone. */
.bg-staff {
  background-color: var(--color-surface-raised);
  border-left: 3px solid var(--color-primary);
}

/* ==========================================================================
 * 13. AUTH CARD (archetype A4) — ref §13, proto §4, spec §2.12 / §3.0 "A4".
 *
 * The glassmorphic card over the brand photograph. Five surfaces use it:
 * client/login.php, client/forgot.php, client/maintenance.php (panel variant,
 * no form) and the two STANDALONE staff documents staff/login.php and
 * staff/login_two_factor.php.
 * ========================================================================== */

/* Wrap — verbatim from ref §13 `.ds-auth-wrap`, plus the `.55` scrim that the
 * reference paints with a separate `.ds-screen-overlay` element (ref §11). The
 * scrim is an `::after` here so no extra markup element is needed. */
.ds-auth-wrap {
  position: relative;
  min-height: 100vh;
  min-height: 100dvh;   /* the mobile URL bar may not clip the card (spec §2.12) */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
  background-image: url('../images/background_mobile.png');
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}

.ds-auth-wrap::after {
  content: '';
  position: absolute;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.55);   /* verbatim from component-reference.css §11 `.ds-screen-overlay` */
  pointer-events: none;
}

@media (min-width: 768px) {
  .ds-auth-wrap { background-image: url('../images/background_web.png'); }
}

/* Shell-embedded variant — client/login.php and client/forgot.php render
 * INSIDE client/template.php, which owns the navbar and the footer. A wrap
 * claiming `min-height: 100dvh` there would push the footer a full screen
 * below the fold. The photograph is therefore painted by a FIXED pseudo
 * element at `z-index: -1`: it covers the real viewport (behind the navbar and
 * the footer too, which is what "full-viewport background" means), while the
 * wrap itself only centres the card inside the shell's content band. A
 * negative z-index paints ABOVE the canvas background but BELOW every in-flow
 * element, so the navbar and the footer keep their own chrome on top of it. */
.ds-auth-wrap-inline {
  min-height: 0;
  background-image: none;
  padding: var(--space-10) var(--space-4);
}

.ds-auth-wrap-inline::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  background-image: url('../images/background_mobile.png');
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}

.ds-auth-wrap-inline::after {
  position: fixed;
  z-index: -1;
}

@media (min-width: 768px) {
  .ds-auth-wrap-inline { background-image: none; }
  .ds-auth-wrap-inline::before { background-image: url('../images/background_web.png'); }
}

@media (max-width: 767px) {
  .ds-auth-wrap-inline { padding: var(--space-6) var(--space-4); }
}

/* Card — verbatim from ref §13 `.ds-auth-card`. The 92 %-opaque fill alone
 * satisfies contrast, so no `backdrop-filter` fallback rule is needed
 * (spec §2.12).
 *
 * `position: relative` + `z-index: 1` is LOAD-BEARING, not decoration: the
 * wrap's scrim is an `::after`, i.e. the wrap's LAST child. Two positioned
 * siblings at `z-index: auto` paint in DOM order, so without an explicit level
 * the scrim paints OVER the card and the whole card renders dimmed — which is
 * exactly what happened on the three standalone documents before this line
 * existed. (The `-inline` variant escapes it only because its scrim is pinned
 * to `z-index: -1`.) */
.ds-auth-card {
  position: relative;
  z-index: 1;
  background: rgba(26, 29, 38, 0.92);   /* verbatim from component-reference.css §13 — 92% opaque --color-surface-alt */
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-modal);
  max-width: 440px;
  width: calc(100% - 32px);
  padding: var(--space-10);
  animation: dsCardEntry 200ms ease forwards;
}

@keyframes dsCardEntry {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Below 768px the card spans the viewport minus a 16px gutter per side and its
 * padding steps down, so its content still fits at 360px (spec §2.12). */
@media (max-width: 767px) {
  .ds-auth-card { padding: var(--space-6); }
}

/* The maintenance panel carries a prose message rather than a form, so it gets
 * a wider measure. It is the same card otherwise. */
.ds-auth-card-wide { max-width: 560px; }

/* The maintenance panel's copyright / powered-by line — a two-part row that
 * stacks on a phone. */
.ds-panel-footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--space-2);
  padding-top: var(--space-4);
  border-top: 1px solid var(--color-border);
}

/* Header — brand lockup, title, sub-line; centred. ref §13 `.ds-auth-header`. */
.ds-auth-header {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-4);
  margin-bottom: var(--space-8);
  text-align: center;
}

.ds-auth-title {
  margin: 0;
  font-size: var(--text-xl);
  font-weight: var(--font-weight-bold);
  color: var(--color-text);
  line-height: 1.3;
}

.ds-auth-sub {
  margin: 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  line-height: 1.5;
}

/* Form — ref §13 `.ds-auth-form`. The gap replaces the per-field bottom margin
 * so `.form-group`'s own margin is neutralised inside the card. */
.ds-auth-form {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
}

.ds-auth-form .form-group { margin-bottom: 0; }

/* Footer link line — spec §2.12. */
.ds-auth-footer {
  margin: var(--space-6) 0 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  text-align: center;
  line-height: 1.5;
}

.ds-link,
.ds-auth-footer a {
  color: var(--color-primary);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.ds-link:hover,
.ds-auth-footer a:hover { color: var(--color-primary-hover); }

/* The alert inside the card sits above the form (spec §2.12 "Error/success"). */
.ds-auth-card > .alert { margin-bottom: var(--space-5); }

/* The 2FA code field: rendered wide with a generous letter-spacing so six
 * digits read as six digits (spec §3.2 view 3). `--text-lg` keeps the control
 * above 40px in every band and the letter-spacing is small enough that six
 * characters plus the caret still fit inside a 360px card. */
.ds-code-input {
  letter-spacing: 0.3em;
  text-align: center;
  font-size: var(--text-lg);
  font-variant-numeric: tabular-nums;
}

/* The tracking above exists to space the six TYPED digits, and it does fit them: at
 * `--text-lg` six digits measure 95px against a 212px content box at 360px. The
 * PLACEHOLDER is a different string — the 17-character `Admin.twoFactor.verificationCode`
 * ("Verification Code"), already duplicated by the field's visible <label> — and at 0.3em it
 * measures 239px, so it overflowed by 27px at 360px and 12px at 375px and was clipped
 * mid-word. Tracking is reset for the placeholder only: the value keeps the code treatment,
 * and no markup, `name`, id or language key changes. */
.ds-code-input::placeholder {
  letter-spacing: normal;
}

/* The reCAPTCHA widget renders in a Google-owned iframe with a FIXED 304px
 * intrinsic width — wider than the auth card's content box at 360px. It is
 * therefore given its own horizontal scroll container so the widget, not the
 * page, is what scrolls (TC-6). The widget's dark theme is set where the markup
 * is produced (App\Libraries\reCAPTCHA::display(), `data-theme="dark"`), and
 * the iframe is a NAMED TC-3 carve-out because its document cannot inherit any
 * of this (contract §4.4.3 item 2). */
.ds-recaptcha { max-width: 100%; overflow-x: auto; }

.ds-recaptcha:empty { display: none; }

/* ==========================================================================
 * 14. TILE AND TILE GRID — proto §1 `.p-grid` / `.p-tile`, spec §2.20.
 *
 * Used by the knowledgebase category grid (client/home.php) and by the
 * department picker's radio-tiles (client/ticket_departments.php).
 * ========================================================================== */

.ds-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--space-4);
}

.ds-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }

/* proto: `@container (max-width: 1023px)` → `@media` (§4.1.3 rule 4). 1023/1024
 * is the composition breakpoint inside the tablet band (spec §1.11). */
@media (max-width: 1023px) {
  .ds-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (max-width: 767px) {
  .ds-grid,
  .ds-grid-2 { grid-template-columns: minmax(0, 1fr); gap: var(--space-3); }
}

.ds-tile {
  position: relative;
  min-width: 0;
  background-color: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-5);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  transition: border-color 150ms ease, transform 150ms ease;
}

.ds-tile:hover { border-color: var(--color-primary); transform: translateY(-2px); }

.ds-tile-icon  { font-size: 22px; line-height: 1; color: var(--color-primary); }
.ds-tile-text  { font-size: var(--text-sm); color: var(--color-text-muted); line-height: 1.5; }

/* The title truncates to two lines — a category name is user data and may be
 * long in a verbose locale (spec §2.20 "Overflow"). */
.ds-tile-title {
  font-size: var(--text-lg);
  font-weight: var(--font-weight-medium);
  color: var(--color-text);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
}

a.ds-tile-title:hover { color: var(--color-primary); text-decoration: none; }

/* A list of article links inside a tile (the first N articles of a category). */
.ds-tile-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  min-width: 0;
}

.ds-tile-list li { min-width: 0; font-size: var(--text-sm); line-height: 1.5; }

/* Radio-tile (spec §2.2) — the real `<input type=radio>` stays inside the tile
 * and keeps its id / name / value; the tile shows the checked state with an
 * ember border PLUS the radio dot, never colour alone (§4.1.3 rule 7). The
 * label is stretched over the whole tile so the entire tile is one >= 40px tap
 * target, which is the mobile requirement for this view. */
.ds-tile-radio { padding: 0; }

.ds-tile-radio .custom-control {
  min-height: 40px;
  padding: var(--space-4) var(--space-4) var(--space-4) var(--space-8);
  margin: 0;
}

/* The label is a block so the whole tile row is one tap target, and it keeps
 * the department name as its ONLY text — the E2E suite matches it with
 * `getByText(name, { exact: true })`, so an icon or a description inside the
 * label would break the assertion (and would be added content, which R1
 * forbids anyway). */
.ds-tile-radio .custom-control-label {
  display: block;
  min-width: 0;
  font-size: var(--text-base);
  overflow-wrap: anywhere;
  cursor: pointer;
}

.ds-tile-radio:focus-within { border-color: var(--color-primary); }

/* Checked / hover state — the ember border is the tile-level carrier; the radio
 * dot itself is drawn by theme-bootstrap.css §2 and stays visible, so the state
 * is never signalled by border colour alone (§4.1.3 rule 7).
 * The `translateY` lift of the plain tile is deliberately NOT inherited here:
 * this tile is a form control that gets clicked, and a transform that starts on
 * hover makes the click target move under the pointer. */
.ds-tile-radio:hover,
.ds-tile-radio:focus-within { border-color: var(--color-primary); }

.ds-tile-radio:hover { transform: none; }

/* ==========================================================================
 * 14b. ARTICLE LIST ROW — spec §3.1 views 3 and 5, proto §1.
 *
 * The knowledgebase article list and the search-results list. The markup is a
 * Bootstrap `.list-group` (a DOM contract, §4.2.3) whose rows used a float
 * pair; floats have no shrink behaviour, so a long excerpt escaped the cell at
 * 360px. A flex row with `min-width: 0` on the body is the correct primitive.
 * ========================================================================== */

.ds-list { gap: 0; }

.ds-list-row {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
}

.ds-list-icon { flex: 0 0 auto; line-height: 1.2; }

.ds-list-body {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

/* The title is the row's only identifier, so it wraps rather than truncating to
 * meaninglessness (frontend stack profile, "Responsive guardrails"). */
.ds-list-title {
  color: var(--color-text);
  overflow-wrap: anywhere;
}

.ds-list-title:hover { color: var(--color-primary); text-decoration: none; }

/* The excerpt is already server-truncated to `kb_maxchar`; it clamps to three
 * lines so a long one cannot dominate the list. */
.ds-list-text {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  line-height: 1.5;
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* A muted meta line under a page title (the article's "Posted on <date>"). */
.ds-meta-line {
  margin: calc(-1 * var(--space-3)) 0 var(--space-5);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* ==========================================================================
 * 15. BREADCRUMB STRIP — spec §2.21.
 *
 * The knowledgebase pages do not use Bootstrap's `.breadcrumb` (that one is
 * themed in theme-bootstrap.css §12); they emit bare `.inactive_link` /
 * `.static_link` anchors with ` / ` separators. Those two classes are folded
 * legacy hooks (§12 below) and keep their names; this rule only gives the
 * strip its own line box and its mobile scroll container.
 * ========================================================================== */

.ds-breadcrumb {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-1);
  margin-bottom: var(--space-4);
  font-size: var(--text-sm);
  line-height: 1.5;
}

/* Mobile: the strip scrolls sideways inside its OWN container rather than
 * wrapping into three lines — the page itself never scrolls (TC-6). */
@media (max-width: 767px) {
  .ds-breadcrumb {
    flex-wrap: nowrap;
    overflow-x: auto;
    white-space: nowrap;
    -webkit-overflow-scrolling: touch;
  }
}

/* ==========================================================================
 * 16. KNOWLEDGEBASE TWO-COLUMN LAYOUT — spec §3.1 view 2.
 *
 * `client/page_template.php`'s content column + right sidebar. Below 768px the
 * sidebar becomes a full-width block BELOW the content — never a squeezed
 * second column, which is what the Bootstrap `.col`/`.col-lg-8` pair produced
 * on a phone.
 * ========================================================================== */

.ds-kb-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 300px;
  gap: var(--space-5);
  align-items: start;
}

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

.ds-kb-main { min-width: 0; }

.ds-kb-side {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

/* Bootstrap puts a card's padding on `.card-body`, not on `.card`
 * (theme-bootstrap.css §3). The sidebar blocks are cards WITHOUT a body
 * wrapper — the markup they replace had none either — so they carry their own
 * padding here. Without this the search field and the list titles sit flush
 * against the card border. */
.ds-kb-side > .card { padding: var(--space-5); }

/* A sidebar block renders as a card (spec §3.1 view 2: the `<h4>` blocks become
 * `--text-lg` card titles inside `.card`s). `.card` itself is themed in
 * theme-bootstrap.css §3; only the title size is set here. */
.ds-side-title {
  margin: 0 0 var(--space-3);
  font-size: var(--text-lg);
  font-weight: var(--font-weight-medium);
  color: var(--color-text);
}

.ds-side-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  min-width: 0;
}

/* Each item is an icon + title ROW. The title is a `-webkit-box` (that is what
 * makes the two-line clamp work), which is block-level, so without the flex row
 * the leading icon would be pushed onto a line of its own. */
.ds-side-list li,
.ds-tile-list li {
  display: flex;
  align-items: flex-start;
  gap: var(--space-1);
  min-width: 0;
  font-size: var(--text-sm);
  line-height: 1.5;
}

.ds-side-list li > .fa,
.ds-tile-list li > .fa { flex: 0 0 auto; line-height: 1.5; }

/* Sidebar article titles clamp to two lines (spec §3.1 view 2 "Overflow risk").
 * They read as TEXT with an ember hover, not as ember body copy: a sidebar of
 * eight fully-ember links is noise, and the theme's global link colour
 * (theme-bootstrap.css §0) would otherwise apply. */
.ds-side-link {
  flex: 1 1 auto;
  min-width: 0;
  color: var(--color-text);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
}

.ds-side-link:hover { color: var(--color-primary); text-decoration: none; }

/* ==========================================================================
 * 17. PROSE — the stored-HTML surface. spec §2.23, contract §4.4.3 item 1.
 *
 * `.ds-prose` is the ONE container where arbitrary stored HTML meets the
 * theme: knowledgebase article bodies (client/article.php) and the maintenance
 * message. It is also THE CLASS THE TC-3 CARVE-OUT REFERENCES — elements
 * inside it are excluded from the residual-light-surface detector by name,
 * because legacy content may carry inline styles authored under the old light
 * theme (decision D-4: a carve-out keyed on a class the theme itself defines
 * is auditable; one keyed on a coordinate is not).
 *
 * Escaping is UNCHANGED (A16 / SEC-N1): this adds no new rendering path for
 * stored HTML, only presentation.
 * ========================================================================== */

.ds-prose {
  color: var(--color-text);
  font-size: var(--text-base);
  line-height: 1.6;
  min-width: 0;
  overflow-wrap: anywhere;   /* long unbroken URLs in article bodies (spec §3.1 view 4) */
}

.ds-prose h1, .ds-prose h2, .ds-prose h3,
.ds-prose h4, .ds-prose h5, .ds-prose h6 {
  color: var(--color-text);
  font-weight: var(--font-weight-bold);
  margin-top: var(--space-6);
  margin-bottom: var(--space-2);
  line-height: 1.3;
}

.ds-prose h1 { font-size: var(--text-2xl); }
.ds-prose h2 { font-size: var(--text-xl); }
.ds-prose h3 { font-size: var(--text-lg); }
.ds-prose h4,
.ds-prose h5,
.ds-prose h6 { font-size: var(--text-base); }

.ds-prose p,
.ds-prose li { font-size: var(--text-base); line-height: 1.6; color: var(--color-text); }

.ds-prose p { margin-bottom: var(--space-4); }

.ds-prose a {
  color: var(--color-primary);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.ds-prose ul,
.ds-prose ol { padding-left: var(--space-5); margin-bottom: var(--space-4); }

.ds-prose blockquote {
  border-left: 3px solid var(--color-border);
  padding-left: var(--space-4);
  margin-bottom: var(--space-4);
  color: var(--color-text-muted);
}

.ds-prose code,
.ds-prose pre {
  background-color: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--text-sm);
  color: var(--color-text);
}

.ds-prose code { padding: 2px var(--space-1); }

.ds-prose pre {
  padding: var(--space-3) var(--space-4);
  overflow-x: auto;   /* the block scrolls inside its own container; the page never does */
  margin-bottom: var(--space-4);
}

.ds-prose pre code { background: none; border: 0; padding: 0; }

.ds-prose hr { border: 0; border-top: 1px solid var(--color-border); margin: var(--space-6) 0; }

.ds-prose img { max-width: 100%; height: auto; }

.ds-prose table {
  width: 100%;
  max-width: 100%;
  border-collapse: collapse;
  margin-bottom: var(--space-4);
  color: var(--color-text);
}

.ds-prose th,
.ds-prose td {
  border: 1px solid var(--color-border);
  padding: var(--space-2) var(--space-3);
  text-align: left;
}

.ds-prose th { background-color: var(--color-surface-alt); font-weight: var(--font-weight-medium); }

/* A wide stored table scrolls inside its own wrapper (spec §2.23). The wrapper
 * is emitted by the view around the whole prose block, so a table authored
 * with a fixed pixel width cannot widen the page (TC-6). */
.ds-prose-scroll { overflow-x: auto; max-width: 100%; }

/* LEGACY-CONTENT DEFENCE (Q2, adjudicated — spec §2.23).
 * Articles written under the old LIGHT theme carry inline styles: a white or
 * near-white `background` on a block element, and black/dark ink that would be
 * invisible on the dark surface. These attribute selectors neutralise the
 * common cases without touching the stored HTML itself (cleanup is a recorded
 * follow-up, not this iteration's work). They are attribute matches on the
 * `style` attribute, so they only fire on content that actually carries one. */
.ds-prose [style*="background"],
.ds-prose [style*="BACKGROUND"] { background-color: transparent !important; }   /* counters an inline `background: hex fff` authored in the stored article HTML; an inline declaration cannot be beaten by any author rule except !important — the same precedent spec §2.8 sets for sweetalert2's inline cancelButtonColor */

.ds-prose [style*="color"],
.ds-prose [style*="COLOR"] { color: inherit !important; }   /* counters an inline `color: hex 000` in the stored article HTML, which is invisible on the dark surface; same inline-declaration reason as above */

.ds-prose [width],
.ds-prose [style*="width"] { max-width: 100%; }

/* Attachment pills on the article page reuse the thread's `.ds-attach`
 * treatment (§9). These two legacy hooks keep their class names. */
.knowledgebasearticleattachment {
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  color: var(--color-text);
  margin-bottom: var(--space-2);
}

.knowledgebaseattachmenticon { color: var(--color-text-muted); }

/* ==========================================================================
 * 18. TICKET-THREAD LAYOUT AND META PANEL — proto §3, spec §2.16 / §3.0 "A3"
 *
 * The A3 two-column split. The meta card is the LAST child in DOM order, so a
 * single-column grid lands it BELOW the thread with no reordering — the
 * reading order and the tab order stay identical in both bands.
 * ========================================================================== */

.ds-thread-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 280px;
  gap: var(--space-5);
  align-items: start;
}

/* `minmax(0, 1fr)` above plus `min-width: 0` here is the pair that stops a long
 * unbroken message body from pushing the grid wider than the viewport: a grid
 * item's default `min-width: auto` is its MIN-CONTENT width, which for a pasted
 * URL is the whole string (contract §5.3, TC-6). */
.ds-thread-main { min-width: 0; }
.ds-thread-side { min-width: 0; }

/* proto `@container (max-width: 1023px)` -> a plain media query in the
 * implementation (standing brief). Below 1024px the meta card is a full-width
 * block under the thread, never a squeezed second column. */
@media (max-width: 1023px) {
  .ds-thread-layout { grid-template-columns: minmax(0, 1fr); }
}

/* A card title outside Bootstrap's `.card-title` (which is a DOM contract
 * elsewhere and already themed in theme-bootstrap.css §5). ref proto §3. */
.ds-card-title {
  font-size: var(--text-lg);
  font-weight: var(--font-weight-bold);
  color: var(--color-text);
  margin: 0 0 var(--space-4);
}

.ds-meta-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

/* The meta KEY treatment. On `client/ticket_view.php` the three meta values are
 * the existing READ-ONLY INPUTS (department / status / priority), which the DOM
 * contract pins as `input[readonly][value="…"]` — ten Playwright assertions
 * resolve through that selector (§4.2.4). The panel therefore keeps the real
 * `<label>` + `<input readonly>` pairs and styles the LABEL like the drawn
 * meta key, instead of collapsing them into the reference's key/value spans.
 * spec §2.16 + §2.22 ("`readonly`/`disabled` preserved"). */
.ds-meta-row { min-width: 0; }

.ds-meta-row > label,
.ds-meta-key {
  display: block;
  margin-bottom: var(--space-2);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-muted);
}

.ds-meta-val { font-size: var(--text-sm); color: var(--color-text); }

/* ==========================================================================
 * 19. REPLY CARD AND ATTACHMENT-PILL WRAPPER — proto §3, spec §2.15 / §2.17
 * ========================================================================== */

/* The reply form as a themed card. NOTE — `display` is deliberately NOT set:
 * `#replyForm` is shown by an inline `$('#replyForm').show()` handler that is a
 * behaviour contract (§4.2.5). jQuery's `.show()` clears the inline
 * `display:none` and lets the stylesheet's value take over, so declaring a
 * `display` here would silently change what the toggle restores. */
.ds-reply-card {
  background-color: var(--color-surface-alt);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-4);
}

.ds-reply-card > .form-group:last-child { margin-bottom: 0; }

/* The thread's attachment block keeps its `.alert-info` class (a DOM contract,
 * §4.2.3) but is a PILL ROW, not an alert. Two classes (0,2,0) so it outranks
 * `.alert` and `.alert-info` (0,1,0 each) on their own terms rather than
 * relying on load order alone — the trap L-20260721-05 records. */
.alert.ds-attach-wrap {
  display: block;
  padding: 0;
  margin-top: var(--space-3);
  margin-bottom: 0;
  background-color: transparent;
  border: none;
  color: var(--color-text-muted);
}

.ds-attach-wrap .ds-attach-label {
  display: block;
  margin-bottom: var(--space-1);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-muted);
}

/* Inside a pill row the pills sit side by side; `.ds-attach`'s own
 * `margin-top` is the standalone case, so it is cancelled here. */
.ds-attach-wrap .ds-attach { margin-top: 0; }

/* ==========================================================================
 * 20. FORM GRID (archetype A6) — proto §6 `.p-form-grid`, spec §3.0 "A6"
 *
 * Two columns at >= 768px, one below it. `minmax(0, 1fr)` (never `1fr`) so a
 * long select option or an unbroken custom-field label cannot widen the column
 * past the viewport (contract §5.3).
 * ========================================================================== */

.ds-form-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 var(--space-4);
}

.ds-form-full { grid-column: 1 / -1; }

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

  /* Checkbox and radio options inside an A6 form reach the 40px mobile tap
   * target. Bootstrap's `.custom-control-label` is a ~20px text line whose
   * marker is pinned at `top: .25rem`, so growing the box also has to re-centre
   * the marker or it drifts to the top edge.
   *
   * Scoped to `.ds-form-grid` on purpose: `.custom-control` is a shared DOM
   * contract (§4.2.3) and the department picker already meets the target
   * through `.ds-tile-radio .custom-control` (§14). An unscoped rule here would
   * collide with that at equal specificity and win by load order, silently
   * re-laying-out a surface this task does not own. `.custom-switch` is
   * excluded for the same reason — its `::after` knob carries its own
   * `transform`. */
  .ds-form-grid .custom-checkbox .custom-control-label,
  .ds-form-grid .custom-radio .custom-control-label {
    display: flex;
    align-items: center;
    min-height: 40px;
  }

  .ds-form-grid .custom-checkbox .custom-control-label::before,
  .ds-form-grid .custom-checkbox .custom-control-label::after,
  .ds-form-grid .custom-radio .custom-control-label::before,
  .ds-form-grid .custom-radio .custom-control-label::after {
    top: 50%;
    transform: translateY(-50%);
  }
}

/* ==========================================================================
 * 21. TAB STRIP SCROLL (rule R2) — spec §2.10 "Responsive"
 *
 * Below 768px the tab strip scrolls INSIDE ITS OWN CONTAINER; it never wraps
 * and never widens the page (TC-6). The wrapper is an added element around the
 * existing `ul.nav-tabs`, so every tab id, `data-toggle="tab"` binding and
 * `aria-*` attribute is untouched.
 * ========================================================================== */

.ds-tabs-scroll {
  max-width: 100%;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.ds-tabs-scroll > .nav-tabs {
  flex-wrap: nowrap;
  min-width: max-content;
}

/* >= 40px tap target in the mobile band (spec §2.10, requirements A11y). */
@media (max-width: 767px) {
  .ds-tabs-scroll > .nav-tabs > .nav-item > .nav-link {
    display: flex;
    align-items: center;
    min-height: 40px;
    white-space: nowrap;
  }
}

/* ==========================================================================
 * 22. RESULT PANEL — spec §2.22, proto §6
 *
 * The submission-confirmation surface: a success alert, the record title, and
 * the record's values as read-only rows.
 *
 * ⚠ The value rows STAY A REAL `<table>` at every viewport. The committed
 * suite asserts `getByRole('cell', { name: 'Sales', exact: true })` in BOTH
 * viewport projects, so the `data-label` card collapse of §7 — correct
 * everywhere else — would break that assertion at 375px. The table is short and
 * two-column, so it needs no collapse: it is sized instead.
 *
 * WHY it would break, precisely, because §7 is copied by Stages 3–5:
 * NOT because `display` changes the role. Playwright derives implicit roles
 * from the DOM per ARIA-in-HTML and never reads Chrome's CSS-affected a11y
 * tree, so a `<td>` stays `cell` at any `display` (measured on the shipped
 * ticket list, cells at `display: flex`: `getByRole('cell')` = 24 at 375px AND
 * at 1280px). The breakage comes from `td::before { content: attr(data-label) }`
 * below — generated content joins the accessible NAME, turning "General" into
 * "Department General" (measured: exact-name match 3 → 0 at 375px, and 0 → 3
 * for the combined string). Since that `::before` is inside
 * `@media (max-width: 767px)`, the failure is mobile-project-only.
 *
 * So the check before collapsing ANY table is: grep the spec tree for
 * `getByRole('cell'|'gridcell'|'row', { name: …, exact: true })` on that view;
 * re-point such assertions at the combined name or loosen them.
 * (Corrected during review of #68; lesson L-20260722-11 supersedes -08.)
 * ========================================================================== */

.ds-result-panel { max-width: 640px; }

/* The panel title carries the ticket SUBJECT — user data, realistically 85
 * characters (spec §3.6). It is a block-level heading precisely so it can wrap:
 * inside a table cell the same string cannot, because Chrome's automatic table
 * layout does not let `overflow-wrap` reduce a cell's min-content contribution
 * (measured: 630px of table inside a 343px card at 375px). See the note in
 * client/ticket_confirmation.php. */
.ds-result-title {
  font-size: var(--text-lg);
  font-weight: var(--font-weight-bold);
  color: var(--color-text);
  overflow-wrap: anywhere;
  margin-bottom: var(--space-4);
}

.ds-result-table { width: 100%; }

/* The e-mail address is the one value that must be readable IN FULL — it is
 * what the confirmation is for — so it wraps rather than truncating
 * (spec §3.6 "E-mail address"). */
.ds-result-table td { overflow-wrap: anywhere; }

/* The label column: as wide as its content needs and no wider, so the value
 * column keeps the rest. `width: 1%` + `nowrap` is the classic table idiom for
 * "shrink to fit" and replaces the legacy hard-coded `width="140"`. */
.ds-result-table th[scope="row"],
.ds-result-table .ds-result-key {
  width: 1%;
  white-space: nowrap;
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-muted);
  vertical-align: middle;
}

/* The ticket reference is the panel's primary datum. */
.ds-result-ref {
  font-size: var(--text-lg);
  font-weight: var(--font-weight-bold);
  color: var(--color-primary);
}

@media (max-width: 767px) {
  .ds-result-table th[scope="row"],
  .ds-result-table .ds-result-key,
  .ds-result-table td { padding: var(--space-2) var(--space-2); }
}

/* ==========================================================================
 * 23. STAFF TICKET QUEUE — spec §3.1 view 4, proto §5, issue #69.
 *
 * The staff queue is A2 (§7) plus three things no other list view has yet: a
 * SEARCH CARD that collapses to a filter panel on a phone, a SELECTION COLUMN
 * that has to survive the card collapse, and a MASS-ACTION PANEL that becomes a
 * sticky bar. Everything here composes §7 rather than forking it — the queue's
 * table is a plain `.ds-table-collapse` and only the select column is
 * re-pointed.
 *
 * This section is the reference for `staff/users.php`, `staff/agents.php` and
 * every other Shards list screen in Stages 4-5.
 * ========================================================================== */

/* --- 23.1 The search card as a collapsible filter panel ------------------ */

.ds-filter-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
}

/* The toggle exists only below 768px (`.d-md-none` in the markup). Above it the
 * panel is permanently open because the body carries `.d-md-block`, whose
 * `!important` — every Bootstrap display utility is `!important` — is what
 * beats `.collapse:not(.show) { display: none }`. That is Bootstrap's own
 * documented responsive-collapse idiom, not an escalation, and it is why no
 * `!important` is authored here. */
.ds-filter-toggle {
  flex: 0 0 auto;
  min-width: 40px;
  min-height: 40px;   /* >= 40px tap target — the toggle only renders on a phone */
}

/* --- 23.2 The queue table ------------------------------------------------ */

/* A sortable header is a click target, so it gets the affordance the shared
 * `.pointer` rule (§12) only hints at. The glyph itself comes from the
 * `sort_link()` helper (`fa-sort` / `fa-caret-up` / `fa-caret-down`), so the
 * sort state is carried by a SHAPE, never by colour alone; `aria-sort` on the
 * `<th>` carries it into the accessibility tree. */
.ds-th-sort .pointer { display: inline-flex; align-items: center; gap: var(--space-2); }
.ds-th-sort .pointer:hover { color: var(--color-text); }
.ds-th-sort .pointer .fa { font-size: var(--text-xs); opacity: 0.75; }

/* The selection column is as narrow as its 40px target allows. `width` alone is
 * only a hint to Chrome's automatic table layout — a cell can never be narrower
 * than its content plus its padding — so the horizontal padding is trimmed too,
 * which is what actually buys the width back. Measured: 74px -> 56px, and that
 * is 18px of the 128px the seven columns were over the 1280px content column. */
.ds-queue-table th.ds-col-select { width: 56px; }

/* Seven columns of chips and absolute dates are the densest table in the
 * application, and at 1280px they measured 1021px against a 990px content
 * column. The queue therefore runs on the design's 12px gutter instead of the
 * shared 16px one, which buys back 7 x 8px and lets it fit without
 * `table-layout: fixed` (which would need explicit widths for every later
 * staff list too). Equal specificity to theme-bootstrap.css §4
 * `.table thead th` (0,1,2); this file loads later, so it wins by order.
 * Scoped to >= 768px because below it the §7 collapse owns the padding. */
@media (min-width: 768px) {
  .ds-queue-table thead th,
  .ds-queue-table tbody td {
    padding-left: var(--space-3);
    padding-right: var(--space-3);
  }

  .ds-queue-table th.ds-col-select,
  .ds-queue-table tbody td:first-child {
    padding-left: var(--space-3);
    padding-right: var(--space-2);
  }
}

/* Two chips can share a cell — the status chip and the "Overdue" chip — and on
 * a narrow column they must WRAP rather than widen the table. The row is an
 * inner element on purpose: `display: flex` on the `<td>` itself would pull the
 * cell out of the table formatting context and break column alignment. */
.ds-chip-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}

/* The 40px selection target (spec §3.1 view 4 "Mobile"). A `<label>` wrapper is
 * what makes the whole 40x40 box clickable while the input stays 18px — growing
 * the input itself would render a giant native checkbox. */
.ds-select-target {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  margin: 0;
  cursor: pointer;
}

/* Overrides the `.form-check` class the markup carries ON THE INPUT (a
 * pre-existing misuse of a Bootstrap *container* class, kept because it is part
 * of the DOM contract): `.form-check` would make the box a `display: block`
 * with a 1.25rem left pad and knock it off-centre. 0,1,1 beats 0,1,0. */
.ds-select-target input[type="checkbox"] {
  display: inline-block;
  width: 18px;
  height: 18px;
  margin: 0;
  padding: 0;
  cursor: pointer;
  accent-color: var(--color-primary);
  color-scheme: dark;
}

/* `color-scheme: dark` is what makes the UNCHECKED native box render dark;
 * `accent-color` only colours the CHECKED fill, so on its own it leaves a white
 * plate on the dark surface. The two are applied together wherever this view
 * owns a native checkbox — the row selectors above and the filter panel's
 * "overdue only" box. Deliberately NOT applied globally: no theme file styles
 * `.form-check-input` yet, so every other view still renders the light box, and
 * changing that app-wide belongs to a theme-level task, not to this one. */
.ds-filter-panel .form-check-input {
  accent-color: var(--color-primary);
  color-scheme: dark;
}

/* The priority chip's dot takes the DB-stored `priority_color` inline (ruling
 * D-A10: that colour is per-priority CONTENT, not theme), so the label may not
 * inherit it — a stored colour of unknown luminance is unreadable at 11px. The
 * label is pinned to `--color-text`, 14.93:1 on the chip fill whatever the dot
 * is. */
.ds-chip-priority .ds-chip-label { color: var(--color-text); }

/* Overdue rows keep `.table-danger` (a DOM contract) AND gain the 3px error
 * edge; the "Overdue" chip in the markup is the text carrier, so colour is
 * never alone (§4.1.3 rule 7). The edge lands on the first CELL because a
 * `<tr>` border is unreliable in the collapsing border model. Same specificity
 * as theme-bootstrap.css §4 `.table-danger > td` (0,1,1) — this file loads
 * later, so it wins by order, per the contract's cascade strategy (§5.1). */
.ds-queue-table tbody tr.table-danger > td:first-child {
  border-left: 3px solid var(--color-error);
}

@media (max-width: 767px) {
  /* The queue's FIRST cell is the selection checkbox, not the row headline, so
   * §7's "first cell is the card title" pair is re-pointed here: the checkbox
   * lifts out of flow to the card's top-right and the SECOND cell — the ticket
   * id and its age — becomes the headline. Equal specificity to the §7 rules
   * (0,2,2); this section loads later, so it wins by order.
   *
   * `:not(.ds-table-empty)` is load-bearing, not defensive: the empty queue's
   * carrier row is a single `<td colspan="8">` holding the whole `.ds-empty`
   * panel, and that cell IS `td:first-child` — without the guard the empty
   * state gets lifted into the 40px checkbox slot and renders as a clipped
   * sliver. The table only carries `.ds-table-empty` when there are no rows,
   * so the guard is exactly the "this row is not a ticket" test. */
  .ds-table-select:not(.ds-table-empty) tbody tr {
    position: relative;
    padding-right: var(--space-10);
  }

  .ds-table-select:not(.ds-table-empty) tbody td:first-child {
    position: absolute;
    top: var(--space-2);
    right: var(--space-2);
    display: flex;
    align-items: center;
    justify-content: flex-end;
    width: 40px;
    min-height: 40px;
    margin: 0;
    padding: 0;
  }

  .ds-table-select tbody td:first-child::before { display: none; }

  .ds-table-select tbody td:nth-child(2) {
    display: block;
    margin-bottom: var(--space-1);
  }

  .ds-table-select tbody td:nth-child(2)::before { display: none; }

  /* Every queue cell but the headline carries a value AND a meta line (the
   * requester, the replying agent, the reply count). §7's collapsed cell is a
   * `space-between` flex row, so without wrapping, the label, the value and the
   * meta line become three cramped columns inside a 343px card. Wrapping gives
   * the meta line its own full-width row under its value. Restricted to the
   * LABELLED cells: the headline cell (the ticket id and its age) is already a
   * left-aligned block, and right-aligning its date would push it under the
   * absolutely-positioned checkbox. */
  .ds-queue-table tbody td:nth-child(n + 3) { flex-wrap: wrap; }

  .ds-queue-table tbody td:nth-child(n + 3) > .ds-cell-meta {
    width: 100%;
    text-align: right;
  }

  .ds-queue-table tbody td > .ds-chip-row {
    flex: 1 1 auto;
    min-width: 0;
    justify-content: flex-end;
  }

  /* The subject is the row's only free-text identifier and never fits beside
   * its label at 375px, so this ONE cell stacks — label, the two-line clamped
   * title, then the requester — while the short cells keep the compact
   * `label | value` row. `min-width: 0` is what lets the clamp engage instead
   * of the title widening the card (TC-6). */
  .ds-queue-table tbody td:nth-child(3) { display: block; }

  .ds-queue-table tbody td:nth-child(3)::before {
    display: block;
    margin-bottom: var(--space-1);
  }

  .ds-queue-table tbody td:nth-child(3) > .ds-cell-title { min-width: 0; }

  .ds-queue-table tbody td:nth-child(3) > .ds-cell-meta { text-align: left; }

  /* Collapsed, the row IS a block box, so the overdue edge moves onto the row
   * itself — its first cell has just been lifted out of flow. */
  .ds-queue-table tbody tr.table-danger > td:first-child { border-left: none; }
  .ds-queue-table tbody tr.table-danger { border-left: 3px solid var(--color-error); }
}

/* --- 23.3 The mass-action bar ------------------------------------------- */

/* `#ticket_options` is shown and hidden by `ticketsPage()` in staff.js with
 * jQuery `.show()`/`.hide()`, which write `style.display` INLINE. That is why
 * nothing here declares `display` on the wrapper: jQuery's `.show()` clears the
 * inline `none` and then reads the computed value, so a `display` authored here
 * would be what it has to fight. Position, spacing and the card's own treatment
 * are all it needs, and they are orthogonal to the show/hide contract. */
.ds-massbar { margin-top: var(--space-5); }   /* replaces the legacy inline `margin-top: 20px` */

.ds-massbar-actions {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}

@media (max-width: 767px) {
  /* "A full-width sticky bar at the bottom of the viewport" (spec §3.1 view 4).
   * `sticky`, not `fixed`: the wrapper is the LAST child of `#ticketForm`, which
   * spans the whole result list, so the bar stays pinned for the entire scroll
   * and then settles into flow at the end — without claiming a stacking level
   * above the shell's chrome (Shards' `.main-sidebar` is 1070 and the staff
   * drawer backdrop 1060; a `fixed` bar would have to be slotted between them
   * and would still overlay the drawer's own scrim). */
  .ds-massbar {
    position: sticky;
    bottom: 0;
    margin-top: var(--space-3);
  }

  .ds-massbar > .card {
    margin-bottom: 0;
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    box-shadow: var(--shadow-modal);
  }

  /* Three selects plus their labels are taller than a phone can spare for an
   * action bar, so the FIELDS scroll inside the bar while the two actions stay
   * permanently reachable in the footer. */
  .ds-massbar-body {
    max-height: 30vh;
    overflow-y: auto;
  }

  .ds-massbar-actions {
    flex-direction: column;
    align-items: stretch;
    flex-wrap: nowrap;
  }

  .ds-massbar-actions .btn {
    width: 100%;
    min-height: 40px;
  }
}

/* ==========================================================================
 * 24. STAFF TICKET DETAIL, COMPOSE, CANNED RESPONSES AND PROFILE — issue #72.
 *     spec §3.1 views 5, 6, 7, 16, 17; proto §3 (A3) + §6 (A6).
 *
 * These are the heaviest screens in the application and THREE of them are built
 * on real Bootstrap tab structures whose ids the committed suite asserts
 * (`#home` / `#replyBox` / `#notesBox`, `#profile` / `#password` / `#twofactor`).
 * Rule R2 is decisive: the tabs are restyled IN PLACE and the A3/A6 language is
 * applied INSIDE each pane. Nothing here flattens a tab set into the
 * prototype's side-by-side meta panel.
 *
 * Almost everything these views need already exists: §6 (chips), §7 (the
 * `data-label` collapse), §9 (thread message), §10 (`.ds-plate`), §18 (thread
 * layout), §19 (attachment pill wrapper), §20 (A6 form grid) and §21 (tab-strip
 * scroll). This section adds only what is genuinely new to the staff side.
 * ========================================================================== */

/* --- 24.1 The tab strip inside a Shards `.card-header` (rule R2) ---------- */

/* `client/profile.php` (Stage 2) put `.ds-tabs-scroll` around a BARE
 * `ul.nav-tabs`. The three staff tab sets live inside a `.card-header` and
 * carry Bootstrap's `.card-header-tabs`, which exists to pull the strip out to
 * the header's edges with NEGATIVE margins:
 *   `.card-header-tabs { margin-right:-.625rem; margin-bottom:-.75rem;
 *                        margin-left:-.625rem; border-bottom:0 }`
 * Inside `.ds-tabs-scroll` (which is `overflow-x: auto`) those negative side
 * margins make the child wider than its scroll port, so the strip would show a
 * scrollbar at EVERY width, not just below 768px. They are zeroed here and the
 * header instead gives up its own bottom padding and border, so `.nav-tabs`'s
 * themed 1px divider lands exactly on the header's bottom edge — the same
 * silhouette the negative margins were producing, without the overflow.
 *
 * 0,3,0 against the vendor's 0,1,0 — no `!important` needed. */
.ds-tabs-head {
  padding-bottom: 0;
  border-bottom: none;
}

.ds-tabs-head > .ds-tabs-scroll > .nav-tabs.card-header-tabs {
  margin: 0;
}

/* ⚠ The only `!important` in this section, and it counters vendor rules that are
 * THEMSELVES `!important` (§4.1.3 rule 2 — the sole condition under which the
 * theme layer may use it).
 *
 * BOTH vendors ship the Bootstrap border utilities at `!important`:
 *   bootstrap.min.css  `.border        { border: 1px solid #dee2e6 !important }`
 *   bootstrap.min.css  `.border-bottom { border-bottom: 1px solid #dee2e6 !important }`
 *   shards-dashboards  `.border`, `.border-bottom` … the same, in #e1e5eb
 * Shards loads later, so #e1e5eb wins. theme-bootstrap.css §14 already intends
 * to repair this — `.border, .border-top, … { border-color: var(--color-border) }`
 * — but that declaration is NOT `!important`, so it never applies: specificity
 * and load order are both irrelevant against an `!important` origin. Measured on
 * this branch: `.card-header.border-bottom` computed `rgb(225, 229, 235)` and the
 * internal-note surface's border did too.
 *
 * Scoped to the three hosts THIS issue owns rather than repaired for the whole
 * utility family, because `.border-*` belongs to theme-bootstrap.css §14 (Stage
 * 1) and eight staff views carry it. The app-wide repair is recorded as a lesson
 * instead of taken here. Colour only — the 1px line itself is wanted in all
 * three places. */
.nav-tabs.border-bottom,
.card-header.border-bottom {
  border-bottom-color: var(--color-border) !important;   /* counters the vendors' `!important` utility */
}

.alert.ds-note.border {
  border-color: var(--color-border) !important;          /* counters the vendors' `!important` utility */
}

/* --- 24.2 The internal-note surface — spec §2.7, §3.1 view 5 ------------- */

/* Staff notes are the one surface in the application the CUSTOMER NEVER SEES,
 * and the committed suite pins exactly that (staff-ticket-management.spec.ts:542
 * asserts the note text has count 0 on the customer's view of the same ticket).
 * The distinction therefore has to be legible, and it may not rest on a fill:
 * `.ds-note-chip` below states it in words.
 *
 * `.alert.alert-light` is a DOM contract (§4.2.3) and is KEPT. Two classes
 * (0,2,0) so this outranks theme-bootstrap.css §7's `.alert-info, .alert-light,
 * …` group (0,1,0) on its own terms rather than on load order — the trap
 * L-20260721-05 records. That group gives `.alert-light` the INFO treatment
 * (cobalt wash + 3px accent), which is right for a notice and wrong for a note;
 * spec §2.7's table asks for `--color-surface-raised` + a 1px `--color-border`
 * all round and no accent, which is what this restores — for the note surface
 * only, leaving `.alert-light` elsewhere untouched. */
.alert.ds-note {
  display: block;
  padding: var(--space-4);
  background-color: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-top-width: 1px;
  border-radius: var(--radius-md);
  color: var(--color-text);
}

.ds-note-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-bottom: var(--space-3);
}

.ds-note-author { font-size: var(--text-sm); font-weight: var(--font-weight-medium); }
.ds-note-time   { font-size: var(--text-xs); color: var(--color-text-muted); }

/* The edit/delete affordances sit at the far end of the author line on desktop
 * and wrap onto their own row on a phone (the flex `wrap` above), so they never
 * squeeze the author name out. */
.ds-note-actions {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  margin-left: auto;
}

/* Both affordances are icon-only `.btn-link.btn-sm`, which computes to a 32px
 * box — under the 40px mobile tap target the requirements set (A11y). Measured
 * at 375px before this rule: 32px. `min-width` matters as much as `min-height`
 * here, because a single glyph gives the button almost no horizontal extent. */
@media (max-width: 767px) {
  .ds-note-actions .btn {
    min-width: 40px;
    min-height: 40px;
  }
}

/* A note is free text a staff member typed, so it carries the same
 * `overflow-wrap` defence as a thread message (§9): one pasted URL with no
 * break opportunity would otherwise set the card's min-content width (TC-6). */
.ds-note-text {
  font-size: var(--text-sm);
  line-height: 1.6;
  min-width: 0;
  overflow-wrap: anywhere;
}

.ds-note-text > p:last-child { margin-bottom: 0; }

/* The "Internal note — not visible to the customer" chip. It deliberately
 * DEVIATES from `.ds-chip-label` (§6) on three properties, and each deviation
 * is forced by the string:
 *   · `white-space: normal` — §6 pins `nowrap`, which is right for a one-word
 *     status. This label is 44 characters; uppercased at `--text-xs` it measures
 *     ~330px and would push a 343px card past the viewport at 375px (TC-6).
 *   · `text-transform: none` — a 44-character all-caps run is a legibility
 *     problem, not a style, and sentence case reads as the sentence it is.
 *   · `height: auto` — §6's fixed 28px cannot host two lines.
 * The dot, the border, the pill radius and the ember ink are unchanged, so it is
 * still visibly the same component. Colour is never the carrier here: the words
 * ARE the message (§4.1.3 rule 7). */
.ds-chip-note {
  height: auto;
  min-height: 28px;
  max-width: 100%;
  padding: var(--space-1) var(--space-3);
  align-items: flex-start;
}

.ds-chip-note .ds-chip-dot { margin-top: 5px; }

.ds-chip-note .ds-chip-label {
  white-space: normal;
  text-transform: none;
  letter-spacing: 0;
  line-height: 1.4;
  color: var(--color-primary);
}

/* --- 24.3 Staff thread additions — spec §3.1 view 5 "Below the tabs" ----- */

/* The staff thread reuses §9 (`.ds-msg`, `.ds-avatar`, `.ds-msg-head`) verbatim.
 * Two things the customer's thread does not have need rules of their own: a
 * per-message ACTION (Quote) and a per-file DELETE inside the attachment block.
 *
 * The footer keeps the IP meta line and the Quote button on one row at desktop
 * and stacks them on a phone. The `border-top` replaces the legacy
 * `.border-top.mt-3.pt-4.text-right` utility stack. */
.ds-msg-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-top: var(--space-4);
  padding-top: var(--space-3);
  border-top: 1px solid var(--color-border);
}

.ds-msg-ip {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  min-width: 0;
  overflow-wrap: anywhere;
}

/* An attachment row that owns a destructive action. The pill (§9 `.ds-attach`)
 * is the download link; the delete button is a SIBLING, never nested inside it —
 * a `<button>` inside an `<a>` is invalid HTML and the click would resolve to
 * the wrong target. `align-items: center` keeps the 1px border of the pill and
 * the button's box on the same optical line. */
.ds-attach-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-bottom: var(--space-2);
}

.ds-attach-row:last-child { margin-bottom: 0; }

/* `.ds-attach`'s own `margin-top` is the standalone case (§9); inside a row the
 * pill is aligned by the flex container instead. */
.ds-attach-row > .ds-attach {
  margin-top: 0;
  flex: 0 1 auto;
  min-width: 0;
}

@media (max-width: 767px) {
  /* On a phone the pill takes the full card width and the delete drops beneath
   * it at the 40px tap target, rather than the two competing for 343px. */
  .ds-attach-row { flex-direction: column; align-items: stretch; }
  .ds-attach-row > .ds-attach { max-width: 100%; }
  .ds-attach-row > .btn { width: 100%; min-height: 40px; }

  .ds-msg-foot { flex-direction: column; align-items: stretch; }
  .ds-msg-foot > .btn { width: 100%; min-height: 40px; }
}

/* The page title on a ticket detail is `[#id] subject` — user data, realistically
 * 85 characters (spec §3.6). Two lines is the spec's clamp (§3.1 view 5
 * "Overflow risk"); the view puts the FULL string in `title` so the clamp never
 * hides the only free-text identifier. `[#id]` leads the string, so the ticket's
 * identity is always in the first line. */
.ds-title-clamp {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
}

/* --- 24.4 The 2FA panel — spec §3.1 view 7, contract §4.4.3 item 3 ------- */

/* The QR plate. `.ds-plate` (§10) already carries the light fill that makes a QR
 * scannable and IS the named TC-3 carve-out class; this only makes it a
 * block-level, centred, never-wider-than-the-card box, because the plate is
 * `inline-flex` by default and a 200px QR inside a 343px card has to be able to
 * shrink. `--space-3` of white padding around the code is the quiet zone. */
.ds-qr-plate {
  display: flex;
  max-width: 100%;
  margin-bottom: var(--space-4);
}

.ds-qr-plate > img {
  max-width: 100%;
  height: auto;
  display: block;
}

/* The secret key. It STAYS an `<input readonly class="form-control">` — that is
 * not a style choice: admin-settings-profile.spec.ts:266/478-480/489 resolve the
 * 2FA state through `pane.locator('input[readonly]')` (count 0 before
 * activation, visible and non-empty after), so turning the key into the `<code>`
 * block the prototype draws would break three assertions. Only the TYPOGRAPHY
 * moves: a monospace face and loose tracking so a 16-character base32 secret can
 * be transcribed a character at a time. Background and border are left to
 * `.form-control` because TC-5 pins both on every `.form-control`. */
.ds-secret-key {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  letter-spacing: 0.08em;
  overflow-wrap: anywhere;
}

/* --- 24.5 The staff profile avatar — spec §3.1 view 7 "General" ---------- */

/* 96px circle on a raised surface with a border ring, replacing the legacy
 * inline `style="max-width: 100px"`. Both axes are pinned and `object-fit: cover`
 * crops, because a non-square upload would otherwise stretch the circle — the
 * same reason §9 pins `img.ds-avatar`. */
.ds-avatar-lg {
  width: 96px;
  height: 96px;
  border-radius: var(--radius-full);
  background-color: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  object-fit: cover;
}

/* --- 24.6 The canned-response list (A2) --------------------------------- */

/* The table is a plain `.ds-table-collapse` (§7): the title cell is the card
 * headline and the action cell carries its `data-label`. Only the action cell
 * needs a rule of its own, because §7's collapsed cell is a `space-between` flex
 * row and a two-group button cluster does not belong on the right-hand end of
 * one at 375px.
 *
 * ⚠ The `<tr>` MUST survive the collapse. admin-crud.spec.ts:758 clicks
 * `page.locator('tr', { hasText: title }).locator('button.btn-danger')`, so the
 * title and the delete button have to stay inside ONE `<tr>`. §7 is a CSS
 * reflow of the same table and satisfies that; a markup fork into per-card
 * wrappers would not. This is why the mobile card is never a second DOM tree. */
.ds-canned-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-2);
  flex-wrap: wrap;
}

@media (max-width: 767px) {
  /* The action cell stacks under its label and the two button groups become one
   * full-width row — "actions as a full-width button row inside the card"
   * (issue #72). `flex: 1` on the groups is what makes the four controls share
   * the card width instead of clustering at one edge.
   *
   * ⚠ `:not(.ds-table-empty)` is load-bearing, not defensive (L-20260722-15).
   * The empty list keeps the controller's single `<tr><td colspan="2">` carrier
   * row, and that ONE cell is both `:first-child` and `:last-child`. Without the
   * guard this rule matches the empty state and, because it outranks §7's
   * `td:first-child::before { display: none }` on specificity (0,2,3 vs 0,1,3),
   * re-enables a `::before` whose `attr(data-label)` resolves to the empty
   * string — an invisible block with a margin above the "Records not found."
   * panel. Every positional rule inside a collapse block needs this guard,
   * because the carrier row occupies the same positions with different content. */
  .ds-canned-table:not(.ds-table-empty) tbody td:last-child { display: block; }

  .ds-canned-table:not(.ds-table-empty) tbody td:last-child::before {
    display: block;
    margin-bottom: var(--space-2);
  }

  .ds-canned-actions { justify-content: stretch; flex-wrap: nowrap; }
  .ds-canned-actions > .btn-group { flex: 1 1 0; }
  .ds-canned-actions > .btn-group > .btn { flex: 1 1 0; min-height: 40px; }
}

/* ==========================================================================
 * 25. STAFF ADMINISTRATION — USERS, AGENTS, DEPARTMENTS, CUSTOM FIELDS.
 *     issue #75, spec §3.1 views 8-15; proto §5 (A5 + A2) + §6 (A6).
 *
 * Four list + form CRUD pairs that share ONE shape, so this section adds only
 * what none of the earlier sections already provides. The lists are plain
 * `.ds-table-collapse` tables (§7); the forms are plain `.ds-form-grid` A6
 * grids (§20) with `.ds-form-actions` footers (§5); the flags are §6 chips.
 * `.ds-admin-table` is the shared hook the four lists carry, and it exists so
 * every positional rule below can be scoped to them and to nothing else.
 *
 * §24.6 solved the same collapsed-action-cell problem for the canned-response
 * list. It is NOT reused verbatim: that selector is keyed on `.ds-canned-table`
 * and re-pointing it would mean editing `staff/canned_manage.php`, which issue
 * #75 explicitly may not touch ("no other view file is modified"). The two
 * blocks should be merged behind one hook by whichever stage next owns both.
 * ========================================================================== */

/* --- 25.1 The shared row-action cluster ---------------------------------- */

/* Every one of the four lists ends in an action cell holding one or two
 * `.btn-group`s (edit + delete, optionally preceded by the ordering pair). The
 * groups themselves are DOM contracts and are kept — issue #75 rule: "a
 * btn-group stays a btn-group, a dropdown stays a dropdown". */
.ds-row-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-2);
  flex-wrap: wrap;
}

/* --- 25.2 The collapsed card (below 768px) ------------------------------- */

@media (max-width: 767px) {
  /* The action cell stacks UNDER its label and the button groups become one
   * full-width row — "actions as a full-width button row inside the stacked
   * card" (issue #75). `flex: 1` on the groups is what makes the controls share
   * the card width instead of clustering at one edge, and `min-width`/
   * `min-height: 40px` restores the tap target `.btn-sm` (a 31px box) loses.
   *
   * ⚠ `:not(.ds-table-empty)` is load-bearing, not defensive (L-20260722-15).
   * An empty list keeps the controller's single `<tr><td colspan="N">` carrier
   * row, and that ONE cell is both `:first-child` and `:last-child`. Without
   * the guard this rule matches the empty state and, because it outranks §7's
   * `td:first-child::before { display: none }` on specificity (0,2,3 vs 0,1,3),
   * re-enables a `::before` whose `attr(data-label)` resolves to the empty
   * string — an invisible block with a margin above the "Records not found."
   * panel. Every positional rule inside a collapse block needs this guard. */
  .ds-admin-table:not(.ds-table-empty) tbody td:last-child { display: block; }

  .ds-admin-table:not(.ds-table-empty) tbody td:last-child::before {
    display: block;
    margin-bottom: var(--space-2);
    /* The action cell carries Bootstrap's `.text-right` (`!important`), which
     * the generated label INHERITS — so the collapsed card's "ACTION" heading
     * drifted to the right edge while every other label sat at the left. The
     * `!important` is on the CELL, not on this pseudo-element, so a plain
     * declaration here is enough; no escalation is needed. */
    text-align: left;
  }

  .ds-row-actions { justify-content: stretch; flex-wrap: nowrap; }
  .ds-row-actions > .btn-group { flex: 1 1 0; }
  .ds-row-actions > .btn-group > .btn { flex: 1 1 0; min-width: 40px; min-height: 40px; }

  /* `staff/agents.php`'s self-row has NO actions at all (you cannot delete the
   * account you are signed in as), so its action cell is genuinely empty. The
   * view gives that one cell `.ds-cell-noaction` and omits its `data-label`;
   * without this rule the collapse would print an "ACTION" heading over
   * nothing.
   *
   * The `:not(.ds-table-empty)` here is NOT copied for symmetry — it is what
   * makes the selector (0,3,2), i.e. EQUAL to the `td:last-child` rule above,
   * so it wins by load order. Written as `.ds-admin-table tbody td.ds-cell-noaction`
   * it would be (0,2,2) and the `display: block` above would beat it. */
  .ds-admin-table:not(.ds-table-empty) tbody td.ds-cell-noaction { display: none; }

  /* §7's collapsed cell is a `space-between` flex row, which puts the label at
   * the left and the value at the right. A chip GROUP on the right has to be
   * allowed to wrap onto its own line rather than squeeze the label out, so
   * the cell wraps and the group takes the remaining width. */
  .ds-admin-table tbody td { flex-wrap: wrap; }

  .ds-admin-table tbody td > .ds-chip-row {
    flex: 1 1 auto;
    min-width: 0;
    justify-content: flex-end;
  }

  /* The headline cell is a left-aligned block (§7), so the chips that sit under
   * the row's name there — `staff/agents.php`'s privilege chip — stay left. */
  .ds-admin-table:not(.ds-table-empty) tbody td:first-child > .ds-chip-row {
    justify-content: flex-start;
  }

  /* A labelled cell that carries a value AND a meta line (the agent's e-mail
   * under their full name) is a `space-between` flex row, so the label, the
   * value and the meta line compete for one 343px line: whether the meta line
   * fits beside its value depends on how long the value happens to be, and the
   * same column then renders two different layouts row by row. Giving the meta
   * line its own full-width row under its value makes it deterministic. The
   * §23 idiom, verbatim. Excludes the headline cell, which is already a
   * left-aligned block and whose meta line must not be right-aligned. */
  .ds-admin-table tbody td:not(:first-child) > .ds-cell-meta {
    width: 100%;
    text-align: right;
  }
}

/* --- 25.2b The list card's "New …" action -------------------------------- */

/* Each of the four lists puts its primary action in the table card's header as
 * a `.btn-sm`, which computes to a 32px box — under the 40px mobile tap target
 * the requirements set (A11y). Measured at 375px on all four before this rule:
 * 32px. `.ds-table-header .btn` is (0,2,0) against `.btn-sm`'s (0,1,0), so this
 * wins on specificity rather than on load order — the same shape §10 already
 * uses for `.ds-empty .btn`.
 *
 * Deliberately keyed on `.ds-table-header` rather than on `.ds-admin-table`:
 * the header is a SIBLING of the table, not a descendant, so a table-scoped
 * selector cannot reach it. That also means the rule reaches
 * `staff/canned_manage.php`'s identical header action, which has the identical
 * 32px gap — a repair, not a regression, and the right home for it.
 * `staff/tickets.php`'s header holds only a `<span>` and is unaffected. */
@media (max-width: 767px) {
  .ds-table-header .btn { min-height: 40px; }
}

/* --- 25.2c The validation summary inside a flash alert ------------------- */

/* All four forms surface validation as `$error_msg`, which is
 * `Validation::listErrors()` — and CI4's `Views/validation/list.php` renders
 * `<div class="errors" role="alert"><ul>…`. theme-bootstrap.css §7 styles
 * `.errors` as a STANDALONE danger panel (error fill, 3px error top edge,
 * radius, padding), which is right when a view echoes it bare. Every one of
 * these views, however, echoes it INSIDE `<div class="alert alert-danger">` —
 * the wrapper the committed suite asserts with `toBeVisible()`
 * (admin-crud.spec.ts:389/429/469/599), so it cannot simply be dropped. The
 * result is a danger panel drawn inside a danger alert: two nested boxes with
 * two borders and two paddings, measured on `/staff/users/new` at 375px.
 *
 * The inner chrome is neutralised, not the outer: the alert is the component
 * the design system defines and the one the DOM contract pins. Scoped to
 * `.alert .errors`, so a bare `.errors` anywhere else keeps its panel exactly
 * as §7 draws it. This reaches the same nesting on the views issue #72 merged
 * — a repair of an identical pre-existing defect, not a regression. */
.alert .errors {
  background-color: transparent;
  border: 0;
  border-radius: 0;
  padding: 0;
}

/* Issue #84 gives `validation/list.php` its OWN A7 anatomy (icon + title + list)
 * so that on a BARE `.alert-danger` wrapper — the client submit form and the
 * installer — it reads as a complete alert. The staff admin forms, however, wrap
 * it inside a fully formed A7 alert that ALREADY carries its own
 * `.ds-alert-icon` + text, nesting `.errors` under `.ds-alert-body`. There the
 * inner icon + title would double the chrome, so they are suppressed and the
 * block collapses to just its list. The discriminator is real: staff forms nest
 * under `.ds-alert-body`; the bare wrappers make `.errors` a direct child of
 * `.alert`. */
.ds-alert-body .errors { display: block; }
.ds-alert-body .errors > .ds-alert-icon,
.ds-alert-body .errors .ds-alert-title { display: none; }
.ds-alert-body .errors .ds-errors-list { padding-left: 1.25em; }

/* --- 25.3 Long free-text values in a cell -------------------------------- */

/* An e-mail address is the longest value these lists render and the only one
 * that can widen a 375px card. `overflow-wrap` on the `<td>` itself would not
 * help: Chrome's AUTOMATIC table layout does not consult it when computing a
 * cell's minimum width (L-20260722-09). A BLOCK-LEVEL child does honour it, so
 * the value is wrapped in one. `min-width: 0` is what lets it shrink at all
 * inside a flex/grid context. */
.ds-cell-break {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* The chip row that sits under a headline cell's title needs to clear it. */
.ds-cell-chips { margin-top: var(--space-2); }

/* --- 25.4 The checkbox option group (A6) --------------------------------- */

/* `staff/agents_form.php`'s department assignment. R1: the control stays the
 * list of `department[]` CHECKBOXES the app has always rendered — the spec's
 * select2 multi-select would rename a submitted field — so only the layout
 * changes: a wrapping group instead of a stack of one-per-line rows.
 *
 * Bootstrap's `.form-check` positions its input ABSOLUTELY inside a 1.25rem
 * left pad, which cannot centre against a 40px row. The pair is re-laid out as
 * a flex row at every width; `.form-check`, `.form-check-input` and
 * `.form-check-label` all survive as DOM contracts (§4.2.3). 0,2,0 against
 * Bootstrap's 0,1,0 — no `!important` needed, those rules are not `!important`. */
.ds-check-group {
  display: flex;
  flex-wrap: wrap;
  gap: 0 var(--space-6);
}

.ds-check-group .form-check {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 40px;   /* >= 40px tap target at every width (requirements A11y) */
  margin: 0;
  padding-left: 0;
}

/* `color-scheme: dark` is what makes the UNCHECKED native box render dark;
 * `accent-color` only colours the CHECKED fill, so on its own it leaves a white
 * plate on the dark surface (L-20260722-17). Both are needed, and both are
 * scoped to this group — no theme file styles `.form-check-input` globally yet,
 * and doing so app-wide is a theme-level task, not a form-view one. */
.ds-check-group .form-check-input {
  position: static;
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: var(--color-primary);
  color-scheme: dark;
  cursor: pointer;
}

/* The 40px target is the LABEL, not the box. A native checkbox grown to 40x40
 * renders as a giant plate, so the same split §23 uses for the queue's
 * selection column applies here: the input stays 18px and the label — which
 * activates it through `for` — carries the full-height hit area. */
.ds-check-group .form-check-label {
  display: flex;
  align-items: center;
  min-height: 40px;
  margin: 0;
  cursor: pointer;
  overflow-wrap: anywhere;
}

@media (max-width: 767px) {
  /* One option per row on a phone: two columns of checkboxes at 375px leave the
   * labels no room and the second column falls off the card. */
  .ds-check-group { flex-direction: column; flex-wrap: nowrap; }
}

/* --- 25.4b The A6 switch's tap target ------------------------------------ */

/* §20's mobile block gives `.custom-checkbox` / `.custom-radio` labels a 40px
 * box and re-centres their markers, but EXCLUDES `.custom-switch` because its
 * `::after` knob carries a `transform` that a naive re-centring would clobber.
 * `staff/users_form.php`'s "send welcome e-mail" switch is the first switch in
 * an A6 grid, and it measured 39px at 375px — one pixel under the target.
 *
 * The knob's checked state is Bootstrap's `transform: translateX(.75rem)`, so
 * the vertical re-centring has to be folded INTO that transform rather than
 * layered under it: `translate(.75rem, -50%)` at (0,5,0) against the vendor's
 * (0,4,0). The transition still runs — both keyframes are `translate()`.
 * Applied at every width so the switch never disagrees with itself. */
.ds-form-grid .custom-switch .custom-control-label {
  display: flex;
  align-items: center;
  min-height: 40px;
}

.ds-form-grid .custom-switch .custom-control-label::before,
.ds-form-grid .custom-switch .custom-control-label::after {
  top: 50%;
  transform: translateY(-50%);
}

.ds-form-grid .custom-switch .custom-control-input:checked ~ .custom-control-label::after {
  transform: translate(0.75rem, -50%);
}

/* --- 25.5 The native multiple select (A6) -------------------------------- */

/* `staff/custom_fields_form.php`'s `departments[]`. R1 again: it stays a native
 * `<select multiple>` (the spec's select2 would be new client-side behaviour,
 * and the committed suite drives this form's sibling selects with
 * `selectOption()`, which cannot address a JS-replaced widget).
 *
 * §2 of theme-bootstrap.css paints a chevron onto `.custom-select` and sets
 * `appearance: none` — correct for a closed dropdown, wrong for an open list
 * box, where the glyph floats over the options. Both are undone here at 0,2,0
 * against that file's 0,1,0, and the generous single-line padding is traded for
 * per-option padding so several options fit without scrolling. */
.ds-multiselect.custom-select {
  height: auto;
  padding: var(--space-2);
  background-image: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  color-scheme: dark;   /* the option list is painted by the UA, not by the page */
}

.ds-multiselect.custom-select option {
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
}

/* ==========================================================================
 * 26. REDUCED MOTION — spec §1.10. The two named animations carry their own
 *     blocks verbatim from ref §13/§14; the drawer slide and the backdrop
 *     fade are switched off with them. No `!important` is needed: the theme
 *     layer loads last, so an equal-specificity rule later in the cascade
 *     wins by order.
 *
 * §27 below adds no transition and no animation, so nothing it introduces
 * needs switching off here and the numeric order is kept.
 * ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  /* The two named keyframe animations of this file: the skeleton shimmer
   * (`dsShimmer`) and the auth-card entry (`dsCardEntry`, §13 `.ds-auth-card`).
   * Both must be off under reduced motion (issue #90 dimension 6). `.ds-auth-card`
   * animates to `opacity:1; translateY(0)`, so disabling the animation leaves it
   * at its resting visible state — no `forwards` fill needed. */
  .ds-skeleton { animation: none; }
  .ds-auth-card { animation: none; }

  .ds-navdrawer,
  .ds-sidebar-backdrop,
  .ds-hamburger,
  .ds-segment,
  .ds-chip { transition: none; }

  .btn:active:not(:disabled):not(.disabled) { transform: none; }
}

/* ==========================================================================
 * 27. STAFF ADMINISTRATION — KNOWLEDGEBASE, E-MAIL, API AND SETTINGS.
 *     issue #78, spec §3.1 views 18-31; proto §5 (A5 + A2) + §6 (A6) + §7.
 *
 * Fourteen views, and almost all of them are already covered: the lists are
 * `.ds-table-collapse` (§7) + `.ds-admin-table` (§25), the forms are
 * `.ds-form-grid` (§20) with `.ds-form-actions` (§5), the flags are §6 chips,
 * the empty states are §10 and the e-mail-address form's tab strip is §24.1.
 *
 * This section adds ONLY the five things none of them provides:
 *   27.1  the knowledgebase category tree (indent on desktop, parent meta line
 *         on mobile)
 *   27.2  the API token cell (ellipsis, never wrapped)
 *   27.3  the API permission matrix as a grouped checkbox grid
 *   27.4  the e-mail-template placeholder reference list
 *   27.5  the settings_general logo plate
 *   27.6  the e-mail-template status switch inside a table cell
 * ========================================================================== */

/* --- 27.1 The knowledgebase category tree -------------------------------- */

/* `Staff\Kb` builds its list with `Kb::getChildren(0, false, 0, ' - - - ')`,
 * which PREPENDS a literal ` - - - ` per level to `$item->name` — ASCII
 * indentation baked into the data. The view strips exactly the prefixes the
 * helper added and re-expresses the depth as real indentation, which is a
 * presentation change only: the row still renders the same category, in the
 * same order, and `getByText('FAQ')` still matches (the prefix was never part
 * of the assertion).
 *
 * Indentation is the WRONG affordance in a 375px stacked card — there is no
 * column of siblings left to compare against, so an indented card just looks
 * misaligned. Below 768px the indent is dropped and the parent is NAMED
 * instead (spec §3.1 view 20).
 *
 * ⚠ The parent name is carried by `attr(data-parent)` on a `::after`, NOT as
 * DOM text, and that is load-bearing rather than stylistic. `hdz_kb_category`
 * seeds FAQ (id 3) as a CHILD of "Getting Started" (id 1), and
 * admin-crud.spec.ts:202 asserts `getByText('Getting Started')` with
 * `toBeVisible()` — which is strict. A real text node reading "Parent category:
 * Getting Started" on the FAQ row would be a SECOND substring match and the
 * assertion would fail with a strict-mode violation in both viewport projects.
 * Playwright's text engine reads `textContent`, which never contains generated
 * content, so the `::after` is invisible to it while still being announced by
 * the screen reader — the same mechanism §7's `data-label` collapse already
 * relies on. (Companion to L-20260722-11, which documents the opposite half:
 * generated content DOES join an accessible NAME, so `getByRole` sees it.) */
.ds-cat-node[data-depth="1"] { padding-left: var(--space-4); }
.ds-cat-node[data-depth="2"] { padding-left: var(--space-8); }
.ds-cat-node[data-depth="3"] { padding-left: var(--space-12); }
.ds-cat-node[data-depth="4"] { padding-left: var(--space-16); }

/* The tree guide: a hairline that makes the indent read as hierarchy rather
 * than as a stray margin. Only nested rows carry a depth attribute. */
.ds-cat-node[data-depth]:not([data-depth="0"]) {
  border-left: 1px solid var(--color-border);
}

.ds-cat-parent { display: none; }

@media (max-width: 767px) {
  /* The indent and its guide are meaningless in a stacked card.
   *
   * ⚠ The two selectors below are NOT interchangeable, and the difference is
   * load-bearing (L-20260722-28). `padding-left` is set above by
   * `.ds-cat-node[data-depth="1"]`, which is (0,2,0), so the simple
   * `.ds-cat-node[data-depth]` — also (0,2,0) — beats it by coming later. The
   * guide, however, is set by `…[data-depth]:not([data-depth="0"])`, and
   * `:not()` CONTRIBUTES ITS ARGUMENT'S SPECIFICITY, making that rule (0,3,0).
   * Written simply here, `border-left: 0` would be (0,2,0), lose on specificity
   * regardless of order, and the hairline would survive into the phone card —
   * measured exactly that way before this comment existed: `padding-left: 0px`
   * (applied) next to `border-left-width: 1px` (ignored) on the same element.
   * The `:not()` below carries no filtering meaning; it is there purely to
   * match the weight. Do not "simplify" it away. */
  .ds-cat-node[data-depth] { padding-left: 0; }

  .ds-cat-node[data-depth]:not([data-depth="0"]) { border-left: 0; }

  .ds-cat-parent {
    display: block;
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    margin-top: 2px;
  }

  .ds-cat-parent::after { content: attr(data-parent); }
}

/* --- 27.1b The DROPDOWN row action's mobile tap target ------------------- */

/* §25.1/§25.2 built `.ds-row-actions` for the four administration lists, whose
 * actions are all `.btn-group`s — so its mobile block is written as
 * `.ds-row-actions > .btn-group > .btn`. Three of the lists in THIS issue
 * (`kb_articles`, `kb_categories`, `email_addresses`) carry a `.dropdown`
 * instead, which issue #78 requires to stay a dropdown ("a btn-group stays a
 * btn-group, a dropdown stays a dropdown"). That element is not a `.btn-group`,
 * so none of §25.2's rules reach it.
 *
 * Measured on `/staff/kb/categories` at 375px before this rule: the ordering
 * pair inside its `.btn-group` correctly rendered at 40px while the "Action"
 * toggle right beside it rendered at **32px** — two controls in one row, one
 * meeting the tap target and one not, which is exactly the kind of gap a
 * screenshot review reads as "fine". `.btn-sm` is (0,1,0) and this is (0,3,0),
 * so it wins on specificity, not on load order.
 *
 * `.dropdown-menu` is deliberately NOT repositioned: the committed suite scopes
 * its delete clicks to the row (`locator('tr', …).getByRole('button', …)`), so
 * the menu must stay a DOM descendant of its `<tr>` and must not be portalled
 * to `<body>`. */
@media (max-width: 767px) {
  .ds-row-actions > .dropdown { flex: 1 1 0; }

  .ds-row-actions > .dropdown > .btn {
    width: 100%;
    min-width: 40px;
    min-height: 40px;
  }

  /* The menu's own entries are the actual destinations — Edit, Add
   * subcategory, New article, Delete — and they measured **36px** at 375px, so
   * the tap-target requirement is not met by sizing the toggle alone. They are
   * easy to miss because a closed menu has zero height: any sweep that walks
   * `.dropdown-item` on page load reports nothing at all. Measured only after
   * opening the menu, which is the only state in which they exist visually. */
  .ds-row-actions .dropdown-item {
    display: flex;
    align-items: center;
    min-height: 40px;
  }
}

/* --- 27.2 The API token cell --------------------------------------------- */

/* "Truncated with an ellipsis, never wrapped across lines" is an explicit
 * requirement (spec §3.1 view 26, issue #78). A 60-character alphanumeric token
 * has NO break opportunity, so left alone it sets the table's minimum width and
 * blows the page open — the `<td>` variant of L-20260722-09.
 *
 * `overflow: hidden` is what makes this work at all: it drops the element's
 * min-content contribution to zero, so the cell no longer sizes to the token.
 * `max-width` in `ch` keeps the desktop column proportionate to the type size
 * rather than to a hard pixel count.
 *
 * The FULL value stays in the text node (only the glyphs are clipped), which is
 * what keeps admin-crud.spec.ts:249 — `getByText('SEEDapiTOKEN…')` — matching:
 * Playwright reads `textContent`, not the painted box. The view also puts the
 * value in `title` so a human can read it without opening the edit form. */
.ds-token {
  display: block;
  max-width: 28ch;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

@media (max-width: 767px) {
  /* §7's collapsed cell is a `space-between` flex row: the `data-label` takes
   * the left, so the token gets what is left and must be allowed to shrink into
   * it. Without `min-width: 0` (inherited from the base rule) a flex item
   * refuses to go below its content's min-content size and the ellipsis never
   * engages.
   *
   * ⚠ `max-width` is deliberately NOT reset to `none` here, which was the first
   * thing written and is wrong. Flex sizing does not govern this box on its
   * own: the collapsed card still lives inside a real `<table>`, whose width
   * comes from its cells' max-content contribution, and a 60-character token
   * has no break opportunity at all. Measured at 375px with `max-width: none`:
   * the token box and the card both went to **493px**, the ellipsis never
   * engaged, and `.ds-table-scroll` quietly absorbed it as a sideways scroll —
   * so `documentElement.scrollWidth` still equalled `innerWidth` and the
   * standard TC-6 overflow probe reported the page as clean while the phone
   * card was 118px wider than the screen. Keeping the `28ch` cap bounds the
   * max-content contribution, so the table cannot be widened and the ellipsis
   * engages at every width. */
  .ds-token { flex: 0 1 auto; text-align: right; }
}

/* --- 27.3 The API permission matrix -------------------------------------- */

/* `api_form.php` renders `$api_permissions` as a wall of bare checkboxes in
 * `.col-md-4` columns. The checkbox `name="<resource>[<action>]"` and
 * `id="<resource>_<action>"` patterns are api-contract-I §2.7 contracts and are
 * untouched (R1) — only the container changes: each resource becomes a bordered
 * sub-panel so the reader can see where one group ends and the next begins.
 *
 * Two columns at >= 768px, one below, matching §20's A6 grid rather than
 * inventing a second breakpoint. `minmax(0, 1fr)` (never `1fr`) so a long
 * permission label cannot widen a column past the viewport (contract §5.3). */
.ds-perm-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-4);
}

.ds-perm-group {
  background-color: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-4);
  min-width: 0;
}

.ds-perm-group-title {
  margin: 0 0 var(--space-3);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-muted);
}

/* The group's own options. `.custom-control` keeps Bootstrap's absolute marker
 * positioning and every `name`/`id` it carries; only the rhythm is set here. */
.ds-perm-group .custom-control + .custom-control { margin-top: var(--space-2); }

.ds-perm-group .custom-control-label { overflow-wrap: anywhere; }

@media (max-width: 767px) {
  .ds-perm-grid { grid-template-columns: minmax(0, 1fr); gap: var(--space-3); }
  .ds-perm-group { padding: var(--space-3); }

  /* The 40px mobile tap target. §20's block does exactly this, but its hook is
   * `.ds-form-grid` and the permission matrix is NOT inside one — it is a
   * full-width block in its own card, because a grid whose only child spans
   * both columns is a grid in name only. The rules are therefore restated
   * against this section's own hook rather than the markup being bent to reach
   * them.
   *
   * Bootstrap's `.custom-control-label` is a ~20px text line whose marker is
   * pinned at `top: .25rem`, so growing the box also has to re-centre the
   * marker or it drifts to the top edge. `.custom-switch` is not involved here
   * (these are all checkboxes), so the `transform` collision §25.4b documents
   * does not arise. */
  .ds-perm-group .custom-checkbox .custom-control-label {
    display: flex;
    align-items: center;
    min-height: 40px;
  }

  .ds-perm-group .custom-checkbox .custom-control-label::before,
  .ds-perm-group .custom-checkbox .custom-control-label::after {
    top: 50%;
    transform: translateY(-50%);
  }
}

/* --- 27.3b The `#getToken` regeneration checkbox in the danger zone ------ */

/* Same 40px target for the same reason, on the one checkbox that sits outside
 * the permission grid. `.ds-danger-zone` (§10) is the card that hosts it. */
@media (max-width: 767px) {
  .ds-danger-zone .custom-checkbox .custom-control-label {
    display: flex;
    align-items: center;
    min-height: 40px;
  }

  .ds-danger-zone .custom-checkbox .custom-control-label::before,
  .ds-danger-zone .custom-checkbox .custom-control-label::after {
    top: 50%;
    transform: translateY(-50%);
  }
}

/* --- 27.4 The e-mail-template placeholder reference ---------------------- */

/* `email_template_form.php` edits a template whose body may contain `%tags%`,
 * and today nothing on the screen says which ones exist — the admin has to
 * already know. The list is rendered from the placeholders the template's own
 * stored body uses, so it states a fact about THIS record rather than inventing
 * a catalogue (R1), and it is headed by the existing `Admin.form.specialTags`
 * key so no new i18n key is added (A15).
 *
 * These are literal tokens, not prose: they must never be broken across lines,
 * because half a `%placeholder%` is not copyable. */
.ds-tag-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ds-tag {
  display: inline-flex;
  align-items: center;
  height: 28px;
  padding: 0 var(--space-3);
  border-radius: var(--radius-sm);
  background-color: var(--color-surface-inset);
  border: 1px solid var(--color-border);
  font-size: var(--text-xs);
  color: var(--color-primary);
  white-space: nowrap;
}

/* --- 27.5 The current-logo plate ----------------------------------------- */

/* `.ds-plate` (§10) is the named TC-3 carve-out — one of exactly three surfaces
 * that are light BY DESIGN (contract §4.4.3 item 3, decision D-4). The site
 * logo is admin-supplied artwork that is very often dark ink on transparency;
 * on `--color-surface` it would disappear entirely. §10 defines the plate and
 * says the owning stage attaches it; this is that attachment.
 *
 * The image keeps its baseline `max-height: 50px` inline style — a functional
 * constraint on unknown artwork, not a theme value. */
.ds-logo-plate { max-width: 100%; }

.ds-logo-plate img {
  max-height: 50px;
  max-width: 100%;
  height: auto;
}

/* --- 27.6 The status switch inside a table cell -------------------------- */

/* `email_template.php`'s per-row Enable/Disable control. §25.4b solves exactly
 * this 40px-tap-target problem for `.custom-switch`, but its hook is
 * `.ds-form-grid` and this switch lives in a `<td>`, not in a form grid, so the
 * rules cannot be reached from here. They are restated against this section's
 * own hook rather than merged, which is the precedent §25 itself set for §24.6
 * (a selector re-pointing would mean editing a view issue #78 may not touch).
 * Whichever stage next owns both should collapse them behind one hook.
 *
 * The mechanism is L-20260722-29's: Bootstrap owns `::after`'s `transform` for
 * the knob's travel (`translateX(.75rem)` when checked), and `transform` is a
 * SINGLE property — so the vertical centring has to be folded INTO the checked
 * keyframe (`translate(.75rem, -50%)`) instead of layered under it, or the knob
 * snaps to the top of the taller box the instant the switch is turned on. That
 * failure is invisible in the resting state. (0,5,0) against the vendor's
 * (0,4,0); the transition still animates because both keyframes are
 * `translate()`. */
.ds-switch-cell {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}

.ds-switch-cell .custom-switch .custom-control-label {
  display: flex;
  align-items: center;
  min-height: 40px;
}

.ds-switch-cell .custom-switch .custom-control-label::before,
.ds-switch-cell .custom-switch .custom-control-label::after {
  top: 50%;
  transform: translateY(-50%);
}

.ds-switch-cell .custom-switch .custom-control-input:checked ~ .custom-control-label::after {
  transform: translate(0.75rem, -50%);
}

/* A locked template (`status > 1`) renders a DISABLED switch. Bootstrap conveys
 * that with opacity alone, which is colour-only meaning by another name
 * (§4.1.3 rule 7) and is indistinguishable from "off" at a glance — so the row
 * also carries a "Locked" chip (§6) built from the existing `Admin.form.locked`
 * key. The chip is the accessible carrier; the opacity is decoration. */
.ds-switch-cell .ds-chip { flex-shrink: 0; }

@media (max-width: 767px) {
  /* In the stacked card the cell is a `space-between` flex row, so the switch
   * group is free to wrap onto its own line under the label rather than
   * squeezing the "STATUS" heading out. */
  .ds-switch-cell { flex: 1 1 auto; justify-content: flex-end; }
}

