/* ==========================================================================
   Page-level composition. Tokens live in tokens.css; nothing here invents a
   value.
   ========================================================================== */

/* ---- first-run prompt ---------------------------------------------------- */
.setup-banner {
  background: var(--amber-wash);
  /* The wash is 1.005:1 on the chassis under it, so this line is the whole
     boundary between the banner and the page. It was a raw #F0DDC6, which is
     1.21:1 there: a border nobody can see, in a value no token owns. --amber
     is 3.06:1 on the chassis and 3.08:1 on the wash, so the edge holds on both
     sides of itself. .setup-banner-warn already draws this same line in
     --error; this is the amber half of that pair. */
  border-bottom: var(--bw) solid var(--amber);
}
.setup-in {
  /* The bar's measure, read from the bar's own token rather than copied as a
     second 62rem. .topbar-in is max-width: var(--col-live), and /new narrows
     that to the flow column; without this the banner would keep the wide
     measure and its left edge would stop up to 46px outside the logo directly
     above it. */
  max-width: var(--col-live); margin: 0 auto;
  padding: var(--s3) var(--s4);
}
/* ONE ROW, and the action is a link inside the sentence rather than a button on
   a line of its own.

   This was a wrapping flex row with a 48px .btn in it. The paragraph takes the
   full content width at every phone size, so the button ALWAYS wrapped, and the
   banner measured 132.4px at both 320 and 390 (154.1px past due at 320) - main
   started at y=204.4 with it and y=72 without. On a 320x568 phone that is 204px
   of chrome before the page begins. What it cost is measurable somewhere else:
   /new step 1 is laid out to fit a 320x640 phone with 0.3px to spare, so with
   the banner Continue sat 132px underneath its own fixed action bar, and on
   /history the first estimate row landed at y=809 of a 640px viewport.

   Measured after: 46.69px. The 2.5.8 inline exception applies because the link
   is inside a sentence - the same exception the drawer's `.nav-credits .link`
   rule already takes, in the same words. .link-block was the other candidate and
   it sets min-height: var(--tap), which would put 48px of the 85.7px straight
   back. */
.setup-in p { margin: 0; color: var(--amber-ink); font-size: var(--fs-meta); }

/* On /new this band lands INSIDE the navy field, between the bar and the flow
   head, so it takes the field's surface rather than cutting it in half. The
   case that matters is not the rare one: a roofer who has not filled in his
   company details yet gets this on his first ever estimate, which is the worst
   possible screen to show him a navy / amber / white / navy stack on.

   --amber-ink is 3.65:1 on the bezel and fails 1.4.3 outright, so the ink has
   to move with the surface. --brand is 9.05:1 there and is a boundary rather
   than information, which rule 4 permits; the boundary is kept because without
   it the paragraph is unattributed text floating between the bar above and the
   flow head below, both of which are the same navy.

   :not(.setup-banner-warn) because a failed payment is the ONE thing allowed
   to break the field. --error measures 8.67:1 on its own wash and 2.11:1 on
   the bezel, so merging that band would either fail the text or spend the only
   channel saying this one is different from the other two. It stays a light
   band, deliberately, and it is the only one. */
.page-new .setup-banner:not(.setup-banner-warn) {
  background: var(--bezel);
  border-bottom-color: var(--brand);                    /* 9.05:1, non-text */
}
.page-new .setup-banner:not(.setup-banner-warn) .setup-in p {
  color: var(--on-bezel);                               /* 18.31:1 */
}
/* The link is now inside that paragraph, so it has to move with it. --navy is
   1.20:1 on the bezel and --amber-ink 3.65:1, and both fail outright; --brand is
   9.05:1 there, which is one of the two surfaces rule 4 allows it to carry
   meaning on. The underline and the 600 weight .link already carries are what
   say "link", so hue is not the only channel either way. */
.page-new .setup-banner:not(.setup-banner-warn) .setup-in .link {
  color: var(--brand);                                  /*  9.05:1 */
}

/* ---- home ---------------------------------------------------------------
   The opening. v3 opens a screen with a dark band and hangs the first
   interactive surface off its bottom edge, and here that is structural rather
   than stylistic: --readout on --chassis is 1.09:1, so this palette has no
   value step between a card and the page. The band is where the contrast is.

   .hero / .hero-tight are gone. Nothing else in the product used them (checked:
   home.html was the only caller), and .hero h1 carried a raw
   clamp(1.75rem, 6.5vw, 2.5rem) that was not a token and topped out 6px above
   --fs-h1's ceiling, so the home h1 was the only h1 in the app with its own
   scale. */
.home-hero {
  /* Bleeds to main's own box: main is `0 auto` with var(--s4) side padding, so
     these exactly cancel it and nothing can overflow the 62rem column. On a
     phone that is edge to edge; on a desktop it is a band across the column,
     which is the right answer for a hero rather than a full-viewport stripe. */
  margin: calc(var(--s6) * -1) calc(var(--gutter) * -1) 0;
  /* --gutter for the sides, not --s5, and it is the same 4px argument
     tokens.css:157 already makes about a bleed reading its container's own
     name. Measured at 390x844 this band paid out 20px while everything else on
     the same screen paid out 16: the greeting, the h1 and the white New
     estimate plate all began at x=20, and the topbar's own lockup, the two job
     cards hanging off this band's bottom edge and the recent-estimates card
     all began at x=16. Nobody can name 4px and everybody can see the hero's
     text hanging inboard of the logo directly above it.
     The vertical steps are untouched: --s6 above and the deep --s8 + --s7
     below are the room the job cards pull up into. */
  padding: var(--s6) var(--gutter) calc(var(--s8) + var(--s7));
  /* --field-face, which STARTS on --bezel: the bar's own colour, so the seam is
     preserved at exactly 1.000:1. The top margin cancels main's padding, so this
     band begins where the sticky bar ends and there is no chassis between them.
     On --ink that seam measured 1.254:1 against the bar - two dark bands almost
     but not quite the same, which is the most reliable "nobody finished this"
     signal in the trade. The bar's --shadow-2 does not save it either:
     composited over #062A4A it resolves to #062A4A, a 1.000:1 line.

     The graduation itself is the fix for a second near-miss, one navigation
     wide. Measured at 390x844, this band had NO background-image and computed a
     flat rgb(0,22,41), while all four .step-hero instances on /new computed
     `linear-gradient(rgb(0,22,41), rgb(6,42,74))`. The roofer opens home to a
     flat navy field, taps New estimate, and one second later the same shaped
     navy field is graded. tokens.css:166-171 already records the doctrine for
     exactly this case: a difference on a band seen twice in one session reads
     as a bug rather than a style.

     Every ink on it is measured against the bottom edge, which is the worst
     case: --on-bezel 18.31 falling to 14.61, --on-bezel-muted 8.74 to 6.97.
     Both clear 4.5:1 the whole way down, and .home-start is a white plate whose
     boundary goes 18.31 to 14.61. Forced colours discards a background image,
     which is why the border-bottom below is not optional. */
  background: var(--field-face);
  color: var(--on-bezel);
  /* --r-hero, the one radius every full-bleed dark band in the product uses.
     The estimate flow draws two more of these within a minute of this one, and
     a 10px corner change between them reads as a bug. */
  border-radius: 0 0 var(--r-hero) var(--r-hero);
}
/* The bottom padding above is the room the job cards pull up into. On an empty
   account there are no job cards, so without this the band ends in 72px of
   unexplained navy under the button. */
.home-hero.is-alone { padding-bottom: var(--s8); }

/* ---- the landing hero's footnote ----------------------------------------
   Only the signed-OUT front door uses these two. They are here rather than in a
   stylesheet of their own because they are two rules hanging off .home-hero and
   a fourth CSS file for them would be a file to remember.

   --on-bezel-muted, measured: 8.74:1 at the top of the band falling to 6.97:1
   at the bottom edge, which is where this sits. Clears AA for body text the
   whole way down, on the same worst-case reading .home-hero's own comment
   uses. */
.home-hero-note {
  margin: var(--s4) 0 0;
  color: var(--on-bezel-muted);
  font-size: var(--fs-meta);
  max-width: 34rem;
}
/* .link on this band would be var(--navy) on navy. Full white and underlined:
   18.31:1 falling to 14.61:1, and the underline carries it independently of
   colour, which is the 1.4.1 requirement for a link inside a run of text. */
.home-hero-note .link-on-dark,
.link-on-dark {
  color: var(--on-bezel);
  text-decoration: underline;
  text-underline-offset: 2px;
  font-weight: 600;
}
/* Held to a measure rather than run across 62rem: a 34px headline on a
   thousand-pixel dark band is a poster, not a screen. */
.home-hero h1,
.home-hero .lede,
.home-greet,
.home-start { max-width: var(--measure); }

/* The greeting. --fs-meta and never --fs-micro: 12px is documented in
   tokens.css as pills and printed column heads only, and --fs-meta is the
   outdoor floor. --on-bezel-muted, because --muted on --bezel is 3.06:1. */
.home-greet {
  margin: 0 0 var(--s3);
  font-size: var(--fs-meta); font-weight: var(--w-bold);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--on-bezel-muted);
}
.home-hero h1 {
  margin: 0 0 var(--s3);
  font-size: var(--fs-h1); line-height: var(--lh-tight);
  letter-spacing: var(--track-display);
  color: var(--on-bezel);
}
.home-hero .lede { margin: 0 0 var(--s6); color: var(--on-bezel-muted); }

/* The one dominant action, on the populated page and on the empty one both.
   Deliberately NOT .btn-primary: that control is --navy, which measures 1.64:1
   on the bezel and is invisible. White on --bezel is 18.31:1 for the label and
   18.31:1 for the boundary. */
.home-start {
  display: flex; align-items: center; justify-content: center; gap: var(--s2);
  width: 100%; min-height: var(--tap-xl); padding: 0 var(--s5);
  background: var(--readout); color: var(--ink);
  border: var(--bw-strong) solid var(--readout);
  border-radius: var(--r-lg);
  font-size: var(--fs-lead); font-weight: var(--w-bold);
  letter-spacing: var(--track-tight);
  text-decoration: none;
}
.home-start svg { width: var(--s5); height: var(--s5); flex: none; }
/* Hover is never colour alone, and on this surface it cannot be an elevation
   either: --readout to --well is 1.110:1, and every shadow token is a navy
   alpha, which over the bezel composites back to about 1.03:1. So the label
   underlines, the same channel .jobpick-card and .job-addr already use. The
   label is wrapped rather than underlined through .home-start, because a
   decoration on the flex container draws across the plus glyph too.

   (hover: hover) so a phone cannot leave the state stuck on after a tap. */
@media (hover: hover) {
  .home-start:hover { background: var(--well); border-color: var(--well); }
  .home-start:hover .home-start-label { text-decoration: underline;
                                        text-underline-offset: 3px; }
}
.home-start:active { transform: translateY(1px); }

/* The shell's global ring is `2px --navy` plus a white halo, and on --bezel the
   navy band is 1.64:1 - only the halo is doing any work. Restated here the way
   .topbar restates it for the same reason: a white 2px band at 18.31:1, and a
   dark halo that still reads if the ring straddles the hero's bottom edge onto
   the chassis (composited #1E3E5B, 10.13:1 there). */
.home-hero :focus-visible {
  outline: var(--bw-strong) solid var(--on-bezel);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--ring-halo);
}

/* ---- the job cards ------------------------------------------------------
   The service selector, and the thing that hangs off the hero's bottom edge.
   Pulled up by --s7 into the --s8 + --s7 of padding the hero reserved for it,
   so the overlap is exact and the hero text never sits behind a card. */
.jobpick { position: relative; z-index: 1; margin: calc(var(--s7) * -1) 0 0; }
/* --s-list, the one gap every stacked-card list in the product uses. This and
   .joblist below were 12px while four lists on /new were 8 and 10, so the page
   rhythm changed between screens with nothing saying why. */
.jobpick-list { list-style: none; margin: 0; padding: 0;
                display: grid; gap: var(--s-list); }
@media (min-width: 40rem) { .jobpick-list { grid-template-columns: 1fr 1fr; } }

/* The card edge follows .card and .searchbar, and it moved for the reason
   tokens.css states in capitals: --hair resolves to --construction, and
   tokens.css:67 says that token is for "ROWS INSIDE AN ALREADY-BOUNDED SURFACE
   ONLY. Never a control, never a surface edge." This is both. Measured, the edge
   was rgb(221,228,236) = 1.28:1 against the fill it bounds, against
   .searchbar's rgb(126,136,148) = 3.29:1 on five otherwise identical white
   20px-radius panels. --rule-soft is the value tokens.css now gives .card,
   taken from there rather than picked again here.

   It is still not what identifies this as a control - the name, the chevron and
   the 48px tile are, and each of those clears its own floor - which is why the
   forced-colours block below is not optional either. */
.jobpick-card {
  display: flex; align-items: center; gap: var(--s4);
  min-height: var(--tap-xl); padding: var(--s3) var(--s4);
  background: var(--readout); color: var(--ink);
  border: var(--bw) solid var(--rule-soft);              /* 3.29:1 on chassis */
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-2);
  text-decoration: none;
}
/* Hover and focus are never colour alone: --well on --readout is 1.11:1 and
   would be no signal at all on its own, so the name underlines. */
.jobpick-card:hover { background: var(--well); }
.jobpick-card:hover .jobpick-name,
.jobpick-card:focus-visible .jobpick-name { text-decoration: underline; }
.jobpick-card:active { transform: translateY(1px); }

.jobpick-icon {
  width: var(--tap); height: var(--tap); flex: none;
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--r);
  background: var(--well); color: var(--ink);
}
.jobpick-icon svg { width: var(--s5); height: var(--s5); }
/* min-width: 0, never a bare auto floor. One long job label would otherwise
   widen the flex track past the viewport, and body{overflow-x:hidden} would
   HIDE that rather than prevent it. */
.jobpick-text { display: grid; gap: var(--s1); min-width: 0; }
.jobpick-name { font-size: var(--fs-body); font-weight: var(--w-bold);
                color: var(--ink); }
.jobpick-sub { font-size: var(--fs-meta); line-height: var(--lh-snug);
               color: var(--muted); }
.jobpick-go { width: var(--s4); height: var(--s4); flex: none;
              color: var(--muted); }

.section { margin-top: var(--s7); }
/* --navy and not --amber-ink. v3's link colour is --amber-ink, which measures
   5.02:1 on --readout - but this link sits on the CHASSIS, where the same ink
   is 4.58:1 at --fs-meta. That passes, with 0.08 of headroom, on the only route
   from this page to the full list. --navy is 10.21:1 there and the design's
   intent (a link that reads as a link) is already carried by the underline and
   the weight. */
.link { color: var(--navy); text-decoration: underline;
        text-underline-offset: 2px; font-weight: 600; }

@media (prefers-reduced-motion: no-preference) {
  .home-start, .jobpick-card {
    transition: background var(--t-fast) var(--ease),
                border-color var(--t-fast) var(--ease),
                transform var(--t-fast) var(--ease);
  }
}

/* Backgrounds and box-shadows are DISCARDED here, which takes the hero, both
   card surfaces and every elevation with them. border-width and border-style
   survive, so they are the whole signal. */
@media (forced-colors: active) {
  .home-hero { border-bottom: var(--bw-strong) solid; }
  .home-start,
  .jobpick-card { border: var(--bw-strong) solid; }
  .jobpick-icon { border: var(--bw) solid; }
}

/* ---- the job list ------------------------------------------------------
   A list, not a table: on a phone a four-column table of addresses is a
   horizontal scroll, and the address is the only thing he scans for.

   v3 has no bordered list box. Each estimate is its own white card on the
   chassis, separated by space, bounded by elevation and a 20px radius. The
   hairline this replaces measured 1.28:1 against the card; the elevation edge
   measures 1.42:1, so the softer looking treatment is the stronger boundary.
   Neither is required to make 3:1: the card groups content, it is not a
   control and it carries no state. Everything inside it clears its own floor,
   and forced colours gets a real border below. */
.joblist {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--s-list);
}

.job {
  display: grid;
  /* minmax(0, 1fr), never a bare 1fr. `1fr` is `minmax(auto, 1fr)` and the auto
     floor is min-content, so one unbroken postcode widens the track past the
     viewport, and body{overflow-x:hidden} would HIDE that rather than prevent
     it, which is a reflow failure wearing a clean scrollbar. */
  grid-template-columns: minmax(0, 1fr) auto auto;
  /* start, not center. An address long enough to wrap - "Flat 2, Cranbrook
     House, 116 Hazelbourne Avenue, Borough Green, Sevenoaks" runs to four
     lines at 320px - made the row 247px tall and floated the price at its
     vertical middle, level with nothing. The price belongs beside the first
     line of the address it prices; .job-price below carries the same 48px box
     .job-addr does so the two share a line rather than a box edge. */
  align-items: start;
  gap: var(--s2) var(--s4);
  /* Asymmetric on purpose. .job-addr carries its own 48px box, so 8px above it
     renders as about 21px of optical space; 16px below the meta line balances
     that. Equal padding looks top heavy. */
  padding: var(--s2) var(--s4) var(--s4);
  background: var(--card);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-1);
}
/* NOT overflow:hidden, on either the list or the card. The focus ring is a 2px
   outline at 2px offset PLUS a 4px halo, so it sits about 8px outside the box,
   and a box-shadow is clipped unconditionally by an ancestor's overflow:hidden.
   The address is a link, so that would cut the ring on every row. The same
   fault is already documented on .skip below. */

.job-main { min-width: 0; display: grid; gap: var(--s1); }

/* The address IS the link, because it is the one string that distinguishes the
   row: 200 links with 200 different accessible names, for free, rather than 200
   identical "Open" controls each needing a label to tell them apart.

   A row-height target rather than a text-height one: 25px of link text is a
   miss with a glove on. The negative margin exactly cancels the padding, so the
   text still starts flush with the meta line while the BOX is a full 48px. */
.job-addr {
  display: flex; align-items: center;
  /* min-width: 0, and it is not the whole fix. .job-addr is a grid item of
     .job-main, so its floor is `auto` = its own min-content = the longest word
     in the address. At 320px column 1 of .job is 144px, and UK place names
     supply single tokens well past that: "Kirkcudbrightshire" sets about 155px
     at 16px/600, "Northumberland" about 122px. The card then grew wider than
     the glass on the list every roofer opens first, and body{overflow-x:hidden}
     threw the difference away rather than preventing it, which is the reflow
     failure this file already documents on .job above. */
  min-width: 0;
  min-height: var(--tap);
  margin: 0 calc(var(--s2) * -1);
  padding: var(--s1) var(--s2);
  border-radius: var(--r-sm);
  font-weight: 600;
  color: var(--ink);
  font-size: var(--fs-body);
  line-height: var(--lh-snug);
  text-decoration: none;
}
/* The other half, and the half that is easy to miss.
   This box is a FLEX container - align-items: center is what puts a one-line
   address on the price's line rather than at the top of a 48px box - so a bare
   text node inside it becomes an ANONYMOUS flex item. That item still floors at
   its own min-content, and no selector can reach it to say otherwise: it has no
   element. tokens.css:328 does not rescue it either, because break-word breaks a
   word that cannot fit a line without lowering min-content at all. So the box
   would shrink to 144px and "Kirkcudbrightshire" would go on painting 11px
   outside the card, which widens the document exactly as before.
   Hence the span in home.html and history.html: it is the item this declaration
   can name. It carries no other purpose, and the accessible name is unchanged -
   a link is still named from its contents. */
.job-addr-text { min-width: 0; }
/* No underline at rest is defensible for a block link that titles its own row,
   it is not a link inside a sentence, but it must underline on both hover and
   focus. The tint is 1.11:1 and carries nothing on its own; the underline is
   the state channel and the only one that survives sunlight. */
.job-addr:hover { background: var(--sunk); text-decoration: underline; }
.job-addr:focus-visible { text-decoration: underline; }
/* THE PRESS, and it had none. A grep for ":active" across all four stylesheets
   returned seven lines and not one was .job or .job-addr, while .jobpick-card
   200px up the same screen has carried `translateY(1px)` all along. The hover
   above is no substitute: the tint measures 1.11:1 and hover does not exist on
   the phone this runs on, so tapping an estimate produced nothing at all until
   the next page painted - on the control a roofer taps two hundred times a week.

   A 1px settle, the same channel .jobpick-card and .home-start already use for
   the same gesture, and on the LINK rather than the card: the link is what is
   pressed and what carries the target size, and this way the one press state in
   the list does not depend on :has(). Not a shadow change either - every
   elevation token is a navy alpha and --shadow-1 to --shadow-2 measures about
   1.10:1 at the peak of the blur, which is nothing.

   Under reduce the transform simply does not happen: nothing here is the only
   channel, because activating a link navigates. */
@media (prefers-reduced-motion: no-preference) {
  .job-addr { transition: transform var(--t-fast) var(--ease); }
  .job-addr:active { transform: translateY(1px); }
}

.job-meta {
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
  color: var(--muted);
}

/* 6rem is 192px at 200% text, which is 69% of a 288px card -- the price was
   drawn on top of the address on every row. The stable column is worth having
   while there is room for it and is not worth the address. */
@media (max-width: 15rem) {
  .job { grid-template-columns: minmax(0, 1fr); }
  .job-price { min-width: 0; justify-self: start; }
}
.job-price {
  justify-self: end;
  /* The same 48px box .job-addr carries, with the figure centred in it. Both
     start at the card's top padding, so the price sits on the address's own
     line whether the address runs to one line or four - which grid alignment
     alone cannot do, since `start` would hang the figure 9px above the line
     and `center` chases the height of the whole row. */
  display: flex; align-items: center; justify-content: flex-end;
  min-height: var(--tap);
  /* A stable column so the figures line up card to card. Tabular numerals only
     pay off against a fixed right edge. Same device as .kv's minmax(6rem,auto).
     6rem holds £999,999 at --fs-lead. */
  min-width: 6rem;
  text-align: right;
  font-weight: 700;
  color: var(--ink);
  font-size: var(--fs-lead);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--track-num);
}
/* "Not priced" is the absence of the most valuable thing on the row, so it must
   not be set like a price. This rule only ever existed in scope.css, which
   loads on /new alone, and /new renders no job list: so on the two pages that
   DO render one it has never applied, and "Not priced" has been shipping at
   19px/700/--ink, indistinguishable in weight from £4,200 two rows up. */
.job-unpriced {
  font-size: var(--fs-meta);
  font-weight: 400;
  color: var(--muted);
}

/* The badge is 24px tall in a 48px line box, so half the difference lands it on
   the address's line beside the price. align-self: center would put it back to
   chasing the height of the whole row, which is what .job's `start` above
   exists to stop. */
.job > .pill { justify-self: end; align-self: start; margin-top: var(--s3); }

.job-more { margin: var(--s4) 0 0; font-size: var(--fs-meta); color: var(--muted); }

@media (max-width: 32rem) {
  /* The status badge drops below the price rather than squeezing the address.
     .job-main spans both rows, or row 2 column 1 is a hole: the badge sat alone
     on the second row with nothing to its left on every card in the list. Both
     rows stay `auto` deliberately - a 1fr second row would take the spanning
     item out of track sizing, and a four-line address would then overflow the
     card it is supposed to be making taller. */
  .job { grid-template-columns: minmax(0, 1fr) auto; }
  .job-main { grid-row: 1 / span 2; }
  .job > .pill { grid-column: 2; justify-self: end; margin-top: 0; }
}

/* ---- the job list and the filter bar in forced colours -------------------
   background and box-shadow are both DISCARDED here, so a card whose entire
   boundary is a fill plus an elevation flattens into one undivided run of text:
   200 addresses, 200 prices and 200 badges with nothing saying where one
   estimate stops. border-width and border-style survive, so the grouping is
   restated in the user's own two colours rather than ours. */
@media (forced-colors: active) {
  /* .empty is deliberately NOT in this list, for the same reason .card is not:
     it now carries a real --rule-soft border, and border-width and border-style
     are the two things this mode cannot take away. Only a panel whose whole
     boundary is a fill plus an elevation needs restoring here. */
  .job, .searchbar { border: var(--bw) solid; }
}

/* ---- settings -----------------------------------------------------------
   `.ratefs` and `.ratefs legend` were defined TWICE in this file, here and
   again under "rate rows in setup". The second block won on size, colour,
   padding and margin but did not reset text-transform or letter-spacing, so
   what actually rendered was a fourth thing neither block described: 16px,
   700, uppercase, tracked 0.08em, --ink. Both definitions now live at the one
   site below, stated explicitly. */
.rate-line {
  display: grid;
  /* minmax(0, ...) on both outer tracks, never a bare `1fr` beside a bare
     `7rem`. `1fr` is `minmax(auto, 1fr)`, so track 1 floored at the label's
     longest word, and 7rem is 112px of pure rigidity. The fixed cost of this
     row is 8 + about 9 for the £ + 8 + 112 = 137px; a .card on a 320px screen
     gives it 248px, so the longest word in a label had 111px and no more.
     "Scaffolding," is 88px and fits; "Weatherproofing" or "Reinstatement" does
     not, and neither does any of it at 200% text zoom, which is where this row
     was one added service away from pushing the settings page sideways.
     With both floors at 0 the label track keeps the same 111px whenever there
     is room - the fr track still takes what the other two leave - and gives way
     instead of the page when there is not. */
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 7rem);
  /* A third row for the error message, added with it. Without a named area the
     new <p> was auto-placed into the null cell at row 2 column 1 -- the ~111px
     LABEL track -- so a 70-character message rendered to the LEFT of "Scope:
     £15" despite following it in the DOM, wrapping to five or six lines on a
     320px card. Visual order contradicting DOM order is the exact fault the
     comment above this rule is about. Full width, because a sentence is not a
     figure and has nothing to align to. */
  grid-template-areas: "label pre input" ".  shipped shipped" "state state state";
  align-items: center; gap: var(--s1) var(--s2);
  padding: var(--s3) 0;
  border-bottom: var(--bw) solid var(--hair);
}
.rate-line:last-child { border-bottom: 0; }

/* THE ERROR MESSAGE, WHICH RENDERED AS PLAIN BODY TEXT.
 *
 * `.state.is-error` is styled in two places and neither reached here.
 * app.css:999 scopes it to `.field .state`, and these paragraphs live inside
 * `.rate-line` and `.jobpicker`; scope.css has a bare `.state.is-error` but the
 * shell loads only tokens.css and app.css, and neither /welcome/prices nor
 * /settings/jobs pulls scope.css in.
 *
 * So the message rendered in default ink at default weight, indistinguishable
 * from the "Scope: £15" hint beside it, and the ONLY thing marking the failure
 * was the input's red border -- colour alone, which is 1.4.1. The whole point
 * of adding these messages was that the wizard used to swallow the failure in
 * silence; rendering them as ordinary prose only moves the silence.
 *
 * --error is 8.67:1 on the readout and 7.92:1 on the chassis, and the weight
 * carries it independently of hue for anyone who cannot separate the two. */
.rate-line .state,
.jobpicker > .state {
  grid-area: state;
  margin: var(--s1) 0 0;
  font-size: var(--fs-meta);
  font-weight: 400;
  color: var(--muted);
}
.rate-line .state.is-error,
.jobpicker > .state.is-error {
  color: var(--error);
  font-weight: 600;
}

/* The error summary sends focus here with target.focus(), and app.css:944
   already records why the global ring cannot help: browsers do not match
   :focus-visible for programmatic focus on a non-text element. So a sighted
   keyboard or switch user followed a summary link and landed on a fieldset of
   forty-three checkboxes with nothing on screen saying where they were. Plain
   :focus, the same treatment .errsum:focus gets and for the same reason. */
.jobpicker:focus {
  outline: var(--bw-strong) solid var(--navy);
  outline-offset: 4px;
  border-radius: var(--r);
}
/* The track can shrink, so the item in it must be allowed to as well: a grid
   item keeps its own `auto` floor whatever the track does, and a label wider
   than its track paints over the £ and the input beside it. With this it wraps
   to two lines under tokens.css's break-word, which is what a rate row on a
   phone should do. The input needs no such rule: it is width: 100% of a track
   that is now itself bounded, so it follows the track down. */
.rate-line > label { grid-area: label; min-width: 0; font-size: var(--fs-meta);
                     font-weight: 600; color: var(--ink); margin: 0; }
.rate-line .hint { display: block; font-weight: 400; font-size: var(--fs-small);
                   color: var(--muted); }
.rate-pre { grid-area: pre; color: var(--muted); }
.rate-in, .svc-in { grid-area: input; text-align: right; }
/* The shipped figure, so he can always see what he has moved away from. */
/* `shipped`, not `default`. `default` is reserved by css-values-3 3.1.1 and is
   NOT a valid <custom-ident>, so `grid-area: default` was dropped as invalid and
   this element auto-placed into the label track -- the exact fault the comment
   at the grid-template-areas above believes it closed. justify-self on the same
   line kept applying, which is why one bad declaration went unnoticed.
   Verified in Edge: computed gridColumnStart was "auto" before, "shipped" now. */
/* STACKS once the input alone eats the row.

   .rate-line's fixed cost is the 7rem input plus the pound sign plus two gaps.
   At 200% text that is 258.2px against the 246px a 320px phone gives the card,
   and non-flexible tracks are sized before the 1fr gets anything -- so the
   label track is guaranteed 0px and "Rate per square metre" was set as
   eighteen single-character lines in a 901px-tall row. The comment above is
   right that the label must give way rather than the page; this is where it
   gives way to a second line instead of to nothing.

   15rem is this file's own idiom for "the text has grown past what this row can
   hold": 240px at 100% and 480px at 200%, so at 320px it engages only once the
   reader has enlarged the text past about 133%. */
@media (max-width: 15rem) {
  .rate-line {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas: "label label" "pre input" ".  shipped" "state state";
  }
}
.rate-default { grid-area: shipped; justify-self: end; font-size: var(--fs-small); }

.logo-row { display: flex; gap: var(--s5); align-items: flex-start; flex-wrap: wrap; }
/* The stack needs a basis, or it can never share the row. It had none, so its
   flex base was `auto` = the max-content width of a file input plus a button,
   which at 390 is 312px against the 136px the 160px preview and its 20px gap
   leave: below its own min-content, so it wrapped underneath every single time.
   Measured div.logo-row h=265.7 for one optional setting, three stacked rows
   deep, whose single largest element (160 x 88 = 14,080 px) is an empty box
   saying there is nothing to preview. min-width: 0 so the basis is a target and
   not a floor. */
.logo-row .stack { flex: 1 1 12rem; min-width: 0; }
/* IT SIZES TO WHAT IS IN IT, between a floor and the ceiling it always had.

   This was a rigid 160 x 88 whatever it held, and what it usually holds is the
   words "No logo yet": the single largest element in the whole control was an
   empty box saying there is nothing to preview, and with the stack beside it
   unable to fit the 136px left over, the row was three stacked lines and 265.7px
   tall for one optional setting.

   Sized rather than switched with a modifier class, and that is the load-bearing
   choice. The obvious answer is a class from settings.html's own
   `{% if s.logo %}`, which is deterministic where :has() is not - but
   settings.js:108-115 empties this element and appends an <img> to it in place,
   on the page, without touching its class list. Any state written into the class
   would still be there under the logo he had just uploaded, and it would shrink
   the box around it. A box that measures its contents cannot go stale, because
   there is no second copy of the fact to keep in step.

   Measured: empty it lays out at about 97px, so with the 20px gap it leaves
   199px against the stack's 192px basis at 390 and the two share a row; at 320
   it leaves 129px and the stack still wraps, which is correct. With a logo the
   ceiling holds it at the 160px it has always been, so the populated state does
   not move at all. */
.logo-preview {
  min-width: calc(var(--tap) * 2);   /* 96px, so an empty box is still a panel */
  max-width: 160px;                  /* unchanged: what the rigid width was */
  min-height: var(--tap-lg);
  display: grid; place-items: center;
  padding: var(--s3);
  border: var(--bw) dashed var(--hair-strong);
  border-radius: var(--r);
  background: var(--sunk);
}
.logo-preview img { max-height: 64px; width: auto; }
input[type="file"] {
  min-height: var(--tap); padding: var(--s2);
  border: var(--bw) solid var(--line); border-radius: var(--r);
  background: var(--card); width: 100%;
}

.posture-set { display: grid; gap: var(--s2); }
.posture-opt { position: relative; }
.posture-opt input {
  /* Clipped, never display:none - that removes it from the accessibility tree
     and from the tab order. */
  position: absolute; opacity: 0; width: 1px; height: 1px;
}
.posture-opt label {
  display: grid; gap: 1px;
  min-height: var(--tap); padding: var(--s3) var(--s4) var(--s3) var(--s8);
  /* --rule-soft, not --hair-strong. The native radio is clipped and THIS is the
     visible control, so its edge is a graphical object that has to reach 3:1 --
     and --hair-strong resolves to --construction #DDE4EC, which is 1.28:1 on
     this white card. An unchecked option had no perceivable boundary at all: a
     low-vision user could not see that there were three of them, or where one
     ended. The checked state was always fine at 11.17:1, so what was missing
     was not the STATE but the control.
     tokens.css says this outright where --construction is defined: rows inside
     an already-bounded surface only, never a control. This is a control.
     --rule-soft is 3.60:1. */
  border: var(--bw) solid var(--rule-soft); border-radius: var(--r);
  cursor: pointer; background: var(--card);
  transition: border-color var(--t-fast) var(--ease),
              background var(--t-fast) var(--ease);
}
/* The mark, so selection is carried by shape as well as by weight and colour. */
.posture-opt label::before {
  content: ""; position: absolute; left: var(--s4); top: 50%;
  width: 18px; height: 18px; margin-top: -9px;
  /* --rule (5.99:1) rather than --rule-soft on the card, because this is the
     smaller mark of the two and 18px of ring wants the headroom. */
  border: var(--bw-strong) solid var(--rule); border-radius: 50%;
  background: var(--card);
}
.posture-opt input:checked + label {
  border-color: var(--navy); border-width: var(--bw-strong);
  padding: calc(var(--s3) - 1px) calc(var(--s4) - 1px)
           calc(var(--s3) - 1px) calc(var(--s8) - 1px);
}
.posture-opt input:checked + label::before {
  border-color: var(--navy);
  box-shadow: inset 0 0 0 4px var(--card), inset 0 0 0 12px var(--navy);
}
.posture-opt input:focus-visible + label {
  outline: 2px solid var(--navy); outline-offset: 2px;
}
.posture-name { font-weight: 700; color: var(--ink); }
.posture-note { font-size: var(--fs-small); color: var(--muted); }

/* ---- the sticky action bar ---------------------------------------------- */
.actionbar {
  position: sticky; bottom: 0; z-index: 30;
  background: rgba(255, 255, 255, .9);
  border-top: var(--bw) solid var(--rule);   /* 3.15:1; --hair was 1.60:1 */
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
@supports (backdrop-filter: blur(6px)) {
  .actionbar { background: rgba(255, 255, 255, .84); backdrop-filter: blur(10px); }
}
.actionbar-in {
  /* --page-measure, so the bar holds the same column as the form it submits.
     See tokens.css. Was --col, with the narrow pages listed by name below. */
  max-width: var(--page-measure); margin: 0 auto;
  padding: var(--s3) var(--s4);
  display: flex; align-items: center; gap: var(--s4);
  /* NOT flex-wrap: wrap, and the reason is worth recording because it is the
     obvious fix and it is wrong.

     This row overflows at 200% TEXT ZOOM on /welcome/prices: two rigid controls
     ("Back to Your details" and "Continue"), 372px of them against 320px of
     glass, with Continue - the only way out of setup - hanging 52px off the
     edge. flex-wrap closes it exactly. It also restacks the bar at 100% on a
     320px screen, because wrapping is decided on flex BASE sizes and both
     buttons base at max-content: measured, the row went from one line to two at
     normal text size, which is a visible design change to a screen rather than
     an overflow fix.
     Shrinking instead of wrapping does not get there either - min-width: 0 on
     both the status and the buttons leaves 327px, still 7px over, because
     "Continue" at 200% is 200px on its own and a broken primary label is worse
     than the fault.
     The answer that is neither is the media query below. */
}
.actionbar-in .btn { margin-left: auto; }

/* THE 200% TEXT CASE, closed at last, and 15rem is the whole trick.

   Re-measured with Page.setFontSizes {standard: 32}, which is a text-only
   resize and what 1.4.4 asks for: /welcome/prices at 320 reported
   scrollWidth 372 against clientWidth 320, sole offender
   button.btn.btn-primary.btn-lg at right 372.4 - Continue, the only way out of
   setup, hanging 52px off the glass. /welcome/ready reported 328 against 320.
   /settings and /login measured clean at the same setting.

   An MQ `rem` resolves against the browser's DEFAULT font size, never against
   the page's. So at 100% text 15rem is 240px and no phone in the fleet matches;
   at 150% it is 360px and a 320 phone stacks while a 390 does not; at 200% it is
   480px and both stack. The breakpoint tracks the text, which is exactly what a
   text-overflow fix needs and what a viewport-sized number cannot do - 26rem was
   the obvious value and it is 416px at 100%, which would restack the bar at
   NORMAL size on a 390px phone: the visible design change the comment above
   refuses.

   .page-setup only. This bar is also /new's, where step 1 fits a 320x640 phone
   with 0.3px to spare, and a blanket rule would add a row to it.

   No `order`. Putting the primary first would set visual order against DOM and
   focus order in a two-control bar (1.3.2 / 2.4.3) to save one thumb reach. It
   stacks as it reads: Back, then Continue. And not a clip: rule 9. */
@media (max-width: 15rem) {
  .page-setup .actionbar-in { display: grid; grid-template-columns: 1fr;
                              gap: var(--s3); }
  .page-setup .actionbar-in .btn { margin-left: 0; width: 100%; }
}

/* THE BAR HOLDS THE SAME COLUMN AS THE FORM ABOVE IT.

   Measured on /login at 1440: the fields ran x = 416 to 1024 and the Sign in
   button that submits them ran x = 240 to 1200 - 176px past the form on each
   side, with its left edge lined up with nothing. main.narrow is
   var(--col-narrow) = 40rem and this bar was var(--col) = 62rem, so the two
   diverge the moment the viewport passes 672px. It was never noticed because
   /new, where this bar was designed, is not narrow.

   body carries page-{{ page }} from _shell.html, so this is a plain class
   selector and needs no :has(). reset.html and forgot_sent.html both set
   page = 'forgot', so they are covered by the third name here. */
/* Keyed off the class the page actually chose, not a list of page names.
   `narrow` is set by {% block main_class %} in login, signup, forgot, reset,
   settings, setup, account, billing and account_close; _shell.html copies it
   onto <body> so the three containers outside <main> can see it. The list this
   replaces already had /account, /billing and /welcome/close missing. */
.body-narrow { --page-measure: var(--col-narrow); }
.save-state { margin: 0; }
.save-state.is-ok { color: var(--ok-ink); font-weight: 600; }
.save-state.is-error { color: var(--error); font-weight: 600; }

/* ---- forced colours -----------------------------------------------------
   Every selected state above leans on a tint somewhere, and a tint is simply
   not rendered here. Each one needs a border to survive. */
/* `.pill { forced-color-adjust: none }` used to be here, and it was wrong on
   three counts.

   It produced a measured failure. `.pill-draft` has no background of its own,
   so opting out of the forced palette left --muted #495B69 sitting on Canvas
   #000000 — 2.98:1 on a High Contrast Black theme. The opt-out created the
   contrast failure it was meant to prevent.

   It overruled the user. Someone running a contrast theme has told the OS they
   cannot work with our colours; preserving 1.12:1 red-versus-green inside
   forced colours hands them back exactly what they turned it on to escape.

   And it was unnecessary. Forced colours override colour properties but never
   border-style or border-width — so the badge's dashed/1px/2px ladder comes
   through untouched, the glyphs come through because they are stroked in
   currentColor, and the word comes through because it is a word. Two of the
   three channels survive on their own.

   Only `won`'s polarity needs restating, and it can be restated in the user's
   own two colours rather than ours, which is the point of the mode. */
@media (forced-colors: active) {
  .posture-opt input:checked + label,
  .nav a[aria-current="page"],
  .card { border: var(--bw-strong) solid; }

  .pill-won {
    background: CanvasText;
    color: Canvas;
    border-color: CanvasText;
  }
}

/* ---- the estimate flow's own header -------------------------------------
   MOVED TO scope.css, and moved rather than copied.

   .flowbar is rendered by index.html and by nothing else, and it is now the
   second surface of the navy field rather than a strip of chassis between the
   bar and the hero. It bleeds by cancelling /new's own main box, which is
   var(--s4) top and var(--gutter) each side; main everywhere else is var(--s6)
   and var(--s4). A copy here could only ever cancel one of the two, and the
   one it would get wrong is the only page that renders it.

   The rules that went with it are not worth reviving either. .flow-back
   carried margin-left: calc(var(--s5) * -1) against a 16px gutter, which is
   the one negative margin in the product that did not cancel its container:
   the button's border box started at x = -4px and its focus ring painted to
   -12px, where body { overflow-x: hidden } threw the ring away. A clipped
   indicator on the flow's Back control is a 2.4.13 failure, and it is fixed by
   the button sitting inside a padded surface instead of being pulled out of
   one, not by widening anything. The chevron takes tokens.css's `.btn svg`
   18px now, with every other button icon in the product; the 20px it had here
   was a raw pair nobody recorded a reason for. */

/* ---- first run -----------------------------------------------------------
   The empty state IS the page when there is nothing to list, and it is the same
   page: the same dark band, the same headline rung, the same white .home-start.
   It used to be a centred 52px mark at .55 opacity over a navy pill, so pricing
   the first roof carried the roofer out of one visual system and into another
   at the single most important moment in the product. What is missing on an
   empty account is the LIST, and that is all that should be missing.

   Still exactly one action here, which is what an empty state is for: no job
   cards, no sample rows, no zeroed charts. */
.home-empty-note { margin: var(--s7) 0 0; }

/* ==========================================================================
   The locator — shown while a property is being found
   --------------------------------------------------------------------------
   The wait is two live lookups and a Monte Carlo, so it runs to seconds. A
   spinner would say only "something is happening"; this says WHAT is happening,
   in the order it happens, over the grid the measurement actually comes from.

   The reticle is the Scope mark doing the thing the mark depicts.
   ========================================================================== */
.locator {
  display: grid; justify-items: center;
  padding: var(--s7) var(--s4) var(--s8);
  text-align: center;
}
.locator-map {
  width: min(160px, 42vw); height: auto;
  color: var(--navy);
  margin-bottom: var(--s5);
}
.locator-grid { color: var(--navy); opacity: .5; }
.locator-gb {
  fill: var(--sunk);
  stroke: var(--navy);
  stroke-width: 1.4;
  stroke-linejoin: round;
  /* Drawn on, once, as the map arrives. */
  stroke-dasharray: 1400;
  stroke-dashoffset: 1400;
}
.locator-sweep {
  stroke: var(--amber); stroke-width: 1.2; opacity: .55;
}
.locator-scan { opacity: 0; }
/* Where the reticle lands is set per property, in scope.js, by projecting the
   coordinate the selected address already carries. The default is Thanet, and it
   is computed through the same projection that drew the coastline rather than
   nudged until it looked right. Not centred: a reticle in the middle of the
   North Sea would be telling the wrong story. */
.locator-fix {
  opacity: 0;
  transform: translate(var(--fix-x, 178px), var(--fix-y, 249px));
}
/* No coordinate - a postcode alone is one point for a whole street. Rather than
   pin a place we have not established, the sweep runs on its own. */
.locator[data-fix="none"] .locator-fix { display: none; }
.locator-ring {
  fill: none; stroke: var(--amber); stroke-width: 1.6; opacity: 0;
}
.locator-cross { stroke: var(--navy); stroke-width: 2; stroke-linecap: round; }
.locator-dot { fill: var(--amber); }

.locator-phase {
  font-size: var(--fs-lead); font-weight: 600; color: var(--ink);
  margin: 0 0 var(--s1);
}
.locator-sub { margin: 0; }

/* The whole sequence is timed to fit inside a real lookup - measured at 2.0s
   cold, 0.3s cached - and the locator itself only appears after 200ms. So there
   is about 1.8s to play with, and every beat has to land inside it. The earlier
   version ran to 3.2s and the pin arrived after the answer had. */
@media (prefers-reduced-motion: no-preference) {
  .locator-gb { animation: draw 600ms var(--ease) forwards; }
  @keyframes draw { to { stroke-dashoffset: 0; } }

  /* One pass, handing straight over to the fix as it clears the map. */
  .locator-scan { animation: scan 900ms var(--ease) 150ms 1 both; }
  @keyframes scan {
    0%   { opacity: 0; transform: translateY(10px); }
    12%  { opacity: 1; }
    88%  { opacity: 1; }
    100% { opacity: 0; transform: translateY(290px); }
  }

  .locator-fix { animation: fix 400ms var(--ease) 1s both; }
  @keyframes fix {
    from { opacity: 0;
           transform: translate(var(--fix-x, 178px), var(--fix-y, 249px)) scale(2.4); }
    to   { opacity: 1;
           transform: translate(var(--fix-x, 178px), var(--fix-y, 249px)) scale(1); }
  }
  /* Two rings, offset, so the pulse reads as a repeating fix rather than a
     single event that has finished. */
  .locator-ring { animation: ping 1.8s var(--ease) 1.3s infinite; }
  .locator-ring-2 { animation-delay: 2.2s; }
  @keyframes ping {
    0%   { opacity: .8; transform: scale(.35); }
    70%  { opacity: 0; transform: scale(1.5); }
    100% { opacity: 0; transform: scale(1.5); }
  }
}

/* Without motion the map is simply drawn, with the fix already placed. The
   phase text carries everything the animation would have said. */
@media (prefers-reduced-motion: reduce) {
  .locator-gb { stroke-dashoffset: 0; }
  .locator-fix { opacity: 1; }
}

/* ==========================================================================
   Sign in, sign up, setup and account
   These pages load tokens.css + app.css only. scope.css belongs to the
   estimate flow and is not here, so anything shared has to live in this file.
   ========================================================================== */

/* ---- skip link ----------------------------------------------------------
   The markup finally exists in _shell.html; these rules move here from
   scope.css because that file only loads on /new, and a skip link with no
   rule to reveal it takes focus off-screen and is never seen. */
.skip {
  position: absolute;
  left: -9999px;
  z-index: 60;
  background: var(--card);
  color: var(--navy);
  border: var(--bw-strong) solid var(--navy);
  border-radius: var(--radius);
  font-weight: 700;
  text-decoration: underline;
}
/* :focus, not :focus-visible. The RING comes from the global :focus-visible
   rule; REVEALING the link must never depend on a modality heuristic. */
.skip:focus {
  position: static;
  left: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-lg);
  /* Not flush to the edge: the global focus treatment paints an outline plus a
     4px halo about 6px outside the box, and body{overflow-x:hidden} clips
     anything past the viewport edge — so a flush link loses its ring. */
  margin: var(--s2) var(--s4);
  padding: 0 var(--s4);
}

/* Programmatic focus target, never in the tab sequence, so not a user
   interface component and no indicator is required. A navy ring round the
   whole content column is noise. */
main[tabindex="-1"]:focus,
main[tabindex="-1"]:focus-visible { outline: none; box-shadow: none; }

/* ---- error summary -------------------------------------------------------
   Focused on load. See _error_summary.html for why it is not a live region. */
.errsum {
  border: var(--bw-strong) solid var(--error);
  border-left-width: 8px;   /* widest rung: a failure */
  /* Style, not just hue. --ok-ink, --amber-ink and --error sit 1.08 to 1.21:1
     apart, so desaturated -- greyscale, sunlight, a printout -- these three
     blocks are indistinguishable rectangles. Each bar is drawn differently
     below, which is the channel the .pill fix used and this family never got. */
  background: var(--error-wash);
  border-radius: var(--radius);
  padding: var(--s4) var(--s5);
  margin: 0 0 var(--s6);
}
/* Plain :focus, NOT :focus-visible. This is focused programmatically on a page
   load, and browsers do not match :focus-visible for programmatic focus on a
   non-text element — so the global ring would never draw and a sighted
   keyboard user would have focus sitting somewhere nothing marks. This is the
   one place in the app where :focus is the correct selector. */
.errsum:focus {
  outline: var(--bw-strong) solid var(--navy);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(255, 255, 255, .9);
}
.errsum h2 {
  display: flex;
  align-items: center;
  gap: var(--s2);
  margin: 0 0 var(--s2);
  /* --fs-h2 (18), not --fs-h3, which aliased to --fs-body - so the line
     announcing that the form failed was set at exactly the size of the errors
     listed under it. */
  font-size: var(--fs-h2);
  color: var(--error);
}
.errsum-icon { width: 22px; height: 22px; flex: none; }
.errsum ul { margin: 0; padding: 0; list-style: none; }
.errsum li { color: var(--error); font-weight: 600; }
/* These are discrete targets stacked in a list, not links inside a sentence,
   so the inline exception does not cover them. A 25px line box in a list of
   three is a miss with a glove on. */
.errsum a {
  display: flex;
  align-items: center;
  min-height: var(--tap);
  padding: var(--s1) var(--s2);
  margin-left: calc(var(--s2) * -1);
  border-radius: var(--r-sm);
  color: var(--error);
  font-weight: 600;
  text-underline-offset: 3px;
}
/* --error-tint, not rgba(152, 17, 15, .07), which is that token written out
   longhand. This file carries no values of its own; that rule is real and this
   line was breaking it in the one notation an audit for `#` walks straight
   past. tokens.css declares the tint and says it has one call site. This is it.
   No pixel moves. */
.errsum a:hover { background: var(--error-tint); }

/* ---- per-field state -----------------------------------------------------
   Always in the DOM so aria-describedby never dangles; :empty hides the box
   without removing the node. */
/* NEUTRAL by default. This span carries two different things: a validation
   error on the auth forms, and a provenance note on the estimate flow — "pitch
   assumed for age band 2010-2019, no local measurements". Colouring the class
   red made every measurement on step 3 look like a failure, which is the exact
   opposite of what those notes are for: they are the app being honest about
   how it knows each figure.
   Red is now opt-in, on .is-error, which the field macro sets only when the
   server actually rejected something. */
.field .state {
  margin: 0;
  font-size: var(--fs-meta);
  font-weight: 400;
  color: var(--muted);
  display: flex;
  align-items: flex-start;
  gap: var(--s1);
}
.field .state:empty { display: none; }
.field .state.is-error { color: var(--error); font-weight: 600; }
.field .state svg { width: 18px; height: 18px; flex: none; margin-top: 2px; }
/* _field.html renders this span on every field of every form and no rule has
   ever selected it. Harmless today, and one long single token away from not
   being: .field .state is a flex row with an 18px svg beside it, so without a
   floor of 0 the text item floors at its own min-content and one unbroken
   server message widens the row past the card. min-width: 0 is the fix and
   overflow: hidden would be the concealment - rule 9. */
.field .state-text { min-width: 0; }

/* Three signals, never one: the 2px border already in tokens.css, this icon,
   and the words. In direct sun the hue is the first thing to go and the border
   weight is the first thing that survives. */

/* ---- the throttle warning ------------------------------------------------ */
.warn-note {
  /* ORDER IS THE WHOLE FIX. `border-left-style: double` sat ABOVE the `border`
     shorthand, and a shorthand resets every longhand it covers - so the double
     rule has never once rendered, in any browser, since it was written. Stated
     after the shorthand it finally draws, and caution now differs from failure
     (8px solid) and from success (6px solid) in border STYLE and WIDTH, the two
     properties forced colours cannot take away. The three washes are 1.005:1
     apart and the three inks 1.543:1 apart, so in sun, on a printout, or to a
     red/green-deficient reader, colour was never telling them apart.
     --amber-ink is always defined, so the var() fallback to --warn-text was
     unreachable; dropped. */
  border: var(--bw-strong) solid var(--amber-ink);
  border-left-width: 6px;
  border-left-style: double;
  /* The icon sits on the first line of the text rather than above it, the way
     .errsum h2 arranges its own. */
  display: flex;
  align-items: flex-start;
  gap: var(--s2);
  background: var(--warn-surface);
  color: var(--warn-text);
  border-radius: var(--radius);
  /* The same box as .errsum and .ok-note. Rendered on /login in the state where
     all three appear at once, this one padded 12px/16px and cleared 20px while
     the other two padded 16px/20px and cleared 24px: three notes in one column,
     four pixels out of step with each other on every edge, which is the kind of
     difference nobody names and everybody sees. */
  padding: var(--s4) var(--s5);
  margin: 0 0 var(--s6);
  font-weight: 600;
}

/* ---- show password ------------------------------------------------------- */
.showpw {
  display: flex;
  align-items: center;
  gap: var(--s3);
  min-height: var(--tap);
  margin: calc(var(--s2) * -1) 0 var(--s5);
}
.showpw input {
  width: 24px; height: 24px;
  flex: none; margin: 0;
  accent-color: var(--navy);
}
.showpw label {
  display: flex;
  align-items: center;
  flex: 1;
  min-height: var(--tap);
  font-size: var(--fs-meta);
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
}

/* ---- one action, in the thumb zone --------------------------------------
   Full width rather than right-aligned like settings: it removes the
   horizontal axis from the aiming problem entirely, and horizontal precision
   is the first thing a glove takes away. */
.actionbar-in.is-single { display: block; }
.actionbar-in.is-single .btn { width: 100%; margin-left: 0; }

/* ---- sign out, in the nav ------------------------------------------------ */
.nav-signout { display: contents; }
.linklike {
  background: none;
  border: 0;
  font: inherit;
  /* Was `padding: 0`, which left this at roughly a 23px box — under even the
     24px conformance floor, let alone the 44px this app holds itself to, on the
     one control that appears on every page.
     `.nav a` supplies min-height to its siblings but cannot reach here: that
     selector matches anchors, and this is the one nav item that has to be a
     button because signing out changes state. And `.nav-signout` is
     display:contents, so the button becomes a flex item of an align-items:center
     row and does not stretch to match. So the measure is restated here. */
  display: inline-flex;
  align-items: center;
  min-height: var(--tap);
  padding: 0 var(--s3);
  border-radius: var(--r-sm);
  font-size: var(--fs-meta);
  font-weight: 600;
  color: var(--muted);
  cursor: pointer;
  text-decoration: none;
}

/* ---- setup steps --------------------------------------------------------- */

/* THE /welcome STEPPER, and it has never had a rule on this side of the app.

   setup.html emits `.progress > ol > li > .dot + .lbl` and every rule for those
   names lives in scope.css, which loads on /new alone. Measured on /welcome at
   390 with only tokens.css and app.css loaded: `.progress ol` computed
   `display: block`, `list-style-type: decimal`, `padding-left: 40px`; each
   `.progress .dot` computed 0px x 17px, `display: inline`, transparent; the
   completed `a.lbl` computed rgb(0,0,238), underlined, in a box 82.1 x 17.0;
   and `li[aria-current="step"]` computed NO difference at all from its
   siblings. So the app's front door painted "1. Your details / 2. Your prices /
   3. How it works" as an indented numbered list, and which step he was on was
   conveyed to a screen reader and to nobody else - 1.4.1, on the first three
   screens a new roofer ever sees.

   NOT scope.css's rules moved across, which was the obvious answer and is
   wrong: `.progress .lbl` there is a visually-hidden clip, correct on /new
   where the heading under the rail repeats each step in full, and fatal here
   where those three words are the only thing naming the three screens. Two
   components with one name, and neither moves.

   TWO CHANNELS on the current step, width and ink-plus-weight, so nothing is
   carried by hue alone. --rule-soft is 3.29:1 on the chassis and --amber
   3.06:1, both clear of the 3:1 a graphic that carries meaning owes.
   --construction on the unreached segments carries nothing, which is correct: a
   step you have not reached is not information, and it is inside a bounded row
   rather than on a surface edge, which is the one place tokens.css:67 allows it.

   IT WRAPS, and that is the anti-clip answer rather than a shrink. Measured in
   Archivo at --fs-meta the three labels set 78.0, 68.8 and 78.2px; with the
   dots and the gaps the row needs about 333px against the 343px of content a
   375px phone gives main.narrow and the 288px a 320 gives it. Shrinking the
   labels breaks "How it works" across two lines mid-item; wrapping moves whole
   steps onto a second row and keeps every one of them intact. Measured after:
   one row at 375 and 390, two rows at 320, and scrollWidth == clientWidth at
   all three. Never overflow: hidden - rule 9.

   The two gaps are 4 and 12 rather than one number, and the 3:1 between them is
   what says which dash belongs to which word. At 8 and 16 the same three steps
   needed 353px and wrapped on a 375px screen, which is the SE 2 and 3 and the 6,
   7 and 8 body - this audience's own phone.

   The dot sizes are scope.css's, --s5 unreached and --s7 current, taken from
   there deliberately: a roofer meets this stepper and then the flow's rail
   inside the same session, and two segment sizes on two rails would read as two
   components rather than one idea.

   `section.step` deliberately gets NO rule here. scope.css's version carries
   only a focus reset and the flow's entrance state, and on /welcome the section
   is the last block on the page with nothing to space it from, so importing
   either would be importing /new's behaviour rather than answering a need. */
.progress ol {
  display: flex; flex-wrap: wrap; align-items: center;
  column-gap: var(--s3); row-gap: var(--s2);
  list-style: none; margin: 0 0 var(--s5); padding: 0;
}
.progress li {
  flex: none;                       /* a step wraps whole or not at all */
  display: flex; align-items: center; gap: var(--s1);
  min-height: var(--s6);            /* 24px, 2.5.8 */
}
.progress .dot {
  display: block;                   /* it is a <span>: inline had it 0px wide */
  width: var(--s5); height: var(--s1);
  border-radius: var(--r-pill);
  background: var(--construction);
}
.progress li[data-done] .dot { background: var(--rule-soft); }
.progress li[aria-current="step"] .dot { width: var(--s7); background: var(--amber); }
.progress .lbl {
  display: inline-flex; align-items: center;
  min-height: var(--s6);            /* 24px, 2.5.8. Each label is the whole
                                       content of its <li>, so it is not a link
                                       inside a sentence and takes no inline
                                       exception. */
  font-size: var(--fs-meta); color: var(--muted);          /* 5.48:1 */
}
/* The underline on a completed step is left to the user agent on purpose. Only
   the reached steps are anchors, and with the ink now matching its siblings the
   underline is the one thing saying this word is a way back. */
.progress li[aria-current="step"] .lbl { color: var(--ink); font-weight: 700; }

.step-of {
  margin: 0 0 var(--s1);
  font-size: var(--fs-meta);
  font-weight: 600;
  color: var(--muted);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
}
.howto { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--s4); }
.howto li {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--s3);
  align-items: start;
}
.howto b { display: block; }
.howto .n {
  display: flex; align-items: center; justify-content: center;
  width: 32px; height: 32px;
  border-radius: 50%;
  /* --readout and not #fff, which was the ONE raw hex left in this file: a
     comment-stripped scan of app.css and scope.css found exactly one. The two
     are the same value today, so nothing moves; the fault is that these
     numbered circles would not follow the token layer the next time the readout
     does, and a `grep --readout` audit walks straight past a literal. */
  background: var(--navy); color: var(--readout);
  font-weight: 700; font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
}

/* ---- rate rows in setup --------------------------------------------------
   The ONE definition of this legend. What shipped was inherited rather than
   chosen: tracked caps at 16px, on content that is not tag-shaped.
   settings.html and _setup_prices.html put "Strip and re-cover roof", "Roof
   clean" and "Extras" in here, and "STRIP AND RE-COVER ROOF" at 16px with
   0.08em tracking is roughly 258px of shouting. The design uses tracked caps
   only at 9 to 12px and only for two-word tags. So: the section-head rung,
   sentence case, and every inherited property stated so the next edit can see
   what it is changing. */
.ratefs { border: 0; padding: 0; margin: 0 0 var(--s6); }
.ratefs legend {
  padding: 0;
  margin: 0 0 var(--s3);
  font-size: var(--fs-h2);          /* 18, the section-head rung */
  font-weight: 700;
  letter-spacing: var(--track-tight);
  text-transform: none;             /* was inherited, never chosen */
  color: var(--ink);
}
.rate-default {
  font-size: var(--fs-small);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* ---- forced colours ------------------------------------------------------
   Every state above leans on a tint somewhere, and a tint is not rendered
   here. Each one needs a border to survive. */
/* `.field .state { forced-color-adjust: none }` was here — the same mistake as
   on .pill, on a harder failure and on the one message a user cannot afford to
   miss. The rule sets color: var(--error) with no background, so opting out
   left #98110F on Canvas #000000: 2.42:1 on a High Contrast Black theme, for
   form error text.

   Nothing is lost by removing it. The claim above that there are three signals
   and never one is what makes it safe: the 2px border from aria-invalid
   survives because forced colours cannot override border-width, the icon
   survives because it is stroked in currentColor, and the words are words. Only
   the hue goes, and the hue was the redundant channel. */
@media (forced-colors: active) {
  /* Longhands. `border:` is a shorthand and resets all four widths, which
     deleted the left bar declared above -- the one non-colour channel these
     notes have, and the only thing telling them apart in the very mode that
     takes every colour away.

     The three widths are the ladder here: 8px failure, 6px caution, 6px
     success. Style is stated per rule rather than once for both, because
     .warn-note's `double` is the other half of the ladder and blanket
     `border-style: solid` is what used to flatten it - in the one mode where
     shape is all that is left. */
  .errsum {
    border-style: solid;
    border-width: var(--bw-strong);
    border-left-width: 8px;
  }
  .warn-note {
    border-style: solid;
    border-width: var(--bw-strong);
    border-left-width: 6px;
    border-left-style: double;
  }
  .skip { border: 3px solid; }
  input[aria-invalid="true"] { border-width: var(--bw-strong); }
  .field .state svg { stroke: currentColor; fill: none; }
}

/* ---- the confirmation note -----------------------------------------------
   The mirror of .errsum: focused on load, never announced. See
   _error_summary.html for why neither of them is a live region.

   This had NO rule at all. account.html and billing.html were both rendering
   "Your password has been changed" as an anonymous paragraph — the only
   confirmation that a security-critical action had worked, styled as body
   text, and with a tabindex="-1" that nothing ever focused. The mechanism was
   written and the two halves that make it work were not. */
.ok-note {
  border: var(--bw-strong) solid var(--ok-ink);
  border-left-width: 6px;
  background: var(--ok-wash);
  color: var(--ok-ink);
  border-radius: var(--radius);
  padding: var(--s4) var(--s5);
  margin: 0 0 var(--s6);
  font-weight: 600;
  /* THE GLYPH, and it is the reason for this row.

     login.html states the contract for this family: "the border ladder is one
     non colour channel and the glyph is the second, and now all three notes have
     both". It was not true here. Rendered on /login in the state where all three
     appear at once and the SVG children counted: .errsum 1, .warn-note 1,
     .ok-note 0. So success and caution were separated by border style alone,
     solid against double at the same 6px, which is close to nothing at arm's
     length in sun - and the three inks (#0B5F3B, #A65D00, #98110F) desaturate to
     within about 1.2:1 of one another, so hue was never telling them apart.

     Laid out the way .errsum h2 and .warn-note already lay theirs out: the tick
     sits on the first line of the sentence rather than above it. The sentence is
     wrapped in a <span> at all four call sites so it is a flex item this can
     reach; without that it would be an anonymous one and no rule could. */
  display: flex;
  align-items: flex-start;
  gap: var(--s2);
}
/* Plain :focus, NOT :focus-visible. Focused programmatically on a page load,
   and browsers do not match :focus-visible for programmatic focus on a
   non-text element — so the global ring would never draw and a sighted
   keyboard user would have focus sitting somewhere nothing marks. The same
   exception .errsum:focus makes, for the same reason. */
.ok-note:focus,
.results-head:focus {
  outline: var(--bw-strong) solid var(--navy);
  outline-offset: 2px;
  border-radius: var(--r-sm);
  box-shadow: 0 0 0 4px rgba(255, 255, 255, .9);
}

/* ---- the results heading -------------------------------------------------
   The fragment target of the filter form's action, so this is where focus
   lands after every search rather than at the top of a page he has read.

   No font-size and no tracking of its own. It is an h2 and it is the same rung
   as "Recent estimates" on the home page, which is the point: the two pages
   list the same rows under headings that were 19px here and 23px there. The
   19px was hand-fitted at --fs-lead because no token held v3's 18px section
   head; --fs-h2 is that token now. */
.results-head {
  margin: 0 0 var(--s4);
  font-weight: 700;
}
/* The clause naming the filters is the qualifier, not the count. Same size, so
   the baseline does not jump mid heading; weight and ink separate them. */
.results-head .results-filters { font-weight: 400; color: var(--muted); }

/* ---- the filter bar ------------------------------------------------------
   One instrument, not three controls loose on the page. v3 puts any group of
   controls that submit together on a single white panel; here that also gives
   the form the only boundary it has ever had. */
.searchbar {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--s3);
  margin: 0 0 var(--s6);
  /* --s5, the padding every other white panel in the app carries. This is a
     .card in everything but name - same fill, same 20px radius, same
     --shadow-1 - and it was the one padded 16, so the controls inside it sat
     4px nearer the edge than the content of the record card one click away. */
  padding: var(--s5);
  background: var(--card);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-1);
  /* A SURFACE edge on --rule-soft (3.60:1 on the white fill). --shadow-1 is a
     0.07-alpha navy and measures under 1.31:1 at its own peak, so on the
     chassis this panel had no boundary a person could point at - only a tint.
     --rule here would be 5.99:1, a text-strength outline round a container,
     which is the heaviness this token split exists to stop. */
  border: var(--bw) solid var(--rule-soft);
}
.searchbar .field { margin-bottom: 0; }
.searchfield { flex: 1 1 16rem; }
/* 9rem, and the missing sixteen pixels are the whole story.

   Two 10rem bases plus this bar's 12px gap need 332px. The panel's content box
   is 316px at 390 and 246 at 320, and under flex-wrap an item whose BASE
   overflows the line moves to the next line BEFORE flex-shrink is allowed to
   act. So "Job type" and "Outcome" each took a full 77.7px row and the bar
   became four full-width rows: measured form.searchbar h=367.1 at 390, against
   277.4px at 430 where the same two selects already share a line. That band is
   the iPhone SE, the 12 and 13 mini and the 12 through 16.

   The 10rem floor's intent is kept, not dropped. It exists so "a select narrower
   than its selected option ellipsises the value": measured in Archivo at the
   select's own 16px the widest option, "All outcomes", is 92.4px, and with 12px
   of padding each side plus a 1px border it needs 118.4px. At 9rem each select
   is laid out at 152px, so it has 33.6px of headroom and scrollWidth confirms
   nothing clips. At 320 they still stack, which is correct against 246px. */
.filterfield { flex: 1 1 9rem; }
.searchbar-actions { display: flex; gap: var(--s2); flex: 1 1 100%; }
.searchbar-actions .btn { flex: 1 1 auto; min-height: var(--tap-lg); }
@media (min-width: 48rem) {
  .searchbar-actions { flex: 0 0 auto; }
  .searchbar-actions .btn { flex: 0 0 auto; }
}

/* tokens.css gives .empty p a var(--s6) bottom margin, which is the room an
   action underneath it needs. /history's "nothing matches" branch has no action
   any more - the Clear filters control it used to carry was the same label to
   the same URL as the one in the filter bar 160px above it - so that margin is
   now 24px of reserved space for nothing, inside a card that already pads 56px
   below it. Stated here rather than in tokens.css because it is a page's use of
   the component and not the component. */
.empty p:last-child { margin-bottom: 0; }

.crumb { margin: 0 0 var(--s3); font-size: var(--fs-meta); }
.outcome-form { display: flex; gap: var(--s3); flex-wrap: wrap; }
/* The same sixteen pixels as .filterfield above, and the identical mechanism:
   two 10rem bases plus the 12px gap need 332px against a 316px card, so "Mark
   won" and "Mark lost" wrapped onto a row each. Measured form.outcome-form
   h=124. At 9rem they lay out at 152px and share one line, which takes the
   record page's document height at 390 from 876 to exactly the 844 of the
   viewport - the page stops scrolling. Labels measured in Archivo 16px/600:
   "Mark won" 70.6px and "Mark lost" 66.9px, so 152px holds either with 37px to
   spare and nothing clips. At 320 they still stack, correctly. */
.outcome-form .btn { flex: 1 1 9rem; min-height: var(--tap-lg); }

@media (forced-colors: active) {
  .ok-note {
    border-style: solid;
    border-width: var(--bw-strong);
    border-left-width: 6px;   /* see the note above; the shorthand ate it */
  }
}

/* ---- the bezel in forced colours ----------------------------------------
   background-color and box-shadow are both discarded here, so the bar
   flattens to Canvas and any shadow-drawn marker vanishes. The current-page
   rule survives because forced colours cannot override border-width or
   border-style, and because weight is not a colour property at all. */
@media (forced-colors: active) {
  .topbar { border-bottom: var(--bw) solid CanvasText; }
  .topbar .nav a { color: LinkText; }
  .topbar .nav a:hover { background: Highlight; color: HighlightText; }
  .topbar .nav a[aria-current="page"] {
    color: CanvasText;
    border-bottom-color: CanvasText;
  }
  .topbar :focus-visible { outline-color: Highlight; }
  /* The mark inside scope-logo.svg is an embedded raster, and this mode does
     not recolour rasters. Every other word in the bar becomes CanvasText and
     the mark stays #062A4A, so on High Contrast Black it is 1.44:1 on Canvas
     and the company's name is gone in the mode that exists so that nothing is.
     Mapping the plaque to Canvas does not help; that is the same disappearance
     with a box drawn round it.

     So the plaque opts out of the forced palette instead. This is the one
     element in the app allowed to keep its own colours, and artwork whose ink
     the mode cannot reach is the case forced-color-adjust was added for. The
     border is what gives it an edge when Canvas is white and the plaque is
     white too. */
  .lockup {
    forced-color-adjust: none;
    background: var(--readout);
    border: var(--bw) solid var(--ink);
  }
}

/* ---- key/value pairs ----------------------------------------------------
   This had NO rule at all, so the account page rendered a browser-default
   <dl>: every value indented 40px under its own label, which is the single
   most recognisable "nobody styled this" signature on the web. Two columns
   that line up instead. */
.kv {
  display: grid;
  /* minmax(0, 1fr) on the VALUE track. The label track is safe as it stands -
     its min is a fixed 6rem, so nothing an item contains can push it - but the
     value track was a bare `1fr`, which is `minmax(auto, 1fr)`, and its auto
     floor is the dd's min-content. The dd that matters is the one on /account:
     an email address. UAX 14 lets a line break after a hyphen, so
     accounts.payable@northumberland-roofing-supplies.co.uk floors at about
     230px and fits. Take the hyphens out and it floors at the whole 51
     characters, roughly 367px, against 248px of card at 320px and 263px at the
     26rem the stack below kicks in at. That is the account page sliding
     sideways for one roofer with a long domain, and the same widening again in
     the single-column branch, which was `1fr` for the same reason. */
  grid-template-columns: minmax(6rem, auto) minmax(0, 1fr);
  gap: var(--s2) var(--s4);
  margin: 0;
  align-items: baseline;
}
.kv dt {
  font-size: var(--fs-meta);
  font-weight: 500;
  color: var(--muted);
}
.kv dd {
  margin: 0;
  /* The track may now shrink, so the item in it must be able to: a grid item
     keeps its own auto floor whatever its track does. dd IS in tokens.css's
     break-word list, so with a floor of 0 it breaks that email across two lines
     rather than painting it across the card edge. */
  min-width: 0;
  color: var(--ink);
  font-weight: 500;
}
/* The one figure the record exists to state. /history/{id} is a page about a
   single price, and the price was the FOURTH-largest thing on it: 28px address,
   23px "What you quoted", 19px date, then the price at the inherited 16px/500 -
   set identically to "52.4 m²" in the row beneath it. The list page one click
   back renders the same number at 19px/700 tabular. The record showed the
   figure SMALLER than the index that links to it.
   --fs-figure-sm against a 14px --kv dt label is a 1.64x step, not 1.14x.
   .num already carries tabular-nums from tokens.css, so Price and Range stay
   aligned with each other. */
.kv dd.num {
  font-size: var(--fs-figure-sm);
  font-weight: 700;
  letter-spacing: var(--track-num);
}
@media (max-width: 26rem) {
  /* Stacked, but the label still reads as the label — it does not become an
     indented orphan the way the default rendering did. */
  .kv { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .kv dt { margin-top: var(--s3); }
  .kv dt:first-child { margin-top: 0; }
}

/* ---- signed-in devices --------------------------------------------------- */
.sessions { list-style: none; margin: 0 0 var(--s4); padding: 0; }
.sessions li {
  padding: var(--s2) 0;
  border-bottom: var(--bw) solid var(--construction);
  font-size: var(--fs-meta);
}
.sessions li:last-child { border-bottom: 0; }
.sess-when { display: block; font-weight: 500; color: var(--ink); }
.sessions .meta { display: block; word-break: break-all; }

/* ---- the plan line on the account page ----------------------------------- */
.figure-line { margin: 0 0 var(--s3); font-size: var(--fs-lead); }

/* ---- the failed-payment banner ------------------------------------------
   .setup-banner-warn was rendered on every past_due account and had no rule,
   so a payment failure looked exactly like the ordinary first-run prompt. */
.setup-banner-warn {
  background: var(--error-wash);
  border-bottom-color: var(--error);
}
.setup-banner-warn .setup-in p { color: var(--error); }

/* ---- numbers never break -------------------------------------------------
   A price is one word. It is read as a quantity, not as text, and a break
   inside it turns "£1,083" into two numbers that are neither. The global
   break-word rule above is for addresses; these opt out of it entirely. */
.job-price, .figure, .datum-tol, .amount, .num,
.calc td, .calc th, .rate-default {
  overflow-wrap: normal;
  word-break: keep-all;
  hyphens: none;
}
/* Column heads are short by construction, so they never need to break - and
   "RESULT" broken over three lines as "RE SU LT" is what a table does when it
   is allowed to.

   [scope="col"], which is what that reasoning always argued and never said.
   Bare `.calc th` caught the ROW heads too, and those are sentences: "Divided
   by cos 37.4 degrees" held on one line pinned step 4's first column at
   496.5px inside a 318px box, so "How this was worked out" opened on a table
   with no numbers visible in it at all and no partial second column to hint
   that any existed. Nothing is clipped to fix that - the row heads are simply
   allowed to wrap, which is what a sentence does. */
.calc th[scope="col"] { white-space: nowrap; }


/* A link that stands alone rather than sitting in a sentence.

   2.5.8's inline exception covers a link inside a paragraph of text, because
   the line height of the surrounding prose is what constrains it. It does not
   cover a link that IS the paragraph: at --fs-meta the line box is 21.7px,
   under the 24px floor, and "Forgotten your password?" is the control someone
   reaches for one-handed after failing to sign in.

   Underlined, and it has to stay underlined: --navy is the same colour as body
   text, so the underline is the only thing distinguishing a link from a word
   (1.4.1). It is also the only cue that survives sunlight. */
.link-block {
  display: inline-flex; align-items: center;
  min-height: var(--tap);
  padding: var(--s1) var(--s2);
  margin-left: calc(var(--s2) * -1);   /* keeps the text optically aligned */
  border-radius: var(--r-sm);
  color: var(--navy);                  /* 11.05:1 on the page */
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 3px;
}


/* ---- HQ ------------------------------------------------------------------
   Its own rules because every table style in this product lives in scope.css,
   which loads on /new alone. Without these the failures table renders at user
   agent default: content-sized columns, no width cap, and an overflow at 360px
   that body{overflow-x:hidden} conceals rather than prevents. */
.hq-errors { width: 100%; border-collapse: collapse; font-size: var(--fs-meta); }
.hq-errors caption {
  caption-side: top; text-align: left; padding-bottom: var(--s2);
  font-size: var(--fs-meta); color: var(--muted);
}
.hq-errors th, .hq-errors td {
  text-align: left; vertical-align: top;
  padding: var(--s2) var(--s2) var(--s2) 0;
  border-bottom: var(--bw) solid var(--construction);
}
.hq-errors th { color: var(--ink); font-weight: 600; }
/* The stamp must never break MID stamp - and that is not the same thing as
   nowrap, which is what this used to say.

   hq.html renders `2026-08-05 14:23`. That is two tokens with exactly one break
   opportunity between them, so normal wrapping can only ever put the date on
   one line and the time on the next, which is the stamp intact on two lines and
   not a stamp cut in half. nowrap also forbade THAT break, which made the
   column's min-content the whole 16 characters: at 200% text that is about
   250px of a 288px card and the table pushed /hq 17px past the glass with
   nothing able to give way. Measured: 337px becomes 320px with this one word.
   At 100% the stamp fits its column at every width in the fleet and never
   breaks at all, so nothing moves.
   The reason column beside it may wrap as much as it likes. */
.hq-errors td:first-child { white-space: normal; color: var(--muted); }
/* `anywhere`, and this is the ONE place in the product it is right - the same
   standing .sessions .meta has for word-break: break-all above.

   tokens.css:323 rejected `anywhere` for the page-wide rule and was right to:
   it counts the break points it creates when computing min-content, which is
   how a narrow column shreds "RESULT" into "RE SU LT". Here that lowered floor
   is the entire point and the shredding cannot happen. main.py writes
   reason=str(exc)[:200], and an exception string routinely carries a URL or a
   dotted module path as one unbreakable token; break-word breaks such a token
   for RENDERING but leaves the column's min-content at the whole token, so the
   table floored at the nowrap stamp plus that token - about 317px against a
   248px card at 320px - and /hq slid sideways under a thumb. With `anywhere`
   the column's floor is one character, and because the table is width: 100%
   with a fixed stamp beside it, auto layout hands the reason column every pixel
   the stamp does not use, about 130px. It still wraps at the spaces it has and
   breaks a token only when the token alone is wider than the column.

   Not a scroll box: .calc-wrap is the right containment for step 4's working,
   which is a genuinely wide GRID of figures that must stay aligned to be read.
   A one-column run of prose is not that, and 200 characters of Python behind a
   sideways scrollbar on a phone is worse than 200 characters that wrap. */
.hq-errors td:last-child { overflow-wrap: anywhere; }


/* ==========================================================================
   Shell v3 — the header trio and the menu drawer
   --------------------------------------------------------------------------
   The sticky bar, the drawer's box and the whole palette are declared in
   tokens.css. This file holds the components that hang off them, because
   app.css is loaded by every roofer-facing page and scope.css is loaded by
   /new alone.

   --drawer-w and --scrim are read plainly, with no fallback. They were written
   as var(--name, <literal>) while they were missing from tokens.css, on the
   argument that the fallback was a bridge. It was not: an undeclared name
   means the fallback is the value, so the drawer width and the scrim were raw
   literals living in this file, which is the one thing the token rule forbids.
   The names exist in tokens.css now, and a bare var() is what makes a future
   deletion fail loudly instead of silently reverting the design to whatever
   was typed here.

   --fs-wordmark was the third of them and is no longer read anywhere: nothing
   sets type in the lockup, because the lockup is the logo asset. The token is
   tokens.css's to retire.
   ========================================================================== */

/* ---- one tone -----------------------------------------------------------
   THE BAR HAS ONE SURFACE, and the second one is deleted rather than left
   behind unreachable.

   There were two complete sets here behind [data-tone], and the reason was
   real: step 4 put a --bezel bar directly above an --ink hero with a strip of
   chassis between them, and 1.254:1 between two dark bands is the most
   reliable "nobody finished this" signal in the trade. The flip made the bar a
   DIFFERENT colour so the near-match could not be read as a mistake.

   The estimate flow now fixes that the other way up, the way .home-hero
   already did: the bar, the flow head and the step head are one continuous
   navy field, each surface starting on exactly the colour the one above it
   ends on, so the seams measure 1.000:1 and there is nothing to disguise. A
   light bar on step 4 would then be the only thing left interrupting the
   field, on the one screen the roofer turns the phone round and shows a
   customer. Both fixes cannot be right, and this is the better of the two.

   So the whole branch goes, and with it the crossfade that existed only to
   travel between the two tones. tokens.css:356-363 is the entire bar again,
   tokens.css:452-460 is its focus ring and tokens.css:434-451 is its nav ink;
   every rule below this states its ink once, for the bezel, and nothing in
   this file has to be kept in step with a second surface that never renders.
   Thirty lines of unreachable header is read by the next person as covered
   ground, which is how the light bar survived as long as it did. */

/* ---- the wordmark -------------------------------------------------------
   THE PLAQUE IS TOKENS.CSS'S AND IT IS CORRECT. This file used to restate
   .lockup with `background: none` at the same specificity and set the mark as
   type, and both halves were written for a hand-drawn wordmark that has since
   been deleted. app.css loads after tokens.css, so what shipped was the real
   logo with its plaque switched off: the SCOPE mark inside scope-logo.svg is
   an embedded raster inked #062A4A, which is 1.25:1 on the bezel, so on every
   page but step 4 the only visible part of the company's name was ROOFING, in
   orange, 3.8px tall. On the plaque the same raster is 14.61:1 and the plaque
   is 18.31:1 on the bar.

   What is kept here is the bar's geometry, not the plaque. The account square
   and the menu toggle are both var(--tap) at var(--r); tokens' padding alone
   leaves the plaque 43px at var(--r-sm), so the object at the left end of the
   bar would be 5px shorter and 6px sharper than the two at the right end of
   it. Two lines, and the row reads as one set. */
.lockup { min-height: var(--tap); border-radius: var(--r); }

/* A tint on the target, never information. --well is the largest step the
   plaque has (1.11:1), and it is the only step available: the plaque is
   --readout, so there is nowhere brighter to go. (hover: hover) because the
   bar is on every phone screen in the product and a tapped link keeps :hover
   until something else takes it. */
@media (hover: hover) {
  .topbar .lockup:hover { background: var(--well); }
}

/* ---- the right-hand group ------------------------------------------------
   Four items in the bar now, so the row gap tightens by one step. Measured at
   320px: 293px of content in the bar, and .topbar-in keeps its flex-wrap and
   row-gap as the safety net for a three-digit balance at 200% text zoom. */
.topbar-in { gap: var(--s2); }

/* Whichever right-hand item renders FIRST has to push the group over, and
   which one that is depends on server facts: the chip only exists once the
   ledger has something in it, and the square only once there is a name or an
   email to take initials from. So the push is declared on each of them and
   cancelled on any that follows another, which leaves exactly one pusher in
   every combination. .nav-toggle and .nav already carry it from tokens.css. */
.chip-credits, .acct { margin-left: auto; }
.chip-credits ~ .acct,
.chip-credits ~ .nav-toggle,
.chip-credits ~ .nav,
.acct ~ .nav-toggle,
.acct ~ .nav { margin-left: 0; }

/* ---- the credit chip ----------------------------------------------------
   A READOUT and not a control, which is why it keeps a fill measuring 1.64:1
   on the bar and does not gain the border the two squares have. The difference
   is then a channel in its own right: outlined means pressable. */
.chip-credits {
  margin: 0 0 0 auto;
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: var(--s7); padding: 0 var(--s3);
  border-radius: var(--r-pill);
  background: var(--bezel-raised); color: var(--on-bezel);   /* 11.17:1 */
  font-size: var(--fs-meta); font-weight: 600;
  font-variant-numeric: tabular-nums;
}
/* --brand, and it is decoration beside the number rather than the number.
   9.05:1 on the bezel, and the balance is legible with the dot painted out. */
.chip-dot {
  width: var(--s2); height: var(--s2); border-radius: 50%; flex: none;
  background: var(--brand);
}

/* ---- the account square -------------------------------------------------
   The same box as the toggle, and outlined for the same reason: the fill alone
   is 1.64:1 on the bar, so the border is the only thing saying "control". */
.acct {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--tap); height: var(--tap); flex: none;
  border-radius: var(--r);
  border: var(--bw-strong) solid var(--on-bezel-muted);      /*  8.74:1 */
  background: var(--bezel-raised);
  color: var(--on-bezel);                                    /* 11.17:1 */
  font-size: var(--fs-meta); font-weight: 700;
  letter-spacing: var(--track-caps);
  text-decoration: none;
  transition: background var(--t-fast) var(--ease);
}
/* --bezel, and NOT --navy. --bezel-raised and --navy are the same hex (#0F3D66),
   so the resting fill and the hover fill were identical and this rule set the
   colour the square already had: confirmed with a real pointer, computed style
   before and after, rgb(15,61,102) to rgb(15,61,102), on 24 of 24 page and width
   combinations, while every other hover in the probe moved. --bezel is the bar's
   own surface, 1.64:1 below the resting fill, and the 2px --on-bezel-muted
   border carries the boundary at 8.74:1 throughout. */
@media (hover: hover) { .acct:hover { background: var(--bezel); } }
/* Where you are, and TWO channels, because one of them is hue and hue on its
   own is a 1.4.1 failure. The recoloured border was the whole marker: same
   width, same fill, same ink, same weight, same two letters, and --amber
   against --on-bezel-muted measures 1.60:1 (1.79:1 against --rule on the light
   tone). In greyscale, or held at arm's length in sun, that is the same
   border.

   The rule under the initials is the second channel, and it is the same one
   .topbar .nav a[aria-current="page"] already uses three doors along. It is a
   text decoration rather than a box-shadow so it survives forced colours,
   where the width step below is doing the same job for the border. */
.acct[aria-current="page"] {
  border-color: var(--amber);                                /*  5.47:1 */
  text-decoration: underline;                                /* 11.17:1 */
  text-decoration-thickness: var(--bw-strong);
  text-underline-offset: var(--s1);
}

/* ---- the toggle ---------------------------------------------------------
   A square, to match the account link beside it. tokens.css sizes it as a
   labelled pill because the word Menu used to be on screen; the word is
   visually hidden now, so the padding and the gap have nothing to space.
   `margin-left: auto` is left where it is: it is the fallback pusher for the
   case where neither the chip nor the square renders, which is every page
   today. The 2px border, the icon swap and the open-state inversion all stay
   in tokens.css untouched. */
.nav-toggle {
  width: var(--tap); height: var(--tap); min-width: 0;
  padding: 0; gap: 0;
  border-radius: var(--r);
  background: var(--bezel-raised);
}
/* The same 120ms the square beside it takes, for a bigger change. Measured,
   .acct was `background @ 0.12s` and this was `all @ 0s` - two 48px squares 8px
   apart with the same radius and the same fill, one of which moved and one of
   which did not, and the one that did not is the one whose whole appearance
   inverts when it opens (16.85:1).
   Colour only, so nothing moves and the icon swap stays instant. That is right
   rather than incidental: the glyph IS the state, and a bar-to-cross crossfade
   is a control in two minds. */
@media (prefers-reduced-motion: no-preference) {
  .nav-toggle {
    transition: background var(--t-fast) var(--ease),
                color var(--t-fast) var(--ease),
                border-color var(--t-fast) var(--ease);
  }
}

/* ---- the drawer ---------------------------------------------------------
   EVERY rule below is inside `.nav-toggle:not([hidden]) + .nav`, which is the
   enhanced state and nothing else. With the script blocked the button keeps
   its `hidden` attribute, none of this matches, and the nav is a wrapped row
   of plain links in the bar. That is the whole no-script story, and it is why
   the selector is written this way rather than with a media query alone. */
@media (max-width: 47.999rem) {
  .nav-toggle:not([hidden]) + .nav {
    /* Right aligned instead of full width. `left: auto` is load-bearing: the
       rule in tokens.css sets left:0 AND right:0, so overriding `right` alone
       would leave the panel stretched across the bar. */
    left: auto; right: 0;
    width: var(--drawer-w);
    border-radius: var(--r-lg) 0 0 var(--r-lg);
    /* 16px, up from the 8px in tokens.css. The rows sit flush against each
       other now, separated by a hairline rather than by a gap, so the panel's
       own padding is the only thing keeping a focus ring clear of the
       `overflow-y: auto` edge that would clip it. */
    padding: var(--s4);
    gap: 0;
    /* Three layers, and the order is the paint order. The white 2px ring is
       the outer half of the two-tone edge documented in tokens.css. The scrim
       is a spread shadow rather than an element: it needs no markup, it cannot
       be left behind in the DOM, and it takes no pointer events, so a tap on
       it reaches the page and nav.js's existing pointerdown handler closes the
       drawer. */
    box-shadow: 0 0 0 var(--bw-strong) var(--readout),
                0 0 0 100vmax var(--scrim),
                var(--shadow-2);
  }

  /* It OPENS, out of the bar it hangs off.

     nav.js writes `panel.hidden = !open` and [hidden] is display:none !important,
     so the app's whole navigation went from nothing to a fully painted 324x569
     white card PLUS a scrim over the entire viewport in one frame: measured on
     the open panel, transition-duration 0s, animation-duration 0s, and a
     rgba(0,22,41,.5) spread layer, so the page darkened by 3.43:1 instantly.

     Fade plus a 6px drop, and never a lateral slide. The panel is right aligned
     directly under the toggle the thumb has just released, and 324px of white
     travelling through where a finger is is a surface moving under the hand,
     which this file refuses elsewhere for the same reason.

     The scrim is a layer of this element's own box-shadow, so it fades with the
     panel and the page comes back rather than snapping bright.

     `display` is transitioned DISCRETELY so the panel is still painted while it
     fades out; a transition declaration outranks even an important author one,
     which is what lets it hold against [hidden]'s !important. Engines without
     allow-discrete (iOS Safari below 17.4, which this audience does run) ignore
     all four lines and get exactly today's behaviour. Nothing regresses; some
     phones get less. */
  .nav-toggle:not([hidden]) + .nav {
    transition: opacity var(--t) var(--ease),
                transform var(--t) var(--ease),
                display var(--t) allow-discrete;
  }
  .nav-toggle:not([hidden]) + .nav[hidden] {
    opacity: 0;
    transform: translateY(calc(var(--s2) * -0.75));          /* 6px */
  }
  /* Safe on first paint: the template ships the toggle hidden and the panel
     open, so at the first style resolution this selector does not match at all,
     and nav.js inverts both in the same task. The browser's first resolution
     already sees the closed state. */
  @starting-style {
    .nav-toggle:not([hidden]) + .nav {
      opacity: 0;
      transform: translateY(calc(var(--s2) * -0.75));
    }
  }
  @media (prefers-reduced-motion: reduce) {
    /* aria-expanded and the toggle's own polarity inversion already say it is
       open, at 16.85:1. Nothing here is the only channel carrying the state. */
    .nav-toggle:not([hidden]) + .nav { transition: none; }
  }

  /* His name, not ours. */
  .nav-toggle:not([hidden]) + .nav .nav-who {
    display: block;
    margin: 0 0 var(--s3); padding: 0 var(--s2);
    font-size: var(--fs-meta); font-weight: 600;
    letter-spacing: var(--track-caps); text-transform: uppercase;
    color: var(--muted);                                      /* 5.99:1 */
  }

  /* The one action, at the top, and the only filled thing in the panel. */
  .nav-toggle:not([hidden]) + .nav .nav-new {
    display: flex; align-items: center; gap: var(--s3);
    min-height: var(--tap-lg);
    margin-bottom: var(--s3);
    padding: 0 var(--s4);
    border: var(--bw-strong) solid var(--ink);
    border-radius: var(--r-lg);
    background: var(--ink); color: var(--readout);            /* 14.61:1 */
    font-size: var(--fs-body); font-weight: 600;
  }
  .nav-toggle:not([hidden]) + .nav .nav-new svg {
    width: var(--s5); height: var(--s5); flex: none;
    /* The `stroke: var(--brand)` that used to sit here did nothing and is now
       stated once, on the path, beside the base .nav-new rule below the media
       query. Measured as shipped: this element computed rgb(255,161,40) and the
       <path> inside it computed rgb(255,255,255), because the path's own
       `stroke="currentColor"` presentation attribute beats an inherited value.
       The plus is brand in both shapes now, which is what this comment always
       claimed. */
  }
  /* The whole drawer is below 48rem, which is a phone, which is where :hover
     is a state a tap turns on and nothing turns off. Both of the panel's
     hovers are gated so a tapped row does not sit lit until the next tap
     somewhere else. */
  @media (hover: hover) {
    .nav-toggle:not([hidden]) + .nav .nav-new:hover { background: var(--navy); }
  }
  /* THIS IS THE OWNER'S BUG, and it is here rather than on the wide bar.
     "New estimates button in the menu is white text on white button."

     On /new, and ONLY on /new, this element matches the drawer's current-page
     ROW rule in tokens.css - `.nav-toggle:not([hidden]) + .nav
     a[aria-current="page"]`, which is (0,4,1) - while the rule that fills it is
     `... .nav .nav-new` at (0,4,0). One attribute selector, and the row rule
     wins. It sets `background: var(--well)` and sets no colour, so the fill went
     to --well and the ink stayed --readout from the losing rule: measured on the
     real page with the real nav.js, `rgb(255,255,255)` on `rgb(237,244,250)`,
     which is 1.11:1. White text on a white button, exactly as reported, on the
     one page where the roofer is most likely to open the menu.

     Two auditors could not reproduce it because they opened the drawer on /,
     /history, /settings and /account, where .nav-new is not the current page and
     the row rule does not match it.

     The fix is the rule this whole section is built on: background and color are
     written TOGETHER, in the same rule, wherever either one is touched. This one
     is (0,5,0) and beats the row rule outright.

     --amber and not --brand for the border, which is 2.02:1 against the white
     panel this border also touches. Amber is 4.36:1 on the ink fill and 3.35:1
     on the panel, so it holds on both sides, and it is the same colour the four
     rows below use to say the same thing. */
  .nav-toggle:not([hidden]) + .nav .nav-new[aria-current="page"] {
    border-color: var(--amber);
    background: var(--ink); color: var(--readout);            /* 14.61:1 */
  }

  /* The rows. Chevron aside, these keep the sizing tokens.css already gives
     them: 56px, a 4px reserved leading border, --ink at --fs-body. The colour
     is restated because the light-tone override above matches these too, at a
     specificity the tokens.css rule cannot beat. */
  .nav-toggle:not([hidden]) + .nav a:not(.nav-new):not(.link) {
    justify-content: space-between;
    color: var(--ink);                                        /* 14.61:1 */
    /* A row rule inside an already bounded surface, which is the only place
       --construction is allowed. */
    border-bottom: var(--bw) solid var(--construction);
  }
  .nav-toggle:not([hidden]) + .nav .nav-chev { display: block; }

  /* The credits block, and it is ONE paragraph now.

     It was an eyebrow, a 23px figure and a note: 140.7px at 320 and 119px at
     375/390, between a fifth and a quarter of the whole menu, restating a
     number that is already on screen 32px above it in p.chip-credits. What that
     cost is the item at the other end: measured with the real toggle and the
     real nav.js, the panel's scrollHeight was 586 against a clientHeight of 532
     at 320x640 and 559 at 375x667, so Sign out hung 36.4px and 9.4px below the
     panel's own fold. 375x667 is the iPhone SE 2 and 3 and the 6, 7 and 8 body.
     The panel's comment in tokens.css says it scrolls "when it still does not
     fit - 400% zoom, large system type"; it did not fit at default text size on
     ordinary phones.

     The sentence is kept in full. Nothing is clipped and max-height is not
     raised: the fix is that the block stops saying the same thing twice. */
  .nav-toggle:not([hidden]) + .nav .nav-credits {
    display: block;
    margin: var(--s4) 0 0;
    padding: var(--s4);
    border-radius: var(--r-lg);
    background: var(--well);
    font-size: var(--fs-meta); color: var(--muted);           /* 5.40:1 */
  }
  /* "See your plan" is a link inside a sentence, and every anchor in this
     panel is otherwise a 56px row. Left alone it would become one, which would
     break the sentence in half. Undone here rather than by excluding the panel
     from the row rule, because the row rule is what makes the four
     destinations targets. The underline has to be restated too: `.nav a` sets
     text-decoration: none at a higher specificity than `.link`.
     It read "Top up" until the audit: the anchor sat outside the balance
     branch, so it offered a top-up at a full balance, and there is no route
     that sells one. The rule is unchanged, only the name of the control it
     describes, so that the next reader can find it. */
  .nav-toggle:not([hidden]) + .nav .nav-credits .link {
    display: inline; min-height: 0; padding: 0;
    border: 0; border-radius: 0; background: none;
    color: var(--navy);                                       /* 11.17:1 on the
                                                                 white panel */
    font-size: inherit; font-weight: 600;
    text-decoration: underline; text-underline-offset: 2px;
  }

  /* Sign out, ruled off and bounded. A button with no boundary on its own
     background is not a button, which is what the design's borderless amber
     text on white would have shipped. */
  .nav-toggle:not([hidden]) + .nav .nav-signout {
    display: block; margin-top: var(--s4); padding-top: 0; border-top: 0;
  }
  .nav-toggle:not([hidden]) + .nav .linklike {
    width: 100%; justify-content: center;
    min-height: var(--tap-lg);
    border: var(--bw-strong) solid var(--rule);               /* 5.99:1 */
    border-radius: var(--r);
    color: var(--amber-ink);                                  /* 5.02:1 */
    font-size: var(--fs-body);
  }
  /* --amber-wash on --readout is 1.09:1, which on a bright screen is not a
     signal, it is nothing. The border is the second channel and it moves
     2.44:1, which is the same fix the two shared button families take: hover
     is never colour alone. The width is already reserved, so nothing shifts. */
  @media (hover: hover) {
    .nav-toggle:not([hidden]) + .nav .linklike:hover {
      background: var(--amber-wash);
      border-color: var(--ink);                 /* --rule to --ink, 2.44:1 */
    }
  }
}

/* Drawer only. In the wide bar the nav is a row of inline links, and neither an
   eyebrow nor a paragraph nor a row chevron belongs in a 48px row. Stated
   outside the media query so the unenhanced wrapped row below 48rem drops them
   too.

   The chevron carries its size here rather than in the drawer block, because an
   inline SVG with no width or height attribute has no intrinsic size and falls
   back to 300x150. Sized only where it is shown, one stale selector would tear
   the bar apart on every page. */
.nav-who, .nav-credits { display: none; }
.nav-chev {
  display: none;
  width: var(--s2); height: var(--s4); flex: none;
  color: var(--muted);                                        /* 5.99:1 */
}
/* Same reason, and the plus is visible in both shapes, so it is sized in both. */
.nav-new { gap: var(--s2); }
.nav-new svg { width: var(--s5); height: var(--s5); flex: none; }

/* ---- the one action in the nav, filled in BOTH shapes -------------------
   OWNER REPORT: "new estimates button in the menu is white text on white
   button". Two things were wrong and only one of them is what he saw.

   The one he saw, measured at 768 and 1280: `.nav-new` computed
   `background-color: rgba(0, 0, 0, 0)`, `color: rgb(168, 180, 200)`,
   `border-width: 0px`, 14px/500 - byte for byte the same box as Home, Estimates,
   Settings and Account beside it. The pattern "New estimate is the only filled
   thing in the nav" was adopted by the phone drawer alone, so on the wide bar
   the app's primary action is an unfilled label. A button with no fill is what
   gets reported as a broken button.

   The one underneath it: every visual property .nav-new had - fill, ink, border,
   radius - lived behind `.nav-toggle:not([hidden]) + .nav .nav-new`, a selector
   gated on a sibling adjacency AND an attribute AND a media query. Outside that
   state it had a gap and an svg size and nothing else, so its ink came from one
   rule and its surface from a different, conditional one. That is the shape of
   the fault the report describes even where it is not what rendered, so it goes:
   background and color are written TOGETHER in every rule that touches either,
   and the unconditional one is the base.

   SPECIFICITY IS THE WHOLE TRICK HERE, and the obvious way is wrong. Written as
   a bare `.nav-new` (0,1,0) this rule changes nothing: tokens.css's
   `.topbar .nav a` is (0,2,1) and sets `background: none`, `border: 0` and
   `color: var(--on-bezel-muted)`, so all three declarations lose no matter what
   order the files load in. Measured that way in a real browser first - the
   background stayed rgba(0,0,0,0) - which is the same class of silent miss as
   the conditional rule this fix exists to remove. `.topbar .nav a.nav-new` is
   (0,3,1) and beats it; the drawer's rules are (0,4,0) and still beat this.

   --bezel-raised is a wash and bounds nothing on the bezel, so the 2px
   --on-bezel-muted border is the boundary, exactly as .acct does it three doors
   along: 8.74:1 against the bar outside it and 5.33:1 against the fill inside
   it, both clear of the 3:1 a UI boundary owes. */
.topbar .nav a.nav-new {
  border: var(--bw-strong) solid var(--on-bezel-muted); /*  8.74:1 on the bar */
  border-radius: var(--r-sm);
  background: var(--bezel-raised);
  color: var(--on-bezel);                               /* 11.17:1 on the fill */
  font-weight: 600;
}
/* The stroke has to be set on the PATH, not on the <svg>. The path carries
   `stroke="currentColor"` as a presentation attribute, and a presentation
   attribute beats an inherited value - so `.nav-new svg { stroke: ... }` computes
   on the <svg> and never reaches the shape that paints. Measured as shipped: the
   drawer's svg computed rgb(255,161,40) and its path computed rgb(255,255,255),
   so the brand plus the comment below has always described has never once been
   drawn. Same seed as the report: a rule that matches an element that is not the
   one doing the painting.

   Decoration in both shapes - the words "New estimate" carry the meaning - and
   above the 3:1 floor anyway: 5.52:1 on --bezel-raised, 7.22:1 on the drawer's
   --ink row. Both are surfaces rule 4 permits --brand on. */
.topbar .nav a.nav-new svg path { stroke: var(--brand); }

/* The two rules the base rule above makes necessary, and neither is optional.

   CURRENT PAGE. `.topbar .nav a[aria-current="page"]` in tokens.css is (0,3,1),
   the same weight as the rule above, and app.css loads second - so without this
   the filled button would silently lose both of its markers on /new: the 700
   weight and the amber bottom rule, the latter because a `border` shorthand
   resets it. Two channels, as everywhere else in this bar: weight, and a border
   that goes --on-bezel-muted to --amber. --amber is 3.64:1 on the bar outside
   the button and 3.34:1 on the fill inside it, both clear of 3:1.

   HOVER. `.topbar .nav a:hover` is also (0,3,1) and also loses to the rule
   above, so the resting fill would have swallowed the hover fill and this
   control would have had no hover state at all - which is precisely the .acct
   bug fixed forty lines up, reintroduced by the fix for it. --bezel is the same
   1.64:1 step down .acct takes, the border holds the boundary throughout at
   8.74:1, and --on-bezel goes 11.17:1 to 18.31:1 rather than dimming. */
.topbar .nav a.nav-new[aria-current="page"] {
  border-color: var(--amber);                           /*  3.64:1 on the bar */
  font-weight: 700;
}
@media (hover: hover) {
  .topbar .nav a.nav-new:hover { background: var(--bezel); }
}

@media (forced-colors: active) {
  /* Backgrounds and shadows are discarded here, so anything whose shape came
     from a fill needs a border to survive. The scrim simply goes, which is
     what "decoration" is supposed to mean. */
  .acct, .chip-credits { border: var(--bw) solid; }
  /* The one place the current-page marker can still differ, now that both
     border colours have collapsed to CanvasText. */
  .acct[aria-current="page"] { border-width: var(--bw-strong); }
  .nav-new { border: var(--bw-strong) solid; }
  .nav-new svg, .nav-chev { stroke: currentColor; fill: none; }
  /* The mark needs nothing here and must not be given `fill: currentColor`.
     This mode already forces fill and stroke to the system pair, so the ring
     and the triangle both come through; setting fill on the <svg> would
     inherit into the ring as well and turn it into a solid disc. */
  .nav-credits { border: var(--bw) solid; }
}

/* --- long form prose: /terms and /privacy ---------------------------------
   These two pages are the only ones in Scope that are read rather than used,
   and the difference is the measure: everywhere else the job is to get a thumb
   to the next control, here it is to keep an eye on a line of text for four
   screens without losing its place.

   MEASURE IS SET BY .narrow ON <main>, at 40rem. Text at this size wants 60 to
   75 characters a line and 40rem lands at roughly 70. Nothing here overrides
   it, so the two legal pages and the sign in form share one column width.

   No max-width on .prose itself, deliberately: two widths that both mean
   "reading measure" is how one of them ends up stale. */
.prose { padding-bottom: var(--s6, 2rem); }

/* 1.6 rather than the interface's tighter default. Body copy read in sequence
   needs the extra leading; a button label does not, which is why this is scoped
   to .prose and not raised globally. */
.prose p,
.prose li { line-height: 1.6; }

.prose p { margin: 0 0 1em; }

/* Space ABOVE a heading and little below it, so a heading sits with the text it
   introduces rather than floating between two blocks equidistant from both.
   Getting this backwards is the commonest reason a long document reads as one
   undifferentiated slab. */
.prose h2 {
  margin: 2em 0 0.6em;
  font-size: 1.125rem;
  line-height: 1.3;
  color: var(--ink);
}

.prose h1 + .lede { margin-top: 0.5em; }

.prose ul {
  margin: 0 0 1em;
  padding-left: 1.25rem;
}

.prose li { margin-bottom: 0.5em; }

/* strong, not a colour change. The emphasised runs in these lists are the lead
   in to each item ("Your password", "Billing records") and they have to survive
   a printout and a greyscale screen, which a hue shift does not. */
.prose strong { font-weight: 600; }

/* The one clause a roofer must not skim: that an estimate is not a survey.
   Given a left rule in the brand amber and a wash, so it is findable by shape
   when scrolling back for it. The rule is a second, non colour channel, so it
   still reads as set apart with the colour removed. */
.callout {
  margin: 1.25em 0;
  padding: 0.875rem 1rem;
  border-left: 4px solid var(--brand);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  background: var(--well);
  color: var(--ink);
}

.callout strong { display: block; margin-bottom: 0.25em; }

/* Sits directly above the submit button on /signup. Slightly tighter than the
   sign in line it follows so the two do not read as a pair of equal options. */
.consent-note { margin-top: 0.75rem; }

/* --- the one destructive control in Scope ---------------------------------
   Closing an account. Filled rather than outlined, because the two controls on
   that page are "keep" and "close" and an outlined destructive button beside an
   outlined safe one asks the eye to read before it can tell them apart.

   #98110F carries white at 8.67:1, which clears AAA, so the fill is safe at any
   weight. Colour is NOT the only signal: it is the only filled red in the
   product, it sits second in the source order behind the safe choice, and the
   page it lives on exists to say what it does. A person who cannot see the red
   still gets a page headed "Close your account" and a control labelled "Close
   my account". */
.btn-danger {
  background: var(--error);
  border-color: var(--error);
  color: var(--readout);
}

/* Darkened rather than lightened, so the hover of the destructive control never
   drifts toward the wash used for its own error messages. Guarded by
   (hover: hover) for the same reason the other families are: a tap on a phone
   otherwise latches the state on after the finger has gone. */
@media (hover: hover) {
  .btn-danger:hover { background: #7A0D0C; border-color: #7A0D0C; }
}

/* A list that is a list semantically, without the bullets. Used where the items
   are consequences rather than prose: they need to be countable by a screen
   reader ("list, 4 items") but a bulleted column inside a card reads as
   decoration. Marker removed in two ways because list-style alone is dropped by
   Safari when a list has role or is styled as flex. */
.plain-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.plain-list li {
  position: relative;
  padding-left: 1.35rem;
  margin-bottom: 0.5rem;
  line-height: 1.5;
}

/* The tick is drawn, not typed, so it cannot be read out as a character and
   cannot be selected with the text. */
.plain-list li::before {
  content: "";
  position: absolute;
  left: 0.15rem;
  top: 0.55em;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--rule);
}

/* --- top-up packs ---------------------------------------------------------
   Three prices a roofer compares side by side. Grid rather than flex so all
   three cards share a row height whatever the copy does: with flex, "100
   properties" wrapping on a narrow phone would make one card taller than its
   neighbours and the three buttons would stop lining up, which reads as a
   rendering fault rather than as a set of options.

   auto-fit with a 9rem floor, so it is three across on a desktop, two on a
   large phone and one on a small one, with no breakpoint to maintain. */
.pack-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem;
  margin: 1rem 0 0;
  padding: 0;
  list-style: none;
}

.pack {
  border: 1px solid var(--line);
  border-radius: var(--r);
  padding: 0.875rem;
  background: var(--card);
  /* min-width: 0 on the grid ITEM. A grid item defaults to min-width: auto,
     which refuses to shrink below its content, so a long price string would
     push the track wider than the column and give the page a horizontal
     scrollbar. This is the same fault the measurement input had on /new. */
  min-width: 0;
}

.pack form {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  height: 100%;
}

.pack-size {
  font-weight: 600;
  color: var(--ink);
  line-height: 1.25;
}

/* The figure a person is actually comparing, so it is the one that is large.
   tabular-nums keeps the three prices aligned on the decimal down the row. */
.pack-price {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.15;
  font-variant-numeric: tabular-nums;
}

.pack-each {
  font-size: 0.875rem;
  color: var(--muted);
  /* Pushes the button to the bottom of whichever card is tallest, so the three
     controls line up even when the text above them does not. */
  margin-bottom: auto;
  padding-bottom: 0.625rem;
}

/* ---- His own job types, on /settings ---------------------------------------
 *
 * A stacked card per row rather than a .rate-line, which is a three track grid
 * built for one label and one money box. Four controls plus a Remove will not
 * fit it: app.css already records that grid as "one added service away from
 * pushing the settings page sideways" at 200% zoom.
 */
.cj-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s-list); }
.cj-row .ratefs { margin: 0; }
.cj-legend { font-size: var(--fs-meta); font-weight: 700; color: var(--ink); }
.cj-row .field { margin-top: var(--s3); }
.cj-del { margin-top: var(--s4); }

.cj-undone {
  display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  padding: var(--s4);
  background: var(--well);
  border: var(--bw) dashed var(--rule);
  border-radius: var(--r);
  color: var(--ink);
}
.cj-empty { margin: var(--s3) 0 0; color: var(--muted); font-size: var(--fs-meta); }

@media (min-width: 40rem) {
  /* minmax(0, ...) on every track. A bare 1fr floors at its content, which is
     the fault this file records five times over, each ending with a figure
     hanging off the edge of the page. */
  .cj-row .ratefs {
    display: grid; gap: var(--s3) var(--s4);
    grid-template-columns: minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr) auto;
    align-items: end;
  }
  .cj-row .cj-legend { grid-column: 1 / -1; }
  .cj-row .field { margin-top: 0; }
  .cj-del { margin-top: 0; }
}

/* Backgrounds go in forced colours, so the placeholder's edge has to carry
   itself on width and style. `border:` shorthand is banned in here because it
   resets all four widths at once. */
@media (forced-colors: active) {
  .cj-undone { border-width: var(--bw-strong); border-style: dashed; }
}

/* ---- sector groups on the home screen --------------------------------------
 *
 * The eyebrow rung: 12px tracked caps is what tokens.css reserves for a two
 * word tag, and a trade name is exactly that. Rendered only when the account
 * holds more than one sector, so this is never a label over the only group.
 */
.jobpick-trade + .jobpick-trade { margin-top: var(--s6); }
.jobpick-trade-h {
  margin: 0 0 var(--s3);
  font-size: var(--fs-micro);
  font-weight: 700;
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--muted);
}


/* ---- the site foot -------------------------------------------------------
   The statutory disclosure and the two legal links, on every page of the app.
   See _shell.html for why it exists at all; this only places it.

   Quiet on purpose. It is a legal requirement and a wayfinder, not content: it
   sits below the action bar's reserved space so it can never be what a thumb
   finds first, and --muted on --chassis is 5.48:1, so quiet does not mean
   unreadable. */
.sitefoot {
  margin: var(--s8) auto 0;
  padding: var(--s5) var(--gutter) calc(var(--s6) + var(--bar-h, 0px));
  /* --page-measure, or the statutory line and its border-top run 352px past
     the form on /settings, /account and /billing at 1440. */
  max-width: var(--page-measure);
  border-top: var(--bw) solid var(--hair);
  text-align: center;
}
.sitefoot p { margin: 0 0 var(--s1); }
.sitefoot p:last-child { margin: 0; }

/* ==========================================================================
   The desktop tier, and the landscape phone
   ==========================================================================

   WHY THIS BLOCK EXISTS. Until now the largest width-based breakpoint anywhere
   in the customer-facing CSS was 48rem. Nothing changed between 768px and
   1920px: a 1440 window and a 1920 window rendered byte-identically and only
   the dead margins grew. Measured in Edge across 320/375/390/768/1024/1440/1920
   there was no horizontal overflow at any width and no input under 16px -- the
   mobile-first work is genuinely good -- but the desktop end was the phone page
   centred on a monitor, which is what it looked like because that is what it
   was.

   These are corrections, not a redesign. A persistent sidebar and a two-pane
   step 3 are the right answer above 64rem and they are a bigger piece of work
   than an audit should start on its own. */

/* ---- a full-width button stops being an aiming aid once there is room ----

   `width: 100%` inside the bar is correct mobile reasoning -- it removes the
   horizontal axis from the aiming problem for a thumb in a glove -- and it was
   applied unconditionally. Measured at 1440: "Continue" on /new rendered 864px
   wide and "Sign in" on /login 608px. Nothing in the product is 864px of
   button; at that size it stops reading as a control and starts reading as a
   banner, and the eye has to travel the whole width to find the label centred
   in it.

   20rem, and aligned to the END of the measure, because the bar sits under a
   form whose fields are full-width: the button lines up with their right edge,
   which is where the eye already is after the last field. 2.5.8 is unaffected
   -- 320px is far past the 24px floor. */
@media (min-width: 48rem) {
  .actionbar-in.is-single { display: flex; justify-content: flex-end; }
  .actionbar-in.is-single .btn { width: auto; min-width: 20rem; margin-left: auto; }
  main.narrow .btn-block,
  .page-login .btn-block, .page-signup .btn-block,
  .page-forgot .btn-block { width: auto; min-width: 20rem; }
}

/* ---- above 64rem, stop making the form the narrowest thing on the screen --

   /settings crams a five-column custom-rate row into 566px of card interior --
   a "Minimum charge" box about 95px wide -- while 800px of page sits blank on
   either side. The columns are cramped because the container is artificially
   narrow, not because the screen is. 48rem gives the row 768px to work in and
   is still inside the 50-75 character measure that makes prose readable, because
   the pages this touches are forms rather than prose. */
@media (min-width: 64rem) {
  .page-settings, .page-setup { --page-measure: 48rem; }
  .page-settings main.narrow, .page-setup main.narrow { max-width: 48rem; }
  /* main.narrow is 40rem from tokens.css:571; the other narrow pages keep it,
     because a sign-in form does not get better at 768px. */
}

/* ---- the phone on its side ----------------------------------------------

   There was not one height-based or orientation-based media query in the
   product. Measured at 844x390 -- an iPhone 14 rotated -- the chrome on /new
   was a 72px topbar plus an 80px action bar against 390px of viewport: 39% of
   the screen, leaving a 238px content window. The one reason to rotate a phone
   on this product is to see the aerial photograph wider, and rotating gained no
   width (it is capped at 600px) and lost more than a third of the height.

   Heights, not widths, so this does not fight the width tier above. 30rem is
   480px, which clears every phone in portrait and catches every one in
   landscape. */
@media (max-height: 30rem) {
  .topbar-in { min-height: var(--tap); padding-top: var(--s1); padding-bottom: var(--s1); }
  .actionbar-in { padding-top: var(--s2); padding-bottom: var(--s2); }
  .actionbar .btn-lg { min-height: var(--tap); }
  /* The picture is the reason the phone got turned. Give it the room the bars
     just gave back rather than letting it push the controls off screen. */
  .viewer-frame img, #aerial { max-height: 62dvh; width: auto; }
}
