/* DrivePay fleet portal — Epic A / A1. LIGHT THEME.
 *
 * Colour hierarchy per the brand guideline: Electric Ultramarine is primary (buttons, links,
 * CTAs); Volt Green is an accent only (logo, active-nav indicator, small highlights). This
 * supersedes the earlier green-dominant direction.
 *
 * ⚠️ TWO SURFACES WITH OPPOSITE POLARITY — a Midnight Ash sidebar against a near-white
 * content pane. The Cultured and Crystal-Gray tinted-pane experiments are abandoned; the
 * pane is Snow Drift again. Nothing carries over from those rounds, because a dark surface
 * inverts which colours work: the whole table was recomputed.
 *
 *                          sidebar        pane        cards
 *                          #121212      #FAFBFC     #FFFFFF
 *   White        #FFFFFF    18.73:1          -           -
 *   Snow Drift   #FAFBFC    18.08:1          -           -
 *   Midnight Ash #121212          -     18.08:1     18.73:1
 *   Iron Dust    #5E6366     3.08:1      5.87:1      6.08:1
 *   light-muted  #9AA1A6     7.16:1          -           -
 *   E.Ultramarine#028CFD     5.51:1      3.28:1      3.40:1
 *   dark blue    #0263B4     3.07:1      5.88:1      6.09:1
 *   Volt Green   #32FF6A    13.96:1      1.30:1      1.34:1
 *
 * ⚠️ FOUR THINGS FLIP, AND EACH IS THE OPPOSITE OF THE LIGHT-SURFACE ANSWER:
 *
 *  1. IRON DUST FAILS ON DARK — 3.08:1, exactly the failure it had in the very first dark
 *     build. The sidebar uses --sidebar-muted #9AA1A6 (7.16:1) instead. It is NOT reused.
 *  2. THE TWO BLUES SWAP ROLES. --primary-text #0263B4 is 5.88:1 on the pane but only
 *     3.07:1 on the sidebar. Electric Ultramarine #028CFD is the reverse: 3.28:1 on the
 *     pane, 5.51:1 on the sidebar. So the pane uses the darkened blue and the sidebar uses
 *     the exact brand blue. Neither is universal.
 *  3. VOLT GREEN FINALLY WORKS. 13.96:1 on Midnight Ash, against 1.30:1 on light. The
 *     active-nav bar is now genuinely high-contrast rather than decorative.
 *  4. THE GREEN ACTIVE TINT IS REMOVED, and it was actively harmful. Volt Green at 16%
 *     over Midnight Ash composites to #173820: only 1.45:1 against the sidebar, so nearly
 *     invisible, AND it drops the blue label on top of it to 3.80:1 — a FAIL. At 10% it is
 *     still 4.48:1, still failing. Removing it restores the label to 5.51:1. So the answer
 *     to "is the bar + blue text + semibold + aria-current enough" is yes, and the tint had
 *     to go regardless.
 *
 * ⚠️ CRYSTAL GRAY AS A SIDEBAR DIVIDER WOULD BE TOO VISIBLE, NOT INVISIBLE — 14.89:1 on
 * near-black, a bright hairline. Dividers use white at 14% alpha (1.48:1) instead.
 *
 * Every text element was re-checked against the surface it actually sits on; all 13 pass.
 * The two low-contrast pairings that remain are decorative and named as such below.
 */

/* ------------------------------------------------------------ fonts
 * Self-hosted brand faces. No Google Fonts — no third-party request, and nothing for a
 * fleet manager's browser to leak to a CDN on every page load.
 *
 * Extracted from app/static/fonts/{Varien,Instrument_Sans}.zip to URL-safe filenames; the
 * originals ship with spaces and a comma in the names, which do not belong in a url().
 */

/* ⚠️ VARIEN HAS EXACTLY ONE WEIGHT. The zip contains only Varien / Varien-Italic and their
 * Outline variants — no bold. So headings below are set to font-weight 400, not 700: asking
 * for 700 would make the browser synthesise a faux-bold and smear a display face. */
@font-face {
  font-family: "Varien";
  src: url("/static/fonts/varien/varien.woff2") format("woff2"),
       url("/static/fonts/varien/varien.woff") format("woff");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* Instrument Sans ships TTF only — no woff2 in the zip — so this is the variable font,
 * which is one 189 KB request covering every weight instead of ~68 KB per static weight.
 * Both format() hints are listed because Safari wanted "truetype-variations" historically
 * while others accept plain "truetype". */
@font-face {
  font-family: "Instrument Sans";
  src: url("/static/fonts/instrument-sans/instrument-sans-variable.ttf")
         format("truetype-variations"),
       url("/static/fonts/instrument-sans/instrument-sans-variable.ttf")
         format("truetype");
  font-weight: 100 900;
  font-stretch: 75% 100%;
  font-style: normal;
  font-display: swap;
}

:root {
  /* Brand palette, exact values */
  --text: #121212;           /* Midnight Ash */
  --primary: #028CFD;        /* Electric Ultramarine */
  --accent: #32FF6A;         /* Volt Green — accent only */
  --muted: #5E6366;          /* Iron Dust */
  /* Crystal Gray #E0E6EB is now the CONTENT PANE, not a border — see --bg-content. It was
     doing both jobs, which meant a Crystal Gray border on a Crystal Gray pane: 1.00:1,
     literally the same colour. There is no --border token any more; the only borders left
     are interactive ones, which use --border-control. */

  /* Surfaces, lightest-on-top layering: sidebar sits back, content pane recedes,
     cards come forward. All three are brand values, no invented greys. */
  --bg-sidebar: #121212;     /* Midnight Ash — dark panel, brand token, sidebar only */
  --bg-content: #FAFBFC;     /* Snow Drift   — near-white pane */
  --surface: #FFFFFF;        /* cards, with a shadow since both are near-white */

  /* ---- on-dark tokens. Only used inside the sidebar. Every one exists because its
     light-surface counterpart fails on Midnight Ash. ---- */
  --sidebar-text: #FAFBFC;              /* 18.08:1 */
  --sidebar-muted: #9AA1A6;             /* 7.16:1 — Iron Dust is 3.08:1 and unusable here */
  --sidebar-active: #028CFD;            /* 5.51:1 — the exact brand blue works on dark */
  --sidebar-hover: rgba(255, 255, 255, 0.06);
  --sidebar-divider: rgba(255, 255, 255, 0.14);   /* 1.48:1 hairline */
  --sidebar-btn-border: rgba(255, 255, 255, 0.45);/* 4.55:1, over the 3:1 non-text bar */

  /* Derived, each for a stated accessibility reason */
  /* Electric Ultramarine darkened until it clears 4.5:1 on the DARKEST surface it can land
     on (the Crystal Gray pane, 4.84:1). Also the focus-ring colour: #028CFD is only 2.70:1
     there and would miss the 3:1 non-text bar. */
  --primary-text: #0263B4;
  --on-primary: #121212;     /* Midnight Ash on the blue fill = 5.51:1 (white is 3.40) */
  /* Iron Dust. Inputs and buttons are interactive, so their boundary needs 3:1 — this is
     6.08:1 on white. Crystal Gray could never do this job at 1.26:1. */
  --border-control: var(--muted);

  --primary-tint: rgba(2, 140, 253, 0.08);   /* light-surface hover only */

  /* ---- type scale ----
   * One scale, used everywhere, so a bump is systemic rather than per-element. Sizes are
   * load-bearing for contrast: --fs-lg is 24px precisely because that is the WCAG
   * large-text threshold that makes Electric Ultramarine legal as a heading colour. */
  --fs-xs:   14px;   /* form labels (600 weight) */
  --fs-sm:   15px;   /* secondary meta: signed-in, login note */
  --fs-base: 17px;   /* body, nav labels, inputs, buttons, links, page subtitle */
  --fs-md:   19px;   /* card body copy */
  --fs-lg:   24px;   /* card headings — at the large-text threshold, not near it */
  --fs-xl:   28px;   /* login title */
  --fs-2xl:  34px;   /* page title */

  --font-head: "Varien", "Space Grotesk", "Helvetica Neue", Arial, sans-serif;
  --font-body: "Instrument Sans", -apple-system, "Segoe UI", Roboto, Arial, sans-serif;

  --sidebar-w: 264px;   /* widened for the larger nav labels */
  --radius: 10px;
}

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

html, body {
  margin: 0;
  padding: 0;
  min-height: 100%;
  /* Cultured is the page ground; the sidebar and cards sit on top of it. The login page
     has no sidebar, so it is a white card on this same Cultured ground — consistent
     layering rather than a special case. */
  background: var(--bg-content);
  color: var(--text);
  font-family: var(--font-body);
  font-size: var(--fs-base);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* font-weight 400, not 700 — Varien has one weight. See the @font-face note. */
h1, h2, h3 {
  font-family: var(--font-head);
  font-weight: 400;
  letter-spacing: 0.005em;
  margin: 0;
}

a { color: var(--primary-text); }

/* --primary-text, not --primary or --accent. Volt Green is 1.07:1 on the pane and invisible;
   Electric Ultramarine is 2.70:1 and misses the 3:1 non-text bar. This is the one ring colour
   that clears 3:1 on all three surfaces, so it is correct wherever focus lands. outline-offset
   keeps it on the background rather than against a button fill. */
:focus-visible {
  outline: 2px solid var(--primary-text);
  outline-offset: 2px;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--primary);
  color: var(--on-primary);
  padding: 10px 16px;
  font-size: var(--fs-base);
  font-weight: 600;
  z-index: 10;
}
.skip-link:focus { left: 8px; top: 8px; }

/* ---------------------------------------------------------------- shell */

.shell {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  min-height: 100vh;
}

.sidebar {
  display: flex;
  flex-direction: column;
  gap: 28px;
  padding: 24px 18px;
  /* No border: Midnight Ash against a near-white pane is an 18.08:1 step. A dividing line
     between those two would be invisible next to the fill change itself. */
  background: var(--bg-sidebar);
  color: var(--sidebar-text);
}

/* ----------------------------------------------------------------- logo
 * The real asset, not a CSS approximation. "Green + Black" is the correct pairing for a
 * light background with blue as primary: green icon, black wordmark. The all-green variant
 * in the same folder is for dark backgrounds and is deliberately unused here.
 *
 * Sized by width with height:auto so the 783x96 artwork keeps its aspect ratio. */
/* ⚠️ NO IMAGE MAY EXCEED ITS CONTAINER. The brand SVGs carry width="783", so an unconstrained
   one renders 783px wide and simply runs out of whatever card it is in — which is exactly
   what happened when the logo was placed on the wider signup card. A global cap makes that
   impossible for every image rather than for the ones someone remembered to style. */
img { max-width: 100%; height: auto; }

.brand-logo { display: block; }
/* ⚠️ BOTH SELECTORS ON PURPOSE. `.brand-logo img` only matches a WRAPPED image
   (<div class="brand-logo"><img></div>), so putting the class straight on the <img> — the
   obvious thing to write, and what the signup and reset templates do — matched nothing and
   left the logo at its intrinsic 783px. Supporting both shapes is cheaper than requiring
   every future template to remember the wrapper. */
.brand-logo img, img.brand-logo {
  display: block;
  width: 100%;
  max-width: 190px;
  height: auto;
}
.brand-logo--lg img, img.brand-logo--lg { max-width: 244px; }

.nav { display: flex; flex-direction: column; gap: 2px; }

.nav-link {
  display: block;
  padding: 10px 12px;
  border-radius: 8px;
  /* Flipped to light: --text (Midnight Ash) on Midnight Ash would be invisible. */
  color: var(--sidebar-text);
  text-decoration: none;
  font-size: var(--fs-base);
  /* 3px of transparent border reserved so the active state does not shift the text. */
  border-left: 3px solid transparent;
}
.nav-link:hover { background: var(--sidebar-hover); }

/* ⚠️ NO BACKGROUND TINT — see point 4 in the header. The green tint was both nearly
   invisible on near-black (1.45:1) and enough to push this blue label under 4.5:1 (3.80:1).
   Removing it puts the label back at 5.51:1 with full margin.
   Three visual signals plus aria-current, and on dark the bar is the strongest of them at
   13.96:1 rather than the 1.30:1 it managed on a light surface. */
.nav-link.is-active {
  border-left-color: var(--accent);
  color: var(--sidebar-active);
  font-weight: 600;
}

.sidebar-foot {
  margin-top: auto;
  /* The one divider inside the sidebar, separating nav from the account block. White at
     14% rather than Crystal Gray, which would be a 14.89:1 glare line on near-black. */
  border-top: 1px solid var(--sidebar-divider);
  padding-top: 18px;
}

.signed-in {
  /* NOT --muted. Iron Dust is 3.08:1 on Midnight Ash and fails outright; this is 7.16:1. */
  color: var(--sidebar-muted);
  font-size: var(--fs-sm);
  margin: 0 0 12px;
  /* An email address is one unbroken token and can be longer than the sidebar. Without this
     it either overflows or forces the panel wider. */
  overflow-wrap: anywhere;
}
.signed-in strong { color: var(--sidebar-text); font-weight: 600; }

/* The company name is the primary line in the sidebar foot now — a fleet manager sees only
   their own tenant, so naming it is a standing reminder of whose data is on screen. Full
   contrast (18.08:1) rather than the muted account line under it. */
.signed-in--company {
  font-size: var(--fs-base);
  margin-bottom: 2px;
  /* A long company name must wrap, not widen or clip the fixed-width sidebar. */
  overflow-wrap: anywhere;
}

.content {
  padding: 36px 40px;
  max-width: 1100px;

  /* ⚠️ min-width: 0 IS LOAD-BEARING AND IS NOT REDUNDANT. `.content` is the `1fr` column of
   * the .shell grid, and a grid item defaults to `min-width: auto`, meaning it refuses to
   * shrink below its content's minimum. So a wide table pushed the COLUMN wider than the
   * viewport and the whole PAGE scrolled sideways — with the panel's own `overflow-x: auto`
   * never engaging, because the panel was never actually constrained.
   *
   * Measured 2026-09-03: without this, a 1440px viewport gave a 1252px panel in a ~1096px
   * space and documentElement.scrollWidth exceeded clientWidth. With it, the column stops
   * at the available width and the TABLE scrolls inside its card instead of the page moving.
   * The same trap applies to any future flex/grid child wrapping a wide element. */
  min-width: 0;
}

/* ⚠️ THE LIST PAGES GET A WIDER COLUMN, AND 1100px IS NOT AN ARBITRARY CAP TO RAISE
 * GLOBALLY. It is a reading measure: forms and prose become hard to scan much past it, so
 * Account details, Payment details and every form page keep it. A data table is a different
 * object — trackers now carries eight columns (IMEI, Name, Device, Vehicle, Connectivity,
 * Device settings, Unassign, Delete), and capping it at 1100 wasted the ~1336px actually
 * available at a 1600px viewport while clipping the last column.
 *
 * Scoped by data-page, which base.html already sets on <html> from the nav key, so no
 * template needed a new class. */
html[data-page="trackers"] .content,
html[data-page="vehicles"] .content { max-width: 1400px; }
.content-plain { padding: 0; }

/* ------------------------------------------------------------- controls */

.btn-primary, .btn-secondary, .btn-danger, .btn-outline {
  font-family: var(--font-body);
  font-size: var(--fs-base);
  font-weight: 600;
  border-radius: 8px;
  padding: 12px 20px;
  cursor: pointer;
  border: 1px solid transparent;
}

/* Electric Ultramarine is the primary CTA. Dark text on it, not white: 5.51:1 against
   3.40:1. */
.btn-primary {
  background: var(--primary);
  color: var(--on-primary);
  width: 100%;
}
.btn-primary:hover { filter: brightness(1.06); }

/* ⚠️ .btn-secondary IS FOR THE DARK SIDEBAR ONLY. Its only use is Log out. It is styled for
   dark — light label (18.08:1) and a light border at 4.55:1, over the 3:1 non-text bar — so
   ON A WHITE CARD IT IS INVISIBLE: near-white text on white measures 1.04:1, with a
   transparent background, leaving a clickable box you can only find by selecting it.
   That is not hypothetical; the deactivate confirmation shipped with this class and did
   exactly that. On a light surface use .btn-primary, or .btn-danger below. */
.btn-secondary {
  background: transparent;
  color: var(--sidebar-text);
  border-color: var(--sidebar-btn-border);
}
.btn-secondary:hover {
  background: var(--sidebar-hover);
  border-color: var(--sidebar-text);
}

/* A destructive-leaning confirmation on a LIGHT surface. Outlined rather than filled: the
   filled blue .btn-primary is the affirmative action across the portal, and this must not
   read as the same weight — but it must still be unmistakably a button, not a link. The
   1px border, 20px padding, 600 weight and 8px radius come from the shared base above, so
   it is the same object as every other button, only in the error palette.

   Colours are the ones the form errors already use: #8E2A20 label at 6.4:1 on white (WCAG
   AA for text needs 4.5:1) and a #C0392B border at 5.25:1 (non-text needs 3:1). Hover
   fills to make the affordance obvious without changing the label's contrast. */
.btn-danger {
  background: transparent;
  color: #8E2A20;
  border-color: #C0392B;
  width: auto;
}
.btn-danger:hover, .btn-danger:focus-visible {
  background: rgba(192, 57, 43, 0.07);
  border-color: #8E2A20;
}

/* A GENUINE SECONDARY ACTION ON A LIGHT SURFACE — equal weight to .btn-primary, not a
 * lesser one. Built exactly like .btn-danger above (transparent, outlined, sharing the base
 * rule's padding/radius/weight/border) but in the primary palette, so the pair reads as
 * "two things you might do" rather than "one action and one afterthought".
 *
 * ⚠️ THIS EXISTS BECAUSE .btn-secondary CANNOT BE USED HERE. That class is documented
 * sidebar-only: near-white text on a transparent background measures 1.04:1 on a white card
 * and leaves an invisible clickable box. That is not hypothetical — the deactivate
 * confirmation shipped with it and did exactly that. Read the warning above .btn-secondary
 * before reaching for it on any light surface.
 *
 * ⚠️ AND NOT .btn-quiet EITHER, which is documented as deliberately NOT equal to the primary
 * action ("Cancel"). When two actions are genuinely alternatives — add one tracker, or add a
 * delivery of them — making one look like a cancel link misrepresents the choice.
 *
 * --primary-text #0263B4 is 6.09:1 on a card for the label and the same for the border,
 * comfortably over 4.5:1 for text and 3:1 for non-text. NOT the brand blue #028CFD, which is
 * 3.40:1 on white and would fail as a label — the two blues swap roles by surface, per the
 * table at the top of this file. */
.btn-outline {
  background: transparent;
  color: var(--primary-text);
  border-color: var(--primary-text);
  width: auto;
}
.btn-outline:hover, .btn-outline:focus-visible {
  background: var(--primary-tint);
}

/* ⚠️ THE `hidden` ATTRIBUTE MUST ACTUALLY HIDE, AND WITHOUT THIS IT DOES NOT.
 *
 * The UA stylesheet's `[hidden] { display: none }` is an ATTRIBUTE selector — specificity
 * (0,1,0) — so ANY class in this file that sets `display` beats it. That is not theoretical:
 * `.assign-form { display: flex }` and `.btn-quiet { display: inline-block }` both did,
 * which left the inline-edit form and its Edit button visible while carrying `hidden`, in
 * both directions — the initial no-JavaScript render AND every toggle. Found by driving a
 * real browser; the markup tests could not see it, because the attribute was correctly
 * present the whole time.
 *
 * This is the SECOND time `hidden` has silently failed in this portal. The first was the
 * password toggle, where it was inert because `.hidden` is an HTMLElement property and the
 * icons are SVG (see the note in _password_toggle.html). Different cause, same symptom: a
 * control that claims to be hidden and is not.
 *
 * ⚠️ `!important` IS DELIBERATE AND IS THE POINT, not a shortcut around a specificity
 * problem. `hidden` is a semantic promise — the element is not relevant yet — and no
 * decorative `display` rule should be able to override it by accident. Scoping it to one
 * component instead would fix this component and leave the trap armed for the next one.
 * Anything that needs to be visible must not carry the attribute. */
[hidden] { display: none !important; }

label {
  display: block;
  font-size: var(--fs-xs);
  font-weight: 600;
  margin: 18px 0 7px;
  color: var(--text);
}

/* email/tel/textarea added for the B1 customer form. Listed explicitly rather than as a bare
   `input` selector so a future checkbox or radio does not silently inherit a 100%-wide text
   box. */
input[type="text"], input[type="password"], input[type="email"], input[type="tel"],
textarea {
  width: 100%;
  padding: 12px 14px;
  border-radius: 8px;
  /* Iron Dust, not Crystal Gray — an input boundary needs 3:1 (WCAG 1.4.11). */
  border: 1px solid var(--border-control);
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-body);
  font-size: var(--fs-base);
}
input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus,
input[type="tel"]:focus, textarea:focus {
  border-color: var(--primary);
}

textarea {
  /* vertical only: horizontal resize can drag the textarea wider than the card. */
  resize: vertical;
  line-height: 1.5;
}

/* ---------------------------------------------------------------- login */

.login-wrap {
  /* min-height, NOT height, AND THAT IS THE LOAD-BEARING PART. Because it is a minimum, the
     wrapper grows past the viewport when the card is taller than it — so there is never
     negative free space for the centring below to distribute, and the top of a long form
     can always be scrolled to. Change this to `height: 100vh` and the signup card becomes
     centre-clipped with its first field unreachable. Verified in Chromium, WebKit and
     Firefox at 900px and 600px viewport heights, 2026-09-01. */
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  /* So a centred card never touches the viewport edge, and a tall one keeps breathing room
     above it once the page scrolls. */
  padding: 48px 24px;
}

.login-card {
  width: 100%;
  max-width: 380px;
  padding: 32px;
  border-radius: var(--radius);
  background: var(--surface);
  /* Same as .panel: the fill step carries the boundary, the shadow gives it lift. */
  box-shadow: 0 1px 2px rgba(18, 18, 18, 0.04), 0 8px 24px rgba(18, 18, 18, 0.06);
}
.login-card h1 { font-size: var(--fs-xl); margin: 24px 0 4px; }

/* Signup and finish-setup carry the five company fields plus credentials, which is far too
   much for the 380px sign-in card. */
.login-card--wide { max-width: 560px; }

/* Section headings inside a long form. --fs-lg is 24px, the WCAG large-text threshold, so
   Electric Ultramarine is legal here at 3.40:1 — below 24px it would need 4.5:1 and fail. */
.form-section {
  font-size: var(--fs-lg);
  color: var(--primary);
  margin: 34px 0 4px;
}
.form-section:first-of-type { margin-top: 26px; }

/* A secondary action that has to sit directly under a form without inheriting its spacing. */
.inline-form { margin: 10px 0 0; }
.btn-quiet--flush { padding-left: 0; padding-right: 0; }
.btn-quiet--flush:hover { background: transparent; border-color: transparent;
                          text-decoration: underline; }

.form-error {
  margin: 16px 0 0;
  padding: 10px 12px;
  border-radius: 8px;
  /* Not colour alone: role="alert" in the template carries it to a screen reader, and the
     left bar is a non-colour visual cue. */
  border-left: 3px solid #C0392B;
  background: rgba(192, 57, 43, 0.07);
  color: #8E2A20;            /* 6.4:1 on white */
  font-size: var(--fs-sm);
}

/* ------------------------------------------------- password show/hide toggle */

/* The input keeps its own full-width styling; this only positions the button over its
   right-hand end. Note that input[type="text"] and input[type="password"] share the same
   rule above, so flipping the type does not change how the field looks. */
.password-wrap { position: relative; }
.password-wrap input { padding-right: 46px; }

.password-toggle {
  position: absolute;
  top: 0;
  right: 0;
  /* Full height of the input so the hit area is the whole right-hand end, not just the
     glyph. 44px is the WCAG 2.5.5 target-size guideline and the input is 46px tall. */
  height: 100%;
  width: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--muted);          /* 6.08:1 on white; it is an icon, not text, but same token */
  cursor: pointer;
  border-radius: 8px;
}
.password-toggle:hover { color: var(--text); }

/* Exactly one icon at a time. See the note in _password_toggle.html for why this is a class
   rather than the `hidden` attribute: `hidden` does not apply to SVG elements, so both icons
   rendered at once. */
.password-toggle__icon.is-hidden { display: none; }
/* Keyboard focus must be visible: the button sits inside the input's visual box, so an
   outline is the only thing that shows which control is focused. */
.password-toggle:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: -2px;
}

.login-card form { margin-top: 4px; }
.login-card .btn-primary { margin-top: 22px; }

.login-note {
  margin: 22px 0 0;
  font-size: var(--fs-sm);
  color: var(--muted);
}
.login-note--tight { margin-top: 10px; }

/* ---------------------------------------------------------------- pages */

.page-head { margin-bottom: 26px; }
.page-head h1 { font-size: var(--fs-2xl); }

.page-sub {
  margin: 8px 0 0;
  color: var(--muted);
  font-size: var(--fs-base);
}

/* ⚠️ THE SHADOW IS NOW LOAD-BEARING, NOT DECORATION. White on Snow Drift is a ΔL* of only
   1.4 — the two are all but the same colour, which is exactly the problem the Cultured pane
   had. With the tinted pane gone, the shadow is the only thing separating a card from the
   pane, so it must not be removed as "polish". */
.panel {
  padding: 30px;
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: 0 1px 2px rgba(18, 18, 18, 0.04), 0 6px 18px rgba(18, 18, 18, 0.05);
}

/* "Coming soon" is a heading, so it uses --primary at large size where 3:1 applies and
   Electric Ultramarine passes. The green dot beside it is the accent detail. */
.soon {
  /* 24px exactly: the WCAG large-text threshold. Below it, Electric Ultramarine
     (3.40:1 on white) would need 4.5:1 and fail — which is what the 20px version did. */
  font-size: var(--fs-lg);
  color: var(--primary);
  margin-bottom: 10px;
  display: flex;
  align-items: center;
  gap: 9px;
}
.soon::before {
  content: "";
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--accent);
  /* A 1.30:1 green dot needs an edge to be visible at all on a light background. */
  outline: 1px solid rgba(18, 18, 18, 0.18);
  flex: 0 0 auto;
}

/* ⚠️ `.muted` WAS NEVER DEFINED AT ALL UNTIL 2026-09-02, ONLY `.muted-lg`. Six spans across
 * four templates carried `class="muted"` — "Unassigned" and "Offline" on the trackers list,
 * "Unassigned" on the vehicles list, "No billing address yet" on Payment details — and every
 * one rendered in full-strength body text, indistinguishable from the primary value beside
 * it. Measured in a real browser at rgb(18,18,18) (--text) where --muted is rgb(94,99,102).
 *
 * ⚠️ AND MARKUP TESTS CANNOT SEE THIS. Asserting `<span class="muted">…</span>` is in the
 * HTML passes whether or not the class does anything — which is precisely how it survived
 * this long. Same lesson as the `[hidden]` rule above: the presence of a class or attribute
 * is not evidence of its effect. Only scripts/portal_render_check.py measuring computed
 * style can tell.
 *
 * ⚠️ THE `.data-table td` SELECTOR IS WHY THIS NEEDS THE SECOND LINE. Once `.muted` exists
 * at specificity (0,1,0), `.data-table td { color: var(--text) }` at (0,2,0) still beats it
 * inside a table — which is where four of the six uses are. The compound selector below
 * wins on specificity without `!important`. Do not collapse the two rules into one.
 *
 * Iron Dust is 5.87:1 on the pane and 6.08:1 on a card — comfortably over the 4.5:1 that
 * applies to this text, so a muted label stays readable rather than becoming decorative. */
.muted { color: var(--muted); }
.data-table .muted { color: var(--muted); }

.muted-lg { margin: 0; font-size: var(--fs-md); color: var(--muted); }

/* ------------------------------------------------------------------- empty states
 *
 * The "no trackers yet" / "no vehicles yet" panels.
 *
 * ⚠️ THE DESCRIPTION USED .muted-lg AND THAT WAS THE WRONG CLASS, NOT A MISSING ONE.
 * `.muted-lg` is `font-size: var(--fs-md)` — 19px, i.e. 2px LARGER than the 17px body text,
 * because it is the card-body-copy style for a card's PRIMARY prose. Applied to supporting
 * copy it muted the colour while emphasising the size, so the paragraph read as important
 * body text rather than as explanation. Measured in a real browser at 19px/rgb(94,99,102)
 * against a 17px body.
 *
 * ⚠️ SO THIS IS NOT ANOTHER INSTANCE OF THE `.muted` / `[hidden]` CLASS-DOES-NOTHING BUG.
 * Those were controls whose styling silently never applied. Here the class applied perfectly
 * and did exactly what it says; it was simply the wrong one to ask for. Worth keeping the two
 * diagnoses apart: the first is found by measuring computed style, this one only by asking
 * what the size SHOULD be.
 *
 * --fs-base, not --fs-sm: this is the primary guidance on an otherwise empty page, so it
 * should sit at body size and be de-emphasised by colour alone. 15px would make the only
 * text on the screen the smallest thing on it. */
.empty-state__text {
  margin: 0 0 20px;
  font-size: var(--fs-base);
  color: var(--muted);
}

/* ⚠️ wrap, NOT nowrap — the same reasoning as .modal__actions. "Add trackers in bulk"
 * beside "Add single tracker" is wide, and a button pushed off the edge of a card is a
 * broken action rather than an ugly one. */
.empty-state__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
}

/* ------------------------------------------------- customers (Epic B / B1)
 *
 * Everything below sits on the CONTENT PANE (--bg-content #FAFBFC) or inside a white card,
 * never in the sidebar, so it uses the light-surface tokens. The on-dark tokens
 * (--sidebar-*) must not appear here.
 *
 * Three new colours were needed and each was picked by measurement, not by eye — see the
 * contrast table in the header comment. The status colours could not reuse the brand palette:
 * Volt Green is 1.30:1 on white and cannot carry text at any size, which is why "created"
 * is a dark green rather than the brand green.
 */

/* Visible to a screen reader, not on screen. Used for a table caption that would be
   redundant next to the page heading but leaves the table unnamed if simply dropped. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.page-head--with-action {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
}

/* The page-level buttons are inline-sized; .btn-primary on its own is a full-width form
   button (the login screen), so width has to be undone rather than assumed. */
.btn-inline {
  width: auto;
  display: inline-block;
  text-decoration: none;
  flex: 0 0 auto;
}

/* Cancel. A link styled as a quiet button: it must not look equal to the primary action,
   but it still needs a 4.5:1 label because it is real text. */
.btn-quiet {
  display: inline-block;
  padding: 12px 18px;
  border-radius: 8px;
  border: 1px solid transparent;
  background: transparent;
  color: var(--primary-text);
  font-family: var(--font-body);
  font-size: var(--fs-base);
  font-weight: 600;
  text-decoration: none;
}
.btn-quiet:hover {
  background: var(--primary-tint);
  border-color: var(--primary-text);
}

.breadcrumb {
  display: flex;
  align-items: center;
  gap: 9px;
  margin-bottom: 14px;
  font-size: var(--fs-sm);
  color: var(--muted);
}
.breadcrumb a { font-weight: 600; }

/* ---- notices ----
 * Never colour alone: each has a left bar (a non-colour visual cue) and the template puts
 * role="status" or role="alert" on it so the message is announced, not just shown.
 * Same construction as the existing .form-error. */
.notice {
  margin: 0 0 22px;
  padding: 13px 16px;
  border-radius: 8px;
  border-left: 3px solid;
  font-size: var(--fs-base);
}
.notice--ok {
  /* #1B6E3A: 5.42:1 on its own tint, 6.07:1 as the bar against the pane.
     ⚠️ NOT Volt Green, and this is the reason: the brand green is 1.30:1 on the pane, so it
     cannot carry text at any size. A "success" notice in brand green would be unreadable. */
  border-left-color: #1B6E3A;
  background: rgba(27, 110, 58, 0.08);
  color: #1B6E3A;
}
.notice--warn {
  /* #7A4B00: 6.35:1 on its own tint, 7.15:1 as the bar. Amber is the one hue whose readable
     version looks brown; that is the cost of it being readable. */
  border-left-color: #7A4B00;
  background: rgba(122, 75, 0, 0.08);
  color: #7A4B00;
}
.notice--error {
  /* 7.30:1 on its own tint; the #C0392B bar is 5.25:1 against the pane. */
  border-left-color: #C0392B;
  background: rgba(192, 57, 43, 0.07);
  color: #8E2A20;
}
.notice a { color: inherit; font-weight: 600; }

/* Inside a card the notice follows a heading or an intro line whose bottom margin is 0, so
   without this it butts straight against the text. Scoped to the card rather than added to
   .notice itself, because on a full page the notice follows .page-head, which already
   contributes 26px. */
.login-card .notice { margin-top: 18px; }

/* ---- table ---- */

/* panel--flush: the table draws its own cell padding, so the card must not add its own or
   the first column sits 30px from the edge while the divider lines run full width. */
/* ⚠️ overflow-x: auto, NOT hidden — THIS WAS CLIPPING THE DELETE BUTTON OFF EVERY ROW.
 * `overflow: hidden` kept the table's square corners inside the card's radius, and also
 * silently cut off anything wider than the card. Measured 2026-09-03 at a 1440px viewport:
 * panel 1020px, trackers table 1159px, so Delete sat 214px past the edge — invisible and
 * unclickable, at every desktop width, not just on a phone.
 *
 * overflow-y stays hidden so the corner clipping still works; only the horizontal axis
 * becomes scrollable. This is the correctness floor: whatever else changes, no control can
 * end up unreachable. The width fixes below are what stop the scrollbar being needed at all
 * on a normal screen. */
.panel--flush { padding: 0; overflow-x: auto; overflow-y: hidden; }

/* ⚠️ THE TABLE MUST SCROLL INSIDE THIS, NOT PUSH THE PAGE WIDE. Four columns fit today;
   an email address plus a long company name does not, on a narrow window. Without this the
   whole layout scrolls sideways and the sidebar leaves the screen. */
.table-scroll { overflow-x: auto; }

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-base);
}
.data-table th, .data-table td {
  padding: 15px 20px;
  text-align: left;
  vertical-align: top;
}
.data-table thead th {
  font-family: var(--font-body);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);              /* 6.08:1 on white */
  /* Iron Dust at full strength, not a hairline: this is the line that separates headers
     from data, which is structural rather than decorative. */
  border-bottom: 1px solid var(--muted);
  white-space: nowrap;
}
/* Row dividers are decorative and stay light on purpose — 1.20:1, well under 3:1, and that
   is a deliberate exemption rather than an oversight. The rows are already separated by
   padding and each begins with a link, so the content is understandable without them, which
   is what puts them outside 1.4.11. A 3:1 divider on every row would read as a grid of
   boxes. The header rule, which IS structural, is Iron Dust at 6.08:1 instead. */
.data-table tbody tr + tr th, .data-table tbody tr + tr td {
  border-top: 1px solid rgba(18, 18, 18, 0.09);
}
.data-table tbody tr:hover { background: var(--primary-tint); }
/* The row header is a <th scope="row">, so it needs its heading weight undone — it is data,
   and the browser default would bold every company name into a false emphasis. */
.data-table tbody th { font-weight: 600; }
.data-table tbody th a { font-weight: 600; }
.data-table td { color: var(--text); }

/* Underlines are KEPT, only thinned. Two link columns underlined at the browser default
   makes the table look struck through, but removing them would leave colour as the only
   thing marking a link (WCAG 1.4.1). Thinner and offset keeps the cue and calms it down. */
.data-table a {
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}

.table-foot {
  margin: 16px 0 0;
  font-size: var(--fs-sm);
  color: var(--muted);
}

/* ---- form ---- */

/* ⚠️ THE FORM IS A COLUMN, NOT THE FULL CARD WIDTH. Inputs inherit width:100%, so without
   this cap a company-name box is ~1330px on a desktop window — a single line of text in a
   field wide enough for a paragraph, which makes it genuinely hard to tell where one field
   ends and the next begins. The cap is on the form rather than on the inputs so the labels,
   help text and error messages share the same measure. */
.panel form { max-width: 640px; }

.field { margin-bottom: 4px; }
.field-help {
  margin: 6px 0 0;
  font-size: var(--fs-sm);
  color: var(--muted);              /* 6.08:1 on white */
}
.field-error {
  margin: 7px 0 0;
  font-size: var(--fs-sm);
  font-weight: 600;
  color: #8E2A20;                   /* 8.39:1 on white */
}
/* Not colour alone: the message above states the problem in words, and aria-invalid in the
   template carries it to a screen reader. The red edge is the third cue, not the only one.
   5.44:1 against the card, comfortably over the 3:1 an input boundary needs. */
.field--error input, .field--error textarea { border-color: #C0392B; }

.req {
  font-weight: 400;
  color: var(--muted);
  /* Not an asterisk. "*" has to be explained by a legend somewhere, and a screen reader
     reads it as "star" or skips it entirely. The word costs nothing. */
}

.form-actions {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 28px 0 0;
  flex-wrap: wrap;
}

/* ---- company members ----
   The seat list on /account. One row per person: who, what kind of account, and (for an
   invited member only) the Remove trigger. The primary row has no third cell at all rather
   than a disabled one — see the note in account.html. */

.member-list {
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--fs-sm);
}

.member-row {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px 0;
  border-top: 1px solid rgba(18, 18, 18, 0.09);
}
.member-row:first-child { border-top: 0; }

/* Takes the slack so the role and the action sit hard right, and long addresses wrap
   instead of pushing the Remove button off a narrow screen. */
.member-row__who {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
  color: var(--text);
}

.member-row__tag {
  display: inline-block;
  margin-left: 6px;
  padding: 1px 7px;
  border-radius: 999px;
  font-size: 0.82em;
  color: var(--muted);
  background: rgba(18, 18, 18, 0.06);
}

.member-row__role {
  flex: 0 0 auto;
  color: var(--muted);
}

/* ---- invite a member ----
   Sits under the member list on /account. Rendered only when a seat is free; the route
   refuses at capacity regardless (see account.html). */

.invite-form {
  margin-top: 18px;
  padding-top: 18px;
  border-top: 1px solid rgba(18, 18, 18, 0.09);
}

.invite-form__label {
  display: block;
  margin-bottom: 8px;
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--text);
}

/* Wraps rather than shrinking the input to nothing on a narrow screen — the same
   overflow lesson as the trackers table, where a fixed-width control pushed the action
   off the panel edge. */
.invite-form__row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}

.invite-form__input {
  flex: 1 1 260px;
  min-width: 0;
  padding: 10px 12px;
  font-family: inherit;
  font-size: var(--fs-base);
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--border-control);
  border-radius: 8px;
}

.invite-form__input:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 1px;
}

.invite-form__help {
  margin: 10px 0 0;
  font-size: var(--fs-sm);
  color: var(--muted);
}

/* ---- record metadata ---- */

.panel--meta { margin-top: 22px; }
.meta-heading { font-size: var(--fs-lg); color: var(--primary); margin-bottom: 14px; }

.meta-list {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 8px 24px;
  margin: 0;
  font-size: var(--fs-sm);
}
.meta-list dt { color: var(--muted); }
.meta-list dd { margin: 0; color: var(--text); }
.meta-list code {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 0.92em;
  padding: 2px 6px;
  border-radius: 5px;
  background: rgba(18, 18, 18, 0.05);
}

/* A readable bulleted list inside a panel. The browser default indents with a padding that
   does not line up with anything else in the card, and sets a line-height tuned for prose
   rather than for our 15px body. */
.plain-list {
  margin: 0 0 22px;
  padding-left: 20px;
  color: var(--text);
  line-height: 1.55;
}
.plain-list li { margin-bottom: 8px; }
.plain-list li:last-child { margin-bottom: 0; }

/* The deactivate link in the Account section. ⚠️ Deliberately quiet and deliberately NOT a
   button: this is a destructive-sounding action reached from a page people visit to read
   their own details, so it must not sit under the thumb of someone skimming. Underlined
   rather than coloured alone, because colour is not a cue everyone gets. */
.meta-danger-link {
  display: inline-block;
  margin-top: 18px;
  font-size: var(--fs-sm);
  color: var(--muted);
  text-decoration: underline;
}
.meta-danger-link:hover, .meta-danger-link:focus-visible { color: #8E2A20; }

/* The inline tracker-assignment control: a select and a button on one line, wrapping on a
   narrow viewport rather than overflowing the table cell. */
.assign-form {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  margin: 0;
}
.assign-form select {
  font-family: var(--font-body);
  font-size: var(--fs-sm);
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--border-control);
  background: var(--surface);
  color: var(--text);

  /* ⚠️ CAPPED, BECAUSE A <select> IS AS WIDE AS ITS LONGEST OPTION AND THAT WIDTH IS
   * UNBOUNDED. The option labels are "{name} ({registration})" and "{name} ({model})", so
   * one vehicle called something long silently widened the whole column — measured at
   * 232px for the Vehicle column, 114px of the table's 139px overspill. The cap bounds a
   * row's contribution to the table's minimum width regardless of what anyone names a
   * vehicle; the full label is still readable in the open dropdown, which is not
   * constrained by this. */
  max-width: 190px;
  text-overflow: ellipsis;
}
.assign-form .btn-inline { padding: 8px 14px; font-size: var(--fs-sm); }

/* The connectivity LED. A dot plus a text label beside it, never colour alone — a red and
   a green circle are indistinguishable to a red-green colour-blind reader, which is roughly
   1 in 12 men. The word is the accessible signal; the dot is the glanceable one. */
.led {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  margin-right: 6px;
  vertical-align: middle;
  /* A ring rather than a flat dot, so it stays visible against the row on hover. */
  box-shadow: 0 0 0 1px rgba(18, 18, 18, 0.18) inset;
  /* ⚠️ A NEUTRAL BASE, SO A DOT WITH NO STATE CLASS IS STILL VISIBLE. The device screen's
     connection indicator has a genuine third state — "we could not ask" — which must not
     borrow the red of "not connected", because that would assert something about the
     hardware on the strength of our own failure. It doubles as a safety net: a .led that
     ever lost its modifier used to render as a transparent 9px gap rather than as anything
     a reader could see. */
  background: #9AA0A6;
}
.led--on  { background: #1E8E3E; }   /* 4.6:1 on white as a non-text indicator */
.led--off { background: #C0392B; }   /* the same red the form errors use */

/* The device screen's connection line: dot, state word, then a muted explanation. Laid out
   rather than left to inline flow so the dot stays optically aligned with the words at any
   font size. */
.device-connection {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
}
.device-connection .led { align-self: center; margin-right: 0; }

/* The "Last signal" line under the connectivity badge on the trackers list. Its own block so
   it sits under the badge rather than running on after it, and at --fs-sm because it is
   supporting detail for the badge above it, not a value in its own right. */
/* ⚠️ TWO RULES, AND BOTH ARE NEEDED — THE SAME TRAP `.muted` FELL INTO ON 2026-09-02.
   `.data-table td { color: var(--text) }` is specificity (0,2,0) and beats a bare class at
   (0,1,0), so the bare rule alone would render this line in full-strength body text,
   indistinguishable from the value above it. And the token is `--muted`, not
   `--text-muted`: an undefined custom property makes the whole declaration invalid at
   computed-value time, so `color` would silently inherit rather than fail visibly. Both
   mistakes were in the first draft of this rule; a markup test asserting the class is
   present would have passed through either. */
.tracker-last-seen {
  display: block;
  margin-top: 2px;
  font-size: var(--fs-sm);
  color: var(--muted);
  /* The column is already the narrowest thing overflowing this table (see .panel--flush);
     a wrap here would push it wider again. */
  white-space: nowrap;
}
.data-table .tracker-last-seen { color: var(--muted); }

/* The delete control. Quiet by default so it is not one stray thumb away, red on intent. */
.btn-quiet--danger { color: #8E2A20; }
.btn-quiet--danger:hover, .btn-quiet--danger:focus-visible {
  background: rgba(192, 57, 43, 0.07);
  color: #8E2A20;
}

.meta-note {
  margin: 18px 0 0;
  font-size: var(--fs-sm);
  color: var(--muted);
}

/* ------------------------------------------------------------ confirmation dialogs
 *
 * The one confirmation mechanism in this portal — a native <dialog>, opened with
 * showModal(). See app/templates/portal/_confirm_modal.html.
 *
 * ⚠️ A CLOSED <dialog> IS display:none BY THE UA STYLESHEET, so nothing here needs to hide
 * it and nothing here should try. Setting `display` on the element itself is the classic way
 * to break showModal(): an explicit `display: block` makes the dialog visible even when
 * closed, and `display: none` stops it opening. Style the OPEN state only, via [open].
 *
 * The card matches .panel — same radius, same white surface, same shadow scale — because it
 * is the same object as every other card in the portal, just floating. */
.modal {
  border: 0;
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text);
  padding: 28px;
  max-width: 520px;
  /* min() not a bare percentage: on a narrow phone a 520px card would overflow, and on a
     wide screen a percentage would stretch a two-sentence dialog across the display. */
  width: min(520px, calc(100vw - 32px));
  box-shadow: 0 8px 32px rgba(18, 18, 18, 0.18);
}

/* Dims the page behind. ⚠️ ::backdrop ONLY EXISTS FOR showModal(), not for a dialog
   opened by setting the `open` attribute — which is one more reason the JS uses
   showModal(). */
.modal::backdrop {
  background: rgba(18, 18, 18, 0.45);
}

.modal__heading {
  font-family: var(--font-head);
  font-size: var(--fs-lg);
  margin: 0 0 10px;
  color: var(--text);
}

.modal__text {
  margin: 0 0 22px;
  font-size: var(--fs-md);
  color: var(--muted);
}

/* ⚠️ wrap, NOT nowrap. "Unassign and delete" beside "Unassign only" beside "Cancel" is wide
   enough to overflow a 520px card at the base font size, and a button pushed off the edge of
   a modal is unreachable — there is nothing to scroll. */
.modal__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}

/* Each action is its own <form> (one endpoint per outcome), so the wrappers must not
   introduce layout of their own. */
.modal__action { margin: 0; }
/* ⚠️ OVERRIDES `.panel form { max-width: 640px }`. A dialog inside a panel would otherwise
   inherit a max-width that squeezes the button row. */
.modal form { max-width: none; }

/* --------------------------------------------------------- inline name editing
 *
 * See app/templates/portal/_inline_name.html. The form is rendered visible and the
 * plain-text view hidden; portal.js swaps them, so with JavaScript off this degrades to the
 * always-visible input + Save that shipped before. */
.inline-name {
  display: flex;
  align-items: center;
  gap: 8px;

  /* ⚠️ nowrap BECAUSE A HYPHEN IS A LINE-BREAK OPPORTUNITY, AND THAT IS NOT A WIDTH
   * PROBLEM. "Tracker-1" was rendering as "Tracker-" / "1" on two lines, and
   * "Van-Depot-1" on three, with apparent empty space left in the cell. Measured in a real
   * browser at a 820px viewport: 2 and 3 line boxes respectively, 54px and 82px tall
   * against a 27px line-height.
   *
   * The empty space is the giveaway and explains the whole mechanism: `table-layout: auto`
   * sizes a column to its MINIMUM content width, and a hyphen gives the browser permission
   * to make that minimum "Tracker-" instead of "Tracker-1". So the column is allocated
   * narrower than the name, and the name then has to wrap to fit the column it caused.
   * Widening anything would not fix it — the browser would just take the narrower minimum
   * again.
   *
   * ⚠️ `hyphens: none` DOES NOT FIX THIS and is not used. It suppresses AUTOMATIC
   * hyphenation — inserting hyphens that are not in the text — and says nothing about
   * breaking at a hyphen the author typed. `word-break: keep-all` is also not it: it is
   * defined for CJK and does not remove a break opportunity at U+002D in Latin text.
   * Removing the opportunity outright is what works.
   *
   * Inherited, so it covers the name text, the Edit button and the input in one place —
   * and both tables, since they share _inline_name.html.
   *
   * ⚠️ THE COST, STATED: a very long name now widens its column instead of wrapping, which
   * adds to the narrow-screen table overflow already recorded against .panel--flush. A name
   * is capped at 60 characters, so the width is bounded. */
  white-space: nowrap;
}

/* The read-mode name. Weight matches the table body rather than the muted secondary text:
   it is the row's primary identifier on both lists. */
.inline-name__text {
  font-weight: 600;
  color: var(--text);
}

/* ⚠️ THE EDIT AFFORDANCE IS SMALLER THAN A ROW ACTION, ON PURPOSE. Every row has one, so at
   full button size the column would read as a wall of controls rather than a list of names.
   Still a real button with a 4.5:1 label — see .btn-quiet. */
.inline-name__edit,
.inline-name__form .btn-inline {
  padding: 6px 10px;
  font-size: var(--fs-sm);
}

.inline-name__form {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
}

/* -------------------------------------------------------------- billing status
 *
 * The matched/custom indicator on the Payment details page. ⚠️ COMPUTED AT RENDER TIME from
 * the four field pairs — there is no stored flag. See
 * fleet_customers.billing_matches_account(). */
.billing-status { margin: 0 0 18px; }

/* ⚠️ NOT COLOUR ALONE, and not green-for-good. "Same as account address" and "Custom
   billing address" are both perfectly fine states — neither is a warning — so the badge
   carries the WORDS and only a neutral tint. Colouring one of them green would imply the
   other is a problem. */
.billing-status__badge {
  display: inline-block;
  padding: 5px 11px;
  border-radius: 999px;
  font-size: var(--fs-sm);
  font-weight: 600;
  /* Iron Dust on white is 5.87:1; the tint behind it is a 4% wash that does not move it. */
  color: var(--muted);
  background: rgba(94, 99, 102, 0.09);
  border: 1px solid rgba(94, 99, 102, 0.22);
}

/* The matched state gets the blue used for informational emphasis everywhere else —
   --primary-text is 5.88:1 on the pane, not the 3.28:1 brand blue. */
.billing-status__badge--same {
  color: var(--primary-text);
  background: var(--primary-tint);
  border-color: rgba(2, 99, 180, 0.28);
}

.billing-match { margin-bottom: 0; }

/* ------------------------------------------------------------------ list filtering
 *
 * The shared filter bar above the trackers and vehicles tables. See
 * app/templates/portal/_filter_bar.html — rendered `hidden` and revealed by portal.js, so
 * with JavaScript off no dead controls appear. */
.filter-bar { margin-bottom: 16px; }

/* ⚠️ wrap, for the same reason as .modal__actions and .empty-state__actions: five controls
 * plus a button do not fit one line on a laptop, and a filter pushed off the edge is an
 * unusable control rather than an untidy one. */
.filter-bar__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 12px;
}

.filter-bar__field {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

/* ⚠️ A REAL <label>, NOT A PLACEHOLDER STANDING IN FOR ONE. A placeholder disappears the
 * moment someone types, so a filled-in filter would have nothing saying which column it
 * belongs to — and placeholder text is not reliably announced. The placeholder is "Any",
 * which is a hint about the default, not a label. */
.filter-bar__label {
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--muted);
  margin: 0;
}

.filter-bar__field input,
.filter-bar__field select {
  font-family: var(--font-body);
  font-size: var(--fs-sm);
  padding: 8px 10px;
  border: 1px solid var(--border-control);
  border-radius: 8px;
  background: var(--surface);
  color: var(--text);
  /* Wide enough for a registration or a device name, narrow enough that five fit a row. */
  min-width: 150px;
}

.filter-bar__clear { padding: 8px 12px; font-size: var(--fs-sm); }

/* The "Showing 3 of 12." line. Only ever populated while a filter is set. */
.filter-bar__count {
  margin: 10px 0 0;
  font-size: var(--fs-sm);
  color: var(--muted);
  /* Reserve the line so the table does not jump up and down as the count appears and
     disappears on every keystroke. */
  min-height: 1.4em;
}

/* The clear control inside the "nothing matches" notice — a button, because it does
 * something, but styled as the inline link it reads as in a sentence. */
.filter-bar__reset {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  font-weight: 600;
  text-decoration: underline;
  cursor: pointer;
}

/* --------------------------------------------------- device settings (Flespi)
 *
 * The read-only schema dump on the device screen. ⚠️ IT IS A DISCOVERY TOOL, NOT DEBUG
 * OUTPUT — flespi does not publish per-device setting names, so this is how the real ones
 * get established. Scrolls inside its own box rather than stretching the page: the schema
 * for one device type can be long and deeply nested. */
.device-schema {
  margin: 0;
  padding: 14px 16px;
  max-height: 340px;
  overflow: auto;
  background: var(--bg-content);
  border: 1px solid var(--border-subtle, rgba(18, 18, 18, 0.12));
  border-radius: 8px;
  font-size: var(--fs-sm);
  /* pre already preserves newlines; this stops one long line forcing the whole page wide,
     which is the .panel--flush overflow problem in a different costume. */
  white-space: pre-wrap;
  word-break: break-word;
}

/* The history panel is .panel--flush for its table, so its heading and empty state need
   the padding the panel gives up. */
.device-history__heading { padding: 24px 24px 0; }
.device-history__empty { padding: 8px 24px 24px; }

/* ------------------------------------------------- the pinned Delete column
 *
 * ⚠️ THE LAST COLUMN IS STICKY SO DELETE IS VISIBLE WITHOUT SCROLLING, AT ANY WIDTH. Making
 * the panel scroll (overflow-x: auto) fixed *reachability*; it did not make Delete visible.
 * The trackers table needs ~1252px and a 1280px viewport offers ~936px, so a destructive
 * action people look for by position was still off-screen until they scrolled sideways —
 * and a control you have to go looking for is one people click the wrong neighbour of.
 *
 * Pinning it costs nothing when the table fits (sticky only engages once there is overflow)
 * and guarantees the row's most consequential control is always where the eye expects it.
 * The alternative was shaving ~150px off column content, which would have meant weakening
 * the Device and Connectivity labels for a layout reason rather than a meaning one.
 *
 * ⚠️ AN OPAQUE BACKGROUND IS REQUIRED, NOT COSMETIC. A sticky cell with a transparent
 * background lets the scrolled-under columns show through it, which renders as overlapping
 * text. The hover rule is repeated for the same reason: without it the pinned cell keeps its
 * own background while the rest of the row tints, and the row visually breaks in two. */
.data-table th:last-child,
.data-table td:last-child {
  position: sticky;
  right: 0;
  background: var(--surface);
  /* A hairline so the pinned column reads as pinned rather than as a rendering fault when
     content scrolls beneath it. */
  box-shadow: inset 1px 0 0 var(--border-subtle, rgba(18, 18, 18, 0.10));
}

/* The header sits on the card, not on the page ground — same --surface as the
   body cells, or the pinned header reads as a different colour block. */
.data-table thead th:last-child { background: var(--surface); }
.data-table tbody tr:hover td:last-child { background: var(--primary-tint); }

/* ------------------------------------------------------------- column sorting
 *
 * A sortable header is a real <button> inside the <th>, not a click handler on the th —
 * a bare th is not focusable or keyboard-operable, and a sort control that only works with
 * a mouse is a control half the users cannot reach. Rendered hidden and revealed by
 * portal.js, like every other JS-only control here.
 *
 * ⚠️ THE ARROW IS NOT THE ONLY SIGNAL. portal.js also sets aria-sort on the live header, so
 * the state is announced rather than only drawn. */
.sort-button {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: inherit;
  cursor: pointer;
}
.sort-button:hover { color: var(--text); }
.sort-button:focus-visible { outline: 2px solid var(--primary-text); outline-offset: 2px; }

/* The indicator. A neutral glyph until sorted, so the column reads as sortable rather than
   as already sorted — an empty slot would make the arrow appear from nowhere on first
   click and shift the header text sideways. */
.sort-button::after {
  content: "↕";
  font-size: 0.9em;
  opacity: 0.45;
}
.is-sorted-asc .sort-button::after  { content: "↑"; opacity: 1; }
.is-sorted-desc .sort-button::after { content: "↓"; opacity: 1; }

/* --------------------------------------------------------------- narrow */

@media (max-width: 720px) {
  .shell { grid-template-columns: 1fr; }
  .sidebar { border-bottom: 0; }
  .nav { flex-direction: row; flex-wrap: wrap; }
  .nav-link { border-left: 0; border-bottom: 3px solid transparent; }
  .nav-link.is-active { border-left-color: transparent; border-bottom-color: var(--accent); }
  .content { padding: 24px 20px; }

  /* The "New customer" button drops below the heading rather than squeezing it. */
  .page-head--with-action { flex-direction: column; }
  .data-table th, .data-table td { padding: 13px 16px; }
  /* Full width and stacked, so the primary action is not a small target on a phone and
     Cancel cannot be hit by accident reaching for it. */
  .form-actions { flex-direction: column; align-items: stretch; }
  .form-actions .btn-inline, .form-actions .btn-quiet { width: 100%; text-align: center; }
  /* One column: max-content on a label like "Last updated" leaves almost no room for the
     value on a narrow screen. */
  .meta-list { grid-template-columns: 1fr; gap: 2px; }
  .meta-list dd { margin-bottom: 10px; }

  /* Stacked and full width, so "Unassign and delete" is not a sliver of a target next to
     Cancel — the same reasoning as .form-actions above, and it matters more here because
     one of these choices is destructive. */
  .modal__actions { flex-direction: column; align-items: stretch; }
  .modal__actions .btn-inline { width: 100%; text-align: center; }
  .modal__action { width: 100%; }

  /* Same treatment as .form-actions and .modal__actions: full-width and stacked, so
     neither action is a small target on a phone. */
  .empty-state__actions { flex-direction: column; align-items: stretch; }
  .empty-state__actions .btn-inline { width: 100%; text-align: center; }

  /* One filter per row, each full width. Five 150px controls side by side would overflow
     a phone, and a filter you cannot reach is worse than one you have to scroll to. */
  .filter-bar__controls { flex-direction: column; align-items: stretch; }
  .filter-bar__field input, .filter-bar__field select { min-width: 0; width: 100%; }
  .filter-bar__clear { width: 100%; }
}
