/* AppPlatform base styles — consumed by app-* projects */

/* ThemeProvider — primary token injection point (AC-18) */
.ap-theme-root {
    font-family: inherit;
    color: var(--ap-text-primary);
    background-color: var(--ap-bg-page);
}

*, *::before, *::after {
    box-sizing: border-box;
}

html {
    max-width: 100%;
}

body {
    margin: 0;
    font-family: var(--rz-body-font-family, system-ui, sans-serif);
    color: var(--ap-text-primary);
    background-color: var(--ap-bg-page);
    max-width: 100%;
    overflow-x: clip;
}

img,
svg,
canvas,
video,
iframe {
    max-width: 100%;
}

/* Layout */
.ap-layout {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    min-width: 0;
    max-width: 100%;
    overflow-x: clip;
}

/* App switcher */
.ap-app-switcher {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    flex: 0 0 auto;
    min-width: 0;
    gap: var(--ap-space-2);
    padding: var(--ap-space-2);
}

/* Theme toggle (Light <-> Dark) */
.ap-theme-toggle {
    background: none;
    border: none;
    cursor: pointer;
    padding: var(--ap-space-2);
    min-width: 44px;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: var(--ap-text-primary);
    border-radius: var(--ap-radius-sm);
}

.ap-theme-toggle .rzi {
    color: var(--ap-text-primary);
}

.ap-theme-toggle:hover {
    background-color: var(--ap-surface);
}

.ap-theme-toggle:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

.ap-app-switcher--loading {
    pointer-events: none;
}

/* App-switcher island slot (1.5.1): on static-SSR hosts the switcher is an
   InteractiveServer island rendered with prerender:false, so this slot holds only
   Blazor marker comments (:empty) until the circuit connects and the island
   hydrates already-wired. Reserve the trigger's footprint (a 44px control inside
   the .ap-app-switcher padding) so hydration causes NO layout shift, and — while
   :empty — paint a subtle, non-interactive placeholder so the pre-hydration state
   reads as "a control will appear here", never as a clickable button. Once the
   island renders content (its own RadzenSkeleton while loading, then the trigger)
   the slot is no longer :empty and the placeholder disappears. Deliberately not
   animated: in the rare settled-empty case (registry down → the component renders
   nothing) a static block is honest, whereas an infinite pulse would imply loading
   that never resolves. */
.ap-app-switcher-slot {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: calc(44px + 2 * var(--ap-space-2));
    min-height: calc(44px + 2 * var(--ap-space-2));
}

.ap-app-switcher-slot:empty {
    padding: var(--ap-space-2);
}

.ap-app-switcher-slot:empty::before {
    content: "";
    width: 44px;
    height: 44px;
    border-radius: var(--ap-radius-sm);
    background-color: var(--ap-surface);
    opacity: 0.5;
    pointer-events: none;
}

.ap-app-tile {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--ap-space-1);
    padding: var(--ap-space-2);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
    background: none;
    cursor: pointer;
    font-family: inherit;
    font-size: var(--ap-text-xs);
    color: var(--ap-text-primary);
    overflow-wrap: anywhere;
    min-width: 0;
    max-width: 100%;
    transition: background-color var(--ap-motion-duration-fast) var(--ap-motion-ease-standard);
}

.ap-app-tile:hover {
    background-color: var(--ap-surface);
}

.ap-app-icon {
    width: 40px;
    height: 40px;
    flex-shrink: 0;
}

.ap-app-icon--image {
    object-fit: contain;
}

.ap-app-icon--text {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: var(--ap-text-sm);
    font-weight: 700;
    line-height: 1;
    letter-spacing: 0;
    color: var(--ap-primary);
    background-color: var(--ap-primary-subtle);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-sm);
}

/* Breadcrumbs */
.ap-breadcrumbs ol {
    display: flex;
    flex-wrap: wrap;
    list-style: none;
    margin: 0;
    padding: 0;
    gap: var(--ap-space-1);
}

.ap-breadcrumbs li + li::before {
    content: '/';
    margin-right: var(--ap-space-1);
    color: var(--ap-border-default);
}

/* Layout header (used by RCL MainLayout) */
.ap-layout-header {
    position: sticky;
    top: 0;
    z-index: 100;
    background-color: var(--ap-bg-page);
    border-bottom: 1px solid var(--ap-border-default);
    padding: 0 var(--ap-space-4);
    min-width: 0;
    max-width: 100%;
}

.ap-header-nav {
    display: flex;
    align-items: center;
    gap: var(--ap-space-4);
    height: 56px;
    max-width: 1200px;
    min-width: 0;
    width: 100%;
    margin: 0 auto;
}

.ap-header-brand {
    font-family: inherit;
    font-weight: 600;
    font-size: var(--ap-text-lg);
    color: var(--ap-primary);
    text-decoration: none;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.ap-layout-content {
    flex: 1;
    max-width: 1200px;
    min-width: 0;
    margin: 0 auto;
    padding: var(--ap-space-6) var(--ap-space-4);
    width: 100%;
    overflow-wrap: anywhere;
}

.ap-layout-content > * {
    min-width: 0;
    max-width: 100%;
}

.ap-error {
    padding: var(--ap-space-4);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
    color: var(--ap-text-primary);
    background-color: var(--ap-surface);
}

/* App switcher trigger + dropdown */
.ap-app-switcher-trigger {
    background: none;
    border: none;
    cursor: pointer;
    padding: var(--ap-space-2);
    min-width: 44px;
    min-height: 44px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--ap-text-primary);
    border-radius: var(--ap-radius-sm);
}

.ap-app-switcher-trigger:hover {
    background-color: var(--ap-surface);
}

.ap-app-switcher-trigger:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

.ap-app-switcher-glyph {
    width: 20px;
    height: 20px;
    display: block;
    fill: currentColor;
    flex: 0 0 auto;
}

.ap-app-switcher-dropdown {
    position: absolute;
    top: 100%;
    /* Anchor to the right of the trigger — the trigger sits at the right edge of the
       header, so left: 0 made the dropdown overflow off-screen and trigger a horizontal
       page scrollbar (violating css-conformity-gate AC-09). */
    right: 0;
    z-index: 200;
    background-color: var(--ap-bg-page);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
    padding: var(--ap-space-2);
    display: flex;
    flex-wrap: wrap;
    gap: var(--ap-space-2);
    min-width: min(240px, calc(100vw - var(--ap-space-8, 2rem)));
    max-width: calc(100vw - var(--ap-space-8, 2rem));
    overflow-x: hidden;
}

.ap-app-switcher-tile-slot {
    display: flex;
    min-width: 0;
    max-width: 100%;
}

.ap-app-switcher-tile-slot .ap-app-tile {
    min-width: 0;
    max-width: 100%;
}

.ap-app-switcher-tile-slot .rz-button-box,
.ap-app-tile .rz-button-box {
    min-width: 0;
    max-width: 100%;
    white-space: normal;
}

/* Status badge */
.ap-status-badge {
    display: inline-block;
    padding: 2px var(--ap-space-2);
    border-radius: var(--ap-radius-sm);
    font-size: var(--ap-text-xs);
    font-weight: 600;
}

.ap-status-badge--live {
    color: var(--ap-primary);
}

.ap-status-badge--comingsoon {
    color: var(--ap-text-primary);
    opacity: var(--ap-state-disabled-opacity);
}

/* Notification host */
.ap-notification-host {
    position: fixed;
    bottom: var(--ap-space-4);
    right: var(--ap-space-4);
    z-index: 9999;
    display: flex;
    flex-direction: column;
    gap: var(--ap-space-2);
    max-width: calc(100vw - var(--ap-space-8, 2rem));
}

/* Toast notifications */
.ap-toast {
    display: flex;
    align-items: center;
    gap: var(--ap-space-2);
    padding: var(--ap-space-3) var(--ap-space-4);
    background-color: var(--ap-bg-page);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
    box-shadow: var(--ap-shadow-sm);
    min-width: min(240px, 100%);
    max-width: min(400px, 100%);
}

.ap-toast--info    { border-left: 3px solid var(--ap-info); }
.ap-toast--success { border-left: 3px solid var(--ap-success); }
.ap-toast--warning { border-left: 3px solid var(--ap-warning); }
.ap-toast--error   { border-left: 3px solid var(--ap-error); }

.ap-toast-icon {
    font-size: var(--ap-text-base);
    flex-shrink: 0;
}

.ap-toast-message {
    flex: 1;
    color: var(--ap-text-primary);
    font-family: inherit;
    font-size: var(--ap-text-sm);
}

.ap-toast-dismiss {
    background: none;
    border: none;
    cursor: pointer;
    color: var(--ap-text-primary);
    font-size: var(--ap-text-base);
    min-width: 44px;
    min-height: 44px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--ap-radius-sm);
    padding: 0;
}

.ap-toast-dismiss:hover {
    background-color: var(--ap-surface);
}

@media (max-width: 40rem) {
    .ap-layout-header {
        padding-inline: var(--ap-space-2);
    }

    .ap-header-nav {
        gap: var(--ap-space-2);
    }

    .ap-header-brand {
        flex: 1 1 auto;
        max-width: 100%;
    }

    .ap-layout-content {
        padding-inline: var(--ap-space-3);
    }

    .ap-app-switcher-dropdown {
        right: 0;
        min-width: 0;
        width: min(240px, calc(100vw - var(--ap-space-4, 1rem)));
        max-width: calc(100vw - var(--ap-space-4, 1rem));
        overflow-x: hidden;
    }

    .ap-app-switcher-tile-slot,
    .ap-app-switcher-tile-slot .ap-app-tile {
        width: 100%;
    }
}

/* === Shared WODVTT trait control (TraitRating) ===========================
   Compact 5+5 grouped, numberless marks. Visible mark is drawn on ::before so
   the button stays a larger tappable target (C2/mobile). The control fits its
   slot (min-width:0, max-width:100%) and never forces the parent wider (C1):
   it is a single non-wrapping row that compresses its marks down to
   --wodvtt-mark-floor, and scrolls inside its own box below that rather than
   compressing them past it.
   All color/shadow/gradient/radius come from --ap-wodvtt-* tokens (C5); the
   parchment variant is supplied by the host re-binding those tokens under its
   own theme scope (no theme selector baked in here). */
.trait-rating {
    /* WI-12 public mark-grammar tokens. Consumer-overridable (set them on a
       narrower selector, or on `.trait-rating` inline, to retarget a single
       instance) -- this is the contract a host like app-wodvtt's 10-cap trait
       row (design-reference CharacterSheet.md §3: 11px mark, 3px within-group
       gap, 6px 5|6 group gap at 272px content width) binds against, rather
       than the derived-from-ratio internals below. Defaults match that 10-cap
       row so the control is correct out of the box for the case the row
       grammar was written for.

       spec-final §9 item 7 (2026-09-10, app-shared spec-final §5/§6
       amendment) supersedes the glyph-derived target model below: 11px is
       the readonly/interactive glyph on the character-sheet density, and the
       WI-10 accessibility floor (27px fine / 44px coarse) is preserved
       independently of it. WI-12 Part B (orchestrator decision, 2026-09-10)
       further supersedes the DELIVERY mechanism: a per-mark `.tr-item::after`
       overlay was withdrawn (ten exclusive 27px targets need 270px against a
       143px row) in favor of the ROW's own padding -- see `.tr-marks` further
       down. --wodvtt-target-ratio is retained only to size the row's floor;
       overriding --ap-trait-mark-size alone no longer moves the layout box,
       only the ink and the row padding's floor multiple. */
    --ap-trait-mark-size: 11px;
    --ap-trait-mark-gap: 3px;
    --ap-trait-mark-group-gap: 6px;

    /* WI-12 item 2: unfilled-mark border, resolved against this control's own
       card background so the empty glyph stays visible as a *shape*, not only
       as an absence of fill (WCAG 1.4.11 non-text contrast, >=3:1). The
       default comes from the platform's --ap-trait-mark-empty-border design
       token (generated by scripts/generate-tokens.js into
       AppPlatform.DesignTokens/wwwroot/css/tokens.generated.css from
       theme.tokens.json / tokens.catalog.json) and cascades in from :root, so
       it is not redeclared here. That token is measured against
       --ap-surface-elevated as shipped by the platform's own (dark,
       unthemed-root) token set with headroom above the WCAG 1.4.11 3:1 floor
       rather than landing on it. Consumer-overridable the same way as the
       tokens above: a host like app-wodvtt rebinds it in its own theme scope
       (its dark card uses
       color-mix(in srgb, var(--wod-border-default) 70%, var(--wod-primary)),
       which the WoDVTT-specific --ap-wodvtt-mark-empty-border token still
       carries and takes precedence via the fallback where the border is
       consumed below) rather than this platform default being expected to
       clear every possible card colour. */

    --wodvtt-mark-size: var(--ap-trait-mark-size);       /* standard glyph inside the target */

    /* The visual grammar (glyph size, within-gap, group-gap) and the tap
       target are now INDEPENDENT axes, per the spec-final §9 item 7
       amendment cited above -- the target no longer derives from the glyph
       via a padding ratio, it is a same-size overlay pinned to
       --wodvtt-mark-floor regardless of glyph. --wodvtt-within-gap is the
       real, visible gap between adjacent glyph edges (--ap-trait-mark-gap,
       3px by default); the 5th/6th boundary is the independent
       --ap-trait-mark-group-gap token (6px by default) so the two can be
       tuned apart (design reference: 3px within, 6px at the group
       boundary). */
    --wodvtt-target-ratio: 1.5;
    --wodvtt-mark-target: calc(var(--wodvtt-mark-size) * var(--wodvtt-target-ratio));
    --wodvtt-within-gap: var(--ap-trait-mark-gap);         /* between marks inside a group */
    --wodvtt-group-gap: var(--ap-trait-mark-group-gap);  /* between 5-mark groups */

    /* The lower bound the OVERLAY (not the glyph box) sits at, and the reason
       the row below can scroll instead of shrinking past it. Since spec-final
       §9 item 7 (2026-09-10) the glyph is fixed at 11px/9px by the
       character-sheet density and no longer derives the floor -- the floor is
       the WI-10 accessibility target (27px fine / 44px coarse) applied via
       --wodvtt-target-ratio to a nominal 18px reference glyph, kept only as
       the ratio's base so the floor stays a density rather than a magic
       length. It is NOT the smallest glyph this capability ships (that is
       9px, .trait-rating--compact below); the two are independent since the
       amendment.

       --wodvtt-scroll-ring-room is what a scrolling control owes its focus ring.
       `overflow-x: auto` forces `overflow-y: auto`, and the ring is drawn OUTSIDE
       the box it hugs, so without reserved room the scroll container clips it and
       the control trades WCAG 2.5.8 for 2.4.11/2.4.13. */
    --wodvtt-mark-floor-glyph: 18px;
    --wodvtt-mark-floor: calc(var(--wodvtt-mark-floor-glyph) * var(--wodvtt-target-ratio));
    /* WI12-D5-01 (delta-5 review finding): HORIZONTAL padding must never move with pointer
       density. The previous alias (`--wodvtt-mark-hpad-floor: var(--wodvtt-mark-floor)`) looked
       pointer-independent but was NOT: a custom property is resolved at used-value time from
       the cascade, not frozen where it is declared, so once the `@media (pointer: coarse)`
       block below reassigned --wodvtt-mark-floor on this same element, the alias silently
       picked up the coarse value too (27px -> 44px), and padding-inline grew right along with
       it. --wodvtt-mark-hpad fixes that by being a LITERAL length, never a var() reference to
       --wodvtt-mark-floor or anything derived from it, so there is no cascade path from the
       coarse-pointer rule to this token at all. It is the fine floor's own answer -- (27px -
       11px) / 2 -- restated as a constant; the compact density below restates it as 9px --
       (27px - 9px) / 2 -- for its narrower glyph. NearestMarkIndex in TraitRating.razor mirrors
       these same two literals via TraitPointerGeometry.HpadFinePx / HpadCompactPx. */
    --wodvtt-mark-hpad: 8px;
    --wodvtt-scroll-ring-room: calc(var(--ap-focus-ring-width) + var(--ap-focus-ring-offset));

    /* WI-12 restructure (finding WI12-W1-05): `.trait-rating` is the column that
       holds the mark row (`.tr-marks`) and the sub-line list (`.tr-sub-lines`), so
       both are inside the component root and both see this scope's tokens and
       state classes. It no longer generates the row itself -- see `.tr-marks`
       below for the row declarations this rule used to carry. */
    display: flex;
    flex-direction: column;
    min-width: 0;
    max-width: 100%;
}

/* The mark row. One row, always -- a trait is read as a single 5+5 quantity, so a
   10-mark rating that breaks after the fifth mark reads as two separate ratings;
   `wrap` (plus the row-gap it needed) made that the default outcome from the
   first slot narrower than 2 x 5 x target + group gap. The narrow case is
   handled by compression instead: the marks carry `min-width: 0`, so the row
   shrinks into its slot rather than wrapping out of it or overflowing it. */
.trait-rating .tr-marks {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;

    /* The marks are this row's flex line, not the groups -- see `.tr-group` below,
       which is `display: contents`. So this gap is the WITHIN-group one, and the
       5th/6th boundary is a margin on the sixth mark. */
    column-gap: var(--wodvtt-within-gap);
    min-width: 0;
    max-width: 100%;
    line-height: 1;

    /* Where the shrink stops. Below the width ten floored targets need, the marks no
       longer give way -- the control does, by scrolling inside its own box. That is
       §2.2's own answer for coarse pointers ("Fingers need room; scrolling is
       anticipated there") applied to both densities, and it is what keeps the §6.1
       target floors true at every width rather than only at or above nominal.

       `overflow-y` is stated rather than left implicit: `overflow-x: auto` computes a
       `visible` cross axis to `auto` anyway, so writing it makes the padding below
       read as the deliberate answer to it rather than as decorative spacing. The
       padding IS that answer -- the focus ring is drawn outside the mark it hugs, and
       a scroll container clips at its padding box, so without reserved room this rule
       would trade WCAG 2.5.8 for 2.4.11/2.4.13 and not be a fix at all. */
    overflow-x: auto;
    overflow-y: auto;

    /* WI-12 Part B (orchestrator decision 2026-09-10, spec-final §9 amendment
       supersession): the exclusive per-mark 27px `::after` overlay is withdrawn --
       10 marks x 27px = 270px cannot fit a 143px row. The accessible tap target is
       now delivered by THIS row, not by any one mark: the row's own vertical
       padding is sized so the row's box reaches --wodvtt-mark-floor tall (27px
       fine / --ap-touch-target-min coarse -- the same token the withdrawn overlay
       used, still WI-10-correct), while each mark keeps its own --wodvtt-mark-size
       layout box untouched, so the row's WIDTH (glyphs + gaps) is unaffected.
       `max()` against the focus-ring room means neither requirement starves the
       other; on a readonly control --wodvtt-mark-floor collapses to
       --wodvtt-mark-size (see `.trait-rating.trait-rating--readonly` below), so
       this reduces to the ring room alone there, same as before this block.

       WI12-D-01 (delta review, 2026-09-10) completes the "row is the target"
       model: the ROW itself is now the single `role="slider"` control (see
       TraitRating.razor) -- pointer down/move/click on the row map the event's
       OffsetX to the nearest mark, and keyboard (arrows/Home/End/PageUp/
       PageDown/digits) targets the row's own focus stop. The marks are
       presentational spans with `pointer-events: none` (below), so
       `elementFromPoint` anywhere in the row -- glyph or gap alike -- always
       resolves to THIS element, which is exactly what gives the row's own
       padding box (not any one mark) a WI-10-sized, dead-zone-free hit area.

       WI12-D3-01 (delta-3 review finding): HEIGHT and WIDTH answer the coarse-pointer
       floor asymmetrically. Vertical padding still uses --wodvtt-mark-floor (27px fine /
       44px coarse) -- the coarse floor is a taller tap target, not a wider one. Horizontal
       padding uses the literal --wodvtt-mark-hpad instead (WI12-D5-01: a fixed length, never
       a var() derived from --wodvtt-mark-floor -- see that token's declaration above for why
       the earlier alias still moved) -- so `padding-inline` never moves between pointer types.
       NearestMarkIndex in TraitRating.razor mirrors this same fixed horizontal constant
       unconditionally (no `CoarsePointer` parameter), so the C# offset math and the rendered
       padding box agree at both pointer densities without either side needing to know which
       one is live. */
    padding-block: max(var(--wodvtt-scroll-ring-room), calc((var(--wodvtt-mark-floor) - var(--wodvtt-mark-size)) / 2));
    padding-inline: var(--wodvtt-mark-hpad);
}

/* WI12-D-01: the row (not any mark) is the slider now, so it is the row's own
   focus ring that a11y needs -- cursor stays on `.tr-interactive` below
   (unchanged, only its element moved from each mark to the row). Its marks
   carry `pointer-events: none` (see `.trait-rating .tr-item` below) so every
   pointer event's target, and therefore OffsetX, is always this row -- never
   a mark span -- which is what keeps the row's own nearest-mark mapping
   accurate anywhere in the row, glyph or gap alike. */
.trait-rating .tr-marks[role="slider"] {
    outline: none;
}

/* Compact restates only the glyph -- 9px per the design reference's compact
   density (spec-final §9 item 7, 2026-09-10). Since the target and floor are
   now independent of the glyph (see `.trait-rating` above), this no longer
   touches the tap target at all: the overlay stays pinned at
   --wodvtt-mark-floor regardless of density. Compact expresses ONLY the ink
   size here; the accessibility floor is the same control-wide constant. */
.trait-rating.trait-rating--compact {
    --ap-trait-mark-size: 9px;
    /* WI12-D5-01: the compact glyph is narrower, so the fine-floor horizontal padding that
       keeps the mark centred within --wodvtt-mark-hpad-floor's old 27px answer differs too --
       (27px - 9px) / 2 = 9px, one pixel more than the 11px-glyph density above. Still a
       literal, still never touched by `@media (pointer: coarse)`. */
    --wodvtt-mark-hpad: 9px;
}

.trait-rating .tr-group {
    /* The group is markup, not a box. It carries the 5+5 reading -- five marks per
       wrapper, so the grouping is structural rather than nth-child arithmetic -- and
       the boundary gap it used to hold is now the margin rule below.

       It generated a flex box until 2026-08-31, and both of that box's successive
       states are worth keeping in view, because each is what the other one's absence
       costs:

       WITH a content-based minimum (the state the control shipped in) a group could
       not compress. A flex item's automatic minimum is content-based, and a mark
       carries a definite `width`, so the minimum resolved to the sum of the marks at
       nominal size rather than to what they can compress to -- 150px here, 220px
       under `pointer: coarse`. The surplus escaped as scrollable overflow through
       every `overflow-x: visible` ancestor up to the document. Measured on a consumer
       (app-wodvtt character sheet, 2026-08-19, when the target was a fixed 44px and
       the within-gap 2px): a three-virtue panel gave each rating a 151px slot, each
       group held its 228px, and the document gained 20px of horizontal pan that no
       element box accounted for -- every box fitted; only the overflow escaped. That
       is app-platform#1878.

       WITHOUT it (`min-width: 0`, the app-platform#1878 fix) the group box shrinks
       with its slot and the marks compress inside it -- which is correct only while
       the marks have no floor of their own. Once they do, the box shrinks under its
       own floored content and the two groups draw over one another: in a 151px slot
       each group box is 72px while its five 27px marks need 135px, so the sixth mark
       starts 21px before the fifth one ends. Overlapping marks are a worse outcome
       than small ones.

       Sizing the marks from `flex-basis` rather than `width` lowers the minimum to
       the floor, but lowers the group's preferred width to the floor with it, so the
       marks measure 27px at every slot and never reach nominal. There is no third
       state of a group BOX that both compresses and floors.

       `display: contents` dissolves it, leaving the marks as the row's own flex line
       -- which is the thing that has to shrink, and the level the floor is set at.
       Measured across a 10-mark row: 30px at 400px and at 307px, 27.5px at 290px, the
       27px floor from 285px down, scrolling below that, with the boundary gap intact
       throughout and no overlap at any width. */
    display: contents;
}

/* The 5th/6th boundary. It was the row's `column-gap` while the groups were boxes;
   with the groups dissolved above, the row's gap is the within-group one and this
   margin is what makes the boundary strictly wider (.85x glyph against .5x, AC-03).
   It stays a multiple of the glyph, so it keeps separating the fifth mark from the
   sixth as the marks shrink -- which the fixed 7px it replaced did not. */
.trait-rating .tr-group + .tr-group > .tr-item:first-child {
    /* --wodvtt-group-gap is the TOTAL glyph-edge distance at the boundary (6px by
       default), not an addition on top of the row's own column-gap -- the row's
       `column-gap: var(--wodvtt-within-gap)` (3px) already applies between every
       adjacent pair of marks, boundary included, so stacking a further 6px margin
       here rendered a 9px boundary instead of 6px. This margin is only the EXTRA
       width the boundary needs beyond the plain gap already in place. */
    margin-left: calc(var(--wodvtt-group-gap) - var(--wodvtt-within-gap));
}

/* Each mark's LAYOUT box is the glyph itself (spec-final §9 item 7,
   2026-09-10): width/height equal --wodvtt-mark-size, with NO min-width
   floor, so ten 11px marks plus the within/group gaps fit the 272px
   character-sheet content width (AC-WI12-4) at Max 9/10/12. The accessible
   tap target (27px fine / 44px coarse, WI-10) is delivered by `.tr-marks`'
   own padding since WI-12 Part B (2026-09-10) -- see that rule -- not by a
   per-mark overlay, so the mark's box stays exactly its glyph. Reset the
   user-agent button box model so buttons and readonly spans remain aligned. */
.trait-rating .tr-item {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: var(--wodvtt-mark-size);
    height: var(--wodvtt-mark-size);
    min-width: 0;

    /* The glyph is a fixed density now, not a compressible floor (see the rule
       comment above) -- `flex-shrink: 0` keeps it at that exact size even in a
       slot narrower than the row's content, so `.tr-marks`' `overflow-x: auto`
       is what a too-narrow slot falls back to, rather than a squished glyph. */
    flex-shrink: 0;
    padding: 0;
    margin: 0;
    border: 0;
    background: transparent;
    font: inherit;
    cursor: default;
}

/* WI-12 Part B (orchestrator decision 2026-09-10): the per-mark `::after`
   accessible-hit-target overlay that used to live here is WITHDRAWN -- ten
   exclusive 27px-square overlays need 270px and the row is 143px, so they
   necessarily overlapped their neighbours, which defeats a per-mark target
   as surely as an undersized one does. The WI-10 floor is now delivered by
   `.tr-marks`' own padding (see that rule) instead of by any one mark. */

/* WI12-D-01: marks inside the interactive row are purely presentational
   (`aria-hidden`, no role, no handlers) -- the row itself is the one
   `role="slider"` control. `pointer-events: none` is what makes that true for
   the mouse/pointer/click path too: without it, a click on a mark's glyph
   would target the SPAN, not the row, and its `OffsetX` would be relative to
   an 11px box instead of the row's own coordinate space that the nearest-mark
   mapping in TraitRating.razor assumes. */
.trait-rating .tr-marks[role="slider"] .tr-item {
    pointer-events: none;
}

.trait-rating .tr-interactive { cursor: pointer; }

/* The glyph is square by construction rather than by two matching lengths: once
   the mark can compress, a fixed `height: var(--wodvtt-mark-size)` against a
   shrinking width turns every dot into a vertical ellipse. `aspect-ratio` keeps
   the compressed mark a circle (dots) or a square (boxes); `box-sizing` is
   border-box, so the .6px/.7px mark borders sit inside the ratio and at or above
   the nominal width it resolves to exactly --wodvtt-mark-size, as before. The
   box and health tracks keep the two-length form: only this control compresses
   its marks. */
.trait-rating .tr-item::before {
    content: "";
    width: var(--wodvtt-mark-size);
    height: auto;
    aspect-ratio: 1;
    box-sizing: border-box;
}

.trait-rating .tr-dot::before { border-radius: var(--ap-wodvtt-mark-radius-dot); }
.trait-rating .tr-box::before { border-radius: var(--ap-wodvtt-mark-radius-box); }

.trait-rating .tr-dot.off::before {
    background-color: var(--ap-wodvtt-mark-empty-bg);
    background-image: var(--ap-wodvtt-dot-empty-image);
    border: .6px solid var(--ap-wodvtt-mark-empty-border, var(--ap-trait-mark-empty-border));
    box-shadow: var(--ap-wodvtt-dot-empty-shadow);
}

.trait-rating .tr-dot.on::before {
    background-color: transparent;
    background-image: var(--ap-wodvtt-dot-filled-image);
    border: .6px solid var(--ap-wodvtt-mark-filled-border);
    box-shadow: var(--ap-wodvtt-dot-filled-shadow);
}

.trait-rating .tr-box.off::before {
    background: var(--ap-wodvtt-mark-empty-bg);
    border: .7px solid var(--ap-wodvtt-mark-empty-border, var(--ap-trait-mark-empty-border));
    box-shadow: var(--ap-wodvtt-box-empty-shadow);
}

.trait-rating .tr-box.on::before {
    background-image: var(--ap-wodvtt-box-filled-image);
    border: .7px solid var(--ap-wodvtt-mark-filled-border);
    box-shadow: var(--ap-wodvtt-box-filled-shadow);
}

/* WI12-D-01: one focus ring per row (C6 a11y preserved), not one per mark --
   `.tr-interactive` is now the row itself (`.tr-marks[role="slider"]`), so the
   ring hugs the whole row rather than a single glyph's `::before`. */
.trait-rating .tr-interactive:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

/* WI-12 specialisation sub-line list ("-> Firearms +1"), rendered by the
   control INSIDE its own root, below the mark row (see `.trait-rating` /
   `.tr-marks` above -- CharacterSheet.md §4's specialisation pattern). It is a
   semantic list (`role="list"` on the `<ul>`, one `<li>` per specialisation --
   see TraitRating.razor) rather than running text, so a screen-reader user can
   tell how many specialisations a trait has. Only platform scale tokens
   (--ap-space-*, --ap-text-*) are used here -- this file's own header
   establishes that the platform side owns --ap-wodvtt-* and the host rebinds,
   never the reverse, so a --wod-* read here would be backwards. font-size is
   set explicitly to the label-sized step (--ap-text-sm) rather than
   `inherit`, since the escaped-sibling-div bug this replaced made `inherit`
   resolve against whatever host container received the sibling, not the
   label. */
ul.tr-sub-lines {
    display: flex;
    flex-direction: column;
    gap: var(--ap-space-1);
    font-size: var(--ap-text-sm);
    line-height: 1.3;
    margin: var(--ap-space-1) 0 0;
    padding: 0;
    list-style: none;
}

.tr-sub-line {
    display: inline-flex;
    align-items: baseline;
    gap: .35em;
    color: var(--ap-text-secondary);
}

/* === Shared WODVTT box track (BoxTrack) ==================================
   Willpower / Humanity / Blood damage tracks. Breaks after exactly ten boxes;
   inside a row the boxes hold --wodvtt-mark-floor rather than force the parent
   wider, so even Blood (Max=100) fits its slot (C1/C2). The crossover is ten
   floored targets plus the group gap = 276.3px on a fine pointer and 446.3px on a
   coarse one; below that the boxes stop shrinking and `.bt-boxes` scrolls inside
   its own box, which is what keeps "ten per row, in every mode" from costing the
   section 6.1 target floors. Boxes group in fives, two groups per row. Optional
   numeric meter header (ShowValue). Tokenized via --ap-wodvtt-* (C5). */
.box-track {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    min-width: 0;
    max-width: 100%;
    column-gap: var(--ap-space-2);
    row-gap: var(--ap-space-1);
}

.box-track .bt-meter {
    font-size: var(--ap-text-xs);
    color: var(--ap-text-secondary);
    white-space: nowrap;
}

/* Same mark size + 5-grouping as TraitRating, so a damage track and a trait box
   read as the same control family.

   The break after ten is STRUCTURAL, not a wrap. BoxTrack.razor emits explicit
   .bt-row wrappers of two groups, and this container stacks them: a free-wrapping
   flex row would put all twenty boxes of a blood pool on one line whenever the
   slot happened to be wide enough, which is the defect (spec-final.md section 2.4).
   Stacking rows rather than wrapping marks also means the 5th/6th group gap and the
   row break are two different mechanisms, so neither can eat the other.

   This container only stacks. Everything about how a row FITS is decided by the
   grid on .bt-row below, so there is deliberately no `align-items` here: at the
   `stretch` default a row is handed this container's width, and the two
   declarations an earlier flex-based draft of .bt-row needed
   (`align-items: stretch` here and `min-width: 0` on .bt-group) were measured inert
   once .bt-row became a grid -- across Max 6/11/20/25/100 and slots 120..440px at
   both pointer densities, removing either or both changed no box width and no
   overhang by a single pixel. They are left out rather than kept as reassurance,
   because a declaration whose comment has to say "does nothing today" is worse than
   its absence. Left-alignment needs nothing either: a partial final row
   (25 -> 10 / 10 / 5) starts at the same x because its grid tracks do. */
.box-track .bt-boxes {
    --wodvtt-mark-size: 18px;

    /* Same glyph-derived ratios as .trait-rating above, so the two controls stay
       one family at every density instead of drifting apart again -- including the
       floor and the ring room, whose derivations are documented there.

       Note what the floor costs THIS control, which is nothing and everything: its
       glyph is already the 18px the floor is derived from, so its nominal target IS
       the floor and it has no shrink range on a fine pointer at all. Every slot
       narrower than a row of ten needs scrolls rather than compresses. That is not a
       special case -- it is the same rule, met immediately. */
    --wodvtt-target-ratio: 1.5;
    --wodvtt-mark-target: calc(var(--wodvtt-mark-size) * var(--wodvtt-target-ratio));
    --wodvtt-within-gap: 0;
    --wodvtt-group-gap: calc(var(--wodvtt-mark-size) * .35);
    --wodvtt-mark-floor-glyph: 18px;
    --wodvtt-mark-floor: calc(var(--wodvtt-mark-floor-glyph) * var(--wodvtt-target-ratio));
    --wodvtt-scroll-ring-room: calc(var(--ap-focus-ring-width) + var(--ap-focus-ring-offset));
    display: flex;
    flex-direction: column;
    flex-wrap: nowrap;
    row-gap: var(--ap-space-1);
    min-width: 0;
    max-width: 100%;

    /* The scroll box, and the focus-ring room a scroll box owes its marks -- see
       `.trait-rating`. It sits here rather than on `.box-track` so the numeric meter
       stays put while the boxes scroll under it. Measured: a row of ten holds 27px
       boxes down to a 276.3px slot and scrolls below it (446.3px under
       `pointer: coarse`), instead of the 24.4px at 250px and 19.4px at 200px it read
       while the shrink was unbounded. */
    overflow-x: auto;
    overflow-y: auto;
    padding: var(--wodvtt-scroll-ring-room);
}

/* One structural row: at most two groups of five, i.e. at most ten boxes. The
   group gap lives here because it is now a within-row gap.

   A GRID of two equal tracks, not a flex row. The tracks carry BOTH properties
   this row needs, which is why nothing else in this control has to:

   1. They FIT. `1fr` caps each track at half the row, so a row is never wider than
      the container hands it and the boxes inside compress instead. A flex row here
      had a hard floor of ten definite-width boxes plus the group gap (276px fine /
      446px coarse) and simply overhung anything narrower -- measured 76px past a
      200px slot on a fine pointer, 106px past a 340px phone card on a coarse one --
      escaping through `overflow-x: visible` ancestors the same way TraitRating's
      groups did on the app-wodvtt character sheet on 2026-08-19.

      `minmax(min-content, 1fr)` rather than the `minmax(0, 1fr)` this shipped with:
      a zero minimum lets a track shrink under its own boxes, which is how the
      compression crossed the section 6.1 floors. `min-content` is the floored boxes'
      own width, so the track stops there and `.bt-boxes` scrolls instead. Stated
      rather than left as the bare `1fr` that means the same thing, because the
      minimum is the half of this declaration that does the work.
   2. They are UNIFORM. Each row is its own formatting context, so as flex only an
      over-constrained row compressed: a partial final row had slack and kept the
      nominal target while the full rows above it shrank, and one control drew the
      same mark at two sizes (Max=25 measured 24.4 / 24.4 / 27px at a 250px fine
      slot, and 33.4 / 33.4 / 44px at 340px coarse). Two fixed tracks give the last
      row the same track width as every other row. The free-wrapping container this
      replaces was uniform, so anything less would be a regression.

   In a slot too narrow for ten targets the marks no longer shrink below them: the
   track minimum below holds them at --wodvtt-mark-floor and `.bt-boxes` scrolls.
   Ten per row is unchanged by that -- the rows are structural, and the floor changes
   only what happens below the width they fit in.

   Not fixed here, deliberately: a track holds a whole group, so a group with
   fewer than five boxes leaves its tail box at the nominal size while a full
   group beside it compresses to the same track width. Max=6 is the smallest case
   (one row, a group of five beside a group of one: measured 19.4px x 5 next to a
   27px tail at a 200px fine slot). The condition is EffectiveMax % 5 != 0, not
   "large Max" -- 7, 8, 9 and 16 all hit it too. A lone tail group with no sibling
   in its row (Max=11 -> rows of 10 / 1) is nominal for a different and harmless
   reason: it simply receives a half-row track of its own.

   It is left alone because no in-repo consumer renders such a Max --
   WodvttTraitControlsExhibit.razor uses 5, 10 and 20, and blood is 20 or 25 --
   so fixing it would mean tuning against a case nobody has measured in situ. */
.box-track .bt-row {
    display: grid;
    grid-template-columns: repeat(2, minmax(min-content, 1fr));
    align-items: center;
    column-gap: var(--wodvtt-group-gap);
}

/* 18px for the same reason .trait-rating--compact is: 14px x 1.5 would be a 21px
   fine-pointer target, below the 24px AA floor the §6.1 NFR makes a Must. That
   equals this control's standard glyph, so Compact is currently a no-op here --
   accepted, because no consumer sets Compact (spec-final.md §2.3) and a silently
   sub-floor target is not a trade worth making for a density nobody requests. */
.box-track--compact .bt-boxes {
    --wodvtt-mark-size: 18px;
}

/* A real box, unlike .trait-rating .tr-group, which is `display: contents`. The
   difference is not an inconsistency: a trait row is ONE flex line, so a group box in
   it is the level the shrink would have to happen at and cannot be; a box row is a
   grid of two tracks, so the group box IS a track's content and the track minimum
   floors it from outside. Each control puts the bound at the level its own layout
   makes reachable.

   Do NOT reason about which compresses first from mark count -- the ordering is
   the opposite of what "ten versus five" suggests. A ten-mark trait row is 307px
   nominal (the figure .trait-rating's own comment quotes) against a row of ten
   boxes at 276.3px, so the TRAIT row starts compressing in the wider slot; a
   five-mark trait row, at 150px, is the last of the three to give way. The
   exhibit renders TraitRating at MaxValue 10, so the ten-mark case is the live
   one, not a hypothetical.

   Both now stop at the same floor, and that is the asymmetry worth carrying: this
   control's nominal target already IS --wodvtt-mark-floor, so it never compresses
   by a pixel and simply scrolls below 276.3px, while a trait row has a 30px -> 27px
   range to spend first and reaches the same behaviour at 285px. */
.box-track .bt-group {
    display: flex;
    align-items: center;
    column-gap: var(--wodvtt-within-gap);
}

.box-track .bt-box {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: var(--wodvtt-mark-target);
    height: var(--wodvtt-mark-target);

    /* The floor the track minimum above reads. It is what makes `min-content` a
       floored width rather than the 18px glyph the box would otherwise compress to,
       and it is stated on the mark itself so the bound survives any future container
       that hands this box less room than a `.bt-row` does. */
    min-width: var(--wodvtt-mark-floor);
    margin: 0;
    padding: 0;
    border: 0;
    background: transparent;
    font: inherit;
    cursor: pointer;
}

.box-track .bt-box::before {
    content: "";
    box-sizing: border-box;
    width: var(--wodvtt-mark-size);
    height: var(--wodvtt-mark-size);
    border: .7px solid var(--ap-wodvtt-mark-empty-border);
    border-radius: var(--ap-wodvtt-mark-radius-box);
    background: var(--ap-wodvtt-mark-empty-bg);
    box-shadow: var(--ap-wodvtt-box-empty-shadow);
}

.box-track .bt-box.active:not(.cross)::before {
    border-color: var(--ap-wodvtt-mark-filled-border);
    background-image: var(--ap-wodvtt-box-filled-image);
    box-shadow: var(--ap-wodvtt-box-filled-shadow);
}

/* Cross mode → engraved well stays; ink X is drawn on top (damage track idiom). */
.box-track .bt-box.cross.active::before {
    border-color: var(--ap-wodvtt-mark-filled-border);
    background: var(--ap-wodvtt-mark-empty-bg);
}

.box-track .bt-box.cross.active::after {
    content: "";
    position: absolute;
    width: calc(var(--wodvtt-mark-size) * .64);
    height: calc(var(--wodvtt-mark-size) * .64);
    background:
        linear-gradient(to bottom right, transparent calc(50% - 1px), var(--ap-wodvtt-cross-ink) calc(50% - 1px), var(--ap-wodvtt-cross-ink) calc(50% + 1px), transparent calc(50% + 1px)),
        linear-gradient(to bottom left, transparent calc(50% - 1px), var(--ap-wodvtt-cross-ink) calc(50% - 1px), var(--ap-wodvtt-cross-ink) calc(50% + 1px), transparent calc(50% + 1px));
}

.box-track .bt-box:disabled { cursor: default; }

.box-track:focus-visible,
.box-track .bt-box:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

/* === Shared WODVTT health chart (HealthTrack) ============================
   Vertical wound-level chart: each row is a named level (label + penalty +
   damage boxes). A 3-column grid aligns label / penalty / boxes across rows.
   Boxes reuse the same --ap-wodvtt-* box visual as BoxTrack, so a health box
   matches a trait box. */
.health-track {
    --wodvtt-mark-size: 18px;

    /* Same glyph-derived target and floor as the other two controls -- both
       derivations are documented on `.trait-rating`. The chart has no 5-mark groups,
       so it carries no group gap, and .health-level-boxes below sets column-gap: 0
       for the same reason .trait-rating's within-gap is zero -- the target's own
       padding already spaces adjacent glyph edges by .5x. */
    --wodvtt-target-ratio: 1.5;
    --wodvtt-mark-target: calc(var(--wodvtt-mark-size) * var(--wodvtt-target-ratio));
    --wodvtt-mark-floor-glyph: 18px;
    --wodvtt-mark-floor: calc(var(--wodvtt-mark-floor-glyph) * var(--wodvtt-target-ratio));
    --wodvtt-scroll-ring-room: calc(var(--ap-focus-ring-width) + var(--ap-focus-ring-offset));
    display: grid;
    grid-template-columns: 1fr auto auto;   /* label | penalty | boxes */
    align-items: center;
    column-gap: var(--ap-space-3);
    row-gap: var(--ap-space-1);
    min-width: 0;
    max-width: 100%;

    /* The pressure here is the label column, not the boxes: the level names are
       `nowrap`, so a slot narrower than the longest name plus its penalty and boxes
       used to push the surplus out through every `overflow-x: visible` ancestor.
       Scrolling inside the chart contains it on the same terms as the other two
       controls, and the padding reserves the room a scroll box owes the focus ring
       -- which matters most here, because the ring hugs the whole box rather than
       the smaller glyph inside it and is therefore entirely outside the mark. */
    overflow-x: auto;
    overflow-y: auto;
    padding: var(--wodvtt-scroll-ring-room);
}

/* 18px -- see .box-track--compact above; 14px would give a 21px target. */
.health-track--compact { --wodvtt-mark-size: 18px; }

.health-track .health-level-label {
    color: var(--ap-text-primary);
    font-size: var(--ap-text-sm);
    white-space: nowrap;
}

.health-track .health-level-penalty {
    color: var(--ap-text-secondary);
    font-size: var(--ap-text-sm);
    text-align: right;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.health-track .health-level-boxes {
    display: flex;
    align-items: center;
    column-gap: 0;
}

.health-track .health-box {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: var(--wodvtt-mark-target);
    height: var(--wodvtt-mark-target);

    /* The same bound the other two marks carry. A level holds one or two boxes, so
       this control has never been the one under width pressure -- which is exactly
       why the floor is stated rather than assumed: the grid's `auto` boxes column
       falls back to a content minimum, and a mark's content minimum is its glyph,
       18px, not its target. */
    min-width: var(--wodvtt-mark-floor);
    margin: 0;
    padding: 0;
    border: 0;
    background: transparent;
    font: inherit;
    cursor: pointer;
}

.health-track .health-box::before {
    content: "";
    box-sizing: border-box;
    width: var(--wodvtt-mark-size);
    height: var(--wodvtt-mark-size);
    border: .7px solid var(--ap-wodvtt-mark-empty-border);
    border-radius: var(--ap-wodvtt-mark-radius-box);
    background: var(--ap-wodvtt-mark-empty-bg);
    box-shadow: var(--ap-wodvtt-box-empty-shadow);
}

/* Damaged box → ink mark drawn on top of the engraved well (same idiom as BoxTrack
   Cross mode). The two damage states are told apart by DRAWN GEOMETRY, never by ink
   alone: lethal damage is one diagonal stroke, aggravated damage is two crossing
   strokes. That is WCAG 1.4.1 (Use of Color) — the states used to share a single
   `.cross` mark and differed only in hue, which left them indistinguishable to a
   colour-blind reader and in greyscale. Colour still reinforces the distinction; it no
   longer carries it. `HealthTrack.CssFor` therefore emits `stroke` or `cross`, not both.

   The grouped `--hb-ink` below is the DEFAULT ink for either mark. `HealthTrack` only ever
   pairs `cross` with `aggravated`, whose own rule overrides it, so the bare `.cross` arm is
   reached by hand-written markup rather than by the component — including the negative
   control in tests/visual/computed-style.spec.ts, which renders the pre-change pair to prove
   its geometry check can see the defect. Keep the arm when editing these rules. */
.health-track .health-box.stroke,
.health-track .health-box.cross { --hb-ink: var(--ap-wodvtt-cross-ink); }
.health-track .health-box.normal.stroke::before { border-color: var(--ap-wodvtt-mark-filled-border); }
.health-track .health-box.aggravated.cross { --hb-ink: var(--ap-semantic-error); }
.health-track .health-box.aggravated.cross::before { border-color: var(--ap-semantic-error); }

/* The surface both marks draw into. Square and derived from the glyph, so the strokes
   stay on the box's own diagonals at every declared --wodvtt-mark-size rather than only
   at the default. */
.health-track .health-box.stroke::after,
.health-track .health-box.cross::after {
    content: "";
    position: absolute;
    width: calc(var(--wodvtt-mark-size) * .64);
    height: calc(var(--wodvtt-mark-size) * .64);
}

/* Lethal damage → EXACTLY ONE band. A gradient's colour stops are perpendicular to its
   axis, so `to bottom right` (axis on the top-left → bottom-right diagonal) paints the
   band bottom-left → upper-right, i.e. "/". Established by rendering both candidates
   rather than by reasoning about the axis, per spec-final.md §8, and pinned by
   SharedHealthTrackTests. */
.health-track .health-box.stroke::after {
    background:
        linear-gradient(to bottom right, transparent calc(50% - 1px), var(--hb-ink) calc(50% - 1px), var(--hb-ink) calc(50% + 1px), transparent calc(50% + 1px));
}

/* Aggravated damage → the lethal band plus its mirror, crossing it. */
.health-track .health-box.cross::after {
    background:
        linear-gradient(to bottom right, transparent calc(50% - 1px), var(--hb-ink) calc(50% - 1px), var(--hb-ink) calc(50% + 1px), transparent calc(50% + 1px)),
        linear-gradient(to bottom left, transparent calc(50% - 1px), var(--hb-ink) calc(50% - 1px), var(--hb-ink) calc(50% + 1px), transparent calc(50% + 1px));
}

.health-track .health-box:disabled { cursor: default; }

.health-track .health-box:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

/* === WODVTT mark target follows the input device =========================
   Density is an input-device question, not a screen-size one. A mouse or
   trackpad is precise, so a 1.5x-glyph target puts a whole ten-mark rating in
   view at once with no aiming cost; a fingertip is not, so on a coarse pointer
   the three controls hand their target back to the platform touch floor and
   scroll inside their own box once the marks no longer fit at it. Deriving that
   from `pointer:` rather than from a width breakpoint is what keeps a touch
   laptop and a phone on the same side of the line.

   The FLOOR moves with the target, which is the half of this block that makes the
   sentence above true. Handing back a nominal target alone left the marks free to
   compress below it -- measured 33.6px at the ~343px slot a 375px phone gives a
   trait, 27.3px at 280px and 19.3px at 200px, against a "coarse-pointer target
   >= 44px" Must and an unconditionally worded AC-02. Binding the floor to the same
   token means a coarse pointer has no shrink range at all: the marks are 44px or
   the control scrolls, and there is no width at which they are neither. That is
   what §2.2 already anticipates here ("Fingers need room; scrolling is anticipated
   there"), and the header of this block claimed before it was true.

   The rebinding is deliberately confined to these three control roots.
   `--ap-touch-target-min` itself is never redefined: four unrelated
   touch-critical surfaces read it (the hosted-shell navigation rows and
   hamburger, the approval-card button, the one-time-token button), and moving
   the token would shrink every one of them from a WODVTT styling change. */
/* Readonly marks accept no pointer action and have no keyboard focus target.
   Keep the existing 18px/20px glyphs and grouped single-line vocabulary, with
   2px between adjacent glyphs instead of interactive target padding. Ten compact
   trait marks need 206.3px (200px plus the 6.3px group gap); consumers still own
   that slot. The floor preserves ink legibility and scrolling at narrower slots.
   Disabled BoxTrack buttons remain disabled; neither component gains a tab stop. */
.trait-rating.trait-rating--readonly,
.box-track.box-track--readonly .bt-boxes {
    --wodvtt-mark-target: calc(var(--wodvtt-mark-size) + 2px);
    --wodvtt-mark-floor: var(--wodvtt-mark-target);
    /* WI12-D5-01: --wodvtt-mark-hpad is a literal now (declared once, on .trait-rating), not
       derived from --wodvtt-mark-floor, so it needs no re-derivation here -- readonly inherits
       the same fixed 8px/9px value the interactive row uses, and it is still never touched by
       the coarse-pointer override below (that selector excludes readonly anyway). */
    --wodvtt-scroll-ring-room: 0px;
}

@media (pointer: coarse) {
    .trait-rating:not(.trait-rating--readonly),
    .box-track:not(.box-track--readonly) .bt-boxes,
    .health-track {
        --wodvtt-mark-target: var(--ap-touch-target-min);
        --wodvtt-mark-floor: var(--ap-touch-target-min);
    }
}

/* PlatformShellLayout — NAV-03 hosted-app shell, built on Radzen primitives
   (RadzenSidebar / RadzenPanelMenu / RadzenPanelMenuItem / RadzenHeader / RadzenLink).
   One shell nav container: a platform section (always rendered first, never hidden)
   plus one bounded app-local section below it. Desktop uses a persistent left side-rail
   plus a top bar; mobile mirrors the same order inside a hamburger-triggered drawer
   that pushes content (no overlay, no bottom nav). The version + changelog link is
   pinned to the bottom of the rail / drawer. The drawer disclosure is CSS-only (a
   hidden checkbox + <label> hamburger + :checked sibling selector) so the shell works
   under static SSR as well as InteractiveServer. */
.ap-hosted-shell {
    display: flex;
    min-height: 100vh;
}

/* Hidden state holder for the CSS-only mobile drawer. */
.ap-hosted-shell-drawer-toggle {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
}

/* Persistent dark side-rail. The Radzen sidebar element carries both .rz-sidebar and
   this class; override the Radzen defaults so the rail is in-flow and always visible on
   desktop. */
.ap-hosted-shell-nav.rz-sidebar,
.ap-hosted-shell-nav {
    display: none;
    position: static;
    inset: auto;
    transform: none;
    flex-direction: column;
    width: 260px;
    flex-shrink: 0;
    height: auto;
    min-height: 100vh;
    padding: var(--ap-space-3);
    gap: var(--ap-space-3);
    border: none;
    border-right: 1px solid var(--ap-border-default);
    background-color: var(--ap-bg-elevated);
    color: var(--ap-text-primary);
}

/* Brand tile + wordmark at the very top of the rail. */
.ap-hosted-shell-brand {
    display: flex;
    align-items: center;
    gap: var(--ap-space-2);
    padding: var(--ap-space-2);
    font-weight: 600;
    color: var(--ap-text-primary);
}

.ap-hosted-shell-brand-tile {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    flex-shrink: 0;
    border-radius: var(--ap-radius-sm);
    background-color: var(--ap-action-primary-bg, var(--ap-action-primary));
    color: var(--ap-action-primary-text, var(--ap-text-on-accent));
}

.ap-hosted-shell-brand-text {
    font-size: var(--ap-text-base);
}

/* One Radzen panel menu per nav section (a valid <ul> of RadzenPanelMenuItem rows).
   Strip the default Radzen chrome/margin so the section heading sits directly above a
   tight list of item rows and the two sections stack cleanly. */
.ap-hosted-shell-panel-menu.rz-panel-menu,
.ap-hosted-shell-panel-menu {
    background: none;
    border: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

/* Each nav section: heading + its own panel menu, stacked. Section-to-section spacing
   comes from the rail / drawer flex container gap. */
.ap-hosted-shell-nav-section {
    display: flex;
    flex-direction: column;
    gap: var(--ap-space-1);
}

.ap-hosted-shell-nav-heading {
    padding: 0 var(--ap-space-2);
    font-size: var(--ap-text-xs);
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--ap-text-secondary);
}

.ap-hosted-shell-nav-divider {
    height: 1px;
    margin: var(--ap-space-1) 0;
    background-color: var(--ap-border-default);
}

/* Radzen renders each RadzenPanelMenuItem as an <li><a> row — give the anchors the
   icon+label appearance and an active/current highlight consistent with the approved
   mockup. */
.ap-hosted-shell-nav .rz-navigation-item-link,
.ap-hosted-shell-mobile-nav .rz-navigation-item-link {
    display: flex;
    align-items: center;
    gap: var(--ap-space-2);
    min-height: var(--ap-touch-target-min);
    padding: var(--ap-space-2);
    border-inline-start: var(--ap-focus-ring-width) solid transparent;
    border-radius: var(--ap-radius-sm);
    background-image: none;
    color: var(--ap-text-primary);
    text-decoration: none;
}

.ap-hosted-shell-mobile-nav .rz-navigation-item-link,
.ap-hosted-shell-nav .rz-navigation-item-link:not(.rz-state-disabled)::before,
.ap-hosted-shell-mobile-nav .rz-navigation-item-link:not(.rz-state-disabled)::before {
    background-image: none;
}

.ap-hosted-shell-nav .rz-navigation-item-link .rzi,
.ap-hosted-shell-mobile-nav .rz-navigation-item-link .rzi,
.ap-hosted-shell-nav .rz-navigation-item-link .rz-navigation-item-icon,
.ap-hosted-shell-mobile-nav .rz-navigation-item-link .rz-navigation-item-icon {
    color: var(--ap-text-primary) !important;
    -webkit-text-fill-color: var(--ap-text-primary) !important;
}

/* Radzen's desktop PanelMenu icon rule is more specific than the link colour;
   keep this override scoped to the hosted shell so dark rail icons stay legible. */
.ap-hosted-shell-nav:is(#ap-hosted-shell-nav-icon-contrast, .ap-hosted-shell-nav) .ap-hosted-shell-panel-menu.rz-panel-menu .rz-navigation-item-wrapper .rz-navigation-item-link .rz-navigation-item-icon {
    color: var(--ap-text-primary) !important;
    -webkit-text-fill-color: var(--ap-text-primary) !important;
}

.ap-hosted-shell-nav .rz-navigation-item-link:hover,
.ap-hosted-shell-mobile-nav .rz-navigation-item-link:hover {
    background-color: var(--ap-bg-surface);
}

.ap-hosted-shell-nav .rz-navigation-item-link:focus-visible,
.ap-hosted-shell-mobile-nav .rz-navigation-item-link:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

.ap-hosted-shell-nav .rz-navigation-item-wrapper-active > .rz-navigation-item-link,
.ap-hosted-shell-mobile-nav .rz-navigation-item-wrapper-active > .rz-navigation-item-link,
.ap-hosted-shell-nav .rz-navigation-item-link.active,
.ap-hosted-shell-mobile-nav .rz-navigation-item-link.active,
.ap-hosted-shell-nav .rz-navigation-item-link[aria-current="page"],
.ap-hosted-shell-mobile-nav .rz-navigation-item-link[aria-current="page"] {
    background-color: var(--ap-action-primary-subtle);
    color: var(--ap-text-primary);
    font-weight: 600;
    border-inline-start-color: var(--ap-action-primary);
}

/* Version + changelog link pinned to the very bottom of the rail. */
.ap-hosted-shell-version {
    margin-top: auto;
    padding: var(--ap-space-2);
    border-top: 1px solid var(--ap-border-default);
}

.ap-hosted-shell-version--mobile {
    margin-top: var(--ap-space-2);
}

.ap-hosted-shell-version-link.rz-link,
.ap-hosted-shell-version-link {
    display: inline-flex;
    align-items: center;
    gap: var(--ap-space-1);
    font-family: var(--ap-font-mono);
    font-size: var(--ap-text-xs);
    color: var(--ap-text-secondary);
    text-decoration: none;
}

.ap-hosted-shell-main {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
}

/* Top bar. The Radzen header element carries both .rz-header and this class.
   Radzen's base .rz-header is position:fixed, which pulls the bar out of normal flow so page
   content underlaps it — the first heading of every shell page was occluded by the bar.
   Pin it with position:sticky instead: it stays in normal flow (reserving its own height,
   so content starts below it) AND remains visible on scroll. The two-class selector shares
   the specificity of the background/border overrides above, and this sheet loads after
   Radzen's theme, so it wins the position declaration too. */
.ap-hosted-shell-topbar.rz-header,
.ap-hosted-shell-topbar {
    position: sticky;
    top: 0;
    z-index: 100;
    display: flex;
    align-items: center;
    gap: var(--ap-space-3);
    min-height: var(--ap-header-height);
    padding: var(--ap-space-2) var(--ap-space-3);
    border-bottom: 1px solid var(--ap-border-default);
    background-color: var(--ap-bg-surface);
}

/* Right-aligned: app switcher first, then the app's own top-bar controls. */
.ap-hosted-shell-topbar-content {
    display: flex;
    align-items: center;
    gap: var(--ap-space-2);
    margin-left: auto;
    min-width: 0;
    max-width: 100%;
    flex-wrap: nowrap;
}

/* Global fallback for the platform's authenticated header island. These rules
   intentionally mirror AppHeader.razor.css so the shell stays coherent even when
   the interactive island hydrates later than the static shell or component-scoped
   CSS is not yet applied. */
.ap-hosted-shell-topbar-content .ap-header-actions {
    display: flex;
    align-items: center;
    gap: var(--ap-space-3);
    min-width: 0;
    flex: 0 1 auto;
    flex-wrap: nowrap;
}

.ap-hosted-shell-topbar-content .ap-language-switcher {
    display: inline-flex;
    align-items: center;
    gap: var(--ap-space-1);
    flex: 0 0 auto;
}

.ap-hosted-shell-topbar-content .ap-lang-form {
    display: inline-flex;
    margin: 0;
}

.ap-hosted-shell-topbar-content .ap-lang-btn[aria-pressed="true"] {
    background: var(--ap-action-primary-bg, var(--ap-primary));
    color: var(--ap-action-primary-text, var(--ap-color-on-accent));
    border-color: var(--ap-action-primary-bg, var(--ap-primary));
    font-weight: 700;
}

.ap-hosted-shell-topbar-content .ap-user-menu {
    flex: 0 0 auto;
}

/* Hamburger is a <label> for the CSS-only drawer toggle. */
.ap-hosted-shell-hamburger {
    display: inline-flex;
    flex-direction: column;
    justify-content: center;
    gap: 4px;
    width: var(--ap-touch-target-min);
    height: var(--ap-touch-target-min);
    background: none;
    border: none;
    cursor: pointer;
    border-radius: var(--ap-radius-sm);
}

.ap-hosted-shell-hamburger:hover {
    background-color: var(--ap-bg-elevated);
}

.ap-hosted-shell-drawer-toggle:focus-visible + .ap-hosted-shell-nav ~ .ap-hosted-shell-main .ap-hosted-shell-hamburger,
.ap-hosted-shell-hamburger:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

.ap-hosted-shell-hamburger-line {
    display: block;
    width: 20px;
    height: 2px;
    margin: 0 auto;
    background-color: var(--ap-text-primary);
}

/* Mobile drawer: rendered in-flow (not absolutely positioned) so it pushes content
   rather than overlaying it — no bottom nav is ever part of this shell. Hidden until
   the CSS-only toggle is checked. */
.ap-hosted-shell-mobile-nav {
    display: none;
    flex-direction: column;
    gap: var(--ap-space-3);
    padding: var(--ap-space-3);
    border-bottom: 1px solid var(--ap-border-default);
    background-color: var(--ap-bg-elevated);
}

.ap-hosted-shell-drawer-toggle:checked ~ .ap-hosted-shell-main .ap-hosted-shell-mobile-nav {
    display: flex;
}

.ap-hosted-shell-content {
    flex: 1;
    padding: var(--ap-space-3);
    min-width: 0;
}

@media (min-width: 768px) {
    .ap-hosted-shell-nav.rz-sidebar,
    .ap-hosted-shell-nav {
        display: flex;
    }

    .ap-hosted-shell-hamburger {
        display: none;
    }

    .ap-hosted-shell-mobile-nav,
    .ap-hosted-shell-drawer-toggle:checked ~ .ap-hosted-shell-main .ap-hosted-shell-mobile-nav {
        display: none !important;
    }
}

/* Stage C shell slots — PlatformShellLayout PageHeaderContent / FooterContent (WI-03,
   shared-ui-shell-consolidation). Wrapper elements only; no layout of their own beyond spacing so
   consumer content controls its own presentation. */
.ap-hosted-shell-page-header {
    margin-bottom: var(--ap-space-3);
}

.ap-hosted-shell-footer {
    margin-top: var(--ap-space-4);
    padding-top: var(--ap-space-3);
    border-top: 1px solid var(--ap-border-default);
    color: var(--ap-text-secondary);
    font-size: var(--ap-text-sm);
}

/* PlatformUserMenu */
.ap-user-menu {
    display: flex;
    align-items: center;
    gap: var(--ap-space-2);
}

.ap-user-menu-avatar {
    width: var(--ap-space-6);
    height: var(--ap-space-6);
    border-radius: var(--ap-radius-full);
    object-fit: cover;
}

.ap-user-menu-initials {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--ap-space-6);
    height: var(--ap-space-6);
    border-radius: var(--ap-radius-full);
    background-color: var(--ap-bg-elevated);
    color: var(--ap-text-primary);
    font-size: var(--ap-text-xs);
    font-weight: 700;
    flex: 0 0 auto;
    white-space: nowrap;
}

.ap-user-menu-name {
    font-size: var(--ap-text-sm);
    color: var(--ap-text-primary);
    white-space: nowrap;
    min-width: 4ch;
    max-width: 18ch;
    overflow: hidden;
    text-overflow: ellipsis;
}

.ap-user-menu-sign-out-form {
    display: inline-flex;
    margin: 0;
    flex: 0 0 auto;
    white-space: nowrap;
}

/* PlatformNavItem count badge */
.ap-nav-item-label {
    flex: 1 1 auto;
}

.ap-nav-count {
    margin-left: var(--ap-space-2);
}

/* ---------------------------------------------------------------------------
   Ops design-system components (agent-operations-interface KC-01..KC-03)
   Candidate lifecycle — staging-only until contract approval.
   --------------------------------------------------------------------------- */

/* Visually hidden (screen-reader-only) utility */
.ap-visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* KPI tile (kpi-tile) */
.ap-kpi-tile {
    display: flex;
    flex-direction: column;
    gap: var(--ap-space-2);
    min-width: 0;
    min-height: 132px;
    padding: var(--ap-space-4);
    background-color: var(--ap-bg-surface);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
}

.ap-kpi-tile-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--ap-space-2);
}

.ap-kpi-tile-label {
    min-width: 0;
    font-size: var(--ap-text-sm);
    color: var(--ap-text-secondary);
    overflow-wrap: anywhere;
}

.ap-kpi-tile-state-icon {
    color: var(--ap-text-primary);
}

.ap-kpi-tile-value {
    font-size: var(--ap-text-2xl);
    font-weight: 600;
    color: var(--ap-text-primary);
}

.ap-kpi-tile-value-muted {
    color: var(--ap-text-primary);
}

.ap-kpi-tile-unit {
    margin-left: var(--ap-space-1);
    font-size: var(--ap-text-sm);
    font-weight: 400;
    color: var(--ap-text-secondary);
}

.ap-kpi-tile-state-text {
    font-size: var(--ap-text-sm);
    color: var(--ap-text-secondary);
}

.ap-kpi-tile-state-degraded {
    color: var(--ap-text-primary);
    font-weight: 600;
}

.ap-kpi-tile-skeleton {
    display: flex;
    flex-direction: column;
    gap: var(--ap-space-2);
}

.ap-kpi-tile-skeleton-bar {
    border-radius: var(--ap-radius-sm);
    background-color: var(--ap-bg-elevated);
}

.ap-kpi-tile-skeleton-value {
    width: 60%;
    height: var(--ap-space-6);
}

.ap-kpi-tile-skeleton-trend {
    width: 100%;
    height: var(--ap-space-4);
}

.ap-kpi-tile-drill {
    align-self: flex-end;
    margin-top: auto;
    padding: 0;
    border: 0;
    background: none;
    font-size: var(--ap-text-xs);
    color: var(--ap-text-primary);
    font-weight: 600;
    text-decoration: none;
    cursor: pointer;
}

.ap-kpi-tile-drill:hover {
    text-decoration: underline;
}

.ap-kpi-tile-drill:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

/* KPI trend sparkline (kpi-trend-chart) */
.ap-kpi-trend {
    display: block;
    width: 100%;
    height: 28px;
}

.ap-kpi-trend-svg {
    display: block;
    width: 100%;
    height: 28px;
}

.ap-kpi-trend-line {
    fill: none;
    stroke: var(--ap-action-primary);
    stroke-width: 2px;
    stroke-linejoin: round;
    stroke-linecap: round;
}

.ap-kpi-trend-empty {
    display: block;
    height: 28px;
    font-size: var(--ap-text-xs);
    color: var(--ap-text-secondary);
    overflow-wrap: anywhere;
}

/* Approval decision card (approval-card) */
.ap-approval-card {
    display: flex;
    flex-direction: column;
    gap: var(--ap-space-3);
    padding: var(--ap-space-4);
    background-color: var(--ap-bg-surface);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
    box-shadow: var(--ap-shadow-sm);
}

.ap-approval-card-header {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--ap-space-2);
}

.ap-approval-card-risk {
    display: inline-flex;
    align-items: center;
    gap: var(--ap-space-1);
    padding: 2px var(--ap-space-2);
    border-radius: var(--ap-radius-sm);
    border: 1px solid var(--ap-border-default);
    font-size: var(--ap-text-xs);
    font-weight: 600;
    text-transform: uppercase;
    color: var(--ap-text-primary);
}

.ap-approval-card-risk-high .ap-approval-card-risk-icon {
    color: var(--ap-text-primary);
}

.ap-approval-card-risk-medium .ap-approval-card-risk-icon {
    color: var(--ap-semantic-warning);
}

.ap-approval-card-risk-low .ap-approval-card-risk-icon {
    color: var(--ap-semantic-info);
}

.ap-approval-card-title {
    font-size: var(--ap-text-base);
    font-weight: 600;
    color: var(--ap-text-primary);
    overflow-wrap: anywhere;
}

.ap-approval-card-facts {
    display: flex;
    flex-direction: column;
    gap: var(--ap-space-1);
    margin: 0;
}

.ap-approval-card-fact {
    display: flex;
    gap: var(--ap-space-2);
    min-width: 0;
    font-size: var(--ap-text-sm);
}

.ap-approval-card-fact dt {
    min-width: 88px;
    color: var(--ap-text-secondary);
}

.ap-approval-card-fact dd {
    min-width: 0;
    margin: 0;
    color: var(--ap-text-primary);
    overflow-wrap: anywhere;
}

.ap-approval-card-identity-class,
.ap-approval-card-target-resource {
    color: var(--ap-text-primary);
}

.ap-approval-card-args summary {
    cursor: pointer;
    font-size: var(--ap-text-sm);
    color: var(--ap-text-secondary);
}

.ap-approval-card-args summary:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

.ap-approval-card-args-json {
    margin: var(--ap-space-2) 0 0;
    padding: var(--ap-space-2);
    background-color: var(--ap-bg-elevated);
    border-radius: var(--ap-radius-sm);
    font-size: var(--ap-text-xs);
    box-sizing: border-box;
    max-width: 100%;
    overflow-x: hidden;
    overflow-wrap: anywhere;
    white-space: normal;
    word-break: break-all;
}

.ap-approval-card-status {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--ap-space-2);
    margin: 0;
    font-size: var(--ap-text-sm);
    color: var(--ap-text-primary);
}

.ap-approval-card-state-approved .ap-approval-card-status-icon {
    color: var(--ap-semantic-success);
}

.ap-approval-card-state-rejected .ap-approval-card-status-icon {
    color: var(--ap-text-primary);
}

.ap-approval-card-outcome,
.ap-approval-card-reason {
    color: var(--ap-text-primary);
}

.ap-approval-card-busy {
    width: var(--ap-space-3);
    height: var(--ap-space-3);
    border: 2px solid var(--ap-border-default);
    border-top-color: var(--ap-action-primary);
    border-radius: var(--ap-radius-full);
    animation: ap-approval-card-spin 0.9s linear infinite;
}

@keyframes ap-approval-card-spin {
    to {
        transform: rotate(360deg);
    }
}

.ap-approval-card-actions {
    display: flex;
    align-items: stretch;
    flex-wrap: wrap;
    gap: var(--ap-space-2);
    justify-content: flex-end;
}

.ap-approval-card-action-slot {
    display: flex;
    flex: 1 1 0;
    min-width: 0;
}

.ap-approval-card-btn {
    width: 100%;
    min-height: var(--ap-touch-target-min);
    padding: var(--ap-space-2) var(--ap-space-4);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
    font-size: var(--ap-text-sm);
    font-weight: 600;
    cursor: pointer;
    transition: opacity var(--ap-motion-duration-fast) var(--ap-motion-ease-standard);
}

.ap-approval-card-btn:hover:not(:disabled) {
    opacity: var(--ap-state-hover-opacity);
}

.ap-approval-card-btn:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

.ap-approval-card-btn:disabled {
    opacity: var(--ap-state-disabled-opacity);
    cursor: not-allowed;
}

.ap-approval-card-btn-approve {
    background-color: var(--ap-action-primary-bg);
    border-color: var(--ap-action-primary-bg);
    color: var(--ap-action-primary-text);
}

.ap-approval-card-btn-reject {
    background-color: var(--ap-bg-surface);
    color: var(--ap-text-primary);
}

@media (min-width: 48em) {
    .ap-approval-card-actions {
        justify-content: flex-end;
    }

    .ap-approval-card-btn {
        width: auto;
    }

    .ap-approval-card-action-slot {
        flex: 0 0 auto;
        width: auto;
    }
}

@media (max-width: 40rem) {
    .ap-approval-card-actions {
        justify-content: stretch;
    }

    .ap-approval-card-fact {
        flex-direction: column;
        gap: var(--ap-space-1);
    }

    .ap-approval-card-fact dt {
        min-width: 0;
    }
}

/* Show-once token reveal (token-once-dialog) */
.ap-token-once {
    display: flex;
    flex-direction: column;
    gap: var(--ap-space-3);
    max-width: 35em;
    padding: var(--ap-space-4);
    background-color: var(--ap-bg-surface);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
}

.ap-token-once-warning {
    display: flex;
    gap: var(--ap-space-2);
    padding: var(--ap-space-3);
    background-color: var(--ap-bg-elevated);
    border-left: 3px solid var(--ap-semantic-warning);
    border-radius: var(--ap-radius-sm);
}

.ap-token-once-warning-icon {
    color: var(--ap-text-primary);
    flex: 0 0 auto;
}

.ap-token-once-warning-body p {
    margin: var(--ap-space-1) 0 0;
    font-size: var(--ap-text-sm);
    color: var(--ap-text-secondary);
}

.ap-token-once-token-label {
    font-size: var(--ap-text-sm);
    color: var(--ap-text-secondary);
}

.ap-token-once-token-row {
    display: flex;
    align-items: stretch;
    flex-wrap: wrap;
    gap: var(--ap-space-2);
}

.ap-token-once-token {
    flex: 1;
    padding: var(--ap-space-2) var(--ap-space-3);
    background-color: var(--ap-bg-elevated);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-sm);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: var(--ap-text-sm);
    color: var(--ap-text-primary);
    overflow-wrap: anywhere;
    user-select: all;
}

.ap-token-once-copied {
    display: flex;
    align-items: center;
    gap: var(--ap-space-1);
    min-height: var(--ap-space-4);
    margin: 0;
    font-size: var(--ap-text-sm);
    color: var(--ap-text-primary);
}

.ap-token-once-copied-icon {
    color: var(--ap-semantic-success);
}

.ap-token-once-hint {
    margin: 0;
    font-size: var(--ap-text-sm);
    color: var(--ap-text-secondary);
}

.ap-token-once-path {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    color: var(--ap-text-primary);
    overflow-wrap: anywhere;
}

.ap-token-once-ack {
    display: flex;
    align-items: center;
    gap: var(--ap-space-2);
    font-size: var(--ap-text-sm);
    color: var(--ap-text-primary);
    cursor: pointer;
}

.ap-token-once-ack input:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

.ap-token-once-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--ap-space-2);
}

.ap-token-once-btn {
    min-height: var(--ap-touch-target-min);
    max-width: 100%;
    padding: var(--ap-space-2) var(--ap-space-4);
    border: 1px solid var(--ap-border-default);
    border-radius: var(--ap-radius-md);
    font-size: var(--ap-text-sm);
    font-weight: 600;
    cursor: pointer;
    transition: opacity var(--ap-motion-duration-fast) var(--ap-motion-ease-standard);
    white-space: normal;
}

.ap-token-once-btn:hover:not(:disabled) {
    opacity: var(--ap-state-hover-opacity);
}

.ap-token-once-btn:focus-visible {
    outline: var(--ap-focus-ring-width) solid var(--ap-focus-ring-color);
    outline-offset: var(--ap-focus-ring-offset);
}

.ap-token-once-btn:disabled {
    opacity: var(--ap-state-disabled-opacity);
    cursor: not-allowed;
}

.ap-token-once-btn-copy {
    background-color: var(--ap-bg-surface);
    color: var(--ap-text-primary);
}

.ap-token-once-btn-close {
    background-color: var(--ap-action-primary-bg);
    border-color: var(--ap-action-primary-bg);
    color: var(--ap-action-primary-text);
}

/* === Changelog ===
   Body rules for ChangelogPage's rendered release-notes content (shared-content-surfaces
   WI-01). Moved verbatim from AppPlatform.Web/wwwroot/app.css:71-129 — the content is
   injected via @((MarkupString)html) inside ChangelogPage, and Blazor CSS isolation never
   stamps its scope attribute onto MarkupString-injected markup, so these rules MUST live
   here (non-scoped) rather than in ChangelogPage.razor.css. See spec-final.md §2.3. */
.ap-changelog-content {
  box-sizing: border-box;
  width: 100%;
  max-width: 100%;
  margin: 0;
  color: var(--ap-text-primary);
  font-size: var(--ap-text-base);
  line-height: 1.6;
  overflow-wrap: break-word;
  word-break: break-word;
}

.ap-changelog-content > p {
  max-width: 72ch;
  margin: 0 0 var(--ap-space-4);
  color: var(--ap-text-secondary);
}

.ap-changelog-release {
  box-sizing: border-box;
  width: 100%;
  max-width: 100%;
  margin: 0 0 var(--ap-space-4);
  padding: var(--ap-space-4);
  border: 1px solid var(--ap-border-default);
  border-radius: var(--ap-radius-md);
  background-color: var(--ap-bg-surface);
  color: var(--ap-text-primary);
}

.ap-changelog-release h2 {
  margin: 0 0 var(--ap-space-3);
  font-size: var(--ap-text-xl);
  line-height: 1.25;
}

.ap-changelog-release h3 {
  margin: var(--ap-space-4) 0 var(--ap-space-2);
  font-size: var(--ap-text-base);
  line-height: 1.35;
  color: var(--ap-text-primary);
}

.ap-changelog-release ul {
  margin: 0 0 var(--ap-space-3);
  padding-left: var(--ap-space-6);
}

.ap-changelog-release li {
  margin-bottom: var(--ap-space-2);
}

.ap-changelog-note {
  padding: var(--ap-space-3);
  border-left: 3px solid var(--ap-action-primary-bg);
  background: color-mix(in srgb, var(--ap-text-secondary) 8%, transparent);
  color: var(--ap-text-primary) !important;
}

/* .ap-changelog-empty — first rule for this class (the renderer already emits it for the
   empty branch, but nothing styled it until now). Follows the approved empty-state pattern
   (cf. .ap-kpi-trend-empty): muted secondary-text ink, no border, no surface. */
.ap-changelog-empty {
  margin: 0;
  color: var(--ap-text-secondary);
  font-size: var(--ap-text-base);
}
