/*
 * Dashboard Styles: Ulfratech Client Portal
 * Extends /assets/css/ulfratech.css (light mode, no overrides to public site)
 *
 * Table of Contents:
 *   1.  Font
 *   2.  Dashboard Tokens
 *   3.  Base
 *   4.  Layout Shell
 *   5.  Sidebar
 *   6.  Main Wrapper & Topbar
 *   7.  Content Area
 *   8.  Cards
 *   9.  Stat Cards
 *  10.  Status Badges
 *  11.  Tables
 *  12.  Forms & Inputs
 *  13.  Buttons
 *  14.  Progress
 *  15.  Nav Pills (content-area scoped)
 *  16.  List Groups
 *  17.  Utility Classes
 *  18.  Dev Toolbar
 *  19.  Permission Visibility
 *  20.  Responsive
 */

/* ── 1. Fonts ───────────────────────────────────────────────── */
/* Manual §05 / rule 9: Sora for display, Manrope for body, NO OTHER FACES.
   This line used to load 'Outfit', and it is the only place any of the four
   dashboard shells loads a font: so the whole product UI has been rendering
   in a face the brand manual does not contain, while every marketing page
   beside it loaded Sora. */
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Sora:wght@400;600;700;800&display=swap');

/* ── 2. Dashboard Tokens ────────────────────────────────────── */
:root {
  --sidebar-width:   256px;
  --topbar-height:   60px;

  /* --primary-300 is the action TINT. It was defined only in the dark block,
     so all six of its call sites: stat-card icons, two sidebar surfaces and
     three in docs.css: painted TRANSPARENT in the light theme. Nothing looked
     broken, because a missing tint reads as "flat by design"; it was found by
     removing a hard-coded rgba(0,0,0,.04) that had been masking it on the stat
     icons. Value is ulfra-action-tint from the brand manual v1.2.1. */
  --primary-300:     #EDE9FB;

  /* Surfaces: light, clean */
  --dash-bg:         #f4f6f9;
  --dash-surface:    #ffffff;
  --dash-border:     rgba(0, 0, 0, 0.06);
  --dash-border-2:   rgba(0, 0, 0, 0.10);

  /* Elevation */
  --dash-shadow:     0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
  --dash-shadow-md:  0 4px 16px rgba(0,0,0,0.09);

  /* --accent-200 was the retired cyan's dark step. The manual admits no
     second hue, so it is the signature violet: a shim, like --accent-100. */
  --accent-200:      #3C10AF;

  /* Semantic status palette: manual §09. The previous values were a Tailwind
     ramp: #059669 measured 3.76:1 on a white card, so every "Activo",
     "Pagada", "Emitida" and "Predeterminado" chip in the product was below AA.
     Colour is never the sole signal here: the chips carry a dot + a word. */
  --status-green:    #0C6849;  /* 6.79:1 white · 6.27:1 --dash-bg */
  --status-green-bg: #E6F6EF;
  --status-amber:    #855507;  /* 6.30:1 white */
  --status-amber-bg: #FDF3E0;
  --status-red:      #B3261E;  /* 6.51:1 white */
  --status-red-bg:   #FCECEA;
  --status-gray:     #6A6F81;  /* --ulfra-text-muted: 4.99:1 white */
  --status-gray-bg:  #F7F8FB;
}

/* ── 3. Base ────────────────────────────────────────────────── */
body {
  background-color: var(--dash-bg);
  font-family: 'Manrope', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 0.9375rem;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
}

/* ── 4. Layout Shell ────────────────────────────────────────── */
.dashboard-layout {
  display: flex;
  min-height: 100vh;
}

/* ── 5. Sidebar ─────────────────────────────────────────────── */
.sidebar {
  width: var(--sidebar-width);
  background: var(--dash-surface);
  border-right: 1px solid var(--dash-border);
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  z-index: 1030;
  display: flex;
  flex-direction: column;
  transition: transform var(--transition-normal);
  overflow: hidden;
}

.sidebar-brand {
  height: var(--topbar-height);
  display: flex;
  align-items: center;
  padding: 0 1.25rem;
  border-bottom: 1px solid var(--dash-border);
  flex-shrink: 0;
}

.sidebar-brand img {
  height: 34px;
  width: auto;
}

.sidebar-content {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 0.5rem 0 1rem;
  scrollbar-width: thin;
  scrollbar-color: var(--bg-300) transparent;
}

.sidebar-content::-webkit-scrollbar       { width: 3px; }
.sidebar-content::-webkit-scrollbar-track { background: transparent; }
.sidebar-content::-webkit-scrollbar-thumb { background: var(--bg-300); border-radius: 2px; }

/* Nav Categories */
.nav-category {
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-weight: 700;
  color: var(--text-300);
  padding: 0 1.25rem;
  margin: 1.25rem 0 0.25rem;
  display: block;
}

.nav-category:first-child { margin-top: 0.5rem; }

/* Sidebar Nav Links: scoped to .sidebar to prevent conflicts with Bootstrap
   .navbar-nav .nav-link and content-area .nav-pills .nav-link               */
.sidebar .nav-item { position: relative; }

.sidebar .nav-link {
  display: flex;
  align-items: center;
  padding: 0.5rem 1.25rem;
  color: var(--text-200) !important;
  font-weight: 500;
  font-size: 0.875rem;
  border-left: 3px solid transparent;
  border-radius: 0;
  transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
  text-decoration: none;
  gap: 0.625rem;
  margin: 1px 0;
}

.sidebar .nav-link i {
  font-size: 1rem;
  width: 18px;
  text-align: center;
  opacity: 0.6;
  flex-shrink: 0;
  transition: opacity var(--transition-fast);
}

.sidebar .nav-link:hover {
  background-color: var(--primary-300);
  color: var(--primary-100) !important;
}

.sidebar .nav-link:hover i { opacity: 1; }

.sidebar .nav-link.active {
  background-color: var(--primary-300);
  color: var(--primary-100) !important;
  border-left-color: var(--primary-100);
  font-weight: 600;
}

.sidebar .nav-link.active i { opacity: 1; }

/* Locked nav items (user lacks required group/permission).
 *
 * 45%, the brand's disabled step: never a new grey. WCAG 1.4.3 exempts an
 * INACTIVE control from the 4.5:1 floor, which is what makes a faded label
 * legitimate here; the exemption is only honest if the control really is
 * inactive, so the markup also carries aria-disabled + tabindex="-1"
 * (shared/sidebar.js). pointer-events:none alone stops the mouse and nothing
 * else: the link stayed Tab-reachable and Enter-activatable into a page the
 * user has no permission to open, and assistive tech announced it as an
 * ordinary link. Measured 2.00:1 light / 2.55:1 dark before that. */
.sidebar .nav-link-locked {
  opacity: 0.45;
  cursor: not-allowed;
  pointer-events: none;
}

/* ── Collapsible Nav Sections ───────────────────────────────────────────
   Sectioned sidebar entries render as an accordion: a button header
   (small-caps label + chevron) and a list of indented nav items. State
   persistence lives in localStorage under `dashboard.sidebar.sections`.
   Honors prefers-reduced-motion by suppressing chevron rotation. */
.sidebar .nav-section {
  margin: 0.75rem 0 0.25rem;
}

.sidebar .nav-section--first { margin-top: 0.5rem; }

.sidebar .nav-section-header {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.375rem 1.25rem;
  background: none;
  border: 0;
  color: var(--text-300);
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  text-align: left;
  cursor: pointer;
  border-radius: 0;
  transition: color var(--transition-fast), background var(--transition-fast);
}

.sidebar .nav-section-header:hover,
.sidebar .nav-section-header:focus-visible {
  color: var(--primary-100);
  background: var(--bg-200);
  outline: none;
}

.sidebar .nav-section-header:focus-visible {
  box-shadow: inset 0 0 0 2px var(--primary-100);
}

.sidebar .nav-section-label {
  flex: 1;
  min-width: 0;
}

.sidebar .nav-section-chevron {
  font-size: 0.75rem;
  opacity: 0.7;
  transition: transform var(--transition-fast);
}

.sidebar .nav-section.is-open .nav-section-chevron {
  transform: rotate(180deg);
}

/* Items are visually indented relative to the section header so the
   grouping is obvious at a glance without changing the link's gutter. */
.sidebar .nav-section-items .nav-link {
  padding-left: 1.75rem;
}

@media (prefers-reduced-motion: reduce) {
  .sidebar .nav-section-chevron,
  .sidebar .nav-section-header {
    transition: none;
  }
  .sidebar .nav-section.is-open .nav-section-chevron {
    transform: rotate(180deg);
  }
}

/* Sidebar Footer */
.sidebar-footer {
  padding: 0.875rem 1.25rem;
  border-top: 1px solid var(--dash-border);
  flex-shrink: 0;
}

.user-avatar {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--primary-100);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.75rem;
  font-weight: 700;
  flex-shrink: 0;
  letter-spacing: -0.02em;
}

/* Mobile overlay */
.sidebar-overlay {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.3);
  z-index: 1025;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--transition-normal);
  backdrop-filter: blur(2px);
}

/* ── Layout utilities ────────────────────────────────────────
 *
 * `.min-w-0` is used 57 times across the five dashboard shells and was
 * DEFINED NOWHERE: not by Bootstrap 5.3 (which ships `.mw-100` and no
 * `min-w-*` scale at all) and not by any stylesheet in this repo. It is a
 * Tailwind idiom that was carried into a Bootstrap codebase, so every one of
 * those 57 uses was inert.
 *
 * That is not cosmetic. A flex item's initial `min-width` is `auto`, which
 * means it refuses to shrink below its content: so `text-truncate` on a
 * child never engages and a long string pushes its siblings out of the
 * viewport instead. The workspace-card header did exactly that on a phone:
 * the tenant name kept its full width and the "Detalle →" button was clipped
 * off the right edge. The markup READ correct, which is why it survived.
 *
 * Defining it makes 57 call sites do what their authors wrote. `min-width: 0`
 * only ever GRANTS the ability to shrink, so no site that was laying out
 * correctly can start laying out incorrectly because of it.
 *
 * Pinned by dashboard/js/shared/layout-utilities.test.mjs, which fails if a
 * layout utility used in the source is absent from every stylesheet the
 * dashboard loads.
 */
.min-w-0 { min-width: 0; }

/* ⚠ BEFORE YOU REACH FOR `min-w-0`, READ THIS.
 *
 * `min-width: 0` is not a layout. It REMOVES a floor; it does not supply a
 * width. On a flex item with no flex-basis it means "you may shrink to
 * nothing", and given a sibling that refuses to shrink: anything
 * `flex-shrink-0`, and especially a `flex-wrap` cluster of buttons, which
 * claims its full single-line max-content width: the item does shrink to
 * nothing. Text then breaks at every character and renders as a vertical
 * ribbon one letter wide. That is not hypothetical: "Mi rol: Super admin"
 * shipped that way, about twenty lines tall, on the tenant detail header.
 *
 * It is also the shape of the fix that CAUSED it. Defining this class in
 * 0.309.0 was correct, it was undefined and 57 call sites were inert, but
 * on that card it converted a horizontal overflow into a collapsed column.
 *
 * So pair it with a BASIS you are willing to live with, the way
 * `.u-entity-header > .u-entity-identity` does (`flex: 1 1 12rem`), and make
 * sure the siblings can give something back. If you are writing an
 * icon / title / actions header, do not hand-roll it at all: use
 * shared/entity-header.js, which is that arrangement already solved. */

/* ── The entity header: ONE structure, every screen ──────────────────
 *
 * Workspace cards, the tenant detail, site detail and the editor panels all
 * drew the same header by hand: an icon, a name that must truncate, some
 * badges and a cluster of actions, and each one drifted. The controls moved
 * between screens and between widths, which is the thing the user actually
 * asked to stop. This class is that header, defined once.
 *
 * THE BUG IT FIXES, and it is a two-part one:
 *
 *   `<div class="flex-grow-1 min-w-0">` beside
 *   `<div class="d-flex gap-2 flex-shrink-0 flex-wrap">`
 *
 * A flex-wrap group that is ALSO flex-shrink-0 claims its full single-line
 * max-content width and refuses to give any of it back: three pill buttons
 * is ~300px. On a 390px phone that leaves the identity column a handful of
 * pixels, and because `min-w-0` lets it shrink all the way to zero, the text
 * broke at every character: "Mi rol: Super admin" rendered as a twenty-line
 * vertical ribbon, one letter per line.
 *
 * Note the sequencing honestly: before `.min-w-0` was defined (0.309.0) that
 * same header overflowed sideways instead. Defining the utility was right:
 * 57 call sites were inert, but it converted this card's symptom from
 * horizontal overflow into a collapsed column. `min-width: 0` alone is not a
 * layout; it needs a flex-basis to shrink FROM.
 *
 * So the identity column gets `flex: 1 1 12rem`: it may shrink and truncate,
 * but it starts from a readable basis and wraps to its own line rather than
 * collapsing. The action bar may shrink. Nothing here is `flex-shrink-0`
 * except the icon, which is 48px and genuinely fixed.
 */
.u-entity-header {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
  flex-wrap: wrap;
}
.u-entity-header > .u-entity-icon {
  flex: 0 0 auto;
}
.u-entity-header > .u-entity-identity {
  /* 12rem basis, NOT 0: a column that shrinks from nothing collapses to
     nothing. min-width:0 is what still permits text-truncate to engage. */
  flex: 1 1 12rem;
  min-width: 0;
}
.u-entity-header > .u-entity-actions {
  flex: 0 1 auto;
  display: flex;
  gap: .5rem;
  flex-wrap: wrap;
  margin-left: auto;
}

@media (max-width: 575.98px) {
  /* Identity on its own full-width row, actions on theirs, in this order, on
     every screen that uses the class: so a control never moves between
     pages or between widths. */
  .u-entity-header > .u-entity-identity { flex-basis: 100%; }
  .u-entity-header > .u-entity-actions {
    flex-basis: 100%;
    margin-left: 0;
  }
  /* Full-width taps rather than three pills crushed onto one line. */
  .u-entity-header > .u-entity-actions > .btn { flex: 1 1 auto; min-height: 44px; }
}

/* ── Row actions inside a table ──────────────────────────────────────────────
   A table row's action cluster is NOT a .u-entity-header: a list row must stay
   one line tall on a desktop. But it has the same failure at phone widths: five
   inline buttons claim ~500px of a 390px viewport, and the overflow lives
   inside .table-responsive, so the control is present, off-screen, and only
   reachable by a horizontal scroll nobody discovers.

   Measured on #team-roles at 390px: «Revocar» sat at x [391..470]: one pixel
   past the right edge, entirely unreachable with a thumb. A DESTRUCTIVE action
   that is invisible rather than disabled is the worst of the three states,
   because the operator concludes the feature does not exist.

   Wrapping is the fix: the cluster keeps its single line wherever it fits and
   stacks when it does not, so nothing is ever outside the viewport. */
.u-row-actions {
  display: flex;
  flex-wrap: wrap;
  gap: .25rem;
  justify-content: flex-end;
}
@media (max-width: 575.98px) {
  .u-row-actions { justify-content: flex-start; }
  /* 32px is the row-density compromise: these sit inside a table row, so the
     44px target of a page-level control would double every row's height. They
     are still wrapped and on-screen, which is the defect being fixed. */
  .u-row-actions > .btn { min-height: 32px; }

  /* Wrapping the CLUSTER is not enough on its own: measured on #team-roles at
     390px, the table was 454px wide inside a 356px .table-responsive, so the
     actions cell was positioned outside the viewport before its contents ever
     got a chance to wrap. The column widths have to be capped too.

     `table-layout: fixed` is what makes the percentages binding: with auto
     layout the browser sizes columns by min-content and an unbreakable email
     address wins the argument. `overflow-wrap: anywhere` is the other half:
     without it a long address just overflows its own fixed column instead. */
  .u-actions-table { table-layout: fixed; width: 100%; }
  /* `anywhere` is scoped to the FIRST column, and only there.
     Applied to every cell it broke the short labels the other columns hold:
     measured at 390 on #team-roles, "Owner" rendered as "Ow / ner" and the
     «Permisos» control as "Per / miso / s": the "overflowing text" report,
     produced by the fix for a different one. Column 1 is the only cell with
     an unbreakable token (an email address), and it is the only cell that
     needs to be allowed to break mid-word. The rest wrap between words like
     any other text, or the column is too narrow and that is a column-width
     bug to fix as one. */
  .u-actions-table th, .u-actions-table td { overflow-wrap: break-word; }
  .u-actions-table th:nth-child(1), .u-actions-table td:nth-child(1) { overflow-wrap: anywhere; }
  /* 42/16/16 gave the two label columns 57px each at 390: under the width
     "Activo" and "Owner" need, which is what forced them to break mid-word in
     the first place. Column 1 can give the 6% up: it is the only column whose
     content (an email) is allowed to break anywhere, so it degrades by
     wrapping rather than by crushing. */
  .u-actions-table th:nth-child(1), .u-actions-table td:nth-child(1) { width: 36%; }
  .u-actions-table th:nth-child(2), .u-actions-table td:nth-child(2) { width: 18%; }
  /* The role column carries a name plus, for a wildcard role, a trailing
     asterisk glyph: "Owner *" needs ~60px of the 52 that 19% left after
     cell padding, which is why it rendered as "Owne / r". 22% is measured
     against the longest system role name the tenant plane can hold. */
  .u-actions-table th:nth-child(3), .u-actions-table td:nth-child(3) { width: 22%; }
  .u-actions-table th:nth-child(4), .u-actions-table td:nth-child(4) { width: 24%; }
  /* The avatar in a fixed-layout table cell is decoration that costs data.
     It is the first letter of the address printed beside it, and at 390 it
     took 40 of the 128px the address had: the address then wrapped over
     five lines and the row grew to 186px. `flex-shrink-0` (which it needs,
     or the circle renders as an 11px ellipse) is what makes the cost fixed
     rather than negotiable, so below 576 the chip goes and the address gets
     the column. It returns unchanged at 576. */
  .u-avatar-chip { display: none !important; }

  /* One control per line in a 26% column, full width so the tap target is the
     whole cell rather than a pill floating in it. */
  .u-actions-table .u-row-actions { flex-direction: column; align-items: stretch; }
}

/* ── Every modal is scrollable on a phone ────────────────────────────
 *
 * Fourteen modals in the editor shell alone are plain `.modal-dialog`, so
 * their body grows with its content and pushes the footer. Cancelar /
 * Guardar / Guardar y cerrar: off the bottom of the screen. On a desktop the
 * viewport hides the problem; on a 390×844 phone the save button is simply
 * not reachable, which makes the dialog a dead end.
 *
 * These are the three declarations Bootstrap's own `.modal-dialog-scrollable`
 * uses, applied to every dialog at phone widths rather than added by hand to
 * fourteen markup sites (and forgotten on the fifteenth). The body scrolls,
 * the footer does not move.
 */
@media (max-width: 575.98px) {
  .modal-dialog {
    height: calc(100% - var(--bs-modal-margin, .5rem) * 2);
  }
  .modal-dialog > .modal-content {
    max-height: 100%;
    overflow: hidden;
  }
  .modal-dialog .modal-body {
    overflow-y: auto;
    min-height: 0;
  }
  /* A footer of three buttons does not fit on one 390px line. Wrap, and make
     each one a real tap target rather than a 33px sliver. */
  .modal-footer {
    flex-wrap: wrap;
    gap: .5rem;
  }
  .modal-footer > .btn {
    flex: 1 1 auto;
    min-height: 44px;
    margin: 0;
  }
}

/* A floor for identity columns that are NOT entity headers.
 *
 * This started as a stopgap for headers that had not adopted
 * `.u-entity-header` yet, and every entity header has now adopted it: so
 * the two mechanisms no longer overlap on that shape. It stays for the OTHER
 * ~25 uses of `flex-grow-1 min-w-0`, which are list rows and pickers rather
 * than headers: an activity row, a notification, an MFA factor, a template
 * version. Those legitimately keep the hand-rolled arrangement (a list row
 * must stay one line tall; the entity header deliberately stacks its actions
 * onto a full-width row, which would be absurd inside a 40px row), and they
 * are subject to exactly the same collapse.
 *
 * `min-w-0` on its own permits a collapse to zero width, and a
 * character-per-line ribbon is never the intended reading of "let it shrink".
 * 6rem is below any card this fleet draws and above the width at which text
 * stops being text. */
.flex-grow-1.min-w-0 { min-width: min(6rem, 100%); }

.min-h-0 { min-height: 0; }

/* ── 6. Main Wrapper & Topbar ───────────────────────────────── */
.main-wrapper {
  flex: 1;
  margin-left: var(--sidebar-width);
  display: flex;
  flex-direction: column;
  min-width: 0;
  transition: margin-left var(--transition-normal);
}

.topbar {
  /* min-height, NOT height, and this is now safe because docs.css no longer
     subtracts this value from the viewport (it fills the remainder instead),
     which is the dependency that blocked this fix last time.
     A fixed height on a flex row whose contents can grow means the contents
     overflow the BOX rather than the box following the contents; and because
     .topbar is `position: sticky; z-index: 1020`, anything overflowing it
     paints on top of the page header below: which is the breadcrumb sitting
     over the bell and theme controls. A box that grows cannot overlap; a box
     with a fixed height can. */
  min-height: var(--topbar-height);
  background: var(--dash-surface);
  border-bottom: 1px solid var(--dash-border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 1.5rem;
  position: sticky;
  top: 0;
  z-index: 1020;
  gap: 1rem;
}

.toggle-sidebar {
  display: none;
  background: none;
  border: none;
  font-size: 1.25rem;
  color: var(--text-200);
  cursor: pointer;
  padding: 0.25rem 0.375rem;
  margin-right: 0.5rem;
  border-radius: 0.375rem;
  transition: background var(--transition-fast), color var(--transition-fast);
  line-height: 1;
}

.toggle-sidebar:hover { background: var(--bg-200); color: var(--primary-100); }

/* ── 7. Content Area ────────────────────────────────────────── */
.content-area {
  padding: 1.5rem;
  flex: 1;
  min-width: 0;
}

.page-header {
  /* The topbar is sticky, so anything scrolled to would otherwise come to rest
     underneath it. Reserve its height for any programmatic scroll. */
  scroll-margin-top: calc(var(--topbar-height) + 1rem);
  margin-bottom: 1.5rem;
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.page-title {
  font-size: 1.375rem;
  font-weight: 700;
  color: var(--text-100);
  margin-bottom: 0.125rem;
  letter-spacing: -0.015em;
  line-height: 1.2;
}

.page-subtitle {
  font-size: 0.8125rem;
  color: var(--text-300);
  margin-bottom: 0;
}

/* ── 8. Cards ───────────────────────────────────────────────── */
.card {
  border: 1px solid var(--dash-border) !important;
  box-shadow: var(--dash-shadow) !important;
  border-radius: 0.75rem !important;
  background: var(--dash-surface);
  /* ulfratech.css sets transition:all on .card: narrow it so hover
     transform changes don't animate and bleed into adjacent elements */
  transition: box-shadow var(--transition-fast) !important;
}

.card:hover {
  transform: none !important;               /* beat ulfratech.css translateY(-5px) */
  box-shadow: var(--dash-shadow-md) !important;
}

.stat-card {
  /* Explicit reset so no stacking-context side-effects from any
     transform left on the element between paint cycles */
  transform: none !important;
  position: relative !important;
}

.card-header {
  padding: 0.875rem 1.25rem !important;
  background: var(--dash-surface) !important;
  border-bottom: 1px solid var(--dash-border) !important;
  border-radius: 0.75rem 0.75rem 0 0 !important;
  font-weight: 600;
  /* assets/css/ulfratech.css sets `.card-header { color: white }` for the
     marketing site's brand-filled headers. The dashboard flattens the header
     onto --dash-surface but never reset the colour, so every card-header
     heading rendered WHITE ON WHITE and was invisible: including
     "Recibos incluidos (N)" and "Relaciones (N)" in the Contabilidad console.
     Bound to the body colour so it stays correct in both themes. */
  color: var(--bs-body-color, var(--text-100)) !important;
}

.card-body { padding: 1.25rem !important; }

.card-footer {
  padding: 0.75rem 1.25rem !important;
  background: var(--bg-200) !important;
  border-top: 1px solid var(--dash-border) !important;
  border-radius: 0 0 0.75rem 0.75rem !important;
}

/* ── 9. Stat Cards ──────────────────────────────────────────── */
.stat-card {
  display: flex;
  flex-direction: column;
  padding: 1.25rem !important;
}

.stat-label {
  font-size: 0.6875rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-300);
}

.stat-value {
  font-size: 2rem;
  font-weight: 700;
  color: var(--text-100);
  line-height: 1.1;
  letter-spacing: -0.02em;
  margin: 0.375rem 0;
}

.stat-icon {
  width: 36px;
  height: 36px;
  border-radius: 0.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1rem;
  background: var(--primary-300);
  color: var(--primary-100);
  margin-bottom: 0.75rem;
}

.stat-trend {
  font-size: 0.75rem;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 0.2rem;
}

.stat-trend.positive { color: var(--status-green); }
.stat-trend.negative { color: var(--status-red); }
.stat-trend.neutral  { color: var(--status-gray); }

/* ── 10. Status Badges: tone-tinted dot chips ──────────────────
   Elevated to the edge-fleet console language: a small tone dot + soft
   halo, a ~10% tone-tinted surface, a hairline tone border, and a squared
   (not pill) corner. Theme-aware by construction: every tint is mixed
   against --dash-surface, so it flips automatically in dark mode. The
   four semantic variants (active/pending/inactive/neutral) are preserved,
   so no markup changes anywhere they're used. */
.status-badge {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: 0.2em 0.6em 0.2em 0.5em;
  font-size: 0.6875rem;
  font-weight: 650;
  border-radius: 7px;
  letter-spacing: 0.02em;
  line-height: 1.45;
  vertical-align: middle;
  white-space: nowrap;
  color: var(--chip-fg, var(--status-gray));
  background: var(--chip-bg, var(--status-gray-bg));
  border: 1px solid var(--chip-bd, var(--dash-border-2));
}
/* Leading tone dot with a faint halo (currentColor = the chip's tone). */
.status-badge::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  flex: none;
  box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 20%, transparent);
}

.status-active {
  --chip-fg: var(--status-green);
  --chip-bg: color-mix(in srgb, var(--status-green) 10%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--status-green) 28%, var(--dash-border-2));
}
.status-pending {
  --chip-fg: var(--status-amber);
  --chip-bg: color-mix(in srgb, var(--status-amber) 12%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--status-amber) 30%, var(--dash-border-2));
}
.status-inactive {
  --chip-fg: var(--status-red);
  --chip-bg: color-mix(in srgb, var(--status-red) 10%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--status-red) 30%, var(--dash-border-2));
}
.status-neutral {
  --chip-fg: var(--status-gray);
  --chip-bg: color-mix(in srgb, var(--status-gray) 10%, var(--dash-surface));
  --chip-bd: var(--dash-border-2);
}

/* Recibo vs. Factura: same tone-tinted chip mechanic as the status badges,
   but a document-KIND signal, not a lifecycle-status one. Deliberately
   distinct: a Recibo (neutral gray, receipt icon) must never read as the
   same kind of thing as a Factura (brand purple, fiscal-check icon): see
   ulfratech-main-site/dashboard/js/pages/tax-documents.js. */
.doctype-recibo {
  --chip-fg: var(--status-gray);
  --chip-bg: color-mix(in srgb, var(--status-gray) 10%, var(--dash-surface));
  --chip-bd: var(--dash-border-2);
}
.doctype-factura {
  --chip-fg: var(--primary-100);
  --chip-bg: color-mix(in srgb, var(--primary-100) 10%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--primary-100) 28%, var(--dash-border-2));
}
/* These are a document-KIND signal (icon carries it); suppress the
   lifecycle-status tone dot so it doesn't read as a second, redundant cue. */
.doctype-recibo::before,
.doctype-factura::before {
  display: none;
}

/* Destino fiscal: the THIRD fiscal axis (where the settled OPERATION ended
   up), never to be confused with the document's own lifecycle status sitting
   next to it in the same row. Same tone mechanics, but a dashed hairline and a
   suppressed tone dot so the two chips are told apart by SHAPE, not only by
   colour: a wrong fiscal reading is expensive and some operators are
   colour-blind. Icon + full Spanish text always accompany the tone.
   See dashboard/js/admin/fiscal-status.js. */
.dest-badge {
  border-style: dashed;
  font-weight: 600;
}
.dest-badge::before { display: none; }

/* A fiscal status chip carries a MEANING icon (hourglass, patch-check, warning
   triangle, …) as its non-colour signal. The generic tone dot would then be a
   second, redundant cue right next to it, so it is suppressed: same treatment
   the doctype chips already get. Applied by fiscal-status.js `statusBadge()`. */
.status-badge.sb-icon::before { display: none; }

/* Quiet manual-refresh spin (fleet list-refresh convention): applied to the
   icon only, never blocks the surrounding list with a full skeleton. */
@keyframes u-spin { to { transform: rotate(360deg); } }
.u-spin { animation: u-spin .7s linear infinite; display: inline-block; }

/* ── Deadline chip (factura global) ──────────────────────────────────────
   Live countdown to a deadline the BACKEND reported. Four tones, escalating:
   ok (>6 h) → warn (≤6 h) → critical (≤2 h) → breached (past due). A breached
   stamping deadline is a compliance failure, not a backlog item, so it gets a
   solid red chip plus a slow pulse: it must be impossible to scroll past.
   See dashboard/js/admin/fiscal-globals-tab.js. */
.deadline-chip {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  padding: .18rem .5rem;
  border-radius: 999px;
  font-size: .76rem;
  font-weight: 600;
  line-height: 1.2;
  white-space: nowrap;
  border: 1px solid var(--chip-bd, var(--dash-border-2));
  color: var(--chip-fg, inherit);
  background: var(--chip-bg, var(--dash-surface));
}
.deadline-ok {
  --chip-fg: var(--status-gray);
  --chip-bg: color-mix(in srgb, var(--status-gray) 8%, var(--dash-surface));
  --chip-bd: var(--dash-border-2);
}
.deadline-warn {
  --chip-fg: var(--status-amber);
  --chip-bg: color-mix(in srgb, var(--status-amber) 14%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--status-amber) 34%, var(--dash-border-2));
}
.deadline-critical {
  --chip-fg: var(--status-red);
  --chip-bg: color-mix(in srgb, var(--status-red) 14%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--status-red) 40%, var(--dash-border-2));
}
.deadline-breached {
  --chip-fg: #fff;
  --chip-bg: var(--status-red);
  --chip-bd: var(--status-red);
  animation: deadline-pulse 1.8s ease-in-out infinite;
}
@keyframes deadline-pulse {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--status-red) 55%, transparent); }
  50%      { box-shadow: 0 0 0 5px color-mix(in srgb, var(--status-red) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
  .deadline-breached { animation: none; }
}
/* Row accents so an at-risk period is visible while scanning the table. */
.globals-row-alert > td:first-child { box-shadow: inset 3px 0 0 0 var(--status-red); }
.globals-row-warn  > td:first-child { box-shadow: inset 3px 0 0 0 var(--status-amber); }

/* ── Cierre contable mensual (§19-H) ─────────────────────────────────────
   The outcome of a close check is THREE facts, not two: cumplida, no
   cumplida, and INDETERMINADA: a check that could not be evaluated. A
   close that reads green because something was unevaluable is the exact
   failure this screen exists to prevent, so the indeterminate variant is
   told apart from both others by SHAPE as well as tone: a dashed 2 px
   hairline against the solid 1 px of pass/fail, plus its own icon and its
   own Spanish word. Colour is never the only signal.
   See dashboard/js/admin/fiscal-close-tab.js. */
.check-badge { border-width: 1px; }
.check-pass {
  --chip-fg: var(--status-green);
  --chip-bg: color-mix(in srgb, var(--status-green) 10%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--status-green) 30%, var(--dash-border-2));
}
.check-fail {
  --chip-fg: var(--status-red);
  --chip-bg: color-mix(in srgb, var(--status-red) 12%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--status-red) 40%, var(--dash-border-2));
}
.check-indet {
  --chip-fg: var(--status-amber);
  --chip-bg: color-mix(in srgb, var(--status-amber) 10%, var(--dash-surface));
  --chip-bd: color-mix(in srgb, var(--status-amber) 45%, var(--dash-border-2));
  border-style: dashed;
  border-width: 2px;
}
/* There is deliberately no "no aplicable" tone: billing-service emits three
   verdicts and only three. A control that does not apply to a month is a
   control nobody evaluated, which is INDETERMINATE: giving it a fourth,
   calmer tone would be inventing a way for an unevaluated control to look
   settled. */

/* Compact counter used in the list row and the checks header. Same tone
   variables as the chips above, so a count can never disagree with the
   badge it summarises. */
.check-count {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  font-size: .74rem;
  font-weight: 700;
  line-height: 1;
  padding: .16rem .42rem;
  border-radius: 6px;
  white-space: nowrap;
  color: var(--chip-fg, var(--status-gray));
  background: var(--chip-bg, var(--status-gray-bg));
  border: 1px solid var(--chip-bd, var(--dash-border-2));
}
.check-count.check-indet { border-style: dashed; border-width: 2px; }

/* The ten §19-H requirements. Rendered as a fixed list: a requirement the
   service did not report is a visible "no informada" row, never a missing
   one: with a left accent per outcome so the checklist can be scanned. */
.check-list { list-style: none; margin: 0; padding: 0; }
.check-item {
  padding: .55rem .7rem;
  border: 1px solid var(--dash-border-2);
  border-left-width: 4px;
  border-radius: 8px;
  margin-bottom: .4rem;
  background: var(--dash-surface);
}
.check-item-passed        { border-left-color: var(--status-green); }
.check-item-failed        { border-left-color: var(--status-red); }
.check-item-indeterminate { border-left-color: var(--status-amber); border-left-style: dashed; }
.check-num {
  flex: none;
  width: 1.4rem; height: 1.4rem;
  border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: .68rem; font-weight: 700;
  color: var(--status-gray);
  border: 1px solid var(--dash-border-2);
}

/* The §15 equation, laid out so it visibly balances or does not. Terms wrap
   on narrow screens rather than scrolling the page sideways. */
.eq-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: .4rem;
}
.eq-term {
  flex: 1 1 8.5rem;
  min-width: 8rem;
  padding: .5rem .6rem;
  border: 1px solid var(--dash-border-2);
  border-radius: 8px;
  background: var(--dash-surface);
}
.eq-term-missing { border-style: dashed; border-width: 2px; }
.eq-term-label {
  font-size: .66rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  font-weight: 700;
  color: var(--status-gray);
}
.eq-term-value { font-size: .95rem; font-weight: 700; line-height: 1.4; }
.eq-term-note  { font-size: .64rem; color: var(--status-gray); margin-top: .15rem; }
.eq-term-flag  {
  font-size: .64rem;
  font-weight: 700;
  color: var(--status-amber);
  margin-top: .2rem;
  display: flex;
  align-items: center;
  gap: .2rem;
}
.eq-op {
  flex: none;
  align-self: center;
  font-size: 1.1rem;
  font-weight: 700;
  color: var(--status-gray);
  padding: 0 .1rem;
}
.eq-op-equals { color: inherit; }

/* Row accents for the period list: same mechanic the factura-global list
   uses, so the two Contabilidad tables are read the same way. */
.close-row-alert > td:first-child { box-shadow: inset 3px 0 0 0 var(--status-red); }
.close-row-warn  > td:first-child { box-shadow: inset 3px 0 0 0 var(--status-amber); }

/* ── 11. Tables ─────────────────────────────────────────────── */
.table {
  font-size: 0.875rem;
  margin-bottom: 0;
}

.table thead th {
  font-size: 0.6875rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--text-300);
  background: var(--bg-200) !important;
  border-bottom: 1px solid var(--dash-border) !important;
  padding: 0.625rem 1rem !important;
  white-space: nowrap;
}

.table tbody td {
  padding: 0.75rem 1rem !important;
  vertical-align: middle;
  border-color: var(--dash-border) !important;
  color: var(--text-100);
}

.table tbody tr:last-child td {
  border-bottom-color: transparent !important;
}

/* Accent-tinted row hover, mixed against the surface so it stays subtle in
   both themes (the flat --primary-300 stayed light on dark surfaces). */
.table-hover tbody tr:hover td {
  background: color-mix(in srgb, var(--primary-100) 6%, var(--dash-surface)) !important;
}

/* ── 12. Forms & Inputs ─────────────────────────────────────── */
.form-control,
.form-select {
  font-size: 0.875rem;
  padding: 0.45rem 0.875rem;
  border-radius: 0.5rem;
  border-color: var(--dash-border-2);
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
  background-color: var(--dash-surface);
}

.form-control:focus,
.form-select:focus {
  border-color: var(--primary-100);
  box-shadow: 0 0 0 3px rgba(90, 61, 190, 0.1);
}

.form-control.bg-light,
.form-select.bg-light {
  background-color: var(--bg-200) !important;
  border-color: transparent;
}

.form-control.bg-light:focus {
  background-color: var(--dash-surface) !important;
  border-color: var(--primary-100);
}

.form-label {
  font-weight: 600;
  font-size: 0.8125rem;
  color: var(--text-200);
  margin-bottom: 0.375rem;
}

.input-group-text {
  font-size: 0.875rem;
  border-color: var(--dash-border-2);
  background: var(--bg-200);
  padding: 0.45rem 0.75rem;
}

.form-control-color {
  width: 56px;
  height: 42px;
  padding: 0.25rem;
  border-radius: 0.5rem;
  border-color: var(--dash-border-2);
}

/* ── 13. Buttons ────────────────────────────────────────────── */
.btn {
  font-weight: 600;
  font-family: 'Manrope', system-ui, sans-serif;
  border-radius: 0.5rem;
  font-size: 0.875rem;
  padding: 0.45rem 1rem;
  transition: all var(--transition-fast);
}

.btn-sm  { padding: 0.3rem 0.75rem;  font-size: 0.8125rem; }
.btn-lg  { padding: 0.65rem 1.5rem;  font-size: 0.9375rem; }

/* ── 14. Progress ───────────────────────────────────────────── */
.progress {
  height: 8px;
  border-radius: 1rem;
  background: var(--bg-300);
  overflow: hidden;
}

.progress-bar {
  background: var(--primary-100);
  border-radius: 1rem;
}

.progress-bar-striped.progress-bar-animated {
  /* Flat color: no gradient overlay per design rule. */
  background-image: none;
}

/* ── 15. Sub-nav: underline tab bar (content-area scoped) ──────
   Elevated to the edge-fleet console language: a hairline underline tab
   bar (no pill fill), muted inactive labels, and an accent underline +
   weight bump on the active tab. Any count badge inside a tab renders as
   a squared mono chip that lights in the brand accent only when active
   (see §25). Scoped so it NEVER leaks into .sidebar .nav-link. Behaviour
   is unchanged. Bootstrap still toggles .active on click. */
.content-area .nav-pills {
  gap: 2px;
  border-bottom: 1px solid var(--dash-border);
  flex-wrap: wrap;
}

/* A sub-nav that opted into a segmented pill container (bg-light / p-1 /
   rounded-pill) is folded into the same underline system for one look. */
.content-area .nav-pills.rounded-pill {
  border-radius: 0 !important;
  background: transparent !important;
  padding: 0 !important;
}

.content-area .nav-pills .nav-link {
  padding: 0.5rem 0.9rem;
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--text-300);
  background: transparent;
  border: 0;
  border-bottom: 2px solid transparent;
  border-radius: 0;
  margin-bottom: -1px;
  transition: color var(--transition-fast), border-color var(--transition-fast);
}

.content-area .nav-pills .nav-link.active {
  background: transparent;
  color: var(--primary-100);
  border-bottom-color: var(--primary-100);
  box-shadow: none;
  font-weight: 700;
}

.content-area .nav-pills .nav-link:not(.active):hover {
  background: transparent;
  color: var(--text-100);
  border-bottom-color: var(--dash-border-2);
}

.content-area .nav-pills .nav-link:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--primary-100) 55%, transparent);
  outline-offset: 2px;
  border-radius: 4px;
}

/* ── 16. List Groups ────────────────────────────────────────── */
.list-group-item {
  padding: 0.875rem 1.25rem !important;
  border-color: var(--dash-border) !important;
  font-size: 0.875rem;
}

.list-group-item-action:hover {
  background: var(--primary-300) !important;
  color: var(--primary-100) !important;
}

/* ── 17. Utility Classes ────────────────────────────────────── */

/* Gradient backgrounds used in accent cards */
.bg-brand  { background: var(--primary-100) !important; }
.bg-accent { background: var(--accent-200)  !important; }

/* ── 18. Subscription chips (my-site overview) ─────────────── */

/* Tenant card body: stacks below 992px, side-by-side at lg+.
   Subs column is fixed to its natural cap so its space is predictable;
   sites column claims everything else and uses a wrap-grid (below) so
   the remaining width fills with cards instead of going blank. */
.tenant-card-grid {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.tenant-card-subs { min-width: 0; }
.tenant-card-sites {
  padding-top: 1rem;
  border-top: 1px solid var(--dash-border, #dee2e6);
  min-width: 0;
}
@media (min-width: 992px) {
  .tenant-card-grid {
    display: grid;
    grid-template-columns: minmax(280px, 560px) minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
  }
  .tenant-card-sites {
    padding-top: 0;
    border-top: none;
    border-left: 1px solid var(--dash-border, #dee2e6);
    padding-left: 1.5rem;
  }
}

/* Soft plan pill (used on sub-chip + site mini-card). Keeps brand color
   recognizable via a tinted background and strong-colored text, but with
   far less visual weight than the saturated solid fill from PLAN_BADGE. */
.plan-pill-soft {
  display: inline-flex;
  align-items: center;
  padding: .1rem .45rem;
  border-radius: .85rem;
  font-size: .6rem;
  font-weight: 700;
  letter-spacing: .01em;
  background: var(--plan-pill-bg, rgba(108,117,125,.14));
  color:      var(--plan-pill-fg, #495057);
  white-space: nowrap;
  flex-shrink: 0;
  line-height: 1.3;
}
/* The rgba() triples here were the retired #5E4AAE and the retired cyan: a
   migration cannot stop at the hex literals, because a retired colour hiding
   inside an rgba() is still that colour on the screen. */
.plan-pill-soft[data-plan="plan.crece"]      { --plan-pill-bg: rgba(90,61,190,.14);  --plan-pill-fg: var(--primary-100); }
.plan-pill-soft[data-plan="plan.evoluciona"] { --plan-pill-bg: rgba(60,16,175,.12);  --plan-pill-fg: var(--accent-200); }
.plan-pill-soft[data-plan="plan.inicia"]     { --plan-pill-bg: rgba(106,111,129,.14); --plan-pill-fg: var(--text-200); }

/* Dark theme: the pill's foregrounds above are chosen for a WHITE card, and a
   14%-opacity tint over a dark surface is nearly the surface itself. Lift both:
   brighter text, stronger tint: so the plan name stays readable. Measured
   before: 1.00–1.74:1. */
html[data-theme="dark"] .plan-pill-soft {
  background: var(--plan-pill-bg, rgba(255,255,255,.10));
  color:      var(--plan-pill-fg, #c9ced8);
}
html[data-theme="dark"] .plan-pill-soft[data-plan="plan.crece"]      { --plan-pill-bg: rgba(167, 148, 240,.24); --plan-pill-fg: #c3b5f5; }
html[data-theme="dark"] .plan-pill-soft[data-plan="plan.evoluciona"] { --plan-pill-bg: rgba(90, 61, 190,.22);   --plan-pill-fg: #7fd4ff; }
html[data-theme="dark"] .plan-pill-soft[data-plan="plan.inicia"]     { --plan-pill-bg: rgba(255,255,255,.12); --plan-pill-fg: #c9ced8; }

/* Site mini-cards: responsive wrap-grid (replaces former horizontal scroll).
   - Mobile: minmax forces 1 column on phones < ~400px (one full-width card).
   - Tablet & wide: auto-fills as many ~170px columns as fit, then stretches
     each to 1fr so cards keep edges aligned with no leftover blank space. */
.tenant-sites-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
  gap: .5rem;
}
.tenant-site-cell {
  min-height: 64px;
  transition: box-shadow .15s, border-color .15s;
}
.tenant-site-icon {
  width: 26px;
  height: 26px;
  font-size: .7rem;
}
@media (max-width: 399.98px) {
  .tenant-sites-grid {
    grid-template-columns: 1fr;
  }
}

.sub-list {
  max-width: 560px;
  /* Caps the visible list at ~4 typical rows. Mixed row heights
     (with/without the linked-sites sub-line) average ~50px + .5rem gap,
     so 4 rows ≈ 224px; 240px leaves a hint of the next row to signal scroll. */
  max-height: 240px;
  overflow-y: auto;
  padding-right: .35rem;
  scrollbar-gutter: stable;
}
.sub-list::-webkit-scrollbar { width: 6px; }
.sub-list::-webkit-scrollbar-thumb {
  background: var(--dash-border, #dee2e6);
  border-radius: 3px;
}
.sub-list::-webkit-scrollbar-thumb:hover { background: var(--primary-200); }
.sub-list { scrollbar-width: thin; scrollbar-color: var(--dash-border,#dee2e6) transparent; }

.sub-row {
  display: flex;
  flex-direction: column;
  gap: .15rem;
}

.sub-chip {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  width: 100%;
  padding: .35rem .55rem;
  background: transparent;
  border: 1px solid var(--dash-border);
  border-radius: .5rem;
  cursor: pointer;
  text-align: left;
  transition: background .15s, border-color .15s;
  line-height: 1.2;
}
.sub-chip:hover {
  background: var(--primary-300, rgba(90, 61, 190,.07));
  border-color: var(--primary-200);
}
.sub-chip:hover .sub-chip-arrow { opacity: 1; }
.sub-chip-price {
  font-size: .72rem;
  white-space: nowrap;
  flex-shrink: 0;
}
.sub-chip-renew {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  font-size: .7rem;
  white-space: nowrap;
  flex-shrink: 0;
}
.sub-chip-renew .bi { font-size: .72rem; opacity: .7; }
.sub-chip-renew-warn { color: var(--status-amber, #ad8b00); }
.sub-chip-renew-warn .bi { opacity: .85; }

/* Allocation pill colored by ENTITLEMENT UTILIZATION: using what you pay for is
   good. Base is NEUTRAL purple, matching the countable-quota vocabulary #usage
   uses; the dots show how full. Red means OVERAGE only. The previous scheme
   (red when empty, green when full) said the opposite of #usage for the same
   number, so the two screens disagreed about whether 0/3 was alarming. */
.sub-alloc {
  margin-left: auto;
  padding: .12rem .5rem;
  border-radius: .85rem;
  background: rgba(255,193,7,.18);
  color: var(--status-amber, #ad8b00);
  font-size: .68rem;
  font-weight: 600;
  white-space: nowrap;
  flex-shrink: 0;
}
/* Neutral by default: filling a plan's slots is a completion state, not an
   alarm and not a prize. The dots carry the "how full" signal, exactly as
   #usage does for the same countable quotas. */
.sub-alloc-neutral {
  background: rgba(90, 61, 190,.10);
  color: var(--primary-100);
}
/* Danger is reserved for genuine OVERAGE (used > cap): the one case that is
   actually wrong rather than merely full. */
.sub-alloc-over {
  background: rgba(220,53,69,.14);
  color: var(--status-red, #dc3545);
}
.sub-alloc-dots {
  display: inline-flex;
  align-items: center;
  gap: .1rem;
  margin-right: .35rem;
  font-size: .5rem;
  vertical-align: middle;
}
.sub-alloc-dots .bi-circle-fill { color: var(--primary-100); }
.sub-alloc-dots .bi-circle      { opacity: .35; }

/* Shown only when every slot the plan grants is in use. States the fact and the
   two ways out; the offer itself comes from the service when the button is
   pressed, never from a guess made here. */
.sub-alloc-hint {
  margin-top: .35rem;
  font-size: .7rem;
  line-height: 1.35;
  color: var(--bs-secondary-color, #6c757d);
  max-width: 46ch;
}

/* ── Expansion nudge (engagement Phase 3): contextual upsell, theme-aware.
   Inherits the .card background (dark-mode override handles it); adds a primary
   left accent. No gradient. ── */
.expansion-nudge {
  border: 1px solid var(--dash-border-2);
  border-left: 3px solid var(--primary-100);
}

/* ── Activation checklist (engagement Phase 1): mobile-first, theme-aware ── */
.activation-card { border: 1px solid var(--dash-border-2); }
.act-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem 1.25rem; flex-wrap: wrap; }
.act-head-text { min-width: 0; }
.act-cta { flex-shrink: 0; white-space: nowrap; align-self: center; }
.act-progress { height: 6px; border-radius: 100px; background: var(--dash-border-2); margin: 1.1rem 0 .35rem; overflow: hidden; }
.act-progress > span { display: block; height: 100%; background: var(--primary-100); border-radius: 100px; }
.act-steps { list-style: none; margin: .5rem 0 0; padding: 0; display: flex; flex-direction: column; }
.act-step { display: flex; align-items: flex-start; gap: .8rem; padding: .5rem 0; }
.act-mark { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: .72rem; border: 2px solid var(--dash-border-2); color: var(--text-300); margin-top: .05rem; }
.act-done .act-mark { background: var(--status-green); border-color: var(--status-green); color: #fff; }
.act-current .act-mark { border-color: var(--primary-100); color: var(--primary-100); }
.act-body { display: flex; flex-direction: column; min-width: 0; }
.act-label { font-weight: 600; font-size: .92rem; color: var(--text-100); }
.act-done .act-label { color: var(--text-300); text-decoration: line-through; }
.act-hint { font-size: .78rem; color: var(--text-300); }
@media (prefers-reduced-motion: no-preference) { .act-progress > span { transition: width .5s ease; } }
@media (max-width: 575.98px) { .act-cta { width: 100%; align-self: stretch; margin-top: .25rem; } }

.sub-chip-arrow {
  margin-left: auto;
  flex-shrink: 0;
  opacity: 0;
  color: var(--primary-100);
  font-size: .65rem;
  transition: opacity .15s;
}
/* When the alloc pill is present, it takes the right-anchor slot
   and the chevron sits immediately after it with a small gap. */
.sub-alloc ~ .sub-chip-arrow { margin-left: .25rem; }

.sub-row-sites {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .35rem;
  padding: 0 .55rem 0 .75rem;
  font-size: .7rem;
  line-height: 1.3;
  color: var(--text-300);
}
.sub-row-sites::before {
  content: "└";
  color: var(--dash-border, #dee2e6);
  margin-right: .15rem;
}
.sub-row-sep { color: var(--dash-border, #dee2e6); }
.sub-site-link {
  color: var(--primary-100);
  text-decoration: none;
  font-weight: 500;
  display: inline-flex;
  align-items: center;
  gap: .2rem;
}
.sub-site-link:hover { text-decoration: underline; }
.sub-site-link .bi-globe { font-size: .65rem; opacity: .7; }
.sub-site-missing {
  color: var(--text-300);
  font-style: italic;
}
.sub-site-free {
  color: var(--primary-100);
  font-weight: 500;
}

.sub-chip-expired { opacity: .65; }
.sub-chip-expired:hover { opacity: 1; }

/* Cancelled subscription detail-cards inside the multi-sub <details> toggle.
   Slightly muted to signal "historical" without hiding their content. */
.sub-detail-card-muted { opacity: .75; }
.sub-detail-card-muted:hover { opacity: 1; }

/* ── Billing page (/dashboard/#billing) compact rows ───────────────────── */

.billing-sub-row {
  padding: .55rem .65rem;
  border: 1px solid var(--dash-border, #dee2e6);
  border-radius: .5rem;
  background: var(--dash-surface, #fff);
  color: var(--bs-body-color, inherit);
  transition: border-color .15s, box-shadow .15s;
}
.billing-sub-row:hover {
  border-color: var(--primary-200);
  box-shadow: 0 1px 4px rgba(0,0,0,.04);
}
.billing-sub-row.is-muted { opacity: .65; }
.billing-sub-row.is-muted:hover { opacity: 1; }

.billing-invoice-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .75rem;
  padding: .55rem .25rem;
  border-bottom: 1px solid var(--dash-border, #dee2e6);
}
.billing-invoice-row:last-child { border-bottom: none; }
/* Row is clickable → invoice-detail modal (delegated handler). */
.billing-invoice-row[data-invoice-id] { cursor: pointer; }
.billing-invoice-row[data-invoice-id]:hover { background: var(--dash-hover, rgba(0,0,0,.03)); }
/* The "Historial de pagos" per-page rows share the row style; the last row in a
   page shouldn't lose its border when a "Cargar más" wrap follows it. */
.history-rows-container .history-more-wrap { border-top: none; }

/* ── Subscription detail block (inside _renderSubscriptionBlock) ──
   Tight horizontal info bar replaces the old 3-card strip; section blocks
   with small uppercase titles replace the nested card wrappers around
   limits / features / addons / mgmt. */

.sub-info-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: .25rem 1.25rem;
  padding: .65rem .25rem;
  border-top: 1px solid var(--dash-border, #dee2e6);
  border-bottom: 1px solid var(--dash-border, #dee2e6);
  margin-bottom: 1rem;
}
.sub-info-cell {
  display: flex;
  flex-direction: column;
  gap: .2rem;
  min-width: 0;
  flex: 1 1 auto;
}
.sub-info-divider {
  width: 1px;
  background: var(--dash-border, #dee2e6);
  align-self: stretch;
}
@media (max-width: 575.98px) {
  .sub-info-bar { flex-direction: column; gap: .6rem; }
  .sub-info-divider { display: none; }
}

.sub-section { margin-bottom: 1rem; }
.sub-section:last-child { margin-bottom: 0; }
.sub-section-title {
  display: flex;
  align-items: center;
  gap: .35rem;
  font-size: .68rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--text-300);
  margin-bottom: .4rem;
}

.limit-chip {
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  padding: .15rem .55rem;
  font-size: .7rem;
  background: var(--dash-bg, #f8f9fa);
  color: var(--text-200, #495057);
  border-radius: .85rem;
  white-space: nowrap;
}
.limit-chip .bi { color: var(--primary-100); font-size: .72rem; }
.limit-chip .limit-value { font-weight: 700; color: var(--text-100, #212529); }

.feature-chip {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  padding: .12rem .5rem;
  font-size: .67rem;
  font-weight: 500;
  border-radius: .85rem;
  background: color-mix(in srgb, var(--status-green) 12%, var(--dash-surface));
  color: var(--status-green);
}
.feature-chip .bi { font-size: .7rem; }
/* "not included in your plan": informational text, not an inactive control,
   so WCAG applies to it in full. It read --text-300 at 75% opacity on its own
   tint: 2.81:1, and 4.41:1 even at full strength, because --text-300 is not
   for text on a tint. The strikethrough and the lighter weight already say
   "not yours"; the colour does not need to say it again. Recede by size,
   weight and air (manual §11), never by dropping below the floor. */
.feature-chip.is-disabled {
  background: rgba(108,117,125,.10);
  color: var(--text-200);        /* 5.48:1 on the chip tint */
  font-weight: 400;
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

.addon-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .75rem;
  padding: .35rem .25rem;
  font-size: .8rem;
  border-bottom: 1px solid var(--dash-border, #dee2e6);
}
.addon-row:last-child { border-bottom: none; }

.sub-expired-details summary {
  cursor: pointer;
  color: var(--primary-100);
  font-size: .72rem;
  font-weight: 500;
  list-style: none;
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  user-select: none;
}
.sub-expired-details summary::-webkit-details-marker { display: none; }
.sub-expired-details summary::after { content: "▾"; font-size: .6rem; }
.sub-expired-details[open] summary::after { content: "▴"; }

/* Cleanly land the expanded subscription card below any sticky chrome. */
[id^="subCollapse-"] { scroll-margin-top: 90px; }

/* Dropdown items */
.dropdown-item {
  padding: 0.6rem 1rem;
  font-size: 0.875rem;
  font-weight: 500;
}

/* Alerts */
.alert {
  font-size: 0.875rem;
  border-radius: 0.75rem;
}

/* Section divider label */
.section-label {
  font-size: 0.6875rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-300);
}

/* ── 19. Permission Visibility ──────────────────────────────── */
/*   Elements with data-requires-group / data-requires-permission
 *   are visible by default. dashboard.js hides them via .perm-hidden
 *   when the current user lacks the required claim.             */

.perm-hidden {
  display: none !important;
}

/* ── 20. Responsive ─────────────────────────────────────────── */
@media (max-width: 991.98px) {
  :root { --sidebar-width: 0px; }

  /* The topbar loses the sidebar's width here, so it also loses the room its
     1.5rem side padding was spending. 48px of padding out of 390px is 12% of
     a phone screen given to nothing. Matches .content-area's 1rem below. */
  .topbar { padding: 0 1rem; gap: .5rem; }
  /* Both topbar groups must be able to SHRINK. Without this the fixed-width
     search box and the button cluster force the header wider than the screen,
     which is a page-level horizontal scroll on every route. */
  .topbar > .d-flex { min-width: 0; }

  .sidebar {
    transform: translateX(-100%);
    width: 260px;
  }

  .sidebar.show {
    transform: translateX(0);
    box-shadow: var(--dash-shadow-md);
  }

  .sidebar-overlay {
    display: block;
  }

  .sidebar.show + .sidebar-overlay {
    opacity: 1;
    pointer-events: auto;
  }

  .toggle-sidebar { display: block; }
  .content-area   { padding: 1rem; }
}

@media (max-width: 575.98px) {
  .page-header    { flex-direction: column; align-items: stretch; }

  /* ── Stat cards on a phone ───────────────────────────────────
     Reported by the product owner: "all those boxes are too big for mobile".

     They sit two-up in a `col-6`, so at 390px each card is ~171px wide: and
     carried 1.25rem of padding on both sides with a 1.625rem value. A DATE
     value ("30 jul 2026") needs about 145px at that size, which is most of
     the card before padding, so it wrapped or pushed. The desktop sizes were
     never wrong; they were never re-decided for the width where two of them
     share a row.

     These live INSIDE this block on purpose. The first version of this fix
     added a SECOND `@media (max-width: 575.98px)` earlier in the file: same
     specificity, earlier in source order, so every declaration lost to the
     line below and the fix did nothing. Measured at 26px when 20px was
     intended. One block per breakpoint. */
  .stat-value     { font-size: 1.25rem; margin: 0.25rem 0; overflow-wrap: anywhere; }
  .stat-card      { padding: 0.875rem !important; }
  .stat-icon      { width: 28px; height: 28px; font-size: 0.875rem; margin-bottom: 0.5rem; }
  .stat-label     { font-size: 0.625rem; letter-spacing: 0.06em; }

  /* A breadcrumb naming a workspace AND a site is easily 60 characters. It
     must wrap and break rather than push the header wider than the screen:
     the page title and subtitle share the same constraint. */
  .page-header > div { min-width: 0; }
  .page-header .breadcrumb { flex-wrap: wrap; row-gap: .15rem; }
  .page-header .breadcrumb-item,
  .page-title,
  .page-subtitle { overflow-wrap: anywhere; }

  /* THE SUBSCRIPTION CHIP. Its children: plan pill, price, renewal date and
     the allocation pill: are every one of them `white-space: nowrap` and
     `flex-shrink: 0`, so on a narrow card there is nothing left that CAN give
     and the row simply runs past its container: the chevron was the element
     hanging outside the viewport. Letting the chip wrap turns
     "Inicia · $2,079/año · 31 dic 2030 · 1/1 sitios" into two readable lines
     instead of one clipped one. The arrow keeps its right anchor per line. */
  .sub-chip { flex-wrap: wrap; row-gap: .2rem; }
  .sub-chip > .plan-pill-soft { flex-shrink: 1; min-width: 0; }
}

/* ── 21. W5 List-view primitives ────────────────────────────────────
 *   Extends the .u-empty-state / .u-skeleton primitives from
 *   /assets/css/ulfratech.css with classes the new shared/list-view.js
 *   helpers emit. See dashboard/UX_CONVENTIONS.md for adoption guide.
 */

.u-skeleton-block {
  display: flex;
  flex-direction: column;
  gap: .35rem;
  padding: 1rem;
}
.u-skeleton-row {
  display: flex;
  flex-direction: column;
  gap: .35rem;
}

.u-list-error.alert {
  margin: 0;
}
.u-list-error-msg {
  color: var(--text-200, #495057);
  word-break: break-word;
}

.u-load-more {
  border-top: 1px solid var(--dash-border);
}
.u-load-more-btn .spinner-border {
  vertical-align: -2px;
}

.u-list-count {
  display: inline-block;
}

/* Sortable column headers (shared/sort-header.js).
   Hover and focus get a subtle background hint so the affordance is
   discoverable; the arrow indicator gets pulled into the inline run by
   adjacent margin via .ms-1 in the JS, but live colors here. */
th.u-sortable {
  user-select: none;
  position: relative;
  white-space: nowrap;
  transition: background-color .12s;
}
th.u-sortable:hover {
  background: rgba(90, 61, 190, 0.06);
}
th.u-sortable:focus-visible {
  /* Was a hard-coded fallback to the retired #9076e0 (3.59:1 on white). The
     ring token resolves per theme: see ulfratech.css's focus block. */
  outline: 2px solid var(--ulfra-focus-ring, #5A3DBE);
  outline-offset: -2px;
}
th.u-sortable[aria-sort="ascending"] .u-sort-arrow,
th.u-sortable[aria-sort="descending"] .u-sort-arrow {
  color: var(--primary-100);
  opacity: 1;
}
th.u-sortable .u-sort-arrow {
  opacity: .55;
  font-size: .75rem;
  display: inline-block;
}

/* Date-bucket separators (shared/date-buckets.js).
   Sticky-ish header row inside long lists. Rounded top corners help
   visually punctuate the bucket inside .table-responsive. */
tr.u-date-bucket-row td.u-date-bucket {
  border-top: 1px solid var(--dash-border);
  border-bottom: 1px solid var(--dash-border);
  font-size: .68rem;
  letter-spacing: .04em;
}

/* ── 22. W5 Confirm-modal helper hooks ──────────────────────────────
 *   The shared/confirm-modal.js helper renders a typed-confirmation
 *   gate for irreversible operations. Match the surrounding form styles.
 */
#dashSharedConfirmModal .docs-confirm-list {
  margin: 0;
  padding-left: 1.1rem;
}
#dashSharedConfirmModal .docs-confirm-list li { line-height: 1.4; }
#dashSharedConfirmModal #dash-confirm-typed {
  font-family: 'JetBrains Mono', ui-monospace, monospace;
}

/* ── 23. W5 Required-field markers ──────────────────────────────────
 *   Forms that mark required fields with class .form-label-required
 *   get a small red asterisk auto-inserted via ::after. Pairs with
 *   aria-required="true" on the input.
 */
.form-label-required::after {
  content: " *";
  color: var(--status-red, #dc2626);
  font-weight: 700;
  margin-left: .15rem;
}

/* ── 24. Subscription summary card (shared component) ──────────────
 *   Used on #overview, #my-site, and #billing to give the customer a
 *   one-glance answer to "what am I paying for?". Composed of a header
 *   row (plan name + code + status), an optional description, a
 *   3-metric strip (cost / renewal / sites), and stacked sections for
 *   limits, add-ons, and capabilities. Inherits .sub-section,
 *   .limit-chip, .feature-chip, .plan-pill-soft, .status-badge from
 *   sections 10 and 18. */
.sub-summary-card {
  background: var(--dash-surface, #fff);
  color: var(--bs-body-color, inherit);
  border: 1px solid var(--dash-border, #dee2e6);
  border-radius: .65rem;
  padding: 1rem 1.1rem;
  box-shadow: 0 1px 2px rgba(0,0,0,.02);
}
.sub-summary-card-empty {
  background: var(--dash-bg, #f8f9fa);
  border-style: dashed;
}

.sub-summary-strip {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: .5rem 1.25rem;
  padding: .65rem .15rem;
  border-top: 1px solid var(--dash-border, #dee2e6);
  border-bottom: 1px solid var(--dash-border, #dee2e6);
  margin: .5rem 0 .85rem;
}
@media (max-width: 575.98px) {
  .sub-summary-strip {
    grid-template-columns: 1fr;
    gap: .55rem;
  }
}
.sub-summary-metric {
  display: flex;
  flex-direction: column;
  gap: .15rem;
  min-width: 0;
}
.sub-summary-metric-label {
  font-size: .62rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--text-300);
}
.sub-summary-metric-value {
  font-size: .85rem;
  line-height: 1.25;
  color: var(--text-100, #212529);
  display: flex;
  flex-direction: column;
  gap: .15rem;
  align-items: flex-start;
}
.sub-summary-actions { margin-top: .25rem; }

/* ── 21. Dark theme ────────────────────────────────────────────
 *
 * Minimal dark palette wired through the existing --dash-* tokens so every
 * surface that already read those tokens (sidebar, topbar, cards, tables,
 * modals) flips automatically: no per-component rewrite. The Bootstrap
 * bg-light / border-* utilities are the only stragglers; we override those
 * inline below where they appear in the dashboard chrome.
 *
 * Flat colors only: per [[no-gradients]] fleet convention. The palette
 * mirrors GitHub / Linear: near-black canvas, one step lighter for cards.
 * ─────────────────────────────────────────────────────────────── */
html[data-theme="dark"] {
  --dash-bg:        #0f1115;
  --dash-surface:   #171a21;
  --dash-border:    rgba(255, 255, 255, 0.08);
  --dash-border-2:  rgba(255, 255, 255, 0.14);
  --dash-shadow:    0 1px 3px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.35);
  --dash-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.55);

  --bs-body-bg:     #0f1115;
  --bs-body-color:  #e6e8ec;

  /* The rest of the Bootstrap variables this stylesheet actually CONSUMES.
     They were missing, and a missing --bs-* does not fall back to a dark
     value: it keeps Bootstrap's LIGHT default, because we set our own
     `data-theme` attribute and never `data-bs-theme`, so Bootstrap's own dark
     block never activates.

     That is not cosmetic. `.plan-price-tax` on the checkout page reads
     `var(--bs-secondary-color)` and so rendered near-black #212529 at 75%
     opacity on a #171a21 card: a measured 1.13:1, i.e. the words "Incluye
     impuestos" were invisible on the page where the customer confirms what
     they are about to pay.

     Bridged here rather than by adding `data-bs-theme="dark"` to the shells:
     that would flip every Bootstrap component at once across four shells in
     one step, which is a much larger visual change than the bug warrants.
     Grep for `var(--bs-` before adding a consumer: if it is not in this
     list, it is still light. */
  --bs-secondary-color: rgba(236, 237, 242, 0.72);
  --bs-secondary:       #9093A0;
  --bs-border-color:    rgba(255, 255, 255, 0.14);
  --bs-tertiary-bg:     #1c1f27;
  --bs-light:           #1c1f27;

  /* Added 2026-08-29 after a rendered-DOM contrast sweep of every dashboard
     route in BOTH themes. Every line below was a real measurement on a real
     page, not a suspicion, and each one is the same failure the comment
     above describes, found again because nobody had re-run the grep:

       .text-body        #212529 on #171a21 ... 1.12:1  (--bs-body-color-RGB
                                                 was never set; the non-rgb
                                                 one alone does not feed it)
       a / .btn-link     #0d6efd on #171a21 ... 3.86:1
       code              #d63384 on #171a21 ... 3.86:1
       .alert-light      #9093A0 on #fcfcfd ... 2.98:1  (the alert stayed a
                                                 near-WHITE panel in dark)
     Grep for `var(--bs-` before adding a consumer. */
  --bs-body-color-rgb:       236, 237, 242;
  --bs-secondary-bg:         #1c1f27;
  --bs-link-color:           #A794F0;  /* --ulfra-action-text: 6.74:1 surface */
  --bs-link-color-rgb:       167, 148, 240;
  --bs-link-hover-color:     #C9BFF7;  /* 10.18:1 surface */
  --bs-link-hover-color-rgb: 201, 191, 247;
  --bs-code-color:           #C9BFF7;  /* --ulfra-data-code dark */
  --bs-card-cap-bg:          rgba(255, 255, 255, 0.04);
  --bs-light-bg-subtle:      #1c1f27;
  --bs-light-border-subtle:  rgba(255, 255, 255, 0.14);
  --bs-light-text-emphasis:  #ECEDF2;
  --bs-danger:               #FF7A6E;
  --bs-danger-rgb:           255, 122, 110;
  --bs-success:              #58D9A6;
  --bs-success-rgb:          88, 217, 166;
  --bs-warning:              #F2C14E;
  --bs-warning-rgb:          242, 193, 78;

  /* Text tokens borrowed from ulfratech.css so utilities inherit correctly.
     The manual's §11 DARK column, v1.1. In dark, colours go UP in luminosity
     and DOWN in saturation.

     --text-300 was #8a8f9a, and that was a second, quieter failure than the
     light one: it measured 4.38:1 on the manual's bg/overlay #272A33: below
     AA exactly where a modal or a popover lives, and carried no annotation at
     all, so there was no wrong number for anyone to contradict. v1.1 re-cut it
     to #9093A0. It is TEXT: metadata recedes by size, weight and air. */
  --text-100: #ECEDF2; /* --ulfra-text-primary   : 14.89:1 surface · 16.08:1 canvas */
  --text-200: #B0B3BF; /* --ulfra-text-secondary: 8.32:1 surface ·  8.98:1 canvas */
  --text-300: #9093A0; /* --ulfra-text-muted     :  5.69:1 surface ·  6.17:1 canvas · 5.38:1 --bg-200 */
  --bg-100:   #171a21;
  --bg-200:   #1c1f27;
  --bg-300:   rgba(255, 255, 255, 0.16);

  /* ── Brand colours in dark ─────────────────────────────────────────────
   *
   * These were MISSING. ulfratech.css defines --primary-100/200/300 on bare
   * :root only, and a variable that is not redefined here keeps its LIGHT
   * value: so every link, active tab, chip and icon painted with
   * var(--primary-100) rendered the light-theme violet on a carbon surface:
   *
   *   #5A3DBE on #171a21 ......... 2.54:1   (pre-migration)
   *   #5A3DBE on #171a21 ......... 2.36:1   (post-migration, still failing)
   *
   * Nothing reported it, because a CSS variable that falls through is not an
   * error: it is simply the wrong colour, everywhere, in one theme.
   *
   * The manual's answer (§08/§11) is that in dark, colours go UP in
   * luminosity and DOWN in saturation, and that #3C10AF / #5A3DBE are never
   * text on dark. So the dark column of brand/tokens.css applies:
   *
   *   --ulfra-action-text #A794F0 on #171a21 .... 6.75:1
   *   --ulfra-action-text #A794F0 on #0f1115 .... 7.32:1
   *   white on --ulfra-action-fill #6D4FD6 ...... 5.62:1
   *   --ulfra-data-code  #C9BFF7 on #171a21 .... 10.18:1
   *
   * --primary-100 carries BOTH roles in this codebase (text and fill), and
   * the text role is the one that fails, so it takes the text value; the two
   * fill sites that matter (.btn-primary, .bg-primary) are re-pointed at
   * --primary-fill below so white-on-violet stays a button.
   */
  --primary-100: #A794F0;              /* --ulfra-action-text */
  --primary-200: #C9BFF7;              /* brighter still, for the hover step */
  --primary-300: rgba(167, 148, 240, .14); /* --ulfra-action-tint */
  --primary-fill: #6D4FD6;             /* --ulfra-action-fill (dark column) */
  --primary-100-rgb: 167, 148, 240;
  --primary-200-rgb: 201, 191, 247;
  --accent-100: #A794F0;               /* migration shim: see ulfratech.css */
  --ulfra-focus-ring: #A794F0;         /* 6.75:1 on the dark surface */
}

/* The two places --primary-100 is a FILL rather than text. In light they are
 * the same token; in dark they cannot be, because #A794F0 with white on it is
 * 1.6:1. Scoped narrowly on purpose: a blanket override would repaint every
 * text use back to the failing value. */
html[data-theme="dark"] .btn-primary,
html[data-theme="dark"] .bg-primary,
html[data-theme="dark"] .text-bg-primary,
html[data-theme="dark"] .btn-check:checked + .btn-outline-primary,
html[data-theme="dark"] .btn-outline-primary.active,
html[data-theme="dark"] .btn-outline-primary:active {
  background-color: var(--primary-fill) !important;
  border-color: var(--primary-fill);
  color: #fff;
}
html[data-theme="dark"] .btn-primary:hover {
  /* One luminosity step up from --ulfra-action-fill; white on it = 4.51:1. */
  background-color: #7d61e0;
  border-color: #7d61e0;
}

html[data-theme="dark"] .btn-outline-secondary {
  --bs-btn-color:                 #B0B3BF;  /* --ulfra-text-secondary: 8.32:1 */
  /* Was `border/strong` #3A3E4A, which the manual names as the exact defect
     v1.2 was opened for: it is a DECORATIVE token: card edges, separators,
     table rules, and drawing a CONTROL boundary with it fails SC 1.4.11.
     Measured on the dashboard's own dark grounds: 1.63:1 on the surface
     #171a21, 1.77:1 on the canvas #0f1115, against a 3:1 floor.
     `--ulfra-border-control` dark (#74798B) measures 4.02:1 / 4.36:1, and
     3.81:1 on the raised surface: the worst case of the three. Truncated. */
  --bs-btn-border-color:          #74798B;
  --bs-btn-hover-color:           #16171D;
  --bs-btn-hover-bg:              #B0B3BF;
  --bs-btn-hover-border-color:    #B0B3BF;
  --bs-btn-active-color:          #16171D;
  --bs-btn-active-bg:             #B0B3BF;
  --bs-btn-active-border-color:   #B0B3BF;
  --bs-btn-disabled-color:        #B0B3BF;
  /* Disabled components are exempt from SC 1.4.11 by the SC itself; the
     token still moves, so the retired value does not sit next to the one
     that replaced it waiting to be copied. */
  --bs-btn-disabled-border-color: #74798B;
}
html[data-theme="dark"] .btn-outline-danger {
  --bs-btn-color:                 #FF7A6E;  /* --ulfra-danger dark: 6.84:1 */
  --bs-btn-border-color:          rgba(255, 122, 110, .45);
  --bs-btn-hover-color:           #16171D;
  --bs-btn-hover-bg:              #FF7A6E;
  --bs-btn-hover-border-color:    #FF7A6E;
  --bs-btn-active-color:          #16171D;
  --bs-btn-active-bg:             #FF7A6E;
  --bs-btn-active-border-color:   #FF7A6E;
}

/* A .section-label inside a violet-filled header painted --text-300 on the
   fill: 1.73:1. It is a LABEL, so it keeps its size and weight and simply
   inherits the ink its container already established. */
html[data-theme="dark"] .card-header .section-label,
html[data-theme="dark"] .bg-primary .section-label { color: inherit; }

html[data-theme="dark"] body {
  background-color: var(--dash-bg);
  color: var(--bs-body-color);
}

/* Bootstrap chrome that hardcodes bg-light / white: hoist to dark surface. */
html[data-theme="dark"] .bg-light,
html[data-theme="dark"] .bg-white,
html[data-theme="dark"] .form-control.bg-light,
html[data-theme="dark"] .input-group-text.bg-light {
  background-color: var(--dash-surface) !important;
  color: var(--bs-body-color) !important;
  border-color: var(--dash-border) !important;
}

/* Cards read --dash-surface for background/border but the shadow needs a heavier
 * value in dark mode; --dash-shadow above already handles that. */
html[data-theme="dark"] .card {
  color: var(--bs-body-color);
}

/* Tables: Bootstrap defaults to light striping and dark text. */
html[data-theme="dark"] .table {
  --bs-table-bg:            transparent;
  --bs-table-color:         var(--bs-body-color);
  --bs-table-border-color:  var(--dash-border);
  --bs-table-striped-bg:    rgba(255, 255, 255, 0.03);
  --bs-table-striped-color: var(--bs-body-color);
  --bs-table-hover-bg:      rgba(255, 255, 255, 0.05);
  --bs-table-hover-color:   var(--bs-body-color);
  color: var(--bs-body-color);
}
html[data-theme="dark"] .table > :not(caption) > * > * {
  background-color: transparent;
  border-color: var(--dash-border);
}

/* Modals: background + border, plus header/footer separators. */
html[data-theme="dark"] .modal-content {
  background-color: var(--dash-surface);
  color: var(--bs-body-color);
  border-color: var(--dash-border);
}
html[data-theme="dark"] .modal-header,
html[data-theme="dark"] .modal-footer {
  border-color: var(--dash-border);
}
html[data-theme="dark"] .btn-close {
  /* Bootstrap ships a dark-on-light X SVG: invert it for dark surfaces. */
  filter: invert(1) grayscale(1) brightness(1.8);
}

/* Plain Bootstrap .nav-tabs (used by the unified #site-domains shell + the
 * domain detail modal). Bootstrap's default active tab is dark text on a light
 * fill with a light border: unreadable on a dark surface. Give the active tab
 * light text on the dark surface with a visible border; inactive/hover legible. */
html[data-theme="dark"] .nav-tabs {
  border-bottom-color: var(--dash-border);
}
html[data-theme="dark"] .nav-tabs .nav-link {
  color: var(--text-200);
}
html[data-theme="dark"] .nav-tabs .nav-link:hover,
html[data-theme="dark"] .nav-tabs .nav-link:focus {
  color: var(--text-100);
  border-color: var(--dash-border-2) var(--dash-border-2) var(--dash-border);
}
html[data-theme="dark"] .nav-tabs .nav-link.active {
  color: var(--text-100);
  background-color: var(--dash-surface);
  border-color: var(--dash-border-2) var(--dash-border-2) var(--dash-surface);
}

/* Form controls that don't opt into bg-light explicitly. */
html[data-theme="dark"] .form-control,
html[data-theme="dark"] .form-select,
html[data-theme="dark"] .input-group-text {
  background-color: var(--dash-surface);
  color: var(--bs-body-color);
  border-color: var(--dash-border-2);
}
html[data-theme="dark"] .form-control::placeholder {
  color: var(--text-300);
  opacity: 0.7;
}
html[data-theme="dark"] .form-control:focus,
html[data-theme="dark"] .form-select:focus {
  background-color: var(--dash-surface);
  color: var(--bs-body-color);
  border-color: var(--primary-200);
  box-shadow: 0 0 0 0.2rem rgba(167, 148, 240, 0.22);
}

/* Dropdown menus (notifications, admin, etc.). */
html[data-theme="dark"] .dropdown-menu {
  background-color: var(--dash-surface);
  color: var(--bs-body-color);
  border-color: var(--dash-border);
}
html[data-theme="dark"] .dropdown-item {
  color: var(--bs-body-color);
}
html[data-theme="dark"] .dropdown-item:hover,
html[data-theme="dark"] .dropdown-item:focus {
  background-color: rgba(255, 255, 255, 0.06);
  color: var(--bs-body-color);
}

/* text-muted maps to a softer gray on dark. */
html[data-theme="dark"] .text-muted {
  color: var(--text-300) !important;
}

/* List groups (search suggestions, tenant tabs, etc.). */
html[data-theme="dark"] .list-group-item {
  background-color: var(--dash-surface);
  color: var(--bs-body-color);
  border-color: var(--dash-border);
}

/* Billing-pending panels: warm peach tone in light mode; keep a subtle warm
 * tint in dark mode so the "attention" affordance survives the theme flip. */
.billing-pending-row {
  background: #fffaf0;
}
html[data-theme="dark"] .billing-pending-row {
  background: var(--dash-surface);
  color: var(--bs-body-color);
  border-color: var(--dash-border-2) !important;
}

/* Add-on price chip inside the subscription-summary add-on cards. The row
 * itself already sources --dash-surface / --dash-border and flips
 * automatically; the price pill sat on a hardcoded #f5f5f7 wash that stayed
 * light on dark. Tokenized here so both themes get the right neutral. */
.addon-price-chip {
  background: #f5f5f7;
}
html[data-theme="dark"] .addon-price-chip {
  background: rgba(255, 255, 255, 0.06);
  color: var(--text-200) !important;
}


/* ── Informative "i": hidden explanation, shown on hover or click-pin ──── */
/* Shared by the admin console (admin-dashboard.js) and the customer dashboard
   (dashboard.js) via shared/help-icon.js. Hidden by default; revealed on hover
   or when pinned open (.is-open) by a click. */
.admin-help {
  position: relative;
  display: inline-flex;
  vertical-align: middle;
  margin-left: .35rem;
}
.admin-help-i {
  border: 0;
  background: transparent;
  color: var(--bs-secondary, #6c757d);
  padding: 0;
  line-height: 1;
  font-size: .85rem;
  cursor: help;
  display: inline-flex;
  transition: color .15s ease;
}
.admin-help-i:hover,
.admin-help.is-open .admin-help-i { color: var(--primary-100); }
.admin-help-pop {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 1080;
  width: max-content;
  max-width: 340px;
  margin-top: .3rem;
  padding: .55rem .7rem;
  background: var(--dash-surface, #fff);
  color: var(--text-100, #212529);
  border: 1px solid var(--dash-border-2, #dee2e6);
  border-radius: .5rem;
  box-shadow: var(--dash-shadow-md, 0 .5rem 1.1rem rgba(0,0,0,.13));
  font-size: .78rem;
  font-weight: 400;
  line-height: 1.45;
  white-space: normal;
  text-align: left;
  text-transform: none;
  letter-spacing: normal;
}
.admin-help:hover .admin-help-pop,
.admin-help.is-open .admin-help-pop { display: block; }
/* Near the right edge of a container, anchor the popover to the right. */
.admin-help.help-end .admin-help-pop { left: auto; right: 0; }

/* Legend variant: a definition LIST rather than a one-line definition.
   The fiscal legend explains three orthogonal axes and cannot fit the 340px
   single-definition box: it would run 2000px tall and off the right edge.
   Widened, right-anchored and scrolled inside itself, so it stays the same
   component (hover / click-to-pin, same chrome) without ever pushing the page
   sideways. Used by fiscal-status.js `fiscalLegendHtml()`. */
.admin-help-pop.help-pop-legend {
  /* Left/right anchoring is decided at runtime by help-icon.js `_fitHelpPop`
     (which measures), so it is deliberately NOT pinned here. */
  width: min(30rem, calc(100vw - 3rem));
  max-width: none;
  max-height: min(70vh, 34rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: .75rem .9rem;
}

/* ══ 25. Elevated design layer: edge-fleet parity ══════════════════════
 *
 * A shared, dashboard-wide presentation layer that brings the refined
 * "precision-minimal" language shipped on the Flota Edge tab
 * (edge-fleet-ops-tab.js § _injectCss) to every OTHER tab, by enhancing the
 * SHARED components those tabs already render: with ZERO markup changes.
 *
 *   • Bootstrap badges (.badge.bg-*-subtle / .bg-* / .rounded-pill) become
 *     squared, hairline-bordered, tone-tinted chips: the loud rounded
 *     "pill" is gone platform-wide, and the tint is theme-aware (mixed
 *     against --dash-surface) so it reads correctly in dark mode too
 *     (Bootstrap's -subtle/-emphasis colours don't follow [data-theme]).
 *   • The underline sub-nav + accent mono counts live in §15 above.
 *   • Optional tone side-stripe stat cards (opt-in classes, additive).
 *
 * Rules are scoped to :is(.content-area, .modal) so they cover tab bodies
 * AND dialogs (incl. body-appended shared modals) without touching the
 * sidebar, topbar, or provisioning overlay. The edge-fleet tab is untouched:
 * it uses its own .fl-* classes + #fleetConsoleCss and never .badge /
 * .nav-pills. Flat colours only. NO gradients. */

/* Brighter status tones on dark surfaces so tinted chips + trend text keep
   their contrast (GitHub-dark-style palette). Also lifts .stat-trend and
   .status-* text automatically. */
/* Manual §09, dark column: colours go UP in luminosity and DOWN in
   saturation. What was here was a GitHub-dark ramp: recognisable, but not in
   brand/tokens.css, and "info" was a cyan the manual retired. There is no
   fourth state colour, so info reads as the ACTION colour. */
html[data-theme="dark"] {
  --status-green: #58D9A6;  /* 10.13:1 --dash-surface */
  --status-amber: #F2C14E;  /* 10.61:1 --dash-surface */
  --status-red:   #FF7A6E;  /*  6.84:1 --dash-surface */
  --status-gray:  #9093A0;  /* --ulfra-text-muted dark: 5.69:1 */
  --tone-info:    #A794F0;  /* --ulfra-action-text: 6.74:1 */
}
:root { --tone-info: var(--primary-100); }  /* --ulfra-action-text light: 7.00:1 on #f8f9fa */

/* Base chip: typography + shape shared by every dashboard badge. Also
   normalises the oversized .badge padding inherited from the public-site
   ulfratech.css so counts and status tags read as compact chips. */
:is(.content-area, .modal) .badge {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  padding: 0.32em 0.6em;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  line-height: 1.4;
  vertical-align: middle;
  border-radius: 7px;
  border: 1px solid transparent;
}
/* Kill the pill: squared corners platform-wide. */
:is(.content-area, .modal) .badge.rounded-pill { border-radius: 7px; }

/* ── Alignment refinements: v/h align across shared components ───────────
   Additive, CSS-only. Inline icons, chips, and buttons sit on the OPTICAL
   MIDDLE of adjacent text instead of the baseline (the classic "icon/chip
   rides low" look); table headers align to their cells; icon-only buttons
   center their glyph. In flex containers vertical-align is ignored, so these
   only touch the inline contexts where the misalignment actually shows. */
:is(.content-area, .modal) .bi { vertical-align: -0.125em; line-height: 1; }
:is(.content-area, .modal) .btn { vertical-align: middle; }
:is(.content-area, .modal) .btn .bi { vertical-align: -0.075em; }
:is(.content-area, .modal) .table th,
:is(.content-area, .modal) .table td { vertical-align: middle; }
/* Icon-only buttons (just a .bi, no text) center the glyph both axes. */
:is(.content-area, .modal) .btn:has(> .bi:only-child) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* Tone mapping: each variant sets a tinted surface + tone text + hairline
   tone border, mixed against --dash-surface for theme awareness. !important
   + the two-class specificity beats Bootstrap's `.bg-* { …!important }` and
   any paired `.text-* {…!important}` utility. */
:is(.content-area, .modal) .badge.bg-success,
:is(.content-area, .modal) .badge.bg-success-subtle,
:is(.content-area, .modal) .badge.text-bg-success {
  color: var(--status-green) !important;
  background: color-mix(in srgb, var(--status-green) 11%, var(--dash-surface)) !important;
  border-color: color-mix(in srgb, var(--status-green) 30%, var(--dash-border-2)) !important;
}
:is(.content-area, .modal) .badge.bg-warning,
:is(.content-area, .modal) .badge.bg-warning-subtle,
:is(.content-area, .modal) .badge.text-bg-warning {
  color: var(--status-amber) !important;
  background: color-mix(in srgb, var(--status-amber) 13%, var(--dash-surface)) !important;
  border-color: color-mix(in srgb, var(--status-amber) 32%, var(--dash-border-2)) !important;
}
:is(.content-area, .modal) .badge.bg-danger,
:is(.content-area, .modal) .badge.bg-danger-subtle,
:is(.content-area, .modal) .badge.text-bg-danger {
  color: var(--status-red) !important;
  background: color-mix(in srgb, var(--status-red) 11%, var(--dash-surface)) !important;
  border-color: color-mix(in srgb, var(--status-red) 32%, var(--dash-border-2)) !important;
}
:is(.content-area, .modal) .badge.bg-primary,
:is(.content-area, .modal) .badge.bg-primary-subtle,
:is(.content-area, .modal) .badge.text-bg-primary {
  color: var(--primary-100) !important;
  background: color-mix(in srgb, var(--primary-100) 11%, var(--dash-surface)) !important;
  border-color: color-mix(in srgb, var(--primary-100) 32%, var(--dash-border-2)) !important;
}
:is(.content-area, .modal) .badge.bg-info,
:is(.content-area, .modal) .badge.bg-info-subtle,
:is(.content-area, .modal) .badge.text-bg-info {
  color: var(--tone-info) !important;
  background: color-mix(in srgb, var(--tone-info) 12%, var(--dash-surface)) !important;
  border-color: color-mix(in srgb, var(--tone-info) 32%, var(--dash-border-2)) !important;
}
:is(.content-area, .modal) .badge.bg-secondary,
:is(.content-area, .modal) .badge.bg-secondary-subtle,
:is(.content-area, .modal) .badge.bg-light,
:is(.content-area, .modal) .badge.bg-white,
:is(.content-area, .modal) .badge.text-bg-secondary,
:is(.content-area, .modal) .badge.text-bg-light,
:is(.content-area, .modal) .badge:not([class*="bg-"]) {
  color: var(--status-gray) !important;
  background: color-mix(in srgb, var(--status-gray) 10%, var(--dash-surface)) !important;
  border-color: var(--dash-border-2) !important;
}

/* Squared mono count chip inside an underline sub-nav tab (§15). Neutral by
   default; lights in the brand accent only when its tab is active. */
.content-area .nav-pills .nav-link .badge {
  margin-left: 0.1rem;
  padding: 0 0.32em;
  min-width: 1.15em;
  justify-content: center;
  font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-variant-numeric: tabular-nums;
  font-size: 0.625rem;
  border-radius: 5px !important;
  color: var(--text-300) !important;
  background: var(--dash-bg) !important;
  border: 1px solid var(--dash-border) !important;
}
.content-area .nav-pills .nav-link.active .badge {
  color: var(--primary-100) !important;
  background: color-mix(in srgb, var(--primary-100) 10%, var(--dash-surface)) !important;
  border-color: color-mix(in srgb, var(--primary-100) 42%, var(--dash-border)) !important;
}

/* Optional tone side-stripe stat cards (opt-in, additive: no existing card
   regresses). Add .tone-ok / .tone-warn / .tone-bad / .tone-accent to a
   .stat-card to get a hairline elevation + a 3px inset tone stripe. */
.stat-card.tone-ok,
.stat-card.tone-warn,
.stat-card.tone-bad,
.stat-card.tone-accent {
  box-shadow: var(--dash-shadow), inset 3px 0 0 var(--stat-tone) !important;
}
.stat-card.tone-ok     { --stat-tone: var(--status-green); }
.stat-card.tone-warn   { --stat-tone: var(--status-amber); }
.stat-card.tone-bad    { --stat-tone: var(--status-red); }
.stat-card.tone-accent { --stat-tone: var(--primary-100); }
.stat-card.tone-warn .stat-value { color: var(--status-amber); }
.stat-card.tone-bad  .stat-value { color: var(--status-red); }

/* ── Rendered markdown (policy documents, read-only viewers) ────────────────
   Typography only. The HTML reaching here has already been through DOMPurify;
   these rules must never rely on that, they just make a long structured
   document readable instead of a wall of <pre>. */
.md-body { font-size: .875rem; line-height: 1.6; }
.md-body h1 { font-size: 1.35rem; }
.md-body h2 { font-size: 1.15rem; margin-top: 1.4rem; }
.md-body h3 { font-size: 1rem;    margin-top: 1.1rem; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4 { font-weight: 700; }
.md-body h2, .md-body h3 { border-top: 1px solid var(--bs-border-color, #dee2e6); padding-top: .6rem; }
.md-body p, .md-body ul, .md-body ol, .md-body table { margin-bottom: .8rem; }
.md-body ul, .md-body ol { padding-left: 1.25rem; }
.md-body li { margin-bottom: .2rem; }
.md-body code {
  font-size: .82em;
  background: rgba(90, 61, 190,.09);
  padding: .1rem .3rem;
  border-radius: .25rem;
}
.md-body pre {
  background: var(--bs-tertiary-bg, #f8f9fa);
  padding: .7rem .85rem;
  border-radius: .5rem;
  overflow-x: auto;
}
.md-body pre code { background: none; padding: 0; }
.md-body blockquote {
  border-left: 3px solid var(--primary-100);
  padding: .2rem 0 .2rem .8rem;
  margin-left: 0;
  color: var(--bs-secondary-color, #6c757d);
}
/* Wide tables scroll inside the document, never widen the modal. */
.md-body table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
.md-body th, .md-body td { border: 1px solid var(--bs-border-color, #dee2e6); padding: .35rem .5rem; text-align: left; }
.md-body th { background: var(--bs-tertiary-bg, #f8f9fa); font-weight: 600; }
.md-body img { max-width: 100%; height: auto; }
.md-plain { white-space: pre-wrap; font-size: .8rem; margin: 0; }

/* ── Otorgar saldo: high-consequence value creation ───────────────────────
   Deliberately NOT an alert/danger treatment. Red is reserved for destruction,
   and an operator trained to click past red pays for it on a teardown screen.
   The weight here is FRICTION: a locked amount field, a consequence panel that
   updates as you choose, and a typed confirmation. No gradients. */
.gc-step { padding: .85rem 0; border-top: 1px solid var(--dash-border, rgba(0,0,0,.08)); }
.gc-step:first-of-type { border-top: 0; padding-top: .35rem; }
.gc-step-label {
  font-size: .68rem; font-weight: 700; letter-spacing: .06em;
  text-transform: uppercase; color: var(--text-300, #717171);
  margin-bottom: .35rem;
}
.gc-choices { display: grid; gap: .4rem; }
.gc-choice {
  display: flex; align-items: flex-start; gap: .1rem;
  padding: .55rem .7rem; border-radius: .5rem; cursor: pointer;
  border: 1px solid var(--dash-border-2, rgba(0,0,0,.12));
  font-size: .82rem;
}
.gc-choice:hover { border-color: var(--primary-200); }
.gc-choice:has(input:checked) {
  border-color: var(--primary-100);
  background: rgba(90, 61, 190, .06);
}
/* The consequence panel states, in the operator's own view, THE WORDS THE
   CUSTOMER WILL SEE: the most effective anti-confusion device on this screen,
   because a courtesy and a real balance are identical as a number. */
.gc-consequences {
  margin-top: 1rem; padding: .75rem .9rem;
  border-left: 3px solid var(--primary-100);
  background: var(--dash-surface-2, rgba(0,0,0,.03));
  border-radius: 0 .5rem .5rem 0;
}
.gc-consequences-title {
  font-size: .68rem; font-weight: 700; letter-spacing: .05em;
  text-transform: uppercase; color: var(--text-300, #717171);
  margin-bottom: .35rem;
}
.gc-consequences-list { margin: 0; padding-left: 1.1rem; font-size: .8rem; line-height: 1.5; }
.gc-consequences-list > li + li { margin-top: .2rem; }

html[data-theme="dark"] .gc-choice:has(input:checked) { background: rgba(90, 61, 190, .18); }

/* ── Guided tour ─────────────────────────────────────────────────────────── */
/* The scrim is four solid panels around the target rather than one element
   with a hole: a box-shadow "hole" cannot be clicked through, and a gated step
   REQUIRES the real control to stay clickable. */
.tour-scrim {
    position: fixed;
    background: rgba(16, 16, 24, .55);
    z-index: 2000;
    pointer-events: auto;
}
.tour-ring {
    position: fixed;
    z-index: 2001;
    border-radius: .5rem;
    box-shadow: 0 0 0 3px #5A3DBE, 0 0 0 9px rgba(90, 61, 190, .28);
    pointer-events: none;
    transition: top .15s ease, left .15s ease, width .15s ease, height .15s ease;
}
.tour-pop {
    position: fixed;
    z-index: 2002;
    max-width: 22rem;
    background: var(--bs-body-bg, #fff);
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: .6rem;
    box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .18);
    padding: .9rem 1rem;
}
.tour-pop h6 { margin: 0 0 .35rem; font-weight: 600; }
.tour-pop p  { margin: 0 0 .75rem; font-size: .86rem; color: var(--bs-secondary-color, #6c757d); }
.tour-pop-actions { display: flex; gap: .5rem; justify-content: flex-end; }
.tour-hint { font-size: .78rem; color: var(--bs-secondary-color, #6c757d); }

/* ─────────────────────────────────────────────────────────────────────────────
   Scrolling list boxes
   ---------------------------------------------------------------------------
   `.claude/rules/list-views.md`: a list of records is paginated and scrolls
   inside its own box. It must not grow the page without limit: once a list
   pushes everything below it off-screen, the controls around it (filters, the
   "new" button, the next section) stop being reachable without a long scroll
   back up, and on a phone that is most of the screen.

   `max-height` is in `vh` so the box adapts to the viewport instead of
   assuming a desktop window, with a `min-height` floor so a two-row list does
   not collapse into a sliver. `overscroll-behavior: contain` stops a flick at
   the end of the list from scrolling the page behind it, which on touch reads
   as the list "jumping".
   ────────────────────────────────────────────────────────────────────────── */
.support-scroll-list {
    max-height: 58vh;
    min-height: 8rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    /* Room for the scrollbar so rows do not shift when it appears. */
    padding-right: .25rem;
}

/* The ticket thread inside the detail modal. Same rule, smaller budget: the
   modal also has to fit a reply box and its footer buttons, and a long thread
   that pushes "Responder" below the fold makes the modal look read-only. */
.support-thread-scroll {
    max-height: 42vh;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding-right: .25rem;
}

/* ── Two-pane layouts must STACK on a phone ───────────────────────────────
 *
 * A flex ROW carrying a rail beside its content is fine on a desktop and is a
 * defect on a 390px screen, in two different ways, and this fleet has shipped
 * BOTH, in the same file, one of them directly under a comment claiming the
 * other had already been fixed this way:
 *
 *   1. The rail is given `width: 100%` at a phone breakpoint but the PARENT is
 *      never told to stack. The rail fills the row, and the content pane is
 *      laid out AFTER it: starting at x = viewport width, entirely off-screen,
 *      with no page-level horizontal scroll to reach it because an ancestor
 *      clips. Measured on the shipped stylesheet at 390px, editor
 *      Activos → Imágenes: `.assets-main` at x=390..549, with the upload,
 *      search and refresh controls all outside the viewport.
 *
 *   2. The rail keeps a fixed px width and there is no media query at all, so
 *      the content pane is squeezed to whatever is left. Measured the same
 *      way: `.arch-editor-sidebar` takes 210px of 358px and leaves
 *      `#archSectionContent` 148px: about 100px of usable width once its own
 *      padding is removed. That pane is where every field of a page is edited.
 *
 * Both are one mistake: changing the CHILD's width without changing the
 * PARENT's direction. Put `u-stack-under-md` on the flex PARENT and
 * `u-stack-rail` on the rail. Horizontal margins on the direct children are
 * neutralised because a rail typically carries `me-3`, which becomes a dead
 * 1rem gap once stacked: and, while the parent is still a row, is precisely
 * what pushes the content pane its last 16px past the edge.
 *
 * `u-stack-rail` also drops `min-height`. A rail sized to look like a column
 * (`.folder-sidebar` asks for 240px) keeps that height once it is a band, so
 * an empty folder tree renders ~40px of content inside a 240px box: the
 * "large empty gap" reported from a real phone.
 */
@media (max-width: 767.98px) {
    .u-stack-under-md { flex-direction: column !important; }
    .u-stack-under-md > * { margin-right: 0 !important; margin-left: 0 !important; }
    .u-stack-under-md > .u-stack-rail {
        width: 100% !important;
        flex-shrink: 1 !important;
        min-height: 0 !important;
        /* A rail that was a full-height column can be long. Once it is a band
           above the content, cap it so the content it navigates to is still on
           screen without scrolling past the whole rail to reach it. */
        max-height: 40vh;
        overflow-y: auto;
    }
}

/* ── The informative "i" is a control, and it was 14x14 ───────────────────
 *
 * `.admin-help-i` renders at 14x14 CSS px and appears beside the title of
 * essentially every dashboard page in both shells, so it is simultaneously the
 * smallest and the most repeated tap target in the product. Growing the glyph
 * would change every page header, so the HIT AREA is grown instead, with a
 * transparent pseudo-element centred on the icon: the icon looks identical and
 * the touch target reaches the 24px CSS-px floor (WCAG 2.2 SC 2.5.8) in every
 * place it is used.
 *
 * `position: relative` is safe here: the popover is positioned against
 * `.admin-help`, its parent, not against the button.
 */
.admin-help-i {
    position: relative;
}
.admin-help-i::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 24px;
    height: 24px;
    transform: translate(-50%, -50%);
    /* Hit area only: never paints, never affects layout. */
}

/* ── The theme toggle was 20x21, on every page of every shell ─────────────
 *
 * Same defect as `.admin-help-i` above, found by the same measurement and
 * missed by the same reasoning: the sweep that fixed the "i" recorded the
 * remaining sub-44px controls as "reported, not fixed", but this one is not
 * merely under the 44px comfort target, it is under the 24px CSS-px FLOOR
 * (WCAG 2.2 SC 2.5.8), which is the line the "i" fix was drawn at.
 *
 * Measured 20x21 at 390 on both the customer and admin shells. The glyph is a
 * `btn-link p-0` sized by its font, so as with the "i" the HIT AREA grows and
 * the icon does not move.
 */
#themeToggleBtn {
    position: relative;
}
#themeToggleBtn::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 24px;
    height: 24px;
    transform: translate(-50%, -50%);
    /* Hit area only: never paints, never affects layout. */
}

/* ══════════════════════════════════════════════════════════════════════════
   PHONE LAYOUT UTILITIES: measured at 390, both themes, 2026-09-02
   ══════════════════════════════════════════════════════════════════════════

   Three shapes account for every "uneven buttons / misaligned elements /
   overflowing text" report from a real phone, and each is fixed once here
   rather than per page, because each already appears on five or more screens.

   The shared property they are all reaching for: at 390 a control row has
   room for ONE control. Anything that tries to keep two side by side either
   crushes the text (a label wrapping to "Ow / ner"), reflows into rows of
   unequal height, or leaves a button floating alone against the right edge:
   which is what "uneven" and "misaligned" look like from the outside.
*/

/* ── 1. `.u-filter-row`: a row of filter controls ───────────────────────
 *
 * Two independent defects, both measured on #facturacion at 390:
 *
 *   - UNEVEN HEIGHT. A `form-select-sm` measured 37px beside a
 *     `btn-sm` measured 25px in the same row. With `align-items:center`
 *     that ALSO offsets their tops by 6px, so the row reads as misaligned
 *     even though nothing is misaligned: the boxes are simply different
 *     sizes. Height is the cause; the offset is the symptom.
 *   - RAGGED WRAP. The selects carry inline `max-width` values chosen for a
 *     desktop row (240px, 200px, 160px). At 390 those widths make the row
 *     wrap into bands of 240/160 and 200/200, so the controls line up with
 *     nothing and the row's right edge is a staircase.
 *
 * `min-height` rather than `height`: a control whose content genuinely needs
 * two lines must still be allowed to grow, and forcing a height would clip
 * it. 2.25rem = 36px, comfortably over the 24px CSS-px floor (SC 2.5.8).
 *
 * The `max-width` override needs `!important` because the value it is
 * beating is an INLINE style, and an important author declaration is the
 * only thing in the cascade that outranks one.
 */
.u-filter-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .5rem;
}
.u-filter-row > .btn,
.u-filter-row > .form-select,
.u-filter-row > .form-control,
.u-filter-row > .btn-group > .btn,
.u-filter-row > .dropdown > .btn {
    min-height: 2.25rem;
}
/* `ms-auto` is neutralised a breakpoint EARLIER than the stacking, because
   the two answer different questions. At 768 the row still has room for the
   controls but not for a trailing action, so the action wraps, and `ms-auto`
   then pins it to the right edge of a band it is the only occupant of. That
   is the "one button floating alone" shape, and it is the same defect at 768
   as at 390 even though the row itself is fine. Measured on #inbox at 768:
   «Marcar todas como leídas» alone on row 2, right-aligned against nothing. */
@media (max-width: 767.98px) {
    .u-filter-row > .ms-auto { margin-left: 0 !important; }
}
@media (max-width: 575.98px) {
    .u-filter-row > .form-select,
    .u-filter-row > .form-control {
        /* Beats the desktop inline max-width; see the comment above. */
        max-width: none !important;
        width: auto !important;
        /* `1 1 10rem` rather than `1 1 100%`: a short leading <label> ("Ver",
           "Estado:") stays on the same band as its control instead of being
           orphaned above it, while two controls still land on separate bands
           because neither can reach its 10rem basis in what is left. */
        flex: 1 1 10rem;
    }
    .u-filter-row > .btn,
    .u-filter-row > .dropdown,
    .u-filter-row > .btn-group {
        /* `ms-auto` pushes an action to the right edge of a row it is the
           only occupant of, which is the "one button floating alone" shape.
           On a phone the action is simply the next full-width band. */
        margin-left: 0 !important;
        flex: 1 1 100%;
    }
    .u-filter-row > .dropdown > .btn,
    .u-filter-row > .btn-group { width: 100%; }
    .u-filter-row > .btn-group > .btn { flex: 1 1 0; }
}

/* ── 2. `.u-head-stack`: a card header with a title and one action ──────
 *
 * Measured on #team-roles at 390: the "Roles personalizados" header is 78px
 * tall against 47px for a header with no action, because the title wraps to
 * two lines AND the button wraps its own label to two lines, in a row that
 * has neither `flex-wrap` nor `gap`. The same markup shape carries
 * "Nuevo plan" on #billing and "Registrar passkey" on #account.
 *
 * Below 576 the action becomes its own full-width band under the title. The
 * title then has the whole width, so it stops wrapping; the button has the
 * whole width, so its label stops wrapping; and the two are aligned to the
 * same left edge instead of one being pinned to each side of the card.
 *
 * `min-width: 0` on the heading is what lets a long organisation name
 * truncate or wrap inside the row instead of forcing the row wider: a flex
 * item's default `min-width: auto` refuses to shrink below its content.
 */
.u-head-stack {
    flex-wrap: wrap;
    gap: .5rem;
}
.u-head-stack > h5,
.u-head-stack > h6,
.u-head-stack > strong { min-width: 0; }
@media (max-width: 575.98px) {
    .u-head-stack > .btn,
    .u-head-stack > a.btn {
        margin-left: 0 !important;
        flex: 1 1 100%;
        text-align: center;
    }
}

/* ── 3. `.u-meta-wrap`: a body line with a timestamp pinned right ───────
 *
 * Measured on #activity at 390: the event text renders in a 153px column
 * because a `flex-shrink-0` timestamp holds ~152px of a 306px row, so a
 * one-sentence event wraps over five lines. The row does not overflow and
 * nothing clips, so no scroll or overflow check sees it: it is the silent
 * half of the class described further up this file.
 *
 * Below 576 the timestamp becomes its own band and takes `order: -1`, which
 * reads as a date label above the entry rather than an orphan below it.
 */
@media (max-width: 575.98px) {
    .u-meta-wrap { flex-wrap: wrap; }
    .u-meta-wrap > .u-meta-stamp {
        flex-basis: 100%;
        order: -1;
    }
}

/* ── 4. Stat cards: two up on a phone, and never a ragged row ────────────
 *
 * `col-md-3 col-sm-6` left the four KPI cards one-per-row below 576. Each
 * measures 358x179 at 390, so four numbers consumed 780px of vertical
 * space and pushed "Tus suscripciones" to y=1500: the "sections not
 * displayed" report, which is really "displayed, eight screens down".
 * The markup now asks for `col-6`, and 179px of card in a 171px column is
 * comfortable for a 2rem numeral.
 *
 * `height: 100%` is the second half: cards in one row carry labels of
 * different lengths ("Workspaces" vs "Suscripciones activas"), so without
 * it the row's cards end at different heights and the grid looks broken.
 * It also means the card does NOT change height when its value arrives:
 * the tallest sibling has already set the row's height.
 */
.stat-card { height: 100%; }

/* ── 5. The workspace row on #overview ────────────────────────
 *
 * `px-4` on both sides spends 48px of a 356px card before the 34px icon and
 * the chevron are placed, leaving the name + metadata column 187px: measured
 * at 390. The metadata then wraps mid-phrase ("0 de 0 / suscripciones
 * activas"). The row is a list item, not a card, so the outer card's padding
 * is already doing the work the inner padding was repeating.
 */
@media (max-width: 575.98px) {
    .workspace-row { padding-left: 1rem !important; padding-right: 1rem !important; }
}

/* ── 6. A link whose label wraps must not hang under its own icon ────────
 *
 * The support FAQ list renders each question as `a.d-block` with a leading
 * chevron glyph in the text flow, so a question that wraps starts its
 * second line at the anchor's left edge: to the LEFT of the chevron that
 * introduces it. A flex row with the icon as a non-shrinking first item
 * gives the text its own column and a proper hanging indent.
 */
.u-icon-link {
    display: flex;
    align-items: flex-start;
    gap: .5rem;
}
.u-icon-link > .bi { flex-shrink: 0; margin-top: .3em; }

/* ── The 16px every page jumped, on every route ──────────────────────────
 *
 * `#pendingInvitations` is created by dashboard.js and PREPENDED to <main>
 * after the first paint, with `px-3 pt-3`. Most accounts have no pending
 * invitation, so the element holds nothing, but `pt-3` is 1rem whether or
 * not there is anything to pad, so an empty host still occupies 16px and
 * pushes the whole page down by exactly that, ~70ms after it painted.
 *
 * Measured at 390 on #overview: `.page-header` at y=76 at 80ms, y=92 at
 * 150ms. Every one of the 13 customer routes shifted by the same 16px, and
 * on #support the host was mounted and re-mounted, so a very tall block
 * moved 150→166→150 and the route's CLS was 0.17: well past the 0.1 that
 * counts as poor.
 *
 * The cause is a container that is empty but not zero-height. `:empty` is
 * exactly that condition: the host keeps its padding the moment it holds a
 * banner, and takes no space at all when it does not. Fixing it in the
 * stylesheet rather than at the two mount sites also covers the branch that
 * mounts into the provisioning overlay's card with `mb-4`.
 */
#pendingInvitations:empty { display: none; }

/* ── A page must not pull itself up by its own grid gutter ───────────────
 *
 * Bootstrap's `.row` carries `margin-top: calc(-1 * var(--bs-gutter-y))`:
 * −16px with `g-3`: to cancel the gutter its own columns add. That is
 * internal bookkeeping, but it ESCAPES: when the row is the first descendant
 * of a plain block wrapper with no padding or border, its negative top
 * margin collapses out through the wrapper and meets `.page-header`'s
 * `margin-bottom: 1.5rem`. Collapsing takes the largest positive plus the
 * smallest negative, so 24px of deliberate spacing silently becomes 8px.
 *
 * On a synchronously rendered page that is merely a wrong gap. On an
 * async-mounted one it is a JUMP: the loading placeholder's first child is a
 * flex container, which does not collapse, so the page sits at the correct
 * y, and then the real content lands, the collapse takes effect, and
 * everything above the fold moves up 16px. Measured at 390: #account's mount
 * at y=149.8 while loading, y=133.8 once loaded (CLS 0.015); #support did it
 * three times as its panels resolved, for a route CLS of 0.174: poor by any
 * reading of the metric, and visible as the page twitching under your thumb.
 *
 * `flow-root` establishes a block formatting context, which is exactly the
 * "contain your own margins" primitive. The page keeps its grid; the grid
 * stops reaching outside the page.
 */
.async-mount-content { display: flow-root; }

/* Reserved band for #support's ticket area: see the comment at its markup
   in js/pages/support.js. 17rem is the measured height of the shortest
   resolved branch (the entitlement gate card, 271px at 390). A taller
   branch simply exceeds it; nothing is clipped. */
.u-reserve-ticket-area { min-height: 17rem; }

/* ── The help menu is one control with a responsive LABEL ────────────────
 *
 * Above md the button reads "Ayuda". Below md it is a question-mark glyph,
 * because the word plus a caret is ~80px of a topbar that also has to hold
 * the menu toggle, notifications and the theme switch, and because the
 * guided tours it opens are otherwise reachable from nowhere on a phone
 * (Soporte is in the sidebar; the Guías group is in nothing).
 *
 * The caret goes with the label. A lone icon followed by a caret reads as
 * two glyphs rather than one control, and the menu opening is its own
 * affordance. The accessible name moves to `aria-label` on the button, so
 * dropping the visible text does not drop the name.
 *
 * Square, not squashed: `btn-sm` sizes by its text, and a one-glyph label
 * would otherwise render narrower than the 24px CSS-px floor (SC 2.5.8).
 */
@media (max-width: 767.98px) {
    .dash-help-btn::after { display: none; }
    .dash-help-btn {
        min-width: 2.25rem;
        min-height: 2.25rem;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        padding-left: .35rem;
        padding-right: .35rem;
    }
}

/* ── The Bootstrap state colours were bridged in DARK only ───────────────
 *
 * `html[data-theme="dark"]` above re-points `--bs-success`, `--bs-danger`
 * and `--bs-warning` at the manual's dark-column tokens. Light was never
 * given the same treatment, so `.text-success`, `.text-danger`,
 * `.bg-*-subtle`, `.border-*` and every `rgba(var(--bs-*-rgb), …)` utility
 * kept Bootstrap's own values on the surface most of the product is made of.
 *
 * Found by sweeping the RENDERED page rather than the stylesheet: a census
 * of every painted colour across the thirteen customer routes: which is
 * the only way this shows up: nothing in our CSS is wrong, the variables
 * were simply never set. Measured against `--dash-bg` (#f4f6f9), the
 * worst-case ground a dashboard chip sits on, ratios TRUNCATED:
 *
 *   .text-success  #198754 → 4.18:1   BELOW AA      →  #0C6849 → 6.27:1
 *   .text-danger   #DC3545 → 4.18:1   BELOW AA      →  #B3261E → 6.04:1
 *   .text-warning  #FFC107 → 1.65:1   far below     →  #855507 → 5.86:1
 *
 * On a white card they measure 4.53:1, i.e. they scrape past AA there and
 * fail one step away: which is exactly the kind of near-miss that survives
 * a spot check.
 *
 * `--bs-info` has no brand equivalent: §09 admits three state colours and
 * no more, and Bootstrap's info is the cyan #0DCAF0 the manual retired
 * (1.96:1 on white). It reads as the ACTION colour, the same decision
 * `--tone-info` already records further up this file.
 *
 * The `-rgb` triples are not optional: the `.bg-*-subtle` and
 * `.text-*-emphasis` utilities compose from them, so bridging only the hex
 * leaves a Bootstrap-coloured tint under a brand-coloured mark.
 */
:root {
  --bs-success:     #0C6849;
  --bs-success-rgb: 12, 104, 73;
  --bs-danger:      #B3261E;
  --bs-danger-rgb:  179, 38, 30;
  --bs-warning:     #855507;
  --bs-warning-rgb: 133, 85, 7;
  --bs-info:        #5A3DBE;
  --bs-info-rgb:    90, 61, 190;

}

/* Bootstrap's `.table-light` declares `--bs-table-color: #000` ON ITS OWN
   RULE, so a `:root` bridge cannot reach it: the variable is not inherited
   from the document, it is set on the element. Every table head in the
   product therefore rendered PURE BLACK, which §08 forbids outright, and
   which is also the wrong ink: the body text one row below is #16171D.
   Measured on #team-roles at 390: six `thead.table-light` cells at
   rgb(0, 0, 0). The striped/active/hover variants carry the same literal. */
:is(.content-area, .modal, .docs-shell) .table-light {
  --bs-table-color:          #16171D;   /* --ulfra-text-primary */
  --bs-table-striped-color:  #16171D;
  --bs-table-active-color:   #16171D;
  --bs-table-hover-color:    #16171D;
}
html[data-theme="dark"] :is(.content-area, .modal, .docs-shell) .table-light {
  --bs-table-color:          #ECEDF2;   /* --ulfra-text-primary dark */
  --bs-table-striped-color:  #ECEDF2;
  --bs-table-active-color:   #ECEDF2;
  --bs-table-hover-color:    #ECEDF2;
}
