h1:focus {
    outline: none;
}

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

/* The theme's bad ink, not a fixed red: it holds AA contrast on the summary's soft pink and on dark
   surfaces alike (see the contrast table on :root). */
.invalid {
    outline: 1px solid var(--ff-bad-ink);
}

.validation-message {
    color: var(--ff-bad-ink);
}

.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."
    }

.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;
}

/* --- Field Force shell ---
   Shared look for the Home + Browse pages (and AccessDenied). No CSS framework is included, so this
   is a small, self-contained set of utility classes. MainLayout/NavMenu-specific chrome lives in
   their own scoped .razor.css files instead; only classes shared across otherwise-unrelated pages
   belong here. */

:root {
  /* Brand: indigo (structure: nav, links, focus, data-viz primary) */
  --ff-brand: #4f46e5; --ff-brand-strong: #4338ca; --ff-brand-soft: #eef2ff;
  /* Accent: VSTECS orange (PRIMARY CALL-TO-ACTION BUTTONS ONLY, ~5% of UI) */
  --ff-accent: #f05030; --ff-accent-strong: #d8431f; --ff-accent-soft: #fdebe5;
  /* Neutrals (slate) */
  --ff-text: #0f172a; --ff-muted: #64748b; --ff-border: #e2e8f0; --ff-bg: #f8fafc; --ff-surface: #ffffff;
  /* Status: semantic, reserved (never decorative) */
  --ff-good: #16a34a; --ff-good-soft: #dcfce7; --ff-good-ink: #15803d;
  --ff-watch: #d97706; --ff-watch-soft: #fef3c7; --ff-watch-ink: #b45309;
  --ff-bad: #dc2626; --ff-bad-soft: #fee2e2; --ff-bad-ink: #b91c1c;
  /* Elevation / shape / motion */
  --ff-shadow-sm: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.04);
  --ff-shadow-md: 0 6px 24px rgba(16,24,40,.08);
  --ff-radius: 10px; --ff-radius-sm: 8px; --ff-ease: .2s cubic-bezier(.4,0,.2,1);
  /* Typography */
  --ff-font: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  /* Lets native controls (checkboxes, date pickers, scrollbars, select dropdowns) render themselves
     in whichever theme is actually active, instead of defaulting to a light chrome that would look
     out of place once the dark block below takes over. */
  color-scheme: light dark;
}

/* --- Dark theme ---
   Redefines the slate/status/shadow tokens above for a dark surface; declared identically TWICE so
   activation has three levels (system preference by default, explicit override wins):
     1. @media (prefers-color-scheme: dark) + :not([data-theme="light"]) - applies when the OS/browser
        prefers dark AND no explicit "light" override is stamped on <html> (see App.razor's inline
        pre-paint script and app.js's setupThemeToggle).
     2. :root[data-theme="dark"] - applies whenever the override is explicitly "dark", regardless of
        system preference.
   --ff-brand, --ff-brand-strong lighten one step (indigo-400/300) because they double as TEXT/stroke
   colour on the dark --ff-bg/--ff-surface (nav links, tab-underline, trend-chart lines, picker-chip
   label) - the light-mode values compute under 2.5:1 there. --ff-accent/--ff-accent-strong are left
   unchanged: their only role that matters for contrast is as a FILLED button/pill background (white
   text on top, already correct per the "brand-filled elements keep #fff" rule below), and lightening
   them would instead break THAT pairing; the one place accent-strong appears as text (the
   Development-only "Dev Login" link) is a local/dev-only affordance outside the audited page list.
   Contrast verified against the WCAG relative-luminance formula (>= 4.5:1 required for every
   text/background pairing the UI actually uses):
     --ff-text   #e2e8f0 on --ff-bg #0f172a      -> 14.49:1   (on --ff-surface #1e293b -> 11.87:1)
     --ff-muted  #94a3b8 on --ff-surface          ->  5.71:1   (on --ff-bg               ->  6.97:1)
     --ff-brand  #818cf8 on --ff-surface          ->  4.90:1   (on --ff-bg               ->  5.99:1)
     --ff-brand-strong #a5b4fc on --ff-brand-soft ->  5.73:1   (picker-chip label/background)
     --ff-brand  #818cf8 on --ff-brand-soft #312e81 -> 4.90:1  (topnav hover/active label)
     --ff-good-ink  #4ade80 on --ff-good-soft  #052e16 -> 8.55:1
     --ff-watch-ink #fbbf24 on --ff-watch-soft #451a03 -> 8.97:1
     --ff-bad-ink   #f87171 on --ff-bad-soft   #450a0a -> 5.84:1  (also -> 5.29:1 on --ff-surface,
                                                                     6.46:1 on --ff-bg, for the plain
                                                                     .bar-value-alert usage)
   --ff-good/--ff-watch/--ff-bad (the base tones) are unchanged: they're only ever used as a filled
   SVG stroke/bar (donut segments, bar-fill-submitted/-pending), a non-text usage whose WCAG 1.4.11
   floor is 3:1, and all three clear it against both --ff-bg and --ff-surface (3.0-5.6:1). */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ff-brand: #818cf8; --ff-brand-strong: #a5b4fc; --ff-brand-soft: #312e81;
    --ff-accent-soft: #431407;
    --ff-text: #e2e8f0; --ff-muted: #94a3b8; --ff-border: #334155; --ff-bg: #0f172a; --ff-surface: #1e293b;
    --ff-good-soft: #052e16; --ff-good-ink: #4ade80;
    --ff-watch-soft: #451a03; --ff-watch-ink: #fbbf24;
    --ff-bad-soft: #450a0a; --ff-bad-ink: #f87171;
    --ff-shadow-sm: 0 1px 2px rgba(0,0,0,.4), 0 1px 3px rgba(0,0,0,.3);
    --ff-shadow-md: 0 6px 24px rgba(0,0,0,.5);
    color-scheme: dark;
  }
}

:root[data-theme="dark"] {
  --ff-brand: #818cf8; --ff-brand-strong: #a5b4fc; --ff-brand-soft: #312e81;
  --ff-accent-soft: #431407;
  --ff-text: #e2e8f0; --ff-muted: #94a3b8; --ff-border: #334155; --ff-bg: #0f172a; --ff-surface: #1e293b;
  --ff-good-soft: #052e16; --ff-good-ink: #4ade80;
  --ff-watch-soft: #451a03; --ff-watch-ink: #fbbf24;
  --ff-bad-soft: #450a0a; --ff-bad-ink: #f87171;
  --ff-shadow-sm: 0 1px 2px rgba(0,0,0,.4), 0 1px 3px rgba(0,0,0,.3);
  --ff-shadow-md: 0 6px 24px rgba(0,0,0,.5);
  color-scheme: dark;
}

/* Nav logo (MainLayout.razor's ".topnav-brand"): TWO logo <img>s are rendered - the navy wordmark
   (.topnav-logo-light, images/vstecs-logo.png) and a dark-surface variant with the wordmark recolored
   to white (.topnav-logo-dark, images/vstecs-logo-dark.png; same mark, no chip behind it) - and these
   rules show exactly one per EFFECTIVE theme, keyed the same dual way as the token blocks (the system
   media query unless an explicit data-theme override says otherwise). Global, not in
   MainLayout.razor.css: Blazor's CSS-isolation rewrite appends the component's scope attribute to the
   LAST compound selector, and a bare ":root[data-theme]" ancestor has no scope attribute to match, so
   scoped variants of these rules could never apply. */
/* ":root" prefix on purpose: MainLayout.razor.css's scoped ".topnav-logo { display:block }" compiles
   to ".topnav-logo[b-scope]" (0,2,0) and loads after this file, so a bare ".topnav-logo-dark" (0,1,0)
   hide would lose and BOTH logos would render in light mode. ":root .topnav-brand ..." is (0,3,0) and
   ties with the dark-mode show rules below, which win by source order - exactly the cascade we want. */
:root .topnav-brand .topnav-logo-dark {
    display: none;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .topnav-logo-light {
    display: none;
  }

  :root:not([data-theme="light"]) .topnav-logo-dark {
    display: inline-block;
  }
}

:root[data-theme="dark"] .topnav-logo-light {
    display: none;
}

:root[data-theme="dark"] .topnav-logo-dark {
    display: inline-block;
}

/* Apple Demo Profile badge (MainLayout.razor's ".topnav-demo-badge"): a small brand-soft pill next to
   the logo, shown only while the Apple demo dataset is active. Uses the existing brand tokens rather
   than fixed colors so it stays readable in both themes without a dedicated dark-mode override, the
   same approach as every other brand-soft chip in this file. */
.topnav-demo-badge {
    display: inline-flex;
    align-items: center;
    margin-left: 0.6rem;
    padding: 0.15rem 0.55rem;
    border-radius: 999px;
    background: var(--ff-brand-soft);
    /* brand-strong, not brand: at 0.7rem this must clear WCAG AA 4.5:1, and in dark mode
       --ff-brand on --ff-brand-soft computes to only 3.83:1 while --ff-brand-strong reaches 5.73:1
       (light mode clears easily either way). */
    color: var(--ff-brand-strong);
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    white-space: nowrap;
}

/* The "Demo Dataset" card on Dev/DevLogin.razor: a bordered surface box (same look as .module-card)
   but full-width above the accounts table rather than a fixed-width tile in a flex row - there is
   only ever one of these, so it does not need module-card's row-sharing sizing rules. */
.demo-dataset-card {
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    padding: 1rem 1.1rem;
    margin-bottom: 1.5rem;
}

.demo-dataset-card h2 {
    margin: 0 0 0.4rem;
    font-size: 1.1rem;
}

.demo-dataset-card form {
    margin-top: 0.75rem;
}

/* Dark-mode toggle icon swap (MainLayout.razor's "#theme-toggle"): the sun/moon spans are two
   siblings, one shown per EFFECTIVE theme - default (light) shows the moon (clicking it is the
   "go dark" action), the dark states show the sun instead. ".theme-toggle" itself is only ever
   rendered by MainLayout.razor, which has its own scoped MainLayout.razor.css, but these rules -
   like the ".topnav-logo" dark-mode rule just above - key off a bare ":root[data-theme]"/media
   ancestor that carries no scope attribute of its own, so a scoped selector could never match
   here. They live in this global stylesheet instead (the existing global "#topnav-toggle" sizing
   rule further down proves a global rule reaches an id MainLayout.razor renders just fine). Base
   button chrome (size, hover, icon sizing) is kept in this same global file rather than split
   across two stylesheets, so the whole control reads as one piece. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.1rem;
  height: 2.1rem;
  padding: 0;
  border: none;
  border-radius: var(--ff-radius-sm);
  background: transparent;
  color: var(--ff-muted);
  transition: var(--ff-ease);
}

.theme-toggle:hover,
.theme-toggle:focus-visible {
  background: var(--ff-brand-soft);
  color: var(--ff-brand);
}

.theme-toggle svg {
  width: 20px;
  height: 20px;
}

.theme-icon-sun {
  display: none;
}

.theme-icon-moon {
  display: flex;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle .theme-icon-sun {
    display: flex;
  }
  :root:not([data-theme="light"]) .theme-toggle .theme-icon-moon {
    display: none;
  }
}

:root[data-theme="dark"] .theme-toggle .theme-icon-sun {
  display: flex;
}

:root[data-theme="dark"] .theme-toggle .theme-icon-moon {
  display: none;
}

body {
    background: var(--ff-bg);
    margin: 0;
    color: var(--ff-text);
    font-family: var(--ff-font);
}

a {
    color: var(--ff-brand);
}

*:focus-visible {
    outline: 2px solid var(--ff-brand);
    outline-offset: 2px;
}

h1 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 0.5rem; }
h2 { font-size: 1.15rem; }
h3 { font-size: 1rem; }

/* Shared page header (title + one-line subtitle), a consistent top rhythm across every page. */
.page-head {
    margin-bottom: 1.5rem;
}
.page-head h1 {
    margin: 0 0 0.25rem;
}
.page-sub {
    margin: 0;
    color: var(--ff-muted);
}
/* Page-head variant carrying a primary action beside the title (e.g. Admin/Users.razor's "New
   user" button): title block and action share one row, spaced apart, instead of the action
   stacking underneath like a second title line. */
.page-head-row {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
}

/* Header actions grouped as ONE flex child of .page-head-row, so a page with more than one header
   button (e.g. New + Import) keeps its buttons together on the right instead of space-between
   scattering each button as its own child across the row. */
.page-head-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
}

/* Export CSV/Excel links (Merchandising Review.razor): a small row below the page-head-row, never its
   own new button style - the links reuse .btn/.btn-ghost/.btn-sm, only the label and the flex row need
   a rule of their own. */
.export-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-top: 0.75rem;
}
.export-label {
    color: var(--ff-muted);
    font-size: 0.85rem;
}
.breadcrumb {
    font-size: 0.8rem;
    margin: 0 0 0.5rem;
}
.breadcrumb a {
    color: var(--ff-muted);
    text-decoration: none;
}
.breadcrumb a:hover {
    color: var(--ff-brand);
}

/* --- Top navigation bar (GLOBAL, deliberately not scoped to NavMenu.razor.css) ---
   The nav items are <NavLink> COMPONENTS; Blazor CSS isolation does not stamp NavMenu's scope
   attribute onto the <a> a NavLink renders, so a scoped rule misses them and they fall back to the
   global `a{}` colour + the browser's default underline (they looked like blue hyperlinks). A global
   rule matches the rendered <a> regardless of scope. See MainLayout's own header comment / this
   comment's original home for the full story.

   Without JS, dropdowns open on hover/focus, pure CSS (":hover"/":focus-within" below), static
   SSR's baseline. app.js's setupTopNav() marks <html> with "js-nav" and then drives every group
   through ".topnav-group.is-open" and aria-expanded alone (click, tap, Escape, and hover for a
   desktop mouse). The active-group highlight uses :has() (broadly supported in evergreen browsers) so a
   descendant NavLink's own ".active" class, which Blazor already applies with no help from us,
   is enough to also highlight the group's trigger, no JS required for that either. */
.topnav-nav {
    display: flex;
    align-items: center;
    gap: 0.15rem;
}

.topnav-link,
.topnav-trigger {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.5rem 0.8rem;
    border: none;
    background: transparent;
    border-radius: var(--ff-radius-sm);
    color: var(--ff-muted);
    font: inherit;
    font-size: 0.92rem;
    font-weight: 500;
    text-decoration: none;
    cursor: pointer;
    transition: var(--ff-ease);
    white-space: nowrap;
}

.topnav-link svg,
.topnav-trigger svg {
    width: 20px;
    height: 20px;
    flex: 0 0 20px;
}

.topnav-link:hover,
.topnav-link:focus-visible,
.topnav-trigger:hover,
.topnav-trigger:focus-visible {
    background: var(--ff-brand-soft);
    color: var(--ff-brand);
}

.topnav-link.active,
.topnav-group.is-active .topnav-trigger,
.topnav-group:has(.topnav-menu-item.active) .topnav-trigger {
    background: var(--ff-brand-soft);
    color: var(--ff-brand);
    font-weight: 600;
}

.topnav-caret {
    font-size: 0.65rem;
    margin-left: -0.1rem;
}

.topnav-group {
    position: relative;
}

/* Invisible hover bridge across the gap between trigger and menu, so the cursor can travel from one
   to the other without the ":hover" chain (and therefore the menu) ever breaking. */
.topnav-group::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: 100%;
    height: 0.5rem;
}

.topnav-menu {
    position: absolute;
    top: 100%;
    left: 0;
    min-width: 250px;
    margin-top: 0.35rem;
    padding: 0.4rem;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-md);
    opacity: 0;
    visibility: hidden;
    transform: translateY(-4px);
    transition: opacity var(--ff-ease), transform var(--ff-ease), visibility var(--ff-ease);
    z-index: 40;
}

/* Hover and focus open a group only without app.js; with it ("js-nav" on <html>) .is-open alone
   decides, so Escape or a second click actually closes the menu under a hovering pointer. */
:root:not(.js-nav) .topnav-group:hover .topnav-menu,
:root:not(.js-nav) .topnav-group:focus-within .topnav-menu,
.topnav-group.is-open .topnav-menu {
    opacity: 1;
    visibility: visible;
    transform: none;
}

.topnav-menu-item {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.55rem 0.65rem;
    border-radius: var(--ff-radius-sm);
    color: var(--ff-text);
    text-decoration: none;
    font-size: 0.9rem;
    transition: var(--ff-ease);
}

.topnav-menu-item:hover {
    background: var(--ff-brand-soft);
    color: var(--ff-brand);
}

.topnav-menu-item svg {
    width: 18px;
    height: 18px;
    flex: 0 0 18px;
}

.topnav-menu-item.active {
    color: var(--ff-brand);
    font-weight: 600;
}

.topnav-menu-text {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}

.topnav-menu-title {
    font-size: 0.9rem;
}

.topnav-menu-hint {
    font-size: 0.72rem;
    color: var(--ff-muted);
}

.topnav-menu-divider {
    height: 1px;
    background: var(--ff-border);
    margin: 0.35rem 0.3rem;
}

.topnav-link-dev {
    color: var(--ff-accent-strong);
}

/* --- Mobile: the bar wraps, the nav collapses behind the hamburger, and each dropdown becomes an
   always-expanded stacked section (no hover surface on touch, so the CSS-hover dropdown would
   otherwise be unreachable) --- */
@media (max-width: 1000px) {
    .topnav {
        flex-wrap: wrap;
    }

    .topnav-toggle {
        display: inline-flex;
        margin-left: auto;
    }

    .topnav-nav {
        display: none;
        flex-direction: column;
        align-items: stretch;
        width: 100%;
        padding-bottom: 0.6rem;
        gap: 0.1rem;
    }

    .app-shell.nav-open .topnav-nav {
        display: flex;
    }

    .topnav-trigger {
        width: 100%;
        justify-content: flex-start;
    }

    .topnav-caret {
        display: none;
    }

    .topnav-menu {
        position: static;
        opacity: 1;
        visibility: visible;
        transform: none;
        box-shadow: none;
        border: none;
        min-width: 0;
        margin: 0 0 0.3rem 0.8rem;
        padding: 0;
    }

    .topnav-group::after {
        display: none;
    }

    /* With app.js each stacked section is collapsed until its trigger is tapped, matching the
       trigger's aria-expanded; the caret shows it can expand. */
    :root.js-nav .topnav-group:not(.is-open) .topnav-menu {
        display: none;
    }

    :root.js-nav .topnav-caret {
        display: inline;
    }
}

.text-muted {
    color: var(--ff-muted);
}

/* "Used when ..." prose that app.js's setupConditionalFields hides once it controls the field's
   visibility; the rule only guarantees [hidden] wins over any later display setting. */
.conditional-hint[hidden] {
    display: none;
}

.mb-0 {
    margin-bottom: 0;
}

.mt-3 {
    margin-top: 1rem;
}

.kpi-strip {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
    gap: 0.9rem;
    margin: 1.5rem 0;
}

.kpi-tile {
    flex: 1 1 160px;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-left: 3px solid var(--ff-brand);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    padding: 0.9rem 1rem;
    /* Flex column so the status badge can pin to the card bottom: the grid stretches every tile in
       a row to the same height, so bottom-pinned badges align across the row no matter how many
       lines each tile's definition wraps to. flex-start keeps the badge at its content width. */
    display: flex;
    flex-direction: column;
    align-items: flex-start;
}

.kpi-value {
    display: block;
    font-size: 1.75rem;
    /* 700 to match .module-figure-value: a big data number carries the same weight everywhere, so the
       review-page KPI tiles and the Home/dashboard summary figures read as one system. Size still
       varies by context (a hero KPI tile is larger than a compact summary figure); only the weight is
       unified. */
    font-weight: 700;
    line-height: 1.2;
    color: var(--ff-text);
}

/* A composite KPI value ("33% - 1 of 3 visited", "4 - RM 500.00 paid") splits into a big headline
   figure and this smaller qualifier on its own line, so the headline number stays exactly the same
   size as every plain-integer tile in the row. The full text is still present in order, so the value
   reads correctly and the export-parity scrapers can still recover it. */
.kpi-value-detail {
    display: block;
    font-size: 0.95rem;
    font-weight: 500;
    color: var(--ff-muted);
    margin-top: 0.15rem;
}

/* The connective between a composite value's headline and its detail ("33%[ - ]1 of 3 visited") is kept
   in the DOM so the value still reads correctly to a screen reader and to the export-parity tests that
   scrape this element's full text, but hidden on screen where the two-line layout already separates the
   parts - so the detail line reads "1 of 3 visited", not "- 1 of 3 visited". */
.kpi-value-sep {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

.kpi-label {
    display: block;
    color: var(--ff-muted);
    font-size: 0.85rem;
    margin-top: 0.2rem;
}

/* Every .data-table is wrapped in one of these so a table with more columns than the viewport (the
   Sales claims and Stock lines tables at desktop, any table on a phone) scrolls inside its own box
   instead of widening the whole page. Harmless on a table that already fits: it simply never scrolls.
   The vertical padding plus matching negative margin keep the table's own drop shadow from being
   clipped by the scroll container without shifting the table off the page's left edge. */
.table-scroll {
    overflow-x: auto;
    padding: 3px 0;
    margin: -3px 0;
}

.data-table {
    width: 100%;
    border-collapse: collapse;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    overflow: hidden;
}

.data-table th,
.data-table td {
    text-align: left;
    padding: 0.6rem 0.9rem;
    border-bottom: 1px solid var(--ff-border);
}

.data-table thead th {
    background: var(--ff-bg);
    font-size: 0.8rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--ff-muted);
}

/* Money/number columns (e.g. "RM 1,234.00") must stay on one line - the space after "RM" would
   otherwise let the value wrap to two lines in a narrow column. The table already scrolls
   horizontally (.table-scroll), so keeping these cells intact never breaks the page layout. */
.data-table .cell-nowrap {
    white-space: nowrap;
}

.data-table tbody tr:last-child td {
    border-bottom: none;
}

.data-table tbody tr:hover {
    background: var(--ff-brand-soft);
}

/* Whole-row navigation: a row carrying data-row-href is clickable through its whole
   body (app.js's setupRowClickNavigation), not just whichever one cell holds the real <a> - this only
   adds the visual affordance. Hover background is already covered by the rule above (every
   .data-table row gets it, real link or not); this adds the pointer cursor plus a keyboard focus ring
   for the tabindex="0"/role="link" the markup puts on these rows. */
tr[data-row-href] {
    cursor: pointer;
}

tr[data-row-href]:focus-visible {
    outline: 2px solid var(--ff-brand);
    outline-offset: -2px;
}

/* When the surrounding row is already the click target, its inner anchor (kept deliberately as the
   no-JS path and the accessible name for screen readers) is styled as plain text rather than also
   reading as a second, separate hyperlink. Scoped to "tr[data-row-href] td a" rather than a blanket
   ".data-table td a" because several .data-table rows in this portal are not row-clickable, so their
   cells' anchors stay untouched. Underline reappears on hover/focus so a keyboard/mouse user still
   gets a "this is the target" cue. The general rule: if the surrounding row/card is already the click
   target, style its inner anchor as plain text; if the anchor is the only affordance, it stays a link. */
tr[data-row-href] td a {
    color: inherit;
    text-decoration: none;
}

tr[data-row-href] td a:hover,
tr[data-row-href] td a:focus-visible {
    text-decoration: underline;
}

.empty-state {
    color: var(--ff-muted);
}

/* --- Pagination (shared) ---
   A compact page-navigation strip below any paginated table: Previous / 1 ... 3 4 [5] 6 7 ... 12 /
   Next. GET-link based (static SSR), matching the portal's filter form + tab strip convention: every
   link is a full-page navigation, never client-side JS. Placed in app.css (promoted immediately)
   because this pattern applies wherever a long table appears - Stock today, other review pages once
   their lists grow. */
.pagination-summary {
    color: var(--ff-muted);
    font-size: 0.85rem;
    /* 0.75rem, not 0.5rem: the summary sits directly above the results table on every review/manage
       list, and the smaller gap read as the text hugging the table header. */
    margin: 0 0 0.75rem;
}

/* Scroll target for paginated tables: page links append #stock-table (or similar id) so the browser
   scrolls here on navigation. scroll-margin-top leaves breathing room above the anchor so the
   summary/nav isn't flush against the viewport edge (or hidden under a sticky header if one exists).
   scroll-behavior:smooth on html makes the fragment jump a gentle animation rather than an abrupt
   snap - respects prefers-reduced-motion for users who've opted out of animations. */
html {
    scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
    html {
        scroll-behavior: auto;
    }

    .filter-summary::after {
        transition: none;
    }
}

[id$="-table"] {
    /* Clears the 73px sticky .topnav (plus breathing room) so a page-link jump lands the summary and
       the page-number buttons just below the header instead of behind it. 1rem was too small - the
       pager scrolled up under the sticky bar and the user could not see the page numbers they'd just
       clicked. */
    scroll-margin-top: 5.5rem;
}

.pagination {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.25rem;
    margin: 1rem 0;
}

.pagination-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2rem;
    padding: 0.35rem 0.6rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    background: var(--ff-surface);
    color: var(--ff-text);
    font-size: 0.85rem;
    text-decoration: none;
    transition: var(--ff-ease);
}

.pagination-link:hover:not(.pagination-disabled):not(.pagination-current) {
    background: var(--ff-bg);
    border-color: var(--ff-muted);
}

.pagination-current {
    background: var(--ff-accent);
    border-color: var(--ff-accent);
    /* Literal white, not var(--ff-surface): this is the label ON TOP OF the filled accent chip, not
       the card background var(--ff-surface) happens to name - it only read as white before because
       --ff-surface's light-mode value IS white. Dark mode redefines --ff-surface to a dark slate, which
       would have flipped this to near-invisible dark text on the orange chip; #fff is unaffected by
       theme and stays correct in both, matching how every other accent/brand-filled element already
       uses a literal white (see the dark-block comment above). */
    color: #fff;
    font-weight: 600;
}

.pagination-disabled {
    color: var(--ff-muted);
    opacity: 0.5;
    cursor: not-allowed;
}

.pagination-ellipsis {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 1.5rem;
    color: var(--ff-muted);
    font-size: 0.85rem;
}

.btn {
    display: inline-block;
    /* border-box so an <a class="btn"> sizes exactly like a <button class="btn">: buttons are
       border-box natively but anchors default to content-box (the portal has no global box-sizing
       reset), which would otherwise land every min-height/width rule ~16px larger on link-buttons -
       e.g. a 44px min-height Cancel link rendering ~60px tall next to a 44px Save button. */
    box-sizing: border-box;
    padding: 0.45rem 0.9rem;
    border-radius: var(--ff-radius-sm);
    border: 1px solid var(--ff-border);
    background: var(--ff-surface);
    color: var(--ff-text);
    text-decoration: none;
    /* font-family inherited (not the OS button default) so a <button class="btn"> - e.g. the
       Access Denied Sign Out - matches an <a class="btn">. */
    font-family: inherit;
    font-size: 0.9rem;
    transition: var(--ff-ease);
}

/* The Sign Out control is a POST form (a GET sign-out is CSRF-able); the wrapping form must add no
   layout of its own. */
.access-denied-signout {
    margin: 0;
}

/* Every clickable button shows the hand cursor, like a link. Anchors get it from the browser for
   free, but <button> elements default to the arrow cursor - which made the portal's submit/toggle
   buttons feel inert. Disabled buttons keep the default arrow so "not clickable" also reads at the
   cursor level. */
button:not(:disabled),
.btn {
    cursor: pointer;
}

.btn:hover {
    background: var(--ff-bg);
    border-color: var(--ff-muted);
}

.btn-primary {
    background: var(--ff-accent);
    border-color: var(--ff-accent);
    /* Literal white, not var(--ff-surface) - see .pagination-current's comment above for why. */
    color: #fff;
}

.btn-primary:hover {
    background: var(--ff-accent-strong);
    border-color: var(--ff-accent-strong);
}

/* Ghost/tertiary button: low-emphasis actions (e.g. Clear) so orange stays the single CTA. */
.btn-ghost {
    background: transparent;
    border-color: var(--ff-border);
    color: var(--ff-muted);
}
.btn-ghost:hover {
    background: var(--ff-bg);
    color: var(--ff-text);
}

/* Destructive action (a record detail page's Delete): outlined in the "bad" palette so it never reads
   like the primary action beside it; filled only on hover, just before the confirm prompt. */
.btn-danger {
    background: transparent;
    border-color: var(--ff-bad);
    color: var(--ff-bad-ink);
}
.btn-danger:hover {
    background: var(--ff-bad-soft);
}

/* A one-button form inside .page-head-actions (Delete beside Edit): no block margins, so it lines up
   with the sibling link instead of sitting lower in the row. */
.page-head-actions form {
    margin: 0;
}

.access-denied {
    max-width: 480px;
    margin: 3rem auto;
    text-align: center;
}

.access-denied-icon {
    font-size: 2.5rem;
    margin-bottom: 0.5rem;
}

/* --- Shared tab/filter strip + stock-status badges (Store Inventory module) ---
   Global because StoreDetail.razor and StockOverview.razor both need the identical tab-strip look for
   their status filter links and the identical status-badge colour-coding for overall and per-store
   statuses. */

.tab-strip {
    display: flex;
    gap: 1.5rem;
    border-bottom: 1px solid var(--ff-border);
    margin-bottom: 1.25rem;
    /* Phones: the strip (Stock / Merchandising / Training / ... on a store detail) is wider than the
       viewport and was silently clipping its last tabs. Swipeable single row, the standard mobile
       tab pattern; harmless on desktop where it never overflows. */
    overflow-x: auto;
}

.tab-strip > * {
    flex: 0 0 auto; /* keep each tab at its natural width so the strip scrolls instead of squashing */
}

.tab-link {
    display: inline-block;
    padding: 0.6rem 0.1rem;
    color: var(--ff-muted);
    text-decoration: none;
    font-size: 0.95rem;
    border-bottom: 2px solid transparent;
}

.tab-link.active {
    color: var(--ff-brand);
    border-bottom-color: var(--ff-brand);
    font-weight: 600;
}

/* Status is never colour-only: each badge always pairs its background colour with the status's text
   label (see StoreDetail.razor's/StockOverview.razor's StatusLabel), so the distinction still reads
   for colour-blind users or in a plain-text HTTP response. */
.status-badge {
    display: inline-flex;
    align-items: center;
    padding: 0.2rem 0.6rem;
    border-radius: 999px;
    font-size: 0.8rem;
    font-weight: 600;
}

.status-instock {
    background: var(--ff-good-soft);
    color: var(--ff-good-ink);
}

.status-low {
    background: var(--ff-watch-soft);
    color: var(--ff-watch-ink);
}

.status-out {
    background: var(--ff-bad-soft);
    color: var(--ff-bad-ink);
}

/* Inventory Discrepancy / Aging Stock: the same status-badge family as In stock/Low/Out (see
   StockOverview.razor's TableRow.StatusKey/StatusLabelFor). Discrepancy reuses the
   "bad" palette - a physical count disagreeing with the books is as urgent as being out of stock;
   Aging reuses the "watch" palette - a slow-moving SKU is a lower-urgency note, not an acute alert. */
.status-discrepancy {
    background: var(--ff-bad-soft);
    color: var(--ff-bad-ink);
}

.status-aging {
    background: var(--ff-watch-soft);
    color: var(--ff-watch-ink);
}

/* Open-check-in reminder: a full-width bar across the very top of the page (inside .app-top, above
   the nav), the persistent CheckInReminder.razor strip in MainLayout while a rep has an un-checked-out
   attendance record. Normal state uses the brand (indigo) tint, not the orange --ff-accent, which is
   reserved for primary CTA buttons only. The overdue variant escalates to the same "bad" palette the
   status-out badge uses, whose contrast is verified for both themes (see the dark-theme token comment
   above). Horizontal padding matches .topnav's so the bar aligns with the nav below it. */
.checkin-reminder {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.55rem clamp(1rem, 3vw, 2.5rem);
    background: var(--ff-brand-soft);
    color: var(--ff-brand-strong);
    border-bottom: 1px solid var(--ff-border);
    font-size: 0.9rem;
}

.checkin-reminder-icon {
    display: inline-flex;
    flex: 0 0 auto;
}

.checkin-reminder-icon svg {
    width: 20px;
    height: 20px;
    display: block;
}

.checkin-reminder-text {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.checkin-reminder-lead {
    font-weight: 600;
}

.checkin-reminder-elapsed {
    flex: 0 0 auto;
    font-size: 0.78rem;
    font-weight: 600;
    padding: 0.15rem 0.55rem;
    border-radius: 999px;
    background: var(--ff-surface);
    border: 1px solid currentColor;
    white-space: nowrap;
}

.checkin-reminder-form {
    flex: 0 0 auto;
    margin: 0;
}

/* Sized to sit beside the elapsed pill as a peer, not dominate it: same type size as the pill, a
   whisker more padding so it still reads as the action. */
.checkin-reminder-action {
    padding: 0.25rem 0.7rem;
    border-radius: var(--ff-radius-sm);
    background: var(--ff-surface);
    border: 1px solid var(--ff-brand-strong);
    color: var(--ff-brand-strong);
    text-decoration: none;
    font-family: inherit;
    font-size: 0.78rem;
    font-weight: 600;
    white-space: nowrap;
    cursor: pointer;
    transition: var(--ff-ease);
}

.checkin-reminder-action:hover {
    background: var(--ff-brand);
    border-color: var(--ff-brand);
    color: #fff;
}

.checkin-reminder-overdue {
    background: var(--ff-bad-soft);
    color: var(--ff-bad-ink);
    border-bottom-color: var(--ff-bad);
}

.checkin-reminder-overdue .checkin-reminder-action {
    background: var(--ff-bad);
    border-color: var(--ff-bad);
    color: #fff;
}

.checkin-reminder-overdue .checkin-reminder-action:hover {
    background: var(--ff-bad-ink);
    border-color: var(--ff-bad-ink);
}

/* Mobile: on a narrow screen the single row squeezes the message to an ellipsis. Let the bar wrap -
   icon + full (wrapping) message on the first line, the elapsed pill and Check Out control on the
   next - so the whole message stays readable and the action stays reachable. */
@media (max-width: 700px) {
    .checkin-reminder {
        flex-wrap: wrap;
        row-gap: 0.45rem;
    }

    .checkin-reminder-text {
        white-space: normal;
        overflow: visible;
        text-overflow: clip;
    }

    .checkin-reminder-elapsed {
        margin-left: calc(20px + 0.75rem);
    }

    .checkin-reminder-form {
        margin-left: auto;
    }
}

/* --- Shared filter toolbar --- the Home dashboard's Chain/Region/Period/Search toolbar and the Stock
   Review page's Chain/Store/Region/Category/Model/SKU toolbar are the exact same look: one GET form,
   auto-submitting <select data-autosubmit>s, a de-emphasized ghost Apply/Clear no-JS fallback, so
   this lives here (global, unscoped) rather than duplicated in each page's own .razor.css. Being
   global costs nothing: it only ever matches elements actually carrying these class names. */
.filter-bar {
    display: flex;
    flex-wrap: wrap;
    /* Top-align, so a picker that grows taller once chips are chosen extends DOWNWARD only and every
       field's label/control stays on the same line as its neighbours - bottom-alignment would let a
       chipped field's label ride up out of line with the rest and look ragged. */
    align-items: flex-start;
    gap: 1rem;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    padding: 0.9rem 1rem;
    margin: 1rem 0 1.5rem;
}

.filter-field {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    font-size: 0.85rem;
}

.filter-label {
    color: var(--ff-muted);
    font-size: 0.8rem;
}

.filter-field select,
.filter-field input[type="text"] {
    padding: 0.35rem 0.5rem;
    border: 1px solid var(--ff-border);
    border-radius: 0.35rem;
    font-size: 0.9rem;
    min-width: 10rem;
    background: var(--ff-surface);
    color: var(--ff-text);
    transition: var(--ff-ease);
}

.filter-field select:focus,
.filter-field input[type="text"]:focus {
    outline: none;
    border-color: var(--ff-brand);
    box-shadow: 0 0 0 3px var(--ff-brand-soft);
}

/* Disabled placeholder select (e.g. "Reporting Period" on both Home and Stock Review, until a real
   weekly/monthly time series exists): same look as every other filter select, just visibly inert
   (dimmer text, a "not-allowed" cursor) so it reads as "coming soon" rather than a select that
   mysteriously ignores clicks. */
.filter-field select:disabled {
    color: var(--ff-muted);
    cursor: not-allowed;
    opacity: 0.75;
}

/* De-emphasized no-JS-fallback buttons (Apply/Clear) - smaller than the portal's default .btn so they
   read as secondary now that the auto-submitting selects are the primary path. */
.btn-sm {
    padding: 0.3rem 0.65rem;
    font-size: 0.8rem;
}

/* Push the actions to the toolbar's right edge, bottom-aligned with the fields. align-self keeps them
   pinned to the bottom now that .filter-bar itself top-aligns its items (see that rule's comment). */
.filter-actions {
    display: flex;
    gap: 0.5rem;
    align-items: flex-end;
    align-self: flex-end;
    margin-left: auto;
}

/* Chain/Store: wider than the plain Region <select> (10rem, set above) - a search box + wrapped
   chips need more breathing room, and this grows to fill the toolbar's leftover width on a wide
   screen rather than clamping to the plain select's fixed size. Global (not Browse/Chains.razor.css)
   so every review page's filter bar can use the same chip-picker markup rather than each page
   re-declaring its own copy. */
.filter-field-picker {
    /* A slimmer flex-basis lets more filters sit on one line so the bar reads as a compact toolbar
       rather than a few oversized boxes. A search box + wrapped chips still fit comfortably at 180px,
       and it grows to share leftover width. */
    flex: 1 1 190px;
    min-width: 180px;
    max-width: 300px;
}

.filter-field-picker .terr-picker,
.filter-field-picker .combo-picker {
    width: 100%;
}

/* --- Mobile: collapsible filter bars ---
   Each review page's filter <form class="filter-bar"> is wrapped in a native
   <details class="filter-collapse" open><summary class="filter-summary">Filters (N Active)</summary>
   ...form...</details> (see each Review.razor's/Chains.razor's own markup comment). `open` is stamped
   server-side by default, and this rule keeps the summary hidden above 700px - a <details open> with a
   hidden summary renders exactly its content, so desktop looks the same as a plain filter bar.
   app.js's setupFilterCollapse() is the only thing that ever removes `open`, only once at load, only
   under the same 700px breakpoint the media block below uses. */
.filter-collapse {
    display: block;
}

.filter-summary {
    display: none;
    align-items: center;
    justify-content: space-between;
    min-height: 44px;
    padding: 0.6rem 1rem;
    margin: 1rem 0 0;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    font: inherit;
    font-weight: 600;
    color: var(--ff-text);
    cursor: pointer;
}

/* display:flex above suppresses the native <details> disclosure triangle, which otherwise would have
   been the only signal that this row is expandable and whether it's currently open or closed. This
   chevron replaces it - closed points sideways (-90deg), open points down (0deg, its own unrotated
   orientation), matching the direction a native marker would point. Muted color to match the topnav
   dropdown triggers' own ".topnav-caret" look, not the heavier text-colored label next to it. */
.filter-summary::after {
    content: "\25BE";
    color: var(--ff-muted);
    transform: rotate(-90deg);
    transition: var(--ff-ease);
}

details[open] > .filter-summary::after {
    transform: rotate(0deg);
}

/* --- Mobile: one breakpoint for every "phone-sized" tap-target/collapse adjustment ---
   max-width, not @media (pointer: coarse): a coarse-pointer query would ALSO fire on a touchscreen
   laptop at full desktop width, where the filter bar/buttons already have plenty of room, and the
   .filter-summary reveal above needs to stay in lockstep with a single viewport number anyway (see
   that rule's own comment) - one mental model ("under 700px, phone-sized") beats mixing a viewport
   query with an input-type query. Desktop (>700px) is completely untouched: none of the rules below
   exist outside this block. */
@media (max-width: 700px) {
    .filter-summary {
        display: flex;
    }

    /* Primary form actions (Save/Cancel, Check In/Check Out, ...) become full-width, stacked,
       comfortably tappable buttons instead of a cramped inline row. The container must actually go
       column for that: .form-actions is a nowrap flex ROW, so width:100% on the children alone would
       just split the row into side-by-side halves (align-items:center also keeps them from stretching
       evenly). */
    .form-actions {
        flex-direction: column;
        align-items: stretch;
    }

    .form-actions .btn,
    .form-actions button {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-height: 44px;
        width: 100%;
    }

    /* Small/secondary tap targets grow to the ~40-44px range Apple/Android both recommend as a
       comfortable minimum. */
    .btn-sm {
        min-height: 40px;
    }

    .pagination-link {
        box-sizing: border-box; /* anchors are content-box by default - see .btn's own note */
        min-height: 40px;
        display: inline-flex;
        align-items: center;
    }

    #topnav-toggle {
        width: 44px;
        height: 44px;
    }

    #theme-toggle {
        width: 44px;
        height: 44px;
    }
}

/* Pinned to the card bottom (see .kpi-tile's flex column) so badges align across a row of tiles;
   the padding keeps the 0.5rem gap on whichever tile is the tallest and sets the row height. */
.kpi-tile .status-badge {
    margin-top: auto;
}
.kpi-tile .kpi-def {
    margin-bottom: 0.5rem;
}

/* One-line definition/denominator under the label (e.g. "84% VM compliance - out of what?"): muted
   and small so it doesn't compete with the value/label pair above it. */
.kpi-def {
    display: block;
    color: var(--ff-muted);
    font-size: 0.75rem;
    line-height: 1.35;
    margin-top: 0.2rem;
}

/* --- Pending KPI tiles --- a summary figure that needs the weekly-cycle stock model
   (opening/sell-out/closing/physical count), not yet available. Deliberately NOT colour-coded
   bad/watch/good like a real tile (there's no real number behind it to colour-code) - a dashed border
   and muted background are the one shape-level tell, matching Home.razor.css's ".issue-sample"
   convention for the same idea. */
.kpi-tile-pending {
    border-style: dashed;
    background: var(--ff-bg);
}

.kpi-pending-note {
    display: block;
    margin-top: 0.5rem;
    padding: 0.15rem 0.5rem;
    border-radius: 999px;
    background: var(--ff-border);
    color: var(--ff-muted);
    font-size: 0.72rem;
    font-weight: 600;
    width: fit-content;
}

/* --- Chart cards + Stock-health donut --- global (not Home.razor.css) because the donut is real
   stock data rendered on both the Home dashboard and the Stock Review page, so both pages' markup is
   styled identically without duplicating the rules. app.js's setupChartHoverTooltips() finds its
   hover targets by querying ".dashboard-charts" specifically, so re-using that exact class name on
   the Stock Review page's chart wrapper gets the same hover tooltip for free, no new JS needed. */
/* The three .chart-cards below can be the same WIDTH but wildly different HEIGHTS, because each
   card's own content (donut+legend vs. svg+caption vs. a variable-length bar list) has its own
   intrinsic size and the grid never equalises them on its own. align-items: stretch makes every card
   in a row exactly as tall as the row's tallest; .chart-card becoming a column flexbox plus the shared
   .chart-body height below is what lets that extra height land somewhere sane instead of just
   stretching the donut's own box oddly. */
.dashboard-charts {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 1rem;
    margin-bottom: 1.5rem;
    align-items: stretch;
}

.chart-card {
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    padding: 1rem 1.1rem;
    display: flex;
    flex-direction: column;
}

.chart-card h3 {
    margin: 0 0 0.75rem;
    font-size: 1rem;
}

/* The shared plot area every chart type (donut / trend svg / bar list / their own empty-states) sits
   inside, so three very different visuals still read as "the same size" - the donut is sized to fit
   THIS budget now, rather than this budget being derived from the donut's own size like before.
   Centered by default (right for a single donut or one svg); the bar-chart list below overrides that
   to fill+scroll instead of floating in the middle of the band. min-height + flex:1 rather than a
   hard height: the charts grid stretches every card in a row to the tallest one, and a fixed-height
   body left the shorter cards with a stubby scroll window and dead space at the card bottom (the
   Submission Completion list clipped mid-row with a blank band below it). Growing the body absorbs
   the stretch, so a scrolling list runs the card's full height; the tallest card is unaffected. */
.chart-body {
    min-height: 190px;
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
}

/* A chart-card whose content is a TABLE, not a chart (the Stock Update Health card): .chart-body's
   fixed-height centered flexbox is built for donuts/bars and lets a tall table spill out of the card
   over the rest of the page (overflow is visible, and flex-centering overflows a too-tall child in
   BOTH directions). This modifier lets the body flow and caps the inner .table-scroll so many rows
   scroll within the card instead of ballooning it. The card renders as a standalone full-width block
   BELOW the .dashboard-charts grid, never inside it - a 1/-1 grid child kept every auto-fit track
   occupied, leaving a dead gap beside the three chart cards on wide screens (see StockOverview.razor). */
.chart-card-wide {
    margin-bottom: 1.5rem;
}

.chart-card-wide .chart-body {
    height: auto;
    display: block;
}

.chart-card-wide .table-scroll {
    max-height: 320px;
    overflow: auto;
}

/* In this card the caption ("N of M stores overdue") sits ABOVE the table, unlike the trend card's
   caption below its chart - the shared .chart-caption margins (top-only) made it overlap the table's
   scroll container by a few pixels. Flip the margin to the other side for this orientation. */
.chart-card-wide .chart-caption {
    margin: 0 0 0.75rem;
}

/* A subtitle line (e.g. the store detail's "Stock Updates: owner, cadence - last updated ..." strip)
   directly followed by a scrolling table: .page-sub carries no bottom margin, and its inline status
   badge dips below the text baseline, so the line visually touched the table underneath. */
.page-sub + .table-scroll {
    margin-top: 0.75rem;
}

.chart-body > .bar-chart {
    align-self: stretch;
    width: 100%;
    height: 100%;
}

.chart-body .empty-state {
    margin: 0;
    text-align: center;
}

/* Pinned to the bottom of the (now possibly-stretched) card rather than sitting directly under
   .chart-body with blank space below it - keeps every card's caption/legend baseline lined up across
   the row regardless of which card ended up tallest. */
.chart-card > .chart-caption {
    color: var(--ff-muted);
    font-size: 0.85rem;
    margin: 0.5rem 0 0;
    margin-top: auto;
}

/* .chart-caption used outside a .chart-card (none today, but kept as the general-purpose fallback
   the class name implies) still gets the plain, non-pinned version above without the auto margin. */
.chart-caption {
    color: var(--ff-muted);
    font-size: 0.85rem;
    margin: 0.5rem 0 0;
}

/* Hand-built SVG <text> axis labels (StockOverview.razor's Weekly Sell-Out Trend chart) - GLOBAL,
   deliberately not scoped, for the exact reason app.css's own "Top navigation bar" comment above
   gives for NavMenu's <a>: these <text> elements are assembled as a raw string and injected via
   MarkupString (Razor's RZ1023 won't allow attributes on a literal <text> tag - see
   AxisLabelMarkup's own doc comment), so Blazor's CSS isolation can never stamp its scope attribute
   onto them; a scoped rule here would be dead code, matching NavMenu's issue exactly. Without this
   global rule these labels render at the browser's ~16px default instead of a deliberate size - 11px
   here, at this chart's own ~1.15x rendered scale (see .trend-chart's own comment), lands the
   RENDERED glyph height in the 11-13px range the type should actually be, legible without being
   oversized. */
/* Sized in viewBox USER UNITS, not screen px - the svg's own scale is applied on top, so the rendered
   size varies with the card's width (~9.7px on the narrowest card the grid produces, ~15px on the
   widest). 12 is chosen so the small end stays legible; see StockOverview's TrendChartHeight comment
   for why the viewBox's height is what governs that scale. */
.trend-axis-label {
    font-size: 12px;
    fill: var(--ff-muted);
}

/* --- Trend chart marks --- GLOBAL because both StockOverview.razor's Weekly Sell-Out Trend and
   Merchandising Review's own trend chart render the same SVG line-chart shape, matching this file's
   "promote only what's shared" convention. */

/* Two constraints govern this rule; both matter before touching it.
   1. Without preserveAspectRatio, the svg (width:100%/height:150px against a viewBox of 300x150)
      stretched every glyph horizontally on any card wider than 300 user-units (scaleX != scaleY).
      preserveAspectRatio="xMidYMid meet" keeps scaleX equal to scaleY at any width.
   2. That fix alone sizes the svg from its WIDTH while .chart-body is a fixed 190px tall, so on any
      card wider than about 380px the drawing grew taller than its box, and since .chart-body centres
      its child, half the excess rode up over the card's own <h3>.
   height:100% is the other half of the fix: with "meet" the drawing scales DOWN to fit its box
   instead, at every width. See StockOverview.razor's TrendChartHeight comment for why the viewBox's
   height (not its width) governs how large the axis text and markers end up rendering. */
.trend-chart {
    display: block;
    width: 100%;
    height: 100%;
}

.trend-line {
    stroke: var(--ff-brand);
    stroke-width: 2;
    fill: none;
    vector-effect: non-scaling-stroke;
}

.trend-gridline {
    stroke: var(--ff-border);
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
}

/* The visible marker for each reading. The 2px surface-coloured ring is what keeps a dot legible
   where the line passes behind it, and where two points sit close enough to touch. */
.trend-dot {
    fill: var(--ff-brand);
    stroke: var(--ff-surface);
    stroke-width: 2;
    vector-effect: non-scaling-stroke;
    pointer-events: none;   /* .trend-mark above it owns the hover */
}

/* One invisible hit-circle per data point, sitting over the dot - transparent at rest and twice the
   dot's radius, so the pointer catches a point before it reaches the mark. On :hover it grows the
   mark rather than creating one (the dot is always visible now), so the marker still needs no JS;
   only the floating tooltip text (app.js's setupChartHoverTooltips) is a genuine JS enhancement. */
.trend-mark {
    fill: transparent;
    stroke: transparent;
    cursor: pointer;
}

/* Grows the DOT, never the hit circle: enlarging the thing the cursor is testing against would let
   it shrink out from under the pointer on leave and flicker. */
.trend-point:hover .trend-dot {
    fill: var(--ff-brand-strong);
    r: 6;
}

/* Stock-health donut: an inline SVG ring - a plain background .donut-track circle plus one
   stroke-dasharray/stroke-dashoffset arc per segment - so each slice is its own hoverable element.
   .donut-ring and .donut-hole are both absolutely positioned over the same box (.donut is their
   positioning parent) so the centre hole sits visually punched out of the ring. */
/* Sized for a 0 0 140 140 viewBox / r=52 ring (see StockOverview.razor's markup and its
   DonutCircumference constant), now that it shares its row with two more real charts rather than
   sitting alone across the whole width. 140px comfortably fits inside .chart-body's shared 190px
   band, so no margin of its own is needed - .chart-body's own flex centering (both axes) covers it. */
.donut {
    position: relative;
    /* Fills the .chart-body's 190px plot band. The SVG's own 0 0 140 140 viewBox just scales up to
       this box, so the ring stays crisp; only the centre hole below is resized to keep its proportion. */
    width: 180px;
    height: 180px;
}

.donut-ring {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    /* Rotate so the ring's first segment starts at 12 o'clock (SVG circles otherwise start drawing
       their stroke-dasharray pattern at 3 o'clock) - purely cosmetic, matches how a conic-gradient
       donut chart conventionally reads. */
    transform: rotate(-90deg);
}

.donut-track {
    fill: none;
    stroke: var(--ff-border);
    stroke-width: 16;
}

.donut-segment {
    fill: none;
    stroke-width: 16;
    cursor: pointer;
    transition: opacity 0.15s ease;
}

.donut-segment:hover {
    opacity: 0.8;
}

/* Reuses the exact class-name vocabulary the .legend-dot modifiers below already use, so a segment and
   its legend entry are visibly the same colour without a second palette. */
.donut-segment-instock {
    stroke: var(--ff-good);
}

.donut-segment-low {
    stroke: var(--ff-watch);
}

.donut-segment-out {
    stroke: var(--ff-bad);
}

.donut-segment-neutral {
    stroke: var(--ff-border);
}

.donut-hole {
    position: absolute;
    inset: 0;
    margin: auto;
    /* Scaled with the .donut ring so the punched-out centre keeps the same proportion and the
       value/label have room to breathe. */
    width: 116px;
    height: 116px;
    border-radius: 50%;
    background: var(--ff-surface);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}

.donut-value {
    font-size: 1.5rem;
    font-weight: 700;
}

.donut-label {
    font-size: 0.75rem;
    color: var(--ff-muted);
}

/* Pinned to the bottom of the card - same reasoning as .chart-card > .chart-caption above, so the
   legend's baseline lines up with the trend chart's caption even when this card gets stretched taller
   to match a taller sibling. */
.donut-legend {
    list-style: none;
    margin: 0;
    margin-top: auto;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    font-size: 0.85rem;
}

.legend-dot {
    display: inline-block;
    width: 0.6rem;
    height: 0.6rem;
    border-radius: 50%;
    margin-right: 0.4rem;
}

.legend-instock {
    background: var(--ff-good);
}

.legend-low {
    background: var(--ff-watch);
}

.legend-out {
    background: var(--ff-bad);
}

/* Compliance Status donut's third segment (Merchandising Review): "Not
   Visited" reuses .donut-segment-neutral's own colour (var(--ff-border)) rather than inventing a new
   one - Stock's donut never needed a legend entry for its own neutral segment (that one only ever
   appears as a whole-ring placeholder when total == 0, a case Merchandising's donut instead replaces
   with an empty-state note - see Review.razor's own comment). */
.legend-neutral {
    background: var(--ff-border);
}

/* --- Bar-chart list --- GLOBAL because both StockOverview.razor and Merchandising Review's Share of
   Shelf by Store card render a .bar-chart list, matching this file's own .trend-* promotion above.
   See StockOverview.razor.css's own header comment for why ONE grid backs the whole list (each
   .bar-row a display:contents child) rather than a column of independent per-row grids. */
.bar-chart {
    display: grid;
    grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
    align-items: center;
    gap: 0.6rem 0.5rem;
    align-content: start;
    font-size: 0.85rem;
    max-height: 260px;
    overflow-y: auto;
}

.bar-row {
    display: contents;
}

.bar-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.bar-track {
    background: var(--ff-border);
    border-radius: 999px;
    overflow: hidden;
    height: 10px;
}

.bar-fill {
    height: 100%;
    width: 100%;
    border-radius: 999px;
    cursor: pointer;
    transition: filter 0.15s ease;
}

.bar-fill:hover {
    filter: brightness(1.15);
}

.bar-fill-submitted {
    background: var(--ff-good);
}

.bar-fill-pending {
    background: var(--ff-bad);
}

/* Merchandising Review's Share of Shelf by Store card: every bar is the SAME brand colour, unlike
   Stock's submitted/pending pair - a share-of-shelf percentage has no good/bad binary of its own to
   colour-code, so one consistent fill lets the bar's WIDTH (set inline per row to the real share
   percent, overriding .bar-fill's own width:100% above) carry all the meaning. */
.bar-fill-share {
    background: var(--ff-brand);
}

.bar-value {
    text-align: right;
    color: var(--ff-muted);
    white-space: nowrap;
}

.bar-value-alert {
    color: var(--ff-bad-ink);
    font-weight: 600;
}

/* --- Shared chart hover tooltip ---
   Created once by app.js's setupChartHoverTooltips and appended straight to <body>, so it deliberately
   lives here in the GLOBAL stylesheet rather than Home.razor.css: Blazor's CSS isolation only rewrites
   selectors/injects its "b-<hash>" scope attribute onto elements that exist in the compiled .razor
   markup at build time, so a plain, unscoped rule is the only kind that can ever match an element a
   script creates at runtime. Pure enhancement - nothing on the dashboard depends on this element
   existing; every chart already shows its numbers in the static, no-JS markup (axis labels, legend
   counts, bar values). */
.chart-tooltip {
    position: fixed;
    z-index: 1000;
    pointer-events: none;
    /* Themed surface, not background:var(--ff-text)+#fff: in dark mode --ff-text is near-white, which
       made the tooltip a white chip with white text (invisible). Surface + border + themed ink stays
       readable in both themes. */
    background: var(--ff-surface);
    color: var(--ff-text);
    border: 1px solid var(--ff-border);
    font-size: 0.75rem;
    line-height: 1.3;
    padding: 0.3rem 0.55rem;
    border-radius: 0.35rem;
    box-shadow: var(--ff-shadow-md);
    white-space: nowrap;
}

.chart-tooltip[hidden] {
    display: none;
}

/* --- Write-form primitives ---
   Shared here (not a page-scoped .razor.css) because every later CRUD module (Partner/Store,
   Merchandising, ...) reuses the same <EditForm> + [SupplyParameterFromForm] pattern and should look
   consistent with it out of the box, the same reasoning that keeps .data-table/.btn global. */
.user-form,
/* Neutral alias for the same write-form width, used by non-user CRUD (Partner/Store, ...) so those
   pages don't have to reference a class named after User Management. See PartnerEdit.razor. */
.entity-form {
    max-width: 560px;
}

/* Login.razor: the email+password form is much shorter than a CRUD .entity-form, so it clamps
   narrower than the 560px default. */
.login-form {
    max-width: 420px;
}

/* Login.razor: separates the staff Microsoft sign-in link from the email+password form above it. */
.login-staff {
    margin-top: 1.5rem;
}

.form-field {
    margin-bottom: 1rem;
}

/* --- Grouped form layout (New Visit / New Training / New Sales Compensation) -------------------
   These operational-entry forms have many fields, so a single stacked column runs very long. A form
   that opts in with .entity-form-wide groups its fields into <fieldset> section cards, and lays the
   short fields inside each section out in .form-grid: a RESPONSIVE grid that fits as many ~240px
   columns as the width allows (three or four on a desktop, one on a phone), so the page is short and scannable
   on a wide screen and collapses to a natural single column on mobile with no horizontal scroll and
   no media-query breakpoint to maintain. Wide controls (a textarea, a per-row input table, the
   attachment uploader) sit outside .form-grid as standalone .form-field blocks, so they stay full
   width. Other .entity-form pages are untouched: they keep the narrow single-column stack. */
.entity-form-wide {
    max-width: 1140px;
}

.form-grid {
    display: grid;
    /* min 320px per column so at the form's 860px max-width it settles on TWO comfortable columns
       (three would feel cramped for selects with long options), and collapses to ONE below ~640px -
       phones included - via min(100%, 320px), which lets a column shrink to the container so it never
       overflows. No media-query breakpoint to maintain. */
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
    gap: 0.85rem 1.25rem;
    align-items: start;
}

.form-grid .form-field {
    margin-bottom: 0;   /* the grid's row gap owns vertical spacing here */
}

.form-field label {
    display: block;
    font-size: 0.85rem;
    font-weight: 600;
    margin-bottom: 0.3rem;
}

/* Help prose sitting between a field's label and its control (e.g. ExternalAccountNew.razor's
   Chain/Store hints). .page-sub carries no margin at all, so reusing it here left the text sitting
   flush against the label above and the control below; this variant gives it a little breathing
   room on both sides. */
.form-field .field-hint {
    margin: 0.15rem 0 0.55rem;
    font-size: 0.85rem;
    color: var(--ff-muted);
}

/* ExternalAccountEdit.razor's read-only Email field: same box/padding as the real inputs right
   below it (so the form's fields all line up), but no border/background - it isn't editable, and
   shouldn't look like it might be. */
.form-static-value {
    margin: 0;
    padding: 0.5rem 0;
    color: var(--ff-text);
}

.form-field-checkbox label {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-weight: 500;
}

/* Every text-like field (text, email, and untyped InputText, which renders no type attribute, so
   an [type="text"] selector would miss it) sized uniformly; the checkbox is the only input excluded.
   textarea MUST be listed explicitly: it is neither an input nor a select, so it matched nothing here
   and fell through to the browser's own defaults. */
.form-field input:not([type="checkbox"]),
.form-field select,
.form-field textarea {
    width: 100%;
    box-sizing: border-box;
    padding: 0.5rem 0.7rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    font: inherit;
    color: var(--ff-text);
    background: var(--ff-surface);
}

/* A textarea does not inherit the page font (browsers render it MONOSPACE) and takes its width from
   the cols attribute rather than its container, so the Remarks box on both edit forms was rendering
   as a 167px monospace field with a square dark-grey border in a column of 560px rounded sans-serif
   ones. The font:inherit/width:100% above fix the type and geometry; these finish the job. resize is
   vertical-only because a horizontal drag can pull the box outside the form's own width. */
.form-field textarea {
    display: block;         /* strips the inline-block baseline gap beneath the box */
    resize: vertical;
    min-height: 5.5rem;
    line-height: 1.5;
}

.form-field input:not([type="checkbox"]):focus,
.form-field select:focus,
.form-field textarea:focus {
    outline: 2px solid var(--ff-brand);
    outline-offset: 1px;
}

/* --- Dropdowns ---------------------------------------------------------------------------------
   A native <select>'s popup is drawn by the OS, and for most of the web's history could not be
   styled at all - which is why every dropdown in the portal opened in Segoe UI on a square white
   system panel while the page around it was Plus Jakarta Sans on rounded indigo-accented surfaces.
   Two layers, so this improves everywhere and is never worse anywhere:

   LAYER 1 (every browser, below): appearance:none strips the OS arrow off the CLOSED control and we
   draw our own chevron, so the control itself matches the inputs beside it. This is the half that
   works today in every engine.

   LAYER 2 (@supports, further down): appearance:base-select opts into the customizable-select model
   (Chrome/Edge 135+), which is what finally exposes the popup as ::picker(select) and the options as
   real styleable elements. Engines without it ignore the entire @supports block and keep the native
   popup - layer 1 still applies, so only the OPEN list falls back.

   The chevron's colour is baked into the data URI (var() cannot be read from inside one) and is
   --ff-muted #64748b; keep the two in step if the token ever changes. */
select {
    appearance: none;
    -webkit-appearance: none;
    cursor: pointer;
}

/* Layer 1 only. In layer 2 the UA draws its own ::picker-icon as a flex child, so a background
   chevron would be a second arrow and the 2rem reservation would leave a dead gap between the icon
   and the control's right edge - both were measurably true before this was fenced off. */
@supports not (appearance: base-select) {
    select {
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2364748b' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6.5l4 4 4-4'/%3E%3C/svg%3E");
        background-repeat: no-repeat;
        background-position: right 0.6rem center;
        background-size: 1rem;
    }

    /* Room for that chevron. Per-context because each context sets `padding` as a shorthand, which
       would otherwise reset the right padding a bare `select` rule tried to add. */
    .form-field select,
    .filter-field select,
    .count-table select,
    .upload-field select {
        padding-right: 2rem;
    }
}

@supports (appearance: base-select) {
    select {
        appearance: base-select;
    }

    /* base-select lays the control out as a flex row ([selectedcontent][picker-icon]), and in the
       stacked-form context that came out 2px shorter than the <input> beside it - a visible
       misalignment in a column of fields. 1.375 (=22px at the 16px body size) makes the two exactly
       equal. Measured, not guessed.

       .form-field ONLY, deliberately. Every context was measured, and the other two are already
       exact without help: .count-table's select matches its row's inputs at 32.52, and
       .filter-field's lands on 30.52 - the same height as the .picker-control beside it in the
       toolbar, which it does not match (it is 1.33px short); applying this rule there would push it
       to 32.31 and break that alignment, so it is scoped away from them.

       Written as a descendant selector rather than on a bare `select` because the context rules set
       `font: inherit`, and the font SHORTHAND resets line-height to normal - a bare rule loses on
       specificity and does nothing. */
    .form-field select {
        line-height: 1.375;
    }

    /* The popup itself. Mirrors the app's menu surfaces (.topnav-menu/.picker-menu): same surface,
       border, radius and elevation, so an open dropdown reads as part of the page rather than as
       an OS panel floating above it. */
    ::picker(select) {
        appearance: base-select;
        margin-top: 0.25rem;
        padding: 0.25rem;
        border: 1px solid var(--ff-border);
        border-radius: var(--ff-radius);
        background: var(--ff-surface);
        box-shadow: var(--ff-shadow-md);
        font: inherit;
        /* Long lists scroll rather than run off-screen. */
        max-height: 18rem;
        overflow-y: auto;
    }

    option {
        padding: 0.45rem 0.6rem;
        border-radius: var(--ff-radius-sm);
        color: var(--ff-text);
        font-size: 0.9rem;
        cursor: pointer;
    }

    option:hover,
    option:focus {
        background: var(--ff-brand-soft);
        color: var(--ff-brand);
        outline: none;
    }

    option:checked {
        background: var(--ff-brand-soft);
        color: var(--ff-brand);
        font-weight: 600;
    }

    /* Placeholder rows ("All chains", "Select") and the deliberately-inert period select. */
    option:disabled {
        color: var(--ff-muted);
        cursor: not-allowed;
    }
}

/* --- Form fieldsets ---------------------------------------------------------------------------
   Keyed off the FORM, not off each fieldset's own class, deliberately. Three fieldsets across the
   two edit forms (.corrective-action-fields, .attachments-fields, .participant-roster-fields) each
   want this identical box, and the first two had a hand-written copy of it while the third had none
   at all - so the Participants box on the training form rendered with the browser's default groove
   border next to two correctly-boxed siblings. Styling the ELEMENT inside .entity-form means the
   next fieldset anyone adds is correct without their having to know a class name, which is the
   failure mode this whole family of bugs kept repeating.

   (UserEdit's .territory-section keeps its own rule - that form is not an .entity-form.) */
.entity-form fieldset {
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    padding: 1rem 1.25rem 1.25rem;
    margin: 1.5rem 0;
    /* Browsers give a fieldset an implicit min-width: min-content, so it refuses to shrink below its
       widest child (e.g. a file input's intrinsic width) and overflows the form on a narrow phone.
       Resetting it lets the section shrink to the viewport like every other block. */
    min-width: 0;
}

.entity-form legend {
    font-weight: 600;
    padding: 0 0.3rem;
}

/* --- Tables of per-row inputs (.count-table: stock counts, training participants) --------------
   GLOBAL, not scoped. This began life in SubmitCount.razor.css, whose header reasoned that "nothing
   else in the portal has this exact table-full-of-inputs shape" - true when written, false the
   moment Training/SessionEdit reused the class, at which point the participant roster's inputs got
   NOTHING (a scoped rule cannot cross components) and rendered as raw browser fields inside an
   otherwise styled table.

   select is included alongside input because the participant roster's Attendance cell is a <select>
   - it was unstyled even on the page the rules were written for. */
.count-table {
    margin: 1rem 0 1.5rem;
}

.count-table input[type="number"] {
    width: 6rem;
}

.count-table input[type="text"] {
    width: 100%;
    min-width: 12rem;
}

.count-table input[type="number"],
.count-table input[type="text"],
.count-table select {
    box-sizing: border-box;
    padding: 0.35rem 0.5rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    font: inherit;
    color: var(--ff-text);
    background: var(--ff-surface);
}

.count-table input:focus,
.count-table select:focus {
    outline: 2px solid var(--ff-brand);
    outline-offset: 1px;
}

.form-actions {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1.5rem;
}

/* The stock chooser's week/submission summary sits inside the chooser card below Load Period;
   .page-sub carries no margin of its own, so give it air above the border-adjacent button row. */
.chooser-summary {
    margin-top: 1rem;
}

/* --- Attachment upload form (Merchandising VisitDetail.razor / Training SessionDetail.razor) ---
   .upload-field/.upload-label give this form its own identity, matching this section's own entity/edit
   forms (.form-field/.form-field label above) rather than the filter-toolbar classes (.filter-field/
   .filter-label), which carry the toolbar's small, muted label type and spacing - wrong for a
   content-section form. The shared input/select rule below makes the file picker (including its own
   ::file-selector-button, so it doesn't look like raw browser chrome) match the adjacent <select>
   exactly - same border/radius/padding/font/focus ring. */
.upload-form {
    display: flex;
    align-items: flex-end;
    flex-wrap: wrap;
    gap: 1rem;
    margin: 0 0 0.55rem;
}

/* .page-sub carries no margin of its own (see its rule), so wherever the uploader's intro prose
   directly precedes the controls (Session/Visit/Announcement edit fieldsets) the labels would sit
   flush against it - same adjacency treatment as .page-sub + .table-scroll. */
.page-sub + .upload-form {
    margin-top: 0.9rem;
}

.upload-field {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    min-width: 0;   /* let the file-input column shrink below its intrinsic width on narrow screens */
}

.upload-label {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--ff-text);
}

.upload-field select,
.upload-field input[type="file"] {
    padding: 0.5rem 0.7rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    box-sizing: border-box;
    max-width: 100%;   /* a file input will not shrink below its intrinsic width without this, overflowing narrow screens */
    min-width: 0;      /* override the input's own min-width:auto (min-content) so it can shrink with its column */
    font: inherit;
    font-size: 0.9rem;
    color: var(--ff-text);
    background: var(--ff-surface);
    transition: var(--ff-ease);
}

/* input[type="file"]'s own box wraps its ::file-selector-button PLUS the "no file chosen" text -
   the select's 0.5rem vertical padding above, stacked on top of the button's OWN padding/border
   below, made the file field measurably taller than the select (47px vs 37px). Trimmed to 0.2rem
   vertical here so the outer field's total height lands within ~2px of the select's - see this
   rule's sibling ::file-selector-button comment for the other half of that math. */
.upload-field input[type="file"] {
    padding: 0.25rem 0.6rem;
}

.upload-field select:focus,
.upload-field input[type="file"]:focus {
    outline: none;
    border-color: var(--ff-brand);
    box-shadow: 0 0 0 3px var(--ff-brand-soft);
}

/* No border of its own (the outer input's border above is already this control's visible field
   boundary - a second border on the button read as a distracting "double frame" nested inside it);
   just a background chip, sized so ITS OWN padding+line-height is what finally closes the gap with
   the select's total height (see the outer rule's comment). */
.upload-field input[type="file"]::file-selector-button {
    margin-right: 0.6rem;
    padding: 0.3rem 0.6rem;
    border: none;
    border-radius: var(--ff-radius-sm);
    background: var(--ff-bg);
    color: var(--ff-text);
    font: inherit;
    font-size: 0.85rem;
    cursor: pointer;
    transition: var(--ff-ease);
}

.upload-field input[type="file"]::file-selector-button:hover {
    background: var(--ff-brand-soft);
    color: var(--ff-brand);
}

/* Sits quietly beneath the controls (muted, small) rather than crowding them - it's a caption on the
   form above, not a third field. */
.upload-hint {
    color: var(--ff-muted);
    font-size: 0.8rem;
    margin: 0 0 1.25rem;
}

/* --- Attachment galleries (.photo-grid/.document-list/.attachment-meta/.attachments-fields) ---
   GLOBAL for the reason recorded in VisitDetail.razor.css: all FOUR attachment pages
   (Merchandising/{VisitDetail,VisitEdit} + Training/{SessionDetail,SessionEdit}) render these same
   class names, and a scoped rule can never style another component.

   None of these had a rule in ANY stylesheet until now - so every photo gallery on the site was
   rendering as a default bulleted <ul> of UNCONSTRAINED full-size <img>s (there is no global
   img{max-width} either), meaning one large uploaded photo widened the whole page. The uniform 4:3
   cover crop below is what makes a portrait phone photo and a landscape one sit in equal cards. */
.photo-grid {
    list-style: none;
    margin: 0 0 1.5rem;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
    gap: 1rem;
}

.photo-grid li {
    padding: 0.6rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    background: var(--ff-surface);
    box-shadow: var(--ff-shadow-sm);
}

/* The thumbnail's own link is the li's first child (it opens the full-size image in a new tab). */
.photo-grid li > a:first-child {
    display: block;
    overflow: hidden;
    border-radius: var(--ff-radius-sm);
    background: var(--ff-bg);
}

.photo-grid img {
    display: block;
    width: 100%;
    aspect-ratio: 4 / 3;
    object-fit: cover;
}

/* Download and Remove share one row beneath the caption. Remove is a <form> (it must POST with an
   antiforgery token) and would otherwise take a line of its own, stranded from Download. */
.photo-grid li > a.btn,
.photo-grid li > form {
    display: inline-block;
    vertical-align: middle;
}

.photo-grid li > form {
    margin-left: 0.3rem;
}

.attachment-meta {
    color: var(--ff-muted);
    font-size: 0.78rem;
    line-height: 1.5;   /* wrapped filenames must not set solid - they read as one blur otherwise */
}

.photo-grid .attachment-meta {
    display: block;
    margin: 0.6rem 0 0.55rem;
    overflow-wrap: anywhere;   /* a long uploaded filename must wrap, never widen its card */
}

.document-list {
    list-style: none;
    margin: 0 0 1.5rem;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.document-list li {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.65rem;
    padding: 0.6rem 0.85rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    background: var(--ff-surface);
}

/* Absorbs the leftover width so the Download/Remove buttons align down the trailing edge of the list
   however long each individual filename happens to be. */
.document-list .attachment-meta {
    margin-right: auto;
}

.document-list li > form {
    margin: 0;
}

/* --- Role-play video gallery (SessionDetail.razor/SessionEdit.razor) --- */
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
.video-item video { width: 100%; border-radius: var(--ff-radius-sm); background: #000; }
.video-item > form { display: inline-block; margin: .3rem 0 0; }
.video-meta { display: flex; justify-content: space-between; align-items: center; gap: .5rem; margin-top: .35rem; }
.video-name { font-size: .82rem; color: var(--ff-muted); overflow-wrap: anywhere; }
/* Layout comes from .upload-form on the same element; this only spaces it from the video grid. */
.video-upload-form { margin-bottom: 1rem; }

/* --- Compliance photo slots (VisitEdit.razor, Field Manager only) --- the three fixed-name slot
   tiles that replace the generic .attachments-fields uploader for this role. Each slot's file input
   stays hidden; its "Take Photo"/"Retake" button opens the live camera viewfinder (below) via app.js's
   getUserMedia-based capture flow, which fills the hidden input after the shutter. Mobile-first: a
   single column, since a Field Manager works from a phone. */
.compliance-fields {
    margin: 0 0 1.5rem;
}

.compliance-slot-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 1rem;
    margin-top: 0.75rem;
}

/* The JS-injected Add Photo control (kind dropdown + button) that appends a fresh compliance slot
   (see app.js setupCompliancePhotoAdder); only ever rendered after the compliance grid. */
.compliance-add-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-top: 0.75rem;
}

.compliance-add-row select {
    max-width: 16rem;
}

/* New Training Session's "Filled from the export" summary (SessionEdit.razor): a soft panel so the
   operator sees which export row the form below came from before reviewing and saving it. */
.import-notice {
    margin: 0.75rem 0 1rem;
    padding: 0.6rem 0.9rem;
    border: 1px solid var(--ff-border);
    border-left-width: 3px;
    border-radius: var(--ff-radius-sm);
}

.import-notice p {
    margin: 0.3rem 0;
}

/* A removable photo's Remove button, sitting directly under its slot's Retake button; doubles as the
   marker app.js strips from cloned slots, since a cloned Remove would target another photo's form. */
.compliance-remove-btn {
    margin-top: 0.35rem;
}
@media (min-width: 640px) {
    .compliance-slot-grid {
        grid-template-columns: repeat(3, 1fr);
    }
}

.corrective-action-evidence {
    margin-top: 1rem;
}

.compliance-slot {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    padding: 0.85rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    background: var(--ff-surface);
    box-shadow: var(--ff-shadow-sm);
}

/* The X that takes away a photo slot added with Add Photo (app.js setupCompliancePhotoAdder). Sits in
   the card's top-right corner, level with the slot label; server-rendered slots never carry it. */
/* Attendance: the live "Checked In Now" list above the filters, and the correction forms on a
   check-in's detail page, each set apart from the read-only content above it. */
.checked-in-now {
    margin: 1rem 0 1.5rem;
}

.open-checkins {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: 0.4rem;
}

.open-checkins li {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.4rem 1rem;
    padding: 0.55rem 0.75rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    background: var(--ff-surface);
}

.open-checkins li a {
    font-weight: 600;
}

/* Session roster: a saved participant ticked for removal reads as struck through until saved. */
.roster-remove {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    font-size: 0.85rem;
    white-space: nowrap;
}

tr:has([data-remove-participant]:checked) td:not(:last-child) {
    opacity: 0.5;
    text-decoration: line-through;
}

.roster-paste {
    margin-top: 1rem;
}

/* Training review: the Needs Retraining list below the sessions table. */
.needs-retraining {
    margin-top: 1.5rem;
}

.retrained-list summary {
    cursor: pointer;
    margin: 0.5rem 0;
    font-weight: 600;
}

/* Visit detail: follow-up notes under the corrective action, and the store's other visits. */
.follow-up-list {
    margin: 0.4rem 0 0;
    padding-left: 1.2rem;
}

.follow-up-list li {
    margin-bottom: 0.3rem;
}

.other-visits {
    margin-top: 1.5rem;
}

.correction-form {
    margin-top: 1.5rem;
    padding-top: 1rem;
    border-top: 1px solid var(--ff-border);
}

/* State edit: what moving a state to another region affects, shown before the confirming save. */
.move-warning {
    margin: 1rem 0;
    padding: 0.75rem 1rem;
    border: 1px solid var(--ff-watch);
    border-radius: var(--ff-radius-sm);
    background: var(--ff-watch-soft);
}

.move-warning ul {
    margin: 0.4rem 0;
    padding-left: 1.2rem;
}

/* Store form Location section (app.js setupLocationPicker). The helper block is server-hidden and
   revealed by script, so its own display rules must not override [hidden]. */
.location-fields {
    margin-bottom: 1rem;
}

.location-summary {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.75rem;
    margin: 0.25rem 0 0.75rem;
}

.location-tools[hidden] {
    display: none;
}

.location-buttons {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
}

.location-paste {
    display: flex;
    gap: 0.5rem;
}

.location-paste input {
    flex: 1;
    min-width: 0;
}

.location-results:empty {
    display: none;
}

.location-message {
    margin: 0.25rem 0 0.5rem;
    color: var(--ff-muted);
}

.location-matches {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: 0.4rem;
}

.location-matches li {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    padding: 0.5rem 0.65rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
}

.location-matches li span {
    flex: 1 1 16rem;
}

.location-credit {
    margin: 0.4rem 0 0;
    font-size: 0.8rem;
    color: var(--ff-muted);
}

.location-coordinates summary {
    cursor: pointer;
    margin: 0.5rem 0;
    font-weight: 600;
}

/* Repeatable form rows (app.js setupRowLists): the X matches the photo-slot close button; the New
   Product SKU inputs fill their cells (the session roster keeps .count-table's input sizes). */
.sku-row-list input {
    width: 100%;
    min-width: 5.5rem;
    box-sizing: border-box;
    padding: 0.35rem 0.5rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    font: inherit;
    color: var(--ff-text);
    background: var(--ff-surface);
}

.sku-row-list input:focus {
    outline: 2px solid var(--ff-brand);
    outline-offset: 1px;
}

.row-list [data-row-add] {
    margin-top: 0.5rem;
}

.row-remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 1px solid transparent;
    border-radius: 50%;
    background: transparent;
    color: var(--ff-muted);
    cursor: pointer;
}

.row-remove:hover,
.row-remove:focus-visible {
    border-color: var(--ff-bad);
    background: var(--ff-bad-soft);
    color: var(--ff-bad-ink);
}

.row-remove[hidden],
.row-list [data-row-add][hidden] {
    display: none;
}

.compliance-slot-close {
    position: absolute;
    top: 0.55rem;
    right: 0.55rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 1px solid transparent;
    border-radius: 50%;
    background: transparent;
    color: var(--ff-muted);
    cursor: pointer;
}
.compliance-slot-close:hover,
.compliance-slot-close:focus-visible {
    border-color: var(--ff-bad);
    background: var(--ff-bad-soft);
    color: var(--ff-bad-ink);
}
.compliance-slot:has(.compliance-slot-close) .upload-label {
    padding-right: 2rem;
}

.compliance-slot-help {
    margin: 0;
    color: var(--ff-muted);
    font-size: 0.8rem;
}

.compliance-thumb {
    display: flex;
    align-items: center;
    justify-content: center;
    aspect-ratio: 4 / 3;
    overflow: hidden;
    border: 1px dashed var(--ff-border);
    border-radius: var(--ff-radius-sm);
    background: var(--ff-bg);
}

.compliance-thumb-img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.compliance-thumb-placeholder {
    color: var(--ff-muted);
    font-size: 0.8rem;
}

/* The real capture target for the multipart POST - never shown; app.js's camera capture flow fills it
   (via a DataTransfer) after the shutter, exactly as a hand-crafted request can also post to it
   directly (see ComplianceVisitPhotosTests). */
.compliance-capture-input {
    display: none;
}

.compliance-noscript-note {
    margin: 0;
    color: var(--ff-muted);
    font-size: 0.8rem;
    font-style: italic;
}

/* Full tile width, unlike the compact inline .btn elsewhere on this form - each slot is a single,
   deliberate tap target on a phone screen. margin-top:auto pins it to the slot's bottom so the three
   buttons line up across columns no matter how long each slot's help text is. */
.compliance-capture-btn {
    width: 100%;
    margin-top: auto;
}

/* Camera-unavailable message: app.js's setupComplianceCapture() inserts this into a slot when
   scripting IS enabled but no live camera is - unsupported browser, an insecure context, a denied
   permission, or no camera hardware. The markup's
   own <noscript> ".compliance-noscript-note" above covers the scripting-DISABLED case only - once
   scripting is on, a <noscript> element's content is never parsed into the DOM at all, so there is
   nothing there for a script to reveal; this is a separate, dynamically-created element instead. Styled
   as a refusal, like .upload-error below, since the tap that triggered it did not get a camera. */
.compliance-camera-unavailable,
.compliance-slot-missing {
    margin: 0.5rem 0 0;
    padding: 0.5rem 0.7rem;
    border: 1px solid var(--ff-bad);
    border-left-width: 3px;
    border-radius: var(--ff-radius-sm);
    background: var(--ff-bad-soft);
    color: var(--ff-bad-ink);
    font-size: 0.8rem;
}

/* The live camera viewfinder: a full-viewport overlay app.js appends to <body> for the duration of
   one capture, opened by a compliance slot's Take Photo/Retake button and closed by its own
   Shutter/Cancel (or Escape, or a navigation away - see
   app.js's openComplianceViewfinder/closeActiveComplianceViewfinder). Mobile-first: a Field Manager
   works from a phone, so this fills the whole viewport there; the panel itself is capped at a sensible
   width so a wide desktop doesn't stretch a phone-shaped camera UI across the whole screen. */
.compliance-viewfinder-overlay {
    position: fixed;
    inset: 0;
    z-index: 2000;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.85);
    padding: 1rem;
}

.compliance-viewfinder-panel {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    width: 100%;
    max-width: 480px;
}

.compliance-viewfinder-video {
    display: block;
    width: 100%;
    max-height: 70vh;
    aspect-ratio: 4 / 3;
    object-fit: cover;
    background: #000;
    border-radius: var(--ff-radius);
}

.compliance-viewfinder-controls {
    display: flex;
    gap: 0.75rem;
}

/* Full-width, evenly split tap targets - a phone camera UI, not a compact inline .btn pair. */
.compliance-viewfinder-shutter,
.compliance-viewfinder-cancel {
    flex: 1;
    padding: 0.75rem 1rem;
    border-radius: var(--ff-radius-sm);
    font-family: inherit;
    font-size: 1rem;
    cursor: pointer;
}

.compliance-viewfinder-shutter {
    border: 1px solid var(--ff-accent);
    background: var(--ff-accent);
    color: #fff;
}

.compliance-viewfinder-shutter:hover {
    background: var(--ff-accent-strong);
    border-color: var(--ff-accent-strong);
}

.compliance-viewfinder-cancel {
    border: 1px solid var(--ff-border);
    background: var(--ff-surface);
    color: var(--ff-text);
}

.compliance-viewfinder-cancel:hover {
    background: var(--ff-bg);
    border-color: var(--ff-muted);
}

/* --- Photo lightbox (setupPhotoLightbox in app.js): click a gallery photo or a capture thumbnail to
   view it enlarged in-page. Transient body child like the viewfinder overlay above (same z plane);
   the whole overlay is the dismiss target, the ✕ is the visible affordance. */
.photo-lightbox-overlay {
    position: fixed;
    inset: 0;
    z-index: 2000;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.85);
    padding: 1.5rem;
    cursor: zoom-out;
}

.photo-lightbox-img {
    max-width: 100%;
    max-height: 100%;
    border-radius: var(--ff-radius-sm);
    box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
}

.photo-lightbox-close {
    position: absolute;
    top: 0.9rem;
    right: 0.9rem;
    width: 44px;
    height: 44px;
    border: 1px solid rgba(255, 255, 255, 0.4);
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.5);
    color: #fff;
    font-size: 1.1rem;
    line-height: 1;
    cursor: pointer;
}

/* Affordance: gallery photos and capture thumbnails read as enlargeable. */
.photo-grid a img,
.compliance-thumb-img {
    cursor: zoom-in;
}

/* A rejected upload, or a refused workflow action (ClaimDetail.razor's .action-error - an illegal
   Approve/Pay etc.). Both have to read as a refusal rather than as body copy, because the attempted
   change did NOT happen (see VisitEdit.razor's Save() / ClaimDetail.razor's workflow handlers). */
.upload-error,
.action-error {
    margin: 0 0 1rem;
    padding: 0.7rem 0.9rem;
    border: 1px solid var(--ff-bad);
    border-left-width: 3px;
    border-radius: var(--ff-radius-sm);
    background: var(--ff-bad-soft);
    color: var(--ff-bad-ink);
    font-size: 0.9rem;
}

/* External Accounts: the one-time temp-password reveal after a create/reset, styled as a success
   notice (the "good" palette, mirroring .upload-error's "bad" palette treatment above) so it reads as
   "here is something to act on now", not body copy. Rendered
   in the SAME response as the POST that created it (Users.razor's in-page handling, no redirect) -
   the whole reason this exists is so the password appears exactly once and nowhere else.

   .notice-good shares the same treatment for the email-sending flows' own success notices ("You can
   sign in now." on the sign-in page, "Invitation sent to {email}." on the account pages, and the
   forgot-password confirmation) - one palette for "this worked", rather than a second near-copy. */
.temp-password-callout,
.notice-good {
    margin: 0 0 1.5rem;
    padding: 0.7rem 0.9rem;
    border: 1px solid var(--ff-good);
    border-left-width: 3px;
    border-radius: var(--ff-radius-sm);
    background: var(--ff-good-soft);
    color: var(--ff-good-ink);
    font-size: 0.9rem;
}

.temp-password-callout code {
    font-size: 1rem;
    font-weight: 600;
    padding: 0.1rem 0.4rem;
    border-radius: var(--ff-radius-sm);
    background: var(--ff-surface);
}

/* Account Manager territory checkbox lists (always rendered; see UserEdit.razor's header comment
   on why static SSR can't reactively hide this section by role). */
.territory-section {
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    padding: 1rem 1.25rem 1.25rem;
    margin: 1.5rem 0;
}

.territory-section legend {
    font-weight: 600;
    padding: 0 0.3rem;
}

.territory-columns {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
    gap: 1.25rem;
    margin-top: 0.75rem;
}

.territory-group h3 {
    margin: 0 0 0.5rem;
    font-size: 0.85rem;
    color: var(--ff-muted);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

/* Manage area list rows (Partners.razor, and Stores.razor next task): an Edit link plus a
   POST-only activate/deactivate <form> share one cell. The form (block by default) is forced inline
   so it sits beside the Edit link instead of dropping to its own line. */
.row-actions {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}
.row-actions form {
    display: inline;
}
.row-actions .btn {
    padding: 0.2rem 0.6rem;
    font-size: 0.8rem;
}

/* Sales Compensation claim detail (ClaimDetail.razor): the validate/approve/pay workflow action
   strip. Several small <EditForm>s share this row (one per transition - Mark Valid/Invalid/Request
   Revision/Approve/Reject/Mark Paid/Mark Payment Failed) so an illegal one's <form> can stay in the
   markup (carrying its own antiforgery token, so a direct POST still reaches the real server-side
   check - see that file's own header comment) while only its <button> is hidden; without this rule an
   empty <form> would still take up a full line each. */
.workflow-actions {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.75rem;
    margin-top: 0.75rem;
}
.workflow-actions form {
    display: inline;
}

/* Geography management: the add-region/add-state forms sit side by side on wide viewports
   and stack on narrow ones: same auto-fit/minmax technique as .territory-columns above. Each
   section's .entity-form drops its usual max-width so it fills its grid column instead of clamping
   to the single-form pages' fixed width. */
.two-col-forms {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
    gap: 2rem;
    margin-top: 1.5rem;
}
.two-col-forms .entity-form {
    max-width: none;
}

/* --- Territory picker (Chains/Stores): searchable multi-select with removable chips ---
   Progressively enhances the plain <select multiple> in UserEdit.razor (app.js's
   setupTerritoryPickers). The native select is the no-JS fallback AND stays the only thing that
   posts: it's just hidden once JS has replaced it with the search box + chips below, via the
   "js-enhanced" class app.js adds to .terr-picker on setup. */
.terr-picker {
    position: relative;
}

/* --- Single-select combobox (Visit form): searchable single-value <select> ---
   Progressively enhances a plain single <select> in VisitEdit.razor (app.js's
   setupSingleSelectPickers, the sibling of setupTerritoryPickers above): same search box + dropdown
   menu, no chips - reuses every ".picker-*" rule below, since the rendered menu/options/empty/hint
   markup is identical. */
.combo-picker {
    position: relative;
}

.js-enhanced .picker-native {
    /* display:none, not disabled: a display:none control is invisible, out of layout AND out of the
       tab order, yet a NON-disabled control still submits its <option selected> set, which is exactly
       what we want (a disabled select would post nothing). The chip UI is the visible control. */
    display: none;
}

/* Attendance location map (CheckIn.razor / Attendance/Detail.razor): a key-free OpenStreetMap embed
   iframe plus its View Larger Map link - see LocationMap.cs. The frame keeps the portal's card chrome
   (token border/radius, so dark mode just works); OSM's own tiles stay light in dark mode, which is
   the conventional look for embedded maps. */
.location-map {
    margin: 1rem 0 0.5rem;
    max-width: 560px;
}

.location-map-frame {
    display: block;
    width: 100%;
    height: 260px;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
}

.location-map-link {
    display: inline-block;
    margin-top: 0.35rem;
    font-size: 0.85rem;
}

/* The one-line nudge above the check-in page's New Visit button (the rep's next step after checking
   in) - sentence case, muted, like the portal's other helper copy. */
.next-step {
    color: var(--ff-muted);
    margin: 0.75rem 0 0.5rem;
}

/* Pre-enhancement flash suppression: every auto-submitting filter is a full-page navigation, and
   app.js loads at the end of <body> - so between the new page's first paint and
   setupTerritoryPickers()/setupSingleSelectPickers() running, each picker showed its raw <select
   multiple> (~100px tall, sized by its widest option),
   roughly doubling the filter bar before it snapped back to the enhanced 31px search boxes. The "js"
   class is stamped on <html> by a tiny inline HEAD script (see App.razor) BEFORE first paint, so these
   rules hide the native select immediately and hold the wrapper at the enhanced control's height -
   the bar paints at its final size and enhancement swaps the UI in without a jump. With JS disabled the
   "js" class never appears, so the native selects stay visible and the no-JS fallback is untouched. */
.js [data-picker]:not(.js-enhanced) .picker-native,
.js [data-combobox]:not(.js-enhanced) .picker-native {
    display: none;
}

.js [data-picker]:not(.js-enhanced),
.js [data-combobox]:not(.js-enhanced) {
    min-height: 2rem;
}

/* A picker that already HAS selections (a hard load of a filtered URL - deep link, F5, browser back;
   filter clicks themselves now go through Blazor's enhanced form handling and never repaint the bar)
   will grow a chips row when app.js enhances it. Reserve that row's height up front so the content
   below the filter bar doesn't shift when the chips appear. One row covers the common one-to-three-chip
   case; more chips than fit a row still wrap and shift slightly, which is rare enough to accept. */
.js [data-picker]:not(.js-enhanced):has(.picker-native option[selected]) {
    min-height: 3.8rem;
}

.picker-native {
    width: 100%;
    box-sizing: border-box;
    padding: 0.4rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    font: inherit;
    color: var(--ff-text);
    background: var(--ff-surface);
}

/* Chips render BELOW the search box (see app.js's picker-control comment) so a selection never pushes
   this field's input out of line with the other filter controls. */
.picker-control {
    position: relative;
}

.picker-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin-top: 0.35rem;
}

.picker-chips:empty {
    margin-top: 0;
}

.picker-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.2rem 0.3rem 0.2rem 0.6rem;
    background: var(--ff-brand-soft);
    color: var(--ff-brand-strong);
    border-radius: 999px;
    font-size: 0.82rem;
    line-height: 1.4;
}

.picker-chip-remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.1rem;
    height: 1.1rem;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: transparent;
    color: inherit;
    font-size: 0.9rem;
    line-height: 1;
    cursor: pointer;
}

.picker-chip-remove:hover {
    background: rgba(79, 70, 229, 0.18);
}

.picker-search {
    width: 100%;
    box-sizing: border-box;
    padding: 0.5rem 0.7rem;
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    font: inherit;
    color: var(--ff-text);
    background: var(--ff-surface);
}

.picker-search:focus {
    outline: 2px solid var(--ff-brand);
    outline-offset: 1px;
}

.picker-menu {
    position: absolute;
    z-index: 20;
    top: 100%;
    left: 0;
    right: 0;
    margin-top: 0.25rem;
    /* Tall enough for app.js's PICKER_MAX_RESULTS (10) rows plus the "Keep typing" hint without
       scrolling, capped on short screens. */
    max-height: min(25rem, 60vh);
    overflow-y: auto;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius-sm);
    box-shadow: var(--ff-shadow-md);
}

.picker-option {
    padding: 0.45rem 0.7rem;
    font-size: 0.88rem;
    cursor: pointer;
}

.picker-option:hover,
.picker-option.is-active {
    background: var(--ff-brand-soft);
}

.picker-empty,
.picker-hint {
    padding: 0.45rem 0.7rem;
    font-size: 0.82rem;
    color: var(--ff-muted);
}

/* --- Module summary widgets --- Home.razor and Components/Shared/EntityDashboard.razor (the
   chain/store drill-down dashboard) both render this exact card shape - one card each for
   Merchandising/Stock/Training/Sales Compensation(/Outstanding Corrective Actions on Home only), REAL
   or PENDING, so the grid always reads as one consistent row rather than "the real cards plus some
   broken ones". Global (not Home.razor.css) because Blazor's CSS isolation scopes a rule to one
   component's own elements, and would not reach EntityDashboard's markup - a different component, a
   different scope attribute - without duplicating the whole block there too. */
/* A flex row of equal-width cards rather than a rigid grid. A grid with a fixed column count stranded
   the last card alone in column one whenever the card count did not fill the final row (five summary
   cards on a four-column row left an ugly gap). Here full rows grow edge to edge, while an incomplete
   last row grows each card only up to its max-width and then CENTERS them, so a leftover card sits
   centred under the row above instead of stranded left or stretched full width. Shared by Home and
   the chain/store dashboards (EntityDashboard), so this one rule fixes every overview. */
.module-grid {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 1rem;
    margin: 1.5rem 0;
}

/* Cards are equal-height flex columns so their footer links sit on a common bottom line instead of
   floating directly under ragged content. See .module-figures for the alignment of the figures
   themselves. */
.module-card {
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    padding: 1rem 1.1rem;
    display: flex;
    flex-direction: column;
    /* grow to fill a full row, but cap the width so a leftover card on the last row is centred at a
       normal size rather than stretched across the whole width. box-sizing:border-box so max-width
       caps the visible width, padding included. */
    flex: 1 1 240px;
    max-width: 320px;
    box-sizing: border-box;
}

/* Reserve exactly two lines for the title so a one-line title ("Stock Inventory Summary") and a wrapping
   one ("Merchandising Compliance Summary") start their figures at the same height. line-height is set
   explicitly so the min-height maps to a whole number of lines: without it the inherited line-height
   left the reserve between one and two lines, so a wrapping title overflowed it (pushing that card's
   figures down) while a one-line title left a ragged gap. */
.module-card h3 {
    margin: 0 0 0.6rem;
    font-size: 1rem;
    line-height: 1.3;
    min-height: 2.6em;   /* two lines at line-height 1.3 */
}

/* A fixed 2-column grid rather than a wrapping flex row: every card's figures then land in the SAME
   columns at the SAME heights, so values/labels/badges line up across all five cards (a wrap-flex sized
   each figure to its own content, which made the row ragged and the cards visibly misaligned).
   TWO columns, not three: with five cards in a row each figure gets only ~77px at three columns, too
   narrow for a longer label ("Non-Compliant Stores") which then wraps to three lines while its
   neighbours take two - pushing that whole card's status badges below every other card's. At two
   columns each figure is ~120px, so every label fits in two lines and the badges line up within a card
   and across the whole grid. A card with an odd figure count simply starts a new row in column 1, still
   aligned. flex:1 fills the remaining card height so, even when cards have different row counts, each
   card's figure area expands to match the tallest card, keeping labels and badges aligned across the grid. */
.module-figures {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 0.9rem 0.75rem;
    /* stretch (not start) so every figure in a row is the same height; combined with the badge's
       margin-top:auto below, this lines all the status badges up on a common bottom edge even when one
       label wraps to two lines and its neighbour takes one. */
    align-items: stretch;
    align-content: start;
    flex: 1;
}

.module-figure {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    min-width: 0;   /* allow the grid column to constrain a long value instead of overflowing the card */
}

.module-figure-value {
    font-size: 1.4rem;
    font-weight: 700;
    line-height: 1.2;
    min-height: 1.68em;  /* reserves one line of value space so all figures align even if one is shorter */
    display: flex;
    align-items: flex-end;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.module-figure-label {
    font-size: 0.78rem;
    color: var(--ff-muted);
    min-height: 2.4em;  /* reserves two lines so short and wrapping labels still sit at the same height */
}

/* A figure whose value is too long for a third of the card takes the whole row instead, and the
   remaining figures share the row beneath it. This keeps long money values (e.g. "RM 2,990.00") from
   overflowing into neighbouring figure cells.
   Selected by a boolean data-wide attribute rather than a class, for the same reason .kpi-value's
   data-tight is: tests regex-match the literal class="module-figure", and Blazor renders a bool-typed
   attribute as present-or-omitted without ever touching the class value. */
.module-figure[data-wide] {
    grid-column: 1 / -1;
}

.module-figure .status-badge {
    margin-top: auto;   /* pin to the bottom of the (stretched) figure so badges align across a row */
    width: fit-content;
}

.module-link {
    display: inline-block;
    margin-top: auto;   /* pins the link to the card's bottom edge, every card being an equal-height flex column */
    padding-top: 0.85rem;
    font-size: 0.88rem;
    font-weight: 600;
    text-decoration: none;
}

/* Blazor's <ValidationSummary> renders this class for its <ul>; .validation-message above already
   colours each <li>. */
.validation-summary-errors {
    list-style: none;
    padding: 0.6rem 0.9rem;
    margin: 0 0 1rem;
    background: var(--ff-bad-soft);
    border: 1px solid var(--ff-bad);
    border-radius: var(--ff-radius-sm);
}

/* --- Record-detail field card (GLOBAL, shared) ---
   The label/value grid used by every "one record" page: a VM visit (VisitDetail.razor) and a training
   session (SessionDetail.razor) today, more as modules land. It lived in VisitDetail's SCOPED css, which
   meant SessionDetail used the same class names and got NO styling at all - its field block rendered as
   a bare <dl>. Shared chrome belongs here, same reasoning as the nav and picker styles above. */
.visit-fields {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    gap: 0.75rem 1.5rem;
    margin: 0 0 1.25rem;
    padding: 1rem 1.25rem;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
}

.visit-field dt {
    margin: 0 0 0.15rem;
    color: var(--ff-muted);
    font-size: 0.78rem;
    text-transform: uppercase;
    letter-spacing: 0.02em;
}

.visit-field dd {
    margin: 0;
    font-size: 0.95rem;
}

/* Free-text fields (Remarks) span the whole card and keep the author's own line breaks, so a
   multi-line note reads like every other captured field rather than as its own separate section. */
.visit-field-wide {
    grid-column: 1 / -1;
}

.visit-field dd.field-note {
    white-space: pre-wrap;
    color: var(--ff-text);
    line-height: 1.45;
    max-width: 70ch;
}

.corrective-action {
    margin-bottom: 1.25rem;
}

.corrective-action h2 {
    margin: 0 0 0.5rem;
}

/* Content sections that carry no box of their own - the galleries/tables inside them carry the
   visible chrome - but which do own the spacing between one another. */
.attachments,
.participant-roster {
    margin-bottom: 1.25rem;
}

/* Bootstrap-shaped class names left in the framework's own scaffold files (Error.razor,
   Routes.razor's Authorizing state). No Bootstrap is loaded, so these matched nothing and the error
   headings rendered in body black rather than as an error. Mapped onto house tokens here instead of
   editing the scaffold markup. */
.text-danger {
    color: var(--ff-bad-ink);
}

.p-4 {
    padding: 1.5rem;
}

/* The panel below a detail page's tab strip (.tab-strip/.tab-link above). Without this the panel
   sat flush against the strip with no separation, so the active tab did not read as connected to
   the content it switches. */
.tab-panel {
    padding-top: 1.25rem;
}

/* Audit Log: the Changes column's per-field before/after list, one field per line rather
   than a single run-on sentence, so a multi-field diff still scans as a list at a glance. */
.audit-changes {
    list-style: none;
    margin: 0;
    padding: 0;
    font-size: 0.85rem;
}

.audit-changes li + li {
    margin-top: 0.25rem;
}

/* New User chooser (UserNew.razor): two big link "cards" for the admin to
   pick Staff vs External, since static SSR can't swap forms in place under one New User button. A
   flex row that wraps to a single column on narrow screens, rather than a fixed grid, so the cards
   never get cramped on a phone-width viewport. */
.choice-cards {
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
}

.choice-card {
    flex: 1 1 260px;
    box-sizing: border-box;
    display: block;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    padding: 1.25rem 1.4rem;
    color: var(--ff-text);
    text-decoration: none;
    transition: var(--ff-ease);
}

.choice-card:hover {
    border-color: var(--ff-brand);
}

.choice-card h2 {
    margin: 0 0 0.4rem;
}

.choice-card p {
    margin: 0;
    color: var(--ff-muted);
}

/* Stock count tables (Stock page, SKU History). A week with no count is one muted cell spanning the
   figures rather than a fabricated placeholder in each; a non-zero variance gets the "bad" ink. */
.data-table td.not-submitted-cell {
    color: var(--ff-muted);
    font-style: italic;
    text-align: center;
}

.data-table td.cell-variance-flag {
    color: var(--ff-bad-ink);
    font-weight: 600;
}

/* Sales page bulk actions: a toolbar above the claim table, and a tick box before each claim date. */
.bulk-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    margin: 0.5rem 0;
}

.bulk-check {
    margin-right: 0.4rem;
    vertical-align: middle;
}

/* The payout table's Total row reads as a total, not another salesperson. */
.payout-table tfoot td {
    font-weight: 600;
    border-top: 2px solid var(--ff-border);
}

#payout-summary {
    margin-top: 2rem;
}

/* A second, muted line inside a table cell, e.g. when an invitation was sent. */
.cell-sub {
    display: block;
    margin-top: 0.2rem;
    font-size: 0.8rem;
    color: var(--ff-muted);
}

/* A table's narrow tick-box column. */
.data-table .cell-check {
    width: 1%;
    white-space: nowrap;
}

/* Announcement board (Home) and its featured card, global because the draft preview on the announcement
   page shows the same card through Components/Shared/AnnouncementCard.razor. */
/* --- Announcement board --- a featured card for the newest current notice plus a compact list of the
   rest. Brand indigo accents (not the status bad/watch/good palette, which mean stock/compliance
   health) so it reads as "announcement", not "problem". */
.board {
    margin-bottom: 1.5rem;
}

.board-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    margin-bottom: 0.75rem;
}

.board-title {
    margin: 0;
    font-size: 1.2rem;
}

.board-count {
    flex: 0 0 auto;
    color: var(--ff-muted);
    font-size: 0.82rem;
    white-space: nowrap;
}

/* Featured (newest) notice: cover image or brand placeholder on the left, content on the right; the
   whole card is the "read more" link. */
.board-featured {
    display: grid;
    grid-template-columns: 200px 1fr;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    box-shadow: var(--ff-shadow-sm);
    overflow: hidden;
    color: inherit;
    text-decoration: none;
    transition: border-color var(--ff-ease), box-shadow var(--ff-ease);
}

.board-featured:hover {
    border-color: var(--ff-brand);
    box-shadow: var(--ff-shadow-md);
}

.board-featured-cover {
    display: block;
    width: 100%;
    height: 100%;
    min-height: 160px;
    object-fit: cover;
}

.board-featured-cover-placeholder {
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--ff-brand-soft);
    color: var(--ff-brand);
}

.board-featured-cover-placeholder svg {
    width: 40px;
    height: 40px;
}

.board-featured-body {
    display: flex;
    flex-direction: column;
    padding: 1rem 1.25rem;
}

.board-featured-title {
    margin: 0.1rem 0 0.35rem;
    font-size: 1.15rem;
    font-weight: 600;
}

.board-featured-summary {
    margin: 0 0 0.75rem;
    color: var(--ff-muted);
    line-height: 1.5;
}

.board-featured-foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    margin-top: auto;
}

.board-date {
    color: var(--ff-muted);
    font-size: 0.82rem;
}

.board-readmore {
    flex: 0 0 auto;
    color: var(--ff-brand);
    font-weight: 600;
    font-size: 0.9rem;
    white-space: nowrap;
}

/* Chips (audience + New), shared by the featured card and the list rows. */
.board-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin-bottom: 0.1rem;
}

.board-chip {
    font-size: 0.72rem;
    font-weight: 600;
    padding: 0.12rem 0.6rem;
    border-radius: 999px;
    white-space: nowrap;
}

.board-chip-audience {
    border: 1px solid var(--ff-border);
    color: var(--ff-muted);
    font-weight: 500;
}

.board-chip-new {
    background: var(--ff-good-soft);
    color: var(--ff-good-ink);
}

/* The rest of the current notices: a compact clickable list, not more full cards, so a busy notice
   week doesn't push the module widgets far down the page. */
.board-list {
    list-style: none;
    margin: 0.6rem 0 0;
    padding: 0;
    background: var(--ff-surface);
    border: 1px solid var(--ff-border);
    border-radius: var(--ff-radius);
    overflow: hidden;
}

.board-list li + li .board-item {
    border-top: 1px solid var(--ff-border);
}

.board-item {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.7rem 1rem;
    color: inherit;
    text-decoration: none;
    transition: background var(--ff-ease);
}

.board-item:hover {
    background: var(--ff-brand-soft);
}

.board-item-main {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

.board-item-title {
    font-weight: 600;
    font-size: 0.92rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.board-item-audience {
    color: var(--ff-muted);
    font-size: 0.78rem;
}

.board-item-date {
    flex: 0 0 auto;
    color: var(--ff-muted);
    font-size: 0.78rem;
    white-space: nowrap;
}

.board-item-chevron {
    flex: 0 0 auto;
    display: flex;
    color: var(--ff-muted);
}

.board-item-chevron svg {
    width: 16px;
    height: 16px;
}

.board-viewall {
    display: inline-block;
    margin-top: 0.6rem;
    color: var(--ff-brand);
    font-weight: 600;
    font-size: 0.88rem;
    text-decoration: none;
}

.board-viewall:hover {
    text-decoration: underline;
}

/* Narrow screens: the featured card stacks (cover on top, content below). */
@media (max-width: 640px) {
    .board-featured {
        grid-template-columns: 1fr;
    }

    .board-featured-cover {
        min-height: 120px;
        max-height: 180px;
    }
}


/* An announcement saved as a draft: neutral, since it is neither live nor a problem. */
.status-draft {
    background: var(--ff-surface);
    border: 1px dashed var(--ff-border);
    color: var(--ff-muted);
}

/* The draft preview above an announcement page: the Home card as viewers will see it. */
.draft-preview {
    margin: 0 0 1.5rem;
    padding: 1rem 1.25rem;
    border: 1px dashed var(--ff-brand);
    border-radius: var(--ff-radius);
    background: var(--ff-brand-soft);
}

.draft-preview h2 {
    margin: 0 0 0.25rem;
    font-size: 1.05rem;
}

.draft-preview .board-featured {
    margin: 0.75rem 0;
}

/* The "?" beside a module page's title, linking to its Help section. */
.help-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    margin-left: 0.5rem;
    border: 1px solid var(--ff-border);
    border-radius: 999px;
    color: var(--ff-muted);
    font-size: 0.85rem;
    font-weight: 600;
    text-decoration: none;
    vertical-align: middle;
}

.help-link:hover {
    border-color: var(--ff-brand);
    color: var(--ff-brand);
}