/* Fonts loaded via <link> in HTML <head> — see header.html, base.html.
   Design tokens: :root block below. Dark theme: [data-theme="dark"] block. */

/* ═══════════════════════════════════════════════════
   Tailwind v3 Preflight Overrides
   ───────────────────────────────────────────────────
   Tailwind v3 preflight is more aggressive than v2 normalize.
   These overrides restore sane defaults for our design system.
   Must load AFTER tailwind.output.css (cascade order).
   ═══════════════════════════════════════════════════ */

/* v3 preflight border-color override moved to src/tailwind-input.css @layer base */

/* Sprint 03 cierre: el atributo HTML `hidden` debe prevalecer sobre utilities
   Tailwind como `flex`, `grid`, `block`. Sin esto, modales con
   `<div hidden class="flex">` (T501 confirmModal, notify-attendees) se
   renderizaban visibles e interceptaban clicks. Specificity 0,2,0 > 0,1,0 de
   utility class sin usar !important (que stylelint prohíbe). */
[hidden].flex,
[hidden].inline-flex,
[hidden].grid,
[hidden].inline-grid,
[hidden].block,
[hidden].inline-block,
[hidden].inline,
[hidden].table,
[hidden].contents {
    display: none;
}

/* v3 resets headings to font-size/weight: inherit.
   All headings in templates use explicit classes (.page-title,
   .dashboard-section-title, .refund-section__title, or Tailwind text-*).
   Only set color inheritance — sizing comes from component classes. */
h5, h6 {
    font-family: var(--font-heading);
    color: var(--color-text-primary);
}
h1 {
    font-family: var(--font-heading);
    color: var(--color-text-primary);
    font-size: clamp(1.75rem, 1.5rem + 1.25vw, 2.25rem);
    font-weight: var(--font-semibold);
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-tighter);
}
h2 {
    font-family: var(--font-heading);
    color: var(--color-text-primary);
    font-size: clamp(1.375rem, 1.2rem + 0.75vw, 1.5rem);
    font-weight: var(--font-semibold);
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-tight);
}
h3 {
    font-family: var(--font-heading);
    color: var(--color-text-primary);
    font-size: var(--text-xl);
    font-weight: var(--font-medium);
    line-height: var(--leading-snug);
}
h4 {
    font-family: var(--font-heading);
    color: var(--color-text-primary);
    font-size: var(--text-lg);
    font-weight: var(--font-medium);
    line-height: var(--leading-snug);
}

/* v3 strips list styling — only restore where explicitly needed via class.
   Most lists in this app are navigation/UI, not content lists.
   Use .list-disc / .list-decimal in templates for content lists. */

/* v3 sets img to display:block — keep it but ensure proper alignment */
img {
    vertical-align: middle;
    /* max-width: 100% from v3 preflight is kept — good for responsive */
    /* display: block from v3 preflight is kept — override per-element if needed */
}
/* Icons inside buttons/links/flex need to be inline */
button img,
a img,
.inline-flex img,
.flex img:where(:not(.block)) {
    display: inline-block;
}

/* v3 strips paragraph margins — spacing handled by Tailwind utilities (mb-*) in templates */

/* ═══════════════════════════════════════════════════
   Base Styles
   ═══════════════════════════════════════════════════ */

body {
    font-family: var(--font-body);
    color: var(--color-text-primary);
    line-height: var(--leading-normal);
    letter-spacing: var(--tracking-normal);
    /* height: 100% removed — caps body at 100vh, preventing content from growing */
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background-color: var(--color-bg-page);
    overflow-x: clip; /* clip instead of hidden — prevents horizontal scroll without breaking position: sticky */
}
/* Form elements inherit body font */
input, select, textarea, button {
    font-family: inherit;
}

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

::placeholder {
    color: var(--color-text-placeholder);
    opacity: 1;
}

::selection {
    background-color: var(--brand-surface-strong);
    color: var(--color-text-inverse);
}

/* Layout configuration */
html {
    height: 100%;
    scroll-behavior: smooth;
    overflow-x: hidden; /* Structural containment — prevents any descendant from causing horizontal scroll */
}

main {
    flex: 1 0 auto;
}
/* ═══════════════════════════════════════════════════
   Design Tokens — Zelebrix
   Source: main.css :root
   Apps: admin (zelebrix), cargaOnline
   ═══════════════════════════════════════════════════ */
:root {
    /* ═══ Layout — dos carriles FLUIDOS (#2796) ═══
       Ambos escalan con la pantalla dejando siempre un margen porcentual
       garantizado y se declaran UNA sola vez aqui. NINGUN @media puede
       redeclararlos: el override de 1536px congelaba el ancho y a partir de
       1648px ESTRECHABA (38,8% de aprovechamiento a 3440px).
       Lo vigila scripts/check_container_width_contract.py.

       Carril de TEXTO (--container-max): forms, cards, prosa.
       El tope de 1400px es un techo de LEGIBILIDAD **medido**, no una cuenta:
       ensanchar este carril SI cuesta caracteres/linea. Medido en Chromium
       sobre /apk/ y /system/ a 1920, 2560 y 3440 px de viewport, contando la
       linea visual mas larga de cada parrafo:
         - 1400px (= lo que daba demo): la prosa que envuelve tope en 75 cpl.
         - 1500px: un parrafo pasa de 42 a 59 cpl y aparece una linea nueva
           por encima de 75.
         - 2400px: parrafos de 75 -> 123 cpl y de 50 -> 89 cpl; 5-6 lineas por
           encima de 75 frente a las 2-3 que ya tenia demo.
       1400px es el mayor tope que NO empeora ni una sola linea respecto a
       demo. El aprovechamiento de pantalla lo da el carril ANCHO, no este.
       85vw => 15% de margen total garantizado antes de llegar al tope.

       Carril ANCHO (--container-max-wide): tablas y charts. No le aplica la
       restriccion de cpl (no es prosa), asi que aqui manda aprovechar pantalla:
       92vw => 8% de margen total garantizado, con tope de cortesia a 3400px
       para ultrawide. Medido en /dispositivos/: el contenido de la tabla pasa
       de 1336px congelados a 1702px @1920, 2291px @2560 y 3101px @3440. */
    --container-max: clamp(1280px, 85vw, 1400px);
    --container-max-wide: clamp(1280px, 92vw, 3400px);
    --container-gutter: clamp(16px, 4vw, 32px);

    /* Medida de la PROSA (#2802). El tope del CONTENEDOR no es la palanca:
       hay parrafos del carril de texto que caben enteros en una sola linea y
       por eso su longitud renderizada NO depende del ancho del contenedor.
       Medido en Chromium sobre /apk/ (tema light y dark, identico): el parrafo
       de "Reset del sistema" renderiza 131 caracteres en UNA linea a 1280,
       1306 y 1400 px de #main — constante, porque nunca llega a envolver.
       Bajarlo a 75 cpl estrechando #main exigiria ~636px y romperia el resto.
       El rango legible es 45-75 cpl, asi que el tope se pone en la MEDIDA del
       texto.

       COMO SE CALIBRA EL VALOR — y por que `55ch` estaba mal.
       `1ch` NO es "un caracter": es el ancho del glifo "0" de la fuente que el
       navegador PINTA de verdad, y la razon cpl-por-ch cambia con la fuente.
       Medida sobre el mismo parrafo y el mismo `Nch`, en tres fuentes reales:
         1,07 cpl/ch  fallback del sistema en Windows (Segoe UI)
         1,25 cpl/ch  Lato — la del diseno
         1,38 cpl/ch  la que pinta el runner Linux que corre el gate E2E
       Y la fuente NO es determinista: `header.html` trae Lato de
       fonts.googleapis.com, asi que quien no alcance ese CDN — el E2E
       hermetico, un usuario offline, tras proxy o con el dominio bloqueado —
       renderiza con el fallback del sistema. El peor caso no es hipotetico:
       es el que mide el gate.
       Por eso el token no se calibra contra UNA fuente: se elige el valor que
       deja TODA la horquilla medida dentro de la banda legible 45-75 cpl.
         55ch -> 59 / 69 / 76 cpl  (el 76 del runner incumple el limite: es el
                                    fallo que cazo el propio gate de #2802 —
                                    job 93117057420 — con el token calibrado
                                    solo con Lato y al borde de la banda)
         48ch -> 51 / 60 / 66 cpl  (dentro de banda en las tres; el cpl del
                                    runner lo mide el spec, no se supone)
       Corolario que costo una ronda: `ch` escala con el font-size, asi que un
       `62ch` a 13px NO entra menos caracteres que a 16px — `.access-card__info`
       (13px) medía 82 cpl con `62ch`, y por eso ahora usa el token.
       El techo del valor lo vigila `scripts/check_container_width_contract.py`
       (R4, barato y local); quien AFIRMA el limite es
       `e2e/tests/system/prose-measure.spec.ts`, que mide cpl reales en el
       navegador del gate y es la autoridad.
       `.refund-section__subtitle` conserva su `62ch`, pero dentro de `#main`
       del admin lo pisa la regla de ~2238 (mas especifica por el `#main`); el
       `62ch` solo sigue vivo en cargaOnline (`.co-main`), que esta fuera del
       alcance de #2802.
       Se aplica en la regla `#main … :is(p, li, dd, label > span)` mas abajo. */
    --prose-max: 48ch;
    --container-radius: var(--radius-xl);
    --container-shadow: 0 6px 18px rgba(0, 0, 0, 0.06),
                        0 4px 10px rgba(51, 190, 255, 0.05);

    /* ═══ Brand ═══ */
    --brand-primary: #33BEFF;
    --brand-primary-dark: #0369a1;            /* HSL(201°, 96%, 32%) — hue-coherent with brand-primary */
    --brand-primary-hover: #00B3EF;          /* HSL(195°, 100%, 47%) */
    --brand-primary-active: #0099D6;         /* HSL(195°, 100%, 42%) */
    --brand-primary-light: #E0F7FF;          /* HSL(195°, 100%, 94%) */

    /* ═══ Brand SURFACES that carry text ═══
       The fill for any brand-coloured box whose ink is --color-text-inverse.
       Contract: this token FLIPS together with --color-text-inverse, so the pair
       is AA in both themes (light = dark fill + white ink 5.93:1; dark = cyan
       fill + slate-900 ink 8.46:1). Never pair it with a fixed-colour ink.

       Why it is not just --brand-primary or --brand-primary-dark:
       - --brand-primary (#33BEFF) is 2.11:1 with white — backgrounds and icons
         only, never behind text (§4.2).
       - --brand-primary-dark doubles as --color-link and is therefore re-aliased
         to --brand-primary inside [data-theme="dark"]; a background declared
         with it inside a dark-mode rule is a silent no-op. Splitting the fill
         job out into its own token is what makes those overrides work again. */
    --brand-surface-strong: #0369a1;         /* sky-700  — 5.93:1 with #ffffff */
    --brand-surface-strong-hover: #075985;   /* sky-800  — 7.56:1 */
    --brand-surface-strong-active: #0c4a6e;  /* sky-900  — 9.46:1 */
    --brand-primary-shadow: rgba(51, 190, 255, 0.32);
    --brand-primary-shadow-strong: rgba(3, 105, 161, 0.35);
    --brand-focus-ring: rgba(51, 190, 255, 0.45);

    /* ═══ State Colors ═══ */
    --color-success: #16a34a;        /* green-600 */
    --color-success-hover: #15803d;  /* green-700 */
    --color-success-bg: #dcfce7;     /* green-100 */
    --color-error: #dc2626;          /* red-600 */
    --color-error-hover: #b91c1c;    /* red-700 */
    --color-error-light: #ef4444;    /* red-500 */
    --color-error-bg: #fee2e2;       /* red-100 */
    --color-info: #2563eb;           /* blue-600 */
    --color-info-hover: #1d4ed8;     /* blue-700 */
    --color-info-bg: #dbeafe;        /* blue-100 */
    --color-warn: #d97706;           /* amber-600 */
    --color-warn-hover: #b45309;     /* amber-700 */
    --color-warn-bg: #fef3c7;        /* amber-100 — pastel */
    --color-purple: #9333ea;         /* purple-600 */
    --color-success-active: #166534; /* green-800 — pressed/hover state of a filled success button */

    /* ═══ Status TEXT (on a page/table/pastel surface, not on a saturated fill) ═══
       The -600 state tokens above are tuned to be FILLS; as text they miss AA
       (#16a34a is 3.29:1 on #ffffff, #dc2626 is 3.95:1 on #fee2e2). Use these
       wherever the status colour is the ink: money cells, toasts, badges,
       textual table actions. Ratios below are the worst measured surface. */
    --color-status-success-text: #15803d;  /* green-700 — 5.02:1 on #fff, 4.56:1 on zebra #f3f4f6 */
    --color-status-error-text: #b91c1c;    /* red-700   — 6.47:1 on #fff, 5.30:1 on #fee2e2 */
    --color-status-warn-text: #b45309;     /* amber-700 — 4.51:1 on #fef3c7 */
    --color-status-info-text: #1d4ed8;     /* blue-700  — 5.49:1 on #dbeafe */

    /* ═══ Gray Palette ═══ */
    --gray-50: #f9fafb;
    --gray-100: #f3f4f6;
    --gray-200: #e5e7eb;
    --gray-300: #d1d5db;
    --gray-400: #9ca3af;
    --gray-500: #6b7280;
    --gray-600: #4b5563;
    --gray-900: #111827;

    /* ═══ Toggle ═══ */
    --toggle-track-off: var(--gray-300);     /* gray-300 */
    --toggle-knob-off: #E6F7FF;      /* subtle brand tint */
    --toggle-focus-ring: rgba(51, 190, 255, 0.35);

    /* ═══ Button spinner ═══ */
    --btn-spinner-ring: rgba(255, 255, 255, 0.35); /* spinner track on dark/brand buttons */

    /* ═══ Typography ═══ */
    --text-3xs: 0.5625rem; /* 9px  — T438: usar solo en badges ultra-compactos (CO labels legacy) */
    --text-2xs: 0.6875rem; /* 11px */
    --text-xs: 0.75rem;    /* 12px */
    --text-sm: 0.875rem;   /* 14px */
    --text-base: 1rem;     /* 16px */
    --text-lg: 1.125rem;   /* 18px */
    --text-xl: 1.25rem;    /* 20px */
    --text-2xl: 1.5rem;    /* 24px */

    /* ═══ Shadows ═══ */
    --shadow-color: 15, 23, 42; /* slate-900 RGB base */
    --shadow-xs: 0 1px 2px rgba(0,0,0,0.05), 0 1px 3px rgba(51, 190, 255, 0.04);
    --shadow-sm: 0 1px 3px rgba(0,0,0,0.08), 0 2px 6px rgba(51, 190, 255, 0.06);
    --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.08), 0 4px 12px -2px rgba(51, 190, 255, 0.08);
    --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08), 0 8px 20px -4px rgba(51, 190, 255, 0.10);
    --shadow-brand-sm: 0 6px 12px var(--brand-primary-shadow);
    --shadow-brand-lg: 0 8px 16px var(--brand-primary-shadow-strong);

    /* ═══ Border Radius ═══ */
    --radius-xs: 2px;
    --radius-sm: 4px;      /* 0.25rem */
    --radius-md: 8px;      /* 0.5rem */
    --radius-lg: 12px;     /* 0.75rem */
    --radius-xl: 16px;
    --radius-2xl: 20px;
    --radius-full: 9999px; /* pills, toggles */

    /* ═══ Typography Scale — 4 niveles, fluid clamps ═══
       display: hero KPI numbers
       h2:      card titles
       body:    default text
       caption: labels, metadata, tooltips
    */
    --text-display-size:    clamp(32px, 5vw, 44px);
    --text-display-leading: 1.05;
    --text-display-tracking: -0.025em;
    --text-display-weight:  700;

    --text-h2-size:         clamp(18px, 2vw, 22px);
    --text-h2-leading:      1.25;
    --text-h2-tracking:     -0.01em;
    --text-h2-weight:       600;

    --text-caption-size:    13px;
    --text-caption-leading: 1.4;
    --text-caption-tracking: 0.02em;

    /* body: el cuarto nivel que el comentario de arriba anuncia y nunca se
       definio. En `rem` y no en `px` como sus tres hermanos, para que respete el
       tamaño de fuente que el usuario tenga puesto en el navegador.
       A <=1125px de viewport el clamp esta en su suelo: MOVIL SE QUEDA EN LOS
       16px DE HOY, el nivel alto es solo de escritorio (una maqueta, no dos).
       Corolario que hay que tener delante antes de tocarlo: el tope de prosa
       esta declarado en `ch`, o sea que ESCALA con el font-size — subir el
       cuerpo ensancha la caja pintada y NO mueve ni un caracter por linea
       mientras mande el tope. En cuanto manda el contenedor (una celda de
       rejilla mas estrecha que el tope) la relacion se invierte y subir el
       tamaño baja los cpl: ese es el riesgo que afirma el SUELO de 45 de
       `e2e/tests/system/prose-measure.spec.ts`. */
    --text-body-size:       clamp(1rem, 1.6vw, 1.125rem); /* 16 -> 18px */

    /* ═══ Semantic Aliases — Theme layer ═══
       Punto de enganche para tarea 034 (temas claro/oscuro).
       La 034 reasigna estos bajo [data-theme="dark"] sin tocar templates.
    */
    --color-text-primary: var(--gray-900);
    --color-text-secondary: var(--gray-600);
    --color-text-muted: var(--gray-500);
    --color-text-inverse: #ffffff;
    /* T174 P174-7 — Token intensificado para texto muted sobre superficies
       elevadas (slate-700 en dark). En light el muted estandar ya supera
       AA sobre bg-elevated, asi que aqui mantenemos el alias. */
    --color-text-muted-on-elevated: var(--color-text-muted);
    /* T174 P174-7 — fg intensificado para los chips de variation; sin
       esto el contraste 1.00 los vuelve invisibles en dark. En light se
       mantiene el semantic hover, que ya es AA sobre bg con alpha 12%. */
    --color-variation-success-fg: var(--color-success-hover);
    --color-variation-error-fg: var(--color-error-hover);
    --color-bg-page: var(--gray-50);
    --color-bg-surface: #ffffff;
    --color-bg-elevated: var(--gray-100);  /* T522 / T106 Fase 1: elevado de var(--gray-50) → var(--gray-100) para contraste WCAG en light mode sobre superficies elevadas */
    --color-bg-total-row: var(--gray-200);  /* #e5e7eb — legend table TOTAL row, one step darker than --color-bg-elevated (gray-100) */
    --color-bg-muted: var(--gray-200);
    --color-border-default: var(--gray-200);
    --color-border-strong: var(--gray-300);
    --color-primary-disabled: rgba(51, 190, 255, 0.55);
    /* Native <select> chevron glyph. Uses currentColor (no baked hex) so the
       arrow follows the element's `color`, which the select sets to
       --color-text-secondary. Theme-aware without a per-theme data-URI:
       the dark override below just re-points the token for any future tweak. */
    --select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
    /* The same X the delete control of `editable-items-table.js` draws inline
       (`M6 18L18 6M6 6l12 12`), as a token so a control that CANNOT hold an
       <svg> child can still wear it. Consumed through `mask-image`, never
       `background-image`: the mask takes only the alpha channel, so the ink is
       whatever `background-color` the rule sets — a token, never a baked hex.
       `stroke='currentColor'` resolves to the initial color inside a detached
       data-URI document, which is opaque: exactly what a mask needs. */
    --icon-close-x: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 18L18 6M6 6l12 12'/%3E%3C/svg%3E");
    --color-bg-blur-light: rgba(255, 255, 255, 0.82);  /* T533/T528: light blur backdrop */
    --color-bg-blur-dark: rgba(15, 23, 42, 0.82);       /* T533/T528: dark blur backdrop */
    --color-bg-blur-nav: rgba(255, 255, 255, 0.85);    /* T927: sticky nav backdrop (0.85 opacity intentional) */
    --color-bg-blur-glass: rgba(255, 255, 255, 0.88);  /* T927: glass panel backdrop (0.88 opacity intentional) */

    /* ═══ shadcn-style aliases (T149 hero v2) — map to canonical tokens ═══
       The hero v2 redesign in T149 introduced shadcn-style names that did not
       exist in the zelebrix token system. The CSS already wrote `var(--name,
       fallback)` so runtime worked, but check_var_references_resolve flags
       the missing definitions. Map them here to the canonical equivalents. */
    --color-text: var(--color-text-primary);

    --color-border-subtle: var(--color-border-default);
    --color-neutral-50: var(--gray-50);
    --color-neutral-100: var(--gray-100);
    --color-primary-50: var(--color-info-bg);

    /* ═══ Semantic Aliases — Extended (tarea 039) ═══ */
    --color-link: var(--brand-primary-dark);
    --color-link-hover: color-mix(in srgb, var(--color-link) 80%, black);
    --color-text-placeholder: var(--gray-400);
    --color-bg-input: var(--color-bg-surface);
    --color-overlay: rgba(0, 0, 0, 0.4);
    /* T749 H5: overlay sutil para refuerzo visual sobre superficies translúcidas
       (zebra hover en .chart-legend-table, etc.). Light/dark divergen para
       mantener legibilidad sobre los tokens bg-* respectivos. */
    --color-overlay-subtle: rgba(0, 0, 0, 0.04);
    --color-bg-disabled: var(--gray-100);
    --color-text-disabled: var(--gray-400);

    /* ═══ Extended Color Palette — KPI badge backgrounds ═══ */
    --color-yellow-100: #fef9c3;
    --color-purple-100: #ede9fe;
    --color-indigo-100: #e0e7ff;
    --color-teal-100: #ccfbf1;
    --color-orange-100: #ffedd5;
    --color-cyan-100: #cffafe;
    --color-emerald-100: #d1fae5;
    --color-amber-100: #fef3c7;

    /* ═══ Slate Palette ═══ */
    --slate-200: #e2e8f0;  /* log_viewer.css border usage */
    --slate-400: #94a3b8;
    --slate-500: #64748b;
    --slate-600: #475569;
    --slate-700: #334155;
    --slate-800: #1e293b;  /* log_viewer.css dark bg usage */
    --slate-900: #0f172a;

    /* ═══ Chart Palette — 6 familias de significado, rampa ordinal dentro de cada una ═══
       FUENTE ÚNICA: zelebrix/zelebrix/chart_palette.py. Este bloque lo ESCRIBE
       scripts/gen_chart_palette.py y lo vigila scripts/check_chart_palette_sync.py.
       Editarlo a mano pone rojo el quality gate — cambia la fuente y regenera.
       Suelos congelados por el guard: ΔE OKLab ≥ 15 (visión normal) y ≥ 6 (CVD) entre
       familias distintas, ≥ 8 contra los tokens de estado, banda de L y croma por tema.
       Data-viz colors — NOT for UI elements. All values MUST be hex (nunca var()).
    */
    /* BEGIN chart-palette (generado por scripts/gen_chart_palette.py — NO editar a mano) */
    --chart-sin-asignar:       #5f4f50;  /* neutro 1 · t0 Sin asignar */
    --chart-tarjeta:           #0050a6;  /* azul 1 · t1 Tarjeta */
    --chart-efectivo:          #0188cc;  /* azul 2 · t2 Efectivo */
    --chart-carga-online:      #579cf2;  /* azul 3 · t6 Carga Online */
    --chart-panel-de-control:  #6000de;  /* violeta 1 · t7 Panel de Control */
    --chart-carga-masiva:      #881bff;  /* violeta 2 · t8 Carga Masiva */
    --chart-pre-carga:         #9961ff;  /* violeta 3 · t12 Pre-Carga */
    --chart-taptopay:          #9f0806;  /* rojo 1 · t15 TapToPay */
    --chart-descarga:          #b33e00;  /* rojo 2 · t4 Descarga */
    --chart-tickets-app:       #fc6064;  /* rojo 3 · t14 Tickets App */
    --chart-invitacion:        #990083;  /* magenta 1 · t3 Invitación */
    --chart-cashback:          #a95894;  /* magenta 2 · t9 Cashback */
    --chart-zelebrix-benefits: #fb0fbb;  /* magenta 3 · t16 Zelebrix Benefits */
    --chart-recargas:          #05793f;  /* verde 1 · agregado Recargas */
    --chart-saldo-zelebrix:    #3ba688;  /* verde 2 · t11 Saldo Zelebrix */
    --chart-envio:             #29c19e;  /* verde 3 · t5 Envio */
    --chart-reembolso:         #aa8404;  /* ambar 1 · t10 Reembolso */
    --chart-correccion:        #e79a10;  /* ambar 2 · t13 Corrección */
    /* Rampa ORDINAL del KPI del pase (#3225): un solo tono, paso 1 = Nivel 0. */
    --chart-milestone-1:       #fe94d3;  /* rampa del pase, paso 1/4 */
    --chart-milestone-2:       #e154af;  /* rampa del pase, paso 2/4 */
    --chart-milestone-3:       #980470;  /* rampa del pase, paso 3/4 */
    --chart-milestone-4:       #5d0043;  /* rampa del pase, paso 4/4 */
    /* END chart-palette */

    /* Chart: Devoluciones — border = same color at full opacity, bg via withOpacity() in JS */
    --chart-devol-periodo:     #93C5FD;  /* blue-300 */
    --chart-devol-panel:       #FDBA74;  /* orange-300 */

    /* Chart: Ticket inventory stacked bar — hex for withOpacity()/applyFinishedOverlay() compat */
    --chart-ticket-disponibles: #F0E68C;  /* khaki 54° (was #E9F0A8 — better deuteranopia separation) */
    --chart-ticket-vendidos:    #7FD77F;  /* green 120° L:67% (was #A8F0A8 L:80% — higher contrast) */
    --chart-ticket-activados:   #2FC1FF;  /* sky 195° — safe, distinct hue */

    /* ═══ Stable Palette — 12 tokens for label-bound tenant datasets ═══
       Uniform HSL distribution (0°→330° step 30°), S=70%, L=70% light.
       Deterministic hash (djb2) maps label → index → token.
       Use via generateStableColors(labels) in dashboard-charts.js.
    */
    --chart-palette-1:  hsl(0deg,   70%, 70%);   /* soft coral  */
    --chart-palette-2:  hsl(30deg,  70%, 70%);   /* peach       */
    --chart-palette-3:  hsl(60deg,  70%, 70%);   /* yellow      */
    --chart-palette-4:  hsl(90deg,  70%, 70%);   /* lime        */
    --chart-palette-5:  hsl(120deg, 70%, 70%);   /* green       */
    --chart-palette-6:  hsl(150deg, 70%, 70%);   /* mint        */
    --chart-palette-7:  hsl(180deg, 70%, 70%);   /* teal        */
    --chart-palette-8:  hsl(210deg, 70%, 70%);   /* sky         */
    --chart-palette-9:  hsl(240deg, 70%, 70%);   /* lavender    */
    --chart-palette-10: hsl(270deg, 70%, 70%);   /* violet      */
    --chart-palette-11: hsl(300deg, 70%, 70%);   /* pink        */
    --chart-palette-12: hsl(330deg, 70%, 70%);   /* rose        */

    /* ═══ Semantic Metric Colors — delegate to state colors for coherence ═══ */
    --semantic-revenue:     var(--color-success);    /* green = income/recharges */
    --semantic-consumption: var(--color-info);       /* blue = activity/spend */
    --semantic-refund:      var(--color-warn);       /* amber = attention/returns */
    --semantic-correction:  var(--color-error);      /* red = requires review */

    /* ═══ Z-index Scale ═══ */
    --z-base: 0;
    --z-raised: 30;          /* T441 H1: elements above normal flow but below sticky (e.g. dashboard-filters) */
    --z-dropdown: 100;
    --z-sticky: 200;
    --z-header: 400;
    --z-dropdown-header: 410;  /* T528: dropdown in header, above header but below chatbot */
    --z-chatbot: 475;
    --z-sidebar-overlay: 480;  /* T522: elevated from 450 to 480 to overlay chatbot (475) while staying below modal (500) */
    --z-modal-backdrop: 500;
    --z-modal: 600;
    --z-confirm-modal: 650;  /* siempre sobre cualquier .modal (=600), bajo popover(700) */
    --z-popover: 700;
    --z-tooltip: 800;
    --z-toast: 900;

    /* ═══ Layout ═══ */
    --header-height: 56px;             /* Desktop admin: py-2 (16px) + h-10 (40px). Mobile uses drawer top:0 */

    /* ═══ Spacing Scale (4px base) ═══ */
    --space-1: 0.25rem;    /* 4px */
    --space-1-5: 0.375rem; /* 6px */
    --space-2: 0.5rem;     /* 8px */
    --space-2-5: 0.625rem; /* 10px */
    --space-3: 0.75rem;    /* 12px */
    --space-3-5: 0.875rem; /* 14px */
    --space-4: 1rem;       /* 16px */
    --space-5: 1.25rem;    /* 20px */
    --space-6: 1.5rem;     /* 24px */
    --space-8: 2rem;       /* 32px */
    --space-10: 2.5rem;    /* 40px */
    --space-12: 3rem;      /* 48px */

    /* ═══ Table column sizing ═══ */
    --table-cell-max-id: 22ch;   /* Truncado para celdas con UUID/chipId/saldo */

    /* ═══ Pase de batalla — ancho de la COLUMNA de un hito en la tira ═══
       Es a la vez el ancho del bocadillo que cuelga de la bolita y el ancho del
       hueco que esa bolita reserva en el carril: las dos cosas TIENEN que ser el
       mismo número o el bocadillo de un hito pisa el del vecino. De aquí sale el
       `min-width` del carril (`--bp-slots` x este ancho), así que subirlo no es
       cosmético: adelanta el punto en el que la tira estrena scroll horizontal.

       ⛔ UN NÚMERO, NO UN RANGO. Se estudió un `clamp()` sobre `100cqi` para que
       el carril negociara el ancho con las columnas, y se descartó: no hay ni un
       `container-type` en toda esta hoja, y estrenar la técnica el día de una
       release deja el ancho del bocadillo dependiendo de una cadena de cuatro
       eslabones (`.bp-card` flex, `.bp-bar` item sin borde ni margen…) que
       cualquiera rompe sin enterarse. Con un token fijo, «¿se solapan?» sigue
       siendo UNA comparación —hueco contra columna— y la contesta el
       `min-width` de más abajo, no el navegador.

       ⛔ CORREGIDO 2026-09-21: la condición de «sin scroll» que decía antes este
       comentario (`pill x slots <= carril`) era FALSA, y por eso afirmaba que 3
       hitos a 88px no scrolleaban a 375px. `.bp-bar:has(> .bp-nodes--labelled)`
       se pone `padding-inline: calc(pill / 2)` a cada lado (más abajo en este
       fichero) y `clientWidth` cuenta ese padding mientras `scrollWidth` cuenta
       padding + carril: la condición real es **`pill x (slots + 1) <=
       clientWidth`**. Con ella, 3 columnas a 88px pedían 352px contra los ~275
       de barra a 375px de viewport: **ya scrolleaba**. El scroll horizontal de
       la tira no lo estrena esta subida a 120px; lo que esta subida hace es
       adelantarlo, y por eso el presupuesto del spec de geometría está escrito
       en COMPARATIVO («nunca peor que con 88px») y no en absoluto.

       ⛔ `--bp-slots` NO es el número de hitos: `presenters.strip_slots` devuelve
       `ceil(100 / hueco porcentual más pequeño)`, acotado a `MAX_STRIP_SLOTS`.
       Tres hitos en 25/50/100 reservan CUATRO columnas.

       120px lo eligió el owner el 2026-09-21 («me gustaría que fueran un poco más
       ancho») sobre el mismo corpus de recompensas, y es además lo que le da su
       propia línea a la tercera fila del bocadillo —«Vincula tu chip» es la
       cadena visible más larga de las tres y en alemán crece— sin empujar la
       recompensa a una línea más. */
    --bp-node-pill-w: 7.5rem;    /* 120px — orden del owner 2026-09-21 */

    /* ═══ Transitions ═══ */
    --transition-fast: 150ms ease;
    --transition-ui: 160ms ease;       /* sidebar, toggle interactions */
    --transition-base: 200ms ease;
    --transition-slow: 300ms ease;

    /* Duration-only tokens — use in `animation` shorthand to avoid
       double-easing when combining with an explicit timing function.
       e.g. animation: fadeInUp var(--dur-slow) ease-out forwards; */
    --dur-fast: 150ms;
    --dur-base: 200ms;
    --dur-slow: 300ms;

    /* ═══ Icon sizes ═══ */
    --icon-xs: 14px;
    --icon-sm: 16px;
    --icon-md: 20px;
    --icon-lg: 24px;
    --icon-xl: 28px;
    --icon-2xl: 36px;  /* desktop color-picker and larger interactive targets (T934) */

    /* ═══ Component sizes ═══ */
    --legend-swatch-size: 12px;
    --legend-color: var(--gray-200);   /* JS overrides per-swatch; fallback prevents invisible swatches */
    --touch-target-min: 2.75rem;       /* 44px — WCAG 2.5.8 minimum */
    --touch-target-min-aa: 1.5rem;     /* 24px — WCAG 2.2 SC 2.5.8 Target Size (Minimum), AA floor */
    --touch-target-cta: 48px;          /* 48px — CTA buttons (co-cta-primary/secondary, co-btn-premium) */
    --size-btn-lg: 3.25rem;            /* 52px — prominent large button (btn-lg) */
    --space-0-5: 2px;                  /* micro spacing between space-0 (0) and space-1 (4px) */
    --toggle-width: 2.25rem;
    --toggle-height: 1.25rem;

    /* ═══ Brand Opacity Tints — rgba(brand-primary, opacity) ═══ */
    --color-brand-tint-10: rgba(51, 190, 255, 0.10);
    --color-brand-tint-12: rgba(51, 190, 255, 0.12);
    --color-brand-tint-25: rgba(51, 190, 255, 0.25);
    --color-brand-tint-30: rgba(51, 190, 255, 0.30);
    --color-brand-tint-35: rgba(51, 190, 255, 0.35);
    --color-brand-tint-60: rgba(51, 190, 255, 0.60);  /* reserved — brand tint series completeness */
    --color-brand-tint-66: rgba(51, 190, 255, 0.66);
    --gradient-btn-recarga: linear-gradient(144deg, #AF40FF, #5B42F3 50%, #00DDEB);
    --gradient-btn-envio: linear-gradient(120deg, #00FF00, #2f9673);
    /* Aurora — gradiente del design system (claude-design-bundle/project/colors_and_type.css:68).
       Ramp cian-marca → teal → índigo. Arranca en #33BEFF, que es exactamente
       --brand-primary, así que el botón entra en la identidad de marca en vez de
       quedarse como el único naranja de cargaOnline. Único gradiente del bundle
       que aterriza por ahora (#666 archivado; el resto del epic queda en el plan). */
    --gradient-aurora: linear-gradient(135deg, #33BEFF 0%, #00C7BE 50%, #5856D6 100%);
    --gradient-btn-recibir: var(--gradient-aurora);

    /* ═══ Font Families ═══ */
    --font-heading: 'Poppins', system-ui, -apple-system, 'Segoe UI', sans-serif;
    --font-body: 'Lato', system-ui, -apple-system, 'Segoe UI', sans-serif;

    /* ═══ Font Weight ═══ */
    --font-normal: 400;
    --font-medium: 500;
    --font-semibold: 600;
    --font-bold: 700;

    /* ═══ Line Height ═══ */
    --leading-tight: 1.2;     /* headings display (h1, h2) */
    --leading-snug: 1.3;      /* headings small, labels, buttons */
    --leading-normal: 1.5;    /* body text */
    --leading-relaxed: 1.6;   /* long-form text, paragraphs */

    /* ═══ Letter Spacing ═══ */
    --tracking-tighter: -0.02em;  /* h1 display */
    --tracking-tight: -0.01em;    /* h2 */
    --tracking-normal: 0;         /* body, medium headings */
    --tracking-wide: 0.02em;      /* buttons */
    --tracking-wider: 0.05em;     /* uppercase labels */
    --tracking-widest: 0.08em;    /* overline, micro-labels */

    /* ═══ CargaOnline — tenant brand accent (injected inline per-preset) ═══
       Consumed in .co-preview-card as border-left.
       Set via style="--card-accent:#hex" from apk.html coResolveHex().
       Fallback keeps the card legible when no color is configured. */
    --card-accent: var(--color-border-default);

    /* ═══ APK Builder (Ionic/Capacitor preview palette) ═══ */
    --co-accent-secondary: #b3e5fc;
    --co-accent-success: #63ba6a;
    --co-accent-warning: #ffc409;
    --co-accent-danger: #bd2727;
    --co-accent-dark: #222428;

    /* ═══ Sidebar Layout ═══ */
    --sidebar-collapsed: 64px;
    --sidebar-expanded: 230px;
    --sidebar-item-size: 44px;
    --sidebar-icon-size: 24px;

    /* ═══ White Opacity (overlays, bullets, captions) ═══ */
    --color-white-22: rgba(255, 255, 255, 0.22);
    --color-white-50: rgba(255, 255, 255, 0.5);
    --color-white-80: rgba(255, 255, 255, 0.8);

    /* ═══ Modal Layout ═══ */
    --modal-width-default: 640px;
    --modal-width-lg: 900px;
    /* T806: spacing entre secciones/fieldsets del modal (H9) */
    --modal-section-gap: var(--space-5);
    /* T806: gap label→control en modal (H8) — sustituye var(--space-1) insuficiente */
    --modal-label-gap: var(--space-2);
    /* T906 H-H1: breakpoint mobile canónico (sincronizado con tailwind.config.js sm: 640px).
       Reemplaza @media (max-width: 639px) hardcoded. */
    --breakpoint-sm: 640px;
    /* T906 H-H2: ancho estimado scrollbar para fallback Safari ≤15.4 cuando
       scrollbar-gutter: stable no se aplica. Reemplaza 15px hardcoded en .modal-content. */
    --scrollbar-width-fallback: 15px;

}

/* #2796: aqui vivia `@media (min-width:1536px){ :root{ --container-max:1400px } }`
   con el comentario "Wider layout on large screens". Hacia lo contrario: solo
   ensanchaba entre 1536-1647px y desde 1648px estrechaba. Los tokens de layout
   se declaran UNA sola vez en :root (arriba) y son fluidos por diseno.
   No lo reintroduzcas: check_container_width_contract.py falla el pre-push. */

/* ═══════════════════════════════════════════════════
   Dark Theme — Tarea 034
   Reasigna los alias semánticos de :root.
   No introduce nuevas variables ni cambia selectores.
   Se activa via data-theme="dark" en <html>.
   ═══════════════════════════════════════════════════ */
[data-theme="dark"] {
    /* ── Surfaces ── */
    --color-bg-page: #0f172a;              /* slate-900 */
    --color-bg-surface: #1e293b;           /* slate-800 */
    --color-bg-elevated: #334155;          /* slate-700 */
    --color-bg-total-row: #475569;  /* slate-600 — distinct from --color-bg-elevated (slate-700/#334155) */
    --color-bg-muted: var(--slate-600);
    /* T528: blur backdrops keep the same semantics across themes (the
       "light" variant is always near-white, the "dark" variant always
       near-slate). The dark override exists to satisfy the ratchet that
       requires every :root --color-* to have a dark counterpart. */
    --color-bg-blur-light: rgba(255, 255, 255, 0.82);
    --color-bg-blur-dark: rgba(15, 23, 42, 0.82);
    --color-bg-blur-nav: rgba(15, 23, 42, 0.85);     /* T927: sticky nav dark mode */
    --color-bg-blur-glass: rgba(30, 41, 59, 0.88);   /* T927: glass panel dark mode */

    /* ── Text ── */
    --color-text-primary: #f1f5f9;         /* slate-100 */
    --color-text-secondary: #cbd5e1;       /* slate-300 */
    --color-text-muted: #94a3b8;           /* slate-400 — ratio ~5.4:1 vs slate-900 */
    --color-text-inverse: #0f172a;         /* slate-900 — texto sobre fondos claros */
    /* T174 P174-7 — slate-400 sobre bg-elevated (slate-700) cae a 4.04:1,
       justo por debajo de AA. slate-300 sube a 6.97:1. */
    --color-text-muted-on-elevated: #cbd5e1;  /* slate-300 */
    /* Dark chevron re-points the token; colour still flows from currentColor
       (the select's --color-text-secondary resolves to slate-300 here), so the
       arrow stays legible on dark surfaces without a baked hex. */
    --select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
    /* T174 P174-7 — fg intensificado para variation chips en dark:
       - success-fg #86efac (green-300) vs bg green-400 @12% ≈ 5.1:1 ✓
       - error-fg   #fca5a5 (red-300)   vs bg red-400 @12%   ≈ 4.9:1 ✓ */
    --color-variation-success-fg: #86efac;
    --color-variation-error-fg: #fca5a5;

    /* ── Extended tokens (tarea 039) ── */
    --color-link: var(--brand-primary);
    --color-link-hover: color-mix(in srgb, var(--color-link) 80%, white);
    --color-text-placeholder: var(--slate-500);
    --color-bg-input: var(--slate-700);
    --color-overlay: rgba(0, 0, 0, 0.65);
    /* T749 H5: variante dark del overlay sutil. Sobre slate-800/slate-700,
       un overlay blanco produce el efecto "lift" equivalente al rgba negro
       en light mode. Mantener delta visible (alpha 0.05) sin saturar. */
    --color-overlay-subtle: rgba(255, 255, 255, 0.05);
    --color-bg-disabled: var(--slate-700);
    --color-text-disabled: var(--slate-500);

    /* ── Borders ── */
    --color-border-default: #334155;       /* slate-700 */
    --color-border-strong: #475569;        /* slate-600 */
    --color-border-subtle: var(--color-border-default);  /* shadcn alias */
    --card-accent: var(--color-border-default);  /* tenant brand — invariant across themes (user configures the hex) */

    /* ── Brand adjustments ── */
    /* T978: explicit dark alias — brand-primary-dark in dark mode equals brand-primary
       so --color-link resolves to cyan (#33BEFF) which passes AA vs dark surfaces.
       CAVEAT (do not remove): because of this alias, --brand-primary-dark is a
       TEXT/LINK colour only. Using it as a `background` inside a
       [data-theme="dark"] rule is a silent no-op (it resolves back to #33BEFF),
       which is how the control-panel navbar ended up failing contrast in BOTH
       themes. Brand fills that carry text use --brand-surface-strong, which is
       re-declared below with its own dark value instead of being aliased. */
    --brand-primary-dark: var(--brand-primary);

    /* Brand fill for --color-text-inverse ink (see :root). It flips WITH the
       ink: dark mode pairs a light cyan fill with slate-900 text, which is why
       these are the -primary/-hover/-active cyans and not the sky-700..900
       ramp used in light mode. Ratios measured against --color-text-inverse
       (#0f172a): 8.46:1 / 7.39:1 / 5.55:1. */
    --brand-surface-strong: #33BEFF;
    --brand-surface-strong-hover: #00B3EF;
    --brand-surface-strong-active: #0099D6;
    --brand-primary-light: #0c2d3f;        /* HSL(195°, 66%, 15%) — fondo sutil oscuro */
    --brand-primary-shadow: rgba(51, 190, 255, 0.20);
    --brand-primary-shadow-strong: rgba(51, 190, 255, 0.30);
    --brand-focus-ring: rgba(51, 190, 255, 0.50);
    --color-brand-tint-12: rgba(51, 190, 255, 0.15);
    --color-primary-disabled: rgba(51, 190, 255, 0.35);

    /* ── State colors — more luminous for dark backgrounds ── */
    --color-success: #4ade80;              /* green-400 */
    --color-success-hover: #22c55e;        /* green-500 */
    --color-success-bg: rgba(74, 222, 128, 0.12);
    --color-error: #f87171;                /* red-400 */
    --color-error-hover: #ef4444;          /* red-500 */
    --color-error-light: #fca5a5;          /* red-300 */
    --color-error-bg: rgba(248, 113, 113, 0.12);
    --color-warn: #fbbf24;                 /* amber-400 */
    --color-warn-hover: #f59e0b;           /* amber-500 */
    --color-warn-bg: rgba(251, 191, 36, 0.12);
    --color-info: #60a5fa;                 /* blue-400 */
    --color-info-hover: #3b82f6;           /* blue-500 */
    --color-info-bg: rgba(96, 165, 250, 0.12);
    --color-purple: #c084fc;               /* purple-400 */
    --color-success-active: #4ade80;       /* green-400 — pressed state reads lighter on dark */

    /* ── Status TEXT (dark) — ratios measured against slate-700 #334155, the
         darkest surface these ever sit on (zebra rows, elevated chips). The
         -400/-500 state tokens are too dim as ink: #f87171 is 3.74:1 and
         #60a5fa is 4.07:1 on slate-700. ── */
    --color-status-success-text: #4ade80;  /* green-400 — 5.94:1 on #334155 */
    --color-status-error-text: #fca5a5;    /* red-300   — 5.46:1 on #334155, 4.73:1 on #4b4758 */
    --color-status-warn-text: #fbbf24;     /* amber-400 — 6.78:1 on the dark warn tint */
    --color-status-info-text: #93c5fd;     /* blue-300  — 5.74:1 on #334155 */

    /* Badge KPI surface tints (dark) */
    --color-yellow-100: rgba(250, 204, 21, 0.12);   /* yellow-400 base */
    --color-purple-100: rgba(168, 85, 247, 0.14);   /* purple-500 base */
    --color-indigo-100: rgba(129, 140, 248, 0.14);  /* indigo-400 base */
    --color-teal-100:   rgba(45, 212, 191, 0.14);   /* teal-400 base */
    --color-orange-100: rgba(251, 146, 60, 0.14);   /* orange-400 base */
    --color-cyan-100:   rgba(34, 211, 238, 0.14);   /* cyan-400 base */
    --color-emerald-100: rgba(52, 211, 153, 0.14);  /* emerald-400 base */
    --color-amber-100:  rgba(251, 191, 36, 0.12);   /* amber-400 base */
    --color-neutral-50:  rgba(148, 163, 184, 0.08);  /* slate-400 base — dark variant for kpi neutral chips */
    --color-neutral-100: rgba(148, 163, 184, 0.15);  /* slate-400 base — variation chip neutro sin trend */

    /* ── Shadows — MD3: dark surfaces use surface-tint for elevation,
         shadows are subtle (lower opacity than light mode) ── */
    --shadow-xs: 0 1px 2px rgba(0,0,0,0.20), 0 1px 3px rgba(51, 190, 255, 0.06);
    --shadow-sm: 0 1px 3px rgba(0,0,0,0.25), 0 2px 8px rgba(51, 190, 255, 0.08);
    --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.30), 0 4px 14px -2px rgba(51, 190, 255, 0.12);
    --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.30), 0 8px 24px -4px rgba(51, 190, 255, 0.15);
    --shadow-brand-sm: 0 6px 12px rgba(51, 190, 255, 0.12);
    --shadow-brand-lg: 0 8px 16px rgba(51, 190, 255, 0.18);

    /* ── Container ── */
    --container-shadow: 0 6px 18px rgba(0, 0, 0, 0.20),
                        0 4px 12px rgba(51, 190, 255, 0.08);

    /* ── Toggle ── */
    --toggle-track-off: #475569;           /* slate-600 */
    --toggle-knob-off: #1e293b;            /* slate-800 */
    --toggle-focus-ring: rgba(51, 190, 255, 0.40);

    /* ── Chart dark overrides — NO son un flip automático del tema claro.
       Se eligieron contra la superficie oscura REAL (--color-bg-surface #1e293b) y su
       banda de luminosidad re-derivada L ∈ [0.52, 0.72] — la banda [0.48, 0.67] de la
       referencia está calibrada sobre #1a1a19 y sobre #1e293b codifica otro contraste.
       Mismo generador y mismo guard que el bloque claro. ── */
    /* BEGIN chart-palette (generado por scripts/gen_chart_palette.py — NO editar a mano) */
    --chart-sin-asignar:       #7c6c7c;  /* neutro 1 · t0 Sin asignar */
    --chart-tarjeta:           #2e62d0;  /* azul 1 · t1 Tarjeta */
    --chart-efectivo:          #0089d4;  /* azul 2 · t2 Efectivo */
    --chart-carga-online:      #00a5d2;  /* azul 3 · t6 Carga Online */
    --chart-panel-de-control:  #8619ff;  /* violeta 1 · t7 Panel de Control */
    --chart-carga-masiva:      #9a51ff;  /* violeta 2 · t8 Carga Masiva */
    --chart-pre-carga:         #b86dff;  /* violeta 3 · t12 Pre-Carga */
    --chart-taptopay:          #c70000;  /* rojo 1 · t15 TapToPay */
    --chart-descarga:          #d44200;  /* rojo 2 · t4 Descarga */
    --chart-tickets-app:       #d7889f;  /* rojo 3 · t14 Tickets App */
    --chart-invitacion:        #ba00a7;  /* magenta 1 · t3 Invitación */
    --chart-cashback:          #df1da3;  /* magenta 2 · t9 Cashback */
    --chart-zelebrix-benefits: #fb35b7;  /* magenta 3 · t16 Zelebrix Benefits */
    --chart-recargas:          #007f47;  /* verde 1 · agregado Recargas */
    --chart-saldo-zelebrix:    #009660;  /* verde 2 · t11 Saldo Zelebrix */
    --chart-envio:             #00af6c;  /* verde 3 · t5 Envio */
    --chart-reembolso:         #b08b10;  /* ambar 1 · t10 Reembolso */
    --chart-correccion:        #d19801;  /* ambar 2 · t13 Corrección */
    /* Rampa ORDINAL del KPI del pase (#3225): un solo tono, paso 1 = Nivel 0. */
    --chart-milestone-1:       #a8086a;  /* rampa del pase, paso 1/4 */
    --chart-milestone-2:       #cd3688;  /* rampa del pase, paso 2/4 */
    --chart-milestone-3:       #fe72b7;  /* rampa del pase, paso 3/4 */
    --chart-milestone-4:       #ffb0d2;  /* rampa del pase, paso 4/4 */
    /* END chart-palette */
    --chart-devol-periodo:     #0F56FA;  /* hsl(222, 96%, 52%) */
    --chart-devol-panel:       #FB7D0E;  /* hsl(28, 97%, 52%) */
    --chart-ticket-disponibles: #B3E84A; /* hsl(80, 77%, 60%) */
    --chart-ticket-vendidos:   #2FDA4B;  /* hsl(130, 70%, 52%) */
    --chart-ticket-activados:  #0AD6FF;  /* hsl(190, 100%, 52%) */
    --chart-ticket-finished-overlay-gray: 180;  /* light gray for dark bg overlay */

    /* Stable palette — dark overrides: S=70%, L=60% for vibrant look on dark bg */
    --chart-palette-1:  hsl(0deg,   70%, 60%);
    --chart-palette-2:  hsl(30deg,  70%, 60%);
    --chart-palette-3:  hsl(60deg,  70%, 60%);
    --chart-palette-4:  hsl(90deg,  70%, 60%);
    --chart-palette-5:  hsl(120deg, 70%, 60%);
    --chart-palette-6:  hsl(150deg, 70%, 60%);
    --chart-palette-7:  hsl(180deg, 70%, 60%);
    --chart-palette-8:  hsl(210deg, 70%, 60%);
    --chart-palette-9:  hsl(240deg, 70%, 60%);
    --chart-palette-10: hsl(270deg, 70%, 60%);
    --chart-palette-11: hsl(300deg, 70%, 60%);
    --chart-palette-12: hsl(330deg, 70%, 60%);

    /* ── Gradient overrides ── */
    --gradient-btn-recarga: linear-gradient(144deg, #9333ea, #4338ca 50%, #06b6d4);
    --gradient-btn-envio: linear-gradient(120deg, #22c55e, #166534);
    /* Aurora dark: MISMOS hues (cian → teal → índigo) levantados en luminosidad.
       En dark `--color-text-inverse` es #0f172a (línea 607), o sea que el texto del
       botón premium es OSCURO: la ramp tiene que ser CLARA, no más profunda.
       Medido contra #0f172a: 9,97 / 9,59 / 7,73 — por encima de AA (4,5:1) en los tres
       tramos, y mejor que cualquier otro gradiente premium dark de hoy
       (recarga 3,32/2,26/7,35 · envío 7,83/2,50 · recibir-naranja 5,60/3,45). */
    --gradient-aurora: linear-gradient(135deg, #5CCEFF 0%, #2DD4BF 50%, #A5A3F0 100%);
    --gradient-btn-recibir: var(--gradient-aurora);

    /* T438: --co-accent-* dark overrides — valores ajustados para contraste sobre fondos dark.
       NO aliasar a --color-error/warn/success: paleta APK diverge intencionalmente (ver apk.html:130-133).
       Luminosidad reducida ~70% vs valores light; ratio AA verificado visualmente contra --color-bg-surface dark. */
    --co-accent-secondary: #4fc3f7;  /* light: #b3e5fc → más saturado para contraste dark */
    --co-accent-success:   #4caf69;  /* light: #63ba6a → tono similar, AA sobre dark surface */
    --co-accent-warning:   #ffa726;  /* light: #ffc409 → naranja-ámbar, mejor contraste dark */
    --co-accent-danger:    #e53535;  /* light: #bd2727 → rojo más intenso sobre dark bg */
    --co-accent-dark:      #e8e8ea;  /* light: #222428 → invertido para ser visible en dark */

    /* T806: modal spacing tokens — mismo valor en dark (Decisión 2: consistencia espaciado) */
    --modal-section-gap: var(--space-5);
    --modal-label-gap: var(--space-2);
    /* T906: breakpoint y scrollbar fallback — mismos valores en dark */
    --breakpoint-sm: 640px;
    --scrollbar-width-fallback: 15px;
}

/* ═══════════════════════════════════════════════════
   Dark Theme — Component Overrides
   Only for components that cannot use semantic tokens
   (e.g. tooltips with intentionally dark bg in light mode).
   ═══════════════════════════════════════════════════ */

/* Dark override moved after base definition — .form-control, .form-select */

/* ── Event overdue ── */
:where(html[data-theme="dark"]) .event-overdue {
    color: var(--color-text-muted);
}

/* ── Theme toggle icon visibility ── */
:where([data-theme="light"]) .theme-icon-light,
:where([data-theme="dark"]) .theme-icon-dark { display: none; }
:where([data-theme="light"]) .theme-icon-dark,
:where([data-theme="dark"]) .theme-icon-light { display: block; }
/* Fallback when data-theme not set yet (prevents FOUC of both icons) */
html:not([data-theme]) .theme-icon-light { display: none; }
html:not([data-theme]) .theme-icon-dark { display: block; }

/* ═══════════════════════════════════════════════════
   Dark Theme — Special Components (Fase 4)
   ═══════════════════════════════════════════════════ */

/* Dark override moved after base definition — .login-image */

/* ── Swiper (CargaOnline banners) ── */
/* Full-bleed banner: spans full width of parent without scrollbar gap */
.co-banner-bleed {
    width: 100%;
    margin: 0;
    padding: 0;
}

/* Swiper banner size variants + theme vars */
.swiper-banner {
    --swiper-theme-color: #fff;
    --swiper-pagination-bullet-inactive-color: var(--color-white-50);
    --swiper-pagination-bullet-inactive-opacity: 1;
    --swiper-pagination-bullet-size: 8px;
}
.swiper-banner .swiper-pagination {
    bottom: 12px;
    z-index: calc(var(--z-base) + 3);
}
.swiper-banner--sm { height: 140px; }
.swiper-banner--md { height: 220px; }
.swiper-banner--lg { height: 320px; }

/* Banner images: ensure crisp rendering */
.swiper-banner .swiper-slide img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

:where([data-theme="dark"]) .swiper {
    --swiper-theme-color: var(--color-text-secondary);
    --swiper-pagination-bullet-inactive-color: var(--color-text-muted);
}

/* ── Autofill: neutralize browser-imposed yellow/blue bg ── */
:where([data-theme="dark"]) input:-webkit-autofill,
:where([data-theme="dark"]) input:autofill,
:where([data-theme="dark"]) select:-webkit-autofill,
:where([data-theme="dark"]) textarea:-webkit-autofill {
    -webkit-box-shadow: 0 0 0 1000px var(--color-bg-surface) inset;
    -webkit-text-fill-color: var(--color-text-primary);
    transition: background-color 5000s ease-in-out 0s;
}

/* ── SVG icons using currentColor: adapt automatically ── */
/* SVGs with hardcoded fill/stroke (Google logo, brand marks) are untouched */
:where([data-theme="dark"]) .logo-mono {
    filter: brightness(0) invert(1);
}

/* Dark override moved after base definition — .modal-content */

/* ── Footer — uses slate palette (intentionally dark in both themes) ── */

/* ═══════════════════════════════════════════════════
   Dark Theme — Header & Navigation
   ═══════════════════════════════════════════════════ */

/* ── Header action buttons (theme toggle, etc.) ── */
.header-action-btn {
    display: grid;
    place-items: center;
    width: var(--touch-target-min);
    height: var(--touch-target-min);
    border-radius: var(--radius-full);
    border: none;
    background: transparent;
    color: var(--color-text-secondary);
    cursor: pointer;
    transition: color var(--transition-ui), background-color var(--transition-ui);
}
.header-action-btn:hover,
.header-action-btn:focus-visible {
    color: var(--brand-primary-dark);
    background: var(--color-bg-elevated);
}

/* ── Header responsive: hide elements on mobile ── */
.header-logo { height: 1.75rem; /* 28px mobile */ }
.header-action-btn--desktop-only { display: none; }
@media (min-width: 640px) {
    .header-logo { height: 2.5rem; /* 40px */ }
}
@media (min-width: 1024px) {
    .header-action-btn--desktop-only { display: grid; }
}

/* ── Sidebar mobile burger ── */
.sidebar-burger {
    display: grid;
    place-items: center;
    width: var(--touch-target-min);
    height: var(--touch-target-min);
    border: none;
    background: transparent;
    color: var(--color-text-primary);
    cursor: pointer;
    border-radius: var(--radius-md);
    transition: background-color var(--transition-ui);
}
.sidebar-burger:hover { background: var(--color-bg-elevated); }

/* Dark overrides moved after base definitions — header, .inverted-color */

/* Dark overrides moved after base definitions — KPI, chart tabs */

/* Dark override moved after base definition — .badge */

/* ── General text overrides for Tailwind direct colors in HTML ──
   html[data-theme] raises specificity (0,1,1,0) above Tailwind (0,1,0,0)
   so !important is not needed. */
:where(html[data-theme="dark"]) .text-gray-700,
:where(html[data-theme="dark"]) .text-gray-800,
:where(html[data-theme="dark"]) .text-gray-900 {
    color: var(--color-text-primary);
}
:where(html[data-theme="dark"]) .text-gray-500,
:where(html[data-theme="dark"]) .text-gray-600 {
    color: var(--color-text-secondary);
}
:where(html[data-theme="dark"]) .text-gray-400 {
    color: var(--color-text-muted);
}

/* ── Background overrides for Tailwind direct colors in HTML ── */
:where(html[data-theme="dark"]) .bg-white {
    background-color: var(--color-bg-surface);
}
:where(html[data-theme="dark"]) .bg-gray-50 {
    background-color: var(--color-bg-page);
}
:where(html[data-theme="dark"]) .bg-gray-100 {
    background-color: var(--color-bg-elevated);
}

/* ── Border overrides for Tailwind direct colors in HTML ── */
:where(html[data-theme="dark"]) .border-gray-200,
:where(html[data-theme="dark"]) .border-gray-300 {
    border-color: var(--color-border-default);
}

/* ── Shadow overrides: Tailwind shadow-md/lg on dark surfaces ── */
:where(html[data-theme="dark"]) .shadow-md {
    box-shadow: var(--shadow-md);
}
:where(html[data-theme="dark"]) .shadow-lg {
    box-shadow: var(--shadow-lg);
}

/* ── Dropdown/select menus + bare text inputs/textareas ──
   Native form controls only inherit font (see reset ~line 107), never color,
   so a bare <input>/<textarea> without a theme-aware helper class (.form-input,
   .cp-input, .form-control) renders with the default text color over the dark
   page — unreadable low-contrast typed text. This gives every un-classed text
   input the same dark surface/text/border the <select> already had.
   Checkbox/radio are excluded so native toggle widgets are not repainted.
   `:where(:not(...))` keeps the input selector at (0,0,1) — identical to the
   plain `select` — so any more-specific helper class still wins where applied. */
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] each plain-element selector here (0,0,1) sits after its own ':-webkit-autofill' variant (0,1,1, lines 769-772): the autofill pseudo-class targets a disjoint state of the same element; ordering the plain rule after it never changes which wins, and hoisting it above the autofill block would split the dark form styling from its component. */
:where([data-theme="dark"]) input:where(:not([type="checkbox"], [type="radio"])),
:where([data-theme="dark"]) textarea,
:where([data-theme="dark"]) select {
/* stylelint-enable no-descending-specificity */
    background-color: var(--color-bg-surface);
    color: var(--color-text-primary);
    border-color: var(--color-border-default);
}

/* ── Table headers & borders ── */
:where([data-theme="dark"]) table th {
    color: var(--color-text-primary);
    border-color: var(--color-border-default);
}
:where([data-theme="dark"]) table td {
    color: var(--color-text-secondary);
    border-color: var(--color-border-default);
}
:where([data-theme="dark"]) table thead {
    background: var(--color-bg-elevated);
}

/* ── Backgrounds: gray-200/300, blue-50 ── */
:where(html[data-theme="dark"]) .bg-gray-200 {
    background-color: var(--color-bg-elevated);
}
:where(html[data-theme="dark"]) .bg-gray-300 {
    background-color: var(--slate-600);
}
:where(html[data-theme="dark"]) .bg-blue-50 {
    background-color: var(--color-brand-tint-10);
}

/* ── Links: Tailwind text-blue-* ── */
html[data-theme="dark"] a.text-blue-600,
html[data-theme="dark"] a.text-blue-700,
:where(html[data-theme="dark"]) .text-blue-500,
:where(html[data-theme="dark"]) .text-blue-600,
:where(html[data-theme="dark"]) .text-blue-700 {
    color: var(--brand-primary);
}

/* ── Text: gray-300 ── */
:where(html[data-theme="dark"]) .text-gray-300 {
    color: var(--color-text-muted);
}

/* ── Semantic color overrides for Tailwind direct classes (tarea 039 Fase 0.4) ── */

/* Text colors — error */
:where(html[data-theme="dark"]) .text-red-400,
:where(html[data-theme="dark"]) .text-red-500,
:where(html[data-theme="dark"]) .text-red-600,
:where(html[data-theme="dark"]) .text-red-700,
:where(html[data-theme="dark"]) .text-red-800 {
    color: var(--color-error);
}

/* Text colors — success */
:where(html[data-theme="dark"]) .text-green-500,
:where(html[data-theme="dark"]) .text-green-600,
:where(html[data-theme="dark"]) .text-green-700,
:where(html[data-theme="dark"]) .text-green-800 {
    color: var(--color-success);
}

/* Text colors — warn */
:where(html[data-theme="dark"]) .text-amber-700,
:where(html[data-theme="dark"]) .text-orange-500,
:where(html[data-theme="dark"]) .text-orange-600,
:where(html[data-theme="dark"]) .text-yellow-500,
:where(html[data-theme="dark"]) .text-yellow-600,
:where(html[data-theme="dark"]) .text-yellow-700,
:where(html[data-theme="dark"]) .text-yellow-800 {
    color: var(--color-warn);
}

/* Text colors — info */
:where(html[data-theme="dark"]) .text-blue-800 {
    color: var(--brand-primary);
}

:where(html[data-theme="dark"]) .text-purple-600 {
    color: var(--color-purple);
}

/* Background colors — light tints that blind in dark */
:where(html[data-theme="dark"]) .bg-red-50    { background-color: rgba(248, 113, 113, 0.12); }
:where(html[data-theme="dark"]) .bg-green-50  { background-color: rgba(74, 222, 128, 0.12); }
:where(html[data-theme="dark"]) .bg-orange-50 { background-color: rgba(251, 191, 36, 0.12); }
:where(html[data-theme="dark"]) .bg-amber-50,
:where(html[data-theme="dark"]) .bg-amber-50\/40 { background-color: rgba(251, 191, 36, 0.12); }
:where(html[data-theme="dark"]) .bg-yellow-50 { background-color: rgba(251, 191, 36, 0.10); }
:where(html[data-theme="dark"]) .bg-yellow-100 { background-color: rgba(251, 191, 36, 0.15); }

/* Borders — invisible light borders in dark */
:where(html[data-theme="dark"]) .border-red-200   { border-color: rgba(248, 113, 113, 0.30); }
:where(html[data-theme="dark"]) .border-red-300   { border-color: rgba(248, 113, 113, 0.35); }
:where(html[data-theme="dark"]) .border-red-400   { border-color: rgba(248, 113, 113, 0.40); }
:where(html[data-theme="dark"]) .border-green-200 { border-color: rgba(74, 222, 128, 0.25); }
:where(html[data-theme="dark"]) .border-green-300 { border-color: rgba(74, 222, 128, 0.30); }
:where(html[data-theme="dark"]) .border-blue-200  { border-color: var(--color-brand-tint-25); }
:where(html[data-theme="dark"]) .border-blue-300  { border-color: var(--color-brand-tint-30); }
:where(html[data-theme="dark"]) .border-yellow-200 { border-color: rgba(251, 191, 36, 0.25); }
:where(html[data-theme="dark"]) .border-amber-200 { border-color: rgba(251, 191, 36, 0.25); }
:where(html[data-theme="dark"]) .border-amber-300 { border-color: rgba(251, 191, 36, 0.30); }
:where(html[data-theme="dark"]) .border-orange-200 { border-color: rgba(251, 191, 36, 0.25); }
:where(html[data-theme="dark"]) .border-orange-400 { border-color: rgba(251, 191, 36, 0.40); }
:where(html[data-theme="dark"]) .border-orange-500 { border-color: rgba(251, 191, 36, 0.50); }

/* ═══════════════════════════════════════════════════
   Dark Theme — Transitions, Print & A11y (Fase 5)
   ═══════════════════════════════════════════════════ */

/* ── Smooth theme transition — MOVED to after base component definitions
   (see "Theme Transition Override" section below .form-control/.form-select) ── */

/* ── Print: force light theme ── */
@media print {
    :root {
        --color-bg-page: #ffffff;
        --color-bg-surface: #ffffff;
        --color-bg-elevated: #ffffff;
        --color-text-primary: #000000;
        --color-text-secondary: #333333;
        --color-text-muted: #666666;
        --color-border-default: #cccccc;
        --color-border-strong: #999999;
    }
    .sidebar, .sidebar-overlay, .header-sticky, .sidebar-burger {
        display: none !important; /* stylelint-disable-line declaration-no-important -- must override inline/cascade for print */
    }
}

/* ── Reduced motion: disable ALL animations and transitions (WCAG 2.3.3) ── */
@media (prefers-reduced-motion: reduce) {
    :root {
        --transition-fast: 0ms;
        --transition-ui: 0ms;
        --transition-base: 0ms;
        --transition-slow: 0ms;
        --dur-fast: 0ms;
        --dur-base: 0ms;
        --dur-slow: 0ms;
    }
    /* stylelint-disable declaration-no-important -- a11y: must override all animations */
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
    /* stylelint-enable declaration-no-important */
}

/* ═══════════════════════════════════════════════════
   Semantic Utility Classes
   Mapped to :root aliases for theme support (tarea 034)
   ═══════════════════════════════════════════════════ */

/* — Surface & Page — */
.bg-page { background-color: var(--color-bg-page); }
.hover\:bg-page:hover { background-color: var(--color-bg-page); }
.bg-surface { background-color: var(--color-bg-surface); }
.bg-elevated { background-color: var(--color-bg-elevated); }
.hover\:bg-elevated:hover { background-color: var(--color-bg-elevated); }
/* stylelint-disable-next-line declaration-no-important -- Tailwind ! convention */
.hover\:\!bg-elevated:hover { background-color: var(--color-bg-elevated) !important; }

/* — Text — */
/* Override Tailwind .text-primary — main.css must load after tailwind.output.css */
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-muted { color: var(--color-text-muted); }
.text-inverse { color: var(--color-text-inverse); }
.hover\:text-primary:hover { color: var(--color-text-primary); }
/* stylelint-disable-next-line declaration-no-important -- Tailwind ! convention */
.hover\:\!text-inherit:hover { color: inherit !important; }

/* — Border — */
.border-default { border-color: var(--color-border-default); }
.border-strong { border-color: var(--color-border-strong); }

/* — Utility — */
.scroll-list {
  max-height: 200px;
  overflow-y: auto;
  display: grid;
  grid-template-columns: 1fr 1fr;
}

/* — Scroll-list checkbox/radio items — */
.scroll-list label {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-sm);
  font-weight: var(--font-medium);
  color: var(--color-text-secondary);
  cursor: pointer;
  user-select: none;
}
.scroll-list :where(label:hover) {
  color: var(--color-text-primary);
}

/* ═══ Color Picker Component ═══ */
.color-picker-popup {
    position: fixed;
    z-index: var(--z-popover);
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
    padding: var(--space-2);
    box-shadow: var(--shadow-md);
}
.color-picker-grid {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: var(--space-1);
}
.color-picker-action {
    color: var(--color-text-muted);
    background: none;
    border: none;
    cursor: pointer;
    padding: var(--space-1);
    border-radius: var(--radius-xs);
    transition: color var(--transition-fast), background-color var(--transition-fast);
    /* WCAG 2.2 SC 2.5.8 floor lives on the component, not on the `min-h-touch`
       utilities the markup also carries: those are overridden further down for
       the compact preset table, which silently shrank these to 18x20px. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-block-size: var(--touch-target-min-aa);
    min-inline-size: var(--touch-target-min-aa);
}
.color-picker-action:hover {
    color: var(--color-text-primary);
    background: var(--color-bg-elevated);
}
.color-picker-action--danger {
    color: var(--color-error-light);
}
.color-picker-action--danger:hover {
    color: var(--color-error);
    background: var(--color-error-bg);
}

/* ═══ Battlepass milestone delete ═══
   «Quiero que sea como el que tenemos en editar los botones de la calculadora
   custom, con un icono de una x roja y mismo funcionamiento» (owner,
   2026-09-21). El de la calculadora es el `.color-picker-action--danger` de
   arriba, y estas reglas le copian los CUATRO tokens para que sean el mismo
   control a la vista.

   ⛔ Pero NO puede ser un <button>: borrar un hito no es inmediato, se marca y
   se aplica al guardar el modal, así que el control sigue siendo el
   `<input type="checkbox" name="bp_ms_delete">` que viaja en el POST. De ahí
   `appearance: none` en vez de markup nuevo — y de ahí que la X venga en
   `mask-image`: un <input> es un elemento reemplazado y no admite un <svg>
   dentro.

   ⛔ NADA de `display:none`, `opacity:0`, `visibility:hidden` ni `sr-only`:
   `admin-crud-pase.spec.ts` afirma `toBeVisible()` sobre este mismo input antes
   de marcarlo. Cualquiera de los cuatro lo pondría rojo.

   El estado marcado NO se distingue sólo por el tono (WCAG 2.2 SC 1.4.1): gana
   fondo Y borde, que se ven en escala de grises. */
.milestone-delete {
    appearance: none;
    -webkit-appearance: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    margin: 0;
    padding: var(--space-1);
    background: none;
    border: 1px solid transparent;
    border-radius: var(--radius-xs);
    color: var(--color-error-light);
    cursor: pointer;
    /* Mismo suelo de diana que `.color-picker-action` (WCAG 2.2 SC 2.5.8 AA). */
    min-block-size: var(--touch-target-min-aa);
    min-inline-size: var(--touch-target-min-aa);
    transition: color var(--transition-fast), background-color var(--transition-fast),
                border-color var(--transition-fast);
}
.milestone-delete::before {
    content: "";
    display: block;
    /* 14px = el `w-3.5 h-3.5` del <svg> que pinta `editable-items-table.js`. */
    inline-size: var(--space-3-5);
    block-size: var(--space-3-5);
    background-color: currentColor;
    /* Longhands y no el atajo `mask:`: ese atajo con `position / size` no lo
       acepta el `-webkit-mask` de WebKit viejo, y ahí la X saldría a su tamaño
       intrínseco de 24px, desbordando su caja de 14. */
    -webkit-mask-image: var(--icon-close-x);
    mask-image: var(--icon-close-x);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-size: contain;
    mask-size: contain;
}
.milestone-delete:hover {
    color: var(--color-error);
    background-color: var(--color-error-bg);
}
.milestone-delete:checked {
    color: var(--color-error);
    background-color: var(--color-error-bg);
    border-color: var(--color-error);
}

/* ═══ Editable Items Table — utility classes for inline JS-generated elements ═══ */
.icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: var(--touch-target-min);
    min-height: var(--touch-target-min);
    border-radius: var(--radius-sm);
    border: 2px solid transparent;
    background: transparent;
    cursor: pointer;
    transition: background-color var(--transition-fast);
}
@media (min-width: 640px) {
    .icon-btn { min-width: var(--touch-target-min); min-height: var(--touch-target-min); }
}
.icon-btn:hover {
    background: var(--color-bg-elevated);
}
.icon-btn--selected {
    border-color: var(--brand-primary-dark);
    background: var(--brand-primary-light);
}

.color-picker-btn {
    width: var(--icon-xl);
    height: var(--icon-xl);
    border-radius: var(--radius-md);
    border: 2px solid var(--color-border-strong);
    cursor: pointer;
    transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
@media (min-width: 640px) {
    .color-picker-btn { width: var(--icon-2xl); height: var(--icon-2xl); }
}
.color-picker-btn:hover {
    transform: scale(1.1);
}
.type-toggle-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    border-radius: var(--radius-md);
    border: 1px solid var(--color-border-default);
    background: var(--color-bg-surface);
    color: var(--color-text-muted);
    cursor: pointer;
    transition: all var(--transition-fast);
}
.type-toggle-btn:hover {
    border-color: var(--color-border-strong);
    color: var(--color-text-primary);
}
.type-toggle-btn--active {
    background: var(--brand-surface-strong);
    border-color: var(--brand-surface-strong);
    color: var(--color-text-inverse);
}
.type-toggle-btn--active:hover {
    background: var(--brand-surface-strong-hover);
    border-color: var(--brand-surface-strong-hover);
    color: var(--color-text-inverse);
}
.input-suffix {
    position: absolute;
    right: var(--space-2);
    top: 50%;
    transform: translateY(-50%);
    pointer-events: none;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    font-weight: var(--font-medium);
}

/* ═══ APK Builder Component ═══ */
.apk-input {
    width: 100%;
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
    padding: var(--space-2) var(--space-3);
    font-size: var(--text-base);
    font-weight: var(--font-semibold);
    color: var(--color-text-muted);
    background: var(--color-bg-surface);
}
.apk-select {
    width: 100%;
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-sm);
    padding: var(--space-2);
    font-size: var(--text-sm);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
    background: var(--color-bg-surface);
}
.apk-label {
    font-size: var(--text-xs);
    font-weight: var(--font-bold);
    text-transform: uppercase;
    letter-spacing: var(--tracking-wider);
    color: var(--color-text-primary);
    margin-bottom: var(--space-1);
}
.apk-empty-msg {
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    text-align: center;
}
.apk-currency-suffix {
    position: absolute;
    right: var(--space-3);
    color: var(--color-text-secondary);
    font-weight: var(--font-semibold);
}

/* — CargaOnline config layout (apk.html) —
   The article lives in an xl:col-span-3 grid row that is ~1180px wide.
   Left column hosts controls + preset table, right column hosts the
   live preview. Without a cap the shared editable_items_table.html
   gives the Etiqueta column ~530px in wide viewports, which looks
   unbalanced against the 88px Importe column and detaches the live
   preview from the rest of the card. */
/* Base = mobile: single-column stack. Grid (not flex) so the preview docks
   as a real sized column at >=1024px via minmax() instead of a fixed 340px
   strip that cannot coexist with the left column at 768-1023px (that clash
   is what pinned the dead white column). Below 1024px the preview stacks
   full-width after the presets in natural DOM order — no `order:` hacks. */
.co-config-layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-6);
}
.co-config-preview {
    width: 100%;
}
/* T510: smooth preview re-render on mode switch (select/cards/manual) and on
   min/max edits — applies at every viewport. The min-height that prevented
   sticky-jump is scoped to >=1024px below (mobile lets the empty state supply
   its own height instead of a hard 120px blank box). */
.co-config-preview #co-preview-wrap {
    transition: opacity 150ms ease-out;
}
.co-config-preview #co-preview-inner {
    transition: opacity 100ms ease-out;
}
/* State-driven border: dashed while the preview is empty (a designed empty
   state, not unexplained whitespace), solid once populated. The `.is-empty`
   class is toggled from coRenderPreview(). The colour accent on preview cards
   is a border, never an absolutely-positioned overlay (no-overlap invariant). */
.co-config-preview #co-preview-wrap.is-empty {
    border-style: dashed;
}
/* Dock the preview as a side column ONLY at >=1024px (was 768px — the root of
   the squeeze). minmax(280px, 340px) lets the preview shrink instead of
   overlapping the left column; the left cell keeps min-w-0 so the subgrid
   preset table can shrink too. */
@media (min-width: 1024px) {
    .co-config-layout {
        grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
        gap: var(--space-8);
        align-items: start;
    }
    .co-config-preview {
        position: sticky;
        top: calc(var(--header-height) + var(--space-4));
    }
    .co-config-preview #co-preview-wrap {
        padding: var(--space-3);
        min-height: 120px;
    }
}

/* — Shared editable items table → CSS-grid rows (issue #2333) —
   The shared editable_items_table.html partial (CargaOnline presets +
   TransactionPoint calculator) lays its rows out with ONE CSS grid at ALL
   breakpoints. The Acciones and Color tracks are `max-content`, so the action
   buttons size exactly to their content instead of overflowing a fixed
   `table-fixed` column. Two consequences fix the reported desktop bugs:
     · the <tr> keeps a real box that spans every track, so its zebra/hover
       background (the .data-table rules below) paints the FULL row width — no
       bare strip past the Acciones column; and
     · the action buttons live in their own max-content track, so they can
       never spill left onto the colour swatch.
   The table is the grid and thead/tbody dissolve (display: contents) so every
   <tr> shares ONE set of column tracks via `subgrid` — header and body columns
   stay aligned, and Acciones/Color size to their widest content across rows.
   DOM stays <table>/<tr>/<td> (E2E data-* / window.<prefix>* hooks intact).
   All spacing is token-based; the zebra/hover colours (bg-surface /
   bg-elevated / brand-tint-12) come from the .data-table rules — zero
   hardcoded colour/space/z here. */
.editable-items-table {
    display: grid;
    /* importe · etiqueta · color · actions (TIPO added by --with-type). */
    grid-template-columns:
        minmax(0, 6rem)
        minmax(0, 1fr)
        max-content
        max-content;
    column-gap: var(--space-2);
}
.editable-items-table--with-type {
    grid-template-columns:
        max-content
        minmax(0, 6rem)
        minmax(0, 1fr)
        max-content
        max-content;
}
/* #2858 — promo editor column set: producto · cantidad · extra · actions.
   The wide track leads here (the product <select> carries the long names),
   which is why it needs its own modifier instead of reusing the default. */
.editable-items-table--bundle {
    grid-template-columns:
        minmax(0, 1fr)
        minmax(0, 5rem)
        max-content
        max-content;
}
/* thead/tbody dissolve so each <tr> is a direct grid item sharing the table
   tracks — header ↔ body columns stay aligned. */
.editable-items-table thead,
.editable-items-table tbody {
    display: contents;
}
/* Each row spans every column and mirrors the table tracks via subgrid, while
   keeping its own box for the zebra/hover/divider background. */
.editable-items-table tr {
    grid-column: 1 / -1;
    display: grid;
    grid-template-columns: subgrid;
    align-items: center;
}
/* Let the importe/etiqueta inputs shrink inside their grid track instead of
   forcing the row wider than its card (pairs with min-w-0 on the inputs).
   :where() keeps specificity at (0,1,0) so it never descends past the later
   :where()-wrapped .data-table cell rules (no-descending-specificity). */
.editable-items-table :where(thead th, tbody td) {
    min-width: 0;
}

/* — CargaOnline preset section desktop polish — CONSUMER-scoped on purpose.
   The 620px cap is layout of the CargaOnline page (the presets live in the left
   column of `.co-config-layout`, beside the sticky live preview), not a
   property of the component. It stays behind `#co-custom-section` so a future
   consumer does not inherit a width cap it never asked for. Everything BELOW
   640px is component-scoped (see the block after this one). */
@media (min-width: 640px) {
    /* The preset table fits comfortably in the capped left column;
       the shared scroll wrapper still forces a 6px
       horizontal scrollbar for rounding. Drop it in ≥sm so desktop
       renders clean. */
    #co-custom-section > .editable-items-table-wrap {
        overflow: visible;
    }
    /* Presets header row (title + "+ Añadir") shares the table's
       effective width so the button can't float into the aisle. */
    #co-custom-section > .flex.items-start {
        max-width: 620px;
    }
    /* The preset table width is bounded by the left column in lg+, but
       on sm/md it would still greedy-grow Etiqueta. Cap it so rows
       stay legible at tablet sizes too. The Etiqueta (1fr) track then
       absorbs the remaining width — no per-cell max-width cap needed
       (that was fragile under the grid: nth-last-child shifts when the
       optional TIPO/Color columns are absent). */
    #co-custom-section table {
        max-width: 620px;
    }
}

/* — Shared editable items table → stacked cards on mobile —
   COMPONENT-scoped (`.editable-items-table`), NOT consumer-scoped. This whole
   block used to hang off `#co-custom-section`, so the CargaOnline presets
   stacked correctly while the TransactionPoint calculator
   (`#calculadora_config_section`, which has zero rules of its own in this
   sheet) inherited nothing: at 375px its Importe/Etiqueta tracks collapsed to
   0px, the IMPORTE/ETIQUETA headers painted on top of each other and the delete
   button sat outside the visible area behind a nested horizontal scroll.
   Hanging the rules off the component class means every consumer — the two that
   exist today and the products section that is coming — inherits the mobile
   layout by including the partial, with zero extra CSS. That is the difference
   between changing this in ONE place and changing it in every screen.
   The shared single-row grid (.editable-items-table above) only has room for
   its 4-5 tracks (TIPO · importe · etiqueta · color · actions) at >=640px,
   where the subgrid block takes over. Below 640px (all phones, including the
   376-639px gap the old <=375px rule left cramped) each <tr> becomes a
   three-row mini-card: the narrow fixed-size pieces on top, the free-text
   Etiqueta at full width so it is never squeezed by the swatch, and the actions
   on their own row. That last row is what removes the width pressure that used
   to justify shrinking the action buttons, so they get the FULL
   `--touch-target-min` (44px) on both axes instead of being compressed further,
   and the destructive button sits away from the move-up/move-down pair
   (WCAG 2.5.8 spacing) instead of being a third identical icon glued to them.
   Selectors use :has() to be column-order-agnostic (TIPO may be absent when
   TICKETS_ENABLED is off). Hard invariant (mirrors the ≥640 subgrid):
   NO position:absolute and NO negative margins inside .editable-items-table tr
   — each cell owns its own grid track/area; that is what kills swatch↔label
   and actions↔swatch overlap and it must survive future edits. */
@media (max-width: 639px) {
    .editable-items-table-wrap {
        overflow: visible;
        margin-left: calc(-1 * var(--space-2));
        margin-right: calc(-1 * var(--space-2));
    }
    .editable-items-table {
        display: block;
        width: 100%;
        table-layout: auto;
    }
    .editable-items-table tbody {
        display: block;
        width: 100%;
    }
    .editable-items-table thead {
        display: none;
    }
    /* Grid avoids the flex-wrap DOM-order problem (label sits between
       value and color in the JS-rendered <tr>, which would force a wrap
       before color/actions). Grid areas decouple visual order from DOM
       order so the row markup — and every data-attribute / window-prefix E2E
       hook on it — stays untouched. */
    .editable-items-table tbody tr {
        display: grid;
        grid-template-columns: auto minmax(0, 1fr) auto;
        grid-template-areas:
            "type     importe  color"
            "etiqueta etiqueta etiqueta"
            "actions  actions  actions";
        align-items: center;
        gap: var(--space-2);
        padding: var(--space-3);
        margin-bottom: var(--space-2);
        background: var(--color-bg-surface);
        border: 1px solid var(--color-border-default);
        border-radius: var(--radius-md);
    }
    .editable-items-table tbody td {
        display: block;
        padding: 0;
        min-width: 0;
    }
    .editable-items-table tbody td:has([data-action="set-type"]) { grid-area: type; }
    .editable-items-table tbody td:has([data-field="value"]),
    .editable-items-table tbody td:has([data-field="event_id"]) {
        grid-area: importe;
        min-width: 0;
    }
    .editable-items-table tbody td:has([data-action="color"]) { grid-area: color; }
    .editable-items-table tbody td:has([data-field="label"]) { grid-area: etiqueta; }
    /* #2858 — the promo-editor row maps onto the SAME three card areas: the
       product <select> is the field that needs the full width (it holds the
       long event names), cantidad takes the narrow numeric slot and the Extra
       toggle the trailing one. Stated as component rules like every other line
       in this block, so the mapping travels with the component. */
    .editable-items-table tbody td:has([data-field="child_event_id"]) {
        grid-area: etiqueta;
        min-width: 0;
    }
    .editable-items-table tbody td:has([data-field="quantity"]) {
        grid-area: importe;
        min-width: 0;
    }
    .editable-items-table tbody td:has([data-field="is_extra"]) { grid-area: color; }
    /* The actions own a full-width row of the card, so the three 44px targets
       fit without stealing width from the inputs. `margin-inline-start: auto`
       pushes the irreversible delete away from the reorder pair (WCAG 2.5.8
       spacing exception; colour alone would also fail WCAG 1.4.1) — an auto
       margin, never a negative one, per the invariant above. */
    .editable-items-table tbody td:has([data-action="del"]) {
        grid-area: actions;
        display: flex;
        align-items: center;
        gap: var(--space-2);
    }
    .editable-items-table tbody td [data-action="del"] {
        margin-inline-start: auto;
    }
    /* …and they must be a REAL 44px target on BOTH axes. `.color-picker-action`
       only floors them at `--touch-target-min-aa` (24px, the WCAG 2.2 SC 2.5.8
       AA minimum) via `min-inline-size`, and a logical property cascades against
       the physical `min-width` of the `min-w-touch` utility the markup carries,
       so the 24px floor won on the inline axis and the up/down/delete controls
       rendered 24px wide — a quarter of the row width they now own, for three
       identical icons one of which deletes without confirmation. Stating the
       full `--touch-target-min` here is the fix; lowering the guard to the 24px
       floor instead would have been calibrating the check to the broken value.
       Scoped through the component's own cells so it outranks BOTH
       `.color-picker-action` and the utility class, and so the colour-picker
       popover — same button class, different context, 24px density on purpose —
       is left alone. */
    .editable-items-table tbody td .color-picker-action {
        min-inline-size: var(--touch-target-min);
        min-block-size: var(--touch-target-min);
    }
    /* `.type-toggle-btn` (the balance/ticket type switch, `data-action="set-type"`)
       sits alone in the "type" grid area — it never competed with the actions
       trio for width, so it was never part of the compression this block exists
       to undo. But its own base rule (`width: 30px; height: 30px;`) has no
       min-* floor of any kind at ANY breakpoint: not the 44px this component
       commits to, not even a token-backed AA floor — just a hardcoded pixel
       size that happens to clear 24px by coincidence. #2787: this is the
       coverage gap the guard below closes. `width`/`height` are independent of
       `min-inline-size`/`min-block-size`, so no specificity contest is needed
       to win — a min floor always clamps a smaller explicit size. */
    .editable-items-table tbody td .type-toggle-btn {
        min-inline-size: var(--touch-target-min);
        min-block-size: var(--touch-target-min);
    }
    /* Force the relative wrapper of the amount input (it hosts the currency
       suffix) to shrink with its grid cell instead of pushing out the 1fr
       column to its min-content. */
    .editable-items-table tbody td > div {
        min-width: 0;
        max-width: 100%;
        width: 100%;
        box-sizing: border-box;
    }
    /* Misma regla de los 16px para la fila de controles de Carga Online
       (Mín. / Máx. / Máx. envío / Modo): sus campos son `text-sm` (14px), y
       por debajo del umbral de 16px de WebKit iOS Safari hace zoom al enfocar
       y no lo deshace (#3307). Va aquí y no en el `class=` de cada input para
       que sólo suba en móvil: en escritorio la fila conserva su `text-sm`.
       Toda la fila, no sólo un campo — subir uno solo dejaría tres tamaños
       distintos en la misma línea.

       ⚠️ ORDEN: este bloque va ANTES del de `.editable-items-table`, no después.
       Los dos terminan en `input`/`select`, y `no-descending-specificity` exige
       que el selector menos específico —este, (0,1,1)— preceda al más específico
       —(0,1,3)—. No compiten por ningún elemento (la tabla de presets vive en
       `#co-custom-section`, fuera de `.co-config-controls-row`: interseccion
       medida = 0 nodos), así que el orden es puro contrato de lint y moverlo
       de vuelta pone el CI rojo sin cambiar un pixel. */
    .co-config-controls-row input,
    .co-config-controls-row select {
        font-size: var(--text-base);
    }
    /* Same shrink rule for the fields themselves, plus the font size: 16px is
       the WebKit threshold below which iOS Safari zooms the page in on focus
       and never zooms back out — the form "feels broken" even when the layout
       is right. Fixing that with `maximum-scale=1` would fail WCAG 1.4.4 Resize
       Text, so the font size goes up instead. */
    .editable-items-table tbody td input,
    .editable-items-table tbody td select {
        min-width: 0;
        max-width: 100%;
        width: 100%;
        box-sizing: border-box;
        font-size: var(--text-base);
    }
    .editable-items-table tbody tr.border-b {
        border-bottom-width: 1px;
    }
}

/* — CargaOnline preset preview cards (JS-generated in apk.html) — */
.co-preview-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(72px, auto));
    gap: var(--space-2);
}
.co-preview-card {
    position: relative;
    display: inline-flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius-md);
    border: 2px solid var(--color-border-default);
    border-left: 4px solid var(--card-accent);
    padding: var(--space-3);
    min-height: 80px;
    background: var(--color-bg-surface);
    cursor: pointer;
    text-align: center;
}
.co-preview-value {
    font-size: var(--text-base);
    font-weight: var(--font-bold);
    color: var(--color-text-primary);
}
.co-preview-label {
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    margin-top: var(--space-1);
    max-width: 80px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* — Preset card quantity badge + clear button (CargaOnline) — */
.card-badge {
    position: absolute;
    bottom: calc(-1 * var(--space-2));
    right: calc(-1 * var(--space-2));
    background: var(--brand-surface-strong);
    color: var(--color-text-inverse);
    border-radius: var(--radius-full);
    font-size: var(--text-xs);
    font-weight: var(--font-bold);
    min-width: var(--icon-md);
    height: var(--icon-md);
    line-height: var(--icon-md);
    text-align: center;
    padding: 0 var(--space-1);
    pointer-events: none;
    z-index: var(--z-dropdown);
}
/* Decrement (-1) button on preset & ticket cards. Top-right red affordance.
   Replaces the legacy .card-clear (×); bulk reset still available via the
   global "Vaciar" cart button. */
.card-minus {
    position: absolute;
    top: calc(-1 * var(--space-2-5));
    right: calc(-1 * var(--space-2-5));
    width: var(--icon-lg);
    height: var(--icon-lg);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 0;
    border-radius: var(--radius-full);
    background: var(--color-error);
    color: var(--color-text-inverse);
    font-weight: var(--font-bold);
    font-size: var(--text-sm);
    line-height: var(--icon-lg);
    text-align: center;
    cursor: pointer;
    padding: 0;
    box-shadow: var(--shadow-xs);
    z-index: var(--z-dropdown);
    transition: background var(--transition-fast);
}
.card-minus:hover,
.card-minus:focus-visible {
    background: var(--color-error-hover);
}
/* WCAG 2.5.5 — touch area 44x44 without changing visual size */
.card-minus::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: var(--touch-target-min);
    height: var(--touch-target-min);
    border-radius: var(--radius-full);
}

/* — CargaOnline config controls row (min/max/mode/toggle) — */
.co-config-controls-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-3);
    align-items: end;
    margin-bottom: var(--space-4);
}
.co-config-controls-row > * {
    display: flex;
    flex-direction: column;
}
.co-config-controls-row .co-controls-toggle-cell {
    grid-column: span 2;
    flex-direction: row;
    align-items: center;
    gap: var(--space-3);
    /* Own full-width row on mobile: align to the row baseline and give it a
       touch-sized height instead of a magic padding-top offset. */
    align-self: end;
    min-height: var(--touch-target-min);
}
/* Consolidated breakpoint to avoid media-query fragmentation (T506 CI fix). */
@media (min-width: 640px) {
    .co-config-controls-row {
        display: flex;
        flex-wrap: wrap;
        gap: var(--space-4);
    }
    .co-config-controls-row .co-controls-toggle-cell {
        grid-column: auto;
    }
}

/* — Global focus ring (WCAG 2.4.11 / SC 1.4.11 non-text contrast — replaces
   per-component repetitions) — #2787: --brand-primary (#33BEFF) on a white/
   #f8fafc page is 2.11:1 / 2.02:1, below the 3:1 SC 1.4.11 floor for a
   non-text indicator. --brand-primary-dark is #0369a1 on light (5.93:1 vs
   #ffffff, 5.67:1 vs #f8fafc) and re-aliases to --brand-primary itself under
   [data-theme="dark"] (main.css ~L699), so dark keeps its already-compliant
   8.46:1 unchanged. Same pattern already proven at .chart-legend-scroll
   :focus-visible (main.css ~L4683). */
:focus-visible {
    outline: 2px solid var(--brand-primary-dark);
    outline-offset: 2px;
}
/* Opt-in enhanced focus ring — adds glow halo on top of the global :focus-visible outline */
.focus-ring:focus-visible {
    box-shadow: 0 0 0 4px var(--brand-focus-ring);
}
/* — Status: Success (replaces broken emerald-*) — */
.bg-status-success { background-color: var(--color-success); }
.bg-status-success-light { background-color: var(--color-success-bg); }
.text-status-success { color: var(--color-status-success-text); }
.border-status-success { border-color: var(--color-success); }

/* -- Status: Warning (replaces broken yellow/amber) -- */
.bg-status-warn { background-color: var(--color-warn); }
.bg-status-warn-light { background-color: var(--color-warn-bg); }
.text-status-warn { color: var(--color-status-warn-text); }
.text-status-warn-dark { color: var(--color-warn-hover); }
.border-status-warn { border-color: var(--color-warn); }
.border-status-warn-light { border-color: var(--color-warn-bg); }

/* — Status: Error — */
.bg-status-error { background-color: var(--color-error); }
.bg-status-error-light { background-color: var(--color-error-bg); }
.text-status-error { color: var(--color-status-error-text); }
.border-status-error { border-color: var(--color-error); }
/* — Brand (replaces broken sky-*) — */
.bg-brand { background-color: var(--brand-primary); }
.text-brand { color: var(--brand-primary-dark); }
.border-brand { border-color: var(--brand-primary); }
.border-primary { border-color: var(--brand-primary); }
.hover\:bg-brand-dark:hover { background-color: var(--brand-surface-strong); }

/* — Shadow utility — */
.shadow-xs { box-shadow: var(--shadow-xs); }

/* — Touch target utility (WCAG 2.5.8) — */
.min-h-touch { min-height: var(--touch-target-min); }

/* — Info (replaces broken indigo-*) — */
.bg-info { background-color: var(--color-info); }
.bg-info-light { background-color: var(--color-info-bg); }
.text-info { color: var(--color-info); }
.hover\:text-info:hover { color: var(--color-info-hover); }
.border-info { border-color: var(--color-info); }
.hover\:border-info:hover { border-color: var(--color-info); }

.text-success { color: var(--color-success); }
.text-error   { color: var(--color-error); }
.text-warn    { color: var(--color-warn); }

/* — Balance display gradients (tickets.html) — */
.balance-positive { background: var(--color-success-bg); border-color: var(--color-success); border-left-width: 3px; }
.balance-negative { background: var(--color-error-bg); border-color: var(--color-error); border-left-width: 3px; }
.balance-neutral  { background: var(--color-info-bg); border-color: var(--color-info); border-left-width: 3px; }

/* ═══════════════════════════════════════════════════
   Alert System
   ═══════════════════════════════════════════════════ */
.alert {
    padding: var(--space-3) var(--space-4);
    border-radius: var(--radius-md);
    border: 1px solid;
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
}
.alert-success {
    background-color: var(--color-success-bg);
    border-color: var(--color-success);
    color: var(--color-success-hover);
}
.alert-error {
    background-color: var(--color-error-bg);
    border-color: var(--color-error);
    color: var(--color-error-hover);
}
.alert-info {
    background-color: var(--color-info-bg);
    border-color: var(--color-info);
    color: var(--color-info-hover);
}
.alert-warning {
    background-color: var(--color-warn-bg);
    border-color: var(--color-warn);
    color: var(--color-warn-hover);
}
:where([data-theme="dark"]) .alert-success { color: var(--color-success); }
:where([data-theme="dark"]) .alert-error { color: var(--color-error); }
:where([data-theme="dark"]) .alert-info { color: var(--color-info); }
:where([data-theme="dark"]) .alert-warning { color: var(--color-warn); }

/* ═══════════════════════════════════════════════════
   Badge System
   ═══════════════════════════════════════════════════ */
.badge {
    display: inline-flex;
    align-items: center;
    padding: var(--space-1) var(--space-2);
    border-radius: var(--radius-full);
    font-size: var(--text-xs);
    font-weight: var(--font-medium);
    line-height: 1;
    white-space: nowrap;
}
.badge-success {
    background-color: var(--color-success-bg);
    color: var(--color-success-hover);
}
.badge-error {
    background-color: var(--color-error-bg);
    color: var(--color-error-hover);
}
.badge-info {
    background-color: var(--color-info-bg);
    color: var(--color-info-hover);
}
.badge-warning {
    background-color: var(--color-warn-bg);
    color: var(--color-warn-hover);
}
.badge-neutral {
    background-color: var(--color-bg-elevated);
    color: var(--color-text-secondary);
}
/* Dark badges sit on --color-bg-elevated (slate-700 #334155, see the rule right
   below), where the -400 state tokens are too dim as ink: #60a5fa scores 4.07:1.
   The status-text tokens are the AA-verified weights for that surface. */
:where([data-theme="dark"]) .badge-success { color: var(--color-status-success-text); }
:where([data-theme="dark"]) .badge-error { color: var(--color-status-error-text); }
:where([data-theme="dark"]) .badge-info { color: var(--color-status-info-text); }
:where([data-theme="dark"]) .badge-warning { color: var(--color-status-warn-text); }
/* ── Badges & pills: background-color from Tailwind classes ── */
:where([data-theme="dark"]) .badge,
:where(html[data-theme="dark"]) .bg-blue-100,
:where(html[data-theme="dark"]) .bg-green-100,
:where(html[data-theme="dark"]) .bg-amber-100,
:where(html[data-theme="dark"]) .bg-orange-100,
:where(html[data-theme="dark"]) .bg-red-100 {
    background-color: var(--color-bg-elevated);
}

/* ═══════════════════════════════════════════════════
   Table Utilities
   ═══════════════════════════════════════════════════ */
.table-header {
    background-color: var(--color-bg-page);
    color: var(--color-text-muted);
    font-family: var(--font-heading);
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    text-transform: uppercase;
    letter-spacing: var(--tracking-wider);
    padding: var(--space-2) var(--space-2);
    line-height: var(--leading-snug);
    vertical-align: middle;
}
/* Fixed-width column (e.g. status columns in refund tables) */
.col-fixed-120 { width: 120px; min-width: 120px; max-width: 120px; }

/* ═══════════════════════════════════════════════════
   Button Outline Variants
   ═══════════════════════════════════════════════════ */
.btn-danger-outline {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    padding: var(--space-3) var(--space-4);
    min-height: var(--touch-target-min);
    font-size: var(--text-sm);
    font-weight: var(--font-semibold);
    font-family: var(--font-heading);
    line-height: var(--leading-snug);
    letter-spacing: var(--tracking-wide);
    cursor: pointer;
    border-radius: var(--radius-sm);
    text-decoration: none;
    color: var(--color-error);
    background: var(--color-bg-surface);
    border: 1px solid var(--color-error);
    transition: background-color var(--transition-fast), color var(--transition-fast);
}
.btn-danger-outline:hover {
    background: var(--color-error-bg);
    color: var(--color-error-hover);
}
.btn-danger-outline:focus-visible {
    background: var(--color-error-bg);
    color: var(--color-error-hover);
    outline: 2px solid var(--color-error-light);
    outline-offset: 2px;
}

/* ═══════════════════════════════════════════════════
   Card System
   ═══════════════════════════════════════════════════ */
.card {
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
}
/* ═══════════════════════════════════════════════════
   Input System — .input-base removed (0 usages in templates/JS)
   Inputs use .form-input/.form-control/.form-select instead.
   ═══════════════════════════════════════════════════ */

/* ═══════════════════════════════════════════════════
   Button System
   ═══════════════════════════════════════════════════ */

/* Shared base for all button variants */
:is(.btn-primary, .btn-danger, .btn-success, .btn-neutral, .btn-secondary, .btn-ghost) {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    padding: var(--space-3) var(--space-4);
    min-height: var(--touch-target-min); /* WCAG 2.1 SC 2.5.8 Target Size */
    font-size: var(--text-sm);
    font-weight: var(--font-semibold);
    font-family: var(--font-heading);
    line-height: var(--leading-snug);
    letter-spacing: var(--tracking-wide);
    cursor: pointer;
    border-radius: var(--radius-md);
    text-decoration: none;
    transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
}

/* Filled variants share inverse text + no border */
:is(.btn-primary, .btn-danger, .btn-success) {
    border: 0;
    color: var(--color-text-inverse);
}
.btn-primary {
    background-color: var(--brand-surface-strong);
}
.btn-primary:hover,
.btn-primary:focus-visible {
    background-color: var(--brand-surface-strong-hover);
}
.btn-primary[disabled],
.btn-primary[aria-disabled="true"] {
    background-color: var(--color-primary-disabled);
    cursor: not-allowed;
    box-shadow: none;
}
.btn-primary[disabled]:hover,
.btn-primary[aria-disabled="true"]:hover {
    background-color: var(--color-primary-disabled);
}

.btn-neutral {
    color: var(--color-text-primary);
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.btn-neutral:hover,
.btn-neutral:focus-visible {
    background: var(--color-bg-elevated);
    border-color: var(--color-border-strong);
}

/* Secondary — bordered surface, container-aligned, premium. For bulk-select
   actions (Seleccionar todos / filtrados). Border + radius match the list
   container tokens so the toolbar reads as one system. Token-only + theme-agnostic
   (passes check_design_tokens.py). Visually subordinate to the primary submit
   (restraint = premium), yet unambiguously clickable — unlike the borderless ghost. */
.btn-secondary {
    color: var(--color-text-primary);
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-strong);
    box-shadow: var(--shadow-xs);                 /* subtle premium lift */
    transition: background-color var(--transition-fast),
                border-color var(--transition-fast),
                box-shadow var(--transition-fast);
}
.btn-secondary:hover,
.btn-secondary:focus-visible {
    background: var(--color-bg-elevated);
    border-color: var(--brand-primary);           /* brand accent on intent */
    box-shadow: var(--shadow-sm);
}
.btn-secondary:active:not([disabled], [aria-disabled="true"]) {
    border-color: var(--brand-primary-active);
    box-shadow: none;
}
.btn-secondary[disabled],
.btn-secondary[aria-disabled="true"] {
    background: var(--color-bg-disabled);
    border-color: var(--color-border-default);
    color: var(--color-text-disabled);
    cursor: not-allowed;
    box-shadow: none;
}

.btn-danger {
    background-color: var(--color-error);
}
.btn-danger:hover,
.btn-danger:focus-visible {
    background-color: var(--color-error-hover);
}

.btn-icon-danger {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 0;
    border-radius: var(--radius-full);
    background-color: var(--color-error);
    color: var(--color-text-inverse);
    cursor: pointer;
    transition: background-color var(--transition-fast);
}
.btn-icon-danger:hover,
.btn-icon-danger:focus-visible {
    background-color: var(--color-error-hover);
}

.btn-success {
    background-color: var(--color-success-hover);
}
.btn-success:hover,
.btn-success:focus-visible {
    background-color: var(--color-success-active);
}

/* Ghost button — for theme toggle (tarea 034) and tertiary actions */
.btn-ghost {
    border: none;
    background: transparent;
    color: var(--color-text-secondary);
    padding: var(--space-2) var(--space-3);
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    transition: color var(--transition-fast), background-color var(--transition-fast);
}
.btn-ghost:hover,
.btn-ghost:focus-visible {
    color: var(--color-text-primary);
    background: var(--color-bg-elevated);
}

/* Size modifiers */
.btn-sm {
    padding: var(--space-1) var(--space-3);
    min-height: var(--touch-target-min); /* WCAG 2.5.5 — T494 Sprint 05 */
    font-size: var(--text-sm);
}
.btn-lg {
    padding: var(--space-4) var(--space-6);
    min-height: var(--size-btn-lg);
    font-size: var(--text-base);
}
/* ═══════════════════════════════════════════════════
   Refund Period Filters (moved from period_list.html \3c style>)
   ═══════════════════════════════════════════════════ */
.filter-card {
    flex: 1;
    min-width: 150px;
    padding: var(--space-4);
    border: 2px solid var(--color-border-default);
    border-radius: var(--radius-md);
    background: var(--color-bg-surface);
    cursor: pointer;
    transition: border-color var(--transition-base), box-shadow var(--transition-base), background-color var(--transition-base), transform var(--transition-fast);
    text-align: left;
}
.filter-card:hover {
    border-color: var(--brand-primary);
    box-shadow: var(--shadow-lg);
    transform: translateY(-2px);
}
.filter-card.active {
    border-color: var(--brand-primary);
    background: var(--color-brand-tint-12);
    box-shadow: 0 2px 8px var(--brand-primary-shadow);
}
.period-table { display: block; }
.period-table.hidden { display: none; }

/* Generic container utility (optional) */
.container {
    width: 100%;
    max-width: var(--container-max);
    margin-inline: auto;
    padding-inline: var(--container-gutter);
}
/* Inside #main, the gutter is already provided by #main itself */
#main:not(.co-main) > .container {
    padding-inline: 0;
}
/* CargaOnline: main has no padding, so glass panel keeps its own gutter */
#main.co-main > .container {
    padding-inline: var(--container-gutter);
}

/* Vertical rhythm for stacked sections (prevents margin stacking) */
.stack > * + * {
    margin-top: clamp(12px, 2.5vw, 28px);
}

/* Neutral semantic helper for panels using stack spacing */
.section-block {
    /* No reset for vertical margins to allow utilities like my-2 */
    margin-left: 0;
    margin-right: 0;
}


@media (min-width: 768px) {
    /* NOTE: .md\:grid-cols-2 was duplicated here and overrode Tailwind's own
       .xl\:grid-cols-3 because main.css loads after tailwind.output.css
       (cargaOnline/templates/header.html: tailwind, then main). Tailwind
       already generates an identical rule in tailwind.output.css, so the
       override is removed to let xl:* breakpoints win at ≥1280px. */

    .md\:items-start {
        align-items: flex-start;
    }

    .md\:gap-6 {
        gap: var(--space-6);
    }
}

/* Primary content container depth and gutters */
#main {
    width: 100%;
    max-width: var(--container-max);
    margin-inline: auto;
    margin-top: clamp(12px, 2vw, 20px);
    padding-inline: var(--container-gutter);
    padding-top: clamp(20px, 3vw, 32px);
    padding-bottom: clamp(12px, 3vw, 24px);
    margin-bottom: var(--space-8);
    background: var(--color-bg-surface);
    border-radius: var(--container-radius);
    box-shadow: var(--container-shadow);
    animation: fadeInSimple var(--dur-base) ease-out;
}

/* #2796 — carril ANCHO automatico, sin tocar ni una plantilla.
   `.table-scroll-wrapper` lo emite el include compartido
   zelebrix/zelebrix/templates/mainTHeader.html:14 (7 tablas del admin) y
   terminales.html:14 (la 8ª), asi que una tabla nueva hereda el carril sola.
   OJO: es `:has()` de DESCENDIENTE, no de hijo directo — en dispositivos.html,
   events.html, grupos.html, tickets-panel.html, transacciones.html,
   transactionPoints.html y users.html el wrapper cuelga de
   `#main > form#filter-form`, nunca de #main. Con `:has(> …)` esta regla no
   casaria con NINGUNA de las 8 tablas.
   `.is-wide` es el escape manual (charts del dashboard).
   OJO 2: el carril ancho da MAS sitio a la tabla, pero NO garantiza que quepa.
   `.table-scroll-wrapper` usa `overflow-x: clip`, y por eso el propio wrapper
   deja `scrollWidth == clientWidth` — pero eso NO significa que el contenido
   quede recortado: el desbordamiento lo absorbe el div INTERIOR
   `.overflow-x-auto.min-w-full` (mainTHeader.html:15, terminales.html:15,
   cerrado en mainTFooter.html:12), que SI es contenedor de scroll. Medido en
   Chromium (headless-shell y chromium completo, 1440px, tabla 1737px en un
   scroller de 1261px): `scrollLeft` llega a 476 con rueda real, con teclado y
   con `scrollIntoView`, y la ultima celda queda dentro del scroller. O sea que
   medir `scrollWidth > clientWidth` SOBRE EL WRAPPER da un falso negativo por
   construccion; hay que medirlo sobre el div interior. Cerrado #2810 con esa
   evidencia: no se toca el `overflow-x` del wrapper. */
#main:has(.table-scroll-wrapper),
#main.is-wide {
    max-width: var(--container-max-wide);
}

/* #2802 — tope de MEDIDA de la prosa del carril de texto.
   Solo el carril de TEXTO: se excluyen el carril ancho (`.is-wide` del
   dashboard y todo `#main` con tabla) y cargaOnline (`.co-main`), que tienen
   su propio sistema de anchos.

   POR QUE `.is-wide` (el dashboard) SIGUE FUERA, y como se cumple alli el
   limite de 75 cpl — la pregunta la abrio el veredicto NEEDS_WORK de #2878,
   que midio 82 cpl en el dashboard con esta regla ya puesta.
   El carril ancho NO es una columna de texto: es una rejilla de KPI cards,
   charts y leyendas. Un `max-width` de BLOQUE es ahi el instrumento
   equivocado, y se midio en Chromium (Lato cargado, light+dark, 1440/1920/
   2560/3440) metiendo `.is-wide` dentro de la regla:
     · `<li>` de leyenda de chart con etiqueta larga: 20 px -> 40 px de alto
       (envuelve a 2 lineas) y su caja se desplaza 9 px — la etiqueta de una
       leyenda es DATO (nombre de evento / punto de transaccion), no prosa, y
       recortarla a la medida de lectura cambia el layout del chart.
     · la KPI card de modo simple cambia de alto (248 px -> 268 px).
   Y no hace falta: la prosa REAL del dashboard es UNA
   (`.access-card__info`, el unico parrafo >75 cpl que habia alli, medido en
   82 cpl); el resto de su `p`/`li` ya trae su propio `max-w-*` o es un
   numero, y ninguno pasa de 63 cpl. Por eso el tope del dashboard se pone en
   ESE componente con el MISMO token (`--prose-max` en `.access-card__info`,
   mas abajo en este mismo fichero), y el limite se
   AFIRMA donde vive, midiendo cpl reales en el navegador:
   `e2e/tests/system/prose-measure.spec.ts` corre sobre /system/ **y** sobre
   el dashboard. Un `<p>` nuevo que se pase de 75 cpl en el dashboard tumba
   ese spec `@smoke`, que es lo que el selector no puede garantizar.
   (Las KPI cards no se desplazaban: `.kpi-card` es `flex column` con
   `align-items: center`, asi que su cuerpo es shrink-to-fit y el tope nunca
   le muerde — medido, cero cambio de rect en `probe-kpi-1/2/3`.)

   El `:not(...)` de `text-center` protege el unico patron que un `max-width`
   SI rompe: con `text-align: center` la caja se estrecha pero se queda anclada
   a la izquierda, asi que el texto deja de verse centrado. Lleva DOS argumentos
   —el propio elemento y `[class*="text-center"] *`— porque el centrado suele
   venir de un ANCESTRO y CSS no puede consultar el `text-align` heredado en un
   selector. Medido sin el argumento descendiente: el `<p>` de "Nombre: …" del
   bloque del QR (apk.html, dentro de `.bg-page…text-center`) se desplazaba
   391 px a la izquierda a 1920 px.
   Van en un solo `:not()` con lista, no encadenados: `selector-not-notation`
   de stylelint esta en modo `complex` y rechaza `:not(a):not(b)`.
   `ch` mantiene el tope atado a la fuente (no es un px hardcodeado).
   `label > span` entra porque en apk.html la prosa mas larga del panel NO vive
   en un `<p>`: son los `<span>` descriptivos de los radios de "Modo de
   importacion" y de los checkboxes del reset (medidos en 89 cpl, el peor del
   panel, y por tanto el que mas se salta el limite). */
/* `.prose-full` es la UNICA salida del tope, y es deliberadamente fea de usar:
   hay que escribirla en el elemento, se ve en el diff y se encuentra con un grep.
   No existe para "cuando el texto no cabe" — existe para cuando el owner decide
   que ESE bloque manda sobre la medida de lectura. Cada uso es una decision suya,
   no una comodidad. El spec `e2e/tests/system/prose-measure.spec.ts` honra la
   misma clase y AFIRMA cuantos opt-outs hay, asi que anadir uno nuevo pone el
   spec rojo hasta que alguien actualice esa cuenta a proposito. */
#main:not(.is-wide, .co-main, :has(.table-scroll-wrapper))
  :is(p, li, dd, label > span):not([class*="text-center"], [class*="text-center"] *, .prose-full) {
    max-width: var(--prose-max);
}

/* CargaOnline: reset admin card styling — glass panel handles the visual container */
#main.co-main {
    margin: 0;
    padding: 0;
    background: transparent;
    border-radius: 0;
    box-shadow: none;
    max-width: none;
    width: 100%;
    overflow: clip; /* clip prevents horizontal overflow without creating a scroll container (unlike hidden), so position:sticky works correctly */
}

/* ── System panel cards (apk.html, /system/) ── */
.system-card {
    background: var(--color-bg-surface);
    box-shadow: var(--shadow-md);
    border-radius: var(--radius-lg);
    padding: var(--space-6);
    border: 1px solid var(--color-border-default);
}

.system-section-title {
    font-size: var(--text-xl);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
    margin-bottom: var(--space-3);
}

/* Variante sin margin-bottom para h2 seguido de p.subtitle */
.system-section-title-tight {
    font-size: var(--text-xl);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

/* ── Dashboard responsive helpers ── */
.dashboard-section-title {
    font-size: var(--text-xl);
    font-weight: var(--font-bold);
    letter-spacing: -0.02em;
    position: relative;
    padding-bottom: var(--space-2);
}
.dashboard-section-title::after {
    content: "";
    position: absolute;
    inset-block-end: 0;
    inset-inline-start: 0;
    block-size: 3px;
    inline-size: var(--space-12, 3rem);
    background: linear-gradient(90deg, var(--brand-primary), transparent);
    border-radius: var(--radius-full, 999px);
}
@media (min-width: 640px) {
    .dashboard-section-title {
        font-size: var(--text-2xl);
    }
}

/* ── Dashboard live-refresh status pill (P9 / Fase 3) ──
   Elegant "En vivo" indicator, driven by dashboard-live-poll.js via
   [data-state]. Rendered server-side only when DASHBOARD_LIVE_REFRESH_ENABLED.
   Uses only existing design tokens; dark theme resolves automatically via the
   --color-success* overrides. SINGLE base definition — pinned by
   scripts/tests/test_dashboard_dead_css_eliminated.py
   (test_dashboard_live_badge_single_definition_and_wired). This RE-ADDS the
   badge the #1895-phase-C note removed "SIN badge ... POR DISENO"; the same
   design authority (P9 / Fase 3, 2026-07) reverses that decision. */
.dashboard-live-badge {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    background: var(--color-success-bg);
    color: var(--color-success-hover);
    border: 1px solid transparent;
    border-radius: var(--radius-full);
    font-size: var(--text-2xs);
    font-weight: 600;
    line-height: 1;
    padding: 2px 8px;
    letter-spacing: 0.02em;
    white-space: nowrap;
    vertical-align: middle;
}
.dashboard-live-badge[hidden] {
    display: none;
}
.dashboard-live-badge__dot {
    inline-size: 6px;
    block-size: 6px;
    border-radius: var(--radius-full);
    background: var(--color-success);
    flex: none;
}
/* paused (tab hidden / transient fetch errors): calm, muted, never red. */
.dashboard-live-badge[data-state="paused"] {
    color: var(--color-text-muted);
}
.dashboard-live-badge[data-state="paused"] .dashboard-live-badge__dot {
    background: var(--color-text-muted);
}
@media (prefers-reduced-motion: no-preference) {
    .dashboard-live-badge[data-state="live"] .dashboard-live-badge__dot {
        animation: dashboard-live-pulse 2.5s ease-in-out infinite;
    }
}
@keyframes dashboard-live-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
}

/* ── Dashboard section cards ── */
.dashboard-card {
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    padding: var(--space-2);
    min-width: 0;
}
@media (min-width: 640px) {
    .dashboard-card { padding: var(--space-4); }
}

.dashboard-card__title {
    font-size: var(--text-h2-size);
    font-weight: var(--text-h2-weight);
    line-height: var(--text-h2-leading);
    letter-spacing: var(--text-h2-tracking);
    color: var(--color-text-secondary);
    padding-bottom: var(--space-2);
    margin-bottom: var(--space-3);
    border-bottom: 1px solid var(--color-border-default);
}

/* ── KPI Hero card (Card 1: Resumen) ── */
.kpi-hero {
    grid-column: 1 / -1;
    position: relative;
    overflow: hidden;
    padding: var(--space-6) var(--space-4);
    background:
        radial-gradient(1200px 400px at 10% -20%, var(--brand-primary-shadow) 0%, transparent 60%),
        radial-gradient(900px 300px at 110% 120%, var(--brand-primary-shadow-strong) 0%, transparent 55%),
        linear-gradient(135deg, var(--color-brand-tint-10), var(--color-brand-tint-25));
    border-radius: var(--radius-xl);
    border: 1px solid var(--color-border-subtle);
    box-shadow:
        0 1px 0 var(--color-white-50) inset,
        var(--shadow-brand-lg);
    display: flex;
    flex-direction: column;
    gap: var(--space-6);
    animation: kpi-hero-fade-in var(--transition-slow) ease-out;
}
:where([data-theme="dark"]) .kpi-hero {
    box-shadow:
        0 1px 0 rgb(255 255 255 / 0.04) inset,
        0 12px 32px rgb(0 0 0 / 0.35);
    border-color: var(--color-border-default);
}
.kpi-hero::before {
    content: "";
    position: absolute;
    inset: 0;
    background-image: linear-gradient(var(--color-border-subtle) 1px, transparent 1px),
                      linear-gradient(90deg, var(--color-border-subtle) 1px, transparent 1px);
    background-size: 48px 48px;
    opacity: 0.25;
    pointer-events: none;
    mask-image: radial-gradient(ellipse at 50% 0%, black 0%, transparent 70%);
}
.kpi-hero > * { position: relative; z-index: calc(var(--z-base) + 1); }

@media (min-width: 768px) {
    .kpi-hero {
        padding: var(--space-8) var(--space-10);
    }
}

.kpi-hero__breakdown-col {
    min-width: 0;
}

.kpi-hero__label {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    font-family: var(--font-body);
    font-size: var(--text-xs);
    font-weight: var(--font-medium);
    letter-spacing: var(--tracking-widest, 0.1em);
    text-transform: uppercase;
    color: var(--color-text-secondary);
    margin-bottom: var(--space-1);
}

.kpi-hero__value {
    font-family: var(--font-heading);
    font-size: clamp(2.5rem, 6vw, 4.5rem);
    font-weight: var(--font-bold);
    line-height: var(--leading-tight);
    letter-spacing: -0.035em;
    font-variant-numeric: tabular-nums slashed-zero;
    color: var(--color-text-primary);
    margin: 0;
    background: linear-gradient(180deg, var(--color-text-primary) 0%,
                                         color-mix(in srgb, var(--color-text-primary) 78%, var(--brand-primary-dark)) 100%);
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
}
:where([data-theme="dark"]) .kpi-hero__value {
    background: linear-gradient(180deg, var(--color-text-primary) 0%,
                                         color-mix(in srgb, var(--color-text-primary) 70%, var(--brand-primary)) 100%);
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
}

.kpi-hero__currency {
    font-size: 0.5em;
    font-weight: var(--font-semibold);
    color: var(--color-text-secondary);
    vertical-align: super;
    margin-left: var(--space-1);
}

.kpi-hero__detail {
    margin-top: var(--space-2);
}

.kpi-hero__trend {
    margin-top: var(--space-1);
}

/* T722 B722-3: hero sparkline + variation chip have a smooth fade-in
 * transition when the SVG is re-rendered after an AJAX filter change.
 * Decision del reporter (T722): fade-in suave de 200ms. */
[data-spark],
[data-variation] {
    transition: opacity 200ms ease-out;
}


.kpi-hero__breakdown {
    flex: 1;
    padding: var(--space-5);
    background: var(--color-bg-surface);
    border-radius: var(--radius-lg);
    border: 1px solid var(--color-border-subtle);
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    box-shadow: var(--shadow-sm);
    transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.kpi-hero__breakdown:hover {
    box-shadow: var(--shadow-md);
}

.kpi-hero__breakdown-title {
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    letter-spacing: var(--tracking-wider);
    text-transform: uppercase;
    color: var(--color-text-muted);
    margin: 0;
}

.kpi-hero__breakdown-table {
    width: auto;
    font-size: var(--text-sm);
}
:where(.kpi-hero__breakdown-table) td:last-child {
    padding-left: var(--space-3);
    white-space: nowrap;
}

/* Clickable breakdown rows (center column → opens detail slot) */
.kpi-hero__breakdown-row--clickable {
    cursor: pointer;
    transition: background var(--transition-fast);
}
.kpi-hero__breakdown-row--clickable:hover td {
    background: var(--color-brand-tint-10);
}
.kpi-hero__breakdown-row--active {
    background-color: color-mix(in srgb, var(--color-success) 12%, transparent);
}
.kpi-hero__breakdown-chevron {
    font-size: var(--text-xs);
    color: var(--color-text-secondary);
    font-weight: var(--font-semibold);
    margin-left: var(--space-1);
    transition: transform 0.15s ease;
}
.kpi-hero__breakdown-row--active .kpi-hero__breakdown-chevron {
    transform: rotate(90deg);
}

/* Detail slot transition */
#hero-detail-slot {
    transition: opacity 0.2s ease;
}
/* #2847 — `fadeIn` NO existe como @keyframes en esta hoja (sólo fadeInUp /
   fadeInSimple / …): el fundido llevaba desde siempre sin ejecutarse. Se apunta
   a `fadeInSimple`, que es opacidad pura y no crea containing block (no rompe
   los tooltips position:fixed), y se tokeniza la duración. */
#hero-detail-slot:not([hidden]) {
    animation: fadeInSimple var(--dur-base) ease;
}

/* Empty state for detail slot */
.kpi-hero__breakdown-empty {
    color: var(--color-text-muted);
    font-size: var(--text-sm);
    padding: var(--space-2) 0;
    font-style: italic;
}

.kpi-hero__breakdown-label {
    color: var(--color-text-secondary);
    padding: var(--space-1) var(--space-2);
}

.kpi-hero__breakdown-value {
    text-align: right;
    font-variant-numeric: tabular-nums;
    font-weight: var(--font-medium);
    color: var(--color-text-secondary);
    padding: var(--space-1) var(--space-2);
}
.kpi-hero__breakdown-value--positive { color: var(--color-success); }
.kpi-hero__breakdown-value--negative { color: var(--semantic-refund); }

.kpi-hero__breakdown-total {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    padding-top: var(--space-3);
    margin-top: auto;
    border-top: 2px solid var(--color-border-strong);
    font-size: var(--text-sm);
    font-weight: var(--font-bold);
    color: var(--color-text-primary);
}

.kpi-hero__breakdown-total-value {
    font-size: var(--text-base);
    font-variant-numeric: tabular-nums;
    color: var(--semantic-revenue);
}

/* Puente bruto → neto del desglose «Remanente»: dos líneas de total encadenadas
   bajo el pie (− cortesías, = neto). Filete fino y sin `margin-top: auto` para
   que se lean como continuación del total y no como tres totales sueltos. */
.kpi-hero__breakdown-total--net {
    margin-top: 0;
    padding-top: var(--space-2);
    border-top-width: 1px;
    font-weight: var(--font-semibold);
}

/* OD-M2-5 — paso BRUTO → NETO del desglose del hero «Total recargado».
   La fila de subtotal es la cifra a la que llega la tabla (== la cifra grande
   de la card); el filete la separa de los sumandos y de la resta que baja al
   neto. Se pinta con clases SOBRE las celdas, no con `... td` descendiente:
   este bloque va DESPUÉS de `.kpi-hero__breakdown-row--clickable:hover td`
   (0,4,0) y un selector `td` aquí sería un no-descending-specificity nuevo. */
.kpi-hero__breakdown-label--subtotal,
.kpi-hero__breakdown-value--subtotal {
    border-top: 1px solid var(--color-border-strong);
    padding-top: var(--space-2);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

/* Fila INFORMATIVA bajo el pie: el ticket pagado con saldo Zelebrix. Vive
   FUERA de la tabla aditiva y con peso visual menor, porque su importe se
   PINTA y no se SUMA (sus euros ya entraron por la recarga que lo pagó). */
.kpi-hero__breakdown-note {
    margin: var(--space-2) 0 0;
    /* #2802 — es PROSA, y vive en el carril ancho (`#main.is-wide`), que queda
       FUERA de la regla generica de ~2385 a proposito. Sin tope propio se
       estiraba a la columna entera y medi­a 93 cpl a 1920px (caja 495px) y 109
       a 2560/3440 (cajas 692/962px) — MEDIDO por el propio gate, no supuesto:
       `e2e/tests/system/prose-measure.spec.ts` en rojo con MAX_CPL=75, los 3
       reintentos. Lleva el MISMO token que `.access-card__info`, la otra prosa
       del dashboard, en vez de un `ch` a mano: `--prose-max` esta calibrado
       contra la horquilla ENTERA de fuentes (51/60/66 cpl), y un valor propio
       repetiria el fallo del `55ch` que ya se colo una vez. `max-inline-size`
       (no `max-width`) porque el tope es de MEDIDA de lectura, atada al eje
       en linea del texto. */
    max-inline-size: var(--prose-max);
    font-size: var(--text-xs);
    line-height: var(--leading-snug);
    color: var(--color-text-muted);
}
.kpi-hero__breakdown-note-label {
    font-weight: var(--font-semibold);
}

/* Cortesías card — start label & total label */
.kpi-hero__breakdown-label--start {
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}
.kpi-hero__breakdown-total-label {
    font-size: var(--text-sm);
    color: var(--color-text-primary);
}

/* Secondary KPIs with staggered slide-up — mobile-first */
.kpi-hero__secondary-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-4);
}
.kpi-hero__secondary-grid--centered {
    grid-template-columns: 1fr;
}

/* T517: 480/481px pair intentional for kpi-hero typography breakpoint.
   Companion breakpoint: max-width: 480px (line ~2134) — keep both. */
@media (min-width: 481px) {
    .kpi-hero__secondary-grid {
        grid-template-columns: repeat(2, 1fr);
    }
    .kpi-hero__secondary-grid--centered {
        grid-template-columns: repeat(2, 1fr);
    }
}

@media (min-width: 768px) {
    .kpi-hero__secondary-grid {
        grid-template-columns: repeat(4, 1fr);
    }
    .kpi-hero__secondary-grid--centered {
        grid-template-columns: repeat(3, 1fr);
    }
}

@keyframes kpi-hero-fade-in {
    from { opacity: 0; transform: translateY(12px); }
    to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
    .kpi-hero { animation: none; }
}

/* Out-of-scope responsive rules for hero inner elements (font sizes).
   Kept as max-width because they target typography, not the 3 layout
   rules being inverted in T149 Fase E.1. */
@media (max-width: 768px) {
    .kpi-hero__value {
        font-size: clamp(1.75rem, 8vw, 2.5rem);
    }
    .kpi-hero__secondary-grid .kpi-card__value {
        font-size: var(--text-xl);
    }
}

@media (max-width: 480px) {
    .kpi-hero__secondary-grid .kpi-card__value {
        font-size: var(--text-lg);
    }
}

.kpi-card [data-kpi-value],
.kpi-card .kpi-value,
.kpi-hero [data-kpi-value] {
    font-variant-numeric: tabular-nums slashed-zero;
}

#dashboard-charts-container { counter-reset: dash-section; }
#dashboard-charts-container > section[data-card-title],
#dashboard-charts-container > .kpi-card {
    counter-increment: dash-section;
}

/* ── KPI row: grid wrap ── */
.kpi-row-wrap {
    position: relative;
    min-width: 0;
    margin-bottom: var(--space-5);
    padding-bottom: var(--space-1);
    border-bottom: 1px solid var(--color-border-default);
}
.kpi-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: var(--space-3);
    padding: var(--space-1) 0 var(--space-2);
}
/* stylelint-disable-next-line no-descending-specificity -- [cross-component false-positive] ':where(.kpi-row) .kpi-card' (0,2,0) vs earlier '#dashboard-charts-container > .kpi-card' (1,1,0, line 2337): the id-scoped chart-container card and the kpi-row card never co-match the same element; no real cascade conflict, and relocating it next to the id rule would scatter the kpi-card layout block. */
:where(.kpi-row) .kpi-card {
    min-width: 0;
    height: auto;
    align-self: stretch;
    box-sizing: border-box;
}
.kpi-row .kpi-card [data-kpi-label] {
    min-height: 2em;
}
/* Prevent KPI numbers from wrapping onto multiple lines */
/* stylelint-disable-next-line no-descending-specificity -- [cross-component false-positive] '.kpi-value' (0,1,0) vs earlier '.kpi-card .kpi-value' (0,2,0, line 2330): the compound variant is strictly more specific and already wins; this base .kpi-value only sets text-overflow and belongs with its sibling .kpi-card rules. */
.kpi-value {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* stylelint-disable-next-line no-descending-specificity -- [cross-component false-positive] '.kpi-card' (0,1,0) vs earlier '#dashboard-charts-container > .kpi-card' (1,1,0, line 2337): the id-scoped override is intentionally higher and ships first; the base .kpi-card definition must stay here with the rest of the card layout. */
.kpi-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-2);
  position: relative;
  padding: var(--space-2);
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-xs);
  transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.kpi-card::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline: var(--space-4);
  block-size: 2px;
  background: linear-gradient(90deg, transparent, var(--brand-primary), transparent);
  opacity: 0;
  transition: opacity var(--transition-fast);
}
/* stylelint-disable-next-line no-descending-specificity -- [real override] ':where(html[data-theme=dark]) .kpi-card' (0,1,0, :where=0) vs earlier '#dashboard-charts-container > .kpi-card' (1,1,0, line 2337): the dark theming is a deliberate override layered on the base; it cannot precede the id-scoped rule without reparenting the chart-container block. */
:where(html[data-theme="dark"]) .kpi-card {
  border-color: var(--color-border-strong);
  box-shadow: 0 2px 8px rgba(var(--shadow-color), 0.3);
}
@media (min-width: 640px) {
  .kpi-card { padding: var(--space-3); }
}
.kpi-card__body {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 0;
  width: 100%;
}
.kpi-card__label {
  font-size: var(--text-xs);
  font-weight: var(--font-medium);
  color: var(--color-text-secondary);
  line-height: var(--leading-snug);
}
.kpi-card__value {
  font-size: var(--text-lg);
  /* #2801 — sube a bold: contra el label (12px/500) la separación era 1,5x de
     tamaño y sólo 600 vs 500 de peso. El tamaño se queda en --text-lg a
     propósito: .kpi-value hace nowrap+ellipsis en cards de 160px mínimos y
     subirlo cortaría importes. La jerarquía la abre el peso. */
  font-weight: var(--font-bold);
  color: var(--color-text-primary);
  margin-top: var(--space-1);
  line-height: var(--leading-snug);
}
/* Hero bottom KPI cards: bigger values for visual prominence */
.kpi-hero__secondary-grid .kpi-card__value {
  font-size: var(--text-2xl);
}
/* T191 P191-D: inline detail row — replaces the old click-to-reveal
   toggle. Both the primary value (Bruto) and the detail value (Neto /
   Cobros / Importe …) are visible at the same time; no interaction
   required. Hierarchy (#2801): label 2xs medium muted / value xs semibold
   SECONDARY — el detalle dejó de pintarse en --color-text-primary, que lo
   igualaba al valor principal de la card y anulaba la jerarquía. */
.kpi-card__detail {
  display: flex;
  align-items: baseline;
  gap: var(--space-1-5);
  margin-top: var(--space-1);
  font-size: var(--text-xs);
  line-height: var(--leading-snug);
  color: var(--color-text-secondary);
  font-variant-numeric: tabular-nums;
}
.kpi-card__detail:empty { display: none; }
.kpi-card__detail-label {
  font-size: var(--text-2xs);
  font-weight: var(--font-medium);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.kpi-card__detail-value {
  /* Sin `color`: hereda --color-text-secondary de .kpi-card__detail (7,56:1
     light · 9,85:1 dark sobre --color-bg-surface). El peso 600 lo separa de su
     propio label (500) sin robarle protagonismo al valor principal (700). */
  font-weight: var(--font-semibold);
}
.kpi-card__variation {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  margin-top: var(--space-1);
  line-height: var(--leading-snug);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}
.kpi-card__variation:empty { display: none; }
.kpi-card__trend {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--text-xs);
  font-weight: var(--font-medium);
  margin-top: var(--space-1);
  padding: 2px var(--space-1-5);
  border-radius: var(--radius-full);
  line-height: 1;
  white-space: nowrap;
}
.kpi-card__trend:empty { display: none; }
.kpi-card__trend--up   { color: var(--color-success); background: var(--color-success-bg); }
.kpi-card__trend--down { color: var(--color-error);   background: var(--color-error-bg); }
.kpi-card__trend--neutral { color: var(--color-text-muted); background: var(--color-bg-elevated); }
.kpi-card__trend--new  { color: var(--color-info);    background: var(--color-info-bg); }
.kpi-info-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--touch-target-min);  /* WCAG 2.5.5 touch target */
  height: var(--touch-target-min);
  font-size: var(--text-sm);
  font-weight: var(--font-semibold);
  line-height: 1;
  border-radius: var(--radius-full);
  border: 1px solid var(--color-border-strong);
  background: var(--color-bg-surface);
  color: var(--brand-primary-dark);
  cursor: pointer;
  transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.kpi-info-button--abs {
  position: absolute;
  top: var(--space-1);
  right: var(--space-1);
  width: 1rem;
  height: 1rem;
  font-size: var(--text-2xs);
  z-index: calc(var(--z-base) + 1); /* local stacking only — must NOT compete with dropdowns */
}
@media (min-width: 640px) {
  .kpi-info-button--abs {
    width: 1.375rem;
    height: 1.375rem;
    font-size: var(--text-xs);
  }
}
.kpi-info-button:hover {
  background: var(--color-bg-elevated);
  border-color: var(--brand-primary);
  color: var(--brand-primary-dark);
}
.kpi-info-button:focus-visible {
  background: var(--color-bg-elevated);
  border-color: var(--brand-primary);
  color: var(--brand-primary-dark);
}
.kpi-info-button[aria-expanded="true"] {
  background: var(--brand-surface-strong);
  border-color: var(--brand-surface-strong);
  color: var(--color-text-inverse);
}
/* T191 P191-D: static info button — pure hover-only tooltip affordance
   (no toggle). Cursor stays as `help` so the user knows it's informational
   and not actionable. Focus ring identical to the toggle variant. */
.kpi-info-button--static {
  cursor: help;
}
.kpi-info-button--static:hover,
.kpi-info-button--static:focus-visible {
  background: var(--color-info-bg);
  color: var(--color-info);
  border-color: var(--color-info);
}
.manual-amount-field {
  position: relative;
  display: inline-flex;
  width: 100%;
  min-width: calc(6ch + 3.1rem);
  max-width: 100%;
  flex: 1 1 auto;
}

.manual-amount-input {
  padding-right: 2.1rem;
  border: 1px solid var(--brand-primary);
  box-shadow: inset 0 2px 4px rgba(var(--shadow-color), 0.08);
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
  -moz-appearance: textfield;
}

.manual-amount-input:focus {
  border-color: var(--brand-primary-dark);
  box-shadow: 0 0 0 3px var(--toggle-focus-ring);
}

.manual-amount-field__suffix {
  position: absolute;
  top: 50%;
  right: var(--space-2);
  transform: translateY(-54%);
  font-weight: var(--font-semibold);
  color: var(--color-text-secondary);
  pointer-events: none;
  z-index: var(--z-base);
}

.manual-amount-input::-webkit-outer-spin-button,
.manual-amount-input::-webkit-inner-spin-button {
  margin: 0;
  -webkit-appearance: none;
}

.kpi-tooltip-content {
  margin-top: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-lg);
  border: 1px solid var(--color-border-default);
  background: var(--color-bg-page);
  color: var(--color-text-secondary);
  font-size: var(--text-sm);
  line-height: var(--leading-snug);
  box-shadow: 0 8px 16px rgba(var(--shadow-color), 0.08);
}
.kpi-tooltip-content[hidden] {
  display: none;
}
.kpi-tooltip-content a {
  color: var(--brand-primary-dark);
  text-decoration: underline;
}
.kpi-tooltip-content :where(a:hover) {
  color: var(--brand-primary-dark);
}

/* Reusable badge for KPI icons with pastel backgrounds */
.kpi-icon-badge {
  --kpi-accent-bg: var(--color-info-bg); /* default blue-100 */
  /* Icon ink filter defaults (tuned for white-line PNGs) */
  --icon-invert: 1;        /* invert white to dark */
  --icon-brightness: .28;  /* darkness of the ink */
  --icon-contrast: 1.2;    /* pop on pastel */
  background: var(--kpi-accent-bg);
  border: 1px solid var(--color-border-default);
  box-shadow: var(--shadow-sm);
}
.kpi-icon-badge--blue { --kpi-accent-bg: var(--color-info-bg); }      /* blue-100 */
.kpi-icon-badge--green { --kpi-accent-bg: var(--color-success-bg); } /* green-100 */
.kpi-icon-badge--yellow { --kpi-accent-bg: var(--color-yellow-100); --icon-brightness: .24; }
.kpi-icon-badge--purple { --kpi-accent-bg: var(--color-purple-100); }
.kpi-icon-badge--red { --kpi-accent-bg: var(--color-error-bg); --icon-brightness: .26; }
.kpi-icon-badge--indigo { --kpi-accent-bg: var(--color-indigo-100); }
.kpi-icon-badge--teal { --kpi-accent-bg: var(--color-teal-100); }
.kpi-icon-badge--orange { --kpi-accent-bg: var(--color-orange-100); --icon-brightness: .26; }
.kpi-icon-badge--cyan { --kpi-accent-bg: var(--color-cyan-100); }
.kpi-icon-badge--emerald { --kpi-accent-bg: var(--color-emerald-100); }
.kpi-icon-badge--amber { --kpi-accent-bg: var(--color-amber-100); --icon-brightness: .25; }

/* KPI icon wrapper – for hover detection */
.kpi-icon-wrap {
  flex-shrink: 0;
}

.kpi-icon {
  width: var(--icon-lg); height: var(--icon-lg);
  /* Make white-line icons stand out over pastel badges */
  filter: invert(var(--icon-invert)) brightness(var(--icon-brightness)) contrast(var(--icon-contrast));
  mix-blend-mode: multiply; /* subtle tint with background */
}

/* ── KPI cards: icon circle backgrounds (pastel in light, subtle dark in dark) ──
 * v15-fix: limitado a wraps SIN badge — si hay badge, --kpi-accent-bg manda. */
:where(html[data-theme="dark"]) .kpi-icon-wrap:not(.kpi-icon-badge) {
    background: var(--color-bg-elevated);
}
/* KPI icon badges: dark tints via tokens — each --color-*-100 overrides in
 * [data-theme="dark"] (L542-548, L532, L538) make per-class dark overrides redundant. */
:where([data-theme="dark"]) .kpi-icon-badge {
    border-color: var(--color-border-default);
    --icon-invert: 0;
    --icon-brightness: 1;
    --icon-contrast: 1;
}
/* In dark mode, white PNGs must NOT be multiplied against the dark bg */
:where([data-theme="dark"]) .kpi-icon {
    mix-blend-mode: normal;
    filter: invert(0) brightness(1) contrast(1);
}
/* KPI card bg itself */
/* stylelint-disable no-descending-specificity -- [real override] ':where([data-theme=dark]) .kpi-card|.kpi-value|.kpi-label' (0,1,0, :where=0) vs earlier '#dashboard-charts-container > .kpi-card' (1,1,0, line 2337) and '.kpi-card .kpi-value' (0,2,0, line 2330): dark text-color overrides grouped here by theme; the lower :where() specificity is intentional and the rules belong in the dark-mode section, not hoisted above the id/compound bases. */
:where([data-theme="dark"]) .kpi-card,
:where([data-theme="dark"]) .kpi-value,
:where([data-theme="dark"]) .kpi-label {
    color: var(--color-text-primary);
}
/* stylelint-enable no-descending-specificity */

/* Wrapper for page content to ensure full height */
.content-wrapper {
    flex: 1 0 auto;
    display: flex;
    flex-direction: column;
}
.content-wrapper > main:not(.co-main) {
    overflow-x: hidden;
}

/* Cards border */

.border-shadow {
	box-shadow: var(--shadow-md);
}

/* Intentional: custom shadow-lg override of Tailwind */
.shadow-lg {
    box-shadow: var(--shadow-lg);
}

/* Header Button */


/* .btn-primary — duplicate removed, single definition kept above. */

/* .btn-primary.hidden specificity (0,2,0) > .btn-primary (0,1,0) — no !important needed */
.btn-primary.hidden,
button.hidden {
        display: none;
}


/* Brand-tinted icon filter for raster <img> elements.
   T928: last remaining callers (clone.png, ticket.png) migrated to inline SVG.
   Rule kept for backwards-compat; candidate for removal in T974 ghost-CSS pass. */
.inverted-color {
  filter: brightness(0) saturate(100%) invert(54%) sepia(76%) saturate(2500%) hue-rotate(170deg);
}

/* ── Header: override bg-white/text-purple from Tailwind in HTML ── */
:where([data-theme="dark"]) header {
    background: var(--color-bg-surface);
    border-bottom: 1px solid var(--color-border-default);
}
/* Override Tailwind text-purple-600 on header container */
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] ':where([data-theme=dark]) header .container' (0,2,0) vs earlier '#main:not(.co-main) > .container' (1,1,0, line 1912): a header .container and the #main child .container are disjoint subtrees; no element matches both, so there is no cascade conflict and moving this dark header override beside #main would break locality. */
:where([data-theme="dark"]) header .container {
/* stylelint-enable no-descending-specificity */
    color: var(--color-text-primary);
}
/* ── Logo: ensure visibility on dark bg ── */
:where([data-theme="dark"]) header img.w-48 {
    filter: brightness(0) invert(1);
}
/* ── Menu button icon (.inverted-color) — already brand-tinted, lighten for dark ── */
:where([data-theme="dark"]) .inverted-color {
    filter: brightness(0) saturate(100%) invert(100%);
}

/* Botones azules en panel: con etiqueta (icono + palabra) o solo icono */
/* T754: extiende selector a `.panel-page-header__actions` (nuevo wrapper post-T747) */
:is(.header-actions, .panel-page-header__actions) .panel-action-wrap.btn-primary {
  box-sizing: border-box;
  flex-shrink: 0;
  justify-content: center;
  align-items: center;
  gap: var(--space-1);
}
/* Solo icono (refunds u otros): tamaño fijo — WCAG 2.5.8 mín 44px */
:is(.header-actions, .panel-page-header__actions) .panel-action-wrap.btn-primary:where(:not(:has(.panel-action-label))) {
  padding: 0;
  min-width: var(--touch-target-min);
  min-height: var(--touch-target-min);
  width: var(--touch-target-min);
  height: var(--touch-target-min);
  line-height: 0;
}
/* Con etiqueta: WCAG 2.5.8 mín 44px de alto */
:is(.header-actions, .panel-page-header__actions) .panel-action-wrap.btn-primary:where(:has(.panel-action-label)),
:is(.header-actions, .panel-page-header__actions) .panel-action-wrap.panel-action-with-label {
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-4);
  font-size: var(--text-sm);
  line-height: var(--leading-snug);
  gap: var(--space-2);
  border-radius: var(--radius-md);
}
:is(.header-actions, .panel-page-header__actions) .panel-action-wrap.btn-primary:where(:has(.panel-action-label)) .panel-btn-icon {
  width: var(--icon-sm);
  height: var(--icon-sm);
  min-width: var(--icon-sm);
  min-height: var(--icon-sm);
}
:is(.header-actions, .panel-page-header__actions) .panel-action-wrap.panel-action-with-label img {
  width: var(--icon-sm);
  height: var(--icon-sm);
  min-width: var(--icon-sm);
  min-height: var(--icon-sm);
  object-fit: contain;
  flex-shrink: 0;
}

/* Iconos dentro del botón azul: tamaño fijo y filtro para blanco.
   filter: brightness(0) invert(1) turns raster icons white — kept because
   .panel-btn-icon and .inverted-color are <img> elements, not inline SVGs.
   For inline SVGs, use .panel-btn-icon-svg instead (see below). */
.btn-primary .inverted-color,
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.btn-primary .panel-btn-icon' (0,2,0) vs earlier ':is(.header-actions,.panel-page-header__actions) .panel-action-wrap.btn-primary:where(:has(.panel-action-label)) .panel-btn-icon' (0,5,0, line 2753): the header-actions compound is far more specific and scopes a different ancestor chain; this base icon sizing for btn-primary cannot be hoisted above that component without separating it from .inverted-color. */
.btn-primary .panel-btn-icon {
/* stylelint-enable no-descending-specificity */
  width: var(--icon-sm);
  height: var(--icon-sm);
  min-width: var(--icon-sm);
  min-height: var(--icon-sm);
  display: block;
  object-fit: contain;
  flex-shrink: 0;
  filter: brightness(0) invert(1);
}

/* SVG dentro de botón azul: trazo blanco */
.btn-primary .panel-btn-icon-svg {
  width: var(--icon-sm);
  height: var(--icon-sm);
  min-width: var(--icon-sm);
  min-height: var(--icon-sm);
  flex-shrink: 0;
  stroke: var(--color-text-inverse);
  color: var(--color-text-inverse);
}

/* Tooltip globo en botones de acción del panel (no depende de Tailwind) */
/* T754: extiende selector al wrapper nuevo post-T747 */
:is(.header-actions, .panel-page-header__actions) .panel-action-wrap {
  position: relative;
  overflow: visible;
}

/* Globo único para todos los botones de acción (Crear, Exportar, CSV, Limpiar): no heredar del botón */
/* Base tooltip for panel action buttons (shared between header and refund pages) */
/* #3229 — esta familia SE QUEDA. Es una de las tres que el owner conserva en la
   v4.1 («los tooltips de los botones se pueden quedar como están»). No es deuda
   pendiente de migrar al canónico: está declarada en `CSS_ALLOWED` de
   `scripts/check_tooltip_pattern.py` con su motivo. Ver CONC-UI-TOOLTIP-01 §4. */
.panel-action-tooltip {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  top: 100%;
  margin-top: var(--space-2);
  padding: var(--space-1-5) var(--space-3);
  min-width: 8rem;
  font-size: var(--text-sm);
  font-weight: var(--font-medium);
  line-height: var(--leading-tight);
  color: var(--gray-50);
  background: var(--gray-900);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  white-space: nowrap;
  text-align: center;
  z-index: var(--z-tooltip);
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--transition-fast), visibility var(--transition-fast);
  pointer-events: none;
  display: inline-block;
  box-sizing: border-box;
}

.panel-action-wrap:hover .panel-action-tooltip {
  opacity: 1;
  visibility: visible;
}

/* Floating variant — viewport-anchored to escape ancestor stacking contexts.
   JS positions top/left on pointer/focus events. Do NOT set top/left here:
   the initial 0/0 would cause a one-frame flicker before JS repositions. */
.panel-action-tooltip--floating {
  position: fixed;
  left: 0;
  top: 0;
  transform: none;
  margin-top: 0;
}

/* Refund pages: mismo tooltip y botón azul solo icono */
.refund-page__actions .panel-action-wrap,
.refund-page .panel-action-wrap {
  position: relative;
  overflow: visible;
}

.refund-page__actions .panel-action-wrap.btn-primary,
.refund-page .panel-action-wrap.btn-primary {
  padding: 0;
  min-width: var(--touch-target-min);
  min-height: var(--touch-target-min);
  width: var(--touch-target-min);
  height: var(--touch-target-min);
  box-sizing: border-box;
  flex-shrink: 0;
  line-height: 0;
  justify-content: center;
  align-items: center;
}

/* Refund: botones con palabra (mismo ancho fijo que en el panel) */
.refund-page__actions .panel-action-wrap.panel-action-with-label,
.refund-page .panel-action-wrap.panel-action-with-label {
  min-height: var(--touch-target-min);
  padding: var(--space-1) var(--space-3);
  font-size: var(--text-sm);
  line-height: var(--leading-tight);
  gap: var(--space-1);
  justify-content: center;
  align-items: center;
}
.refund-page__actions .panel-action-wrap.panel-action-with-label .panel-btn-icon,
.refund-page .panel-action-wrap.panel-action-with-label .panel-btn-icon,
.refund-page__actions .panel-action-wrap.panel-action-with-label img,
.refund-page .panel-action-wrap.panel-action-with-label img {
  width: var(--icon-sm);
  height: var(--icon-sm);
  min-width: var(--icon-sm);
  min-height: var(--icon-sm);
  flex-shrink: 0;
  object-fit: contain;
}

/* Refund page tooltip: inherits all styles from base .panel-action-tooltip above */

/* Outlined variant for secondary panel actions (Exportar, Importar, Limpiar) */
.panel-action-btn--outlined {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--touch-target-min); /* WCAG 2.1 SC 2.5.8 */
  padding: var(--space-2) var(--space-4);
  font-size: var(--text-sm);
  font-weight: var(--font-medium);
  font-family: var(--font-heading);
  line-height: var(--leading-snug);
  cursor: pointer;
  background: var(--color-bg-elevated);
  color: var(--color-text-primary);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-md);
  transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.panel-action-btn--outlined:hover,
.panel-action-btn--outlined:focus-visible {
  background: var(--color-brand-tint-12);
  border-color: var(--brand-primary);
  color: var(--brand-primary-dark);
}
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] ':where(html[data-theme=dark]) .panel-action-btn--outlined img' (0,2,0) vs earlier '.swiper-banner .swiper-slide img' (0,3,0, line 757): banner images and outlined-button icons are disjoint elements; the dark invert filter belongs with the panel-action button styling, not next to the swiper banner. */
:where(html[data-theme="dark"]) .panel-action-btn--outlined img {
/* stylelint-enable no-descending-specificity */
  filter: invert(1) brightness(1.1);
}

/* Layout for action controls */
/* T754: extiende selector al wrapper nuevo post-T747 */
:is(.header-actions, .panel-page-header__actions) {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        justify-content: flex-start; /* asegura alineación a la izquierda */
}


/* Refund experience styling */
.refund-page {
  display: flex;
  flex-direction: column;
  gap: clamp(20px, 3vw, 32px);
  width: 100%;
}

/* Cabecera y botones de acción en móvil (period_detail: volver, XML SEPA, exportar, etc.) */
@media (max-width: 639px) {
  .refund-page__header {
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-3);
  }
  .refund-page__header > a:first-child {
    align-self: flex-start;
  }
  .refund-page__actions {
    width: 100%;
    justify-content: flex-start;
  }
  .refund-page__btn-sepa {
    white-space: nowrap;
  }
}


.refund-section {
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-default);
  border-radius: var(--radius-xl);
  box-shadow: 0 10px 24px rgba(var(--shadow-color), 0.08);
  padding: clamp(22px, 3.6vw, 38px);
}

.refund-section--plain {
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-default);
  box-shadow: 0 6px 18px rgba(var(--shadow-color), 0.06);
}

.refund-section__header {
  display: flex;
  flex-direction: column;
  gap: clamp(8px, 1.5vw, 14px);
  margin-bottom: clamp(14px, 2.8vw, 22px);
}

.refund-section__title {
  font-size: clamp(1.5rem, 2.6vw, 2.2rem);
  font-weight: var(--font-bold);
  color: var(--color-text-primary);
}

.refund-section__subtitle {
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
  max-width: 62ch;
}

.refund-summary-grid {
  display: grid;
  gap: clamp(14px, 2.4vw, 26px);
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  margin-bottom: clamp(18px, 3vw, 30px);
}

.refund-summary-card {
  position: relative;
  padding: clamp(18px, 3vw, 26px);
  border-radius: var(--radius-xl);
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-default);
  box-shadow: 0 6px 18px rgba(var(--shadow-color), 0.06);
}

.refund-summary-card__label {
  display: block;
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-widest);
  text-transform: uppercase;
  color: var(--color-text-muted);
  margin-bottom: var(--space-3);
}

.refund-summary-card__value {
  display: block;
  font-size: clamp(1.35rem, 2.4vw, 1.9rem);
  font-weight: var(--font-bold);
  color: var(--color-text-primary);
}

.refund-summary-card__caption {
  display: block;
  margin-top: var(--space-2);
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
}

.refund-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  justify-content: flex-end;
  align-items: center;
}

/* Refund guide card: dos cards (mensaje + qué hacer), horizontal en escritorio, apiladas en móvil */
.refund-guide-card__header {
  margin-bottom: 0;
}

.refund-guide-card__title {
  font-size: clamp(1.35rem, 2.2vw, 1.75rem);
  font-weight: var(--font-bold);
  color: var(--color-text-primary);
  margin-bottom: var(--space-3);
}

.refund-guide-card__cards {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(14px, 2.4vw, 26px);
  margin-top: var(--space-2);
}

.refund-guide-card__msg-card,
.refund-guide-card__steps-card {
  flex: 1 1 min(100%, 280px);
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.refund-guide-card__msg-card .refund-guide-card__lead {
  margin: 0;
  font-size: var(--text-base);
  line-height: var(--leading-relaxed);
  color: var(--color-text-secondary);
}

.refund-guide-card__steps-list {
  list-style: decimal;
  list-style-position: outside;
  padding-left: var(--space-5);
  font-size: var(--text-base);
  line-height: var(--leading-relaxed);
  color: var(--color-text-secondary);
  margin: 0;
}

.refund-guide-card__steps-list li {
  margin-bottom: var(--space-1);
  padding-left: var(--space-1);
}

.refund-guide-card__steps-list :where(li:last-child) {
  margin-bottom: 0;
}

/* Page title subtle counter and indentation */
.page-title {
  margin-left: var(--space-1);
}
/* El contador vive en DOS cabeceras: la vieja `.page-title` (apk, dashboard) y
   la de los paneles, `components/_panel_page_header.html`. Emitia el mismo
   `<span class="results-count">` y se pintaba SIN estilo —ni color muted ni
   margen— porque este selector solo cubria la primera. Un solo bloque para las
   dos: el contador se lee igual en las once pantallas que lo tienen. */
.page-title .results-count,
.panel-page-header__title .results-count {
  margin-left: var(--space-1);
  color: var(--color-text-muted);
  font-weight: var(--font-medium);
}

/* Data table refinements */
.data-table {
  border-collapse: separate;
  border-spacing: 0;
}
/* T440: sticky thead so column headers remain visible on vertical scroll */
.table-sticky-thead,
.data-table .table-sticky-thead {
    position: sticky;
    top: 0;
    z-index: var(--z-sticky, 200);
    background: var(--color-bg-elevated, var(--color-bg-surface));
}
.data-table thead :where(tr.header-row) th {
  vertical-align: bottom;
  font-weight: var(--font-semibold);
  color: var(--color-text-primary);
  border-bottom: 1px solid var(--color-border-default);
}
.data-table thead :where(tr.filter-row) th {
  padding-top: var(--space-2);
  padding-bottom: var(--space-2);
}
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] ':where(table).data-table th|td' (0,2,1) vs earlier '.data-table thead :where(tr.header-row) th' (0,3,0, line 3133) and '.kpi-hero__breakdown-row--clickable:hover td' (0,4,0, line 2197): the higher-specificity header-row / hover-row rules already win where they apply; this border-reset for plain data-table cells stays with the data-table block. */
:where(table).data-table th,
:where(table).data-table td {
  border-left: none;
  border-right: none;
}
/* stylelint-enable no-descending-specificity */
/* Zebra striping (T940): CSS nth-child puro — robusto frente a renders AJAX
   individuales (partials *_row.html). Con la clase Django forloop.counter,
   toda fila renderizada individualmente para AJAX recibía bg-elevated porque
   forloop.counter=None → divisibleby evalúa False → not False=True.
   nth-child lo recalcula el browser automáticamente tras cualquier DOM change. */
.data-table tbody tr {
  background: var(--color-bg-surface);
  transition: background-color var(--transition-fast);
}
.data-table tbody tr:nth-child(odd) {
  background: var(--color-bg-elevated);
}
/* T950: guard hover para touch devices — evita estado hover pegado en iOS/Android */
@media (hover: hover) {
  .data-table tbody tr:hover {
    background-color: var(--color-brand-tint-12);
  }
}
/* T949: overflow-x:clip no invalida position:sticky del thead — fix iOS Safari.
   El wrapper externo usa clip (no auto/scroll) para que sticky funcione dentro. */
.table-scroll-wrapper {
  overflow-x: clip;
}
/* T08 (s23): override puntual para que el popup nativo del date picker no quede
   clippeado por el overflow-x:clip del wrapper ancestor. El clip crea una región
   de recorte que afecta a todos los descendientes; marcar el filter-row con
   overflow:visible permite que el popup del input[type="date"] se renderice fuera
   del bounding box de la fila sin modificar el wrapper (sticky se preserva). */
.data-table thead tr.filter-row {
  overflow: visible;
}
/* Sticky first column — keeps identifier visible on horizontal scroll.
   Same pattern as .chart-legend-table--with-label (DRY token usage). */
.data-table thead :where(th:first-child),
.data-table tbody :where(td:first-child) {
  position: sticky;
  left: 0;
  z-index: var(--z-raised);  /* = 30 — T943 fix: no solapar con sticky headers */
  background: var(--color-bg-surface);
  border-right: 1px solid var(--color-border-default);
}
.data-table thead :where(tr.header-row) th:first-child {
  z-index: calc(var(--z-sticky) + 1);  /* = 201 — T943 fix: encima de sticky row */
}
.data-table thead :where(tr.filter-row) th:first-child {
  z-index: var(--z-sticky);  /* = 200 — T943 fix: sticky row sin solapar header */
}
/* T940: sticky td:first-child tiene background surface fijo (regla arriba).
   Necesita override para filas impares (nth-child odd) para que la columna
   pegajosa no quede blanca mientras el resto de la fila es elevated.
   Specificity con :where() para mantener cascada correcta vs hover. */
.data-table tbody :where(tr:nth-child(odd)) td:first-child {
  background: var(--color-bg-elevated);
}
@media (hover: hover) {
  /* R-STICKY-OPAQUE (#2415): la columna sticky FLOTA (z-index var(--z-raised)=30)
     sobre las celdas que se deslizan bajo ella en scroll horizontal → su fondo en
     hover DEBE ser opaco. Se mantiene la base opaca zebra (surface/elevated de las
     reglas de arriba, L3237/L3255) y el tinte de marca se pinta con inset box-shadow
     (mismo patrón DRY que .chart-legend-table:hover, ~L4044). NUNCA sustituir
     `background` por un token con alpha<1 en una celda sticky (bleed-through).
     Guards: scripts/check_sticky_cell_opaque.py + e2e sticky-col-hover-opaque.spec.ts.
     Ver obsidian/design-system/DS-ANTIPATTERNS-stacking-overlap.md §1. */
  .data-table tbody :where(tr:hover) td:first-child {
    box-shadow: inset 0 0 0 9999px var(--color-brand-tint-12);
  }
}
/* T955: opt-out variant para tablas sin scroll horizontal donde sticky col no aporta.
   Aplicar a tablas table-fixed o sin overflow-x: auto en el wrapper. */
.data-table.data-table--no-sticky-col thead :where(th:first-child),
.data-table.data-table--no-sticky-col tbody :where(td:first-child) {
  position: static;
  z-index: auto;
  border-right: none;
  background: inherit;
}
/* COMPONENTE `.actions-col` — la columna de acciones, y su PRECONDICION (#3367 §3).

   Lo que cada declaracion hace DE VERDAD, medido en Chromium sobre este mismo
   `main.css` con el markup real de las tres pantallas que tienen columna de
   acciones (`benefits-periods`, `refunds-period-list-archived`,
   `panel-devices-table`), a 375px:

   · `white-space: nowrap`   — esto, y solo esto, es lo que impide que los botones
                               envuelvan.
   · `min-width: max-content` — con el layout AUTOMATICO del canon es INERTE:
                               quitarlo no mueve un pixel (216,0 / 136,2 / 68,2px
                               de columna, identicos con y sin el). Se queda como
                               suelo declarado, no como garantia: quien mantiene
                               la columna a su ancho es el layout de la tabla.
   · `text-align: center`     — la alineacion del componente.

   ⛔ PRECONDICION, y es la razon de que esta columna llegara a solaparse: con
   `table-layout: fixed` el ancho de columna lo fijan los anchos DECLARADOS de la
   primera fila y el `min-width` de la celda NO participa. Medido sobre
   `battlepass_periods.html` mutada a layout fijo: la columna se queda en 49px
   para 99px de botones y el boton de mas a la derecha se pinta 75,5px FUERA de su
   celda, encima de la vecina. `width: max-content` tampoco sirve — medido: la
   columna sigue en 49px.

   O sea que la promesa de este componente vive en OTRO fichero (el atributo
   `class` de la `<table>`), y por eso la sostiene un guard mecanico en vez de un
   comentario: `scripts/check_table_can_grow.py` R3 — ninguna tabla con columna de
   acciones puede declarar layout fijo, con suelo de ancho o sin el. ⚠️ R1 NO
   cubre esto: R1 exige que la TABLA pueda crecer y da por buena `table-fixed` en
   cuanto hay un `min-w-`; eso no dice nada del ancho de la COLUMNA (medido:
   layout fijo + `min-w-[900px]` pasa R1 y estrangula igual). */
.actions-col {
  text-align: center;
  white-space: nowrap;
  min-width: max-content;
}

.table-actions {
  display: flex;
  flex-wrap: nowrap;
  justify-content: center;
  gap: var(--space-2);
}

.table-action-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  width: var(--touch-target-min);
  height: var(--touch-target-min);
  min-width: var(--touch-target-min);
  min-height: var(--touch-target-min);
  padding: var(--space-1);
  font-size: var(--text-xs);
  line-height: 1;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
}

.table-action-button:hover {
  box-shadow: var(--shadow-brand-sm);
}
.table-action-button:focus-visible {
  box-shadow: var(--shadow-brand-sm);
}


.table-action-button--outlined {
  background-color: var(--color-bg-surface);
  color: var(--brand-primary-dark);
  border-color: var(--brand-primary);
}

.table-action-button--outlined:hover,
.table-action-button--outlined:focus-visible {
  background-color: var(--color-brand-tint-12);
  border-color: var(--brand-primary-dark);
  color: var(--brand-primary-dark);
}

.table-action-button--brand {
  background-color: var(--brand-surface-strong);
  border-color: var(--brand-surface-strong);
  color: var(--color-text-inverse);
}

.table-action-button--brand:hover,
.table-action-button--brand:focus-visible {
  background-color: var(--brand-surface-strong-hover);
  border-color: var(--brand-surface-strong-hover);
  color: var(--color-text-inverse);
}

.table-action-button--danger {
  background-color: var(--color-error-bg);
  border-color: var(--color-error-light);
  color: var(--color-status-error-text);
}

/* #2787: hover/focus MUST be distinguishable from resting -- border-color
   moves to --color-error-hover (a strictly darker/more-saturated red) so the
   state change is real, not a same-value no-op. Background and text colour
   stay put (still contrast-safe against --color-error-bg -- see
   --color-status-error-text) so this is the minimal token-only diff.
   Light-theme-only: "darker/more-saturated" reads as MORE contrast because
   the button sits on a light row (darkening a red only helps against a pale
   backdrop). The dark-theme override right below exists because that
   intuition inverts on a dark backdrop -- see its comment. */
.table-action-button--danger:hover,
.table-action-button--danger:focus-visible {
  background-color: var(--color-error-bg);
  border-color: var(--color-error-hover);
  color: var(--color-status-error-text);
}

/* #2787 follow-up (measured 2026-08-21, real .data-table row, composited
   pixels -- see the spec's file-level comment): --color-error-hover in dark
   theme is #ef4444 (red-500), which is LIGHTER than #b91c1c (red-700, its
   light-theme value) -- darkening a red still means picking a token with a
   HIGHER hex value's blue/green channels suppressed, i.e. moving toward
   red-900 territory, and on a dark backdrop that direction LOWERS luminance
   contrast instead of raising it (the opposite of the light-theme case
   above). Measured on real hover/focus backdrops inside a real
   `.data-table` row (button's own translucent --color-error-bg composited
   over the row's real rendered background -- tr:hover's translucent
   --color-brand-tint-12 further composited over #main's --color-bg-surface,
   or the opaque zebra --color-bg-surface/--color-bg-elevated at rest/focus):
   #ef4444 only clears ~2.4-2.6:1, below the SC 1.4.11 3:1 floor. Swapping to
   --color-error (#f87171, red-400 -- already the dark-theme token declared
   in the `[data-theme="dark"]` block above, unchanged) clears >=3.25:1 on every
   backdrop measured and still strictly differs from the resting
   --color-error-light (#fca5a5), so this does not reintroduce the #2787
   no-op. Kept as a dark-only override (not a global token swap) so the
   already-passing light theme (~5.30:1 with --color-error-hover) is
   untouched. */
:where([data-theme="dark"]) .table-action-button--danger:hover,
:where([data-theme="dark"]) .table-action-button--danger:focus-visible {
  border-color: var(--color-error);
}

.table-action-button--success {
  background-color: var(--color-success-bg);
  border-color: var(--color-success);
  color: var(--color-status-success-text);
}
/* #2787: same fix as --danger above -- border-color moves to
   --color-success-hover (strictly darker/more-saturated green in both themes)
   so hover/focus is a real, distinguishable state change. */
.table-action-button--success:hover,
.table-action-button--success:focus-visible {
  background-color: var(--color-success-bg);
  border-color: var(--color-success-hover);
  color: var(--color-status-success-text);
}

.table-action-button--disabled {
  background-color: var(--color-bg-elevated);
  border-color: var(--color-border-strong);
  color: var(--color-text-muted);
  cursor: not-allowed;
  pointer-events: none;
}

.table-action-button--text {
  width: auto;
  height: auto;
  padding: var(--space-1) var(--space-3);
  min-height: 2rem;
}

/* Table footer compact style */
.table-footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  padding-block: var(--space-3);
  font-size: var(--text-sm);
}
@media (min-width: 640px) {
  .table-footer { justify-content: space-between; }
}
.table-footer .footer-left {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: var(--space-2);
}
.table-footer .footer-left a {
  margin-right: 0;
  min-width: 2.25rem;
  min-height: 2.25rem;
  justify-content: center;
}
.table-footer .page-indicator {
  color: var(--color-text-secondary);
  margin-inline: var(--space-1);
  white-space: nowrap;
}

/* === Toggle (Tailwind-like) helpers ===
   Peer-based toggles — rely on:
   <label class="relative inline-flex ...">
     <input type="checkbox" class="sr-only peer"> <div></div>
   </label>
   .sr-only defined by Tailwind v3 preflight — no duplicate needed.
   `relative` en el label es OBLIGATORIO: .sr-only es position:absolute, y sin
   ancestro posicionado el input se ancla al scroller equivocado y el foco del
   click arrastra el modal entero (scroll-into-view del UA).
*/

.toggle__track {
  position: relative;
  /* #2812 — «el slider se debe ver adaptado». El carril es un CONTROL, no un
     texto: es item flex de `label.relative.inline-flex` (components/toggle.html)
     y compite con el `<span class="flex flex-col min-w-0">` de la etiqueta, que
     SÍ puede encoger. Sin esto el algoritmo flex le quita ancho al carril en
     cuanto la fila se estrecha. Pineado por
     scripts/tests/test_toggle_switch_geometry.py. */
  flex: 0 0 auto;
  width: var(--toggle-width);
  height: var(--toggle-height);
  background: var(--toggle-track-off); /* más contraste */
  border-radius: var(--radius-full);
  transition: background-color var(--transition-ui), box-shadow var(--transition-ui);
}
.toggle__track::after {
  content: "";
  position: absolute;
  /* §4.2 — el token de 2px existe (`--space-0-5`) y estaba hardcodeado aquí;
     no lo caza ningún guard (check_design_tokens.py sólo mira color, radius y
     font-size; stylelint, radius y font-size). */
  top: var(--space-0-5);
  left: var(--space-0-5);
  width: var(--icon-sm);
  height: var(--icon-sm);
  background: var(--toggle-knob-off);
  border: 1px solid var(--brand-primary-light);
  box-shadow: var(--shadow-xs), 0 0 0 1px rgba(var(--shadow-color), 0.06);
  border-radius: var(--radius-full);
  transition: transform var(--transition-ui), border-color var(--transition-ui);
}
label.inline-flex > input.sr-only.peer:focus + .toggle__track {
  outline: none;
  box-shadow: 0 0 0 4px var(--toggle-focus-ring);
}
label.inline-flex > input.sr-only.peer:checked + .toggle__track {
  background: var(--brand-primary);
}
label.inline-flex > input.sr-only.peer:checked + .toggle__track::after {
  /* Recorrido = ancho del carril − los dos márgenes − el knob:
     36 − 2 − 2 − 16 = 16px = `--space-4`. Con `--space-5` (20px) el knob
     acababa en 38px sobre un carril de 36 y SOBRESALÍA 2px siempre que
     estaba encendido (#2812). */
  transform: translateX(var(--space-4));
  background: var(--color-bg-surface);
  border-color: rgba(var(--shadow-color), 0.12);
  box-shadow: var(--shadow-xs);
}

/* T754: extiende selector al wrapper nuevo post-T747 */
:is(.header-actions, .panel-page-header__actions) .actions {
        margin-left: 0;
}

/* T756: bloque `.header-actions-no-primary` eliminado — dead code post-T747.
   El refactor de T747 sustituyó `mainTHeader.html` (que añadía dinámicamente
   la clase modificadora cuando faltaba `primary_action`) por
   `_panel_page_header.html`, que simplemente no renderiza el botón primary
   en ese caso. La clase `.header-actions-no-primary` no se emite por ningún
   template actual y las reglas que la usaban no matcheaban ningún DOM. */

/* Con botón Crear, el espacio entre Crear y Export lo da el gap-3 del .header-actions; sin margen extra en el primer botón de .filters */
/* T754: extiende selector al wrapper nuevo post-T747 */
:is(.header-actions, .panel-page-header__actions) .filters .header-actions-export-link {
        margin-left: 0;
}

/* En el área de filtros/acciones de tabla, los botones no flotan a la derecha */
/* T754: extiende selector al wrapper nuevo post-T747 */
:is(.header-actions, .panel-page-header__actions) .btn-primary {
        float: none;
}

/* Hacer que el bloque de filtros ocupe el espacio restante y permita empujar toggles */
/* T754: extiende selector al wrapper nuevo post-T747 */
:is(.header-actions, .panel-page-header__actions) .filters {
        display: flex;
        align-items: center;
        flex-wrap: wrap;
        flex: 1 1 auto; /* ocupa el ancho disponible */
}

@media (max-width: 639px) {
        .btn-primary {
                margin: 1%;
        }


        /* Panel: botones de acción con wrap vertical en móvil */
        /* T754: extiende selector al wrapper nuevo post-T747 */
        :is(.header-actions, .panel-page-header__actions) {
                flex-wrap: wrap;
                gap: var(--space-2);
        }
        :is(.header-actions, .panel-page-header__actions) .actions,
        :is(.header-actions, .panel-page-header__actions) .filters {
                width: 100%;
                flex-wrap: wrap;
                margin-left: 0;
                margin-top: 0;
        }
}


/* Footer estilos */

.footer-distributed {
        background-color: var(--slate-900);
        box-shadow: var(--shadow-xs);
        box-sizing: border-box;
        width: 100%;
        text-align: left;
        padding: var(--space-6) var(--space-8);
        margin-top: auto;
}

/* Control panel footer: align horizontal padding with its header gutter
   (header nav uses px-4 sm:px-6 lg:px-8 == --container-gutter). The control
   panel has no sidebar, so it must NOT inherit the shared --space-8 base nor
   the >=1024px sidebar-calc offset. Scoped to .cp-footer so the CargaOnline
   footer keeps the shared --space-8 base untouched. */
.footer-distributed.cp-footer {
        padding: var(--space-6) var(--container-gutter);
}

/* === Header Nav — responsive layout ===
   - Desktop (>=1024px): horizontal icon row in header bar
   - Mobile (<1024px): hidden dropdown toggled by hamburger
*/

/* ═══════════════════════════════════════════════════
   Collapsible Sidebar Navigation
   States: collapsed (60px icons), expanded (220px icons+labels),
           mobile drawer (slide-in overlay)
   ═══════════════════════════════════════════════════ */

/* ── Base (shared mobile/desktop) ── */
.sidebar {
  background: var(--color-bg-surface);
  border-right: 1px solid var(--color-border-default);
  display: flex;
  flex-direction: column;
}

.sidebar__nav {
  flex: 1;
  min-height: 0; /* allow flex child to shrink below content height, preventing spurious scroll */
  display: flex;
  flex-direction: column;
  padding: var(--space-2) 0;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
}

.sidebar__list {
  list-style: none;
  margin: 0;
  padding: 0 var(--space-2);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.sidebar__link {
  display: grid;
  grid-template-columns: var(--sidebar-item-size) 1fr;
  align-items: center;
  min-height: var(--sidebar-item-size);
  border-radius: var(--radius-md);
  color: var(--color-text-secondary);
  text-decoration: none;
  font-family: var(--font-heading);
  font-size: var(--text-sm);
  font-weight: var(--font-medium);
  transition: background-color var(--transition-ui),
              color var(--transition-ui),
              box-shadow var(--transition-ui);
  overflow: hidden;
  white-space: nowrap;
}

/* Button reset for sidebar__link applied to <button> elements (theme toggle, logout),
   so icon+label grid alignment matches the <a> items. */
button.sidebar__link {
  background: transparent;
  border: 0;
  padding: 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
  width: 100%;
}

.sidebar__icon {
  width: var(--sidebar-icon-size);
  height: var(--sidebar-icon-size);
  justify-self: center;
  flex-shrink: 0;
  transition: transform var(--transition-ui), filter var(--transition-ui);
}

.sidebar__label {
  opacity: 0;
  transition: opacity var(--transition-fast);
  pointer-events: none;
  padding-right: var(--space-2);
  overflow: hidden;
  text-overflow: ellipsis;
}

.sidebar__link:focus-visible {
  background: var(--brand-primary-light);
  color: var(--color-text-primary);
}

/* T950: guard hover para touch devices */
@media (hover: hover) {
  .sidebar__link:hover {
    background: var(--brand-primary-light);
    color: var(--color-text-primary);
  }

  .sidebar__link:hover .sidebar__icon {
    transform: scale(1.08);
  }
}

.sidebar__link--active {
  background: var(--brand-surface-strong);
  color: var(--color-text-inverse);
  box-shadow: var(--shadow-brand-sm);
}

.sidebar__link--active .sidebar__icon {
  filter: invert(1) brightness(1.05) contrast(1.05);
}

.sidebar__separator {
  height: 1px;
  background: var(--color-border-default);
  margin: var(--space-3) var(--space-4);
}

.sidebar__toggle {
  display: none; /* visible only on desktop */
}

/* ── Sidebar icon: white-source PNGs (e.g. logout.png) need opposite treatment ── */
.sidebar__icon--inverted {
  filter: brightness(0) saturate(100%) invert(54%) sepia(76%) saturate(2500%) hue-rotate(170deg);
}

/* ── Dark mode: sidebar ── */
/* stylelint-disable no-descending-specificity -- [real override] ':where([data-theme=dark]) .sidebar__icon' (0,2,0, :where=0) vs earlier '.sidebar__link--active .sidebar__icon' (0,2,0, line 3579): EQUAL specificity once :where() is discounted, so source order is the only tiebreaker and reordering would silently flip which dark/active filter wins (the PR #2186 dark-mode regression class). Kept in dark-sidebar source order. */
:where([data-theme="dark"]) .sidebar__icon {
/* stylelint-enable no-descending-specificity */
  filter: invert(1) brightness(1.1);
}
:where([data-theme="dark"]) .sidebar__icon--inverted {
  filter: none; /* white source is already correct for dark backgrounds */
}
/* stylelint-disable no-descending-specificity -- [real override] ':where([data-theme=dark]) .sidebar__link--active .sidebar__icon' (0,3,0) overrides the dark base ':where([data-theme=dark]) .sidebar__icon' (0,2,0, line 3600 above): defensive suppression kept symmetric with the active/base pair so a future reorder of the dark-sidebar block cannot reintroduce the equal-specificity trap flagged at line 3600. */
:where([data-theme="dark"]) .sidebar__link--active .sidebar__icon {
/* stylelint-enable no-descending-specificity */
  filter: invert(1) brightness(1.05) contrast(1.05);
}
:where([data-theme="dark"]) .sidebar__link--active .sidebar__icon--inverted {
  filter: none; /* active bg is brand-colored, white icon is already visible */
}
:where([data-theme="dark"]) .sidebar__link:hover,
:where([data-theme="dark"]) .sidebar__link:focus-visible {
  background: var(--color-bg-elevated);
}

/* ── Sidebar SVG icon modifier ──
   SVGs use stroke="currentColor", not filter:invert() like PNGs.
   This modifier overrides the PNG filter strategy with color-based theming. */
.sidebar__icon--svg {
  color: var(--color-text-primary);
  filter: none;
}
.sidebar__link--active .sidebar__icon--svg {
  color: var(--color-text-inverse);
  filter: none;
}
/* stylelint-disable-next-line no-descending-specificity -- [real override] ':where([data-theme=dark]) .sidebar__icon--svg' (0,2,0) is the dark base intentionally declared before the higher ':where([data-theme=dark]) .sidebar__link--active .sidebar__icon--svg' (0,3,0, line 3627) active override; base-before-active is the correct cascade and must not be swapped. */
:where([data-theme="dark"]) .sidebar__icon--svg {
  color: var(--color-text-primary);
  filter: none;
}
:where([data-theme="dark"]) .sidebar__link--active .sidebar__icon--svg {
  color: var(--color-text-inverse);
  filter: none;
}

/* ── Sidebar overlay (mobile backdrop) ── */
.sidebar-overlay {
  position: fixed;
  inset: 0;
  background: var(--color-overlay);
  z-index: calc(var(--z-sidebar-overlay) - 1);
  opacity: 0;
  transition: opacity var(--transition-slow);
  pointer-events: none;
}
.sidebar-overlay--visible {
  opacity: 1;
  pointer-events: auto;
}

/* ── Mobile (<1024px): slide-in drawer ── */
@media (max-width: 1023.98px) {
  .sidebar {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    width: min(var(--sidebar-expanded), 85vw);
    transform: translateX(-100%);
    transition: transform var(--transition-slow);
    z-index: var(--z-sidebar-overlay);
    padding-top: var(--space-6);
  }
  .sidebar.sidebar--open {
    transform: translateX(0);
  }
  .sidebar__label {
    opacity: 1;
    pointer-events: auto;
  }
  .sidebar__link {
    grid-template-columns: var(--sidebar-item-size) 1fr;
    padding: 0 var(--space-1);
  }
  body .sidebar__toggle { display: none; }
  .sidebar__theme-toggle {
    padding: 0 var(--space-2);
  }
  .sidebar__logout {
    margin-top: auto;
    flex-shrink: 0;
    padding: var(--space-2);
    border-top: 1px solid var(--color-border-default);
  }
}

/* ── Header sticky — all viewports (085 HR-01) ── */
.header-sticky {
  position: sticky;
  top: 0;
  z-index: var(--z-header);
  transition: box-shadow var(--transition-fast);
}
.header-sticky.is-scrolled {
  box-shadow: var(--shadow-sm);
}

/* ── Desktop (>=1024px): collapsible rail ── */
@media (min-width: 1024px) {

  .app-shell {
    display: grid;
    grid-template-columns: var(--sidebar-collapsed) minmax(0, 1fr);
    grid-template-rows: auto 1fr auto;
    grid-template-areas:
      "header header"
      "nav    main"
      "footer footer";
    min-height: 100vh;
  }

  /* Sidebar overlays content on expand (no grid reflow). Sidebar z-index > content
     keeps labels legible above. Main content stays at collapsed-column width. */

  header { grid-area: header; }

  .sidebar {
    grid-area: nav;
    width: var(--sidebar-collapsed);
    position: sticky;
    top: var(--header-height);
    height: calc(100vh - var(--header-height));
    transition: width var(--transition-base);
    z-index: var(--z-sticky);
    overflow: hidden;
  }
  /* Collapsed: no padding so icons center in the full 64px column */
  .sidebar__list {
    padding: 0;
  }

  .sidebar__link {
    grid-template-columns: var(--sidebar-collapsed) 1fr;
    padding: 0;
    border-radius: 0;
  }

  /* T951: Anti-FOCS — clase aplicada por script inline en <head> antes del render.
     Aplica estado expandido visualmente antes de que common.js se ejecute. */
  html.sidebar-preload-expanded #sidebar {
    width: var(--sidebar-expanded);
    overflow: visible;
  }

  /* Expand on hover (unless suspended after explicit collapse) OR when pinned.
     Pinned (.sidebar--expanded) selectors listed first so lower specificity
     appears before higher specificity (stylelint no-descending-specificity). */
  .sidebar.sidebar--expanded,
  .sidebar:hover:not(.sidebar--suspend-hover) {
    width: var(--sidebar-expanded);
    overflow: visible;
  }
  .sidebar.sidebar--expanded .sidebar__list,
  .sidebar:hover:not(.sidebar--suspend-hover) .sidebar__list {
    padding: 0 var(--space-2);
  }
  .sidebar.sidebar--expanded .sidebar__link,
  .sidebar:hover:not(.sidebar--suspend-hover) .sidebar__link {
    border-radius: var(--radius-md);
  }
  .sidebar.sidebar--expanded .sidebar__label,
  .sidebar:hover:not(.sidebar--suspend-hover) .sidebar__label {
    opacity: 1;
    pointer-events: auto;
  }

  /* Pin toggle button at bottom — height matches sidebar link items for consistent hit area */
  .sidebar__toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: var(--sidebar-item-size);
    background: none;
    border: none;
    border-top: 1px solid var(--color-border-default);
    color: var(--color-text-muted);
    cursor: pointer;
    transition: color var(--transition-ui), background-color var(--transition-ui);
    flex-shrink: 0;
  }
  .sidebar__toggle:hover,
  .sidebar__toggle:focus-visible {
    background: var(--color-bg-elevated);
    color: var(--color-text-primary);
    outline: none;
    box-shadow: 0 0 0 2px var(--brand-primary);
  }
  .sidebar__toggle-icon {
    width: var(--icon-sm);
    height: var(--icon-sm);
    transition: transform var(--transition-base);
  }
  .sidebar--expanded .sidebar__toggle-icon {
    transform: rotate(180deg);
  }

  /* Sidebar bottom items — logout always visible, theme toggle mobile-only.
     Padding mirrors .sidebar__list: collapsed=0, expanded=0 var(--space-2). */
  .sidebar__logout {
    margin-top: auto;
    padding: 0;
    border-top: 1px solid var(--color-border-default);
  }
  .sidebar.sidebar--expanded .sidebar__logout,
  .sidebar:hover:not(.sidebar--suspend-hover) .sidebar__logout {
    padding: var(--space-2) var(--space-2) 0;
  }
  .sidebar__theme-toggle {
    padding: var(--space-2);
  }
  .sidebar__link--logout {
    width: 100%;
  }
  .sidebar__link--logout:hover {
    color: var(--color-error);
    background: var(--color-error-bg);
  }
  .sidebar__mobile-only:not(.sidebar__logout) { display: none; }

  .app-shell .content-wrapper {
    grid-area: main;
    position: relative;
    z-index: var(--z-base);
    padding-left: var(--space-6);
    padding-right: var(--space-2);
  }

  footer.footer-distributed {
    grid-area: footer;
    padding-left: calc(var(--sidebar-collapsed) + var(--space-6));
  }

  footer.footer-distributed.cp-footer {
    grid-area: auto;
    padding-left: var(--container-gutter);
  }

  #sidebarOverlay { display: none; }
  #sidebarMobileToggle { display: none; }
}

.footer-distributed .footer-left p {
	color: var(--slate-400);
	font-size: var(--text-sm);
	margin: 0;
}

/* Chart legend table styling */
.chart-legend-table-wrapper {
  display: flex;
  flex-wrap: nowrap;
  gap: var(--space-4);
  width: 100%;
  justify-content: center;
}
@media (min-width: 1280px) {
  .chart-legend-table-wrapper {
    width: max-content;
    min-width: 100%;
  }
}

.chart-legend-table {
  width: max-content;  /* shrink to content — prevents white gap in label column */
  max-width: 100%;     /* never exceed container */
  table-layout: auto;
  margin-inline: auto;
  border-collapse: separate;
  border-spacing: 0;
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-default);
  border-radius: var(--radius-md);
  overflow: clip; /* clip instead of hidden — preserves border-radius clipping without breaking position: sticky */
}

.chart-legend-table-wrapper .chart-legend-table--with-label {
  flex: 0 0 auto;
}

/* NOTE: Add class .table-header to <th> elements in chart-legend-table templates.
   Base styling (bg, color, font-weight, uppercase, letter-spacing, padding,
   line-height, vertical-align) is provided by .table-header.
   Only sticky positioning and border (specific to chart legends) are kept here. */
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.chart-legend-table thead th' (0,3,0) vs earlier '.data-table thead :where(tr.header-row) th:first-child' (0,4,1, line 3191): chart-legend tables and data-tables are distinct components with disjoint markup; the header-row rule cannot match a chart-legend th, so keep this sticky styling with the chart-legend component. */
.chart-legend-table thead th {
/* stylelint-enable no-descending-specificity */
  border-bottom: 1px solid var(--color-border-default);
  position: sticky;
  top: 0;
  z-index: calc(var(--z-base) + 2); /* Chart legend sticky — local stacking */
  background: var(--color-bg-surface);
  white-space: normal;
  text-wrap: balance;
  overflow-wrap: break-word;
  word-break: keep-all;
  hyphens: manual;
  text-align: right;
}

/* Table body cells - base styles */
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.chart-legend-table tbody td' (0,3,0) vs earlier '.kpi-hero__breakdown-row--clickable:hover td' (0,4,0, line 2197): the kpi-hero breakdown hover row never matches a chart-legend body cell; disjoint surfaces, so the base chart-legend cell styling stays with its component. */
.chart-legend-table tbody td {
/* stylelint-enable no-descending-specificity */
  padding: var(--space-2) var(--space-3);
  text-align: right;
  color: var(--color-text-secondary);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  vertical-align: middle;
}

/* Tablet responsive adjustments */
@media (max-width: 1023.98px) {
  .chart-legend-table thead th:not(:first-child),
  .chart-legend-table tbody td:not(:first-child) {
    max-width: clamp(9rem, 45vw, 14rem);
  }

  .chart-legend-table--with-label thead th:first-child,
  .chart-legend-table--with-label tbody td:first-child {
    max-width: none;
  }
}

/* ── Chart mobile (max-width: 767px) — consolidated ── */
@media (max-width: 768px) {
  /* Table readability */
  .chart-legend-table {
    font-size: var(--text-sm);
    min-width: max-content;
  }
  .chart-legend-table thead th {
    padding: var(--space-2);
    font-size: var(--text-xs);
    white-space: nowrap;
  }
  .chart-legend-table tbody td {
    padding: var(--space-2);
    min-height: var(--touch-target-min);
  }
  .chart-legend-table tfoot td {
    padding: var(--space-2);
    font-weight: var(--font-bold);
  }
  .chart-legend-table tbody tr { cursor: pointer; }

  /* Grid overflow prevention */
  .chart-section-grid > * {
    max-width: 100%;
    overflow-x: hidden;
  }

  /* Legend panel height constraint */
  .chart-section-grid__legend-panel {
    max-height: clamp(300px, 60vh, 500px);
  }

  /* Chart canvas height optimization */
  .chart-section-grid__chart-inner > .relative {
    max-height: clamp(280px, 50vh, 400px);
  }
  .chart-section-grid__chart-inner > .relative.h-64 {
    max-height: clamp(200px, 35vh, 16rem);
    height: clamp(200px, 35vh, 16rem);
  }

  /* Legend scroll row limit */
  .chart-legend-scroll {
    max-height: calc(40px + 5 * var(--legend-row-height, 32px));
  }

  /* Touch targets */
  .legend-ds-th {
    min-height: var(--touch-target-min);
    padding: var(--space-2) var(--space-2);
    white-space: nowrap;
    vertical-align: middle;
  }
}
/* T722 B722-4: zebra par uses --color-bg-elevated (delta ~#fff→#f3f4f6 light,
 * slate-800→slate-700 dark) for perceptible contrast; hover uses a subtle inset
 * overlay so it remains visible on both the surface and the elevated zebra row. */
.chart-legend-table tbody tr:nth-child(even) td { background-color: var(--color-bg-elevated); }
.chart-legend-table tbody tr:hover td {
    background: var(--color-bg-elevated);
    box-shadow: inset 0 0 0 9999px var(--color-overlay-subtle);
}
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.chart-legend-table--with-label td:first-child' (0,3,1) vs earlier '.data-table tbody :where(tr:nth-child(odd)) td:first-child' (0,4,1, line 3201): data-table zebra striping and chart-legend label columns are disjoint markup; no element matches both, so keep the sticky label column with the chart-legend block. */
.chart-legend-table--with-label td:first-child {
/* stylelint-enable no-descending-specificity */
  font-weight: var(--font-medium);
  text-align: left;
  white-space: nowrap;
  max-width: clamp(12rem, 25vw, 20rem);
  position: sticky;
  left: 0;
  z-index: calc(var(--z-base) + 1);
  background: var(--color-bg-surface);
}
/* Truncate label text inside flex cells (clickable-x-label is display:flex) */
.chart-legend-table--with-label td:first-child .legend-label-text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.chart-legend-table--with-label th|tfoot td:first-child' / '.chart-legend-table tfoot td' vs earlier '.data-table thead :where(tr.header-row) th:first-child' (0,4,1, line 3191) and '.chart-legend-table tbody tr:nth-child(even) td' (0,4,0, line 3989): disjoint data-table rules plus an equal-locality chart-legend sibling; reordering this label/footer group out of the chart-legend block would break component locality without changing the cascade. */
.chart-legend-table--with-label th:first-child { text-align: left; }
.chart-legend-table tfoot td { text-align: right; }
.chart-legend-table--with-label tfoot td:first-child {
/* stylelint-enable no-descending-specificity */
  text-align: left;
  z-index: calc(var(--z-base) + 1);
}
/* th:first-child is sticky on BOTH axes (top + left) — needs highest z-index to cover other th and td */
.chart-legend-table--with-label thead th:first-child {
  white-space: nowrap;
  position: sticky;
  left: 0;
  z-index: calc(var(--z-base) + 3);
  background: var(--color-bg-surface);
}
.chart-legend-table tbody tr:nth-child(even) td:first-child { background-color: var(--color-bg-elevated); }
.chart-legend-table tbody tr:hover td:first-child {
    background: var(--color-bg-elevated);
    box-shadow: inset 0 0 0 9999px var(--color-overlay-subtle);
}

/* Specificity must beat .chart-legend-table--with-label td:first-child (0,2,1) */
/* stylelint-disable-next-line no-descending-specificity -- [real override] '.chart-legend-table .chart-legend-total td' (0,3,0) is intentionally placed after the '.chart-legend-table--with-label td:first-child' (0,3,1) family so the total row beats the label column; the preceding comment documents that this order is required, so it cannot be hoisted. */
.chart-legend-table .chart-legend-total td {
  font-weight: var(--font-bold);
  background-color: var(--color-bg-total-row);
  border-top: 2px solid var(--color-border-strong);
}

/* % consumed column: centered number only (bar removed in T422 Fix 2) */
.chart-legend-table .num-cell--pct {
  text-align: center;
}
.legend-pct-text {
  font-variant-numeric: tabular-nums;
  vertical-align: middle;
}



/* Base class for all chart section grids */
.chart-section-grid {
  --legend-min: 320;      /* px — minimum legend column width */
  --legend-max: 520;      /* px — maximum legend column width */
  --legend-vw: 30;        /* vw — preferred legend column width (viewport-relative) */
  --legend-squeeze: 1.15; /* table can be up to 1.15× the container before stacking */
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-items: start;
  gap: clamp(0.75rem, 2vw, 1.25rem);
  padding-top: clamp(16px, 3vw, 24px);
  padding-bottom: clamp(24px, 5vw, 40px);
  overflow-x: clip;
}

.chart-section-grid > * {
  min-width: 0;
  min-height: 0;
}

@media (max-width: 1280px) {
  .chart-section-grid {
    grid-template-columns: 1fr;
    grid-template-rows: auto auto;
    gap: clamp(12px, 2vw, 20px);
  }
}

/* ── Chart desktop (min-width: 1200px) — consolidated ── */
@media (min-width: 1280px) {
  .chart-section-grid {
    grid-template-columns: minmax(320px, clamp(320px, 30vw, 520px)) minmax(0, 1fr);
    align-items: center; /* Sprint 08 T559: homogeneizado con dual-pie-grid */
    gap: clamp(1rem, 2vw, 1.5rem);
  }
  .chart-section-grid--legend-top {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(8px, 1.5vw, 1rem);
  }
  .chart-section-grid .chart-legend-scroll { overflow-x: auto; }
  .chart-section-grid:not(.chart-section-grid--legend-top) .chart-legend-table { width: 100%; max-width: 100%; }
  .chart-section-grid__legend { justify-content: flex-start; }
  .chart-section-grid__legend-panel {
    max-height: clamp(24rem, 80vh, 44rem);
    overflow-y: auto;
    overflow-x: clip;
    scrollbar-gutter: stable both-edges;
  }
}

/* Estado de los Tickets (#tickets-estado): this tab surfaces a very dense
   table (evento / aforo / vendidos / activados / % vendido / % activado)
   alongside a horizontal-bar chart. The default grid (320-520px legend +
   1fr chart, stacked below 1280px) wastes the chart column and stacks
   on common laptop viewports. Override to 2 equal columns from 1024px so
   table and chart live side-by-side with matched widths. Scoped by ID to
   keep the other ticket tabs on the default layout. */
@media (min-width: 1024px) {
  #tickets-estado .chart-section-grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    grid-template-rows: auto;
    align-items: stretch;
    gap: clamp(1rem, 2vw, 1.5rem);
  }
  #tickets-estado .chart-section-grid__legend-panel {
    width: 100%;
    max-width: 100%;
  }
}

/* Base legend and chart styles */
.chart-section-grid__legend {
  display: flex;
  justify-content: center;
  align-items: center;
}

.chart-section-grid__legend-panel {
  width: min(100%, clamp(320px, 30vw, 520px));
  max-width: 100%;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  margin-inline: auto;
  overflow-x: clip; /* clip instead of hidden — preserves clipping without breaking position: sticky in children */
}

/* Legend-top variant: full width legend for horizontal list layout */
.chart-section-grid--legend-top .chart-section-grid__legend-panel {
  width: 100%;
  max-width: 100%;
}

.chart-section-grid__legend-panel .chart-legend-container {
  width: 100%;
}

@media (max-width: 1280px) {
  .chart-section-grid__legend {
    width: 100%;
    max-width: none;
    justify-content: center;
  }

  .chart-section-grid__legend-panel {
    width: 100%;
    max-width: 100%;
    max-height: clamp(400px, calc(100vh - 12rem), 600px);
    overflow-y: auto;
    overflow-x: auto;
    scrollbar-gutter: stable both-edges;
  }

  .chart-legend-container {
    width: 100%;
    max-width: 100%;
    overflow-x: auto;
    scrollbar-width: thin;
  }
  .chart-legend-container::-webkit-scrollbar { height: 6px; }
  .chart-legend-container::-webkit-scrollbar-track {
    background: var(--color-bg-elevated);
    border-radius: var(--radius-xs);
  }
  .chart-legend-container::-webkit-scrollbar-thumb {
    background: var(--color-text-muted);
    border-radius: var(--radius-xs);
  }
  .chart-legend-container::-webkit-scrollbar-thumb:hover {
    background: var(--color-text-secondary);
  }
}

/* Must come AFTER all .chart-section-grid rules so source-order wins */
.chart-section-grid[hidden],
.chart-section-grid.hidden { display: none; }

/* R-DASH-1: canvas/svg interno oculto cuando el chart no tiene datos. El
 * container del chart sigue visible para que `.section-empty-state` se vea
 * centrado. Aplicado simétricamente por applyEmptyStateUniform /
 * clearSectionEmptyState en dashboard-charts.js. Selectores por tipo+clase
 * dan specificity 0-1-1 — gana al UA stylesheet (canvas { display: inline-block })
 * sin necesidad de !important (stylelint declaration-no-important).
 * Histórico: PR #1292 introdujo la regla; PR #1375 (commit c538d2e3) la
 * borró accidentalmente en refactor dark-mode tint tokens. Guard CI
 * scripts/check_chart_hidden_css_present.py vela su presencia. */
canvas.chart-hidden,
svg.chart-hidden { display: none; }

.chart-section-grid__chart {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: stretch;
  min-height: clamp(200px, 40vh, 300px);
}

/* #1653 R-DASH-1: colapsa los dos tracks del grid (legend + chart) cuando no hay
 * datos, para que `.section-empty-state` (grid-column: 1/-1) sea el ÚNICO elemento
 * visible del body. Specificity 0-2-0 gana a las bases 0-1-0 sin !important, en
 * contexto GLOBAL (no @media). Conservado por su test de contrato
 * (test_chart_section_grid_empty_collapse.py); para chart-section-grid queda
 * subsumido por el control genérico `.chart-grid--empty > :not(.section-empty-state)`
 * (junto a `.section-empty-state`), que además cubre dual-pie y layouts futuros. */
.chart-section-grid__legend.chart-hidden,
.chart-section-grid__chart.chart-hidden { display: none; }

.chart-section-grid__chart-inner {
  width: 100%;
  max-width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  flex: 1;
}

.chart-section-grid__chart-inner > .relative {
  width: 100%;
  max-height: 400px;
  display: flex;
  flex-direction: column;
}

/* Pie charts need limited height */
.chart-section-grid__chart-inner > .relative.h-64 {
  max-height: 16rem;
  height: 16rem;
  flex: 0 0 auto;
}


/* "Finished" rows (events table + the chart legend rows, which get the same
   class from dashboard-charts.js). NEVER express this state with `opacity`:
   it composites the ink against the row background, so an already-muted text
   drops to ~2.7:1 and the aforo/importe figures become unreadable. Italic +
   the subtler surface carry the same meaning at full contrast. */
tr.event-finished {
  font-style: italic;
  color: var(--color-text-secondary);
}

/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.event-finished td' (0,2,0) vs earlier '.kpi-hero__breakdown-row--clickable:hover td' (0,4,0, line 2197): the kpi-hero hover row never matches an event-finished cell; disjoint surfaces, so keep the finished-event background with its table styling. */
.event-finished td {
/* stylelint-enable no-descending-specificity */
  background-color: var(--color-bg-page);
}

/* Legend container responsive scroll */
div.chart-legend-scroll {
  display: block;
  max-height: 25rem;
  max-width: calc(100% - 2rem);
  overflow-x: auto;
  overflow-y: auto;
}

/* WCAG 2.1.1 + 2.4.11: when the legend overflows, JS marks it `tabindex="0"`
   so it can be panned with the keyboard. The global `:focus-visible` ring uses
   `outline-offset: 2px`, which the ancestor `overflow: clip` of
   `.chart-section-grid__legend-panel` crops on the scrolling axis — inset the
   ring so it stays fully visible. Tokens only, and both themes are covered:
   `--brand-primary-dark` is #0369a1 on light (contrast-safe against the legend
   surface) and aliases to #33BEFF under `[data-theme="dark"]`. */
.chart-legend-scroll:focus-visible {
  outline: 2px solid var(--brand-primary-dark);
  outline-offset: -2px;
  box-shadow: 0 0 0 4px var(--brand-focus-ring);
}

.chart-legend-scroll--active .chart-legend-table-wrapper {
  justify-content: flex-start;
}
.chart-legend-scroll--active .chart-legend-table {
  margin-inline: 0;
}
.chart-legend { width: 100%; }

/* Clickable X labels - consolidated definition */
.clickable-x-label {
  display: flex;
  align-items: center;
  gap: var(--space-1-5);
  color: var(--brand-primary-dark);
  cursor: pointer;
  text-decoration: none;
  border-radius: var(--radius-sm);
  -webkit-tap-highlight-color: transparent;
}

/* T761: cuando `.clickable-x-label` se aplica directamente al `<td>` (4 usos
   actuales en dashboard-charts.js: rows 699/1264/1404/1587), `display: flex`
   rompe el flow `table-cell` y las alturas de fila divergen — además el
   sticky positioning del primer td queda incoherente. Forzamos `table-cell`
   y `vertical-align: middle`. El swatch + label-text dentro siguen alineados
   por `vertical-align: middle` de inline-block (legend-swatch ya lo declara)
   y el `margin-right` que aporta `.legend-row-swatch`. */
td.clickable-x-label,
th.clickable-x-label {
  display: table-cell;
  vertical-align: middle;
}

.clickable-x-label:hover {
  text-decoration: underline;
  background: var(--color-brand-tint-12);
  color: var(--brand-primary-dark);
}

/* Legend filter button — click to apply dashboard filter */
.legend-filter-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--icon-lg);
  height: var(--icon-lg);
  padding: 0;
  border: none;
  background: transparent;
  color: var(--color-text-muted);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: color var(--transition-fast), background-color var(--transition-fast);
}
.legend-filter-btn:hover {
  color: var(--brand-primary-dark);
  background: var(--color-brand-tint-12);
}
.legend-filter-btn:active {
  transform: scale(0.9);
}
.legend-filter-btn--active {
  color: var(--brand-primary-dark);
  background: var(--color-brand-tint-12);
}

/* Dataset header cell + swatch */
.legend-ds-th {
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

.chart-section-grid__legend-panel .legend-ds-th {
  white-space: normal;
  line-height: var(--leading-snug);
}

.legend-ds-th.is-hidden { opacity: .55; }

.legend-swatch {
  display: inline-block;
  width: var(--legend-swatch-size);
  height: var(--legend-swatch-size);
  min-width: var(--legend-swatch-size);
  min-height: var(--legend-swatch-size);
  border-radius: var(--radius-xs);
  border: 1px solid var(--color-border-strong);
  background-color: var(--legend-color);
  flex-shrink: 0;
  vertical-align: middle;
}

.legend-swatch.legend-row-swatch { min-width: var(--space-2-5); }
.legend-row-swatch {
    margin-right: var(--space-2);
}
.legend-info-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--icon-xs);
    height: var(--icon-xs);
    margin-left: var(--space-1);
    border-radius: 50%;
    background: var(--color-bg-muted);
    color: var(--color-text-muted);
    font-size: var(--text-3xs);
    font-weight: 700;
    cursor: help;
    vertical-align: middle;
}
/* #1903: the "?" glyph comes from CSS, not JS textContent, so the legend <th>
   innerText stays exactly "Validados" for the rc1-no-percent-prefix regression spec. */
.legend-info-icon::after {
    content: '?';
}
/* Info bubble — reuses kpi-bubble visuals but lives on <body> (escapes overflow) */
.info-bubble {
    display: none;
    position: fixed;
    z-index: var(--z-tooltip);
    width: max-content;
    max-width: 260px;
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-md);
    background: var(--color-bg-elevated);
    color: var(--color-text-primary);
    font-size: var(--text-xs);
    font-weight: var(--font-normal);
    line-height: var(--leading-snug);
    box-shadow: 0 4px 12px rgba(var(--shadow-color), 0.2);
    pointer-events: none;
}
.info-bubble::before {
    content: '';
    position: absolute;
    top: 100%;
    left: var(--arrow-left, 1.25rem);
    border: 6px solid transparent;
    border-top-color: var(--color-bg-elevated);
}
.info-bubble.kpi-bubble--below::before {
    top: auto;
    bottom: 100%;
    border-top-color: transparent;
    border-bottom-color: var(--color-bg-elevated);
}
.info-bubble.kpi-bubble--visible {
    display: block;
    animation: fadeInUp var(--dur-fast) ease-out;
}

/* Chart control actions (Ver Todo, Restaurar) — secondary, understated */
.btn-reset-columns {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1-5);
  font-size: var(--text-xs);
  line-height: 1;
  padding: var(--space-1-5) var(--space-2-5);
  border-radius: var(--radius-full);
  border: 1px solid var(--color-border-default);
  color: var(--color-text-secondary);
  background: transparent;
  transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.btn-reset-columns:hover {
  border-color: var(--brand-primary);
  color: var(--brand-primary-dark);
  background: var(--color-brand-tint-12);
}
.btn-reset-columns:active { transform: translateY(1px); }
.btn-reset-columns + .btn-reset-columns {
  margin-left: var(--space-2);
}

/* 085 HR-05: removed float:right — .footer-container uses flexbox */
.footer-distributed .footer-right {
	margin-top: var(--space-2);
	max-width: 180px;
}

.footer-distributed .footer-right a {
	display: inline-block;
	border-radius: var(--radius-xs);
	font-size: var(--text-xl);
	color: var(--color-text-inverse);
	text-align: center;
	line-height: 1.75;
	margin-left: var(--space-1);
	transition: color var(--transition-slow), transform var(--transition-slow);
}

.footer-distributed .footer-right a:hover {
	transform: scale(1.1);
}

/* Media Queries */

@media (max-width: 639px) {

	.footer-distributed .footer-left {
		text-align: center;
	}

	.footer-distributed .footer-right {
		text-align: center;
		float: none;
		margin: 0 auto var(--space-5);
	}

	.co-btn-premium {
		font-size: var(--text-lg);
		min-height: var(--touch-target-cta);
		max-width: 100%;
		padding: var(--space-3) var(--space-4);
		border-radius: var(--radius-md);
	}
}

/* ── CargaOnline premium button system ── */
.co-btn-premium--recarga  { --_btn-gradient: var(--gradient-btn-recarga); }
.co-btn-premium--envio    { --_btn-gradient: var(--gradient-btn-envio); }

.co-btn-premium {
    display: flex;
    align-items: center;
    justify-content: center;
    background-image: var(--_btn-gradient, none);
    background-color: var(--slate-900);
    border: 0;
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    color: var(--color-text-inverse);
    font-size: var(--text-lg);
    font-weight: var(--font-semibold);
    line-height: var(--leading-tight);
    max-width: 480px;
    margin-inline: auto;
    width: 100%;
    min-height: 52px;
    padding: var(--space-4) var(--space-6);
    text-decoration: none;
    touch-action: manipulation;
    cursor: pointer;
    transition: opacity var(--transition-fast), box-shadow var(--transition-fast);
}
.co-btn-premium:hover {
    opacity: 0.9;
    box-shadow: var(--shadow-md);
}
.co-btn-premium:focus-visible {
    outline: 2px solid var(--brand-focus-ring);
    outline-offset: 2px;
}
.co-btn-premium:disabled,
.co-btn-premium[aria-disabled="true"] {
    opacity: 0.5;
    cursor: not-allowed;
}
.co-btn-premium:active:not(:disabled) {
    opacity: 0.85;
}
/* stylelint-disable no-descending-specificity -- [real override] ':where([data-theme=dark]) .co-btn-premium' (0,1,0, :where=0) vs earlier '.co-btn-premium:hover' (0,2,0, line 4516): the dark theming is a deliberate override of the base premium button; placing it before the :hover state would split the dark-mode block, and the :where() keeps it intentionally low so :hover still wins on interaction. */
:where([data-theme="dark"]) .co-btn-premium {
/* stylelint-enable no-descending-specificity */
    box-shadow: var(--shadow-md);
}

/* Estilos para los inputs del modal envio a un amigo*/
.modal-content label {
	display: block;
	/* T806 H8: usa token semántico — sustituye --space-1 (4px) insuficiente */
	margin-bottom: var(--modal-label-gap);
	font-weight: var(--font-bold);
	color: var(--color-text-primary);
}

/* Guerra de especificidad — el interruptor compartido DENTRO de un modal.
   Misma guerra que `.selection-field__list .selection-field__row` (más abajo en
   este fichero), y la causa del desfase que reportó el owner en «Aplicar
   descuento de grupo» y «Mostrar en venta online».

   El `<label>` que envuelve al checkbox (`components/toggle.html`) lleva la
   utilidad `.inline-flex` (0,1,0). La regla de ARRIBA es `.modal-content label`
   (0,1,1): dentro de un modal GANA por especificidad —no por orden ni por
   PurgeCSS, la utilidad SÍ está en `tailwind.output.css`— y el label computa
   `display: block`. Con un contenedor de bloque `items-center` NO HACE NADA, y
   como `.toggle__track` es un `<div>` (nivel bloque) el carril se pinta pegado
   al BORDE SUPERIOR de la caja de 44px que le da `min-h-touch`: su centro queda
   12px por encima del centro de la fila, que es justo lo que se ve.

   Ojo al medir: las CAJAS del label y del texto sí están centradas entre sí. Lo
   que está descolocado es lo que se pinta DENTRO, así que un test que mire el
   `getBoundingClientRect()` del label no lo detecta — hay que mirar
   `.toggle__track`. Lo fija
   `e2e/tests/regression/toggle-switch-alignment.spec.ts`.

   Se restaura aquí y no en el componente porque el problema es exclusivo del
   modal: fuera de `.modal-content` la utilidad ya gana sola. (0,2,1) le gana a
   (0,1,1) por especificidad, así que el arreglo no depende del orden del
   fichero. `inline-flex` y no `flex`: el label es un item flex de
   `.toggle-field`, que lo BLOQUIFICA a `flex` igual — y fuera de un contenedor
   flex sigue fluyendo con el texto, que es lo que quieren los consumidores sin
   `tip=`. */
.modal-content label.inline-flex {
	display: inline-flex;
	align-items: center;
}

/* T806 H9: separación tokenizada entre fieldset y siguiente elemento de sección.
   :where() reduce specificity a 0 — evita conflicto no-descending-specificity
   con selectores de otras secciones que también terminan en child/sibling *. */
:where(.modal-content fieldset + *) {
    margin-top: var(--modal-section-gap);
}
/* T806 H9: separación tokenizada entre h3 de sección y siguiente elemento */
:where(.modal-content h3 + *) {
    margin-top: var(--modal-section-gap);
}

.modal-content select,
.modal-content input[type="text"],
.modal-content input[type="email"],
.modal-content input[type="number"],
.modal-content input[type="password"],
.modal-content input[type="date"],
.modal-content input[type="datetime-local"],
.modal-content textarea {
	width: 100%;
	padding: var(--space-2) var(--space-3);
	min-height: var(--touch-target-min);
	border: 1px solid var(--color-border-default);
	border-radius: var(--radius-md);
	margin-bottom: var(--space-4);
	font-size: var(--text-base);
	background-color: var(--color-bg-surface);
	color: var(--color-text-primary);
}

.modal-content :where(input[type="text"]:read-only),
.modal-content :where(input[type="email"]:read-only),
.modal-content :where(input[type="number"]:read-only),
.modal-content :where(input[type="password"]:read-only),
.modal-content :where(input[type="date"]:read-only),
.modal-content :where(input[type="datetime-local"]:read-only),
.modal-content :where(textarea:read-only),
.modal-content :where(select:disabled) {
	background-color: var(--color-bg-page);
	color: var(--color-text-muted);
	border-color: var(--color-border-default);
	cursor: not-allowed; /* Cursor para indicar que no se puede editar */
}

/* Estilos para el botón de enviar en el modal */

.modal-content button[type="submit"]:hover {
	background-color: var(--brand-primary-active);
}

/* Estilos para el fondo y el contenido del modal */
.modal {
	position: fixed;
	z-index: var(--z-modal);
	inset: 0;
	overflow: hidden;
	background-color: var(--color-overlay);
	padding: calc(var(--header-height) + var(--space-4)) var(--space-4) var(--space-4);
	box-sizing: border-box;
}

/* T901: modal de confirmación (panel pequeño centrado vertical+horizontal).
   Override del padding por header — el confirm aparece sin anchorlo al header
   porque suele invocarse sobre otro modal o como diálogo independiente. */
.modal--centered {
	display: flex;
	align-items: center;
	justify-content: center;
	padding: var(--space-4);
	z-index: var(--z-confirm-modal);
}

/* T955: .modal--centered{display:flex} gana a .hidden{display:none} por orden de carga
   (main.css posterior a tailwind.output.css, misma especificidad 0,1,0). Selector doble
   (0,2,0) garantiza que modales con .hidden siempre queden ocultos. */
.modal.hidden {
	display: none;
}

.modal-content {
	background-color: var(--color-bg-surface);
	margin: 0 auto;
	padding: var(--space-5);
	border-radius: var(--radius-lg);
	max-width: var(--modal-width-default);
	max-height: calc(100vh - var(--header-height) - 2rem);
	overflow-y: auto;
	/* T807 H7: reserve space for the scrollbar so content width stays stable
	   when overflow occurs — prevents the last column from being clipped.
	   Fallback for Safari < 15.4: extra padding-right avoids relying on the
	   property which had no effect there. */
	scrollbar-gutter: stable;
	@supports not (scrollbar-gutter: stable) {
		padding-right: calc(var(--space-5) + var(--scrollbar-width-fallback));
	}
	box-shadow: var(--shadow-md);
	display: flex;
	flex-direction: column;
	width: 100%;
	box-sizing: border-box;
	color: var(--color-text-primary);
	border-color: var(--color-border-default);
}

/* #1866: make <form> a flex child so .modal-body scrolls and .modal-footer
   stays pinned at the bottom — without this the form expands unbounded and
   pushes the footer off-screen when the body content is tall. */
.modal-content > form {
	display: flex;
	flex-direction: column;
	min-height: 0;
	flex: 1 1 auto;
	overflow: hidden;
}

/* ---------------------------------------------------------------------------
   MODAL-DARK (T156): explicit dark-mode overrides for admin modals
   ---------------------------------------------------------------------------
   Although many tokens already flip via :root [data-theme="dark"], admin
   modals (mainModalHeader.html / mainModalFooter.html and friends) were
   reported as washed-out in dark mode because --color-bg-surface (slate-800)
   has insufficient contrast against --color-overlay (0.65 black) and the
   page background. We promote the modal surface to --color-bg-elevated
   (slate-700) and re-assert text + border tokens so header/body/footer
   sections render consistently. These rules are scoped to [data-theme="dark"]
   and therefore have zero effect on the light theme.

   NOTE (stylelint): the dark overrides below are declared BEFORE the base
   layout rules for narrative reasons (dark-surface fix, then layout).
   Swapping them would break the section's storytelling without changing
   the effective cascade, so this block suppresses no-descending-specificity.
   --------------------------------------------------------------------------- */
/* stylelint-disable no-descending-specificity -- [equal-specificity trap] dark modal block: ':where([data-theme=dark]) .modal .modal-header|.modal-body|.modal-footer|h2' (0,2,0 / 0,3,0, :where=0) is declared BEFORE the base '.modal-header' (0,1,0, line 4703), '.modal-body' (line 4729), '.modal-footer' (line 4736) and '.modal-header h2' (line 4710). Once :where() is discounted these dark rules are equal-or-higher and intentionally ship first; reordering the base rules above them re-triggers the PR #2186 dark-modal regression. Whole dark-modal group kept in source order. */
:where([data-theme="dark"]) .modal {
	background-color: var(--color-overlay);
}
:where([data-theme="dark"]) .modal .modal-content {
	background-color: var(--color-bg-elevated);
	color: var(--color-text-primary);
	border: 1px solid var(--color-border-default);
}
:where([data-theme="dark"]) .modal .modal-header,
:where([data-theme="dark"]) .modal .modal-header h2 {
	color: var(--color-text-primary);
	border-color: var(--color-border-default);
}
:where([data-theme="dark"]) .modal .modal-body {
	color: var(--color-text-primary);
	background-color: transparent;
}
:where([data-theme="dark"]) .modal .modal-footer {
	border-top-color: var(--color-border-default);
	color: var(--color-text-primary);
}

/* Modal header layout */
.modal-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-3);
  margin-bottom: var(--space-4);
}
.modal-header h2 {
  font-size: var(--text-xl);
  font-weight: var(--font-bold);
  color: var(--color-text-primary);
  margin: 0;
  line-height: var(--leading-tight);
}

/* Modal size modifiers — use for content-heavy modals (mass operations, multi-column forms) */
.modal-content--lg {
  max-width: var(--modal-width-lg);
}
@media (min-width: 768px) {
  .modal-content--lg {
    min-width: min(700px, 95vw);
  }
}

/* Modal body — scrollable content area */
.modal-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* Modal footer — actions row, right-aligned, wraps on mobile */
.modal-footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-2);
  margin-top: var(--space-4);
  padding-top: var(--space-4);
  border-top: 1px solid var(--color-border-default);
}
/* stylelint-enable no-descending-specificity */

/* Destructive action (Eliminar) docks to the left of the modal footer
   so it sits away from the primary action. Keeps justify-content
   flex-end intact for the common 1-button case. */
.modal-footer #deleteBtn,
.modal-footer .btn-danger-outline {
  margin-right: auto;
}

/* Modal close button — visible, accessible, 44px touch target */
.close-modal {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: var(--touch-target-min);
  height: var(--touch-target-min);
  min-width: var(--touch-target-min);
  font-size: var(--text-xl);
  font-weight: var(--font-bold);
  line-height: 1;
  color: var(--color-text-muted);
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border-default);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.close-modal:hover,
.close-modal:focus-visible {
  color: var(--color-text-primary);
  background: var(--color-bg-page);
  border-color: var(--color-border-strong);
}

/* T906: keep this breakpoint in sync with --breakpoint-sm (640px) and
   tailwind.config.js sm: 640px. CSS3 media queries no aceptan var() en
   la condition; el valor 639 = --breakpoint-sm - 1px se mantiene literal
   pero el comentario documenta el contrato. */
@media (max-width: 639px) {
    .modal {
        padding: var(--space-3) var(--space-2);
    }
    .modal-content {
        width: 100%;
        max-width: none;
        max-height: calc(100vh - 1.5rem);
        max-height: calc(100dvh - 1.5rem);
        overflow-y: auto;
    }
}

.modal-form-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-4);
}
.modal-checkbox-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-3);
}
/* #3229: `>` en vez de descendiente. La variante con "i" de
   `components/toggle.html` anida un <label> de texto DENTRO de la fila; con el
   selector descendiente heredaba `width:100%` y empujaba al interruptor fuera
   de la celda. El hijo directo es lo que esta regla siempre quiso decir (hoy
   un <label> por celda, o un <span> cuando la celda lleva su tooltip). */
.modal-checkbox-grid > label,
.modal-checkbox-grid > span {
    min-height: var(--touch-target-min);
    width: 100%;
    display: inline-flex;
    align-items: center;
    /* T807 H10: flex-start keeps checkbox toggle adjacent to its label text;
       space-between was pushing toggle to the far right in wide grid cells. */
    justify-content: flex-start;
    gap: var(--modal-label-gap);
    cursor: pointer;
}
@media (max-width: 639px) {
    .modal-form-grid,
    .modal-checkbox-grid {
        grid-template-columns: 1fr;
    }
}

/* Formulario de PÁGINA del panel (#3369). Casi todos los formularios de
   crear/editar del panel viven en un modal, y el modal ya acota la medida del
   campo: `--modal-width-default`. Un formulario que ocupa una pantalla entera
   no tiene esa acotación, y medido a 1440 px un campo de UNA línea —«BIC/SWIFT»,
   11 caracteres— se estiraba a 1216 px de ancho.

   ⛔ NO es el envoltorio de gutter que la cláusula C1 prohíbe, y la diferencia
   importa: aquí sólo se acota la MEDIDA del formulario. La cabecera de la
   pantalla sigue siendo el primer hijo de `#main`, a carril completo y con el
   único gutter que pone `#main` — que es lo que hacía que esta pantalla se
   viera distinta de las otras diez. El ancho sale del MISMO token que usa el
   panel en sus modales, para que no haya dos medidas de formulario. */
.panel-page-form {
    max-width: var(--modal-width-default);
}

/* Medida del CAMPO dentro de un formulario de PAGINA.

   Orden del owner (2026-09-20): «siempre deben tener inputs del tamano del
   dato que vayamos a poner». El panel ya dice QUIEN pone la medida —el
   CONTENEDOR, nunca el campo: `.modal-content` la pone en el modal y
   `.panel-page-form` (justo arriba) en la pagina—. Esto es el MISMO principio
   un nivel mas abajo: la celda deja de estirar al campo cuando el propio dato
   ya declara cuanto mide.

   Y la declara el DATO, no la hoja de estilos: el widget sirve server-side
   `maxlength`, `pattern`, `step`, `inputmode`... y ahora tambien `size`, que
   es el atributo HTML cuyo significado literal es «este campo mide N
   caracteres MEDIOS de la fuente» — medios, no del dato: en una proporcional
   una mayuscula pasa de la media, asi que la cota fina se comprueba en px en
   el e2e y en caracteres en el guard Python. Por eso aqui no hay ni un ancho
   escrito: no hay px, no hay ch, no hay un token nuevo de anchura. `datetime-local` y `number` no admiten
   `size` —su medida la pone el control nativo, con su calendario o su
   spinner—, y `width:auto` es justo lo que la deja salir.

   Mobile-first, y esto es lo que la regla NO hace: por debajo de 640 px no
   existe. Medido a 375, cada campo ocupa los 328 px de su columna y TODOS los
   datos caben holgados ahi; acotar tambien abajo dejaria el IBAN en 210 px
   dentro de una pantalla de 375 con el hueco al lado, que es el defecto
   contrario. El mismo 640 al que colapsa `.modal-form-grid`.

   `.co-refund-form` (#3446) es el formulario de devoluciones del PORTAL, y entra
   AQUI y no en un bloque propio a proposito: es la misma regla, no un segundo
   mecanismo — dos medidas de formulario de devoluciones es justo lo que no
   queremos. El selector es solo un gancho: NO acota la caja del formulario (el
   owner pidio inputs de ancho razonable, no un formulario mas estrecho), solo
   devuelve al input su ancho intrinseco para que su `size` signifique algo. Sin
   esto el `size` del widget seria inerte, porque los inputs del portal llevan
   `w-full`.

   `.refund-period-form` (#3446, segunda vuelta) es el MODAL de «Crear periodo de
   devoluciones» de `/refunds/`, y es la pantalla por la que el owner pregunto:
   la de PAGINA (`/refunds/create/`) ya estaba medida, pero no la enlaza NADIE
   —se llega escribiendo la URL— mientras que el boton de la lista abre este
   modal. Aqui el `size` no lo mataba `w-full` sino
   `.modal-content input[type="text"] { width: 100% }` de mas arriba: misma
   consecuencia —los diez campos a fila completa, 547 px medidos en Chromium— y
   por eso el mismo remedio. Gana por orden de aparicion, no por especificidad:
   las dos reglas empatan en (0,2,1) y esta va despues.

   Los `datetime-local` y los `number` se nombran con el gancho del formulario y
   NO con `.modal-content`, aunque la regla seria identica: `.modal-content
   input[type="number"]` alcanzaria a TODOS los modales del panel, y eso es un
   cambio de medida a ciegas en pantallas que nadie ha mirado. `input[size]` si
   podria ir por `.modal-content` sin riesgo (los unicos `size` del repo son los
   de este formulario), pero entonces la misma decision viajaria por dos
   caminos distintos segun el tipo de campo. Un gancho, un formulario. */
@media (min-width: 640px) {
    .panel-page-form input[size],
    .panel-page-form input[type="datetime-local"],
    .panel-page-form input[type="number"],
    .co-refund-form input[size],
    .refund-period-form input[size],
    .refund-period-form input[type="datetime-local"],
    .refund-period-form input[type="number"] {
        width: auto;
        max-width: 100%;
    }
}

/* Permission switch list (#2334) — single-column premium list of permission
   switches in the device-edit modal. Replaces the cramped 2-col
   .modal-checkbox-grid for dispositivos: each row shows the permission name
   plus a visible helper description on the left and the switch on the right,
   separated by a subtle divider with a hover surface. The toggle component
   label already carries .min-h-touch, keeping every row a >=44px touch target.
   Tokens only; dark theme inherited via the --color-* flips. */
.permission-switch-list {
    display: flex;
    flex-direction: column;
}
.permission-switch-list > label,
.permission-switch-list > span {
    /* Sin padding vertical A PROPOSITO. El `min-h-touch` del label del
       interruptor ya reserva 44px (WCAG 2.5.8) y ESO es el alto de la fila: un
       `padding-block` encima sólo estira un objetivo táctil que ya cumplía por
       sí solo. Con los 12px de arriba y abajo cada fila medía 68,8px y las diez
       del bloque «Permisos» sumaban 715,2px; sin ellos son 44,8px y 475,2px
       —240px menos (−34%)— con el área táctil intacta. El padding lateral SÍ se
       queda: es lo que separa el texto del borde de la superficie de hover.
       Si algún día hay que separar las filas entre sí, va con `gap` en este
       contenedor, nunca devolviendo el padding a la fila.

       Y se CAE el `align-items: flex-start` que había aquí: alineaba el
       interruptor con la primera línea del bloque «nombre + descripción», y esa
       descripción ya no existe (#3229 la quitó; hoy la ayuda es la "i" de
       `tip=`, que va DENTRO del `.field-label`). Sin subtítulo, `flex-start`
       pegaba las dos cajas por arriba y de paso TAPABA el defecto de
       `.modal-content label` —el carril quedaba a 2px del texto en vez de a
       12px—.
       ⚠️ OJO AL SENTIDO, que una version anterior de este comentario lo decia
       al reves y mandaba al siguiente lector a restaurar la declaracion
       equivocada: el que enmascara es ESTE `flex-start`, NO el padding. Medido
       en los cuatro escenarios: quitar solo el padding y conservar `flex-start`
       deja el desfase en 2,0px; los 12,0px aparecen al quitar solo el
       `flex-start`. El commit sigue siendo atomico porque el arreglo del alto
       arrastra forzosamente a este `flex-start`, no porque el padding tape nada. Al caer, manda el `align-items: center` del
       propio `.toggle-field`, que es lo que su comentario ya declara correcto:
       con la "i" junto a la etiqueta, un nombre de dos líneas ya no descoloca
       nada (medido: fila de 48,8px, carril centrado a 0,4px). */
    padding-block: 0;
    padding-inline: var(--space-3);
    gap: var(--space-3);
    border-radius: var(--radius-md);
    border-bottom: 1px solid var(--color-border-default);
}
/* :where() keeps :last-child / :hover at (0,1,1) — the same specificity as the
   base `.permission-switch-list > label` rule above — so source order alone
   decides the override. Without it these pseudo-class variants climb to (0,2,1)
   and trip stylelint no-descending-specificity against the lower-specificity
   `.form-check label` / `.dashboard-filters__dropdown-item label` rules that
   ship later in the file (same technique as `.form-check:where(:hover) label`). */
.permission-switch-list > label:where(:last-child),
.permission-switch-list > span:where(:last-child) {
    border-bottom: 0;
}
.permission-switch-list > label:where(:hover),
.permission-switch-list > span:where(:hover) {
    background-color: var(--color-bg-muted);
}

/* ── Body scroll lock for open modals ── */
/* T807 H11/T961 B1: JS scroll-restore pattern captures window.pageYOffset and
   restores on close. Both <html> and <body> get overflow:hidden so scroll is
   locked on all browsers including iOS Safari where overflow:hidden on body
   alone is insufficient. No position:fixed — removes sticky sidebar flicker
   (T961). `scrollbar-gutter: stable` prevents 17px layout-shift. */
html.modal-open {
    overflow: hidden;
}
body.modal-open {
    overflow: hidden;
    /* T807: stable gutter prevents layout shift when scrollbar disappears */
    scrollbar-gutter: stable;
}


/* T954 / T1012: scroll lock canónico — alineado con .modal-open (sin position:fixed). */
html.scroll-locked {
    overflow: hidden;
}
body.scroll-locked {
    overflow: hidden;
    scrollbar-gutter: stable;
}

.login-image {
        background-image: linear-gradient(to bottom,
                        rgba(255, 255, 255, 0.95),
                        var(--color-brand-tint-35),
                        var(--color-brand-tint-66),
                        rgba(3, 105, 161, 0.50)), url('../images/login.jpg');
        background-size: cover;
        background-position: center;
        background-repeat: no-repeat;
        min-height: 100%;
        backdrop-filter: blur(100px);
}
/* ── Login gradient (GAP-1: not extracted to token by 033) ── */
:where([data-theme="dark"]) .login-image {
    background-image: linear-gradient(to bottom,
        rgba(15, 23, 42, 0.95),
        rgba(30, 41, 59, 0.85),
        var(--color-brand-tint-25),
        rgba(15, 23, 42, 0.92)), url('../images/login.jpg');
}

.form-control, .form-select {
    border: 1px solid var(--color-border-default);
    padding: var(--space-2);
    min-height: var(--touch-target-min);
    border-radius: var(--radius-sm);
}
/* ── Forms: browser defaults inputs to white bg ── */
:where([data-theme="dark"]) .form-control,
:where([data-theme="dark"]) .form-select {
    background-color: var(--color-bg-surface);
    border-color: var(--color-border-default);
    color: var(--color-text-primary);
}

/* ── Theme Transition Override ──
   Smooth theme transition (applied only during toggle, not on load).
   Must appear AFTER all base component definitions to satisfy
   no-descending-specificity (compound selectors reference bases above). */
:where(html.theme-transitioning),
:where(html.theme-transitioning) > body,
:where(html.theme-transitioning) .card,
/* stylelint-disable no-descending-specificity -- [real override] ':where(html.theme-transitioning) .btn-primary|.btn-ghost|.btn-neutral' (0,1,0, :where=0) vs earlier '.btn-primary:hover' (0,2,0, line 1791), '.btn-ghost:hover' (line 1860), '.btn-neutral:hover' (line 1812): the transition layer must ship after all base components (see comment above) and its low :where() specificity is intentional so :hover keeps winning; it cannot precede the button :hover rules. */
:where(html.theme-transitioning) .btn-primary,
:where(html.theme-transitioning) .btn-ghost,
:where(html.theme-transitioning) .btn-neutral,
/* stylelint-enable no-descending-specificity */
html.theme-transitioning header,
html.theme-transitioning nav,
/* stylelint-disable no-descending-specificity -- [real override] ':where(html.theme-transitioning) main' (0,0,1, :where=0) vs earlier '.content-wrapper > main:not(.co-main)' (0,2,1, line 2671): the transition selector is intentionally low-specificity and grouped with the other theme-transition targets; the higher content-wrapper rule already wins layout, so this only adds a transition and must stay in the transition block. */
:where(html.theme-transitioning) main,
/* stylelint-enable no-descending-specificity */
:where(html.theme-transitioning) .data-table,
:where(html.theme-transitioning) .form-control,
:where(html.theme-transitioning) .form-select {
    transition: background-color var(--transition-base) ease,
                color var(--transition-base) ease,
                border-color var(--transition-base) ease,
                box-shadow var(--transition-base) ease !important; /* stylelint-disable-line declaration-no-important -- force transitions during theme switch */
}

/* Standard form input — WCAG-compliant, token-based, dark-mode ready */
.form-input {
    width: 100%;
    padding: var(--space-2) var(--space-3); /* 8px 12px — ~40px height with text-sm */
    min-height: var(--touch-target-min); /* WCAG touch target */
    font-size: var(--text-sm);
    font-family: inherit;
    color: var(--color-text-primary);
    background-color: var(--color-bg-input);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
    transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.form-input:focus-visible {
    border-color: var(--brand-primary);
    box-shadow: 0 0 0 2px var(--brand-focus-ring);
    outline: none; /* override global :focus-visible since we use box-shadow here */
}
.form-input:disabled {
    background-color: var(--color-bg-disabled);
    color: var(--color-text-disabled);
    cursor: not-allowed;
}
.form-input::placeholder {
    color: var(--color-text-placeholder);
}

/* Multiple-select dropdown bottom spacing */
.table-scroll-container {
    margin-bottom: var(--space-4);
}

.triangle-asc:before {
    content: "\25B2";  /* Black up-pointing triangle */
}

.triangle-desc:before {
    content: "\25BC";  /* Black down-pointing triangle */
}

.sortable {
    cursor: pointer;
    user-select: none;
}

.sortable:focus-visible {
    outline: 2px solid var(--brand-primary-dark);
    outline-offset: 2px;
    border-radius: var(--radius-sm);
}

.fill-content {
	min-width: 100%; /* fallback */
	min-width: stretch;
}



/* ——— Form-check inline ——— */
.form-check {
  display: flex;
  align-items: center;
  gap: var(--space-2);         /* espaciado interno */
  margin-bottom: var(--space-3);
}
.form-check input[type="checkbox"] {
  width: 1.25rem;
  height: 1.25rem;
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  accent-color: var(--brand-primary);
  cursor: pointer;
}
.form-check label {
  font-size: var(--text-sm);
  font-weight: var(--font-medium);
  color: var(--color-text-secondary);
  cursor: pointer;
  user-select: none;
}
.form-check:where(:hover) label {
  color: var(--color-text-primary);
}

/* Footer responsive container */
.footer-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: space-between;
}

.footer-container .footer-left,
.footer-container .footer-right {
    float: none;
}

@media (min-width: 640px) {
    .footer-container {
        flex-direction: row;
    }
}

/* T441 follow-up: SSR fallback hide for kpi_card.html .kpi-bubble divs.
 * kpi_card.html renders TWO .kpi-bubble divs: one as a direct child of .kpi-card
 * (data-original-tooltip) and one nested inside .kpi-card__body > [data-kpi-primary]
 * (data-detail-tooltip). A child combinator only hid the first; the second leaked
 * its tooltip text inline (e.g. "Cobros directos sin dispositivo: …" on Tap to Pay).
 * Descendant combinator covers both. The .info-bubble.kpi-bubble-portal in <body>
 * handles the actual surface-on-hover (see dashboard.js KpiToggleManager). */
.kpi-card .kpi-bubble { display: none; }


/* ── Chart Card Tabs ── */
.chart-card__tabs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-bottom: var(--space-4);
  border-bottom: 1px solid var(--color-border-default);
  padding-bottom: var(--space-3);
}
.chart-card__tab {
  padding: var(--space-1-5) var(--space-3);
  font-size: var(--text-sm);
  font-weight: var(--font-medium);
  color: var(--color-text-muted);
  background: none;
  border: 1px solid transparent;
  border-radius: 0;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px; /* overlap parent border-bottom */
  cursor: pointer;
  transition: color var(--transition-fast), border-color var(--transition-fast);
  white-space: nowrap;
}
.chart-card__tab:hover {
  color: var(--color-text-primary);
}
.chart-card__tab--active {
  color: var(--brand-primary-dark);
  border-bottom-color: var(--brand-primary);
  font-weight: var(--font-semibold);
}
.chart-card__panel[hidden] { display: none; }
/* ── Chart tabs dark ── */
:where([data-theme="dark"]) .chart-card__tab--active,
:where([data-theme="dark"]) .chart-card__tab[aria-selected="true"] {
    color: var(--brand-primary);
    border-bottom-color: var(--brand-primary);
}


/* ═══════════════════════════════════════════════════
   Responsive Mobile Fixes (≤640px)
   ═══════════════════════════════════════════════════ */

/* Dashboard mobile adjustments (KPI, charts, legend) */
@media (max-width: 639px) {
    .kpi-row { gap: var(--space-2); }

    /* Chart container heights */
    .chart-section-grid .relative.h-48 { height: 12rem; }

    /* Legend tables — tighter padding and smaller text */
    .chart-legend-table { font-size: var(--text-xs); }
    .chart-legend-table td,
    .chart-legend-table th {
        padding-left: var(--space-1);
        padding-right: var(--space-1);
    }
}

/* ═══════════════════════════════════════════════════════════════════
   Dashboard Filters Panel
   ═══════════════════════════════════════════════════════════════════ */

.dashboard-filters {
    background: var(--color-bg-surface);
    border-radius: var(--radius-lg);
    padding: var(--space-3);
}
@media (min-width: 640px) {
    .dashboard-filters { padding: var(--space-3) var(--space-4); }
}

.dashboard-filters__header {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    margin-bottom: var(--space-3);
}
.dashboard-filters__header-left {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
    max-width: 100%;
}
@media (min-width: 640px) {
    .dashboard-filters__header-left { gap: var(--space-3); }
}
.dashboard-filters__grid--dates {
    min-width: 0;
}

/* Same box model as .dashboard-filters__export-btn so Clear and Export read as a
   matching pair; Clear is the primary (filled) variant, Export the outlined one. */
.dashboard-filters__reset-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-1);
    flex-shrink: 0;
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    padding: var(--space-1) var(--space-3);
    border-radius: var(--radius-sm);
    border: 1px solid var(--brand-surface-strong);
    color: var(--color-text-inverse);
    background: var(--brand-surface-strong);
    cursor: pointer;
    transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
    min-height: var(--touch-target-min);
    position: relative;
}
.dashboard-filters__reset-btn svg {
    width: var(--icon-sm);
    height: var(--icon-sm);
}
.dashboard-filters__reset-btn:hover {
    background: var(--brand-surface-strong-hover);
    border-color: var(--brand-surface-strong-hover);
}
@media (min-width: 640px) {
    .dashboard-filters__reset-btn { min-height: 2.25rem; }
}

.dashboard-filters__export-btn {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    padding: var(--space-1) var(--space-3);
    border-radius: var(--radius-sm);
    border: 1px solid var(--brand-primary);
    color: var(--brand-primary-dark);
    background: var(--color-bg-surface);
    cursor: pointer;
    transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
    min-height: var(--touch-target-min);
}
@media (min-width: 640px) {
    .dashboard-filters__export-btn { min-height: 2.25rem; }
}
.dashboard-filters__export-btn:hover {
    background: var(--brand-surface-strong);
    color: var(--color-text-inverse);
}

/* #1895 phase E: export format picker (Excel / PDF). Semantic tokens only, so
   light/dark are covered by the token theme switch (same as the export button). */
.dashboard-export {
    position: relative;
    display: inline-flex;
}
.dashboard-export__menu {
    position: absolute;
    top: calc(100% + var(--space-1));
    right: 0;
    z-index: var(--z-dropdown);
    min-width: 10rem;
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    padding: var(--space-1);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
    background: var(--color-bg-surface);
    box-shadow: var(--shadow-md);
}
/* #1398/T955 pattern: main.css loads after Tailwind, so the single-class
   .dashboard-export__menu { display: flex } beats the .hidden utility and the
   picker never collapses. The compound selector (specificity 0,0,2,0) wins. */
.dashboard-export__menu.hidden { display: none; }
.dashboard-export__item {
    display: flex;
    align-items: center;
    width: 100%;
    padding: var(--space-2) var(--space-3);
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    color: var(--color-text-primary);
    background: transparent;
    border: none;
    border-radius: var(--radius-sm);
    cursor: pointer;
    text-align: left;
    transition: background var(--transition-fast), color var(--transition-fast);
    min-height: var(--touch-target-min);
}
.dashboard-export__item:hover,
.dashboard-export__item:focus-visible {
    background: var(--brand-surface-strong);
    color: var(--color-text-inverse);
}

.dashboard-filters__separator {
    width: 1px;
    align-self: stretch; /* match height of adjacent items instead of fixed px */
    background: var(--color-border-default);
    flex-shrink: 0;
}
@media (max-width: 639px) {
    .dashboard-filters__separator { display: none; }
    .dashboard-filters__label--inline { display: none; }
}
.dashboard-filters__compare-group {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
}
.dashboard-filters__compare-select {
    font-size: var(--text-xs);
    font-weight: var(--font-medium);
    padding: var(--space-1) var(--space-6) var(--space-1) var(--space-2);
    min-height: var(--touch-target-min);
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-sm);
    background: var(--color-bg-surface) var(--select-chevron) no-repeat right var(--space-2) center;
    color: var(--color-text-secondary);
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    transition: border-color var(--transition-fast), color var(--transition-fast), opacity var(--transition-fast);
}
.dashboard-filters__compare-select:hover {
    border-color: var(--brand-primary);
    color: var(--brand-primary-dark);
}
.dashboard-filters__compare-select:focus {
    outline: 2px solid var(--brand-focus-ring);
    outline-offset: 1px;
    border-color: var(--brand-primary);
}
.dashboard-filters__compare-select:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}
@media (min-width: 640px) {
    .dashboard-filters__compare-select { min-height: 2.25rem; }
}
@media (max-width: 639px) {
    .dashboard-filters__compare-group {
        width: 100%;
    }
    .dashboard-filters__compare-select {
        flex: 1 1 auto;
        min-width: 0;
    }
}

.dashboard-filters__grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-3);
}
@media (min-width: 640px) {
    .dashboard-filters__grid { grid-template-columns: 1fr 1fr; gap: var(--space-3) var(--space-4); }
}
@media (min-width: 1024px) {
    .dashboard-filters__grid { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1280px) {
    .dashboard-filters__grid { grid-template-columns: repeat(4, 1fr); }
}

.dashboard-filters__label {
    display: block;
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    text-transform: uppercase;
    letter-spacing: var(--tracking-wider);
    font-weight: var(--font-medium);
    margin-bottom: 2px;
}

.dashboard-filters__input {
    width: 100%;
    font-size: var(--text-sm);
    color: var(--color-text-primary);
    background-color: var(--color-bg-input);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-sm);
    padding: var(--space-1-5) var(--space-2);
    color-scheme: light;
}
:where([data-theme="dark"]) .dashboard-filters__input {
    color-scheme: dark;
}
.dashboard-filters__input:focus {
    border-color: var(--brand-primary);
    outline: none;
    box-shadow: 0 0 0 2px var(--brand-focus-ring);
}

.dashboard-filters__multi-select {
    width: 100%;
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-sm);
    display: flex;
    align-items: center;
    padding: var(--space-1) var(--space-2);
    min-height: var(--touch-target-min);
}
@media (min-width: 640px) {
    .dashboard-filters__multi-select { min-height: 36px; } /* input: 36px intencional vs --touch-target-min=44px para botones — Sprint 08 T559 */
}

.dashboard-filters__multi-select input[type="text"] {
    width: 100%;
    font-size: var(--text-sm);
    padding: 0;
    border: 0;
    background: transparent;
    outline: none;
}
/* The inner text input clears its own outline, so the container surfaces the
   focus ring when the input (or any child) gains focus. Token-based, theme-aware
   via --brand-focus-ring (light + dark overrides). */
.dashboard-filters__multi-select:focus-within {
    border-color: var(--brand-primary);
    box-shadow: 0 0 0 4px var(--brand-focus-ring);
}

.dashboard-filters__dropdown {
    position: absolute;
    margin-top: var(--space-1);
    width: 100%;
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-md);
    z-index: var(--z-dropdown);
}

.dashboard-filters__dropdown-item {
    padding: var(--space-1-5) var(--space-2);
    border-bottom: 1px solid var(--gray-50);
}
.dashboard-filters__dropdown-item:hover {
    background: var(--gray-50);
}
.dashboard-filters__dropdown-item label {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    width: 100%;
    cursor: pointer;
    font-size: var(--text-sm);
}
.dashboard-filters__dropdown-scroll { max-height: min(200px, 50vh); overflow-y: auto; }
/* T814: results counter footer — visible only when total > displayed items. */
.async-results-footer {
    padding: var(--space-1-5) var(--space-2);
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    border-top: 1px solid var(--color-border-default);
    background: var(--color-bg-surface);
}
.dashboard-filters__section { margin-top: var(--space-3); }
.dashboard-filters__label--inline { margin-bottom: 0; line-height: 2.25rem; /* match preset button height */ }

/* T-S29-04: form controls del design system.
   accent-color es theme-agnóstico → tinta el control nativo con el color de
   marca en light y dark sin regla extra. Reemplaza las clases inexistentes
   `.form-checkbox`/`.form-radio` (Tailwind sin @tailwindcss/forms) que dejaban
   los inputs con estilo nativo del navegador. */
.form-checkbox,
.form-radio {
    accent-color: var(--brand-primary-dark);
    width: var(--space-4);
    height: var(--space-4);
    margin-top: var(--space-0-5);
    flex-shrink: 0;
    cursor: pointer;
}

/* T-S29-04: clases del async picker que reemplazan los estilos inline
   (hex + px/rem hardcoded) de `renderResults` en async-user-picker.js. */
.async-picker__row-label { align-items: flex-start; }
.async-picker__info {
    flex: 1;
    min-width: 0;
    overflow: hidden;
}
.async-picker__name {
    display: block;
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.async-picker__meta {
    display: block;
    font-size: var(--text-xs);
    color: var(--color-text-secondary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.async-picker__badge {
    margin-left: var(--space-1);
    font-size: var(--text-xs);
    vertical-align: middle;
}

/* T-S29-R: checkbox-list multi-select (multi_select_checklist.html +
   checklist-picker.js). Tokens-only, theme-agnostic (accent-color on
   .form-checkbox already covers dark mode). */
.checklist {
    display: flex;
    flex-direction: column;
    /* P-S29-Q3 (jerarquía): aire claro entre las secciones del componente
       (buscador → botones → lista → footer) para que no se vean como un bloque
       denso indistinguible. 8px era insuficiente; 16px da separación legible. */
    gap: var(--space-4);
    /* P-S29-Q2a: inner gutter so the .form-input box-shadow focus ring (2px,
       painted outside the border-box) of the search/mode controls fits inside
       the modal's overflow clip box instead of being shaved at the edges.
       Does not alter the 2fr/1fr grid — columns stay proportional. */
    padding-inline: var(--space-1);
}
/* P1: grid 2fr/1fr so the search input fills 2/3 and the "Contiene" select
   fills 1/3 deterministically. The select inherits .form-input width:100%,
   so a flex layout let it eat the row; grid cells clamp each control. */
.checklist-search {
    display: grid;
    grid-template-columns: 2fr 1fr;
    gap: var(--space-2);
    align-items: stretch;
}
.checklist-actions {
    display: flex;
    flex-wrap: wrap;
    /* P-S29-Q3: 8px entre botones (y entre las 2 filas tmp/registrados) — 4px
       los pegaba. row+column gap legible sin romper el wrap. */
    gap: var(--space-2);
}
/* P2: 3-column grid of checkbox items (1 column on mobile). */
.checklist-scroll {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    /* P-S29-Q3: 8px de aire entre filas y columnas (era 4px vertical → filas
       apretadas). + padding interior para que las celdas no toquen el borde. */
    gap: var(--space-2);
    padding: var(--space-2);
    max-height: min(240px, 50vh);
    overflow-y: auto;
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
    background: var(--color-bg-surface);
}
@media (max-width: 640px) {
    .checklist-scroll { grid-template-columns: 1fr; }
}
.checklist-item {
    display: flex;
    align-items: center;
    /* P-S29-Q1/Q3: 16px de aire entre checkbox y label (8px→12px→16px); el gap
       es interno al flex del item, el grid 3-col no se altera. */
    gap: var(--space-4);
    padding: var(--space-2) var(--space-3);
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    color: var(--color-text-secondary);
    cursor: pointer;
    user-select: none;
}
.checklist-item:hover { color: var(--color-text-primary); }
/* P3: keep the checkbox from being squeezed so the gap to the name is visible. */
/* P-S29-Q1: refuerzo independiente del flex-gap (por si un reset lo anula) —
   garantiza separación visible checkbox↔label. */
.checklist-item .form-checkbox { margin-top: 0; margin-inline-end: var(--space-2); flex: 0 0 auto; }
/* P-S29-Q2b: scoped focus ring for the checkboxes. The global :focus-visible
   uses outline-offset:2px (projects 4px outside the 16px box) which .checklist-scroll
   (overflow-y:auto + border) shaves on the edge columns/rows. Inset offset draws
   the ring inside the paint box so overflow has nothing to clip. Same pattern as
   .data-table tbody tr[data-href]:focus-visible. Higher specificity (0,2,1) than
   the global (0,1,0); does NOT touch .form-checkbox outside .checklist. */
.checklist .form-checkbox:focus-visible {
    outline: 2px solid var(--brand-primary);
    outline-offset: -2px;
}
.checklist-item__label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.checklist-item__badge {
    margin-left: var(--space-1);
    font-size: var(--text-xs);
    vertical-align: middle;
}
.checklist-empty {
    padding: var(--space-2) var(--space-3);
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}
.checklist-footer {
    font-size: var(--text-xs);
    color: var(--color-text-muted);
}

/* ═══ SelectionField (#2336) — the unified admin selection component ═══
   selection_field.html + selection-field.js. Tokens only; theme-agnostic (the
   accent tokens carry both light/dark); stacks at <=375px; 44px touch targets;
   focus rings. Shares the checklist's DOM idioms but adds the 3-zone toolbar,
   the "Seleccionados (N)" tab and the "Seleccionar filtrados" shortcut. */
.selection-field {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    padding-inline: var(--space-1);   /* gutter so focus rings aren't shaved by the modal clip */
}

/* ── Zone toolbar (B1): one row, 3 zones + {actions}; wraps, then stacks ── */
.selection-field__toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
}
.selection-field__search {
    display: grid;
    grid-template-columns: 2fr 1fr;
    gap: var(--space-2);
    align-items: stretch;
    flex: 1 1 16rem;
    min-width: 0;
}
.selection-field__zone { display: flex; align-items: center; gap: var(--space-2); }
.selection-field__actions { margin-inline-start: auto; }

/* Quick-filters: a segmented control that reads as a filter group. */
.selection-field__quick {
    display: inline-flex;
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-md);
    overflow: hidden;
}
.selection-field__quick-btn {
    font-size: var(--text-xs);
    font-weight: var(--font-medium);
    padding: var(--space-1) var(--space-3);
    color: var(--color-text-secondary);
    background: var(--color-bg-surface);
    border: 0;
    border-inline-start: 1px solid var(--color-border-default);
    cursor: pointer;
    transition: color var(--transition-fast), background var(--transition-fast);
}
.selection-field__quick-btn:first-child { border-inline-start: 0; }
.selection-field__quick-btn:hover { color: var(--brand-primary-dark); }
.selection-field__quick-btn[aria-pressed="true"],
.selection-field__quick-btn.is-active {
    background: linear-gradient(135deg, var(--brand-surface-strong), var(--brand-surface-strong-active));
    color: var(--color-text-inverse);
    font-weight: var(--font-semibold);
}
.selection-field__quick-btn:focus-visible {
    outline: 2px solid var(--brand-primary);
    outline-offset: -2px;
}

/* ── Tabs (A2) ── */
.selection-field__tabs {
    display: flex;
    gap: var(--space-1);
    border-bottom: 1px solid var(--color-border-default);
}
.selection-field__tab {
    appearance: none;
    background: transparent;
    border: 0;
    border-bottom: 2px solid transparent;
    padding: var(--space-2) var(--space-3);
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    color: var(--color-text-secondary);
    cursor: pointer;
    transition: color var(--transition-fast), border-color var(--transition-fast);
}
.selection-field__tab:hover { color: var(--color-text-primary); }
.selection-field__tab.is-active,
.selection-field__tab[aria-selected="true"] {
    color: var(--brand-primary-dark);
    border-bottom-color: var(--brand-primary);
    font-weight: var(--font-semibold);
}
.selection-field__tab:focus-visible {
    outline: 2px solid var(--brand-primary);
    outline-offset: -2px;
    border-radius: var(--radius-sm);
}
.selection-field__tab-label--cap { text-transform: capitalize; }

/* Bulk-select zone: toggle-all + "Seleccionar filtrados" wrap instead of
   clipping when the toolbar is narrow (mobile-first). */
.selection-field__toggle-all { flex-wrap: wrap; }

/* ── Lists (available + selected) ── */
.selection-field__panel { display: block; }
.selection-field__panel[hidden] { display: none; }
.selection-field__list {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--space-2);
    padding: var(--space-2);
    max-height: min(240px, 50vh);
    overflow-y: auto;
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
    background: var(--color-bg-surface);
}
@media (max-width: 640px) {
    /* minmax(0, 1fr), NOT bare `1fr` (= minmax(auto, 1fr)): the `auto` minimum is
       content-based, so a long unbreakable label (UUID) expands the single track
       past the modal width — the row overflows and its `text-overflow: ellipsis`
       never triggers. The `0` minimum lets the track shrink so the label truncates. */
    .selection-field__list { grid-template-columns: minmax(0, 1fr); }
}
.selection-field__row {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    /* Grid item of .selection-field__list: min-width:0 overrides the default
       `min-width:auto` (min-content) so the row shrinks below its content and the
       flex label (also min-width:0) can ellipsize instead of overflowing the cell. */
    min-width: 0;
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-sm);
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    color: var(--color-text-secondary);
    cursor: pointer;
    user-select: none;
    transition: background var(--transition-fast), color var(--transition-fast);
}
/* Specificity war: inside a modal, `.modal-content label { display:block }` (0,1,1)
   beats the bare `.selection-field__row { display:flex }` (0,1,0). A block row is not
   a flex container, so the label span reverts to `display:inline` and its
   `flex/min-width:0/overflow/text-overflow:ellipsis` ALL no-op → the long UUID never
   truncates, overflows its grid cell and overlaps the neighbour (width-independent).
   Qualifying by the list ancestor (0,2,0) beats `.modal-content label` and keeps the
   component's flex row (hence the label as a flex item) wherever it is embedded.
   `align-items:center` still comes from `.selection-field__row` (not overridden). */
.selection-field__list .selection-field__row {
    display: flex;
    align-items: center;
}
.selection-field__row:hover { color: var(--color-text-primary); }
/* C1: selected row = checkbox + accent highlight. */
.selection-field__row.is-selected {
    background: var(--brand-primary-light);
    color: var(--brand-primary-dark);
}
/* #2660 — alta masiva de tickets: al quitar el checkbox redundante, la fila
   necesita un estado "seleccionado" visible por sí misma. Mismo par de tokens
   que `.selection-field__row.is-selected` (light + dark ya resueltos por el
   token), sin colores hardcoded. */
.event-row.is-selected {
    background: var(--brand-primary-light);
    border-color: var(--brand-primary);
}

.selection-field__control { margin-top: 0; margin-inline-end: var(--space-2); flex: 0 0 auto; }
.selection-field .form-checkbox:focus-visible {
    outline: 2px solid var(--brand-primary);
    outline-offset: -2px;
}
.selection-field__row-label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
/* Rendered as a SIBLING of the row-label (a flex child of the row), never inside
   the ellipsized span — so the NAME truncates but the temporal-vs-registered
   badge (a compliance-relevant marker) always survives on long names. */
.selection-field__row-badge {
    flex: 0 0 auto;
    margin-left: var(--space-1);
    font-size: var(--text-xs);
    vertical-align: middle;
}
.selection-field__empty {
    padding: var(--space-2) var(--space-3);
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

/* ── Selected-tab pagination + footer ── */
.selection-field__pagination {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    margin-top: var(--space-2);
}
.selection-field__page-status {
    font-size: var(--text-sm);
    color: var(--color-text-secondary);
    min-width: 4rem;
    text-align: center;
}
.selection-field__footer {
    font-size: var(--text-xs);
    color: var(--color-text-muted);
}

/* ≤375px: the toolbar collapses to a single column so no zone is clipped. */
@media (max-width: 375px) {
    .selection-field__toolbar { flex-direction: column; align-items: stretch; }
    .selection-field__search { flex-basis: auto; }
    .selection-field__zone,
    .selection-field__quick { width: 100%; }
    .selection-field__quick-btn { flex: 1 1 0; text-align: center; }
    .selection-field__actions { margin-inline-start: 0; }
    .selection-field__toggle-all-btn { width: 100%; }
}

/* Top row: two equal buttons side by side on mobile */
.dashboard-filters__top-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-2);
  width: 100%;
}
@media (min-width: 640px) {
  .dashboard-filters__top-row {
    display: contents; /* dissolve wrapper on desktop — children flow normally */
  }
}
/* Presets toggle — mobile only */
.dashboard-filters__presets-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  min-height: var(--touch-target-min);
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-xs);
  font-weight: var(--font-medium);
  color: var(--color-text-secondary);
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-default);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
/* Presets container: hidden on mobile, flex on desktop */
.dashboard-filters__presets-container { display: none; align-items: center; }
@media (min-width: 640px) {
  .dashboard-filters__presets-toggle { display: none; }
  .dashboard-filters__presets-container { display: flex; }
}
.dashboard-filters__presets-toggle[aria-expanded="true"] [data-chevron] {
  transform: rotate(180deg);
}
.dashboard-filters__preset-badge {
  font-size: var(--text-xs);
  padding: 0 var(--space-1-5);
  border-radius: var(--radius-full);
  background: var(--brand-surface-strong);
  color: var(--color-text-inverse);
}

/* Dashboard loading states */
@keyframes dashboard-spin {
  to { transform: rotate(360deg); }
}
@keyframes dashboard-pulse {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 1; }
}
.dashboard-loading-blur {
  position: absolute;
  inset: 0;
  background: var(--color-bg-page);
  opacity: 0.7;
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  z-index: var(--z-sticky);
  pointer-events: none;
}
.dashboard-loading-spinner {
  position: absolute;
  inset: 0;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  z-index: calc(var(--z-sticky) + 1);
  pointer-events: none;
}
.dashboard-loading-spinner:not(.hidden) {
  display: flex;
}
.dashboard-spinner__icon {
  width: 2rem;
  height: 2rem;
  color: var(--brand-primary);
  animation: dashboard-spin 0.75s linear infinite;
  will-change: transform;
  filter: drop-shadow(0 0 3px var(--brand-primary-shadow));
}
.dashboard-spinner__track { opacity: 0.15; }
.dashboard-spinner__head { opacity: 0.85; }
.dashboard-spinner__text {
  margin-top: var(--space-3);
  font-size: var(--text-sm);
  font-weight: var(--font-medium);
  color: var(--color-text-secondary);
  animation: dashboard-pulse 1.8s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .dashboard-spinner__icon { animation: none; }
  .dashboard-spinner__text { animation: none; }
}

.dashboard-filters__preset {
    font-size: var(--text-xs);
    padding: var(--space-1) var(--space-3);
    border-radius: var(--radius-sm);
    border: 1px solid var(--color-border-strong);
    color: var(--color-text-secondary);
    background: var(--color-bg-surface);
    cursor: pointer;
    transition: border-color var(--transition-fast), color var(--transition-fast);
    min-height: var(--touch-target-min);
}
@media (min-width: 640px) {
    .dashboard-filters__preset { min-height: 2.25rem; /* 36px */ }
    .dashboard-filters__toggle { min-height: 2.25rem; }
}
.dashboard-filters__preset:hover {
    border-color: var(--brand-primary);
    color: var(--brand-primary-dark);
}
.dashboard-filters__preset[aria-pressed="true"],
.dashboard-filters__preset.is-active,
button[data-preset].is-active {
    background: linear-gradient(135deg, var(--brand-surface-strong), var(--brand-surface-strong-active));
    border-color: transparent;
    color: var(--color-text-inverse);
    font-weight: var(--font-semibold);
    box-shadow: 0 2px 6px var(--brand-primary-shadow);
}

/* ── Chart tabs: animated underline on active ── */
.dashboard-tab[role="tab"],
[role="tablist"] > [role="tab"] {
    position: relative;
    transition: color var(--transition-fast);
}
.dashboard-tab[role="tab"]:hover:not([aria-selected="true"])::after,
[role="tablist"] > [role="tab"]:hover:not([aria-selected="true"])::after {
    content: "";
    position: absolute;
    inset-inline: var(--space-3);
    inset-block-end: 0;
    block-size: 2px;
    background: linear-gradient(90deg, var(--brand-primary), var(--brand-primary-dark));
    border-radius: var(--radius-full, 999px);
    opacity: 0.5;
    animation: dash-tab-underline var(--transition-base) ease-out;
}
@keyframes dash-tab-underline {
    from { transform: scaleX(0.3); opacity: 0; }
    to { transform: scaleX(1); opacity: 1; }
}

/* ── Event selector (reuses compare-select style) ── */
.dashboard-filters__event-select {
    font-size: var(--text-xs);
    font-weight: var(--font-medium);
    padding: var(--space-1) var(--space-6) var(--space-1) var(--space-2);
    min-height: 2.75rem;
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-sm);
    background: var(--color-bg-surface) var(--select-chevron) no-repeat right var(--space-2) center;
    color: var(--color-text-secondary);
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    transition: border-color var(--transition-fast), color var(--transition-fast);
}
.dashboard-filters__event-select:hover { border-color: var(--brand-primary); color: var(--brand-primary-dark); }
.dashboard-filters__event-select:focus { outline: 2px solid var(--brand-focus-ring); outline-offset: 1px; border-color: var(--brand-primary); }
.dashboard-filters__event-select:disabled { opacity: 0.5; cursor: not-allowed; }
@media (min-width: 640px) {
    /* R1 T422: override declared after base rule (min-height: 2.75rem above)
       so it wins at ≥640px and aligns event-select with __preset / __drawer-trigger. */
    .dashboard-filters__event-select { min-height: 2.25rem; }
}

/* ── Event-select wrapper + label alignment ── */
.dashboard-filters__event-select-wrapper {
    display: inline-flex;
    align-items: center;
    /* #3229 — `--space-2`, no `--space-1`, y es WCAG 2.2 AA SC 2.5.8, no estética.
     * El `.kpi-info-btn` que cierra este label es el ÚLTIMO hijo, así que este gap
     * es toda la separación que hay entre él y el `<select>`: dos targets vecinos.
     * axe (regla `target-size`, grupo `any`) acepta un target menor de 24×24 sólo
     * si `target-offset` lo salva, y ese offset se mide del CENTRO del botón al
     * borde del vecino, ×2. Medido en Chromium sobre esta misma fila:
     *
     *   gap    centro→select   offset×2   veredicto (umbral 24)
     *   4px    11px            22.0       ROJO
     *   8px    15px            30.0       limpio
     *
     * Con `--space-1` el botón canónico (14px) da 22.0 y tumba los tres tests de
     * `a11y-smoke.spec.ts`. El `.info-tooltip` a mano que había antes aquí medía
     * 16px y daba exactamente 24.0: pasaba por CERO píxeles de margen, que es por
     * lo que cambiarlo por el canónico —2px más pequeño— lo puso rojo. `--space-2`
     * es además el gap que ya usa el resto de la barra de filtros
     * (`__header`, `__header-left`, `__compare-group`, `__saved-presets`), así que
     * esta fila deja de ser la excepción. Bajarlo vuelve a poner rojo el gate. */
    gap: var(--space-2);
    flex-shrink: 0;
}
.dashboard-filters__event-label {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--text-xs);
    color: var(--color-text-secondary);
    white-space: nowrap;
    cursor: default;
}

/* ── Saved preset pills ── */
.dashboard-filters__saved-presets { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.dashboard-filters__preset-pills { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.preset-pill {
    display: inline-flex; align-items: center; gap: var(--space-1);
    padding: var(--space-1) var(--space-2);
    font-size: var(--text-xs); font-weight: var(--font-medium);
    background: var(--color-bg-elevated); color: var(--color-text-secondary);
    border-radius: var(--radius-full); border: 1px solid var(--color-border-default);
    cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast);
}
.preset-pill:hover { background: var(--brand-primary-light); border-color: var(--brand-primary); color: var(--brand-primary-dark); }
.preset-pill__delete {
    display: inline-flex; padding: 1px; border-radius: 50%;
    color: var(--color-text-muted); cursor: pointer; transition: color var(--transition-fast);
}
.preset-pill__delete:hover { color: var(--color-error); }
.preset-pill__shared { color: var(--color-text-muted); }

/* ── Preset save modal ── */
.preset-save-modal { position: fixed; inset: 0; z-index: var(--z-modal-backdrop); display: none; align-items: center; justify-content: center; }
.preset-save-modal:not(.hidden) { display: flex; }
.preset-save-modal__backdrop { position: absolute; inset: 0; background: var(--color-overlay); }
.preset-save-modal__content {
    position: relative; z-index: var(--z-modal);
    background: var(--color-bg-surface); border-radius: var(--radius-lg);
    padding: var(--space-6); width: 100%; max-width: 380px;
    box-shadow: var(--shadow-lg);
}

/* ── Dual-pie grid: legend + 2 pies in a row on desktop ── */
.dual-pie-grid {
    --legend-ratio: 0.4286;  /* 1.5 / 3.5 — legend share of available width */
    --legend-gaps: 2;        /* number of column gaps in 3-col mode */
    --legend-squeeze: 1.15;  /* table can be up to 1.15× the container before stacking */
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-4);
    align-items: center; /* Sprint 08 T559: homogeneizado con chart-section-grid */
}
@media (min-width: 768px) {
    .dual-pie-grid {
        grid-template-columns: minmax(220px, 1.5fr) minmax(0, 1fr) minmax(0, 1fr);
    }
    /* JS-driven: legend overflows → move it full-width on top, charts side-by-side */
    .dual-pie-grid--legend-top {
        grid-template-columns: 1fr 1fr;
    }
    .dual-pie-grid--legend-top > .dual-pie-grid__legend {
        grid-column: 1 / -1;
    }
}
.dual-pie-grid__legend {
    min-width: 0;
    overflow: auto;
}
.dual-pie-grid__chart {
    display: flex;
    flex-direction: column;
    align-items: center;
}
/* T722 F2a: unified centered empty-state for dashboard sections (dual-pie,
 * chart sections, tables). Spans the full grid row so the message is
 * visually centered instead of duplicated across columns.
 * T979: clase generada dinámicamente por dashboard-charts.js — NO es dead
 * code aunque no aparezca en templates HTML. */
.section-empty-state {
    grid-column: 1 / -1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    padding: var(--space-8) var(--space-4);
    min-height: 8rem;
    color: var(--color-text-muted);
    text-align: center;
    background: var(--color-bg-elevated);
    border-radius: var(--radius-md);
}
.section-empty-state__icon {
    width: var(--space-12, 3rem);
    height: var(--space-12, 3rem);
    color: var(--brand-primary);
    opacity: 0.7;
}
.section-empty-state__message {
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    color: var(--color-text-secondary);
    margin: 0;
}
/* R-DASH-1 (control genérico): cuando una sección de chart no tiene datos,
 * dashboard-charts.js marca su target (`.dual-pie-grid`, `.chart-section-grid`
 * o el propio container) con `chart-grid--empty`. Esta única regla colapsa
 * TODA la decoración del target (labels "Operaciones"/"Importe", leyenda,
 * tracks legend+chart, canvas-wraps, toggles) y deja `.section-empty-state`
 * como único elemento visible. Es deliberadamente AGNÓSTICA al layout:
 * cualquier chart actual o futuro que enrute por `applyEmptyStateUniform`/
 * `updateChartOrDestroy` queda cubierto sin tocar CSS. Sustituye al ocultado
 * por-elemento de #1653 (solo cubría chart-section-grid). La clase la genera
 * dinámicamente el JS — NO dead code; un chart con empty-state propio (p.ej.
 * ticketVelocity, que conserva su selector de evento) simplemente NO usa esta vía. */
.chart-grid--empty > :not(.section-empty-state) {
    display: none;
}
.dual-pie-grid__canvas-wrap {
    width: 100%;
    max-width: 260px;
    aspect-ratio: 1;
    /* No position:relative/absolute on canvas — Chart.js responsive mode
       reads this container's dimensions and sizes the canvas internally.
       Forcing canvas size via CSS breaks Chart.js pixel buffer → wrong colors. */
}
.dual-pie-grid__chart-label {
    text-align: center;
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    color: var(--color-text-secondary);
    margin-bottom: var(--space-3);
}

/* ── Chart toggle buttons (source / metric) ── */
/* Chart toggle pills — state toggles (Unidades/Facturado, Vendidos/Validados) */
.chart-source-toggle,
.chart-metric-toggle {
    font-size: var(--text-xs);
    font-weight: var(--font-medium);
    padding: var(--space-1) var(--space-3);
    border-radius: var(--radius-full);
    border: 1px solid var(--color-border-strong);
    background: transparent;
    color: var(--color-text-secondary);
    cursor: pointer;
    transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.chart-source-toggle:hover,
.chart-metric-toggle:hover {
    background: var(--color-brand-tint-12);
    border-color: var(--brand-primary);
    color: var(--brand-primary-dark);
}
.chart-source-toggle--validated,
.chart-metric-toggle--revenue {
    background: var(--brand-surface-strong);
    border-color: var(--brand-surface-strong);
    color: var(--color-text-inverse);
    font-weight: var(--font-semibold);
}
.chart-source-toggle--validated:hover,
.chart-metric-toggle--revenue:hover {
    background: var(--brand-surface-strong-hover);
    border-color: var(--brand-surface-strong-hover);
    color: var(--color-text-inverse);
}
/* stylelint-disable no-descending-specificity -- [real override] ':where([data-theme=dark]) .chart-source-toggle--validated|.chart-metric-toggle--revenue' (0,1,0, :where=0) vs earlier '.chart-source-toggle--validated:hover' / '.chart-metric-toggle--revenue:hover' (0,2,0, line 5902): dark text-color override declared after the :hover state by design; :where() keeps it low so :hover still wins, and the rule belongs in the dark-mode toggle block. */
:where([data-theme="dark"]) .chart-source-toggle--validated,
:where([data-theme="dark"]) .chart-metric-toggle--revenue {
/* stylelint-enable no-descending-specificity */
    color: var(--color-text-inverse);
}

.dashboard-filters__toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-1);
    font-size: var(--text-xs);
    font-weight: var(--font-medium);
    color: var(--color-text-inverse);
    background: var(--brand-surface-strong);
    border: 1px solid var(--brand-surface-strong);
    border-radius: var(--radius-md);
    cursor: pointer;
    padding: var(--space-1) var(--space-3);
    transition: background-color var(--transition-fast), border-color var(--transition-fast);
    min-height: var(--touch-target-min);
}
@media (min-width: 640px) {
    .dashboard-filters__toggle { flex-shrink: 0; }
}
.dashboard-filters__toggle:hover {
    background: var(--brand-surface-strong-hover);
    border-color: var(--brand-surface-strong-hover);
}
.dashboard-filters__toggle svg { flex-shrink: 0; }

.dashboard-filters__apply {
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    padding: var(--space-1) var(--space-4);
    border-radius: var(--radius-sm);
    background: var(--brand-surface-strong);
    color: var(--color-text-inverse);
    border: none;
    cursor: pointer;
    transition: background-color var(--transition-fast);
    min-height: var(--touch-target-min);
}
@media (min-width: 640px) {
    .dashboard-filters__apply { min-height: 0; }
}
.dashboard-filters__apply:hover { background: var(--brand-surface-strong-hover); }
.dashboard-filters__apply:active { transform: scale(0.97); }
/* #1858: staged (unapplied) manual filter edits — ring the Apply button so the
   user knows the shown data is stale until they click "Aplicar filtro". */
.dashboard-filters__apply--pending { box-shadow: 0 0 0 3px var(--brand-primary-shadow); }

/* Status bar — compact info strip below filter card */
.dashboard-filters__status-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    margin-top: var(--space-2);
    padding: 0 var(--space-1);
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    min-height: 0;
    overflow: hidden;
}
.dashboard-filters__status-left {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
    overflow: hidden;
}
.dashboard-filters__status-left > * { white-space: nowrap; }
.dashboard-filters__status-right {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-shrink: 0;
    white-space: nowrap;
}
.dashboard-filters__refresh-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-1);
    border: none;
    background: transparent;
    color: var(--color-text-muted);
    cursor: pointer;
    border-radius: var(--radius-sm);
    transition: color var(--transition-fast);
}
.dashboard-filters__refresh-btn:hover {
    color: var(--brand-primary-dark);
}

/* Polling interval selector */
.dashboard-filters__poll-select {
    font-family: var(--font-body);
    font-size: var(--text-xs);
    padding: var(--space-1) var(--space-5) var(--space-1) var(--space-1-5);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-sm);
    background: var(--color-bg-surface);
    color: var(--color-text-muted);
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    background-image: var(--select-chevron);
    background-repeat: no-repeat;
    background-position: right var(--space-1-5) center;
    min-height: var(--touch-target-min);
    transition: border-color var(--transition-fast), color var(--transition-fast);
}
.dashboard-filters__poll-select:hover {
    border-color: var(--brand-primary);
    color: var(--color-text-primary);
}
.dashboard-filters__poll-select:focus-visible {
    outline: 2px solid var(--brand-focus-ring);
    outline-offset: 2px;
}

.dashboard-filters__tag {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--text-xs);
    padding: var(--space-1) var(--space-1-5);
    border-radius: var(--radius-sm);
    background: var(--brand-primary-light);
    color: var(--brand-primary-dark);
    line-height: var(--leading-normal);
    max-width: 150px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.dashboard-filters__tag-remove {
    font-size: var(--text-sm);
    line-height: 1;
    color: var(--brand-primary-dark);
    background: none;
    border: none;
    cursor: pointer;
    padding: 0;
    flex-shrink: 0;
}
.dashboard-filters__tag-remove:hover { color: var(--brand-primary-dark); }

/* Tooltips for [data-tooltip] elements.
 * Rendering is delegated to a single portal element attached to <body>
 * (see dashboard.js Block 2b) so the tooltip escapes `overflow: hidden`
 * parents (filter drawer panel / body) and all stacking contexts.
 */
[data-tooltip] { cursor: help; }

.data-tooltip-portal {
    position: fixed;
    top: 0;
    left: 0;
    max-width: 260px;
    padding: var(--space-1-5) var(--space-2-5);
    font-size: var(--text-xs);
    font-weight: var(--font-normal);
    line-height: var(--leading-normal);
    color: var(--gray-50);
    background: var(--gray-900);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-md);
    white-space: normal;
    text-transform: none;
    letter-spacing: normal;
    pointer-events: none;
    z-index: var(--z-tooltip);
    animation: fadeInUp var(--dur-fast) ease-out;
}
.data-tooltip-portal[hidden] { display: none; }

/* Disparador de tooltip de KPI (#2667 T15 — components/kpi_info_button.html).
 * Es un <button> real (no un <span> con title=) para que entre en el orden de
 * tabulación y responda al tap: es el único sitio donde se explica qué incluye
 * y qué excluye un número de dinero. Visualmente calca .legend-info-icon (mismo
 * disco gris de 14px) para no meter un lenguaje nuevo en el hero. */
.kpi-info-btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 14px;
    height: 14px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-full);
    background: var(--color-bg-muted);
    /* #2801 — mismo peldaño de contraste que el resto del hero: secondary, no
     * muted, para no repetir el fallo AA del chip de variación. */
    color: var(--color-text-secondary);
    font-size: var(--text-3xs);
    font-weight: 700;
    line-height: 1;
    cursor: help;
    vertical-align: middle;
    margin-inline-start: var(--space-1);
}
.kpi-info-btn:hover {
    background: var(--color-bg-elevated);
    color: var(--color-text);
}
/* WCAG 2.5.5 — área táctil 44x44 sin cambiar el tamaño visual (mismo patrón
 * que .card-minus::before). Un disco de 14px es imposible de acertar con el
 * dedo, y el móvil es justo donde el tooltip no puede depender del hover. */
.kpi-info-btn::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: var(--touch-target-min);
    height: var(--touch-target-min);
    border-radius: var(--radius-full);
}

/* Variante de la guia del panel (#3366 §2 — `components/_panel_guide_link.html`).
 * NO es una familia nueva de tooltip (#3295 censo tres y no se añade una cuarta):
 * es la MISMA clase, el MISMO motor (`data-tooltip` → `static/js/tooltip-portal.js`,
 * que ya escucha hover, focus y tap) y la misma area tactil heredada del `::before`.
 * Lo que cambia son tres cosas y cada una tiene su motivo:
 *   - es un `<a>`, asi que hay que apagar el subrayado del ancla;
 *   - «boton azul», dijo el owner: `--brand-primary-dark` sobre
 *     `--brand-primary-light`. Medido 5,3:1 en claro y 6,8:1 en oscuro (AA en los
 *     dos). ⛔ `--brand-primary` a secas da 3,2:1 y por contrato no va detras de
 *     texto: «azul» no es permiso para bajar el contraste;
 *   - 20 px en vez de 14: acompaña a un `h2`, no a una cifra de 12 px, y con el
 *     disco del hero se perdia al lado del titulo. Sigue sin ser protagonista. */
.kpi-info-btn--guide {
    width: 1.25rem;
    height: 1.25rem;
    background: var(--brand-primary-light);
    color: var(--brand-primary-dark);
    font-size: var(--text-2xs);
    text-decoration: none;
    cursor: pointer;
}
.kpi-info-btn--guide:hover {
    background: var(--brand-primary-dark);
    color: var(--brand-primary-light);
}
.kpi-info-btn--guide:focus-visible {
    outline: 2px solid var(--brand-primary-dark);
    outline-offset: 2px;
}
#js-confirm-modal[hidden] { display: none; }

/* 11. Legend panel — cap height on small phones */
@media (max-width: 639px) {
    .chart-section-grid__legend-panel {
        max-height: clamp(180px, 40vh, 300px);
    }
}
/* ═══════════════════════════════════════════════════════════════════
   ANIMATIONS & TRANSITIONS UX (tarea 039 Fase 5)
   ═══════════════════════════════════════════════════════════════════ */

/* ── Modal open/close ── */
/* Templates use .modal.hidden (Tailwind display:none toggle).
   When .hidden is removed the modal is visible — animate content only.
   Use --dur-slow (just duration) en lugar de --transition-slow (duración +
   timing-function): el shorthand `animation:` sólo permite UNA timing-function
   y `var(--transition-slow)` expandía a `300ms ease`, dejando la declaración
   con dos functions (`ease` + `cubic-bezier(...)`) → CSS inválido, browser la
   descartaba entera, animation-name caía a `none`. Token convention en línea
   ~385: "animation usa --dur-*, transition usa --transition-*".

   T807 H12: .modal.is-closing triggers modalOut before .hidden is re-added
   by JS; JS listens for animationend on .modal-content to then add .hidden.
   NOTE (stylelint): .modal.is-closing (specificity 0,2,1) is placed first so
   it precedes .modal:not(.hidden, .is-closing) (specificity 0,2,1) — equal
   specificity blocks are ordered source-order; the descending-specificity
   disable covers the .modal--co block further below that reuses .modal-content
   without these animation rules. */
/* stylelint-disable no-descending-specificity -- [equal-specificity trap] '.modal.is-closing .modal-content' (0,2,1) and '.modal:not(.hidden,.is-closing) .modal-content' (0,2,1): EQUAL specificity, ordered by source so the closing animation precedes the open animation (see comment above). Reordering would swap which animation applies; the disable also covers the later .modal--co block that reuses .modal-content. */
.modal.is-closing .modal-content {
    /* T807 H12: easing inverso de modalIn (spring-out → ease-in collapse) */
    animation: modalOut var(--dur-slow) cubic-bezier(0.7, 0, 0.84, 0) forwards;
}
.modal:not(.hidden, .is-closing) .modal-content {
    animation: modalIn var(--dur-slow) cubic-bezier(0.16, 1, 0.3, 1);
}
/* stylelint-enable no-descending-specificity */
@keyframes modalIn {
    from { opacity: 0; transform: scale(0.96) translateY(6px); }
    to   { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes modalOut {
    from { opacity: 1; transform: scale(1) translateY(0); }
    to   { opacity: 0; transform: scale(0.96) translateY(6px); }
}
/* #2927 RC-6 — a modal whose body includes .editable-items-table (today: the
   TransactionPoint calculator config) enters WITHOUT the scale() component.
   `transform: scale()` on .modal-content distorts getBoundingClientRect() for
   EVERY descendant for the whole animation — including the row actions this
   component commits to at exactly `--touch-target-min` (44px, WCAG 2.5.8).
   Mid-animation (measured: animationPlayState "running", scale 0.996–0.998)
   those targets are provably a fraction of a pixel under their declared size;
   a scale-based entrance is structurally incompatible with an "always ≥44px"
   guarantee for interactive content inside it, for as long as it is running.
   Fade + slide only (opacity, translateY) — same token/easing, no transform —
   so the targets are their true size for the whole time they are interactive,
   not only once the spring settles. Scoped with :has() to the ONE modal that
   contains this component: every other modal in the app keeps its unchanged
   scale entrance. Comes AFTER the base rule so it wins on source order at
   equal specificity (see the disable above); gated to `no-preference` so
   users who ALSO have prefers-reduced-motion keep getting `animation: none`
   from the rule below untouched — the two conditions are mutually exclusive,
   never fighting over the same paint. */
@media (prefers-reduced-motion: no-preference) {
    .modal:has(.editable-items-table) .modal-content {
        animation-name: modalInFlat;
    }
}
@keyframes modalInFlat {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: translateY(0); }
}
/* T807 H12: respect user motion preference — skip animation entirely */
@media (prefers-reduced-motion: reduce) {
    .modal:not(.hidden) .modal-content,
    .modal.is-closing .modal-content {
        animation: none;
    }
}

/* ── Page content fade-in ── */
@keyframes fadeInUp {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: translateY(0); }
}
/* Use opacity-only fade for #main to avoid transform creating a containing block
   that breaks position:fixed tooltips inside (KPI bubbles). */
@keyframes fadeInSimple {
    from { opacity: 0; }
    to   { opacity: 1; }
}

/* ── Event-card staggered entrance — migrated to .co-stagger-enter (077) ── */
/* Legacy nth-child system removed. Cards now use .co-stagger-enter with
   --card-index custom property via template forloop.counter0. */

/* ── Card hover lift ── */
/* stylelint-disable-next-line no-descending-specificity -- [cross-component false-positive] '.kpi-card:hover' (0,2,0) vs earlier '#dashboard-charts-container > .kpi-card' (1,1,0, line 2337): the id-scoped chart-container card never matches a hovered kpi-card outside it; the hover-lift belongs with the card-hover block, not above the id rule. */
.kpi-card:hover {
    transform: translateY(-2px);
    box-shadow: var(--shadow-md);
    border-color: var(--color-border-default);
}
.kpi-card:hover::before { opacity: 0.6; }
:where(html[data-theme="dark"]) .filter-card:hover {
    box-shadow: 0 6px 20px rgba(var(--shadow-color), 0.45);
    border-color: var(--brand-primary);
}
/* stylelint-disable-next-line no-descending-specificity -- [cross-component false-positive] ':where(html[data-theme=dark]) .kpi-card:hover' (0,2,0, :where=0) vs earlier '#dashboard-charts-container > .kpi-card' (1,1,0, line 2337): disjoint id-scoped vs dark-hover targets; the dark hover-lift stays with the card-hover block. */
:where(html[data-theme="dark"]) .kpi-card:hover {
    box-shadow: 0 6px 20px rgba(var(--shadow-color), 0.45);
    border-color: var(--brand-primary);
}
/* #1398: .kpi-card{display:flex} (main.css) overrides Tailwind .hidden{display:none}
   because main.css loads after tailwind.output.css with equal specificity (0,1,0).
   Selector (0,2,0) guarantees hidden kpi-cards never intercept pointer events.
   Same pattern as .modal.hidden (line 4576). */
.kpi-card.hidden {
    display: none;
    pointer-events: none;
}

/* ── Button press feedback ── */
.btn-primary:active,
.btn-neutral:active,
.btn-danger:active,
.btn-success:active,
.btn-ghost:active,
.btn-danger-outline:active {
    transform: scale(0.97);
}

/* ═══════════════════════════════════════════════════════════════════
   DATA TABLE ENHANCEMENTS (from 032 style unification)
   ═══════════════════════════════════════════════════════════════════ */

/* NOTE: Add class .table-header to <th> elements in data-table templates.
   Base styling (bg, color, font-weight, uppercase, letter-spacing, padding,
   line-height, vertical-align) is provided by .table-header.
   Only overrides that differ from the base are kept here. */
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.data-table thead th' (0,2,0) vs earlier '.data-table thead :where(tr.header-row) th:first-child' (0,3,1, line 3191): the more specific header-row first-child already wins where it applies; this base font-size for all thead th stays with the data-table block. */
.data-table thead th {
/* stylelint-enable no-descending-specificity */
    font-size: var(--text-sm);
}

/* Unified column title styling */
.data-table .column-title {
    font-size: var(--text-sm);
    padding: var(--space-1);
    line-height: var(--leading-snug);
}

/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.data-table tbody td' (0,2,0) vs earlier '.kpi-hero__breakdown-row--clickable:hover td' (0,4,0, line 2197): the kpi-hero hover row never matches a generic data-table cell; disjoint surfaces, keep base cell padding with the data-table block. */
.data-table tbody td {
/* stylelint-enable no-descending-specificity */
    padding: var(--space-1-5) var(--space-2);
    font-size: var(--text-sm);
    white-space: nowrap;
}

/* Superuser shortcut: chipId cell becomes a link to CargaOnline (new tab).
   Keep the value visually anchored to the row (no extra color) and surface
   the affordance only on hover/focus so it does not shout on the table. */
.data-table td.chipId .chipId-link {
    color: inherit;
    text-decoration: none;
    /* WCAG 2.2 SC 2.5.8: the inline <a> was 8.1x17px on a one-char chipId.
       Grow the link BOX only — the sticky first column keeps its own opaque
       background (R-STICKY-OPAQUE), this rule never touches it. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-block-size: var(--touch-target-min-aa);
    min-inline-size: var(--touch-target-min-aa);
    padding-inline: var(--space-1);
}
.data-table td.chipId .chipId-link:hover,
.data-table td.chipId .chipId-link:focus-visible {
    color: var(--color-link);
    text-decoration: underline;
    text-underline-offset: 2px;
}

/* Force actions cell to always lay out buttons horizontally */
/* stylelint-disable no-descending-specificity -- [cross-component false-positive] '.data-table td:last-child' (0,2,0) vs earlier '.data-table tbody :where(tr:nth-child(odd)) td:first-child' (0,4,1, line 3201): last-child actions cell vs odd-row first-child are mutually exclusive cells (never the same td); no cascade conflict, so keep the actions-cell rule with the data-table block. */
.data-table td:last-child {
/* stylelint-enable no-descending-specificity */
    white-space: nowrap;
}

/* ── Clickable rows ── */
/* Clickable rows — only when JS adds [data-href] */
.data-table tbody tr[data-href] {
    cursor: pointer;
}
.data-table tbody tr[data-href]:focus-visible {
    outline: 2px solid var(--brand-primary-dark);
    outline-offset: -2px;
}
.data-table tbody tr.empty-state:hover {
    background: transparent;
}
/* T749 H2 / R-STICKY-OPAQUE: la regla `.data-table tbody :where(tr:hover)
   td:first-child` resalta el sticky col1 al hover (hoy con `box-shadow: inset`
   tinte de marca, opaco por debajo). Sin este override, la fila empty-state al
   hover mostraría su celda full-width con ese resaltado mientras el resto de la
   fila es transparent — inconsistencia visual. Specificity (0,4,3) supera a la
   regla base sin necesidad de disable comments; resetea background Y box-shadow. */
.data-table tbody tr.empty-state:hover td:first-child {
    background: transparent;
    box-shadow: none;  /* R-STICKY-OPAQUE: la fila vacía es un <td colspan> full-width
                          sin columnas hermanas debajo → sin resaltado hover (parity
                          con el background: transparent de arriba, no tinte inset). */
}
/* stylelint-disable-next-line no-descending-specificity
   -- [cross-component false-positive] '.data-table tbody tr.empty-state td'
   (0,3,0) vs earlier '.chart-legend-table tbody tr:nth-child(even) td:first-child'
   (0,4,1, line 4029): the chart-legend zebra rule never matches a data-table
   empty-state cell -- disjoint tables, no effective cascade conflict. */
.data-table tbody tr.empty-state td {
    border-left: 0;
    border-right: 0;
    color: var(--color-text-secondary);
}
/* T749: el bloque legacy `.data-table tbody tr { transition: ... }` se consolidó
   en el bloque base de zebra (L3023). Esta declaración duplicada se eliminó para
   cerrar `no-duplicate-selectors` + `no-descending-specificity` en stylelint. */

/* ── Hide secondary columns to reduce horizontal scroll ── */

/* Dispositivos: NINGUNA columna se esconde aquí (#3368).
   Este bloque escondía `billingPermission` y `creationDate` con `display:none`
   INCONDICIONAL (top-level, sin @media), y main.css se carga DESPUÉS de
   tailwind.output.css, así que ganaba en todo viewport — escritorio incluido.
   Como ocultaba th + td + celda de filtro a la vez, la tabla seguía alineada y
   ningún recuento de HTML lo cazaba: la columna «Facturación» simplemente no
   existía para el operario. Introducido en a401abbf1.
   - `billingPermission` es un PERMISO, y de los que NINGÚN módulo gatea: la regla
     permanente del owner (#3368, firmada el 2026-09-18) dice que ningún permiso de
     un módulo ACTIVO puede esconderse, y el suyo lo está siempre. No puede volver
     aquí — ni él ni ningún otro permiso: el gateo por módulo se expresa en
     `device_table.PERMISSION_COLUMN_FLAGS`, nunca en CSS.
   - `creationDate` se saca también, y por orden explícita del owner en esa misma
     OD («Fecha Creacion se muestra: se quita entera la regla display:none»). El
     bloque escondía además su celda de FILTRO, y filtrar por fecha de alta es el
     flujo que la guía del panel documenta («filtras por el día del alta»): estaba
     documentado y era imposible.
   Si hace falta reducir el scroll horizontal, se resuelve con layout: la tabla ya
   vive dentro de `.table-scroll-wrapper` + `overflow-x-auto`, que es el mecanismo
   previsto para eso. Esconder datos no es hacer sitio, es perderlos. */

/* Tickets: transactionPoint_validation, responsable_validation */
.data-table td.transactionPoint_validation,
.data-table th[data-field="transactionPoint_validation"],
.data-table .filter-row th:has(> [name="transactionPoint_validation"]),
.data-table td.responsable_validation,
.data-table th[data-field="responsable_validation"],
.data-table .filter-row th:has(> [name="responsable_validation"]) {
    display: none;
}

/* Eventos: creator, permitirMultiplesAccesos, description, limitPerDevice */
.data-table td.creator,
.data-table th[data-field="creator"],
.data-table .filter-row th:has(> [name="creator"]),
.data-table td.permitirMultiplesAccesos,
.data-table th[data-field="permitirMultiplesAccesos"],
.data-table .filter-row th:has(> [name="permitirMultiplesAccesos"]),
.data-table td.description,
.data-table th[data-field="description"],
.data-table .filter-row th:has(> [name="description"]),
.data-table td.limitPerDevice,
.data-table th[data-field="limitPerDevice"],
.data-table .filter-row th:has(> [name="limitPerDevice"]) {
    display: none;
}

/* Transacciones: destinationBrazalet (Chip ID) */
.data-table td.destinationBrazalet,
.data-table th[data-field="destinationBrazalet"],
.data-table .filter-row th:has(> [name="destinationBrazalet"]) {
    display: none;
}

/* Narrow columns con UUID-like content — truncar con tooltip nativo (#688, T431).
   Selector tbody td evita colisión con la regla de visibilidad arriba.
   T431: añadidas .responsable y .ticketId (follow-up #688 — scope original era más amplio). */
.data-table tbody td.chipId,
.data-table tbody td.user,
.data-table tbody td.balance,
.data-table tbody td.transactionPoint,
.data-table tbody td.destinationBrazalet,
.data-table tbody td.responsable,
.data-table tbody td.ticketId {
    padding-inline: var(--space-3);
    max-width: var(--table-cell-max-id);
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ═══════════════════════════════════════════════════════════════════
   SEMANTIC INTERACTION CLASSES (tarea 039 Fase 4)
   Replace Tailwind color classes toggled in JS with semantic alternatives.
   ═══════════════════════════════════════════════════════════════════ */

/* Card selection state (stripePayment, tickets) */
.card-selected {
    background-color: var(--color-brand-tint-12);
    border-color: var(--brand-primary);
    box-shadow: var(--shadow-md);
}

/* Tab active state (CO tickets, stripePayment tabs) */
.tab--active {
    border-color: var(--brand-primary);
    color: var(--brand-primary-dark);
    background-color: var(--color-brand-tint-12);
    font-weight: var(--font-bold);
    border-bottom-width: 2px;
}
:where([data-theme="dark"]) .tab--active {
    color: var(--brand-primary);
}
/* ── CargaOnline header ─────────────────────────────────────────── */
/* 085 HR-03: max-width instead of width so logo can shrink on narrow viewports */
.co-logo { max-width: 96px; min-width: 48px; width: auto; height: auto; }
@media (min-width: 640px) { .co-logo { max-width: 128px; } }

.co-avatar-trigger {
  width: var(--touch-target-min); height: var(--touch-target-min); min-width: var(--touch-target-min);
  border-radius: var(--radius-full);
  background: var(--brand-surface-strong); color: var(--color-text-inverse);
  font-weight: var(--font-semibold); font-size: var(--text-lg);
  border: none; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
.co-avatar-icon {
  width: var(--icon-md); height: var(--icon-md);
  color: var(--color-text-inverse);
}

/* T176 F3 — fused capsule for balance + account trigger. Cluster keeps
   both elements visually paired while preserving independent click
   targets (balance → recarga, avatar → menu).

   `overflow: clip visible` keeps horizontal clipping so the rounded
   capsule corners still trim child hover backgrounds, while letting the
   `.co-session-dropdown` (absolute, below the trigger) escape vertically
   instead of being chopped off — restores the account menu functionality
   that regressed after T176 F3 introduced the cluster wrapper. */
.co-header-cluster {
  display: inline-flex; align-items: stretch;
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border-default);
  border-radius: var(--radius-full);
  overflow: clip visible;
}
.co-header-cluster > .co-header-stat {
  border: 0; background: transparent;
  border-radius: 0;
}
:where([data-theme="dark"]) .co-header-cluster {
  background: var(--color-bg-elevated);
  border-color: var(--color-border-default);
}

.co-avatar-mini {
  width: 32px; height: 32px; min-width: 32px;
  border-radius: var(--radius-full);
  background: var(--brand-surface-strong); color: var(--color-text-inverse);
  font-weight: var(--font-semibold); font-size: var(--text-sm);
  display: inline-flex; align-items: center; justify-content: center;
}

/* stylelint-disable-next-line no-descending-specificity
   -- [real override] '.co-header-stat' (0,1,0) ships after the more specific
   '.co-header-cluster > .co-header-stat' (0,2,0, line 6390) cluster variant;
   the cluster override is strictly higher and already wins, so this base stat
   definition stays with its component (reorder would scatter the header-stat block). */
.co-header-stat {
  display: inline-flex; align-items: center; gap: var(--space-1-5);
  padding: var(--space-1-5) var(--space-3);
  border-radius: var(--radius-full);
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border-default);
  text-decoration: none; color: var(--color-text-primary);
  font-size: var(--text-sm); font-weight: var(--font-semibold);
  transition: background var(--transition-fast), box-shadow var(--transition-fast);
  white-space: nowrap; min-height: 32px;
}
.co-header-stat:hover {
  background: var(--brand-primary-light);
  box-shadow: var(--shadow-brand-sm);
}
.co-header-stat__value {
  color: var(--brand-primary-dark); font-weight: var(--font-bold);
}
.co-header-stat__label {
  color: var(--color-text-secondary); font-weight: var(--font-medium);
}
:where([data-theme="dark"]) .co-header-stat__value {
  color: var(--brand-primary-dark);
}
:where([data-theme="dark"]) .co-header-stat:hover {
  background: var(--color-brand-tint-10, rgba(51, 190, 255, 0.1));
}
@media (max-width: 639px) {
  .co-header-stat { padding: var(--space-1) var(--space-2); font-size: var(--text-xs); }
  .co-header-stat__label { display: none; }
}

.co-session-dropdown {
  position: absolute; right: 0; top: calc(100% + 4px);
  z-index: var(--z-dropdown); min-width: 220px;
  /* Constrain to viewport so more than 4 items stay reachable on short mobile. */
  max-height: calc(100vh - var(--header-height) - var(--space-4));
  overflow-y: auto;
  background: var(--color-bg-surface); border: 1px solid var(--color-border-default);
  border-radius: var(--radius-lg);
  box-shadow: var(--container-shadow);
  padding: var(--space-2) 0;
  opacity: 0;
  transform: translateY(-4px) scale(0.98);
  transition: opacity var(--transition-fast),
              transform var(--transition-fast),
              visibility var(--transition-fast);
  visibility: hidden;
  pointer-events: none;
}
.co-session-dropdown__item {
  padding: var(--space-2) var(--space-4); display: flex; align-items: center; gap: var(--space-2);
  min-height: var(--touch-target-min);
}
.co-session-dropdown__logout {
  width: 100%; background: none; border: none; cursor: pointer;
  font: inherit; color: inherit; text-align: left;
  padding: var(--space-2) var(--space-4); display: flex; align-items: center;
  min-height: var(--touch-target-min);
  transition: background-color var(--transition-fast);
}
.co-session-dropdown__logout:hover { background: var(--color-bg-page); }
/* Theme item: sun/moon icon + label — gap between icon and text. */
.co-session-dropdown__theme { gap: var(--space-2); }
.co-session-dropdown__login {
  display: flex; align-items: center;
  width: 100%;
  padding: var(--space-2) var(--space-4);
  font-weight: var(--font-semibold);
  color: var(--brand-primary-dark);
  background: transparent;
  border: 0;
  text-align: left;
  cursor: pointer;
  text-decoration: none;
  transition: background var(--transition-fast);
  min-height: var(--touch-target-min);
}
:where([data-theme="dark"]) .co-session-dropdown__login { color: var(--brand-primary); }
.co-session-dropdown__login:hover { background: var(--color-bg-page); }
.co-session-dropdown__divider { border-top: 1px solid var(--color-border-default); margin: var(--space-1) 0; }

/* ── #1922 F2: first-login password recommendation banner (admin panel) ──────
   Inline, non-blocking. Tokens only; dark mode inherited from the switching
   tokens (no [data-theme="dark"] override needed). CTA mirrors .btn-primary. */
.pw-prompt-banner {
  display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
  margin: var(--space-3) var(--space-4) 0;
  padding: var(--space-3) var(--space-4);
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border-default);
  border-radius: var(--radius-lg);
  color: var(--color-text-primary);
  font-size: var(--text-sm);
}
.pw-prompt-banner__icon { display: inline-flex; flex-shrink: 0; color: var(--brand-primary-dark); }
.pw-prompt-banner__text { flex: 1 1 auto; min-width: 12rem; }
.pw-prompt-banner__actions { display: flex; align-items: center; gap: var(--space-2); }
.pw-prompt-banner__action {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-4);
  background: var(--brand-surface-strong); color: var(--color-text-inverse);
  border: 0; border-radius: var(--radius-md);
  font-weight: var(--font-semibold); text-decoration: none; white-space: nowrap;
  transition: background var(--transition-fast);
}
.pw-prompt-banner__action:hover,
.pw-prompt-banner__action:focus-visible { background: var(--brand-surface-strong-hover); }
.pw-prompt-banner__dismiss-form { margin: 0; }
.pw-prompt-banner__dismiss {
  min-height: var(--touch-target-min);
  padding: var(--space-2) var(--space-3);
  background: none; border: none; cursor: pointer;
  color: var(--color-text-secondary); font: inherit; text-decoration: underline;
  transition: color var(--transition-fast);
}
.pw-prompt-banner__dismiss:hover { color: var(--color-text-primary); }
@media (max-width: 639px) {
  .pw-prompt-banner { flex-direction: column; align-items: stretch; }
  .pw-prompt-banner__actions { flex-direction: column; }
  .pw-prompt-banner__action,
  .pw-prompt-banner__dismiss { width: 100%; }
}
.co-copy-btn {
  background: none; border: none; cursor: pointer;
  color: var(--color-text-muted); display: inline-flex; align-items: center;
  justify-content: center;
  min-height: var(--touch-target-min); min-width: var(--touch-target-min);
  transition: color var(--transition-fast);
}
.co-copy-btn:hover { color: var(--brand-primary); }
.co-wa-link {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: var(--touch-target-min); min-width: var(--touch-target-min);
  transition: transform var(--transition-fast), color var(--transition-fast);
}
.co-wa-link:hover {
  transform: scale(1.1);
  color: var(--color-success);
}

/* Focus-visible for CargaOnline elements — inherited from global :focus-visible rule */

.co-chip-id-label { font-size: var(--text-xs); color: var(--color-text-secondary); flex: 1; min-width: 0; }
.co-session-dropdown__chip-row { flex-wrap: nowrap; }

/* ═══════════════════════════════════════════════════
   CargaOnline — Landing UX Utilities
   ═══════════════════════════════════════════════════ */

/* Number input spinner hide */
.no-arrow::-webkit-inner-spin-button,
.no-arrow::-webkit-outer-spin-button {
    -webkit-appearance: none;
    margin: 0;
}
.no-arrow { -moz-appearance: textfield; }

/* Line clamp for consistent text truncation */
.line-clamp-2 {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* Product card — enhanced for high-value landing pages */
.co-product-card {
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    padding: var(--space-5);
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    position: relative;
    transition: box-shadow var(--transition-base), transform var(--transition-base), border-color var(--transition-base);
}
:where([data-theme="dark"]) .co-product-card {
    background: var(--color-bg-elevated);
    border-color: var(--color-border-strong);
}
@media (hover: hover) {
    .co-product-card:hover {
        box-shadow: var(--shadow-md);
        transform: translateY(-2px);
    }
}
.co-product-card__image {
    width: 100%;
    aspect-ratio: 16 / 9;
    object-fit: cover;
    border-radius: var(--radius-md);
    background: var(--color-bg-muted);
}
:where([data-theme="dark"]) .co-product-card__image {
    background: var(--color-bg-elevated);
}

/* Price display — prominent */
.co-price {
    font-size: var(--text-2xl);
    font-weight: var(--font-bold);
    color: var(--brand-primary-dark);
}
.co-price-strike {
    font-size: var(--text-sm);
    color: var(--color-text-placeholder);
    text-decoration: line-through;
    margin-right: var(--space-2);
}

/* CTA button — primary purchase action */
.co-cta-primary {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: var(--touch-target-cta);
    padding: var(--space-3) var(--space-6);
    font-size: var(--text-base);
    font-weight: var(--font-semibold);
    color: var(--color-text-inverse);
    background: var(--brand-surface-strong);
    border: none;
    border-radius: var(--radius-md);
    cursor: pointer;
    transition: background var(--transition-fast), box-shadow var(--transition-fast),
                border-color var(--transition-fast), opacity var(--transition-fast),
                transform var(--transition-fast);
    box-shadow: var(--shadow-brand-sm);
}
.co-cta-primary:hover {
    background: var(--brand-surface-strong-hover);
    box-shadow: var(--shadow-brand-lg);
}
.co-cta-primary:disabled {
    background: var(--color-text-placeholder);
    cursor: not-allowed;
    box-shadow: none;
    opacity: 0.7;
}
.co-cta-primary:active:not(:disabled) {
    transform: scale(0.98);
    box-shadow: var(--shadow-sm);
}

/* CTA button — secondary (login, back) */
.co-cta-secondary {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: var(--touch-target-cta);
    padding: var(--space-3) var(--space-6);
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    color: var(--color-text-primary);
    background: var(--color-bg-surface);
    border: 2px solid var(--color-border-strong);
    border-radius: var(--radius-md);
    cursor: pointer;
    transition: background var(--transition-fast), border-color var(--transition-fast),
                box-shadow var(--transition-fast), opacity var(--transition-fast),
                transform var(--transition-fast);
    text-decoration: none;
}
.co-cta-secondary:hover {
    background: var(--color-bg-page);
    border-color: var(--color-text-placeholder);
}
.co-cta-secondary:disabled {
    background: var(--color-bg-disabled);
    border-color: var(--color-border-default);
    color: var(--color-text-disabled);
    cursor: not-allowed;
}
.co-cta-secondary:active:not(:disabled) {
    background: var(--color-bg-disabled);
    transform: scale(0.98);
}

/* F3 — modifier para uso del CTA secundario en cards Mis Tickets/Mis Promos
   (override del width:100% del base + padding/font reducidos para compactness). */
.co-cta-secondary--compact {
    width: auto;
    min-height: var(--touch-target-min);
    padding: var(--space-2) var(--space-4);
    font-size: var(--text-xs);
}

/* #1496 — urgency treatment for "Comprar más" when the user's remaining
   accesses run low (≤20% of purchased total). Warn tokens (light + dark, both
   defined in :root and [data-theme="dark"]); reuses the co-badge--low-stock
   subtle pulse. Reduced-motion disables the animation below. */
.co-cta-secondary--urgent {
    color: var(--color-warn);
    border-color: var(--color-warn);
    background: var(--color-warn-bg);
    animation: co-pulse-subtle 3s ease-in-out infinite;
}
.co-cta-secondary--urgent:hover:not(:disabled) {
    color: var(--color-warn-hover);
    border-color: var(--color-warn-hover);
    background: var(--color-warn-bg);
}
.co-low-stock-hint {
    color: var(--color-warn);
}
@media (prefers-reduced-motion: reduce) {
    .co-cta-secondary--urgent { animation: none; }
}

/* Quantity selector — touch-optimized */
.co-qty-selector {
    display: inline-flex;
    align-items: center;
    border: 2px solid var(--color-border-strong);
    border-radius: var(--radius-md);
    overflow: hidden;
}
.co-qty-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    min-width: var(--touch-target-min);
    min-height: var(--touch-target-min);
    padding: var(--space-2);
    background: transparent;
    border: none;
    cursor: pointer;
    color: var(--color-text-secondary);
    transition: background var(--transition-fast), box-shadow var(--transition-fast),
                border-color var(--transition-fast), opacity var(--transition-fast),
                transform var(--transition-fast);
}
.co-qty-btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}
.co-qty-btn:hover:not(:disabled) {
    background: var(--color-bg-disabled);
}
.co-qty-btn:active:not(:disabled) {
    background: var(--color-border-default);
}
.co-qty-input {
    width: 3rem;
    min-height: var(--touch-target-min);
    text-align: center;
    font-size: var(--text-base);
    font-weight: var(--font-semibold);
    border: none;
    background: transparent;
}
.co-qty-input:focus {
    outline: 2px solid var(--brand-primary);
    outline-offset: -2px;
    background: var(--color-bg-page);
}
.co-qty-input:disabled {
    opacity: 0.6;
    cursor: not-allowed;
}

/* Badges */
.co-badge {
    display: inline-flex;
    align-items: center;
    padding: var(--space-1) var(--space-2-5);
    font-size: var(--text-xs);
    font-weight: var(--font-bold);
    border-radius: var(--radius-full);
    line-height: var(--leading-tight);
}
.co-badge--savings {
    background: var(--color-success-bg);
    color: var(--color-success);
    opacity: 0;
    animation: fadeInUp var(--dur-slow) ease 200ms forwards;
}
.co-badge--extra {
    background: var(--color-info-bg);
    color: var(--brand-primary-dark);
    opacity: 0;
    animation: fadeInUp var(--dur-slow) ease 200ms forwards;
}
.co-badge--soldout {
    background: var(--color-error-bg);
    color: var(--color-error);
}
.co-badge--low-stock {
    background: var(--color-warn-bg);
    color: var(--color-warn);
    animation: co-pulse-subtle 3s ease-in-out infinite;
}
@keyframes co-pulse-subtle {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.7; }
}

@media (prefers-reduced-motion: reduce) {
    .co-badge--low-stock { animation: none; }
}
.co-badge--cashback {
    background: var(--color-success-bg);
    color: var(--color-success);
    opacity: 0;
    animation: fadeInUp var(--dur-slow) ease 200ms forwards;
}
.co-badge--neutral {
    background: var(--color-bg-elevated);
    color: var(--color-text-secondary);
}
.co-badge--error {
    background: var(--color-error-bg);
    color: var(--color-error);
}
.co-badge--bundle {
    background: var(--color-info-bg);
    color: var(--color-info);
    border: 1px solid var(--color-border-default);
}
/* #2836 — "En temporal": chip tmp ajeno, solo lectura. Amber/warn (mismo tono que
   .alert-warning) para separarlo visualmente de --savings/--bundle sin inventar un
   token nuevo; el texto (no solo el color) lleva la a11y de daltónicos. */
.co-badge--temporal {
    background: var(--color-warn-bg);
    /* --color-status-warn-text (no --color-warn-hover): en dark el hover cae a
       3.82:1 sobre --color-warn-bg, bajo AA (4.5:1); status-warn-text da 4.91:1
       en este contexto y es el token que ya usa el resto de badges de estado
       (:1912, :7798). */
    color: var(--color-status-warn-text);
    /* inset, no `border`: en la fila flex-wrap de _co_purchase_card.html este
       badge convive con --savings/--soldout (sin borde) y un `border: 1px
       solid` real suma 2px de alto sobre esos hermanos (visible ≤375px, la
       fila se desalinea). El inset pinta el mismo contorno sin cambiar la
       caja — mismo patrón que R-STICKY-OPAQUE (CLAUDE.md §4.2). */
    box-shadow: inset 0 0 0 1px var(--color-warn);
}
/* #3309 C — badge de TIPO (categoria), no de estado. Tinta de marca: es la
   unica familia libre. Los diez modificadores anteriores son tintes de estado
   (success / warn / error / info) y `--extra` ya usa `--color-info-bg` DENTRO
   de esta misma tarjeta de promo (bloque "Incluye"), asi que un badge de
   categoria tiene que leerse fuera de ese eje. Tampoco se reusa
   `.co-badge--bundle`: hoy significa lo CONTRARIO ("Incluido en {pack}" en
   `_co_purchase_card.html`), que en la vista unificada queda a una pestana de
   distancia en la misma pagina, y ademas lleva `border: 1px solid` real — el
   error que el comentario de `--temporal` documenta como causa de que la fila
   `flex-wrap` se desalinee a 375px. De ahi el `inset`, nunca `border`.
   Los dos tokens ya se voltean en dark (:711, :721) -> sin bloque dark propio.
   El texto visible "Promocion" acompana al color y al icono (WCAG 1.4.1). */
.co-badge--promo {
    gap: var(--space-1);
    background: var(--brand-primary-light);
    color: var(--brand-primary-dark);
    box-shadow: inset 0 0 0 1px var(--brand-primary);
}

/* ── Secondary navigation ── */
.co-secondary-nav {
    position: sticky;
    top: var(--header-height, 56px);
    z-index: var(--z-sticky);
    background: var(--color-bg-blur-nav);
    backdrop-filter: blur(12px) saturate(1.2);
    -webkit-backdrop-filter: blur(12px) saturate(1.2);
    padding: var(--space-1) var(--container-gutter);
    margin-bottom: var(--space-2);
}
.co-nav-pills {
    display: flex;
    justify-content: center;
    gap: var(--space-2);
    overflow-x: auto;
    scrollbar-width: none;
    padding: var(--space-2) 0 var(--space-1);
}
.co-nav-pills::-webkit-scrollbar { display: none; }
.co-nav-pill {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-1-5);
    flex: 1;
    max-width: 160px;
    padding: var(--space-2) var(--space-4);
    border-radius: var(--radius-full);
    font-size: var(--text-sm);
    font-weight: var(--font-semibold);
    color: var(--color-text-secondary);
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    white-space: nowrap;
    text-decoration: none;
    transition: all var(--transition-fast);
    min-height: 40px;
}
.co-nav-pill:hover {
    color: var(--color-text-primary);
    border-color: var(--color-border-strong);
    background: var(--color-bg-elevated);
}
.co-nav-pill.is-active {
    color: var(--brand-primary-dark);
    background: var(--brand-primary-light);
    border-color: var(--brand-primary);
    font-weight: var(--font-bold);
}
:where([data-theme="dark"]) .co-nav-pill.is-active {
    color: var(--brand-primary);
    background: var(--color-brand-tint-10);
}
.co-nav-pill__icon {
    width: 18px;
    height: 18px;
    flex-shrink: 0;
}
/* Trust section */
.co-trust-bar {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-6);
    padding: var(--space-3) var(--space-4);
    margin-bottom: var(--space-3);
    border-bottom: 1px solid var(--color-border-default);
}
.co-trust-item {
    display: flex;
    align-items: center;
    gap: var(--space-1-5);
    font-size: var(--text-xs);
    font-weight: var(--font-medium, 500);
    color: var(--color-text-secondary);
    opacity: 0;
    animation: fadeInUp var(--transition-base) forwards;
}
.co-trust-item svg {
    width: var(--icon-sm);
    height: var(--icon-sm);
    flex-shrink: 0;
    color: var(--brand-primary);
}

/* Inline price preview */
.co-inline-total {
    font-size: var(--text-lg);
    font-weight: var(--font-bold);
    color: var(--brand-primary-dark);
    transition: color var(--transition-base);
}

/* ── .modal--co modifier (T239+T243) ─────────────────────────────────────
   CargaOnline modal family. All templates use .modal.modal--co + .modal-content.
   Differs from admin .modal / .modal-content in three key ways:
     1. Visibility: .is-open toggle (opacity/visibility transition) vs
        .hidden toggle (Tailwind display:none + @keyframes modalIn).
        JS activation: .is-open toggle (not .hidden toggle like admin).
     2. Animation: bidirectional CSS transition (enter + exit) vs
        unidirectional @keyframes (enter only).
     3. Backdrop: overlay IS the backdrop (flex container); admin .modal is
        the scrollable backdrop wrapper.
   .co-modal-close remains as the close-button utility class.
   ──────────────────────────────────────────────────────────────────────── */
/* Close button — shared by all CO modals */
.co-modal-close {
    position: absolute;
    top: var(--space-3);
    right: var(--space-3);
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--touch-target-min);
    height: var(--touch-target-min);
    border: none;
    background: transparent;
    border-radius: var(--radius-full);
    cursor: pointer;
    color: var(--color-text-muted);
    transition: background var(--transition-fast);
}
.co-modal-close:hover {
    background: var(--color-bg-disabled);
}
.co-modal-close svg {
    width: var(--icon-md);
    height: var(--icon-md);
}
/* Inputs inside CO modal panels: WCAG 44px touch target + cancel admin margin-bottom bleed */
.modal.modal--co .modal-content input:not([type="hidden"]),
.modal.modal--co .modal-content select,
.modal.modal--co .modal-content textarea {
    min-height: var(--touch-target-min);
    margin-bottom: 0;
}
.modal.modal--co {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    /* Override admin's overflow:auto + padding */
    overflow: visible;
    padding: var(--space-4);
    display: flex;
    align-items: center;
    justify-content: center;
    transition: opacity var(--transition-base) ease,
                visibility var(--transition-base) ease;
}
.modal.modal--co.is-open {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
}
.modal.modal--co .modal-content {
    /* CO panel dimensions */
    max-width: 480px;
    padding: var(--space-6);
    /* Entry: slide-up + scale (bidirectional) */
    transform: translateY(12px) scale(0.97);
    opacity: 0;
    transition: transform var(--transition-base) cubic-bezier(0.16, 1, 0.3, 1),
                opacity var(--transition-base) ease;
    /* Override admin's scroll behaviour — panel handles its own overflow */
    overflow-y: auto;
    max-height: 90vh;
    max-height: 90dvh; /* #3008: dvh excludes the mobile browser chrome that 100vh wrongly counts */
}
.modal.modal--co.is-open .modal-content {
    transform: translateY(0) scale(1);
    opacity: 1;
}
.modal.modal--co .modal-content--wide {
    max-width: 640px;
}
/* CO mobile: bottom-sheet
   #3008: height is pinned to --co-vvh (window.visualViewport.height, kept live by
   modals.js) instead of relying on inset:0's bottom edge alone. On iOS Safari, position:fixed
   is anchored to the LAYOUT viewport, which does NOT shrink when the on-screen keyboard opens —
   only the VISUAL viewport does — so without this override the sheet (and a focused input inside
   it) can render partly behind the keyboard. dvh is the no-JS fallback (still viewport-correct,
   just not keyboard-aware); vh is the last-resort fallback for browsers with neither. */
@media (max-width: 639px) {
    .modal.modal--co {
        align-items: flex-end;
        padding: 0;
        height: 100vh;
        height: 100dvh;
        height: var(--co-vvh, 100dvh);
    }
    .modal.modal--co .modal-content {
        border-radius: var(--radius-lg) var(--radius-lg) 0 0;
        max-width: 100%;
        max-height: 85vh;
        max-height: 85dvh;
        max-height: calc(var(--co-vvh, 100dvh) * 0.85);
        padding: var(--space-4);
        /* Home-indicator / notch clearance so the last row of controls (e.g. the Enviar button)
           never sits under the iOS gesture bar. */
        padding-bottom: calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
        transform: translateY(100%);
    }
    .modal.modal--co.is-open .modal-content {
        transform: translateY(0);
    }
}
/* T948: sistema .is-open de CargaOnline — cubre animación modalIn sin quitar .hidden */
.modal.is-open:not(.is-closing) .modal-content {
    animation: modalIn var(--duration-normal, 200ms) var(--ease-out, ease-out) both;
}
/* CO tablet — rules merged into the shared tablet block below (line ~6849)
   to avoid fragmenting the min-width:640px media query beyond the quality baseline. */

/* Touch targets — ensure minimum 44px for mobile (WCAG 2.1 AA) */
/* stylelint-disable-next-line no-descending-specificity -- [cross-component false-positive] '[role=tab]' (0,1,0) vs earlier '[role=tablist] > [role=tab]' (0,2,0, line 5664): the tablist child combinator is strictly more specific and already wins inside a tablist; this base touch-target sizing applies to standalone tabs and belongs with the a11y touch-target rules. */
[role="tab"] {
    min-height: var(--touch-target-min);
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* ══════════════════════════════════════════════════════════════
   TASK 110 — Unified Toast Notification System
   ══════════════════════════════════════════════════════════════ */

/* ── Container ── */
.toast-container {
    position: fixed;
    z-index: var(--z-toast);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    pointer-events: none;
    max-width: calc(100vw - var(--space-8));
    width: 24rem; /* 384px — comfortable reading width */
}
.toast-container--bottom-center {
    bottom: var(--space-6);
    left: 50%;
    transform: translateX(-50%);
}
/* top-right reserved for future use (control panel, admin) */

/* ── Individual toast ── */
.toast {
    pointer-events: auto;
    display: flex;
    border-radius: var(--radius-lg);
    padding: var(--space-3) var(--space-4);
    box-shadow: var(--shadow-lg);
    animation: toastIn var(--transition-slow) cubic-bezier(0.16, 1, 0.3, 1);
    will-change: transform, opacity;
}
.toast--exiting {
    animation: toastOut var(--dur-base) ease-in forwards;
}

/* ── Type variants ──
   The -600 state token as ink on its own -100 pastel is the classic AA miss
   (#dc2626 on #fee2e2 = 3.95:1); the border keeps the saturated token, the
   label uses the status-text weight. */
.toast--success {
    background: var(--color-success-bg);
    color: var(--color-status-success-text);
    border: 1px solid var(--color-success);
}
.toast--error {
    background: var(--color-error-bg);
    color: var(--color-status-error-text);
    border: 1px solid var(--color-error);
}
.toast--warning {
    background: var(--color-warn-bg);
    color: var(--color-status-warn-text);
    border: 1px solid var(--color-warn);
}
.toast--info {
    background: var(--color-info-bg);
    color: var(--color-status-info-text);
    border: 1px solid var(--color-info);
}

/* ── Inner layout ── */
.toast__body {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    width: 100%;
    font-size: var(--text-sm);
    font-weight: 600;
    line-height: var(--leading-normal);
}
.toast__icon {
    flex-shrink: 0;
    width: var(--icon-md);
    height: var(--icon-md);
}
.toast__message {
    flex: 1;
    margin: 0;
    overflow-wrap: break-word;
}
.toast__close {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-1);
    border-radius: var(--radius-sm);
    background: transparent;
    border: none;
    color: currentColor;
    opacity: 0.7;
    cursor: pointer;
    transition: opacity var(--transition-fast);
}
.toast__close:hover,
.toast__close:focus-visible {
    opacity: 1;
}

/* ── Animations ── */
@keyframes toastIn {
    from { transform: translateY(1rem); opacity: 0; }
    to   { transform: translateY(0); opacity: 1; }
}
@keyframes toastOut {
    to { transform: translateY(-0.5rem); opacity: 0; }
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
    .toast { animation: none; }
    .toast--exiting {
        animation: none;
        opacity: 0;
        transition: opacity var(--transition-fast);
    }
}

/* CTA success variant (e.g., "Pay with card" button) */
.co-cta-success {
    background: var(--color-success);
}
.co-cta-success:hover:not(:disabled) {
    background: var(--color-success-hover);
}

/* Compact variant for "My Bonos" and similar lists */
.co-product-card--compact {
    padding: var(--space-4);
}

/* Shimmer attention animation on CTA buttons (cascading, subtle) */
@keyframes co-shimmer {
    0%   { background-position: -200% 0; }
    100% { background-position: 200% 0; }
}
:is(.co-cta-primary, .co-btn-premium).co-shimmer-active {
    will-change: background-position;
    background-image: linear-gradient(
        110deg,
        transparent 30%,
        rgba(255, 255, 255, 0.12) 42%,
        rgba(255, 255, 255, 0.18) 50%,
        rgba(255, 255, 255, 0.12) 58%,
        transparent 70%
    );
    background-size: 200% 100%;
    animation: co-shimmer 1.6s ease-in-out 1;
}
/* Premium buttons: shimmer overlays the gradient — restore gradient after animation */
.co-btn-premium.co-shimmer-active {
    background-image: linear-gradient(
        110deg,
        transparent 30%,
        rgba(255, 255, 255, 0.15) 42%,
        rgba(255, 255, 255, 0.22) 50%,
        rgba(255, 255, 255, 0.15) 58%,
        transparent 70%
    ), var(--_btn-gradient, none);
}

/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
    :is(.co-cta-primary, .co-btn-premium).co-shimmer-active {
        animation: none;
        background-image: none;
    }
    .co-btn-premium.co-shimmer-active {
        background-image: var(--_btn-gradient, none);
    }
}

/* ── CargaOnline touch targets (all viewports) ──────────────────────── */
.modal-content .close-modal {
    min-height: var(--touch-target-min);
    min-width: var(--touch-target-min);
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* Date format toggle: full by default, short on mobile */
.co-date-short { display: none; }

/* ── CargaOnline mobile ───────────────────────────────────── */
@media (max-width: 639px) {
    /* 085 HR-09: keep horizontal on mobile, tighter gap + smaller text; wrap on 320px */
    .co-trust-bar {
        flex-wrap: wrap;
        justify-content: center;
        gap: var(--space-2) var(--space-3);
        padding: var(--space-2) var(--space-3);
    }
    .co-cta-primary,
    .co-cta-secondary {
        min-height: var(--touch-target-min);
        padding: var(--space-2-5) var(--space-4);
        font-size: var(--text-sm);
    }
    .co-qty-selector { min-width: 110px; }
    .co-badge {
        font-size: var(--text-xs);
        padding: var(--space-1) var(--space-2);
    }
    .co-trust-item { font-size: var(--text-xs); min-width: 0; }
    /* 085 HR-04: fixed positioning on mobile for reliable full-width dropdown */
    .co-session-dropdown {
        position: fixed;
        top: var(--header-height, 56px);
        right: var(--space-4);
        left: var(--space-4);
        width: auto;
        min-width: 0;
        max-width: none;
        z-index: var(--z-dropdown-header); /* T528: above header but below modal */
        border-radius: 0 0 var(--radius-lg) var(--radius-lg);
    }
    /* 085 HR-11: allow wrap on mobile so chip ID + actions don't overflow */
    .co-session-dropdown__chip-row { flex-wrap: wrap; }

    /* C4: Transaction tables — compact for mobile */
    .receipt-table {
        font-size: var(--text-xs);
    }
    .receipt-table td,
    .receipt-table th {
        padding: var(--space-1) var(--space-2);
    }
    .receipt-table .col-secondary {
        display: none;
    }
    .co-date-full { display: none; }
    .co-date-short { display: inline; }

    /* C6: Product cards — reduced padding on mobile */
    .co-product-card {
        padding: var(--space-4);
    }

    /* C6: Price — scale down on mobile */
    .co-price {
        font-size: var(--text-xl);
    }

    /* C6: Preset cards — slightly smaller on mobile */
    .preset-card,
    .ticket-preset-card {
        min-height: 70px;
        padding: var(--space-2);
    }

    /* C6: Orphan card in 2-col grid — span full width */
    #preset-cards > :last-child:nth-child(odd) {
        grid-column: 1 / -1;
    }

    /* C6: Preview grid — tighter on mobile */
    .co-preview-grid {
        grid-template-columns: repeat(auto-fill, minmax(64px, auto));
        gap: var(--space-1-5);
    }
    .co-preview-card {
        padding: var(--space-2);
        min-height: 64px;
    }
}

/* Tablet optimizations */
@media (min-width: 640px) and (max-width: 1023.98px) {
    .co-product-card {
        padding: var(--space-6);
    }
    /* T243 — .modal--co tablet overrides (keep @media count ≤25) */
    .modal.modal--co .modal-content {
        max-width: 520px;
    }
    .modal.modal--co .modal-content--wide {
        max-width: 640px;
    }
}

/* ── CargaOnline tabs ──────────────────────────────────────────────────── */
.co-tab {
    display: block; width: 100%; height: 100%;
    text-align: center; padding: var(--space-2) var(--space-4);
    color: var(--color-text-secondary);
    transition: color var(--transition-fast), background var(--transition-fast);
}
.co-tab:hover {
    color: var(--brand-primary-dark);
}
:where([data-theme="dark"]) .co-tab:hover {
    color: var(--brand-primary);
}

/* ═══════════════════════════════════════════════════════════════════════════
   CO MICRO-ANIMATIONS & VISUAL POLISH (tarea 077)
   ───────────────────────────────────────────────────────────────────────────
   CSS-only additions. All durations use design tokens. All animations respect
   prefers-reduced-motion via the global override at :root (L887-900).
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── A01: Staggered card entrance (unified system) ── */
.co-stagger-enter {
    opacity: 0;
    transform: translateY(8px);
    animation: fadeInUp var(--transition-slow) forwards;
    animation-delay: calc(min(var(--card-index, 0), 8) * 60ms);
}

/* ── A03: Trust bar item stagger ── */
.co-trust-item:nth-child(1) { animation-delay: 80ms; }
.co-trust-item:nth-child(2) { animation-delay: 160ms; }
.co-trust-item:nth-child(3) { animation-delay: 240ms; }

/* ── D01: Total pulse on value change ── */
@keyframes totalPulse {
    50% { transform: scale(1.08); color: var(--color-success); }
}
.co-inline-total.is-updating {
    animation: totalPulse var(--dur-slow) ease;
}

/* ── D02: Cart badge pop-in and bounce ── */
@keyframes badgePopIn {
    from { transform: scale(0); opacity: 0; }
    to   { transform: scale(1); opacity: 1; }
}
@keyframes badgeBounce {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-3px); }
}
.card-badge.is-appearing {
    animation: badgePopIn var(--dur-base) cubic-bezier(0.34, 1.56, 0.64, 1);
}
.card-badge.is-incrementing {
    animation: badgeBounce var(--dur-base) ease-out;
    will-change: transform;
}

/* ── D03: (Removed — purchase toast now via unified toast service, Task 110) ── */

/* ── D04: (Removed — alerts now via unified toast service, Task 110) ── */

/* ── D05: Limit flash improved exit ── */
@keyframes limitFlashOut {
    0%, 80% { opacity: 1; transform: translateY(0); }
    100%    { opacity: 0; transform: translateY(-4px); }
}
.limit-flash {
    animation: limitFlashOut 1200ms ease-out forwards;
}

/* ── D06: Copy checkmark pop-in ── */
.co-check-icon:not(.hidden) {
    animation: badgePopIn var(--dur-base) cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* ── D07: Bonus message entrance ── */
#bonus-message:not(.hidden) {
    animation: fadeInUp var(--dur-base) ease-out;
}

/* ── D10: Page fade-out before navigation ── */
@keyframes pageFadeOut {
    to { opacity: 0.4; filter: blur(2px); }
}
body.is-navigating #main {
    animation: pageFadeOut var(--dur-slow) ease forwards;
    pointer-events: none;
}

/* ── D11: Empty state fade-in ── */
#noEventsMessage:not(.hidden) {
    animation: fadeInUp var(--dur-base) ease-out;
}

/* ── B02 (#3309 C / #3233): el panel inactivo NO se maqueta ──
   Antes: `position:absolute; visibility:hidden` SIN `display:none`. Eso genera
   caja: el panel oculto se maquetaba en cada render y era el unico candidato
   estructural capaz de alargar el documento bajo `#main.co-main{overflow:clip}`
   (declarada a pelo, sin @supports) — el hallazgo de #3233.
   El `display:none` ya existia en este mismo fichero, pero encerrado en
   `@media (prefers-reduced-motion: reduce)` (bloque F01): aqui se PROMUEVE al
   bloque base.
   El crossfade se conserva con `animation` y un keyframe que ya estaba en el
   fichero (`fadeInUp`, el de #noEventsMessage): una `animation` SI corre cuando
   el elemento pasa a mostrarse, una `transition` desde `display:none` no.
   Cero JS: el servidor ya emite `is-active` en el panel activo, y `switchTab()`
   sigue haciendo solo `classList.toggle('is-active')` — ningun selector ni spec
   e2e que dependa de la clase se rompe.
   ⛔ No se usa `panel.hidden = !active` desde JS (los dos paneles pintarian
   hasta que corra el script, y para siempre si falla) ni `:target` (las
   pestanas son `<a href="#nuevosTicketsSection">`: el hash ES el id del panel,
   asi que esa regla podria forzar visible un panel inactivo). */
.co-tab-panel {
    display: none;
    width: 100%;
}
.co-tab-panel.is-active {
    display: block;
    animation: fadeInUp var(--dur-base) ease-out;
}

/* ── B05: Description expand/collapse ── */
.co-description-clamp {
    overflow: hidden;
    display: -webkit-box;
    /* stylelint-disable-next-line property-no-deprecated -- required for -webkit-line-clamp */
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    max-height: 3.2em;
    transition: max-height var(--transition-slow);
}
.co-description-clamp.is-expanded {
    display: block;
    -webkit-line-clamp: unset;
    /* stylelint-disable-next-line property-no-deprecated -- reset for expanded state */
    -webkit-box-orient: initial;
    max-height: 20em;
    overflow: visible;
}

/* ── B06: Accordion expand + arrow rotation ── */
.co-accordion-arrow {
    transition: transform var(--transition-fast);
}
.co-accordion-arrow.is-open {
    transform: rotate(180deg);
}
.co-accordion-body {
    max-height: 0;
    overflow: hidden;
    transition: max-height var(--transition-slow);
}
.co-accordion-body.is-open {
    max-height: 2000px;
}

/* ── C03: Session dropdown fade + slide ── */
.co-session-dropdown.is-open {
    opacity: 1;
    transform: translateY(0) scale(1);
    visibility: visible;
    pointer-events: auto;
}

/* ── C04: Language dropdown fade ── */
.lang-dropdown {
    opacity: 0;
    transform: translateY(-4px);
    transition: opacity var(--transition-fast),
                transform var(--transition-fast),
                visibility var(--transition-fast);
    visibility: hidden;
    pointer-events: none;
}
.lang-dropdown.is-open {
    opacity: 1;
    transform: translateY(0);
    visibility: visible;
    pointer-events: auto;
}

/* ── E05: Search form slide-down ── */
.co-search-form {
    max-height: 0;
    overflow: hidden;
    transition: max-height var(--transition-slow);
}
.co-search-form.is-open {
    max-height: 250px;
    overflow-y: auto;
}
@media (min-width: 768px) {
    /* Higher specificity to override mobile .co-search-form collapse without !important */
    .co-search-form,
    .co-search-form.is-open {
        display: block;
        max-height: none;
        opacity: 1;
        overflow: visible;
    }
}

/* ── B04: Sold-out / maxed card smooth transition ── */
/* Base transition for .ticket-preset-card is in B01 above (superset). */
.ticket-preset-card[data-js-maxed="true"] {
    opacity: 0.6;
    filter: grayscale(40%);
}

/* ── B07: Filter cards fade in/out ── */
.ticket-card {
    transition: opacity var(--transition-base), transform var(--transition-base);
}
.ticket-card.is-filtered-out {
    opacity: 0;
    transform: scale(0.95);
    pointer-events: none;
}

/* ── E03: Focus-visible smooth outline transition ── */
:is(.co-cta-primary, .co-cta-secondary, .co-qty-btn, .co-tab,
    .co-modal-close, .co-copy-btn):focus-visible {
    outline-offset: 4px;
    transition: outline-offset var(--transition-fast);
}

/* ── E04: Input validation glow ── */
.manual-amount-input.is-invalid {
    border-color: var(--color-error);
    box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
.manual-amount-input.is-valid {
    border-color: var(--color-success);
    box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.1);
}

/* ── B01: Card selection — smooth transition on preset/ticket cards ── */
.preset-card,
.ticket-preset-card {
    transition: border-color var(--transition-fast),
                background-color var(--transition-fast),
                box-shadow var(--transition-fast),
                transform var(--transition-fast),
                opacity var(--transition-base),
                filter var(--transition-base);
}

/* ── E01: Product card hover glow (extends existing hover with brand border) ── */
@media (hover: hover) {
    .co-product-card:hover {
        border-color: var(--brand-primary-light);
        box-shadow: var(--shadow-md), var(--shadow-brand-sm);
    }
}

/* ── F01: Safety net — prefers-reduced-motion (defense in depth) ──
   Redundant with global :root override (L887-900) but explicit for
   documentation and belt-and-suspenders safety. */
@media (prefers-reduced-motion: reduce) {
    .co-stagger-enter,
    .co-badge--savings,
    .co-badge--cashback,
    .co-badge--extra,
    .co-trust-item {
        animation: none;
        opacity: 1;
        transform: none;
    }
    .co-inline-total.is-updating,
    .card-badge.is-appearing,
    .card-badge.is-incrementing,
    .limit-flash,
    .co-check-icon:not(.hidden),
    #bonus-message:not(.hidden),
    #noEventsMessage:not(.hidden) {
        animation: none;
    }
    body.is-navigating #main {
        animation: none;
        opacity: 1;
        filter: none;
    }
    /* El `display:none` del panel inactivo vive ahora en el bloque BASE (B02),
       no aqui: lo unico que queda por apagar es el crossfade. */
    .co-tab-panel.is-active {
        animation: none;
    }
    .co-description-clamp,
    .co-accordion-body,
    .co-accordion-arrow,
    .co-search-form,
    .co-session-dropdown,
    .lang-dropdown,
    .preset-card,
    .ticket-preset-card,
    .ticket-card,
    .co-cta-primary,
    .co-cta-secondary,
    .co-qty-btn,
    .manual-amount-input {
        transition: none;
    }
}

/* ═══════════════════════════════════════════════════════════════════════════
   Custom Class Variant Fallbacks
   ───────────────────────────────────────────────────────────────────────────
   Tailwind v3.4 JIT compiles all standard utility variants (sm:*, focus:*,
   group-hover:*, etc.). However, variants of CUSTOM classes defined in
   main.css (bg-surface, text-primary, etc.) are NOT compiled by Tailwind
   because they aren't Tailwind utilities. These fallbacks are necessary.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Custom class variants (Tailwind can't generate these) ──
 * T722 B722-7: `.odd\:bg-surface` and `.even\:bg-page` removed — zero callers
 * across all templates (T940: panel zebra is pure CSS `tr:nth-child(odd)`, not
 * these utilities nor any per-row Django class). */
.focus\:bg-surface:focus { background-color: var(--color-bg-surface); }
.focus\:text-primary:focus { color: var(--color-text-primary); }
.focus\:rounded:focus { border-radius: var(--radius-sm); }
.focus\:shadow-lg:focus { box-shadow: var(--shadow-lg); }
.focus-within\:text-info:focus-within { color: var(--color-info); }

/* ═══════════════════════════════════════════════════════════════════════════
   CO GLASS & GRADIENT SYSTEM (tarea 095)
   ───────────────────────────────────────────────────────────────────────────
   Layered depth system: gradient mesh bg → glass panel → cards → header.
   CSS-only, 0 JS. Respects prefers-reduced-motion, dark mode, @supports.
   ═══════════════════════════════════════════════════════════════════════════ */

.co-gradient-mesh {
    position: fixed;
    inset: 0;
    z-index: -1; /* fondo decorativo, intencional */
    pointer-events: none;
    background:
        radial-gradient(ellipse at 20% 50%, rgba(51, 190, 255, 0.08) 0%, transparent 50%),
        radial-gradient(ellipse at 80% 20%, rgba(147, 51, 234, 0.06) 0%, transparent 50%),
        radial-gradient(ellipse at 50% 80%, rgba(34, 197, 94, 0.05) 0%, transparent 50%),
        var(--color-bg-page);
}
:where([data-theme="dark"]) .co-gradient-mesh {
    background:
        radial-gradient(ellipse at 20% 50%, rgba(51, 190, 255, 0.12) 0%, transparent 50%),
        radial-gradient(ellipse at 80% 20%, rgba(147, 51, 234, 0.10) 0%, transparent 50%),
        radial-gradient(ellipse at 50% 80%, rgba(34, 197, 94, 0.06) 0%, transparent 50%),
        var(--color-bg-page);
}

@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
    .co-glass-panel {
        background: var(--color-bg-blur-glass);
        backdrop-filter: blur(20px) saturate(1.2);
        -webkit-backdrop-filter: blur(20px) saturate(1.2);
        border: none;
        border-radius: 0 0 var(--radius-xl) var(--radius-xl);
        box-shadow: var(--shadow-sm);
        padding: var(--space-3) var(--space-4) var(--space-6);
    }
    :where([data-theme="dark"]) .co-glass-panel {
        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
    }
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .co-glass-panel {
        background: var(--color-bg-surface);
        border: none;
        border-radius: 0 0 var(--radius-xl) var(--radius-xl);
        box-shadow: var(--shadow-sm);
        padding: var(--space-3) var(--space-4) var(--space-6);
    }
}

.header-sticky.co-header--transparent {
    background: transparent;
    box-shadow: none;
    transition: background var(--transition-slow), box-shadow var(--transition-slow), backdrop-filter var(--transition-slow);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
    .header-sticky.co-header--transparent.is-scrolled {
        background: var(--color-bg-blur-light);
        backdrop-filter: blur(16px) saturate(1.3);
        -webkit-backdrop-filter: blur(16px) saturate(1.3);
        box-shadow: var(--shadow-sm);
    }
    :where([data-theme="dark"]) .header-sticky.co-header--transparent.is-scrolled {
        background: var(--color-bg-blur-dark);
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    }
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .header-sticky.co-header--transparent.is-scrolled {
        background: var(--color-bg-surface);
        box-shadow: var(--shadow-sm);
    }
}

.preset-card[data-count]:not([data-count="0"]) {
    border-color: var(--brand-primary);
    box-shadow: 0 0 0 3px rgba(51, 190, 255, 0.25), var(--shadow-md);
    transform: translateY(-2px);
}
:where([data-theme="dark"]) .preset-card[data-count]:not([data-count="0"]) {
    box-shadow: 0 0 0 3px rgba(51, 190, 255, 0.35), var(--shadow-md);
}

/* T206: card-badge / card-minus visibility derives from data-count.
   Reserves the 44×44 touch area (set by .card-minus::before) so that
   toggling does not change card width — keeps Cumulative Layout Shift
   at zero on mobile. visibility:hidden also removes the element from
   the tab order, equivalent to display:none for keyboard nav. */
.preset-card .card-badge,
.preset-card .card-minus,
.ticket-preset-card .card-badge,
.ticket-preset-card .card-minus {
    visibility: hidden;
    opacity: 0;
    transition: opacity var(--transition-fast);
}
/* ⛔ NO añadir aquí una regla que apague `.card-minus` en `--sold-out`/`--maxed`.
   `--maxed` significa "no puedes SUBIR más", no "no puedes cambiar la cantidad":
   restar nunca puede violar un tope superior, así que el decremento NO se gatea
   con el límite (bajar de 1 a 0 es siempre legítimo). La dirección de SUMA sí
   sigue protegida, pero en JS y sobre la card, no sobre el `−`:
   `recharge.js` → `if (card.dataset.originallyLimited || card.dataset.jsMaxed)`
   y `if (current >= effectiveMax)` → `showLimitFlash(card); return;`.
   Una `pointer-events: none` sobre `.card-minus` dejaba el círculo rojo VISIBLE
   (la regla [data-count] de abajo) pero inerte al ratón/dedo — y como
   `pointer-events` no saca el `<button>` del tab order, seguía funcionando con
   teclado: el usuario quedaba con N unidades atrapadas en el carrito y sólo el
   botón global "Vaciar" como salida. Guards: el contrato de habilitación en
   `zelebrix/cargaOnline/tests/test_minus_not_gated_by_limit.py` y el spec
   `e2e/tests/regression/cargaonline-minus-not-gated-by-limit.spec.ts`. */
.preset-card[data-count]:not([data-count="0"]) .card-badge,
.preset-card[data-count]:not([data-count="0"]) .card-minus,
.ticket-preset-card[data-count]:not([data-count="0"]) .card-badge,
.ticket-preset-card[data-count]:not([data-count="0"]) .card-minus {
    visibility: visible;
    opacity: 1;
}

@media (hover: hover) {
    .co-product-card {
        transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
    }
    .co-product-card:hover {
        transform: translateY(-4px);
        box-shadow: var(--shadow-md);
        border-color: var(--brand-primary-light);
    }
    :where([data-theme="dark"]) .co-product-card:hover {
        box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25);
    }
}

body.is-navigating .co-glass-panel {
    opacity: 0;
    transform: translateY(4px);
    transition: opacity 120ms ease-out, transform 120ms ease-out;
}

.co-product-card--sold-out,
.ticket-preset-card--sold-out {
    opacity: 0.55;
    filter: grayscale(0.4);
    cursor: not-allowed;
    transition: opacity var(--transition-base), filter var(--transition-base);
}
.co-product-card--sold-out:hover,
.ticket-preset-card--sold-out:hover {
    opacity: 0.75;
    filter: grayscale(0.2);
}

/* T207 B8 — Card maxed (cart already at max selectable). Replaces inline
   `style.opacity/cursor`, Tailwind `opacity-60 cursor-not-allowed`, and
   server-side inline `style="cursor: not-allowed;"` with a single token-driven
   class shared by recharge.js, stock-polling.js, and `_ticket_preset_cards.html`. */
.preset-card--maxed,
.ticket-preset-card--maxed {
    opacity: 0.6;
    cursor: not-allowed;
    transition: opacity var(--transition-base);
}
/* T208 A3 — Exhausted card (per-user 100% consumido). N4: border-left para indicador visual claro. */
.co-product-card--exhausted { opacity: 0.7; border-left: 3px solid var(--color-error); transition: opacity var(--transition-base); }
:where([data-theme="dark"]) .co-product-card--exhausted { border-left-color: var(--color-error); }

/* F2 — Past event card modifier (#1399). Opacity ya aplicada via utility opacity-60
   inline pero ruleset propio requerido por test_semantic_classes_defined_in_css. */
.co-product-card--past {
    transition: opacity var(--transition-base);
}

/* F2 — Eventos finalizados bajo <details> colapsable.
   Scope-restringido a .co-past-details para no afectar otros <summary> del sitio.
   Opacity sobre cards past ya viene de utility Tailwind opacity-60 inline. */
.co-past-details > summary::-webkit-details-marker,
.bp-details > summary::-webkit-details-marker { display: none; }
.co-past-details > summary::marker,
.bp-details > summary::marker { content: ""; }
.co-past-details[open] > summary > svg { transform: rotate(90deg); }

.co-product-card > :last-child {
    margin-top: auto;
}

@media (prefers-reduced-motion: reduce) {
    .co-gradient-mesh { background: var(--color-bg-page); }
    .header-sticky.co-header--transparent { transition: none; }
    .preset-card { transition: none; }
    .co-product-card { transition: none; }
    body.is-navigating .co-glass-panel { transition: none; opacity: 1; transform: none; }
    .co-product-card--sold-out { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   Chatbot Widget (Felix)
   ───────────────────────────────────────────────────────────────────────────
   Migrated from inline \3c style> to main.css. Tokens for colors, shadows,
   transitions, radii. Fixed sizes (avatar, panel width) kept as px.
   ═══════════════════════════════════════════════════════════════════════════ */

#chatbot-widget {
  position: fixed;
  bottom: var(--space-6);
  right: var(--space-6);
  z-index: var(--z-chatbot);
  font-family: inherit;
}

#chatbot-widget.chatbot-widget--raised {
  bottom: 90px;
}

/* T522: sidebar overlay covers chatbot when open.
   Uses :has() — .sidebar--open toggled by common.js initSidebar(). */
body:has(.sidebar--open) #chatbot-widget {
  z-index: var(--z-base);
}

.chatbot-toggle {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--brand-surface-strong);
  color: var(--color-text-inverse);
  border: none;
  cursor: pointer;
  box-shadow: var(--shadow-brand-sm);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
}
.chatbot-toggle:hover {
  background: var(--brand-surface-strong-hover);
  box-shadow: var(--shadow-brand-lg);
}
.chatbot-toggle:focus-visible {
  outline: 2px solid var(--brand-primary);
  outline-offset: 2px;
}

.chatbot-panel {
  display: none;
  position: absolute;
  bottom: 60px;
  right: 0;
  max-width: min(340px, calc(100vw - 48px));
  width: 340px;
  background: var(--color-bg-surface);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  overflow: hidden;
  flex-direction: column;
  border: 1px solid var(--color-border-default);
  animation: chatbotSlideUp var(--dur-base) ease;
}

.chatbot-header {
  background: var(--brand-primary);
  padding: var(--space-3) var(--space-4);
  display: flex;
  align-items: center;
  gap: var(--space-2-5);
}

.chatbot-avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--color-white-22);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  font-size: var(--text-base);
  line-height: 1;
}

.chatbot-avatar--sm {
  width: 26px;
  height: 26px;
  background: var(--brand-primary);
  font-size: var(--text-sm);
  margin-top: 2px;
}

.chatbot-header-title {
  color: var(--color-text-inverse);
  font-weight: var(--font-semibold);
  font-size: var(--text-sm);
  line-height: var(--leading-tight);
}

.chatbot-header-badge {
  background: var(--color-white-22);
  color: var(--color-text-inverse);
  font-size: var(--text-3xs);
  font-weight: var(--font-bold);
  letter-spacing: var(--tracking-wider);
  padding: 1px var(--space-1-5);
  border-radius: var(--radius-full);
  text-transform: uppercase;
  line-height: var(--leading-relaxed);
}

.chatbot-header-sub {
  color: var(--color-white-80);
  font-size: var(--text-2xs);
}

.chatbot-close {
  margin-left: auto;
  background: none;
  border: none;
  cursor: pointer;
  color: var(--color-white-80);
  padding: var(--space-1);
  border-radius: var(--radius-sm);
  display: flex;
  min-width: var(--touch-target-min);
  min-height: var(--touch-target-min);
  align-items: center;
  justify-content: center;
}
.chatbot-close:hover { color: var(--color-text-inverse); }
.chatbot-close:focus-visible {
  outline: 2px solid var(--color-text-inverse);
  outline-offset: 2px;
}

.chatbot-messages {
  min-height: 200px;
  max-height: 50vh;
  overflow-y: auto;
  padding: var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-2-5);
  background: var(--color-bg-page);
}

.chatbot-bubble-user {
  background: var(--brand-surface-strong);
  color: var(--color-text-inverse);
  border-radius: var(--radius-lg) var(--radius-sm) var(--radius-lg) var(--radius-lg);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-xs);
  line-height: var(--leading-normal);
  max-width: 80%;
  box-shadow: var(--shadow-brand-sm);
}

.chatbot-bubble-bot {
  background: var(--color-bg-surface);
  border: 1px solid var(--color-border-default);
  border-radius: var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-sm);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-xs);
  color: var(--color-text-primary);
  line-height: var(--leading-normal);
  max-width: calc(100% - 38px);
  box-shadow: var(--shadow-xs);
}

.chatbot-suggestions {
  padding: var(--space-2) var(--space-3);
  border-top: 1px solid var(--color-border-default);
  display: flex;
  gap: var(--space-1-5);
  flex-wrap: wrap;
  background: var(--color-bg-surface);
}

.cb-suggestion {
  font-size: var(--text-2xs);
  padding: var(--space-1) var(--space-2-5);
  border-radius: var(--radius-sm);
  border: 1px solid var(--brand-primary);
  background: var(--color-bg-surface);
  color: var(--brand-primary-dark);
  cursor: pointer;
  transition: all var(--transition-fast);
}
.cb-suggestion:hover {
  background: var(--brand-surface-strong);
  color: var(--color-text-inverse);
}
.cb-suggestion:focus-visible {
  outline: 2px solid var(--brand-primary);
  outline-offset: 2px;
}

.chatbot-input-area {
  padding: var(--space-2-5) var(--space-3);
  border-top: 1px solid var(--color-border-default);
  background: var(--color-bg-surface);
  display: flex;
  gap: var(--space-2);
  align-items: flex-end;
}

.chatbot-textarea {
  flex: 1;
  resize: none;
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-xs);
  color: var(--color-text-primary);
  line-height: var(--leading-normal);
  max-height: 80px;
  overflow-y: auto;
  font-family: inherit;
  background: var(--color-bg-page);
  transition: border-color var(--transition-fast), background var(--transition-fast);
}
.chatbot-textarea:focus-visible {
  border-color: var(--brand-primary);
  background: var(--color-bg-surface);
  outline: 2px solid var(--brand-focus-ring);
  outline-offset: -2px;
}

.chatbot-send {
  min-width: var(--touch-target-min);
  min-height: var(--touch-target-min);
  border-radius: var(--radius-sm);
  flex-shrink: 0;
  background: var(--brand-surface-strong);
  color: var(--color-text-inverse);
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background var(--transition-fast);
}
.chatbot-send:hover { background: var(--brand-surface-strong-hover); }
.chatbot-send:focus-visible {
  outline: 2px solid var(--brand-primary);
  outline-offset: 2px;
}

@keyframes chatbotSlideUp {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}
.chatbot-messages::-webkit-scrollbar { width: 4px; }
.chatbot-messages::-webkit-scrollbar-track { background: transparent; }
.chatbot-messages::-webkit-scrollbar-thumb { background: var(--color-border-strong); border-radius: var(--radius-sm); }

.cb-msg-user { display: flex; justify-content: flex-end; }
.cb-msg-bot  { display: flex; gap: var(--space-2); align-items: flex-start; }

.cb-typing-dot {
  width: 6px; height: 6px; border-radius: 50%; background: var(--color-text-muted);
  animation: cbBounce 1.2s infinite ease-in-out; display: inline-block;
}
.cb-typing-dot:nth-child(2) { animation-delay: .2s; }
.cb-typing-dot:nth-child(3) { animation-delay: .4s; }
@keyframes cbBounce {
  0%, 80%, 100% { transform: translateY(0); opacity: .5; }
  40% { transform: translateY(-4px); opacity: 1; }
}

.cb-feedback-row {
  display: flex; gap: var(--space-1-5); align-items: center; margin-top: var(--space-1-5); justify-content: flex-end;
}
.cb-feedback-row > span {
  font-size: var(--text-2xs); color: var(--color-text-muted);
}
.cb-feedback-btn {
  background: none; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm);
  cursor: pointer; padding: var(--space-0-5) var(--space-2); font-size: var(--text-xs); color: var(--color-text-muted);
  transition: all var(--transition-fast);
}
.cb-feedback-btn:hover { border-color: var(--brand-primary); color: var(--brand-primary); }
.cb-feedback-btn:focus-visible { outline: 2px solid var(--brand-primary); outline-offset: 1px; }
.cb-feedback-btn.active { border-color: var(--brand-primary); color: var(--brand-primary); background: var(--brand-primary-light); }

.cb-source-tag {
  display: inline-block; background: var(--color-bg-elevated); border: 1px solid var(--color-border-default);
  border-radius: var(--radius-sm); padding: 1px var(--space-1-5); font-size: var(--text-2xs); color: var(--color-text-secondary);
  margin-top: var(--space-1); margin-right: var(--space-1);
}

.cb-sources { margin-top: var(--space-1); }
.cb-typing-content { display: flex; gap: var(--space-1); align-items: center; }

.cb-md h1 { font-size: var(--text-base); font-weight: var(--font-bold); margin: var(--space-2) 0 var(--space-1); line-height: var(--leading-snug); }
/* stylelint-disable-next-line no-descending-specificity
   -- [cross-component false-positive] '.cb-md h2' (0,1,1) vs earlier
   ':where([data-theme=dark]) .modal .modal-header h2' (0,3,0, line 4688):
   the chatbot markdown surface and the dark modal header are disjoint subtrees;
   no element matches both, so there is no effective cascade conflict. */
.cb-md h2 { font-size: var(--text-sm); font-weight: var(--font-bold); margin: var(--space-2) 0 var(--space-1); line-height: var(--leading-snug); }
.cb-md h3 { font-size: var(--text-sm); font-weight: var(--font-bold); margin: var(--space-1-5) 0 2px; line-height: var(--leading-snug); }
.cb-md ul { margin: var(--space-1) 0; padding-left: var(--space-5); list-style-type: disc; }
.cb-md ol { margin: var(--space-1) 0; padding-left: var(--space-5); list-style-type: decimal; }
.cb-md li { margin: var(--space-0-5) 0; }
.cb-md code { background: var(--color-bg-elevated); padding: 1px var(--space-1); border-radius: var(--radius-sm); font-size: var(--text-xs); font-family: 'Courier New', monospace; }
.cb-md pre { background: var(--color-bg-elevated); border-radius: var(--radius-sm); padding: var(--space-2) var(--space-2-5); font-size: var(--text-xs); overflow-x: auto; font-family: 'Courier New', monospace; margin: var(--space-1-5) 0; white-space: pre-wrap; }
.cb-md pre code { background: none; padding: 0; font-size: inherit; }
.cb-md hr { border: none; border-top: 1px solid var(--color-border-default); margin: var(--space-2) 0; }

/* ── P2P QR Transfer ──────────────────────────────────────── */
.co-btn-premium--recibir { --_btn-gradient: var(--gradient-btn-recibir); }
.p2p-state.hidden { display: none; }
#p2pQrReader { border-radius: var(--radius-lg); overflow: hidden; min-height: 200px; }
#p2pQrReader video { width: 100%; border-radius: var(--radius-lg); }
/* S28-04: larger chip-id input for manual P2P entry (.form-input already dark-aware) */
.p2p-chipid-input {
    font-size: var(--text-lg);
    min-height: var(--size-btn-lg);
    padding: var(--space-3) var(--space-4);
    letter-spacing: 0.05em;
}

/* ══════════════════════════════════════════════════════════════
   TASK 108 — Micro-interactions & wow animations
   ══════════════════════════════════════════════════════════════ */

/* ── Button hover lift ── */
.btn-primary:hover:not(:disabled),
.btn-success:hover:not(:disabled),
.btn-danger:hover:not(:disabled) {
    transform: translateY(-1px);
    box-shadow: var(--shadow-md);
}

/* ── Payment success celebration ── */
@keyframes co-checkmark-scale {
    0%   { transform: scale(0) rotate(-45deg); opacity: 0; }
    50%  { transform: scale(1.1) rotate(0deg); }
    100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
.co-success-checkmark {
    animation: co-checkmark-scale 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes co-confetti-fall {
    0%   { transform: translateY(0) rotate(0deg); opacity: 1; }
    100% { transform: translateY(120px) rotate(360deg); opacity: 0; }
}
.co-confetti-container { position: fixed; inset: 0; pointer-events: none; z-index: var(--z-toast); overflow: hidden; }
.co-confetti { position: absolute; width: 8px; height: 8px; background: var(--brand-primary); left: var(--x); top: 40%; border-radius: var(--radius-xs); animation: co-confetti-fall 1.2s ease-out forwards; animation-delay: var(--delay); }
.co-confetti:nth-child(2n) { background: var(--color-success); border-radius: 50%; }
.co-confetti:nth-child(3) { background: var(--color-warn); width: 10px; height: 6px; }
.co-confetti:nth-child(5) { width: 6px; height: 10px; }

@keyframes co-amount-reveal { from { transform: scale(0.8) translateY(10px); opacity: 0; } }
.co-success-amount { animation: co-amount-reveal 0.5s ease-out 0.3s both; }

/* ── Card selection premium feedback ── */
.preset-card.is-selected,
.ticket-preset-card.is-selected {
    border-color: var(--brand-primary);
    background: var(--brand-primary-light);
}
.preset-card.is-selected::after,
.ticket-preset-card.is-selected::after {
    content: '\2713';
    position: absolute;
    top: -6px;
    right: -6px;
    width: 20px;
    height: 20px;
    background: var(--color-success);
    border-radius: var(--radius-full);
    color: var(--color-text-inverse);
    font-size: var(--text-xs);
    font-weight: var(--font-bold);
    display: flex;
    align-items: center;
    justify-content: center;
    animation: co-badge-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes co-badge-pop {
    0%   { transform: scale(0); }
    100% { transform: scale(1); }
}

/* ── Trust bar lock pulse ── */
.co-trust-icon--animate {
    animation: co-lock-pulse 2.5s ease-in-out infinite;
}
@keyframes co-lock-pulse {
    0%, 100% { opacity: 0.7; transform: scale(1); }
    50%      { opacity: 1; transform: scale(1.08); }
}

/* ── View Transitions API ── */
::view-transition-old(root) {
    animation: co-vt-fade-out 0.15s ease-out forwards;
}
::view-transition-new(root) {
    animation: co-vt-fade-in 0.15s ease-out forwards;
}
@keyframes co-vt-fade-out { to { opacity: 0; } }
@keyframes co-vt-fade-in  { from { opacity: 0; } }

/* ── Payment overlay styles (replaces inline style) ── */
.co-payment-overlay {
    z-index: var(--z-modal-backdrop);
    opacity: 0.97;
}

/* Reduced-motion: all animations above are covered by the global
   @media(prefers-reduced-motion) override at L832 (duration: 0.01ms !important).
   No per-class overrides needed. */

/* ═══════════════ SALDO DEL PORTAL (#3309 B) ═════════════════════
   La fila «Tu saldo» del portal CargaOnline. La CAJA (borde, fondo, radio,
   sombra, padding) se queda en las utilidades que ya tenía: aquí sólo vive la
   rejilla y la tipografía.

   ⛔ Por qué este bloque NO define ningún estado (`.co-balance--negative` y
   compañía): `.balance-positive/-negative/-neutral` ya existen más arriba en
   este mismo fichero y le ganan hoy a `.bg-surface` y a `.border-default` por
   ORDEN DE FICHERO. Una clase de estado declarada AQUÍ, al final, les ganaría a
   ellas y dejaría la fila del modal de pago sin tinte sin que nadie se entere.
   El estado del saldo se comunica con la tinta del valor (`text-status-error`)
   y con el caption, que es lo que los guards de
   `test_co_balance_row.py` ya miden.

   Rejilla de 2 columnas x 3 filas:
       [ label   ][      ]
       [ valor   ][ mark ]   <- `mark` ocupa las dos primeras filas
       [ caption (1 / -1)  ]
*/
.co-balance {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: center;
    gap: var(--space-1) var(--space-3);
}

.co-balance__label {
    grid-column: 1;
    color: var(--color-text-secondary);
    font-size: var(--text-xs);
    font-weight: var(--font-medium);
    letter-spacing: var(--tracking-wide);
}

/* El HÉROE de la fila. 24px frente a los 20px del hero del pase y a los 16px del
   cuerpo: el dinero del usuario es lo más grande de la columna. El guard que lo
   fija es `test_the_balance_outshouts_the_pass`. */
.co-balance__value {
    grid-column: 1;
    font-size: var(--text-2xl);
    font-weight: var(--font-bold);
    line-height: var(--leading-tight);
    white-space: nowrap;
}

/* La divisa NO compite con la cifra: mismo nodo, un peso y un tamaño menos. */
.co-balance__unit {
    font-size: var(--text-base);
    font-weight: var(--font-medium);
    color: var(--color-text-secondary);
}

/* Cápsula del icono. El par `--brand-primary-light` / `--brand-primary-dark` es
   EXACTAMENTE el de `.co-nav-pill.is-active`: no es un acento inventado, es el
   de marca que el portal ya usa, y los dos tokens se voltean solos en dark
   (de ahí que este bloque no tenga rama dark propia). */
.co-balance__mark {
    grid-column: 2;
    grid-row: 1 / 3;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--space-10);
    height: var(--space-10);
    border-radius: var(--radius-full);
    background: var(--brand-primary-light);
    color: var(--brand-primary-dark);
}

.co-balance__caption {
    grid-column: 1 / -1;
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}


/* ═════════════ PASE DE BATALLA — tarjeta del portal (#2812 C2) ═════════════
   Cierra el bloque `co-*` del portal CargaOnline. Sólo tokens: ningún literal
   de color, radio ni espaciado — el gate `check_design_tokens.py` lo verifica.

   ⛔ Los cuatro estados de hito se distinguen por FORMA + ICONO + TEXTO, no por
   color. Aquí vive la pata de FORMA: `claimed` y `available` van RELLENOS,
   `available` además con halo, `holding` relleno en info y `locked` HUECO con
   borde. Si mañana alguien unifica las formas «para que quede más limpio», los
   cuatro estados pasan a ser el mismo estado para un daltónico y el gate axe de
   #2753 lo marca como violación de 1.4.1.

   Tipografía en `em` y no en `px`/`rem` decimales: el DS no tiene tokens de
   tamaño de fuente, y un literal en px lo bloquearía el mismo gate. `em` escala
   con el contenedor, que es además lo que esta tarjeta quiere. */

.bp-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    width: 100%;
    /* ⛔ MEDIDO: la tarjeta es un ITEM de la rejilla de la portada, y un item de
       rejilla trae `min-width: auto`, o sea que su mínimo es su MIN-CONTENT. En
       cuanto la tira declaró un carril más ancho que la pantalla (OD-3370 P0: un
       hueco por hito), ese min-content subió a 736px y la tarjeta se salía de su
       pista de 375 — sin scroll propio, porque quien acababa recortando era
       `.co-main` con su `overflow-x: clip`: los dos últimos hitos quedaban fuera
       de la pantalla y sin forma de llegar a ellos. Con `min-width: 0` la tarjeta
       vuelve a caber en su pista y el desbordamiento lo absorbe el
       `overflow-x: auto` de `.bp-bar`, que es donde tiene que estar. */
    min-width: 0;
    margin-bottom: var(--space-2);
    padding: var(--space-4);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-lg);
    background: var(--color-bg-surface);
    box-shadow: var(--shadow-sm);
}

.bp-card__top {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
}

.bp-level-pill {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    padding: var(--space-1) var(--space-2-5);
    border-radius: var(--radius-full);
    background: var(--color-info-bg);
    color: var(--color-status-info-text);
    font-size: 0.8125em;
    font-weight: 600;
    white-space: nowrap;
}

.bp-window {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    color: var(--color-text-muted);
    font-size: 0.8125em;
    white-space: nowrap;
}

.bp-hero {
    display: flex;
    flex-direction: column;
    gap: var(--space-0-5);
}

.bp-hero__amount {
    color: var(--color-text-primary);
    /* #3309 A [G-movil] — JERARQUÍA: era 1.75em (~28px), más grande que el saldo
       del usuario (16px) que vive DOS tarjetas más arriba en la misma columna.
       La pantalla gritaba el progreso del pase y susurraba el dinero. Con
       `--text-xl` (20px) y el saldo promovido a `--text-2xl` (24px) la escala
       queda saldo 24 > pase 20 > cuerpo 16. Token, no literal: el gate
       `check_design_tokens.py` sólo bloquea px y rem decimales. El guard que lo
       fija es `test_the_balance_outshouts_the_pass` (test_co_balance_row.py):
       si alguien devuelve esto a 1.75em, se pone rojo. */
    font-size: var(--text-xl);
    font-weight: 700;
    line-height: 1.1;
    /* Las cifras del hero cambian de dígito sin cambiar de ancho: sin esto el
       texto salta lateralmente entre recargas de la misma pantalla. */
    font-variant-numeric: tabular-nums;
}

.bp-hero__target {
    color: var(--color-text-secondary);
    font-size: 0.875em;
}

/* ── La tira: barra + nodos ─────────────────────────────────────────────── */

.bp-bar {
    position: relative;
    /* La tira nunca fuerza scroll horizontal a la PÁGINA: si a 375 px no cabe,
       se desplaza dentro de su propio contenedor. */
    overflow-x: auto;
    /* ⛔ MEDIDO, y sin esto lo de arriba es mentira: `.bp-card` es `display:flex`
       en columna, así que `.bp-bar` es un ITEM de flex y su `min-width: auto`
       vale su tamaño MÍNIMO DE CONTENIDO. Desde que el carril declara un
       `min-width` propio (el hueco por hito), ese mínimo pasó a ser el carril
       entero: a 375 px la barra se plantaba en 548 px de `clientWidth` dentro de
       una tarjeta de 309 px —o sea que no scrolleaba, DESBORDABA— y el recorte lo
       acababa haciendo un ancestro. Con `min-width: 0` la barra encoge a su hueco
       y el desbordamiento vuelve a su scroll propio, que es lo que el comentario
       de arriba promete. */
    min-width: 0;
    /* ⛔ El padding lateral NO es estético: es la MITAD de lo que se centra en el
       porcentaje. Cada nodo se centra con translateX(-50%), así que el del 100 %
       asoma media caja por la derecha; sin este colchón el hito final sale
       RECORTADO. Va en .bp-bar y no en un hijo porque encoge a los DOS por igual
       y conserva la alineación 0-100 % entre el carril y las bolitas. Medido en
       dev-local con la tira pelada: sin él, scrollWidth 304 > clientWidth 294. */
    padding: var(--space-3) var(--space-2-5) var(--space-2);
}

/* ⛔ Con bocadillo lo que se centra en el porcentaje ya no es la bolita (20px)
   sino la COLUMNA entera (`--bp-node-pill-w`), así que el colchón pasa a ser
   media columna. MEDIDO: con los 10px de la regla de arriba el último bocadillo
   asomaba y hubo que alinear los extremos al borde en vez de centrarlos, y eso
   DESPLAZABA media columna la primera y la última —el hueco con su vecino se
   comía 44px y los pares extremos se solapaban 39,59px y 8,80px con 6 hitos—.
   Con el colchón correcto TODAS las columnas se centran igual, el hueco es
   uniforme y la aritmética del `min-width` vuelve a ser exacta.
   `:has()` y no un cambio a secas: la tira SIN etiquetar (`.bp-nodes` a pelo)
   sigue centrando una bolita de 20px y 44px de colchón serían aire para nada. */
.bp-bar:has(> .bp-nodes--labelled) {
    padding-inline: calc(var(--bp-node-pill-w) / 2);
}

.bp-track {
    position: relative;
    height: var(--space-2);
    border-radius: var(--radius-full);
    background: var(--color-bg-page);
    border: 1px solid var(--color-border-subtle);
}

.bp-track__fill {
    height: 100%;
    border-radius: var(--radius-full);
    background: var(--color-success);
    transition: width 300ms ease;
}

.bp-nodes {
    position: relative;
    height: var(--space-6);
}

/* ⭐ OD-3370 P0 (2026-09-20) — `.bp-node` dejó de SER la bolita y pasó a ser la
   COLUMNA del hito: bolita + umbral + bocadillo, apilados. La bolita es ahora
   `.bp-node__dot`, un hijo con caja propia.

   ⛔ No es un renombrado estético, es lo que hace posible la orden del owner
   («debajo de cada bolita, el bocadillo»). Mientras `.bp-node` era un
   `position: absolute` de 20x20, CUALQUIER cosa colgada de él tenía que ir
   también absoluta, y un absoluto NO da alto a su contenedor: el bocadillo
   quedaba fuera de la caja de `.bp-bar` —que computa `overflow-y: auto` por
   declarar `overflow-x`— y se RECORTABA. Medido antes de tocar nada:
   `clientHeight` de la barra 68px sin cambiar, `scrollHeight` 102px, 34,09px del
   bocadillo por debajo del borde y `scrollTop` 34 alcanzable. Con la columna en
   FLUJO el alto lo pone el contenido y no hay nada que recortar.

   El `style="left: N%"` sigue en `.bp-node` y sigue significando lo mismo, y eso
   es deliberado: es el ancla del guard de relación
   (`zelebrix/zelebrix/tests/test_bp_preview_node_binding.py`), que exige que todo
   dato de un hito cuelgue del elemento que lleva EL PORCENTAJE de ese hito. */
.bp-node {
    /* Item de rejilla en la MISMA celda que todos los demás (`grid-area: 1 / 1`,
       más abajo) y `position: relative` en vez de `absolute`: el bloque contenedor
       de un item de rejilla es SU ÁREA, que aquí es el carril entero, así que
       `left: 25%` sigue valiendo el 25% del carril — la misma aritmética que con
       `absolute`, pero sin salirse del flujo. */
    position: relative;
    top: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: var(--bp-node-pill-w);
    /* La columna se centra en su porcentaje, así que la bolita —centrada en la
       columna— cae exactamente sobre él. */
    transform: translateX(-50%);
}

.bp-node__dot {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: var(--space-5);
    height: var(--space-5);
    border-radius: var(--radius-full);
}

.bp-node--claimed .bp-node__dot {
    background: var(--color-success);
    border: var(--space-0-5) solid var(--color-bg-surface);
    color: var(--color-bg-surface);
}

.bp-node--available .bp-node__dot {
    background: var(--color-warn);
    border: var(--space-0-5) solid var(--color-bg-surface);
    color: var(--color-bg-surface);
    /* Halo: el único hito accionable tiene que verse ANTES de leer la tira. */
    box-shadow: 0 0 0 var(--space-1) var(--color-warn-bg);
}

.bp-node--holding .bp-node__dot {
    background: var(--color-info);
    border: var(--space-0-5) solid var(--color-bg-surface);
    color: var(--color-bg-surface);
}

.bp-node--locked .bp-node__dot {
    /* HUECO, y ésa es la señal: la diferencia relleno/hueco sobrevive a la
       escala de grises, al daltonismo y a un móvil al sol. */
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-strong);
    color: var(--color-text-muted);
}

/* ── Los tramos ─────────────────────────────────────────────────────────── */

.bp-tiers {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin: 0;
    padding: 0;
    list-style: none;
}

.bp-tier {
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: center;
    gap: var(--space-1) var(--space-2);
    padding: var(--space-2) var(--space-2-5);
    border: 1px solid var(--color-border-subtle);
    border-radius: var(--radius-md);
    background: var(--color-bg-page);
}

.bp-tier__badge {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    white-space: nowrap;
}

.bp-tier__threshold {
    color: var(--color-text-primary);
    font-size: 0.875em;
    font-weight: 600;
    text-align: right;
}

.bp-tier__reward {
    grid-column: 1 / -1;
    color: var(--color-text-secondary);
    font-size: 0.8125em;
}

/* ⭐ OD-3370 P0 (2026-09-20) — el BOCADILLO se fue del tramo a la BOLITA, y con
   el se fue `.bp-tier__reward-pill` entera (clase + rabito). Vive ahora en
   `.bp-node__pill`, mas arriba en este mismo bloque, colgando de `.bp-node`.

   Orden del owner, tercera vez que la repite: *«debajo del node salga el
   bocadillo, tanto en el panel de zelebrix como en CargaOnline. Debajo de cada
   bolita […] Simplificando asi en CO la seccion bp-details, pues la info ya esta
   justo en el grafico»*.

   ⛔ Lo que `bp-details` PIERDE es exactamente UNA ranura, y esta comprobado dato a
   dato contra la tira, no por confianza:
     · la RECOMPENSA (`bp-tier__reward-pill`) la dice ahora la linea 2 del bocadillo;
     · el NIVEL lo dice la linea 1 (antes no estaba en `bp-details`: se fue en #3363
       pt2 por ser la posicion de la bolita).
   ⛔ Lo que en su dia NO se toco, porque la tira NO lo dice: `bp-tier__badge`
   (`state_text` lleva una FECHA en la rama `available` — OD-BP-9) y
   `bp-tier__meta` («Alcanzado el …» / cupo restante). Ni la fecha de alcance ni el
   cupo aparecen en ninguna bolita.
   ⚠️ **DEROGADO EL 2026-09-20 POR LA OD-13 (#3423) para CargaOnline, y solo ahi:**
   el owner mando quitar el desplegable entero — *«ya no tiene valor»*— con esta
   tabla delante, y acepto por escrito perder los dos datos. Las dos clases siguen
   VIVAS en `co_benefits_reward.html` y `battlepass_claims.html`, que esa OD no
   toca; lo que desaparecio es su uso en el pase del portal.

   MEDIDO en el mismo banco que el resto de este bloque (Chromium headless,
   viewport 375x812, markup REAL renderizado por Django, `main.css` +
   `tailwind.output.css` reales, **Lato ya cargada** — se espera a
   `document.fonts.ready` ANTES de medir, porque el ancho de un texto lo fija la
   metrica de la webfont). El ancho util del tramo es **309px**: `.bp-card` mide
   343px externos (375 menos los dos gutters de 16) y su interior es
   343 - 2x1px de borde - 2x`--space-4`.

     tramo (309px)                       demo    AL LADO    SIN el (esta)
     sin `bp-tier__meta`                62,50px   45,55px      38,00px
     con `bp-tier__meta`                84,50px   85,09px      60,00px

   Las dos columnas de la izquierda son las disposiciones historicas del bocadillo
   DENTRO del tramo (la de `demo` antes de #3363 y la «al lado» que el owner revoco
   en OD-3370); la tercera es lo que queda hoy, con el bocadillo fuera. Son
   -31,55px por tramo respecto a lo que habia en `demo` el 2026-09-19 (69,55px sin
   meta / 91,55px con meta, las dos cifras que el owner tuvo delante), o sea
   -126,19px en la `<ul class="bp-tiers">` de 4 hitos y -126,18px en el `<details>`
   abierto. Plegado el ahorro es 0px, y eso tambien es cierto: `<details>` cerrado
   no maqueta su contenido.

   ⚠️ RETIRADA una tercera fila («recompensa de texto libre larga»: 104,00 / 190,38
   / 109,09px) y con ella el argumento que colgaba de ella. No es que estuviera mal
   medida: es que dependia de una CADENA que este comentario nunca escribio. La
   rama `milestone.reward_label` del presenter es texto que teclea el organizador y
   que nadie acota, asi que cada cadena da otro numero y sin la literal nadie puede
   repetir la medicion: eso no es evidencia, es una cifra de autoridad. Vuelve a
   entrar el dia que venga con su literal al lado, escrita como `cadena medida:` y
   la cadena entre comillas angulares.
   `scripts/tests/test_bp_preview_measurement_bank.py` exige esa literal a cualquier
   fila de esta tabla cuyo caso dependa de un texto libre. */

/* ── Regla del pase y estado vacío ──────────────────────────────────────── */

.bp-rule {
    display: flex;
    align-items: flex-start;
    gap: var(--space-2);
    color: var(--color-text-muted);
    font-size: 0.75em;
    line-height: 1.4;
}

.bp-rule__icon {
    flex-shrink: 0;
}

.bp-empty {
    color: var(--color-text-muted);
    font-size: 0.75em;
}

/* ── Desplegable del detalle del pase (#3309 A) ─────────────────────────────
   ⛔ NO se usa `.co-accordion-*` (`max-height: 0` deja el contenido MAQUETADO y
   sólo lo recorta) ni `.co-tab-panel` (que era `position:absolute;
   visibility:hidden` — caja generada — cuando se escribió esto; #3309 C lo pasó
   a `display:none`, pero sigue siendo un mecanismo de pestañas, no de
   desplegable, y no aplica aquí). Cerrado,
   `<details>` no genera cajas para su contenido: no se maqueta, no ocupa y no
   puede alargar el documento. Ésa es exactamente la propiedad que la issue pide
   y la razón de que aquí no haya ni JS ni `aria-expanded` a mano.
   Los dos `::marker` se APILAN arriba, en las reglas que ya existían para
   `.co-past-details`, en vez de duplicar el bloque. */
.bp-details__summary {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-height: var(--touch-target-min);
    padding: var(--space-1) var(--space-2);
    border-radius: var(--radius-md);
    color: var(--color-text-secondary);
    font-size: 0.875em;
    font-weight: 600;
    cursor: pointer;
    user-select: none;
    /* Safari: sin esto el triángulo nativo sigue ahí y el `inline-flex` no aplica. */
    list-style: none;
}

.bp-details__summary:hover {
    background: var(--color-bg-elevated);
}

.bp-details__chevron {
    flex-shrink: 0;
    transition: transform var(--transition-fast);
}

.bp-details[open] .bp-details__chevron {
    transform: rotate(180deg);
}

/* ⭐ OD-13 (2026-09-20, issue #3423) — AQUI HABIA DOS REGLAS MAS Y SE FUERON.
   `.bp-details > .bp-tiers` y `.bp-details > .bp-rule` daban el aire de dentro del
   desplegable del pase de CargaOnline. Ese desplegable YA NO EXISTE: la orden del
   owner fue *«en carga online, quita el desplegable de los detalles por hito, ya no
   tiene valor»*, y se quito entero. Sin `<details>` en `_co_benefits_pass.html` no
   queda ni una `.bp-tiers` ni una `.bp-rule` que sea hija DIRECTA de un
   `.bp-details` en todo el repo, asi que las dos eran selectores muertos y salen
   con el markup — no se dejan «por si vuelve».

   ⛔ Lo que NO sale, y no es inercia: `.bp-details__summary`, su `:hover`,
   `.bp-details__chevron`, `.bp-details[open] …` y los dos `::marker` de arriba
   siguen VIVOS — los usa `zelebrix/zelebrix/templates/battlepass/tutorial.html`
   (#3357), que reutiliza `.bp-details` tal cual. Y `.bp-tiers`, `.bp-tier`,
   `.bp-tier__badge`, `.bp-tier__threshold`, `.bp-tier__reward`, `.bp-tier__meta`,
   `.bp-rule` y `.bp-rule__icon` siguen vivas en `co_benefits_reward.html` (la hoja
   de detalle de UN hito, #2812 C5 §4.5, que esta OD NO toca) y en
   `battlepass_claims.html`. Borrarlas «porque el desplegable se fue» romperia dos
   pantallas que no entran en este encargo. */

/* Cuarta ranura del tramo: «Alcanzado el …» / «Quedan N unidades».
   ⛔ SIN ESTA REGLA el <span> se auto-coloca en la pista `auto` de `.bp-tier`
   (`grid-template-columns: auto 1fr`) — o sea en una columna del ancho del
   badge— y se rompe a TODOS los anchos. `.bp-tier__reward` usa este mismo
   `1 / -1` por la misma razón. */
.bp-tier__meta {
    grid-column: 1 / -1;
    color: var(--color-text-muted);
    font-size: 0.75em;
}

/* ── La tira ETIQUETADA: una columna por hito (#3309 A, OD-3370 P0) ──────────
   Modificador, y NO una edición de `.bp-nodes`: la regla base la comparten el
   portal y `zelebrix/templates/components/_bp_pass_preview.html` (panel staff),
   y la columna sólo la necesita quien pinta etiqueta. Hoy la usan las DOS
   superficies; sigue siendo un modificador porque el día que una tercera tira
   quiera nodos pelados, la base la sirve sin tocar nada.

   ⭐ Rejilla de UNA celda con todos los nodos dentro (`grid-area: 1 / 1`), y no
   el `position: relative` + hijos absolutos de antes. Las dos propiedades que
   compra:
     1. El bloque contenedor de un item de rejilla es SU ÁREA —el carril entero—,
        así que el `left: N%` de cada nodo sigue midiendo sobre el carril y no
        sobre su columna. La posición no cambia ni un píxel.
     2. Los nodos están EN FLUJO, así que la celda crece con el más alto. Ése es
        el punto: el bocadillo puede medir lo que su recompensa necesite (1, 2 o 4
        líneas) sin que nadie tenga que adivinar un alto fijo, y sin que
        `.bp-bar` —que computa `overflow-y: auto` por declarar `overflow-x`— lo
        recorte. El `height: var(--space-10)` fijo de #3309 A se fue por eso. */
.bp-nodes--labelled {
    display: grid;
    grid-template-columns: 1fr;
    /* ⛔ Anula el `height: var(--space-6)` de la base, y es la mitad que hace
       posible la orden. MEDIDO en navegador antes de ponerlo: la tira se quedaba
       en `clientHeight` 52 con `scrollHeight` 139, o sea que los seis bocadillos
       colgaban 87 px por debajo del borde y `.bp-bar` los recortaba —computa
       `overflow-y: auto` por declarar `overflow-x`—. Con alto automático la celda
       crece con el bocadillo más alto y no hay nada fuera que recortar. */
    height: auto;
}

.bp-nodes--labelled > .bp-node {
    grid-area: 1 / 1;
    justify-self: start;
}

/* ⛔ El HUECO que cada hito reserva en el carril, y por qué va en los DOS hijos.
   `.bp-bar` es el contenedor de scroll; sus hijos son bloques normales y su
   ancho es el VISIBLE, no el desplazable. Si sólo `.bp-nodes` creciera, el
   carril (`.bp-track`) se quedaría corto y los porcentajes dejarían de caer
   sobre él. Con el mismo `min-width` en los dos, la tira entera crece a la vez y
   `overflow-x: auto` —que ya estaba declarado y documentado— hace el resto.

   `--bp-slots` lo escribe la plantilla: es el NÚMERO DE HITOS, un entero de
   layout, hermano del `left: N%` que §4.2 ya permite en línea. Sin él (tira sin
   etiquetar) el `min-width` vale una columna y no estorba.

   Sin esto los bocadillos SE PISAN, y es aritmética: el carril mide 289px en el
   portal a 375px, así que con 4 hitos el hueco es 72,25px contra los 88px de la
   columna — 15,75px de solape por pareja. Con el `min-width` el hueco es SIEMPRE
   exactamente una columna, a cualquier N y en cualquier ancho.

   ⛔ `:where(.bp-bar) >` y NO `.bp-bar >`. El combinador esta aqui para ACOTAR
   («solo los hijos directos de la barra»), no para ganar una puja de cascada:
   nadie mas declara `min-width` sobre `.bp-track` ni sobre `.bp-nodes--labelled`,
   asi que la especificidad de esta regla no decide nada. Escrito como `.bp-bar >`
   sube a (0,2,0) y deja por debajo al `:where([data-theme="dark"]) .bp-track` que
   viene DESPUES y vale (0,1,0) —`:where()` no aporta especificidad—: eso es
   justo lo que `no-descending-specificity` prohibe, y tumbo el gate de stylelint.
   Es un ERROR, no un warning, asi que el ratchet `--max-warnings 0` de
   `scripts/ci/checks/stylelint.sh` no lo tapa (y subirlo seria un baseline).
   Con `:where()` la regla vale (0,1,0) —igual que `.bp-track` mas arriba y que el
   bloque dark de mas abajo— y casa EXACTAMENTE los mismos elementos: el remedio
   ya documentado en este fichero para `.editable-items-table :where(thead th,
   tbody td)`. No lo «simplifiques» de vuelta. */
:where(.bp-bar) > .bp-track,
:where(.bp-bar) > .bp-nodes--labelled {
    min-width: calc(var(--bp-node-pill-w) * var(--bp-slots, 1));
}

.bp-node__value {
    margin-top: var(--space-0-5);
    font-size: 0.75em;
    line-height: 1;
    white-space: nowrap;
    color: var(--color-text-muted);
}

/* ── El BOCADILLO bajo la bolita (OD-3370 P0, orden del owner 2026-09-20) ────
   *«en los "bp-node bp-node--locked" o no locked, debajo del node salga el
   bocadillo […] no poner solo el nivel, hacerlo en bocadillo en 2 líneas:
   primero nivel, luego la recompensa»*.

   Las DOS líneas no son adorno: son lo que hace que el bocadillo quepa. Con el
   nivel y la recompensa en UNA línea el ancho lo fija la suma; en dos, lo fija la
   más ancha, y a partir de ahí la recompensa puede repartirse en cuantas líneas
   haga falta dentro de una columna de ancho FIJO (`--bp-node-pill-w`). Ancho fijo
   es lo que convierte «¿se solapan?» en una sola comparación —hueco contra
   columna— en vez de en una pregunta sobre cada cadena.

   ⛔ `overflow-wrap: anywhere` y no `break-word`: la rama `reward_label` del
   presenter es texto libre del organizador y nadie acota su palabra más larga;
   sin esto una sola palabra de 20 caracteres desborda la columna y vuelve el
   solape por la puerta de atrás.

   ⛔ Se llama `-pill` y NO `-bubble`: esto no es un tooltip. Está SIEMPRE visible,
   va en el flujo, no flota sobre nada, no se revela con hover ni con foco y no lo
   toca ni una línea de JS. El vocabulario `tooltip|bubble|popover|tip|hint` está
   congelado en las tres familias de #3229 y `check_tooltip_pattern.py` lo vigila.
   Lo de «bocadillo» es la FORMA que pidió el owner, y vive en el rabito. */
.bp-node__pill {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--space-0-5);
    width: 100%;
    margin-top: var(--space-2);
    padding: var(--space-1) var(--space-1-5);
    border: 1px solid var(--color-border-subtle);
    border-radius: var(--radius-md);
    background: var(--color-bg-page);
    font-size: 0.75em;
    line-height: 1.35;
    text-align: center;
    overflow-wrap: anywhere;
}

.bp-node__level {
    color: var(--color-text-primary);
    font-weight: 600;
    white-space: nowrap;
}

/* ⛔ DOS LINEAS Y SE CORTA — orden del owner 2026-09-21: *«no hace falta poner
   tanto texto, con poner lo importante y hacer que entre […] si una recompensa no
   cabe, se acorta o se trunca»*. La columna NO se ensancha para que quepa: el
   ancho es un token y la comparacion «hueco contra columna» que impide el solape
   depende de que siga siendo un numero.

   ⭐ Y es lo que hace que TODOS los bocadillos midan lo mismo: con la tercera
   linea siempre presente (mas abajo) y la recompensa acotada a dos lineas, la
   altura del globo deja de depender de la cadena, asi que la tira no se
   descuadra al cambiar el progreso ni al configurar una recompensa mas larga.

   ⛔ El texto COMPLETO sigue accesible y no hace falta duplicarlo en un
   `sr-only`: `overflow: hidden` recorta la CAJA, no el nodo de texto — el
   contenido entero sigue en el arbol de accesibilidad y un lector de pantalla lo
   lee hasta el final. Quien quiera verlo con los ojos lo tiene en la hoja de
   detalle del hito, que sigue existiendo.

   ⛔ `display: -webkit-box` es la unica forma con soporte real de `line-clamp`
   hoy, y el fichero ya la usa (`.line-clamp-2` y la celda de la tabla): esto no
   estrena tecnica. */
.bp-node__reward {
    display: -webkit-box;
    /* stylelint-disable-next-line property-no-deprecated -- `-webkit-box-orient` esta deprecada pero sigue siendo obligatoria para que `-webkit-line-clamp` recorte; es la misma supresion que ya lleva la celda de la tabla en este fichero (#3445) */
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
    color: var(--color-text-secondary);
}

/* ── TERCERA LÍNEA del bocadillo: el CANJE (orden del owner 2026-09-21) ─────
   *«el pase debe poder canjearse desde el bocadillo en CO, con un sutil botón
   canjear que aparece en el bocadillo, en una tercera línea»*.

   ⛔ La lleva UN solo hito por tarjeta: el CTA (`CO_BENEFITS.cta`), que es el
   único con el predicado de §2.4 ya resuelto (`status.py::_with_cta`). Pintarla
   en los N hitos pagaría N predicados en la pantalla más visitada del portal
   para pintar un botón.

   ⭐ ENMIENDA A OD-13 (owner, 2026-09-21): en los hitos que NO ofrecen control la
   tercera línea SÍ se pinta, con un mensaje corto —«Te faltan 70», «Canjeado»—
   en `--color-text-muted` y SIN borde: no es accionable y no debe parecerlo. Va
   escrito porque contradice en parte a OD-13, que el 2026-09-20 retiró el texto
   de estado del portal y descartó reubicarlo; lo decidió el owner mirando su
   maqueta y lo construye `presenters.pill_note`, que NO nombra ninguno de los
   cuatro estados. Ventaja medible: con la tercera línea siempre presente todos
   los bocadillos miden lo mismo.

   ⛔ `--touch-target-min-aa` (24px) y NO `--touch-target-min` (44px): el suelo
   que aplica es SC 2.5.8 Target Size (Minimum), nivel AA —que es el nivel del
   gate—; los 44px son SC 2.5.5, AAA, y no se exigen. Interior útil del bocadillo
   a 120px: 120 - 2 de borde - 12 de padding = 106px, o sea un objetivo de
   106x24. ⛔ Prohibido meter `transform: scale()` en la tira: el objetivo está
   EXACTAMENTE en el suelo y cualquier escalado lo baja de 24px.

   ⛔ El color es `--color-link` y no el ámbar de «destacado», y está MEDIDO
   contra las superficies REALES del bocadillo, que son dos: `--color-bg-page` en
   claro y `--color-bg-elevated` en oscuro (el bloque dark de más abajo redefine
   `.bp-node__pill`, así que medir sólo contra `--color-bg-page` daría un número
   que nadie ve). `--color-link` da **5,68:1** en claro (#0369a1 sobre #f9fafb) y
   **4,91:1** en oscuro (#33BEFF sobre #334155): AA por los dos lados. El ámbar
   `--color-warn` da **3,05:1** en claro —por DEBAJO de AA—, así que no es una
   preferencia estética: no cumplía.

   ⛔ Sin bloque dark propio: `--color-link` ya voltea solo. */
/* ⛔ La tercera línea RESERVA su altura aunque no diga nada. Sin este suelo, un
   hito alcanzado y todavía sin cobrar (`pill_note` vacío) dejaría su bocadillo
   24px más corto que los vecinos, que es exactamente el descuadre que la línea
   siempre-presente viene a evitar. El número es el mismo suelo táctil del
   control, así que la línea mide lo mismo lleve botón o lleve mensaje. */
.bp-node__act {
    display: block;
    min-block-size: var(--touch-target-min-aa);
}

/* El mensaje de los hitos NO canjeables. Apagado y SIN borde a propósito: la
   única cosa accionable de la tarjeta es el control de `--color-link`, y un
   recuadro alrededor de «Te faltan 70» lo haría parecer un botón muerto. El peso
   normal va por lo mismo — el control es el que lleva el 700. */
.bp-node__note {
    display: block;
    color: var(--color-text-muted);
    line-height: var(--touch-target-min-aa);
}

.bp-node__claim {
    display: block;
    inline-size: 100%;
    padding: 0;
    border: 0;
    background: none;
    color: var(--color-link);
    font: inherit;
    font-weight: 700;
    /* Después de `font:`, que es un atajo y RESETEA `line-height`. */
    line-height: var(--touch-target-min-aa);
    min-block-size: var(--touch-target-min-aa);
    text-align: center;
    text-decoration: none;
    cursor: pointer;
    /* Mismo motivo que en el bocadillo: «Vincula tu chip» en alemán es una sola
       palabra larga, y sin esto desborda la columna y devuelve el solape por la
       puerta de atrás. */
    overflow-wrap: anywhere;
}

.bp-node__claim:hover,
.bp-node__claim:focus-visible {
    text-decoration: underline;
}

/* El formulario del cobro vive FUERA de la tira —un `<form>` es contenido de
   FLUJO y `.bp-node__pill` es un `<span>`, así que dentro sería HTML inválido— y
   el botón lo alcanza por el atributo `form=`. `display: contents` y no `none`:
   sin caja propia no mete un item vacío en el flex de `.bp-card`, que se
   comería un hueco de la columna. Sus únicos hijos son `<input type="hidden">`,
   que el navegador ya no pinta. */
.bp-claim-form {
    display: contents;
}

/* El rabito, apuntando ARRIBA — a la bolita de la que cuelga el bocadillo. Es un
   cuadrado girado 45 grados con DOS bordes, no un triángulo de `border-color`:
   así hereda el mismo `--color-border-subtle` y el mismo fondo que el globo, y el
   tema oscuro se resuelve solo con los tokens.

   ⛔ Los bordes son `top` + `left`, y el par NO es intercambiable: `rotate(45deg)`
   es HORARIO, así que la esquina superior-izquierda —la que forman esos dos
   bordes— acaba arriba del todo y es la punta.

   ⚠️ Cuánto sobresale, medido y no supuesto: un cuadrado de 8px girado 45 grados
   tiene 5,66px de semidiagonal (8/2 x raíz de 2), no 4, así que con `top: -4px` la
   punta queda a 5,66px por encima del globo y muerde 1,66px el aire del
   `margin-top` (8px). Es deliberado y es lo que hace un rabito: tocar aquello de
   lo que cuelga. */
.bp-node__pill::before {
    content: "";
    position: absolute;
    top: calc(var(--space-1) * -1);
    left: calc(50% - var(--space-1));
    width: var(--space-2);
    height: var(--space-2);
    transform: rotate(45deg);
    border-top: 1px solid var(--color-border-subtle);
    border-left: 1px solid var(--color-border-subtle);
    background: var(--color-bg-page);
}

/* Cobro de la recompensa (#3212). El boton REUSA `.btn-primary` entero: aqui solo
   se fija que ocupe el ancho de la tarjeta y que no se pegue al parrafo de la
   regla. Todo en tokens (regla 4.2) y sin bloque dark propio, porque `.btn-primary`
   ya resuelve los dos temas. */
.bp-claim {
    margin-top: var(--space-3);
}

.bp-claim__button {
    width: 100%;
    min-height: var(--touch-target-min);
}

/* Dark — sólo los dos sitios donde el token claro no basta: la tarjeta se apoya
   en la superficie elevada, y el carril de la barra necesita contraste contra
   ella (en dark, `--color-bg-page` es MÁS oscuro que la superficie, así que el
   carril se leería como un hueco negro). Los estados de nodo no se redefinen:
   sus tokens ya tienen valor propio en dark. */
:where([data-theme="dark"]) .bp-card {
    background: var(--color-bg-surface);
    border-color: var(--color-border-strong);
}

:where([data-theme="dark"]) .bp-track,
:where([data-theme="dark"]) .bp-tier,
:where([data-theme="dark"]) .bp-node__pill,
:where([data-theme="dark"]) .bp-node__pill::before {
    background: var(--color-bg-elevated, var(--color-bg-page));
}

/* ⛔ Y esta es la CONSECUENCIA de la regla de arriba, medida y no supuesta: en
   dark el bocadillo deja de apoyarse en `--color-bg-page` (slate-900) y pasa a
   `--color-bg-elevated` (slate-700, #334155). Sobre esa superficie
   `--color-text-muted` (slate-400, #94a3b8) cae a **4,11:1** — por DEBAJO del
   4,5:1 que pide AA para texto normal, y este texto va a 0.75em. En claro el
   mismo token da 4,70:1 (#6b7280 sobre #f9fafb) y SI cumple, asi que el token
   apagado se queda donde cumple y en dark sube un escalon a
   `--color-text-secondary` (slate-300, 6,97:1), que es el mismo que ya lleva la
   recompensa justo encima.

   ⛔ No es «subir el contraste por si acaso»: el gate de accesibilidad se mide en
   los DOS temas, y un mensaje que solo cumple en uno es un rojo que sale en
   produccion. El aire de «apagado» lo sigue poniendo el contraste relativo con el
   nivel (`--color-text-primary`, blanco), no un gris que no se lee. */
:where([data-theme="dark"]) .bp-node__note {
    color: var(--color-text-secondary);
}


/* ── Composicion etiqueta + boton de info ────────────────────────────────────
   Owner, 2026-09-12: «Los botones de la i de los tooltips tienen que estar en la
   misma linea que el texto».

   El componente NO se toca (`components/kpi_info_button.html` ya es un
   `inline-flex` de 14px con `vertical-align: middle` y su area tactil de 44x44):
   lo que separaba el boton del texto era el CONTENEDOR, y por dos causas
   distintas que se arreglan aqui.

   1. El par vivia en un `<span class="flex items-center">`. En un contenedor
      flex el `<label>` deja de ser texto y pasa a ser una CAJA hermana del
      boton: con una etiqueta que ocupa dos lineas, `align-items: center` centra
      el boton contra el bloque ENTERO y la «i» queda a media altura, sin linea
      propia. Y encima `.modal-content label { margin-bottom: var(--modal-label-gap) }`
      entra en el centrado, asi que el texto sube medio escalon sobre la «i»
      incluso con una sola linea. Aqui el par vuelve a ser TEXTO: el envoltorio
      es de bloque, la etiqueta es `inline` y el boton fluye detras de la ULTIMA
      palabra — que es literalmente lo que pidio el owner. El margen inferior
      sube al envoltorio para que la etiqueta no se lo lleve al centrado.

   2. En un `<th>` el boton es un inline ATOMICO y el navegador puede partir la
      linea justo antes de el: en una columna estrecha la «i» caia sola a la
      linea de abajo. `--inline` lo prohibe con `white-space: nowrap`. Solo para
      cabeceras de tabla, donde el texto es una palabra o dos; en las etiquetas
      largas del formulario NO se usa, porque prohibir el salto alli sacaria la
      etiqueta de su columna. */
.field-label {
    display: block;
    margin-bottom: var(--modal-label-gap);
}

.field-label > label {
    display: inline;
    margin-bottom: 0;
}

.field-label--inline {
    display: inline;
    margin-bottom: 0;
    white-space: nowrap;
}

/* Fila de un booleano CON ayuda (`components/toggle.html` con `tip=`, #3365).
   Orden del owner: la "i" va junto a la etiqueta, no al extremo derecho de la
   tarjeta — «asi el usuario no tiene que buscarlo». Por eso el par etiqueta+"i"
   es UN item flex (el `.field-label` de arriba, el mismo envoltorio que usan los
   campos normales) y el interruptor es el otro, empujado al final por
   `space-between`.

   `align-items: center` y no `start`: con la "i" DENTRO del `.field-label` la
   etiqueta vuelve a ser texto y el boton fluye detras de la ultima palabra, asi
   que una etiqueta de dos lineas ya no descoloca nada — que era justo el motivo
   por el que estas filas llevaban `items-start` cuando la "i" iba suelta al
   final. El `min-width: 0` del texto es obligatorio: sin el, una etiqueta larga
   no encoge dentro del flex y empuja el interruptor fuera de la fila. */
.toggle-field {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    width: 100%;
}

/* `.field-label` trae el hueco inferior que separa una etiqueta de SU campo; en
   una fila el campo esta al lado, no debajo, asi que sobra. Y el `<label>` del
   interruptor hereda `margin-bottom` de `.modal-content label`: sin anularlo el
   switch queda medio escalon por encima del texto. */
.toggle-field > .field-label {
    margin-bottom: 0;
    min-width: 0;
}

.toggle-field > label {
    margin-bottom: 0;
}

/* El importe y su divisa, en la MISMA linea. La divisa es texto (no un control),
   asi que no cuenta como hermano en la comprobacion de controles apinados: lo
   que ese detector persigue es que DOS campos compartan linea, y aqui solo hay
   uno. `min-width: 0` es obligatorio — sin el, un `input` con `width:100%` no
   encoge dentro del flex y empuja la divisa fuera de la celda. */
.amount-field {
    display: flex;
    align-items: center;
    gap: var(--space-1);
}

/* El input lleva clase AQUI y solo aqui, y es deliberado (la regla del modal es
   que los inputs no la lleven: `.modal-content input/select` ya los pinta). La
   razon es de especificidad, no de estilo: hay que ganarle el
   `margin-bottom: var(--space-4)` a `.modal-content input[type="number"]`
   (0,1,1), y un selector cuyo ultimo compuesto sea `input` a secas colisiona con
   `.modal.modal--co .modal-content input:not([type="hidden"])`, que vive mas
   arriba del fichero con mas peso — `no-descending-specificity`. Con
   `.amount-field > .amount-field__input` (0,2,0) el compuesto final es unico, la
   regla gana por peso y no por orden, y no hace falta silenciar nada. */
.amount-field > .amount-field__input {
    flex: 1 1 auto;
    min-width: 0;
    margin-bottom: 0;
}

.amount-field__unit {
    flex: none;
    color: var(--color-text-secondary);
    font-size: var(--text-xs);
    white-space: nowrap;
}

/* ── Vista previa del pase en el panel del tenant ────────────────────────────
   Owner, 2026-09-12: «ahi deberiamos tener la preview del pase activo, la linea
   con las bolitas». REUSA `.bp-bar` / `.bp-track` / `.bp-nodes` / `.bp-node--*`
   del portal (arriba en este mismo bloque): misma tira, mismos tokens y el tema
   oscuro ya resuelto. Lo unico propio es la fila que la envuelve. */
/* #3363 punto 1 — de FILA de dos columnas a COLUMNA de dos filas.
   El badge de estado era hermano de la tira dentro de esta fila flex, o sea una
   columna entera para si solo. MEDIDO en Chromium headless sobre la pantalla
   `/pase/` REALMENTE renderizada por Django (2 hitos, `benefits_enabled` ON, Lato
   cargada, este `main.css` + `tailwind.output.css` reales), `demo` contra esta
   rama: 133,27 px de badge + 8 px de `gap` = 141,27 px que la tira no tenia a
   NINGUN ancho —la resta sale identica en los dos anchos medidos—. A 343 px la
   tira pasa de 201,73 a 343 px (+70,0 %); a 1120 px (viewport 1280, con la barra
   lateral del panel ya pintada), de 978,73 a 1120 px. Ahora el badge va en la
   cabecera, junto al nombre del pase, y la tira se queda con el ancho entero.

   ⚠️ CON LA WEBFONT LATO YA CARGADA, y eso es parte de la cifra, no una nota al
   pie: el badge es TEXTO y su ancho lo fija la metrica de la fuente. El banco
   espera a `document.fonts.ready` ANTES de medir porque Lato es lo que el usuario
   acaba viendo; sin esa espera sale la metrica de la fuente de respaldo y un juego
   entero distinto —el que estuvo escrito en las otras tres copias hasta el
   2026-09-18—. Las cuatro copias de estas cifras (esta, `_bp_pass_preview.html`,
   `battlepass_periods.html` y `test_battlepass_panel_list_screen.py`) las cuadra
   `scripts/tests/test_bp_preview_measurement_bank.py`.

   ⚠️ CONTRAPARTIDA VERTICAL — cifra corregida (OD-3370 ampliada, 2026-09-18).
   Aqui ponia «no hay contrapartida vertical: la fila pasa de 89 px a 92 px», y era
   el unico numero de este carril que no cuadraba. Re-medida en el banco de arriba,
   la fila `.bp-preview` pasa de 237 px a 242 px: +5 px, no +3, y sobre todo NADA
   que ver con 89/92. Aquellos dos absolutos venian de un banco ANTERIOR a #3310,
   cuando bajo la tira todavia no colgaba la lista de tramos —que ella sola mide
   133 px—, asi que describian una pantalla que ya no existe; lo unico que seguia
   en el orden de magnitud correcto era el delta. Contrapartida SI hay, y es
   pequena: 5 px por dejar de robarle 141,27 px de ancho a la tira. */
/* OD-3370 P0 — `.bp-preview__tiers` RETIRADA (2026-09-19) y NO vuelve (2026-09-20).
   Aqui vivia `.bp-preview__tiers { margin-top: var(--space-3); }`, el aire entre la
   tira y la lista de tramos de la vista previa. El owner llamo a esa lista «la
   seccion esa tan troncho de las recompensas» y pidio el bocadillo bajo la bolita.
   El 2026-09-19 se retiro la lista (149,09 px de alto → 0) y se dio el bocadillo por
   imposible; el 2026-09-20 el owner rechazo esa salida y preciso la forma —DOS
   LINEAS, nivel y recompensa—, que es lo que lo hace caber: el ancho lo fija la
   linea mas ancha y no la suma, asi que el bocadillo entra en una columna de ancho
   fijo (`--bp-node-pill-w`) y el solape se cierra con el `min-width` del carril.
   Vive en `.bp-node__pill`, colgando de la bolita, no en una lista aparte — esta
   regla sigue sin tener nada que separar. `.bp-tiers` / `.bp-tier` NO se tocan: los
   sigue usando el portal dentro de su `<details>`, y `.bp-tier__reward` la siguen
   usando `co_benefits_reward.html` y `battlepass_claims.html`. */
.bp-preview {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-2);
}

/* Cabecera de la vista previa: nombre del pase (o el mensaje de vacio) + badge de
   estado. `wrap` para que en movil el badge caiga a su linea en vez de estrujar
   el nombre. */
.bp-preview__head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
}

.bp-preview__strip {
    flex: 1 1 auto;
    min-width: 0;
}

/* Gancho ESTABLE del badge de estado. Existe para que el POM de e2e no dependa de
   la posicion en el arbol: `.bp-preview > .badge` dejo de casar al mudarse el
   badge a la cabecera, y un selector estructural volveria a romperse en la
   siguiente mudanza. */
.bp-preview__state {
    flex: 0 0 auto;
}

.bp-preview__name {
    min-width: 0;
    color: var(--color-text-primary);
    font-size: var(--text-sm);
    font-weight: var(--font-semibold);
}

.bp-preview__empty {
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}


/* T149 DASHBOARD PREMIUM — consolidated into base definitions above (T1000). */

/* ═════════════ DASHBOARD HERO V2 — T154 ═════════════ */
/* Mobile-first 3-card grid with display KPI + variation chip + sparkline slot. */

.kpi-hero--v2 {
    /* Azul de marca del panel resumen: degradado de tint azul + glow de marca,
       tal como en la versión original del hero. El rediseño HERO V2 lo había
       aplanado a --color-bg-surface (blanco) con --shadow-sm neutro, perdiendo
       el azul que daba vida a la cabecera del dashboard. Solo tokens (§4.2). */
    background: linear-gradient(135deg, var(--color-brand-tint-10), var(--color-brand-tint-25));
    border: 1px solid var(--color-border-subtle, rgba(15, 23, 42, 0.06));
    border-radius: var(--radius-2xl, 20px);
    padding: clamp(16px, 3vw, 24px);
    box-shadow: var(--shadow-brand-lg);
    margin-bottom: var(--space-4); /* Sprint 08 T559: tokenizado desde 1rem */
}

.kpi-hero--v2 .kpi-hero__grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-4);
}

@media (min-width: 768px) {
    .kpi-hero--v2 .kpi-hero__grid {
        grid-template-columns: repeat(3, 1fr);
        gap: var(--space-5);
        /* #2847 — la fila 2 son los disparadores del desglose, uno por card.
           8px pegados a SU card frente a 20px entre columnas: la distancia dice
           de quién es cada control antes de que se lea una palabra. */
        row-gap: var(--space-2);
    }
}

.kpi-hero-card {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    /* 18px sin token equivalente exacto — KISS: literal con justificación */
    padding: 18px var(--space-5) var(--space-4);
    border-radius: var(--radius-xl, 16px);
    background: var(--color-bg-page);
    border: 1px solid transparent;
    transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
}

/* Dark mode — declarado antes de :hover para respetar no-descending-specificity
   (:where() tiene especificidad 0 y debe preceder a selectores con pseudo-clase) */
:where([data-theme="dark"]) .kpi-hero-card {
    background: var(--color-bg-page);
}
:where([data-theme="dark"]) .kpi-hero-card__value { color: var(--color-text); }

.kpi-hero-card:hover {
    transform: translateY(-2px);
    box-shadow: var(--shadow-md, 0 4px 12px rgba(15, 23, 42, 0.08));
    border-color: var(--color-border-subtle, rgba(15, 23, 42, 0.08));
}

.kpi-hero-card__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    margin: 0;
}

.kpi-hero-card__label {
    font-size: var(--text-caption-size);
    font-weight: var(--font-semibold);
    line-height: var(--text-caption-leading);
    letter-spacing: var(--text-caption-tracking);
    text-transform: uppercase;
    /* #2801 — el título sube de --color-text-muted a --color-text-secondary para
       abrir la ladder frente a la info (.kpi-hero-card__sub), que se queda en
       muted. Antes ambos eran 13px y el MISMO muted (1,00x de separación) y en
       dark la jerarquía estaba invertida: la info iba forzada a
       --color-text-muted-on-elevated (12,02:1) por encima del título (6,96:1).
       Sobre --color-bg-page: 7,23:1 light (#4b5563/#f9fafb) · 12,02:1 dark
       (#cbd5e1/#0f172a). */
    color: var(--color-text-secondary);
    margin: 0;
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    flex-wrap: wrap;
}

.kpi-hero-card__value {
    font-size: var(--text-display-size);
    font-weight: var(--text-display-weight);
    line-height: var(--text-display-leading);
    letter-spacing: var(--text-display-tracking);
    color: var(--color-text);
    margin: 0;
    display: flex;
    align-items: baseline;
    gap: var(--space-1-5);
    font-variant-numeric: tabular-nums;
    overflow-wrap: anywhere;
}

.kpi-hero-card__currency {
    font-size: 0.55em;
    font-weight: var(--font-semibold);
    color: var(--color-text-muted);
    letter-spacing: 0;
}

.kpi-hero-card__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2) var(--space-3);
    margin-top: 2px;
}

.kpi-hero-card__variation {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    /* 2px sin token equivalente — KISS: literal con justificación */
    padding: 2px var(--space-2);
    border-radius: var(--radius-full, 999px);
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    line-height: 1.4;
    background: var(--color-neutral-100, rgba(148, 163, 184, 0.12));
    /* #2801 — fallo AA real en light: --color-text-muted (#6b7280) sobre
       --color-neutral-100 (#f3f4f6) da 4,39:1 a 12px, por debajo del 4,5:1.
       Pasaba desapercibido en a11y-smoke porque el JS mantiene el chip oculto
       hasta que hay periodo de comparación. Con secondary: 6,87:1 light
       (#4b5563/#f3f4f6) y 9,41:1 dark (#cbd5e1 sobre el chip translúcido
       rgba(148,163,184,.15) compuesto sobre #0f172a ≈ #232c3f). */
    color: var(--color-text-secondary);
    font-variant-numeric: tabular-nums;
}
/* T174 P174-7 — variation chip readability on dark (ratio subía a 1.00 con
   color=bg). Los tokens nuevos se resuelven al fg intensificado en dark
   y mantienen el `-hover` semantic en light (cumple AA en ambos temas). */
.kpi-hero-card__variation[data-trend="up"]   { background: var(--color-success-bg); color: var(--color-variation-success-fg); }
.kpi-hero-card__variation[data-trend="down"] { background: var(--color-error-bg);   color: var(--color-variation-error-fg); }

.kpi-hero-card__sub {
    font-size: var(--text-caption-size);
    color: var(--color-text-muted);
    line-height: 1.4;
}
.kpi-hero-card__sub strong {
    color: var(--color-text);
    font-weight: var(--font-semibold);
    font-variant-numeric: tabular-nums;
}

.kpi-hero-card__spark {
    height: 32px;
    margin-top: auto;
    border-radius: var(--radius-md, 8px);
    overflow: hidden;
    background: linear-gradient(180deg, transparent, var(--color-neutral-50, rgba(148, 163, 184, 0.04)));
    /* JS colapsa con display:none cuando history.length < 2 (T540) */
}

/* Plegable */
.kpi-hero__details {
    margin-top: var(--space-4);
    border-top: 1px solid var(--color-border-subtle, rgba(15, 23, 42, 0.06));
    padding-top: var(--space-3);
}
.kpi-hero__details-summary {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1-5);
    cursor: pointer;
    font-size: var(--text-caption-size);
    font-weight: var(--font-semibold);
    /* T174 P174-7 — `--color-link` resuelve a brand-primary-dark en light
       (contrast OK) y a brand-primary (#33BEFF) en dark (5.6:1 vs
       slate-800). El hardcode a #1d4ed8 caía a 2.18:1 en dark. */
    color: var(--color-link);
    list-style: none;
    user-select: none;
    /* T443 saldada: `--space-1` ES 4px (ver :root). Mismo píxel, un literal menos. */
    padding: var(--space-1) var(--space-2);
    border-radius: var(--radius-md, 8px);
    transition: background 120ms ease;
    /* #2847 — suelo de target táctil. WCAG 2.2 SC 2.5.8 (AA): el summary medía
       ~21px de alto y se queda corto para el dedo del organizador a pie de evento. */
    min-block-size: var(--touch-target-min-aa);
}
.kpi-hero__details-summary::-webkit-details-marker { display: none; }
.kpi-hero__details-summary:hover { background: var(--color-primary-50, rgba(59, 130, 246, 0.08)); }
.kpi-hero__details-summary-arrow {
    display: inline-block;
    /* #2847 — la etiqueta es una frase entera («Ver desglose recargado neto») y
       en una columna de un tercio puede envolver: la flecha no debe encogerse
       ni caer sola a una segunda linea. */
    flex: 0 0 auto;
    transition: transform 200ms ease;
    font-size: 1.25em;
    line-height: 1;
}
.kpi-hero__details[open] .kpi-hero__details-summary-arrow { transform: rotate(90deg); }

.kpi-hero__details-body {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-4);
    margin-top: var(--space-3);
}
@media (min-width: 768px) {
    .kpi-hero__details-body { grid-template-columns: repeat(2, 1fr); }
}

/* #2847 — modificador del plegable cuando vive DENTRO de .kpi-hero__grid, como
   fila 2, colgando de su propia card. Sin filete ni margen propios: lo que lo
   ata a su card es la PROXIMIDAD, y dos hairlines bajo dos de tres columnas no
   agrupan nada, sólo añaden líneas. `min-width: 0` porque un ítem de grid es
   `auto` por defecto y la tabla del desglose desbordaría su columna. */
.kpi-hero__details--card {
    /* Móvil (1 columna, gap 16px): -8px arriba deja 8px sobre el disparador y
       +8px abajo deja 24px hasta la card siguiente. El escalón dice de quién es. */
    margin-top: calc(var(--space-2) - var(--space-4));
    margin-bottom: var(--space-2);
    border-top: none;
    padding-top: 0;
    min-width: 0;
}
/* Alineación óptica: --space-3 (12px) + el padding-left --space-2 (8px) que el
   summary ya trae = 20px = --space-5, el padding horizontal exacto de
   .kpi-hero-card. El texto del disparador cae en el eje del título de su card. */
.kpi-hero__details--card > .kpi-hero__details-summary {
    margin-left: var(--space-3);
}
/* A un tercio de ancho, repeat(2,1fr) deja columnas de ~150px: el cuerpo del
   desglose se apila en una sola columna dentro de su card. */
.kpi-hero__details--card .kpi-hero__details-body {
    grid-template-columns: 1fr;
}

@media (min-width: 768px) {
    /* Colocación explícita SOLO de los plegables: con la fila definida, el paso 2
       del auto-placement (CSS Grid §8.5) los coloca en orden en la primera columna
       libre de la fila 2 — o sea, cada uno bajo su card — y las tres <article>
       siguen auto-colocándose en la fila 1. No se toca .kpi-hero-card. */
    .kpi-hero--v2 .kpi-hero__grid > .kpi-hero__details--card {
        grid-row: 2;
        /* El grid no declara align-items (=> stretch): sin esto, el plegable
           CERRADO se estiraría a la altura del ABIERTO de al lado. */
        align-self: start;
        margin-top: 0;
        margin-bottom: 0;
    }
}

/* Dark mode — mantiene el azul de marca (tints alpha sobre slate; el glow
   --shadow-brand-lg ya se re-tokeniza a azul en [data-theme="dark"]). */
:where([data-theme="dark"]) .kpi-hero--v2 {
    background: linear-gradient(135deg, var(--color-brand-tint-10), var(--color-brand-tint-25));
    border-color: var(--color-border-subtle, rgba(255, 255, 255, 0.08));
}

@media (prefers-reduced-motion: reduce) {
    .kpi-hero-card,
    .kpi-hero__details-summary-arrow { transition: none; }
}
/* ═════════════ end DASHBOARD HERO V2 ═════════════ */

/* ═════════════ CONTROL DE AFORO — card bajo el hero (#2801) ═════════════ */
/* Jerarquía en TRES niveles, con tamaño y peso como eje principal y el color
   sólo como refuerzo (subir el contraste de los tres por igual no crea
   jerarquía, sólo ruido). Sobre --color-bg-surface (#ffffff light / #1e293b dark):
     valor  → display 32-44px / 700 / --color-text-primary   → 17,74:1 light · 13,35:1 dark
     título → caption 13px UPPERCASE / 600 / --color-text-secondary → 7,56:1 · 9,85:1
     info   → caption 13px normal / 400 / --color-text-muted        → 4,83:1 · 5,71:1
   Los tres cumplen AA (4,5:1). Antes el título era un h2 de 22-24px/600 en el
   MISMO --color-text-primary que el valor de 30px/700: 1,25-1,36x de separación
   de tamaño y 1,00x de contraste, o sea ninguna jerarquía. */
.access-card {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-4);
    align-items: center;
    padding: var(--space-5);
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-xl);
    box-shadow: var(--shadow-sm);
}
/* ≥640px: el botón de reset pasa a la derecha; por debajo (incl. ≤375px) la
   card apila métrica + acción en una sola columna. */
@media (min-width: 640px) {
    .access-card { grid-template-columns: minmax(0, 1fr) auto; }
}

.access-card__metric { min-width: 0; }

.access-card__title {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
    margin: 0;
    font-size: var(--text-caption-size);
    font-weight: var(--font-semibold);
    line-height: var(--text-caption-leading);
    letter-spacing: var(--text-caption-tracking);
    text-transform: uppercase;
    color: var(--color-text-secondary);
}
.access-card__value {
    margin: var(--space-1) 0 0;
    font-size: var(--text-display-size);
    font-weight: var(--text-display-weight);
    line-height: var(--text-display-leading);
    letter-spacing: var(--text-display-tracking);
    color: var(--color-text-primary);
    font-variant-numeric: tabular-nums slashed-zero;
    overflow-wrap: anywhere;
}

/* #2802 — la UNICA prosa del dashboard, y por tanto donde vive alli el tope
   de medida: el carril ancho (`#main.is-wide`) queda fuera de la regla
   generica de ~2238 a proposito (razon medida en su comentario), asi que este
   componente lleva el token directamente. NO es un `62ch` a mano: ese valor
   medido en Chromium con Lato daba 82 cpl, por encima del limite legible de
   75 que exige #2802. Es ademas el parrafo donde muerde la calibracion del
   token (ver `--prose-max`): es el mas largo del panel que llega a envolver,
   asi que su peor linea es la que fija el valor en las tres fuentes medidas.
   Identico en light y dark y a 1440/1920/2560/3440 px — envuelve, luego ya no
   depende del ancho del contenedor. */
.access-card__info {
    margin: var(--space-2) 0 0;
    max-width: var(--prose-max);
    font-size: var(--text-caption-size);
    line-height: var(--text-caption-leading);
    color: var(--color-text-muted);
}

.access-card__actions { justify-self: start; }
@media (min-width: 640px) {
    .access-card__actions { justify-self: end; }
}
/* ═════════════ end CONTROL DE AFORO ═════════════ */

/* Filters sticky top.
 * Quick presets + event filter siempre accesibles al hacer scroll.
 * Avanzados (toggle-filters) permanecen plegables.
 * NOTE: no `backdrop-filter` here — it would create a new containing block
 * that traps the descendant `#filters-drawer` (position:fixed + inset:0)
 * into the form's sticky 60px-tall bbox. Solid surface background is
 * already opaque enough without the blur. */
/* stylelint-disable-next-line no-duplicate-selectors
   -- sticky/scroll behaviour layer over base .dashboard-filters at L4835. */
.dashboard-filters {
    position: sticky;
    top: 0;
    /* sticky bajo header (--z-header: 400) y bajo sidebar overlay (--z-sidebar-overlay: 480).
       T441 H1: usa --z-raised (30) en lugar de literal para mantener convención de tokens. */
    z-index: var(--z-raised);
    background: var(--color-bg-surface);
    margin-top: 0;
    padding: var(--space-2) 0;
    transition: box-shadow 200ms ease;
}
.dashboard-filters.is-stuck {
    box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
:where([data-theme="dark"]) .dashboard-filters {
    background: var(--color-bg-surface);
}
@media (max-width: 639px) {
    /* En mobile el sticky puede comerse altura útil; baja a relative. */
    .dashboard-filters { position: relative; }
}

/* ═══════════════════════════════════════════════════════════════════
   Filters 2-level layout — primary row (sticky) + advanced drawer.
   Mobile-first: primary row presets scroll-snap horizontally; the
   drawer is a slide-in side panel on desktop and a full-screen modal
   on mobile.
   ═══════════════════════════════════════════════════════════════════ */

/* Primary row — always visible. Mobile-first. */
.dashboard-filters__primary {
    display: flex;
    align-items: flex-start;
    gap: var(--space-2);
    min-width: 0;
    flex-wrap: wrap;
}
.dashboard-filters__primary-presets {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(52px, 1fr));
    gap: var(--space-1);
    overflow-x: unset;
    scroll-snap-type: unset;
    scrollbar-width: unset;
    -webkit-overflow-scrolling: unset;
    padding-block: var(--space-1);
    width: 100%;
    flex: none;
}
.dashboard-filters__primary-presets::-webkit-scrollbar { display: none; }
.dashboard-filters__primary-presets::-webkit-scrollbar-thumb { background: var(--color-border-subtle); border-radius: var(--radius-full, 999px); }
.dashboard-filters__primary-presets .preset-btn,
.dashboard-filters__primary-presets .dashboard-filters__preset {
    flex-shrink: 0;
    scroll-snap-align: start;
}
.dashboard-filters__primary .dashboard-filters__event-select {
    flex: 0 1 180px;
    min-width: 0;
}
.dashboard-filters__drawer-trigger {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    padding: var(--space-1) var(--space-3);
    border-radius: var(--radius-sm);
    border: 1px solid var(--color-border-default);
    background: var(--color-bg-surface);
    color: var(--color-text-primary);
    font-size: var(--text-sm);
    font-weight: var(--font-semibold);
    min-height: var(--touch-target-min);
    cursor: pointer;
    flex-shrink: 0;
    transition: background var(--transition-fast), border-color var(--transition-fast);
}
.dashboard-filters__drawer-trigger:hover {
    background: var(--color-brand-tint-10);
    border-color: var(--brand-primary);
}
.dashboard-filters__drawer-trigger[aria-expanded="true"] {
    background: var(--color-brand-tint-10);
    border-color: var(--brand-primary);
    color: var(--brand-primary-dark);
}
@media (min-width: 640px) {
    .dashboard-filters__primary {
        flex-wrap: nowrap;
        align-items: center;
    }
    .dashboard-filters__primary-presets {
        display: flex;
        grid-template-columns: unset;
        overflow-x: auto;
        scroll-snap-type: x proximity;
        scrollbar-width: thin;
        -webkit-overflow-scrolling: touch;
        width: auto;
        flex: 1 1 auto;
    }
    .dashboard-filters__primary-presets::-webkit-scrollbar { height: 4px; display: block; }
    .dashboard-filters__primary .dashboard-filters__event-select { flex: 0 1 220px; }
    .dashboard-filters__drawer-trigger { min-height: 2.25rem; } /* alineado con __preset y __event-select en desktop */
}

/* Drawer — slide-in side panel (full-screen on narrow viewports). */
.dashboard-filters__drawer {
    position: fixed;
    inset: 0;
    z-index: var(--z-modal, 600);
    background: color-mix(in srgb, var(--color-text-primary) 40%, transparent);
    display: flex;
    justify-content: flex-end;
    animation: dash-drawer-fade 160ms ease-out;
}
.dashboard-filters__drawer[hidden] { display: none; }

/* Stacking-context fix (T169 NEW-P2-3): the drawer lives inside .content-wrapper
 * (z-index:0), which caps its z-index BELOW the sidebar (z:200, outside the
 * wrapper). While the drawer is open, lift the wrapper above the sidebar so
 * clicks on the dim overlay hit the drawer (not the sidebar underneath). */
body.dashboard-filters__drawer-open .content-wrapper { z-index: var(--z-modal, 600); }

/* Panel: header + scrollable body + footer — flex column so the scroll
 * region is isolated to the body (predictable behaviour for sticky
 * sub-headers and absolutely positioned dropdowns inside sections). */
.dashboard-filters__drawer-panel {
    background: var(--color-bg-surface);
    inline-size: min(100%, 520px);
    block-size: 100%;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
    animation: dash-drawer-slide 200ms ease-out;
}
.dashboard-filters__drawer-header {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--space-4);
    border-bottom: 1px solid var(--color-border-subtle);
    background: var(--color-bg-surface);
}
.dashboard-filters__drawer-title {
    margin: 0;
    font-size: var(--text-h2-size, var(--text-lg));
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}
.dashboard-filters__drawer-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    inline-size: 36px;
    block-size: 36px;
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--color-text-secondary);
    cursor: pointer;
    transition: background var(--transition-fast), color var(--transition-fast);
}
.dashboard-filters__drawer-close:hover {
    background: var(--color-brand-tint-10);
    color: var(--color-text-primary);
}
.dashboard-filters__drawer-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    padding: var(--space-4);
    display: flex;
    flex-direction: column;
    gap: var(--space-5);
}
.dashboard-filters__drawer-section {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}
/* #1858: sticky drawer footer holding the primary "Aplicar filtro" action. */
.dashboard-filters__drawer-footer {
    flex: 0 0 auto;
    display: flex;
    justify-content: flex-end;
    gap: var(--space-2);
    padding: var(--space-4);
    border-top: 1px solid var(--color-border-subtle);
    background: var(--color-bg-surface);
}
.dashboard-filters__drawer-footer .dashboard-filters__apply {
    flex: 1 1 auto;
    min-height: var(--touch-target-min);
}
.dashboard-filters__drawer-section-title {
    margin: 0;
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-text-secondary);
}

/* Drawer-local overrides for existing filter widgets. The original styles
 * target a wide horizontal header; inside a narrow side panel they must
 * stack and expand to 100% to stay usable. */
.dashboard-filters__drawer .dashboard-filters__grid,
.dashboard-filters__drawer .dashboard-filters__grid--dates {
    grid-template-columns: 1fr;
    gap: var(--space-3);
}
/* T517: migrate 480px → 640px for canonical mobile breakpoint */
@media (min-width: 640px) {
    .dashboard-filters__drawer .dashboard-filters__grid--dates {
        grid-template-columns: 1fr 1fr;
    }
}
.dashboard-filters__drawer .dashboard-filters__compare-group {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-2);
    width: 100%;
}
.dashboard-filters__drawer .dashboard-filters__compare-select,
.dashboard-filters__drawer .dashboard-filters__event-select {
    width: 100%;
}
/* Multi-select dropdowns: render in-flow inside the drawer so the scroll
 * region handles them naturally instead of clipping an absolutely
 * positioned overlay. */
.dashboard-filters__drawer .dashboard-filters__dropdown {
    position: static;
    width: 100%;
    margin-top: var(--space-1);
    box-shadow: none;
    border-color: var(--color-border-subtle);
}
.dashboard-filters__drawer .dashboard-filters__dropdown-scroll {
    max-height: 240px;
}

/* T-S29-05: el picker async dentro de un modal queda recortado porque
   `.modal-content` declara `overflow-y:auto` (contenedor de clipping) y el
   dropdown es `position:absolute`. Espejo del patrón del drawer (arriba):
   renderizar el dropdown in-flow dentro del modal — su propio
   `.dashboard-filters__dropdown-scroll` (max-height + overflow-y) sigue
   manejando listas largas, y el body del modal sigue scrolleando con
   `overflow-y:auto`. No toca `aria-controls` ni el stacking (z-index). */
.modal-content .dashboard-filters__dropdown {
    position: static;
    width: 100%;
    margin-top: var(--space-1);
    box-shadow: none;
    border-color: var(--color-border-subtle);
}

.dashboard-filters__refresh-group {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
}
.dashboard-filters__freshness {
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    margin-inline-start: auto;
}

/* Body scroll lock while drawer is open. */
body.dashboard-filters__drawer-open { overflow: hidden; }

:where([data-theme="dark"]) .dashboard-filters__drawer {
    background: var(--color-overlay);
}
:where([data-theme="dark"]) .dashboard-filters__drawer-panel {
    background: var(--color-bg-elevated);
    box-shadow: -8px 0 24px rgba(0, 0, 0, 0.4);
}
:where([data-theme="dark"]) .dashboard-filters__drawer-header {
    background: var(--color-bg-elevated);
}

@keyframes dash-drawer-fade {
    from { opacity: 0; }
    to { opacity: 1; }
}
@keyframes dash-drawer-slide {
    from { transform: translateX(100%); }
    to { transform: translateX(0); }
}

@media (prefers-reduced-motion: reduce) {
    .dashboard-filters__drawer,
    .dashboard-filters__drawer-panel { animation: none; }
}

/* ═══════════════════════════════════════════════════════════════════
   T174 P174-7 — Dark mode contrast polish
   Selectores donde `--color-text-muted` caía a ratios < 4.5:1 sobre
   superficies elevadas; con `--color-text-muted-on-elevated`
   (slate-300) el contraste sube a 6.97:1. El elemento específico de
   cada bloque se justifica con el ratio medido en Playwright live
   (tenant dev_local, 2026-04-18). Light mode hereda el alias al
   muted estandar, asi que este refactor es neutro en light.

   STYLELINT (T178 CSS-T174-DARK-CONTRAST): each re-declared selector
   adds a single `color:` layer on top of existing rules. Consolidating
   into the base definitions is scheduled in T178 §I.4; until then,
   suppress the layering warnings.
   ═══════════════════════════════════════════════════════════════════ */
/* stylelint-disable no-duplicate-selectors, no-descending-specificity -- [cross-component false-positive] dark-contrast color layer: '.chart-legend-table thead th' (0,3,0) vs earlier '.data-table thead :where(tr.header-row) th:first-child' (0,4,1, line 3191) is disjoint chart-legend vs data-table markup; '.dashboard-filters__refresh-btn' (0,1,0) vs '.dashboard-filters__refresh-btn:hover' (0,2,0, line 5997) is a base-before-hover override. This block intentionally adds a single dark color: layer on each selector and must stay grouped (consolidation tracked in the comment above). */
.dashboard-filters__label,
.dashboard-filters__freshness,
.dashboard-filters__refresh-btn,
.chart-legend-table thead th,
.kpi-hero__breakdown-title,
.chart-card__tab:not(.chart-card__tab--active),
.legend-info-icon {
    color: var(--color-text-muted-on-elevated);
}
/* #2801 — `.kpi-hero-card__sub` sale de esta lista. El motivo original de T174
   (muted < 4,5:1 sobre superficies ELEVADAS) no aplica aquí: `.kpi-hero-card`
   pinta `--color-bg-page`, no `--color-bg-elevated`, y ahí muted da 6,96:1 en
   dark (#94a3b8/#0f172a) y 4,63:1 en light (#6b7280/#f9fafb) — AA en ambos.
   Forzar on-elevated ponía la info (12,02:1) por ENCIMA del título (6,96:1) en
   dark, invirtiendo la jerarquía. Ahora: valor 16,29 > título 12,02 > info 6,96
   en dark y 16,97 > 7,23 > 4,63 en light. */

/* `--color-text-inverse` (slate-900) sobre `--color-bg-surface` en dark
   caía a 1.22:1 — el valor Facturado Neto dentro del desglose no se
   leía. Primary color mantiene AA en ambos temas. */
#hero-breakdown-neto {
    color: var(--color-text-primary);
}

/* Separador visual de la fila Total del legend-table: en dark el
   border-default coincide con el bg-elevated, asi que la linea
   desaparecia. Border-strong mantiene la jerarquia visual. */
.chart-legend-total {
    border-top-color: var(--color-border-strong);
}

/* Error banner en dark: rojo-on-rojo@12% dejaba el banner ilegible.
   Subimos el bg y bajamos el fg a red-200 para conseguir ~8:1. */
:where([data-theme="dark"]) .alert-error {
    background: color-mix(in srgb, var(--color-error) 20%, var(--color-bg-page));
    color: var(--color-variation-error-fg);
    border: 1px solid color-mix(in srgb, var(--color-error) 45%, transparent);
}
/* Boton "Reintentar" dentro del banner usa la misma paleta. */
:where([data-theme="dark"]) .alert-error .btn,
:where([data-theme="dark"]) #dashboard-error-retry {
    color: var(--color-variation-error-fg);
    background: color-mix(in srgb, var(--color-error) 30%, transparent);
    border-color: color-mix(in srgb, var(--color-error) 55%, transparent);
}


/* T204 B1 — modal-scoped sticky reinforcement for the admin receipt modal
   (#receipt-modal in zelebrix/zelebrix/templates/transacciones.html).
   The inner `order-details-table` inherits `.data-table` sticky rules
   from line ~2966, but the parent `.modal-content` animates with
   `transform: scale()` during `@keyframes modalIn` (line ~5651), briefly
   creating a stacking context that can let sibling cells paint over the
   sticky first column during open. Pinning `isolation: isolate` on the
   modal content keeps the sticky z-index scoped to the subtree; the
   dedicated rule inside #receipt-modal-content reinforces the cell bump.
   Kept inside the already-suppressed no-descending-specificity block
   because the generic `.modal-content` rules sit earlier in the file. */
#receipt-modal .modal-content {
    isolation: isolate;
}
#receipt-modal-content .order-details-table thead :where(th:first-child),
#receipt-modal-content .order-details-table tbody :where(td:first-child) {
    z-index: calc(var(--z-base) + 2);
}
/* stylelint-enable no-duplicate-selectors, no-descending-specificity */

/* ═══════════════════════════════════════════════════════════════
   T487 — Panel Page Header component (_panel_page_header.html)
   Wave 2, Sprint 03
   ═══════════════════════════════════════════════════════════════ */

/* --- Panel Page Header --- */
/* Mobile-first: título arriba, acciones debajo */
.panel-page-header {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    border-bottom: 1px solid var(--color-border-subtle);
    padding-bottom: var(--space-3, 0.75rem);
}

/* La fila del titulo: `<h2>` + el boton de la guia, pegado al `results-count`
   (#3366 §2, owner 2026-09-18). El `<h2>` es el UNICO que encoge —ya traia
   `min-width: 0` y `truncate`—, asi que a 375 px el que cede es el titulo y el
   boton no se desplaza ni empuja al contador. El boton no ocupa mas de sus 20 px
   de disco: su area tactil de 44 px la pone un `::before` absoluto. */
.panel-page-header__heading {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 0;
}

.panel-page-header__title {
    color: var(--color-text-primary);
    line-height: var(--leading-tight, 1.25);
    min-width: 0; /* permite que truncate funcione como flex item */
}

.panel-page-header__actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
}

/* ≥640px: fila única — título a la izquierda, acciones a la derecha */
@media (min-width: 640px) {
    .panel-page-header {
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        gap: var(--space-4);
    }
    .panel-page-header__title {
        flex: 1 1 0;
    }
    .panel-page-header__actions {
        flex-shrink: 0;
    }
}
/* --- /Panel Page Header --- */

/* ═══════════════════════════════════════════════════════════════
   T487 Wave 3 prerequisites — page-scoped delimiters
   Each Wave 3 task (T488-T494) writes its page-specific CSS
   inside the matching pair of delimiters below.
   ═══════════════════════════════════════════════════════════════ */

/* --- Transacciones --- */
/* --- /Transacciones --- */

/* --- Usuarios --- */
/* --- /Usuarios --- */

/* --- Grupos --- */
/* --- /Grupos --- */

/* --- Dispositivos --- */
/* --- /Dispositivos --- */

/* --- Puntos de Transacción --- */
/* --- /Puntos de Transacción --- */

/* --- Tickets Panel --- */
/* --- /Tickets Panel --- */

/* --- Devoluciones --- */
/* --- /Devoluciones --- */

/* ─── T937: Loading state for async admin form buttons ───────────────────────
   button[aria-busy="true"] appends an inline spinner (::after pseudo-element)
   and disables pointer events. Works for btn-primary, btn-success, btn-danger
   without changing button HTML structure. */
@keyframes admin-btn-spin {
    to { transform: translate(-50%, -50%) rotate(360deg); }
}

button[aria-busy="true"] {
    position: relative;
    pointer-events: none;
    opacity: 0.75;
    cursor: wait;
}

button[aria-busy="true"]::after {
    content: '';
    position: absolute;
    width: 0.9em;
    height: 0.9em;
    top: 50%;
    left: 50%;
    border: 2px solid var(--btn-spinner-ring);
    border-top-color: currentColor;
    border-radius: var(--radius-full);
    animation: admin-btn-spin 0.55s linear infinite;
    transform: translate(-50%, -50%);
}

/* ── Floating save FAB — tenant config page (issue #1619) ──
   Fixed bottom-right; revealed by tenant-config.js on first dirty change.
   Background/foreground pair (--brand-primary-dark / --color-text-inverse)
   keeps AA contrast in both themes: light = dark-blue bg + white text,
   dark  = light-blue bg + slate-900 text (both flip together via tokens). */
.fab-save {
    position: fixed;
    bottom: var(--space-4);
    right: var(--space-4);
    z-index: var(--z-sticky);
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-height: var(--touch-target-min);
    min-width: var(--touch-target-min);
    padding: var(--space-3) var(--space-5);
    border: none;
    border-radius: var(--radius-full);
    background: var(--brand-surface-strong);
    color: var(--color-text-inverse);
    font-size: var(--text-sm);
    font-weight: var(--font-semibold);
    box-shadow: var(--shadow-lg);
    cursor: pointer;
    animation: fab-save-pulse 1.8s ease-in-out infinite;
    transition: background-color var(--transition-fast), transform var(--transition-fast);
}

.fab-save:hover {
    background: var(--brand-surface-strong-hover);
}

.fab-save:active {
    transform: scale(0.96);
}

.fab-save:focus-visible {
    outline: 2px solid var(--brand-focus-ring);
    outline-offset: 2px;
}

.fab-save__icon {
    display: inline-flex;
    align-items: center;
}

.fab-save--hidden {
    display: none;
}

.fab-save--saving {
    opacity: 0.7;
    cursor: wait;
    animation: none;
}

@keyframes fab-save-pulse {
    0%, 100% { box-shadow: var(--shadow-lg); }
    50% { box-shadow: var(--shadow-lg), 0 0 0 6px var(--brand-focus-ring); }
}

@media (prefers-reduced-motion: reduce) {
    .fab-save { animation: none; }
}

/* Small screens: keep the FAB clear of the bottom edge / safe area and
   shrink to an icon-led pill so it never overlaps form content. */
@media (max-width: 480px) {
    .fab-save {
        bottom: calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
        right: var(--space-3);
        padding: var(--space-3) var(--space-4);
    }
}

/* ═══════════════════════════════════════════════════
   Tutorial del pase de batalla (#2812)
   Plantilla: zelebrix/zelebrix/templates/battlepass/tutorial.html

   Todo el color sale de tokens, así que el tema oscuro no necesita ni una
   regla propia: `--color-bg-surface`, `--color-border-default` y compañía ya
   se redeclaran en `[data-theme="dark"]`. El número del paso usa
   `--brand-surface-strong` (no `--brand-primary`, que es 2,11:1 con blanco y
   por contrato no va detrás de texto) emparejado con `--color-text-inverse`:
   ese par es el que flipa junto y mantiene AA en los dos temas.

   Las cards NO tienen estado :hover — no son clicables. Un hover decorativo
   sobre algo que no responde al clic promete una interacción que no existe.
   ═══════════════════════════════════════════════════ */

.bp-tutorial__intro {
    /* El lede. Sube a `--text-body-size` (16 -> 18px en escritorio) y a tinta
       PRIMARIA: el primer parrafo de la pagina en gris es «mucho texto negro de
       golpe» al reves — lo que faltaba no era contraste en todo, era un ancla
       oscura arriba.
       NO lleva `--leading-relaxed`: al subir el tamaño el interlineado BAJA, no
       sube (GOV.UK pasa de 19/25 a 24/30). `relaxed` es para los cuerpos.
       Techo del lede = 18px y no mas, MEDIDO: el h2 de la cabecera de pantalla
       (`components/_panel_page_header.html`, `text-xl`) y este mismo fichero en
       `.bp-tutorial__section-title` valen los dos 20px. Un lede de 22px queda
       POR ENCIMA de los dos titulos de la pagina. */
    margin-block: 0;
    font-size: var(--text-body-size);
    line-height: var(--leading-normal);
    color: var(--color-text-primary);
}

/* Cuerpo de prosa larga del tutorial (#3357). `--leading-relaxed` lleva en el
   fichero desde siempre con el comentario literal «long-form text, paragraphs»
   y no aparecia ni una vez en las 291 lineas del tutorial: todo iba a
   `--leading-normal`. Es el cambio con mas efecto por caracter escrito sobre la
   densidad percibida, y rinde mas en una columna de 343px que en una de 656. */

.bp-tutorial__prose {
    margin: 0;
    font-size: var(--text-base);
    line-height: var(--leading-relaxed);
    color: var(--color-text-secondary);
}

/* `min(320px, 100%)` y no `320px` a secas: con el minimo duro, a 320 px de
   viewport la columna pedia mas ancho que el contenedor y la pagina se iba en
   scroll horizontal. Es el mismo arreglo mobile-first para todas las rejillas. */

.bp-tutorial__steps {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
    gap: var(--space-6);
}

/* #3317/#3357 — la entrada del tutorial.

   Historia corta, porque la primera version de este comentario mandaba en la
   direccion contraria: el owner vio el intro «corto y mal», se mergeo un
   `.prose-full` que lo llevaba a 176 caracteres por linea (el proyecto corta en
   75) y se revirtio el mismo dia; el arreglo siguiente fue meterlo en una
   rejilla de tres hermanos. Eso curo el blanco a la derecha y dejo otro
   defecto medido: «¿Para quien es el pase?» heredaba de su vecina una caja de
   1127 px de alto con 474 px de texto y 628 px de nada debajo (55,7 % de la
   caja con borde, vacia).

   Ahora la fila es un BLOQUE: lede + el primer parrafo de audiencia. Lo que
   impide que el lede vuelva a quedarse huerfano no es un hermano al lado, es el
   `<summary>` de justo debajo, que nace con filete ARRIBA Y ABAJO cruzando los
   1336 px (ver `.bp-tutorial__summary`). Dos reglas a ancho completo anclan el
   margen del parrafo mejor que una caja al lado, y no cuestan ni un elemento.

   Constante medida, por si alguien vuelve a hacer cuentas aqui: `1ch` de Lato
   son **0,580em** — 48ch = 445 px a 16px, 501 px a 18px. Los «414 px» que este
   comentario y el de la plantilla daban por buenos son 48ch SIN Lato (el
   fallback del sistema, 0,5em): es el numero del runner del gate, no el del
   usuario. Los cpl no cambian con eso (el tope esta en `ch`, y `ch` escala),
   pero cualquier promesa de «llena su div» si. */

.bp-tutorial__lead {
    display: block;
    margin-block: var(--space-4) var(--space-6);
}

.bp-tutorial__lead > * + * {
    margin-block-start: var(--space-4);
}

/* «¿Para quien es el pase?» plegado — peticion 2 del owner (#3357).

   Reutiliza `.bp-details` tal cual: `<details>` nativo, cero JS, sin `aria-expanded` a
   mano —el elemento ya lo pone y escribirlo lo pisaria—. Plegado SIEMPRE: el
   atributo `open` no es consultable por media query y esta plantilla es sin JS a
   proposito, asi que «abierto en escritorio y cerrado en movil» no existe sin
   romper esa premisa. El primer parrafo del bloque se queda FUERA del pliegue.

   ⚠️ LA TRAMPA DE ESTA JUGADA, y por que el `<span>` de dentro lleva su propio
   tope: `summary` NO esta en `#main … :is(p, li, dd, label > span)`. Un summary
   a ancho completo con una frase entera seria prosa sin tope en el unico
   elemento que no vigila ni el CSS ni `prose-measure.spec.ts`. El BORDE cruza
   los 1336 px; el TEXTO se topa en 48ch.

   ⭐ OD-13 (2026-09-20, #3423): esta plantilla es desde hoy la UNICA usuaria de
   `.bp-details` en el repo. `_co_benefits_pass.html` la usaba y su `<details>` se
   retiro entero por orden del owner, asi que si algun dia se retira tambien este
   desplegable, `.bp-details__summary`, `.bp-details__chevron`, `.bp-details[open]`
   y los dos `::marker` de `main.css:9027-9029` se quedan sin nadie. */

.bp-tutorial__audience {
    margin-block: var(--space-6);
}

.bp-tutorial__summary {
    display: flex;
    width: 100%;
    justify-content: space-between;
    gap: var(--space-4);
    padding-block: var(--space-3);
    border-block: 1px solid var(--color-border-default);
    border-radius: 0;
    /* Deja de ser un sub-control gris de 0.875em: aqui es una cabecera de
       seccion, y es lo unico que lee quien no abre el desplegable. */
    font-size: var(--text-base);
    color: var(--color-text-primary);
}

.bp-tutorial__summary-text {
    max-width: var(--prose-max);
}

.bp-tutorial__audience-grid {
    /* Misma pista que las palancas (20rem), y por el mismo motivo aritmetico:
       resuelve a 3 columnas a >=1216 px de `#main`, que son exactamente los 3
       parrafos que quedan dentro del pliegue (el primero vive fuera). Columna de
       429 px a 1920 contra un tope de 445: manda el CONTENEDOR, asi que el texto
       llena su celda al 100 %. Con 28rem salian 2 columnas de 656 px y el
       parrafo se quedaba en 445 — el mismo defecto de «texto nadando en su caja»
       que este cambio viene a arreglar, reintroducido aqui dentro. */
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
    gap: var(--space-6);
    margin-block-start: var(--space-4);
}

/* «Que puedes hacer con el» — peticiones 1 y 3.

   Deja de ser card y pasa a banda a ancho completo. La seccion ocupa los
   1336 px y la medida de lectura sigue siendo la de una columna: es la peticion
   1 resuelta sin tocar una sola declaracion de ancho.

   El 20rem de la pista NO es gusto, es aritmetica del numero de piezas: da 3
   columnas a >=1216 px, 2 a 706 y 1 a 343, y 6 palancas entran EXACTAS en las
   tres (cero celdas vacias a cualquier ancho). Es propiedad del CONTENIDO: una
   septima palanca en `tutorial.py` reintroduce la fila a medias, y por eso lo
   vigila `test_the_number_of_levers_fills_its_grid_at_every_width`. */

.bp-tutorial__levers {
    margin-block-start: var(--space-8);
}

.bp-tutorial__levers-grid {
    list-style: none;
    margin: var(--space-6) 0 0;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
    gap: var(--space-6);
}

.bp-tutorial-lever {
    /* ⚠️ El `<li>` SI esta en el selector del tope de prosa. Hoy no mordia
       porque vivia en una columna de 379 px, pero en esta rejilla la celda mide
       429 px a 1920 y el tope a 16px son 445: el `<li>` se quedaria corto de su
       celda y dejaria un hueco DENTRO de la rejilla. El `<li>` es maqueta, no
       prosa; el tope lo lleva su `<p>` hijo, que es donde tiene que estar. */
    max-width: none;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

.bp-tutorial-lever__head {
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

.bp-tutorial-lever__icon {
    flex: none;
    display: inline-flex;
    color: var(--brand-primary);
}

.bp-tutorial-lever__title {
    margin: 0;
    font-family: var(--font-heading);
    /* El peor defecto tipografico MEDIDO de la pagina: este titulo valia
       `--text-sm` (14px), EL MISMO TAMAÑO QUE SU PROPIO CUERPO, y solo los
       separaban peso y color -> 46 lineas seguidas con escalon y medio. Sube a
       18px para tener tres escalones reales (18/600/primary - 16/400/secondary),
       que es exactamente el patron de `.bp-tutorial-card`, el unico bloque de
       esta pagina que hoy se lee bien. */
    font-size: var(--text-lg);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

.bp-tutorial-lever__body {
    margin: 0;
    font-size: var(--text-base);
    line-height: var(--leading-relaxed);
    color: var(--color-text-secondary);
}

/* Titulo de seccion de nivel 2. Existe para que los h3 de las cards no cuelguen
   del ultimo bloque de la fila de arriba, que habla de otra cosa. */

.bp-tutorial__section-title {
    margin: var(--space-8) 0 var(--space-4);
    font-family: var(--font-heading);
    font-size: var(--text-xl);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

/* LA respuesta a la peticion 4 del owner («que el texto ocupe todo el ancho del
   div»), y la unica clase de maqueta nueva del cambio. Dos usos: la nota
   «¿Y si me equivoco?» y la cabecera de «Como le saco partido».

   La peticion tiene dos respuestas posibles: agrandar el tipo hasta que la caja
   tope llene el div, o ENCOGER EL DIV hasta la caja tope. La primera no existe:
   llenar 1336 px con texto son 176 caracteres por linea, 2,3x el `MAX_CPL = 75`
   que el proyecto defiende — es el opt-out que el owner mando revertir. Esta es
   la segunda: el div pasa a ser una pista del ancho del tope, y entonces «el
   texto ocupa todo el ancho de su div» se cumple LITERALMENTE.
   Medido a 1920: el cuerpo de la nota pasa de 388 px de texto en un div de
   1283 (30 %) a llenar su pista, y el subtitulo de casos de 427 px en 1336
   (32 %) a lo mismo.

   ⚠️ `font-size` AQUI y no solo en el hijo: el `ch` de la pista se resuelve
   contra el font-size de ESTE elemento, no contra el del `<p>` que lleva el
   tope (el gotcha de Comeau). Declarandolo en el contenedor, pista y tope valen
   el mismo pixel y el parrafo llena su columna al 100 % con cualquier fuente —
   tambien con la del runner del gate, que no es Lato. */

/* `.guide-split` es EL MISMO patron, no una copia (#3366 §4): las guias del
   panel tenian el defecto identico —entradilla de 441-445 px de texto en un div
   de 952 px a 1440 (46,3-46,7 %) y de 1072 px a 1920 (41,1-41,5 %)— y la issue
   pide reutilizar lo que ya quedo resuelto, no inventar otro. Comparten el
   bloque de declaraciones en vez de duplicarlo: un solo sitio que tocar el dia
   que cambie el tope, y ningun clon que se desincronice. El nombre propio existe
   porque un `bp-tutorial__` dentro de `panelguides/guide.html` no diria nada. */

/* La pista de ETIQUETA es lo unico que cambia entre los dos usos, y por eso es
   una variable y no una segunda regla: el tutorial vive en un contenedor de
   1.283-1.336 px, donde 16rem fijos dejan la fila practicamente llena; las guias
   viven en 952 px a 1440 y 1.072 a 1920, y con esos mismos 16rem la fila se
   quedaria al 77,8 % y al 69,1 % — el mismo hueco a la derecha, mas pequeño.
   Quien quiera la version elastica declara `--split-label-track` en un ancestro
   (lo hace `.guide__body`); la pista de PROSA, que es la que defiende la medida
   de lectura, no se toca en ningun caso. Un `.guide-split { … }` aparte habria
   sido un selector duplicado (stylelint `no-duplicate-selectors`), y copiar el
   bloque entero, un clon. */
.bp-tutorial__split,
.guide-split {
    display: grid;
    grid-template-columns: var(--split-label-track, minmax(0, 16rem)) minmax(0, var(--prose-max));
    gap: var(--space-4) var(--space-10);
    align-items: start;
    font-size: var(--text-base);
}

@media (max-width: 47.99rem) {
    .bp-tutorial__split,
    .guide-split {
        grid-template-columns: 1fr;
    }
}

.bp-tutorial__split-label {
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

/* Dentro del split manda la rejilla: el margen del titulo desplazaria su celda
   y descuadraria las dos pistas. El aire de la seccion lo pone `.bp-tutorial__cases`. */
.bp-tutorial__split > .bp-tutorial__section-title {
    margin-block: 0;
}

.bp-tutorial__cases {
    margin-block-start: var(--space-8);
}

.bp-tutorial__cases-lead {
    margin: 0;
}

.bp-tutorial__cases-grid {
    list-style: none;
    margin: var(--space-6) 0 0;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
    gap: var(--space-6);
}

/* Un caso de uso NO es un paso: no lleva numero ni captura, y se distingue por
   el filete de marca a la izquierda, el mismo recurso que la nota del final. */

.bp-tutorial-case {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    padding: var(--space-6);
    border: 1px solid var(--color-border-default);
    border-inline-start: var(--space-1) solid var(--brand-primary);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-elevated);
}

.bp-tutorial-case__head {
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

.bp-tutorial-case__icon {
    flex: none;
    display: inline-flex;
    color: var(--brand-primary);
}

.bp-tutorial-case__title {
    margin: 0;
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

.bp-tutorial-case__who {
    /* El nivel «caption» del sistema (13px / 1,4 / 0,02em) existe justo para un
       metadato como este y la pantalla no lo usaba ni una vez: iba a
       `--text-xs` pelado. */
    margin: 0;
    color: var(--color-text-muted);
    font-size: var(--text-caption-size);
    line-height: var(--text-caption-leading);
    letter-spacing: var(--text-caption-tracking);
}

.bp-tutorial-case__body {
    margin: 0;
    color: var(--color-text-secondary);
    font-size: var(--text-sm);
    line-height: var(--leading-relaxed);
}

.bp-tutorial-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    padding: var(--space-6);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-surface);
    box-shadow: var(--shadow-sm);
}

.bp-tutorial-card__head {
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

.bp-tutorial-card__num {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--space-8);
    height: var(--space-8);
    border-radius: var(--radius-full);
    background-color: var(--brand-surface-strong);
    color: var(--color-text-inverse);
    font-family: var(--font-heading);
    font-size: var(--text-sm);
    font-weight: var(--font-bold);
}

.bp-tutorial-card__icon {
    flex: none;
    display: inline-flex;
    color: var(--brand-primary);
}

.bp-tutorial-card__title {
    margin: 0;
    font-family: var(--font-heading);
    font-size: var(--text-lg);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

.bp-tutorial-card__intro {
    margin: 0;
    color: var(--color-text-primary);
}

.bp-tutorial-card__points {
    margin: 0;
    padding-inline-start: var(--space-5);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    color: var(--color-text-secondary);
    font-size: var(--text-sm);
    line-height: var(--leading-relaxed);
}

.bp-tutorial-card__figure {
    margin: var(--space-2) 0 0;
}

.bp-tutorial-card__shot {
    display: block;
    width: 100%;
    height: auto;
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
}

/* Hueco de captura: el PNG del paso todavía no existe en el repo. Se rotula
   con el nombre exacto que tiene que llevar el fichero para que hacerla sea
   un encargo cerrado, no una adivinanza. En cuanto el fichero aparece bajo
   `static/`, la vista deja de pintar este hueco y sale la imagen. */

.bp-tutorial-card__shot-slot {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-height: 8rem;
    padding: var(--space-4);
    border: 1px dashed var(--color-border-default);
    border-radius: var(--radius-md);
    background-color: var(--color-bg-elevated);
    text-align: center;
}

.bp-tutorial-card__shot-badge {
    color: var(--color-text-muted);
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    text-transform: uppercase;
    letter-spacing: var(--text-caption-tracking);
}

.bp-tutorial-card__shot-path {
    color: var(--color-text-muted);
    font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace;
    font-size: var(--text-2xs);
    overflow-wrap: anywhere;
}

.bp-tutorial-card__caption {
    margin-block-start: var(--space-2);
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}

.bp-tutorial-note {
    /* `fit-content` es la mitad util del arreglo: la caja deja de medir 1283 px
       de contenido con un parrafo de 388 px dentro y se encoge hasta lo que
       ocupan sus dos pistas. El filete de marca y el fondo elevado se quedan. */
    width: fit-content;
    max-width: 100%;
    margin-block-start: var(--space-8);
    padding: var(--space-6);
    border: 1px solid var(--color-border-default);
    border-inline-start: var(--space-1) solid var(--brand-primary);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-elevated);
}

.bp-tutorial-note__icon {
    flex: none;
    display: inline-flex;
    color: var(--brand-primary);
}

.bp-tutorial-note__title {
    margin: 0;
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

.bp-tutorial-note__body {
    margin: 0;
    color: var(--color-text-secondary);
    font-size: var(--text-base);
    line-height: var(--leading-relaxed);
}

/* Movil: aqui el split ya esta colapsado, asi que el ancho de la nota lo pone el
   CONTENEDOR y no el tope — y entonces subir la fuente BAJA los cpl en
   proporcion directa. Medido a 375 px sin estas dos reglas: caja interior de
   290 px y el cuerpo a 16 px daba **41 caracteres por linea**, por debajo de la
   banda legible 45-75 que el proyecto defiende. Con el padding a 16 px (caja de
   306) y el cuerpo de vuelta a `--text-sm`, **50**.
   Va DESPUES de `.bp-tutorial-note__body` a proposito: misma especificidad, gana
   la ultima — puesta antes, la media query no pintaba nada (medido).
   Es la unica bifurcacion por breakpoint del cambio, y esta aqui porque en esta
   caja concreta el tope deja de mandar; el lede, que sigue atado al tope, se
   resuelve con un `clamp` y sin media query. */
@media (max-width: 40rem) {
    .bp-tutorial-note {
        padding: var(--space-4);
    }

    .bp-tutorial-note__body {
        font-size: var(--text-sm);
    }
}

/* ═══════════════════════════════════════════════════
   Guías «Cómo sacarle partido» del panel (#3318)
   ═══════════════════════════════════════════════════

   Nueve pantallas, entre 18 y 33 apartados cada una. El molde es el de
   `battlepass/tutorial.html` (#3317) —contenido como datos, plantilla de
   bucles— con una diferencia de escala que cambia el layout: aquí no caben
   todos los apartados abiertos, así que se pliegan (`details` nativo) y la
   página necesita un índice.

   MOBILE FIRST, DE VERDAD — qué hace el índice a 375 px.
   La base es una columna: el índice va ARRIBA, pegado al borde superior del
   scroller, como una barra de SECCIONES (cuatro enlaces, área táctil completa,
   scroll horizontal si no caben). La lista de los 20-30 campos está fuera de
   esa barra a propósito: un índice de treinta entradas en un móvil es otro muro
   de texto delante del que ya hay. Para llegar a un apartado concreto está el
   buscador, que filtra en el sitio. A partir de 1024 px la misma lista aparece
   y el índice se convierte en la columna lateral pegajosa que sí cabe.

   MEDIDA DE LECTURA. Ni un opt-out del tope de prosa y ni una declaración de
   ancho sobre el texto: la regla genérica de #main ya topa `p`/`li` en
   `--prose-max`, y aquí lo único que se ensancha son las REJILLAS (capturas y
   casos), que a partir de cierto ancho pasan a dos columnas en vez de estirar
   la línea. El `summary` no entra en aquella regla —no es `p` ni `li`— así que
   lleva su propio tope con el MISMO token: sin él, el nombre de un apartado
   largo sería la única línea de la página fuera de banda.

   La barra pegajosa lleva fondo OPACO (R-STICKY-OPAQUE): el contenido se
   desliza por debajo, y con alpha menor que 1 se leería a través de ella. */

.guide__kicker {
    margin: 0 0 var(--space-3);
    font-size: var(--text-sm);
}

.guide {
    display: grid;
    /* `minmax(0, 1fr)` y no la columna implicita: una columna `auto` se dimensiona
       por el max-content de sus hijos, y la rejilla de capturas de dentro pide
       320 px por columna. Medido a 375 px de viewport sin esto: `#main` daba
       scrollWidth 496 sobre clientWidth 375 — desbordamiento horizontal DENTRO
       de `#main`, que ademas es invisible para `document.scrollWidth` porque el
       scroll se lo come `#main` (que es `overflow-y: auto`). El `min-width: 0`
       del hijo no basta: quien tiene que ceder es la PISTA. */
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-6);
    margin-block-start: var(--space-6);
}

/* ── Índice ───────────────────────────────────────────────────────────────── */

.guide-index {
    position: sticky;
    top: 0;
    z-index: var(--z-sticky);
    padding-block: var(--space-3);
    /* Opaco, no translúcido: por debajo de esta barra pasa el texto de la guía. */
    background-color: var(--color-bg-surface);
    border-block-end: 1px solid var(--color-border-default);
}

.guide-index__title {
    margin: 0 0 var(--space-2);
    font-family: var(--font-heading);
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    text-transform: uppercase;
    letter-spacing: var(--text-caption-tracking);
    color: var(--color-text-muted);
}

.guide-index__sections {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    gap: var(--space-2);
    overflow-x: auto;
}

.guide-index__link {
    display: inline-flex;
    align-items: center;
    min-height: var(--touch-target-min);
    padding-inline: var(--space-3);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-full);
    background-color: var(--color-bg-elevated);
    color: var(--color-text-secondary);
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    white-space: nowrap;
    text-decoration: none;
}

.guide-index__link:hover,
.guide-index__link:focus-visible {
    border-color: var(--brand-primary);
    color: var(--color-text-primary);
}

/* La sección que estás leyendo. La marca el IntersectionObserver de
   `panel-guide.js`; sin JS, el índice sigue navegando igual, solo que sin
   resaltado. */
.guide-index__link.is-current {
    border-color: var(--brand-primary);
    background-color: var(--brand-primary-light);
    color: var(--brand-primary-dark);
}

/* Móvil: la lista de apartados NO se pinta (ver cabecera del bloque). */
.guide-index__fields {
    display: none;
}

/* Y en móvil tampoco su desplegable: a 375 px el índice es la barra de chips de
   secciones, y un `<details>` con treinta entradas dentro sería otro muro
   delante del que ya hay. */
.guide-index__fields-details {
    display: none;
}

/* ── Secciones ────────────────────────────────────────────────────────────── */

.guide__body {
    display: flex;
    flex-direction: column;
    gap: var(--space-10);
    min-width: 0;
    /* La pista de etiqueta ELASTICA de `.guide-split` (#3366 §4). Se declara
       aqui, en el ancestro, y no en `.guide-split`: asi el patron sigue siendo
       una sola regla compartida con `.bp-tutorial__split` en vez de dos. */
    --split-label-track: minmax(0, 1fr);
}

.guide-section {
    scroll-margin-top: var(--space-12);
}

/* Escalón tipográfico, orden del owner (2026-09-16): «que los títulos y
   subtítulos se vean así de claros … que no parezca un papiro de código». El
   defecto medido era «textos masivos con el mismo color de fuente, sin presencia
   visual»: el título de sección estaba a `--text-xl` semibold, o sea a un solo
   peldaño del `--text-base` semibold del nombre de un apartado. Sube a `2xl`
   bold, que es el salto que hace que un título se lea como título sin cambiar la
   arquitectura de la página («no quiero cambio muy grande»). */
.guide-section__title {
    margin: 0 0 var(--space-3);
    font-family: var(--font-heading);
    font-size: var(--text-2xl);
    font-weight: var(--font-bold);
    line-height: var(--leading-tight);
    color: var(--color-text-primary);
}

/* Dentro de `.guide-split` manda la rejilla: el margen del título desplazaría su
   celda y descuadraría las dos pistas — mismo motivo y misma regla que el
   `.bp-tutorial__split > .bp-tutorial__section-title` del tutorial del pase. */
.guide-split > .guide-section__title {
    margin-block-end: 0;
}

.guide-section__lead {
    margin: 0 0 var(--space-5);
    color: var(--color-text-secondary);
}

/* La entradilla que vive en la pista de prosa del split ya está topada por la
   PISTA, así que su margen inferior lo pone la rejilla (`gap`) y no ella. */
.guide-section__lead--split {
    margin-block-end: 0;
}

/* `:not(:last-child)`: cuando el split ES la seccion entera («Para que sirve»),
   el aire de debajo ya lo pone el `gap` de `.guide__body` y un margen propio lo
   sumaria por segunda vez. */
.guide-section .guide-split:not(:last-child) {
    margin-block-end: var(--space-5);
}

/* ── Matriz de permisos por rol (#3366 §5) ────────────────────────────────── */

/* Una tabla de MARCAS, no de texto: la casilla es un disco lleno (obligatorio),
   un anillo (opcional) o un hueco (no le hace falta), y lo que se lee de un
   vistazo es la silueta de cada puesto. El nivel viaja ademas en un `.sr-only`
   dentro de la celda, porque la marca es `aria-hidden` y un color NUNCA es el
   unico portador de una informacion (WCAG 2.2 SC 1.4.1). */

.guide-roles-legend {
    list-style: none;
    margin: 0 0 var(--space-4);
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2) var(--space-5);
}

.guide-roles-legend__item {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    color: var(--color-text-secondary);
    font-size: var(--text-xs);
}

/* La matriz son once columnas: a 375 px NO cabe y se desplaza en horizontal,
   como el resto de tablas del panel. El `tabindex="0"` de la plantilla es lo que
   la hace desplazable tambien con teclado (WCAG 2.2 SC 2.1.1); sin el, un
   contenedor con scroll es una trampa para quien no usa raton. */
.guide-roles__scroll {
    /* ⛔ `position: relative` NO es decoracion: sin el, el `overflow-x` de aqui
       arriba NO recorta el `.sr-only` de cada casilla y la PAGINA ENTERA se
       desborda. `.sr-only` es `position: absolute` (preflight de Tailwind — la
       misma trampa ya escrita para el toggle, ~L4025), asi que su bloque
       contenedor es el ancestro POSICIONADO mas cercano; sin ninguno acaba
       siendo el bloque contenedor inicial, y entonces ni este scroller
       (`overflow-x: auto`), ni `#main` (`overflow: hidden`), ni `body`
       (`overflow-x: clip`) lo recortan: ninguno esta en su cadena de bloques
       contenedores. Su posicion estatica en las columnas de la derecha cae en
       x≈865 y eso es lo que mide `document.documentElement.scrollWidth`.
       MEDIDO a 375 px: `documentElement.scrollWidth` 865 sobre `clientWidth`
       375 = **490 px de desbordamiento** (176 a 768 px), con el `#main` a 0 —
       por eso una medicion que solo mire `#main` da un cero que no significa
       nada. Con esta linea: 0 en 375/768/1440/1920.
       Lo firma `e2e/tests/smoke/panel-guides.spec.ts` (@smoke, gate requerido),
       que mide el MAXIMO de documento y `#main` justamente para esto. */
    position: relative;
    overflow-x: auto;
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-surface);
}

.guide-roles__scroll:focus-visible {
    outline: 2px solid var(--brand-primary);
    outline-offset: 2px;
}

.guide-roles {
    border-collapse: collapse;
    width: 100%;
}

.guide-roles__col {
    /* `--space-1` y no `--space-2` en horizontal, y el numero manda: medido a
       1440 con la columna de contenido en 950 px, las ONCE columnas ocupaban
       966 px en español, 985 en ingles y 1.115 en aleman — la ultima quedaba
       cortada hasta en español. Con 4 px por lado: 950 (es, cabe justo), 961
       (en, 11 px fuera) y 1.051 (de). A 1920 (1.070 de ancho) caben los tres.
       Lo que no cabe se desplaza en el contenedor, que ademas es alcanzable con
       teclado: once columnas no caben en todos los idiomas a todos los anchos,
       y fingir que si obligaria a recortar las etiquetas del modelo. */
    padding: var(--space-3) var(--space-1);
    border-block-end: 1px solid var(--color-border-default);
    color: var(--color-text-muted);
    font-size: var(--text-2xs);
    font-weight: var(--font-semibold);
    text-transform: uppercase;
    letter-spacing: var(--text-caption-tracking);
    text-align: center;
    /* Una etiqueta de permiso son una o dos palabras: partirlas en cuatro lineas
       hace la cabecera mas alta que la matriz. 4,5rem y no 5 porque es lo que
       hace que las ONCE columnas quepan en los 952 px de la columna de contenido
       a 1440 (10 x 72 + 208 = 928): con 5rem la ultima —«Control de Acceso»—
       caia fuera y habia que desplazar para verla en un escritorio normal. */
    min-width: 4.5rem;
}

/* R-STICKY-OPAQUE — la PRIMERA celda de cada fila (la esquina y el nombre del
   puesto) va anclada y su fondo es OPACO (alpha == 1): por debajo se desliza el
   resto de la matriz, y con alpha < 1 se leeria a traves de ella. Se selecciona
   por posicion —`tr > :first-child`, igual que la primera columna anclada de
   `.data-table`— y no listando las dos clases, que seria un selector duplicado. */
.guide-roles tr > :first-child {
    position: sticky;
    inset-inline-start: 0;
    z-index: var(--z-base);
    background-color: var(--color-bg-surface);
    border-inline-end: 1px solid var(--color-border-default);
    text-align: start;
}

.guide-roles__corner {
    padding: var(--space-3) var(--space-4);
    border-block-end: 1px solid var(--color-border-default);
    color: var(--color-text-muted);
    font-size: var(--text-2xs);
    font-weight: var(--font-semibold);
    text-transform: uppercase;
    letter-spacing: var(--text-caption-tracking);
}

.guide-roles__role {
    display: table-cell;
    padding: var(--space-3) var(--space-4);
    font-weight: var(--font-normal);
    min-width: 13rem;
}

/* A 375 px la columna anclada se comia 208 de los 343 disponibles y dejaba a la
   vista menos de dos permisos: la matriz no se leia, se adivinaba. */
@media (max-width: 47.99rem) {
    .guide-roles__role {
        min-width: 9rem;
        padding-inline: var(--space-3);
    }
}

.guide-roles__row + .guide-roles__row > * {
    border-block-start: 1px solid var(--color-border-subtle);
}

.guide-roles__role-icon {
    display: none;
}

.guide-roles__role-text {
    display: flex;
    flex-direction: column;
    gap: var(--space-0-5);
}

.guide-roles__role-name {
    color: var(--color-text-primary);
    font-family: var(--font-heading);
    font-size: var(--text-sm);
    font-weight: var(--font-semibold);
}

.guide-roles__role-summary {
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}

.guide-roles__cell {
    padding: var(--space-3) var(--space-1);
    text-align: center;
}

.guide-roles__mark {
    display: inline-block;
    width: 0.75rem;
    height: 0.75rem;
    border-radius: var(--radius-full);
    vertical-align: middle;
}

.guide-roles__mark--required {
    background-color: var(--brand-primary-dark);
    box-shadow: 0 0 0 1px var(--brand-primary-dark);
}

.guide-roles__mark--optional {
    background-color: transparent;
    box-shadow: inset 0 0 0 2px var(--brand-primary-dark);
}

/* «No le hace falta» NO es un hueco vacio: es un punto apagado. Una celda en
   blanco se confunde con una celda a medio pintar, y el owner ya vio esa
   ambiguedad en la tabla de dispositivos. */
.guide-roles__mark--no {
    width: 0.25rem;
    height: 0.25rem;
    background-color: var(--color-border-default);
}

.guide-roles-notes {
    list-style: none;
    margin: var(--space-4) 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
}

/* El nombre del puesto va ENCIMA de su aviso, no a su izquierda, y el motivo es
   una medicion, no una preferencia: la medida de lectura se cuenta por LINEA
   VISUAL, y en fila los dos `span` comparten la primera. MEDIDO con `flex-wrap:
   wrap`: «Jefe de barra» (13 car.) + la primera linea del aviso (64) = **77
   caracteres** en la misma linea, contra el tope de 75 del proyecto — y lo mismo
   a 768, 1440 y 1920, porque el que manda es el tope de la caja (445 px), no el
   ancho de la ventana. Apilados, la peor linea de la pagina baja a 71.
   El tope del aviso NO se toca (`--prose-max`, abajo): el defecto no era la caja
   del parrafo, era que la etiqueta se le sumaba delante.
   Lo firma `e2e/tests/smoke/panel-guides.spec.ts` (@smoke, gate requerido). */
.guide-roles-notes__item {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-1) var(--space-2);
    padding-inline-start: var(--space-3);
    border-inline-start: var(--space-0-5) solid var(--brand-primary);
}

.guide-roles-notes__role {
    color: var(--color-text-primary);
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
}

.guide-roles-notes__body {
    /* Un `span` no entra en la regla generica de prosa de `#main` (`p, li, dd,
       label > span`), asi que su tope va aqui y con el MISMO token. */
    max-inline-size: var(--prose-max);
    color: var(--color-text-secondary);
    font-size: var(--text-xs);
}

/* ── Capturas ─────────────────────────────────────────────────────────────── */

.guide-shots {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    /* `min(320px, 100%)` y no `320px` a secas: con el mínimo duro, a 320 px de
       viewport la columna pide más ancho que el contenedor y la página se va en
       scroll horizontal. */
    grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
    gap: var(--space-6);
}

.guide-shot__figure {
    margin: 0;
}

.guide-shot__img {
    display: block;
    width: 100%;
    height: auto;
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
}

/* Hueco de captura: el PNG todavía no existe en el repo. Se rotula con el
   nombre exacto que tiene que llevar el fichero para que hacerla sea un encargo
   cerrado. En cuanto aparece bajo `static/`, la vista deja de pintar el hueco. */
.guide-shot__slot {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-height: 8rem;
    padding: var(--space-4);
    border: 1px dashed var(--color-border-default);
    border-radius: var(--radius-md);
    background-color: var(--color-bg-elevated);
    text-align: center;
}

.guide-shot__badge {
    color: var(--color-text-muted);
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    text-transform: uppercase;
    letter-spacing: var(--text-caption-tracking);
}

.guide-shot__path {
    color: var(--color-text-muted);
    font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace;
    font-size: var(--text-2xs);
    overflow-wrap: anywhere;
}

.guide-shot__caption {
    /* Un `figcaption` NO entra en la regla generica de prosa (`p, li, dd,
       label > span`), asi que su tope va aqui y con el MISMO token. Medido sin
       el: 90 caracteres por linea a 1440 px, el peor de la pagina entera. */
    max-inline-size: var(--prose-max);
    margin-block-start: var(--space-2);
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}

/* ── Buscador ─────────────────────────────────────────────────────────────── */

.guide-search {
    display: flex;
    flex-direction: column;
    gap: var(--space-1-5);
    margin-block-end: var(--space-5);
}

/* Nace `hidden` y lo revela el JS: un buscador que no filtraría nada no se
   pinta. El atributo tiene que ganarle al `display: flex` de arriba. */
.guide-search[hidden] {
    display: none;
}

.guide-search__label {
    font-size: var(--text-sm);
    /* SEMIBOLD y no medium, y no es cosmetica: la etiqueta lleva ademas la
       utilidad `font-semibold` porque `check_form_label_weight.py` la exige
       (regla 1 de #2248, las etiquetas de formulario van en negrita). Las dos
       reglas tienen la misma especificidad y `main.css` carga DESPUES de
       `tailwind.output.css` (header.html), asi que esta gana: con `medium`
       aqui, el guard veria la clase y el navegador pintaria 500 — una regla
       satisfecha en el texto y desmentida en pantalla. */
    font-weight: var(--font-semibold);
    color: var(--color-text-secondary);
}

.guide-search__input {
    min-height: var(--touch-target-min);
    max-inline-size: var(--prose-max);
    padding-inline: var(--space-3);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-md);
    background-color: var(--color-bg-surface);
    color: var(--color-text-primary);
    font-size: var(--text-base);
}

.guide-search__input:focus-visible {
    border-color: var(--brand-primary);
    outline: 2px solid var(--brand-primary);
    outline-offset: 1px;
}

.guide-search__status {
    margin: 0;
    min-height: var(--space-5);
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}

/* ── Apartados plegables ──────────────────────────────────────────────────── */

.guide-fields {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

.guide-field {
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-surface);
    scroll-margin-top: var(--space-12);
}

.guide-field[open] {
    box-shadow: var(--shadow-sm);
}

.guide-field__summary {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    min-height: var(--touch-target-min);
    padding: var(--space-3) var(--space-4);
    cursor: pointer;
    list-style: none;
}

.guide-field__summary::-webkit-details-marker {
    display: none;
}

.guide-field__summary:hover,
.guide-field__summary:focus-visible {
    background-color: var(--color-bg-elevated);
    border-radius: var(--radius-lg);
}

.guide-field__chevron {
    flex: none;
    display: inline-flex;
    color: var(--brand-primary);
    transition: transform var(--dur-base) ease;
}

.guide-field[open] .guide-field__chevron {
    transform: rotate(180deg);
}

.guide-field__name {
    /* El `summary` no entra en la regla genérica de prosa (no es `p` ni `li`),
       así que su tope va aquí y con el MISMO token — no con un ancho a mano. */
    max-inline-size: var(--prose-max);
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

.guide-field__body {
    padding: 0 var(--space-4) var(--space-4);
}

.guide-field__label {
    margin: var(--space-3) 0 var(--space-1);
    font-size: var(--text-xs);
    font-weight: var(--font-semibold);
    text-transform: uppercase;
    letter-spacing: var(--text-caption-tracking);
    color: var(--color-text-muted);
}

.guide-field__text {
    margin: 0;
    color: var(--color-text-secondary);
    font-size: var(--text-sm);
}

/* ── Casos de uso ─────────────────────────────────────────────────────────── */

.guide-cases {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
    gap: var(--space-6);
}

/* Un caso NO es un apartado: no se pliega y se distingue por el filete de marca
   a la izquierda, el mismo recurso que la nota del tutorial del pase. */
.guide-case {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    padding: var(--space-6);
    border: 1px solid var(--color-border-default);
    border-inline-start: var(--space-1) solid var(--brand-primary);
    border-radius: var(--radius-lg);
    background-color: var(--color-bg-elevated);
    scroll-margin-top: var(--space-12);
}

.guide-case__head {
    display: flex;
    align-items: center;
    gap: var(--space-3);
}

.guide-case__icon {
    flex: none;
    display: inline-flex;
    color: var(--brand-primary);
}

.guide-case__title {
    margin: 0;
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

.guide-case__setting {
    margin: 0;
    color: var(--color-text-muted);
    font-size: var(--text-xs);
}

.guide-case__body {
    margin: 0;
    color: var(--color-text-secondary);
    font-size: var(--text-sm);
}

/* ── Las otras ocho guías ─────────────────────────────────────────────────── */

.guide-siblings {
    padding-block-start: var(--space-6);
    border-block-start: 1px solid var(--color-border-default);
}

.guide-siblings__title {
    margin: 0 0 var(--space-3);
    font-family: var(--font-heading);
    font-size: var(--text-base);
    font-weight: var(--font-semibold);
    color: var(--color-text-primary);
}

.guide-siblings__list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
}

.guide-siblings__link {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-height: var(--touch-target-min);
    padding-inline: var(--space-3);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-full);
    color: var(--color-text-secondary);
    font-size: var(--text-sm);
    text-decoration: none;
}

.guide-siblings__link:hover,
.guide-siblings__link:focus-visible {
    border-color: var(--brand-primary);
    color: var(--color-text-primary);
}

.guide-siblings__icon {
    flex: none;
    display: inline-flex;
    color: var(--brand-primary);
}

/* ── A partir de 1024 px: el índice se va al lateral y aparece la lista ───── */

@media (min-width: 1024px) {
    /* ⭐ #3366 §3 — «el indice debe llegar hasta el final».
       MEDIDO sobre `origin/demo`, las nueve guias: la caja del indice media
       840-852 px a 1440x900 y 794-1032 px a 1920x1080 (topada por su
       `max-height`), contra 3.876-4.556 px y 3.473-4.105 px de contenido — o
       sea el 18,7-21,5 % y el 22,8-27,8 % de la altura de la columna derecha.
       Por eso se veian dos columnas de altura muy distinta.

       `align-items: start` era justo lo que lo causaba: encogia la PISTA del
       indice a la altura de su contenido, y con ella el filete que la separa.
       Pasa a `stretch` (la pista llega abajo del todo) y lo pegajoso se mueve al
       `__inner`. Se necesitan los DOS cambios: un elemento `sticky` que ademas
       se estira no se pega a nada, porque ya es tan alto como su contenedor. */
    .guide {
        grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
        align-items: stretch;
    }

    .guide-index {
        position: static;
        padding-block: 0;
        padding-inline-end: var(--space-4);
        border-block-end: none;
        border-inline-end: 1px solid var(--color-border-default);
    }

    .guide-index__inner {
        position: sticky;
        top: var(--space-4);
        max-height: calc(100vh - var(--space-12));
        padding-block: var(--space-4);
        overflow-y: auto;
    }

    /* La lista de apartados, PLEGABLE pero abierta de origen (`open` en la
       plantilla, con el porque escrito alli). Sigue en el HTML —las anclas
       `#campo-…` son un contrato con su test— y no alarga la pagina: el
       `max-height` + `overflow-y: auto` del `__inner` la deja en 752 px de caja
       para 924 de contenido (medido a 1280), asi que se desplaza dentro de la
       columna pegajosa en vez de ser un segundo muro de texto. El indice
       «premium» que pidio el owner es eso: corto de CAJA y siempre visible. */
    .guide-index__fields-details {
        display: block;
        margin-block: var(--space-1);
    }

    .guide-index__fields-summary {
        display: flex;
        align-items: center;
        min-height: var(--touch-target-min-aa);
        padding: var(--space-1) var(--space-2);
        border-radius: var(--radius-sm);
        color: var(--color-text-muted);
        font-size: var(--text-2xs);
        text-transform: uppercase;
        letter-spacing: var(--text-caption-tracking);
        cursor: pointer;
        list-style: none;
    }

    .guide-index__fields-summary::-webkit-details-marker {
        display: none;
    }

    .guide-index__fields-summary:hover,
    .guide-index__fields-summary:focus-visible {
        background-color: var(--color-bg-elevated);
        color: var(--color-text-primary);
    }

    .guide-index__sections {
        flex-direction: column;
        gap: var(--space-1);
        overflow-x: visible;
    }

    .guide-index__link {
        width: 100%;
        border: none;
        border-radius: var(--radius-md);
        background-color: transparent;
        white-space: normal;
    }

    .guide-index__fields {
        display: flex;
        flex-direction: column;
        gap: var(--space-0-5);
        margin: var(--space-1) 0 var(--space-2);
        padding-inline-start: var(--space-3);
        list-style: none;
        border-inline-start: 1px solid var(--color-border-default);
    }

    .guide-index__field-link {
        display: block;
        /* Suelo de AA (WCAG 2.2 SC 2.5.8 = 24 px), no los 44 px del dedo: esta
           lista SOLO existe a partir de 1024 px, donde se apunta con el raton, y
           son treinta entradas — a 44 px cada una el indice mediria mas que la
           pantalla. En movil no se pinta, y alli el suelo de 44 px lo cumplen
           los chips de seccion, que son lo que se toca. Medido: 26 px. */
        min-height: var(--touch-target-min-aa);
        padding: var(--space-1) var(--space-2);
        border-radius: var(--radius-sm);
        color: var(--color-text-muted);
        font-size: var(--text-xs);
        text-decoration: none;
    }

    .guide-index__field-link:hover,
    .guide-index__field-link:focus-visible {
        background-color: var(--color-bg-elevated);
        color: var(--color-text-primary);
    }
}
