/* ==========================================================================
   echoSignals — component layer
   ==========================================================================

   Loads after css/echosignals-theme.css and consumes its tokens. Everything
   here is a component this panel needs and the template does not have: KPI
   tiles, status pills, the numeric table treatment, write-only credential
   fields, the custom dropdown, meters, timelines.

   No colour, size or radius is chosen here — they all come from the theme, so
   both modes are handled once, in one place, and this file never has to know
   which one is active.
   ========================================================================== */

/* --- numeric text ----------------------------------------------------------
   Trading figures have to align on the decimal down a column and must not
   reflow when a digit changes. `tabular-nums` is already the body default in
   the theme; this class adds the no-wrap and the optical tightening that a
   figure wants and a sentence does not. */
.es-num {
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum" 1;
    letter-spacing: -0.012em;
    white-space: nowrap;
}

.es-num-lg {
    font-size: var(--es-text-2xl);
    font-weight: 600;
    line-height: var(--es-lh-tight);
    letter-spacing: var(--es-track-tight);
}

.es-num-xl {
    font-size: var(--es-text-3xl);
    font-weight: 600;
    line-height: var(--es-lh-tight);
    letter-spacing: -0.026em;
}

/* Profit and loss. Always paired with a sign from Ui.Signed() or an arrow:
   the pair holds a 12-point L* gap so it survives colour-blindness, but the
   sign is what survives colour being absent altogether.

   Each rule below is written twice, once bare and once qualified by `td`/`th`,
   and the second form is not redundant. Bootstrap colours table cells with

       .table > :not(caption) > * > * { color: var(--bs-table-color-state, …) }

   which is specificity (0,1,1) against a single class's (0,1,0) — so every one
   of these applied DIRECTLY to a cell lost, silently, and the panel's most
   important colour coding never rendered: `+$578.08` in the exposure table
   computed to plain body near-black. It only ever worked where the class sat
   on a span inside the cell, which is why the KPI cards looked correct and the
   tables looked deliberate rather than broken.

   `td.es-pl-up` is (0,1,1) — a tie, broken by source order, and this
   stylesheet loads after the template's. A tie rather than `!important` keeps
   a page able to override it in the ordinary way. `check-channels.mjs` asserts
   the computed colour matches the class on every page in both modes, because
   the failure is invisible: a P&L column in body colour reads as a design
   choice, not as a cascade loss. */
.es-pl-up,   td.es-pl-up,   th.es-pl-up   { color: var(--es-pos); }
.es-pl-down, td.es-pl-down, th.es-pl-down { color: var(--es-neg); }
.es-pl-flat, td.es-pl-flat, th.es-pl-flat { color: var(--es-text-3); }

/* The same trap once more, in the definition lists the detail dialogs are
   built from: `.es-dl dd` sets `color: var(--es-text)` at (0,1,1), so a money
   figure applied directly to a `dd` lost exactly as it lost in a table cell —
   the trade dialog showed "Net −$3.19" in body colour beside a table showing
   the same figure in red. Found by opening a dialog inside the check rather
   than by reading either file. */
.es-dl dd.es-pl-up   { color: var(--es-pos); }
.es-dl dd.es-pl-down { color: var(--es-neg); }
.es-dl dd.es-pl-flat { color: var(--es-text-3); }

/* --- health channel --------------------------------------------------------
   Operational figures — copy latency, p95, reconnects — are NOT money and do
   not use the classes above. A latency inside budget used to print in
   `--es-pos`, so the dashboard showed a millisecond count in the same green as
   money earned; in a panel where green and red mean a balance moved, that is a
   category error.

   Nominal has no hue on purpose. On an SLO readout the absence of colour is
   the statement that the figure is where it should be, and the budget is
   printed next to it regardless. Only deviation is coloured.

   Amber and red remain the alarm channel. Red doing double duty as "wrong" and
   as "down on the day" is tolerable because both mean bad; green could not do
   the equivalent, which is the whole reason this channel exists. Amber and red
   still collapse under deuteranopia, so neither is ever the only signal.

   Qualified by `td`/`th` for the same reason as the P&L pair above: a latency
   column is a table cell, and Bootstrap's cell rule outranks a single class. */
.es-health-ok,   td.es-health-ok,   th.es-health-ok   { color: var(--es-text); }
.es-health-warn, td.es-health-warn, th.es-health-warn { color: var(--es-warn); }
.es-health-bad,  td.es-health-bad,  th.es-health-bad  { color: var(--es-neg); }
.es-health-none, td.es-health-none, th.es-health-none { color: var(--es-text-3); }

/* Kept as an alias of the health warn band: some pages label an over-budget
   figure directly rather than through Ui.LatencyClass. */
.es-pl-warn, td.es-pl-warn, th.es-pl-warn { color: var(--es-warn); }

/* For a coloured figure that carries no sign of its own — a profit factor, a
   reconnect count. The arrow restores the second cue. */
.es-dir {
    width: .75em;
    height: .75em;
    vertical-align: -0.05em;
    margin-right: .1em;
    flex-shrink: 0;
    display: inline-block;
}

/* --- KPI tile -------------------------------------------------------------- */
.es-kpi {
    display: flex;
    flex-direction: column;
    gap: .3rem;
    min-width: 0;              /* long values ellipsize instead of stretching
                                  the grid track */
}

.es-kpi-label {
    font-size: var(--es-text-nano);
    font-weight: 600;
    letter-spacing: var(--es-track-label);
    text-transform: uppercase;
    color: var(--es-text-3);
}

.es-kpi-sub {
    font-size: var(--es-text-xs);
    line-height: 1.45;
    color: var(--es-text-2);
}

.es-kpi-icon {
    width: 2.25rem;
    height: 2.25rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--es-radius-sm);
    flex-shrink: 0;
    background: rgba(var(--es-accent-rgb), .12);
    color: var(--es-accent-text);
}

.es-kpi-icon > svg { display: inline-block; }

/* The chip takes the colour of the figure it introduces, and never introduces
   one the figure does not already carry: a signed P&L tints to match, an
   unsigned equity and a nominal latency stay on the accent. `Ui.ChipClass`
   takes the value's own class as its input so the chip and the number above it
   cannot disagree.

   Each variant restates BOTH the wash and the glyph colour, which the first
   version did not. `.es-pl-down` also sets `color`, so re-using the value's
   class looked like it would carry the hue across for free — but the two
   selectors have the same specificity and `.es-kpi-icon` is declared later, so
   its `--es-accent-text` won. The chips rendered a blue glyph on a pale red
   wash: 4.46:1, and wrong in a way that reads as a palette slip rather than a
   cascade one. Two classes beat one, so these rules win outright. */
.es-kpi-icon.es-pl-up {
    background: color-mix(in srgb, var(--es-pos) 12%, transparent);
    color: var(--es-pos);
}

.es-kpi-icon.es-pl-down {
    background: color-mix(in srgb, var(--es-neg) 12%, transparent);
    color: var(--es-neg);
}

.es-kpi-icon.es-health-warn {
    background: color-mix(in srgb, var(--es-warn) 12%, transparent);
    color: var(--es-warn);
}

.es-kpi-icon.es-health-bad {
    background: color-mix(in srgb, var(--es-neg) 12%, transparent);
    color: var(--es-neg);
}

/* --- charts ---------------------------------------------------------------- */
.es-spark { height: 44px; min-width: 0; }

/* ApexCharts measures its parent; without min-width a flex or grid parent can
   never shrink and the chart ratchets the column wider on every resize. */
.es-chart { min-width: 0; }

/* --- tables ----------------------------------------------------------------
   Wide trade tables scroll inside their card rather than widening the page. */
.es-tbl-wrap {
    max-width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
}

.es-tbl { margin-bottom: 0; font-size: var(--es-text-sm); }

.es-tbl > :not(caption) > * > * {
    padding: .55rem .75rem;
    vertical-align: middle;
    border-bottom-color: var(--es-border);
}

.es-tbl thead th {
    font-size: var(--es-text-nano);
    font-weight: 600;
    letter-spacing: var(--es-track-label);
    text-transform: uppercase;
    color: var(--es-text-3);
    white-space: nowrap;
    background: transparent;
    border-bottom: 1px solid var(--es-border);
}

.es-tbl td.es-col-num,
.es-tbl th.es-col-num {
    text-align: right;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* A skipped signal is a filter doing its job, not a failure. It recedes
   rather than alarming. */
.es-tbl tbody tr.es-row-muted td { opacity: .62; }


/* ---- sorting, filtering, paging ----------------------------------------
   Driven by esTable.js. The heading row stays the heading row: sorting adds
   an indicator and a cursor, not a second visual language. */

.es-tbl th.es-sortable {
    cursor: pointer;
    user-select: none;
}
.es-tbl th.es-sortable:hover { color: var(--es-text-2); }
.es-tbl th.es-sortable:focus-visible {
    outline: 2px solid var(--es-accent);
    outline-offset: -2px;
}

/* The indicator holds its width whether or not it is showing a caret, so a
   heading does not shift sideways when the column becomes the sort column. */
.es-sort-ind {
    display: inline-block;
    width: .7em;
    font-size: .75em;
    color: var(--es-text-3);
    opacity: 0;
}
.es-tbl th.es-sortable:hover .es-sort-ind { opacity: .45; }
.es-sort-ind.is-on { opacity: 1; color: var(--es-accent-text); }

/* The per-column filter row. Borderless with a single rule underneath,
   because a bordered form-control-sm leaves about three visible characters in
   the narrowest column on this panel (55px, the Lots column) once its padding
   and border are taken out. This shape gives that column back roughly a third
   of its width. */
.es-tbl-filters th {
    padding: .25rem .4rem .45rem !important;
    background: var(--es-surface-2);
    border-bottom: 1px solid var(--es-border) !important;
}
/* Filled rather than underlined. Underlines were too quiet: the row read as
   an empty band rather than as thirteen inputs, and a filter nobody can see is
   a filter nobody uses. A filled field reads as a field at any size, and it
   still costs less horizontal padding than a bordered form-control-sm. */
.es-tbl-filter {
    width: 100%;
    min-width: 4.5rem;
    padding: .2rem .4rem;
    font-family: var(--es-font-sans);
    font-size: var(--es-text-xs);
    font-weight: 400;
    letter-spacing: normal;
    text-transform: none;
    color: var(--es-text);
    background: var(--es-surface);
    border: 1px solid var(--es-border-strong);
    border-radius: var(--es-radius-sm);
}
.es-tbl-filter::placeholder { color: var(--es-text-3); }
.es-tbl-filter:hover { border-color: var(--es-border-input); }
.es-tbl-filter:focus {
    outline: none;
    border-color: var(--es-accent);
    box-shadow: 0 0 0 2px rgba(var(--es-accent-rgb), .18);
}
/* A column carrying a filter is called out, so an active narrowing is visible
   in the table itself and not only in the chip above it.
   The state is a class esTable owns, not :not(:placeholder-shown) — that
   pseudo-class matches an input with no placeholder attribute at all, so it
   made every field look active, which is the same rule broken the other way
   round. */
.es-tbl-filter.is-on {
    border-color: var(--es-accent);
    background: rgba(var(--es-accent-rgb), .06);
    color: var(--es-accent-text);
    font-weight: 500;
}
.es-col-num .es-tbl-filter { text-align: right; }

/* A table-wide search, for the tables that get one box rather than a row. */
.es-tbl-search { padding: 0 0 .75rem; max-width: 22rem; }

/* Active filters, stated. A total narrowed by an invisible filter is how
   someone concludes they made less money than they did. */
.es-tbl-chips {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .4rem;
    padding: 0 0 .6rem;
}
.es-tbl-count {
    font-size: var(--es-text-xs);
    color: var(--es-text-3);
}
.es-tbl-chips.is-on .es-tbl-count { color: var(--es-text-2); font-weight: 500; }
.es-tbl-chip {
    display: inline-flex;
    align-items: center;
    gap: .3rem;
    padding: .1rem .45rem;
    font-size: var(--es-text-xs);
    color: var(--es-accent-text);
    background: rgba(var(--es-accent-rgb), .10);
    border: 1px solid rgba(var(--es-accent-rgb), .28);
    border-radius: var(--es-radius-pill, 999px);
    cursor: pointer;
}
.es-tbl-chip:hover { background: rgba(var(--es-accent-rgb), .18); }
.es-tbl-chip-k { font-weight: 600; }
.es-tbl-chip-all {
    color: var(--es-text-2);
    background: transparent;
    border-color: var(--es-border-strong);
}

.es-tbl-none td {
    padding: 1.5rem .75rem !important;
    text-align: center;
    color: var(--es-text-3);
    font-size: var(--es-text-sm);
}

/* The pager. Three groups so the row count sits between the size selector and
   the arrows rather than being crowded against one of them. */
.es-tbl-pager {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .75rem;
    padding: .7rem .9rem;
    border-top: 1px solid var(--es-border);
}
.es-tbl-pager-size { display: flex; align-items: center; gap: .4rem; }
.es-tbl-pager-at { margin-left: auto; }
.es-tbl-pager-nav { display: flex; align-items: center; gap: .4rem; }
.es-tbl-pager-of { min-width: 3.5rem; text-align: center; }
.es-tbl-pager .btn { min-width: 2rem; }

.es-select-native {
    padding: .15rem 1.4rem .15rem .4rem;
    font-size: var(--es-text-xs);
    color: var(--es-text);
    background-color: var(--es-surface);
    border: 1px solid var(--es-border-strong);
    border-radius: var(--es-radius-sm);
}
.es-select-native:focus {
    outline: 2px solid var(--es-accent);
    outline-offset: 1px;
}

/* At phone width the pager stacks, and the row count leads rather than being
   pushed to a second line on its own. */
@media (max-width: 575.98px) {
    .es-tbl-pager { gap: .5rem; }
    .es-tbl-pager-at { margin-left: 0; order: -1; width: 100%; }
}

/* Tickets, logins, hosts, servers. JetBrains Mono so 0/O and 1/l cannot be
   misread in a string a client is about to retype into their broker. */
.es-code {
    font-family: var(--es-font-mono);
    font-size: var(--es-text-xs);
    letter-spacing: -0.012em;
    font-feature-settings: "tnum" 1;
}

/* --- status pills ----------------------------------------------------------
   Always icon + label. Green, amber and red collapse under deuteranopia no
   matter how they are picked, so the colour is the third signal here, not the
   first. The tints carry the at-a-glance difference; the text clears AA on its
   own tint in both modes. */
.es-pill {
    display: inline-flex;
    align-items: center;
    gap: .3rem;
    padding: .16rem .48rem;
    border-radius: 999px;
    font-size: var(--es-text-nano);
    font-weight: 600;
    line-height: 1.45;
    white-space: nowrap;
    border: 1px solid transparent;
    letter-spacing: .012em;
}

.es-pill > svg {
    width: .8125rem;
    height: .8125rem;
    display: inline-block;
    flex-shrink: 0;
}

.es-pill-ok   { color: var(--es-pos);  background: color-mix(in srgb, var(--es-pos) 12%, transparent);  border-color: color-mix(in srgb, var(--es-pos) 30%, transparent); }
.es-pill-err  { color: var(--es-neg);  background: color-mix(in srgb, var(--es-neg) 12%, transparent);  border-color: color-mix(in srgb, var(--es-neg) 30%, transparent); }
.es-pill-warn { color: var(--es-warn); background: color-mix(in srgb, var(--es-warn) 12%, transparent); border-color: color-mix(in srgb, var(--es-warn) 30%, transparent); }
.es-pill-info { color: var(--es-info); background: color-mix(in srgb, var(--es-info) 12%, transparent); border-color: color-mix(in srgb, var(--es-info) 30%, transparent); }

/* Order direction: green long, red short — the trading convention, chosen
   deliberately over a dedicated pair.

   These share the money hues, and that is a real cost, stated here so nobody
   "fixes" it later: a short in profit renders a red SELL pill beside its own
   green Net in the same row, and green no longer means only "your balance went
   up". The alternative was measured rather than argued about. Every hue pair
   that could have carried direction on its own collided with an existing
   status colour under protan/deutan/tritan — blue/orange landed dE 1.2 from
   warn, teal/violet dE 2.8 from idle, slate/rust dE 4.7 from info, and all
   three had under 5 L* between long and short, which is the one gap a
   direction pair cannot afford. The palette's five statuses plus the accent
   already occupy the usable space.

   So direction and money share hue, and are kept apart by everything else:

     - the MARK differs. Direction uses `arrow-up` / `arrow-down`; money uses
       `trending-up` / `trending-down`. Same colour, different glyph, and the
       glyph is what says which kind of thing this is.
     - the pill carries the WORD. "BUY" and "SELL" are unambiguous with no
       colour at all, which is what makes sharing the hue survivable.
     - the classes are named for direction rather than reusing `es-pill-ok` and
       `es-pill-err`. They resolve to the same colours today; naming them
       separately means a decision to split them later is one rule, not a
       search through four pages of markup.

   `check-channels.mjs` asserts the pairing (BUY is pos, SELL is neg, never the
   reverse) and that a direction pill never uses the trend marks. */
.es-pill-long {
    color: var(--es-pos);
    background: color-mix(in srgb, var(--es-pos) 12%, transparent);
    border-color: color-mix(in srgb, var(--es-pos) 30%, transparent);
    letter-spacing: .03em;
}

.es-pill-short {
    color: var(--es-neg);
    background: color-mix(in srgb, var(--es-neg) 12%, transparent);
    border-color: color-mix(in srgb, var(--es-neg) 30%, transparent);
    letter-spacing: .03em;
}

/* Idle has no hue on purpose: "offline" and "skipped" are the absence of a
   state, not a state of their own. */
.es-pill-idle {
    color: var(--es-idle);
    background: var(--es-surface-3);
    border-color: var(--es-border);
}

/* Live indicator. Pulses only while the feed is actually up. */
.es-live-dot {
    width: .5rem;
    height: .5rem;
    border-radius: 50%;
    background: var(--es-pos);
    display: inline-block;
    flex-shrink: 0;
    animation: es-pulse 2.4s infinite;
}

.es-live-dot.es-stale { background: var(--es-warn); animation: none; }
.es-live-dot.es-down  { background: var(--es-neg);  animation: none; }

@keyframes es-pulse {
    0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--es-pos) 55%, transparent); }
    70%  { box-shadow: 0 0 0 .4rem color-mix(in srgb, var(--es-pos) 0%, transparent); }
    100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--es-pos) 0%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
    .es-live-dot { animation: none; }
}

/* --- credential fields -----------------------------------------------------
   Passwords are write-only through every echoSignals API, so the UI must never
   imply a stored secret can be read back. These fields are "replace only" and
   say so permanently. */
.es-secret { position: relative; }

.es-secret-note {
    font-size: var(--es-text-xs);
    line-height: 1.45;
    color: var(--es-text-2);
    display: flex;
    align-items: flex-start;
    gap: .35rem;
    margin-top: .4rem;
}

.es-secret-note > svg {
    width: .875rem;
    height: .875rem;
    flex-shrink: 0;
    margin-top: .16rem;
    display: inline-block;
}

/* --- custom dropdown ------------------------------------------------------
   A select replacement, used where an option needs a second line that a native
   <select> cannot show — a broker and a terminal state under an account name. */
.es-select { position: relative; }

.es-select > .es-select-toggle {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .5rem;
    text-align: left;
    background: var(--es-surface);
    border: 1px solid var(--es-border-input);
    border-radius: var(--es-radius-sm);
    padding: .4rem .7rem;
    color: var(--es-text);
    font-size: var(--es-text-base);
    min-height: calc(1.5em + .8rem + 2px);
}

.es-select > .es-select-toggle:hover { border-color: var(--es-border-strong); }

.es-select.is-invalid > .es-select-toggle { border-color: var(--es-neg); }

.es-select-menu {
    max-height: 17rem;
    overflow-y: auto;
    width: 100%;
    min-width: 15rem;
    padding: .3rem;
}

.es-select-menu .dropdown-item {
    display: flex;
    align-items: center;
    gap: .5rem;
    white-space: normal;
    border-radius: var(--es-radius-sm);
    padding: .4rem .55rem;
}

.es-select-menu .dropdown-item small {
    color: var(--es-text-3);
    font-size: var(--es-text-xs);
}

.es-select-menu .dropdown-item.active small,
.es-select-menu .dropdown-item:hover small { color: var(--es-text-2); }

.es-select-menu .dropdown-item > svg { display: inline-block; flex-shrink: 0; }

.es-select-caret { flex-shrink: 0; opacity: .5; display: inline-block; }

/* Inline error under any control, revealed by adding .d-block */
.es-error {
    display: none;
    font-size: var(--es-text-xs);
    color: var(--es-neg);
    margin-top: .3rem;
    line-height: 1.45;
}

.es-error.d-block { display: block; }

/* --- copy-settings ranges -------------------------------------------------- */
.es-range-row { display: flex; align-items: center; gap: .75rem; }

.es-range-row output {
    min-width: 4.5rem;
    text-align: right;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
    font-size: var(--es-text-md);
}

.form-range::-webkit-slider-thumb { background: var(--es-accent); }
.form-range::-moz-range-thumb { background: var(--es-accent); }

/* --- meter ----------------------------------------------------------------- */
.es-meter {
    height: .375rem;
    border-radius: 999px;
    background: var(--es-surface-3);
    overflow: hidden;
}

.es-meter > span {
    display: block;
    height: 100%;
    border-radius: 999px;
    background: var(--es-accent);
    transition: width .4s ease;
}

.es-meter.es-meter-warn > span   { background: var(--es-warn); }
.es-meter.es-meter-danger > span { background: var(--es-neg); }

/* --- empty state ----------------------------------------------------------- */
.es-empty {
    text-align: center;
    padding: 2.5rem 1rem;
    color: var(--es-text-2);
    font-size: var(--es-text-sm);
}

.es-empty > svg {
    width: 1.75rem;
    height: 1.75rem;
    opacity: .38;
    margin-bottom: .75rem;
    display: inline-block;
}

/* --- definition list ------------------------------------------------------- */
.es-dl {
    display: grid;
    grid-template-columns: minmax(7rem, auto) 1fr;
    gap: .45rem 1rem;
    margin: 0;
    font-size: var(--es-text-sm);
    align-items: baseline;
}

.es-dl dt { color: var(--es-text-3); font-weight: 500; }
.es-dl dd { margin: 0; overflow-wrap: anywhere; color: var(--es-text); }

/* An inline pill straight after prose gets no space from inline-flex. */
.es-dl dd > .es-pill:not(:first-child) { margin-left: .3rem; }

@media (max-width: 420px) {
    .es-dl { grid-template-columns: 1fr; gap: .1rem .5rem; }
    .es-dl dd { margin-bottom: .5rem; }
}

.es-hr {
    border: 0;
    border-top: 1px solid var(--es-border);
    margin: 1rem 0;
}

/* --- timeline -------------------------------------------------------------- */
.es-timeline {
    list-style: none;
    margin: 0;
    padding: 0 0 0 1.25rem;
    border-left: 1px solid var(--es-border);
}

.es-timeline > li { position: relative; padding-bottom: 1.1rem; }
.es-timeline > li:last-child { padding-bottom: 0; }

.es-timeline > li::before {
    content: "";
    position: absolute;
    left: -1.6rem;
    top: .4rem;
    width: .4375rem;
    height: .4375rem;
    border-radius: 50%;
    background: var(--es-warn);
    box-shadow: 0 0 0 3px var(--es-surface);
}

.es-timeline > li.es-tl-ok::before   { background: var(--es-pos); }
.es-timeline > li.es-tl-err::before  { background: var(--es-neg); }
.es-timeline > li.es-tl-idle::before { background: var(--es-idle); }

/* --- buttons --------------------------------------------------------------
   Measured, not assumed: inside the template an <svg class="sa-icon">
   computes to display:block. A block-level icon in a block-level button takes
   a line of its own, so an icon+label button renders as two stacked lines —
   and `white-space: nowrap` cannot help, because the break is a block boundary
   and not a text wrap.

   Scoped to icons carrying a margin utility, because that is precisely the
   "there is a label next to me" case. Deliberately NOT applied to icon-only
   buttons: the header's bell, cog and theme toggles position a .badge-icon
   absolutely against the button's content box, and changing the icon's display
   moves that box out from under the badge. */
.btn { white-space: nowrap; }

.btn > .sa-icon.me-1,
.btn > .sa-icon.me-2,
.btn > .sa-icon.ms-1 {
    display: inline-block;
    flex-shrink: 0;
    vertical-align: -0.15em;
}

/* The template sets --sa-icon-size to 1.25rem (20px) globally, which is right
   for the header's standalone icon buttons and much too large next to a 13px
   button label: the glyph is taller than the line box, so it rides above the
   text and reads as a wrapped second line even though the button is a single
   line. Icons are sized to the text they sit beside. */
.btn > .sa-icon { --sa-icon-size: 1rem; }
.btn-sm > .sa-icon { --sa-icon-size: 0.9375rem; }
.btn-lg > .sa-icon { --sa-icon-size: 1.125rem; }
.input-group-text > .sa-icon { --sa-icon-size: 1rem; }
.es-secret-note > svg, .es-dl dd > svg { --sa-icon-size: 0.875rem; }
.es-timeline .sa-icon { --sa-icon-size: 0.9375rem; }
.alert > .sa-icon { --sa-icon-size: 1.0625rem; }

.input-group-text > svg { display: inline-block; flex-shrink: 0; }

/* --- icons paint from currentColor -----------------------------------------
   THE reason every icon in this panel was grey, and the thing that had to be
   fixed before any of the colour work below could take effect.

   SmartAdmin paints its icons from its own variables, not from `color`:

       .sa-icon { --sa-icon-color: rgb(155 155 155);
                  --sa-icon-fill:  rgb(219 219 219 / .3);
                  stroke: var(--sa-icon-color);
                  fill:   var(--sa-icon-fill); }

   Both are literals, and they are declared ON `.sa-icon`, so nothing set
   further up reaches them. Every icon therefore stroked the same grey whatever
   colour it inherited — a "Copied" pill measured `color: #296E3D` and stroked
   #9B9B9B, so the mark inside a green pill was grey, and had been since the
   first build. The same went for the P&L trend marks, the KPI chips, the
   status marks and the sidebar. Measuring `color` reported all of these as
   correctly coloured, which is why it took a screenshot to see: the property
   being measured was not the property doing the painting.

   Redefined here at the same level, so the icons follow `color` like any other
   glyph and every semantic class in this file actually lands. The template's
   30% fill is kept as a tint of the same colour rather than dropped — it is
   what gives these outline icons their weight — so a green mark now carries a
   pale green fill instead of a grey one. `check-icons.mjs` measures `stroke`,
   not `color`, for exactly the reason above. */
.sa-icon {
    --sa-icon-color: currentColor;
    --sa-icon-fill: color-mix(in srgb, currentColor 30%, transparent);
}

/* --- icon colour -----------------------------------------------------------
   An icon inherits the colour of whatever it sits in, which meant almost every
   icon in the panel rendered in body or muted grey. Measured across all 17
   pages: 156 sidebar marks, 96 inline marks and 43 table-cell marks all
   neutral. The notification feed was the clearest case — a failure's
   `x-circle` rendered near-black immediately beside its own red "New" pill, so
   the two marks for one fact disagreed.

   These classes exist so an icon can state what it means. They are not
   decoration: the channels are the same ones the figures use, so an icon never
   introduces a hue the panel has not already defined —

     ok / warn / bad   a state, matching the pill that names it
     info              informational, and the credential story's own mark
     mark              a section's own icon; the accent, because a section
                       heading is a place you go rather than a state
     muted             deliberately recessed (an empty state, a device type)

   A single class is enough even inside a table cell: an inherited colour loses
   to any declaration on the element itself, so unlike the P&L classes these
   need no `td` form. */
/* A disabled dropdown option rendered #adb5bd — the one colour in the menu
   that did not follow the theme. Found by measuring icon colour and getting
   back a hex that is in no ramp: the unassignable proxy in the account picker.

   Disabled SHOULD be grey; it should be the theme's grey. Setting the token
   does not achieve that, which cost two wrong attempts. Bootstrap reads
   `--bs-dropdown-link-disabled-color`, but SmartAdmin then restates the whole
   rule as `color: var(--bs-gray-500)`, so the token is never consulted. The
   only thing that lands is restating the rule back — the same situation as the
   handful of template rules that carry literal hex and no token can reach
   (see the theme notes, finding 4). Equal specificity, and this stylesheet
   loads last. */
.dropdown-item.disabled,
.dropdown-item:disabled { color: var(--es-text-3); }

.es-ic-ok    { color: var(--es-pos); }
.es-ic-warn  { color: var(--es-warn); }
.es-ic-bad   { color: var(--es-neg); }
.es-ic-info  { color: var(--es-info); }
.es-ic-mark  { color: var(--es-accent-text); }
.es-ic-muted { color: var(--es-text-3); }

/* The sidebar's marks take the accent while their labels stay muted. Icon and
   label were both `--es-text-2`, which is what made the rail read as a wall of
   grey; colouring the label too would shout, and the active item needs
   somewhere left to go. The accent is right here on its own terms — every one
   of these is a link. */
.app-sidebar .nav-menu li > a > svg { color: var(--es-accent-text); }

/* A timeline entry's icon follows its own dot. The dots were already coloured
   by state and the icons beside them were not, so a green dot sat next to a
   near-black check. */
.es-timeline > li svg                { color: var(--es-warn); }
.es-timeline > li.es-tl-ok   svg     { color: var(--es-pos); }
.es-timeline > li.es-tl-err  svg     { color: var(--es-neg); }
.es-timeline > li.es-tl-idle svg     { color: var(--es-idle); }

/* --- terminal hosting ------------------------------------------------------
   The recurring cost of one hosted terminal, on the terminal's own card. It
   sits between the body and the footer rather than in either: it is neither a
   health figure nor an action, and putting a monthly charge in with the health
   stats is how a client stops seeing it. */
.es-hosting {
    padding: .625rem .875rem;
    border-top: 1px solid var(--es-border);
    background: var(--es-surface-2);
    font-size: var(--es-text-sm);
}

/* An unpaid terminal has a deletion date on it. The tint is the difference
   between a card the eye skips and one it stops at. */
.es-hosting.is-at-risk {
    background: color-mix(in srgb, var(--es-neg) 6%, var(--es-surface-2));
    border-top-color: color-mix(in srgb, var(--es-neg) 30%, transparent);
}

/* Consequences of a destructive action, one per line with its own mark. A
   paragraph gets skimmed; four lines each with an icon get counted. */
/* --- an identifier that opens its own detail -------------------------------
   A ticket number in a table cell that opens that trade's dialog. It has to be
   a <button> — a click target that is not a link and not a form control is
   unreachable by keyboard and invisible to a screen reader — but it must not
   look like one, because a cell of buttons reads as a toolbar and a money
   table has enough going on.

   So: button semantics, text appearance. The dotted underline is the only
   affordance, and it is dotted rather than solid so it is not mistaken for a
   navigation link: this opens a dialog and stays on the page. */
.es-linkish {
    appearance: none;
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    color: inherit;
    text-align: inherit;
    cursor: pointer;
    border-bottom: 1px dotted var(--es-text-3);
}

.es-linkish:hover,
.es-linkish:focus-visible {
    color: var(--es-accent-text);
    border-bottom-color: currentColor;
}

/* --- confirm dialog surface ------------------------------------------------
   A dialog has to sit on the same surface as a card, because every colour on
   this panel was measured against that surface and nothing was measured
   against another one.

   It did not. SmartAdmin redefines the token on the element that reads it —
   `.modal { --bs-modal-bg: var(--bs-body-bg) }` and
   `[data-bs-theme=dark] .modal-content { --bs-modal-bg: #373b3f }` — so the
   theme's `:root` value never reaches a dialog whatever the source order:
   light dialogs came out body-grey and dark ones a lifted #373b3f that no
   other element in the panel uses. On that lighter surface the `es-ic-bad`
   mark measured 4.19:1, under AA, in the last thing a client reads before a
   deletion or a charge. Same shape as the disabled dropdown item above: the
   only fix is restating the declaration where it is read. */
.modal,
.modal-content,
[data-bs-theme="dark"] .modal,
[data-bs-theme="dark"] .modal-content {
    --bs-modal-bg: var(--es-surface);
}

.es-facts {
    list-style: none;
    margin: 0 0 1rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: .55rem;
}

.es-facts > li {
    display: flex;
    align-items: flex-start;
    gap: .5rem;
    font-size: var(--es-text-sm);
    line-height: 1.45;
}

.es-facts > li > svg {
    flex-shrink: 0;
    margin-top: .15rem;
}

/* --- deposit address -------------------------------------------------------
   The one component on this panel where getting it wrong costs the client
   their money rather than their patience. USDT sent to the right address on
   the wrong chain is gone, and no one — not the platform, not the provider,
   not the chain — can return it.

   So the layout is built to make the address and its network inseparable:
   they share a bordered block, the network is stated twice (as we name it and
   as the client's own wallet names it), and the warning is inside the same
   block rather than once at the top of the card. Two networks are rendered
   side by side with no selector, because a selector is state that can
   disagree with what is beside it. */
.es-deposit {
    display: flex;
    flex-direction: column;
    gap: .75rem;
    padding: .875rem;
    border: 1px solid var(--es-border-strong);
    border-radius: var(--es-radius-sm);
    background: var(--es-surface-2);
    height: 100%;
}

.es-deposit-head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .5rem;
}

.es-deposit-body {
    display: flex;
    gap: .875rem;
    align-items: flex-start;
}

.es-deposit-addr {
    display: flex;
    flex-direction: column;
    gap: .35rem;
    min-width: 0;
    flex: 1 1 auto;
}

/* A saved WITHDRAWAL address. It borrows the deposit block's bordered plate,
   because the thing being framed is the same — a string that money moves to,
   which has to be readable in full — but it is deliberately NOT .es-deposit.

   That class carries `height: 100%`, which is right for two deposit addresses
   sitting side by side in one row and wrong for a list: stacked, every entry
   claimed the full height of the card and the list overflowed it. The list
   also has no QR and no per-block warning, so the deposit block's internal
   layout is weight it does not use. */
.es-saved-addr {
    display: flex;
    flex-direction: column;
    gap: .5rem;
    padding: .75rem .875rem;
    border: 1px solid var(--es-border-strong);
    border-radius: var(--es-radius-sm);
    background: var(--es-surface-2);
}

.es-saved-addr + .es-saved-addr { margin-top: .75rem; }

.es-saved-addr-head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .5rem;
}

/* White plate behind the QR in both modes. A QR inverted for dark mode scans
   unreliably on a fair number of phone cameras, and this is not a place to
   find that out. */
.es-qr {
    flex-shrink: 0;
    width: 132px;
    height: 132px;
    border-radius: var(--es-radius-sm);
    background: #FFFFFF;
    padding: 4px;
    border: 1px solid var(--es-border);
}

/* The address itself: mono, and wrapping to however many lines it needs.

   Deliberately NOT a form control. An <input> is single-line and ellipsized
   the 42-character ERC-20 address; a <textarea> needs a row count that is
   correct at one viewport width and clips at another. Both hide the exact
   characters the client is checking against their own wallet, which is the
   only thing this field is for. A block element cannot clip at any width, and
   `user-select: all` gives one click to select the whole thing. */
.es-addr {
    font-size: var(--es-text-xs);
    line-height: 1.5;
    word-break: break-all;
    overflow-wrap: anywhere;
    padding: .4rem .5rem;
    background: var(--es-surface);
    border: 1px solid var(--es-border-strong);
    border-radius: var(--es-radius-sm);
    user-select: all;
    -webkit-user-select: all;
    cursor: text;
}

.es-addr:focus-visible {
    outline: 2px solid var(--es-accent);
    outline-offset: 1px;
}

.es-copy.es-copied {
    color: var(--es-pos);
    border-color: color-mix(in srgb, var(--es-pos) 45%, transparent);
}

.es-dl-tight dt, .es-dl-tight dd { padding-block: .2rem; }

/* A funding method that exists but is switched off. Shown rather than removed:
   the client has been told cards are coming, and an absent option reads as an
   option that does not exist. Muted throughout so it cannot be mistaken for
   something that will respond to a click. */
.es-offer {
    display: flex;
    align-items: center;
    gap: .75rem;
    padding: .625rem .75rem;
    border: 1px dashed var(--es-border-strong);
    border-radius: var(--es-radius-sm);
}

.es-offer.is-off { background: var(--es-surface-3); }
.es-offer.is-off .fw-500 { color: var(--es-text-2); }

.es-confirming + .es-confirming {
    margin-top: .875rem;
    padding-top: .875rem;
    border-top: 1px solid var(--es-border);
}

@media (max-width: 400px) {
    /* The QR stacks above the address rather than squeezing it: below this
       width the side-by-side layout leaves the address three characters
       wide. */
    .es-deposit-body { flex-direction: column; align-items: stretch; }
    .es-qr { align-self: center; }
}

/* --- card grid -------------------------------------------------------------
   One 1.5rem gap in every direction between one card and the next.

   The canvas is one shade off the card surface (#F4F6F8 against #FFFFFF), so
   the gap between two cards carries almost no contrast of its own. At 1rem
   four KPI cards read as one banded strip divided by hairlines rather than as
   four separate objects; 1.5rem, which is Bootstrap's own `.row` default,
   resolves it at every width measured. 2rem was tried and is
   indistinguishable at 1440px while costing 12px off each card in a four-up
   row — enough to wrap a KPI's secondary line.

   Between rows the gap was **zero**, and the reason is worth writing down
   because the markup gives no hint of it. Bootstrap implements the vertical
   gutter as

       .row   { margin-top: calc(-1 * var(--bs-gutter-y)) }
       .row > * { margin-top: var(--bs-gutter-y) }

   — a negative margin on the row cancelled by a positive one on each column.
   The arithmetic nets to zero at the row's top edge, which is the point: it
   spaces WRAPPED LINES inside one row and contributes nothing between one row
   and the next. So two sibling rows of cards touched, separated by their 1px
   borders alone, and the negative margin also swallowed the whole 16px bottom
   margin of any banner above the first row. Raising `--bs-gutter-y` could not
   have fixed it; it would have widened the wrapped-line gap and left the seam
   between rows at zero.

   Hence `--bs-gutter-y: 0` and no negative margin at all. `row-gap` does the
   wrapped-line job directly — `.row` is a wrapping flexbox, so it is the
   modern spelling of the same intent — and `margin-bottom` does the row-to-row
   job, where with the negative margin gone it now renders at its face value.
   Horizontal spacing stays on `--bs-gutter-x`, which is column padding rather
   than a gap, so the two do not interact.

   This is a class rather than `gx-4 gy-0` on every row so the value lives in
   one place, and rather than `.row:has(> * > .card)` so the markup says what
   it is. `check-cards.mjs` measures the gap that actually renders in both
   directions, because every failure here is silent. */
.row.es-cards {
    --bs-gutter-x: 1.5rem;
    --bs-gutter-y: 0;
    row-gap: 1.5rem;
    margin-bottom: 1.5rem;
}

/* Everything else at page level joins the same rhythm: a banner, and a card
   that stands on its own rather than inside a row. Neither carried usable
   spacing before, because whatever margin they had was eaten by the negative
   top margin of the row beneath them — a bare card above a table row measured
   0px to it.

   Scoped to `.main-content >` so only page-level siblings are affected; an
   alert or a card nested inside a card body keeps its own spacing. A `mb-*`
   utility still overrides this, since Bootstrap's utilities carry
   `!important` — which is the right precedence: a page asking for different
   spacing on one element should get it. */
.main-content > .alert,
.main-content > .card { margin-bottom: 1.5rem; }

/* --- responsive guards ----------------------------------------------------
   Grid columns are flex items; without min-width:0 a wide child (a table, a
   chart) grows the track instead of scrolling inside it. */
.row > [class*="col-"] { min-width: 0; }

@media (max-width: 575.98px) {
    .es-num-xl { font-size: var(--es-text-2xl); }
    .es-hide-xs { display: none !important; }
}
