/* Archivo 800, the marketing site's heading face, self-hosted rather than pulled from Google Fonts:
   the wordmark is the only thing that uses it, and a render-blocking third-party request for five
   glyphs is not worth it. Latin subset only, matching the unicode-range Google serves. */
@font-face {
    font-family: 'Archivo';
    font-style: normal;
    font-weight: 800;
    font-display: swap;
    src: url('fonts/archivo-800-latin.woff2') format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
                   U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
                   U+2212, U+2215, U+FEFF, U+FFFD;
}

html, body {
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

a, .btn-link {
    color: #006bb7;
}

.btn-primary {
    color: #fff;
    background-color: #1b6ec2;
    border-color: #1861ac;
}

.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
  box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}

.content {
    padding-top: 1.1rem;
}

h1:focus {
    outline: none;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid #26b050;
}

.invalid {
    outline: 1px solid #e50000;
}

.validation-message {
    color: #e50000;
}

.blazor-error-boundary {
    background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
    padding: 1rem 1rem 1rem 3.7rem;
    color: white;
}

    .blazor-error-boundary::after {
        content: "An error has occurred."
    }

.darker-border-checkbox.form-check-input {
    border-color: #929292;
}

/* Authentication Forms */
.auth-card {
    max-width: 400px;
    margin: 0 auto;
}

.auth-form .form-control-sm {
    padding: 0.375rem 0.75rem;
}

.auth-form .form-label.small {
    font-weight: 500;
    margin-bottom: 0.25rem;
}

.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
    color: var(--bs-secondary-color);
    text-align: end;
}

.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
    text-align: start;
}

/* White/ghost button used in filter bars */
.btn-white {
    background-color: var(--bs-body-bg);
    color: var(--bs-body-color);
    border-color: var(--bs-border-color);
    transition: all 0.15s ease-in-out;
}

.btn-white:hover {
    background-color: var(--bs-secondary-bg);
    border-color: var(--bs-border-color);
    color: var(--bs-body-color);
}

.btn-white:active,
.btn-white.active {
    background-color: var(--bs-tertiary-bg);
    border-color: var(--bs-border-color);
    color: var(--bs-body-color);
}

/* Spin animation for loading indicators */
.spin {
    animation: spin 1s linear infinite;
}

@keyframes spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
}

/* Custom badge colors */
.bg-warning-orange {
    background-color: #fd7e14 !important;
    color: #000 !important;
}

/* Sortable table headers */
th.sortable {
    user-select: none;
    transition: background-color 0.15s ease-in-out;
}

th.sortable:hover {
    background-color: rgba(0, 0, 0, 0.03);
}

th.sortable i {
    font-size: 0.75rem;
    opacity: 0.7;
}

/* Compact status-dot indicator column used in session list tables */
.status-indicator-col {
    width: 24px;
    min-width: 24px;
    padding-left: 0.5rem !important;
    padding-right: 0.25rem !important;
}

/* Environment and Framework columns in session list tables */
.session-col-environment {
    max-width: 120px;
}

.session-col-framework {
    max-width: 140px;
}

/*
 * Layout shell: keeps the footer at the bottom of the viewport.
 *
 * MudLayout ships as display:block with height:100%, which resolves to nothing when its
 * ancestors are content-height — so the main content stops at the end of the page content and
 * the footer floats up mid-screen on short pages. Making the shell a viewport-tall flex column
 * fixes that: .mud-main-content already declares flex: 1 1 auto, and the container around
 * @Body carries flex-grow-1, so the slack lands above the footer.
 *
 * These target classes set from MainLayout.razor rather than MudBlazor's own, so a MudBlazor
 * upgrade cannot silently change what is being overridden.
 */
.app-shell {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}

/*
 * Sticky rather than fixed: the footer keeps the main content's box, so it follows the drawer
 * in and out and needs no width or left-offset maths of its own. It stays pinned to the bottom
 * of the viewport while long pages scroll, and settles into place at the end of short ones.
 */
.app-footer {
    position: sticky;
    bottom: 0;
    /* Background, not surface: surface is the card colour, which would read as a floating strip
       over the page rather than as part of it. The border replaces the MudDivider that used to
       sit inside the footer. */
    background-color: var(--mud-palette-background);
    border-top: 1px solid var(--mud-palette-lines-default);
}

/* ==========================================================================
   Xping design tokens that MudBlazor's palette has no slot for
   ==========================================================================

   Two families: the accent and danger *tints* (a surface colour paired with the accent text
   colour, which Palette models as a single value), and the sparkline colours, which the handoff
   deliberately sets apart from Success/Warning/Error.

   MudBlazor emits one :root{--mud-palette-*} block and swaps it wholesale, so there is no
   .mud-theme-dark class to hang these on. MainLayout puts .xping-dark / .xping-light on MudLayout
   instead. The dark set is repeated on :root because popovers are teleported to
   MudPopoverProvider, which sits outside MudLayout and would otherwise not resolve them. */

:root,
.xping-dark {
    --xping-accent-bg: #1c2b57;
    --xping-danger-bg: #3a1d1d;
    --xping-success-bg: #1d3a23;
    --xping-warning-bg: #3a2f1a;
    --xping-neutral-bg: #2a2a2e;
    --xping-spark-pass: #639922;
    --xping-spark-degraded: #EF9F27;
    --xping-spark-fail: #E24B4A;
    --xping-gutter-bg: rgba(255, 255, 255, 0.03);
    /* One step up from the page ground, for bands that carry a caveat rather than a value:
       the execution page's staleness line, and the mono block its failure panel prints a stack
       trace into. Distinct from --xping-neutral-bg, which tints a pill to say "no verdict". */
    --xping-surface-1: #232326;
}

.xping-light {
    --xping-accent-bg: #e8eefc;
    --xping-danger-bg: #fdecec;
    --xping-success-bg: #eaf6ec;
    --xping-warning-bg: #fdf3e3;
    --xping-neutral-bg: #f0f0f2;
    /* The handoff's gutter is a white wash over a dark surface; on a light one the same separation
       has to come from a black wash, since a lighter white is the surface itself. */
    --xping-gutter-bg: rgba(0, 0, 0, 0.03);
    /* Darker than the light surface rather than lighter, for the same reason the gutter inverts:
       on a light ground, separation cannot come from adding more white. */
    --xping-surface-1: #f4f4f6;
}

/* ==========================================================================
   App shell — the design handoff's sidebar and top bar
   ==========================================================================

   These live here rather than in a MainLayout.razor.css because scoped CSS only stamps its
   attribute onto MainLayout's own elements. Every rule below targets a class MudBlazor renders
   inside a child component, so scoping would force ::deep on all of them. */

/* The handoff separates the sidebar with a 1px border rather than a shadow. */
.xping-drawer.mud-drawer {
    border-inline-end: 1px solid var(--mud-palette-lines-default);
}

/* 10px inline rather than the handoff's 14px, so the brand block shares the nav's gutter below
   it: the switcher is a control in the same column as the nav rows and has to measure the same
   as them. It moves the lockup 4px toward the edge, which also lines the mark up with that column
   instead of floating inside it. */
.xping-drawer-brand {
    padding: 16px 10px 12px;
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

.xping-drawer-nav {
    padding: 12px 10px;
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
}

.xping-drawer-footer {
    padding: 12px 10px;
    border-top: 1px solid var(--mud-palette-lines-default);
}

/* Brand lockup, matching xping.io's navbar: 26px mark + 10px gap + Archivo 800 wordmark. */
.xping-brand-lockup {
    display: flex;
    align-items: center;
    gap: 10px;
}

.xping-brand-mark {
    flex: 0 0 auto;
    width: 26px;
    height: 26px;
    object-fit: contain;
}

.xping-brand-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.xping-wordmark {
    font-family: 'Archivo', var(--mud-typography-default-family, sans-serif);
    font-size: 18px;
    font-weight: 800;
    letter-spacing: 0.01em;
    /* Tight enough that the two lines read as one lockup. Archivo's caps leave a little room
       above the baseline at 1, so the pair does not actually touch. */
    line-height: 1;
    /* The site paints this near-white on its dark navbar; here it follows the theme so the
       wordmark stays legible in light mode. */
    color: var(--mud-palette-text-primary);
}

/* Replaces the "pilot" pill. Small and muted so it reads as a subtitle under the wordmark rather
   than competing with it. */
.xping-brand-subtitle {
    font-size: 10px;
    font-weight: 500;
    letter-spacing: 0.02em;
    line-height: 1;
    color: var(--mud-palette-primary);
}

/* Inline trailing pill — deliberately not MudBadge, which is an absolutely positioned corner
   overlay rather than something that sits in the row's flow. */
.xping-pill {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    border-radius: 5px;
    padding: 2px 6px;
    font-size: 10px;
    line-height: 1.4;
    font-weight: 500;
    white-space: nowrap;
}

/* MudIcon sizes its svg as 1em, so the pill's own font-size would shrink a nested icon to the
   label's height and lose the line work. Sizing it here keeps the glyph legible against 11px text
   without the label having to grow to meet it. */
.xping-pill__icon {
    width: 17px;
    height: 17px;
    font-size: 17px;
    flex-shrink: 0;
}

.xping-pill--danger {
    font-size: 11px;
    padding: 1px 6px;
    color: var(--mud-palette-error);
    background-color: var(--xping-danger-bg);
}

/* Tone variants, paired with ScoreVisualHelper.GetScoreCategoryPillClass. Each carries its meaning
   in the label as well as the tint, so the category survives being read in greyscale — the score
   beside it is coloured and nothing else. */
.xping-pill--negative {
    color: var(--mud-palette-error);
    background-color: var(--xping-danger-bg);
}

.xping-pill--warning {
    color: var(--mud-palette-warning);
    background-color: var(--xping-warning-bg);
}

.xping-pill--positive {
    color: var(--mud-palette-success);
    background-color: var(--xping-success-bg);
}

.xping-pill--neutral {
    color: var(--mud-palette-text-secondary);
    background-color: var(--xping-neutral-bg);
}

.xping-workspace-switcher {
    display: flex;
    align-items: center;
    gap: 7px;
    width: 100%;
    padding: 7px 9px;
    background-color: var(--mud-palette-surface);
    border: 1px solid var(--mud-palette-lines-default);
    border-radius: 8px;
    cursor: pointer;
    text-align: start;
}

.xping-workspace-switcher:hover {
    background-color: var(--mud-palette-action-disabled-background);
}

/* MudMenu's FullWidth sizes the popover, not the activator, so the wrapper it renders around
   ActivatorContent shrink-wrapped and left the switcher row narrower than the nav beneath it.
   Nothing below can fix it — width:100% on the switcher only ever resolved against this box. */
.xping-sidebar-menu.mud-menu {
    display: block;
    width: 100%;
}

/* Matched to MudNavLink's own glyph so the switcher lines up with the rows under it, in the rail
   and expanded alike. MudIcon's Medium is 24px, which is the nav icon size; this pins the box so
   the label starts on the nav's text column rather than 2px off it. */
.xping-workspace-icon.mud-icon-root {
    flex: 0 0 auto;
    color: var(--mud-palette-text-secondary);
}

.xping-workspace-name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.xping-nav-section {
    padding: 14px 9px 6px;
}

/* MudBlazor's own active rule for the default menu colour is
   `.mud-navmenu.mud-navmenu-default .mud-nav-link.active:not(.mud-nav-link-disabled)`, setting
   `background: var(--mud-palette-action-default-hover)` — grey at 6% opacity, far too faint
   against the handoff's solid tint. It also draws a 2px inline-end marker the design does not have.

   Keyed on aria-current rather than on Mud's .active class, for two reasons. NavLink writes both
   together when its own Href matches, so nothing is lost. And a row can be current on a route its
   Href cannot match — Sessions, while the reader is inside a run at /projects/{slug}/sessions/{key}
   — which the layout states by passing aria-current through; a Class of "active" does not survive
   MudNavLink, which composes its own class list. The attribute is also the accessible truth, so
   styling from it means the highlight and the announcement cannot disagree.

   .mud-navmenu is on the selector to match Mud's specificity, not to narrow it: without it ours is
   one class lighter and loses outright, which is why this tint never painted and every active row
   was showing Mud's grey wash. At equal specificity app.css wins on order — it is loaded after
   MudBlazor.min.css in App.razor. */
.mud-navmenu.xping-nav .mud-nav-link[aria-current="page"]:not(.mud-nav-link-disabled) {
    color: var(--mud-palette-primary);
    background-color: var(--xping-accent-bg);
    border-inline-end: none;
}

/* Mud restates its own wash on hover and focus of an active row, at a specificity the rule above
   does not reach. Without this the tint drops out from under the pointer on the one row the
   reader is most likely to point at. */
.mud-navmenu.xping-nav .mud-nav-link[aria-current="page"]:not(.mud-nav-link-disabled):hover:not(.mud-nav-link-disabled),
.mud-navmenu.xping-nav .mud-nav-link[aria-current="page"]:not(.mud-nav-link-disabled):focus-visible:not(.mud-nav-link-disabled),
.mud-navmenu.xping-nav .mud-nav-link[aria-current="page"]:not(.mud-nav-link-disabled):active:not(.mud-nav-link-disabled) {
    background-color: var(--xping-accent-bg);
}

/* Insights and Flaky have no routes yet. Mud's default disabled treatment reads as "broken";
   the inactive nav colour reads as "not yet". */
.xping-nav .mud-nav-link.mud-nav-link-disabled {
    color: var(--mud-palette-text-secondary);
    opacity: 1;
    cursor: default;
}

/* Lets a nav row push a trailing badge to its end. */
.xping-nav .mud-nav-link .mud-nav-link-text {
    display: flex;
    align-items: center;
    width: 100%;
}

.xping-nav-trailing {
    margin-inline-start: auto;
}

/* ==========================================================================
   Collapsed sidebar (the rail)
   ==========================================================================

   MudBlazor ships mini-drawer rules, but they only reach a MudNavMenu that is a *direct* child
   of .mud-drawer-content. Ours sits inside .xping-drawer-nav, which owns the nav's padding and
   its scroll, so none of them apply and every rule below is ours. They would not have covered
   the brand block, the switcher, the account row or the section headings in any case.

   .xping-drawer--rail is set from MainLayout, not read off .mud-drawer--closed.mud-drawer-mini,
   for the same reason as the rules above: an upgrade must not be able to silently change what is
   being overridden. Below md a closed drawer is a hidden overlay rather than a rail, so these
   rules are still set down there and simply paint nothing visible. */

/* The two sidebar controls, split at the drawer's own 960px breakpoint: the app bar's hamburger
   below it, where the drawer is an overlay, and the brand row's collapse toggle above it, where
   there is a rail to collapse to. Exactly one is ever on screen.

   Deliberately not d-md-none / d-md-inline-flex. Bootstrap and MudBlazor both ship those classes
   and disagree on md — 768px against 960px — so the utility that reads as "hide below the
   drawer's breakpoint" actually hid the hamburger from 768px up, stranding 768–960px with an
   overlay drawer and nothing to open it. These are ours and track Breakpoint.Md in MainLayout. */
@media (min-width: 960px) {
    .xping-appbar-toggle {
        display: none;
    }
}

/* Qualified with .mud-tooltip-root, which is not decoration: MudTooltip's own
   `.mud-tooltip-root.mud-tooltip-inline` is two classes and would otherwise outrank a bare
   .xping-rail-toggle and keep the button on screen. Matching its count is enough — app.css
   loads after MudBlazor's, so an equal-specificity tie resolves here. */
@media (max-width: 959.98px) {
    .mud-tooltip-root.xping-rail-toggle {
        display: none;
    }
}

/* The row the lockup and the collapse toggle share while there is width for both. */
.xping-brand-row {
    display: flex;
    align-items: center;
    gap: 8px;
}

/* Pushed to the sidebar's trailing edge rather than sitting against the wordmark, so the toggle
   keeps a fixed home the eye can return to instead of drifting with the brand's width. */
.mud-tooltip-root.xping-rail-toggle {
    margin-inline-start: auto;
}

/* 64px of rail, less the brand block's gutters, does not hold a 26px mark and a 30px button at
   once — so the lockup gives way and the toggle takes the row. Getting the brand back is one
   click, and Overview below it still goes home. */
.xping-drawer--rail .xping-brand-lockup {
    display: none;
}

.xping-drawer--rail .xping-brand-row {
    justify-content: center;
}

/* With the lockup gone the toggle is the row's only child, and an auto start-margin would beat
   the centring above and pin it to the edge instead. */
.xping-drawer--rail .mud-tooltip-root.xping-rail-toggle {
    margin-inline-start: 0;
}

.xping-drawer--rail .xping-drawer-brand {
    padding-inline: 8px;
}

.xping-drawer--rail .xping-drawer-nav,
.xping-drawer--rail .xping-drawer-footer {
    padding-inline: 8px;
}

/* Three classes deep, to outrank the display:flex the expanded nav puts on the same element. */
.xping-drawer--rail .xping-nav .mud-nav-link .mud-nav-link-text {
    display: none;
}

/* The switcher's name and its trailing affordance go the same way — the avatar is the whole
   control at this width, and the tooltip carries the name. */
.xping-drawer--rail .xping-workspace-name,
.xping-drawer--rail .xping-workspace-switcher > .mud-icon-root:last-child {
    display: none;
}

/* Centre what is left. The nav link keeps its own vertical padding; only the inline padding has
   to go, or the icon sits off to one side of a 48px row. */
.xping-drawer--rail .xping-nav .mud-nav-link,
.xping-drawer--rail .xping-workspace-switcher {
    justify-content: center;
    padding-inline: 0;
}

/* The switcher's framed box is the handoff's, and it earns its border while it is a labelled
   control. Reduced to a glyph it would be the only bordered thing in a column of bare icons, so
   the framing goes and the row reads like the nav. The hover tint above still answers the click. */
.xping-drawer--rail .xping-workspace-switcher {
    background-color: transparent;
    border-color: transparent;
}

/* The headings cannot be read at this width, but the grouping still has to survive or Analyse
   and Configure run together into one undifferentiated column of icons. The label collapses to
   the rule that was implicitly under it. */
.xping-drawer--rail .xping-nav-section {
    height: 0;
    padding: 0;
    margin: 10px 12px;
    overflow: hidden;
    border-top: 1px solid var(--mud-palette-lines-default);
}

/* MudTooltip's root is an inline-block wrapper, which would shrink the row it wraps to the width
   of its icon — the hover target would then stop matching the highlight, and the active tint
   would no longer span the nav. Qualified to two classes to outrank the
   `.mud-tooltip-root.mud-tooltip-inline` rule setting that inline-block. */
.mud-tooltip-root.xping-rail-tooltip {
    display: block;
    width: 100%;
}

/* Flat and bordered rather than elevated, per the handoff. */
.xping-appbar.mud-appbar {
    background-color: var(--mud-palette-background);
    border-bottom: 1px solid var(--mud-palette-lines-default);
    box-shadow: none;
}

.xping-appbar .mud-toolbar {
    padding: 0 20px;
    gap: 8px;
}

/* An ancestor crumb is a link; the crumb naming the current page is not. The theme's AppbarText is
   primary text, so a Color.Inherit link came out byte-identical to the plain text beside it and the
   trail read as one flat label — nothing said the earlier segments could be clicked.

   The links are demoted rather than the current crumb promoted: "Workspace" already leads the trail
   in secondary, and the page you are on should be the emphatic end of it. Hover restores primary,
   which together with MudLink's Underline.Hover is the affordance.

   !important is not decoration: Color.Inherit renders `.mud-inherit-text { color: inherit
   !important }`, which nothing without it can outrank. */
.xping-appbar .xping-crumb-link {
    color: var(--mud-palette-text-secondary) !important;
}

.xping-appbar .xping-crumb-link:hover,
.xping-appbar .xping-crumb-link:focus-visible {
    color: var(--mud-palette-text-primary) !important;
}

/* The account menu sits at the end of the toolbar, after the spacer. Deliberately not
   .xping-sidebar-menu: that rule stretches the menu to its column, which here is the whole bar.
   MudMenu's own wrapper is what the toolbar lays out, so the sizing has to be pinned on it. */
.xping-topbar-account.mud-menu {
    flex: 0 0 auto;
    min-width: 0;
}

/* Bare rather than framed, unlike the sidebar's switcher: the toolbar is already bordered below and
   a second box inside it would read as a field next to the real one. The hover tint answers the
   click instead. */
.xping-topbar-account-activator {
    display: flex;
    align-items: center;
    gap: 7px;
    max-width: 220px;
    padding: 4px 8px;
    border-radius: 8px;
    cursor: pointer;
}

.xping-topbar-account-activator:hover {
    background-color: var(--mud-palette-action-disabled-background);
}

/* Muted like the chevron on its other side, so the row reads as one control rather than a glyph
   with a label after it. Never shrinks: the name beside it is what gives way when the toolbar is
   tight. */
.xping-topbar-account-icon.mud-icon-root {
    flex: 0 0 auto;
    color: var(--mud-palette-text-secondary);
}

.xping-topbar-account-name {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The search field drops out entirely below md, but this menu cannot: it is the only route to the
   theme switcher and to sign-out now that the sidebar carries neither. So it narrows instead — the
   name and the chevron go, the avatar stays, and the activator's title attribute carries the name
   (the menu's own header row spells it out too, once opened). */
@media (max-width: 599.98px) {
    .xping-topbar-account-name,
    .xping-topbar-account-activator > .mud-icon-root:last-child {
        display: none;
    }

    .xping-topbar-account-activator {
        padding-inline: 4px;
    }
}

/* The handoff's content gutters. */
.xping-page {
    padding: 20px 24px;
}

/* A form has to live inside the menu item rather than around the menu, because MudMenu renders
   its children through MudPopoverProvider at the top of the layout. */
.xping-logout-form {
    width: 100%;
}

.xping-logout-button {
    display: flex;
    align-items: center;
    gap: 12px;
    width: 100%;
    padding: 0;
    background: none;
    border: none;
    color: inherit;
    font: inherit;
    cursor: pointer;
}

/* ==========================================================================
   Shared tone classes for the overview screen
   ========================================================================== */

.xping-tone-positive { color: var(--mud-palette-success); }
.xping-tone-warning  { color: var(--mud-palette-warning); }
.xping-tone-negative { color: var(--mud-palette-error); }
.xping-tone-neutral  { color: var(--mud-palette-text-secondary); }

.xping-mono {
    font-family: ui-monospace, 'SF Mono', Menlo, monospace;
}

/* MudBlazor sets its own font-family on the <input> itself, so the class landing on the field's
   wrapper — which is where a MudTextField's Class parameter puts it — stops at the border. The
   API key page needs this: a secret is full of glyphs that a proportional face makes ambiguous,
   and O against 0 or l against 1 is exactly what a reader mis-transcribes. */
.xping-mono input {
    font-family: inherit;
}

/* ==========================================================================
   Overview screen
   ==========================================================================

   These live here rather than in per-component .razor.css files because Blazor's CSS isolation
   stamps its scope attribute onto plain HTML elements only. Almost every class below is handed to
   a MudBlazor component through its Class parameter, and a component's rendered output never
   carries the caller's scope — so the scoped rule would compile and then silently match nothing.
   OutcomeSparkline keeps its own stylesheet because it styles elements it writes itself, with the
   one exception directly below, which is exactly this constraint biting inside that component. */

/* ── Run-history sparkline: the tooltipped slot ───────────────────────────────
   OutcomeSparkline wraps a slot in MudTooltip only when the segment has something to say, and a
   padding slot on a test with fewer runs than the strip has slots does not. So the strip mixes two
   kinds of flex item: a bare 9px span, and a div.mud-tooltip-root holding that span beside
   MudBlazor's own div.mud-popover-cascading-value.

   That wrapper has no width of its own, so it sizes against whatever the cell it sits in says —
   which is how a rule written for a neighbouring element could reach in and stretch it. The bars
   then divided the cell between them instead of each being 9px, and the two kinds of item stopped
   matching: on a test with four runs the wrappers measured 23.75px against the bare spans' 9px, so
   the coloured slots read as spaced and the grey ones as tight. The gap was a uniform 3px
   throughout; it was the boxes that differed. A full history looked right only because every slot
   was then wrapped and they divided the space evenly — still wrong, merely uniformly so.

   Pinning the wrapper to the slot width makes the two kinds of item the same box. The popover host
   overflows it, which is invisible: it is empty, MudBlazor positions the popover itself, and the
   tooltip still opens from the bar the reader is pointing at.

   This cannot live in OutcomeSparkline.razor.css. The wrapper is rendered by MudTooltip, not by
   OutcomeSparkline, so it never carries that component's scope attribute and a scoped rule would
   compile and match nothing — the same trap .xping-project-row__health and .xping-verdict-cell
   document. The width is read from the custom property the component declares, so the two cannot
   drift apart. */
.outcome-sparkline .mud-tooltip-root {
    width: var(--xping-spark-slot-width);
    flex: none;
}

/* ── Overview layout ──────────────────────────────────────────────────────────
   The handoff's two columns: how the workspace is doing on the left, what has just run on the
   right. The filter chips are the left column's first row rather than a band above both, because
   they narrow only that column — spanning the right one would misdescribe a panel that is
   deliberately workspace-wide. */

/* Flex rather than grid, for the way the surplus is shared. The main column is held at the app's
   standard measure and the side column takes whatever is left over, which two grid tracks cannot
   express: an fr track hands the slack to whichever column it sits on, and with the main column
   capped that slack landed between the two as dead space the reader read as a gap rather than as a
   margin. In flex the main column's basis is the cap and it does not grow, so once it is satisfied
   every remaining pixel goes to the side column.

   Both are still allowed to shrink, in proportion to those bases, so the pair narrows together
   until the side column reaches its 340px floor. */
.xping-overview-grid {
    display: flex;
    gap: 20px;
    align-items: flex-start;
}

/* min-width: 0 on both, because a flex item defaults to min-content and a long test name or branch
   would widen its column instead of ellipsing inside it. The side column keeps a floor instead:
   below 340px the session rows stop fitting on their two lines. */
.xping-overview-grid__main {
    flex: 0 1 var(--xping-page-max);
    min-width: 0;
}

.xping-overview-grid__side {
    flex: 1 1 340px;
    min-width: 340px;
}

/* The side column's heading shares its line with the filter chips, so it takes the chip row's 18px
   gap rather than the 8px its mb-2 gives it — otherwise the panel's box would start ten pixels
   above the stat cards it sits beside. Two classes, so it outweighs .mb-2. */
.xping-overview-grid__side > .mud-typography {
    margin-bottom: 18px;
}

/* Higher than the 960px this screen uses elsewhere, because a media query measures the viewport
   while this grid gets what is left after the 200px drawer and the page's 24px gutters. Below this
   the main column falls under ~700px, which is narrower than the attention rows read well at. */
@media (max-width: 1279.98px) {
    .xping-overview-grid {
        flex-direction: column;
    }

    /* The bases and the floor above are widths only while the columns sit side by side — stacked,
       flex-basis would size their height and the floor would force a horizontal scrollbar on a
       narrow screen. */
    .xping-overview-grid__main,
    .xping-overview-grid__side {
        flex: 0 0 auto;
        width: 100%;
        min-width: 0;
    }
}

/* ── Project filter chips ─────────────────────────────────────────────────── */

/* The handoff's filter row: 8px between chips, wrapping, 18px clear of the stat grid. */
.xping-chip-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 18px;
}

/* MudChipSet is its own flex container, so the row's gap stops at its edge and the chips inside
   need their own — they carry margin: 0 below to keep the spacing on one property. */
.xping-chipset {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin: 0;
}

.xping-chipset .mud-chip {
    height: auto;
    margin: 0;
    padding: 4px 12px;
    border-radius: 8px;
    font-size: 12px;
    color: var(--mud-palette-text-secondary);
    border: 1px solid var(--mud-palette-lines-default);
    background-color: transparent;
}

/* The handoff's selected chip is an accent tint behind accent text, which no MudBlazor Color
   value renders — Text variant gives transparent, Filled gives a solid primary fill. */
.xping-chipset .mud-chip.mud-chip-selected {
    background-color: var(--xping-accent-bg);
    color: var(--mud-palette-primary);
    border-color: transparent;
}

/* The overflow control is drawn as an unselected chip in the handoff, not as bare "+N" text.
   It sits outside MudChipSet (it opens a menu rather than being selectable), so it repeats the
   chip's box rather than inheriting it. */
.xping-chip-more {
    display: inline-flex;
    align-items: center;
    padding: 4px 12px;
    border-radius: 8px;
    font-size: 12px;
    /* MudChip leaves its line-height at normal; anything else here makes the pill a couple of
       pixels taller than the chips it sits beside. */
    line-height: normal;
    color: var(--mud-palette-text-secondary);
    border: 1px solid var(--mud-palette-lines-default);
    cursor: pointer;
    user-select: none;
    white-space: nowrap;
}

.xping-chip-more:hover {
    color: var(--mud-palette-text-primary);
}

/* ── Stat cards ───────────────────────────────────────────────────────────── */

.xping-stat-grid {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 12px;
    margin-bottom: 22px;
}

/* The handoff has no responsive spec; these are the widths at which four cards stop fitting. */
@media (max-width: 960px) {
    .xping-stat-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }
}

@media (max-width: 600px) {
    .xping-stat-grid {
        grid-template-columns: 1fr;
    }
}

.xping-stat-card,
.xping-stat-skeleton {
    background-color: var(--mud-palette-surface);
    border-radius: 8px;
    padding: 14px;
    border-inline-start: 3px solid var(--xping-stat-accent, transparent);
}

/* The filled variants, sharing the session verdict strip's tint tokens so the same figure reads the
   same on both screens. A fill rather than the leading edge the other cards use: this is the one card
   a reader should be able to clear the page on without reading a number.

   The tone is set as the card's `color` and inherited downward rather than applied per element.
   MudBlazor's own text-colour classes carry !important, so a rule targeting the label directly would
   lose to `mud-secondary-text` — the label instead asks for Color.Inherit, whose
   `color: inherit !important` resolves to whatever is set here. The value carries no colour class at
   all, so it inherits for free. */
.xping-stat-card--positive {
    background-color: var(--xping-success-bg);
    color: var(--mud-palette-success);
}

.xping-stat-card--warning {
    background-color: var(--xping-warning-bg);
    color: var(--mud-palette-warning);
}

.xping-stat-card--negative {
    background-color: var(--xping-danger-bg);
    color: var(--mud-palette-error);
}

.xping-stat-card__label {
    margin-bottom: 6px;
    display: flex;
    align-items: center;
    gap: 4px;
}

/* Shared title treatment for the home page's stat cards and the session verdict strip: bold,
   uppercase and letter-spaced so a card's label reads as a title distinct from ordinary secondary
   body text elsewhere on the page, while staying visually quieter than the value beneath it. */
.xping-card-title {
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}

/* Pushed to the far edge of the card rather than sitting against the label, so it reads as an
   affordance for the card as a whole and the label keeps its own left-aligned column with the
   cards beside it. margin-left rather than justify-content on the row: it applies only
   when a hint is present, leaving hint-less cards laid out exactly as before. */
.xping-stat-card__label .xping-stat-card__hint {
    margin-left: auto;
    display: inline-flex;
    align-items: center;
}

/* Dimmed and smaller than the surrounding text so it does not compete with the figure. */
.xping-stat-card__label .xping-stat-card__hint-icon {
    cursor: help;
    opacity: 0.5;
    font-size: 1rem;
}

.xping-stat-card__label .xping-stat-card__hint:hover .xping-stat-card__hint-icon {
    opacity: 0.9;
}

/* The icon dims itself to 0.5 to sit behind body text. On a filled card it is already the card's own
   tone against a tint of that tone, and 0.5 leaves it barely there. */
.xping-stat-card--positive .xping-stat-card__label .xping-stat-card__hint-icon,
.xping-stat-card--warning .xping-stat-card__label .xping-stat-card__hint-icon,
.xping-stat-card--negative .xping-stat-card__label .xping-stat-card__hint-icon {
    opacity: 0.75;
}

/* MudTooltip lets its content run to the viewport width, so a caveat of more than a few words
   renders as a single edge-to-edge line that is harder to read than the label it explains.
   pre-line keeps the newlines a caller puts between sentences — Text is interpolated into HTML,
   which would otherwise collapse them — while still wrapping anything past the max width.
   Tooltips shorter than this, and any without newlines, are unaffected. The rule is global
   because the tooltip renders in the popover provider, outside the DOM of whatever anchored it. */
.mud-tooltip {
    max-width: 320px;
    white-space: pre-line;
}

.xping-stat-card__value {
    margin-bottom: 2px;
}

/* A figure that leads somewhere is an anchor, and an anchor is inline by default; block keeps it on
   the same line of its own as the plain-text figure it replaces, so linked and unlinked cards line
   up across the row. Underline on hover only — a permanently underlined number reads as a typo. */
.xping-stat-card__value--link {
    display: block;
    width: fit-content;
}

.xping-stat-card__placeholder {
    cursor: help;
    opacity: 0.7;
}

/* ── Needs attention panel ────────────────────────────────────────────────── */

.xping-panel {
    border-radius: 8px;
    overflow: hidden;
    margin-bottom: 24px;
}

.xping-panel__row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 12px 16px;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

/* The whole strip is the click target, so the link has to lay out as the row does rather than
   as an inline anchor. */
.xping-panel__row--link {
    width: 100%;
    text-align: start;
}

.xping-panel__row--link:hover {
    background-color: var(--mud-palette-table-hover);
}

/* Only the test name truncates — the project and the score are what make a clipped name
   identifiable. */
.xping-panel__name {
    font-size: 13px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

.xping-panel__project {
    flex-shrink: 0;
}

/* Pushes the trailing cell to the end. MudSpacer cannot sit inside MudLink's content without
   breaking its layout, so the margin does the same job. The grid rows below place their own cells
   and must be exempt, or the auto margin collapses their second column. */
.xping-panel__row:not(.xping-panel__row--attention) > :last-child {
    margin-inline-start: auto;
    flex-shrink: 0;
}

/* A message row holds one cell, so the trailing-cell rule above would push its text to the far
   end of an otherwise empty strip. Specificity has to match that rule's :not() compound to win. */
.xping-panel__row.xping-panel__row--message > :last-child {
    margin-inline-start: 0;
}

/* ── Attention rows ───────────────────────────────────────────────────────────
   Two lines: identity and score on top, evidence and recent runs underneath. A grid rather than
   two stacked flex rows so the score column and the run-history strip share one edge — they are
   read together, and a per-line flex would let them drift apart as names change width. */

.xping-panel__row--attention {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    column-gap: 12px;
    row-gap: 4px;
    align-items: center;
}

/* Rows that carry an outcome accent gain a leading column for it. The accent has to run the row's
   full height, which it cannot do with the row's own padding above and below it, so the padding
   moves onto the cells — the same trade .xping-session-row makes below. */
.xping-panel__row--attention.xping-panel__row--accent {
    grid-template-columns: 3px minmax(0, 1fr) auto;
    align-items: stretch;
    padding: 0;
}

.xping-panel__row--accent > .xping-panel__title,
.xping-panel__row--accent > .xping-panel__score {
    padding-block-start: 11px;
}

.xping-panel__row--accent > .xping-panel__meta,
.xping-panel__row--accent > .xping-panel__spark {
    padding-block-end: 11px;
}

.xping-panel__row--accent > .xping-panel__title,
.xping-panel__row--accent > .xping-panel__meta {
    padding-inline-start: 14px;
}

.xping-panel__row--accent > .xping-panel__score,
.xping-panel__row--accent > .xping-panel__spark {
    padding-inline-end: 16px;
}

/* Every cell of an accented row is placed explicitly. The accent is a fifth child in a three-column
   grid, and auto-placement would flow the second line's cells into the accent's own column and
   crush them — the bar has to own column one outright for both lines. */
.xping-panel__row--accent > .xping-panel__title {
    grid-column: 2;
    grid-row: 1;
}

.xping-panel__row--accent > .xping-panel__score {
    grid-column: 3;
    grid-row: 1;
}

.xping-panel__row--accent > .xping-panel__meta {
    grid-column: 2;
    grid-row: 2;
}

.xping-panel__row--accent > .xping-panel__spark {
    grid-column: 3;
    grid-row: 2;
}

/* Spans both lines so a short row cannot leave the bar stopping halfway down. Square by design;
   the colour arrives per row on --xping-row-accent (see ScoreVisualHelper.GetSessionOutcomeColor). */
.xping-panel__accent {
    grid-column: 1;
    grid-row: 1 / span 2;
    background-color: var(--xping-row-accent, transparent);
}

/* The name, its qualifier and the badges read as one label, so they share the first cell. Wrapping
   rather than shrinking: the badges are short and are the reason the row caught the eye, so
   clipping them would remove the signal and keep the noise. */
.xping-panel__title {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px 10px;
    min-width: 0;
}

/* The tone class colours this box; the score and the delta inside it inherit.
   The min-width holds one column width down the whole list — without it every row is its own grid
   with an auto-sized trailing column, and rows whose evidence line reads "6 runs · Sparse" would
   sit their scores at a different x from ones reading "142 runs · Robust". */
.xping-panel__score {
    display: flex;
    align-items: baseline;
    justify-content: flex-end;
    gap: 6px;
    white-space: nowrap;
    min-width: 128px;
}

.xping-panel__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    row-gap: 2px;
    min-width: 0;
    font-size: 12px;
    line-height: 1.45;
    color: var(--mud-palette-text-secondary);
}

.xping-panel__meta-item {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    min-width: 0;
}

/* The score's range. Tabular figures so the ranges stack down a list of rows and a reader can see
   which is wider without reading either, and no wrap: a range broken across two lines reads as two
   separate numbers. */
.xping-panel__meta-item--range {
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

/* The separator belongs to the item that follows it rather than sitting between them as an element
   of its own, so a wrap can never strand a bare "·" at the start of the second line. */
.xping-panel__meta-item + .xping-panel__meta-item::before {
    content: "·";
    margin-inline-end: 1px;
    opacity: 0.5;
}

.xping-panel__spark {
    display: flex;
    justify-content: flex-end;
}

/* Below the phone breakpoint the strip would take the width the test name needs to stay
   identifiable, and a clipped name costs more than the run history it buys. */
@media (max-width: 600px) {
    .xping-panel__spark {
        display: none;
    }

    /* The score column stops earning a fixed width here, and the headline needs the room more than
       the alignment does. */
    .xping-panel__score {
        min-width: 0;
    }
}

.xping-panel__header {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
}

.xping-panel__header-badge {
    opacity: 0.7;
    white-space: nowrap;
}

.xping-panel__footer {
    padding: 10px 16px;
    background-color: var(--mud-palette-surface);
}

.xping-panel > :last-child {
    border-bottom: none;
}

/* ── Empty attention list ─────────────────────────────────────────────────────
   The medallion is a ring with a filled disc inside it, so the tick reads as a stamp on the
   panel rather than as one more inline icon. Sized generously because this row is the only
   thing in the panel when it shows. */

.xping-panel__empty {
    gap: 16px;
    padding: 20px 16px;
}

.xping-panel__empty-medallion {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 48px;
    height: 48px;
    border-radius: 50%;
    /* The gap between ring and disc is the ring's padding, so the two circles stay concentric
       whatever the icon size is. */
    border: 2px solid var(--mud-palette-text-primary);
    padding: 5px;
}

.xping-panel__empty-medallion .mud-icon-root {
    width: 100%;
    height: 100%;
    padding: 3px;
    border-radius: 50%;
    background-color: var(--mud-palette-success);
    color: var(--mud-palette-success-text);
}

/* ── Project rows ─────────────────────────────────────────────────────────── */

.xping-project-row,
.xping-project-skeleton {
    background-color: var(--mud-palette-surface);
    padding: 13px 16px;
    border-radius: 8px;
    margin-bottom: 8px;
}

/* The accent replaces the rounding on that edge rather than sitting inside it, so the marker
   reads as part of the row's silhouette. */
.xping-project-row--regressing {
    border-left: 2px solid var(--mud-palette-error);
    border-radius: 0 8px 8px 0;
}

/* ── Recent sessions panel ────────────────────────────────────────────────────
   Rows are flush to the panel edge so the status gutter can run their full height, which means the
   padding moves off the row and onto the cells inside it.

   These rules come after .xping-panel__row deliberately: both are single-class selectors, so the
   overrides below rely on source order rather than on specificity. */

.xping-session-row {
    align-items: stretch;
    gap: 0;
    padding: 0;
    /* Every row reserves the stripe, transparent when there is nothing to say, so that a scored row
       and an unscored one keep the same left edge and the panel does not look ragged. It sits
       outside the gutter the way .xping-project-row--regressing sits outside its row, and
       .xping-panel's overflow:hidden clips it to the panel radius top and bottom. The colour
       arrives per row on --xping-session-accent; see RecentSessionsPanel.AccentStyle. */
    border-inline-start: 2px solid var(--xping-session-accent, transparent);
}

.xping-session-row__gutter {
    display: flex;
    flex: 0 0 34px;
    align-items: center;
    justify-content: center;
    background-color: var(--xping-gutter-bg);
    border-inline-end: 1px solid var(--mud-palette-lines-default);
}

.xping-session-row__body {
    flex: 1 1 auto;
    min-width: 0;
    padding: 11px 14px;
}

.xping-session-row__project {
    font-size: 13px;
    font-weight: 600;
    line-height: 1.35;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.xping-session-row__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px;
    row-gap: 2px;
    margin-top: 3px;
    font-size: 11px;
    line-height: 1.45;
    color: var(--mud-palette-text-secondary);
}

.xping-session-row__meta-item {
    display: inline-flex;
    align-items: center;
    gap: 3px;
    min-width: 0;
}

/* The separator belongs to the item that follows it rather than sitting between them as an element
   of its own, so a wrap can never strand a bare "·" at the start of the second line. */
.xping-session-row__meta-item + .xping-session-row__meta-item::before {
    content: "·";
    margin-inline-end: 1px;
    opacity: 0.5;
}

.xping-session-row__hash {
    color: var(--mud-palette-primary);
}

/* MudIcon sizes its svg at 1em, so without this the glyph shrinks to the 11px label and its stroke
   work closes up — the same reason .xping-pill__icon exists. */
.xping-session-row__icon {
    width: 12px;
    height: 12px;
    font-size: 12px;
    flex-shrink: 0;
}

/* The branch takes a line of its own rather than wrapping onto one. At this column width it almost
   never fits beside the machine name, and a wrap would carry the separator that introduces it to the
   start of the next line, where a leading "·" reads as a bullet rather than as a join. */
.xping-session-row__meta-item--branch {
    flex: 0 1 100%;
}

/* Same two-class weight as the separator rule above, and later in the file, so it wins. */
.xping-session-row__meta-item + .xping-session-row__meta-item--branch::before {
    content: none;
}

/* The name is the only part of the line that shrinks, and its floor is load-bearing: a flex item
   with overflow:hidden and no floor shrinks to zero, and the branch then disappears outright
   instead of ellipsing. The floor belongs here rather than on the item around it — the icon and the
   sha beside it are fixed width, so a floor on the parent would be spent on them and leave the name
   showing nothing but its ellipsis. */
.xping-session-row__branch-name {
    min-width: 90px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The sha is seven characters and is the half of "branch @ sha" that pins the run to an exact
   commit, so it must not be what the ellipsis eats. */
.xping-session-row__sha {
    display: inline-flex;
    gap: 3px;
    flex-shrink: 0;
    white-space: nowrap;
}

/* Top-aligned with the project name rather than centred on the row: it qualifies the row's first
   line, and centring would tie it to a meta line that wraps. */
.xping-session-row__time {
    flex-shrink: 0;
    margin-inline-start: auto;
    padding: 11px 14px 0 0;
    font-size: 11px;
    line-height: 1.45;
    color: var(--mud-palette-text-secondary);
    white-space: nowrap;
}

.xping-panel__footer-link {
    display: inline-flex;
    align-items: center;
    gap: 4px;
}

.xping-panel__footer-icon {
    width: 14px;
    height: 14px;
    font-size: 14px;
}

/* ── Session detail ── */

/* AI summary markdown output, used by SessionAnalysisCallout. */
.ai-summary-body > *:last-child {
    margin-bottom: 0;
}

.ai-summary-body h1, .ai-summary-body h2, .ai-summary-body h3 {
    font-size: 1rem;
    font-weight: 600;
    margin-top: 0.75rem;
}

.ai-summary-body ul, .ai-summary-body ol {
    padding-left: 1.25rem;
}

.ai-summary-body code {
    font-size: 0.85em;
}

/* Informational tag — deliberately not MudChip, which implies selectable filter behaviour
   (see .xping-chipset). Used by SessionMetaChips and SessionScoringBasis. */
.xping-meta-chip {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    background-color: var(--mud-palette-surface);
    border: 1px solid var(--mud-palette-lines-default);
    border-radius: 6px;
    padding: 3px 8px;
    font-size: 0.72rem;
    color: var(--mud-palette-text-secondary);
}

.xping-meta-chip__icon {
    width: 13px;
    height: 13px;
    font-size: 13px;
}

/* Title row separator and branch/PR text, used by Sessions/Index.razor. */
.xping-title-sep {
    margin: 0 6px;
    color: var(--mud-palette-text-secondary);
}

.xping-title-branch {
    font-size: 0.95rem;
    color: var(--mud-palette-text-secondary);
}

/* Four separate cards on a gap, the same shape as the home page's .xping-stat-grid, rather than
   one panel split by rules. */
.xping-verdict-strip {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 12px;
    margin-bottom: 16px;
}

@media (max-width: 991.98px) {
    .xping-verdict-strip {
        grid-template-columns: repeat(2, 1fr);
    }
}

@media (max-width: 575.98px) {
    .xping-verdict-strip {
        grid-template-columns: 1fr;
    }
}

/* The leading edge is declared on every cell, not only the ones that colour it, so all four cards
   keep the same inner width. Same variable and width as the home page's stat cards. */
.xping-verdict-cell {
    padding: 14px;
    border-radius: 8px;
    border-inline-start: 3px solid var(--xping-stat-accent, transparent);
    background-color: var(--mud-palette-surface);
    text-align: start;
    color: inherit;
    text-decoration: none;
}

.xping-verdict-doc-link {
    display: inline-flex;
    align-items: center;
    color: var(--mud-palette-text-secondary);
}

/* MudTooltip's own root wraps the link in a flex container (display:flex, align-items unset), so
   without this it can stretch taller than the icon and throw off the row's centering. */
.xping-verdict-cell .mud-tooltip-root {
    display: inline-flex;
    align-items: center;
}

.xping-verdict-cell--danger {
    background-color: var(--xping-danger-bg);
}

.xping-verdict-cell--success {
    background-color: var(--xping-success-bg);
}

/* Amber, not red: a timed-out run is red for the run's verdict but a different defect from an
   assertion failure, and the two must stay tellable apart. */
.xping-verdict-cell--warning {
    background-color: var(--xping-warning-bg);
}

.xping-verdict-cell--danger-text {
    color: var(--mud-palette-error);
}

.xping-verdict-cell--success-text {
    color: var(--mud-palette-success);
}

.xping-verdict-cell--warning-text {
    color: var(--mud-palette-warning);
}

.xping-verdict-value {
    font-weight: 500;
    line-height: 1.3;
}

.xping-verdict-value__unit {
    font-size: 1rem;
    color: var(--mud-palette-text-secondary);
}

/* The confidence card's bar, sat beneath the number. Replaces the ring the score used to be drawn
   in: the ring encoded a proportion the number already states, and was the largest object on the
   page for its least surprising fact. A flat 4px rule reads as a gauge without competing with the
   figure above it.

   The track is always full width so the card's height does not change with the score; only the
   fill's width and colour move, both set inline from ScoreVisualHelper. Omitted entirely when
   there is no score — a zero-width fill would assert a proportion the page does not have. */
.xping-verdict-bar {
    height: 4px;
    border-radius: 2px;
    background-color: var(--mud-palette-lines-default);
    overflow: hidden;
    margin-top: 6px;
}

.xping-verdict-bar__fill {
    display: block;
    height: 100%;
    border-radius: 2px;
}

/* The credible band, drawn inside the same track as the fill so the card's height never depends on
   whether a score has an interval. Positioning is only turned on for the banded variant, which
   leaves an unbanded track byte-identical to what it was.

   Read as a bracket around the fill's leading edge rather than as a second bar: two whiskers with a
   wash between them.

   The whiskers are ink rather than the category colour, and that is the whole trick. A band always
   straddles the fill's leading edge — the score sits inside its own interval — so the left whisker
   is drawn over the fill and the right one over the empty track. A tint of the score's own hue
   disappears against the fill it lands on, which is exactly the half of the bracket that says how
   far down the score could be. Ink shows on both grounds.

   The 2px floor is what makes it safe to draw the band on every scored test rather than only the
   imprecise ones. A well-evidenced score's interval is a fraction of a percent wide and would
   otherwise round to nothing, which would read as "no interval" — the state the band exists to be
   distinguishable from. */
.xping-verdict-bar--banded {
    position: relative;
}

.xping-verdict-bar__interval {
    position: absolute;
    top: 0;
    bottom: 0;
    background-color: color-mix(in srgb, var(--xping-band-color) 22%, transparent);
}

/* The whiskers are drawn inside the band, not as borders on it, and that is load-bearing rather
   than stylistic.

   As borders they had a floor: two 2px edges cannot shrink, so a band narrower than 4px was widened
   to 4px whatever its declared percentage — and a narrow band lives at the right-hand end of the
   track, because a narrow band is what a near-perfect score with hundreds of runs produces. Widened
   from a left edge already at 99%, the box ran past 100% and the track's own overflow:hidden cut the
   right whisker off. The band was least legible on exactly the score it had most to say about.

   Positioned inward from the band's own edges instead, they cannot leave it: ScoreBand clamps both
   ends into [0, 100], so left + width never exceeds the track, and a band narrower than the two
   whiskers renders as them overlapping into a single tick — which is the correct reading of an
   interval too tight to draw. */
.xping-verdict-bar__interval::before,
.xping-verdict-bar__interval::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    width: 2px;
    background-color: color-mix(in srgb, var(--mud-palette-text-primary) 55%, transparent);
}

.xping-verdict-bar__interval::before {
    left: 0;
}

.xping-verdict-bar__interval::after {
    right: 0;
}

/* The same interval as text, beneath the bar. Tabular figures so the two bounds line up with the
   score above them and with the ranges on neighbouring cards, and no wrapping: a range broken
   across two lines reads as two numbers. */
.xping-verdict-range {
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

/* ── Staleness band ───────────────────────────────────────────────────────────
   The caveat that sits between an execution page's header and its verdict strip, saying when the
   run was recorded and what the profile has scored since.

   It is not decorative. A per-session page shows one point in time, and every number on it is as
   old as the run; without the band a reader takes it for a live view of the test and acts on a
   verdict the profile has already moved past. The band therefore reads as an aside rather than as
   data — one step up from the page ground on --xping-surface-1, no accent edge, secondary text —
   so that it registers before the strip without competing with it.

   Shares the strip's 8px radius and 16px bottom margin so the two read as one stacked object. */
.xping-staleness-band {
    background-color: var(--xping-surface-1);
    border-radius: 8px;
    padding: 10px 14px;
    margin-bottom: 16px;
}

/* ── Attempt stepper ──────────────────────────────────────────────────────────
   One node per attempt on the execution page, and the rail that makes them a sequence rather than a
   list. The rail belongs to the node above it rather than being one line drawn down the panel and
   masked at both ends: nodes have different heights once a secondary line appears, and a single
   line would have to be told about each of them.

   The dot takes its colour from ScoreVisualHelper through an inline custom background, the same
   arrangement .xping-run-row__dot uses on the profile — the component names no colour itself. */

.xping-attempts-panel,
.xping-failure-panel {
    padding: 12px 14px;
    border-radius: 8px;
    margin-bottom: 16px;
}

.xping-attempt-stepper {
    list-style: none;
    margin: 0;
    padding: 0;
}

.xping-attempt-node {
    display: grid;
    grid-template-columns: 10px minmax(0, 1fr);
    column-gap: 12px;
}

/* The last node's rail would run past the sequence into nothing, so the marker stops being a column
   there and the dot is all that is left. */
.xping-attempt-node:last-child .xping-attempt-node__rail {
    display: none;
}

.xping-attempt-node__marker {
    display: flex;
    flex-direction: column;
    align-items: center;
    /* Lines the dot up with the cap height of the primary line rather than with the top of its box,
       so the dot reads as belonging to the words beside it. */
    padding-top: 6px;
}

.xping-attempt-node__dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    flex: 0 0 auto;
}

.xping-attempt-node__rail {
    flex: 1 1 auto;
    width: 2px;
    margin: 4px 0;
    background-color: var(--mud-palette-lines-default);
}

.xping-attempt-node__body {
    display: block;
    padding-bottom: 12px;
}

/* No bottom padding on the last node: the panel's own 12px is the gap, and doubling it would leave
   the sequence floating above the panel's floor. */
.xping-attempt-node:last-child .xping-attempt-node__body {
    padding-bottom: 0;
}

.xping-attempt-node__head {
    display: block;
    font-size: 0.875rem;
}

.xping-attempt-node__meta {
    display: block;
    margin-top: 2px;
    font-size: 0.78rem;
    color: var(--mud-palette-text-secondary);
    /* Long exception type names are one unbroken token and would otherwise widen the main column
       rather than wrapping inside it. */
    overflow-wrap: anywhere;
}

/* A single attempt is not a sequence, so it takes the dot and the words on one line and none of the
   stepper's structure. */
.xping-attempt-single {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 8px;
}

/* ── Failure panel ────────────────────────────────────────────────────────────
   The classification, the exception type, and the block carrying the message and the trace.

   The block sits one step up from the panel on --xping-surface-1, the token the staleness band
   introduced, rather than on the outlined surface .xping-stack-trace-panel gives the error-details
   dialog. Two reasons: the panel is already a bordered box, so a second border inside it reads as a
   nested card; and a trace is transcript rather than content, which the tonal step says without
   spending a rule on it. */

.xping-failure-panel__chips {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin-bottom: 8px;
}

.xping-failure-panel__exception {
    font-size: 0.8rem;
    margin-bottom: 10px;
    overflow-wrap: anywhere;
}

.xping-failure-block {
    background-color: var(--xping-surface-1);
    border-radius: 8px;
    padding: 10px 12px;
    /* Capped and scrolled within itself: a full trace is longer than the rest of the page put
       together, and letting it set the page's height would push everything after it out of reach. */
    max-height: 360px;
    overflow: auto;
}

.xping-failure-panel__frames {
    display: block;
    margin-top: 6px;
}

/* ── Reliability distribution panel ───────────────────────────────────────────
   One row per score category, used by SessionDistributionPanel in the overview side column. */

.xping-dist-panel {
    border-radius: 8px;
    overflow: hidden;
    margin-bottom: 16px;
}

/* Two lines per category: the identity and its numbers on top, the proportional track beneath
   spanning the row's full width. The track is what makes the shares comparable at a glance, and at
   340px there is not enough room to put it on the same line as a label like "Moderately Reliable"
   without squeezing it to a stub. */
.xping-dist-panel__row {
    display: grid;
    grid-template-columns: 10px minmax(0, 1fr) auto auto;
    align-items: center;
    column-gap: 8px;
    row-gap: 4px;
    padding: 6px 16px;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

.xping-dist-panel__row:last-child {
    border-bottom: none;
}

/* A category no test landed in still holds its row, so the panel keeps one height and one reading
   order across sessions — but it recedes rather than competing with the categories that have data. */
.xping-dist-panel__row--empty {
    opacity: 0.45;
}

.xping-dist-panel__swatch {
    width: 10px;
    height: 10px;
    border-radius: 50%;
}

.xping-dist-panel__label {
    font-size: 13px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

.xping-dist-panel__count {
    font-size: 13px;
    font-variant-numeric: tabular-nums;
}

.xping-dist-panel__pct {
    font-size: 12px;
    font-variant-numeric: tabular-nums;
}

/* Deliberately thin. Five of these stack in a 340px side column, and the track only has to carry a
   proportion — the count and the percentage beside the label are what get read for the value. */
.xping-dist-panel__track {
    grid-column: 1 / -1;
    height: 4px;
    border-radius: 2px;
    background-color: var(--mud-palette-lines-default);
    overflow: hidden;
}

/* min-width keeps a single test out of 200 from rounding away to an invisible fill — the row is
   the point of this layout, and an empty-looking track would read as a zero. */
.xping-dist-panel__fill {
    display: block;
    height: 100%;
    min-width: 3px;
    border-radius: 2px;
}

/* A closing line that states what the rows above add up to. Inside the panel, on the shared surface,
   so it reads as the list's total rather than as a caption that happens to sit nearby. Only the test
   profile's evidence composition uses it — the session distribution's shares already total 100% and
   have nothing to close with.

   No border of its own: the rule above only strips the divider from the *last* row, so with a footer
   present the final row keeps its hairline and that is the one separating the two. A border-top here
   would land directly on it and draw the line twice. */
.xping-dist-panel__footer {
    padding: 8px 16px;
    font-size: 12px;
    color: var(--mud-palette-text-secondary);
    background-color: var(--mud-palette-surface);
}

/* Same card silhouette as .xping-panel / .xping-verdict-cell so the analysis reads as one of the
   page's containers rather than as loose text on the page background. */
.xping-analysis-callout {
    background-color: var(--mud-palette-surface);
    border-radius: 8px;
    padding: 14px 16px;
}


/* ==========================================================================
   Sessions list (/sessions)
   ==========================================================================
   Global rather than scoped to SessionsList.razor. Blazor stamps its scope attribute only on plain
   HTML elements, and the panel and each row are MudBlazor components — MudPaper and MudLink — so a
   scoped rule has no scoped ancestor to hang a ::deep off, and the row's own class never receives an
   attribute either. The same constraint is why .xping-panel, .xping-session-row and .xping-pill above
   live here.
   ========================================================================== */

/*
 * Deliberately a separate family from the .xping-session-row classes above rather than an extension
 * of them: those size a two-line row for the overview's narrow side column, where the only cells are
 * identity and time. This row carries five cells across the full content width and needs its own
 * column geometry. The surface, border, radius and hover colours are the shared ones, so the two
 * still read as the same component.
 *
 * Colours come from the MudBlazor palette and the shared --xping-* tokens only. The one exception is
 * the row's left stripe, whose colour arrives as --xping-row-accent from ScoreVisualHelper — the
 * reliability scale has five bands and the tone tokens have three, so a token would merge two of them.
 */

:root {
    /* The app's standard content measure, shared by the full-width pages and by the overview grid's
       main column so every page's content stops at the same line. */
    --xping-page-max: 1280px;
}

/* The gap under a page's title, set here rather than as a utility class on each heading so every
   page opens at the same rhythm. Applied by the shared PageHeading component; the size itself is
   the theme's h4, so a page title outranks the section headings inside the page. */
.xping-page-title {
    margin-bottom: 12px;
}

/* The title's row. Baseline-aligned rather than centred, so the caption sits on the title's own
   line rather than floating in the middle of its cap height, and immediately beside the title
   rather than at the far end of the measure — the two are read together, and a caption alone
   against the right edge of a 1280px page reads as an unrelated figure rather than as the title's
   scope.

   It wraps: on a narrow viewport the caption drops beneath the title instead of squeezing it, which
   is why both carry the same bottom margin — whichever ends up last supplies the gap under the
   heading, so the page opens at the same rhythm either way. */
.xping-page-heading {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 4px 16px;
}

.xping-page-subtitle {
    margin-bottom: 12px;
}

/* The shared content width for full-page surfaces.

   The page is full-bleed inside the shell, and a row of a handful of columns has nothing useful to
   do with the width of a 2000px monitor: the slack lands between the identity and the numbers as a
   hole the eye has to jump. Capping the column keeps the row at the proportions it was designed for
   and turns the surplus into page margin, which reads as layout rather than as a gap in the row. The
   cap sits on a wrapper around the whole page so the title, filters, list and pager keep one left and
   right edge.

   Named for the page rather than for a list because the cap is not a property of lists — any
   full-width page wanting to sit at the app's standard measure uses this, and every page that does
   lines up with every other. */
.xping-main-page {
    max-width: var(--xping-page-max);
}

.xping-list {
    border-radius: 8px;
    overflow: hidden;
}

/* ── Day headers ──────────────────────────────────────────────────────────────
   Plain text rather than a component: the header is a divider with a word on it, and it must not
   compete with the project names below it. Not sticky — a list of at most fifty rows does not scroll
   far enough for a pinned header to earn the space it would take from the first row. */

.xping-list__day {
    padding: 10px 16px 6px;
    font-size: 12px;
    font-weight: 500;
    color: var(--mud-palette-text-secondary);
    background-color: var(--mud-palette-surface);
    cursor: help;
}

/* ── Rows ─────────────────────────────────────────────────────────────────────
   A grid rather than nested flex rows. The four content cells have to line up down the list — a
   reader compares reliability against reliability, not against whatever the row above happened to
   put there — and per-row flex would let the columns drift as project names and branch names change
   width. The identity column is the only one that flexes; the rest are sized to their content so the
   numbers stay in a column.

   min-width: 0 on the identity cell is what allows its children to truncate: a grid item defaults to
   min-content width, which would otherwise push the whole row wider than its container rather than
   letting a long branch name ellipsise.

   The outcome column takes a fractional share rather than a fixed width so that identity is not the
   only cell that absorbs spare room. A project name and a branch stop needing width long before the
   row runs out of it, where the outcome bar reads better the longer it is — splitting the slack
   between the two keeps the numbers within a glance of the name they belong to. Its floor is the
   width the bar and its counts need to stay legible; below that the media query takes over. */

.xping-list-row {
    display: grid;
    /* The reliability column is sized to the longest category name — "Moderately Reliable" — plus the
       meter and the coverage count that share its second line. At 140px that label ellipsized, which
       cost the reader the one word that distinguishes the band from the two next to it.

       The outcome column is capped rather than proportional. As a fraction it kept taking whatever
       the viewport gave it, so on a wide screen a fourteen-test run drew a bar half the row wide and
       the two cells that carry the verdict looked incidental beside it. The bar only has to show a
       proportion; past a couple of hundred pixels the extra width adds nothing, so the identity
       column absorbs the remainder instead. */
    grid-template-columns: 18px minmax(0, 1fr) 190px minmax(150px, 240px) 84px;
    align-items: center;
    gap: 12px;
    width: 100%;
    padding: 10px 16px;
    text-align: start;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
    position: relative;
}

.xping-list-row:last-child {
    border-bottom: none;
}

.xping-list-row:hover {
    background-color: var(--mud-palette-table-hover);
}

/* The reliability stripe. A pseudo-element rather than a border-left so that rows without a stripe
   keep the same content box — a conditional border would shift every unscored row two pixels left. */
.xping-list-row::before {
    content: "";
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    width: 3px;
    background-color: var(--xping-row-accent, transparent);
}

.xping-list-row--skeleton:hover {
    background-color: var(--mud-palette-surface);
}

.xping-list-row__status {
    display: flex;
    align-items: center;
    justify-content: center;
}

/* ── Identity cell ──────────────────────────────────────────────────────────── */

.xping-list-row__identity {
    display: flex;
    flex-direction: column;
    gap: 3px;
    min-width: 0;
}

.xping-list-row__title {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
}

.xping-list-row__project {
    font-size: 14px;
    font-weight: 500;
    color: var(--mud-palette-text-primary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.xping-list-row__meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 4px 10px;
    font-size: 12px;
    color: var(--mud-palette-text-secondary);
    min-width: 0;
}

.xping-list-row__meta-item {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    min-width: 0;
}

/* MudIcon sizes its svg to 1em, so the meta line's 12px would shrink a Lucide glyph until its stroke
   work closes up. Sizing it here keeps the icon legible without the label having to grow to meet it —
   the same reason .xping-pill__icon exists in app.css. */
.xping-list-row__icon {
    width: 14px;
    height: 14px;
    font-size: 14px;
    flex-shrink: 0;
    opacity: 0.75;
}

/* The branch is the only meta item allowed to truncate. A clipped branch name is still recognisable
   from its prefix, where a clipped sha or run type is not. */
.xping-list-row__branch {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 220px;
}

/* ── Trailing cells ─────────────────────────────────────────────────────────── */

.xping-list-row__reliability,
.xping-list-row__outcome {
    min-width: 0;
}

.xping-list-row__when {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 2px;
    text-align: end;
}

.xping-list-row__duration {
    font-size: 13px;
    color: var(--mud-palette-text-primary);
    white-space: nowrap;
}

.xping-list-row__age {
    font-size: 11px;
    color: var(--mud-palette-text-secondary);
    white-space: nowrap;
    cursor: help;
}

/* ── States ───────────────────────────────────────────────────────────────────
   Both empty states and the error live inside the same panel as the rows, so the page keeps one
   outline whatever it is showing. */

.xping-list__alert {
    margin: 0;
    border-radius: 0;
}

.xping-list__alert-body {
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
}

.xping-list__empty {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 6px;
    padding: 32px 16px;
    background-color: var(--mud-palette-surface);
}

.xping-list__empty-action {
    margin-top: 8px;
}

/* ── Pager ────────────────────────────────────────────────────────────────────
   Outside the panel: it acts on the list rather than being part of it, and inside the outline it
   reads as a final row. */

.xping-list__pager {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    flex-wrap: wrap;
    margin-top: 12px;
}

.xping-list__page-size {
    max-width: 104px;
}

/* ── Loading ──────────────────────────────────────────────────────────────────
   Placeholders in the row's own grid rather than a spinner in a panel, as every other list in the
   application does: the panel then has the height of a list while it waits, and nothing below it
   moves when the query lands. Four of the seven cells are left bare — the three a folder row never
   fills, and the trend, because a shape that promised more than the first rows deliver would settle
   into a smaller one. */

.xping-tree-row--skeleton,
.xping-tree-row--skeleton:hover {
    background-color: var(--mud-palette-surface);
}

/* ── A class that opened onto nothing ─────────────────────────────────────────
   Two states share this row: a class that answered with no tests, and one that did not answer. Both
   would otherwise be a chevron that turned and a gap beneath it, which reads as an expand that broke.
   Like the offer row it fills cell 1 and leaves the rest, and takes no accent stripe — it stands for
   tests that are not there, so there is no reliability for a colour to state.

   The retry button is the offer row's, reused verbatim: it is the same thing in the same place, a
   text action inside a 24px name cell whose height every row above it shares. */

.xping-tree-row--notice:hover {
    background-color: var(--mud-palette-surface);
}

.xping-tree-row__notice {
    font-size: 13px;
    color: var(--mud-palette-text-secondary);
}

/* ── Narrow viewports ─────────────────────────────────────────────────────────
   Below this the five columns cannot all hold their width, so the two derived cells drop under the
   identity rather than being squeezed to illegibility. The status dot and the timestamp keep their
   places: they are the row's anchors at any width. */

@media (max-width: 900px) {
    .xping-list-row {
        grid-template-columns: 18px minmax(0, 1fr) 84px;
        row-gap: 8px;
    }

    .xping-list-row__reliability,
    .xping-list-row__outcome {
        grid-column: 2 / 4;
    }
}

/* ── Reliability cell ─────────────────────────────────────────────────────────
   Two lines: the score, then what the score is made of. The score is the only thing on the row that
   is coloured by its own value, which is why the category label sits beside it — a band carried in
   text colour alone is not something WCAG 1.4.1 accepts as the sole carrier of meaning. */

.xping-reliability {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

/* tabular-nums so the scores form a column the eye can compare down the list; proportional digits
   would let 0.61 and 0.34 sit at different widths and defeat the point of ranking them. */
.xping-reliability__score {
    font-size: 15px;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
}

/* An unscored session states why in the second line, so the first line only has to stop looking like
   a number that failed to arrive. Disabled rather than secondary: it is the absence of a value. */
.xping-reliability__score--absent {
    font-size: 13px;
    font-weight: 400;
    color: var(--mud-palette-text-disabled);
}

.xping-reliability__basis {
    display: flex;
    align-items: center;
    gap: 4px;
    font-size: 11px;
    color: var(--mud-palette-text-secondary);
    min-width: 0;
}

.xping-reliability__category {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.xping-reliability__separator {
    opacity: 0.4;
}

.xping-reliability__icon {
    width: 13px;
    height: 13px;
    font-size: 13px;
    flex-shrink: 0;
    opacity: 0.75;
}

/* ── Outcome cell ─────────────────────────────────────────────────────────────
   A stacked bar over its own counts. The segments are flex items rather than positioned widths, so
   the three always divide the track exactly — see SessionOutcomeBar's Width remark. */

.xping-outcome {
    display: flex;
    flex-direction: column;
    gap: 4px;
    min-width: 0;
}

.xping-outcome__bar {
    display: flex;
    width: 100%;
    height: 4px;
    border-radius: 2px;
    overflow: hidden;
    background-color: var(--mud-palette-lines-default);
    cursor: help;
}

.xping-outcome__segment {
    display: block;
    height: 100%;
    /* A single test in a suite of hundreds would otherwise round away to nothing, and an invisible
       segment reads as a zero — which for a failure is the one wrong answer. */
    min-width: 2px;
}

.xping-outcome__segment--passed {
    background-color: var(--mud-palette-success);
}

.xping-outcome__segment--failed {
    background-color: var(--mud-palette-error);
}

/* Amber, sitting between failed and skipped. The bar reads left-to-right worst-to-absent, and a
   timed-out test belongs on the red side of that: it is a verdict, not a missing one. */
.xping-outcome__segment--timeout {
    background-color: var(--mud-palette-warning);
}

/* Neutral, not amber. A skipped test is an absence of evidence rather than a problem, and amber is
   already spoken for — by the warning tone the attention count uses two elements away, and now by
   the timeout segment beside this one. */
.xping-outcome__segment--skipped {
    background-color: var(--mud-palette-text-disabled);
}

.xping-outcome__counts {
    display: flex;
    align-items: center;
    gap: 4px;
    font-size: 11px;
    font-variant-numeric: tabular-nums;
    color: var(--mud-palette-text-secondary);
}

/* The three counts are one hoverable unit, so the tooltip covers the whole tally rather than
   whichever number the pointer happened to land on. The attention link stays outside it: it has its
   own tooltip and its own destination. */
.xping-outcome__tally {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    cursor: help;
}

.xping-outcome__separator {
    opacity: 0.4;
}

/* A bare anchor, so it needs the affordances MudLink would have supplied — and the colour it must
   NOT supply: every MudLink Color maps to a class that sets one, and Color.Inherit's
   `.mud-inherit-text` does it with !important, which the warning tone cannot outrank. The tone is
   the point of this link, so it is plain and styled here. Same fix as the tree row's weakest
   figure. */
.xping-outcome__attention {
    display: inline-flex;
    align-items: center;
    gap: 2px;
    margin-inline-start: 4px;
    font-size: 11px;
    font-weight: 500;
    text-decoration: none;
}

.xping-outcome__attention:hover {
    text-decoration: underline;
}

.xping-outcome__attention:focus-visible {
    outline: 2px solid var(--mud-palette-primary);
    outline-offset: 2px;
    border-radius: 2px;
}

.xping-outcome__attention-icon {
    width: 12px;
    height: 12px;
    font-size: 12px;
    flex-shrink: 0;
}

/* Retried tests. Neutral, not amber: a test that failed and passed on retry ended green, so this is
   information about how the run got there rather than a verdict on it. Amber is spoken for by the
   attention count sitting immediately after — the same neutral/amber split the skipped segment
   above draws on. */
.xping-outcome__retries {
    display: inline-flex;
    align-items: center;
    gap: 2px;
    margin-inline-start: 4px;
    font-size: 11px;
    color: var(--mud-palette-text-secondary);
    cursor: help;
}

.xping-outcome__retries-icon {
    width: 12px;
    height: 12px;
    font-size: 12px;
    flex-shrink: 0;
}

/* Evidence strength meter. Uses currentColor so it inherits the muted tone of the line it sits on,
   which keeps it subordinate to the score above it — evidence qualifies the score rather than
   competing with it for the reader's attention. */
/* MudTooltip wraps its children in a root element of its own, which lands between the basis row and
   the meter and becomes the flex item. It has to carry the meter's flex behaviour, or the bars are
   what shrink when the column is narrow instead of the label that knows how to ellipsize. It holds
   both halves of the evidence statement — the depth meter and the coverage count — so hovering
   either one explains both. */
.xping-reliability__evidence-tip {
    display: inline-flex;
    align-items: center;
    flex-shrink: 0;
    cursor: help;
}

/* Coverage as a fraction, deliberately quieter than the meter beside it. The two are separate
   measurements and the meter is the one being qualified, so the count recedes rather than competing
   for the same reading. tabular-nums keeps the fractions from jittering down the column. */
.xping-reliability__coverage {
    /* Its own margin rather than a gap on the wrapper: MudTooltip's root carries two of its own
       classes, so whether a gap declared there survives depends on stylesheet order. The count sits
       hard against the last bar without this. */
    margin-left: 6px;
    font-variant-numeric: tabular-nums;
    opacity: 0.75;
    white-space: nowrap;
}

.xping-reliability__evidence {
    display: inline-flex;
    align-items: flex-end;
    gap: 1.5px;
    height: 9px;
    cursor: help;
    flex-shrink: 0;
}

.xping-reliability__bar {
    display: block;
    width: 3px;
    border-radius: 1px;
    background-color: currentColor;
    /* Unlit bars stay visible so the meter always shows its full scale — a lone lit bar with nothing
       beside it reads as a dot rather than as one of three. */
    opacity: 0.22;
}

.xping-reliability__bar:nth-child(1) { height: 4px; }
.xping-reliability__bar:nth-child(2) { height: 6.5px; }
.xping-reliability__bar:nth-child(3) { height: 9px; }

.xping-reliability__bar--on {
    opacity: 1;
}

/* ── Filter bars (sessions, projects) ────────────────────────────────────────
   One row: a search field on the leading edge, a block of pickers on the trailing edge. The pickers
   keep a fixed, legible width each and the block wraps beneath the search field as one unit when the
   viewport cannot hold it — but at the page's own cap it fits, because the sessions bar keeps only
   the controls it must have on the row (see SessionFilterBar for which and why) and tucks the rest
   behind a Filters button. */

.xping-filters {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 12px;
}

/* Sized in pixels rather than as a share of the row: against the page's capped width a percentage
   basis grew the field past what was left and wrapped it onto a second line. A search box wants
   enough room for a branch name, not for the whole row's slack. */
.xping-filters__search {
    flex: 0 1 280px;
    min-width: 240px;
}

/* The sessions bar has slack on its row now that four pickers sit behind a button, and search is
   the one control that benefits from it: a branch name or a commit SHA is the longest thing typed
   into this bar. Capped so it does not swallow the whole line on a wide monitor and leave the
   pickers stranded on the far edge. */
.xping-filters__search--grow {
    flex: 1 1 280px;
    max-width: 360px;
}

/* The pickers travel together as one block on the trailing edge; auto margin rather than
   justify-content so search keeps its own width instead of being stretched by the gap. */
.xping-filters__controls {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin-left: auto;
}

/* The pickers are labelled and hold one short value each, so they lose nothing at this width, where
   the search box has a full placeholder sentence to show. */
.xping-filters__select {
    flex: 0 0 auto;
    width: 144px;
}

/* The one control in the bar that is typed into rather than picked from, and the only one holding a
   value shaped like a path. At 144px a branch field shows about sixteen characters, which truncates
   "feature/" before the branch name has started. */
.xping-filters__select--branch {
    width: 200px;
}

/* The two outlined buttons on the sessions row. MudButton is 36px tall against the dense outlined
   inputs' 40px, and the mismatch shows on a row that aligns everything to one centre line. */
.xping-filters__button {
    height: 40px;
}

/* The body of the Filters popover: the four pickers stacked, each the full width of the popover, so
   "Moderately Reliable" — the longest value any of them shows — has room without the row-width
   trade-offs that put them here. Sized on this inner div rather than on MudMenu's own list wrapper,
   which has its own padding and minimum width. */
.xping-filters__stack {
    display: flex;
    flex-direction: column;
    gap: 12px;
    width: 256px;
    padding: 12px 16px;
}

/* The chip row sits between the bar and the list, so it reads as a consequence of the controls above
   rather than as a header on the rows below. */
.xping-filters__active {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 4px 12px;
    margin-bottom: 12px;
}

.xping-filters__chipset {
    margin: 0;
}


/* ==========================================================================
   Test profile overview (/tests/{id})
   ==========================================================================
   Global rather than scoped to the components that use them, for the reason given at the top of the
   overview-grid section: these classes are handed to MudPaper through its Class parameter, and a
   MudBlazor component's rendered output never carries the caller's scope attribute, so a .razor.css
   rule would compile and then match nothing.
   ========================================================================== */

/* ── Why this score ───────────────────────────────────────────────────────────
   One row per confidence factor: the factor's identity and its three numbers on the first line, a
   proportional track beneath spanning the row's full width. Deliberately the same two-line shape as
   .xping-dist-panel, because the two are read the same way — a list of named parts with a share
   each — and a reader who has learned one should not have to learn the other. */

.xping-factor-panel {
    border-radius: 8px;
    overflow: hidden;
    margin-bottom: 16px;
}

/* Four tracks: the factor name takes the slack and the three numeric columns size to their content,
   so score, weight and loss line up down the panel however long "Environment Consistency" is. The
   track then spans all four on its own line. */
.xping-factor-panel__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto auto auto;
    align-items: center;
    column-gap: 12px;
    row-gap: 6px;
    padding: 8px 16px;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

/* Same four tracks as a row, so the captions sit over the columns they name, but no track line and a
   lighter vertical rhythm — this is a label strip, not a seventh factor. */
.xping-factor-panel__head {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto auto auto;
    align-items: center;
    column-gap: 12px;
    padding: 6px 16px;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

.xping-factor-panel > :last-child {
    border-bottom: none;
}

/* A factor that could not be scored keeps its row, so the six always read in the same order and a
   reader can compare this test's Retry Behavior against another's by position. It recedes rather
   than competing with the factors that carry a number — the same treatment, and the same value, as
   an empty score category in .xping-dist-panel__row--empty. */
.xping-factor-panel__row--empty {
    opacity: 0.45;
}

/* Holds the factor name and, on one row, the largest-loss marker. min-width: 0 so the name ellipses
   inside the cell instead of widening the column and pushing the numbers out of alignment. */
.xping-factor-panel__name {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    font-size: 13px;
}

.xping-factor-panel__name > span:first-child {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* tabular-nums so the decimal points stack down the column: these three numbers are read against
   each other row by row, and proportional digits make that a hunt. */
.xping-factor-panel__num {
    font-size: 13px;
    font-variant-numeric: tabular-nums;
    text-align: end;
}

.xping-factor-panel__num--caption {
    font-size: 11px;
    color: var(--mud-palette-text-secondary);
    text-align: end;
}

/* What a silent factor withheld from the evidence floor, in the Loss column's position but not in
   its arithmetic — the losses sum to exactly 1 − Score and this number is outside that sum. The
   brackets carry that distinction; italics and the secondary colour stop it being read down the
   column as one more loss. */
.xping-factor-panel__num--withheld {
    font-style: italic;
    color: var(--mud-palette-text-secondary);
}

/* One sentence under the six rows, saying what the table above could not measure. It is prose about
   the whole breakdown rather than a seventh row, so it takes the panel's full width and the caption
   treatment the per-factor explanations use.

   No border of its own in either direction. The row above already draws its bottom rule, and
   .xping-factor-panel > :last-child strips the trailing one — a border-top here would land on top of
   that rule and read as a 2px line on exactly the panels that have a footer. */
.xping-factor-panel__foot {
    padding: 8px 16px;
    font-size: 11px;
    line-height: 1.45;
    color: var(--mud-palette-text-secondary);
    background-color: var(--mud-palette-surface);
}

/* The calculator's account of the factor, spanning all four columns on its own line so a long
   sentence wraps instead of widening the name column and pulling the numbers out of alignment.
   Caption-sized and secondary: it qualifies the row above it and must not compete with the numbers
   a reader came for. -2px pulls it under the name rather than floating midway between the two
   lines it sits between. */
.xping-factor-panel__why {
    grid-column: 1 / -1;
    margin-top: -2px;
    font-size: 11px;
    line-height: 1.45;
    color: var(--mud-palette-text-secondary);
}

/* 3px rather than the distribution panel's 4px. Six of these stack under rows that already state the
   score as a number, so the track only has to carry the shape of the profile at a glance — at four
   it started to read as a component in its own right and competed with the numbers above it. */
.xping-factor-panel__track {
    grid-column: 1 / -1;
    height: 3px;
    border-radius: 2px;
    background-color: var(--mud-palette-lines-default);
    overflow: hidden;
}

/* min-width keeps a very low score from rounding away to an invisible fill, which would be
   indistinguishable from the empty track a null score renders — and those two mean opposite things. */
.xping-factor-panel__fill {
    display: block;
    height: 100%;
    min-width: 3px;
    border-radius: 2px;
}

/* ── Score trend ──────────────────────────────────────────────────────────────
   The series is loaded on the panel's own query, so this panel has three more states than its
   siblings — loading, failed, and never scored. They share one centred block rather than each
   getting its own rule: they differ in what they say, not in how they sit. */

.xping-trend-panel {
    padding: 12px;
    border-radius: 8px;
    margin-bottom: 16px;
}

/* Half the width, because that is the aspect of MudBlazor's chart viewBox — see the panel. The
   floor keeps a narrow column from collapsing the plot to a strip. */
.xping-trend-panel__chart {
    width: 100%;
    aspect-ratio: 2 / 1;
    min-height: 220px;
}

/* Roughly the chart's height so the column does not jump when the series arrives or fails. */
.xping-trend-panel__state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    min-height: 260px;
    text-align: center;
}

.xping-trend-panel__foot {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 16px;
    padding-top: 8px;
    margin-top: 4px;
    border-top: 1px solid var(--mud-palette-lines-default);
    font-size: 0.8125rem;
}

.xping-trend-panel__foot-label {
    color: var(--mud-palette-text-secondary);
    margin-right: 4px;
}

/* ── Side column panels ───────────────────────────────────────────────────────
   Details and Recent runs. Both are lists of short rows with a trailing figure, so they share one
   container and differ only in how a row divides its width — Details puts a label against a value,
   a run row leads with an outcome dot. Same 8px radius, hairline rows and 16px trailing rhythm as
   .xping-dist-panel above, since the three stack in one 340px column and a reader should read them
   as one object rather than as three. */

/* The trailing affordance on a panel heading — "All" beside Recent runs. Caption-sized and in the
   accent colour: it sits on the heading's baseline and must read as a way out of the panel without
   competing with the heading it follows. Underlined only on hover, matching MudLink's
   Underline.Hover used for the project link in the page header. */
.xping-heading-link {
    font-size: 12px;
    color: var(--mud-palette-primary);
    text-decoration: none;
    cursor: pointer;
}

.xping-heading-link:hover,
.xping-heading-link:focus-visible {
    text-decoration: underline;
}

/* Body-sized, for the places the same affordance sits on a row of body text rather than beside a
   caption-scale heading — the attention list's "All tests in this session · N" and its footer.
   These were MudLink Typo.body2 before they became tab switches, and the switch is not a reason to
   shrink them. */
.xping-heading-link--body {
    font-size: 0.875rem;
}

.xping-detail-panel {
    border-radius: 8px;
    overflow: hidden;
    margin-bottom: 16px;
}

/* Two tracks: the label takes the slack and the value sizes to its content, so the figures line up
   down the panel's trailing edge however long "Average duration" is. Baseline alignment rather than
   centre, because the two cells are the same size and read as one line of prose. */
.xping-detail-panel__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: baseline;
    column-gap: 12px;
    padding: 8px 16px;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

.xping-detail-panel > :last-child {
    border-bottom: none;
}

.xping-detail-panel__label {
    font-size: 13px;
    color: var(--mud-palette-text-secondary);
}

/* tabular-nums so the durations and dates stack down the trailing edge: these are read against each
   other row by row, and proportional digits make that a hunt. */
.xping-detail-panel__value {
    font-size: 13px;
    font-variant-numeric: tabular-nums;
    text-align: end;
}

/* Flush, for a detail panel nested inside a settings panel's body. That body already pads 16px, so
   the panel's own bottom margin and its rows' side padding inset the rows from a container that has
   already spaced them — and a settings page reads its panels' rows as one vocabulary, which a second
   indent breaks.

   A modifier rather than the mb-0/px-0 MudBlazor utilities the API key page reaches for:
   project-settings-redesign-spec.md §11 bars class names of that shape from the project pages, and
   its grep cannot tell MudBlazor's spacing utilities from Bootstrap's. */
.xping-detail-panel--flush {
    margin-bottom: 0;
}

.xping-detail-panel--flush .xping-detail-panel__row {
    padding-inline: 0;
}

/* ── Execution context panel ──────────────────────────────────────────────────────────────────
   Spec §4.2's promotion. When a test is flagged flaky for order dependency the context panel leads
   the main column instead of trailing the side one, and the border is what tells a reader the panel
   is here on purpose rather than by the page's usual order. 2px on all four edges rather than a
   leading stripe, because the panel has moved rather than been ranked — the stripe vocabulary is
   already spoken for by the verdict cells and the session rows, which use it to grade things in
   place. The colour is the warning palette token, matching .xping-tone-warning; no literal, per
   spec §1.2. */
.xping-detail-panel--promoted {
    border: 2px solid var(--mud-palette-warning);
}

/* The footer states why the panel was promoted. Padded like a row but without the hairline above it
   carried by the row rule, since it closes the panel rather than continuing the list. */
.xping-detail-panel__promotion {
    padding: 10px 16px;
    background-color: var(--xping-surface-1);
}

/* A collection name or a previous test's method name is long, and in the 340px side column the
   row's `auto` value track has no slack to give: the value keeps its intrinsic width and rides over
   its own label. These rows hand the slack to the value instead and let it break inside a word.
   Scoped to a modifier rather than changed on the base row, because the profile's panels put short
   durations and dates in that track and want them sized to content. */
.xping-detail-panel__row--identifier {
    grid-template-columns: minmax(0, auto) minmax(0, 1.6fr);
}

.xping-detail-panel__row--identifier .xping-detail-panel__value {
    overflow-wrap: anywhere;
}

/* The previous test and its outcome share the value cell. The name can be long and the outcome must
   stay legible beside it, so the pair wraps to a second line at the trailing edge rather than
   forcing the panel wider. */
.xping-context-previous {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    align-items: baseline;
    column-gap: 8px;
}

.xping-context-previous__outcome {
    font-size: 12px;
}

/* Categories, tags and attributes are an unbounded set of short labels with no natural right-hand
   alignment, so their rows stack the label above the chips and drop the two-track grid. The chip
   row's own bottom margin is cancelled — the row's padding already closes it. */
.xping-detail-panel__row--stacked {
    display: block;
}

.xping-detail-panel__row--stacked .xping-chip-row {
    margin-top: 6px;
    margin-bottom: 0;
}

/* Three tracks: the outcome dot, the run's identity, and its duration. Same padding and hairline as
   .xping-detail-panel__row so the two panels' rows are the same height. */
.xping-run-row {
    display: grid;
    grid-template-columns: 10px minmax(0, 1fr) auto;
    align-items: center;
    column-gap: 8px;
    padding: 8px 16px;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

/* Matches .xping-dist-panel__swatch, so an outcome and a revision tier are the same object at a
   glance in a column that carries both. */
.xping-run-row__dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
}

/* MudTooltip wraps its content in its own .mud-tooltip-root div, so that div is the grid item here
   and the min-width has to sit on it — on the span inside it the cell would still refuse to shrink
   below its content and push the duration out of the panel. */
.xping-run-row > .mud-tooltip-root {
    min-width: 0;
}

.xping-run-row__when {
    display: block;
    font-size: 13px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.xping-run-row__duration {
    font-size: 12px;
    font-variant-numeric: tabular-nums;
}
/* ==========================================================================
   TEST PROFILE — REMAINING TABS
   Environments, History and Flakiness. The three tabs behind the profile page's
   MudTabs strip, ported from Bootstrap.
   ========================================================================== */

/* ── Environments table ───────────────────────────────────────────────────────
   The environment key is the only thing identifying a row, so it truncates rather than wraps and
   the MudTooltip beside it carries the full value. A fixed max-width rather than a share of the
   column: the eight columns are already width-constrained and a percentage here would let one long
   key push the numeric columns out of alignment down the table. */

.xping-env-name {
    display: inline-block;
    max-width: 160px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    vertical-align: bottom;
}

/* ── History filters ──────────────────────────────────────────────────────────
   Three pickers and a clear button on one line, wrapping to the next when they stop fitting. The
   pickers are sized rather than left to their content: an outlined MudSelect sizes to its widest
   option, so the environment picker would change width between tests and the row would never
   settle in the same place twice. */

.xping-history-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    margin-bottom: 12px;
}

.xping-history-filters__select {
    width: 200px;
    flex: 0 0 auto;
}

/* ── Error details dialog ─────────────────────────────────────────────────────
   The label and its copy button sit on one line above the block they describe, so the button is
   attached to the thing it copies rather than floating at the end of a long trace. */

.xping-error-block__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 4px;
}

.xping-stack-trace-panel {
    padding: 8px;
    border-radius: 8px;
    max-height: 320px;
    overflow: auto;
}

/* pre-wrap rather than a horizontal scroller: a frame that runs off the edge of a dialog is a frame
   a reader has to drag to see, and the deep frames are exactly the ones that identify the failure.
   break-word catches the single unbroken generic type name that pre-wrap alone cannot fold. */
.xping-stack-trace {
    margin: 0;
    font-size: 12px;
    white-space: pre-wrap;
    word-break: break-word;
}


/* The retry label and the passed-on-retry tick are one statement, so they hold one line rather than
   letting the tick drop beneath the label it qualifies. */
.xping-attempt-cell {
    white-space: nowrap;
}
/* ── Flakiness tab ────────────────────────────────────────────────────────────
   A panel heading with a trailing pill, the causes and actions rows, and the two bars. The rows
   share .xping-detail-panel's container so a flakiness panel and a profile side panel are the same
   object; only the row divides its width differently — these stack their two lines rather than
   putting a label against a value. */

.xping-panel-heading {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-top: 16px;
    margin-bottom: 8px;
}

.xping-cause-row {
    padding: 12px 16px;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

.xping-cause-row__label {
    font-weight: 500;
}

/* Numbers outside the text block, so a wrapped second line aligns under the first rather than under
   the marker. The list is inside a padded row already, hence no padding of its own beyond the
   indent the markers need. */
.xping-action-list {
    margin: 0;
    padding-inline-start: 20px;
    font-size: 13px;
}

.xping-action-list li + li {
    margin-top: 8px;
}

/* Discs rather than the numbers .xping-action-list carries: the observations under one root cause
   are a set, not a sequence — nothing says to read the second after the first. Same indent and type
   size as that list, so the two panels keep one rhythm. */
.xping-cause-list {
    margin: 4px 0 0;
    padding-inline-start: 20px;
    font-size: 13px;
    color: var(--mud-palette-text-secondary);
}

.xping-cause-list li + li {
    margin-top: 4px;
}

/* ── Pass / fail split bar ────────────────────────────────────────────────────
   Two segments filling one track, with no empty remainder: a run either passed or it did not, so
   there is no unfilled share for a background to represent. Both segment colours come from
   ScoreVisualHelper's outcome palette, set inline, so this bar and every outcome dot on the profile
   agree on what passed and failed look like. */

.xping-split-bar {
    display: flex;
    height: 18px;
    border-radius: 4px;
    overflow: hidden;
    background-color: var(--mud-palette-lines-default);
}

.xping-split-bar__segment {
    display: block;
    height: 100%;
}

.xping-split-bar__legend {
    display: flex;
    justify-content: space-between;
    gap: 12px;
    font-size: 13px;
    font-weight: 500;
    margin-bottom: 4px;
}

/* ── Execution timing range bar ───────────────────────────────────────────────
   The track is the whole min-to-max spread, so it is always full width; only the mean marker moves.
   A marker taller than its track, rather than a fill up to the mean: the mean is a position on the
   range, not a quantity of it, and a fill would read as "this much of the range is accounted for". */

.xping-range-bar {
    position: relative;
    height: 24px;
    margin-bottom: 8px;
}

.xping-range-bar::before {
    content: "";
    position: absolute;
    inset-inline: 0;
    top: 8px;
    height: 8px;
    border-radius: 4px;
    background-color: var(--mud-palette-lines-default);
}

.xping-range-bar__marker {
    position: absolute;
    top: 0;
    width: 3px;
    height: 24px;
    border-radius: 2px;
    background-color: var(--mud-palette-primary);
    transform: translateX(-50%);
}

/* tabular-nums so the three durations keep their decimal alignment as the values change. */
.xping-range-bar__legend {
    display: flex;
    justify-content: space-between;
    gap: 12px;
    font-size: 13px;
    font-variant-numeric: tabular-nums;
    color: var(--mud-palette-text-secondary);
}

/* ── The tab strip ────────────────────────────────────────────────────────────
   Shared, despite sitting in this section: the test profile and the session page both use it, and
   the two strips have to be the same object or the app grows two ways of saying "these are views
   of one thing". Named .xping-tabs rather than after either page for that reason.

   MudTabs draws the tab bar and the panels flush against one another, so the active panel's first
   card reads as hanging off the tab bar. The gap belongs on the panel container rather than on
   each tab's own content, so every tab gets it without repeating the margin. */
.xping-tabs .mud-tabs-panels {
    padding-top: 16px;
}

/* An underline rail: no track, no pill, no hover wash. One hairline separates the tabs from the
   panel below, and the active tab is marked by a heavier accent line sitting directly on top of
   it. Everything else is carried by text colour, which is what keeps the bar quiet next to the
   panels it introduces. MudTabs is told Ripple="false" and MinimumTabWidth="0" in the markup,
   since the ripple is a highlight this design does not want and the 160px default would space the
   labels far wider than they read. */
.xping-tabs .mud-tabs-tabbar {
    background-color: transparent;
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

.xping-tabs .mud-tabs-tabbar .mud-tabs-tabbar-inner,
.xping-tabs .mud-tab {
    min-height: 38px;
}

/* No horizontal padding, so the underline is the width of the label and the first tab's text
   lines up with the page's left edge; the gap between tabs is the margin instead. Sentence case
   and normal tracking, against the uppercase Material default, so the labels match every other
   piece of text on the page. */
.xping-tabs .mud-tab {
    padding: 0;
    margin-right: 28px;
    font-size: 0.82rem;
    font-weight: 500;
    letter-spacing: normal;
    text-transform: none;
    color: var(--mud-palette-text-secondary);
    transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}

.xping-tabs .mud-tab:hover,
.xping-tabs .mud-tab.mud-tab-active:hover {
    background-color: transparent;
    color: var(--mud-palette-text-primary);
}

.xping-tabs .mud-tab.mud-tab-active {
    color: var(--mud-palette-primary);
}

/* Two pixels against the bar's one, and pulled down over the border so the two read as a single
   rail rather than a line above a line. */
.xping-tabs .mud-tab-slider {
    height: 2px;
    bottom: -1px;
    background: var(--mud-palette-primary);
}


/* ==========================================================================
   Test explorer (/workspace)
   ==========================================================================
   Global rather than scoped to WorkspaceExplorer/Index.razor. Blazor stamps its scope attribute
   only on elements a component renders itself, and the list container is a MudPaper, so a rule in
   a .razor.css targeting a class handed to a MudBlazor Class parameter compiles and then matches
   nothing. Same constraint as the sessions list above.

   The container, the states and the empty panel are .xping-list, .xping-list__alert and
   .xping-list__empty, reused unchanged — the explorer is a list of rows in a panel, the same
   component family as /sessions, and only the row's own geometry differs.
   ========================================================================== */

/* ── Search and filters ─────────────────────────────────
   The bar above the tree. Its chips are the .xping-chipset treatment defined further up, unchanged
   — this only lays the two controls out beside each other and caps the field, which would otherwise
   take the whole row and leave the chips wrapped underneath at every width.

   Wraps rather than shrinking below the cap: the placeholder names four things the term is matched
   against, and a field narrow enough to clip it stops saying what it searches. */

.xping-tree-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px;
    margin-bottom: 16px;
}

.xping-tree-filters__search {
    flex: 1 1 260px;
    max-width: 340px;
    margin: 0;
}

/* Under the bar rather than inside the field. MudTextField's helper text reserves its line whether
   or not there is anything on it, so putting it there would move the tree down by a row that is
   blank almost all of the time. */
.xping-tree-filters__hint {
    display: block;
    margin: -8px 0 16px;
}

/* ── Rows ─────────────────────────────────────────────────────────────────────
   One track list for every row in the list, folder and leaf alike. Each row is its own grid — a
   grid does not align across siblings — so the only thing that puts a class's weakest figure
   directly above a test's confidence figure is both rows resolving the same tracks against the same
   container width. That is why every column but the name is a fixed width: an auto or
   content-sized track would size to its own row's content and the numbers would stagger down the
   page, which is the one failure the row contract exists to prevent.

   Three of the seven tracks are empty on a folder row. They are held open rather than dropped so
   the shared columns do not shift when a class is expanded, and because a folder deliberately has
   no success rate — a lifetime figure set beside window-bound bands would put two time ranges in
   one row under headings that give the reader no way to tell them apart.

   The distribution column is a fixed width rather than a minmax range, and that is load-bearing
   rather than tidiness. Grid grows every non-flexible track to its growth limit before a 1fr track
   receives anything, so a minmax(150px, 240px) beside minmax(0, 1fr) takes its full 240px first and
   the name track absorbs the whole shortfall. Below about 1000px that starved the name cell to less
   than its own icons, and since the label may shrink to nothing, the row rendered a count with no
   name — the name dropping in favour of a placeholder cell holding space for a bar that is not
   drawn yet. A fixed width cannot outbid the name, and it makes the folder-and-leaf alignment this
   list exists for a property of the track list rather than of the content that lands in it.

   The accent is not a track. It is the ::before below, which keeps an unaccented row's content box
   identical to an accented one's and avoids the leading-narrow-track trap recorded further up this
   file, where auto-placement flowed a row's second-line cells into the accent column. */

.xping-tree-row {
    display: grid;
    grid-template-columns:
        minmax(0, 1fr)  /* 1  name — chevron, icon, name, counts, chips */
        200px           /* 2  distribution bar | leaf outcome strip */
        64px            /* 3  success rate — leaf only */
        88px            /* 4  weakest member | leaf confidence */
        64px            /* 5  trend — leaf only */
        84px            /* 6  last run */
        72px;           /* 7  average duration — leaf only */
    align-items: center;
    gap: 12px;
    width: 100%;
    padding: 10px 16px;
    text-align: start;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
    position: relative;
}

.xping-tree-row:last-child {
    border-bottom: none;
}

.xping-tree-row:hover {
    background-color: var(--mud-palette-table-hover);
}

/* Every row is a focus target: the tree is one tab stop and the arrows move a roving tabindex within
   it, so the ring has to say which row holds it. Inset, because the row runs the full width of the
   panel and an outset ring would be clipped by its rounded corners. */
.xping-tree-row:focus-visible {
    outline: 2px solid var(--mud-palette-primary);
    outline-offset: -2px;
}

/* The reliability stripe, as on the sessions row: a pseudo-element rather than a border-left so
   that a row without one keeps the same content box. The variable is unset until the folder-row
   visuals land, at which point the stripe appears with no change to this rule. */
.xping-tree-row::before {
    content: "";
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    width: 3px;
    background-color: var(--xping-row-accent, transparent);
}

/* ── The anchored row ─────────────────────────────────────────────────────────
   The node a URL named, marked so a reader who followed a deep link can see where they landed
   among rows that otherwise look alike. --xping-accent-bg is the application's own "this one" tint,
   already carrying the active nav item and the selected chip, so the explorer says it the same way
   the shell does rather than inventing a second vocabulary for the same idea.

   The hover rule is restated because the row's own hover would otherwise take the mark away under
   the pointer — the one moment a reader is most likely to be looking at it. The mark is not a
   selection: it is spent as soon as the reader works a chevron, and the code-behind drops it then. */
.xping-tree-row--anchored,
.xping-tree-row--anchored:hover {
    background-color: var(--xping-accent-bg);
}

/* What scrollIntoView is given. Out of flow so it adds no grid item to a row whose every cell is
   placed by hand, and the row's full height so centring it centres the row rather than its top
   edge. Zero width: it is a coordinate, not a decoration — the tint above is what is seen. */
.xping-tree-row__anchor-mark {
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    width: 0;
    pointer-events: none;
}

/* ── Cell placement ───────────────────────────────────────────────────────────
   Every cell is placed explicitly. A folder row emits four of the seven cells, so under
   auto-placement its weakest figure would land in the success-rate track and its timestamp in the
   weakest track — the folder columns sitting two tracks left of the leaf columns they are supposed
   to line up with, and nothing on screen saying so until a class is expanded. */

.xping-tree-row__name {
    grid-column: 1;
    min-width: 0;
    display: flex;
    align-items: center;
    gap: 6px;

    /* Clips its own contents rather than letting them widen the track. With the floor on the label
       below, what gives way under a squeeze is the trailing child count — never the name. */
    overflow: hidden;

    /* Depth indents the name and nothing else: the numeric columns have to stay in a column
       regardless of how deep the row sits. Delivered as a custom property on the row for the same
       reason the accent is — the value is per-row data, not a class the stylesheet can enumerate. */
    padding-inline-start: calc(var(--xping-tree-depth, 0) * 20px);

    /* The row's height, in practice: this cell is the tallest of the seven, and what makes it tall
       is the name's line box. A folder's 14px label resolves to 24px and a leaf's 13px monospace one
       to 19.5px, so without a floor the leaf rows come out 4.5px shorter than the folder rows they
       are meant to continue — a change of rhythm exactly where a class opens. Pinned on the cell
       rather than as a line-height on each name, so a row stays this tall whatever lands in it. */
    min-height: 24px;
}

.xping-tree-row__dist {
    grid-column: 2;
    min-width: 0;
}

.xping-tree-row__rate {
    grid-column: 3;
}

.xping-tree-row__score {
    grid-column: 4;
}

.xping-tree-row__trend {
    grid-column: 5;
}

.xping-tree-row__when {
    grid-column: 6;
    font-size: 12px;
    color: var(--mud-palette-text-secondary);
    white-space: nowrap;
}

.xping-tree-row__duration {
    grid-column: 7;
}

/* ── Name cell internals ──────────────────────────────────────────────────────
   The chevron and the kind icon are a fixed leading pair, so a row with no children indents its
   name by the same amount as one that expands — otherwise names would jag in and out down the
   list depending on whether a namespace happened to hold anything. */

.xping-tree-row__chevron,
.xping-tree-row__icon {
    /* Both are spans where they stand in as placeholders — on a folder row with nothing beneath it,
       and on both slots of a leaf row. Neither width nor height applies to a non-replaced inline
       box, so an empty placeholder would occupy nothing at all and the leaf names would sit 36px
       left of where the indent puts them, breaking the one column this list is built around. */
    display: inline-block;
    width: 18px;
    height: 18px;
    font-size: 18px;
    flex-shrink: 0;
}

.xping-tree-row__chevron {
    color: var(--mud-palette-text-secondary);
}

.xping-tree-row__icon {
    color: var(--mud-palette-text-secondary);
    opacity: 0.75;
}

/* overflow: hidden on a flex item resolves its automatic minimum size to zero, so without a floor
   the label is the first thing the row gives up — and a row showing "3 tests" against no name is
   worse than one showing "BrokenF…". Enough characters to identify the row, never enough to push
   the numeric columns out of line. */
.xping-tree-row__label {
    min-width: 5ch;
    font-size: 14px;
    color: var(--mud-palette-text-primary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.xping-tree-row__count {
    font-size: 12px;
    color: var(--mud-palette-text-secondary);
    white-space: nowrap;
    flex-shrink: 0;
}

/* ── Folder row payload ───────────────────────────────────────────────────────
   What the row is for: the subtree's reliability distribution, its weakest member, and the two
   counts that qualify them. Nothing here carries a colour literal — the five reliability bands
   arrive inline from ScoreVisualHelper.GetScoreCategoryColor, because the scale has five colours
   and the shared xping-tone-* classes have three, so a class would silently merge two bands. */

/* MudTooltip's root is an inline-block wrapper, so a width:100% bar inside one resolves against a
   shrink-to-fit box and collapses to the sum of its segments' 2px minimums. The outcome bar on the
   sessions list escapes this only because its cell is a column flex container that stretches the
   wrapper on the cross axis; this cell is a plain block, and would not. Same specificity as
   MudBlazor's own `.mud-tooltip-root.mud-tooltip-inline`, so this wins on source order rather than
   by outranking it — which holds only because app.css loads after MudBlazor's stylesheet.

   The child combinator is load-bearing. This column is shared: a folder row puts the distribution
   bar here, wrapped in one tooltip that is a direct child of the cell, but a leaf row puts an
   OutcomeSparkline here whose every populated slot is separately tooltipped, two levels down.
   Written as a descendant selector this rule reached those slots too and stretched each 9px bar to
   the full column, which flex-shrink then squashed back into an even row — plausible-looking, and
   wrong the moment a test had fewer runs than the strip has slots. */
.xping-tree-row__dist > .mud-tooltip-root {
    display: block;
    width: 100%;
}

.xping-tree-row__bar {
    display: flex;
    width: 100%;
    height: 10px;
    border-radius: 5px;
    overflow: hidden;
    background-color: var(--mud-palette-lines-default);
    cursor: help;
}

/* Sized against what shares this column rather than against the sessions list's 4px outcome bar,
   which sits above a line of counts and only has to read as a proportion. The leaf row's outcome
   strip is 14px, and a 6px folder bar directly above it would read as two different columns —
   which is the alignment failure the shared track list exists to prevent. */
.xping-tree-row__bar-segment {
    display: block;
    height: 100%;
    /* One unreliable test among four hundred would otherwise round away to nothing, and an
       invisible segment reads as a zero — which for the band that matters is the wrong answer. Six
       segments at the floor take 12px of the narrow track, so the distortion is real; it is still
       the right trade against silently dropping the band a reader is looking for. */
    min-width: 2px;
}

/* A hairline in the surface colour between segments. Reading best-first puts Highly reliable's
   #10B981 directly against Reliable's #34D399, which at this height are all but the same green, and
   it also stops a segment clamped to its 2px floor from reading as part of its neighbour. Inset on
   the leading edge, so the first segment's sits under the track's own rounded corner. */
.xping-tree-row__bar-segment + .xping-tree-row__bar-segment {
    box-shadow: inset 1px 0 0 var(--mud-palette-surface);
}

/* ── Weakest member ───────────────────────────────────────────────────────────
   Tabular figures, so the scores form a column a reader can compare down the page rather than a
   ragged edge. The cell is a flex row because the staleness marker sits beside the number it
   qualifies rather than beneath it. */

.xping-tree-row__score {
    display: flex;
    align-items: center;
    gap: 4px;
}

/* A bare anchor, so it needs the affordances MudLink would have supplied — and the colour it must
   NOT supply: every MudLink Color maps to a class that sets one, and Color.Inherit's
   `.mud-inherit-text` does it with !important, which no tone class can outrank. The tone is the
   point of this cell, so the link is plain and styled here. */
.xping-tree-row__weakest {
    font-size: 13px;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
    text-decoration: none;
}

.xping-tree-row__weakest:hover {
    text-decoration: underline;
}

/* Visible focus, since this is the row's only tab stop until the chevrons are wired. */
.xping-tree-row__weakest:focus-visible {
    outline: 2px solid var(--mud-palette-primary);
    outline-offset: 2px;
    border-radius: 2px;
}

/* Neutral and small. It qualifies the score without competing with it: the band shown is still the
   best estimate available — stale, not wrong — so this must not read as an error. */
.xping-tree-row__stale-icon {
    width: 13px;
    height: 13px;
    font-size: 13px;
    flex-shrink: 0;
    color: var(--mud-palette-text-secondary);
    opacity: 0.75;
}

/* ── Name-cell chips ──────────────────────────────────────────────────────────
   The chips are wrapped in MudTooltip, so the flex item in the name cell is the tooltip's own root
   and not the pill inside it — a rule on the pill would compile and do nothing, the same trap
   recorded further up this file. Everything the chip looks like still comes from .xping-pill; this
   only stops the wrapper shrinking, so that a squeeze spends itself on the label's ellipsis, which
   still identifies the row. */

.xping-tree-row__name > .mud-tooltip-root {
    flex-shrink: 0;
}

/* The words beside the em dash when nothing beneath the row is scored. Small enough to sit in an
   88px track next to the dash, loud enough to be read without hovering. */
.xping-tree-row__unscored {
    font-size: 10px;
    line-height: 1.4;
    white-space: nowrap;
    color: var(--mud-palette-text-secondary);
}

/* ── Chevron ──────────────────────────────────────────────────────────────────
   A bare button rather than MudIconButton, which brings a 40px ripple target and its own padding —
   that would push every name in the list sideways to buy nothing, and the row is already the click
   target for everything else. So the browser's button chrome is stripped and the 18px box the
   placeholder span holds is kept exactly, which is what stops names jagging in and out between rows
   that open and rows that do not.

   Qualified by the element, because the same class is worn by three things: this button, the
   placeholder on a folder row with nothing beneath it, and the leading placeholder on every leaf
   row. Only the first is a control, and only it should carry a pointer, a hover colour, a focus
   ring or a hit area. */

button.xping-tree-row__chevron {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: none;
    background: none;
    color: var(--mud-palette-text-secondary);
    cursor: pointer;
    position: relative;
}

/* Sized on the box, not on the glyph: the button also holds a spinner while a class is fetching, and
   a box that sized to its content would shrink by 2px at that moment and shunt the name across. */
button.xping-tree-row__chevron .mud-icon-root {
    width: 18px;
    height: 18px;
    font-size: 18px;
}

button.xping-tree-row__chevron:hover {
    color: var(--mud-palette-text-primary);
}

/* The glyph is 18px because the icon slot beside it is, and the names have to stay in a column. A
   pointer target that small is not one, so the hit area is grown by an overlay rather than by
   padding, which would push every name in the list sideways. */
button.xping-tree-row__chevron::before {
    content: "";
    position: absolute;
    inset: -8px;
}

button.xping-tree-row__chevron:focus-visible {
    outline: 2px solid var(--mud-palette-primary);
    outline-offset: 1px;
    border-radius: 2px;
}

/* Smaller than the glyph it stands in for, so it reads as activity inside the control rather than as
   the control having grown. MudProgressCircular sizes from its own root, so both axes are set. */
.xping-tree-row__chevron-busy {
    width: 14px;
    height: 14px;
}

/* ── Leaf rows ────────────────────────────────────────────────────────────────
   A test, beneath its class, on the same seven tracks — which is the point: a reader compares a
   class's distribution against the scores of the tests in it by reading straight down, and that only
   works while both rows are the same grid. The three tracks a folder leaves empty are filled here.

   The row is an <a>, so it needs the two declarations that stop a link looking like one inside a
   list. Its focus ring is the shared one above — every row carries the roving tabindex now, so a
   rule for this kind alone would only be the general one written twice. */

.xping-tree-row--leaf {
    text-decoration: none;
    color: inherit;
}

/* Monospace, because a test name is an identifier and the reader is scanning a column of them for a
   differing character. Same ellipsis and same floor as the folder label above it. */
.xping-tree-row__test-name {
    min-width: 5ch;
    font-size: 13px;
    color: var(--mud-palette-text-primary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The argument tuple that tells one [DataRow] case from its siblings. Secondary and a size down, so
   three rows of a parameterized test read as one name with three arguments rather than as three
   unrelated names.

   It has a floor of its own, and that is the point: under a squeeze both it and the name give way
   together, and the tuple must not be the one that disappears. Within a class the method name is
   the half the sibling rows share — three rows reading "MemberDataTes…" are still told apart by
   what follows, where three reading the full name and nothing else are the very defect OQ-2 exists
   to fix. Measured at the 900px breakpoint, where both do truncate. */
/* The search term inside a test's name. A bare <mark> is a browser yellow that belongs to neither
   theme and reads as an annotation the reader made; this is the accent tint that already carries the
   active nav item, the selected chip and the anchored row, so a marked term says "this is what you
   asked for" in the same voice as the rest of the page. The text keeps its own colour, because the
   name is monospace and recolouring half of it would read as two names. */
.xping-tree-row__hit {
    background-color: var(--xping-accent-bg);
    color: inherit;
    border-radius: 3px;
    padding: 0 1px;
}

.xping-tree-row__case {
    min-width: 8ch;
    font-size: 12px;
    color: var(--mud-palette-text-secondary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The outcome strip needs no rule of its own in this cell, unlike the distribution bar that shares
   the track. That bar is width:100% inside MudTooltip's shrink-to-fit wrapper and collapses without
   help; the strip is twelve fixed-width bars that measure themselves, and it is not inside a tooltip
   — the tooltips are on the individual bars. A display rule here would tie on specificity with
   OutcomeSparkline's own scoped one, win on load order, and break the row into a stack. */

/* The leaf's numeric cells. Tabular figures for the same reason the weakest member has them: these
   form columns a reader compares down the page, and proportional digits make a ragged edge of them.
   The tone comes from a class on the cell — delta tone on the trend, neutral on an absent rate — so
   nothing here sets a colour. */
.xping-tree-row__figure {
    font-size: 13px;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.xping-tree-row__duration {
    color: var(--mud-palette-text-secondary);
}

/* ── The rest of a class ──────────────────────────────────────────────────────
   The row that offers the tests a class did not hand over in its first slice. It needs no track-list
   change: it fills cell 1 and leaves the other six empty, as a folder row already does for three of
   them, and every cell in this list is placed explicitly so an absent one cannot shift a present one.

   No accent stripe and no hover tint — the row is a control rather than a thing to compare, and a
   hover state on the whole row would promise a click anywhere in it, where only the button acts. */
.xping-tree-row--more:hover {
    background-color: var(--mud-palette-surface);
}

/* The button carries the row's affordance, so it is the thing that highlights. A text action against
   the theme's primary rather than a MudButton: this sits inside a 24px name cell whose height every
   row above it shares, and a filled control with its own padding would break that rhythm at exactly
   the boundary the row marks. */
button.xping-tree-row__more {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 0;
    border: none;
    background: none;
    font-family: inherit;
    font-size: 13px;
    color: var(--mud-palette-primary);
    cursor: pointer;
}

button.xping-tree-row__more:hover {
    text-decoration: underline;
}

button.xping-tree-row__more:focus-visible {
    outline: 2px solid var(--mud-palette-primary);
    outline-offset: 2px;
    border-radius: 2px;
}

/* Disabled while the slice is out. It keeps its label rather than swapping to "Loading…" — the
   spinner beside the words already says so, and a label that changes width shifts the row it is in. */
button.xping-tree-row__more:disabled {
    color: var(--mud-palette-text-secondary);
    cursor: default;
    text-decoration: none;
}

.xping-tree-row__more-busy {
    width: 14px;
    height: 14px;
}

/* ── Narrow viewports ─────────────────────────────────────────────────────────
   Each breakpoint restates the track list as well as re-placing the survivors: a hidden cell still
   leaves its track, and the track keeps its width. */

/* Trend and average duration go first. Both are leaf-only refinements — a test still states its
   score, its run history and when it last ran without them. */
@media (max-width: 1200px) {
    .xping-tree-row {
        grid-template-columns: minmax(0, 1fr) 200px 64px 88px 84px;
    }

    .xping-tree-row__trend,
    .xping-tree-row__duration {
        display: none;
    }

    .xping-tree-row__when {
        grid-column: 5;
    }
}

/* Then the leaf's success rate. Name, distribution and weakest never drop: they are the reason the
   page exists. Last run keeps its place for the reason the sessions row keeps its timestamp — it is
   the row's anchor at any width. */
@media (max-width: 900px) {
    .xping-tree-row {
        grid-template-columns: minmax(0, 1fr) 144px 88px 84px;
    }

    .xping-tree-row__rate {
        display: none;
    }

    .xping-tree-row__score {
        grid-column: 3;
    }

    .xping-tree-row__when {
        grid-column: 4;
    }
}

/* ==========================================================================
   Settings (/settings)
   ==========================================================================
   The settings area's only styles. It replaced a Bootstrap block near the top of this file — a
   :has() shell that synthesised a boxed page out of a nav column beside a content column, the rules
   for the second sidebar that column held, and two page-heading rules — deleted once every page had
   been rebuilt against these. The class names are deliberately not repeated here: spec §12 checks
   they are gone with a grep over src/, and a comment naming them would answer it wrongly. Nothing
   in the Portal styles a settings page from anywhere else.

   Global rather than scoped to the components under Components/Shared/Settings/. Blazor stamps its
   scope attribute only on elements a component renders itself, and both the panel and the action row
   are MudPaper, so a rule in a .razor.css targeting a class handed to a MudBlazor Class parameter
   compiles and then matches nothing. Same constraint as the sessions list and the test explorer.

   The panels are the .xping-detail-panel family's larger sibling: a bordered surface with hairline
   dividers, at the 12px radius the redesigned pages use rather than the side column's 8px, because
   these run the full 1280px measure and a small radius on a wide box reads as an unfinished square.
   ========================================================================== */

/* ── Page furniture ───────────────────────────────────────────────────────────
   The sentence under the title. Bottom margin only — the type and colour are the MudText the page
   passes, so a page that wants a different emphasis changes the component rather than this rule. The
   gap is larger than the heading's own 12px: the lede closes the header block, and the first panel
   should read as the start of the page's content rather than as a third line of the title. */

.xping-settings-lede {
    margin-bottom: 24px;
}

/* ── Hub ──────────────────────────────────────────────────────────────────────
   auto-fit rather than a fixed column count, so the grid reflows on its own between the sidebar's
   960px breakpoint and a wide monitor without a media query per step. 280px is the width at which a
   card's contents line — "Password · Two-factor · Login history" — stops wrapping to three lines. */

.xping-settings-hub {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
    gap: 12px;
}

/* The card states its own surface, border and radius rather than being a MudPaper, because an
   available card is an <a> and MudPaper renders a div. Colour is inherited and the underline
   removed so the anchor reads as a panel the reader can press, not as a block of link text. */
.xping-settings-card {
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 16px;
    border: 0.5px solid var(--mud-palette-lines-default);
    border-radius: 12px;
    background-color: var(--mud-palette-surface);
    color: inherit;
    text-decoration: none;
}

/* The border is the whole hover affordance: a background change would have to move the card off the
   surface every other panel on the page sits on, and a shadow would lift one card out of a grid the
   reader is scanning as a plane. focus-visible carries the same treatment so the keyboard reader
   sees what the pointer reader sees. */
.xping-settings-card:hover,
.xping-settings-card:focus-visible {
    border-color: var(--mud-palette-primary);
}

/* Flush with the page ground rather than raised onto the surface, which is what says the card is not
   a thing to press — the live cards stand off the page and this one does not. Deliberately no hover
   rule: the inert variant is a div, so there is no anchor state to undo, and adding one would invite
   a click that goes nowhere. */
.xping-settings-card--unavailable {
    background-color: var(--mud-palette-background);
    color: var(--mud-palette-text-secondary);
}

/* ── Panels ───────────────────────────────────────────────────────────────────
   overflow is what lets the body and the footer be plain strips: whichever ends up last is clipped
   to the panel's radius, so neither has to know whether it closes the panel. That is also why a
   panel with no footer keeps its rounded bottom corners without a modifier.

   0.5px overrides MudPaper's outlined border. app.css loads after MudBlazor.min.css, so this wins at
   equal specificity without !important. */

.xping-settings-panel {
    border-width: 0.5px;
    border-radius: 12px;
    overflow: hidden;
    margin-bottom: 24px;
}

/* The heading strip pushes its trailing affordance to the end and shares the title's baseline, the
   same shape the detail panels use for their "All" link. Wraps rather than shrinking, so a long
   panel title does not squeeze the link out of legibility on a narrow viewport. */
.xping-settings-panel__head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: 4px 16px;
    padding: 12px 16px;
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

.xping-settings-panel__body {
    padding: 16px;
}

/* One step back to the page ground, so the strip reads as the panel's base rather than as another
   row of its body — the reader should see where the panel's content stops and its controls start.
   The hairline lives here as a border-top, which is what makes the footer optional: no footer, no
   border, and nothing left to clean up. */
.xping-settings-panel__footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: 16px;
    padding: 12px 16px;
    border-top: 1px solid var(--mud-palette-lines-default);
    background-color: var(--mud-palette-background);
}

/* ── Action rows ──────────────────────────────────────────────────────────────
   A panel whose whole content is one statement and one control, so it carries .xping-settings-panel
   for its shape and only its interior differs. Padded like a body rather than like a head, because
   the statement is the panel's content and not its title.

   The text column takes the slack and the control sizes to itself, and the pair wraps: below roughly
   a phone's width the control drops under the description rather than crushing it, since a truncated
   "Require a code from an authenticator app at sign-in" stops saying what the button turns on. */

.xping-settings-action-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 12px 16px;
    padding: 16px;
}

/* ── Field pairs ──────────────────────────────────────────────────────────────
   First/last name, company/department. Two equal columns rather than tracks sized to content: these
   are a pair of peers and a reader reads them as one line, which a 60/40 split would deny. */

/* ── Upload failure reason ────────────────────────────────────────────────────
   The row beneath a failed upload batch in the session's Uploads tab, carrying why it failed.

   Tinted rather than outlined, and with no border between it and the row above: it is a continuation
   of that row rather than an entry of its own, and a table this dense reads as one list of batches —
   an eight-batch failure would otherwise look like sixteen rows. The left rule is what ties it to the
   status pill two cells up, in the same colour that pill already uses. */
.xping-upload-failure > td {
    background-color: var(--xping-danger-bg);
    border-top: none;
    border-left: 3px solid var(--mud-palette-error);
    padding: 8px 16px;
}

.xping-upload-failure__body {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    color: var(--mud-palette-error);
}

/* The reason is a sentence and the table is wide; left to itself it would run the full width of the
   viewport and become a line the eye cannot track back from. */
.xping-upload-failure__body > div {
    max-width: 90ch;
}

.xping-settings-field-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 16px;
}

/* One column below the phone breakpoint. Two text fields side by side stop being fillable long
   before they stop fitting. */
@media (max-width: 600px) {
    .xping-settings-field-grid {
        grid-template-columns: 1fr;
    }
}

/* ── Login history rows ───────────────────────────────────────────────────────
   The device cell, on Security's preview rows and on the full list at
   /settings/security/login-history — one rule, because the two views show the same string in the
   same place and a device cut at one width on one page and another on the other reads as two
   different lists. It carries a raw user agent, which is long, and the panel runs the full 1280px
   measure — so the string is cut by the space actually left beside the timestamp rather than by a
   character count picked for the seven-column table these rows replaced. The row's grid track is
   already minmax(0, 1fr), which is what lets the cell shrink; display: block is what lets the
   overflow apply to an inline element. */

.xping-settings-login-device {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* ── API key rows ─────────────────────────────────────────────────────────────
   A row that is a link with a destructive control beside it. The anchor is a sibling of the delete
   button rather than its parent, which is the whole point of the class: a button nested inside a
   link is one keyboard stop that does two things, and a pointer reader who misses the button by two
   pixels navigates away instead of deleting — or, worse, the other way round.

   The anchor takes the slack and the button sizes to itself, so the common action gets the full
   width of the row and the destructive one keeps the small, deliberate target it should have. */

.xping-settings-key-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 0;
    border-bottom: 1px solid var(--mud-palette-lines-default);
}

/* min-width: 0 so the name and the key prefix inside can be cut by the row rather than pushing the
   delete button off the end — the same constraint .xping-settings-login-device solves one level
   down. Colour inherited and the underline dropped, so the row reads as a surface the reader can
   press rather than as a block of link text. */
.xping-settings-key-row__link {
    display: flex;
    flex: 1;
    min-width: 0;
    flex-direction: column;
    gap: 2px;
    color: inherit;
    text-decoration: none;
}

/* The hover lands on the name alone rather than tinting the whole row: the row's trailing cell is a
   delete button, and a row-wide hover state would suggest the button is part of what is about to be
   followed. */
.xping-settings-key-row__link:hover .xping-settings-key-row__name,
.xping-settings-key-row__link:focus-visible .xping-settings-key-row__name {
    color: var(--mud-palette-primary);
}

.xping-settings-key-row__name {
    font-size: 14px;
    font-weight: 500;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* ── Feature lists ────────────────────────────────────────────────────────────
   Subscription's plan features, both the included list and the locked list under "Available in …".
   Two columns because the list runs from five entries on Trial to twenty-six on Enterprise, and a
   single column of five-word statements down a 1280px measure is mostly whitespace.

   Deliberately not .xping-settings-field-grid, which is the same shape at a different job: that one
   pairs form controls at their own 16px rhythm, this one stacks short statements at a tighter row
   gap. Merged, neither could be adjusted without checking what the other does with it. */

.xping-settings-feature-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 8px 16px;
}

/* The same breakpoint the field grid collapses at — one settings page should not have two ideas
   about when a two-column layout stops fitting. */
@media (max-width: 600px) {
    .xping-settings-feature-grid {
        grid-template-columns: 1fr;
    }
}

/* A feature and its status on one line. The status sits at the end of the entry rather than at the
   column's trailing edge, so a "Coming soon" pill reads as belonging to the name beside it rather
   than to the row across the gutter. */
.xping-settings-feature {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
    font-size: 13px;
}

.xping-settings-feature__name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Muted for a feature the plan grants but the product cannot yet do, so the list reads as what
   works today before the reader gets to the pill (spec §7.6). */
.xping-settings-feature--pending .xping-settings-feature__name {
    color: var(--mud-palette-text-secondary);
}


/* ==========================================================================
   Projects list (/projects)
   ==========================================================================
   Global rather than scoped to Index.razor, for the reason recorded above the sessions list: Blazor
   stamps its scope attribute only onto plain HTML elements, and the panel and every row here are
   MudBlazor components, so a scoped rule would have nothing to attach to.

   The panel and the state blocks are the shared .xping-list family — a bordered box with rows in it
   is not a per-page idea, and the sessions list and the test explorer already share those. The row
   is a separate family. It shares a silhouette with .xping-list-row and nothing else: three content
   cells against five, and a structure the session row does not have (see below). Coupling them would
   mean a change to the session row's geometry silently moved this one. The values they do share —
   surface, hover, radius, hairline, stripe width — come from the same tokens, so the two still read
   as the same component.
   ========================================================================== */

/* Two elements rather than one, which is the structural difference from .xping-list-row.
   There, the row *is* the MudLink: the anchor carries the grid. That leaves nowhere to put the
   actions menu except inside the anchor, and a button inside an anchor is invalid markup whose
   recovery differs per browser. So the outer element is a plain div holding the row's chrome — the
   hover, the bottom hairline, the accent stripe — and lays out its two children, the link and the
   menu. The link then carries the content columns.

   The consequence to keep in mind when editing: hover belongs here, not on the link, or the row
   would only light up over three quarters of itself. */
.xping-project-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
    gap: 12px;
    width: 100%;
    padding: 10px 16px;
    background-color: var(--mud-palette-surface);
    border-bottom: 1px solid var(--mud-palette-lines-default);
    position: relative;
}

.xping-project-row:last-child {
    border-bottom: none;
}

.xping-project-row:hover {
    background-color: var(--mud-palette-table-hover);
}

/* The reliability stripe, as a pseudo-element rather than a border-left so that a row without one
   keeps the same content box — a conditional border would shift every unstriped row three pixels.
   The colour arrives inline as --xping-row-accent from ScoreVisualHelper, because the scale has five
   bands and the shared xping-tone-* classes have four: a class would silently merge two of them. An
   inactive row sets no accent, so this stays transparent. */
.xping-project-row::before {
    content: "";
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    width: 3px;
    background-color: var(--xping-row-accent, transparent);
}

/* Nothing to hover towards on a row that is still loading, and a hover that lit up a skeleton would
   suggest it could be clicked. */
.xping-project-row--skeleton:hover {
    background-color: var(--mud-palette-surface);
}

/* The three content cells. A grid rather than nested flex, so the health bars and the test counts
   line up down the list: a reader compares one project's health against another's, not against
   whatever the row above happened to put there, and per-row flex would let the columns drift as
   project names change width.

   Health is fixed at 200px rather than proportional. The bar only has to show a proportion, and past
   a couple of hundred pixels the extra width adds nothing — where the identity column can always use
   more, since a project name and a slug are what actually vary. Activity is sized to hold "9999
   tests" over a relative age on the line beneath it. */
.xping-project-row__link {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 200px 104px;
    align-items: center;
    gap: 12px;
    min-width: 0;
    text-align: start;
}

/* ── Identity cell ──────────────────────────────────────────────────────────── */

/* min-width: 0 is what lets the name truncate. A grid item defaults to min-content width, which
   would otherwise push the whole row wider than its container rather than ellipsising. */
.xping-project-row__identity {
    display: flex;
    flex-direction: column;
    gap: 3px;
    min-width: 0;
}

.xping-project-row__title {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
}

.xping-project-row__name {
    font-size: 14px;
    font-weight: 500;
    color: var(--mud-palette-text-primary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* A retired project is still a link and still readable; it just stops competing with the projects
   that are collecting. Secondary rather than disabled — the row is not inert. */
.xping-project-row--inactive .xping-project-row__name {
    color: var(--mud-palette-text-secondary);
}

/* Deliberately not MudChip, whose smallest size still carries the height and inline margins of a
   control. This says one word beside a name and must not out-weigh it. */
.xping-project-row__chip {
    flex-shrink: 0;
    padding: 0 6px;
    border: 1px solid var(--mud-palette-lines-default);
    border-radius: 999px;
    font-size: 11px;
    line-height: 18px;
    color: var(--mud-palette-text-secondary);
    background-color: var(--mud-palette-surface);
    white-space: nowrap;
}

.xping-project-row__meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 4px 10px;
    font-size: 12px;
    color: var(--mud-palette-text-secondary);
    min-width: 0;
}

/* The slug is the one thing here allowed to truncate: it is what a reader copies into CI config, and
   a clipped slug is still recognisable from its prefix where a clipped count is not. Set slightly
   smaller than the line it shares, because a monospace face at the same size reads as larger. */
.xping-project-row__slug {
    font-size: 11px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.xping-project-row__meta-item {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    min-width: 0;
    white-space: nowrap;
}

/* MudIcon sizes its svg as 1em, so the meta line's 12px would shrink a Lucide glyph until its stroke
   work closes up. Sized here so the icon stays legible without the label having to grow to meet it. */
.xping-project-row__icon {
    width: 14px;
    height: 14px;
    font-size: 14px;
    flex-shrink: 0;
    opacity: 0.75;
}

/* ── Health cell ────────────────────────────────────────────────────────────── */

.xping-project-row__health {
    display: flex;
    flex-direction: column;
    gap: 4px;
    min-width: 0;
}

/* MudTooltip's root is an inline-block wrapper, so a width:100% bar inside one resolves against a
   shrink-to-fit box and collapses to the sum of its segments' 2px minimums. The test explorer's
   folder bar hit exactly this. Same specificity as MudBlazor's own
   `.mud-tooltip-root.mud-tooltip-inline`, so this wins on source order rather than by outranking it
   — which holds only because app.css loads after MudBlazor's stylesheet. */
.xping-project-row__health .mud-tooltip-root {
    display: block;
    width: 100%;
}

/* The line beneath the bar, saying in words what the bar says in colour. Not optional decoration:
   the bar alone would make colour the sole carrier of the row's verdict. */
.xping-project-row__health-summary {
    font-size: 12px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* An inactive row replaces the bar and its summary with one line saying why there is nothing to
   show, rather than drawing an empty track — which reads as a bar that failed to render. */
.xping-project-row__health-none {
    font-size: 12px;
    color: var(--mud-palette-text-secondary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Shorter than the test explorer's 10px folder bar, which is sized against the 14px outcome strip it
   shares a column with. Nothing sits under this one, so it can be the quieter of the two. */
.xping-health-bar {
    display: flex;
    width: 100%;
    height: 6px;
    border-radius: 3px;
    overflow: hidden;
    background-color: var(--mud-palette-lines-default);
    cursor: help;
}

.xping-health-bar__segment {
    display: block;
    height: 100%;
    /* One unreliable test among four hundred would otherwise round away to nothing, and an invisible
       segment reads as a zero — which for the band a reader is looking for is the wrong answer. Six
       segments at the floor take 12px of the 200px cell, so the distortion is real; it is still the
       right trade against silently dropping the band that matters. */
    min-width: 2px;
}

/* A hairline in the surface colour between segments. Reading best-first puts highly reliable's
   #10B981 directly against reliable's #34D399, which at 6px are the same green, and it also stops a
   segment clamped to its 2px floor from reading as part of its neighbour. Inset on the leading edge,
   so the first segment's sits under the track's own rounded corner. */
.xping-health-bar__segment + .xping-health-bar__segment {
    box-shadow: inset 1px 0 0 var(--mud-palette-surface);
}

/* ── The bar outside a list row ───────────────────────────────────────────────
   The project settings page shows the same bar in a .xping-detail-panel row, where the value track
   is `auto` rather than a 200px column. Two things follow from that.

   A width, because the bar asks for 100% of whatever holds it: in an `auto` track it would draw a
   6px hairline the full 1280px measure, which stops reading as a proportion and starts reading as a
   rule across the page. 240px is close enough to the list's health column that the same project's
   bar is recognisably the same object on both screens.

   And centre alignment on the row, because this cell is two stacked lines against a one-line label —
   on the shared baseline the label would ride at the top of a cell twice its height. */

.xping-detail-panel__row--health {
    align-items: center;
}

.xping-health-bar-cell {
    display: flex;
    flex-direction: column;
    gap: 4px;
    width: 240px;
    max-width: 100%;
    text-align: end;
}

/* MudTooltip's root is an inline-block wrapper, so a width:100% bar inside one resolves against a
   shrink-to-fit box and collapses to the sum of its segments' 2px minimums — the same trap
   .xping-project-row__health documents, and the reason the bar's hosting cell always needs this
   rule. Same specificity as MudBlazor's own `.mud-tooltip-root.mud-tooltip-inline`, so it wins on
   source order rather than by outranking it, which holds only because app.css loads after
   MudBlazor's stylesheet. */
.xping-health-bar-cell .mud-tooltip-root {
    display: block;
    width: 100%;
}

/* ── Activity cell ──────────────────────────────────────────────────────────── */

.xping-project-row__activity {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 2px;
    text-align: end;
}

.xping-project-row__tests {
    font-size: 13px;
    color: var(--mud-palette-text-primary);
    white-space: nowrap;
}

/* cursor: help because the relative age carries the absolute timestamp as a title. */
.xping-project-row__age {
    font-size: 11px;
    color: var(--mud-palette-text-secondary);
    cursor: help;
}

/* "Never run" is not a time, so it gets no tooltip and no help cursor promising one. */
.xping-project-row__age--never {
    cursor: default;
}

/* ── Actions cell ───────────────────────────────────────────────────────────── */

/* A sibling of the link rather than a child of it — see the note at the top of this section. It sits
   outside the anchor, so a click here does not also navigate. */
.xping-project-row__actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
}

/* ── Narrow viewports ─────────────────────────────────────────────────────────
   Below this the three content columns cannot all hold their width, so health — the widest, and the
   only one that is derived rather than stated — drops beneath the identity and spans the link's full
   width, where a 6px bar reads better than it did squeezed. Identity, activity and the actions menu
   keep their places: they are the row's anchors at any width. */

@media (max-width: 900px) {
    .xping-project-row__link {
        grid-template-columns: minmax(0, 1fr) 104px;
        row-gap: 8px;
    }

    /* All three placed explicitly. Pinning only the health cell is not enough: auto-placement then
       finds column 1 of the first row already taken, drops health to a row of its own, and pushes
       activity down to a third row — which reads as a stacked list rather than as a row whose widest
       cell wrapped, and moves the two cells that were supposed to keep their places. */
    .xping-project-row__identity {
        grid-column: 1;
        grid-row: 1;
    }

    .xping-project-row__activity {
        grid-column: 2;
        grid-row: 1;
    }

    .xping-project-row__health {
        grid-column: 1 / 3;
        grid-row: 2;
    }
}
