/* ============================================================================
   Isnaad Certificates — design-system component classes.
   Ported from the shared Halaqat/HaramApp component set (the same classes the
   QM HelpDesk uses), driven by the tokens in tokens.css.
   Borders-not-shadows, RTL-first via logical properties.
   ============================================================================ */

/* ---------------------------------------------------------------- Card ---- */
/* NOTE: Bootstrap also defines `.card`. Ours loads after Bootstrap and wins on
   source order at equal specificity, which is intentional — every existing
   `.card` in the templates inherits the design-system look for free. */
.card {
  background: var(--bs-paper-bg); border: 1px solid var(--bs-border-color);
  border-radius: var(--r-sm); box-shadow: none;
}
.card-h {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 18px 22px; border-bottom: 1px solid var(--bs-border-color); flex-wrap: wrap;
}
.card-t { font-size: 16px; font-weight: var(--fw-extrabold); color: var(--bs-heading-color); margin: 0; }
.card-b { padding: 22px; }
.card-actions { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }

/* ---------------------------------------------------------- Stat tile ---- */
.tile {
  padding: 18px 20px; border: 1px solid var(--bs-border-color); border-radius: var(--r-sm);
  display: flex; align-items: center; gap: 16px; background: var(--bs-paper-bg);
  transition: border-color .2s; height: 100%;
}
.tile:hover { border-color: var(--bs-primary); }
.tile-ic {
  width: 50px; height: 50px; border-radius: 14px; display: flex;
  align-items: center; justify-content: center; font-size: 25px; flex: none;
}
.tile-label { font-size: 13px; font-weight: var(--fw-bold); color: var(--bs-gray-600); }
.tile-value {
  font-size: 24px; font-weight: var(--fw-extrabold); color: var(--bs-heading-color);
  line-height: 1.1; margin-top: 2px; font-variant-numeric: tabular-nums;
}
.tile-sub { font-size: 11.5px; color: var(--bs-gray-500); margin-top: 2px; }

/* Pastel stat variants — data tiles only. */
.tile-forest     .tile-ic { background: rgba(45, 90, 69, .15);   color: var(--stat-forest); }
.tile-sage       .tile-ic { background: rgba(107, 155, 122, .18); color: var(--stat-sage); }
.tile-terracotta .tile-ic { background: rgba(196, 133, 122, .18); color: var(--stat-terracotta); }
.tile-slate      .tile-ic { background: rgba(122, 155, 181, .18); color: var(--stat-slate); }
.tile-amber      .tile-ic { background: rgba(196, 168, 74, .18);  color: var(--stat-amber); }
.tile-tan        .tile-ic { background: rgba(212, 165, 116, .18); color: var(--stat-tan); }

/* ------------------------------------------------------- Page hero -------- */
.qm-hero {
  position: relative; overflow: hidden; border-radius: var(--r-md); margin-bottom: 20px;
  padding: 26px 28px; color: #fff; display: flex; align-items: center; gap: 18px;
  flex-wrap: wrap;
  background: linear-gradient(135deg, var(--bs-primary) 0%, var(--haram-primary-d1) 60%, var(--haram-primary-d2) 100%);
}
.qm-hero-ic {
  position: relative; z-index: 1; width: 54px; height: 54px; border-radius: 14px; flex: none;
  display: flex; align-items: center; justify-content: center; font-size: 28px;
  background: rgba(255, 255, 255, .14);
}
.qm-hero-body { flex: 1; min-width: 200px; position: relative; z-index: 1; }
.qm-hero-title { font-size: 22px; font-weight: var(--fw-extrabold); margin: 0; color: #fff; }
.qm-hero-sub { opacity: .85; margin: 4px 0 0; font-size: 13.5px; }
.qm-hero-stat {
  position: relative; z-index: 1; flex: none; text-align: center;
  background: rgba(255, 255, 255, .14); border-radius: var(--r-xs); padding: 10px 18px;
}
.qm-hero-stat .v { font-size: 20px; font-weight: var(--fw-extrabold); line-height: 1.1; font-variant-numeric: tabular-nums; }
.qm-hero-stat .l { font-size: 11.5px; opacity: .85; }

/* --------------------------------------------------- Inputs / buttons ---- */
.inp {
  width: 100%; height: 44px; border: 1px solid var(--bs-border-color);
  border-radius: var(--r-xs); background: var(--bs-paper-bg); padding: 0 14px;
  font-family: inherit; font-size: 14px; color: var(--bs-heading-color);
  transition: border-color .2s;
}
/* Focus THICKENS the border — the system has no focus shadow
   (DESIGN_SYSTEM.md §5: "Focus rings use border thickening instead of
   box-shadow"). box-sizing is border-box, so 1px→2px causes no reflow. */
.inp:focus { outline: none; border-color: var(--bs-primary); border-width: 2px; padding-inline: 13px; }
.inp::placeholder { color: var(--bs-gray-500); }

/* `.btn` carries GEOMETRY ONLY.
   Bootstrap remains loaded and paints button colour through its own
   `--bs-btn-*` custom properties (see bootstrap-bridge.css, where every variant
   is re-pointed at the design-system tokens). If this rule set `background` or
   `color` directly it would out-rank `background-color: var(--bs-btn-bg)` on
   source order and flatten every `.btn-outline-*` into a solid block. So the
   design-system look is expressed as size/radius/weight here, and as colour
   variables there. */
/* Heights come from the design system's own button scale
   (xs 36 / sm 44 / md 50 / lg 56). This app is data-dense, so the default sits
   at the system's `sm` step and `.btn-lg` at `md`, rather than inventing
   in-between values. */
.btn {
  height: 44px; padding: 0 20px; border-radius: var(--r-xs);
  font-family: inherit; font-size: 14px; font-weight: var(--fw-bold);
  display: inline-flex; align-items: center; gap: 8px; justify-content: center;
  text-decoration: none; white-space: nowrap; box-shadow: none;
  transition: var(--transition);
}
.btn:disabled, .btn.disabled { opacity: .55; cursor: not-allowed; }
.btn-sm { height: 36px; padding: 0 14px; font-size: 13px; border-radius: var(--r-xs); }
.btn-lg { height: 50px; padding: 0 26px; font-size: 15px; border-radius: var(--r-xs); }
.btn-block { width: 100%; }
/* An icon-only button stays square rather than stretching to the text padding. */
.btn-icon { width: 44px; padding: 0; }
.btn-icon.btn-sm { width: 36px; }

.chip {
  display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; height: 38px;
  border: 1px solid var(--bs-border-color); border-radius: var(--r-xs); font-size: 13px;
  font-weight: var(--fw-bold); color: var(--bs-gray-700); cursor: pointer;
  background: var(--bs-paper-bg); transition: all .2s; white-space: nowrap; text-decoration: none;
}
.chip:hover { border-color: var(--bs-primary); color: var(--bs-primary); }
.chip.on { background: var(--bs-primary); border-color: var(--bs-primary); color: #fff; }


/* ------------------------------------------------------------ Badges ---- */
.bdg {
  display: inline-flex; align-items: center; gap: 5px; font-size: 12px;
  font-weight: var(--fw-bold); padding: 3px 11px; border-radius: var(--r-pill);
  white-space: nowrap;
}
.bdg-ok    { background: rgba(var(--bs-success-rgb), .14); color: var(--success-text); }
.bdg-off   { background: rgba(var(--bs-danger-rgb), .14);  color: var(--danger-text); }
.bdg-warn  { background: rgba(var(--bs-warning-rgb), .18); color: var(--warning-text); }
.bdg-info  { background: rgba(var(--bs-info-rgb), .14);    color: var(--info-text); }
.bdg-muted { background: var(--bs-tertiary-bg); color: var(--bs-gray-700); }
.bdg-gold  { background: rgba(var(--bs-gold-rgb), .16); color: var(--gold-text); }
.bdg-admin { background: rgba(var(--bs-primary-rgb), .12); color: var(--bs-primary); }

/* ---- Certificate render states -------------------------------------------
   `.status-badge` and `.status-<state>` are the names the polling JS writes
   (certificate_list.html, certificate_detail.html). They are part of the
   JS contract — the class names must not change, only their appearance. */
.status-badge {
  display: inline-flex; align-items: center; gap: .4rem; padding: 3px 11px;
  border-radius: var(--r-pill); font-size: 12px; font-weight: var(--fw-bold);
  line-height: 1.5; white-space: nowrap;
}
.status-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
.status-pending    { background: rgba(var(--bs-warning-rgb), .18); color: var(--warning-text); }
.status-processing { background: rgba(var(--bs-info-rgb), .16);    color: var(--info-text); }
.status-done       { background: rgba(var(--bs-success-rgb), .14); color: var(--success-text); }
.status-failed     { background: rgba(var(--bs-danger-rgb), .14);  color: var(--danger-text); }
.status-processing .dot { animation: sb-pulse 1s ease-in-out infinite; }
@keyframes sb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }

/* ------------------------------------------------------------- Table ---- */
.tbl { width: 100%; border-collapse: collapse; font-size: 13.5px; min-width: 640px; }
.tbl th {
  text-align: start; font-weight: var(--fw-bold); color: var(--bs-secondary-color);
  padding: 13px 16px;
  /* `border-top: 0` is not redundant. In the admin shell Django's base.css
     carries `thead th, tfoot td { border: none; border-top: 1px solid
     var(--hairline-color) }`, and this rule used to declare only the BOTTOM
     edge — so that top border passed straight through and drew a second hairline
     immediately under the card's own 1px top edge, reading as one thick or
     doubled line above every header row. Stating the edge here settles it:
     `.tbl th` is (0,1,1) and outranks `thead th` at (0,0,2), because one class
     beats any number of elements. */
  border-top: 0; border-bottom: 1px solid var(--bs-border-color);
  font-size: 12px; letter-spacing: .02em; white-space: nowrap;
  background: rgba(var(--bs-primary-rgb), .03);
}
.tbl td {
  padding: 13px 16px; border-bottom: 1px solid var(--bs-border-color);
  color: var(--bs-gray-700); vertical-align: middle;
}
/* The vertical rule between columns.

   It is drawn at the FULL border token, not a lightened one. It used to be
   `var(--bs-border-color)`, and that reads
   as a reasonable "make it subtler" until you notice `--bs-border-color` is
   already `rgba(47, 43, 61, .16)`: mixing 60% of a 16%-alpha colour with
   transparent lands on alpha .096, under a tenth of an opaque line, which simply
   does not survive on a near-white cell — the columns had no visible separation
   at all. Matching the row divider's weight gives the table one consistent grid. */
/* The column divider is named PHYSICALLY, per page direction, and that is not a
   style preference — a logical edge is the wrong tool here.

   `border-inline-end` resolves against each CELL's own direction, and a cell
   holding an Arabic value carries `.arabic`, which sets `direction: rtl`. Its
   "inline end" is therefore the LEFT edge: the divider that should have sat
   between Student Name and Status was drawn on the name cell's left instead,
   doubling the line already there from the column before it and leaving the
   Name-Status boundary with no rule at all.

   Nothing is lost by naming the edge: a table's columns run with the PAGE, not
   with whatever language happens to be in one cell, so the two directions are
   stated separately and every cell in a row agrees. */
html[dir="ltr"] .tbl th:not(:last-child),
html[dir="ltr"] .tbl td:not(:last-child) {
  border-inline-end: 0;
  border-right: 1px solid var(--bs-border-color);
}
html[dir="rtl"] .tbl th:not(:last-child),
html[dir="rtl"] .tbl td:not(:last-child) {
  border-inline-end: 0;
  border-left: 1px solid var(--bs-border-color);
}
/* An .ltr cell flips its inline-end divider to the wrong physical side in an RTL
   grid, dropping the border at the LTR-to-RTL seam. Pin it to the other edge. */
[dir="rtl"] .tbl th.ltr:not(:last-child), [dir="rtl"] .tbl td.ltr:not(:last-child) {
  border-inline-end: 0;
  border-inline-start: 1px solid var(--bs-border-color);
}
.tbl tr:last-child td { border-bottom: none; }
.tbl tbody tr { transition: background .12s; }
.tbl tbody tr:hover td { background: rgba(var(--bs-primary-rgb), .03); }
.ltr { direction: ltr; }
.num { font-variant-numeric: tabular-nums; }

/* Horizontal scroll container — the table keeps a legible floor instead of
   crushing its columns into slivers on a phone. */
.tbl-wrap { width: 100%; overflow-x: auto; }
/* A table sitting flush inside a card has to be clipped to the card's corners.
   The card rounds itself, but the wrapper and the table inside it are square —
   so the header's tinted fill ran straight into both top corners and showed as
   a square shoulder outside the curve, and the last row did the same below.

   `inherit` rather than a literal radius: whatever the card is rounded to, the
   table follows. `overflow-x: auto` already makes this box a scroll container,
   so the radius clips without needing `overflow: hidden` — and nothing inside
   these tables opens a popover that the clip could cut off. Scoped to a direct
   child of a card, so a wrapper used anywhere else is untouched.

   The certificate list is the one table this cannot reach: it is swapped in by
   the AJAX filter, so it sits inside `#resultsContainer` rather than in the card
   itself, and it opens a kebab popover that a clip would cut off. Its corners
   are handled on the header cells instead — see `.tbl-wrap-certs` below. */
.card > .tbl-wrap { border-radius: inherit; }

@media (max-width: 767.98px) {
  .tbl th, .tbl td { padding-inline: 10px; }
}

/* Compact icon action buttons for table rows. */
.qm-acts { display: inline-flex; align-items: center; gap: 6px; }
.qm-acts form { display: inline; margin: 0; }
.qm-act {
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; padding: 0; border: 1px solid var(--bs-border-color);
  border-radius: var(--r-xs); background: var(--bs-paper-bg); color: var(--bs-gray-600);
  cursor: pointer; text-decoration: none; position: relative;
  transition: color .12s, border-color .12s, background .12s;
}
.qm-act:hover { color: var(--bs-primary); border-color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), .06); }
.qm-act.qm-act-view:hover { color: var(--success-text); border-color: var(--success-text); background: rgba(var(--bs-success-rgb), .08); }
.qm-act.qm-act-gold:hover { color: var(--gold-text); border-color: var(--bs-gold); background: rgba(var(--bs-gold-rgb), .10); }
.qm-act.qm-act-danger:hover { color: var(--danger-text); border-color: var(--bs-danger); background: rgba(var(--bs-danger-rgb), .08); }
.qm-act:disabled, .qm-act.disabled { opacity: .5; cursor: not-allowed; }
.qm-act i { font-size: 17px; line-height: 1; }
/* Tooltip renders above and only on hover — an always-present opacity:0 box
   would extend the scroll wrapper and show a permanent scrollbar. */
.qm-act[data-tip]::after {
  content: attr(data-tip); position: absolute; bottom: calc(100% + 7px); left: 50%;
  transform: translateX(-50%); background: var(--bs-heading-color); color: #fff;
  font-size: 11px; font-weight: var(--fw-bold); padding: 4px 8px; border-radius: 6px;
  white-space: nowrap; display: none; pointer-events: none; z-index: 30;
}
.qm-act[data-tip]:hover::after { display: block; }

/* Kebab (⋮) that hides the row actions until clicked, so the Actions column
   stays narrow. Native <details>; app.js closes it on outside-click / Escape. */
.qm-actmenu { position: relative; display: inline-block; }
.qm-actmenu > summary {
  list-style: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.qm-actmenu > summary::-webkit-details-marker { display: none; }
.qm-actmenu[open] > summary { color: var(--bs-primary); border-color: var(--bs-primary); }
/* Hidden until the <details> is open. A bare `display:flex` here would beat the
   UA rule that collapses a closed <details>, leaving the menu always visible. */
.qm-actmenu-pop {
  display: none;
  position: absolute; z-index: 40; top: calc(100% + 4px); inset-inline-end: 0;
  flex-direction: column; gap: 3px; min-width: 148px; padding: 6px;
  background: var(--bs-paper-bg); border: 1px solid var(--bs-border-color);
  border-radius: var(--r-sm); box-shadow: 0 8px 24px rgba(0, 0, 0, .14);
}
.qm-actmenu[open] > .qm-actmenu-pop { display: flex; }
.qm-actmenu-pop .qm-act {
  width: 100%; height: auto; justify-content: flex-start; gap: 8px;
  padding: 7px 9px; border: 0; border-radius: var(--r-xs);
  color: var(--bs-body-color); white-space: nowrap;
}
.qm-actmenu-pop .qm-act span { font-size: 13px; font-weight: var(--fw-medium); }
.qm-actmenu-pop .qm-act i { font-size: 16px; }

/* The certificate list uses table-layout:fixed, so on a desktop width it never
   needs the wrapper's horizontal scroll — let the kebab popover escape the box
   instead of being clipped. Phones are the opposite case, below. */
.tbl-wrap-certs { overflow: visible; }

/* Which is why this table cannot use the clip that rounds every other one: the
   popover is `position: absolute`, so any ancestor `overflow: hidden` would cut
   it off on the last rows. Without a clip the header's rectangular background
   ran straight past the card's arc and squared off both top corners — and
   because the header is `position: sticky` with a z-index it painted OVER the
   card's 1px border, erasing the curve rather than merely sitting outside it.
   Measured 6px in along the corner diagonal, well outside the 12px arc, the
   header's fill reached the page background.

   It reads as clean today only because the header background was set to
   `--bs-paper-bg` to match the card; the geometry is still wrong, and any tint,
   zebra or hover colour on the header would show it again.

   So round the header's outer cells instead of clipping the box. `:first-child`
   and `:last-child` are DOM order and the radii are logical, so the pair maps to
   the correct physical corners in both directions without an RTL branch. One
   border-width in from the card's own radius, because the header starts inside
   that border and an equal radius would leave a hairline of square corner. */
.tbl-wrap-certs .tbl-certs thead th:first-child {
  border-start-start-radius: calc(var(--r-sm) - 1px);
}
.tbl-wrap-certs .tbl-certs thead th:last-child {
  border-start-end-radius: calc(var(--r-sm) - 1px);
}

/* -------------------------------------------------------- Pagination ---- */
.pgbar {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  flex-wrap: wrap; padding: 14px 20px; border-top: 1px solid var(--bs-border-color);
  border-radius: 0 0 var(--r-sm) var(--r-sm);
}
.pgbar .pginfo { font-size: 12px; color: var(--bs-secondary-color); }
.pgbar .pginfo b { color: var(--bs-heading-color); }
.pgnums { display: inline-flex; align-items: center; gap: 6px; }
/* 36x36 with the 8px control radius. The shipped Vuexy core.css still sets
   `--bs-pagination-border-radius: 50%` (circular); the design system overrides
   that to a rounded square, and the design system wins. */
.pg {
  min-width: 36px; height: 36px; border: 1px solid var(--bs-border-color);
  background: var(--bs-paper-bg); border-radius: var(--r-xs); font-family: inherit;
  font-size: 12.5px; font-weight: var(--fw-semibold); color: var(--bs-gray-700);
  cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
  padding: 0 9px; transition: all .15s; text-decoration: none;
}
.pg.on { background: var(--bs-primary); color: #fff; border-color: var(--bs-primary); }
.pg:hover:not(.on):not(.is-disabled) { border-color: var(--bs-primary); color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), .04); }
.pg.is-disabled { opacity: .45; pointer-events: none; }

/* -------------------------------------------------------------- Tabs ---- */
.tab {
  padding: 14px 18px; font-size: 14px; font-weight: var(--fw-bold);
  color: var(--bs-gray-600); cursor: pointer; border-bottom: 2px solid transparent;
  background: none; border-top: none; border-inline: none; font-family: inherit;
}
.tab:hover { color: var(--bs-heading-color); }
.tab.on { color: var(--bs-primary); border-bottom-color: var(--bs-primary); }

/* ------------------------------------------------------------- Links ---- */
.lnk { color: var(--bs-primary); font-weight: var(--fw-bold); text-decoration: none; cursor: pointer; }
.lnk:hover { color: var(--haram-primary-dark, var(--bs-primary)); text-decoration: none; }

/* ---------------------------------------------------- Form primitives ---- */
.qm-form { display: block; }
.qm-form > * + .qm-field { margin-top: 16px; }
/* min(100%,280px) floor so a single track never exceeds its container at 320px,
   yet the row still goes two-up once it is wide enough. */
.qm-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 18px 22px; }
.qm-field { display: flex; flex-direction: column; gap: 6px; }
.qm-field.col-span-2 { grid-column: 1 / -1; }
/* A grid holding a `col-span-2` child cannot use `auto-fit`. `grid-column: 1 / -1`
   spans every track, which counts as occupying them, so an empty track never
   collapses: on a wide card the browser opened THREE tracks and the two
   half-width fields above were left with a dead 347px column beside them. An
   explicit pair of tracks is the only way to say "two up, then one full-width",
   and it drops to a single column at the same breakpoint the repeater uses. */
.qm-form-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 767.98px) {
  .qm-form-grid-2 { grid-template-columns: minmax(0, 1fr); }
}
.field-lbl,
.qm-field > label {
  font-size: 13px; font-weight: var(--fw-bold); color: var(--bs-heading-color);
  margin-bottom: 0; display: block;
}
.req { color: var(--bs-danger); margin-inline-start: 2px; }
/* `margin: 0` because a <p> carries Bootstrap's 1rem bottom margin, and this hint
   is used as a flex item beside the "Add student" button. `align-items: center`
   centres the MARGIN box, so that phantom 16px sat entirely below the text and
   pushed the line 8px above the button's centre. Where the hint instead follows
   a card title it needs a small top gap of its own, because `.card-t` is
   margin-free too and the two lines would otherwise touch. */
.qm-hint {
  margin: 0; font-size: 12.5px; color: var(--bs-gray-600);
  display: flex; align-items: center; gap: 6px;
}
.card-t + .qm-hint { margin-top: 4px; }
.qm-field-error { font-size: 12.5px; color: var(--danger-text); font-weight: var(--fw-bold); }
.inp.is-invalid, .qm-input.is-invalid { border-color: var(--bs-danger); }

/* A numbered step heading inside a form. */
.qm-step { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.qm-step-n {
  width: 26px; height: 26px; border-radius: 50%; flex: none;
  background: rgba(var(--bs-primary-rgb), .12); color: var(--bs-primary);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 13px; font-weight: var(--fw-extrabold);
}
.qm-step-t { font-size: 15px; font-weight: var(--fw-extrabold); color: var(--bs-heading-color); margin: 0; }

/* ------------------------------------------------------- Empty state ---- */
/* Empty state, per the system's section 36 (design-system-preview.html:1932-1943).
   The glyph sits in a 62px tinted tile rather than floating bare - the tile is
   what stops an empty panel reading as a rendering failure. The <i> IS the tile,
   so the nine call sites keep their markup unchanged. Measure stays at the
   reference's 380px (Support System components.css:317) rather than the
   preview's 260px: Arabic empty-state copy is longer and 260px wraps it to four
   ragged lines. */
.qm-empty {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  padding: 40px 24px; text-align: center;
}
.qm-empty i {
  width: 62px; height: 62px; flex: none;
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--r-sm);
  background: var(--bs-gray-100);
  font-size: 1.625rem; color: var(--bs-gray-400);
  margin-bottom: 4px;
}
.qm-empty b { font-size: var(--fs-base); font-weight: var(--fw-bold); color: var(--bs-heading-color); }
.qm-empty p { margin: 0; max-width: 380px; color: var(--bs-gray-500); font-size: var(--fs-sm); line-height: 1.6; }

/* ---------------------------------------------------- Loading states ---- */
.qm-spinner {
  display: inline-block; width: 1em; height: 1em; vertical-align: -.125em;
  border: 2px solid currentColor; border-inline-start-color: transparent;
  border-radius: 50%; animation: qm-spin .75s linear infinite;
}
.qm-spinner-lg { width: 2rem; height: 2rem; border-width: 3px; }
@keyframes qm-spin { to { transform: rotate(360deg); } }
.qm-loading {
  display: flex; flex-direction: column; align-items: center; gap: 12px;
  padding: 32px 16px; text-align: center; color: var(--bs-secondary-color);
}
.qm-loading .qm-spinner { color: var(--bs-primary); }
/* The AJAX table swap dims the region while it loads. */
.is-busy { opacity: .5; transition: opacity .15s ease; }

/* ------------------------------------------------------------ Alerts ---- */
.qm-alert {
  display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px;
  border: 1px solid var(--bs-border-color); border-inline-start: 4px solid var(--bs-primary);
  border-radius: var(--r-sm); background: var(--bs-tertiary-bg);
  color: var(--bs-body-color); font-size: .92rem; margin-bottom: 16px;
}
.qm-alert i { font-size: 20px; flex: none; color: var(--bs-primary); }
.qm-alert-body { flex: 1; min-width: 0; }
.qm-alert-close {
  background: none; border: 0; color: inherit; opacity: .6; cursor: pointer;
  font-size: 18px; line-height: 1; padding: 2px; display: inline-flex; flex: none;
}
.qm-alert-close:hover { opacity: 1; }
.qm-alert--success { border-inline-start-color: var(--bs-success); background: rgba(var(--bs-success-rgb), .08); color: var(--success-text); }
.qm-alert--success i { color: var(--bs-success); }
.qm-alert--warning { border-inline-start-color: var(--bs-warning); background: rgba(var(--bs-warning-rgb), .12); color: var(--warning-text); }
.qm-alert--warning i { color: var(--bs-warning); }
.qm-alert--danger  { border-inline-start-color: var(--bs-danger);  background: rgba(var(--bs-danger-rgb), .08);  color: var(--danger-text); }
.qm-alert--danger i { color: var(--bs-danger); }
.qm-alert--info    { border-inline-start-color: var(--bs-info);    background: rgba(var(--bs-info-rgb), .08);    color: var(--info-text); }
.qm-alert--info i { color: var(--bs-info); }

/* ------------------------------------------------------------- Modal ---- */
/* A native <dialog>: ESC and the focus trap come free from the element, so the
   confirm dialog needs no Bootstrap JS. */
.qm-modal {
  /* fixed + inset:0 + margin:auto keeps a showModal() <dialog> centred in the
     viewport no matter how far the page is scrolled — `position: relative`
     pinned it to the scroll offset instead. */
  position: fixed; inset: 0; margin: auto;
  width: min(480px, calc(100vw - 32px)); max-height: min(86vh, 720px);
  overflow-y: auto; padding: 24px; border: 1px solid var(--bs-border-color);
  border-radius: var(--r-md); background: var(--bs-paper-bg); color: var(--bs-body-color);
}
.qm-modal::backdrop { background: rgba(0, 0, 0, .45); }
.qm-modal-x {
  position: absolute; inset-inline-end: 14px; top: 14px; width: 32px; height: 32px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--bs-border-color); border-radius: var(--r-xs);
  background: var(--bs-paper-bg); color: var(--bs-gray-600); cursor: pointer; font-size: 16px;
}
.qm-modal-x:hover { color: var(--bs-danger); border-color: var(--bs-danger); }
.qm-modal-h { text-align: center; margin: 0 0 14px; padding: 0 26px; }
.qm-modal-h .ic {
  width: 52px; height: 52px; border-radius: 50%; margin: 0 auto 12px;
  display: flex; align-items: center; justify-content: center; font-size: 26px;
  background: rgba(var(--bs-danger-rgb), .12); color: var(--bs-danger);
}
.qm-modal-h h3 { margin: 0 0 4px; font-size: 1.1rem; color: var(--bs-heading-color); }
.qm-modal-h p { margin: 0; font-size: .85rem; color: var(--bs-secondary-color); }
.qm-modal-f { display: flex; gap: 8px; justify-content: center; margin-top: 18px; flex-wrap: wrap; }
.qm-modal-b { display: grid; gap: 12px; text-align: start; margin: 0; }
.qm-modal-b .qm-hint.val-danger { color: var(--danger-text, #b00020); }

/* ------------------------------------------------ Certificate pledge ---- */
/* The generate form issues one certificate per student row, and the serial it
   allocates to each cannot be handed back, so the submit is gated behind an
   explicit undertaking rather than a bare "are you sure".

   Only the body parts are new — the shell, backdrop, close button and footer
   are .qm-modal's, so this dialog inherits the same focus trap and Escape
   behaviour every other one has. */
.qm-pledge-lbl {
  display: block; margin: 0 0 6px; font-size: 12px; font-weight: var(--fw-bold);
  color: var(--bs-secondary-color);
}
/* The names the operator actually typed, numbered, so what is being signed for
   is on screen rather than a count they have to trust.

   The list scrolls INSIDE its own box rather than growing the dialog, and it is
   deliberately not paginated: the point of showing the names is that the
   operator can take them in before accepting responsibility, and paging hides
   most of them behind a click. A viewport-relative cap gives roughly eight rows
   on a laptop and more on a large screen, while .qm-modal's own max-height
   keeps the pledge and both buttons reachable however many names there are.
   Nothing caps the row count — neither the form nor the view — so fifty is
   reachable and has to stay usable. */
.qm-names {
  margin: 0; padding: 0; list-style: none; counter-reset: qm-name;
  max-height: min(40vh, 320px); overflow-y: auto; overscroll-behavior: contain;
  border: 1px solid var(--bs-border-color); border-radius: var(--r-sm);
}
.qm-names li {
  display: flex; align-items: center; gap: 10px; padding: 9px 12px;
  font-size: 13.5px; color: var(--bs-heading-color);
  border-bottom: 1px solid var(--bs-border-color);
}
.qm-names li:last-child { border-bottom: none; }
.qm-names li::before {
  counter-increment: qm-name; content: counter(qm-name);
  flex: none; width: 20px; height: 20px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 11px; font-variant-numeric: tabular-nums;
  background: var(--bs-tertiary-bg); color: var(--bs-secondary-color);
}
/* A <label>, so the sentence is part of the hit target rather than a 16px box
   being the only way to accept. */
.qm-pledge {
  display: flex; align-items: flex-start; gap: 10px; cursor: pointer;
  margin-top: 14px; padding: 12px 14px;
  border: 1px solid var(--bs-border-color); border-radius: var(--r-sm);
  background: var(--bs-tertiary-bg);
}
.qm-pledge:has(input:checked) { border-color: var(--bs-primary); }
.qm-pledge input {
  flex: none; width: 16px; height: 16px; margin: 2px 0 0;
  accent-color: var(--bs-primary); cursor: pointer;
}
.qm-pledge span { font-size: 13px; line-height: 1.65; }


/* --------------------------------------------------- Collapsible disc ---- */
.qm-disc { border: 1px solid var(--bs-border-color); border-radius: var(--r-sm); background: var(--bs-paper-bg); overflow: hidden; }
.qm-disc + .qm-disc { margin-top: 12px; }
.qm-disc > summary {
  display: flex; align-items: center; gap: 12px; padding: 16px 20px;
  cursor: pointer; list-style: none;
}
.qm-disc > summary::-webkit-details-marker { display: none; }
.qm-disc > summary:hover { background: var(--bs-tertiary-bg); }
.qm-disc .ic {
  flex: none; width: 34px; height: 34px; border-radius: var(--r-xs);
  background: rgba(var(--bs-primary-rgb), .1); color: var(--bs-primary);
  display: flex; align-items: center; justify-content: center; font-size: 18px;
}
.qm-disc .ttl { flex: 1; font-size: 15px; font-weight: var(--fw-bold); color: var(--bs-heading-color); }
.qm-disc .chev { flex: none; font-size: 18px; color: var(--bs-gray-400); transition: transform .2s ease-in-out; }
.qm-disc[open] .chev { transform: rotate(180deg); }
.qm-disc-body { border-top: 1px solid var(--bs-border-color); padding: 18px 20px; }

/* ---------------------------------------------------- Definition list ---- */
/* The certificate-detail key/value table, as a real description list. */
.qm-dl { display: grid; grid-template-columns: auto 1fr; gap: 0; margin: 0; }
.qm-dl > dt, .qm-dl > dd {
  padding: 11px 0; border-bottom: 1px solid var(--bs-border-color); margin: 0;
  font-size: 13.5px; display: flex; align-items: center;
}
.qm-dl > dt { font-weight: var(--fw-bold); color: var(--bs-secondary-color); padding-inline-end: 18px; white-space: nowrap; }
.qm-dl > dd { color: var(--bs-heading-color); justify-content: flex-start; overflow-wrap: anywhere; }
.qm-dl > dt:last-of-type, .qm-dl > dd:last-of-type { border-bottom: none; }

/* -------------------------------------------------- Certificate media ---- */
.certificate-preview {
  max-width: 100%; height: auto; border: 1px solid var(--bs-border-color);
  border-radius: var(--r-sm); box-shadow: none; display: block; margin-inline: auto;
}

/* ------------------------------------------------------ Repeater rows ---- */
/* The multi-student entry list on the generate page. */
.qm-repeat { display: flex; flex-direction: column; gap: 10px; }
.qm-repeat-row {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 42px;
  gap: 10px; align-items: center;
}
/* The head and the rows are two SEPARATE grids, so they only line up while their
   track lists are identical — the 2px inline padding that used to be here made
   every head track 2px narrower than the row's, and each label stopped sitting
   over its own input. The 6px below is `.qm-field`'s label-to-control gap, which
   every other labelled control on the page already uses; without it the headers
   sat flush on the inputs with no gap at all. */
.qm-repeat-head {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 42px;
  gap: 10px; margin-bottom: 6px; font-size: 12.5px; font-weight: var(--fw-bold);
  color: var(--bs-secondary-color);
}
@media (max-width: 767.98px) {
  .qm-repeat-head { display: none; }
  .qm-repeat-row {
    grid-template-columns: minmax(0, 1fr) 42px;
    grid-template-areas: "name remove" "mail mail";
    padding: 12px; border: 1px solid var(--bs-border-color); border-radius: var(--r-xs);
  }
  .qm-repeat-row > :nth-child(1) { grid-area: name; }
  .qm-repeat-row > :nth-child(2) { grid-area: mail; }
  .qm-repeat-row > :nth-child(3) { grid-area: remove; }
}

/* ============================================================================
   LAYOUT PRIMITIVES
   ----------------------------------------------------------------------------
   A deliberately small set of composable classes, added so the templates can
   express "a stack", "a responsive card grid" or "an end-aligned numeric cell"
   without reaching for a style="" attribute. Utility classes are used only
   where a named component would be overkill; anything with its own identity
   (card, tile, table, badge) stays a component above.
   ============================================================================ */

/* Vertical stack — the default page rhythm. */
.qm-stack   { display: flex; flex-direction: column; gap: var(--sp-5); }
.qm-stack-sm { display: flex; flex-direction: column; gap: var(--sp-3); }
.qm-stack-lg { display: flex; flex-direction: column; gap: var(--sp-6); }

/* Responsive auto-fit grids. The min() floor keeps a single column from
   exceeding its container at 320px. */
.qm-grid       { display: grid; gap: var(--sp-5); grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); }
.qm-grid-wide  { display: grid; gap: var(--sp-5); grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); align-items: start; }
.qm-grid-tiles { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }
/* Two tiles under the plain auto-fit grid take half the page each — far more box
   than an icon and a four-digit number need, and nothing like the size the same
   tile has on the statistics page, where three of them share the row. Capping
   the pair at the width three-across would give them keeps them the same object
   on both screens. Below that the cap stops applying and they shrink, then stack,
   on their own. */
.qm-grid-tiles-2 { max-width: calc(340px * 2 + var(--sp-4)); }
.qm-grid-compact { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); }

/* A content column that should not run to the full 1392px. */

/* Inline row of actions. */
.qm-row { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }

/* Text/number alignment and emphasis inside tables and definition lists. */
.ta-end    { text-align: end; }
.ta-center { text-align: center; }
.val-strong { font-weight: var(--fw-bold); color: var(--bs-heading-color); }
.val-gold   { font-weight: var(--fw-bold); color: var(--gold-text); }
.val-muted  { color: var(--bs-gray-500); }

/* Flex children that must be allowed to shrink and ellipsize. */
.minw0 { min-width: 0; }
.grow  { flex: 1; min-width: 0; }

/* The big numeral on an error page. */
.qm-errcode {
  font-size: 2.25rem; font-weight: var(--fw-extrabold);
  color: var(--bs-primary); line-height: 1;
}
.qm-erricon { font-size: 44px; }

/* A form that is only a wrapper (e.g. the logout POST inside a menu) should not
   introduce its own spacing. */
.qm-usermenu-pop form, .qm-sidebar-usermenu form { margin: 0; }

/* Error-page block + the danger-tinted icon used in disabled row actions. */
.qm-errblock { padding-block: 22px 8px; }
.val-danger  { color: var(--danger-text); }

/* Spacing helpers used by the page bodies. */
.qm-row-mt   { margin-top: var(--sp-3); }
.mt-block    { margin-top: var(--sp-5); }
.mt-block-lg { margin-top: var(--sp-6); }

/* The list page's filter card is denser than the default card body. */
.qm-filters       { padding: 18px 20px; }
.qm-filters-search { margin-bottom: 14px; }
.qm-filters .qm-form-grid { gap: 12px 16px; }
/* The reset button sits on the field baseline, not the label baseline. */
.qm-field-action { justify-content: flex-end; }

/* Optional-field hint inside a repeater column header. */
.lbl-optional { font-weight: var(--fw-normal); }

/* A step heading that is already inside a card header needs no bottom margin. */
.qm-step-flush { margin-bottom: 0; }
/* Fixed-width row-number column. */
.col-idx { width: 56px; }

/* ---------------------------------------------------- Icon input (in-shell) ----
   An input with a trailing glyph, at the app's 44px control height.

   The list page was borrowing `.qm-auth-input` for this, which is declared
   inside the auth-screen block and pins its field to 48px for the taller login
   card. Borrowing it put a 48px search next to 38px selects and a 36px button -
   three heights in one row. This is the same affordance at the shell's height,
   so the filter bar sits on a single line.

   The icon-side padding is PHYSICAL and resolved from the page direction rather
   than the input's own, matching the auth version: an input holding Arabic can
   carry its own dir, and the glyph must stay on the page's inline end. */
.qm-input-ic { position: relative; }
.qm-input-ic > i {
  position: absolute; inset-inline-end: 14px; top: 50%; transform: translateY(-50%);
  color: var(--bs-gray-500); font-size: 18px; pointer-events: none;
}
html[dir="rtl"] .qm-input-ic .inp { padding-left: 42px; padding-right: 14px; }
html[dir="ltr"] .qm-input-ic .inp { padding-right: 42px; padding-left: 14px; }

/* ============================================================================
   §40  SUPPORT-SYSTEM PARITY PRIMITIVES
   ----------------------------------------------------------------------------
   Four building blocks the reference helpdesk uses on nearly every inner page
   that this app had no equivalent for. They are copied at the reference's own
   values so a screen built from them lands identical in both products.
   ============================================================================ */

/* ---- Outline button ---------------------------------------------------------
   The reference's secondary action. This app had `.btn-outline-secondary` from
   Bootstrap, whose hover INVERTS to a filled grey; `.btn-o` keeps the fill and
   moves the border and label to primary, which is the behaviour every other
   bordered control here already has (tile, chip, filter summary). */
.btn-o {
  background: var(--bs-paper-bg);
  color: var(--bs-gray-700);
  border: 1px solid var(--bs-border-color);
}
.btn-o:hover, .btn-o:focus-visible { border-color: var(--bs-primary); color: var(--bs-primary); opacity: 1; }
.btn-o[aria-current="page"], .btn-o.on {
  border-color: var(--bs-primary);
  background: rgba(var(--bs-primary-rgb), .07);
  color: var(--bs-primary);
}

/* ---- Pager ------------------------------------------------------------------
   A footer band inside the card: a count on one side, the page buttons on the
   other. `.pgbar-bare` drops the band when the pager sits outside a card. */
.pgbar {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; flex-wrap: wrap;
  padding: 14px 20px;
  border-top: 1px solid var(--bs-border-color);
  border-radius: 0 0 var(--r-sm) var(--r-sm);
}
.pgbar.pgbar-bare { border-top: none; border-radius: 0; padding: 14px 0 0; }
.pgbar .pginfo { font-size: 12px; color: var(--bs-secondary-color); }
.pgbar .pginfo b { color: var(--bs-heading-color); }
.pg {
  min-width: 32px; height: 32px; padding: 0 9px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--bs-border-color); border-radius: 6px;
  background: var(--bs-paper-bg);
  color: var(--bs-gray-700);
  font-family: inherit; font-size: 12.5px; font-weight: var(--fw-semibold);
  text-decoration: none; cursor: pointer; transition: all .15s;
}
.pg.on { background: var(--bs-primary); border-color: var(--bs-primary); color: #fff; }
.pg:hover:not(.on) { border-color: var(--bs-primary); color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), .04); }
.pg.disabled, .pg:disabled { opacity: .45; pointer-events: none; }
/* The count-and-sizes group on the pager's leading edge. */
.pgbar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* The gap marker between page runs is a label, not a button. */
.pgbar .pg-gap { border: 0; background: none; min-width: 0; padding: 0 2px; cursor: default; }
.pgsize {
  min-width: 30px; height: 30px; padding: 0 9px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--bs-border-color); border-radius: 6px;
  background: var(--bs-paper-bg);
  color: var(--bs-gray-700);
  font-size: 12px; font-weight: var(--fw-bold);
  text-decoration: none; cursor: pointer; transition: all .15s;
}
.pgsize.on { background: var(--bs-primary); border-color: var(--bs-primary); color: #fff; }
.pgsize:hover:not(.on) { border-color: var(--bs-primary); color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), .04); }

/* ---- Fixed-layout table -----------------------------------------------------
   For tables whose columns are declared as percentages. Without it one long
   unbroken cell (a translation key, a URL) widens its column past the width the
   header asked for and the rest of the row collapses to fit. */
.tbl-fixed { table-layout: fixed; }
.tbl-fixed th { white-space: normal; }
.tbl-fixed td { overflow-wrap: anywhere; word-break: break-word; }
.tbl-fixed td.nowrap { white-space: nowrap; overflow-wrap: normal; word-break: normal; }

/* ---- Switch -----------------------------------------------------------------
   The checkbox itself stays in the DOM and keeps the focus ring; the track is
   painted next to it. No shadow: the off state is a hairline border on the
   tertiary fill, exactly like an unfocused input. */
.qm-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
.qm-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.qm-switch .track {
  position: relative; flex: none;
  width: 38px; height: 22px; border-radius: 50rem;
  border: 1px solid var(--bs-border-color);
  background: var(--bs-tertiary-bg);
  transition: background .18s ease, border-color .18s ease;
}
.qm-switch .track::after {
  content: ''; position: absolute; top: 2px; inset-inline-start: 2px;
  width: 16px; height: 16px; border-radius: 50%;
  background: var(--bs-paper-bg);
  border: 1px solid var(--bs-border-color);
  transition: transform .18s ease;
}
.qm-switch input:checked + .track { background: var(--bs-primary); border-color: var(--bs-primary); }
.qm-switch input:checked + .track::after { transform: translateX(16px); border-color: transparent; }
html[dir="rtl"] .qm-switch input:checked + .track::after { transform: translateX(-16px); }
.qm-switch input:focus-visible + .track { border-color: var(--bs-primary); border-width: 2px; }

/* ============================================================================
   §41  ADMIN HUB
   ----------------------------------------------------------------------------
   The dashboard's tiles: a subject heading, then a responsive grid of equal
   destinations. Values are the reference helpdesk's own — 230px minimum column,
   14px gutter, 44px icon square on a 10% primary wash.

   `.tile` already supplies the border, radius, paper fill and hover; only the
   hub's own spacing and the fixed icon tint are declared here.
   ============================================================================ */
.qm-hub-section {
  display: flex; align-items: center; gap: 8px;
  padding: 8px 2px 10px; font-size: 12px;
}
.qm-hub-section i { font-size: 15px; color: var(--bs-primary); }
.qm-hub-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 230px), 1fr));
  gap: 14px;
  margin-bottom: 22px;
}
.qm-hub-tile { gap: 14px; padding: 16px 18px; text-decoration: none; }
.qm-hub-ic {
  width: 44px; height: 44px; font-size: 22px; flex: none;
  background: rgba(var(--bs-primary-rgb), .10);
  color: var(--bs-primary);
}
.qm-hub-label {
  min-width: 0;
  font-size: 14px; font-weight: var(--fw-bold);
  color: var(--bs-heading-color);
}

/* ---- Utilities the admin needs but Bootstrap is not there to supply ---------
   The admin shell loads tokens + shell + components + admin.css and no
   Bootstrap, so three utilities the app templates use freely were simply inert
   on every admin page. Declared identically to Bootstrap's own, so on the app
   side (where Bootstrap loads first) this restates rather than overrides. */
.d-block { display: block !important; }
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sr-only, .visually-hidden {
  position: absolute !important; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

/* ============================================================================
   §42  TAB STRIP, CARD ICON, COUNT PILL
   ----------------------------------------------------------------------------
   The reference helpdesk's tab strip: the active tab is a filled `.btn` and the
   rest are `.btn-o`, so no new button treatment is invented — the strip is only
   the row they sit in. Wrapping, because four labels of this length do not fit
   one line on a tablet.
   ============================================================================ */
.qm-tabs {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
/* The tabs are section switches, not primary actions, so they sit at the same
   height as a small button rather than the 44px of a submit. */
.qm-tabs .btn { height: 38px; padding: 0 16px; font-size: 13px; }
.qm-tabs .btn i { font-size: 16px; }

/* A card heading that carries an icon, matching the collapsible panel's. */
.card-t-ic { display: inline-flex; align-items: center; gap: 12px; }
.card-ic {
  flex: none; width: 34px; height: 34px; border-radius: var(--r-xs);
  background: rgba(var(--bs-primary-rgb), .1); color: var(--bs-primary);
  display: flex; align-items: center; justify-content: center; font-size: 18px;
}

/* How many rows are under this heading — the same pill the reference puts beside
   a section title. `.card-h` is `justify-content: space-between`, so it lands at
   the inline end on its own. */
.qm-count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 26px; height: 24px; padding: 0 9px;
  border-radius: var(--r-pill);
  /* The primary tint, not `--bs-tertiary-bg`: #f3f2f3 against a white card is a
     3% step and the pill simply did not read as one. This is the same fill the
     design system's own `.bdg-admin` count badge uses. */
  background: rgba(var(--bs-primary-rgb), .10);
  color: var(--bs-primary);
  font-size: 12px; font-weight: var(--fw-bold);
  font-variant-numeric: tabular-nums;
}

/* ---- Statistics tables ------------------------------------------------------
   A rank column and a count column, both fixed, with the name columns taking the
   remainder. `.tbl` carries `min-width: 640px` for the wide certificate table;
   these have three or four short columns and do not need it, and inheriting it
   forced a horizontal scrollbar inside the card at tablet widths. */
.tbl-stats { min-width: 0; }
.tbl-stats .col-idx { width: 52px; }
.tbl-stats .col-count { width: 108px; }
.tbl-stats td.col-idx { font-size: 12.5px; }
.tbl-stats .val-strong { font-size: 14px; }

/* `.arabic` sets `direction: rtl`, which also flips what `text-align: start`
   means — so an Arabic name sat against the FAR edge of a 600px column while its
   header stayed at the near one, and the two stopped looking related.
   `direction: inherit` puts the cell back on the table's axis so both align to
   the same edge; `unicode-bidi: isolate` keeps the Arabic itself laying out
   right-to-left inside the cell, which is the part that actually matters. */
.tbl-stats td.arabic {
  direction: inherit;
  unicode-bidi: isolate;
}

/* ============================================================================
   §43  SELECT MENU
   ----------------------------------------------------------------------------
   A <select> rebuilt as the same dropdown the admin's filters are: a chip that
   opens a popover, with a search box inside once the list is long enough to need
   one.

   A native option list cannot be styled — the browser draws it as an OS widget —
   so matching the rest of the system means replacing the widget, not restyling
   it. app.js builds this markup from the real <select>, which stays in the DOM
   and stays the thing that gets read and submitted.

   Shared, not admin-scoped: the certificate list and the admin action bar are
   the same control and now the same code.
   ============================================================================ */
.qm-menu { position: relative; display: flex; align-items: center; }
.qm-menu > summary {
  display: inline-flex; align-items: center; gap: 8px;
  width: 100%; min-height: 44px; padding: 0 14px;
  border: 1px solid var(--bs-border-color); border-radius: var(--r-xs);
  background: var(--bs-paper-bg); color: var(--bs-heading-color);
  font-size: 14px; cursor: pointer; list-style: none;
  transition: border-color .15s;
}
.qm-menu > summary::-webkit-details-marker { display: none; }
.qm-menu > summary::marker { content: ''; }
.qm-menu > summary:hover { border-color: var(--bs-primary); }
.qm-menu[open] > summary { border-color: var(--bs-primary); }
/* Focus thickens the border and the padding gives the pixel back, so the chip
   does not shift — the same contract every other control here has. */
.qm-menu > summary:focus-visible {
  border-color: var(--bs-primary); border-width: 2px; padding: 0 13px; outline: none;
}
.qm-menu-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; }
/* A chip holding a real choice reads as applied; the placeholder does not. */
.qm-menu.is-set > summary { border-color: var(--bs-primary); color: var(--bs-primary); font-weight: var(--fw-bold); }
.qm-menu-caret { flex: none; font-size: 16px; color: var(--bs-gray-500); transition: transform .15s ease; }
.qm-menu[open] .qm-menu-caret { transform: rotate(180deg); }

.qm-menu > ul {
  position: absolute; z-index: 40; top: calc(100% + 6px);
  inset-inline-start: 0; min-width: 100%;
  max-width: min(360px, 90vw); max-height: 320px;
  /* Both axes are named. Setting only `overflow-y` leaves `overflow-x` at
     `visible`, and the spec then computes it to `auto` — so the search row's
     negative side margins, which are there to make its divider span the popover,
     counted as 6px of horizontal overflow and raised a scrollbar under the list.
     Options wrap, so there is never anything to scroll sideways to. */
  overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain;
  margin: 0; padding: 6px; list-style: none;
  border: 1px solid var(--bs-border-color); border-radius: var(--r-sm);
  background: var(--bs-paper-bg);
  scrollbar-width: thin;
}
.qm-menu > ul > li { margin: 0; padding: 0; }
.qm-menu > ul > li > button {
  display: block; width: 100%; margin: 0;
  padding: 8px 10px; border: 0; border-radius: var(--r-xs);
  background: none; color: var(--bs-body-color);
  font-family: inherit; font-size: 13px; line-height: 1.35;
  text-align: start; cursor: pointer;
}
.qm-menu > ul > li > button:hover { background: rgba(var(--bs-primary-rgb), .06); color: var(--bs-primary); }
.qm-menu > ul > li.selected > button { background: rgba(var(--bs-primary-rgb), .10); color: var(--bs-primary); font-weight: var(--fw-bold); }

/* The search sticks to the top so it stays reachable while the list scrolls
   under it, and stays opaque so options do not show through behind it.

   Written as `li.qm-menu-search`, not `.qm-menu-search`: these rows ARE the
   `.qm-menu > ul > li` zeroed above, and that selector is (0,1,2) against a bare
   class's (0,1,0) — so it won, and this row rendered with no padding and no
   margin at all. The negative inline margin (which cancels the popover's own
   6px so the divider spans its full width) and the 10px below it only exist
   once the selector outweighs the reset. Same for the empty-state row. */
.qm-menu > ul > li.qm-menu-search {
  position: sticky; top: -6px; z-index: 1;
  display: flex; align-items: center; gap: 8px;
  margin: -6px -6px 10px; padding: 10px 12px;
  border-bottom: 1px solid var(--bs-border-color);
  background: var(--bs-paper-bg);
}
.qm-menu-search > i { flex: none; font-size: 15px; color: var(--bs-gray-500); }
.qm-menu-search input {
  flex: 1; min-width: 0; height: 28px; margin: 0; padding: 0;
  border: 0; background: none; outline: none;
  font-family: inherit; font-size: 13px; color: var(--bs-heading-color);
}
.qm-menu > ul > li.qm-menu-empty {
  padding: 14px 10px; color: var(--bs-gray-500); font-size: 12.5px; text-align: center;
}

/* ---- Certificate list table -------------------------------------------------
   Fixed layout with measured shares, so the eight columns fit the content
   column instead of compressing the two free-text ones into two- and three-line
   cells. Values clip; the row stays one line and every row the same height.

   `unicode-bidi: plaintext` is the same fix the admin's results table needed: a
   cell defaults to the PAGE's direction, so on an English page the ellipsis went
   to the LTR end — the visual right — which is where an Arabic string STARTS.
   Every clipped name lost its beginning and kept its tail. Resolved per cell, an
   Arabic cell reads right-to-left and clips at its own end. */
.tbl-certs { table-layout: fixed; min-width: 0; }
.tbl-certs th, .tbl-certs td { padding: 12px; }
/* Header stays put while the rows scroll. Opaque background (the shared .tbl th
   tint is nearly transparent) so rows don't bleed through, and an inset shadow
   for the bottom hairline since a collapsed border can drop out when stuck. */
.tbl-certs thead th {
  position: sticky; top: 0; z-index: 6;
  background: var(--bs-paper-bg);
  box-shadow: inset 0 -1px 0 var(--bs-border-color);
}
.tbl-certs tbody td {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  unicode-bidi: plaintext;
}
/* The actions cell holds buttons, not text — clipping it would cut one in half. */
.tbl-certs tbody td.js-actions { overflow: visible; }
/* Alignment and bidi are separate problems and need separate answers.
   `plaintext` resolves each cell's DIRECTION from its content, which is what puts
   the ellipsis at the end of an Arabic string instead of its beginning — but it
   also makes `text-align: start` resolve to the right, so a name sat against the
   far edge of its column while the header stayed at the near one. Naming the edge
   physically, per page direction, keeps the header and the value on the same side
   without giving up the bidi resolution. Measured: the text now begins at the
   cell's start edge and a clipped template still loses its tail, not its head. */
html[dir="ltr"] .tbl-certs tbody td { text-align: left; }
html[dir="rtl"] .tbl-certs tbody td { text-align: right; }
html[dir="ltr"] .tbl-certs tbody td.ta-end { text-align: right; }
html[dir="rtl"] .tbl-certs tbody td.ta-end { text-align: left; }
/* Centring has to outrank those direction rules, and used to lose to them: they
   are (0,2,3) against this rule's (0,2,2), and specificity is compared before
   source order, so `.ta-center` was dead on every body cell no matter where it
   sat in the file. The actions column showed it — a centred header over
   right-aligned buttons, measured 44px apart. Matching the `html[dir]` prefix
   takes this to (0,3,3) and it wins; the bare attribute selector deliberately
   does not name a direction, so one rule covers both. */
html[dir] .tbl-certs th.ta-center,
html[dir] .tbl-certs tbody td.ta-center { text-align: center; }
.tbl-certs th { white-space: nowrap; }
.tbl-certs .col-idx    { width: 4.5%; }
.tbl-certs .col-name   { width: 18%; }
.tbl-certs .col-status { width: 11%; }
.tbl-certs .col-cert   { width: 8%; }
.tbl-certs .col-global { width: 7.5%; }
.tbl-certs .col-sheikh { width: 14%; }
.tbl-certs .col-qiraa  { width: 9.5%; }
.tbl-certs .col-ravi   { width: 9.5%; }
.tbl-certs .col-when   { width: 12%; }
.tbl-certs .col-acts   { width: 6%; }
/* The status badge is never cut: it is short, and an ellipsis beside a whole
   badge read as a stray "…". */
.tbl-certs tbody td.col-status { overflow: visible; text-overflow: clip; }
/* Clipped cells: one line with an ellipsis; the full text is the tooltip, and a
   click opens the cell in place. */
.tbl-certs tbody td.clip { cursor: pointer; }
.tbl-certs tbody td.clip.is-open { white-space: normal; overflow: visible; text-overflow: clip; overflow-wrap: anywhere; }

/* Phones: eight columns cannot share 360px. `table-layout: fixed` had been
   dividing whatever space existed by the percentage widths, so at 390px the
   status, both serials, the template and the date all rendered as an ellipsis
   and a character or two — measured 28.8px for a six-digit number. `.tbl`
   already carries a 640px floor for exactly this reason; `.tbl-certs` set
   `min-width: 0` to opt out of it, which is right only while the table has room.

   920px is what the columns actually need, measured rather than guessed: at the
   640px floor `.tbl` uses, the serials fit but the status badge and the date
   still clipped — the date wants ~100px against an 11% share, which is what
   sets the number. The template column is left free to ellipsis, as it does at
   every width; that one is by design.

   The breakpoint is 991.98px, not the 767.98px used elsewhere in this file,
   because the columns were clipping well past phone widths: at a 768px viewport
   three of them were cut with no way to scroll to the rest. From 992px up the
   table has the room and nothing changes — no floor binding, no scroll
   container, and so no clip on the kebab popover. */
@media (max-width: 991.98px) {
  .tbl-wrap-certs { overflow-x: auto; }
  .tbl-certs { min-width: 1040px; }
  /* Scrolling costs the kebab popover its escape route: `overflow-x: auto`
     forces the computed `overflow-y` to `auto` as well — the two cannot
     disagree unless the other is `clip` — so the box that now scrolls sideways
     also clips downwards, and on the last row the menu was cut 97px short.

     Opening room only while a menu is open keeps the menu exactly as it is on
     desktop, and leaves no dead space under the table the rest of the time.
     `:has()` is how this file already asks that question. */
  .tbl-wrap-certs:has(.qm-actmenu[open]) { padding-bottom: 120px; }
}


/* ---- Certificate list filter row --------------------------------------------
   One row: Book, the three short filters, and Reset.

   `.qm-form-grid`'s `auto-fit, minmax(280px, 1fr)` fits three tracks in this
   card and wrapped Order By and Reset onto a second line. Explicit tracks
   instead, because these five are not equals: Book holds book titles — the
   longest values on the page — while Gender, Status and Order By hold one short
   word each, so Book is given twice the share and the rest are squeezed to fit
   beside it. Reset is `auto`: it is a button, and sizing it from a fraction of
   the row would only pad it. */
.qm-filters .qm-filters-row {
  display: grid;
  grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)) auto;
  gap: 12px 16px;
  align-items: end;
}
/* Below the desktop breakpoint five controls on one line leaves each too narrow
   to read its own value, so the row goes back to wrapping. */
@media (max-width: 991.98px) {
  .qm-filters .qm-filters-row {
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
  }
}

/* Option boxes on the generate page (note / QR code). A label wrapping the
   input so the text is a click target too. */
/* ---- Option rows (generate page, "Certificate options") ---------------------
   A settings-style list: icon square, title + one-line description, and the
   design-system switch at the end. The whole row is the label. */
.qm-options { display: grid; gap: 10px; }
.qm-option {
  display: flex; align-items: center; gap: 14px; margin: 0; padding: 12px 14px;
  border: 1px solid var(--bs-border-color); border-radius: var(--r-xs);
  background: var(--bs-paper-bg); cursor: pointer; transition: border-color .18s ease, background .18s ease;
}
.qm-option:hover { border-color: var(--bs-primary); }
.qm-option:has(input:checked) { border-color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), .04); }
.qm-option-ic {
  flex: none; width: 40px; height: 40px; border-radius: var(--r-xs);
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(var(--bs-primary-rgb), .10); color: var(--bs-primary); font-size: 20px;
}
.qm-option-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.qm-option-t { font-weight: var(--fw-bold, 700); font-size: 14px; color: var(--bs-heading-color); }
.qm-option-d { font-size: 12.5px; color: var(--bs-gray-600); }
.qm-option .qm-switch { flex: none; margin-inline-start: auto; }
.qm-options + .qm-hint { margin-top: 12px; }

/* ---- Timeline (certificate page, edit history) ------------------------------
   One entry per correction: who and when on top, then the change as
   old value (struck through) -> new value. The rail is a hairline. */
.qm-timeline { list-style: none; margin: 0; padding: 0; position: relative; }
.qm-timeline::before { content: ''; position: absolute; top: 6px; bottom: 6px; inset-inline-start: 7px; width: 2px; background: var(--bs-border-color); }
.qm-tl-item { position: relative; padding-inline-start: 30px; padding-bottom: 16px; }
.qm-tl-item:last-child { padding-bottom: 0; }
.qm-tl-dot { position: absolute; inset-inline-start: 2px; top: 5px; width: 12px; height: 12px; border-radius: 50%; background: var(--bs-paper-bg); border: 3px solid var(--bs-primary); }
.qm-tl-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; }
.qm-tl-who { display: inline-flex; align-items: center; gap: 6px; font-weight: var(--fw-bold, 700); color: var(--bs-heading-color); }
.qm-tl-who i { font-size: 17px; color: var(--bs-primary); }
.qm-tl-when { font-size: 12.5px; color: var(--bs-gray-600); }
.qm-tl-change { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 14px; padding: 8px 12px; border-radius: var(--r-xs); background: var(--bs-tertiary-bg); }
.qm-tl-change + .qm-tl-change { margin-top: 6px; }
.qm-tl-lbl { font-size: 12px; color: var(--bs-gray-600); min-width: 90px; }
.qm-tl-old { color: var(--bs-gray-600); text-decoration: line-through; }
.qm-tl-new { font-weight: var(--fw-bold, 700); color: var(--bs-heading-color); }
.qm-tl-arrow { color: var(--bs-gray-500); font-size: 16px; }
html[dir="rtl"] .qm-tl-arrow { transform: scaleX(-1); }
.qm-modal-wide { width: min(600px, calc(100vw - 32px)); }
.qm-modal-wide .qm-modal-b { max-height: 60vh; overflow-y: auto; padding: 2px; text-align: start; }
.qm-act.qm-history-btn { width: auto; height: 32px; padding: 0 12px; gap: 6px; white-space: nowrap; }
.qm-history-btn span { font-size: 13px; font-weight: var(--fw-medium, 500); }

/* ---- Combobox (generate page, Book) ------------------------------------------
   A plain .inp you type into; the list below filters as you type. No shadow,
   like the rest of the system. */
.qm-combo { position: relative; }
.qm-combo-in { padding-inline-end: 38px; }
.qm-combo-x {
  position: absolute; top: 0; inset-inline-end: 0; width: 38px; height: 44px;
  border: 0; background: transparent; color: var(--bs-gray-600); font-size: 22px; line-height: 1; cursor: pointer;
}
.qm-combo-x:hover { color: var(--danger-text, #b00020); }
.qm-combo-caret {
  position: absolute; top: 0; inset-inline-end: 0; width: 38px; height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--bs-gray-600); font-size: 16px; pointer-events: none; transition: transform .18s ease;
}
.qm-combo-in[aria-expanded="true"] ~ .qm-combo-caret { transform: rotate(180deg); }
.qm-combo-list {
  position: absolute; z-index: 1050; inset-inline: 0; top: calc(100% + 4px);
  margin: 0; padding: 4px 0; list-style: none; max-height: 280px; overflow-y: auto;
  background: var(--bs-paper-bg); border: 1px solid var(--bs-border-color); border-radius: var(--r-xs);
}
.qm-combo-list li { padding: 8px 14px; font-size: 14px; cursor: pointer; }
.qm-combo-list li.is-selected { font-weight: var(--fw-bold, 700); }
.qm-combo-list li.is-active, .qm-combo-list li[role=option]:hover { background: var(--bs-primary); color: #fff; }
.qm-combo-empty { color: var(--bs-gray-600); cursor: default; }

.qm-check { display: flex; align-items: center; gap: 10px; padding: 8px 0; cursor: pointer; font-size: .95rem; }
.qm-check input[type=checkbox] { width: 18px; height: 18px; accent-color: var(--qm-primary, #1f346d); cursor: pointer; }
.qm-check + .qm-check { border-top: 1px solid var(--qm-hairline, #e6e6e6); }

/* ---- Public verify page (opened from the QR code) --------------------------
   Reuses the auth card. The card is a touch wider than the login card so the
   two small tiles sit side by side on a laptop; on a phone every tile stacks. */
.qm-auth-inner:has(.qm-verify) { max-width: 520px; }
.qm-verify { margin-top: 22px; display: flex; flex-direction: column; gap: 18px; }
.qm-verify-status {
  display: flex; align-items: center; gap: 14px; padding: 14px 16px;
  border-radius: var(--r-sm, 12px); border: 1px solid transparent;
}
.qm-verify-status.ok  { background: rgba(var(--bs-success-rgb), .08); border-color: rgba(var(--bs-success-rgb), .25); }
.qm-verify-status.bad { background: rgba(var(--bs-danger-rgb), .08);  border-color: rgba(var(--bs-danger-rgb), .25); }
.qm-verify-mark {
  flex: 0 0 44px; width: 44px; height: 44px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center; font-size: 26px; color: #fff;
}
.qm-verify-status.ok  .qm-verify-mark { background: var(--bs-success); }
.qm-verify-status.bad .qm-verify-mark { background: var(--bs-danger); }
.qm-verify-head { font-size: 16px; font-weight: var(--fw-bold); color: var(--bs-heading-color); line-height: 1.35; }
.qm-verify-sub  { font-size: 12.5px; color: var(--bs-gray-600); margin-top: 2px; }
.qm-verify-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.qm-verify-fact {
  display: flex; flex-direction: column; gap: 4px; padding: 12px 14px;
  background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: var(--r-sm, 12px);
  min-width: 0;
}
.qm-verify-fact.wide { grid-column: 1 / -1; }
.qm-verify-label { font-size: 12px; color: var(--bs-gray-600); }
.qm-verify-value { font-size: 16px; font-weight: var(--fw-bold); color: var(--bs-heading-color); line-height: 1.5; overflow-wrap: anywhere; }
.qm-verify-value.num { font-size: 20px; letter-spacing: .02em; }
.qm-verify-dl { height: 48px; display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 15px; }
@media (max-width: 480px) {
  .qm-verify-facts { grid-template-columns: 1fr; }
}

/* ---- Generate page: template picker ----------------------------------------
   Choices on one side, the resolved template on the other. On a phone the
   preview drops under the choices. */
.qm-pick { display: flex; flex-direction: column; gap: 20px; }
.qm-pick-fields { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.qm-pick-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.qm-pick-pair[hidden], .qm-pick-sum[hidden] { display: none; }
.qm-pick-sum { display: flex; flex-direction: column; gap: 10px; }
.qm-pick-num { display: flex; align-items: baseline; gap: 8px; line-height: 1.7; }
.qm-pick-num .tile-label { margin: 0; }
.qm-pick-num .tile-value { font-size: 18px; }
/* The whole template, full width under the choices, as on imutoon. */
.qm-pick-preview {
  border: 1px dashed var(--bs-border-color); border-radius: var(--r-sm); background: var(--bs-body-bg);
  min-height: 160px; display: flex; align-items: center; justify-content: center; overflow: hidden;
}
.qm-pick-preview:has(img:not([hidden])) { border-style: solid; background: #fff; padding: 12px; }
.qm-pick-preview img { display: block; width: 100%; height: auto; margin: 0; border: 0; border-radius: 0; box-shadow: none; }
.qm-pick-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 24px; text-align: center;
  color: var(--bs-gray-600); font-size: 12.5px; line-height: 1.6; }
.qm-pick-empty[hidden] { display: none; }
.qm-pick-empty i { font-size: 34px; color: var(--bs-gray-400); }
@media (max-width: 520px) { .qm-pick-pair { grid-template-columns: 1fr; } }

/* Segmented switch: two exclusive modes, quieter than buttons. */
.qm-seg { display: inline-flex; align-self: flex-start; padding: 4px; gap: 4px; border-radius: var(--r-sm);
  background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); }
.qm-seg[hidden] { display: none; }
.qm-seg-btn { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px; border: 0; border-radius: calc(var(--r-sm) - 3px);
  background: transparent; color: var(--bs-secondary-color); font: inherit; font-size: 13px; font-weight: var(--fw-bold); cursor: pointer; transition: var(--transition); }
.qm-seg-btn:hover { color: var(--bs-primary); }
.qm-seg-btn.on { background: var(--bs-paper-bg); color: var(--bs-primary); box-shadow: 0 1px 3px rgba(0, 0, 0, .1); }
.qm-seg-btn i { font-size: 16px; }

/* A box whose list is not loaded yet. */
.qm-combo-in:disabled { background: var(--bs-body-bg); color: var(--bs-gray-500); cursor: not-allowed; }
.qm-combo-in:disabled + .qm-combo-caret { opacity: .4; }

/* A certificate number: digits and a backslash separator, always left-to-right,
   in a face where the separator is clearly visible. */
/* bidi-override, not isolate: two Arabic-digit numbers around a backslash are
   reordered by the browser even inside an LTR isolate, which showed the number
   back to front compared with the printed certificate. */
.qm-number { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; letter-spacing: .04em; unicode-bidi: bidi-override; direction: ltr; display: inline-block; }
