/* ============================================================================
   platform.css — the one palette, type scale and control vocabulary for the
   pages served straight out of this repository.

   There is no build step here. What is committed is what a browser downloads,
   so this file is linked with a plain <link>, never imported, and every rule in
   it has to be safe on a page that has not been read line by line.

   Adoption is per page and deliberate. A page adopts by linking this file and
   deleting its own :root block; the aliases at the bottom are what let that
   happen in one commit instead of a rename sweep across the whole file. Until a
   page links it, this file cannot affect it at all.

   Two rules for anything added here:
     - Custom properties and opt-in classes, not bare element selectors. A rule
       on `button` changes 43 pages the moment they adopt; a `.btn` class changes
       the buttons that ask for it.
     - Anything that does have to reach every element goes through :where(), so
       its specificity is zero and a page's own rule still wins without !important.
   ============================================================================ */

:root {
  /* --- Brand ---------------------------------------------------------------
     The green does not change. It is 3.15:1 on white, which is exactly what
     WCAG asks of a boundary or of display type, and nowhere near what it asks
     of a 12px label. So the colour keeps its job and loses the one it was never
     legal for: --green-text is the same hue carrying small text, --on-green is
     the label colour on a green fill, and --green-light is for dark shells only,
     where it measures 7.35:1 and is the best of the three.
     Measured against #fff and --bg both; ratios in tests/platformTokens.test.mjs. */
  --green: #779d36;            /* 3.15 / 2.97 — borders, underlines, accents, fills, display ≥24px */
  --green-text: #5a7728;       /* 5.12 / 4.82 — green text below 18.66px bold */
  /* The same role one step darker, for green text on a pale GREEN tint. --green-text is measured
     against white and --bg, where it is 5.12 and 4.82; on the rgba(119,157,54,0.15) chips four
     pages use for a count badge the surface is about #e6eade and it lands at 4.17–4.46 — under the
     bar, and by little enough that nobody would find it by looking. 5.03 there, 6.02 on white. */
  --green-on-tint: #4f6a22;      /* 6.15 white / 5.78 --bg / 5.03 on the tint */
  --green-light: #8fb844;      /* 2.31 on white, 7.35 on --navy — dark backgrounds only */
  --on-green: #0c1e2d;         /* 5.37 against --green — the label on a green fill */
  --green-dim: rgba(119, 157, 54, 0.1);

  --navy: #0c1e2d;
  --navy-light: #375673;

  /* --- Surfaces and text --------------------------------------------------- */
  --bg: #f7f8fa;
  --card: #ffffff;
  --surface-alt: #f0f2f5;
  --text: #0c1e2d;
  --muted: #5a6b7a;            /* 5.50 / 5.17 */
  --text-dim: #66727e;         /* 4.91 / 4.62 — replaces #8a96a3 at 2.83, and every
                                  opacity-dimmed body copy, which multiplies instead */

  /* --- Status --------------------------------------------------------------
     Colour alone never carries the meaning (WCAG 1.4.1) — these are the hues for
     a badge that also says its state in words. */
  --success: #1b7a43;          /* 5.37 / 5.05 */
  --warn: #a05a00;             /* 5.31 / 4.99 */
  --danger: #c0392b;           /* 5.44 / 5.12 */
  /* The hover and pressed state of a danger fill. A destructive button has to *darken*: the
     obvious lighter red, #e74c3c, is 3.82:1 against the white label it carries, so hovering it
     made the label harder to read at the moment of confirming a delete. */
  --danger-strong: #9c2c20;    /* white on this is 7.51 */
  --bid-blue: #1f6fd0;         /* 4.95 / 4.66 */
  --success-dim: rgba(27, 122, 67, 0.08);
  --warn-dim: rgba(160, 90, 0, 0.08);
  --danger-dim: rgba(192, 57, 43, 0.08);
  --bid-blue-dim: rgba(31, 111, 208, 0.1);

  /* Status colours for the dark shells — overview/, the navy chrome, the upload log. The
     light-surface set above is the wrong answer there and the numbers say so plainly: on
     #0d1820, --success is 3.35:1 and --warn 3.39:1, while these are 7.7 and 8.5. This is the
     same argument as --green-light, which is why they live beside it rather than replacing
     anything. None of them may touch a light surface: each is near 2.2:1 on white. */
  --success-on-dark: #4cc47f;  /* 8.14 on #0d1820, 7.67 on --navy */
  --warn-on-dark: #e0a94b;     /* 8.51 / 8.03 */
  --info-on-dark: #6ab7f5;     /* 8.30 / 7.83 */
  /* Secondary text on a dark shell. --muted is 3.08:1 on --navy, which is why portal/prompt.html
     had unreadable helper text long before this branch touched it. */
  --muted-on-dark: #9aa8b4;    /* 6.97 on --navy, 2.43 on white — dark surfaces only */
  /* po/index.html's sidebar is #375673 — what this file calls --navy-light. It is a mid-tone, not a
     dark shell, and --muted-on-dark is only 3.15:1 on it. This is the same role one step lighter:
     4.96 on #375673, 10.98 on --navy, and 1.54 on white, so it is as unusable on a light surface as
     its darker sibling and cannot be reached for by mistake. */
  --muted-on-mid: #c9d1d9;

  /* Tier ramp for variance and severity. The three used to sit at 5.13, 3.79 and
     5.62 against white, so the middle tier read as retreating whatever it meant.
     They are now within half a point of each other and all clear 4.5 on both
     surfaces, which leaves position in the ramp — not weight of colour — doing
     the talking. */
  --tier-good: #2e7d32;        /* 5.13 / 4.83 */
  --tier-watch: #a75700;       /* 5.24 / 4.93 */
  --tier-bad: #c62828;         /* 5.62 / 5.29 */

  /* --- Lines ---------------------------------------------------------------
     Split on purpose. WCAG 1.4.11 asks 3:1 of a boundary that tells you a
     component is there, not of every hairline: a row divider is decoration and
     stays at --rule, an input's edge is the only thing saying where the field is
     and gets --border-control. #c3cbd4, the obvious middle, is 1.64 and does not
     clear the bar either. */
  --rule: #dde2e8;             /* 1.30 — decorative dividers only */
  --rule-light: #eef0f3;
  --border-control: #7d8996;   /* 3.56 / 3.35 — inputs, selects, textareas */

  /* --- Focus ---------------------------------------------------------------
     Not green. A focus indicator needs 3:1 against the component and the page
     both, and --green is 2.97 on --bg — an outline nobody can see around a green
     button is how you end up with 57 `outline:none` and no replacement. */
  --focus-ring: #375673;       /* 7.66 / 7.21 on white / --bg */
  /* The second tone. No single colour can be a 3:1 indicator on both a white card and this
     platform's navy shells: clearing 3:1 against #fff caps luminance at 0.300, and clearing it
     against po/index.html's #375673 sidebar needs at least 0.361. That is an empty window, not a
     colour nobody found. So the ring is two-tone — a dark band with a light band either side of
     it — and one of the two always has 3:1 against whatever it lands on. White is 7.66 against
     --focus-ring and 5.89 against the darkest shell in the repository. */
  --focus-ring-contrast: #ffffff;
  /* The fill behind a focused row. Low enough alpha to leave the row's own text contrast alone —
     it composites to about #eef1f5 over white, which is 1.06:1 against the page and changes no
     ratio that matters. */
  --focus-wash: rgba(55, 86, 115, 0.07);

  /* --- Type ----------------------------------------------------------------
     rem, not px, so a browser's text-size setting reflows the layout instead of
     magnifying it. 12px is the floor for a label and 14px for anything read as a
     sentence; --fs-display is where green display type becomes legal again. */
  --fs-micro: 0.75rem;         /* 12px — the floor, labels only */
  --fs-label: 0.8125rem;       /* 13px */
  --fs-body: 0.875rem;         /* 14px — body copy floor */
  --fs-base: 1rem;             /* 16px */
  --fs-lg: 1.125rem;           /* 18px */
  --fs-xl: 1.25rem;            /* 20px */
  --fs-display: 1.5rem;        /* 24px — ≥ this, --green may set colour */

  --lh-body: 1.5;
  --lh-heading: 1.25;          /* headings wrap once; body copy never goes below 1.4 */
  --lh-min: 1.4;               /* the floor min-line-height enforces */

  /* Every stack in the repository ends at `sans-serif`, so a proxy that blocks
     fonts.googleapis.com drops Barlow Condensed to Arial — a normal-width face
     in a layout measured for a condensed one, which overflows the labels rather
     than degrading. Arial Narrow and system-ui are the fallbacks that keep the
     metrics close. */
  --font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-display: 'Barlow Condensed', 'Arial Narrow', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  /* --- Space and shape ----------------------------------------------------- */
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;
  --radius-sm: 2px;
  --radius: 4px;
  --radius-lg: 8px;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.06);

  /* The smallest thing a finger can hit reliably. WCAG 2.5.5 asks 44px; the one
     control in the repository that already gets it right is vendor/form.html's
     .btn at padding:14px 40px. */
  --tap-min: 44px;
}

/* ============================================================================
   Migration aliases — a shim, not vocabulary.

   Nineteen pages declare their own :root and no two agree: --text-muted here,
   --muted there, --olive and --ink and --line in operations/, --red for what
   another page calls --danger. These map the old names onto the new values so a
   page can adopt by deleting its :root block, in a diff a reviewer can read.

   They are meant to be deleted. Every one of them is a page still spelling a
   token the old way; when the last page using a name is converted, the alias
   goes with it.

   Two are not straight renames and are the reason this list is commented:
     --border  was doing double duty as a row divider and an input edge. It maps
               to --rule, the decorative one, because that is the safe direction;
               the input rules an adopting page brings with it have to be moved
               to --border-control by hand, which is the point of doing this one
               page per commit.
     --green-dark #5f7e2b is 4.66 on white but 4.39 on --bg, so it fails the
               surface most of the text using it sits on. It maps to --green-text
               rather than to a darkened green nobody measured.
   ============================================================================ */

:root {
  --text-muted: var(--muted);
  --border: var(--rule);
  --border-light: var(--rule-light);
  --green-dark: var(--green-text);
  --red: var(--danger);
  /* No --red-light alias. It was a *lighter* red used for a hover fill, and mapping it onto
     --danger silently made hover identical to rest; a destructive button darkens instead. */
  --amber: var(--warn);
  --yellow: var(--warn);
  --blue: var(--bid-blue);
  --olive: var(--green);          /* operations/ */
  --oliveD: var(--green-text);    /* operations/ */
  --oliveL: var(--green-light);   /* operations/ */
  --ink: var(--text);             /* operations/ */
  --ink2: var(--navy-light);      /* operations/ */
  --ink3: var(--muted);           /* operations/ — was #7a8894, 4.02 on white */
  --ink4: var(--text-dim);        /* operations/ — was #a7b2bc, 2.07 on white */
  --line: var(--rule);            /* operations/ */
  --line2: var(--rule-light);     /* operations/ */
  --page: var(--bg);              /* operations/ */
  --rail: var(--navy);            /* operations/ */
  --mono: var(--font-mono);       /* operations/ */
  --disp: var(--font-display);    /* operations/ */
  --body: var(--font-body);       /* operations/ */
}

/* ============================================================================
   Sections below are anchors. Each one is filled by the task named against it,
   so that the rule and the pages it changes arrive in the same commit and a
   bisect lands on one of them. Adding a rule to an empty section early means
   shipping a change to every page that has adopted, ahead of the task that was
   supposed to check it.
   ============================================================================ */

/* --- Typography ------------------------------------------------------- P4-1 */

/* The repository had 1,818 declarations at 11px or below against about 490 at 16px or above, in a
   condensed face, much of it uppercase with letter-spacing out to 4px. The floor is 12px for a
   label and 14px for anything read as a sentence, and every size is in rem — which is what makes
   a browser's text-size setting reflow the layout instead of magnifying it. No page sets a root
   font-size, so 1rem is 16px and the conversion was exact.

   These are :where() so a page rule still wins, and they set only what was missing: a floor, not
   a design. Nothing here changes a size that a page states explicitly. */
:where(body) {
  font-family: var(--font-body);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

/* Read text keeps its leading. line-height 1 is defensible on a single-line numeric readout and
   nowhere else, and 37 rules had it. */
:where(p, li, td, th, dd, dt, label, .hint, .help, blockquote) {
  line-height: var(--lh-min);
}

/* A label column measured in ch or px stops fitting the moment its text grows — a longer word, a
   larger text-size setting, a translation. min-width lets it grow; width does not. This is the
   expensive half of any future i18n work and costs nothing to get right now. */
:where(.label-col, .form-label, dt, th[scope="row"]) {
  min-width: max-content;
}

/* --- Motion ---------------------------------------------------------- P1-4 */

/* Fifty-two infinite animations ran in this repository against zero occurrences of this
   query — nine drifting background images alone, on a 44-58s loop. A reader who has asked
   their operating system for reduced motion was being ignored by every page.

   Motion is neutralised rather than removed: duration goes to a value too short to perceive
   and iteration to one, so an animation that ends in a different state than it started still
   lands there. `animation: none` would freeze such an element mid-transition.

   !important, and the universal selector, because several animations are declared in inline
   style objects in JSX; a stylesheet rule without it loses to the attribute. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* --- Numerals -------------------------------------------------------- P1-5 */

/* Inter's figures are proportional by default — a 1 is narrower than a 7 — so a column of
   money does not line up and the decimal points wander. There are 127 toFixed calls feeding
   right-aligned columns in this repository and no tabular figures anywhere.

   Both spellings are given: font-variant-numeric is the property, font-feature-settings the
   lower-level switch it compiles to, and older WebKit honours only the second.

   inputs.html:3 already sets font-feature-settings:'cv02','cv03','cv04','cv11'. Those are
   letterform alternates — a single-storey a, a straight-legged l — and have nothing to do
   with figure spacing, which is why they did not help. */
.num,
td.num,
th.num,
.edit-input.num,
.mono,
[data-numeric] {
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

/* --- Focus ----------------------------------------------------------- P3-1 */

/* Ninety-three :focus rules existed in this repository and every one of them targeted an input,
   a select or a textarea. Not one targeted a button, a link, a tab or a row — so a keyboard user
   tabbing through a page could see where they were only while standing in a text field.

   :focus-visible, not :focus. That distinction is the whole reason this can land: the browser
   applies it to keyboard focus and withholds it from a mouse click, which is the objection that
   produced 57 `outline: none` declarations in the first place. Anyone deleting this rule because
   "the ring shows on click" should check that first — it does not.

   :where() holds the specificity at zero, so any page rule still wins without !important. The
   cost of that is the reverse: a page rule setting `outline: none` on :focus also wins, which is
   why P3-2 has to remove those rather than rely on this.

   The colour is not the brand green. WCAG 1.4.11 asks 3:1 of a focus indicator against both the
   component and the page, and --green is 2.97:1 on --bg — an invisible ring around a green
   button.

   The ring is TWO-TONE, and the earlier version of this comment was wrong about why one colour
   could serve. It claimed the 2px offset "puts it on the page rather than on the control, which
   is what makes one colour work on every background". The page is navy on nine shells here.
   Measured, --focus-ring alone was 2.21:1 on the navy sidebar, 1.83 on vendor/dashboard's, and
   1.00 — the same colour, no ring at all — on po/index.html's #375673. The arithmetic says no
   single colour can fix that: see --focus-ring-contrast.

   So the box-shadow paints a 6px band from the border box outward and the outline paints over
   the middle 2px of it, giving light / dark / light. On a white card the dark band carries the
   indicator; on a navy shell the light bands do.

   Twenty-two pages set box-shadow on `input:focus` at a specificity this rule cannot outrank, so
   those inputs lose the light bands and keep the outline. That is the case where the outline
   already works on its own — they are all inputs on light cards — which is why the ring is built
   this way round rather than putting the dark tone in the shadow. */
:where(a, button, input, select, textarea, summary, [role="button"], [role="tab"], [role="link"], [role="menuitem"], [tabindex]):focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  box-shadow: 0 0 0 6px var(--focus-ring-contrast);
  border-radius: 2px;
}

/* A row or a card that is clickable is reached by keyboard only if it has a tabindex, which the
   selector above already covers. This adds the fill, because a 2px ring around a full-width row
   is easy to miss. */
:where([tabindex], [role="button"]):focus-visible {
  background-color: var(--focus-wash);
}

/* --- Buttons and active state ---------------------------------------- P3-5 */

/* Two :active rules existed in the whole repository, one of them a `cursor: grabbing`. So a press
   produced no acknowledgement anywhere: on a slow connection the only evidence a button had been
   pressed was whatever happened next, which is how people end up clicking Submit twice.

   A press is a distinct signal from a hover, so this is not just "more hover". It moves — 1px
   down, which reads as depression — and darkens the surface slightly. `filter: brightness` rather
   than a colour, because it composites over whatever fill the control already has: a green
   button, a navy one and a white one all darken by the same proportion without this file needing
   to know any of their colours, and the label's contrast ratio moves with the background rather
   than against it.

   transform is the reason the reduced-motion block above matters here: it is a transition, and a
   reader who asked for less motion gets the brightness without the movement. */
:where(button, [role="button"], .btn, .act-btn, summary):active {
  transform: translateY(1px);
  filter: brightness(0.94);
}

/* A clickable row cannot move — a table would jump — so it takes the wash instead, at a stronger
   alpha than the focus one so a press on an already-focused row still registers. */
:where(tr[tabindex], tr[role="button"], .doc-item, li[tabindex]):active {
  background-color: rgba(55, 86, 115, 0.12);
}

/* A control that cannot act does not pretend to. */
:where(button, [role="button"], .btn):disabled:active,
:where(button, [role="button"], .btn)[aria-disabled="true"]:active {
  transform: none;
  filter: none;
}

/* --- Touch targets --------------------------------------------------- P3-4 */

/* WCAG 2.5.5 asks 44px of anything a finger has to hit. The controls in this repository run from
   `.btn-del` at roughly 12x16px through 15px checkboxes to `.doc-item` at 21px tall; the one that
   already gets it right is vendor/form.html's .btn at padding:14px 40px.
                        
   Inside (pointer: coarse) rather than everywhere, deliberately. These are dense data screens —
   an estimate grid, a staffing table — and 44px rows on a mouse-driven desktop would cost a third
   of the visible data to fix a problem a mouse does not have. The query asks the *input device*,
   not the viewport width, so a touchscreen laptop gets the larger targets and a small window on a
   desktop does not.
                        
   min-height and min-width rather than height: a control with more content than that grows, and
   one with less stops shrinking. inline-flex plus centring keeps a short label from sitting at
   the top of a suddenly taller button. The checkbox and radio cases need the explicit width and
   height because a native control ignores min-* in several browsers. */
@media (pointer: coarse) {
  :where(button, [role="button"], .btn, .act-btn, .doc-item, summary, a.nav-link) {
    min-height: var(--tap-min);
    min-width: var(--tap-min);
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /* A link inside a sentence must not become a 44px block — only stand-alone ones are targets. */
  :where(td, th) :where(button, [role="button"]) {
    min-width: 0;
    /* ...and the same 24px the inputs beside them take. This rule used to drop only the width, so a
       button in a grid cell still carried a 44px min-height and a hundred-row estimate grid still
       became three screens on a tablet — which is the exact cost the cell exception exists to
       avoid, half-applied. */
    min-height: 24px;
  }

  /* The confirm dialog's own buttons. `.dei-dialog-cancel` is specificity (0,1,0) and the rule
     above is (0,0,0) by design, so they stayed at their 36px min-height on touch — and these are
     the two buttons where a mis-tap costs the most, since one of them is usually Delete. */
  .dei-dialog-cancel,
  .dei-dialog-accept {
    min-height: var(--tap-min);
  }

  :where(input[type="checkbox"], input[type="radio"]) {
    width: 24px;
    height: 24px;
  }

  /* The label is part of the target: 24px of box plus the text beside it clears 44 vertically
     without making the box itself cartoonish. */
  :where(label):has(> :where(input[type="checkbox"], input[type="radio"])) {
    min-height: var(--tap-min);
    display: inline-flex;
    align-items: center;
    gap: var(--sp-2);
  }

  /* Checkbox and radio are excluded, and that exclusion is the whole point of the :not() pair.
     They are sized explicitly above because a native control ignores min-* in several browsers —
     but min-height is not ignored, so a checkbox caught by this rule gets 44px of height while a
     page rule the :where() above cannot outrank keeps its width. vendor/form.html's
     `.checkbox-group input[type="checkbox"]{width:15px}` is specificity (0,2,1) against this
     file's (0,0,0), and the result rendered as a 15x44 sliver on every touch device.
     engineering-equipment-selector.html was 24x44 for the same reason.
     The label wrapping the box carries the 44px target instead — see the :has() rule above. */
  :where(input:not([type="checkbox"]):not([type="radio"]), select, textarea) {
    min-height: var(--tap-min);
  }

  /* A cell in a data grid is the one place 44px is the wrong number. WCAG has two target sizes:
     2.5.5 asks 44px at AAA, and 2.5.8 asks 24px at AA. An estimate grid is a hundred cells deep,
     and 44px each turns one screen into three on a tablet — so the cells take the AA floor of
     24px and the standalone controls above keep the AAA one. That is a deliberate choice of the
     lower conformance level for one context, not an oversight. */
  :where(td, th) :where(input:not([type="checkbox"]):not([type="radio"]), select, textarea) {
    min-height: 24px;
  }
}

/* --- Inputs, hints, validation --------------------------------------- P5-3 */

/* The entire validation vocabulary on this platform was one rule — `.form-input.error{
   border-color:var(--red)}` on hr.html — which tells a reader that something is wrong with one
   field, in colour alone (1.4.1), and never what. There was no hint style at all: no `.hint`,
   `.help` or `.description` class existed anywhere, and 422 `placeholder` attributes were carrying
   that load — text which vanishes the moment somebody types, exactly when they need it.
                          
   Four pieces, and the field, its label, its hint and its error are one unit rather than four
   loose rules. Every input gets a boundary that clears 3:1 (1.4.11), which the 1.30:1 --rule
   never did. */

.field {
  display: grid;
  gap: 4px;
  margin-bottom: var(--sp-3);
}

:where(.field) label,
.field-label {
  font-size: var(--fs-label);
  font-weight: 600;
  color: var(--text);
  line-height: var(--lh-min);
}

/* The required marker. 169 `required` attributes exist and nothing marked any of them, so the only
   way to discover a field was required was to submit. The asterisk is generated content beside a
   word, and `aria-hidden` is unnecessary for it, but the *input* still needs its own `required`
   attribute — this is a visual cue, not the semantics. */
.field-label[data-required]::after,
.field-required::after {
  content: " *";
  color: var(--danger);
  font-weight: 700;
}

/* Persistent helper text. The distinction from a placeholder is the whole point: this is still
   there while the field is being filled in. */
.hint,
.field-hint {
  font-size: var(--fs-micro);
  color: var(--text-dim);
  line-height: var(--lh-min);
}

:where(input, select, textarea) {
  border: 1px solid var(--border-control);
  border-radius: var(--radius);
  font-family: inherit;
  font-size: var(--fs-body);
  color: var(--text);
  background: var(--card);
}

/* The error state: a border, a mark, and a message. The mark is a border-left rather than an icon
   font so it needs no glyph and survives a print in monochrome. */
:where(input, select, textarea).error,
:where(input, select, textarea)[aria-invalid="true"] {
  border-color: var(--danger);
  border-left-width: 3px;
  background: var(--danger-dim);
}

/* The message. `role="alert"` on the element in the markup is what announces it; this is only its
   appearance. The glyph is generated so the sentence a screen reader gets is the sentence, not a
   symbol read aloud in front of it. */
.field-error {
  display: flex;
  align-items: flex-start;
  gap: 0.4em;
  font-size: var(--fs-micro);
  font-weight: 600;
  color: var(--danger);
  line-height: var(--lh-min);
}

.field-error::before {
  content: "✕";
  font-weight: 700;
  flex-shrink: 0;
}

/* A field that is fine after having been wrong. Without this, correcting an error leaves the field
   looking exactly like one that was never touched, which is why people re-check the whole form. */
:where(input, select, textarea).valid,
:where(input, select, textarea)[aria-invalid="false"] {
  border-color: var(--success);
}

/* A form-level summary above the fields, for the case where the failure is not about one field —
   which is most submit failures. */
.form-error,
.form-notice {
  display: flex;
  align-items: flex-start;
  gap: var(--sp-2);
  padding: var(--sp-3);
  border: 1px solid var(--warn);
  border-left-width: 4px;
  border-radius: var(--radius);
  background: var(--warn-dim);
  color: var(--text);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  margin-bottom: var(--sp-4);
}

.form-error {
  border-color: var(--danger);
  background: var(--danger-dim);
}

/* --- Badges ---------------------------------------------------------- P5-1 */

/* The badges this replaces were 9px uppercase with padding:1px 5px — a 13px mark — and two of
   their four colours were 2.87:1 and 2.82:1, which at that size made "approved" and "pending"
   near-identical greys. Two separate failures: too small to read, and colour as the only carrier
   of meaning (1.4.1).
                     
   So a badge here has four things, and the tier decides all of them: a hue, a *weight*, a glyph,
   and — for the two tiers that mean something is wrong — a heavier border. Read in monochrome, or
   by someone who cannot distinguish the hues, the glyph and the weight still separate them.
                     
   The glyph is a ::before with content, not an image or an emoji: it inherits the text colour, it
   scales with the font-size, and it costs no request. aria-hidden is not needed because generated
   content is not in the accessibility tree — the badge's own text is what a screen reader reads,
   which is why every state has a word and not only a mark. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: 2px 8px;
  border: 1px solid transparent;
  border-radius: 3px;
  font-family: var(--font-body);
  font-size: var(--fs-micro);
  font-weight: 600;
  line-height: var(--lh-min);
  letter-spacing: 0.01em;
  white-space: nowrap;
  vertical-align: baseline;
}

.badge::before {
  font-weight: 700;
  /* The mark is decoration beside a word, so it must not be the thing that is read. */
  content: "";
}

.badge--neutral { background: var(--surface-alt); color: var(--muted); }
.badge--neutral::before { content: "–"; }

/* Not --bid-blue-dim: at 10% alpha that tint composites to #e9f1fa, where --bid-blue is 4.34:1
   — the tint was quietly eating the contrast the colour was chosen for. 6% keeps it at 4.56. */
.badge--info { background: rgba(31, 111, 208, 0.06); color: var(--bid-blue); }
.badge--info::before { content: "i"; }

.badge--success { background: var(--success-dim); color: var(--success); }
.badge--success::before { content: "✓"; }

/* Warning and critical carry more weight and a visible border, because these are the two a reader
   is being asked to act on. */
.badge--warning {
  background: var(--warn-dim);
  color: var(--warn);
  border-color: var(--warn);
  font-weight: 700;
}
.badge--warning::before { content: "!"; }

.badge--critical {
  background: var(--danger-dim);
  color: var(--danger);
  border-color: var(--danger);
  font-weight: 700;
}
.badge--critical::before { content: "✕"; }

/* The state that did not exist before, and the reason the whole enum does. A status the code does
   not recognise used to render as silent grey — indistinguishable from a deliberate neutral one —
   so rewording a status in the API turned a badge blank and nothing anywhere said so. An
   unrecognised value now looks unrecognised: dashed, questioned, and legible. */
.badge--unknown {
  background: var(--card);
  color: var(--text-dim);
  border-style: dashed;
  border-color: var(--border-control);
}
.badge--unknown::before { content: "?"; }

/* On a dark shell the tiers keep their glyphs and take the dark-surface hues. */
.badge--on-dark.badge--success { background: rgba(76, 196, 127, 0.14); color: var(--success-on-dark); }
.badge--on-dark.badge--warning { background: rgba(224, 169, 75, 0.14); color: var(--warn-on-dark); border-color: var(--warn-on-dark); }
.badge--on-dark.badge--info { background: rgba(106, 183, 245, 0.14); color: var(--info-on-dark); }
.badge--on-dark.badge--neutral { background: rgba(255, 255, 255, 0.08); color: var(--muted-on-dark); }

/* --- Toasts and dialog ----------------------------------------------- P5-4 */

/* The appearance of scripts/notify.js. The behaviour and the reasoning are in that file. */

.dei-notify-host {
  position: fixed;
  right: var(--sp-4);
  bottom: var(--sp-4);
  z-index: 9999;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--sp-2);
  /* The container must not swallow clicks on the page behind it; each toast takes them back. */
  pointer-events: none;
  max-width: min(420px, calc(100vw - 2 * var(--sp-4)));
}

/* Clipped, not hidden. `display: none` and the `hidden` attribute both take an element out of the
   accessibility tree, so a live region with either announces nothing at all. */
.dei-notify-live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.dei-toast {
  pointer-events: auto;
  align-items: flex-start;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-3);
  border-radius: var(--radius);
  box-shadow: 0 6px 24px rgba(8, 25, 39, 0.18);
  font-size: var(--fs-body);
  font-weight: 500;
  line-height: var(--lh-body);
  /* A message is a sentence, not a label: it wraps. */
  white-space: normal;
  text-align: left;
}

/* pre-line, not normal: these messages were written for confirm() and alert(), where a `\n\n`
   renders as a paragraph break, and P5-5's instruction was to move the copy verbatim. They are set
   with textContent, so without this the breaks in eight of them — "Delete this item?\n\n" and the
   list of failed uploads among them — collapsed into a single space and ran the sentences together.
   pre-line keeps the newlines and still collapses runs of spaces, so nothing else about the wrapping
   changes. */
.dei-toast-text { flex: 1; white-space: pre-line; }

.dei-toast-action,
.dei-toast-close {
  flex-shrink: 0;
  border: 1px solid currentColor;
  border-radius: var(--radius-sm);
  background: transparent;
  color: inherit;
  font: inherit;
  font-weight: 700;
  padding: 2px 8px;
  cursor: pointer;
}

.dei-toast-close {
  border-color: transparent;
  padding: 0 4px;
}

.dei-dialog {
  border: 1px solid var(--rule);
  border-top: 4px solid var(--warn);
  border-radius: var(--radius-lg);
  padding: var(--sp-5);
  max-width: min(480px, calc(100vw - 2 * var(--sp-4)));
  background: var(--card);
  color: var(--text);
  box-shadow: 0 12px 48px rgba(8, 25, 39, 0.24);
}

/* The tier is on the top edge, so the severity is visible before the sentence is read. */
.dei-dialog--critical { border-top-color: var(--danger); }
.dei-dialog--info { border-top-color: var(--bid-blue); }

.dei-dialog::backdrop { background: rgba(12, 30, 45, 0.45); }

.dei-dialog-text {
  font-size: var(--fs-base);
  line-height: var(--lh-body);
  margin-bottom: var(--sp-4);
  /* See .dei-toast-text: the confirm copy carries its own paragraph breaks. */
  white-space: pre-line;
}

.dei-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--sp-2);
}

.dei-dialog-cancel,
.dei-dialog-accept {
  min-height: 36px;
  padding: 8px 18px;
  border-radius: var(--radius);
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  cursor: pointer;
}

.dei-dialog-cancel {
  border: 1px solid var(--border-control);
  background: var(--card);
  color: var(--text);
}

/* The destructive answer carries the danger colour and a navy label — white on --danger is 5.44:1
   and fine, but the pattern across this platform is a dark label on a saturated fill. */
.dei-dialog-accept {
  border: 1px solid var(--danger);
  background: var(--danger);
  color: #fff;
}

.dei-dialog--info .dei-dialog-accept {
  border-color: var(--green);
  background: var(--green);
  color: var(--on-green);
}

/* --- Disabled and read-only ------------------------------------------ P5-6 */

/* `.btn:disabled{opacity:.5}` was the entire vocabulary for three different situations, and on the
   green button it lands at about 1.8:1 — a label nobody can read, which is a poor way to say "you
   cannot press this". Worse, it says the same thing about all three:
                        
     disabled       the action is unavailable *right now*, and will be later — nothing entered
                    yet, a save already in flight.
     read-only      the value is real and worth reading, and this reader cannot change it.
     not-permitted  the action exists and this account may not take it. The reason is a person's
                    role, not the state of the form, so it is the one that most needs saying.
                        
   None of the three uses opacity. Opacity multiplies against the background, so it dims the label
   and the border together and takes the contrast with it; these change the fill and keep the text
   above 4.5:1. */

:where(button, .btn, .act-btn, [role="button"]):disabled,
:where(button, .btn, .act-btn, [role="button"])[aria-disabled="true"] {
  background: var(--surface-alt);
  border-color: var(--rule);
  /* 5.17:1 on --surface-alt: unmistakably inactive, still legible. */
  color: var(--muted);
  cursor: not-allowed;
  box-shadow: none;
}

/* A read-only field is not a disabled one: its value is worth reading, and it must stay
   selectable and reachable by keyboard, which is why this styles [readonly] rather than
   [disabled] and leaves the text at full contrast. */
:where(input, select, textarea)[readonly],
.is-readonly {
  background: var(--surface-alt);
  border-color: var(--rule);
  border-style: dashed;
  color: var(--text);
  cursor: default;
}

/* Not permitted: available to somebody, not to this account. The lock is generated content so it
   needs no image, and the *reason* is required — see the assertion in tests/states.test.mjs —
   because "you cannot do this" without "because" is what generates support tickets. */
.not-permitted {
  background: var(--surface-alt);
  border: 1px dashed var(--border-control);
  color: var(--muted);
  cursor: not-allowed;
}

.not-permitted::before {
  content: "🔒";
  margin-right: 0.4em;
  /* Kept small: it is a mark beside a label, not an illustration. */
  font-size: 0.9em;
}

/* The reason, on hover *and* on focus. A tooltip that only answers a mouse tells a keyboard user
   nothing, and title= is unreachable by touch entirely — so the reason is a sibling element that
   the control points at with aria-describedby, and this is what reveals it. */
.state-reason {
  display: block;
  font-size: var(--fs-micro);
  color: var(--muted);
  line-height: var(--lh-min);
  margin-top: 2px;
}

:where(.not-permitted, [aria-disabled="true"], :disabled) + .state-reason {
  /* Always present, never only-on-hover: the reason a control is unavailable is not a detail. */
  visibility: visible;
}

/* --- Viewport shells -------------------------------------------------- P6-2 */

/* Five app shells size themselves at exactly the viewport height and then set overflow:hidden on
   the same element — `.app{display:flex;height:100vh;overflow:hidden}` and four like it.
                       
   On iOS Safari 100vh is *taller* than the visible area: it excludes the browser chrome. So the last
   row of such a shell sits underneath the toolbar, and because the same element forbids scrolling
   there is no way to reach it. The content is not awkward to get to; it is unreachable.
                       
   Every `height: 100vh` in the repository now has a `100dvh` line immediately after it — dvh is the
   viewport as it actually is, moment to moment — with the vh line kept first so a browser that does
   not know dvh keeps today's behaviour rather than falling back to `auto`.
                       
   The overflow is the other half, and it has to be conditional rather than deleted: these shells are
   three-pane desktop layouts where each pane scrolls independently, and unpinning them everywhere
   would break that. Below the drawer's breakpoint the panes stack, so the page itself is what
   scrolls. */
@media (max-width: 900px) {
  :where(body, .app, .opShell, .shell) {
    overflow: visible;
    /* A shell that stacks cannot also be exactly one screen tall. */
    height: auto;
    min-height: 100dvh;
  }
}

/* --- Navigation drawer ----------------------------------------------- P6-1 */

/* The pattern being replaced deletes the navigation and puts nothing in its place:
   `@media(max-width:768px){.sidebar{display:none}}` in five files and a sixth at 1100px. On a phone
   the whole app becomes one screen with no way to reach any other, and 75 files contained no
   hamburger, drawer or toggle of any kind.
                        
   This is the one drawer. A nav marked `data-drawer` slides in from the left below the breakpoint
   instead of vanishing; scripts/drawer.js adds the toggle, the scrim, Escape, and the focus
   handling, and its reasoning lives there.
                        
   Two structural decisions:
                        
   `transform` rather than `display` for the open and closed states, because a display:none element
   is not in the accessibility tree and cannot be reached, focused or announced — which is what made
   the original pattern a dead end rather than a small bug. Off-canvas, it is still there.
                        
   `visibility` alongside the transform, so a closed drawer is not a tab stop. Transform alone leaves
   its links focusable off-screen, which sends a keyboard user somewhere they cannot see. */

.drawer-toggle {
  display: none;
  align-items: center;
  gap: 0.5em;
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  padding: 0 var(--sp-3);
  border: 1px solid var(--border-control);
  border-radius: var(--radius);
  background: var(--card);
  color: var(--text);
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  cursor: pointer;
}

.drawer-scrim {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 900;
  background: rgba(12, 30, 45, 0.5);
}

@media (max-width: 900px) {
  .drawer-toggle { display: inline-flex; }

  [data-drawer] {
    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 901;
    width: min(300px, 86vw);
    max-width: 86vw;
    /* dvh, not vh: on iOS Safari 100vh is taller than the visible area, so the foot of a drawer
       sits under the browser chrome. See P6-2. */
    height: 100dvh;
    overflow-y: auto;
    transform: translateX(-100%);
    /* Not a tab stop while it is off-screen. */
    visibility: hidden;
    transition: transform 0.2s ease, visibility 0.2s;
    box-shadow: 4px 0 24px rgba(8, 25, 39, 0.28);
  }

  [data-drawer][data-drawer-open] {
    transform: translateX(0);
    visibility: visible;
  }

  [data-drawer-open] ~ .drawer-scrim,
  .drawer-scrim[data-drawer-open] { display: block; }

  /* A drawer that opens must not leave the page behind it scrolling under a finger. */
  body[data-drawer-locked] { overflow: hidden; }
}

/* Above the breakpoint the drawer is simply the sidebar it always was: no fixed position, no
   transform, no scrim, and the toggle is gone. Stated rather than left implicit, because the
   properties above are the kind that leak upwards when somebody adds a rule later. */
@media (min-width: 901px) {
  [data-drawer] {
    position: static;
    transform: none;
    visibility: visible;
    height: auto;
  }
  .drawer-scrim { display: none; }
}

/* --- Responsive grid (rs-*) ------------------------------------------ P6-3 */

/* Lifted verbatim from preconstruction's theme/global.css, comments and all, because it is the one
   place in this repository where a set of breakpoints was derived from real column minimums and the
   arithmetic was written down. Twenty-one pages had no width query at all; inventing a second
   system for them would leave two, neither of which anybody trusts.
                        
   The classes are opt-in markers, so lifting them here changes nothing on a page until that page
   asks. global.css keeps its copy for now: preconstruction loads both files, and deleting a
   working layer from a bundled app to prove a point about tidiness is not a trade worth making
   in the same change that moves it. */

/* Narrow-viewport layout. Every panel in this app sets its own columns inline, so
   below the breakpoint the side-by-side layouts squeeze each column to a few
   characters wide instead of dropping one — company names ellipsis away and the
   controls in a panel header overlap. These classes let a layout say what it should
   do with the space it no longer has:
     rs-stack      panels flow down the page, one per row
     rs-stack-wide the same, at the width this grid's own columns stop fitting,
                   for the few whose declared minimums add up past 1080px
     rs-fit        a fixed column count becomes as many columns as fit
     rs-keep       stays multi-column at every width — for row grids whose columns
                   line up with a header, where stacking would break the alignment
                   the row depends on. A marker only, with no rule of its own.
     rs-divider    a child whose right border is a divider against the column beside
                   it, rather than its own edge, so it loses that border once stacked
   !important because an inline grid-template-columns otherwise outranks a class.
   1080px is where the two-panel layouts stop working: below it the 200px nav plus
   two 16px gutters leave each panel under ~400px. */
/* `screen and` matters: a printed page is ~816px wide, so an unqualified max-width
   would restack the RFI and submittal forms on their way to the printer. */
/* A grid whose own minimums exceed what 1080px leaves it overflows in the band
   between the two, so it stacks at its own width instead. The RFP bidder desk is
   the only one: minmax(440px,…) + minmax(680px,…) + a 22px gap + 20px of padding is
   an 1182px floor, and the chrome around it — 200px nav, 48px of scroller padding,
   the card border, a scrollbar on the platforms that reserve one — takes ~265px
   more. 1480px clears that with room to spare rather than sitting on the seam. */
@media screen and (max-width:1480px){
  .rs-stack-wide{grid-template-columns:minmax(0,1fr) !important}
  .rs-stack-wide>*{min-width:0}
  .rs-stack-wide>.rs-divider{border-right:none !important}
}
@media screen and (max-width:1080px){
  .rs-stack{grid-template-columns:minmax(0,1fr) !important}
  /* Each grid's declared floor is what it says its columns need; reusing it keeps a
     6-up row of 120px tiles from wrapping at the width a 150px default would. */
  .rs-fit{grid-template-columns:repeat(auto-fit,minmax(var(--rs-fit-min,150px),1fr)) !important}
  /* min-width:0 so a wide child (a table, a long unbroken string) scrolls inside
     its panel instead of stretching the column back out. Dropping the right border
     is only right for a child using it as a divider — most stacked children are
     fully bordered cards, and a blanket rule left them open on one side. */
  .rs-stack>*{min-width:0}
  .rs-stack>.rs-divider{border-right:none !important}
}


/* --- Print ----------------------------------------------------------- P6-4 */

/* Eight of 43 pages had print styles, and none of them were the pages people print: pmo/,
   inputs.html, staffing.html, resource-tracker.html and contracts/ all had nothing. The one correct
   print block in the repository is operations/index.html:377 — it hides the chrome, unclamps the
   max-heights, avoids breaking inside a card and unsticks the sticky headers — so this is lifted
   from it rather than written fresh, generalised from that page's class names to the ones the rest
   of the platform uses.
                    
   The four additions past what it does are all things that only matter on paper:
                    
   `thead { display: table-header-group }` repeats a table's header on every page. Without it a
   twelve-page schedule has column headings on page one and anonymous numbers on the other eleven.
                    
   `break-inside: avoid` on rows and cards, so a line item does not straddle a page break.
                    
   A link's href printed after it, because a printed page cannot be clicked and "see the dashboard"
   is useless in a folder.
                    
   And `border-color: #767676` on everything. --rule is #dde2e8, which is 1.30:1 on screen and
   simply does not appear on paper — every table this platform prints came out as columns of numbers
   with no visible grid. #767676 is the lightest grey that survives a laser printer.
                    
   Not qualified with `screen and` anywhere, obviously — but note the converse rule, which the
   responsive queries above all follow: a max-width query without `screen and` applies to the
   printed page, which is about 816px wide, and restacks a form on its way to the printer. */
@media print {
  /* A dark shell prints as a solid block of toner and nothing else. */
  body {
    background: #fff !important;
    color: #000 !important;
  }

  /* Everything that exists to navigate or act, which is nothing on paper. */
  :where(nav, aside, .sidebar, .left-nav, .opSidebar, .opAiRail, .drawer-toggle, .drawer-scrim,
         .dei-notify-host, .tabs, .toolbar, .actions, .filters, .pagination, .btn, button,
         [role="button"], .search-wrap, .input-bar) {
    display: none !important;
  }

  /* A layout built for three panes on a screen becomes one column of paper. */
  :where(.app, .shell, .opShell, .main, .opMain, .content, .opContent, .card, .panel, .est-card) {
    display: block !important;
    width: 100% !important;
    max-width: none !important;
    margin: 0 !important;
    padding: 0 !important;
    border: 0 !important;
    box-shadow: none !important;
    overflow: visible !important;
  }

  /* A scroller on screen is a truncation on paper: whatever did not fit is simply missing. */
  :where([class*="scroll"], [class*="Wrap"], .table-wrap, .tab-content, .doc-list) {
    max-height: none !important;
    overflow: visible !important;
  }

  /* The header on every page, not only the first. */
  thead { display: table-header-group; }
  tfoot { display: table-footer-group; }

  /* Nothing straddles a page break if it can be helped. */
  tr, .card, .panel, .est-card, .badge, li { break-inside: avoid; }
  h1, h2, h3 { break-after: avoid; }

  /* A sticky header on paper prints once, at the top, over the content it was covering. */
  :where(th, .sticky, [style*="sticky"]) { position: static !important; }

  /* An input's value is the only part of it that means anything printed. */
  :where(input, select, textarea) {
    border: 0 !important;
    background: transparent !important;
    padding: 0 !important;
    /* A textarea that scrolls on screen truncates on paper otherwise. */
    height: auto !important;
    resize: none !important;
  }

  /* #dde2e8 does not print. Every table this platform prints came out as numbers with no grid. */
  * { border-color: #767676 !important; }

  a[href^="http"]::after {
    content: " (" attr(href) ")";
    font-size: 9pt;
    word-break: break-all;
  }

  /* An internal link's href is a path, which tells a reader on paper nothing. */
  a[href^="/"]::after, a[href^="#"]::after { content: ""; }
}

/* --- Derived and entered values --------------------------------------- P7-1 */

/* There are 128 toFixed calls in this repository and every one of them renders looking exactly like
   an input. inputs.html gives four classes — .edit-input, .num, .small, .tiny — all of which look
   editable, and no read-only or derived variant at all, so a figure the platform calculated is
   indistinguishable from one somebody typed. On a reconciliation screen that is the difference
   between "this number is wrong" and "this number is wrong *and I can fix it here*".
                        
   Two treatments, and the distinction is deliberately visible without colour: an entered value sits
   in a box, and a derived one does not. Colour separates them further, but the *box* is the signal,
   because a printed page and a monochrome display both keep it.
                        
   The affordance for full precision is the third piece and the reason this matters at all: rounding
   is where reconciliation breaks. A figure shown as 1,234.57 that is really 1,234.5749 explains a
   penny of variance nobody can otherwise find, so a derived value can carry its unrounded form in
   `data-exact` and hand it over on demand — a title for a mouse, and a marker for everyone else,
   since title= answers nothing but a pointer. */

.value-derived {
  /* No border and no fill: this is a result, not a field. */
  border: 0;
  background: transparent;
  padding: 0;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  text-align: right;
  color: var(--text);
  /* Slightly heavier than an entered figure, because a total is the thing being read. */
  font-weight: 600;
}

/* A derived value that carries its full precision says so, rather than leaving a reader to wonder
   whether the two pence are missing or merely hidden. The mark is generated content beside the
   number and is not read aloud in place of it. */
.value-derived[data-exact] {
  border-bottom: 1px dotted var(--border-control);
  cursor: help;
}

.value-derived[data-exact]::after {
  content: "≈";
  margin-left: 0.25em;
  color: var(--text-dim);
  font-weight: 400;
}

/* An entered value keeps its box. Stated here rather than left to each page, because the pairing is
   the point: derived has no box *because* entered does. */
.value-entered {
  border: 1px solid var(--border-control);
  border-radius: var(--radius);
  background: var(--card);
  padding: 4px 8px;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  text-align: right;
}

/* --- Negative numbers -------------------------------------------------- P7-2 */

/* No convention existed. A negative was red and nothing else, which is colour carrying the whole
   meaning (1.4.1) and invisible in a monochrome print — and red is also how this platform marks an
   error, so a legitimately negative variance read as a fault.
                        
   Accounting parentheses are the convention finance already reads: (1,234.56) is negative, and it
   survives colour blindness, a photocopier and a screen reader, which says "left paren". The colour
   stays as reinforcement, at 5.44:1 rather than decoration. */
.value-negative {
  color: var(--danger);
}

.value-negative::before { content: "("; }
.value-negative::after { content: ")"; }

/* A negative that is expected — a credit, a refund, a downward correction that is good news —
   takes the parentheses without the alarm. */
.value-negative.is-expected {
  color: var(--text);
}

/* --- Comparison layout ------------------------------------------- P7-3 */

/* Four separate requests turned out to be one missing pattern: bank reconciliation,
   invoice-to-purchase-order three-way match, budget variance, and duplicate review. Each is two or
   three columns of the same shape, with matched and unmatched rows marked, discrepancies
   highlighted, and a tolerance beyond which a difference stops being noise.
                        
   Built once here. The variance colours are the corrected tier ramp from P2-1, which sits within
   half a point of contrast across all three tiers so position in the ramp carries the meaning
   rather than the weight of the colour. */

.compare {
  display: grid;
  /* auto-fit rather than a fixed count: the same markup serves a two-way and a three-way match. */
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--sp-3);
  align-items: start;
}

.compare-side {
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--card);
  padding: var(--sp-3);
}

.compare-side-label {
  font-family: var(--font-display);
  font-size: var(--fs-label);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: var(--sp-2);
}

/* A row's state, never by colour alone: each carries a mark and a border weight as well. */
.compare-row {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: var(--sp-2);
  align-items: baseline;
  padding: 4px 0;
  border-bottom: 1px solid var(--rule-light);
}

.compare-row::before {
  font-weight: 700;
  margin-right: 0.35em;
}

.compare-row.is-matched { color: var(--text); }
.compare-row.is-matched::before { content: "✓"; color: var(--tier-good); }

/* Within tolerance: a difference exists and is not worth acting on. Saying that is the whole
   purpose of a tolerance — otherwise every rounding penny reads as a discrepancy. */
.compare-row.is-within-tolerance::before { content: "≈"; color: var(--tier-watch); }

.compare-row.is-unmatched {
  border-bottom-color: var(--tier-bad);
  border-bottom-width: 2px;
  font-weight: 600;
}
.compare-row.is-unmatched::before { content: "✕"; color: var(--tier-bad); }

/* The one row a reader is looking for on a reconciliation screen. */
.compare-row.is-discrepancy {
  background: var(--danger-dim);
  padding-left: var(--sp-2);
  padding-right: var(--sp-2);
}

.compare-tolerance {
  font-size: var(--fs-micro);
  color: var(--text-dim);
  line-height: var(--lh-min);
  margin-top: var(--sp-2);
}

.compare-total {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: var(--sp-2);
  border-top: 2px solid var(--border-control);
  margin-top: var(--sp-2);
  padding-top: var(--sp-2);
  font-weight: 700;
}
