@layer base, layout, components;

/* Nunito is the legacy portal's typeface and the strongest recognition cue after
   the green. Self-hosted: Google Fonts is a third party and `default-src 'none'`
   blocks it, so serving the files ourselves costs one CSP directive
   (`font-src 'self'`) and no external request.

   One variable file per subset, weight axis 400-700, which is every weight this
   stylesheet asks for. `swap` because the fallback stack below is real and the
   page is readable before the font arrives. Licence: static/fonts/OFL.txt. */
@font-face {
  font-family: "Nunito";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/static/fonts/nunito-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Nunito";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/static/fonts/nunito-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@layer base {
  :root {
    /* Hue 160 is the legacy portal's brand green, #008556 =
       oklch(54.4% 0.123 160), measured off the live site. The stops are
       re-lightened rather than copied: --green-700 sits at 45% so white text
       on a button and the link colour on --surface both clear AA with
       headroom, where the legacy 54.4% only just reaches 4.6:1. --green-100 is
       the legacy mint header band, #d1fae5 = oklch(95% 0.051 163), rounded to
       the same hue as the rest of the ramp. --ok-bg tracks the ramp because
       the success flash is a green-700 border over that tint, not an
       independent status colour. */
    --green-900: oklch(30% 0.06 160);
    --green-700: oklch(45% 0.11 160);
    --green-100: oklch(95% 0.051 160);
    /* The one place the legacy green is used raw rather than re-lightened: the
       table header band, which is the legacy portal's other recognition cue
       after the mint header. White on it measures 4.67:1 — above AA for the
       0.85rem header row and the reason this stop is not pulled up to
       --green-700 with the rest of the ramp. It does not change in dark mode:
       the band is the brand, and the same white clears AA on either page. */
    --table-head: oklch(54.4% 0.123 160); /* #008556 */
    --table-head-ink: oklch(100% 0 0);
    --ink: oklch(22% 0.01 250);
    --ink-muted: oklch(50% 0.01 250);
    --surface: oklch(99% 0.004 250);
    --surface-raised: oklch(100% 0 0);
    --line: oklch(88% 0.008 250);
    /* --line draws containers; this draws the edge of something you can type
       in. WCAG 2.2 SC 1.4.11 asks 3:1 of the boundary that identifies a
       control, and --line is 1.4:1 against the page — ample for a card, not for
       the only thing telling a reader where a field begins. 62% clears 3:1
       against both the field's own fill and the card behind it, measured rather
       than picked. Keep the two apart: lifting --line itself would drag every
       table rule and card edge up with it. */
    --control-line: oklch(62% 0.01 250);
    --danger: oklch(48% 0.17 25);
    --danger-bg: oklch(96% 0.03 25);
    --ok-bg: oklch(95% 0.05 160);

    /* Chart palette. The three categorical slots are the validated default
       categorical order, checked with the data-viz validator against the
       surfaces they actually render on (--surface-raised): both modes pass the
       lightness band, chroma floor, all-pairs CVD separation and the
       normal-vision floor. Slot 3 sits below 3:1 on the light surface, which is
       why every chart ships visible value labels and a table view.

       --series-single is the brand green, used wherever there is one series and
       identity is not being encoded. */
    --series-1: #2a78d6;
    --series-2: #eb6834;
    --series-3: #1baf7a;
    --series-single: var(--green-700);
    --chart-grid: var(--line);

    /* Status colours are reserved: they never stand in for a series. On the
       light surface warning is below 3:1 against white by design — every use
       ships a written label beside it ("on track" / "behind"), so the colour
       is never the only thing carrying the meaning. */
    --status-good: #0ca30c;
    --status-warning: #fab219;
    --meter-track: var(--green-100);

    /* The logo is a fixed-palette raster whose dark-green strokes were drawn
       for a white page. On a dark surface they all but vanish, so the mark
       keeps its own light plate rather than being filtered — a filter would
       shift the brand greens. In light mode the plate is transparent and the
       mark sits directly on the header. */
    --logo-plate: transparent;

    /* The legacy portal's signed-out page: a pale mint field (#ECFDF5) with the
       white login card on top of it, under the darker mint header band. The two
       greens are a step apart on purpose — the band is the header, the field is
       the page. */
    --auth-bg: oklch(97.5% 0.028 163);

    --space: clamp(1rem, 0.6rem + 1.2vw, 1.75rem);
    /* The inner padding of a card-sized surface, and deliberately a step below
       --space: the page gutter and the padding inside a tile reading the same
       size flattens the difference between the page and the things on it. Fluid
       for the same reason --space is, so the two keep their proportion as the
       viewport grows rather than converging at one end of the range. */
    --pad: clamp(0.875rem, 0.7rem + 0.6vw, 1.25rem);
    /* The distance from one section of a page to the next, and the only step
       above --space. A page whose sections are the same distance apart as the
       things inside them reads as one undifferentiated ribbon, which is what
       the dashboard was: eight headings down a column, none of them grouped.
       Fluid like the two above so the three keep their proportion. */
    --space-section: clamp(2rem, 1.4rem + 2vw, 3rem);
    /* The header's height is the mark plus its padding, and the sticky things
       below it are offset by exactly that. Two numbers rather than one measured
       total, so the compact state can change either and the offset follows:
       --header-h is declared on body, not here, because a custom property
       substitutes the values of the element it is declared on — put the calc on
       :root and body.header-compact could never move it. */
    --header-logo: 3rem;
    --header-pad: 0.75rem;

    --radius: 6px;
    --measure: 68ch;
    /* The one duration and the one curve. Both were already in the file, typed
       out twice; naming them is what stops the third and fourth copy drifting.
       An exponential ease-out — fast to start, settling rather than arriving —
       and short enough that someone filling a form does not wait for it. */
    --dur: 160ms;
    --ease: cubic-bezier(0.22, 1, 0.36, 1);
    /* The mark's plate is padding, and padding insets the ink whether or not
       the plate is painted. Naming it lets the header pull the mark back out by
       exactly as much, so the logo starts where the page content below it
       starts. */
    --logo-plate-pad: 0.3rem;

    color-scheme: light dark;
  }

  @media (prefers-color-scheme: dark) {
    :root {
      --green-900: oklch(88% 0.06 160);
      --green-700: oklch(78% 0.12 160);
      --green-100: oklch(28% 0.04 160);
      --ink: oklch(93% 0.01 250);
      --ink-muted: oklch(70% 0.01 250);
      --surface: oklch(18% 0.01 250);
      --surface-raised: oklch(22% 0.012 250);
      --line: oklch(34% 0.01 250);
      /* Lighter than the light theme's is darker: on this surface the boundary
         has to come up, not down. 54% clears 3:1 against both the field fill
         and --surface-raised. */
      --control-line: oklch(54% 0.01 250);
      --danger: oklch(72% 0.15 25);
      --danger-bg: oklch(28% 0.06 25);
      --ok-bg: oklch(28% 0.05 160);

      /* Barely-tinted white, so the plate reads as paper rather than a
         floating white rectangle. */
      --logo-plate: oklch(97% 0.012 160);

      /* The legacy portal has no dark mode. Its mint field would be a glare at
         night, so the dark theme keeps its own page colour and the card is what
         separates from it. */
      --auth-bg: oklch(18% 0.01 250);

      /* The same three hues re-stepped for the dark surface, not an automatic
         flip. Validated as a set against --surface-raised in dark mode. */
      --series-1: #3987e5;
      --series-2: #d95926;
      --series-3: #199e70;
    }
  }

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

  body {
    --header-h: calc(var(--header-logo) + var(--header-pad) * 2 + 1px);
    margin: 0;

    /* Scroll anchoring off, for the collapsing header above everything else.
       When the header gives up its height it does so *above* the viewport, and
       anchoring's job is to compensate — it moves the scroll position back by
       the same amount so the reader keeps their place. That correction arrives
       as a scroll event going the other way, which expands the header, which
       moves the position again: the header flickers.

       It was visible on the FSG side first, because that header wraps to two
       rows and gives up 63px where the client's gives up 28, so it cleared the
       hysteresis in app.js that was holding the client side together. A
       threshold was never the fix: how much the header gives up depends on how
       the nav wraps, which depends on the viewport and on how long the signed-in
       account's email is.

       Nothing else here depends on anchoring. Every page is server-rendered
       whole, the charts are inline SVG with their size already in the markup,
       and there is no lazily-loaded content above the fold to resize under
       someone. Nor does the in-place update lose anything: it replaces the
       region's children outright, which destroys whatever node anchoring had
       chosen, so it could never have compensated for that either. */
    overflow-anchor: none;
    background: var(--surface);
    color: var(--ink);
    font: 1rem/1.6 "Nunito", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    text-wrap: pretty;

    /* A column the height of the viewport, so the footer sits at the bottom of
       a short page instead of halfway up it, and so main has a height for the
       signed-out pages to centre within. */
    min-block-size: 100dvh;
    display: flex;
    flex-direction: column;
  }

  h1, h2, h3 { line-height: 1.2; text-wrap: balance; color: var(--green-900); }
  h1 { font-size: clamp(1.6rem, 1.3rem + 1.4vw, 2.25rem); margin-block: 0 var(--space); }
  h2 { font-size: 1.25rem; margin-block: calc(var(--space) * 1.5) 0.5rem; }

  a { color: var(--green-700); }
  a:hover { text-decoration-thickness: 2px; }

  /* The header is sticky, so anything jumped to by a fragment — #results on the
     entries screen, a section heading — would otherwise land underneath it. */
  :where([id]) { scroll-margin-block-start: calc(var(--header-h) + 0.5rem); }

  :focus-visible {
    outline: 3px solid var(--green-700);
    outline-offset: 2px;
  }

  code { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 0.9em; }
}

@layer layout {
  .wrap {
    max-width: 72rem;
    margin-inline: auto;
    padding-inline: var(--space);
  }

  main.wrap {
    padding-block: var(--space) calc(var(--space) * 2);
    flex: 1;
    width: 100%;
    /* Named so a navigation morphs it rather than cross-fading it with
       everything else; see the @view-transition rule at the foot of the file.
       A name has to be unique in a document — never put one on anything inside
       a range, or the browser silently skips the whole transition. */
    view-transition-name: page-content;
  }

  /* --- signed-out pages ---------------------------------------------------
     Sign in, forgotten password and set-a-new-password, laid out as the legacy
     portal lays out its login: a mint field, a narrow centred column, and a
     white card. base.html renders .signed-out-header only when there is no
     session, so :has() finds these pages without the templates needing a flag
     of their own.

     The column is centred; the text inside it is not. The legacy page is
     left-aligned throughout and a centred label sits a different distance from
     every field it names, which is harder to scan down.

     The column is 32.5rem, deliberately wider than the legacy page's. Bootstrap
     col-lg-4 comes to about 350px of content in their 1140px container, which
     is a narrow measure for the intro paragraph above the card. This is a
     departure from the reproduction, not a value drifting from it — leave it
     unless the wider card is what looks wrong. */
  body:has(.signed-out-header) { background: var(--auth-bg); }
  body:has(.signed-out-header) main {
    display: grid;
    justify-items: center;
    align-content: center;
  }
  body:has(.signed-out-header) main > * {
    inline-size: min(100%, 32.5rem);
  }

  .skip-link {
    position: absolute;
    inset-inline-start: -100vw;
  }
  .skip-link:focus {
    position: static;
    display: inline-block;
    padding: 0.5rem;
    background: var(--green-100);
  }

  /* The mint band is the legacy portal's most recognisable feature and the
     reason --green-100 exists. It carries recognition, not structure: at
     1.10:1 against the page it is far too close to separate the regions on its
     own, which was the legacy site's own weakness. The border below is what
     actually divides them, so it stays. */
  /* Sticky, and above the sticky table headers inside .table-scroll — those
     carry z-index 1 against their own scroll container and would otherwise
     ride over the band. */
  .site-header {
    position: sticky;
    inset-block-start: 0;
    z-index: 10;
    background: var(--green-100);
    border-block-end: 1px solid var(--line);
  }
  .site-header .wrap {
    display: flex;
    flex-wrap: wrap;
    /* --pad, not --space: this is the gap inside a component, and the page
       gutter reading as an internal gap is what made the brand and the nav
       drift apart as the viewport grew. */
    gap: var(--pad);
    align-items: center;
    padding-block: var(--header-pad);
    transition: padding-block var(--dur) var(--ease);
  }
  .brand {
    display: flex;
    align-items: center;
    font-weight: 600;
    color: var(--green-900);
    margin: 0;
  }

  /* The link is the whole lockup, so it carries the row rather than sitting
     inside it — otherwise the mark and the wordmark are two targets with a
     dead gap between them. It inherits the brand's colour and drops the
     underline: eight underlined nav links plus an underlined wordmark is one
     underline too many, and the mark already reads as the way home. */
  .brand > a {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    color: inherit;
    text-decoration: none;
  }
  .brand > a:hover span { text-decoration: underline; text-decoration-thickness: 2px; }

  /* width/height are on the element so the row does not reflow when the image
     arrives; these restore the intrinsic ratio while pinning the height.

     3rem is a floor, not a preference: the mark is a stacked lockup and the
     "shades greener" line under the 50 turns to mush below roughly 44px. */
  .logo {
    block-size: var(--header-logo);
    transition: block-size var(--dur) var(--ease);
    inline-size: auto;
    background: var(--logo-plate);
    border-radius: var(--radius);
    padding: 0.15rem var(--logo-plate-pad);
  }

  /* The plate is invisible in light mode but its padding still insets the ink,
     so the mark sat a plate's width to the right of where every heading on the
     page below it starts. Pulling it back by exactly the padding puts the two
     on the same line. In dark mode the plate bleeds that far into the gutter,
     which reads as deliberate where the misalignment did not.

     Only in .site-header: the signed-out header centres its mark, and a
     negative start margin there would push it off centre. */
  .site-header .brand .logo { margin-inline-start: calc(var(--logo-plate-pad) * -1); }

  /* The legacy portal's header: the mint band, the mark and the tagline,
     centred, with no navigation because there is nothing yet to navigate to.

     The band is --green-100 (the legacy #d1fae5) against the paler --auth-bg
     field below it, which is the separation the legacy page relies on; the
     border stays regardless, because 1.10:1 cannot carry it alone. */
  .signed-out-header {
    background: var(--green-100);
    border-block-end: 1px solid var(--line);
  }
  /* Narrow first: everything centred on as many rows as it needs. The link,
     where there is one, drops to its own row rather than squeezing the lockup. */
  .signed-out-header .wrap {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    padding-block: 0.75rem;
  }
  .signed-out-header .brand-lockup {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin: 0;
  }
  /* Wide enough for both on one row. The empty first column is what holds the
     lockup in the centre it has always had: space-between would put it at the
     left edge instead, which is a visible change to a header nobody asked to
     move. Five of the six signed-out pages name no link, and there the middle
     column is still all that is filled, so they render exactly as before. */
  @media (min-width: 46rem) {
    .signed-out-header .wrap {
      display: grid;
      grid-template-columns: 1fr auto 1fr;
    }
    .signed-out-header .brand-lockup { grid-column: 2; }
    .signed-out-header .header-link { grid-column: 3; justify-self: end; }
  }
  /* Colour comes from the base link rule. It is the pairing .brand-tagline
     already uses on this band, so the contrast is one that has been checked. */
  .header-link { font-weight: 600; }
  /* The legacy navbar renders this mark 34px tall, where the stacked lockup's
     "shades greener" line is unreadable. 2.75rem is the floor that keeps it
     legible and is still close to the original. */
  .signed-out-header .logo { block-size: 2.75rem; }
  .brand-tagline { font-weight: 600; color: var(--green-700); font-size: 1.5rem; }
  .site-header nav ul {
    display: flex;
    flex-wrap: wrap;
    /* Small, because the links now carry their own padding; the space between
       two labels comes out much as it was. */
    gap: 0.25rem;
    list-style: none;
    margin: 0;
    padding: 0;
  }

  /* The mark is 3rem and sets the header's height, so the links were bare 25px
     text floating in a 48px band with nothing for a pointer to aim at. Padding
     them to a real target costs no height at all — the mark still governs — and
     gives hover and focus somewhere to land.

     Undecorated at rest, which is the point: eight underlined links made the
     current page's underline one slightly thicker line among eight rather than
     the only one in the row. */
  .site-header nav a,
  .account a {
    display: block;
    padding: 0.6rem;
    border-radius: var(--radius);
    text-decoration: none;
    transition: background-color var(--dur) var(--ease);
  }
  .site-header nav a:hover,
  .account a:hover {
    background: var(--surface-raised);
    text-decoration: underline;
    text-underline-offset: 4px;
  }

  /* Where you are. Weight and an underline carry it, not colour alone: the FSG
     nav is eight links and colour would be the only cue for anyone who cannot
     separate these two greens. The underline is now the only one at rest, so it
     reads as a mark rather than as slightly more of what every link has. */
  .site-header nav [aria-current="page"] {
    font-weight: 600;
    text-decoration: underline;
    text-decoration-thickness: 2px;
    text-underline-offset: 4px;
  }

  /* Who you are and what you can do about it, gathered at the end of the row.
     Change password lived in the main nav, where an account setting sat at the
     same weight as Overview and Organisations while Sign out was already over
     here — the two halves of one thing in two places. */
  .account {
    margin-inline-start: auto;
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    align-items: center;
  }
  .signout { display: flex; align-items: center; }
  .who {
    color: var(--ink-muted);
    font-size: 0.9rem;
    padding-inline: 0.6rem;
  }

  /* --- the tiered (FSG) header ---------------------------------------------
     Two rows on purpose: the brand and the account on the first, the navigation
     across the second. Six links, a full name and two account links do not fit
     beside the brand at any width this is used at, and left to wrap they put
     the account on a line of its own with a band of empty header under the nav —
     three rows and about 60px of nothing on every page.

     A grid rather than wrapped flex because the break is a decision here, not
     an accident of how long somebody's name is. The client header keeps its
     single row; it is five links and it fits. */
  .site-header.tiered .wrap {
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: center;
    row-gap: 0.35rem;
  }
  .site-header.tiered .brand { grid-area: 1 / 1; }
  .site-header.tiered .account { grid-area: 1 / 2; justify-self: end; margin-inline-start: 0; }
  /* Full width, and pulled back by the link padding so the first label starts
     on the same line as the mark above it rather than a padding's width in. */
  .site-header.tiered nav[aria-label="Main"] {
    grid-area: 2 / 1 / 3 / -1;
    margin-inline-start: -0.6rem;
  }
  /* The second row is the navigation, so it carries no separate top padding. */
  .site-header.tiered .wrap { padding-block: var(--header-pad) 0.35rem; }

  /* --- compact header -----------------------------------------------------
     Scrolling down takes the navigation away and leaves the mark; scrolling up
     brings it back. The class comes from app.js because CSS cannot ask which
     way a page is going — a scroll timeline knows how far down it is and
     nothing else. With the script blocked the header is simply always full,
     which is what it was before, and it is still sticky.

     Two guards keep it off a keyboard user's path. :focus-within holds the
     header open while focus is inside it, and app.js restores it on Tab, so
     focus is never moved past a nav that is not there. */
  body.header-compact { --header-logo: 1.75rem; --header-pad: 0.5rem; }
  body.header-compact .site-header:not(:focus-within) nav { display: none; }

  /* One name across both headers. base.html renders exactly one of them, so the
     name stays unique in any given document, and sharing it is what makes
     signing in morph the mint band into the signed-in header rather than
     throwing one away and fading the other in. */
  .site-header,
  .signed-out-header { view-transition-name: site-header; }

  .site-footer {
    view-transition-name: site-footer;
    border-block-start: 1px solid var(--line);
    padding-block: var(--space);
    color: var(--ink-muted);
    font-size: 0.875rem;
  }

  /* Two lines that belong together — who this is, and the Annex II contact and
     support window beneath it. The paragraph default would set them a full em
     apart and read as two unrelated footers. */
  .site-footer p { margin-block: 0.25rem; }
}

@layer components {
  .lede { max-inline-size: var(--measure); color: var(--ink-muted); margin-block-start: 0; }
  .muted { color: var(--ink-muted); }
  /* A paragraph of it is prose and takes the measure the lede takes. Only a
     paragraph: .muted also marks cells and spans, which belong to their column
     and must not be narrowed. */
  p.muted { max-inline-size: var(--measure); }
  .hint { color: var(--ink-muted); font-size: 0.875rem; margin-block: 0.25rem 0; }
  .help { color: var(--ink-muted); font-size: 0.875rem; }

  .flash, .error {
    padding: var(--pad);
    border-radius: var(--radius);
    max-inline-size: var(--measure);
  }
  /* A tint and a full border, not a coloured edge down one side. The stripe sat
     on top of a background that already carried the same meaning, and a 4px
     accent on one edge is the house style of a framework rather than a decision
     anybody made here. */
  .flash { background: var(--ok-bg); border: 1px solid var(--green-700); }
  .error { background: var(--danger-bg); border: 1px solid var(--danger); color: var(--danger); }

  /* --- boxes ---------------------------------------------------------------
     Two ideas, each written once instead of seven times and nine.

     The outline is what makes a thing a box, and every box on these pages draws
     the same edge at the same radius. The raised surface is a second idea and
     not the same one: it says a box sits *above* the page. Two of the nine do
     not take it — a scrolling table and a target card keep the page's own
     colour, because each already sits inside something raised, and a card on a
     card is a card too many.

     These come first in the layer, so anything below with a reason to differ
     has only to say the one thing that differs. */
  .card,
  .auth-card,
  .queue-item,
  .stat,
  .summary,
  .chart-figure,
  .filter-bar,
  .table-scroll,
  .target {
    border: 1px solid var(--line);
    border-radius: var(--radius);
  }
  .card,
  .auth-card,
  .queue-item,
  .stat,
  .summary,
  .chart-figure,
  .filter-bar {
    background: var(--surface-raised);
  }

  .card {
    padding: var(--space);
    max-inline-size: var(--measure);
  }

  /* --- the login card -----------------------------------------------------
     Measured off the legacy portal so a returning user meets the page they
     know: the heading and intro sit above a white card, the card opens with
     "Login" beside the mark, and the fields are tall with the label carried by
     a placeholder. The values are the legacy ones — 3rem of padding, a 3.175rem
     field, 1.25rem text in it, a pill button the full width of the card. */
  .auth-heading { font-size: 2rem; }
  .auth-intro { color: var(--ink-muted); margin-block: 0 var(--space); }

  .auth-card {
    padding: 3rem clamp(1.5rem, 1rem + 2.5vw, 3rem);
  }

  .auth-card-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 1rem;
    margin-block-end: 1rem;
  }
  /* Login greets the reader above the card, so its card heading is an h2 under
     that h1. The password screens have no greeting — the legacy pages put the
     heading inside the card and nothing above it — so there the card heading is
     the page's h1. Same size either way; the level follows the document, not
     the look. */
  .auth-card-head :is(h1, h2) { margin-block: 0; font-size: 2rem; }
  /* The legacy pages carry the explanation as a light h6 beside the heading,
     which is a subtitle doing a heading's job. Same position, made a paragraph. */
  .auth-card-sub {
    margin-block: 0.35rem 0;
    font-size: 0.95rem;
    font-weight: 400;
    color: var(--ink-muted);
  }
  .auth-card-logo {
    inline-size: 100px;
    block-size: auto;
    background: var(--logo-plate);
    border-radius: var(--radius);
  }

  .auth-form { gap: 1.5rem; }
  .auth-form input {
    block-size: 3.175rem;
    padding: 0.94rem;
    font-size: 1rem;
    border-radius: 0.3rem;
  }
  /* Full width and pill-shaped, as on the legacy page.
     .button.auth-submit, not .auth-submit: every element carrying this also
     carries .button, both are one class in the same layer, and .button is
     declared *below* — so on source order alone .button won every property the
     two share and this rule shipped as a 6px rectangle for as long as it has
     existed. The comment claimed the override; the cascade never gave it one.
     Two classes beats one, which is what a modifier of .button should be. */
  .button.auth-submit {
    justify-self: stretch;
    /* inline-size as well as justify-self, because "Request a new link" on the
       expired-reset screen is an anchor in a paragraph rather than a grid item
       in the form. */
    inline-size: 100%;
    text-align: center;
    border-radius: 50px;
    /* The legacy pill was 1.375rem of padding around 0.875rem text, which was
       proportionate when the field above it was 1.25rem. At the field's current
       1rem it left the loudest shape on the page carrying the quietest label. */
    padding-block: 1rem;
    font-size: 1rem;
  }
  .auth-links { display: flex; justify-content: flex-end; margin-block: 1rem 0; }

  /* Available to a screen reader, absent from the page. Not display:none —
     that would take it out of the accessibility tree along with the pixels. */
  .visually-hidden {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* --- forms ---------------------------------------------------------------
     One gap between fields, two between groups. That 2:1 is the whole of the
     grouping on these forms: no rules, no boxes, nothing for a long form to
     nest inside a card it already sits in. A form with no .form-group children
     is unaffected and keeps a single even rhythm, which is right for the short
     ones. */
  /* --- disclosures ---------------------------------------------------------
     Two jobs, both of them "this belongs here but is not what you came for".

     A row's own action lives in the row. What this replaced was a second list
     under the table with one form per row, where the only thing joining a form
     to its row was the row's name repeated in the label — four components, four
     stacked forms, and a matching exercise before you could remove one.

     A create form sits under the list it adds to. The list is the page; the
     form is occasional, so it is folded away and opens itself when the server
     sends it back with an error.

     <summary> is a control the browser already built: it toggles, it takes
     focus, and it is announced expanded or collapsed. Nothing here rebuilds any
     of that. It makes it look like the control it is and gives it a target
     worth aiming at — SC 2.5.8 asks for 24px, and bare summary text is 19. */
  summary {
    cursor: pointer;
    font-weight: 600;
    padding-block: 0.35rem;
    color: var(--green-700);
    /* As wide as its label and no wider. A summary is display: list-item, so it
       fills its container by default and the whole width of a card becomes a
       toggle — which is a lot of page that does something unexpected when
       clicked. It showed up on the dashboard, where the year select sits
       directly above the help card and its open list of years covers the right
       half of a summary that reaches under it. */
    inline-size: fit-content;
    /* fit-content can still exceed the container on a narrow viewport, and a
       heading is one long unbreakable run to a summary. */
    max-inline-size: 100%;
  }
  summary:hover { text-decoration: underline; text-decoration-thickness: 2px; }
  details[open] > summary { margin-block-end: 0.75rem; }

  /* A create form folded into a disclosure keeps the heading it had, so the
     document outline does not lose a level for a presentational reason. The
     heading is inline so it sits on the marker's line rather than under it. */
  summary > h2, summary > h3 {
    display: inline;
    font-size: 1.25rem;
    color: inherit;
  }

  /* Shut, the card holds one line, so it should not reserve the padding a form
     needs. The block padding is what makes an empty box look like a mistake. */
  details.card:not([open]) { padding-block: 0.75rem; }

  /* A row action is a label, not a sentence: let it widen its column rather
     than wrap, which otherwise leaves the disclosure marker on a line of its
     own above the word it belongs to. These tables scroll, so a wider column
     costs nothing the layout cannot absorb. */
  td summary { white-space: nowrap; }

  /* A cell is as wide as its widest word, which is no width at all for a form.
     Only when open: a closed disclosure must not push the column out. */
  td > details[open] { min-inline-size: 20rem; }

  /* An open form makes its row several hundred pixels tall, and cells are
     centred in a row by default — so the email address the form is about sank
     to the middle of the row while the form began at the top. Which row this
     is has to stay beside where the form starts. */
  tr:has(details[open]) > td { vertical-align: top; }

  /* Two forms in one disclosure — recalculate and correct — are two answers to
     different questions, not one form in two halves. */
  details > .form + .form { margin-block-start: var(--space); }

  /* --- the wide form ------------------------------------------------------
     Entry is the one form here with enough fields to earn the full 72rem, and
     the one people fill in every month. Width is only an improvement if the
     fields lay out across it: a select stretched to 68ch is the same lie about
     how much input is wanted that .field.narrow exists to stop telling. So the
     card drops the measure and every group lays out on one shared column grid.

     auto-fill, not auto-fit. auto-fit collapses the tracks nothing occupies and
     stretches two fields across the whole card, which would make the first
     group's columns a different width from the second's. The empty tracks are
     the point: Site, Month and the quality choices all start in the same
     place, three groups down the form.

     17rem is a track count, not a field width: it is the largest floor that
     still fits three tracks across the card at 72rem and two at tablet width.
     15rem fitted four, and no group here has four things in it — every row
     ended in a dead track down the right-hand side. */
  .form.wide { max-inline-size: none; container-type: inline-size; }
  .form.wide .form-group {
    /* min(100%, 17rem), not a bare 17rem. This form is a .card inside .wrap,
       so two paddings are already taken out of the viewport; at 320px that
       leaves 256px against a 272px track and the row overflows a page whose
       body must never scroll sideways. Same guard .dashboard and .split use. */
    grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
    align-items: start;
  }

  /* A field carrying a paragraph of hint, or a textarea, wants two tracks. Only
     once two exist — a span wider than the explicit grid conjures an implicit
     column and pushes the form off the side of a phone. */
  @container (min-inline-size: 35rem) {
    .form.wide .field.span-2 { grid-column: span 2; }
  }

  /* --- the year grid ------------------------------------------------------
     Twelve rows of one number. The cell is the field here, so the input drops
     the width it would take in a form and sits at the size of what goes in it:
     a monthly meter reading is a handful of digits, and a box the width of the
     column says otherwise.

     The month is a row header rather than a label, so nothing here needs the
     .field grid — a table row is already the association a form would have had
     to build. */
  .form [name^="amount["] {
    inline-size: min(100%, 11rem);
    font-variant-numeric: tabular-nums;
    text-align: end;
  }
  /* A message under a cell, not beside it: the column is as narrow as the
     figure it holds, and a sentence set to that width would be a word a line. */
  td .field-error { max-inline-size: 22rem; }
  /* The row header carries the month, so it should read as a label does rather
     than as data. */
  .form tbody th[scope="row"] { font-weight: 600; white-space: nowrap; }

  /* Two ways in, one of them the usual one. A row rather than a stack, so the
     second reads as the alternative it is and not as a second step. */
  .entry-ways { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }

  /* Prose in a disclosure, not a form: the browser's list indent and 1em
     paragraph margins are the only things here that are not on the page's
     spacing scale. The card runs to the same edge as the wide form below it,
     but its contents keep the measure — a card edge is alignment, a line of
     text at 72rem is unreadable. */
  .guidance { max-inline-size: none; }
  .guidance > :not(summary) { max-inline-size: var(--measure); }
  .guidance ol { display: grid; gap: 0.6rem; margin-block: 0; padding-inline-start: 1.25rem; }
  .guidance p { margin-block: var(--space) 0; }

  .form { display: grid; gap: 1rem; }
  .form-group { display: grid; gap: 1rem; }
  .form-group + .form-group,
  .form > * + .form-actions { margin-block-start: 1rem; }

  /* The submit and its escape hatch are one row. As bare grid children they
     landed on separate rows, and .cancel's old inline margin indented the
     second one under the first. */
  .form-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 1rem;
  }

  .form.inline {
    grid-template-columns: 1fr auto;
    align-items: end;
    max-inline-size: 40rem;
  }
  /* In an inline form the actions are the second column, not a row below it. */
  .form.inline .form-actions { margin-block-start: 0; }
  /* A hint belongs under the whole row, not inside the field. Inside it, the
     field grows by the hint's height and `align-items: end` then lines the
     button up with the bottom of the sentence instead of with the input. */
  .form.inline > .hint { grid-column: 1 / -1; margin-block-start: 0.35rem; }
  .field { display: grid; gap: 0.35rem; }
  /* A field sized to what goes in it. A four-digit year stretched to the full
     68ch of the card is not just untidy — the width is a claim about how much
     input is wanted, and it should not lie. */
  .field.narrow :is(input, select) { inline-size: min(100%, 9rem); }
  .field-row {
    display: grid;
    gap: 1rem;
    grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
    /* Grid stretches its items by default, and a stretched .field is a grid
       whose auto rows stretch in turn. So a field carrying a hint made the
       field beside it grow a *taller input* than the one it is paired with —
       Amount and Unit on the entry form, side by side and different heights. */
    align-items: start;
  }
  label, legend { font-weight: 600; }
  input, select, textarea {
    font: inherit;
    color: inherit;
    background: var(--surface);
    padding: 0.5rem 0.6rem;
    /* Not --line: a field's edge is the thing that says a field is there, and
       SC 1.4.11 holds it to 3:1 where a card's edge is free to whisper. */
    border: 1px solid var(--control-line);
    border-radius: var(--radius);
    inline-size: 100%;
    transition: border-color var(--dur) var(--ease);
  }
  /* A field had one state and a focus ring. The rest of the set, in the order a
     reader meets them:

     Hover darkens the edge only — a fill change would read as the field having
     been selected. Focus is the shared :focus-visible ring, so a field looks
     focused the same way a link and a button do.

     :user-invalid, not :invalid. Every required field on this form is invalid
     before it has been filled in, and :invalid paints all of them red the
     moment the page loads; :user-invalid waits until the field has been left or
     the form submitted, which is the timing the rest of the form already uses.
     The border alone is not the message — the browser's own bubble and the
     server's error say what is wrong — so this is the signal, not the whole
     answer, and it never fires on a form nobody has touched yet. */
  input:hover:not(:disabled), select:hover:not(:disabled), textarea:hover:not(:disabled) {
    border-color: var(--ink-muted);
  }
  /* Two ways a field is wrong, drawn the same: the browser's own verdict before
     the form was sent, and the server's after. A reader should not have to
     learn that these are different things. */
  input:user-invalid, select:user-invalid, textarea:user-invalid,
  [aria-invalid="true"] {
    border-color: var(--danger);
  }

  /* The message the server sent back, under the field it is about. It is the
     only copy — the page-level alert stays empty when a field owns the message
     — so it carries the danger colour on its own rather than merely tinting a
     sentence that also appears at the top of the page. */
  .field-error {
    color: var(--danger);
    font-size: 0.875rem;
    font-weight: 600;
    margin-block: 0.25rem 0;
  }
  input:disabled, select:disabled, textarea:disabled {
    background: var(--surface);
    color: var(--ink-muted);
    border-color: var(--line);
    cursor: not-allowed;
  }
  /* The UA draws a select's arrow where the platform says, which on most of them
     is flush to the border and ignoring the padding the text sits on. Taking
     appearance off is the only way to bring it in line; there is no property
     that moves it while the control is native.

     Two gradients rather than an SVG data URI: the caret is then --ink-muted
     itself and follows the dark theme, where a data URI would hardcode the
     colour and need a second copy for the dark scheme. The 45deg tile is the
     caret's left half, the 135deg tile its right, and the right one is set at
     0.6rem — the same inset as the text on the other side. */
  select {
    appearance: none;
    background-image:
      linear-gradient(45deg, transparent 50%, var(--ink-muted) 50%),
      linear-gradient(135deg, var(--ink-muted) 50%, transparent 50%);
    background-position: right 1.05rem center, right 0.6rem center;
    background-size: 0.45rem 0.45rem, 0.45rem 0.45rem;
    background-repeat: no-repeat;
    /* Clears the caret, so a long option does not run under it. */
    padding-inline-end: 2rem;
  }

  /* A bordered fieldset inside a bordered .card is a box in a box, and the
     second one says nothing the legend and the group's own spacing did not
     already say. The grouping is carried by space; this only has to stop
     drawing over it. */
  fieldset { border: 0; margin: 0; padding: 0; }
  legend { padding: 0; }
  /* Choices sit closer than fields do: they are one answer, not several. */
  fieldset.field { gap: 0.5rem; }
  /* Two columns rather than a block, so a label long enough to wrap keeps its
     second line under its first instead of running back under the control. The
     entry form's quality choices are a sentence each and wrap at any width
     narrower than the whole card. */
  .choice {
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: start;
    gap: 0.5rem;
    font-weight: 400;
    margin-block: 0;
  }
  /* The control's own box is taller than the text beside it; this sits the dot
     on the first line rather than above it. */
  .choice input { inline-size: auto; margin: 0; translate: 0 0.25em; }

  /* What a choice means, under what it is called. Smaller and quieter, so the
     three names carry the scan and the explanations are there for whoever needs
     one — where a dash put both on one line at one weight and made all three
     read alike. Size and colour do the separating: a choice is an answer, not a
     heading, which is why the name stays at the weight it had.

     Choices with a note need more air between them than bare ones do, or the
     note runs into the next name. Only where there is one: the plain choice
     lists on the admin screens are a tighter list on purpose. */
  .choice-note { display: block; font-size: 0.875rem; color: var(--ink-muted); }
  fieldset.field:has(.choice-note) { gap: 0.75rem; }

  .button {
    display: inline-block;
    background: var(--green-700);
    color: var(--surface-raised);
    border: 1px solid transparent;
    border-radius: var(--radius);
    padding: 0.6rem 1.1rem;
    font: inherit;
    font-weight: 600;
    text-decoration: none;
    cursor: pointer;
    justify-self: start;
    transition: background-color var(--dur) var(--ease);
  }
  .button:hover { background: var(--green-900); }
  /* Pressed reads as pressed: the transition is what makes a hover feel like a
     surface and a click feel like a press, and without an active state the
     button gives nothing back between the click and the next page. */
  .button:active { background: var(--green-900); translate: 0 1px; }
  /* Submitted. The button is disabled by app.js for the moment between the
     click and the response, so it needs to look unavailable rather than just
     stop working. */
  .button:disabled {
    background: var(--ink-muted);
    cursor: progress;
  }
  /* No margin of its own: every place it appears is a flex row with a gap, and
     the margin was fighting that gap or indenting an orphaned row. */
  .cancel { align-self: center; }
  .link-button {
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    color: var(--green-700);
    text-decoration: underline;
    cursor: pointer;
  }

  /* Sign out has to be dressed from this layer, not from the header's. The rest
     of the account cluster is styled in @layer layout, and layer order settles
     the cascade before specificity gets a say — .link-button's padding: 0 and
     underline win from components no matter how specific the layout rule is. It
     showed: Sign out sat underlined and untargetable beside Change password,
     which was neither. */
  .account .link-button {
    padding: 0.6rem;
    border-radius: var(--radius);
    text-decoration: none;
    transition: background-color var(--dur) var(--ease);
  }
  .account .link-button:hover {
    background: var(--surface-raised);
    text-decoration: underline;
    text-underline-offset: 4px;
  }

  /* --- the work queue ------------------------------------------------------
     A list of things somebody might do, each one a sentence saying what and a
     sentence saying why. Not a table: there are no columns here, and a table
     would promise a comparison between rows that does not exist.

     The rule about the marker is the same one the meter follows — a colour
     alone cannot carry meaning, so the item leads with the name of the thing
     and what is wrong with it, and the bar down the side only groups them. */
  .queue {
    list-style: none;
    padding: 0;
    margin: 0;
    display: grid;
    gap: 0.75rem;
  }
  /* A full border, not a coloured edge down one side. The stripe was carrying
     nothing: every item on this list needs attention, so a warning colour on
     all of them distinguishes none of them, and it left the item looking like
     an alert that had been styled rather than a job to be done. What tells them
     apart is the sentence, which is what the reader is here for. */
  .queue-item {
    padding: 0.75rem var(--pad);
  }
  .queue-what { margin: 0; font-weight: 600; }
  .queue-why { margin-block: 0.25rem 0; color: var(--ink-muted); font-size: 0.875rem; }

  /* The column beside a list: narrower, quieter, and holding the thing you go
     to when the list tells you to do something. */
  .aside-card { align-self: start; }
  .aside-card h3 { margin-block: 0 0.5rem; font-size: 1rem; }
  .aside-card .aside-lead { margin-block: 0 var(--pad); color: var(--ink-muted); font-size: 0.9rem; }
  .aside-card .aside-lead strong { color: var(--ink); }
  .aside-card .entry-ways { margin-block: 0; }
  .aside-card .hint { margin-block-start: var(--pad); }

  .stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
    gap: 1rem;
    list-style: none;
    padding: 0;
  }
  .stat {
    padding: var(--pad);
  }
  .stat-value { display: block; font-size: 2rem; font-weight: 700; color: var(--green-900); }
  .stat-label { color: var(--ink-muted); font-size: 0.9rem; }

  /* --- page head ----------------------------------------------------------
     Title and lede on one side, the controls that act on the page on the
     other. The dashboard's year selector and its two downloads used to sit in
     a bar of their own below the heading, which read as a form somebody had
     left there. Wraps to two rows before it crowds, and the actions keep the
     start edge when it does rather than hanging off the end of a short lede. */
  .page-head {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    align-items: end;
    gap: var(--space) 2rem;
    margin-block-end: var(--space);
  }
  .page-head h1 { margin-block-end: 0.25rem; }
  /* Tighter than a page head standing on its own: this one is pinned to the
     top of the viewport, and every rem of it is a rem of figures the reader
     cannot see. */
  .dashboard-head { padding-block-end: 0.75rem; }
  .dashboard-head .page-head { margin-block-end: 0.75rem; }
  .page-head .lede { margin-block-end: 0; }
  .page-title { min-inline-size: min(100%, 22rem); }
  /* One form, not a form and two links beside it. The downloads are submit
     buttons carrying formaction, so they export whatever the select is showing
     *now* rather than the year the page was rendered with — which is what an
     <a href="?year=…"> could only ever be, and which is wrong the moment the
     page updates in place. Native HTML, no script involved. */
  .year-form {
    display: flex;
    flex-wrap: wrap;
    align-items: end;
    gap: 0.75rem 1rem;
    margin: 0;
  }
  .year-form .hint { max-inline-size: 20rem; }

  /* --- sticky page head ---------------------------------------------------
     The title, the year and the downloads stay put while the figures under
     them scroll, so the control that changes the page is reachable from
     anywhere in it. Offset by the header's own height, which is why that is a
     token and not a measured number.

     Only where there is height to spare, and only where the header is a single
     row: below this width it wraps to two or three and the offset would be
     wrong, and two stacked sticky bars would take most of a phone screen.

     :has() releases it when the help is opened. A shut disclosure is a 3rem
     bar; an open one is most of a screen, and pinning that to the top of every
     scroll would cost more than it gives. */
  @media (min-width: 66rem) {
    .dashboard-head {
      position: sticky;
      inset-block-start: var(--header-h);
      z-index: 5;
      background: var(--surface);
      border-block-end: 1px solid var(--line);
    }
    .dashboard-head:has(details[open]) { position: static; }
  }
  /* The downloads are secondary to the year: three solid green buttons in a row
     make the two that only export look like the one that changes the page. */
  .button.ghost {
    background: var(--surface-raised);
    color: var(--green-700);
    border-color: var(--control-line);
  }
  .button.ghost:hover,
  .button.ghost:active { background: var(--green-100); color: var(--green-900); }

  /* --- section rhythm -----------------------------------------------------
     One grid over the page's sections, so the distance between two of them is
     set once and is visibly larger than the gaps inside one. The h2's own top
     margin is what used to carry it, which meant the heading could not be
     moved without changing the rhythm of the whole page. */
  .dashboard {
    display: grid;
    /* minmax(0, 1fr), not the auto a single implicit column would get. A grid
       item is sized to its max-content by default, and the charts are a fixed
       720px, so an auto column would take its width from the widest drawing on
       the page and scroll the whole document sideways on a phone — the figure
       scrolls inside itself, which is the point of it. */
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-section);
    /* The help panel above is not one of these sections, so the grid's own gap
       does not reach it. */
    margin-block-start: var(--space-section);
  }
  .dashboard > section > h2 { margin-block-start: 0; }
  /* The head above draws the line and the padding under it, so the grid does
     not also open a section gap at the top of the page. */
  .dashboard-head + .dashboard { margin-block-start: var(--space); }
  /* Tight to the thing it introduces, generous to the section before it. */
  .dashboard > section > h2 + * { margin-block-start: 0; }

  /* A link out of a section, set apart from the section's content rather than
     hanging off its last row. */
  .section-action { margin-block: 0.75rem 0; }
  .site-line { margin-block: 0; }

  /* --- two-up -------------------------------------------------------------
     A chart is a fixed 720px drawing and .wrap is 72rem, so a section holding
     one and nothing else leaves a third of the page empty — which every
     section on this dashboard did, in the same place, all the way down.

     The second column takes what is left. It only splits once both halves have
     room to be read: below that the two stack, which is also what a phone
     gets. min(100%, ...) keeps the first column from overflowing a narrow
     viewport, and minmax(0, ...) lets the second one's table scroll inside its
     track instead of pushing the grid wider than the page. */
  .split {
    display: grid;
    grid-template-columns: minmax(0, 1fr); /* see .dashboard above */
    gap: var(--space);
    align-items: start;
  }
  @media (min-width: 66rem) {
    .split { grid-template-columns: min(100%, 46rem) minmax(0, 1fr); }
  }
  /* A table paired with a chart is bounded by the catalogue and grows a row at
     a time beside a chart that grows a bar at a time, so the two stay roughly
     as tall as each other. The 70vh cap is for the lists that have no bound;
     applied here it scrolls one half of a pair while the other stands still. */
  .split .table-scroll { max-block-size: none; }

  /* --- headline summary ---------------------------------------------------
     The figures beside the bar that splits them. This replaced four identical
     tiles that repeated the same three numbers immediately above the chart;
     the total leads on size because it is the number somebody opened the page
     for, and the scopes are a definition list because that is what they are. */
  .summary {
    padding: var(--pad);
    width: auto;
  }
  .summary-total {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.5rem;
    margin-block: 0;
  }
  .summary-total strong {
    font-size: clamp(2.25rem, 1.8rem + 1.8vw, 3rem);
    font-weight: 700;
    line-height: 1.1;
    color: var(--green-900);
    font-variant-numeric: tabular-nums;
  }
  .summary-total span { color: var(--ink-muted); font-size: 0.9rem; }
  .summary-delta { margin-block: 0.5rem 0; font-size: 0.9rem; }
  .summary-delta .muted { display: block; }

  /* Three abreast rather than three rows: the panel then stands about as tall
     as the chart it sits beside, instead of leaving the chart with a third of
     a screen of nothing under it. Each label sits over its own figure, which
     is what the grid's column flow does with a <dl>'s alternating children. */
  .scope-figures {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    grid-template-rows: auto auto;
    grid-auto-flow: column;
    gap: 0.15rem 0.75rem;
    margin-block: var(--pad) 0;
    padding-block-start: var(--pad);
    border-block-start: 1px solid var(--line);
  }
  .scope-figures dt { color: var(--ink-muted); font-size: 0.875rem; }
  .scope-figures dd {
    margin: 0;
    font-weight: 600;
    font-size: 1.125rem;
    font-variant-numeric: tabular-nums;
  }

  /* Cost is not a fourth scope, so it does not sit in the same three-column
     run as the three that are. Its own row, sized to what it holds, keeps a
     euro figure from reading as an emissions one - and two currencies stay
     side by side rather than being added, which is the condition FSG attached
     to recording cost at all. */
  .cost-figures {
    grid-template-columns: repeat(auto-fit, minmax(0, max-content));
    gap: 0.15rem 1.5rem;
  }

  .plain { list-style: none; padding: 0; }

  table {
    inline-size: 100%;
    border-collapse: collapse;
    margin-block: 0.5rem 1rem;
    font-size: 0.95rem;
  }
  caption { text-align: start; color: var(--ink-muted); padding-block-end: 0.5rem; }
  th, td { text-align: start; padding: 0.5rem 0.6rem; border-block-end: 1px solid var(--line); }
  thead th {
    background: var(--table-head);
    color: var(--table-head-ink);
    font-size: 0.85rem;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    font-weight: 400;
  }
  .num { text-align: end; font-variant-numeric: tabular-nums; }
  tfoot th, tfoot td { border-block-end: 0; }

  /* Tables are the widest thing on these pages: let them scroll alone rather
     than making the page scroll sideways. This is the baseline for the short
     fixed tables; the ones that grow get .table-scroll below. */
  section:has(table) { overflow-x: auto; }

  /* --- scrollable tables --------------------------------------------------
     For the lists that grow without a bound. The region scrolls in both axes
     and the header row stays put, so the columns are still named at row 90.

     tabindex="0" on the wrapper is not decoration: a region that scrolls only
     under the mouse is unreachable by keyboard (WCAG 2.2 SC 2.1.1). It gets
     role="region" and is named by the table's own <caption>, so the tab stop
     announces something useful rather than "group". */
  .table-scroll {
    max-block-size: 70vh;
    overflow: auto;
  }

  /* A sticky cell loses its borders under border-collapse: collapse — the
     collapsed border belongs to the table, not the cell, so it does not travel
     with it. Separate borders are the price of a sticky header. */
  .table-scroll table {
    border-collapse: separate;
    border-spacing: 0;
    margin: 0;
  }
  .table-scroll thead th {
    position: sticky;
    inset-block-start: 0;
    z-index: 1;
    /* Opaque, or the rows scroll through the header — which the green band
       already is. */
    border-block-end: 1px solid var(--line);
  }
  .table-scroll caption { padding: 0.5rem 0.6rem; }
  /* The container draws the last edge, so the last row should not draw it too. */
  .table-scroll tbody tr:last-child > * { border-block-end: 0; }

  /* --- charts ------------------------------------------------------------
     The SVG is a fixed size, so the figure scrolls rather than the page. A
     scaling viewBox would scale the labels with it and they would be
     unreadable at phone width; the table under each chart is the view that
     reflows, and is what a screen reader and a printout get. */
  .chart-figure {
    margin: 0;
    padding-block: 0.5rem;
    overflow-x: auto;
    /* The SVG inside is a fixed 720px. Without a cap the figure stretches to
       the full 72rem of .wrap and the chart sits in a bordered box half again
       its own width. 46rem is the chart plus its gutters. */
    max-inline-size: 46rem;
  }
  .chart-figure figcaption {
    color: var(--ink-muted);
    font-size: 0.875rem;
    padding: 0.25rem 0.75rem 0.5rem;
  }
  .chart { display: block; }

  /* Text in a chart wears text tokens, never a series colour: a light
     categorical hue is illegible as text. Identity comes from the mark. */
  .chart-label { fill: var(--ink); font-size: 13px; }
  .chart-value { fill: var(--ink); font-size: 13px; font-variant-numeric: tabular-nums; }
  .chart-axis  { fill: var(--ink-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
  /* The one exception: a label sitting inside a filled segment, so it wears the
     page's own surface rather than a text token. chart.go emits this in exactly
     one place — the stacked scope bar — so the fills it ever lands on are the
     three categorical series and nothing else.

     --surface-raised, not #fff. The two are the same colour in light mode and
     are not in dark, where the series are light enough that white on them runs
     3.41-3.88:1. The token flips with the scheme and gives 4.45-5.08:1.

     Light mode is unchanged and still short: 4.42, 3.20 and 2.82:1 against the
     4.5 that 12px demands. Closing that needs either the labels outside the
     segments or a palette retune, and the palette is validated for *mark*
     contrast rather than text. The table beside every chart carries the same
     figures and is the accessible view, which is what makes this a gap worth
     recording rather than a defect worth rushing. */
  .chart-inline-label { fill: var(--surface-raised); font-size: 12px; font-weight: 600; }
  .chart-grid { stroke: var(--chart-grid); stroke-width: 1; }
  /* The unfilled part of a meter is a lighter step of the fill's own ramp, so
     the state reads across the whole bar and not only where it is filled. That
     tint is 1.13:1 against the card, which is the point of it and also why it
     cannot carry the meter's extent on its own — the frame does that. Neutral
     rather than a third green: the track and the fill are already two. */
  .meter-track { fill: var(--meter-track); }
  .meter-frame { fill: none; stroke: var(--control-line); stroke-width: 1; }

  /* The meter is the one drawing that takes its width from the page rather than
     bringing its own; the percentages inside it are resolved against this box.
     Its frame sits on the canvas edge, so the outer half of that 1px stroke
     would be clipped without overflow: visible — which is safe here only
     because .target is not a scroll container. */
  .meter {
    inline-size: 100%;
    overflow: visible;
  }

  /* Two targets abreast wherever two fit. The cards were full-width blocks
     holding one short bar each, which on a desktop is a column of wide boxes
     mostly made of nothing. 26rem is chosen so 72rem of .wrap takes exactly
     two: three would squeeze the heading — "Electricity — 50 tCO2e by 2030" —
     into three lines to save space that is not short. */
  .targets {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 26rem), 1fr));
    gap: 1rem;
    align-items: start;
  }

  .target {
    padding: var(--pad);
  }
  .target h3 { margin-block: 0 0.25rem; font-size: 1rem; }
  .target-state { font-size: 0.875rem; font-weight: 600; }
  .target-state.met { color: var(--green-700); }
  .target-state.behind { color: var(--danger); }
  .delta { font-variant-numeric: tabular-nums; font-weight: 600; }
  .delta.down { color: var(--green-700); }
  .delta.up { color: var(--danger); }

  /* The legend is the dependable identity channel for the stacked chart;
     direct labels only supplement it. */
  .legend { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; list-style: none; padding: 0 0.75rem; margin: 0.25rem 0 0; }
  .legend li { display: flex; align-items: center; gap: 0.4rem; font-size: 0.875rem; color: var(--ink-muted); }
  .legend .key { inline-size: 0.75rem; block-size: 0.75rem; border-radius: 2px; }
  .legend .key-1 { background: var(--series-1); }
  .legend .key-2 { background: var(--series-2); }
  .legend .key-3 { background: var(--series-3); }

  /* --- filter bar ---------------------------------------------------------
     A plain GET form of native controls. It wraps rather than scrolls, and the
     submit button is real: filtering is server-side, so the screen behaves the
     same with scripting off. */
  .filter-bar {
    display: flex;
    flex-wrap: wrap;
    align-items: start;
    gap: 0.75rem 1rem;
    padding: var(--space);
    margin-block-end: var(--space);
  }
  .filter-bar .field { gap: 0.25rem; }
  .filter-bar select,
  .filter-bar input { inline-size: auto; min-inline-size: 10rem; }
  .filter-bar .hint { max-inline-size: 22rem; }
  .filter-actions { display: flex; align-items: center; gap: 0.5rem; align-self: normal; }

  /* --- category navigation ------------------------------------------------
     The legacy portal's utility buttons, as links rather than buttons: each one
     is a URL that can be bookmarked, opened in a new tab and gone back from,
     which a button posting a filter is not. Same chip as the pagination below,
     because it is the same job — a row of small destinations, the one you are
     on marked rather than removed, so the row does not reflow as you move
     along it. */
  /* --- the chip shape, shared with the pagination below --------------------
     Both are a row of small destinations with the one you are on marked rather
     than removed, so neither row reflows as you move along it. That is one
     shape, written once; what actually differs between them is four
     declarations, and they are in the two blocks after this one. */
  .category-nav ul,
  .pagination ul {
    display: flex;
    flex-wrap: wrap;
    list-style: none;
    padding: 0;
    margin: 0;
  }
  .category-nav a,
  .pagination a,
  .pagination [aria-current="page"] {
    padding: 0.35rem 0.7rem;
    border: 1px solid var(--line);
    border-radius: var(--radius);
    text-decoration: none;
  }
  .category-nav a:hover,
  .pagination a:hover { background: var(--green-100); }
  .category-nav [aria-current="page"],
  .pagination [aria-current="page"] {
    background: var(--green-700);
    border-color: var(--green-700);
    color: var(--surface-raised);
    font-weight: 600;
  }

  .category-nav { margin-block-end: var(--space); }
  .category-nav ul { gap: 0.35rem; }
  /* A chip here carries a count beside its label; a page number does not. */
  .category-nav a {
    display: flex;
    align-items: baseline;
    gap: 0.4rem;
  }
  .category-nav .count {
    color: var(--ink-muted);
    font-size: 0.875rem;
    font-variant-numeric: tabular-nums;
  }
  /* The muted grey is a contrast result taken against a page background, and
     this one is on green. */
  .category-nav [aria-current="page"] .count { color: inherit; }

  /* --- pagination ---------------------------------------------------------
     A list of links in a <nav>, so it is a landmark and reads as "list, 6
     items" rather than a run-on line. The current page is a <span> with
     aria-current, not a link to where you already are. */
  .pagination { margin-block-start: 1rem; }
  .pagination ul { gap: 0.25rem; }
  /* Page numbers line up under each other, so they take the tabular figures
     the category names have no use for. */
  .pagination a,
  .pagination [aria-current="page"] {
    display: block;
    font-variant-numeric: tabular-nums;
  }
  .pagination li > .muted { display: block; padding: 0.35rem 0.7rem; }

  /* --- auto-submitting year forms -----------------------------------------
     app.js adds .is-autosubmit to a form only once it has actually wired the
     behaviour up, so both rules below describe a page where the script ran. The
     default state is the no-script one: the button is the control, and the note
     saying the page updates on its own stays hidden, because without the script
     it would not be true.

     Exactly one of the two is visible at a time, which is why the note can sit
     in the grid cell the button vacates. */
  .autosubmit-note { display: none; }
  .is-autosubmit .autosubmit-note { display: block; }
  .is-autosubmit .autosubmit-fallback { display: none; }

  .toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem 1rem;
    margin-block-end: var(--space);
  }
  .toolbar .field { margin: 0; }
  .downloads { display: flex; gap: 0.5rem; }
}

@media (forced-colors: active) {
  /* Forced colours drop the fills; the table view carries the numbers. */
  .chart-figure { border-color: CanvasText; }

  /* A background image is one of the fills that gets dropped, and the select's
     caret is drawn as two of them. Hand the control back to the UA rather than
     shipping a select with no arrow at all; the reserved padding is the only
     thing left over, and it reads as space. */
  select { appearance: auto; }
}

@media print {
  /* Browsers drop background fills when printing, and white ink on a dropped
     fill prints as nothing. The table under every chart is what a printout
     gets, so its header has to survive losing the green. */
  thead th { background: none; color: #000; }
}

/* --- page transitions -------------------------------------------------------
   Every navigation here is still a full document load: the server renders in
   about 50ms and none of that changes. This only asks the browser to animate
   between the two renders instead of cutting between them, which costs no
   JavaScript, no dependency and nothing the CSP has an opinion about. Where it
   is unsupported the result is exactly what the portal did before.

   The named elements above are the ones that are the same object on both sides
   of a navigation, so they morph in place rather than joining the root's
   cross-fade. A header that holds still while the content changes underneath it
   is the whole of the effect; more than that would be animation for its own
   sake.

   160ms, not the 300ms the examples use. These pages render in 50ms, and this
   is a portal somebody filters all day: long enough to read as continuity,
   short enough not to become a wait. */
@view-transition { navigation: auto; }

::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
  /* The tokens, not a third copy of their values. --dur and --ease were named
     precisely to stop this drifting, and this rule was the copy that got away.
     They resolve here: the pseudo-element tree is anchored at the root, so it
     inherits :root's custom properties. */
  animation-duration: var(--dur);
  animation-timing-function: var(--ease);
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }

  /* The rule above does not reach these. View-transition pseudo-elements hang
     off the root in a tree of their own, which the universal selector in the
     document tree never matches — so without this a page that honours the
     preference everywhere else would still animate every navigation. */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
